Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[wip] better type safety around networked packets #17

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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.
slice committed Jan 11, 2024
commit 8910deae9cbf9204103e92a96718542db2687be9
6 changes: 3 additions & 3 deletions src/mappers/mappers.ts
Original file line number Diff line number Diff line change
@@ -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<GatewayPresenceUpdateData, 'since' | 'afk'>): UserPresence => {
const activity = presence.activities?.length > 0 ? presence.activities?.[0] as any : undefined
return {
userID,
12 changes: 11 additions & 1 deletion src/network-api.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
28 changes: 28 additions & 0 deletions src/websocket/emitter.ts
Original file line number Diff line number Diff line change
@@ -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<MessageType>) => void }

/// A thin wrapper around {@link EventEmitter} for more accurate types.
export default class GatewayEventEmitter {
private emitter = new EventEmitter({ captureRejections: true })

on<Event extends keyof Events>(event: Event, handler: Events[Event]) {
this.emitter.on(event, handler)
}

emit<Event extends keyof Events>(event: Event, ...data: Parameters<Events[Event]>) {
this.emitter.emit(event, ...data)
}
}
59 changes: 18 additions & 41 deletions src/websocket/event-handlers.ts
Original file line number Diff line number Diff line change
@@ -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<APIGuild & { channels: APIChannel[], threads: APIChannel[] }>

user_guild_settings: {
entries: Array<{
channel_overrides: Array<{ muted: boolean, channel_id: string }>
}>
}
}
function isGuildChannel(channel: APIChannel): channel is Exclude<APIChannel, APIGroupDMChannel | APIDMChannel> {
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<GatewayPresenceUpdateData & { user_id: Snowflake }>
}
}

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,
142 changes: 141 additions & 1 deletion src/websocket/types.ts
Original file line number Diff line number Diff line change
@@ -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<Data = unknown> =
t: null
}

export type OutboundGatewayMessage = Pick<GatewayMessage<any>, "op" | "d">
export type OutboundGatewayMessage = Pick<GatewayMessage<any>, '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<MessageType extends keyof EventData> = {
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<UserAPIPresenceBase & { user_id: Snowflake }>
}
}

[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