diff --git a/package.json b/package.json index 9172700..bfae418 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "build:nocheck": "bun run build:types:nocheck && bun build src/index.ts --outdir out --noCheck", "build:types": "tsc --emitDeclarationOnly --project tsconfig.json", "build:types:nocheck": "tsc --emitDeclarationOnly --project tsconfig.json --noCheck", - "build:watch": "concurrently \"tsc --emitDeclarationOnly --project tsconfig.json --watch\" \"bun build src/index.ts --outdir out --watch\"" + "build:watch": "concurrently \"tsc --emitDeclarationOnly --project tsconfig.json --watch\" \"bun build src/index.ts --outdir out --watch\"", + "build:packages:watch": "concurrently \"tsc --emitDeclarationOnly --project tsconfig.json --watch\" \"bun build src/index.ts --packages external --outdir out --watch\"" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/index.ts b/src/index.ts index 4d3f626..67e42d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,4 +14,5 @@ export { Message, type MessagePayload } from './types/message'; export { Embed } from './types/embed'; export { Emoji } from './types/emoji'; // Other -export { createCommandHandler } from './types/handler'; \ No newline at end of file +export { createCommandHandler } from './types/handler'; +export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand'; \ No newline at end of file diff --git a/src/services/processor.ts b/src/services/processor.ts index 660fe99..72cf459 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -3,7 +3,7 @@ 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 type { API } from "./api"; -import { ApplicationCommandType, type ApplicationCommandOption, type ApplicationCommandOptionResult } from "../types/applicationCommand"; +import { ApplicationCommandOptionType, ApplicationCommandType, type ApplicationCommandOption } from "../types/applicationCommand"; import type { Emitter } from "./bus"; export class Processor { @@ -59,10 +59,19 @@ export class Processor { for (const handler of handlers) { if (Processor.isCommandHandler(handler)) { // TODO: Enforce this? - // if (type === HandlerType.Events) { - // throw new Error('Event handlers cannot be a CommandHandler'); - // } - const resolvedArgs = await this.validateAndResolveArgs(argsOrPayload, handler.args); + 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); await Promise.resolve(handler[Trait.execute](context!, resolvedArgs)); } else { // TODO: Fix ts error @@ -73,27 +82,47 @@ export class Processor { } } - private validateAndResolveArgs = (args: ApplicationCommandOptionResult[], definition: T): Promise> => { + private validateAndResolveArgs = async (args: { value: any }[], definition: T): Promise> => { // Zip the args and definition together - const zipped: [ApplicationCommandOption, ApplicationCommandOptionResult][] = args.map((arg, i) => [definition[i], arg]); + const zipped: [ApplicationCommandOption, any][] = args.map((arg, i) => [definition[i], arg?.value ?? null]); // Validate the args const validated: Record = {}; // TODO: Attempt some type coresion/resolution here (mostly for text commands) for (const [def, arg] of zipped) { if (def.choices) { // If the definition has choices, validate the arg against them - const choice = def.choices.find((choice: any) => choice.value === arg.value); + const choice = def.choices.find((choice: any) => choice.value === arg); if (!choice) { - throw new Error(`Invalid choice for ${def.name}: expected one of ${def.choices.map((c: any) => c.value).join(', ')}, got ${arg.value}`); + throw new Error(`Invalid choice for ${def.name}: expected one of ${def.choices.map((c: any) => c.value).join(', ')}, got ${arg}`); } validated[def.name] = choice.value; + } else if (def.type === ApplicationCommandOptionType.User) { + // If the definition is a user, resolve the user + // Check if the arg is a string + if (typeof arg === 'string') { + // Parse out the user ID as a mention or raw ID + const userId = arg.match(/<@!?(\d+)>/)?.[1] ?? arg.match(/\d+/)?.[0]; + if (!userId) { + throw new Error(`Invalid user for ${def.name}: expected a user mention or snowflake id, got ${arg}`); + } + validated[def.name] = await this.client.users.get(userId); + } else if (typeof arg === 'object') { + // Check if the object has an ID + if (arg.id) { + validated[def.name] = await this.client.users.get(arg.id); + } else { + throw new Error(`Invalid user for ${def.name}: expected a user object with an ID, got ${arg}`); + } + } else { + throw new Error(`Invalid user for ${def.name}: expected a user mention, snowflake id, or object, got ${arg}`); + } } else { // Otherwise just check the type if (arg.value instanceof OptionConstructorMap[def.type]) { // TODO: Coerce the type if possible throw new Error(`Invalid type for ${def.name}: expected ${def.type}, got ${typeof arg}`); } else { - validated[def.name] = arg.value; + validated[def.name] = arg; } } } @@ -139,7 +168,6 @@ export class Processor { // Register the command with the API const command: Record = handler.toJSON(); command.name = n; - command.description = handler.description; command.type = type; this.api.post(`/applications/${this.client.application_id}/commands`, command); } diff --git a/src/types/applicationCommand.ts b/src/types/applicationCommand.ts index 53dd545..ef2ebaf 100644 --- a/src/types/applicationCommand.ts +++ b/src/types/applicationCommand.ts @@ -23,6 +23,13 @@ export enum ApplicationCommandOptionType { Attachment = 11, } +// Enum for Application Command Contexts +export enum ApplicationCommandContext { + Guild = 0, + DM = 1, + PrivateChannel = 2, +} + // Enum for Channel Types (if applicable for options) export enum ChannelType { GuildText = 0, @@ -87,7 +94,7 @@ export type DiscordApplicationCommand = { default_permission?: boolean; nsfw?: boolean; integration_types?: number[]; - contexts?: number[]; + contexts?: string; version: string; handler?: ApplicationCommandHandlerType; }; @@ -108,7 +115,7 @@ export class ApplicationCommand { default_permission?: boolean; nsfw?: boolean; integration_types?: number[]; - contexts?: number[]; + contexts?: ApplicationCommandContext[]; version: string; handler?: ApplicationCommandHandlerType; @@ -127,7 +134,7 @@ export class ApplicationCommand { this.default_permission = command.default_permission; this.nsfw = command.nsfw; this.integration_types = command.integration_types; - this.contexts = command.contexts; + this.contexts = command.contexts?.split(',').map((context) => parseInt(context) as ApplicationCommandContext); this.version = command.version; this.handler = command.handler; } @@ -135,4 +142,26 @@ export class ApplicationCommand { static [Trait.fromDiscord](command: DiscordApplicationCommand): ApplicationCommand { return new ApplicationCommand(command); } + + public toJSON = (): DiscordApplicationCommand => { + return { + id: this.id, + type: this.type, + application_id: this.application_id, + guild_id: this.guild_id, + name: this.name, + name_localizations: this.name_localizations, + description: this.description, + description_localizations: this.description_localizations, + options: this.options, + default_member_permissions: this.default_member_permissions, + dm_permission: this.dm_permission, + default_permission: this.default_permission, + nsfw: this.nsfw, + integration_types: this.integration_types, + contexts: this.contexts?.join(','), + version: this.version, + handler: this.handler + }; + } } \ No newline at end of file diff --git a/src/types/context.ts b/src/types/context.ts index 651a68f..88a4812 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -102,10 +102,6 @@ export class Context { * @returns The message that was sent */ public reply = async (message: Message | string, reference: boolean = true): Promise => { - if (isNil(this.message)) { - throw new Error('Cannot reply without a message'); - } - if (typeof message === 'string') { message = new Message().setContent(message); } @@ -159,7 +155,7 @@ export class Context { if (content) { clone.setContent(content); } - return Message[fromDiscord](await this.api.patch(`/channels/${clone.channel_id}/messages/${clone.id}`, clone)); + return Message.fromDiscord(await this.api.patch(`/channels/${clone.channel_id}/messages/${clone.id}`, clone)); }; // Interaction methods diff --git a/src/types/embed.ts b/src/types/embed.ts index e771c25..15a2b44 100644 --- a/src/types/embed.ts +++ b/src/types/embed.ts @@ -13,6 +13,13 @@ type EmbedImage = { width?: number; }; +type EmbedAuthor = { + name: string; + url?: string; + icon_url?: string; + proxy_icon_url?: string; +}; + type EmbedVideo = { url?: string; height?: number; @@ -98,7 +105,7 @@ export class Embed { return this; }; - setAuthor = (author: EmbedImage & { name?: string }) => { + setAuthor = (author: EmbedAuthor) => { this.author = author; return this; }; diff --git a/src/types/handler.ts b/src/types/handler.ts index a4012e9..0c33797 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -1,6 +1,7 @@ -import { ApplicationCommandOptionType, type ApplicationCommandOption } from "./applicationCommand"; +import { ApplicationCommandContext, ApplicationCommandOptionType, type ApplicationCommandOption } from "./applicationCommand"; import { Trait, type EventPayload } from "./common"; import type { Context } from "./context"; +import type { User } from "./user"; export const OptionConstructorMap = { [ApplicationCommandOptionType.String]: String, @@ -21,7 +22,7 @@ export type OptionTypeMap = { [ApplicationCommandOptionType.Integer]: number, [ApplicationCommandOptionType.Boolean]: boolean, [ApplicationCommandOptionType.Number]: number, - [ApplicationCommandOptionType.User]: string, // Could be a user ID or object depending on your use case + [ApplicationCommandOptionType.User]: User, // Could be a user ID or object depending on your use case [ApplicationCommandOptionType.Channel]: string, // Could be a channel ID or object [ApplicationCommandOptionType.Role]: string, // Could be a role ID or object [ApplicationCommandOptionType.Mentionable]: string, // Could be a mentionable ID or object @@ -47,17 +48,19 @@ export type ArgsFromOptions = { : unknown | undefined; }; -export type CommandHandlerExecuteFunction = (context: Context, args: ArgsFromOptions) => Promise | void; +export type CommandHandlerExecuteFunction = (context: Context, args: ArgsFromOptions) => Promise | any; export type CommandHandlerInput = { args: T; description: string; + contexts?: ApplicationCommandContext[]; execute: CommandHandlerExecuteFunction; }; export class CommandHandler { args: T; description: string; + contexts?: ApplicationCommandContext[]; [Trait.execute]!: CommandHandlerExecuteFunction; get execute() { @@ -78,12 +81,15 @@ export class CommandHandler { } this.args = input.args; this.description = input.description + this.contexts = input.contexts; this[Trait.execute] = input.execute; } toJSON() { return { options: this.args, + description: this.description, + contexts: this.contexts, }; } }; diff --git a/src/types/user.ts b/src/types/user.ts index 6df7db6..bf50e5d 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,3 +1,4 @@ +import { exists } from "../utils"; import { GatewayObject } from "./base"; import { Trait } from "./common"; @@ -17,6 +18,15 @@ export class User extends GatewayObject { email?: string; // TODO: Add the rest of the properties + get avatarURL() { + // Check if avatar is an animated gif + if (this.avatar?.startsWith('a_')) { + return `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.gif`; + } else { + return `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.png`; + } + } + constructor(data: Partial & { id: string; username: string; discriminator: string; display_name: string; avatar: string }) { super(); this.id = data.id; diff --git a/src/utils/index.ts b/src/utils/index.ts index 3a67962..b65de19 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,12 @@ export const isNil = (value: any): value is null | undefined => value === null || value === undefined; -export const exists = (value: T | null | undefined): value is T => !isNil(value); +export const exists = (value: T | null | undefined): value is NonNullable => !isNil(value); export const isObject = (value: any): value is Record => typeof value === 'object' && !Array.isArray(value); -export const log = (...args: any[]) => console.log('\x1b[35m[discord.ts]\x1b[0m', ...args); \ No newline at end of file +export const log = (...args: any[]) => { + const LOG_LEVEL = process.env.DISCORD_TS_LOG_LEVEL?.toLocaleLowerCase(); + + if (LOG_LEVEL === 'debug') { + const timestamp = `\x1b[37m[${new Date().toLocaleString()}]\x1b[0m`; + const prefix = `\x1b[35m[discord.ts]`; + console.log(`${timestamp} ${prefix} \x1b[0m`, ...args); + } +}; \ No newline at end of file