2e038a1d3f
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
1042 lines
37 KiB
Go
1042 lines
37 KiB
Go
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> и <a:name:id> (AGENT.md 7.12).
|
|
var customEmojiPattern = regexp.MustCompile(`<a?:([a-zA-Z0-9_]{2,32}):([0-9]{1,20})>`)
|
|
|
|
// 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
|
|
}
|
|
// Антиспам-лимит: 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,
|
|
})
|
|
}
|