Files
glchat/internal/server/guild_templates.go
T

722 lines
28 KiB
Go
Raw Normal View History

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=<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:
// Личных бесед в шаблонах нет.
}
}
}