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 | null { return typeof value === 'object' && value !== null ? (value as Record) : 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; }