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:
@@ -357,9 +357,23 @@ func TestResumeReplaysMissedEvents(t *testing.T) {
|
|||||||
t.Fatalf("resume replay = %q, want CHANNEL_CREATE", replayed.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)
|
third, _ := f.dial(t)
|
||||||
send(t, third, gateway.OpResume, map[string]any{"token": token, "resume_seq": ready.S + 10000})
|
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)
|
full := readEnvelope(t, third)
|
||||||
if full.T != "READY" {
|
if full.T != "READY" {
|
||||||
t.Fatalf("stale resume = %q, want full READY", full.T)
|
t.Fatalf("stale resume = %q, want full READY", full.T)
|
||||||
|
|||||||
@@ -217,7 +217,14 @@ func (s *Service) readLoop(ctx context.Context, session *clientSession) {
|
|||||||
// sendInvalidSession сообщает об ошибке идентификации и дожидается отправки
|
// sendInvalidSession сообщает об ошибке идентификации и дожидается отправки
|
||||||
// кадра: соединение закрывается сразу после этого (AGENT.md 8.3).
|
// кадра: соединение закрывается сразу после этого (AGENT.md 8.3).
|
||||||
func (s *Service) sendInvalidSession(session *clientSession, reason string) {
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,7 +283,10 @@ func (s *Service) handleIdentify(ctx context.Context, session *clientSession, pa
|
|||||||
s.register(session, authSession.TokenHash)
|
s.register(session, authSession.TokenHash)
|
||||||
return nil
|
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)
|
snapshot, err := s.readiness.Build(ctx, user)
|
||||||
|
|||||||
@@ -888,11 +888,21 @@ func (s *Server) changeMemberRole(ctx context.Context, guildIDRaw, userIDRaw, ro
|
|||||||
action = "member.role_remove"
|
action = "member.role_remove"
|
||||||
}
|
}
|
||||||
s.recordAudit(ctx, user, guildID, action, "role", &roleID, "")
|
s.recordAudit(ctx, user, guildID, action, "role", &roleID, "")
|
||||||
s.dispatchGuildEvent(guildID, "MEMBER_ROLES_UPDATE", map[string]any{
|
// Смена ролей — это обновление участника (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),
|
"guild_id": formatSnowflake(guildID),
|
||||||
"user_id": formatSnowflake(targetID),
|
"user_id": formatSnowflake(targetID),
|
||||||
|
"role_ids": formatted,
|
||||||
"role_id": formatSnowflake(roleID),
|
"role_id": formatSnowflake(roleID),
|
||||||
"assigned": assign,
|
"role_added": assign,
|
||||||
})
|
})
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
}
|
}
|
||||||
@@ -1052,7 +1062,7 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "")
|
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 := &roleOutput{}
|
||||||
output.Body.Role = roleFromStore(*role)
|
output.Body.Role = roleFromStore(*role)
|
||||||
return output, nil
|
return output, nil
|
||||||
@@ -1124,7 +1134,7 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "")
|
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 := &roleOutput{}
|
||||||
output.Body.Role = roleFromStore(*updated)
|
output.Body.Role = roleFromStore(*updated)
|
||||||
return output, nil
|
return output, nil
|
||||||
@@ -1172,7 +1182,7 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.recordAudit(ctx, user, guildID, "role.delete", "role", &roleID, "")
|
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),
|
"guild_id": formatSnowflake(guildID),
|
||||||
"role_id": formatSnowflake(roleID),
|
"role_id": formatSnowflake(roleID),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func TestRealtimeEventsReachConnectedClients(t *testing.T) {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"role"`
|
} `json:"role"`
|
||||||
}](t, roleRec)
|
}](t, roleRec)
|
||||||
owner.expectEvent("GUILD_ROLE_CREATE")
|
owner.expectEvent("ROLE_CREATE")
|
||||||
|
|
||||||
memberUser, err := srv.auth.UserByEmail(t.Context(), "rt-member@example.com")
|
memberUser, err := srv.auth.UserByEmail(t.Context(), "rt-member@example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,7 +154,7 @@ func TestRealtimeEventsReachConnectedClients(t *testing.T) {
|
|||||||
"", ownerCookie); rec.Code != http.StatusOK {
|
"", ownerCookie); rec.Code != http.StatusOK {
|
||||||
t.Fatalf("assign role = %d, body = %s", rec.Code, rec.Body.String())
|
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",
|
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me",
|
||||||
|
|||||||
Reference in New Issue
Block a user