Files
glchat/internal/server/api_guilds.go
T
grendervill 7ec9dbfdee fix(realtime): уход на вход после отзыва сессии и бан в открытом сервере
- invalidateCurrentUser перечитывает профиль, а не только удаляет кэш: без
  запроса AuthGuard не видит 401 и второе устройство остаётся в приложении
  после logout-all или смены пароля (AGENT.md 11.6, пункт 12);
- сброс сессии и уход из последнего сервера инвалидируют список серверов:
  пустой снапшот подставлял устаревший REST-ответ, и сервер возвращался в
  рейку (видно и после кика);
- joinGuild проверяет бан сервера: публичный сервер больше не обходится
  нажатием «Войти» вместо приглашения (AGENT.md 7.17, 7.20), тест расширен;
- e2e/voice-ten приведён к линту, console.log разрешён в e2e (диагностика
  прогона), артефакты Playwright исключены из prettier.
2026-09-22 20:35:13 +03:00

1965 lines
70 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/bootstrap"
"glchat/internal/permissions"
"glchat/internal/store"
)
type channelPayload struct {
ID string `json:"id"`
GuildID string `json:"guild_id,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Position int `json:"position"`
ParentID string `json:"parent_id,omitempty"`
SlowmodeSeconds int `json:"slowmode_seconds"`
UserLimit int `json:"user_limit"`
CanView bool `json:"can_view"`
CanSend bool `json:"can_send"`
CanConnect bool `json:"can_connect"`
// BackgroundFileID — фон комнаты (AGENT.md 7.5, 7.7).
BackgroundFileID string `json:"background_file_id,omitempty"`
// PermissionOverwrites — права комнаты поверх серверных (AGENT.md 6.2):
// именно ими закрывают комнату для @everyone и открывают её ролям.
PermissionOverwrites []channelOverwritePayload `json:"permission_overwrites"`
}
type channelOverwritePayload struct {
// TargetType — role или user.
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Allow string `json:"allow"`
Deny string `json:"deny"`
}
func channelOverwritePayloads(overrides []store.ChannelOverride) []channelOverwritePayload {
payloads := make([]channelOverwritePayload, 0, len(overrides))
for _, override := range overrides {
payloads = append(payloads, channelOverwritePayload{
TargetType: override.TargetType,
TargetID: formatSnowflake(override.TargetID),
Allow: strings.Join(permissions.Names(permissions.Permission(override.Allow)), "|"),
Deny: strings.Join(permissions.Names(permissions.Permission(override.Deny)), "|"),
})
}
return payloads
}
type rolePayload struct {
ID string `json:"id"`
// GuildID нужен событиям ROLE_CREATE/ROLE_UPDATE: в REST он избыточен
// (сервер и так в пути), а в кадре шлюза без него роль некуда отнести.
GuildID string `json:"guild_id,omitempty"`
Name string `json:"name"`
Color int64 `json:"color"`
Position int `json:"position"`
Permissions string `json:"permissions"`
IsDefault bool `json:"is_default"`
Hoist bool `json:"hoist"`
Mentionable bool `json:"mentionable"`
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
type memberPayload struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Nickname string `json:"nickname,omitempty"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
Status string `json:"status"`
CustomStatus string `json:"custom_status"`
IsInstanceAdmin bool `json:"is_instance_admin"`
JoinedAt string `json:"joined_at"`
TimeoutUntil string `json:"timeout_until,omitempty"`
RoleIDs []string `json:"role_ids"`
// Системные бейджи (AGENT.md 7.2): назначаются только системой.
Badges []string `json:"badges"`
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
}
type guildDetailPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
IconFileID string `json:"icon_file_id,omitempty"`
BannerFileID string `json:"banner_file_id,omitempty"`
SplashFileID string `json:"splash_file_id,omitempty"`
AccentColor int64 `json:"accent_color,omitempty"`
IsMain bool `json:"is_main"`
MemberCount int `json:"member_count"`
Roles []rolePayload `json:"roles"`
MyRoleIDs []string `json:"my_role_ids"`
MyPermissions []string `json:"my_permissions"`
Channels []channelPayload `json:"channels,omitempty"`
}
// channelEventPayload собирает тело комнаты для события шлюза: права в нём
// не вычислены (они персональные), поэтому признаки can_* не выставляются —
// клиент по этому событию перечитывает список комнат, а не прячет комнату по
// нулевому can_view.
func channelEventPayload(channel store.Channel) channelPayload {
return channelFromStore(channel, nil, nil)
}
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
payload := channelPayload{
ID: formatSnowflake(channel.ID),
Type: string(channel.Type),
Name: channel.Name,
Description: channel.Description,
Position: channel.Position,
SlowmodeSeconds: channel.SlowmodeSeconds,
UserLimit: channel.UserLimit,
}
if channel.GuildID != nil {
payload.GuildID = formatSnowflake(*channel.GuildID)
}
if channel.ParentID != nil {
payload.ParentID = formatSnowflake(*channel.ParentID)
}
if channel.BackgroundFileID != nil {
payload.BackgroundFileID = formatSnowflake(*channel.BackgroundFileID)
}
if resolved != nil {
payload.CanView = resolved.CanViewChannel()
payload.CanSend = resolved.Can(permissions.SendMessages)
payload.CanConnect = resolved.Can(permissions.ConnectVoice)
}
payload.PermissionOverwrites = channelOverwritePayloads(overrides)
return payload
}
func roleFromStore(role store.Role) rolePayload {
payload := rolePayload{
ID: formatSnowflake(role.ID),
GuildID: formatSnowflake(role.GuildID),
Name: role.Name,
Color: role.Color,
Position: role.Position,
Permissions: permissions.Permission(role.Permissions).String(),
IsDefault: role.IsDefault,
Hoist: role.Hoist,
Mentionable: role.Mentionable,
NickColor: role.NickColor,
}
if role.CosmeticFrameID != nil {
payload.CosmeticFrameID = formatSnowflake(*role.CosmeticFrameID)
}
if role.CosmeticBadgeID != nil {
payload.CosmeticBadgeID = formatSnowflake(*role.CosmeticBadgeID)
}
if role.NickEffect != "" && role.NickEffect != string(store.NickEffectNone) {
payload.NickEffect = role.NickEffect
}
return payload
}
type guildOutput struct {
Body struct {
Guild guildDetailPayload `json:"guild"`
}
}
type channelOutput struct {
Body struct {
Channel channelPayload `json:"channel"`
}
}
type channelListOutput struct {
Body struct {
Channels []channelPayload `json:"channels"`
}
}
type memberListOutput struct {
Body struct {
Members []memberPayload `json:"members"`
}
}
type roleOutput struct {
Body struct {
Role rolePayload `json:"role"`
}
}
type roleListOutput struct {
Body struct {
Roles []rolePayload `json:"roles"`
}
}
// registerGuildRoutes описывает ручки серверов, комнат, участников и ролей.
func (s *Server) registerGuildRoutes(api huma.API) {
s.registerGuildCoreRoutes(api)
s.registerChannelRoutes(api)
s.registerMemberRoutes(api)
s.registerRoleRoutes(api)
}
func (s *Server) registerGuildCoreRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "createGuild",
Method: http.MethodPost,
Path: "/guilds",
Summary: "Создать сервер",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
Body struct {
Name string `json:"name" minLength:"1" maxLength:"64"`
// TemplateID — встроенный шаблон структуры (AGENT.md 7.3);
// пусто — «Пустой».
TemplateID string `json:"template_id,omitempty"`
}
},
) (*guildOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(input.Body.Name)
if name == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
}
guild, err := s.createGuild(ctx, user, name, input.Body.TemplateID)
if err != nil {
return nil, err
}
return s.guildOutput(ctx, user, *guild)
})
huma.Register(api, huma.Operation{
OperationID: "listGuildTemplates",
Method: http.MethodGet,
Path: "/guild-templates",
Summary: "Встроенные шаблоны серверов",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*guildTemplateListOutput, error) {
if _, _, err := requireUser(ctx); err != nil {
return nil, err
}
output := &guildTemplateListOutput{}
output.Body.Templates = make([]guildTemplatePayload, 0, len(guildTemplates))
for _, template := range guildTemplates {
output.Body.Templates = append(output.Body.Templates, guildTemplateFromCatalog(template))
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getGuild",
Method: http.MethodGet,
Path: "/guilds/{guild_id}",
Summary: "Сервер: роли и права",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*guildOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
return s.guildOutput(ctx, user, *guild)
})
huma.Register(api, huma.Operation{
OperationID: "updateGuild",
Method: http.MethodPatch,
Path: "/guilds/{guild_id}",
Summary: "Изменить сервер",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Body struct {
Name *string `json:"name,omitempty" maxLength:"64"`
Description *string `json:"description,omitempty" maxLength:"500"`
// Публичность сервера: попадает в каталог и открывает вход без
// приглашения (AGENT.md 7.20).
Public *bool `json:"public,omitempty"`
// Акцентный цвет сервера (0 — цвет темы по умолчанию).
AccentColor *int64 `json:"accent_color,omitempty" minimum:"0" maximum:"16777215"`
}
},
) (*guildOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageGuild)
if err != nil {
return nil, err
}
params := store.UpdateGuildParams{
Description: input.Body.Description,
AccentColor: input.Body.AccentColor,
}
if input.Body.Public != nil {
// Главный сервер публичным не делаем: он и так точка входа.
if *input.Body.Public {
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
if guild.IsMain {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "main guild is always open and cannot be published")
}
}
params.IsDiscoverable = input.Body.Public
}
if input.Body.Name != nil {
trimmed := strings.TrimSpace(*input.Body.Name)
if trimmed == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
}
params.Name = &trimmed
}
guild, err := s.store.UpdateGuild(ctx, guildID, params)
if err != nil {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.dispatchGuildUpdate(*guild)
return s.guildOutput(ctx, user, *guild)
})
huma.Register(api, huma.Operation{
OperationID: "deleteGuild",
Method: http.MethodDelete,
Path: "/guilds/{guild_id}",
Summary: "Удалить сервер (владелец или админ инстанса)",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*okOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
if !resolved.IsOwner && !user.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the owner can delete the guild")
}
// Удаление сервера требует свежего подтверждения личности (AGENT.md 9.3).
// Тело у DELETE не спрашиваем: клиент сначала вызывает /auth/step-up.
if err := s.auth.RequireStepUp(ctx, user, session, "", ""); err != nil {
return nil, humaError(err)
}
if err := s.deleteGuild(ctx, user, guildID); err != nil {
return nil, err
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "joinGuild",
Method: http.MethodPost,
Path: "/guilds/{guild_id}/join",
Summary: "Присоединиться к открытому серверу",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, err := parseID("guild_id", input.GuildID)
if err != nil {
return nil, err
}
if err := s.joinGuild(ctx, user, guildID); err != nil {
return nil, err
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "leaveGuild",
Method: http.MethodPost,
Path: "/guilds/{guild_id}/leave",
Summary: "Покинуть сервер",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
if resolved.IsOwner {
return nil, humaErrorStatus(http.StatusForbidden, "guild.owner_cannot_leave", "owner cannot leave the guild")
}
if err := s.store.RemoveGuildMember(ctx, guildID, user.ID); err != nil {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
if s.gateway != nil {
s.gateway.SendToUser(user.ID, "GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)})
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "listAuditLog",
Method: http.MethodGet,
Path: "/guilds/{guild_id}/audit-log",
Summary: "Журнал действий сервера",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
Action string `query:"action" maxLength:"64"`
ActorID string `query:"actor_id"`
TargetID string `query:"target_id"`
BeforeID string `query:"before_id"`
},
) (*auditListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewAuditLog)
if err != nil {
return nil, err
}
filter := store.AuditFilter{Limit: input.Limit, Action: strings.TrimSpace(input.Action)}
for _, field := range []struct {
raw string
apply func(uint64)
}{
{input.ActorID, func(value uint64) { filter.ActorID = value }},
{input.TargetID, func(value uint64) { filter.TargetID = value }},
{input.BeforeID, func(value uint64) { filter.BeforeID = value }},
} {
if strings.TrimSpace(field.raw) == "" {
continue
}
parsed, err := parseID("filter", field.raw)
if err != nil {
return nil, err
}
field.apply(parsed)
}
entries, err := s.store.ListAuditLogFiltered(ctx, guildID, filter)
if err != nil {
return nil, humaError(err)
}
output := &auditListOutput{}
output.Body.Entries = auditPayloads(entries)
return output, nil
})
}
func (s *Server) registerChannelRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "listGuildChannels",
Method: http.MethodGet,
Path: "/guilds/{guild_id}/channels",
Summary: "Комнаты сервера, видимые пользователю",
Tags: []string{"Channels"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*channelListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
channels, err := s.visibleChannels(ctx, guildID, user)
if err != nil {
return nil, err
}
output := &channelListOutput{}
output.Body.Channels = channels
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "createChannel",
Method: http.MethodPost,
Path: "/guilds/{guild_id}/channels",
Summary: "Создать комнату",
Tags: []string{"Channels"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Body struct {
Name string `json:"name" minLength:"1" maxLength:"64"`
Type string `json:"type,omitempty" enum:"text,voice,category"`
ParentID string `json:"parent_id,omitempty"`
SlowmodeSeconds int `json:"slowmode_seconds,omitempty" minimum:"0" maximum:"21600"`
UserLimit int `json:"user_limit,omitempty" minimum:"0" maximum:"99"`
}
},
) (*channelOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels)
if err != nil {
return nil, err
}
channelType := store.ChannelType(input.Body.Type)
if channelType == "" {
channelType = store.ChannelText
}
switch channelType {
// Личные комнаты (dm) создаются не через API сервера (AGENT.md 6.3).
case store.ChannelText, store.ChannelVoice, store.ChannelCategory:
case store.ChannelDM:
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "dm channels are created by the messaging API")
default:
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown channel type")
}
name := strings.TrimSpace(input.Body.Name)
if name == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "channel name must not be empty")
}
params := store.CreateChannelParams{
GuildID: &guildID,
Type: channelType,
Name: name,
SlowmodeSeconds: input.Body.SlowmodeSeconds,
UserLimit: input.Body.UserLimit,
}
if input.Body.ParentID != "" {
parentID, err := parseID("parent_id", input.Body.ParentID)
if err != nil {
return nil, err
}
params.ParentID = &parentID
}
channel, err := s.store.CreateChannel(ctx, params)
if err != nil {
return nil, humaError(err)
}
// Системная запись о создании комнаты (AGENT.md 7.6).
s.postSystemMessage(ctx, channel.ID, "channel_created", map[string]any{
"name": channel.Name,
"type": string(channel.Type),
})
s.invalidateGuild(guildID)
// Видимость комнаты считается персонально, поэтому вместо тела комнаты
// (в нём can_view был бы нулевым) просим клиентов перечитать список.
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelEventPayload(*channel))
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
})
// Права на новую комнату считаем для автора запроса.
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
if err != nil {
return nil, humaError(err)
}
output := &channelOutput{}
output.Body.Channel = channelFromStore(*channel, &resolved, nil)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateChannel",
Method: http.MethodPatch,
Path: "/guilds/{guild_id}/channels/{channel_id}",
Summary: "Изменить комнату",
Tags: []string{"Channels"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
ChannelID string `path:"channel_id"`
Body struct {
Name *string `json:"name,omitempty" maxLength:"64"`
Description *string `json:"description,omitempty" maxLength:"500"`
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
SlowmodeSeconds *int `json:"slowmode_seconds,omitempty" minimum:"0" maximum:"21600"`
UserLimit *int `json:"user_limit,omitempty" minimum:"0" maximum:"99"`
}
},
) (*channelOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels)
if err != nil {
return nil, err
}
channelID, err := parseID("channel_id", input.ChannelID)
if err != nil {
return nil, err
}
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
return nil, err
}
params := store.UpdateChannelParams{
Name: input.Body.Name,
Description: input.Body.Description,
Position: input.Body.Position,
SlowmodeSeconds: input.Body.SlowmodeSeconds,
UserLimit: input.Body.UserLimit,
}
channel, err := s.store.UpdateChannel(ctx, channelID, params)
if err != nil {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelEventPayload(*channel))
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
})
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
if err != nil {
return nil, humaError(err)
}
output := &channelOutput{}
output.Body.Channel = channelFromStore(*channel, &resolved, nil)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "setChannelOverwrite",
Method: http.MethodPut,
Path: "/guilds/{guild_id}/channels/{channel_id}/overwrites/{target_type}/{target_id}",
Summary: "Задать права комнаты для роли или участника",
Tags: []string{"Channels"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
ChannelID string `path:"channel_id"`
TargetType string `path:"target_type" enum:"role,user"`
TargetID string `path:"target_id"`
Body struct {
// Allow и Deny — имена прав через `|`, как в ролях (AGENT.md 6.2, 8.1).
Allow string `json:"allow,omitempty"`
Deny string `json:"deny,omitempty"`
// Смена прав комнаты — чувствительная операция (AGENT.md 9.3).
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*channelOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
channelID, err := parseID("channel_id", input.ChannelID)
if err != nil {
return nil, err
}
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
return nil, err
}
targetID, err := parseID("target_id", input.TargetID)
if err != nil {
return nil, err
}
if err := s.requireOverwriteTarget(ctx, guildID, input.TargetType, targetID); err != nil {
return nil, err
}
if err := s.auth.RequireStepUp(ctx, user, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
allow, err := permissions.Parse(input.Body.Allow)
if err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
}
deny, err := permissions.Parse(input.Body.Deny)
if err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
}
if err := s.store.SetChannelOverride(ctx, store.ChannelOverride{
ChannelID: channelID,
TargetType: input.TargetType,
TargetID: targetID,
Allow: uint64(allow),
Deny: uint64(deny),
}); err != nil {
return nil, humaError(err)
}
s.afterOverwriteChange(ctx, user, guildID, channelID, "channel.overwrite_set",
input.TargetType, targetID)
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
overrides, err := s.store.ListChannelOverrides(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
if err != nil {
return nil, humaError(err)
}
output := &channelOutput{}
output.Body.Channel = channelFromStore(*channel, &resolved, overrides)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteChannelOverwrite",
Method: http.MethodDelete,
Path: "/guilds/{guild_id}/channels/{channel_id}/overwrites/{target_type}/{target_id}",
Summary: "Снять права комнаты для роли или участника",
Tags: []string{"Channels"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
ChannelID string `path:"channel_id"`
TargetType string `path:"target_type" enum:"role,user"`
TargetID string `path:"target_id"`
},
) (*okOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
channelID, err := parseID("channel_id", input.ChannelID)
if err != nil {
return nil, err
}
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
return nil, err
}
targetID, err := parseID("target_id", input.TargetID)
if err != nil {
return nil, err
}
if err := s.requireOverwriteTarget(ctx, guildID, input.TargetType, targetID); err != nil {
return nil, err
}
// Подтверждение личности берём из недавнего step-up: тело у DELETE
// необязательно, а клиент уже подтверждал личность перед изменением.
if err := s.auth.RequireStepUp(ctx, user, session, "", ""); err != nil {
return nil, humaError(err)
}
removed, err := s.store.DeleteChannelOverride(ctx, channelID, input.TargetType, targetID)
if err != nil {
return nil, humaError(err)
}
if removed {
s.afterOverwriteChange(ctx, user, guildID, channelID, "channel.overwrite_delete",
input.TargetType, targetID)
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteChannel",
Method: http.MethodDelete,
Path: "/guilds/{guild_id}/channels/{channel_id}",
Summary: "Удалить комнату",
Tags: []string{"Channels"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
ChannelID string `path:"channel_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels)
if err != nil {
return nil, err
}
channelID, err := parseID("channel_id", input.ChannelID)
if err != nil {
return nil, err
}
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
return nil, err
}
if err := s.store.DeleteChannel(ctx, channelID); err != nil {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_DELETE", map[string]any{
"channel_id": formatSnowflake(channelID),
"guild_id": formatSnowflake(guildID),
})
// Удалили комнату — у части участников могли остаться права на неё,
// поэтому список перечитывают все (AGENT.md 11.6).
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
})
return newOKOutput(), nil
})
}
// requireChannelInGuild проверяет, что комната принадлежит серверу (AGENT.md 9.7).
func (s *Server) requireChannelInGuild(ctx context.Context, guildID, channelID uint64) error {
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return humaError(err)
}
if channel.GuildID == nil || *channel.GuildID != guildID {
return humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
}
return nil
}
// requireOverwriteTarget проверяет цель оверрайда: роль должна принадлежать
// серверу, участник — состоять в нём (AGENT.md 6.2).
func (s *Server) requireOverwriteTarget(ctx context.Context, guildID uint64, targetType string, targetID uint64) error {
switch targetType {
case "role":
roles, err := s.store.ListGuildRoles(ctx, guildID)
if err != nil {
return humaError(err)
}
for _, role := range roles {
if role.ID == targetID {
return nil
}
}
return humaErrorStatus(http.StatusNotFound, "not_found", "role not found in this guild")
case "user":
member, err := s.store.GetGuildMember(ctx, guildID, targetID)
if err != nil || member == nil {
return humaErrorStatus(http.StatusNotFound, "not_found", "user is not a member of this guild")
}
return nil
default:
return humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "target_type must be role or user")
}
}
// afterOverwriteChange сбрасывает кэш прав комнаты, пишет аудит и рассылает
// события: клиенты должны перечитать список комнат, потому что видимость
// комнаты меняется не только у автора правки (AGENT.md 6.2, 11.6).
func (s *Server) afterOverwriteChange(ctx context.Context, actor *store.User, guildID, channelID uint64, action, targetType string, targetID uint64) {
if s.perms != nil {
s.perms.InvalidateChannel(channelID)
}
s.recordAudit(ctx, actor, guildID, action, targetType, &targetID, "")
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
})
}
// visibleChannels возвращает комнаты сервера, которые пользователь видит.
func (s *Server) visibleChannels(ctx context.Context, guildID uint64, user *store.User) ([]channelPayload, error) {
channels, err := s.store.ListGuildChannels(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
// Оверрайды всех комнат сервера — одним запросом: список комнат не должен
// превращаться в N+1 (AGENT.md 6.2).
overrides, err := s.store.ListGuildChannelOverrides(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
visible := make([]channelPayload, 0, len(channels))
for _, channel := range channels {
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
if err != nil {
return nil, humaError(err)
}
if !resolved.CanViewChannel() {
continue
}
visible = append(visible, channelFromStore(channel, &resolved, overrides[channel.ID]))
}
return visible, nil
}
// guildOutput собирает подробную карточку сервера с ролями и комнатами.
func (s *Server) guildOutput(ctx context.Context, user *store.User, guild store.Guild) (*guildOutput, error) {
resolved, err := s.guildPermissions(ctx, guild.ID, user)
if err != nil {
return nil, err
}
payload := guildDetailPayload{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
Description: guild.Description,
OwnerID: formatSnowflake(guild.OwnerID),
IsMain: guild.IsMain,
MyRoleIDs: []string{},
MyPermissions: permissions.Names(resolved.Guild),
Roles: []rolePayload{},
}
if guild.IconFileID != nil {
payload.IconFileID = formatSnowflake(*guild.IconFileID)
}
if guild.BannerFileID != nil {
payload.BannerFileID = formatSnowflake(*guild.BannerFileID)
}
if guild.SplashFileID != nil {
payload.SplashFileID = formatSnowflake(*guild.SplashFileID)
}
payload.AccentColor = guild.AccentColor
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID)
if err != nil {
return nil, humaError(err)
}
for _, roleID := range roleIDs {
payload.MyRoleIDs = append(payload.MyRoleIDs, formatSnowflake(roleID))
}
roles, err := s.store.ListGuildRoles(ctx, guild.ID)
if err != nil {
return nil, humaError(err)
}
for _, role := range roles {
payload.Roles = append(payload.Roles, roleFromStore(role))
}
channels, err := s.visibleChannels(ctx, guild.ID, user)
if err != nil {
return nil, err
}
payload.Channels = channels
count, err := s.store.CountGuildMembers(ctx, guild.ID)
if err != nil {
return nil, humaError(err)
}
payload.MemberCount = count
output := &guildOutput{}
output.Body.Guild = payload
return output, nil
}
// dispatchGuildEvent рассылает событие всем подключённым клиентам. Клиент
// сам решает, относится ли событие к его серверу: в Фазе 1 подписки на
// серверы не отслеживаются, фильтрация появится вместе с чатом (AGENT.md 8.3).
func (s *Server) dispatchGuildEvent(_ uint64, event string, payload any) {
if s.gateway == nil {
return
}
s.gateway.Dispatch(event, payload)
}
func (s *Server) invalidateGuild(guildID uint64) {
if s.perms != nil {
s.perms.InvalidateGuild(guildID)
}
}
func (s *Server) registerMemberRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "listGuildMembers",
Method: http.MethodGet,
Path: "/guilds/{guild_id}/members",
Summary: "Участники сервера",
Tags: []string{"Members"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Limit int `query:"limit" default:"100" minimum:"1" maximum:"1000"`
},
) (*memberListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
profiles, err := s.store.ListGuildMemberProfiles(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
// Оформление считается поэлементно для всего сервера (AGENT.md 7.2).
cosmetics, err := s.store.EffectiveCosmeticsForGuild(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
output := &memberListOutput{}
output.Body.Members = make([]memberPayload, 0, len(profiles))
for _, profile := range profiles {
if len(output.Body.Members) >= input.Limit {
break
}
payload := memberFromProfile(profile)
payload.Cosmetics = cosmeticsFromEffective(cosmetics[profile.UserID])
output.Body.Members = append(output.Body.Members, payload)
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateGuildMember",
Method: http.MethodPatch,
Path: "/guilds/{guild_id}/members/{user_id}",
Summary: "Никнейм и тайм-аут участника",
Tags: []string{"Members"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
UserID string `path:"user_id"`
Body struct {
Nickname *string `json:"nickname,omitempty" maxLength:"32"`
TimeoutUntil *string `json:"timeout_until,omitempty"`
}
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
// Свой никнейм участник меняет сам: иерархия и MANAGE_NICKNAMES для
// этого не нужны (AGENT.md 7.2, 7.17). Тайм-аут на себя — запрещён.
selfEdit := targetID == user.ID
if !selfEdit {
if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil {
return nil, err
}
}
var (
nickname *string
timeoutUntil *time.Time
)
if input.Body.Nickname != nil {
if !selfEdit && !resolved.Has(permissions.ManageNicknames) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MANAGE_NICKNAMES is required")
}
trimmed := strings.TrimSpace(*input.Body.Nickname)
nickname = &trimmed
}
if input.Body.TimeoutUntil != nil {
if selfEdit {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot time yourself out")
}
if !resolved.Has(permissions.TimeoutMembers) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "TIMEOUT_MEMBERS is required")
}
parsed, err := time.Parse(time.RFC3339, *input.Body.TimeoutUntil)
if err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "timeout_until must be RFC3339")
}
timeoutUntil = &parsed
}
if err := s.store.UpdateGuildMember(ctx, guildID, targetID, nickname, timeoutUntil); err != nil {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
payload := map[string]any{
"guild_id": formatSnowflake(guildID),
"user_id": formatSnowflake(targetID),
"nickname": nickname,
}
// Тайм-аут показываем в событии: клиенты обновляют состояние без F5.
if timeoutUntil != nil {
if timeoutUntil.After(time.Now().UTC()) {
payload["timeout_until"] = timeoutUntil.UTC().Format(time.RFC3339)
} else {
payload["timeout_until"] = nil
}
}
s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", payload)
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "kickGuildMember",
Method: http.MethodDelete,
Path: "/guilds/{guild_id}/members/{user_id}",
Summary: "Исключить участника",
Tags: []string{"Members"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
UserID string `path:"user_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.KickMembers)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil {
return nil, err
}
if err := s.store.RemoveGuildMember(ctx, guildID, targetID); err != nil {
return nil, humaError(err)
}
// Системная запись об исключении участника.
s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_kick", map[string]any{
"user_id": formatSnowflake(targetID),
})
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "member.kick", "user", &targetID, "")
s.dispatchGuildEvent(guildID, "MEMBER_REMOVE", map[string]any{
"guild_id": formatSnowflake(guildID),
"user_id": formatSnowflake(targetID),
})
if s.gateway != nil {
s.gateway.SendToUser(targetID, "GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)})
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "addMemberRole",
Method: http.MethodPut,
Path: "/guilds/{guild_id}/members/{user_id}/roles/{role_id}",
Summary: "Выдать роль участнику",
Tags: []string{"Roles"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
UserID string `path:"user_id"`
RoleID string `path:"role_id"`
},
) (*okOutput, error) {
return s.changeMemberRole(ctx, input.GuildID, input.UserID, input.RoleID, true)
})
huma.Register(api, huma.Operation{
OperationID: "removeMemberRole",
Method: http.MethodDelete,
Path: "/guilds/{guild_id}/members/{user_id}/roles/{role_id}",
Summary: "Снять роль с участника",
Tags: []string{"Roles"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
UserID string `path:"user_id"`
RoleID string `path:"role_id"`
},
) (*okOutput, error) {
return s.changeMemberRole(ctx, input.GuildID, input.UserID, input.RoleID, false)
})
}
// changeMemberRole выдаёт или снимает роль с проверкой иерархии (AGENT.md 6.3).
func (s *Server) changeMemberRole(ctx context.Context, guildIDRaw, userIDRaw, roleIDRaw string, assign bool) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, guildIDRaw, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", userIDRaw)
if err != nil {
return nil, err
}
roleID, err := parseID("role_id", roleIDRaw)
if err != nil {
return nil, err
}
// Роли на себя выдавать можно (MANAGE_ROLES уже проверено): запрет
// самомодерации касается кика, бана и тайм-аута, а не оформления.
if err := s.checkRoleHierarchy(ctx, guildID, user, resolved, targetID); err != nil {
return nil, err
}
role, err := s.store.GetRole(ctx, roleID)
if err != nil {
return nil, humaError(err)
}
if role.GuildID != guildID {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found")
}
if !s.outranksRole(ctx, guildID, user, resolved, role) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours")
}
if role.IsDefault {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "default role is assigned to everyone")
}
if assign {
err = s.store.AssignRole(ctx, guildID, targetID, roleID)
} else {
err = s.store.RemoveRole(ctx, guildID, targetID, roleID)
}
if err != nil {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
action := "member.role_add"
if !assign {
action = "member.role_remove"
}
s.recordAudit(ctx, user, guildID, action, "role", &roleID, "")
// Смена ролей — это обновление участника (AGENT.md 8.3: MEMBER_UPDATE).
roleIDs, err := s.store.MemberRoleIDs(ctx, guildID, targetID)
if err != nil {
return nil, humaError(err)
}
formatted := make([]string, 0, len(roleIDs))
for _, id := range roleIDs {
formatted = append(formatted, formatSnowflake(id))
}
s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{
"guild_id": formatSnowflake(guildID),
"user_id": formatSnowflake(targetID),
"role_ids": formatted,
"role_id": formatSnowflake(roleID),
"role_added": assign,
})
return newOKOutput(), nil
}
// checkRoleHierarchy — проверка иерархии для выдачи и снятия ролей: в отличие
// от модерации, свои роли менять можно (владелец сервера не теряет владение),
// но владельца и администратора инстанса по-прежнему не трогаем.
func (s *Server) checkRoleHierarchy(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, targetID uint64) error {
if actor.IsInstanceAdmin {
return nil
}
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return humaError(err)
}
if targetID == guild.OwnerID && actor.ID != guild.OwnerID {
return humaErrorStatus(http.StatusForbidden, "perm.denied", "guild owner cannot be moderated")
}
if target, err := s.store.GetUser(ctx, targetID); err == nil && target.IsInstanceAdmin && actor.ID != targetID {
return humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be moderated")
}
if targetID == actor.ID {
return nil
}
actorRoles, err := s.roleData(ctx, guildID, actor.ID)
if err != nil {
return err
}
targetRoles, err := s.roleData(ctx, guildID, targetID)
if err != nil {
return err
}
if !permissions.OutranksMember(actorRoles, targetRoles, resolved.IsOwner, targetID == guild.OwnerID) {
return humaErrorStatus(http.StatusForbidden, "perm.denied", "member has a higher role")
}
return nil
}
// checkMemberHierarchy запрещает действовать против владельца и участников с
// более высокими ролями (AGENT.md 6.3, 7.17).
func (s *Server) checkMemberHierarchy(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, targetID uint64) error {
// Модерировать себя нельзя никому, включая администратора инстанса: иначе
// он может случайно исключить или забанить сам себя (проверено на стенде).
if targetID == actor.ID {
return humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot moderate yourself")
}
if actor.IsInstanceAdmin {
return nil
}
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return humaError(err)
}
// Администратора инстанса нельзя модерировать с сервера: его права
// глобальны и не могут быть отозваны владельцем сервера (AGENT.md 7.19).
if target, err := s.store.GetUser(ctx, targetID); err == nil && target.IsInstanceAdmin {
return humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be moderated")
}
if targetID == guild.OwnerID && actor.ID != guild.OwnerID {
return humaErrorStatus(http.StatusForbidden, "perm.denied", "guild owner cannot be moderated")
}
actorRoles, err := s.roleData(ctx, guildID, actor.ID)
if err != nil {
return err
}
targetRoles, err := s.roleData(ctx, guildID, targetID)
if err != nil {
return err
}
targetIsOwner := targetID == guild.OwnerID
if !permissions.OutranksMember(actorRoles, targetRoles, resolved.IsOwner, targetIsOwner) {
return humaErrorStatus(http.StatusForbidden, "perm.denied", "target member has an equal or higher role")
}
return nil
}
// outranksRole проверяет, что роль ниже роли действующего пользователя.
func (s *Server) outranksRole(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, role *store.Role) bool {
if actor.IsInstanceAdmin || resolved.IsOwner {
return true
}
actorRoles, err := s.roleData(ctx, guildID, actor.ID)
if err != nil {
return false
}
return permissions.OutranksRole(actorRoles, permissions.RoleData{
ID: role.ID,
Permissions: permissions.Permission(role.Permissions),
IsDefault: role.IsDefault,
Position: role.Position,
}, false)
}
// roleData переводит роли участника в данные движка прав.
func (s *Server) roleData(ctx context.Context, guildID, userID uint64) ([]permissions.RoleData, error) {
roles, err := s.store.MemberRoles(ctx, guildID, userID)
if err != nil {
return nil, humaError(err)
}
result := make([]permissions.RoleData, 0, len(roles))
for _, role := range roles {
result = append(result, permissions.RoleData{
ID: role.ID,
Permissions: permissions.Permission(role.Permissions),
IsDefault: role.IsDefault,
Position: role.Position,
})
}
return result, nil
}
func (s *Server) registerRoleRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "listGuildRoles",
Method: http.MethodGet,
Path: "/guilds/{guild_id}/roles",
Summary: "Роли сервера",
Tags: []string{"Roles"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*roleListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
roles, err := s.store.ListGuildRoles(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
output := &roleListOutput{}
output.Body.Roles = make([]rolePayload, 0, len(roles))
for _, role := range roles {
output.Body.Roles = append(output.Body.Roles, roleFromStore(role))
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "createGuildRole",
Method: http.MethodPost,
Path: "/guilds/{guild_id}/roles",
Summary: "Создать роль",
Tags: []string{"Roles"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Body struct {
Name string `json:"name" minLength:"1" maxLength:"64"`
Permissions string `json:"permissions,omitempty"`
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
Hoist bool `json:"hoist,omitempty"`
Mentionable bool `json:"mentionable,omitempty"`
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
},
) (*roleOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
permissionSet, err := permissions.Parse(input.Body.Permissions)
if err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
}
if !s.canGrantPermissions(ctx, guildID, user, permissionSet) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot grant permissions you do not have")
}
name := strings.TrimSpace(input.Body.Name)
if name == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "role name must not be empty")
}
role, err := s.store.CreateRole(ctx, store.CreateRoleParams{
GuildID: guildID,
Name: name,
Permissions: uint64(permissionSet),
Color: input.Body.Color,
Hoist: input.Body.Hoist,
Mentionable: input.Body.Mentionable,
})
if err != nil {
return nil, humaError(err)
}
// Оформление роли (AGENT.md 7.4) сохраняем тем же обработчиком правки.
if role, err = s.applyRoleCosmetics(ctx, guildID, role, roleCosmeticsInput{
FrameID: input.Body.CosmeticFrameID,
BadgeID: input.Body.CosmeticBadgeID,
NickColor: input.Body.NickColor,
NickEffect: input.Body.NickEffect,
FrameSet: input.Body.CosmeticFrameID != "",
BadgeSet: input.Body.CosmeticBadgeID != "",
ColorSet: input.Body.NickColor != nil,
EffectSet: input.Body.NickEffect != "",
}); err != nil {
return nil, err
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "")
s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role))
output := &roleOutput{}
output.Body.Role = roleFromStore(*role)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateGuildRole",
Method: http.MethodPatch,
Path: "/guilds/{guild_id}/roles/{role_id}",
Summary: "Изменить роль",
Tags: []string{"Roles"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
RoleID string `path:"role_id"`
Body struct {
Name *string `json:"name,omitempty" maxLength:"64"`
Permissions *string `json:"permissions,omitempty"`
Color *int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
Hoist *bool `json:"hoist,omitempty"`
Mentionable *bool `json:"mentionable,omitempty"`
// Оформление роли (AGENT.md 7.4): пустая строка снимает элемент.
CosmeticFrameID *string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect *string `json:"nick_effect,omitempty"`
// Step-up обязателен при смене прав роли (AGENT.md 9.3).
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*roleOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
// Смена набора прав роли — чувствительная операция: подтверждаем
// личность свежим паролем (AGENT.md 9.3).
if input.Body.Permissions != nil {
if err := s.auth.RequireStepUp(ctx, user, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
}
roleID, err := parseID("role_id", input.RoleID)
if err != nil {
return nil, err
}
role, err := s.store.GetRole(ctx, roleID)
if err != nil {
return nil, humaError(err)
}
if role.GuildID != guildID {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found")
}
if !s.outranksRole(ctx, guildID, user, resolved, role) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours")
}
params := store.UpdateRoleParams{
Name: input.Body.Name,
Color: input.Body.Color,
Position: input.Body.Position,
Hoist: input.Body.Hoist,
Mentionable: input.Body.Mentionable,
}
if input.Body.Permissions != nil {
permissionSet, err := permissions.Parse(*input.Body.Permissions)
if err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
}
if !s.canGrantPermissions(ctx, guildID, user, permissionSet) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot grant permissions you do not have")
}
value := uint64(permissionSet)
params.Permissions = &value
}
updated, err := s.store.UpdateRole(ctx, roleID, params)
if err != nil {
return nil, humaError(err)
}
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
if updated, err = s.applyRoleCosmetics(ctx, guildID, updated, roleCosmeticsInput{
FrameID: optionalString(input.Body.CosmeticFrameID),
BadgeID: optionalString(input.Body.CosmeticBadgeID),
NickColor: input.Body.NickColor,
NickEffect: optionalString(input.Body.NickEffect),
FrameSet: input.Body.CosmeticFrameID != nil,
BadgeSet: input.Body.CosmeticBadgeID != nil,
ColorSet: input.Body.NickColor != nil,
EffectSet: input.Body.NickEffect != nil,
}); err != nil {
return nil, err
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "")
s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated))
output := &roleOutput{}
output.Body.Role = roleFromStore(*updated)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteGuildRole",
Method: http.MethodDelete,
Path: "/guilds/{guild_id}/roles/{role_id}",
Summary: "Удалить роль",
Tags: []string{"Roles"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
RoleID string `path:"role_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
roleID, err := parseID("role_id", input.RoleID)
if err != nil {
return nil, err
}
role, err := s.store.GetRole(ctx, roleID)
if err != nil {
return nil, humaError(err)
}
if role.GuildID != guildID {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found")
}
if role.IsDefault {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "default role cannot be deleted")
}
if !s.outranksRole(ctx, guildID, user, resolved, role) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours")
}
if err := s.store.DeleteRole(ctx, roleID); err != nil {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "role.delete", "role", &roleID, "")
s.dispatchGuildEvent(guildID, "ROLE_DELETE", map[string]any{
"guild_id": formatSnowflake(guildID),
"role_id": formatSnowflake(roleID),
})
return newOKOutput(), nil
})
}
// canGrantPermissions запрещает выдавать права выше собственных (AGENT.md 6.3).
func (s *Server) canGrantPermissions(ctx context.Context, guildID uint64, user *store.User, requested permissions.Permission) bool {
if user.IsInstanceAdmin {
return true
}
resolved, err := s.guildPermissions(ctx, guildID, user)
if err != nil {
return false
}
if resolved.IsOwner || resolved.Guild.Has(permissions.Administrator) {
return true
}
return resolved.Guild.Has(requested)
}
// memberFromProfile собирает участника для ответа API.
func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
payload := memberPayload{
UserID: formatSnowflake(profile.UserID),
Username: profile.Username,
DisplayName: profile.DisplayName,
Nickname: profile.Nickname,
Status: profile.Status,
CustomStatus: profile.CustomStatus,
IsInstanceAdmin: profile.IsInstanceAdmin,
JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339),
RoleIDs: make([]string, 0, len(profile.RoleIDs)),
Badges: profile.Badges,
}
if payload.Badges == nil {
payload.Badges = []string{}
}
if profile.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
}
// Истёкший тайм-аут не показываем: он уже не действует.
if profile.TimeoutUntil != nil && profile.TimeoutUntil.After(time.Now().UTC()) {
payload.TimeoutUntil = profile.TimeoutUntil.UTC().Format(time.RFC3339)
}
for _, roleID := range profile.RoleIDs {
payload.RoleIDs = append(payload.RoleIDs, formatSnowflake(roleID))
}
return payload
}
type auditEntryPayload struct {
ID string `json:"id"`
GuildID string `json:"guild_id,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorInstanceAdmin bool `json:"actor_instance_admin"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id,omitempty"`
Reason string `json:"reason,omitempty"`
Changes json.RawMessage `json:"changes,omitempty"`
CreatedAt string `json:"created_at"`
}
type auditListOutput struct {
Body struct {
Entries []auditEntryPayload `json:"entries"`
}
}
func auditPayloads(entries []store.AuditEntry) []auditEntryPayload {
payloads := make([]auditEntryPayload, 0, len(entries))
for _, entry := range entries {
payload := auditEntryPayload{
ID: formatSnowflake(entry.ID),
Action: entry.Action,
TargetType: entry.TargetType,
Reason: entry.Reason,
Changes: entry.Changes,
CreatedAt: entry.CreatedAt.UTC().Format(time.RFC3339),
ActorInstanceAdmin: entry.ActorInstanceAdmin,
}
if entry.GuildID != nil {
payload.GuildID = formatSnowflake(*entry.GuildID)
}
if entry.ActorID != nil {
payload.ActorID = formatSnowflake(*entry.ActorID)
}
if entry.TargetID != nil {
payload.TargetID = formatSnowflake(*entry.TargetID)
}
payloads = append(payloads, payload)
}
return payloads
}
// postSystemMessage создаёт системное сообщение в комнате: лента показывает
// такие записи отдельным стилем (AGENT.md 7.6).
func (s *Server) postSystemMessage(ctx context.Context, channelID uint64, kind string, payload map[string]any) {
if channelID == 0 {
return
}
data := map[string]any{"event": kind}
for key, value := range payload {
data[key] = value
}
encoded, err := json.Marshal(data)
if err != nil {
return
}
message, err := s.store.CreateMessage(ctx, store.CreateMessageParams{
ChannelID: channelID,
AuthorID: 0,
Content: string(encoded),
Type: store.MessageSystem,
})
if err != nil {
s.logger.DebugContext(ctx, "failed to post system message", slog.Any("error", err))
return
}
if s.gateway == nil {
return
}
payloadMessage, err := s.messagePayload(ctx, message, 0)
if err != nil {
return
}
s.gateway.DispatchToChannel(ctx, channelID, "MESSAGE_CREATE", payloadMessage)
}
// systemChannelID выбирает комнату для системных записей: первая текстовая.
func (s *Server) systemChannelID(ctx context.Context, guildID uint64) uint64 {
channels, err := s.store.ListGuildChannels(ctx, guildID)
if err != nil {
return 0
}
for _, channel := range channels {
if channel.Type == store.ChannelText {
return channel.ID
}
}
return 0
}
// recordAudit пишет действие в журнал; ошибка записи не ломает операцию.
func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uint64, action, targetType string, targetID *uint64, reason string) {
var guild *uint64
if guildID != 0 {
guild = &guildID
}
params := store.AuditParams{
GuildID: guild,
ActorID: &actor.ID,
ActorInstanceAdmin: actor.IsInstanceAdmin,
Action: action,
TargetType: targetType,
TargetID: targetID,
Reason: reason,
}
if err := s.store.RecordAudit(ctx, params); err != nil {
s.logger.WarnContext(ctx, "failed to record audit entry",
slog.String("action", action), slog.Any("error", err))
}
}
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
template, known := findGuildTemplate(templateID)
if !known {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return nil, humaError(err)
}
// Лимиты считаются по числу серверов; администратор инстанса обходит их
// осознанно, и это фиксируется в аудите (AGENT.md 6.5).
created, err := s.store.CountGuildMemberships(ctx, owner.ID)
if err != nil {
return nil, humaError(err)
}
bypassed := false
if created >= settings.MaxGuildsPerUser {
if !owner.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "limits.guilds_reached", "guild limit for this account is reached")
}
bypassed = true
} else if !settings.AllowGuildCreation && !owner.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "limits.guild_creation_disabled", "guild creation is disabled")
}
guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{
Name: name,
OwnerID: owner.ID,
// Пользовательский сервер становится главным только если главного нет.
IsMain: settings.MainGuildID == 0,
IsDiscoverable: false,
})
if err != nil {
return nil, humaError(err)
}
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
return nil, humaError(err)
}
// Структура комнат и ролей — из выбранного шаблона (AGENT.md 7.3).
if err := s.applyGuildTemplate(ctx, guild, template); err != nil {
return nil, err
}
if guild.IsMain {
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
return nil, humaError(err)
}
}
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID)
if bypassed {
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
}
if s.gateway != nil {
s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)})
}
return guild, nil
}
// joinGuild добавляет пользователя в открытый сервер: главный сервер открыт
// всегда, остальные — если помечены как discoverable (AGENT.md 6.3).
func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64) error {
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return humaError(err)
}
if !guild.IsMain && !guild.IsDiscoverable && !user.IsInstanceAdmin {
return humaErrorStatus(http.StatusForbidden, "guild.invite_required", "this guild requires an invite")
}
// Бан сервера закрывает вход и в публичный сервер: иначе достаточно было бы
// нажать «Войти» вместо приглашения (AGENT.md 7.17, 7.20).
if banned, err := s.store.IsGuildBanned(ctx, guildID, user.ID); err != nil {
return humaError(err)
} else if banned {
return humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return humaError(err)
}
count, err := s.store.CountGuildMembers(ctx, guildID)
if err != nil {
return humaError(err)
}
if count >= settings.MaxMembersPerGuild && !user.IsInstanceAdmin {
return humaErrorStatus(http.StatusForbidden, "limits.members_reached", "guild is full")
}
if _, err := s.store.AddGuildMember(ctx, guildID, user.ID, ""); err != nil {
if errors.Is(err, store.ErrConflict) {
return nil
}
return humaError(err)
}
s.invalidateGuild(guildID)
s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_join", map[string]any{
"user_id": formatSnowflake(user.ID),
})
s.recordAudit(ctx, user, guildID, "member.join", "user", &user.ID, "")
if s.gateway != nil {
s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guildID)})
}
return nil
}
// dispatchGuildUpdate рассылает GUILD_UPDATE со всеми полями оформления:
// клиенты обновляют иконку, баннер, splash и акцент без перезагрузки
// (AGENT.md 7.4, 11.6).
func (s *Server) dispatchGuildUpdate(guild store.Guild) {
if s.gateway == nil {
return
}
payload := map[string]any{
"guild_id": formatSnowflake(guild.ID),
"name": guild.Name,
"description": guild.Description,
"accent_color": guild.AccentColor,
}
if guild.IconFileID != nil {
payload["icon_file_id"] = formatSnowflake(*guild.IconFileID)
} else {
payload["icon_file_id"] = nil
}
if guild.BannerFileID != nil {
payload["banner_file_id"] = formatSnowflake(*guild.BannerFileID)
} else {
payload["banner_file_id"] = nil
}
if guild.SplashFileID != nil {
payload["splash_file_id"] = formatSnowflake(*guild.SplashFileID)
} else {
payload["splash_file_id"] = nil
}
s.gateway.Dispatch("GUILD_UPDATE", payload)
}
// deleteGuild удаляет сервер: главный сервер удалить нельзя (AGENT.md 6.3).
func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uint64) error {
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return humaError(err)
}
if guild.IsMain {
return humaErrorStatus(http.StatusForbidden, "guild.main_protected", "main guild cannot be deleted")
}
// Пути файлов собираем до удаления: записи уходят каскадом, а содержимое
// на диске осталось бы мусором (AGENT.md 6.4).
storagePaths, err := s.store.GuildStoragePaths(ctx, guildID)
if err != nil {
s.logger.WarnContext(ctx, "failed to list guild files",
slog.String("guild_id", formatSnowflake(guildID)), slog.Any("error", err))
}
if err := s.store.DeleteGuild(ctx, guildID); err != nil {
return humaError(err)
}
for _, path := range storagePaths {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logger.WarnContext(ctx, "failed to remove guild file",
slog.String("path", path), slog.Any("error", err))
}
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, actor, 0, "guild.delete", "guild", &guildID, "")
if s.gateway != nil {
s.gateway.Dispatch("GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)})
}
return nil
}