diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 7569ab0..67d44b5 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -357,9 +357,23 @@ func TestResumeReplaysMissedEvents(t *testing.T) { t.Fatalf("resume replay = %q, want CHANNEL_CREATE", replayed.T) } - // RESUME с неизвестной последовательностью приводит к полному READY. + // RESUME с неизвестной последовательностью: INVALID_SESSION с признаком + // resumable и следом полный READY (AGENT.md 8.3). third, _ := f.dial(t) send(t, third, gateway.OpResume, map[string]any{"token": token, "resume_seq": ready.S + 10000}) + invalid := readEnvelope(t, third) + if invalid.Op != gateway.OpInvalidSess { + t.Fatalf("stale resume op = %d, want INVALID_SESSION", invalid.Op) + } + var invalidPayload struct { + Resumable bool `json:"resumable"` + } + if err := json.Unmarshal(invalid.D, &invalidPayload); err != nil { + t.Fatalf("decode INVALID_SESSION: %v", err) + } + if !invalidPayload.Resumable { + t.Fatal("resume failure must be marked resumable: the client keeps the session") + } full := readEnvelope(t, third) if full.T != "READY" { t.Fatalf("stale resume = %q, want full READY", full.T) diff --git a/internal/gateway/session.go b/internal/gateway/session.go index d7fdd39..5a68ae6 100644 --- a/internal/gateway/session.go +++ b/internal/gateway/session.go @@ -217,7 +217,14 @@ func (s *Service) readLoop(ctx context.Context, session *clientSession) { // sendInvalidSession сообщает об ошибке идентификации и дожидается отправки // кадра: соединение закрывается сразу после этого (AGENT.md 8.3). func (s *Service) sendInvalidSession(session *clientSession, reason string) { - payload, err := frame(OpInvalidSess, map[string]any{"reason": reason}) + s.sendInvalidSessionResumable(session, reason, false) +} + +// sendInvalidSessionResumable отдаёт INVALID_SESSION с признаком resumable: +// при true клиент продолжает работу (сервер пришлёт полный READY), при false — +// уходит на экран входа. +func (s *Service) sendInvalidSessionResumable(session *clientSession, reason string, resumable bool) { + payload, err := frame(OpInvalidSess, map[string]any{"reason": reason, "resumable": resumable}) if err != nil { return } @@ -276,7 +283,10 @@ func (s *Service) handleIdentify(ctx context.Context, session *clientSession, pa s.register(session, authSession.TokenHash) return nil } - // Буфер не содержит нужную последовательность: полный READY (AGENT.md 8.3). + // Буфер не содержит нужную последовательность: по AGENT.md 8.3 клиенту + // сообщаем INVALID_SESSION с признаком resumable и сразу отдаём полный + // READY, чтобы состояние восстановилось без повторного входа. + s.sendInvalidSessionResumable(session, "resume failed", true) } snapshot, err := s.readiness.Build(ctx, user) diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index cfc5685..96647d4 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -888,11 +888,21 @@ func (s *Server) changeMemberRole(ctx context.Context, guildIDRaw, userIDRaw, ro action = "member.role_remove" } s.recordAudit(ctx, user, guildID, action, "role", &roleID, "") - s.dispatchGuildEvent(guildID, "MEMBER_ROLES_UPDATE", map[string]any{ - "guild_id": formatSnowflake(guildID), - "user_id": formatSnowflake(targetID), - "role_id": formatSnowflake(roleID), - "assigned": assign, + // Смена ролей — это обновление участника (AGENT.md 8.3: MEMBER_UPDATE). + roleIDs, err := s.store.MemberRoleIDs(ctx, guildID, targetID) + if err != nil { + return nil, humaError(err) + } + formatted := make([]string, 0, len(roleIDs)) + for _, id := range roleIDs { + formatted = append(formatted, formatSnowflake(id)) + } + s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "user_id": formatSnowflake(targetID), + "role_ids": formatted, + "role_id": formatSnowflake(roleID), + "role_added": assign, }) return newOKOutput(), nil } @@ -1052,7 +1062,7 @@ func (s *Server) registerRoleRoutes(api huma.API) { } s.invalidateGuild(guildID) s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "") - s.dispatchGuildEvent(guildID, "GUILD_ROLE_CREATE", roleFromStore(*role)) + s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role)) output := &roleOutput{} output.Body.Role = roleFromStore(*role) return output, nil @@ -1124,7 +1134,7 @@ func (s *Server) registerRoleRoutes(api huma.API) { } s.invalidateGuild(guildID) s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "") - s.dispatchGuildEvent(guildID, "GUILD_ROLE_UPDATE", roleFromStore(*updated)) + s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated)) output := &roleOutput{} output.Body.Role = roleFromStore(*updated) return output, nil @@ -1172,7 +1182,7 @@ func (s *Server) registerRoleRoutes(api huma.API) { } s.invalidateGuild(guildID) s.recordAudit(ctx, user, guildID, "role.delete", "role", &roleID, "") - s.dispatchGuildEvent(guildID, "GUILD_ROLE_DELETE", map[string]any{ + s.dispatchGuildEvent(guildID, "ROLE_DELETE", map[string]any{ "guild_id": formatSnowflake(guildID), "role_id": formatSnowflake(roleID), }) diff --git a/internal/server/realtime_test.go b/internal/server/realtime_test.go index 5c50555..ba132b5 100644 --- a/internal/server/realtime_test.go +++ b/internal/server/realtime_test.go @@ -143,7 +143,7 @@ func TestRealtimeEventsReachConnectedClients(t *testing.T) { ID string `json:"id"` } `json:"role"` }](t, roleRec) - owner.expectEvent("GUILD_ROLE_CREATE") + owner.expectEvent("ROLE_CREATE") memberUser, err := srv.auth.UserByEmail(t.Context(), "rt-member@example.com") if err != nil { @@ -154,7 +154,7 @@ func TestRealtimeEventsReachConnectedClients(t *testing.T) { "", ownerCookie); rec.Code != http.StatusOK { t.Fatalf("assign role = %d, body = %s", rec.Code, rec.Body.String()) } - member.expectEvent("MEMBER_ROLES_UPDATE") + member.expectEvent("MEMBER_UPDATE") // Профиль: событие приходит только владельцу сессии. if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me",