feat(messages): счётчики упоминаний, квота приглашений и read state автора

- упоминания в сообщении увеличивают счётчик упоминаний комнаты у упомянутых
  (store.BumpMentionCount) и отправляют им READ_STATE_UPDATE на все устройства;
- отправка сообщения отмечает его прочитанным для автора;
- подтверждение прочтения сбрасывает упоминания;
- суточная квота 10 приглашений на пользователя (AGENT.md 8.6), администратор
  инстанса её обходит; httpx.NewRateLimiterWindow для произвольных окон;
- тест на рост и сброс счётчика упоминаний.
This commit is contained in:
2026-09-19 23:37:02 +03:00
parent 0e39b8e027
commit 8619f37cd5
15 changed files with 186 additions and 6 deletions
+50
View File
@@ -518,3 +518,53 @@ func TestFileUploadAndAccess(t *testing.T) {
t.Fatalf("attachments = %+v", message.Message.Attachments)
}
}
func TestMentionsIncrementReadState(t *testing.T) {
f := newMessagingFixture(t)
// Владелец упоминает участника: у того растёт счётчик упоминаний.
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"<@`+f.memberID+`> привет"}`, f.ownerCookie)
if sent.Code != http.StatusOK {
t.Fatalf("message with mention = %d, body = %s", sent.Code, sent.Body.String())
}
memberID := guildIDOf(t, f.memberID)
channelID := guildIDOf(t, f.openChannel)
states, err := f.srv.store.ListReadStates(t.Context(), memberID)
if err != nil {
t.Fatalf("ListReadStates: %v", err)
}
var mentions int
for _, state := range states {
if state.ChannelID == channelID {
mentions = state.MentionCount
}
}
if mentions != 1 {
t.Fatalf("mention count = %d, want 1 (states: %+v)", mentions, states)
}
// Автор отметил своё сообщение прочитанным.
authorStates, err := f.srv.store.ListReadStates(t.Context(), guildIDOf(t, f.ownerID))
if err != nil {
t.Fatalf("ListReadStates owner: %v", err)
}
if len(authorStates) != 1 || formatSnowflake(authorStates[0].LastMessageID) == "" {
t.Fatalf("author read state = %+v", authorStates)
}
// Подтверждение прочтения сбрасывает упоминания.
ack := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/ack", `{}`, f.memberCookie)
if ack.Code != http.StatusOK {
t.Fatalf("ack = %d, body = %s", ack.Code, ack.Body.String())
}
after, err := f.srv.store.ListReadStates(t.Context(), memberID)
if err != nil {
t.Fatalf("ListReadStates after ack: %v", err)
}
for _, state := range after {
if state.ChannelID == channelID && state.MentionCount != 0 {
t.Fatalf("mention count after ack = %d, want 0", state.MentionCount)
}
}
}