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:
+54
-11
@@ -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", " "), ",")
|
||||
|
||||
Reference in New Issue
Block a user