diff --git a/src/index.ts b/src/index.ts index 67e42d1..aba8e70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export { Guild } from './types/guild'; export { Role } from './types/role'; export { Member } from './types/member'; export { User } from './types/user'; -export { Message, type MessagePayload } from './types/message'; +export { Message, type MessagePayload, ComponentStyle, ComponentType } from './types/message'; export { Embed } from './types/embed'; export { Emoji } from './types/emoji'; // Other diff --git a/src/services/client.ts b/src/services/client.ts index 33143d0..9fe5886 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,7 +1,7 @@ import { API } from './api'; import { Gateway } from './connectors/gateway'; import { Processor } from './processor'; -import { Expectation, Intent, type ClientConfig } from '../types/common'; +import { Expectation, HandlerType, Intent, type ClientConfig } from '../types/common'; import { exists, isNil } from '../utils/index'; import { type DiscordMessage, Message } from '../types/message'; import { Guilds } from './caches/guilds'; @@ -23,8 +23,9 @@ export class Client { guilds: Guilds; users: Users; messages: Messages; - chatCommands: Processor['chat_commands']; - applicationCommands: Processor['application_commands']; + chatCommands: Processor[HandlerType.ChatCommands]; + applicationCommands: Processor[HandlerType.ApplicationCommands]; + interactions: Processor[HandlerType.Interactions]; constructor(config: ClientConfig) { // Validate the configuration @@ -43,8 +44,9 @@ export class Client { this.users = new Users(this.api, this.processor); this.messages = new Messages(this, this.api, this.processor); // Save references to command and event handlers - this.chatCommands = this.processor.chat_commands; - this.applicationCommands = this.processor.application_commands; + this.chatCommands = this.processor[HandlerType.ChatCommands]; + this.applicationCommands = this.processor[HandlerType.ApplicationCommands]; + this.interactions = this.processor[HandlerType.Interactions]; } private static validateConfig(config: ClientConfig) { diff --git a/src/services/connectors/gateway.ts b/src/services/connectors/gateway.ts index 8c7997f..d67e24c 100644 --- a/src/services/connectors/gateway.ts +++ b/src/services/connectors/gateway.ts @@ -1,6 +1,6 @@ import { WebSocket, type Data } from 'ws'; import type { Client } from "../client"; -import { fromDiscord, HandlerType, type InteractionPayload, type Payload } from "../../types/common"; +import { fromDiscord, HandlerType, type Payload } from "../../types/common"; import { exists, isNil, isObject, log } from "../../utils"; import type { API } from "../api"; import type { Processor } from '../processor'; @@ -9,6 +9,7 @@ import { type DiscordMessage, Message } from '../../types/message'; import { DiscordUser, User } from '../../types/user'; import { Guild } from '../../types/guild'; import type { Emitter } from '../bus'; +import type { InteractionCommandData, InteractionCommpoentData, InteractionPayload } from '../../types/handler'; export class Gateway { private socket: WebSocket | null = null; @@ -228,29 +229,39 @@ export class Gateway { } if (payload.d.type === 2) { - this.handleShashInteraction(payload as Payload & { d: InteractionPayload }); + this.handleShashInteraction(payload as Payload & { d: InteractionPayload & { data: InteractionCommandData } }); + } else if (payload.d.type === 3) { + this.handleChatInteraction(payload as Payload & { d: InteractionPayload & { data: InteractionCommpoentData } }); } else { // this.handleChatInteraction(payload); } }; - private handleShashInteraction = (payload: Payload & { d: InteractionPayload }) => { - console.log('handleShashInteraction') + private handleShashInteraction = (payload: Payload & { d: InteractionPayload & { data: InteractionCommandData } }) => { + if (isNil(payload.d.data)) { + throw new Error('Invalid INTERACTION_CREATE data received'); + } + const command = payload.d.data.name; - const args = payload.d.data.options; + const args = payload.d.data.options ?? []; const context = new Context(this.client, this.api, { interaction: payload.d }); - if (payload.d.data.guild) { - const guild = Guild[fromDiscord](payload.d.data.guild); + if (payload.d.guild) { + const guild = Guild.fromDiscord(payload.d.guild); this.client.guilds.set(guild.id, guild); } - if (payload.d.data.member) { - const member = User[fromDiscord](payload.d.data.member.user); + if (payload.d.member) { + const member = User.fromDiscord(payload.d.member?.user); this.client.users.set(member.id, member); } + if (payload.d.user) { + const user = User.fromDiscord(payload.d.user); + this.client.users.set(user.id, user); + } + // TODO: Fix when channel class is implemented // if (payload.d.data.channel) { // const channel = Channel[fromDiscord](payload.d.data.channel); @@ -258,13 +269,27 @@ export class Gateway { // } if (this.processor.application_commands.has(command)) { - this.processor.application_commands.execute(command, context, args); + this.processor.application_commands.execute(command, context, args?.map(arg => arg.value)); } else { // TODO: Custom unknown command handler context.reply(`Unknown command: ${command}`); } }; + private handleChatInteraction = (payload: Payload & { d: InteractionPayload & { data: InteractionCommpoentData } }) => { + const context = new Context(this.client, this.api, { interaction: payload.d }); + + if (exists(payload.d.data)) { + if (this.processor[HandlerType.Interactions].has(payload.d.data.custom_id)) { + this.processor[HandlerType.Interactions].execute(payload.d.data.custom_id, context, payload.d.data); + } else { + context.reply(`Unknown interaction: \`${payload.d.data.custom_id}\``); + } + } else { + context.reply('Invalid interaction data received'); + } + }; + async connect() { // Fetch the Discord gateway URL const response = await this.api.request('GET', '/gateway/bot'); diff --git a/src/services/processor.ts b/src/services/processor.ts index 72cf459..5472c27 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -1,7 +1,7 @@ import type { Client } from "./client"; import type { Context } from "../types/context"; import { HandlerType, Trait, type EventPayload } from "../types/common"; -import { CommandHandler, type Handler, type ArgsFromOptions, OptionConstructorMap } from "../types/handler"; +import { CommandHandler, type Handler, type ArgsFromOptions, OptionConstructorMap, type InteractionData } from "../types/handler"; import type { API } from "./api"; import { ApplicationCommandOptionType, ApplicationCommandType, type ApplicationCommandOption } from "../types/applicationCommand"; import type { Emitter } from "./bus"; @@ -51,7 +51,8 @@ export class Processor { }; public async execute(type: HandlerType.Events, event: string, context: Context | null, payload: EventPayload): Promise - public async execute(type: Omit, event: string, context: Context, args: any[]): Promise + public async execute(type: HandlerType.Interactions, event: string, context: Context | null, payload: InteractionData): Promise + public async execute(type: Omit, event: string, context: Context, args: any[]): Promise public async execute(type: HandlerType, event: string, context: Context | null, argsOrPayload: any[] | EventPayload) { const handlers = this.handlers[type].get(event); @@ -62,16 +63,9 @@ export class Processor { if (type === HandlerType.Events) { throw new Error('Event handlers cannot be a CommandHandler'); } - const formattedArgs = (() => { - if (type === HandlerType.ChatCommands) { - // TODO: Fix type narrowing here - // @ts-ignore - Haven't figured out how to type narrow this yet - return argsOrPayload.map((arg, i) => ({ name: handler.args[i].name, value: arg })); - } else { - return argsOrPayload; - } - })(); - const resolvedArgs = await this.validateAndResolveArgs(formattedArgs, handler.args); + // Validate and resolve the args + const resolvedArgs = await this.validateAndResolveArgs(argsOrPayload, handler.args); + // Execute the handler await Promise.resolve(handler[Trait.execute](context!, resolvedArgs)); } else { // TODO: Fix ts error @@ -82,9 +76,9 @@ export class Processor { } } - private validateAndResolveArgs = async (args: { value: any }[], definition: T): Promise> => { + private validateAndResolveArgs = async (args: any[], definition: T): Promise> => { // Zip the args and definition together - const zipped: [ApplicationCommandOption, any][] = args.map((arg, i) => [definition[i], arg?.value ?? null]); + const zipped: [ApplicationCommandOption, any][] = args.map((arg, i) => [definition[i], arg]); // Validate the args const validated: Record = {}; // TODO: Attempt some type coresion/resolution here (mostly for text commands) @@ -182,7 +176,7 @@ export class Processor { [HandlerType.Interactions] = { register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler), unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Interactions, event, handler), - execute: async (event: string, context: Context, args: any[]) => this.execute(HandlerType.Interactions, event, context, args), + execute: async (event: string, context: Context, args: InteractionData) => this.execute(HandlerType.Interactions, event, context, args), has: (event: string) => this.handlers[HandlerType.Interactions].has(event) ?? this.handlers[HandlerType.Interactions].get(event)?.length! > 0 }; diff --git a/src/types/common.ts b/src/types/common.ts index a8079a9..7ea6802 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -55,8 +55,6 @@ export type Payload = { t: string | null; }; -export type InteractionPayload = Record; - export enum InteractionResponseType { Pong = 1, Message = 4, diff --git a/src/types/context.ts b/src/types/context.ts index 88a4812..b427e75 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -2,8 +2,9 @@ import type { API } from "../services/api"; import type { Client } from "../services/client"; import { hydrate, hydrator, type Hydrateable, type Hydrated } from "../services/hydrator"; import { isNil, exists } from "../utils"; -import { fromDiscord, InteractionResponseType, type Expectation, type InteractionPayload } from "./common"; +import { fromDiscord, InteractionResponseType, type Expectation } from "./common"; import type { Guild } from "./guild"; +import type { InteractionPayload } from "./handler"; import { type DiscordMessage, Message, type MessagePayload } from "./message"; /** * Represents the context of a message in a Discord channel. @@ -106,14 +107,14 @@ export class Context { message = new Message().setContent(message); } - if (exists(this.message)) { + if (exists(this.interaction)) { + return this.ack(InteractionResponseType.Message, message.toJSON()); + } else if (exists(this.message)) { if (reference) { message.setReference(this.message) } return this.client.sendMessage(this.channel_id, message); - } else if (exists(this.interaction)) { - return this.ack(InteractionResponseType.Message, message.toJSON()); } else { throw new Error('Cannot reply without a message or interaction payload'); } diff --git a/src/types/guild.ts b/src/types/guild.ts index ca87ac7..bfd1363 100644 --- a/src/types/guild.ts +++ b/src/types/guild.ts @@ -1,10 +1,11 @@ +import { GatewayObject } from "./base"; import { Channel } from "./channel"; import { Trait } from "./common"; import { Emoji } from "./emoji"; import { Member } from "./member"; import { Role } from "./role"; -export class Guild { +export class Guild extends GatewayObject { id: string; name: string; description: string | null; @@ -15,6 +16,7 @@ export class Guild { // roles: Role[]; constructor(data: Guild) { + super(); this.id = data.id; this.name = data.name; this.description = data.description; @@ -25,7 +27,7 @@ export class Guild { // this.roles = data.roles; } - public static [Trait.fromDiscord](data: DiscordGuild) { + public static [Trait.fromDiscord](data: DiscordGuild): T { return new Guild({ id: data.id, name: data.name, @@ -35,7 +37,7 @@ export class Guild { owner_id: data.owner_id, // emojis: data.emojis, // roles: data.roles, - }); + }) as T; } } diff --git a/src/types/handler.ts b/src/types/handler.ts index 0c33797..964e29f 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -1,7 +1,66 @@ import { ApplicationCommandContext, ApplicationCommandOptionType, type ApplicationCommandOption } from "./applicationCommand"; import { Trait, type EventPayload } from "./common"; import type { Context } from "./context"; -import type { User } from "./user"; +import type { DiscordGuild } from "./guild"; +import type { DiscordMessage } from "./message"; +import type { DiscordUser, User } from "./user"; + +// TODO: Flesh out InteractionPayload type +export type InteractionPayload = { + id: string; + token: string; + type: number; + data?: InteractionData; + guild?: DiscordGuild; + guild_id: string; + // TODO: Add channel class + // channel?: DiscordChannel; + channel_id: string; + // TODO: Add member class + member?: Record & { user: DiscordUser }; + user?: DiscordUser + message?: DiscordMessage; +}; + +// TODO: Flesh out InteractionData type +export type InteractionData = InteractionCommpoentData | InteractionCommandData; + +export type InteractionCommpoentData = { + custom_id: string; + component_type: number; + values?: any[]; + resolved?: { + users?: Record; + members?: Record; + roles?: Record; + channels?: Record; + messages?: Record; + attachments?: Record; + }; +}; + +export type InteractionCommandData = { + id: string; + name: string; + type: number; + resolved?: { + users?: Record; + members?: Record; + roles?: Record; + channels?: Record; + messages?: Record; + attachments?: Record; + } + options?: { + name: string; + type: ApplicationCommandOptionType; + value?: any; + options?: InteractionCommandData[]; + focused?: boolean; + }[]; + guild_id?: string; + target_id?: string; +} export const OptionConstructorMap = { [ApplicationCommandOptionType.String]: String, diff --git a/testing/demo.test.ts b/testing/demo.test.ts index 9001653..29436d1 100644 --- a/testing/demo.test.ts +++ b/testing/demo.test.ts @@ -1,4 +1,4 @@ -import { Client, type Context, createCommandHandler, Expectation, Intent, type MessagePayload } from '../src/index'; +import { Client, ComponentStyle, type Context, createCommandHandler, Expectation, Intent, InteractionPayload, Message, type MessagePayload } from '../src/index'; import { ApplicationCommandOptionType, ApplicationCommandType } from '../src/types/applicationCommand'; const client = new Client({ @@ -10,7 +10,7 @@ const client = new Client({ Intent.GUILD_MESSAGE_REACTIONS, Intent.MESSAGE_CONTENT ], - prefix: '!' + prefix: '!!' }); // Register arbitrary event handler to the client bus @@ -73,13 +73,13 @@ Nice to meet you, I'm ${context.self.username}!` } })); +// BUG: Repeated hydration does not type narrow correctly. See below. // Repeated hydration client.chatCommands.register('me', async (context: Context, _args: any[]) => { // Note: Testing repeated hydration const { message } = await context.hydrate(context, [Expectation.Message]) const hydrated = await context.hydrate(message, [Expectation.Channel]) const rehydrated = await context.hydrate(hydrated, [Expectation.Guild]); - rehydrated.guild.name const hydrate = await context.hydrator(hydrated, [Expectation.Guild]); @@ -87,7 +87,7 @@ client.chatCommands.register('me', async (context: Context, _args: any[]) => { if (hasGuild) { // Send a message stating the user that they are in a guild - await context.reply(`You are ${message.author.username} and this is ${message.guild.name}`, true); + await context.reply(`You are ${message.author.username} and this is ${hydrated.guild.name}`, true); } else { // Send a message stating the user that they are not in a guild await context.reply(`You are ${message.author.username} and we are not in a server`, true); @@ -107,4 +107,98 @@ client.chatCommands.register('here', async (context: Context, _args: any[]) => { } }); +// Interactions +client.chatCommands.register('demo', async (context: Context, _args: any[]) => { + const msg = new Message() + .setContent('Hello World!') + .addComponent( + 'This is an action row', + // Max buttons in a row is 5 + // Custom ID is required and must be unique + // Non-Link buttons can not have a URL + // Link buttons can not have a custom ID + Message.button({ + label: 'Click me!', + style: ComponentStyle.Primary, + custom_id: 'button_1' + }), + Message.button({ + label: 'No me!', + style: ComponentStyle.Secondary, + custom_id: 'button_2', + disabled: true + }), + Message.button({ + label: 'Never, me!', + style: ComponentStyle.Success, + custom_id: 'button_3' + }), + // Link requires url and can not have custom_id + Message.button({ + label: 'Piss off.', + style: ComponentStyle.Link, + url: 'https://example.com' + }), + Message.button({ + label: 'Emoji', + style: ComponentStyle.Secondary, + custom_id: 'button_4', + emoji: { + name: '👍', + id: null, + animated: false + } + }) + ) + .addComponent( + Message.button({ + label: 'Bad :(', + style: ComponentStyle.Danger, + custom_id: 'button_5' + }), + Message.button({ + label: 'More Bad :(', + style: ComponentStyle.Danger, + custom_id: 'button_6' + }) + ) + .addComponent( + Message.selectMenu({ + custom_id: 'select_1', + options: [ + { + label: 'Select me', + value: 'select_me', + description: 'This is a description', + emoji: { + name: '👍', + id: null, + animated: false + } + } + ], + placeholder: 'Select an option', + min_values: 1, + max_values: 1 + }) + ); + + context.reply(msg); +}); + +const buttonInteractionHandler = (context: Context, data: InteractionData) => { + context.reply(`You clicked the ${data.custom_id} button!`); +}; + +client.interactions.register('button_1', buttonInteractionHandler); +client.interactions.register('button_2', buttonInteractionHandler); +client.interactions.register('button_3', buttonInteractionHandler); +client.interactions.register('button_4', buttonInteractionHandler); +client.interactions.register('button_5', buttonInteractionHandler); +client.interactions.register('button_6', buttonInteractionHandler); + +client.interactions.register('select_1', async (context: Context, data: InteractionData) => { + context.reply(`You selected ${data.values[0]}!`); +}); + client.connect(); \ No newline at end of file