diff --git a/internal/httpx/ratelimit.go b/internal/httpx/ratelimit.go index 292151a..1feee4c 100644 --- a/internal/httpx/ratelimit.go +++ b/internal/httpx/ratelimit.go @@ -33,9 +33,24 @@ func NewRateLimiter(perMinute, burst int) *RateLimiter { if burst <= 0 { burst = perMinute } + return NewRateLimiterWindow(perMinute, time.Minute, burst) +} + +// NewRateLimiterWindow создаёт лимитер на произвольное окно: нужно для +// суточных квот (например, 10 приглашений в день, AGENT.md 8.6). +func NewRateLimiterWindow(limit int, window time.Duration, burst int) *RateLimiter { + if limit <= 0 { + limit = 1 + } + if window <= 0 { + window = time.Minute + } + if burst <= 0 { + burst = limit + } return &RateLimiter{ buckets: map[string]*rateBucket{}, - rate: float64(perMinute) / 60, + rate: float64(limit) / window.Seconds(), burst: float64(burst), now: time.Now, } diff --git a/internal/server/api_invites.go b/internal/server/api_invites.go index fd81568..0fdc3bf 100644 --- a/internal/server/api_invites.go +++ b/internal/server/api_invites.go @@ -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") diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index e103c3d..20e629c 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -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) diff --git a/internal/server/files/94894465700855808 b/internal/server/files/94894465700855808 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894465700855808 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894465721827328 b/internal/server/files/94894465721827328 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894465721827328 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index ed67587..5deb8d5 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -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) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7ce9416..e4322dc 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 + // 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 { diff --git a/internal/store/messages.go b/internal/store/messages.go index 92c56ce..319d2c6 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -274,6 +274,24 @@ func (s *Store) SetReadState(ctx context.Context, userID, channelID, lastMessage return err } +// BumpMentionCount увеличивает счётчик упоминаний пользователя в комнате и +// возвращает новое значение (AGENT.md 7.16). +func (s *Store) BumpMentionCount(ctx context.Context, userID, channelID uint64) (int, error) { + var count int + err := s.writer.QueryRowContext(ctx, ` + INSERT INTO channel_read_states (user_id, channel_id, last_message_id, mention_count, updated_at) + VALUES (?, ?, 0, 1, ?) + ON CONFLICT (user_id, channel_id) DO UPDATE SET + mention_count = channel_read_states.mention_count + 1, + updated_at = excluded.updated_at + RETURNING mention_count`, + int64(userID), int64(channelID), s.Now()).Scan(&count) + if err != nil { + return 0, mapError(err) + } + return count, nil +} + // ListReadStates отдаёт состояния прочтения пользователя (для READY). func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState, error) { rows, err := s.reader.QueryContext(ctx, ` diff --git a/web/src/components/ui/Menu.tsx b/web/src/components/ui/Menu.tsx index 281769e..c157a80 100644 --- a/web/src/components/ui/Menu.tsx +++ b/web/src/components/ui/Menu.tsx @@ -93,8 +93,9 @@ export function Menu({ open, label, onClose, children, anchorRef, className = '' const onKeyDown = (event: ReactKeyboardEvent): void => { if (event.key === 'Tab') { - // Меню — не часть tab-порядка: уводим фокус дальше по странице. + // Меню — не часть tab-порядка: закрываем его и оставляем фокус на якоре. onClose(); + anchorRef?.current?.focus(); return; } const items = Array.from(panelRef.current?.querySelectorAll(ITEM_SELECTOR) ?? []); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index a4879c9..8421e8d 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export default function LoginPage() { const registrationEnabled = instance.data?.registration_enabled === true; return ( -
+

{t('auth.login.title')}

{t('auth.login.subtitle')}

diff --git a/web/src/pages/OnboardingPage.tsx b/web/src/pages/OnboardingPage.tsx index 07ba428..8095fea 100644 --- a/web/src/pages/OnboardingPage.tsx +++ b/web/src/pages/OnboardingPage.tsx @@ -131,7 +131,7 @@ export default function OnboardingPage() { } return ( -
+

{suggestedName === '' diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index d75e4e0..a670854 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -111,7 +111,7 @@ export default function RegisterPage() { } return ( -
+

{t('auth.register.title')}

diff --git a/web/src/pages/StatusPage.tsx b/web/src/pages/StatusPage.tsx index 6aef53c..89d8394 100644 --- a/web/src/pages/StatusPage.tsx +++ b/web/src/pages/StatusPage.tsx @@ -40,7 +40,7 @@ export default function StatusPage() { }; return ( -

+

{t('app.name')}

diff --git a/web/tests/servers.test.tsx b/web/tests/servers.test.tsx index eb729ad..6c403eb 100644 --- a/web/tests/servers.test.tsx +++ b/web/tests/servers.test.tsx @@ -255,6 +255,20 @@ describe('настройки серверов', () => { expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull(); }); + it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => { + installFetch(serversRoutes()); + + renderApp('/settings/servers/g-2'); + await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' })); + + const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' }); + await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + }); + it('покинуть сервер можно после подтверждения в модальном окне', async () => { const fetchMock = installFetch( serversRoutes([ diff --git a/web/tests/session.test.ts b/web/tests/session.test.ts index 94ab125..f9b760a 100644 --- a/web/tests/session.test.ts +++ b/web/tests/session.test.ts @@ -112,4 +112,47 @@ describe('session store', () => { expect(state.guilds).toEqual([]); expect(state.selectedGuildId).toBeNull(); }); + + it('хранит выбранный в настройках сервер отдельно от выбора в приложении', () => { + act(() => { + dispatchGatewayEvent({ + op: 0, + t: 'READY', + s: 6, + d: makeReadySnapshot([ + { id: 'g-1', name: 'Main' }, + { id: 'g-2', name: 'Second' }, + ]), + }); + useSessionStore.getState().selectChannel('g-1', 'c-1'); + useSessionStore.getState().selectSettingsGuild('g-2'); + }); + + const state = useSessionStore.getState(); + expect(state.settingsGuildId).toBe('g-2'); + // Выбор в приложении не пострадал. + expect(state.selectedGuildId).toBe('g-1'); + + act(() => { + useSessionStore.getState().reset(); + }); + expect(useSessionStore.getState().settingsGuildId).toBeNull(); + }); + + it('обновляет свой никнейм на сервере', () => { + act(() => { + dispatchGatewayEvent({ + op: 0, + t: 'READY', + s: 7, + d: makeReadySnapshot([{ id: 'g-1', name: 'Main', my_nickname: 'Аля' }]), + }); + }); + expect(useSessionStore.getState().guilds[0]?.my_nickname).toBe('Аля'); + + act(() => { + useSessionStore.getState().setMyNickname('g-1', 'Алиса'); + }); + expect(useSessionStore.getState().guilds[0]?.my_nickname).toBe('Алиса'); + }); });