d241714a6d
Дефекты, найденные 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).
658 lines
21 KiB
TypeScript
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;
|
|
}
|