fix(gateway): имена событий и resumable-флаг по спецификации §8.3

- ROLE_CREATE/ROLE_UPDATE/ROLE_DELETE вместо GUILD_ROLE_*;
- выдача/снятие роли приходит как MEMBER_UPDATE с полным списком role_ids:
  клиент пересчитывает доступные действия сразу после события;
- промах RESUME отдаёт INVALID_SESSION с {"resumable": true} и следом полный
  READY — сессия сохраняется, повторный вход не нужен;
- тесты обновлены под новый контракт.
This commit is contained in:
2026-09-19 21:57:46 +03:00
parent 463021161a
commit d6ca8e420f
4 changed files with 47 additions and 13 deletions
+18 -8
View File
@@ -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),
})
+2 -2
View File
@@ -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",