Files
glchat/web/src/api/gatewayEvents.ts
T
grendervill d241714a6d 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).
2026-09-22 20:10:53 +03:00

658 lines
21 KiB
TypeScript

import { parseGuildEmojiList } from './emojis';
import { parseGuildSoundList } from './sounds';
import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
import type { GuildEmoji } from './emojis';
import type { GuildSound } from './sounds';
import type { Message, MessageAttachment, MessageReaction } from './messages';
/**
* Разбор payload'ов событий шлюза (AGENT.md §8.3).
*
* Все функции возвращают `null`, если кадр не соответствует контракту:
* неизвестные и лишние поля не ломают диспетчер, а событие просто
* игнорируется.
*/
export interface UserUpdateEvent {
user: GatewayUser;
}
export interface GuildUpdateEvent {
guild_id: string;
name?: string;
description?: string;
/** Оформление сервера (AGENT.md 7.4): пустая строка означает «убрано». */
icon_file_id?: string;
banner_file_id?: string;
splash_file_id?: string;
accent_color?: number;
}
export interface GuildDeleteEvent {
guild_id: string;
}
export interface GuildCreateEvent {
guild_id: string;
}
export interface ChannelDeleteEvent {
channel_id: string;
guild_id: string;
}
export interface MemberUpdateEvent {
guild_id: string;
user_id: string;
role_ids?: string[];
role_id?: string;
role_added?: boolean;
}
export interface MemberRemoveEvent {
guild_id: string;
user_id: string;
}
export interface RoleEvent {
guild_id: string;
role_id: string;
}
export interface GuildEmojisUpdateEvent {
guild_id: string;
emojis: GuildEmoji[];
}
export interface GuildSoundsUpdateEvent {
guild_id: string;
sounds: GuildSound[];
}
/** Событие `SOUNDBOARD_PLAY` (AGENT.md 7.13). */
export interface SoundboardPlayEvent {
guild_id: string;
channel_id: string;
sound_id: string;
file_id: string;
name: string;
user_id: string;
played_at: string;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function asStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: undefined;
}
/** Пользователь в объёме `USER_UPDATE` (совместим со снапшотом READY). */
export function parseGatewayUserPayload(value: unknown): GatewayUser | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const username = asString(record['username']);
if (id === undefined || username === undefined) {
return null;
}
const user: GatewayUser = {
id,
username,
display_name: asString(record['display_name']) ?? username,
is_instance_admin: record['is_instance_admin'] === true,
badges: asStringArray(record['badges']) ?? [],
};
const avatar = asString(record['avatar_file_id']);
const status = asString(record['status']);
const customStatus = asString(record['custom_status']);
const customEmoji = asString(record['custom_status_emoji']);
if (avatar !== undefined) {
user.avatar_file_id = avatar;
}
if (userStatuses.includes(status as UserStatus)) {
user.status = status as UserStatus;
}
if (customStatus !== undefined) {
user.custom_status = customStatus;
}
if (customEmoji !== undefined) {
user.custom_status_emoji = customEmoji;
}
return user;
}
/** Деструктурирует `d` у событий, где payload вложен в `user`. */
export function parseUserUpdate(value: unknown): UserUpdateEvent | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const user = parseGatewayUserPayload(record['user'] ?? value);
return user === null ? null : { user };
}
export function parseGuildUpdate(value: unknown): GuildUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
if (record === null || guildId === undefined) {
return null;
}
const event: GuildUpdateEvent = { guild_id: guildId };
const name = asString(record['name']);
const description = asString(record['description']);
if (name !== undefined) {
event.name = name;
}
if (description !== undefined) {
event.description = description;
}
// Оформление: сервер присылает null, когда файл удалён, — приводим к пустой
// строке, чтобы заглушка-буквы вернулась сразу (AGENT.md 11.6).
for (const field of ['icon_file_id', 'banner_file_id', 'splash_file_id'] as const) {
if (field in record) {
event[field] = asString(record[field]) ?? '';
}
}
if (typeof record['accent_color'] === 'number') {
event.accent_color = record['accent_color'];
}
return event;
}
export function parseGuildId(value: unknown): string | null {
const record = asRecord(value);
return asString(record?.['guild_id']) ?? null;
}
export function parseChannelDelete(value: unknown): ChannelDeleteEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const guildId = asString(record?.['guild_id']);
if (channelId === undefined || guildId === undefined) {
return null;
}
return { channel_id: channelId, guild_id: guildId };
}
export function parseMemberUpdate(value: unknown): MemberUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
const userId = asString(record?.['user_id']);
if (record === null || guildId === undefined || userId === undefined) {
return null;
}
const event: MemberUpdateEvent = { guild_id: guildId, user_id: userId };
const roleIds = asStringArray(record['role_ids']);
const roleId = asString(record['role_id']);
if (roleIds !== undefined) {
event.role_ids = roleIds;
}
if (roleId !== undefined) {
event.role_id = roleId;
}
if (typeof record['role_added'] === 'boolean') {
event.role_added = record['role_added'];
}
return event;
}
export function parseMemberRemove(value: unknown): MemberRemoveEvent | null {
const event = parseMemberUpdate(value);
return event === null ? null : { guild_id: event.guild_id, user_id: event.user_id };
}
export function parseRoleEvent(value: unknown): RoleEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
// Сервер шлёт тело роли (поле id), старый формат — ссылку role_id:
// принимаем оба, иначе событие роли отбрасывалось и список не обновлялся.
const roleId = asString(record?.['role_id']) ?? asString(record?.['id']);
if (record === null || guildId === undefined || roleId === undefined) {
return null;
}
return { guild_id: guildId, role_id: roleId };
}
/**
* `GUILD_EMOJIS_UPDATE`: набор эмодзи сервера изменился (загрузка,
* переименование, удаление). Список приходит целиком — тем же форматом, что
* и в снапшоте READY, поэтому заменяем его в сторе, а не мержим.
*/
export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
if (record === null || guildId === undefined) {
return null;
}
return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) };
}
/**
* `GUILD_SOUNDS_UPDATE`: набор звуков сервера изменился (AGENT.md 7.13).
* Список приходит целиком — тем же форматом, что в снапшоте READY.
*/
export function parseGuildSoundsUpdate(value: unknown): GuildSoundsUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
if (record === null || guildId === undefined) {
return null;
}
return { guild_id: guildId, sounds: parseGuildSoundList(record['sounds']) };
}
/**
* `SOUNDBOARD_PLAY`: кто-то сыграл звук в голосовой комнате. Событие
* приходит только участникам этой комнаты, и каждый клиент играет звук сам.
*/
export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const guildId = asString(record['guild_id']);
const channelId = asString(record['channel_id']);
const soundId = asString(record['sound_id']);
const fileId = asString(record['file_id']);
const userId = asString(record['user_id']);
if (
guildId === undefined ||
channelId === undefined ||
soundId === undefined ||
fileId === undefined ||
userId === undefined
) {
return null;
}
return {
guild_id: guildId,
channel_id: channelId,
sound_id: soundId,
file_id: fileId,
name: asString(record['name']) ?? '',
user_id: userId,
played_at: asString(record['played_at']) ?? '',
};
}
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
/** `GUILD_CHANNELS_SYNC`: права комнат сервера изменились — перечитать список. */
export function parseGuildChannelsSyncEvent(value: unknown): { guild_id: string } | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const guildId = asString(record['guild_id']);
return guildId === undefined ? null : { guild_id: guildId };
}
/** parseChannelOverwrites читает список оверрайдов прав комнаты. */
export function parseChannelOverwrites(value: unknown): ChannelOverwrite[] {
if (!Array.isArray(value)) {
return [];
}
const result: ChannelOverwrite[] = [];
for (const item of value) {
const record = asRecord(item);
if (record === null) {
continue;
}
const targetType = asString(record['target_type']);
const targetId = asString(record['target_id']);
if ((targetType !== 'role' && targetType !== 'user') || targetId === undefined) {
continue;
}
result.push({
target_type: targetType,
target_id: targetId,
allow: asString(record['allow']) ?? '0',
deny: asString(record['deny']) ?? '0',
});
}
return result;
}
export function parseChannelPayload(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
const type = asString(record['type']);
if (id === undefined || name === undefined) {
return null;
}
if (type !== 'text' && type !== 'voice' && type !== 'category' && type !== 'dm') {
return null;
}
const channel: Channel = {
id,
name,
type,
position: typeof record['position'] === 'number' ? record['position'] : 0,
};
const guildId = asString(record['guild_id']);
const parentId = asString(record['parent_id']);
if (guildId !== undefined) {
channel.guild_id = guildId;
}
if (parentId !== undefined) {
channel.parent_id = parentId;
}
if (typeof record['user_limit'] === 'number') {
channel.user_limit = record['user_limit'];
}
if (typeof record['slowmode_seconds'] === 'number') {
channel.slowmode_seconds = record['slowmode_seconds'];
}
if (typeof record['can_send'] === 'boolean') {
channel.can_send = record['can_send'];
}
if (typeof record['can_connect'] === 'boolean') {
channel.can_connect = record['can_connect'];
}
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
// Права комнаты: нужны редактору доступа (AGENT.md 6.2).
const overwrites = parseChannelOverwrites(record['permission_overwrites']);
if (overwrites.length > 0) {
channel.permission_overwrites = overwrites;
}
// Фон комнаты: приходит в READY и CHANNEL_UPDATE (AGENT.md 7.5).
const backgroundFileId = asString(record['background_file_id']);
if (backgroundFileId !== undefined) {
channel.background_file_id = backgroundFileId;
}
// Личная беседа: собеседник и последнее сообщение (READY и CHANNEL_*).
const recipientId = asString(record['recipient_id']);
const iconFileId = asString(record['icon_file_id']);
const status = asString(record['status']);
const lastMessageId = asString(record['last_message_id']);
const lastMessageAt = asString(record['last_message_at']);
if (recipientId !== undefined) {
channel.recipient_id = recipientId;
}
if (iconFileId !== undefined) {
channel.icon_file_id = iconFileId;
}
if (userStatuses.includes(status as UserStatus)) {
channel.status = status as UserStatus;
}
if (lastMessageId !== undefined) {
channel.last_message_id = lastMessageId;
}
if (lastMessageAt !== undefined) {
channel.last_message_at = lastMessageAt;
}
return channel;
}
/* ------------------------------------------------------------------ */
/* Друзья и личные беседы (AGENT.md §8.3 / 7.8) */
/* ------------------------------------------------------------------ */
export interface DmChannelEvent {
channel_id: string;
}
export interface RelationshipEvent {
user_id: string;
}
export interface PresenceEvent {
user_id: string;
status?: UserStatus;
visible_status?: UserStatus;
custom_status?: string;
last_seen_at?: string;
}
/** `DM_CHANNEL_CREATE`: создана личная беседа — перечитываем список бесед. */
export function parseDmChannelEvent(value: unknown): DmChannelEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
return record === null || channelId === undefined ? null : { channel_id: channelId };
}
/** `RELATIONSHIP_UPDATE`: изменилась связь с пользователем. */
export function parseRelationshipEvent(value: unknown): RelationshipEvent | null {
const record = asRecord(value);
const userId = asString(record?.['user_id']);
return record === null || userId === undefined ? null : { user_id: userId };
}
/**
* `PRESENCE_UPDATE`: статус друга. Поля необязательны — сервер присылает
* только изменившиеся, поэтому разбираем каждое отдельно.
*/
export function parsePresenceEvent(value: unknown): PresenceEvent | null {
const record = asRecord(value);
const userId = asString(record?.['user_id']);
if (record === null || userId === undefined) {
return null;
}
const event: PresenceEvent = { user_id: userId };
const status = asString(record['status']);
const visibleStatus = asString(record['visible_status']);
const customStatus = asString(record['custom_status']);
const lastSeenAt = asString(record['last_seen_at']);
if (userStatuses.includes(status as UserStatus)) {
event.status = status as UserStatus;
}
if (userStatuses.includes(visibleStatus as UserStatus)) {
event.visible_status = visibleStatus as UserStatus;
}
if (customStatus !== undefined) {
event.custom_status = customStatus;
}
if (lastSeenAt !== undefined) {
event.last_seen_at = lastSeenAt;
}
return event;
}
/* ------------------------------------------------------------------ */
/* События сообщений Фазы 2 (AGENT.md §8.3) */
/* ------------------------------------------------------------------ */
export interface MessageDeleteEvent {
id: string;
channel_id: string;
}
export interface ReactionEvent {
channel_id: string;
message_id: string;
user_id: string;
emoji: string;
}
export interface PinsUpdateEvent {
channel_id: string;
message_id: string;
pinned: boolean;
}
export interface TypingStartEvent {
channel_id: string;
user_id: string;
}
export interface ReadStateEvent {
channel_id: string;
last_message_id?: string;
mention_count: number;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function parseAttachment(value: unknown): MessageAttachment | null {
const record = asRecord(value);
const fileId = asString(record?.['file_id']);
const filename = asString(record?.['filename']);
if (record === null || fileId === undefined || filename === undefined) {
return null;
}
const attachment: MessageAttachment = { file_id: fileId, filename };
const contentType = asString(record['content_type']);
const sizeBytes = asNumber(record['size_bytes']);
const width = asNumber(record['width']);
const height = asNumber(record['height']);
if (contentType !== undefined) {
attachment.content_type = contentType;
}
if (sizeBytes !== undefined) {
attachment.size_bytes = sizeBytes;
}
if (width !== undefined) {
attachment.width = width;
}
if (height !== undefined) {
attachment.height = height;
}
return attachment;
}
function parseReaction(value: unknown): MessageReaction | null {
const record = asRecord(value);
const emoji = asString(record?.['emoji']);
if (record === null || emoji === undefined) {
return null;
}
const reaction: MessageReaction = {
emoji,
count: asNumber(record['count']) ?? 0,
me: record['me'] === true,
};
const userIds = asStringArray(record['user_ids']);
if (userIds !== undefined) {
reaction.user_ids = userIds;
}
return reaction;
}
/** Сообщение — та же форма, что в REST (payload `MESSAGE_CREATE`/`MESSAGE_UPDATE`). */
export function parseMessagePayload(value: unknown): Message | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const channelId = asString(record['channel_id']);
const createdAt = asString(record['created_at']);
if (id === undefined || channelId === undefined || createdAt === undefined) {
return null;
}
const message: Message = {
id,
channel_id: channelId,
content: typeof record['content'] === 'string' ? record['content'] : '',
type: asString(record['type']) ?? 'default',
pinned: record['pinned'] === true,
attachments: Array.isArray(record['attachments'])
? record['attachments']
.map(parseAttachment)
.filter((item): item is MessageAttachment => item !== null)
: [],
mentions: asStringArray(record['mentions']) ?? [],
reactions: Array.isArray(record['reactions'])
? record['reactions']
.map(parseReaction)
.filter((item): item is MessageReaction => item !== null)
: [],
created_at: createdAt,
};
const authorId = asString(record['author_id']);
const replyToId = asString(record['reply_to_id']);
const editedAt = asString(record['edited_at']);
if (authorId !== undefined) {
message.author_id = authorId;
}
if (replyToId !== undefined) {
message.reply_to_id = replyToId;
}
if (editedAt !== undefined) {
message.edited_at = editedAt;
}
return message;
}
export function parseMessageDelete(value: unknown): MessageDeleteEvent | null {
const record = asRecord(value);
const id = asString(record?.['id']);
const channelId = asString(record?.['channel_id']);
if (record === null || id === undefined || channelId === undefined) {
return null;
}
return { id, channel_id: channelId };
}
export function parseReactionEvent(value: unknown): ReactionEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const messageId = asString(record?.['message_id']);
const userId = asString(record?.['user_id']);
const emoji = asString(record?.['emoji']);
if (
record === null ||
channelId === undefined ||
messageId === undefined ||
userId === undefined ||
emoji === undefined
) {
return null;
}
return { channel_id: channelId, message_id: messageId, user_id: userId, emoji };
}
export function parsePinsUpdate(value: unknown): PinsUpdateEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const messageId = asString(record?.['message_id']);
if (record === null || channelId === undefined || messageId === undefined) {
return null;
}
return { channel_id: channelId, message_id: messageId, pinned: record['pinned'] === true };
}
export function parseTypingStart(value: unknown): TypingStartEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const userId = asString(record?.['user_id']);
if (record === null || channelId === undefined || userId === undefined) {
return null;
}
return { channel_id: channelId, user_id: userId };
}
export function parseReadStateUpdate(value: unknown): ReadStateEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
if (record === null || channelId === undefined) {
return null;
}
const event: ReadStateEvent = {
channel_id: channelId,
mention_count: asNumber(record['mention_count']) ?? 0,
};
const lastMessageId = asString(record['last_message_id']);
if (lastMessageId !== undefined) {
event.last_message_id = lastMessageId;
}
return event;
}