mirror of
https://github.com/jaronpate/unicord.git
synced 2026-10-18 21:47:40 +00:00
Added user type resolution. Fixed bug in arg validation. Small polish here and there
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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';
|
||||
export { createCommandHandler } from './types/handler';
|
||||
export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand';
|
||||
+39
-11
@@ -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 = <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
|
||||
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<string, any> = {};
|
||||
// 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<string, any> = handler.toJSON();
|
||||
command.name = n;
|
||||
command.description = handler.description;
|
||||
command.type = type;
|
||||
this.api.post(`/applications/${this.client.application_id}/commands`, command);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,6 @@ export class Context<D extends ContextData = ContextData> {
|
||||
* @returns The message that was sent
|
||||
*/
|
||||
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') {
|
||||
message = new Message().setContent(message);
|
||||
}
|
||||
@@ -159,7 +155,7 @@ export class Context<D extends ContextData = ContextData> {
|
||||
if (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
|
||||
|
||||
+8
-1
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<T extends readonly ApplicationCommandOption[]> = {
|
||||
: 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[]> = {
|
||||
args: T;
|
||||
description: string;
|
||||
contexts?: ApplicationCommandContext[];
|
||||
execute: CommandHandlerExecuteFunction<T>;
|
||||
};
|
||||
|
||||
export class CommandHandler<T extends readonly ApplicationCommandOption[]> {
|
||||
args: T;
|
||||
description: string;
|
||||
contexts?: ApplicationCommandContext[];
|
||||
[Trait.execute]!: CommandHandlerExecuteFunction<T>;
|
||||
|
||||
get execute() {
|
||||
@@ -78,12 +81,15 @@ export class CommandHandler<T extends readonly ApplicationCommandOption[]> {
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<User> & { id: string; username: string; discriminator: string; display_name: string; avatar: string }) {
|
||||
super();
|
||||
this.id = data.id;
|
||||
|
||||
+10
-2
@@ -1,4 +1,12 @@
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user