feat(messages): счётчики упоминаний, квота приглашений и read state автора
- упоминания в сообщении увеличивают счётчик упоминаний комнаты у упомянутых (store.BumpMentionCount) и отправляют им READ_STATE_UPDATE на все устройства; - отправка сообщения отмечает его прочитанным для автора; - подтверждение прочтения сбрасывает упоминания; - суточная квота 10 приглашений на пользователя (AGENT.md 8.6), администратор инстанса её обходит; httpx.NewRateLimiterWindow для произвольных окон; - тест на рост и сброс счётчика упоминаний.
This commit is contained in:
@@ -33,9 +33,24 @@ func NewRateLimiter(perMinute, burst int) *RateLimiter {
|
|||||||
if burst <= 0 {
|
if burst <= 0 {
|
||||||
burst = perMinute
|
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{
|
return &RateLimiter{
|
||||||
buckets: map[string]*rateBucket{},
|
buckets: map[string]*rateBucket{},
|
||||||
rate: float64(perMinute) / 60,
|
rate: float64(limit) / window.Seconds(),
|
||||||
burst: float64(burst),
|
burst: float64(burst),
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ func (s *Server) registerInviteRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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()
|
code, err := newInviteCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot generate invite code")
|
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)
|
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)
|
payload, err := s.messagePayload(ctx, message, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 проверяет права пользователя в комнате и отдаёт её.
|
// requireChannelPermission проверяет права пользователя в комнате и отдаёт её.
|
||||||
func (s *Server) requireChannelPermission(ctx context.Context, rawChannelID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, *store.Channel, error) {
|
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)
|
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)
|
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
|
messageLimiter *httpx.RateLimiter
|
||||||
typingLimiter *httpx.RateLimiter
|
typingLimiter *httpx.RateLimiter
|
||||||
searchLimiter *httpx.RateLimiter
|
searchLimiter *httpx.RateLimiter
|
||||||
|
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
|
||||||
|
inviteLimiter *httpx.RateLimiter
|
||||||
// slowmode — время последней отправки в комнату для режима медленной
|
// slowmode — время последней отправки в комнату для режима медленной
|
||||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||||
slowmodeMu sync.Mutex
|
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),
|
messageLimiter: httpx.NewRateLimiter(60, 10),
|
||||||
typingLimiter: httpx.NewRateLimiter(20, 1),
|
typingLimiter: httpx.NewRateLimiter(20, 1),
|
||||||
searchLimiter: httpx.NewRateLimiter(10, 10),
|
searchLimiter: httpx.NewRateLimiter(10, 10),
|
||||||
|
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||||
slowmode: map[string]time.Time{},
|
slowmode: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -274,6 +274,24 @@ func (s *Store) SetReadState(ctx context.Context, userID, channelID, lastMessage
|
|||||||
return err
|
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).
|
// ListReadStates отдаёт состояния прочтения пользователя (для READY).
|
||||||
func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState, error) {
|
func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState, error) {
|
||||||
rows, err := s.reader.QueryContext(ctx, `
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
|||||||
@@ -93,8 +93,9 @@ export function Menu({ open, label, onClose, children, anchorRef, className = ''
|
|||||||
|
|
||||||
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
||||||
if (event.key === 'Tab') {
|
if (event.key === 'Tab') {
|
||||||
// Меню — не часть tab-порядка: уводим фокус дальше по странице.
|
// Меню — не часть tab-порядка: закрываем его и оставляем фокус на якоре.
|
||||||
onClose();
|
onClose();
|
||||||
|
anchorRef?.current?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const items = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []);
|
const items = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function LoginPage() {
|
|||||||
const registrationEnabled = instance.data?.registration_enabled === true;
|
const registrationEnabled = instance.data?.registration_enabled === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
<main className="gl-page mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.login.title')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.login.title')}</h1>
|
||||||
<p className="mt-1 text-fg-muted">{t('auth.login.subtitle')}</p>
|
<p className="mt-1 text-fg-muted">{t('auth.login.subtitle')}</p>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function OnboardingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
<main className="gl-page mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
{suggestedName === ''
|
{suggestedName === ''
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
<main className="gl-page mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.register.title')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.register.title')}</h1>
|
||||||
<p className="mt-1 text-fg-muted">
|
<p className="mt-1 text-fg-muted">
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function StatusPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
<div className="gl-page mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
|
||||||
|
|||||||
@@ -255,6 +255,20 @@ describe('настройки серверов', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
|
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 () => {
|
it('покинуть сервер можно после подтверждения в модальном окне', async () => {
|
||||||
const fetchMock = installFetch(
|
const fetchMock = installFetch(
|
||||||
serversRoutes([
|
serversRoutes([
|
||||||
|
|||||||
@@ -112,4 +112,47 @@ describe('session store', () => {
|
|||||||
expect(state.guilds).toEqual([]);
|
expect(state.guilds).toEqual([]);
|
||||||
expect(state.selectedGuildId).toBeNull();
|
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('Алиса');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user