feat(messages): команды чата, личные сообщения и лимиты
AGENT.md 7.6: - команды разбираются на сервере: /me (блок действия), /whisper и /wisper (шёпот), /scream (громкий блок), «//» экранирует слэш, неизвестная команда остаётся обычным текстом, пустой текст у команды — 422; - /ls <логин> <текст> — личное сообщение: тип private, адресат в mentions, получатель проверяется на участие в сервере и доступ к комнате; сообщение не отдаётся посторонним ни в истории, ни в поиске (и FTS, и подстрочном) и доставляется в Gateway только автору и адресатам; - лимиты: 10 правок в минуту, 20 уникальных реакций на сообщение, 50 закреплений на комнату, не более 10 упоминаний, @everyone/@here только с MENTION_EVERYONE; - тесты: команды и экранирование, изоляция личного сообщения (история, поиск, третий участник), лимит правок и @everyone.
This commit is contained in:
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -114,6 +115,13 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
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")
|
||||
}
|
||||
@@ -125,12 +133,28 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
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: s.extractMentions(ctx, channelID, content),
|
||||
Mentions: mentions,
|
||||
}
|
||||
if input.Body.ReplyToID != "" {
|
||||
replyTo, err := parseID("reply_to_id", input.Body.ReplyToID)
|
||||
@@ -164,12 +188,22 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
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))
|
||||
}
|
||||
s.notifyMentions(ctx, channelID, user.ID, message.Mentions)
|
||||
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
|
||||
}
|
||||
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload)
|
||||
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
|
||||
@@ -203,7 +237,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
messages, err := s.store.ListMessages(ctx, channelID, beforeID, input.Limit)
|
||||
messages, err := s.store.ListMessages(ctx, channelID, beforeID, user.ID, input.Limit)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
@@ -240,7 +274,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
if allowed, retryAfter := s.searchLimiter.Allow("search:" + formatSnowflake(user.ID)); !allowed {
|
||||
return nil, rateLimitedError(retryAfter)
|
||||
}
|
||||
messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, input.Query, input.Limit)
|
||||
messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, user.ID, input.Query, input.Limit)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
@@ -287,6 +321,12 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
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)
|
||||
@@ -506,6 +546,94 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
})
|
||||
}
|
||||
|
||||
// Лимиты сообщений (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) {
|
||||
@@ -626,13 +754,34 @@ func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reactionErr error
|
||||
if add {
|
||||
err = s.store.AddReaction(ctx, message.ID, user.ID, emoji)
|
||||
// Не больше 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 {
|
||||
err = s.store.RemoveReaction(ctx, message.ID, user.ID, emoji)
|
||||
reactionErr = s.store.RemoveReaction(ctx, message.ID, user.ID, emoji)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
if reactionErr != nil {
|
||||
return nil, humaError(reactionErr)
|
||||
}
|
||||
event := "MESSAGE_REACTION_REMOVE"
|
||||
if add {
|
||||
@@ -661,6 +810,16 @@ func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID strin
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user