Skip to main content
Module

x/harmony/src/interactions/client.ts

An easy to use Discord API Library for Deno.
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
import { ApplicationCommandInteraction, InteractionApplicationCommandResolved} from '../structures/applicationCommand.ts'import { Interaction, InteractionChannel } from '../structures/interactions.ts'import { InteractionPayload, InteractionResponsePayload, InteractionType} from '../types/interactions.ts'import { ApplicationCommandOptionType, ApplicationCommandType, InteractionApplicationCommandData} from '../types/applicationCommand.ts'import type { Client } from '../client/mod.ts'import { RESTManager } from '../rest/mod.ts'import { ApplicationCommandsModule } from './commandModule.ts'import { edverify, decodeHex, readAll } from '../../deps.ts'import { User } from '../structures/user.ts'import { HarmonyEventEmitter } from '../utils/events.ts'import { decodeText, encodeText } from '../utils/encoding.ts'import { ApplicationCommandsManager } from './applicationCommand.ts'import { Application } from '../structures/application.ts'import { Member } from '../structures/member.ts'import { Guild } from '../structures/guild.ts'import { GuildPayload } from '../types/guild.ts'import { Channel } from '../structures/channel.ts'import { TextChannel } from '../structures/textChannel.ts'import { Role } from '../structures/role.ts'import { Message } from '../structures/message.ts'import { MessageComponentInteraction } from '../structures/messageComponents.ts'import { AutocompleteInteraction } from '../structures/autocompleteInteraction.ts'import { ModalSubmitInteraction } from '../structures/modalSubmitInteraction.ts'
export type ApplicationCommandHandlerCallback = ( interaction: ApplicationCommandInteraction) => any // Any to include both sync and async return types
export interface ApplicationCommandHandler { name: string type?: ApplicationCommandType guild?: string parent?: string group?: string handler: ApplicationCommandHandlerCallback}
// Deprecatedexport type { ApplicationCommandHandlerCallback as SlashCommandHandlerCallback }export type { ApplicationCommandHandler as SlashCommandHandler }
export type AutocompleteHandlerCallback = (d: AutocompleteInteraction) => any
export interface AutocompleteHandler { cmd: string option: string parent?: string group?: string handler: AutocompleteHandlerCallback}
/** Options for InteractionsClient */export interface SlashOptions { id?: string | (() => string) client?: Client enabled?: boolean token?: string rest?: RESTManager publicKey?: string}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitionsexport type InteractionsClientEvents = { interaction: [Interaction] interactionError: [Error] ping: []}
/** Slash Client represents an Interactions Client which can be used without Harmony Client. */export class InteractionsClient extends HarmonyEventEmitter<InteractionsClientEvents> { id: string | (() => string) client?: Client
#token?: string
get token(): string | undefined { return this.#token }
set token(val: string | undefined) { this.#token = val }
enabled: boolean = true commands: ApplicationCommandsManager handlers: ApplicationCommandHandler[] = [] autocompleteHandlers: AutocompleteHandler[] = [] readonly rest!: RESTManager modules: ApplicationCommandsModule[] = [] publicKey?: string
constructor(options: SlashOptions) { super() let id = options.id if (options.token !== undefined) id = atob(options.token?.split('.')[0]) if (id === undefined) { throw new Error('ID could not be found. Pass at least client or token') } this.id = id
if (options.client !== undefined) { Object.defineProperty(this, 'client', { value: options.client, enumerable: false }) }
this.token = options.token this.publicKey = options.publicKey
this.enabled = options.enabled ?? true
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const client = this.client as unknown as { _decoratedAppCmd: ApplicationCommandHandler[] _decoratedAutocomplete: AutocompleteHandler[] } if (client?._decoratedAppCmd !== undefined) { client._decoratedAppCmd.forEach((e) => { e.handler = e.handler.bind(this.client) this.handlers.push(e) }) }
if (client?._decoratedAutocomplete !== undefined) { client._decoratedAutocomplete.forEach((e) => { e.handler = e.handler.bind(this.client) this.autocompleteHandlers.push(e) }) }
const self = this as unknown as InteractionsClient & { _decoratedAppCmd: ApplicationCommandHandler[] _decoratedAutocomplete: AutocompleteHandler[] }
if (self._decoratedAppCmd !== undefined) { self._decoratedAppCmd.forEach((e) => { e.handler = e.handler.bind(this.client) self.handlers.push(e) }) }
if (self._decoratedAutocomplete !== undefined) { self._decoratedAutocomplete.forEach((e) => { e.handler = e.handler.bind(this.client) self.autocompleteHandlers.push(e) }) }
Object.defineProperty(this, 'rest', { value: options.client === undefined ? options.rest === undefined ? new RESTManager({ token: this.token }) : options.rest : options.client.rest, enumerable: false })
this.client?.on( 'interactionCreate', async (interaction) => await this._process(interaction) )
this.commands = new ApplicationCommandsManager(this) }
getID(): string { return typeof this.id === 'string' ? this.id : this.id() }
/** Adds a new Application Command Handler */ handle(cmd: ApplicationCommandHandler): this handle(cmd: string, handler: ApplicationCommandHandlerCallback): this handle( cmd: string, handler: ApplicationCommandHandlerCallback, type: ApplicationCommandType | keyof typeof ApplicationCommandType ): this handle( cmd: string | ApplicationCommandHandler, handler?: ApplicationCommandHandlerCallback, type?: ApplicationCommandType | keyof typeof ApplicationCommandType ): this { const handle = { name: typeof cmd === 'string' ? cmd : cmd.name, ...(handler !== undefined ? { handler } : {}), ...(typeof cmd === 'string' ? {} : cmd) }
if (type !== undefined) { handle.type = typeof type === 'string' ? ApplicationCommandType[type] : type }
if (handle.handler === undefined) { throw new Error('Invalid usage. Handler function not provided') }
if ( (handle.type === undefined || handle.type === ApplicationCommandType.CHAT_INPUT) && typeof handle.name === 'string' && handle.name.includes(' ') && handle.parent === undefined && handle.group === undefined ) { const parts = handle.name.split(/ +/).filter((e) => e !== '') if (parts.length > 3 || parts.length < 1) { throw new Error('Invalid command name') } const root = parts.shift() as string const group = parts.length === 2 ? parts.shift() : undefined const sub = parts.shift()
handle.name = sub ?? root handle.group = group handle.parent = sub === undefined ? undefined : root }
this.handlers.push(handle as ApplicationCommandHandler) return this }
/** * Add a handler for autocompletions (for application command options). * * @param cmd Command name. Can be `*` * @param option Option name. Can be `*` * @param handler Handler callback that is fired when a matching Autocomplete Interaction comes in. */ autocomplete( cmd: string, option: string, handler: AutocompleteHandlerCallback ): this { const handle: AutocompleteHandler = { cmd, option, handler }
if ( typeof handle.cmd === 'string' && handle.cmd.includes(' ') && handle.parent === undefined && handle.group === undefined ) { const parts = handle.cmd.split(/ +/).filter((e) => e !== '') if (parts.length > 3 || parts.length < 1) { throw new Error('Invalid command name') } const root = parts.shift() as string const group = parts.length === 2 ? parts.shift() : undefined const sub = parts.shift()
handle.cmd = sub ?? root handle.group = group handle.parent = sub === undefined ? undefined : root }
this.autocompleteHandlers.push(handle) return this }
/** Load a Slash Module */ loadModule(module: ApplicationCommandsModule): InteractionsClient { this.modules.push(module) return this }
/** Get all Handlers. Including Slash Modules */ getHandlers(): ApplicationCommandHandler[] { let res = this.handlers for (const mod of this.modules) { if (mod === undefined) continue res = [ ...res, ...mod.commands.map((cmd) => { cmd.handler = cmd.handler.bind(mod) return cmd }) ] } return res }
/** Get Handler for an Interaction. Supports nested sub commands and sub command groups. */ private _getCommand( i: ApplicationCommandInteraction ): ApplicationCommandHandler | undefined { return this.getHandlers().find((e) => { if ( (e.type === ApplicationCommandType.MESSAGE || e.type === ApplicationCommandType.USER) && i.targetID !== undefined && i.name === e.name ) { return true }
const hasGroupOrParent = e.group !== undefined || e.parent !== undefined const groupMatched = e.group !== undefined && e.parent !== undefined ? i.data.options ?.find( (o) => o.name === e.group && o.type === ApplicationCommandOptionType.SUB_COMMAND_GROUP ) ?.options?.find((o) => o.name === e.name) !== undefined : true const subMatched = e.group === undefined && e.parent !== undefined ? i.data.options?.find( (o) => o.name === e.name && o.type === ApplicationCommandOptionType.SUB_COMMAND ) !== undefined : true const nameMatched1 = e.name === i.name const parentMatched = hasGroupOrParent ? e.parent === i.name : true const nameMatched = hasGroupOrParent ? parentMatched : nameMatched1
const matched = groupMatched && subMatched && nameMatched return matched }) }
/** Get Handler for an autocomplete Interaction. Supports nested sub commands and sub command groups. */ private _getAutocompleteHandler( i: AutocompleteInteraction ): AutocompleteHandler | undefined { return [ ...this.autocompleteHandlers, ...this.modules.map((e) => e.autocomplete).flat() ].find((e) => { if (i.targetID !== undefined && i.name === e.cmd) { return true }
const hasGroupOrParent = e.group !== undefined || e.parent !== undefined const groupMatched = e.group !== undefined && e.parent !== undefined ? i.data.options ?.find( (o) => o.name === e.group && o.type === ApplicationCommandOptionType.SUB_COMMAND_GROUP ) ?.options?.find((o) => o.name === e.cmd) !== undefined : true const subMatched = e.group === undefined && e.parent !== undefined ? i.data.options?.find( (o) => o.name === e.cmd && o.type === ApplicationCommandOptionType.SUB_COMMAND ) !== undefined : true const nameMatched1 = e.cmd === i.name const parentMatched = hasGroupOrParent ? e.parent === i.name : true const nameMatched = hasGroupOrParent ? parentMatched : nameMatched1 const optionMatched = // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions i.options.some((o) => o.name === e.option && o.focused) || e.option === '*'
const matched = groupMatched && subMatched && nameMatched && optionMatched return matched }) }
/** Process an incoming Interaction */ async _process( interaction: Interaction | ApplicationCommandInteraction ): Promise<void> { if (!this.enabled) return
await this.emit('interaction', interaction)
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (interaction.isAutocomplete()) { const handle = this._getAutocompleteHandler(interaction) ?? [ ...this.autocompleteHandlers, ...this.modules.map((e) => e.autocomplete).flat() ].find((e) => e.cmd === '*') try { await handle?.handler(interaction) } catch (e) { await this.emit('interactionError', e as Error) } return }
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (!interaction.isApplicationCommand()) return
const cmd = this._getCommand(interaction) ?? this.getHandlers().find((e) => e.name === '*')
if (cmd === undefined) return
try { await cmd.handler(interaction) } catch (e) { await this.emit('interactionError', e as Error) } }
/** Verify HTTP based Interaction */ verifyKey( rawBody: string | Uint8Array, signature: string | Uint8Array, timestamp: string | Uint8Array ): boolean { if (this.publicKey === undefined) { throw new Error('Public Key is not present') }
const fullBody = new Uint8Array([ ...(typeof timestamp === 'string' ? encodeText(timestamp) : timestamp), ...(typeof rawBody === 'string' ? encodeText(rawBody) : rawBody) ])
return edverify( decodeHex(encodeText(this.publicKey)), decodeHex( signature instanceof Uint8Array ? signature : encodeText(signature) ), fullBody ) }
/** * Verify [Deno Std HTTP Server Request](https://deno.land/std/http/server.ts) and return Interaction. * * **Data present in Interaction returned by this method is very different from actual typings * as there is no real `Client` behind the scenes to cache things.** */ async verifyServerRequest(req: { headers: Headers method: string body: Deno.Reader | Uint8Array respond: (options: { status?: number headers?: Headers body?: BodyInit }) => Promise<void> }): Promise<false | Interaction> { if (req.method.toLowerCase() !== 'post') return false
const signature = req.headers.get('x-signature-ed25519') const timestamp = req.headers.get('x-signature-timestamp') if (signature === null || timestamp === null) return false
const rawbody = req.body instanceof Uint8Array ? req.body : await readAll(req.body) const verify = this.verifyKey(rawbody, signature, timestamp) if (!verify) return false
try { const payload: InteractionPayload = JSON.parse(decodeText(rawbody))
// Note: there's a lot of hacks going on here.
const client = this as unknown as Client
let res
const channel = payload.channel_id !== undefined ? (new Channel(client, { id: payload.channel_id!, type: 0, flags: 0 }) as unknown as TextChannel) : undefined
const user = new User(client, (payload.member?.user ?? payload.user)!)
const guild = payload.guild_id !== undefined ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions new Guild(client, { id: payload.guild_id!, unavailable: true } as GuildPayload) : undefined
const member = payload.member !== undefined ? // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion new Member(client, payload.member, user, guild!) : undefined
if ( payload.type === InteractionType.APPLICATION_COMMAND || payload.type === InteractionType.AUTOCOMPLETE ) { const resolved: InteractionApplicationCommandResolved = { users: {}, members: {}, roles: {}, channels: {}, messages: {} }
for (const [id, data] of Object.entries( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion (payload.data as InteractionApplicationCommandData).resolved?.users ?? {} )) { resolved.users[id] = new User(client, data) }
for (const [id, data] of Object.entries( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion (payload.data as InteractionApplicationCommandData).resolved ?.members ?? {} )) { resolved.members[id] = new Member( client, data, resolved.users[id], undefined as unknown as Guild ) }
for (const [id, data] of Object.entries( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion (payload.data as InteractionApplicationCommandData).resolved?.roles ?? {} )) { resolved.roles[id] = new Role( client, data, undefined as unknown as Guild ) }
for (const [id, data] of Object.entries( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion (payload.data as InteractionApplicationCommandData).resolved ?.channels ?? {} )) { resolved.channels[id] = new InteractionChannel(client, data) }
for (const [id, data] of Object.entries( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion (payload.data as InteractionApplicationCommandData).resolved ?.messages ?? {} )) { resolved.messages[id] = new Message( client, data, data.channel_id as unknown as TextChannel, new User(client, data.author) ) }
res = payload.type === InteractionType.APPLICATION_COMMAND ? new ApplicationCommandInteraction(client, payload, { user, member, guild, channel, resolved }) : new AutocompleteInteraction(client, payload, { user, member, guild, channel, resolved }) } else if (payload.type === InteractionType.MODAL_SUBMIT) { res = new ModalSubmitInteraction(client, payload, { channel, guild, member, user }) } else if (payload.type === InteractionType.MESSAGE_COMPONENT) { res = new MessageComponentInteraction(client, payload, { channel, guild, member, user, message: new Message( client, payload.message!, payload.message!.channel_id as unknown as TextChannel, new User(client, payload.message!.author) ) }) } else { res = new Interaction(client, payload, { user, member, guild, channel }) }
res._httpRespond = async (d: InteractionResponsePayload | FormData) => await req.respond({ status: 200, headers: new Headers({ 'content-type': d instanceof FormData ? 'multipart/form-data' : 'application/json' }), body: d instanceof FormData ? d : JSON.stringify(d) })
await this.emit('interaction', res)
return res } catch (e) { return false } }
/** Verify FetchEvent (for Service Worker usage) and return Interaction if valid */ async verifyFetchEvent({ request: req, respondWith }: { respondWith: CallableFunction request: Request }): Promise<false | Interaction> { if (req.bodyUsed === true) throw new Error('Request Body already used') if (req.body === null) return false const body = new Uint8Array(await req.arrayBuffer())
return await this.verifyServerRequest({ headers: req.headers, body, method: req.method, respond: async (options) => { await respondWith( new Response(options.body, { headers: options.headers, status: options.status }) ) } }) }
async verifyOpineRequest< T extends { headers: Headers body: Deno.Reader } >(req: T): Promise<boolean> { const signature = req.headers.get('x-signature-ed25519') const timestamp = req.headers.get('x-signature-timestamp') const contentLength = req.headers.get('content-length')
if (signature === null || timestamp === null || contentLength === null) { return false }
const body = new Uint8Array(parseInt(contentLength)) await req.body.read(body)
const verified = await this.verifyKey(body, signature, timestamp) if (!verified) return false
return true }
/** Middleware to verify request in Opine framework. */ async verifyOpineMiddleware< Req extends { headers: Headers body: Deno.Reader }, Res extends { setStatus: (code: number) => Res end: () => Res } >(req: Req, res: Res, next: CallableFunction): Promise<boolean> { const verified = await this.verifyOpineRequest(req) if (!verified) { res.setStatus(401).end() return false }
await next() return true }
// TODO: create verifyOakMiddleware too /** Method to verify Request from Oak server "Context". */ async verifyOakRequest< T extends { request: { headers: Headers hasBody: boolean body: () => { value: Promise<Uint8Array> } } } >(ctx: T): Promise<boolean> { const signature = ctx.request.headers.get('x-signature-ed25519') const timestamp = ctx.request.headers.get('x-signature-timestamp') const contentLength = ctx.request.headers.get('content-length')
if ( signature === null || timestamp === null || contentLength === null || !ctx.request.hasBody ) { return false }
const body = await ctx.request.body().value
const verified = await this.verifyKey(body, signature, timestamp) if (!verified) return false return true }
/** Fetch Application of the Client (if Token is present) */ async fetchApplication(): Promise<Application> { const app = await this.rest.api.oauth2.applications['@me'].get() // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return new Application(this.client!, app) }}
export { InteractionsClient as SlashClient }