fix(realtime): события ролей, комнат и отзыв сессий без перезагрузки

Дефекты, найденные e2e-матрицей AGENT.md 11.6 (пункты 1, 2, 7, 12):

- ROLE_CREATE/ROLE_UPDATE уходили телом роли без guild_id, а клиент ждал
  ссылку {guild_id, role_id} — событие отбрасывалось, роли у второго
  участника не обновлялись. В rolePayload добавлено guild_id, parseRoleEvent
  принимает и тело роли, и старую ссылку;
- выдача роли (MEMBER_UPDATE) и правки ролей не перечитывали список комнат,
  поэтому приватная комната не появлялась в сайдбаре без F5 — теперь клиент
  перечитывает комнаты по этим событиям;
- CHANNEL_CREATE/CHANNEL_UPDATE уходили телом комнаты с нулевым can_view
  (права персональные), и клиент прятал комнату по этому признаку. Сервер
  шлёт тело события без вычисленных прав плюс GUILD_CHANNELS_SYNC, а клиент
  по событию комнаты перечитывает список;
- отзыв сессий (logout-all, смена пароля, админский сброс и «выйти везде»,
  бан, удаление) не доходил до Gateway: второе устройство оставалось в
  приложении. Добавлен gateway.InvalidateUser — клиент получает
  INVALID_SESSION и закрывает соединение; клиент дополнительно обрабатывает
  событие SESSION_INVALIDATED.

Тесты: gateway (отзыв сессий закрывает соединения и не задевает чужого),
web (событие SESSION_INVALIDATED, перечитывание комнат по CHANNEL_CREATE).
This commit is contained in:
2026-09-22 20:10:53 +03:00
parent ff2fc878b0
commit d241714a6d
10 changed files with 188 additions and 26 deletions
+24 -2
View File
@@ -53,6 +53,8 @@ interface GatewayState {
invalidated: boolean;
connect: (userId: string) => void;
disconnect: () => void;
/** Сессия отозвана сервером (logout-all, смена пароля, бан): экран входа. */
markInvalidated: () => void;
reset: () => void;
}
@@ -107,6 +109,10 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
set({ ...defaults });
},
/** markInvalidated — сессия отозвана сервером: показываем экран входа. */
markInvalidated: () => {
set({ invalidated: true, status: 'disconnected' });
},
reset: () => {
disconnectGateway();
set({ ...defaults });
@@ -199,7 +205,10 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
const guildId = channel.guild_id ?? session.selectedGuildId;
if (guildId !== null) {
session.upsertChannel(guildId, channel);
// Права на комнату персональные: тело события их не несёт, поэтому
// перечитываем список — иначе новая или переименованная комната не
// появится в сайдбаре без перезагрузки (AGENT.md 11.6).
void session.reloadGuildChannels(guildId);
}
return;
}
@@ -274,10 +283,12 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
case 'MEMBER_UPDATE':
case 'MEMBER_REMOVE': {
// Роли участника влияют на права: перечитываем участников и карточку сервера.
// Роли участника влияют на права: перечитываем участников, карточку
// сервера и список комнат (приватные комнаты открываются ролью).
const guildId = parseGuildId(event.d);
if (guildId !== null) {
void invalidateMembershipQueries(guildId);
void session.reloadGuildChannels(guildId);
}
return;
}
@@ -287,6 +298,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
const role = parseRoleEvent(event.d);
if (role !== null) {
void invalidateMembershipQueries(role.guild_id);
void session.reloadGuildChannels(role.guild_id);
}
return;
}
@@ -335,6 +347,16 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
return;
}
case 'SESSION_INVALIDATED': {
// Сервер отозвал сессии (logout-all, смена пароля, бан, удаление):
// уходим на экран входа, не дожидаясь следующего запроса (AGENT.md 11.6).
useGatewayStore.getState().markInvalidated();
session.reset();
messages.reset();
useVoiceConnectionStore.getState().reset();
void invalidateCurrentUser();
return;
}
case 'GUILD_CHANNELS_SYNC': {
// Права комнаты изменились: видимость может поменяться у всех
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).