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
+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", " "), ",")