91f415e36d
- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли, категории, текстовые и голосовые комнаты без сообщений - POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит фиксирует выбранный шаблон - GET /guild-templates отдаёт каталог с предпросмотром структуры - клиент: выбор шаблона в окне создания сервера - тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
238 lines
8.0 KiB
Go
238 lines
8.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
|
|
"glchat/internal/permissions"
|
|
"glchat/internal/store"
|
|
)
|
|
|
|
// Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный
|
|
// шаблон — структура комнат и трёх ролей без сообщений. Свои шаблоны — после
|
|
// MVP, поэтому каталог статический.
|
|
|
|
// guildTemplateRole — роль, которую создаёт шаблон.
|
|
type guildTemplateRole struct {
|
|
Name string
|
|
Permissions []permissions.Permission
|
|
Position int
|
|
// Hoist — показывать участников с этой ролью отдельной группой.
|
|
Hoist bool
|
|
}
|
|
|
|
// guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри
|
|
// которой она создаётся (пусто — верхний уровень).
|
|
type guildTemplateChannel struct {
|
|
Name string
|
|
Type store.ChannelType
|
|
Category string
|
|
}
|
|
|
|
// guildTemplate — встроенный шаблон сервера.
|
|
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,
|
|
},
|
|
},
|
|
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,
|
|
},
|
|
},
|
|
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,
|
|
Permissions: uint64(permissionSet(role.Permissions)),
|
|
Position: role.Position,
|
|
Hoist: role.Hoist,
|
|
Mentionable: true,
|
|
}); err != nil {
|
|
return humaError(err)
|
|
}
|
|
}
|
|
|
|
// Категории создаются до комнат: комнаты ссылаются на них через parent_id.
|
|
categories := map[string]uint64{}
|
|
position := 0
|
|
for _, channel := range template.Channels {
|
|
parentID := uint64(0)
|
|
if channel.Category != "" {
|
|
id, ok := categories[channel.Category]
|
|
if !ok {
|
|
created, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
|
GuildID: &guild.ID, Type: store.ChannelCategory,
|
|
Name: channel.Category, Position: position,
|
|
})
|
|
if err != nil {
|
|
return humaError(err)
|
|
}
|
|
categories[channel.Category] = created.ID
|
|
position++
|
|
id = created.ID
|
|
}
|
|
parentID = id
|
|
}
|
|
params := store.CreateChannelParams{
|
|
GuildID: &guild.ID,
|
|
Type: channel.Type,
|
|
Name: channel.Name,
|
|
Position: position,
|
|
}
|
|
if parentID != 0 && channel.Type != store.ChannelCategory {
|
|
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"`
|
|
}
|
|
|
|
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
|
|
}
|