Added user type resolution. Fixed bug in arg validation. Small polish here and there

This commit is contained in:
2024-12-30 16:00:24 -06:00
parent 3bd0a0899b
commit 57d86e0e1c
9 changed files with 113 additions and 27 deletions
+2 -1
View File
@@ -12,7 +12,8 @@
"build:nocheck": "bun run build:types:nocheck && bun build src/index.ts --outdir out --noCheck", "build:nocheck": "bun run build:types:nocheck && bun build src/index.ts --outdir out --noCheck",
"build:types": "tsc --emitDeclarationOnly --project tsconfig.json", "build:types": "tsc --emitDeclarationOnly --project tsconfig.json",
"build:types:nocheck": "tsc --emitDeclarationOnly --project tsconfig.json --noCheck", "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": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
+1
View File
@@ -15,3 +15,4 @@ export { Embed } from './types/embed';
export { Emoji } from './types/emoji'; export { Emoji } from './types/emoji';
// Other // Other
export { createCommandHandler } from './types/handler'; export { createCommandHandler } from './types/handler';
export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand';
+39 -11
View File
@@ -3,7 +3,7 @@ import type { Context } from "../types/context";
import { HandlerType, Trait, type EventPayload } from "../types/common"; 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 } from "../types/handler";
import type { API } from "./api"; 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"; import type { Emitter } from "./bus";
export class Processor { export class Processor {
@@ -59,10 +59,19 @@ export class Processor {
for (const handler of handlers) { for (const handler of handlers) {
if (Processor.isCommandHandler(handler)) { if (Processor.isCommandHandler(handler)) {
// TODO: Enforce this? // TODO: Enforce this?
// if (type === HandlerType.Events) { if (type === HandlerType.Events) {
// throw new Error('Event handlers cannot be a CommandHandler'); throw new Error('Event handlers cannot be a CommandHandler');
// } }
const resolvedArgs = await this.validateAndResolveArgs(argsOrPayload, handler.args); 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)); await Promise.resolve(handler[Trait.execute](context!, resolvedArgs));
} else { } else {
// TODO: Fix ts error // TODO: Fix ts error
@@ -73,27 +82,47 @@ export class Processor {
} }
} }
private validateAndResolveArgs = <T extends readonly ApplicationCommandOption[]>(args: ApplicationCommandOptionResult[], definition: T): Promise<ArgsFromOptions<T>> => { private validateAndResolveArgs = async <T extends readonly ApplicationCommandOption[]>(args: { value: any }[], definition: T): Promise<ArgsFromOptions<T>> => {
// Zip the args and definition together // 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 // Validate the args
const validated: Record<string, any> = {}; const validated: Record<string, any> = {};
// TODO: Attempt some type coresion/resolution here (mostly for text commands) // TODO: Attempt some type coresion/resolution here (mostly for text commands)
for (const [def, arg] of zipped) { for (const [def, arg] of zipped) {
if (def.choices) { if (def.choices) {
// If the definition has choices, validate the arg against them // 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) { 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; 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 { } else {
// Otherwise just check the type // Otherwise just check the type
if (arg.value instanceof OptionConstructorMap[def.type]) { if (arg.value instanceof OptionConstructorMap[def.type]) {
// TODO: Coerce the type if possible // TODO: Coerce the type if possible
throw new Error(`Invalid type for ${def.name}: expected ${def.type}, got ${typeof arg}`); throw new Error(`Invalid type for ${def.name}: expected ${def.type}, got ${typeof arg}`);
} else { } else {
validated[def.name] = arg.value; validated[def.name] = arg;
} }
} }
} }
@@ -139,7 +168,6 @@ export class Processor {
// Register the command with the API // Register the command with the API
const command: Record<string, any> = handler.toJSON(); const command: Record<string, any> = handler.toJSON();
command.name = n; command.name = n;
command.description = handler.description;
command.type = type; command.type = type;
this.api.post(`/applications/${this.client.application_id}/commands`, command); this.api.post(`/applications/${this.client.application_id}/commands`, command);
} }
+32 -3
View File
@@ -23,6 +23,13 @@ export enum ApplicationCommandOptionType {
Attachment = 11, Attachment = 11,
} }
// Enum for Application Command Contexts
export enum ApplicationCommandContext {
Guild = 0,
DM = 1,
PrivateChannel = 2,
}
// Enum for Channel Types (if applicable for options) // Enum for Channel Types (if applicable for options)
export enum ChannelType { export enum ChannelType {
GuildText = 0, GuildText = 0,
@@ -87,7 +94,7 @@ export type DiscordApplicationCommand = {
default_permission?: boolean; default_permission?: boolean;
nsfw?: boolean; nsfw?: boolean;
integration_types?: number[]; integration_types?: number[];
contexts?: number[]; contexts?: string;
version: string; version: string;
handler?: ApplicationCommandHandlerType; handler?: ApplicationCommandHandlerType;
}; };
@@ -108,7 +115,7 @@ export class ApplicationCommand {
default_permission?: boolean; default_permission?: boolean;
nsfw?: boolean; nsfw?: boolean;
integration_types?: number[]; integration_types?: number[];
contexts?: number[]; contexts?: ApplicationCommandContext[];
version: string; version: string;
handler?: ApplicationCommandHandlerType; handler?: ApplicationCommandHandlerType;
@@ -127,7 +134,7 @@ export class ApplicationCommand {
this.default_permission = command.default_permission; this.default_permission = command.default_permission;
this.nsfw = command.nsfw; this.nsfw = command.nsfw;
this.integration_types = command.integration_types; 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.version = command.version;
this.handler = command.handler; this.handler = command.handler;
} }
@@ -135,4 +142,26 @@ export class ApplicationCommand {
static [Trait.fromDiscord](command: DiscordApplicationCommand): ApplicationCommand { static [Trait.fromDiscord](command: DiscordApplicationCommand): ApplicationCommand {
return new ApplicationCommand(command); 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
};
}
} }
+1 -5
View File
@@ -102,10 +102,6 @@ export class Context<D extends ContextData = ContextData> {
* @returns The message that was sent * @returns The message that was sent
*/ */
public reply = async (message: Message | string, reference: boolean = true): Promise<MessagePayload> => { public reply = async (message: Message | string, reference: boolean = true): Promise<MessagePayload> => {
if (isNil(this.message)) {
throw new Error('Cannot reply without a message');
}
if (typeof message === 'string') { if (typeof message === 'string') {
message = new Message().setContent(message); message = new Message().setContent(message);
} }
@@ -159,7 +155,7 @@ export class Context<D extends ContextData = ContextData> {
if (content) { if (content) {
clone.setContent(content); clone.setContent(content);
} }
return Message[fromDiscord](await this.api.patch<DiscordMessage>(`/channels/${clone.channel_id}/messages/${clone.id}`, clone)); return Message.fromDiscord(await this.api.patch<DiscordMessage>(`/channels/${clone.channel_id}/messages/${clone.id}`, clone));
}; };
// Interaction methods // Interaction methods
+8 -1
View File
@@ -13,6 +13,13 @@ type EmbedImage = {
width?: number; width?: number;
}; };
type EmbedAuthor = {
name: string;
url?: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedVideo = { type EmbedVideo = {
url?: string; url?: string;
height?: number; height?: number;
@@ -98,7 +105,7 @@ export class Embed {
return this; return this;
}; };
setAuthor = (author: EmbedImage & { name?: string }) => { setAuthor = (author: EmbedAuthor) => {
this.author = author; this.author = author;
return this; return this;
}; };
+9 -3
View File
@@ -1,6 +1,7 @@
import { ApplicationCommandOptionType, type ApplicationCommandOption } from "./applicationCommand"; import { ApplicationCommandContext, ApplicationCommandOptionType, type ApplicationCommandOption } from "./applicationCommand";
import { Trait, type EventPayload } from "./common"; import { Trait, type EventPayload } from "./common";
import type { Context } from "./context"; import type { Context } from "./context";
import type { User } from "./user";
export const OptionConstructorMap = { export const OptionConstructorMap = {
[ApplicationCommandOptionType.String]: String, [ApplicationCommandOptionType.String]: String,
@@ -21,7 +22,7 @@ export type OptionTypeMap = {
[ApplicationCommandOptionType.Integer]: number, [ApplicationCommandOptionType.Integer]: number,
[ApplicationCommandOptionType.Boolean]: boolean, [ApplicationCommandOptionType.Boolean]: boolean,
[ApplicationCommandOptionType.Number]: number, [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.Channel]: string, // Could be a channel ID or object
[ApplicationCommandOptionType.Role]: string, // Could be a role ID or object [ApplicationCommandOptionType.Role]: string, // Could be a role ID or object
[ApplicationCommandOptionType.Mentionable]: string, // Could be a mentionable ID or object [ApplicationCommandOptionType.Mentionable]: string, // Could be a mentionable ID or object
@@ -47,17 +48,19 @@ export type ArgsFromOptions<T extends readonly ApplicationCommandOption[]> = {
: unknown | undefined; : unknown | undefined;
}; };
export type CommandHandlerExecuteFunction<T extends readonly ApplicationCommandOption[]> = (context: Context, args: ArgsFromOptions<T>) => Promise<void> | void; export type CommandHandlerExecuteFunction<T extends readonly ApplicationCommandOption[]> = (context: Context, args: ArgsFromOptions<T>) => Promise<any> | any;
export type CommandHandlerInput<T extends readonly ApplicationCommandOption[]> = { export type CommandHandlerInput<T extends readonly ApplicationCommandOption[]> = {
args: T; args: T;
description: string; description: string;
contexts?: ApplicationCommandContext[];
execute: CommandHandlerExecuteFunction<T>; execute: CommandHandlerExecuteFunction<T>;
}; };
export class CommandHandler<T extends readonly ApplicationCommandOption[]> { export class CommandHandler<T extends readonly ApplicationCommandOption[]> {
args: T; args: T;
description: string; description: string;
contexts?: ApplicationCommandContext[];
[Trait.execute]!: CommandHandlerExecuteFunction<T>; [Trait.execute]!: CommandHandlerExecuteFunction<T>;
get execute() { get execute() {
@@ -78,12 +81,15 @@ export class CommandHandler<T extends readonly ApplicationCommandOption[]> {
} }
this.args = input.args; this.args = input.args;
this.description = input.description this.description = input.description
this.contexts = input.contexts;
this[Trait.execute] = input.execute; this[Trait.execute] = input.execute;
} }
toJSON() { toJSON() {
return { return {
options: this.args, options: this.args,
description: this.description,
contexts: this.contexts,
}; };
} }
}; };
+10
View File
@@ -1,3 +1,4 @@
import { exists } from "../utils";
import { GatewayObject } from "./base"; import { GatewayObject } from "./base";
import { Trait } from "./common"; import { Trait } from "./common";
@@ -17,6 +18,15 @@ export class User extends GatewayObject {
email?: string; email?: string;
// TODO: Add the rest of the properties // 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<User> & { id: string; username: string; discriminator: string; display_name: string; avatar: string }) { constructor(data: Partial<User> & { id: string; username: string; discriminator: string; display_name: string; avatar: string }) {
super(); super();
this.id = data.id; this.id = data.id;
+10 -2
View File
@@ -1,4 +1,12 @@
export const isNil = (value: any): value is null | undefined => value === null || value === undefined; export const isNil = (value: any): value is null | undefined => value === null || value === undefined;
export const exists = <T>(value: T | null | undefined): value is T => !isNil(value); export const exists = <T>(value: T | null | undefined): value is NonNullable<T> => !isNil(value);
export const isObject = (value: any): value is Record<string, any> => typeof value === 'object' && !Array.isArray(value); export const isObject = (value: any): value is Record<string, any> => typeof value === 'object' && !Array.isArray(value);
export const log = (...args: any[]) => console.log('\x1b[35m[discord.ts]\x1b[0m', ...args); 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);
}
};