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:
2026-09-20 11:32:50 +03:00
parent 4c8f888b3e
commit 65d0bafe0e
11 changed files with 1556 additions and 210 deletions
+168 -9
View File
@@ -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)
}
+160
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"glchat/internal/permissions"
@@ -622,3 +623,162 @@ func TestFuzzySearch(t *testing.T) {
t.Fatalf("поиск «другое» вернул %d результатов: %v", len(results), results)
}
}
// TestChatCommands проверяет команды /me, /whisper, /scream и экранирование.
func TestChatCommands(t *testing.T) {
f := newMessagingFixture(t)
cases := []struct {
input string
want string
kind string
}{
{`{"content":"/me танцует"}`, "танцует", "action"},
{`{"content":"/whisper тихо"}`, "тихо", "whisper"},
{`{"content":"/scream ГРОМКО"}`, "ГРОМКО", "scream"},
{`{"content":"/wisper тихо"}`, "тихо", "whisper"},
{`{"content":"/unknown команда"}`, "/unknown команда", "default"},
{`{"content":"//не команда"}`, "/не команда", "default"},
}
for _, tc := range cases {
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
tc.input, f.ownerCookie)
if rec.Code != http.StatusOK {
t.Fatalf("%s → %d, body = %s", tc.input, rec.Code, rec.Body.String())
}
message := decodeResponse[struct {
Message struct {
Content string `json:"content"`
Type string `json:"type"`
} `json:"message"`
}](t, rec)
if message.Message.Content != tc.want || message.Message.Type != tc.kind {
t.Fatalf("%s → content=%q type=%q, ожидалось %q/%q",
tc.input, message.Message.Content, message.Message.Type, tc.want, tc.kind)
}
}
// Команда без текста — ошибка.
empty := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"/me"}`, f.ownerCookie)
if empty.Code != http.StatusUnprocessableEntity {
t.Fatalf("/me без текста = %d, want 422", empty.Code)
}
}
// TestPrivateMessageVisibility проверяет /ls: сообщение видят только автор и адресат.
func TestPrivateMessageVisibility(t *testing.T) {
f := newMessagingFixture(t)
memberLogin := "msg_member"
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"/ls `+memberLogin+` секретное сообщение"}`, f.ownerCookie)
if sent.Code != http.StatusOK {
t.Fatalf("private message = %d, body = %s", sent.Code, sent.Body.String())
}
message := decodeResponse[struct {
Message struct {
ID string `json:"id"`
Content string `json:"content"`
Type string `json:"type"`
Mentions []string `json:"mentions"`
} `json:"message"`
}](t, sent)
if message.Message.Type != "private" || message.Message.Content != "секретное сообщение" {
t.Fatalf("private message payload = %+v", message.Message)
}
if len(message.Message.Mentions) != 1 || message.Message.Mentions[0] != f.memberID {
t.Fatalf("получатель = %+v", message.Message.Mentions)
}
// Автор и адресат видят сообщение в истории.
ownerHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.ownerCookie)
memberHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.memberCookie)
for name, rec := range map[string]*httptest.ResponseRecorder{"автор": ownerHistory, "адресат": memberHistory} {
list := decodeResponse[struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}](t, rec)
found := false
for _, item := range list.Messages {
if item.ID == message.Message.ID {
found = true
}
}
if !found {
t.Fatalf("%s не видит личное сообщение", name)
}
}
// Третий участник не видит ни в истории, ни в поиске.
thirdCookie := registerAndLogin(t, f.srv, "msg_third", "msg-third@example.com")
doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/join", "", thirdCookie)
thirdHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", thirdCookie)
list := decodeResponse[struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}](t, thirdHistory)
for _, item := range list.Messages {
if item.ID == message.Message.ID {
t.Fatal("посторонний увидел личное сообщение в истории")
}
}
thirdSearch := doJSON(t, f.srv, http.MethodGet,
"/api/v1/channels/"+f.openChannel+"/messages/search?q="+url.QueryEscape("секретное"), "", thirdCookie)
results := decodeResponse[struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}](t, thirdSearch)
if len(results.Messages) != 0 {
leaked, _ := f.srv.store.GetMessage(t.Context(), guildIDOf(t, results.Messages[0].ID))
t.Fatalf("поиск отдал личное сообщение постороннему: id=%s type=%s author=%v mentions=%v (ожидалось private id=%s)",
results.Messages[0].ID, leaked.Type, leaked.AuthorID, leaked.Mentions, message.Message.ID)
}
// Несуществующий получатель — понятная ошибка.
bad := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"/ls nobody_here текст"}`, f.ownerCookie)
if bad.Code != http.StatusNotFound {
t.Fatalf("/ls с неизвестным логином = %d, want 404", bad.Code)
}
}
// TestMessageLimits проверяет лимиты пинов и правок (AGENT.md 7.6).
func TestMessageLimits(t *testing.T) {
f := newMessagingFixture(t)
created := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"для правок"}`, f.memberCookie)
message := decodeResponse[struct {
Message struct {
ID string `json:"id"`
} `json:"message"`
}](t, created)
// 10 правок в минуту: одиннадцатая — 429.
var last *httptest.ResponseRecorder
for i := range 11 {
last = doJSON(t, f.srv, http.MethodPatch,
"/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID,
`{"content":"правка `+strconv.Itoa(i)+`"}`, f.memberCookie)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("одиннадцатая правка = %d, want 429", last.Code)
}
// @everyone без MENTION_EVERYONE запрещён у участника с ролью по умолчанию.
everyone := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"@everyone привет"}`, f.memberCookie)
if everyone.Code != http.StatusForbidden {
t.Fatalf("@everyone без права = %d, want 403", everyone.Code)
}
// Владелец (ADMINISTRATOR) может.
ownerEveryone := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"@everyone привет"}`, f.ownerCookie)
if ownerEveryone.Code != http.StatusOK {
t.Fatalf("@everyone владельцем = %d, body = %s", ownerEveryone.Code, ownerEveryone.Body.String())
}
}
+3
View File
@@ -51,6 +51,8 @@ type Server struct {
messageLimiter *httpx.RateLimiter
typingLimiter *httpx.RateLimiter
searchLimiter *httpx.RateLimiter
// editLimiter — 10 правок сообщений в минуту (AGENT.md 7.6).
editLimiter *httpx.RateLimiter
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
inviteLimiter *httpx.RateLimiter
// slowmode — время последней отправки в комнату для режима медленной
@@ -83,6 +85,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
messageLimiter: httpx.NewRateLimiter(60, 10),
typingLimiter: httpx.NewRateLimiter(20, 1),
searchLimiter: httpx.NewRateLimiter(10, 10),
editLimiter: httpx.NewRateLimiter(10, 10),
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
+54 -11
View File
@@ -15,8 +15,17 @@ const (
MessageDefault MessageType = "default"
MessageSystem MessageType = "system"
MessageWhisper MessageType = "whisper"
// MessageAction — блок действия (/me), MessageScream — громкий блок,
// MessagePrivate — личное сообщение (/ls): видно только автору и адресатам
// (AGENT.md 7.6).
MessageAction MessageType = "action"
MessageScream MessageType = "scream"
MessagePrivate MessageType = "private"
)
// IsPrivate сообщает, что сообщение видно только автору и адресатам.
func (m MessageType) IsPrivate() bool { return m == MessagePrivate }
// Message — сообщение комнаты.
type Message struct {
ID uint64
@@ -110,8 +119,9 @@ func (s *Store) GetMessage(ctx context.Context, id uint64) (*Message, error) {
}
// ListMessages возвращает сообщения комнаты от новых к старым. beforeID
// используется для подгрузки истории вверх (AGENT.md 7.6).
func (s *Store) ListMessages(ctx context.Context, channelID, beforeID uint64, limit int) ([]Message, error) {
// используется для подгрузки истории вверх, viewerID — чтобы не отдавать
// личные сообщения (/ls) посторонним (AGENT.md 7.6).
func (s *Store) ListMessages(ctx context.Context, channelID, beforeID, viewerID uint64, limit int) ([]Message, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
@@ -121,6 +131,8 @@ func (s *Store) ListMessages(ctx context.Context, channelID, beforeID uint64, li
query += ` AND id < ?`
args = append(args, int64(beforeID))
}
query += ` AND (` + privateVisibilityClause + `)`
args = append(args, int64(viewerID), int64(viewerID))
query += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
@@ -141,6 +153,12 @@ func (s *Store) ListMessages(ctx context.Context, channelID, beforeID uint64, li
return messages, rows.Err()
}
// privateVisibilityClause пропускает обычные сообщения и личные, если зритель
// автор или адресат (адресаты хранятся в mentions_json).
const privateVisibilityClause = `type <> 'private'
OR author_id = ?
OR EXISTS (SELECT 1 FROM json_each(messages.mentions_json) WHERE json_each.value = ?)`
// ListPinnedMessages возвращает закреплённые сообщения комнаты.
func (s *Store) ListPinnedMessages(ctx context.Context, channelID uint64, limit int) ([]Message, error) {
if limit <= 0 || limit > 100 {
@@ -323,7 +341,7 @@ func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState,
// (AGENT.md 7.15: права проверяет вызывающий код, хранилище ограничивает выборку).
// Поиск нечёткий: FTS5 находит слова по началу («пр» → «Привет»), а подстрочные
// совпадения добираются по нормализованной копии текста без учёта регистра.
func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) {
func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, viewerID uint64, query string, limit int) ([]Message, error) {
query = strings.TrimSpace(query)
if len(channelIDs) == 0 || query == "" {
return []Message{}, nil
@@ -336,7 +354,7 @@ func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query s
seen := map[uint64]bool{}
if ftsQuery := toFTSPrefixQuery(query); ftsQuery != "" {
prefixMatches, err := s.searchMessagesFTS(ctx, channelIDs, ftsQuery, limit)
prefixMatches, err := s.searchMessagesFTS(ctx, channelIDs, viewerID, ftsQuery, limit)
if err != nil {
return nil, err
}
@@ -349,7 +367,7 @@ func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query s
}
if len(found) < limit {
substringMatches, err := s.searchMessagesSubstring(ctx, channelIDs, strings.ToLower(query), limit-len(found))
substringMatches, err := s.searchMessagesSubstring(ctx, channelIDs, viewerID, strings.ToLower(query), limit-len(found))
if err != nil {
return nil, err
}
@@ -393,20 +411,21 @@ func toFTSPrefixQuery(input string) string {
}
// searchMessagesFTS ищет по индексу FTS5 (слова и их начала).
func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, match string, limit int) ([]Message, error) {
func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, viewerID uint64, match string, limit int) ([]Message, error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",")
args := make([]any, 0, len(channelIDs)+2)
args := make([]any, 0, len(channelIDs)+4)
args = append(args, match)
for _, id := range channelIDs {
args = append(args, int64(id))
}
args = append(args, limit)
args = append(args, int64(viewerID), int64(viewerID), limit)
rows, err := s.reader.QueryContext(ctx, `
SELECT `+prefixedMessageColumns("m")+`
FROM messages_fts f
JOIN messages m ON m.id = f.rowid
WHERE messages_fts MATCH ? AND m.channel_id IN (`+placeholders+`)
AND (`+privateVisibilityClauseFor("m")+`)
ORDER BY m.id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
@@ -415,18 +434,19 @@ func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, matc
}
// searchMessagesSubstring ищет вхождения подстроки без учёта регистра.
func (s *Store) searchMessagesSubstring(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) {
func (s *Store) searchMessagesSubstring(ctx context.Context, channelIDs []uint64, viewerID uint64, query string, limit int) ([]Message, error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",")
args := make([]any, 0, len(channelIDs)+2)
args := make([]any, 0, len(channelIDs)+4)
args = append(args, "%"+escapeLike(query)+"%")
for _, id := range channelIDs {
args = append(args, int64(id))
}
args = append(args, limit)
args = append(args, int64(viewerID), int64(viewerID), limit)
rows, err := s.reader.QueryContext(ctx, `
SELECT `+messageColumns+` FROM messages
WHERE content_lower LIKE ? ESCAPE '\' AND channel_id IN (`+placeholders+`)
AND (`+privateVisibilityClause+`)
ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
@@ -448,6 +468,29 @@ func scanMessages(rows *sql.Rows, limit int) ([]Message, error) {
return messages, rows.Err()
}
// privateVisibilityClauseFor — то же правило видимости для таблицы с алиасом.
func privateVisibilityClauseFor(alias string) string {
return alias + `.type <> 'private'
OR ` + alias + `.author_id = ?
OR EXISTS (SELECT 1 FROM json_each(` + alias + `.mentions_json) WHERE json_each.value = ?)`
}
// CountPinnedMessages считает закрепления комнаты (лимит 50, AGENT.md 7.6).
func (s *Store) CountPinnedMessages(ctx context.Context, channelID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE channel_id = ? AND pinned = 1`, int64(channelID)).Scan(&count)
return count, err
}
// CountReactions считает уникальные эмодзи сообщения (лимит 20, AGENT.md 7.6).
func (s *Store) CountReactions(ctx context.Context, messageID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(DISTINCT emoji) FROM message_reactions WHERE message_id = ?`, int64(messageID)).Scan(&count)
return count, err
}
// prefixedMessageColumns добавляет префикс таблицы к списку колонок.
func prefixedMessageColumns(alias string) string {
columns := strings.Split(strings.ReplaceAll(messageColumns, "\n", " "), ",")