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): при создании можно выбрать встроенный // шаблон — структура комнат и трёх ролей без сообщений — или свой, снятый с // существующего сервера. Встроенный каталог статический (guildTemplates), // личные шаблоны живут в БД (guild_templates) и приватны для владельца. // guildTemplateRole — роль, которую создаёт шаблон. type guildTemplateRole struct { Name string Permissions []permissions.Permission // 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 SlowmodeSeconds int UserLimit int } // guildTemplate — шаблон сервера: встроенный из каталога или личный, // разобранный из structure_json (AGENT.md 7.3). type guildTemplate struct { ID string Name string Description string Roles []guildTemplateRole Channels []guildTemplateChannel } // guildTemplates — каталог шаблонов; первый — пустой сервер (поведение по // умолчанию: одна общая текстовая комната). var guildTemplates = []guildTemplate{ { ID: "empty", Name: "Пустой", Description: "Одна общая текстовая комната и роли по умолчанию.", Channels: []guildTemplateChannel{ {Name: "общий", Type: store.ChannelText}, }, }, { ID: "community", Name: "Сообщество", Description: "Информация и общение: правила, объявления, флудилка и голосовая комната.", Roles: []guildTemplateRole{ { Name: "Модератор", Permissions: []permissions.Permission{ permissions.KickMembers, permissions.TimeoutMembers, permissions.ManageMessages, permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers, permissions.MoveMembers, }, Position: 50, Hoist: true, Mentionable: true, }, }, Channels: []guildTemplateChannel{ {Name: "Информация", Type: store.ChannelCategory}, {Name: "правила", Type: store.ChannelText, Category: "Информация"}, {Name: "объявления", Type: store.ChannelText, Category: "Информация"}, {Name: "Общение", Type: store.ChannelCategory}, {Name: "общий", Type: store.ChannelText, Category: "Общение"}, {Name: "флудилка", Type: store.ChannelText, Category: "Общение"}, {Name: "Голосовой", Type: store.ChannelVoice, Category: "Общение"}, }, }, { ID: "gaming", Name: "Игровой", Description: "Текстовые комнаты для сбора и две голосовые под команды.", Roles: []guildTemplateRole{ { Name: "Игрок", Permissions: []permissions.Permission{ permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard, permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks, }, Position: 50, Hoist: true, Mentionable: true, }, }, Channels: []guildTemplateChannel{ {Name: "Текстовые", Type: store.ChannelCategory}, {Name: "общий", Type: store.ChannelText, Category: "Текстовые"}, {Name: "поиск-игр", Type: store.ChannelText, Category: "Текстовые"}, {Name: "Голосовые", Type: store.ChannelCategory}, {Name: "Игровая 1", Type: store.ChannelVoice, Category: "Голосовые"}, {Name: "Игровая 2", Type: store.ChannelVoice, Category: "Голосовые"}, }, }, } // permissionSet собирает маску прав из списка. func permissionSet(list []permissions.Permission) permissions.Permission { var result permissions.Permission for _, permission := range list { result |= permission } return result } // findGuildTemplate ищет шаблон по идентификатору; пустой id — «Пустой». func findGuildTemplate(id string) (guildTemplate, bool) { if id == "" { id = guildTemplates[0].ID } for _, template := range guildTemplates { if template.ID == id { return template, true } } return guildTemplate{}, false } // applyGuildTemplate создаёт роли и комнаты шаблона. Администратор и // «Пользователь» уже созданы SeedGuildDefaults, поэтому шаблон добавляет // только свои роли (AGENT.md 6.3, 7.3). func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, template guildTemplate) error { existing, err := s.store.ListGuildRoles(ctx, guild.ID) if err != nil { return humaError(err) } known := make(map[string]bool, len(existing)) for _, role := range existing { known[role.Name] = true } for _, role := range template.Roles { if known[role.Name] { continue } 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: role.Mentionable, }); err != nil { return humaError(err) } } // Категории создаём первыми: комнаты ссылаются на них через parent_id. // В шаблоне категория перечислена отдельной записью, поэтому повторно её // не создаём — только запоминаем идентификатор. categories := map[string]uint64{} position := 0 for _, channel := range template.Channels { if channel.Type != store.ChannelCategory { continue } if _, exists := categories[channel.Name]; exists { continue } created, err := s.store.CreateChannel(ctx, store.CreateChannelParams{ GuildID: &guild.ID, Type: store.ChannelCategory, Name: channel.Name, Position: position, }) if err != nil { return humaError(err) } categories[channel.Name] = created.ID position++ } for _, channel := range template.Channels { if channel.Type == store.ChannelCategory { continue } params := store.CreateChannelParams{ 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 } if _, err := s.store.CreateChannel(ctx, params); err != nil { return humaError(err) } position++ } return nil } // guildTemplatePayload — шаблон для клиента: предпросмотр структуры. type guildTemplatePayload struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Roles []string `json:"roles"` 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 { Body struct { Templates []guildTemplatePayload `json:"templates"` } } // guildTemplateFromCatalog собирает описание шаблона для клиента. func guildTemplateFromCatalog(template guildTemplate) guildTemplatePayload { payload := guildTemplatePayload{ ID: template.ID, Name: template.Name, Description: template.Description, Roles: []string{"Администратор", "Пользователь"}, Categories: []string{}, } for _, role := range template.Roles { payload.Roles = append(payload.Roles, role.Name) } categories := map[string]bool{} for _, channel := range template.Channels { switch 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: // Личных бесед в шаблонах нет. } } return payload } // Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): владелец сохраняет структуру // своего сервера и создаёт по ней новые. Шаблон приватный — видит и использует // только владелец; чужой шаблон неотличим от несуществующего (AGENT.md 9.7). // // Записи в аудит на CRUD личных шаблонов нет осознанно: шаблон — приватная // сущность пользователя, у неё нет сервера, а журнал аудита живёт на сервере // (AGENT.md 7.10). Единственное действие, попадающее в аудит, — создание // сервера по шаблону: там причина содержит template=. const ( // personalTemplatePrefix — личный шаблон в template_id: personal:. 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. 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:. Второе // значение — причина для аудита 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: // Личных бесед в шаблонах нет. } } }