feat(messages): сообщения, реакции, пины, typing, read states и поиск

Бэкенд текстовой связи (AGENT.md 7.6, 7.15, 7.16, 8.6):

- POST/GET/PATCH/DELETE /channels/{id}/messages, ответы и упоминания,
  окно правки 24 часа, права автора или MANAGE_MESSAGES;
- реакции (PUT/DELETE .../reactions/{emoji}) с агрегацией и отметкой «моя»;
- закрепления (GET/PUT/DELETE .../pins) под MANAGE_MESSAGES;
- typing с лимитом 1/3 с и адресной рассылкой всем, кроме автора;
- read states: POST /channels/{id}/ack, синхронизация READ_STATE_UPDATE между
  устройствами пользователя;
- поиск FTS5 по комнате (10/мин) с экранированием запроса;
- лимиты отправки 5/5 с (burst 10) и slowmode комнаты; администратор инстанса
  обходит и то, и другое (AGENT.md 7.19);
- Gateway: DispatchToChannel/DispatchToChannelExcept доставляют события комнат
  только тем, кто видит комнату (VIEW_CHANNEL), права считает общий движок;
- store: messages, message_reactions, channel_read_states, files (загрузка
  файлов появится вместе с вложениями).

Тесты: жизненный цикл сообщения, видимость скрытой комнаты (404 участнику,
200 админу), slowmode, упоминания и ответы, typing и read state, фильтрация
событий комнаты между двумя WS-клиентами.
This commit is contained in:
2026-09-19 23:32:11 +03:00
parent 4550bea6cc
commit e4ffbb05a5
30 changed files with 3182 additions and 106 deletions
+48
View File
@@ -175,6 +175,54 @@ func TestRealtimeEventsReachConnectedClients(t *testing.T) {
}
}
// expectNoEvent проверяет, что за отведённое время событие не пришло.
func (c *realtimeClient) expectNoEvent(event string, wait time.Duration) {
c.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
for {
_, data, err := c.conn.Read(ctx)
if err != nil {
// Таймаут — то, что нужно: событие не доставлено.
return
}
var frame realtimeFrame
if err := json.Unmarshal(data, &frame); err != nil {
c.t.Fatalf("decode frame: %v", err)
}
if frame.Op == 0 && frame.T == event {
c.t.Fatalf("event %s must not reach this client", event)
}
}
}
// TestChannelEventsFilteredByPermissions проверяет фильтрацию событий комнат:
// сообщения скрытой комнаты не уходят тем, кто её не видит (AGENT.md 8.3, 9.7).
func TestChannelEventsFilteredByPermissions(t *testing.T) {
f := newMessagingFixture(t)
httpServer := httptest.NewServer(f.srv.Handler())
t.Cleanup(httpServer.Close)
owner := dialGateway(t, httpServer, f.ownerCookie)
member := dialGateway(t, httpServer, f.memberCookie)
// Открытая комната: событие получают оба.
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"всем"}`, f.ownerCookie); rec.Code != http.StatusOK {
t.Fatalf("open channel message = %d, body = %s", rec.Code, rec.Body.String())
}
owner.expectEvent("MESSAGE_CREATE")
member.expectEvent("MESSAGE_CREATE")
// Скрытая комната: владелец получает событие, участник — нет.
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages",
`{"content":"только для владельца"}`, f.ownerCookie); rec.Code != http.StatusOK {
t.Fatalf("secret channel message = %d, body = %s", rec.Code, rec.Body.String())
}
owner.expectEvent("MESSAGE_CREATE")
member.expectNoEvent("MESSAGE_CREATE", 700*time.Millisecond)
}
// TestInstanceAdminManagesForeignGuild проверяет §11.5: администратор инстанса,
// не состоящий в сервере, видит и меняет всё, а его самого модерировать нельзя.
func TestInstanceAdminManagesForeignGuild(t *testing.T) {