From 56e139c27428a3440993b2e3a49bab42043f9d6d Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 10 Jan 2024 01:49:11 -0800 Subject: [PATCH 1/3] bake data invariants into GatewayMessage's type The Discord API documentation describes some invariants that would be useful to express in our types, and that are mostly true in practice - notably, that `s` and `t` are `null` when `op` isn't `DISPATCH`. This likely absolves us from some otherwise gratuitous null checking in high level code. At the lower levels (i.e. when first receiving WebSocket data), we should still perform some sanity checks to make sure our types are OK in case of cosmic rays or freak accidents. GatewayMessage also becomes parameterized over its data, with a default of `unknown` (in preparation for stricter modeling). In the future, let's automatically resolve `Data` based on `op` (if we can) to reduce accidents. --- src/websocket/types.ts | 71 +++++++++++++++++++++++---------------- src/websocket/wsclient.ts | 25 ++++---------- 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/websocket/types.ts b/src/websocket/types.ts index 3c780d0..d4e6463 100644 --- a/src/websocket/types.ts +++ b/src/websocket/types.ts @@ -1,42 +1,55 @@ import type { OPCode, GatewayMessageType } from './constants' /** - * A single packet received from the Discord Gateway, which is a WebSocket + * A single packet received as-is from the Discord Gateway - a WebSocket * service that distributes realtime events and data to clients. * * See: https://discord.com/developers/docs/topics/gateway */ -export interface GatewayMessage { - /** - * A code denoting the meaning of this message. - * - * Most codes concern themselves with presence updates or heartbeating. The - * vast majority of realtime events that are received have an `op` of `READY`, - * with the `t` field actually denoting the event type of interest. - */ - op: OPCode +export type GatewayMessage = + | { + /** + * A code denoting the meaning of this message. + * + * Most codes concern themselves with events related to connection + * lifecycle and general bringup. The vast majority of realtime events that + * are received have an `op` of `DISPATCH`, with the `t` field actually + * denoting the event type of interest. + */ + op: OPCode.DISPATCH, - /** - * Data associated with this Gateway packet. - */ - d?: any + /** + * The enclosed event data contained within this Gateway packet. + */ + d: Data, - /** - * Packet sequence number, which is used for resuming sessions and heartbeats. - * - * This number is incremented by the gateway with every event sent, and can - * be used in the event that the connection is unexpectedly severed---the - * gateway will use the sequence number as a reference point in order to send - * us all of the events that we missed. - */ - s?: number + /** + * Packet sequence number, which is used for resuming sessions and heartbeats. + * + * This number is incremented by the gateway with every event sent, and can + * be used in the event that the connection is unexpectedly severed---the + * gateway will use the sequence number as a reference point in order to send + * us all of the events that we missed. + */ + s: number, - /** - * The event name for this gateway message (only relevant if `op` is - * `DISPATCH`). For low-level events, see the {@link op} field. - */ - t?: GatewayMessageType -} + /** + * The event name for this gateway message (only relevant if `op` is + * `DISPATCH`). For low-level events, see the {@link op} field. + */ + t: GatewayMessageType + } + | { + op: OPCode, + d: Data, + + // The sequence number and event name fields are always (supposed to be) + // `null` when `op` isn't `DISPATCH`. + s: null, + t: null + } + +export type OutboundGatewayMessage = Pick, "op" | "d"> export interface GatewayConnectionOptions { version: number diff --git a/src/websocket/wsclient.ts b/src/websocket/wsclient.ts index 0d89ee0..fd6325a 100644 --- a/src/websocket/wsclient.ts +++ b/src/websocket/wsclient.ts @@ -6,7 +6,7 @@ import type { Packer } from '../packers' import { DEBUG } from '../preferences' import { DiscordPresenceStatus, GatewayCloseCode, GatewayMessageType, OPCode } from './constants' import { WSError } from './errors' -import type { GatewayConnectionOptions, GatewayMessage } from './types' +import type { GatewayConnectionOptions, GatewayMessage, OutboundGatewayMessage } from './types' const LOG_PREFIX = '[discord ws]' @@ -94,7 +94,7 @@ class WSClient { this.ws?.dispose(code) } - public send = async (message: GatewayMessage) => { + public send = async (message: OutboundGatewayMessage) => { if (DEBUG) texts.log('<', message) const packed = this.packer.pack(message) this.ws.send(packed) @@ -150,7 +150,7 @@ class WSClient { this.connect() break case OPCode.HELLO: - this.setupHeartbeat(message.d.heartbeat_interval) + this.setupHeartbeat((message.d as { heartbeat_interval: number }).heartbeat_interval) this.sendIdentifyOrResume() this.shouldResume = false break @@ -170,13 +170,6 @@ class WSClient { texts.log(LOG_PREFIX, `Received send-only OPCode (${message.op})!`, message) break } - - // * Default - - default: { - texts.log(LOG_PREFIX, `Unhandled OPCode (${message.op})!`, message) - break - } } this.gatewayMessageHandler?.(message) @@ -201,7 +194,7 @@ class WSClient { if (DEBUG) texts.log(LOG_PREFIX, this.shouldResume ? 'Resuming...' : 'Sending identify...') - let payload: GatewayMessage + let payload: OutboundGatewayMessage if (this.shouldResume) { payload = { op: OPCode.RESUME, @@ -251,11 +244,7 @@ class WSClient { return } - const payload: GatewayMessage = { - op: OPCode.HEARTBEAT, - d: this.lastSequenceNumber, - } - this.send(payload) + this.send({ op: OPCode.HEARTBEAT, d: this.lastSequenceNumber }) this.receivedHeartbeatAck = false } @@ -263,11 +252,11 @@ class WSClient { switch (message.t) { case GatewayMessageType.READY: { if (DEBUG) texts.log(LOG_PREFIX, 'Got dispatch !') - this.sessionID = message.d?.session_id + this.sessionID = (message.d as { session_id: string }).session_id break } case GatewayMessageType.SESSIONS_REPLACE: { - const session = message.d?.at(0) + const session = (message.d as Array<{ session_id: string }>).at(0) if (session.session_id) this.sessionID = session.session_id break } From 47cdcde3324c86f66dc18a72a58536ced69a1285 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 10 Jan 2024 22:54:23 -0800 Subject: [PATCH 2/3] lint --- src/websocket/types.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/websocket/types.ts b/src/websocket/types.ts index d4e6463..7aeb1ab 100644 --- a/src/websocket/types.ts +++ b/src/websocket/types.ts @@ -16,12 +16,12 @@ export type GatewayMessage = * are received have an `op` of `DISPATCH`, with the `t` field actually * denoting the event type of interest. */ - op: OPCode.DISPATCH, + op: OPCode.DISPATCH /** * The enclosed event data contained within this Gateway packet. */ - d: Data, + d: Data /** * Packet sequence number, which is used for resuming sessions and heartbeats. @@ -31,7 +31,7 @@ export type GatewayMessage = * gateway will use the sequence number as a reference point in order to send * us all of the events that we missed. */ - s: number, + s: number /** * The event name for this gateway message (only relevant if `op` is @@ -40,12 +40,12 @@ export type GatewayMessage = t: GatewayMessageType } | { - op: OPCode, - d: Data, + op: OPCode + d: Data // The sequence number and event name fields are always (supposed to be) // `null` when `op` isn't `DISPATCH`. - s: null, + s: null t: null } From 8910deae9cbf9204103e92a96718542db2687be9 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 10 Jan 2024 22:48:08 -0800 Subject: [PATCH 3/3] provide strong typing for gateway event handling When ingesting incoming Gateway events via event emitter, have the types correspond to the actual (believed) shape of the data. This way, the app code can be completely free of casts, and we can encode the data shape invariants elsewhere. This is done by subclassing EventEmitter, and defining a new "lookup table" type that maps each message type to the type of event data it receives. Gateway messages that are emitted then take on the corresponding type automatically. Changes were made to the mapping code so that all of the types line up. Further verification of the event data types can come in a future patch, as I'm still not 100% confident that they align with reality. --- src/mappers/mappers.ts | 6 +- src/network-api.ts | 12 ++- src/websocket/emitter.ts | 28 +++++++ src/websocket/event-handlers.ts | 59 ++++--------- src/websocket/types.ts | 142 +++++++++++++++++++++++++++++++- 5 files changed, 201 insertions(+), 46 deletions(-) create mode 100644 src/websocket/emitter.ts diff --git a/src/mappers/mappers.ts b/src/mappers/mappers.ts index 3482774..a3524da 100644 --- a/src/mappers/mappers.ts +++ b/src/mappers/mappers.ts @@ -1,5 +1,5 @@ import { Message, MessageActionType, Attachment, AttachmentType, MessageLink, MessageReaction, Thread, ThreadType, User, PartialWithID, UserPresence, texts } from '@textshq/platform-sdk' -import { APIChannel, EmbedType, MessageActivityType, APIAttachment, MessageType, GatewayPresenceUpdateData, APIUser } from 'discord-api-types/v9' +import { APIChannel, EmbedType, MessageActivityType, APIAttachment, MessageType, GatewayPresenceUpdateData, APIUser, GatewayMessageReactionAddDispatchData, GatewayPresenceUpdateDispatchData, APIPartialEmoji } from 'discord-api-types/v9' import { uniqBy } from 'lodash' import { IGNORED_MESSAGE_TYPES, StickerFormat, THREAD_TYPES } from '../constants' @@ -8,7 +8,7 @@ import { mapTextAttributes } from '../text-attributes' import { handleArticleEmbed, handleGifvEmbed, handleImageEmbed, handleLinkEmbed, handleRichEmbed, handleVideoEmbed } from './rich-embeds' import type { DiscordMessage, DiscordReactionDetails } from '../types/discord-types' -export const mapReaction = (reaction: DiscordReactionDetails, participantID: string): MessageReaction => { +export const mapReaction = (reaction: { emoji: APIPartialEmoji }, participantID: string): MessageReaction => { // reaction.emoji = { id: '352592187265122304', name: 'pat' } // reaction.emoji = { id: null, name: '👍' } const reactionKey = (reaction.emoji.name || reaction.emoji.id)! @@ -21,7 +21,7 @@ export const mapReaction = (reaction: DiscordReactionDetails, participantID: str } } -export const mapPresence = (userID: string, presence: GatewayPresenceUpdateData): UserPresence => { +export const mapPresence = (userID: string, presence: Omit): UserPresence => { const activity = presence.activities?.length > 0 ? presence.activities?.[0] as any : undefined return { userID, diff --git a/src/network-api.ts b/src/network-api.ts index 8e6b119..71e0340 100644 --- a/src/network-api.ts +++ b/src/network-api.ts @@ -22,6 +22,7 @@ import _emojis from './resources/emojis.json' import _emojiShortcuts from './resources/shortcuts.json' import type { GatewayConnectionOptions, GatewayMessage } from './websocket/types' import { attachReadyHandlers, attachChannelHandlers, attachGuildHandlers, attachReactionHandlers, attachMessageHandlers, attachRelationshipHandlers, attachRecipientHandlers } from './websocket/event-handlers' +import GatewayEventEmitter from './websocket/emitter' const API_VERSION = 9 const API_ENDPOINT = `https://discord.com/api/v${API_VERSION}` @@ -73,7 +74,7 @@ export default class DiscordNetworkAPI { private deviceFingerprint?: string = undefined - gatewayEvents = new EventEmitter({ captureRejections: true }) + gatewayEvents = new GatewayEventEmitter() token?: string @@ -618,6 +619,15 @@ export default class DiscordNetworkAPI { // texts.log(LOG_PREFIX, op, d, t) this.gatewayEvents.emit('message', message) if (message.t) { + // This is the part where we tell TypeScript that the data coming in is, + // in fact, the shape that we say it is. Of course, this can change from + // underneath us at a moment's notice. We can eliminate this by actually + // checking that received data is of the expected shape (e.g. via + // something like Zod). For now, though, just assume that we're right. + // When Discord changes the shape of data they send to users, we'll + // simply have to catch up. + // + // @ts-expect-error this.gatewayEvents.emit(message.t, message) } } diff --git a/src/websocket/emitter.ts b/src/websocket/emitter.ts new file mode 100644 index 0000000..1cbb6b9 --- /dev/null +++ b/src/websocket/emitter.ts @@ -0,0 +1,28 @@ +import EventEmitter from 'node:events' + +import type { EventData, DispatchMessage, GatewayMessage } from './types' + +type Events = { + error: (error: any) => void + + // This event is disptached for every Gateway message we get. If the event in + // question has a known type shape in `EventData`, that is _also_ emitted. + // Prefer that event for stronger guarantees. + message: (message: GatewayMessage) => void +} & + // Transforms every known event data payload into a handler type for our + // event emitter. + { [MessageType in keyof EventData]: (message: DispatchMessage) => void } + +/// A thin wrapper around {@link EventEmitter} for more accurate types. +export default class GatewayEventEmitter { + private emitter = new EventEmitter({ captureRejections: true }) + + on(event: Event, handler: Events[Event]) { + this.emitter.on(event, handler) + } + + emit(event: Event, ...data: Parameters) { + this.emitter.emit(event, ...data) + } +} diff --git a/src/websocket/event-handlers.ts b/src/websocket/event-handlers.ts index da85d8c..a53083c 100644 --- a/src/websocket/event-handlers.ts +++ b/src/websocket/event-handlers.ts @@ -1,7 +1,7 @@ import { texts, ServerEvent, ServerEventType, Message, ActivityType, UserPresence } from '@textshq/platform-sdk' import { Snowflake } from 'discord-api-types/globals' -import { APIUser, APIGuild, APIChannel, GatewayPresenceUpdateData, APIEmoji, ChannelType } from 'discord-api-types/v9' +import { APIUser, APIGuild, APIChannel, GatewayPresenceUpdateData, APIEmoji, ChannelType, APIGroupDMChannel, APIDMChannel } from 'discord-api-types/v9' import { IGNORED_CHANNEL_TYPES } from '../constants' import { mapThread, mapPresence, mapUser, mapMessage, mapReaction } from '../mappers/mappers' import type DiscordNetworkAPI from '../network-api' @@ -10,28 +10,12 @@ import { DiscordEmoji } from '../types/discord-types' import { getEmojiURL } from '../util' import { GatewayMessageType } from './constants' -export function attachReadyHandlers(api: DiscordNetworkAPI) { - api.gatewayEvents.on(GatewayMessageType.READY, message => { - // Assert the entire structure of the READY packet in one go. TODO: Move - // this somewhere else. - const d = message.d as { - analytics_token: string - users: APIUser[] - read_state: { - entries: Array<{ id: Snowflake, last_message_id: Snowflake }> - } - user: { premium_type?: number } - - // TODO: Verify if this type is truly the case: - guilds: Array - - user_guild_settings: { - entries: Array<{ - channel_overrides: Array<{ muted: boolean, channel_id: string }> - }> - } - } +function isGuildChannel(channel: APIChannel): channel is Exclude { + return channel.type !== ChannelType.DM && channel.type !== ChannelType.GroupDM +} +export function attachReadyHandlers(api: DiscordNetworkAPI) { + api.gatewayEvents.on(GatewayMessageType.READY, ({ d }) => { if (ENABLE_DISCORD_ANALYTICS) api.analyticsToken = d.analytics_token api.usernameIDMap = new Map( @@ -88,13 +72,7 @@ export function attachReadyHandlers(api: DiscordNetworkAPI) { texts.log('[discord] Pumped READY') }) - api.gatewayEvents.on(GatewayMessageType.READY_SUPPLEMENTAL, message => { - const d = message.d as { - merged_presences: { - friends?: Array - } - } - + api.gatewayEvents.on(GatewayMessageType.READY_SUPPLEMENTAL, ({ d }) => { api.usersPresence = Object.fromEntries( d.merged_presences.friends?.map(presence => [ presence.user_id, @@ -105,9 +83,8 @@ export function attachReadyHandlers(api: DiscordNetworkAPI) { } export function attachGuildHandlers(api: DiscordNetworkAPI) { - api.gatewayEvents.on(GatewayMessageType.GUILD_CREATE, ({ d }) => { + api.gatewayEvents.on(GatewayMessageType.GUILD_CREATE, ({ d: guild }) => { if (api.guildCustomEmojiMap) { - const guild = d as APIGuild const emojis: DiscordEmoji[] = guild.emojis.map(e => ({ displayName: e.name ?? e.id!, reactionKey: `<:${e.name}:${e.id}>`, @@ -132,11 +109,11 @@ export function attachGuildHandlers(api: DiscordNetworkAPI) { if (!ENABLE_GUILDS) return - const channels = (d.channels as APIChannel[]) + const channels = guild.channels .filter(c => !IGNORED_CHANNEL_TYPES.has(c.type)) .map(c => mapThread(c, api.readStateMap.get(c.id), api.mutedChannels.has(c.id), api.currentUser)) - api.channelsMap?.set(d.id, channels) + api.channelsMap?.set(guild.id, channels) const channelEvents: ServerEvent[] = channels.map(c => ({ type: ServerEventType.STATE_SYNC, @@ -149,14 +126,14 @@ export function attachGuildHandlers(api: DiscordNetworkAPI) { api.eventCallback(channelEvents) }) - api.gatewayEvents.on(GatewayMessageType.GUILD_DELETE, ({ d }) => { - api.guildCustomEmojiMap?.delete(d.id) + api.gatewayEvents.on(GatewayMessageType.GUILD_DELETE, ({ d: guild }) => { + api.guildCustomEmojiMap?.delete(guild.id) api.onGuildCustomEmojiMapUpdate() // TODO: State sync if (!ENABLE_GUILDS) return - const channelIDs = api.channelsMap?.get(d.id)?.map(c => c.id) + const channelIDs = api.channelsMap?.get(guild.id)?.map(c => c.id) if (!channelIDs) return const events: ServerEvent[] = channelIDs.map(id => ({ @@ -168,7 +145,7 @@ export function attachGuildHandlers(api: DiscordNetworkAPI) { })) api.eventCallback(events) - api.channelsMap?.delete(d.id) + api.channelsMap?.delete(guild.id) }) api.gatewayEvents.on(GatewayMessageType.GUILD_EMOJIS_UPDATE, ({ d }) => { @@ -214,7 +191,7 @@ export function attachMessageHandlers(api: DiscordNetworkAPI) { mutationType: 'upsert', objectName: 'message', objectIDs: { threadID: d.channel_id }, - entries: [mapMessage(d, api.currentUser?.id) as Message], + entries: [mapMessage(d, api.currentUser?.id)], }]) } }) @@ -295,7 +272,7 @@ export function attachMessageHandlers(api: DiscordNetworkAPI) { export function attachChannelHandlers(api: DiscordNetworkAPI) { api.gatewayEvents.on(GatewayMessageType.CHANNEL_CREATE, ({ d }) => { - if (!ENABLE_GUILDS && d.guild_id) return + if (!ENABLE_GUILDS && isGuildChannel(d)) return if (d.type !== ChannelType.DM && d.type !== ChannelType.GroupDM) { return @@ -318,7 +295,7 @@ export function attachChannelHandlers(api: DiscordNetworkAPI) { }) api.gatewayEvents.on(GatewayMessageType.CHANNEL_UPDATE, ({ d }) => { - if (!ENABLE_GUILDS && d.guild_id) return + if (!ENABLE_GUILDS || !isGuildChannel(d)) return const channels = api.channelsMap?.get(d.guild_id) if (!channels) return @@ -342,7 +319,7 @@ export function attachChannelHandlers(api: DiscordNetworkAPI) { }) api.gatewayEvents.on(GatewayMessageType.CHANNEL_DELETE, ({ d }) => { - if (!ENABLE_GUILDS && d.guild_id) return + if (!ENABLE_GUILDS && isGuildChannel(d)) return api.eventCallback([{ type: ServerEventType.STATE_SYNC, diff --git a/src/websocket/types.ts b/src/websocket/types.ts index 7aeb1ab..6981579 100644 --- a/src/websocket/types.ts +++ b/src/websocket/types.ts @@ -1,3 +1,22 @@ +import type { Snowflake } from 'discord-api-types/globals' +import type { + APIUser, + APIGuild, + APIChannel, + GatewayGuildEmojisUpdateDispatchData, + APIMessage, + GatewayMessageDeleteBulkDispatchData, + GatewayTypingStartDispatchData, + GatewayChannelCreateDispatchData, + GatewayChannelUpdateDispatchData, + GatewayChannelDeleteDispatchData, + GatewayMessageReactionAddDispatchData, + GatewayMessageReactionRemoveEmojiDispatchData, + GatewayMessageReactionRemoveDispatchData, + GatewayMessageReactionRemoveAllDispatchData, + PresenceUpdateStatus, + GatewayActivityUpdateData, +} from 'discord-api-types/v9' import type { OPCode, GatewayMessageType } from './constants' /** @@ -49,7 +68,128 @@ export type GatewayMessage = t: null } -export type OutboundGatewayMessage = Pick, "op" | "d"> +export type OutboundGatewayMessage = Pick, 'op' | 'd'> + +/** + * A single `DISPATCH` packet received from the Discord Gateway with a known + * event data type. Prefer this type over `GatewayMessage` whenever possible. + */ +export type DispatchMessage = { + op: OPCode.DISPATCH + + /** + * The event data associated with this Gateway message. This is different + * depending on the {@link MessageType}. + */ + d: EventData[MessageType] + + s: number + + t: MessageType +} + +/** A guild with channels included within. */ +type GuildWithChannels = APIGuild & { + channels: APIChannel[] + threads: APIChannel[] +} + +/** A message received from the Gateway, but with fun data that only users get. */ +type UserAPIMessage = APIMessage & { + guild_id: Snowflake + + // Override `string | number`. + nonce: string +} + +type UserAPIPresenceBase = { + activities: GatewayActivityUpdateData[] + status: PresenceUpdateStatus + client_status: { + desktop?: PresenceUpdateStatus + web?: PresenceUpdateStatus + // might be more possible fields ... + } +} + +/** + * Our assumed types of event data contained within dispatch messages from the + * Gateway, keyed by the Gateway message type (the `t` field). This type is + * used in conjunction with `DispatchMessage` in order to provide stronger + * type guarantees. + * + * Because the user API surface is private and potentially unstable, diligent + * care must be taken in order to ensure that we don't crash and burn at + * runtime. + */ +export type EventData = { + // TODO: Verify. + [GatewayMessageType.READY]: { + analytics_token: string + + read_state: { + entries: Array<{ id: Snowflake, last_message_id: Snowflake }> + } + + user: { premium_type?: number } + users: APIUser[] + + guilds: GuildWithChannels[] + + user_guild_settings: { + entries: Array<{ + channel_overrides: Array<{ muted: boolean, channel_id: string }> + }> + } + + session_id: string + } + + [GatewayMessageType.READY_SUPPLEMENTAL]: { + merged_presences: { + friends?: Array + } + } + + [GatewayMessageType.GUILD_CREATE]: GuildWithChannels + [GatewayMessageType.GUILD_DELETE]: GuildWithChannels + [GatewayMessageType.GUILD_EMOJIS_UPDATE]: GatewayGuildEmojisUpdateDispatchData + + // TODO: Verify. + [GatewayMessageType.MESSAGE_CREATE]: UserAPIMessage + [GatewayMessageType.MESSAGE_UPDATE]: UserAPIMessage + [GatewayMessageType.MESSAGE_DELETE]: UserAPIMessage + [GatewayMessageType.MESSAGE_DELETE_BULK]: GatewayMessageDeleteBulkDispatchData + [GatewayMessageType.MESSAGE_ACK]: { + guild_id: Snowflake + channel_id: Snowflake + message_id: Snowflake + ack_type: number + } + + [GatewayMessageType.TYPING_START]: GatewayTypingStartDispatchData + + [GatewayMessageType.CHANNEL_CREATE]: GatewayChannelCreateDispatchData + [GatewayMessageType.CHANNEL_UPDATE]: GatewayChannelUpdateDispatchData + [GatewayMessageType.CHANNEL_DELETE]: GatewayChannelDeleteDispatchData + + [GatewayMessageType.MESSAGE_REACTION_ADD]: GatewayMessageReactionAddDispatchData + [GatewayMessageType.MESSAGE_REACTION_REMOVE_EMOJI]: GatewayMessageReactionRemoveEmojiDispatchData + [GatewayMessageType.MESSAGE_REACTION_REMOVE]: GatewayMessageReactionRemoveDispatchData + [GatewayMessageType.MESSAGE_REACTION_REMOVE_ALL]: GatewayMessageReactionRemoveAllDispatchData + + [GatewayMessageType.PRESENCE_UPDATE]: UserAPIPresenceBase & { + user: { id: Snowflake } + guild_id: Snowflake + broadcast: unknown + } + + [GatewayMessageType.CHANNEL_RECIPIENT_ADD]: { channel_id: Snowflake, user: APIUser } + [GatewayMessageType.CHANNEL_RECIPIENT_REMOVE]: { channel_id: Snowflake } + + [GatewayMessageType.RELATIONSHIP_ADD]: { id: Snowflake, user: APIUser } + [GatewayMessageType.RELATIONSHIP_REMOVE]: { id: Snowflake } +} export interface GatewayConnectionOptions { version: number