feat(templates): личные шаблоны серверов в хранилище и API (Фаза 7)
AGENT.md 7.3: свои шаблоны серверов — сохранить структуру сервера
(роли с правами и цветом, категории и комнаты) и создать по ней новый.
Миграция 00023 создаёт guild_templates (structure_json, source_guild_id,
каскад по владельцу) с индексом владельца.
- store: CRUD личных шаблонов с фильтром по owner_id и защитой лимитов
(20 шаблонов, 16 КиБ structure_json);
- server: POST /guild-templates, GET /guild-templates/mine и
DELETE /guild-templates/{id} (MANAGE_GUILD, чужой шаблон — 404),
создание сервера по template_id=personal:<id> (аудит template=personal:<id>);
- структура шаблона повторяет API ролей (права строкой) и хранит
slowmode_seconds, user_limit и категорию по имени.
This commit is contained in:
@@ -2,33 +2,50 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный
|
||||
// шаблон — структура комнат и трёх ролей без сообщений. Свои шаблоны — после
|
||||
// MVP, поэтому каталог статический.
|
||||
// шаблон — структура комнат и трёх ролей без сообщений — или свой, снятый с
|
||||
// существующего сервера. Встроенный каталог статический (guildTemplates),
|
||||
// личные шаблоны живут в БД (guild_templates) и приватны для владельца.
|
||||
|
||||
// guildTemplateRole — роль, которую создаёт шаблон.
|
||||
type guildTemplateRole struct {
|
||||
Name string
|
||||
Permissions []permissions.Permission
|
||||
Position int
|
||||
// Color — цвет роли в RGB (0 — по умолчанию).
|
||||
Color int64
|
||||
Position int
|
||||
// Hoist — показывать участников с этой ролью отдельной группой.
|
||||
Hoist bool
|
||||
// Mentionable — роль разрешено упоминать.
|
||||
Mentionable bool
|
||||
}
|
||||
|
||||
// guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри
|
||||
// которой она создаётся (пусто — верхний уровень).
|
||||
type guildTemplateChannel struct {
|
||||
Name string
|
||||
Type store.ChannelType
|
||||
Category string
|
||||
Name string
|
||||
Type store.ChannelType
|
||||
Category string
|
||||
SlowmodeSeconds int
|
||||
UserLimit int
|
||||
}
|
||||
|
||||
// guildTemplate — встроенный шаблон сервера.
|
||||
// guildTemplate — шаблон сервера: встроенный из каталога или личный,
|
||||
// разобранный из structure_json (AGENT.md 7.3).
|
||||
type guildTemplate struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -60,8 +77,9 @@ var guildTemplates = []guildTemplate{
|
||||
permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers,
|
||||
permissions.MoveMembers,
|
||||
},
|
||||
Position: 50,
|
||||
Hoist: true,
|
||||
Position: 50,
|
||||
Hoist: true,
|
||||
Mentionable: true,
|
||||
},
|
||||
},
|
||||
Channels: []guildTemplateChannel{
|
||||
@@ -85,8 +103,9 @@ var guildTemplates = []guildTemplate{
|
||||
permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard,
|
||||
permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks,
|
||||
},
|
||||
Position: 50,
|
||||
Hoist: true,
|
||||
Position: 50,
|
||||
Hoist: true,
|
||||
Mentionable: true,
|
||||
},
|
||||
},
|
||||
Channels: []guildTemplateChannel{
|
||||
@@ -141,10 +160,11 @@ func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, tem
|
||||
if _, err := s.store.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID,
|
||||
Name: role.Name,
|
||||
Color: role.Color,
|
||||
Permissions: uint64(permissionSet(role.Permissions)),
|
||||
Position: role.Position,
|
||||
Hoist: role.Hoist,
|
||||
Mentionable: true,
|
||||
Mentionable: role.Mentionable,
|
||||
}); err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
@@ -177,10 +197,12 @@ func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, tem
|
||||
continue
|
||||
}
|
||||
params := store.CreateChannelParams{
|
||||
GuildID: &guild.ID,
|
||||
Type: channel.Type,
|
||||
Name: channel.Name,
|
||||
Position: position,
|
||||
GuildID: &guild.ID,
|
||||
Type: channel.Type,
|
||||
Name: channel.Name,
|
||||
Position: position,
|
||||
SlowmodeSeconds: channel.SlowmodeSeconds,
|
||||
UserLimit: channel.UserLimit,
|
||||
}
|
||||
if parentID, ok := categories[channel.Category]; ok {
|
||||
params.ParentID = &parentID
|
||||
@@ -202,6 +224,9 @@ type guildTemplatePayload struct {
|
||||
TextCount int `json:"text_channels"`
|
||||
VoiceCount int `json:"voice_channels"`
|
||||
Categories []string `json:"categories"`
|
||||
// CreatedAt и SourceGuildID заполнены только у личных шаблонов.
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
SourceGuildID string `json:"source_guild_id,omitempty"`
|
||||
}
|
||||
|
||||
type guildTemplateListOutput struct {
|
||||
@@ -240,3 +265,457 @@ func guildTemplateFromCatalog(template guildTemplate) guildTemplatePayload {
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): владелец сохраняет структуру
|
||||
// своего сервера и создаёт по ней новые. Шаблон приватный — видит и использует
|
||||
// только владелец; чужой шаблон неотличим от несуществующего (AGENT.md 9.7).
|
||||
//
|
||||
// Записи в аудит на CRUD личных шаблонов нет осознанно: шаблон — приватная
|
||||
// сущность пользователя, у неё нет сервера, а журнал аудита живёт на сервере
|
||||
// (AGENT.md 7.10). Единственное действие, попадающее в аудит, — создание
|
||||
// сервера по шаблону: там причина содержит template=<id>.
|
||||
const (
|
||||
// personalTemplatePrefix — личный шаблон в template_id: personal:<id>.
|
||||
personalTemplatePrefix = "personal:"
|
||||
// maxUserGuildTemplateName и maxUserGuildTemplateDescription повторяют
|
||||
// ограничения имён сервера и коротких описаний.
|
||||
maxUserGuildTemplateName = 64
|
||||
maxUserGuildTemplateDescription = 200
|
||||
// maxUserGuildTemplateRoles и maxUserGuildTemplateChannels ограничивают
|
||||
// саму структуру: категории считаются комнатами.
|
||||
maxUserGuildTemplateRoles = 50
|
||||
maxUserGuildTemplateChannels = 100
|
||||
)
|
||||
|
||||
// userGuildTemplateStructure — structure_json личного шаблона. Формат повторяет
|
||||
// внутренний guildTemplate: роли (кроме роли по умолчанию) с цветом, позицией
|
||||
// и строкой прав, затем категории и комнаты. Права — строкой как в API ролей
|
||||
// («VIEW_CHANNEL|SEND_MESSAGES», «NONE» — нет прав): маска uint64 не теряет
|
||||
// точность в JSON. Числовая строка при разборе тоже принимается.
|
||||
type userGuildTemplateStructure struct {
|
||||
Roles []userGuildTemplateRole `json:"roles"`
|
||||
Channels []userGuildTemplateChannel `json:"channels"`
|
||||
}
|
||||
|
||||
type userGuildTemplateRole struct {
|
||||
Name string `json:"name"`
|
||||
Color int64 `json:"color"`
|
||||
Permissions string `json:"permissions"`
|
||||
Position int `json:"position"`
|
||||
Hoist bool `json:"hoist"`
|
||||
Mentionable bool `json:"mentionable"`
|
||||
}
|
||||
|
||||
type userGuildTemplateChannel struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Position int `json:"position"`
|
||||
SlowmodeSeconds int `json:"slowmode_seconds,omitempty"`
|
||||
UserLimit int `json:"user_limit,omitempty"`
|
||||
}
|
||||
|
||||
type userGuildTemplateOutput struct {
|
||||
Body struct {
|
||||
Template guildTemplatePayload `json:"template"`
|
||||
}
|
||||
}
|
||||
|
||||
type userGuildTemplateListOutput struct {
|
||||
Body struct {
|
||||
Templates []guildTemplatePayload `json:"templates"`
|
||||
}
|
||||
}
|
||||
|
||||
type guildTemplateDeleteOutput struct {
|
||||
Body struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
}
|
||||
|
||||
// registerUserGuildTemplateRoutes описывает ручки личных шаблонов серверов.
|
||||
func (s *Server) registerUserGuildTemplateRoutes(api huma.API, security []map[string][]string) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createUserGuildTemplate",
|
||||
Method: http.MethodPost,
|
||||
Path: "/guild-templates",
|
||||
Summary: "Сохранить структуру сервера личным шаблоном",
|
||||
Tags: []string{"Guilds"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
GuildID string `json:"guild_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
},
|
||||
) (*userGuildTemplateOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guildID, err := parseID("guild_id", input.Body.GuildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Право MANAGE_GUILD: администратор инстанса проходит всегда
|
||||
// (permissions.Resolved.IsInstanceAdmin).
|
||||
resolved, err := s.guildPermissions(ctx, guildID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resolved.Has(permissions.ManageGuild) {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
|
||||
}
|
||||
guild, err := s.store.GetGuild(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(input.Body.Name)
|
||||
if name == "" {
|
||||
name = guild.Name
|
||||
}
|
||||
if len([]rune(name)) > maxUserGuildTemplateName {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "template name must be 1-64 characters")
|
||||
}
|
||||
description := strings.TrimSpace(input.Body.Description)
|
||||
if len([]rune(description)) > maxUserGuildTemplateDescription {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "template description must be at most 200 characters")
|
||||
}
|
||||
|
||||
count, err := s.store.CountUserGuildTemplates(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if count >= store.MaxUserGuildTemplates {
|
||||
return nil, humaErrorStatus(http.StatusConflict, "template.limit_reached", "personal guild template limit is reached")
|
||||
}
|
||||
|
||||
structure, err := s.buildUserGuildTemplateStructure(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(structure.Roles) > maxUserGuildTemplateRoles || len(structure.Channels) > maxUserGuildTemplateChannels {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild structure is too large for a template: at most 50 roles and 100 channels")
|
||||
}
|
||||
encoded, err := json.Marshal(structure)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if len(encoded) > store.MaxUserGuildTemplateBytes {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is too large")
|
||||
}
|
||||
|
||||
created, err := s.store.CreateUserGuildTemplate(ctx, store.CreateUserGuildTemplateParams{
|
||||
OwnerID: user.ID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Structure: encoded,
|
||||
SourceGuildID: &guildID,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrTemplateLimitReached):
|
||||
return nil, humaErrorStatus(http.StatusConflict, "template.limit_reached", "personal guild template limit is reached")
|
||||
case errors.Is(err, store.ErrTemplateTooLarge):
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is too large")
|
||||
}
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &userGuildTemplateOutput{}
|
||||
output.Body.Template = s.userGuildTemplatePayload(*created)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listUserGuildTemplates",
|
||||
Method: http.MethodGet,
|
||||
Path: "/guild-templates/mine",
|
||||
Summary: "Свои шаблоны серверов",
|
||||
Tags: []string{"Guilds"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*userGuildTemplateListOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates, err := s.store.ListUserGuildTemplates(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &userGuildTemplateListOutput{}
|
||||
output.Body.Templates = make([]guildTemplatePayload, 0, len(templates))
|
||||
for _, template := range templates {
|
||||
output.Body.Templates = append(output.Body.Templates, s.userGuildTemplatePayload(template))
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteUserGuildTemplate",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/guild-templates/{template_id}",
|
||||
Summary: "Удалить свой шаблон сервера",
|
||||
Tags: []string{"Guilds"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
TemplateID string `path:"template_id"`
|
||||
},
|
||||
) (*guildTemplateDeleteOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Принимаем и «personal:<id>» (как в списке шаблонов клиента), и сам id.
|
||||
templateID, err := parseID("template_id", strings.TrimPrefix(input.TemplateID, personalTemplatePrefix))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleted, err := s.store.DeleteUserGuildTemplate(ctx, templateID, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if !deleted {
|
||||
// Чужой шаблон неотличим от несуществующего (AGENT.md 9.7).
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "guild template not found")
|
||||
}
|
||||
output := &guildTemplateDeleteOutput{}
|
||||
output.Body.OK = true
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
|
||||
// buildUserGuildTemplateStructure снимает структуру сервера: роли (кроме роли
|
||||
// по умолчанию — «Пользователь» создаётся для каждого сервера сам, AGENT.md 6.3)
|
||||
// и все комнаты вместе с категориями.
|
||||
func (s *Server) buildUserGuildTemplateStructure(ctx context.Context, guildID uint64) (userGuildTemplateStructure, error) {
|
||||
roles, err := s.store.ListGuildRoles(ctx, guildID)
|
||||
if err != nil {
|
||||
return userGuildTemplateStructure{}, humaError(err)
|
||||
}
|
||||
structure := userGuildTemplateStructure{
|
||||
Roles: make([]userGuildTemplateRole, 0, len(roles)),
|
||||
Channels: make([]userGuildTemplateChannel, 0, 8),
|
||||
}
|
||||
for _, role := range roles {
|
||||
if role.IsDefault {
|
||||
continue
|
||||
}
|
||||
structure.Roles = append(structure.Roles, userGuildTemplateRole{
|
||||
Name: role.Name,
|
||||
Color: role.Color,
|
||||
Permissions: permissions.Permission(role.Permissions).String(),
|
||||
Position: role.Position,
|
||||
Hoist: role.Hoist,
|
||||
Mentionable: role.Mentionable,
|
||||
})
|
||||
}
|
||||
|
||||
channels, err := s.store.ListGuildChannels(ctx, guildID)
|
||||
if err != nil {
|
||||
return userGuildTemplateStructure{}, humaError(err)
|
||||
}
|
||||
// Комната ссылается на категорию по имени: идентификаторы в новом сервере
|
||||
// будут другими (AGENT.md 6.4).
|
||||
categories := make(map[uint64]string, len(channels))
|
||||
for _, channel := range channels {
|
||||
if channel.Type == store.ChannelCategory {
|
||||
categories[channel.ID] = channel.Name
|
||||
}
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if channel.Type == store.ChannelDM {
|
||||
continue
|
||||
}
|
||||
item := userGuildTemplateChannel{
|
||||
Name: channel.Name,
|
||||
Type: string(channel.Type),
|
||||
Position: channel.Position,
|
||||
SlowmodeSeconds: channel.SlowmodeSeconds,
|
||||
UserLimit: channel.UserLimit,
|
||||
}
|
||||
if channel.ParentID != nil {
|
||||
item.Category = categories[*channel.ParentID]
|
||||
}
|
||||
structure.Channels = append(structure.Channels, item)
|
||||
}
|
||||
return structure, nil
|
||||
}
|
||||
|
||||
// structureToGuildTemplate превращает сохранённую структуру во внутренний
|
||||
// шаблон: порядок комнат задаёт position. Битый шаблон — ошибка целиком:
|
||||
// половина сервера хуже, чем отказ.
|
||||
func structureToGuildTemplate(structure userGuildTemplateStructure) (guildTemplate, error) {
|
||||
template := guildTemplate{
|
||||
Roles: make([]guildTemplateRole, 0, len(structure.Roles)),
|
||||
Channels: make([]guildTemplateChannel, 0, len(structure.Channels)),
|
||||
}
|
||||
for _, role := range structure.Roles {
|
||||
name := strings.TrimSpace(role.Name)
|
||||
if name == "" {
|
||||
return guildTemplate{}, errors.New("role name must not be empty")
|
||||
}
|
||||
mask, err := parseTemplatePermissions(role.Permissions)
|
||||
if err != nil {
|
||||
return guildTemplate{}, err
|
||||
}
|
||||
template.Roles = append(template.Roles, guildTemplateRole{
|
||||
Name: name,
|
||||
Permissions: permissionList(mask),
|
||||
Color: role.Color,
|
||||
Position: role.Position,
|
||||
Hoist: role.Hoist,
|
||||
Mentionable: role.Mentionable,
|
||||
})
|
||||
}
|
||||
channels := make([]userGuildTemplateChannel, len(structure.Channels))
|
||||
copy(channels, structure.Channels)
|
||||
sort.SliceStable(channels, func(i, j int) bool { return channels[i].Position < channels[j].Position })
|
||||
for _, channel := range channels {
|
||||
name := strings.TrimSpace(channel.Name)
|
||||
if name == "" {
|
||||
return guildTemplate{}, errors.New("channel name must not be empty")
|
||||
}
|
||||
channelType := store.ChannelType(channel.Type)
|
||||
switch channelType {
|
||||
case store.ChannelText, store.ChannelVoice, store.ChannelCategory:
|
||||
case store.ChannelDM:
|
||||
return guildTemplate{}, errors.New("dm channels are not allowed in a template")
|
||||
default:
|
||||
return guildTemplate{}, fmt.Errorf("unknown channel type %q", channel.Type)
|
||||
}
|
||||
template.Channels = append(template.Channels, guildTemplateChannel{
|
||||
Name: name,
|
||||
Type: channelType,
|
||||
Category: channel.Category,
|
||||
SlowmodeSeconds: channel.SlowmodeSeconds,
|
||||
UserLimit: channel.UserLimit,
|
||||
})
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// permissionList разворачивает маску в список прав — внутренний вид шаблона.
|
||||
func permissionList(mask permissions.Permission) []permissions.Permission {
|
||||
list := make([]permissions.Permission, 0, 8)
|
||||
for bit := permissions.Permission(1); bit != 0 && bit <= permissions.Administrator; bit <<= 1 {
|
||||
if mask.Has(bit) {
|
||||
list = append(list, bit)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// parseTemplatePermissions разбирает права роли из structure_json: строка
|
||||
// названий через «|» (как в API ролей), «NONE» или пустая строка — нет прав;
|
||||
// числовая строка-маска тоже принимается.
|
||||
func parseTemplatePermissions(value string) (permissions.Permission, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || strings.EqualFold(trimmed, "NONE") {
|
||||
return 0, nil
|
||||
}
|
||||
if mask, err := strconv.ParseUint(trimmed, 10, 64); err == nil {
|
||||
return permissions.Permission(mask), nil
|
||||
}
|
||||
mask, err := permissions.Parse(trimmed)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
// resolveGuildTemplate выбирает шаблон структуры для нового сервера: встроенный
|
||||
// по идентификатору каталога или личный по префиксу personal:<id>. Второе
|
||||
// значение — причина для аудита guild.create (AGENT.md 7.3).
|
||||
func (s *Server) resolveGuildTemplate(ctx context.Context, user *store.User, templateID string) (guildTemplate, string, error) {
|
||||
if !strings.HasPrefix(templateID, personalTemplatePrefix) {
|
||||
template, known := findGuildTemplate(templateID)
|
||||
if !known {
|
||||
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||
}
|
||||
return template, "template=" + template.ID, nil
|
||||
}
|
||||
|
||||
id, err := parseID("template_id", strings.TrimPrefix(templateID, personalTemplatePrefix))
|
||||
if err != nil {
|
||||
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||
}
|
||||
stored, err := s.store.GetUserGuildTemplate(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||
}
|
||||
return guildTemplate{}, "", humaError(err)
|
||||
}
|
||||
// Чужой личный шаблон неотличим от несуществующего (AGENT.md 9.7).
|
||||
if stored.OwnerID != user.ID {
|
||||
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||
}
|
||||
var structure userGuildTemplateStructure
|
||||
if err := json.Unmarshal(stored.Structure, &structure); err != nil {
|
||||
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is invalid")
|
||||
}
|
||||
template, err := structureToGuildTemplate(structure)
|
||||
if err != nil {
|
||||
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is invalid")
|
||||
}
|
||||
return template, "template=" + personalTemplatePrefix + formatSnowflake(stored.ID), nil
|
||||
}
|
||||
|
||||
// userGuildTemplatePayload описывает личный шаблон для клиента: тот же
|
||||
// предпросмотр, что у встроенных шаблонов, плюс дата создания и сервер-источник.
|
||||
func (s *Server) userGuildTemplatePayload(template store.UserGuildTemplate) guildTemplatePayload {
|
||||
payload := guildTemplatePayload{
|
||||
ID: formatSnowflake(template.ID),
|
||||
Name: template.Name,
|
||||
Description: template.Description,
|
||||
Roles: []string{"Администратор", "Пользователь"},
|
||||
Categories: []string{},
|
||||
CreatedAt: s.store.Timestamp(template.CreatedAt),
|
||||
}
|
||||
if template.SourceGuildID != nil {
|
||||
payload.SourceGuildID = formatSnowflake(*template.SourceGuildID)
|
||||
}
|
||||
var structure userGuildTemplateStructure
|
||||
if err := json.Unmarshal(template.Structure, &structure); err != nil {
|
||||
// Битый шаблон не должен ломать список: отдаём его без предпросмотра.
|
||||
return payload
|
||||
}
|
||||
fillTemplatePreview(&payload, structure)
|
||||
return payload
|
||||
}
|
||||
|
||||
// fillTemplatePreview дополняет предпросмотр по структуре шаблона. Роли
|
||||
// «Администратор» и «Пользователь» уже перечислены: они создаются для каждого
|
||||
// сервера (AGENT.md 6.3), поэтому повторно их не добавляем.
|
||||
func fillTemplatePreview(payload *guildTemplatePayload, structure userGuildTemplateStructure) {
|
||||
seen := make(map[string]bool, len(payload.Roles))
|
||||
for _, name := range payload.Roles {
|
||||
seen[name] = true
|
||||
}
|
||||
for _, role := range structure.Roles {
|
||||
name := strings.TrimSpace(role.Name)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
payload.Roles = append(payload.Roles, name)
|
||||
}
|
||||
categories := make(map[string]bool, 4)
|
||||
for _, channel := range structure.Channels {
|
||||
switch store.ChannelType(channel.Type) {
|
||||
case store.ChannelText:
|
||||
payload.TextCount++
|
||||
case store.ChannelVoice:
|
||||
payload.VoiceCount++
|
||||
case store.ChannelCategory:
|
||||
if !categories[channel.Name] {
|
||||
categories[channel.Name] = true
|
||||
payload.Categories = append(payload.Categories, channel.Name)
|
||||
}
|
||||
case store.ChannelDM:
|
||||
// Личных бесед в шаблонах нет.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user