package server import ( "context" "encoding/json" "errors" "log/slog" "net/http" "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"` } type rolePayload struct { ID string `json:"id"` 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"` } 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"` } 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"` 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"` } func channelFromStore(channel store.Channel, resolved *permissions.Resolved) 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 resolved != nil { payload.CanView = resolved.CanViewChannel() payload.CanSend = resolved.Can(permissions.SendMessages) payload.CanConnect = resolved.Can(permissions.ConnectVoice) } return payload } func roleFromStore(role store.Role) rolePayload { return rolePayload{ ID: formatSnowflake(role.ID), Name: role.Name, Color: role.Color, Position: role.Position, Permissions: permissions.Permission(role.Permissions).String(), IsDefault: role.IsDefault, Hoist: role.Hoist, Mentionable: role.Mentionable, } } 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"` } }, ) (*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) if err != nil { return nil, err } return s.guildOutput(ctx, user, *guild) }) 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"` } }, ) (*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} 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) if s.gateway != nil { s.gateway.Dispatch("GUILD_UPDATE", map[string]any{ "guild_id": formatSnowflake(guildID), "name": guild.Name, }) } 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, _, 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") } 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) s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil)) // Права на новую комнату считаем для автора запроса. 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) 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", channelFromStore(*channel, nil)) 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) return output, 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), }) 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 } // 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) } 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)) } 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) } 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) } output := &memberListOutput{} output.Body.Members = make([]memberPayload, 0, len(profiles)) for _, profile := range profiles { if len(output.Body.Members) >= input.Limit { break } output.Body.Members = append(output.Body.Members, memberFromProfile(profile)) } 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 } if err := s.checkMemberHierarchy(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 } // 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"` } }, ) (*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) } 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"` } }, ) (*roleOutput, 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 !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) } 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)), } 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 string) (*store.Guild, error) { 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) } if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{ GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0, }); err != nil { return nil, humaError(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, "") 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") } 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 } // 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") } if err := s.store.DeleteGuild(ctx, guildID); err != nil { return humaError(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 }