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 3c780d0..6981579 100644 --- a/src/websocket/types.ts +++ b/src/websocket/types.ts @@ -1,41 +1,194 @@ +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' /** - * 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 + } + | { + 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'> + +/** + * 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 name for this gateway message (only relevant if `op` is - * `DISPATCH`). For low-level events, see the {@link op} field. - */ - t?: GatewayMessageType + * 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 { 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 }