feat(messages): счётчики упоминаний, квота приглашений и read state автора
- упоминания в сообщении увеличивают счётчик упоминаний комнаты у упомянутых (store.BumpMentionCount) и отправляют им READ_STATE_UPDATE на все устройства; - отправка сообщения отмечает его прочитанным для автора; - подтверждение прочтения сбрасывает упоминания; - суточная квота 10 приглашений на пользователя (AGENT.md 8.6), администратор инстанса её обходит; httpx.NewRateLimiterWindow для произвольных окон; - тест на рост и сброс счётчика упоминаний.
This commit is contained in:
@@ -93,6 +93,12 @@ func (s *Server) registerInviteRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Суточная квота: 10 приглашений на пользователя (AGENT.md 8.6).
|
||||
if !user.IsInstanceAdmin {
|
||||
if allowed, retryAfter := s.inviteLimiter.Allow("invite:" + formatSnowflake(user.ID)); !allowed {
|
||||
return nil, rateLimitedError(retryAfter)
|
||||
}
|
||||
}
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot generate invite code")
|
||||
|
||||
@@ -159,6 +159,12 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
}
|
||||
}
|
||||
s.rememberSlowmode(channelID, user.ID)
|
||||
// Своё сообщение считаем прочитанным, а упомянутым — увеличиваем
|
||||
// счётчик упоминаний и сообщаем об этом их устройствам (AGENT.md 7.16).
|
||||
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)
|
||||
payload, err := s.messagePayload(ctx, message, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -500,6 +506,28 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
})
|
||||
}
|
||||
|
||||
// notifyMentions увеличивает счётчики упоминаний и уведомляет устройства
|
||||
// упомянутых пользователей (AGENT.md 7.16).
|
||||
func (s *Server) notifyMentions(ctx context.Context, channelID, authorID uint64, mentions []uint64) {
|
||||
if s.gateway == nil || len(mentions) == 0 {
|
||||
return
|
||||
}
|
||||
for _, userID := range mentions {
|
||||
if userID == authorID {
|
||||
continue
|
||||
}
|
||||
count, err := s.store.BumpMentionCount(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to bump mention count", slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
s.gateway.SendToUser(userID, "READ_STATE_UPDATE", map[string]any{
|
||||
"channel_id": formatSnowflake(channelID),
|
||||
"mention_count": count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requireChannelPermission проверяет права пользователя в комнате и отдаёт её.
|
||||
func (s *Server) requireChannelPermission(ctx context.Context, rawChannelID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, *store.Channel, error) {
|
||||
channelID, err := parseID("channel_id", rawChannelID)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ type Server struct {
|
||||
messageLimiter *httpx.RateLimiter
|
||||
typingLimiter *httpx.RateLimiter
|
||||
searchLimiter *httpx.RateLimiter
|
||||
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
|
||||
inviteLimiter *httpx.RateLimiter
|
||||
// slowmode — время последней отправки в комнату для режима медленной
|
||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||
slowmodeMu sync.Mutex
|
||||
@@ -78,6 +80,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),
|
||||
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||
slowmode: map[string]time.Time{},
|
||||
}
|
||||
switch {
|
||||
|
||||
Reference in New Issue
Block a user