package server import ( "context" "errors" "log/slog" "net/http" "regexp" "strings" "time" "github.com/danielgtaylor/huma/v2" "glchat/internal/permissions" "glchat/internal/store" ) // mentionPattern находит упоминания вида <@123> (AGENT.md 7.6). var mentionPattern = regexp.MustCompile(`<@([0-9]{1,20})>`) // customEmojiPattern находит кастомные эмодзи <:name:id> и (AGENT.md 7.12). var customEmojiPattern = regexp.MustCompile(``) // messageKey ключует лимиты по комнате и пользователю. func messageKey(channelID, userID uint64) string { return formatSnowflake(channelID) + ":" + formatSnowflake(userID) } // editWindow — сколько времени автор может править сообщение (AGENT.md 7.6). const editWindow = 24 * time.Hour type attachmentPayload struct { FileID string `json:"file_id"` Filename string `json:"filename"` ContentType string `json:"content_type,omitempty"` SizeBytes int64 `json:"size_bytes,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` } type reactionPayload struct { Emoji string `json:"emoji"` Count int `json:"count"` Me bool `json:"me"` UserIDs []string `json:"user_ids,omitempty"` } type messagePayload struct { ID string `json:"id"` ChannelID string `json:"channel_id"` AuthorID string `json:"author_id,omitempty"` Content string `json:"content"` ReplyToID string `json:"reply_to_id,omitempty"` Type string `json:"type"` EditedAt string `json:"edited_at,omitempty"` Pinned bool `json:"pinned"` Attachments []attachmentPayload `json:"attachments"` Mentions []string `json:"mentions"` Reactions []reactionPayload `json:"reactions"` CreatedAt string `json:"created_at"` // Поля вебхука (AGENT.md 7.11): у таких сообщений нет автора-пользователя, // а имя и аватар отправителя лежат в самом сообщении. WebhookID string `json:"webhook_id,omitempty"` WebhookName string `json:"webhook_name,omitempty"` WebhookAvatar string `json:"webhook_avatar,omitempty"` } type messageListOutput struct { Body struct { Messages []messagePayload `json:"messages"` } } type messageOutput struct { Body struct { Message messagePayload `json:"message"` } } // registerMessageRoutes описывает ручки сообщений, реакций, пинов, typing и // read states (AGENT.md 7.6, 7.16). func (s *Server) registerMessageRoutes(api huma.API) { security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} huma.Register(api, huma.Operation{ OperationID: "createMessage", Method: http.MethodPost, Path: "/channels/{channel_id}/messages", Summary: "Отправить сообщение", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Body struct { Content string `json:"content" maxLength:"4000"` ReplyToID string `json:"reply_to_id,omitempty"` AttachmentIDs []string `json:"attachment_ids,omitempty" maxItems:"20"` Nonce string `json:"nonce,omitempty" maxLength:"64"` } }, ) (*messageOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, resolved, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.SendMessages) if err != nil { return nil, err } // Личная беседа: блокировка запрещает переписку в обе стороны (AGENT.md 7.2). if channel.GuildID == nil { blocked, err := s.store.DMBlocked(ctx, channelID, user.ID) if err != nil { return nil, humaError(err) } if blocked { return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") } } // Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя, // администратор инстанса лимит обходит (AGENT.md 8.6, 7.19). if !user.IsInstanceAdmin { if allowed, retryAfter := s.messageLimiter.Allow(messageKey(channelID, user.ID)); !allowed { return nil, rateLimitedError(retryAfter) } if err := s.checkSlowmode(ctx, channel, user, resolved); err != nil { return nil, err } } content := strings.TrimSpace(input.Body.Content) attachments, err := s.attachmentsFromIDs(ctx, channelID, user.ID, input.Body.AttachmentIDs) if err != nil { return nil, err } // Команды чата разбираются на сервере (AGENT.md 7.6): /me, /whisper, // /scream, /ls. Неизвестная команда остаётся обычным текстом, «//» в // начале экранирует слэш. command, err := s.applyCommand(ctx, channelID, user.ID, &content) if err != nil { return nil, err } if content == "" && len(attachments) == 0 { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments") } settings, err := s.store.InstanceSettings(ctx) if err != nil { return nil, humaError(err) } if len([]rune(content)) > settings.MaxMessageLength { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message is too long") } mentions := s.extractMentions(ctx, channelID, content) if len(mentions) > maxMentionsPerMessage { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "too many mentions in one message") } messageType := store.MessageDefault if command != nil { messageType = command.kind if command.recipientID != 0 { mentions = []uint64{command.recipientID} } } if mentionsEveryone(content) && !resolved.Has(permissions.MentionEveryone) { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MENTION_EVERYONE is required for @everyone") } params := store.CreateMessageParams{ ChannelID: channelID, AuthorID: user.ID, Content: content, Type: messageType, Attachments: attachments, Mentions: mentions, } if input.Body.ReplyToID != "" { replyTo, err := parseID("reply_to_id", input.Body.ReplyToID) if err != nil { return nil, err } parent, err := s.store.GetMessage(ctx, replyTo) if err != nil { return nil, humaError(err) } if parent.ChannelID != channelID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "reply target is in another channel") } params.ReplyToID = &replyTo } message, err := s.store.CreateMessage(ctx, params) if err != nil { return nil, humaError(err) } // Вложения привязываем к сообщению: до этого они считаются сиротами. for _, attachment := range attachments { if err := s.store.AttachFileToMessage(ctx, attachment.FileID, message.ID); err != nil { s.logger.WarnContext(ctx, "failed to attach file to message", slog.String("file_id", formatSnowflake(attachment.FileID)), slog.Any("error", err)) } } s.rememberSlowmode(channelID, user.ID) // Своё сообщение считаем прочитанным, а упомянутым — увеличиваем // счётчик упоминаний и сообщаем об этом их устройствам (AGENT.md 7.16). if err := s.store.SetReadState(ctx, user.ID, channelID, message.ID, 0); err != nil { s.logger.WarnContext(ctx, "failed to update author read state", slog.Any("error", err)) } if !message.Type.IsPrivate() { s.notifyMentions(ctx, channelID, user.ID, message.Mentions) } payload, err := s.messagePayload(ctx, message, user.ID) if err != nil { return nil, err } if message.Type.IsPrivate() { // Личное сообщение (/ls) доставляем только автору и адресатам. for _, target := range message.Mentions { s.gatewaySendToUser(target, "MESSAGE_CREATE", payload) } s.gatewaySendToUser(user.ID, "MESSAGE_CREATE", payload) } else { s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload) } output := &messageOutput{} output.Body.Message = payload return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listMessages", Method: http.MethodGet, Path: "/channels/{channel_id}/messages", Summary: "История сообщений комнаты", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Before string `query:"before,omitempty"` Limit int `query:"limit" default:"50" minimum:"1" maximum:"100"` }, ) (*messageListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ReadMessageHistory) if err != nil { return nil, err } beforeID := uint64(0) if input.Before != "" { beforeID, err = parseID("before", input.Before) if err != nil { return nil, err } } messages, err := s.store.ListMessages(ctx, channelID, beforeID, user.ID, input.Limit) if err != nil { return nil, humaError(err) } payloads, err := s.messagePayloads(ctx, messages, user.ID) if err != nil { return nil, err } output := &messageListOutput{} output.Body.Messages = payloads return output, nil }) huma.Register(api, huma.Operation{ OperationID: "searchMessages", Method: http.MethodGet, Path: "/channels/{channel_id}/messages/search", Summary: "Поиск по сообщениям комнаты (FTS5)", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Query string `query:"q" minLength:"1" maxLength:"200"` Limit int `query:"limit" default:"25" minimum:"1" maximum:"100"` }, ) (*messageListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ReadMessageHistory) if err != nil { return nil, err } if allowed, retryAfter := s.searchLimiter.Allow("search:" + formatSnowflake(user.ID)); !allowed { return nil, rateLimitedError(retryAfter) } messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, user.ID, input.Query, input.Limit) if err != nil { return nil, humaError(err) } payloads, err := s.messagePayloads(ctx, messages, user.ID) if err != nil { return nil, err } output := &messageListOutput{} output.Body.Messages = payloads return output, nil }) huma.Register(api, huma.Operation{ OperationID: "updateMessage", Method: http.MethodPatch, Path: "/channels/{channel_id}/messages/{message_id}", Summary: "Изменить сообщение", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` MessageID string `path:"message_id"` Body struct { Content string `json:"content" maxLength:"4000"` } }, ) (*messageOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, resolved, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.SendMessages) if err != nil { return nil, err } message, err := s.messageInChannel(ctx, channelID, input.MessageID) if err != nil { return nil, err } if err := s.requireMessageAuthor(user, resolved, message, true); err != nil { return nil, err } content := strings.TrimSpace(input.Body.Content) if content == "" && len(message.Attachments) == 0 { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments") } // Правки ограничены: 10 в минуту (AGENT.md 7.6). if !user.IsInstanceAdmin { if allowed, retryAfter := s.editLimiter.Allow("edit:" + formatSnowflake(user.ID)); !allowed { return nil, rateLimitedError(retryAfter) } } updated, err := s.store.UpdateMessageContent(ctx, message.ID, content) if err != nil { return nil, humaError(err) } payload, err := s.messagePayload(ctx, updated, user.ID) if err != nil { return nil, err } s.dispatchChannelEvent(ctx, channelID, "MESSAGE_UPDATE", payload) output := &messageOutput{} output.Body.Message = payload return output, nil }) huma.Register(api, huma.Operation{ OperationID: "deleteMessage", Method: http.MethodDelete, Path: "/channels/{channel_id}/messages/{message_id}", Summary: "Удалить сообщение", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` MessageID string `path:"message_id"` }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, resolved, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) if err != nil { return nil, err } message, err := s.messageInChannel(ctx, channelID, input.MessageID) if err != nil { return nil, err } if err := s.requireMessageAuthor(user, resolved, message, false); err != nil { return nil, err } if err := s.store.DeleteMessage(ctx, message.ID); err != nil { return nil, humaError(err) } s.dispatchChannelEvent(ctx, channelID, "MESSAGE_DELETE", map[string]any{ "id": formatSnowflake(message.ID), "channel_id": formatSnowflake(channelID), }) return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "addReaction", Method: http.MethodPut, Path: "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}", Summary: "Поставить реакцию", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` MessageID string `path:"message_id"` Emoji string `path:"emoji"` }, ) (*okOutput, error) { return s.changeReaction(ctx, input.ChannelID, input.MessageID, input.Emoji, true) }) huma.Register(api, huma.Operation{ OperationID: "removeReaction", Method: http.MethodDelete, Path: "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}", Summary: "Снять реакцию", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` MessageID string `path:"message_id"` Emoji string `path:"emoji"` }, ) (*okOutput, error) { return s.changeReaction(ctx, input.ChannelID, input.MessageID, input.Emoji, false) }) huma.Register(api, huma.Operation{ OperationID: "listPinnedMessages", Method: http.MethodGet, Path: "/channels/{channel_id}/pins", Summary: "Закреплённые сообщения", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` }, ) (*messageListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) if err != nil { return nil, err } messages, err := s.store.ListPinnedMessages(ctx, channelID, 50) if err != nil { return nil, humaError(err) } payloads, err := s.messagePayloads(ctx, messages, user.ID) if err != nil { return nil, err } output := &messageListOutput{} output.Body.Messages = payloads return output, nil }) huma.Register(api, huma.Operation{ OperationID: "pinMessage", Method: http.MethodPut, Path: "/channels/{channel_id}/pins/{message_id}", Summary: "Закрепить сообщение", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` MessageID string `path:"message_id"` }, ) (*okOutput, error) { return s.changePin(ctx, input.ChannelID, input.MessageID, true) }) huma.Register(api, huma.Operation{ OperationID: "unpinMessage", Method: http.MethodDelete, Path: "/channels/{channel_id}/pins/{message_id}", Summary: "Открепить сообщение", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` MessageID string `path:"message_id"` }, ) (*okOutput, error) { return s.changePin(ctx, input.ChannelID, input.MessageID, false) }) huma.Register(api, huma.Operation{ OperationID: "sendTyping", Method: http.MethodPost, Path: "/channels/{channel_id}/typing", Summary: "Сообщить о наборе текста", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.SendMessages) if err != nil { return nil, err } // Typing — не чаще одного раза в 3 секунды (AGENT.md 8.6). if allowed, _ := s.typingLimiter.Allow("typing:" + formatSnowflake(user.ID) + ":" + formatSnowflake(channelID)); !allowed { return newOKOutput(), nil } s.dispatchChannelEventExcept(ctx, channelID, user.ID, "TYPING_START", map[string]any{ "channel_id": formatSnowflake(channelID), "user_id": formatSnowflake(user.ID), }) return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "acknowledgeChannel", Method: http.MethodPost, Path: "/channels/{channel_id}/ack", Summary: "Отметить комнату прочитанной", Tags: []string{"Messages"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Body struct { LastMessageID string `json:"last_message_id,omitempty"` } }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) if err != nil { return nil, err } lastID := uint64(0) if input.Body.LastMessageID != "" { lastID, err = parseID("last_message_id", input.Body.LastMessageID) if err != nil { return nil, err } } if err := s.store.SetReadState(ctx, user.ID, channelID, lastID, 0); err != nil { return nil, humaError(err) } // Состояние прочтения синхронизируется между устройствами (AGENT.md 7.16). if s.gateway != nil { s.gateway.SendToUser(user.ID, "READ_STATE_UPDATE", map[string]any{ "channel_id": formatSnowflake(channelID), "last_message_id": formatSnowflake(lastID), "mention_count": 0, }) } return newOKOutput(), nil }) } // Лимиты сообщений (AGENT.md 7.6). const ( maxMentionsPerMessage = 10 maxReactionsPerMessage = 20 maxPinsPerChannel = 50 ) // chatCommand — разобранная команда чата. type chatCommand struct { kind store.MessageType recipientID uint64 } // applyCommand разбирает команду в начале сообщения (AGENT.md 7.6): // /me, /whisper (/wisper), /scream и /ls (личное сообщение). func (s *Server) applyCommand(ctx context.Context, channelID, authorID uint64, content *string) (*chatCommand, error) { text := strings.TrimSpace(*content) if strings.HasPrefix(text, "//") { // Экранирование: «//текст» превращается в «/текст». *content = strings.TrimPrefix(text, "/") return nil, nil } if !strings.HasPrefix(text, "/") { return nil, nil } name, rest, _ := strings.Cut(strings.TrimPrefix(text, "/"), " ") rest = strings.TrimSpace(rest) switch strings.ToLower(name) { case "me": if rest == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /me requires text") } *content = rest return &chatCommand{kind: store.MessageAction}, nil case "whisper", "wisper": if rest == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /whisper requires text") } *content = rest return &chatCommand{kind: store.MessageWhisper}, nil case "scream": if rest == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /scream requires text") } *content = rest return &chatCommand{kind: store.MessageScream}, nil case "ls": login, body, _ := strings.Cut(rest, " ") login = strings.TrimPrefix(strings.TrimSpace(login), "@") body = strings.TrimSpace(body) if login == "" || body == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /ls requires a username and text") } recipient, err := s.store.GetUserByUsername(ctx, login) if err != nil { if errors.Is(err, store.ErrNotFound) { return nil, humaErrorStatus(http.StatusNotFound, "user.not_found", "получатель не найден") } return nil, humaError(err) } if recipient.ID == authorID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot send a private message to yourself") } // Получатель должен быть участником сервера и видеть комнату. if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(channelID), recipient, permissions.ViewChannel); err != nil { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "получатель не имеет доступа к комнате") } *content = body return &chatCommand{kind: store.MessagePrivate, recipientID: recipient.ID}, nil default: // Неизвестная команда — обычный текст. return nil, nil } } // mentionsEveryone ищет @everyone/@here в тексте. func mentionsEveryone(content string) bool { lowered := strings.ToLower(content) return strings.Contains(lowered, "@everyone") || strings.Contains(lowered, "@here") } // gatewaySendToUser отправляет событие пользователю, если Gateway доступен. func (s *Server) gatewaySendToUser(userID uint64, event string, payload any) { if s.gateway != nil { s.gateway.SendToUser(userID, event, payload) } } // notifyMentions увеличивает счётчики упоминаний и уведомляет устройства // упомянутых пользователей (AGENT.md 7.16). func (s *Server) notifyMentions(ctx context.Context, channelID, authorID uint64, mentions []uint64) { if s.gateway == nil || len(mentions) == 0 { return } for _, userID := range mentions { if userID == authorID { continue } count, err := s.store.BumpMentionCount(ctx, userID, channelID) if err != nil { s.logger.WarnContext(ctx, "failed to bump mention count", slog.Any("error", err)) continue } s.gateway.SendToUser(userID, "READ_STATE_UPDATE", map[string]any{ "channel_id": formatSnowflake(channelID), "mention_count": count, }) } } // requireChannelPermission проверяет права пользователя в комнате и отдаёт её. func (s *Server) requireChannelPermission(ctx context.Context, rawChannelID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, *store.Channel, error) { channelID, err := parseID("channel_id", rawChannelID) if err != nil { return 0, permissions.Resolved{}, nil, err } channel, err := s.store.GetChannel(ctx, channelID) if err != nil { return 0, permissions.Resolved{}, nil, humaError(err) } if channel.GuildID == nil { // Личная беседа: участник получает права на переписку, посторонний // не видит канал вовсе (AGENT.md 7.8). if channel.Type != store.ChannelDM { return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") } participant, err := s.store.IsDMParticipant(ctx, channelID, user.ID) if err != nil { return 0, permissions.Resolved{}, nil, humaError(err) } if !participant { return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") } dmPermissions := permissions.ViewChannel | permissions.SendMessages | permissions.ReadMessageHistory | permissions.AttachFiles | permissions.AddReactions resolved := permissions.Resolved{ Guild: dmPermissions, Channel: dmPermissions, IsMember: true, } if !resolved.Can(permission) { return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") } return channelID, resolved, channel, nil } resolved, err := s.perms.Channel(ctx, *channel.GuildID, channelID, user.ID, user.IsInstanceAdmin) if err != nil { return 0, permissions.Resolved{}, nil, humaError(err) } if !resolved.CanViewChannel() { return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") } if !resolved.Can(permission) { return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") } return channelID, resolved, channel, nil } // messageInChannel проверяет, что сообщение принадлежит комнате. func (s *Server) messageInChannel(ctx context.Context, channelID uint64, rawMessageID string) (*store.Message, error) { messageID, err := parseID("message_id", rawMessageID) if err != nil { return nil, err } message, err := s.store.GetMessage(ctx, messageID) if err != nil { return nil, humaError(err) } if message.ChannelID != channelID { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "message not found") } return message, nil } // requireMessageAuthor разрешает действие автору сообщения или модератору с // MANAGE_MESSAGES; правка ограничена окном editWindow (AGENT.md 7.6). func (s *Server) requireMessageAuthor(user *store.User, resolved permissions.Resolved, message *store.Message, editing bool) error { isAuthor := message.AuthorID != nil && *message.AuthorID == user.ID if isAuthor { if editing && message.EditedAt == nil && time.Since(message.CreatedAt) > editWindow { return humaErrorStatus(http.StatusForbidden, "message.edit_window_expired", "message can no longer be edited") } return nil } if resolved.Has(permissions.ManageMessages) { return nil } return humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") } // changeReaction ставит или снимает реакцию и рассылает событие. func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID, emoji string, add bool) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } emoji = strings.TrimSpace(emoji) if emoji == "" || len([]rune(emoji)) > 64 { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji is invalid") } // Реакция кастомным эмодзи: проверяем, что файл существует и это эмодзи // доступного сервера (AGENT.md 7.12). if match := customEmojiPattern.FindStringSubmatch(emoji); match != nil { fileID, err := parseID("emoji", match[2]) if err != nil { return nil, err } if _, err := s.store.EmojiByFileID(ctx, fileID); err != nil { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown custom emoji") } } channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.AddReactions) if err != nil { return nil, err } message, err := s.messageInChannel(ctx, channelID, rawMessageID) if err != nil { return nil, err } var reactionErr error if add { // Не больше 20 уникальных реакций на сообщение (AGENT.md 7.6). unique, err := s.store.CountReactions(ctx, message.ID) if err != nil { return nil, humaError(err) } if unique >= maxReactionsPerMessage { existing, err := s.store.ListReactions(ctx, message.ID, user.ID) if err != nil { return nil, humaError(err) } known := false for _, reaction := range existing { if reaction.Emoji == emoji { known = true } } if !known { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "reaction limit reached for this message") } } reactionErr = s.store.AddReaction(ctx, message.ID, user.ID, emoji) } else { reactionErr = s.store.RemoveReaction(ctx, message.ID, user.ID, emoji) } if reactionErr != nil { return nil, humaError(reactionErr) } event := "MESSAGE_REACTION_REMOVE" if add { event = "MESSAGE_REACTION_ADD" } s.dispatchChannelEvent(ctx, channelID, event, map[string]any{ "channel_id": formatSnowflake(channelID), "message_id": formatSnowflake(message.ID), "user_id": formatSnowflake(user.ID), "emoji": emoji, }) return newOKOutput(), nil } // changePin закрепляет или открепляет сообщение (нужно MANAGE_MESSAGES). func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID string, pinned bool) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.ManageMessages) if err != nil { return nil, err } message, err := s.messageInChannel(ctx, channelID, rawMessageID) if err != nil { return nil, err } if pinned { // Не больше 50 закреплений на комнату (AGENT.md 7.6). pins, err := s.store.CountPinnedMessages(ctx, channelID) if err != nil { return nil, humaError(err) } if pins >= maxPinsPerChannel { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "pin limit reached for this channel") } } if err := s.store.SetMessagePinned(ctx, message.ID, pinned); err != nil { return nil, humaError(err) } s.dispatchChannelEvent(ctx, channelID, "CHANNEL_PINS_UPDATE", map[string]any{ "channel_id": formatSnowflake(channelID), "message_id": formatSnowflake(message.ID), "pinned": pinned, }) return newOKOutput(), nil } // messagePayload собирает сообщение для API с реакциями и автором. func (s *Server) messagePayload(ctx context.Context, message *store.Message, viewerID uint64) (messagePayload, error) { payload := messagePayload{ ID: formatSnowflake(message.ID), ChannelID: formatSnowflake(message.ChannelID), Content: message.Content, Type: string(message.Type), Pinned: message.Pinned, Attachments: make([]attachmentPayload, 0, len(message.Attachments)), Mentions: make([]string, 0, len(message.Mentions)), Reactions: []reactionPayload{}, CreatedAt: message.CreatedAt.UTC().Format(time.RFC3339), } if message.AuthorID != nil { payload.AuthorID = formatSnowflake(*message.AuthorID) } if message.WebhookID != nil { payload.WebhookID = formatSnowflake(*message.WebhookID) } payload.WebhookName = message.WebhookName payload.WebhookAvatar = message.WebhookAvatar if message.ReplyToID != nil { payload.ReplyToID = formatSnowflake(*message.ReplyToID) } if message.EditedAt != nil { payload.EditedAt = message.EditedAt.UTC().Format(time.RFC3339) } for _, attachment := range message.Attachments { payload.Attachments = append(payload.Attachments, attachmentPayload{ FileID: formatSnowflake(attachment.FileID), Filename: attachment.Filename, ContentType: attachment.ContentType, SizeBytes: attachment.SizeBytes, Width: attachment.Width, Height: attachment.Height, }) } for _, mention := range message.Mentions { payload.Mentions = append(payload.Mentions, formatSnowflake(mention)) } reactions, err := s.store.ListReactions(ctx, message.ID, viewerID) if err != nil { return messagePayload{}, humaError(err) } for _, reaction := range reactions { item := reactionPayload{Emoji: reaction.Emoji, Count: reaction.Count, Me: reaction.Me} for _, userID := range reaction.UserIDs { item.UserIDs = append(item.UserIDs, formatSnowflake(userID)) } payload.Reactions = append(payload.Reactions, item) } return payload, nil } func (s *Server) messagePayloads(ctx context.Context, messages []store.Message, viewerID uint64) ([]messagePayload, error) { payloads := make([]messagePayload, 0, len(messages)) for i := range messages { payload, err := s.messagePayload(ctx, &messages[i], viewerID) if err != nil { return nil, err } payloads = append(payloads, payload) } return payloads, nil } // dispatchChannelEvent рассылает событие комнаты только тем, кто её видит. func (s *Server) dispatchChannelEvent(ctx context.Context, channelID uint64, event string, payload any) { if s.gateway == nil { return } s.gateway.DispatchToChannel(ctx, channelID, event, payload) } // dispatchChannelEventExcept рассылает событие всем, кроме указанного пользователя. func (s *Server) dispatchChannelEventExcept(ctx context.Context, channelID, exceptUserID uint64, event string, payload any) { if s.gateway == nil { return } s.gateway.DispatchToChannelExcept(ctx, channelID, exceptUserID, event, payload) } // checkSlowmode проверяет режим медленной отправки комнаты (AGENT.md 7.5). func (s *Server) checkSlowmode(_ context.Context, channel *store.Channel, user *store.User, resolved permissions.Resolved) error { if channel.SlowmodeSeconds <= 0 || resolved.Has(permissions.ManageMessages) || resolved.Has(permissions.ManageChannels) { return nil } s.slowmodeMu.Lock() last, ok := s.slowmode[messageKey(channel.ID, user.ID)] s.slowmodeMu.Unlock() if !ok { return nil } elapsed := time.Since(last) wait := time.Duration(channel.SlowmodeSeconds)*time.Second - elapsed if wait <= 0 { return nil } return rateLimitedError(wait) } // rememberSlowmode запоминает время последней отправки в комнату. func (s *Server) rememberSlowmode(channelID, userID uint64) { s.slowmodeMu.Lock() defer s.slowmodeMu.Unlock() // Попутная уборка, чтобы словарь не рос бесконечно. if len(s.slowmode) > 4096 { cutoff := time.Now().Add(-time.Hour) for key, at := range s.slowmode { if at.Before(cutoff) { delete(s.slowmode, key) } } } s.slowmode[messageKey(channelID, userID)] = time.Now() } // extractMentions ищет упоминания вида <@123> и проверяет, что пользователь // состоит в сервере (AGENT.md 7.6). func (s *Server) extractMentions(ctx context.Context, channelID uint64, content string) []uint64 { if !strings.Contains(content, "<@") { return nil } channel, err := s.store.GetChannel(ctx, channelID) if err != nil || channel.GuildID == nil { return nil } members, err := s.store.ListGuildMembers(ctx, *channel.GuildID) if err != nil { return nil } known := make(map[uint64]bool, len(members)) for _, member := range members { known[member.UserID] = true } mentions := make([]uint64, 0, 4) seen := map[uint64]bool{} for _, candidate := range mentionPattern.FindAllStringSubmatch(content, -1) { id, err := parseID("mention", candidate[1]) if err != nil || !known[id] || seen[id] { continue } seen[id] = true mentions = append(mentions, id) } return mentions } // attachmentsFromIDs проверяет, что файлы загружены этим пользователем в эту // комнату и ещё не привязаны к сообщению (AGENT.md 7.7). func (s *Server) attachmentsFromIDs(ctx context.Context, channelID, userID uint64, rawIDs []string) ([]store.Attachment, error) { if len(rawIDs) == 0 { return nil, nil } attachments := make([]store.Attachment, 0, len(rawIDs)) for _, raw := range rawIDs { fileID, err := parseID("attachment_ids", raw) if err != nil { return nil, err } file, err := s.store.GetFile(ctx, fileID) if err != nil { return nil, humaError(err) } if file.UploaderID == nil || *file.UploaderID != userID || file.ChannelID == nil || *file.ChannelID != channelID { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "attachment belongs to another user or channel") } attachments = append(attachments, store.Attachment{ FileID: file.ID, Filename: file.Filename, ContentType: file.ContentType, SizeBytes: file.SizeBytes, Width: file.Width, Height: file.Height, }) } return attachments, nil } // rateLimitedError отдаёт 429 с подсказкой по паузе (AGENT.md 8.5, 8.6). func rateLimitedError(retryAfter time.Duration) huma.StatusError { milliseconds := retryAfter.Milliseconds() if milliseconds <= 0 { milliseconds = 1000 } return humaErrorStatusDetails(http.StatusTooManyRequests, "rate_limited", "too many requests", map[string]any{ "retry_after_ms": milliseconds, }) }