diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 173c3c5..808108a 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -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) } diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index bcfeb10..db5cbb9 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 77b8f08..65e6968 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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{}, diff --git a/internal/store/messages.go b/internal/store/messages.go index 5e27ad0..7f7050e 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -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", " "), ",") diff --git a/web/src/components/chat/Composer.tsx b/web/src/components/chat/Composer.tsx index dd3eb9c..182b594 100644 --- a/web/src/components/chat/Composer.tsx +++ b/web/src/components/chat/Composer.tsx @@ -6,6 +6,11 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file import { fetchMeta, metaQueryKey } from '@/api/meta'; import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages'; import type { Channel } from '@/api/types'; +import { + COMPOSER_TEXT_CLASS, + ComposerMirror, + type ComposerSelection, +} from '@/components/chat/ComposerMirror'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { formatBytes, errorCode } from '@/lib/format'; @@ -32,6 +37,11 @@ const LINE_HEIGHT_PX = 22; * Композер сообщения: автовысота, Enter/Shift+Enter, счётчик символов, * вложения (скрепка и drag&drop), плашка ответа и обработка ошибок отправки * (лимит частоты 429 с таймером, отсутствие прав). + * + * Над полем лежит зеркало (`ComposerMirror`): оно рисует разметку, поэтому + * форматирование видно ещё до отправки, а текст самой textarea прозрачный. + * Позиция каретки живёт в состоянии и обновляется на ввод, выделение, клик и + * отпускание клавиши. */ export function Composer({ channel, @@ -55,8 +65,11 @@ export function Composer({ /** Тикающее «сейчас»: по нему считается остаток лимита частоты. */ const [now, setNow] = useState(() => Date.now()); const [dragging, setDragging] = useState(false); + /** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */ + const [selection, setSelection] = useState({ start: 0, end: 0 }); const textareaRef = useRef(null); + const mirrorRef = useRef(null); const fileInputRef = useRef(null); // Предельный размер файла знает сервер (`GET /meta`); если он неизвестен, @@ -69,6 +82,26 @@ export function Composer({ }); const maxUploadSize = meta.data?.max_upload_size; + /** Прокрутка зеркала повторяет прокрутку поля (текст длиннее восьми строк). */ + const syncScroll = useCallback(() => { + const element = textareaRef.current; + const mirror = mirrorRef.current; + if (element === null || mirror === null) { + return; + } + mirror.scrollTop = element.scrollTop; + mirror.scrollLeft = element.scrollLeft; + }, []); + + /** Обновление каретки: лишних перерисовок не делаем. */ + const syncSelection = useCallback((element: HTMLTextAreaElement) => { + const start = element.selectionStart; + const end = element.selectionEnd; + setSelection((current) => + current.start === start && current.end === end ? current : { start, end }, + ); + }, []); + // Автовысота: от одной строки до ~8. useEffect(() => { const element = textareaRef.current; @@ -78,7 +111,8 @@ export function Composer({ element.style.height = 'auto'; const max = Math.min(MAX_TEXTAREA_HEIGHT_PX, LINE_HEIGHT_PX * 8); element.style.height = `${String(Math.min(element.scrollHeight, max))}px`; - }, [value]); + syncScroll(); + }, [value, syncScroll]); // Таймер лимита частоты: пока он идёт, отправка заблокирована. // Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно. @@ -160,6 +194,8 @@ export function Composer({ ...(files.length === 0 ? {} : { files }), }); setValue(''); + // Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров. + setSelection({ start: 0, end: 0 }); setFiles([]); setNotice(null); // Отправка прошла — лимит частоты больше не держим. @@ -292,33 +328,60 @@ export function Composer({ event.target.value = ''; }} /> -