Files
glchat/internal/server/api_guilds.go
T

1541 lines
52 KiB
Go
Raw Normal View History

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"`
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"`
}
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"`
// Акцентный цвет сервера (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)
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)
}
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)
}
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
}