diff --git a/deps.ts b/deps.ts index 7a04e52..6656d75 100644 --- a/deps.ts +++ b/deps.ts @@ -7,3 +7,5 @@ export type { Redis, RedisConnectOptions } from 'https://deno.land/x/redis@v0.14.1/mod.ts' +export { walk } from 'https://deno.land/std@0.86.0/fs/walk.ts' +export { join } from 'https://deno.land/std@0.86.0/path/mod.ts' diff --git a/src/gateway/handlers/interactionCreate.ts b/src/gateway/handlers/interactionCreate.ts index fac882a..a27fa81 100644 --- a/src/gateway/handlers/interactionCreate.ts +++ b/src/gateway/handlers/interactionCreate.ts @@ -8,13 +8,30 @@ export const interactionCreate: GatewayEventHandler = async ( gateway: Gateway, d: InteractionPayload ) => { - const guild = await gateway.client.guilds.get(d.guild_id) + // NOTE(DjDeveloperr): Mason once mentioned that channel_id can be optional in Interaction. + // This case can be seen in future proofing Interactions, and one he mentioned was + // that bots will be able to add custom context menus. In that case, Interaction will not have it. + // Ref: https://github.com/discord/discord-api-docs/pull/2568/files#r569025697 + if (d.channel_id === undefined) return + + const guild = + d.guild_id === undefined + ? undefined + : await gateway.client.guilds.get(d.guild_id) if (guild === undefined) return - await guild.members.set(d.member.user.id, d.member) - const member = ((await guild.members.get( - d.member.user.id - )) as unknown) as Member + if (d.member !== undefined) + await guild.members.set(d.member.user.id, d.member) + const member = + d.member !== undefined + ? (((await guild.members.get(d.member.user.id)) as unknown) as Member) + : undefined + if (d.user !== undefined) await gateway.client.users.set(d.user.id, d.user) + const dmUser = + d.user !== undefined ? await gateway.client.users.get(d.user.id) : undefined + + const user = member !== undefined ? member.user : dmUser + if (user === undefined) return const channel = (await gateway.client.channels.get(d.channel_id)) ?? @@ -23,7 +40,8 @@ export const interactionCreate: GatewayEventHandler = async ( const interaction = new Interaction(gateway.client, d, { member, guild, - channel + channel, + user }) gateway.client.emit('interactionCreate', interaction) } diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 955b61d..c6dbc0a 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -157,7 +157,7 @@ export class Gateway extends HarmonyEventEmitter { const handler = gatewayHandlers[t] - if (handler !== undefined) { + if (handler !== undefined && d !== null) { handler(this, d) } } diff --git a/src/models/command.ts b/src/models/command.ts index cca21c1..05e59ff 100644 --- a/src/models/command.ts +++ b/src/models/command.ts @@ -5,7 +5,7 @@ import { User } from '../structures/user.ts' import { Collection } from '../utils/collection.ts' import { CommandClient } from './commandClient.ts' import { Extension } from './extensions.ts' -import { parse } from '../../deps.ts' +import { join, parse, walk } from '../../deps.ts' export interface CommandContext { /** The Client object */ @@ -284,13 +284,103 @@ export class CommandBuilder extends Command { } } +export class CommandsLoader { + client: CommandClient + #importSeq: { [name: string]: number } = {} + + constructor(client: CommandClient) { + this.client = client + } + + /** + * Load a Command from file. + * + * @param filePath Path of Command file. + * @param exportName Export name. Default is the "default" export. + */ + async load( + filePath: string, + exportName: string = 'default', + onlyRead?: boolean + ): Promise { + const stat = await Deno.stat(filePath).catch(() => undefined) + if (stat === undefined || stat.isFile !== true) + throw new Error(`File not found on path ${filePath}`) + + let seq: number | undefined + + if (this.#importSeq[filePath] !== undefined) seq = this.#importSeq[filePath] + const mod = await import( + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + 'file:///' + + join(Deno.cwd(), filePath) + + (seq === undefined ? '' : `#${seq}`) + ) + if (this.#importSeq[filePath] === undefined) this.#importSeq[filePath] = 0 + else this.#importSeq[filePath]++ + + const Cmd = mod[exportName] + if (Cmd === undefined) + throw new Error(`Command not exported as ${exportName} from ${filePath}`) + + let cmd: Command + try { + if (Cmd instanceof Command) cmd = Cmd + else cmd = new Cmd() + if (!(cmd instanceof Command)) throw new Error('failed') + } catch (e) { + throw new Error(`Failed to load Command from ${filePath}`) + } + + if (onlyRead !== true) this.client.commands.add(cmd) + return cmd + } + + /** + * Load commands from a Directory. + * + * @param path Path of the directory. + * @param options Options to configure loading. + */ + async loadDirectory( + path: string, + options?: { + recursive?: boolean + exportName?: string + maxDepth?: number + exts?: string[] + onlyRead?: boolean + } + ): Promise { + const commands: Command[] = [] + + for await (const entry of walk(path, { + maxDepth: options?.maxDepth, + exts: options?.exts, + includeDirs: false + })) { + if (entry.isFile !== true) continue + const cmd = await this.load( + entry.path, + options?.exportName, + options?.onlyRead + ) + commands.push(cmd) + } + + return commands + } +} + export class CommandsManager { client: CommandClient list: Collection = new Collection() disabled: Set = new Set() + loader: CommandsLoader constructor(client: CommandClient) { this.client = client + this.loader = new CommandsLoader(client) } /** Number of loaded Commands */ diff --git a/src/models/slashClient.ts b/src/models/slashClient.ts index 3318e05..eac0486 100644 --- a/src/models/slashClient.ts +++ b/src/models/slashClient.ts @@ -155,6 +155,7 @@ function buildOptionsArray( ) } +/** Slash Command Builder */ export class SlashBuilder { data: SlashCommandPartial @@ -200,6 +201,7 @@ export class SlashBuilder { } } +/** Manages Slash Commands, allows fetching/modifying/deleting/creating Slash Commands. */ export class SlashCommandsManager { slash: SlashClient rest: RESTManager @@ -351,7 +353,7 @@ export class SlashCommandsManager { } } -export type SlashCommandHandlerCallback = (interaction: Interaction) => any +export type SlashCommandHandlerCallback = (interaction: Interaction) => unknown export interface SlashCommandHandler { name: string guild?: string @@ -360,6 +362,7 @@ export interface SlashCommandHandler { handler: SlashCommandHandlerCallback } +/** Options for SlashClient */ export interface SlashOptions { id?: string | (() => string) client?: Client @@ -369,6 +372,7 @@ export interface SlashOptions { publicKey?: string } +/** Slash Client represents an Interactions Client which can be used without Harmony Client. */ export class SlashClient { id: string | (() => string) client?: Client @@ -469,12 +473,20 @@ export class SlashClient { const groupMatched = e.group !== undefined && e.parent !== undefined ? i.options - .find((o) => o.name === e.group) + .find( + (o) => + o.name === e.group && + o.type === SlashCommandOptionType.SUB_COMMAND_GROUP + ) ?.options?.find((o) => o.name === e.name) !== undefined : true const subMatched = e.group === undefined && e.parent !== undefined - ? i.options.find((o) => o.name === e.name) !== undefined + ? i.options.find( + (o) => + o.name === e.name && + o.type === SlashCommandOptionType.SUB_COMMAND + ) !== undefined : true const nameMatched1 = e.name === i.name const parentMatched = hasGroupOrParent ? e.parent === i.name : true @@ -485,11 +497,15 @@ export class SlashClient { }) } - /** Process an incoming Slash Command (interaction) */ + /** Process an incoming Interaction */ private _process(interaction: Interaction): void { if (!this.enabled) return - if (interaction.type !== InteractionType.APPLICATION_COMMAND) return + if ( + interaction.type !== InteractionType.APPLICATION_COMMAND || + interaction.data === undefined + ) + return const cmd = this._getCommand(interaction) if (cmd?.group !== undefined) diff --git a/src/structures/slash.ts b/src/structures/slash.ts index 388aa56..0df43af 100644 --- a/src/structures/slash.ts +++ b/src/structures/slash.ts @@ -1,12 +1,18 @@ import { Client } from '../models/client.ts' -import { MessageOptions } from '../types/channel.ts' +import { + AllowedMentionsPayload, + EmbedPayload, + MessageOptions +} from '../types/channel.ts' import { INTERACTION_CALLBACK, WEBHOOK_MESSAGE } from '../types/endpoint.ts' import { - InteractionData, - InteractionOption, + InteractionApplicationCommandData, + InteractionApplicationCommandOption, InteractionPayload, + InteractionResponseFlags, InteractionResponsePayload, - InteractionResponseType + InteractionResponseType, + InteractionType } from '../types/slash.ts' import { SnowflakeBase } from './base.ts' import { Embed } from './embed.ts' @@ -15,7 +21,6 @@ import { Member } from './member.ts' import { Message } from './message.ts' import { GuildTextChannel, TextChannel } from './textChannel.ts' import { User } from './user.ts' -import { Webhook } from './webhook.ts' interface WebhookMessageOptions extends MessageOptions { embeds?: Embed[] @@ -25,39 +30,57 @@ interface WebhookMessageOptions extends MessageOptions { type AllWebhookMessageOptions = string | WebhookMessageOptions -export interface InteractionResponse { - type?: InteractionResponseType +/** Interaction Message related Options */ +export interface InteractionMessageOptions { content?: string - embeds?: Embed[] + embeds?: EmbedPayload[] tts?: boolean - flags?: number + flags?: number | InteractionResponseFlags[] + allowedMentions?: AllowedMentionsPayload + /** Whether to reply with Source or not. True by default */ + withSource?: boolean +} + +export interface InteractionResponse extends InteractionMessageOptions { + /** Type of Interaction Response */ + type?: InteractionResponseType + /** + * DEPRECATED: Use `ephemeral` instead. + * + * @deprecated + */ temp?: boolean - allowedMentions?: { - parse?: string - roles?: string[] - users?: string[] - everyone?: boolean - } + /** Whether the Message Response should be Ephemeral (only visible to User) or not */ + ephemeral?: boolean } export class Interaction extends SnowflakeBase { client: Client - type: number + /** Type of Interaction */ + type: InteractionType + /** Interaction Token */ token: string + /** Interaction ID */ id: string - data: InteractionData - channel: GuildTextChannel - guild: Guild - member: Member - _savedHook?: Webhook + /** Data sent with Interaction. Only applies to Application Command, type may change in future. */ + data?: InteractionApplicationCommandData + /** Channel in which Interaction was initiated */ + channel?: TextChannel | GuildTextChannel + guild?: Guild + /** Member object of who initiated the Interaction */ + member?: Member + /** User object of who invoked Interaction */ + user: User + responded: boolean = false constructor( client: Client, data: InteractionPayload, others: { - channel: GuildTextChannel - guild: Guild - member: Member + channel?: TextChannel | GuildTextChannel + guild?: Guild + member?: Member + user: User } ) { super(client) @@ -66,31 +89,43 @@ export class Interaction extends SnowflakeBase { this.token = data.token this.member = others.member this.id = data.id + this.user = others.user this.data = data.data this.guild = others.guild this.channel = others.channel } - get user(): User { - return this.member.user + /** Name of the Command Used (may change with future additions to Interactions!) */ + get name(): string | undefined { + return this.data?.name } - get name(): string { - return this.data.name - } - - get options(): InteractionOption[] { - return this.data.options ?? [] + get options(): InteractionApplicationCommandOption[] { + return this.data?.options ?? [] } + /** Get an option by name */ option(name: string): T { return this.options.find((e) => e.name === name)?.value } /** Respond to an Interaction */ async respond(data: InteractionResponse): Promise { + if (this.responded) throw new Error('Already responded to Interaction') + let flags = 0 + if (data.ephemeral === true || data.temp === true) + flags |= InteractionResponseFlags.EPHEMERAL + if (data.flags !== undefined) { + if (Array.isArray(data.flags)) + flags = data.flags.reduce((p, a) => p | a, flags) + else if (typeof data.flags === 'number') flags |= data.flags + } const payload: InteractionResponsePayload = { - type: data.type ?? InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + type: + data.type ?? + (data.withSource === false + ? InteractionResponseType.CHANNEL_MESSAGE + : InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE), data: data.type === undefined || data.type === InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE || @@ -99,8 +134,8 @@ export class Interaction extends SnowflakeBase { content: data.content ?? '', embeds: data.embeds, tts: data.tts ?? false, - flags: data.temp === true ? 64 : data.flags ?? undefined, - allowed_mentions: (data.allowedMentions ?? undefined) as any + flags, + allowed_mentions: data.allowedMentions ?? undefined } : undefined } @@ -109,6 +144,52 @@ export class Interaction extends SnowflakeBase { INTERACTION_CALLBACK(this.id, this.token), payload ) + this.responded = true + + return this + } + + /** Acknowledge the Interaction */ + async acknowledge(withSource?: boolean): Promise { + await this.respond({ + type: + withSource === true + ? InteractionResponseType.ACK_WITH_SOURCE + : InteractionResponseType.ACKNOWLEDGE + }) + return this + } + + /** Reply with a Message to the Interaction */ + async reply(content: string): Promise + async reply(options: InteractionMessageOptions): Promise + async reply( + content: string, + options: InteractionMessageOptions + ): Promise + async reply( + content: string | InteractionMessageOptions, + messageOptions?: InteractionMessageOptions + ): Promise { + let options: InteractionMessageOptions | undefined = + typeof content === 'object' ? content : messageOptions + if ( + typeof content === 'object' && + messageOptions !== undefined && + options !== undefined + ) + Object.assign(options, messageOptions) + if (options === undefined) options = {} + if (typeof content === 'string') Object.assign(options, { content }) + + await this.respond( + Object.assign(options, { + type: + options.withSource === false + ? InteractionResponseType.CHANNEL_MESSAGE + : InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE + }) + ) return this } @@ -232,6 +313,7 @@ export class Interaction extends SnowflakeBase { return this } + /** Delete a follow-up Message */ async deleteMessage(msg: Message | string): Promise { await this.client.rest.delete( WEBHOOK_MESSAGE( diff --git a/src/types/channel.ts b/src/types/channel.ts index e1d44e0..10c5ea2 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -156,16 +156,25 @@ export interface MessagePayload { stickers?: MessageStickerPayload[] } +export enum AllowedMentionType { + Roles = 'roles', + Users = 'users', + Everyone = 'everyone' +} + +export interface AllowedMentionsPayload { + parse?: AllowedMentionType[] + users?: string[] + roles?: string[] + replied_user?: boolean +} + export interface MessageOptions { tts?: boolean embed?: Embed file?: MessageAttachment files?: MessageAttachment[] - allowedMentions?: { - parse?: 'everyone' | 'users' | 'roles' - roles?: string[] - users?: string[] - } + allowedMentions?: AllowedMentionsPayload } export interface ChannelMention { diff --git a/src/types/slash.ts b/src/types/slash.ts index 2dac0cd..692611c 100644 --- a/src/types/slash.ts +++ b/src/types/slash.ts @@ -1,22 +1,25 @@ -import { EmbedPayload } from './channel.ts' +import { AllowedMentionsPayload, EmbedPayload } from './channel.ts' import { MemberPayload } from './guild.ts' +import { UserPayload } from './user.ts' -export interface InteractionOption { +export interface InteractionApplicationCommandOption { /** Option name */ name: string + /** Type of Option */ + type: SlashCommandOptionType /** Value of the option */ value?: any /** Sub options */ - options?: any[] + options?: InteractionApplicationCommandOption[] } -export interface InteractionData { +export interface InteractionApplicationCommandData { /** Name of the Slash Command */ name: string /** Unique ID of the Slash Command */ id: string /** Options (arguments) sent with Interaction */ - options: InteractionOption[] + options: InteractionApplicationCommandOption[] } export enum InteractionType { @@ -26,27 +29,31 @@ export enum InteractionType { APPLICATION_COMMAND = 2 } +export interface InteractionMemberPayload extends MemberPayload { + permissions: string +} + export interface InteractionPayload { /** Type of the Interaction */ type: InteractionType /** Token of the Interaction to respond */ token: string /** Member object of user who invoked */ - member: MemberPayload & { - /** Total permissions of the member in the channel, including overrides */ - permissions: string - } + member?: InteractionMemberPayload + /** User who initiated Interaction (only in DMs) */ + user?: UserPayload /** ID of the Interaction */ id: string /** * Data sent with the interaction - * **This can be undefined only when Interaction is not a Slash Command** + * + * This can be undefined only when Interaction is not a Slash Command. */ - data: InteractionData + data?: InteractionApplicationCommandData /** ID of the Guild in which Interaction was invoked */ - guild_id: string + guild_id?: string /** ID of the Channel in which Interaction was invoked */ - channel_id: string + channel_id?: string } export interface SlashCommandChoice { @@ -68,13 +75,18 @@ export enum SlashCommandOptionType { } export interface SlashCommandOption { + /** Name of the option. */ name: string - /** Description not required in Sub-Command or Sub-Command-Group */ + /** Description of the Option. Not required in Sub-Command-Group */ description?: string + /** Option type */ type: SlashCommandOptionType + /** Whether the option is required or not, false by default */ required?: boolean default?: boolean + /** Optional choices out of which User can choose value */ choices?: SlashCommandChoice[] + /** Nested options for Sub-Command or Sub-Command-Groups */ options?: SlashCommandOption[] } @@ -103,19 +115,20 @@ export enum InteractionResponseType { } export interface InteractionResponsePayload { + /** Type of the response */ type: InteractionResponseType + /** Data to be sent with response. Optional for types: Pong, Acknowledge, Ack with Source */ data?: InteractionResponseDataPayload } export interface InteractionResponseDataPayload { tts?: boolean + /** Text content of the Response (Message) */ content: string + /** Upto 10 Embed Objects to send with Response */ embeds?: EmbedPayload[] - allowed_mentions?: { - parse?: 'everyone' | 'users' | 'roles' - roles?: string[] - users?: string[] - } + /** Allowed Mentions object */ + allowed_mentions?: AllowedMentionsPayload flags?: number }