diff --git a/bun.lockb b/bun.lockb index f91cde19..3a7b2c54 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..1366d686 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,11 @@ +[install] + +# whether to install optionalDependencies +optional = true + +# whether to install devDependencies +dev = true + +# whether to install peerDependencies +peer = true + diff --git a/packages/gil/__tests__/bot_mongo/commands/Help.ts b/packages/gil/__tests__/bot_mongo/commands/Help.ts new file mode 100644 index 00000000..45776ee3 --- /dev/null +++ b/packages/gil/__tests__/bot_mongo/commands/Help.ts @@ -0,0 +1,34 @@ +import { Command, CommandExecuteContext, GilClient } from "../../../lib"; + +export default class Help extends Command { + public constructor(client: GilClient) { + super(client, { + name: "help", + description: "Shows this help message.", + aliases: ["h"], + args: [ + { + name: "command", + optional: true, + type: "string", + }, + ], + }); + } + + public async execute(ctx: CommandExecuteContext<{ command: string }>) { + const { command } = ctx.args; + + if (command) { + const getCommand = this.gil.commands.getCommand(command); + if (!getCommand) { + return ctx.message.reply("Command not found."); + } + + return ctx.message.reply(`Help for ${getCommand.options.name}`); + } + + const allCommands = this.gil.commands.commands.map((command) => command.options.name); + return ctx.message.reply(`All commands: ${allCommands.join(", ")}`); + } +} diff --git a/packages/gil/__tests__/bot_mongo/commands/Ping.ts b/packages/gil/__tests__/bot_mongo/commands/Ping.ts new file mode 100644 index 00000000..5281aae0 --- /dev/null +++ b/packages/gil/__tests__/bot_mongo/commands/Ping.ts @@ -0,0 +1,12 @@ +import { Command } from "../../../lib"; + +export default class Ping extends Command { + options = { + name: "ping", + description: "Tests the bot.", + }; + + public async execute() { + return "Pong!"; + } +} diff --git a/packages/gil/__tests__/bot_mongo/db/Server.ts b/packages/gil/__tests__/bot_mongo/db/Server.ts new file mode 100644 index 00000000..05efdc46 --- /dev/null +++ b/packages/gil/__tests__/bot_mongo/db/Server.ts @@ -0,0 +1,21 @@ +import mongoose, { Schema, Document } from "mongoose"; + +// This is for typescript +export interface IServer extends Document { + server_id: string; + prefix: string; + premium_level: string; + staff_roles: string[]; +} + +// This is for mongodb to know what we are storing +const ServerSchema: Schema = new Schema({ + server_id: { type: String, required: true }, + prefix: { type: String, required: false }, + premium_level: { type: String, required: false, default: null }, + staff_roles: { type: Array, required: false, default: [] }, +}); + +const Server = mongoose.model("Server", ServerSchema); + +export default Server; diff --git a/packages/gil/__tests__/bot_mongo/docker-compose.yaml b/packages/gil/__tests__/bot_mongo/docker-compose.yaml new file mode 100644 index 00000000..97a2ddf6 --- /dev/null +++ b/packages/gil/__tests__/bot_mongo/docker-compose.yaml @@ -0,0 +1,15 @@ +version: '3' +services: + mongo: + image: mongo:4.2.5 + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: guildedjs + MONGO_INITDB_ROOT_PASSWORD: testpass + MONGO_INITDB_DATABASE: guildedjs + volumes: + - mongo-vol:/data/db + +volumes: + mongo-vol: diff --git a/packages/gil/__tests__/bot_mongo/index.ts b/packages/gil/__tests__/bot_mongo/index.ts new file mode 100644 index 00000000..af6fb753 --- /dev/null +++ b/packages/gil/__tests__/bot_mongo/index.ts @@ -0,0 +1,28 @@ +import { join } from "path"; +import "dotenv/config"; +import { GilClient } from "../../lib/GilClient"; +import { MongoAdapter } from "../../lib/adapters/db/MongoAdapter"; + +import mongoose from "mongoose"; +import Server from "./db/Server"; + +mongoose.connect("mongodb://guildedjs:testpass@localhost:27017/", { + connectTimeoutMS: 5000, + retryWrites: true, + retryReads: true, + waitQueueTimeoutMS: 5000, + socketTimeoutMS: 5000, +}); + +const YokiBot = new GilClient({ + token: process.env.TOKEN!, + commandDirectory: join(__dirname, "commands"), + listenerDirectory: join(__dirname, "listeners"), + databaseAdapter: new MongoAdapter({ + serverModel: Server, + serverIdKey: "server_id", + serverStaffRolesKey: "staff_roles", + }), +}); + +YokiBot.start(); diff --git a/packages/gil/lib/BotClient.ts b/packages/gil/lib/BotClient.ts deleted file mode 100644 index 58770c74..00000000 --- a/packages/gil/lib/BotClient.ts +++ /dev/null @@ -1,450 +0,0 @@ -import path from "node:path"; -import { Collection } from "@discordjs/collection"; -import { bgBlue, bgGreen, bgYellow, black } from "colorette"; -import type { Message } from "guilded.js"; -import { Client } from "guilded.js"; -import type { Cooldown } from "./inhibitors/cooldown"; -import type { Argument } from "./structures/Argument"; -import type { Command } from "./structures/Command"; -import type { Inhibitor } from "./structures/Inhibitor"; -import type { Monitor } from "./structures/Monitor"; -import type Task from "./structures/Task"; -import { walk } from "./utils/walk"; - -export class BotClient extends Client { - /** - * All your bot's monitors will be available here - */ - monitors = new Collection(); - - /** - * All your bot's commands will be available here - */ - commands = new Collection(); - - /** - * All your bot's arguments will be available here - */ - arguments = new Collection(); - - /** - * All your bot's inhibitors will be available here - */ - inhibitors = new Collection(); - - /** - * All your bot's tasks will be available here - */ - tasks = new Collection(); - - /** - * The bot's prefixes per server. - */ - prefixes = new Map(); - - /** - * The message collectors that are pending. - */ - messageCollectors = new Collection(); - - /** - * The path that the end users commands,monitors, inhibitors and others will be located. - */ - sourceFolderPath = this.options.sourceFolderPath ?? path.join(process.cwd(), "src/"); - - constructor( - public options: BotClientOptions, - autoInit = true, - ) { - super(options); - if (autoInit) void this.init(); - } - - /** - * Get the default client prefix. - */ - get prefix(): string { - return this.options.prefix; - } - - /** - * Get the bot's mention. Guilded api does not provide a way to dynamically detect this so for now its manual. - */ - get botMention(): string | undefined { - return this.options.botMention; - } - - // biome-ignore lint/suspicious/noExplicitAny: - async loadFile(result: any, dir: string, collection: Collection): Promise { - const [filename, file] = result; - const { name } = path.parse(filename); - const piece = file.default ? new file.default(this, name) : new file(this, name); - - let cmd: Command | undefined; - if (dir === "commands" && piece.parentCommand) { - const subcommandNames = piece.parentCommand.split("-"); - for (const subname of subcommandNames) { - // LOOK FOR MAIN COMMAND - if (!cmd) { - const mainCmd = collection.get(subname); - if (mainCmd) { - cmd = mainCmd as Command; - continue; - } - throw new Error(`You tried to create a subcommand named ${piece.name}. However, the parent command, ${subname}, was not found.`); - } - - const subcmd = cmd?.subcommands?.get(subname) as Command; - if (subcmd) { - cmd = subcmd; - } else { - throw new Error(`You tried to create a subcommand named ${piece.name} inside the main command ${cmd.name}. However, the parent command, ${subname}, was not found.`); - } - } - } - - if (cmd) { - if (!cmd.subcommands) cmd.subcommands = new Collection(); - cmd.subcommands.set(piece.name ?? name, piece); - } else { - collection.set(piece.name ?? name, piece); - } - - if (piece.init) await piece.init(); - } - - /** - * Prepares the bot to run. Ideally used for loading files to the bot. - */ - async init(): Promise { - await Promise.allSettled( - [ - ["monitors", this.monitors] as const, - ["commands", this.commands] as const, - ["arguments", this.arguments] as const, - ["inhibitors", this.inhibitors] as const, - ["tasks", this.tasks] as const, - ].map(async ([dir, collection]) => { - try { - for await (const result of walk(path.join(__dirname, dir))) { - await this.loadFile(result, dir, collection); - } - } catch (error) { - console.log(error); - } - }), - ).catch(() => null); - - // Load all end user files - await Promise.allSettled( - [ - ["monitors", this.monitors] as const, - ["commands", this.commands] as const, - ["arguments", this.arguments] as const, - ["inhibitors", this.inhibitors] as const, - ["tasks", this.tasks] as const, - ].map(async ([dir, collection]) => { - try { - for await (const result of walk(this.options.monitorDirPath ?? path.join(this.sourceFolderPath, dir))) { - await this.loadFile(result, dir, collection); - } - } catch (error) { - console.log(error); - } - }), - ).catch(() => null); - - this.initializeMessageListener(); - this.initializeTasks(); - } - - /** - * Allows users to override and customize the addition of a event listener - */ - initializeMessageListener(): void { - this.on("messageCreated", (message) => this.processMonitors(message)); - } - - /** - * Allows users to override and customize the initialization of scheduled task intervals. - */ - initializeTasks(): void { - // biome-ignore lint/complexity/noForEach: Legacy code - this.tasks.forEach(async (task) => { - // TASKS THAT NEED TO RUN IMMEDIATELY ARE EXECUTED FIRST - if (task.runOnStartup) await this.executeTask(task); - - // SET TIMEOUT WILL DETERMINE THE RIGHT TIME TO RUN THE TASK FIRST TIME AFTER STARTUP - setTimeout(async () => { - await this.executeTask(task); - - setInterval(async () => { - await this.executeTask(task); - }, task.millisecondsInterval); - }, Date.now() % task.millisecondsInterval); - }); - } - - /** - * Handler to execute a task when it is time. - */ - async executeTask(task: Task): Promise { - // IF TASK REQUIRES BOT BEING FULLY READY EXIT OUT IF BOT ISN'T READY - if (task.requireReady && !this.readyTimestamp) return; - - console.log(`${bgBlue(`[${this.getTime()}]`)} [TASK: ${bgYellow(black(task.name))}] started.`); - const started = Date.now(); - try { - await task.execute(); - console.log(`${bgBlue(`[${this.getTime()}]`)} [TASK: ${bgGreen(black(task.name))}] executed in ${this.humanizeMilliseconds(Date.now() - started)}.`); - } catch (error) { - console.log(error); - } - } - - /** - * Handler that is run on messages and can - */ - processMonitors(message: Message): void { - for (const [id, monitor] of this.monitors) { - if (monitor.ignoreBots && message.createdByWebhookId) continue; - if (monitor.ignoreOthers && message.authorId !== this.user?.botId) continue; - if (monitor.ignoreEdits && message.updatedAt && message.updatedAt !== message.createdAt) continue; - // TODO: When the api supports using dm channels - // if (monitor.ignoreDM && !message.serverId) return; - void monitor.execute(message); - } - } - - /** - * Converts a number of milliseconds to a easy to read format(1d2h3m). - */ - humanizeMilliseconds(milliseconds: number): string { - // Gets ms into seconds - const time = milliseconds / 1_000; - if (time < 1) return "1s"; - - const days = Math.floor(time / 86_400); - const hours = Math.floor((time % 86_400) / 3_600); - const minutes = Math.floor(((time % 86_400) % 3_600) / 60); - const seconds = Math.floor(((time % 86_400) % 3_600) % 60); - - const dayString = days ? `${days}d ` : ""; - const hourString = hours ? `${hours}h ` : ""; - const minuteString = minutes ? `${minutes}m ` : ""; - const secondString = seconds ? `${seconds}s ` : ""; - - return `${dayString}${hourString}${minuteString}${secondString}`; - } - - /** - * Converts a text form(1d2h3m) of time to a number in milliseconds. - */ - stringToMilliseconds(text: string): number | undefined { - const matches = text.match(/(\d+[dhmsw|])/g); - if (!matches) return; - - let total = 0; - - for (const match of matches) { - // Finds the first of these letters - const validMatch = /([dhmsw])/.exec(match); - // if none of them were found cancel - if (!validMatch) return; - // Get the number which should be before the index of that match - const number = match.slice(0, Math.max(0, validMatch.index)); - // Get the letter that was found - const [letter] = validMatch; - if (!number ?? !letter) return; - - let multiplier = 1_000; - switch (letter.toLowerCase()) { - case "w": - multiplier = 1_000 * 60 * 60 * 24 * 7; - break; - case "d": - multiplier = 1_000 * 60 * 60 * 24; - break; - case "h": - multiplier = 1_000 * 60 * 60; - break; - case "m": - multiplier = 1_000 * 60; - break; - } - - const amount = number ? Number.parseInt(number, 10) : undefined; - if (!amount && amount !== 0) return; - - total += amount * multiplier; - } - - return total; - } - - /** - * Request some message(s) from a user in a channel. - */ - async needMessage( - userId: string, - channelId: string, - options?: MessageCollectorOptions & { - amount?: 1; - }, - ): Promise; - async needMessage( - userId: string, - channelId: string, - options: MessageCollectorOptions & { - amount?: number; - }, - ): Promise; - async needMessage(userId: string, channelId: string, options?: MessageCollectorOptions): Promise { - const messages = await this.collectMessages({ - key: userId, - channelId, - createdAt: Date.now(), - filter: options?.filter ?? ((msg): boolean => userId === msg.createdById), - amount: options?.amount ?? 1, - // DEFAULTS TO 5 MINUTES - duration: options?.duration ?? 300_000, - }); - - return (options?.amount ?? 1) > 1 ? messages : messages[0]; - } - - /** - * Handler that will create a collecetor internally. Users should be using needMessage. - */ - async collectMessages(options: CollectMessagesOptions): Promise { - return new Promise((resolve, reject) => { - this.messageCollectors.get(options.key)?.reject("A new collector began before the user responded to the previous one."); - - this.messageCollectors.set(options.key, { - ...options, - messages: [], - resolve, - reject, - }); - }); - } - - /** - * Get a clean string form of the current time. For example: 12:00PM - */ - getTime(): string { - const now = new Date(); - const hours = now.getHours(); - const minute = now.getMinutes(); - - let hour = hours; - let amOrPm = "AM"; - if (hour > 12) { - amOrPm = "PM"; - hour -= 12; - } - - return `${hour >= 10 ? hour : `0${hour}`}:${minute >= 10 ? minute : `0${minute}`} ${amOrPm}`; - } - - /** - * Handler that is executed when a user is using a command too fast and goes into cooldown. Override this to customize the behavior. - */ - async cooldownReached(message: Message, command: Command, options: RespondToCooldownOption): Promise { - return message.reply(`You must wait **${this.humanizeMilliseconds(options.cooldown.timestamp - options.now)}** before using the *${command.fullName}* command again.`); - } -} - -// export interface BotClientOptions extends ClientOptions { -export type BotClientOptions = { - /** - * The bot mention. Most likely @botname This is required as Guilded does not currently give any way to dynamically detect the mention. - */ - botMention?: string; - /** - * The path to a custom dir where commands are located. - */ - commandDirPath?: string; - /** - * The path to a custom dir where the inhibitors are located - */ - inhibitorDirPath?: string; - /** - * The path to a custom dir where the monitors are located - */ - monitorDirPath?: string; - /** - * The prefix that will be used to determine if a message is executing a command. - */ - prefix: string; - /** - * The path that the end users commands, monitors, inhibitors and others will be located. - */ - sourceFolderPath?: string; - // TODO: THESE ARE REMOVED WHEN EXTENDS IS fixed - token: string; -}; - -export type MessageCollectorOptions = { - /** - * The amount of messages to collect before resolving. Defaults to 1 - */ - amount?: number; - /** - * The amount of milliseconds this should collect for before expiring. Defaults to 5 minutes. - */ - duration?: number; - /** - * Function that will filter messages to determine whether to collect this message. Defaults to making sure the message is sent by the same member. - */ - filter?(message: Message): boolean; -}; - -export type CollectMessagesOptions = { - /** - * The amount of messages to collect before resolving. - */ - amount: number; - /** - * The channel Id where this is listening to - */ - channelId: string; - /** - * The timestamp when this collector was created - */ - createdAt: number; - /** - * The duration in milliseconds how long this collector should last. - */ - duration: number; - /** - * Function that will filter messages to determine whether to collect this message - */ - filter(message: Message): boolean; - /** - * The unique key that will be used to get responses for this. Ideally, meant to be for member id. - */ - key: string; -}; - -export type GilMessageCollector = CollectMessagesOptions & { - /** - * Where the messages are stored if the amount to collect is more than 1. - */ - messages: Message[]; - reject(reason?: unknown): void; - resolve(value: Message[] | PromiseLike): void; -}; - -export default BotClient; - -export type RespondToCooldownOption = { - /** - * The cooldown details - */ - cooldown: Cooldown; - /** - * The timestamp right when the user used the command. - */ - now: number; -}; diff --git a/packages/gil/lib/GilClient.ts b/packages/gil/lib/GilClient.ts new file mode 100644 index 00000000..ee157d74 --- /dev/null +++ b/packages/gil/lib/GilClient.ts @@ -0,0 +1,62 @@ +import EventEmitter from "node:events"; +import { Client, ClientOptions } from "guilded.js"; +import TypedEmitter from "typed-emitter"; +import { DatabaseAdapter } from "./adapters/db/DatabaseAdapter"; +import { ConsoleAdapter } from "./adapters/logging/ConsoleAdapter"; +import { LoggerAdapter } from "./adapters/logging/LoggerAdapter"; +import { ListenerManager } from "./defaults/ListenerManager"; +import { GilEvents } from "./events"; +import { CommandManager } from "./structures/Command"; +import { TaskManager } from "./structures/Task"; + +interface GilClientOptions { + token: string; + clientOptions?: ClientOptions; + customContext?: unknown; + // adapters + loggingAdapter?: LoggerAdapter; + databaseAdapter: DatabaseAdapter; + // dirs + taskDirectory?: string; + commandDirectory: string; + listenerDirectory?: string; + // other + operators?: string[]; + premiumPrioritys?: string[]; +} +export class GilClient { + public readonly client = new Client({ + ...this.options.clientOptions, + token: this.options.token, + }); + public readonly emitter = new EventEmitter() as TypedEmitter; + public readonly logger = this.options.loggingAdapter ?? new ConsoleAdapter(); + public readonly db = this.options.databaseAdapter; + + public readonly commands = new CommandManager(this); + public readonly listeners = new ListenerManager(this); + public readonly tasks = new TaskManager(this); + + public constructor(public options: GilClientOptions) { + if (!options.token) throw new Error("No token provided"); + if (!options.token.startsWith("gapi_")) throw new Error("Invalid token provided"); + } + + public async start() { + await this.tasks.init(); + await this.listeners.init(); + await this.commands.init(); + this.hookClientInternals(); + + this.logger.info("Starting client..."); + await this.client.login(); + } + + private hookClientInternals() { + this.client.ws.emitter.on("error", (reason, err, data) => { + console.log(reason, err, data); + }); + this.client.ws.emitter.on("exit", this.logger.warn); + // this.client.ws.emitter.on("debug", this.logger.debug); + } +} diff --git a/packages/gil/lib/adapters/db/DatabaseAdapter.ts b/packages/gil/lib/adapters/db/DatabaseAdapter.ts new file mode 100644 index 00000000..26a1a773 --- /dev/null +++ b/packages/gil/lib/adapters/db/DatabaseAdapter.ts @@ -0,0 +1,23 @@ +export abstract class DatabaseAdapter { + public abstract getServer(serverId: string): Promise; + public abstract createServer(serverId: string): Promise; + public abstract getRoles(serverId: string): Promise; +} + +export interface StoredServer { + server_id: string; + prefix: string | null; + premium_level: string; +} + +export interface StoredRole { + role_id: string; + server_id: string; + type: StoredRoleType; +} + +export enum StoredRoleType { + Minimod = "minimod", + Mod = "mod", + Admin = "admin", +} diff --git a/packages/gil/lib/adapters/db/MongoAdapter.ts b/packages/gil/lib/adapters/db/MongoAdapter.ts new file mode 100644 index 00000000..a5c77940 --- /dev/null +++ b/packages/gil/lib/adapters/db/MongoAdapter.ts @@ -0,0 +1,48 @@ +// @ts-ignore +import type mongoose from "mongoose"; +import { DatabaseAdapter, StoredRole, StoredServer } from "./DatabaseAdapter"; + +export class MongoAdapter extends DatabaseAdapter { + public constructor( + readonly options: { + serverModel: typeof mongoose.Model; + serverIdKey: string; + serverStaffRolesKey: string; + }, + ) { + super(); + } + + public async getServer(serverId: string): Promise { + const server = await this.options.serverModel.findOne({ + [this.options.serverIdKey]: serverId, + }); + if (!server) return null; + + return { + server_id: server[this.options.serverIdKey], + prefix: server.prefix, + premium_level: server.premium_level, + }; + } + + public async createServer(serverId: string): Promise { + const server = await this.options.serverModel.create({ + [this.options.serverIdKey]: serverId, + prefix: null, + premium_level: null, + }); + + return server; + } + + public async getRoles(serverId: string): Promise { + const server = await this.options.serverModel.findOne({ [this.options.serverIdKey]: serverId }); + if (!server) return []; + + if (!(this.options.serverStaffRolesKey in server)) return []; + const roles = server[this.options.serverStaffRolesKey as keyof typeof server] as StoredRole[]; + + return roles; + } +} diff --git a/packages/gil/lib/adapters/logging/ConsoleAdapter.ts b/packages/gil/lib/adapters/logging/ConsoleAdapter.ts new file mode 100644 index 00000000..d059a6b4 --- /dev/null +++ b/packages/gil/lib/adapters/logging/ConsoleAdapter.ts @@ -0,0 +1,20 @@ +import { LoggerAdapter } from "./LoggerAdapter"; + +export class ConsoleAdapter extends LoggerAdapter { + public error(error: Error): void { + console.error(`[ERROR] ${error}`); + } + + public warn(message: string): void { + console.warn(`[WARN] ${message}`); + } + + public info(message: string): void { + console.info(`[INFO] ${message}`); + } + + public debug(message: string, decorate?: string): void { + if (decorate) console.debug(`[DEBUG] ${decorate}: ${message}`); + else console.debug(`[DEBUG] ${message}`); + } +} diff --git a/packages/gil/lib/adapters/logging/LoggerAdapter.ts b/packages/gil/lib/adapters/logging/LoggerAdapter.ts new file mode 100644 index 00000000..1bca32f3 --- /dev/null +++ b/packages/gil/lib/adapters/logging/LoggerAdapter.ts @@ -0,0 +1,6 @@ +export abstract class LoggerAdapter { + public abstract error(error: Error, decorate?: string): void | Promise; + public abstract warn(message: string, decorate?: string): void | Promise; + public abstract info(message: string): void | Promise; + public abstract debug(message: string, decorate?: string): void | Promise; +} diff --git a/packages/gil/lib/adapters/logging/PinoAdapter.ts b/packages/gil/lib/adapters/logging/PinoAdapter.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/gil/lib/adapters/logging/WinstonAdapter.ts b/packages/gil/lib/adapters/logging/WinstonAdapter.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/gil/lib/arguments/...string.ts b/packages/gil/lib/arguments/...string.ts deleted file mode 100644 index dc958c92..00000000 --- a/packages/gil/lib/arguments/...string.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Argument } from "../structures/Argument"; -import type { CommandArgument } from "../structures/Command"; - -export class RemainingStringArgument extends Argument { - name = "...string"; - - execute(argument: CommandArgument, parameters: string[]): string | undefined { - if (!parameters.length) return; - - return argument.lowercase ? parameters.join(" ").toLowerCase() : parameters.join(" "); - } - - init(): void { - // shut up eslint - } -} - -export default RemainingStringArgument; diff --git a/packages/gil/lib/arguments/ArgumentParser.ts b/packages/gil/lib/arguments/ArgumentParser.ts new file mode 100644 index 00000000..ea755d98 --- /dev/null +++ b/packages/gil/lib/arguments/ArgumentParser.ts @@ -0,0 +1,81 @@ +import type { Channel, Message, PartialMember, Role } from "guilded.js"; +import { Command } from "../structures/Command"; + +import boolean from "./args/boolean"; +import channel from "./args/channel"; +import member from "./args/member"; +import number from "./args/number"; +import role from "./args/role"; +import string from "./args/string"; + +export type Result = ({ error: false } & T) | { error: true; reason_code: string }; +export type CommandArgument = string | number | boolean | PartialMember | Message | Channel | Role | null; +export type CommandArgumentType = "string" | "number" | "boolean" | "member" | "channel" | "role"; +export type CommandArgumentValidator = { + validate: (ctx: { + input: string; + rawArgs: string[]; + argument: CommandArgument; + message: Message; + argIndex: number; + mentionCounters: { users: number; roles: number; channels: number }; + }) => Result<{ argument: CommandArgument }> | Promise>; +}; + +const validators: Record = { + boolean, + string, + channel, + member, + number, + role, +}; +export async function convertArguments(params: { + args: string[]; + command: Command; + message: Message; +}): Promise }>> { + if (!params.command.options.args) return { error: false, arguments: {} }; + + const castedArguments: Record = {}; + const mentionCounters = { users: 0, roles: 0, channels: 0 }; + const commandArgs = params.command.options.args; + + for (let i = 0; i < commandArgs.length; i++) { + const currentArg = commandArgs[i]; + const currentInput = params.args[i]; + + if (currentArg.optional && params.args.length <= i) { + castedArguments[currentArg.name] = null; + continue; + } + + if (currentInput === undefined) { + if (currentArg.optional) { + castedArguments[currentArg.name] = null; + continue; + } + } + + const validator = validators[currentArg.type].validate; + const validation_run = await validator({ + mentionCounters, + rawArgs: params.args, + message: params.message, + argument: castedArguments[currentArg.name], + argIndex: i, + input: currentInput, + }); + + if (validation_run.error) { + return { error: true, reason_code: validation_run.reason_code }; + } + + castedArguments[currentArg.name] = validation_run.argument; + } + + return { + error: false, + arguments: castedArguments, + }; +} diff --git a/packages/gil/lib/arguments/args/boolean.ts b/packages/gil/lib/arguments/args/boolean.ts new file mode 100644 index 00000000..67d9ab36 --- /dev/null +++ b/packages/gil/lib/arguments/args/boolean.ts @@ -0,0 +1,21 @@ +import type { CommandArgumentValidator } from "../ArgumentParser"; + +const yesType = ["true", "enable", "yes"]; +const noType = ["disable", "false", "no"]; + +export default { + validate: ({ input }) => { + // if not a proper "yes/no" type input, notify the user + if (![...yesType, ...noType].includes(input.toLowerCase())) + return { + error: true, + reason_code: "INVALID_BOOLEAN", + }; + + // if the input is a truthy value, the argument will be set to true, otherwise false. + return { + error: false, + argument: yesType.includes(input.toLowerCase()), + }; + }, +} satisfies CommandArgumentValidator; diff --git a/packages/gil/lib/arguments/args/channel.ts b/packages/gil/lib/arguments/args/channel.ts new file mode 100644 index 00000000..6ea4eb35 --- /dev/null +++ b/packages/gil/lib/arguments/args/channel.ts @@ -0,0 +1,34 @@ +import { isUUID } from "../../utils/uuid"; +import type { CommandArgumentValidator } from "../ArgumentParser"; + +export default { + validate: async ({ input, mentionCounters, message, rawArgs, argIndex }) => { + if (input.startsWith("#")) { + const mention = message.mentions?.channels?.[mentionCounters.channels++]; + if (!mention) + return { + error: true, + reason_code: "NO_CHANNEL_IN_MENTIONS", + }; + + const channel = await message.client.channels.fetch(mention.id).catch(() => null); + if (!channel) return { error: true, reason_code: "CHANNEL_NOT_FOUND" }; + + const spaceCount = channel.name.split(" ").length; + // [..., "#super", "cool", "channel", ...] => [..., "#super cool channel", ...] + rawArgs.splice(argIndex + 1, spaceCount - 1); + rawArgs[argIndex] = channel.name; + + return { error: false, argument: channel }; + } + + if (isUUID(input)) { + const channel = await message.client.channels.fetch(input); + if (!channel) return { error: true, reason_code: "CHANNEL_NOT_FOUND" }; + + return { error: false, argument: channel }; + } + + return { error: true, reason_code: "INVALID_CHANNEL_ETC" }; + }, +} satisfies CommandArgumentValidator; diff --git a/packages/gil/lib/arguments/args/member.ts b/packages/gil/lib/arguments/args/member.ts new file mode 100644 index 00000000..65d15bf0 --- /dev/null +++ b/packages/gil/lib/arguments/args/member.ts @@ -0,0 +1,60 @@ +import { Member, PartialMember } from "guilded.js"; +import { isHashId } from "../../utils/uuid"; +import type { CommandArgumentValidator } from "../ArgumentParser"; + +export default { + validate: async ({ input, mentionCounters, message, rawArgs, argIndex }) => { + if (input.startsWith("@")) { + const mention = message.mentions?.users?.[mentionCounters.users++]; + if (!mention) return { error: true, reason_code: "NO_USER_IN_MENTIONS" }; + + const member = await message.client.members.fetch(message.serverId!, mention.id).catch(() => null); + if (!member) return { error: true, reason_code: "MEMBER_NOT_FOUND" }; + + const name = member.username ?? member.nickname ?? member.displayName; + const spaceCount = name!.split(" ").length; + rawArgs.splice(argIndex + 1, spaceCount - 1); + rawArgs[argIndex] = name!; + + return { + error: false, + argument: normalizeMember(member), + }; + } + + if (isHashId(input)) { + const member = await message.client.members + .fetch(message.serverId!, input) + .then(normalizeMember) + .catch(() => { + return new PartialMember(message.client, { + id: input, + serverId: message.serverId!, + roleIds: [], + user: { + id: input, + name: "Unknown User (generated by Bot)", + }, + }); + }); + + if (!member) return { error: true, reason_code: "MEMBER_NOT_FOUND" }; + + return { error: false, argument: member }; + } + + return { error: true, reason_code: "INVALID_MEMBER_INPUT" }; + }, +} satisfies CommandArgumentValidator; + +function normalizeMember(member: Member): PartialMember { + return new PartialMember(member.client, { + id: member.id, + serverId: member.serverId!, + roleIds: member.roleIds, + user: { + id: member.id, + name: member.username ?? member.nickname ?? member.displayName!, + }, + }); +} diff --git a/packages/gil/lib/arguments/args/number.ts b/packages/gil/lib/arguments/args/number.ts new file mode 100644 index 00000000..b085fbf3 --- /dev/null +++ b/packages/gil/lib/arguments/args/number.ts @@ -0,0 +1,19 @@ +import type { CommandArgumentValidator } from "../ArgumentParser"; + +const MAX_LIMIT = 2147483647; +const MIN_LIMIT = -MAX_LIMIT - 1; + +export default { + validate: ({ input, argument }) => { + const castedNumber = Number(input); + if (Number.isNaN(castedNumber)) { + return { error: true, reason_code: "INVALID_NUMBER" }; + } + + if (castedNumber > MAX_LIMIT || castedNumber < MIN_LIMIT) { + return { error: true, reason_code: "NUMBER_OUT_OF_RANGE" }; + } + + return { error: false, argument: castedNumber }; + }, +} satisfies CommandArgumentValidator; diff --git a/packages/gil/lib/arguments/args/role.ts b/packages/gil/lib/arguments/args/role.ts new file mode 100644 index 00000000..d1ac1c4e --- /dev/null +++ b/packages/gil/lib/arguments/args/role.ts @@ -0,0 +1,30 @@ +import type { CommandArgumentValidator } from "../ArgumentParser"; + +export default { + validate: async ({ input, mentionCounters, message, rawArgs, argIndex }) => { + if (input.startsWith("@")) { + const mention = message.mentions?.roles?.[mentionCounters.roles++]; + if (!mention) return { error: true, reason_code: "NO_ROLE_IN_MENTIONS" }; + + const role = await message.client.roles.fetch(message.serverId!, mention.id).catch(() => null); + if (!role) return { error: true, reason_code: "ROLE_NOT_FOUND" }; + + const name = role.name; + const spaceCount = name.split(" ").length; + rawArgs.splice(argIndex + 1, spaceCount - 1); + rawArgs[argIndex] = name; + + return { error: false, argument: role }; + } + + const parsed = Number(input); + if (!Number.isNaN(parsed)) { + const role = await message.client.roles.fetch(message.serverId!, parsed).catch(() => null); + if (!role) return { error: true, reason_code: "ROLE_NOT_FOUND" }; + + return { error: false, argument: role }; + } + + return { error: true, reason_code: "INVALID_ROLE_INPUT" }; + }, +} satisfies CommandArgumentValidator; diff --git a/packages/gil/lib/arguments/args/string.ts b/packages/gil/lib/arguments/args/string.ts new file mode 100644 index 00000000..60430b8e --- /dev/null +++ b/packages/gil/lib/arguments/args/string.ts @@ -0,0 +1,16 @@ +import type { CommandArgumentValidator } from "../ArgumentParser"; + +export default { + validate: ({ input }) => { + if (typeof input !== "string") { + return { + error: true, + reason_code: "BAD_STRING", + }; + } + return { + error: false, + argument: input, + }; + }, +} satisfies CommandArgumentValidator; diff --git a/packages/gil/lib/arguments/boolean.ts b/packages/gil/lib/arguments/boolean.ts deleted file mode 100644 index 7ddf8741..00000000 --- a/packages/gil/lib/arguments/boolean.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Argument } from "../structures/Argument"; -import type { CommandArgument } from "../structures/Command"; - -export class BooleanArgument extends Argument { - name = "boolean"; - - execute(argument: CommandArgument, parameters: string[]): boolean | undefined { - const [boolean] = parameters; - - if (["true", "false", "on", "off", "enable", "disable"].includes(boolean)) { - return ["true", "on", "enable"].includes(boolean); - } - } - - init(): void { - // shut up eslint - } -} - -export default BooleanArgument; diff --git a/packages/gil/lib/arguments/command.ts b/packages/gil/lib/arguments/command.ts deleted file mode 100644 index 78812f3f..00000000 --- a/packages/gil/lib/arguments/command.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Argument } from "../structures/Argument"; -import type { Command, CommandArgument } from "../structures/Command"; - -export class CommandTypeArgument extends Argument { - name = "command"; - - execute(argument: CommandArgument, parameters: string[]): Command | undefined { - const [name] = parameters; - if (!name) return; - - const commandName = name.toLowerCase(); - const command = this.client.commands.get(commandName); - if (command) return command; - - // Check if its an alias - return this.client.commands.find((cmd) => Boolean(cmd.aliases?.includes(commandName))); - } - - init(): void { - // shut up eslint - } -} - -export default CommandTypeArgument; diff --git a/packages/gil/lib/arguments/duration.ts b/packages/gil/lib/arguments/duration.ts deleted file mode 100644 index cf7498b8..00000000 --- a/packages/gil/lib/arguments/duration.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Argument } from "../structures/Argument"; -import type { CommandArgument } from "../structures/Command"; - -export class DurationArgument extends Argument { - name = "duration"; - - execute(argument: CommandArgument, parameters: string[]): number | undefined { - const [time] = parameters; - if (!time) return; - - return this.client.stringToMilliseconds(time); - } - - init(): void { - // shut up eslint - } -} - -export default DurationArgument; diff --git a/packages/gil/lib/arguments/number.ts b/packages/gil/lib/arguments/number.ts deleted file mode 100644 index c2bb831e..00000000 --- a/packages/gil/lib/arguments/number.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Argument } from "../structures/Argument"; -import type { CommandArgument } from "../structures/Command"; - -export class NumberArgument extends Argument { - name = "number"; - - execute(argument: CommandArgument, parameters: string[]): number | undefined { - const [number] = parameters; - - const valid = Number(number); - if (!valid) return; - - if (valid < (argument.minimum || 0)) return; - if (argument.maximum && valid > argument.maximum) return; - if (!argument.allowDecimals) return Math.floor(valid); - - if (valid) return valid; - } - - init(): void { - // shut up eslint - } -} - -export default NumberArgument; diff --git a/packages/gil/lib/arguments/string.ts b/packages/gil/lib/arguments/string.ts deleted file mode 100644 index ea70a204..00000000 --- a/packages/gil/lib/arguments/string.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Argument } from "../structures/Argument"; -import type { CommandArgument } from "../structures/Command"; - -export class StringArgument extends Argument { - name = "string"; - - execute(argument: CommandArgument, parameters: string[]): string | undefined { - const [text] = parameters; - - const valid = - // If the argument required literals and some string was provided by user - argument.literals?.length && text ? (argument.literals.includes(text.toLowerCase()) ? text : undefined) : text; - - if (valid) { - return argument.lowercase ? valid.toLowerCase() : valid; - } - } - - init(): void { - // shut up eslint - } -} - -export default StringArgument; diff --git a/packages/gil/lib/arguments/subcommand.ts b/packages/gil/lib/arguments/subcommand.ts deleted file mode 100644 index 545a8ec7..00000000 --- a/packages/gil/lib/arguments/subcommand.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Message } from "guilded.js"; -import { Argument } from "../structures/Argument"; -import type { Command, CommandArgument } from "../structures/Command"; - -export class SubcommandArgument extends Argument { - name = "subcommand"; - - execute(argument: CommandArgument, parameters: string[], message: Message, command: Command): Command | undefined { - const subcommandName = parameters[0]?.toLowerCase(); - if (!subcommandName) return; - - const sub = command.subcommands?.find((sub) => sub.name === subcommandName || Boolean(sub.aliases?.includes(subcommandName))); - if (sub) return sub; - - return typeof argument.defaultValue === "string" ? command.subcommands?.get(argument.defaultValue) : undefined; - } - - init(): void { - // shut up eslint - } -} - -export default SubcommandArgument; diff --git a/packages/gil/lib/commands/ping.ts b/packages/gil/lib/commands/ping.ts deleted file mode 100644 index f8d6bc43..00000000 --- a/packages/gil/lib/commands/ping.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Message } from "guilded.js"; -import { Command } from "../structures/Command"; - -export class PingCommand extends Command { - name = "ping"; - - execute(message: Message) { - return this.client.messages.send(message.channelId, "Pong"); - } - - init(): void { - // comment to shut up eslint error - } -} - -export default PingCommand; diff --git a/packages/gil/lib/defaults/CommandMessageListener.ts b/packages/gil/lib/defaults/CommandMessageListener.ts new file mode 100644 index 00000000..144c08bc --- /dev/null +++ b/packages/gil/lib/defaults/CommandMessageListener.ts @@ -0,0 +1,133 @@ +import { Message, Role } from "guilded.js"; +import * as lexure from "lexure"; +import { GilClient } from "../GilClient"; +import { StoredRole, StoredRoleType } from "../adapters/db/DatabaseAdapter"; +import { convertArguments } from "../arguments/ArgumentParser"; +import { CommandMessageParams } from "../events"; +import { Listener, ListenerContext } from "../structures/Listener"; + +export default class CommandMessageListener extends Listener { + constructor(gil: GilClient) { + super(gil, { event: "commandMessage", emitter: "gil" }); + } + + async execute(ctx: ListenerContext, params: CommandMessageParams) { + const result = this.parseCommand(params.message.content, params.prefix); + if (!result) { + this.gil.logger.debug("Invalid parsing of command", params.message.id); + return; + } + + const { name, args: parsedArgs } = result; + const command = this.gil.commands.getCommand(name); + if (!command) { + this.gil.logger.debug(`Command ${name} not found`, params.message.id); + return; + } + + const serverRoles = await this.gil.db.getRoles(params.server.server_id); + const permissionsCheck = this.userPermissionsCheck({ + userRoles: params.member.roleIds.map((x) => x.toString()), + serverRoles, + requiredRoleType: command.options.userRole, + }); + + // if user is an operator, skip all the perm checks + if (!this.gil.options.operators?.includes(params.member.id)) { + if (command.options.operatorOnly) { + this.gil.logger.debug(`User tried to run a dev only command ${name}`, params.message.id); + return; + } + + if (!permissionsCheck) { + this.gil.logger.debug(`User does not have permissions for command ${name}`, params.message.id); + return; + } + + if (command.options.serverPremiumLevel && this.gil.options.premiumPrioritys) { + const serverPremiumPriority = this.gil.options.premiumPrioritys.indexOf(params.server.premium_level); + const requiredMinimumPriority = this.gil.options.premiumPrioritys.indexOf(command.options.serverPremiumLevel); + + if (serverPremiumPriority < requiredMinimumPriority) { + this.gil.logger.debug(`Server does not have the required premium level for command ${name}`, params.message.id); + // todo: allow user to put premium prompt message + return; + } + } + + if (command.options.additionalCheck && !command.options.additionalCheck(params)) { + this.gil.logger.debug("User failed the command additional check", params.message.id); + return; + } + } + + const attemptConvertArguments = await convertArguments({ + args: parsedArgs, + message: params.message, + command, + }); + + if (attemptConvertArguments.error) { + this.gil.logger.debug(`Error converting arguments for command ${name}, reason: ${attemptConvertArguments.reason_code}`, params.message.id); + // TODO: in-depth error messages for users + return; + } + + try { + await command.execute({ + message: params.message, + args: attemptConvertArguments.arguments, + }); + } catch (e) { + // todo: user friendly error "something went wrong" message + + this.gil.logger.error(e as Error); + this.gil.logger.warn(`Error executing command ${name}`, params.message.id); + } + } + + private parseCommand(content: string, prefix: string): { name: string; args: string[] } | null { + const lexer = new lexure.Lexer(content); + lexer.setQuotes([ + ['"', '"'], + ["'", "'"], + ["“", "”"], + ]); + + const parsed = lexer.lexCommand((token) => (token.startsWith(prefix) ? prefix.length : null)); + if (!parsed) return null; + const getArgs = parsed[1]; + + return { + name: parsed[0].value.toLowerCase(), + args: getArgs().map((arg) => arg.value), + }; + } + + private userPermissionsCheck(options: { + userRoles: string[]; + serverRoles: StoredRole[]; + requiredRoleType?: StoredRoleType; + }): boolean { + if (!options.requiredRoleType) return true; + + const rolePrioritys = [StoredRoleType.Minimod, StoredRoleType.Mod, StoredRoleType.Admin]; + + // given user roles ["role_1", "role_2", "role_3"] + // server roles [{ "role_id": "role_2", "type": "minimod" }] + // this will transform it into [-1, 0, -1] (turns into priority indexes, -1 if role is not a role with priority) + const userRolesPrioritys = options.userRoles.map((userRoleId) => { + const serverRole = options.serverRoles.find((serverRole) => serverRole.role_id === userRoleId); + const priority = serverRole ? rolePrioritys.indexOf(serverRole.type) : -1; + if (priority === -1) return -1; + + return priority; + }); + + const userHighestRolePriority = Math.max(...userRolesPrioritys); + if (userHighestRolePriority === -1) return false; + + const requiredRolePriority = rolePrioritys.indexOf(options.requiredRoleType); + return userHighestRolePriority >= requiredRolePriority; + } +} diff --git a/packages/gil/lib/defaults/ListenerManager.ts b/packages/gil/lib/defaults/ListenerManager.ts new file mode 100644 index 00000000..34628768 --- /dev/null +++ b/packages/gil/lib/defaults/ListenerManager.ts @@ -0,0 +1,54 @@ +import { Collection } from "@discordjs/collection"; +import { glob } from "fast-glob"; +import { ClientEvents } from "guilded.js"; +import { Listener } from "../structures/Listener"; +import { Manager } from "../structures/Manager"; + +import { GilEvents } from "../events"; +import CommandMessageListener from "./CommandMessageListener"; +import MessageListener from "./MessageListener"; +import ReadyListener from "./ReadyListener"; + +export class ListenerManager extends Manager { + public listeners = new Collection(); + + public async init(): Promise { + if (!this.gil.options.listenerDirectory) { + this.gil.logger.warn("No listener directory provided, skipping listener initialization."); + return; + } + + this.gil.logger.info("Loading listeners..."); + const files = await glob(`${this.gil.options.listenerDirectory}/**/*`, { + dot: true, + absolute: true, + concurrency: 10, + }); + + if (!files.length) this.gil.logger.warn("Despite providing a listener directory, no listeners were found."); + else { + for (const file of files) { + const imported = await import(file); + if (!imported.default) { + this.gil.logger.warn(`Listener file ${file} does not export a default export.`); + continue; + } + + const createdListener: Listener = new imported.default(this.gil); + this.gil.logger.info(`Listener ${createdListener.options.event} loaded.`); + this.listeners.set(createdListener.options.event, createdListener); + } + } + this.listeners.set("ready", new ReadyListener(this.gil)); + this.listeners.set("messageCreated", new MessageListener(this.gil)); + this.listeners.set("commandMessage", new CommandMessageListener(this.gil)); + + for (const listener of this.listeners.values()) { + if (listener.options.emitter === "gjs") { + this.gil.client.on(listener.options.event as keyof ClientEvents, listener.execute.bind(listener, { gil: this.gil })); + } else { + this.gil.emitter.on(listener.options.event as keyof GilEvents, listener.execute.bind(listener, { gil: this.gil })); + } + } + } +} diff --git a/packages/gil/lib/defaults/MessageListener.ts b/packages/gil/lib/defaults/MessageListener.ts new file mode 100644 index 00000000..0aa893c9 --- /dev/null +++ b/packages/gil/lib/defaults/MessageListener.ts @@ -0,0 +1,59 @@ +import { Message, UserType } from "guilded.js"; +import { GilClient } from "../GilClient"; +import { Listener, ListenerContext } from "../structures/Listener"; +import { getPrefix } from "../utils/prefix"; + +export default class MessageListener extends Listener { + constructor(gil: GilClient) { + super(gil, { event: "messageCreated", emitter: "gjs" }); + } + + public async execute(ctx: ListenerContext, message: Message) { + const clientId = this.gil.client.user?.id; + this.gil.logger.debug(`Received message from ${message.authorId} in ${message.serverId}`, message.id); + + if (!message.serverId) { + this.gil.logger.debug("Message was not sent in a server", message.id); + return; + } + if (message.authorId === clientId) { + this.gil.logger.debug("Message was sent by the bot", message.id); + return; + } + if (message.createdByWebhookId || message.authorId === "Ann6LewA") { + this.gil.logger.debug("Message was sent by a webhook or Ann6LewA", message.id); + return; + } + + let server = await this.gil.db.getServer(message.serverId); + if (!server) { + this.gil.logger.debug("Server was not found, creating...", message.id); + server = await this.gil.db.createServer(message.serverId); + } + + const member = await this.gil.client.members.fetch(message.serverId, message.authorId); + if (!member || member.user?.type === UserType.Bot) { + this.gil.logger.debug("Member was not found or is a bot", message.id); + return; + } + + const prefix = getPrefix(server); + if (!message.content.startsWith(prefix)) { + this.gil.logger.debug("Message does not start with prefix", message.id); + this.gil.emitter.emit("nonCommandMessage", { + message, + server, + member, + }); + return; + } + + this.gil.logger.debug("Message starts with prefix", message.id); + this.gil.emitter.emit("commandMessage", { + message, + server, + member, + prefix, + }); + } +} diff --git a/packages/gil/lib/defaults/ReadyListener.ts b/packages/gil/lib/defaults/ReadyListener.ts new file mode 100644 index 00000000..cfec3df8 --- /dev/null +++ b/packages/gil/lib/defaults/ReadyListener.ts @@ -0,0 +1,12 @@ +import { GilClient } from "../GilClient"; +import { Listener, ListenerContext } from "../structures/Listener"; + +export default class ReadyListener extends Listener { + constructor(gil: GilClient) { + super(gil, { event: "ready", emitter: "gjs" }); + } + + async execute(ctx: ListenerContext) { + this.gil.logger.info(`Client is logged in as ${this.gil.client.user?.name}`); + } +} diff --git a/packages/gil/lib/events.ts b/packages/gil/lib/events.ts new file mode 100644 index 00000000..d638f6ab --- /dev/null +++ b/packages/gil/lib/events.ts @@ -0,0 +1,20 @@ +import { Member, Message } from "guilded.js"; +import { StoredServer } from "./adapters/db/DatabaseAdapter"; + +export interface NonCommandMessageParams { + message: Message; + member: Member; + server: StoredServer; +} + +export interface CommandMessageParams { + message: Message; + member: Member; + server: StoredServer; + prefix: string; +} + +export type GilEvents = { + nonCommandMessage(params: NonCommandMessageParams): unknown; + commandMessage(params: CommandMessageParams): unknown; +}; diff --git a/packages/gil/lib/index.ts b/packages/gil/lib/index.ts index 4a0f839a..d53367c6 100644 --- a/packages/gil/lib/index.ts +++ b/packages/gil/lib/index.ts @@ -1,20 +1,11 @@ -export * from "./arguments/...string"; -export * from "./arguments/boolean"; -export * from "./arguments/command"; -export * from "./arguments/duration"; -export * from "./arguments/number"; -export * from "./arguments/string"; -export * from "./arguments/subcommand"; -export * from "./BotClient"; -export * from "./commands/ping"; -export * from "./inhibitors/allowedIn"; -export * from "./inhibitors/cooldown"; -export * from "./monitors/commands"; -export * from "./monitors/messageCollector"; -export * from "./structures/Argument"; +export * from "./GilClient"; export * from "./structures/Command"; -export * from "./structures/Inhibitor"; -export * from "./structures/Monitor"; +export * from "./structures/Listener"; export * from "./structures/Task"; -export * from "./utils/walk"; -export * from "guilded.js"; +export * from "./structures/Manager"; +export * from "./adapters/logging/ConsoleAdapter"; +export * from "./adapters/logging/LoggerAdapter"; +export * from "./adapters/db/DatabaseAdapter"; +export * from "./adapters/db/MongoAdapter"; +// export * from "./adapters/logging/WinstonAdapter"; +// export * from "./adapters/logging/PinoAdapter"; diff --git a/packages/gil/lib/inhibitors/allowedIn.ts b/packages/gil/lib/inhibitors/allowedIn.ts deleted file mode 100644 index b7fb79d6..00000000 --- a/packages/gil/lib/inhibitors/allowedIn.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Message } from "guilded.js"; -import type { Command } from "../structures/Command"; -import { Inhibitor } from "../structures/Inhibitor"; - -export class AllowedInInhibitor extends Inhibitor { - name = "allowedIn"; - - execute(message: Message, command: Command): boolean { - // The command should be allowed to run because it meets the requirements - // allowedIn defaults to ["server"] - if ((!command.allowedIn || command.allowedIn.includes("server")) && message.serverId) return false; - - // If the command is allowed in dms - if (command.allowedIn?.includes("dm") && !message.serverId) return false; - - // THE COMMANDS NEEDS TO BE INHIBITED. - console.log(`${command.name} Inhibited: ALLOWED IN`); - return true; - } - - init(): void { - // shut up eslint - } -} - -export default AllowedInInhibitor; diff --git a/packages/gil/lib/inhibitors/cooldown.ts b/packages/gil/lib/inhibitors/cooldown.ts deleted file mode 100644 index 480e53b7..00000000 --- a/packages/gil/lib/inhibitors/cooldown.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Collection } from "@discordjs/collection"; -import type { Message } from "guilded.js"; -import type { Command } from "../structures/Command"; -import { Inhibitor } from "../structures/Inhibitor"; - -export class CooldownInhibitor extends Inhibitor { - name = "cooldown"; - - /** - * The collection of users that are in cooldown - */ - membersInCooldown = new Collection(); - - async execute(message: Message, command: Command): Promise { - if (!command.cooldown) return false; - - const key = `${message.createdById}-${command.name}`; - const cooldown = this.membersInCooldown.get(key); - if (cooldown) { - if (cooldown.used >= (command.cooldown.allowedUses || 1)) { - const now = Date.now(); - if (cooldown.timestamp > now) { - await this.client.cooldownReached(message, command, { - now, - cooldown, - }); - return true; - } - - cooldown.used = 0; - } - - this.membersInCooldown.set(key, { - used: cooldown.used + 1, - timestamp: Date.now() + command.cooldown.seconds * 1_000, - }); - return false; - } - - this.membersInCooldown.set(key, { - used: 1, - timestamp: Date.now() + command.cooldown.seconds * 1_000, - }); - return false; - } - - init(): void { - // shut up eslint - } -} - -export type Cooldown = { - /** - * The timestamp when this command should be available to use again. - */ - timestamp: number; - /** - * The amount of times a command was used. - */ - used: number; -}; - -export default CooldownInhibitor; diff --git a/packages/gil/lib/monitors/commands.ts b/packages/gil/lib/monitors/commands.ts deleted file mode 100644 index 9a85aae4..00000000 --- a/packages/gil/lib/monitors/commands.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { bgBlack, bgBlue, bgGreen, bgMagenta, bgYellow, black, green, red, white } from "colorette"; -import type { Message } from "guilded.js"; -import type { Command } from "../structures/Command"; -import { Monitor } from "../structures/Monitor"; - -export class CommandsMonitor extends Monitor { - // Commands should not ignore dms. - ignoreDM = false; - - execute(message: Message): unknown { - let prefix = this.parsePrefix(message.serverId); - - // If the message is not using the valid prefix or bot mention cancel the command - // IF THE MESSAGE IS ONLY BOT MENTION, SEND THE PREFIX - if (this.client.botMention) { - if (this.client.botMention === message.content) - return this.client.messages.send(message.channelId, { - content: this.parsePrefix(message.serverId), - replyMessageIds: [message.id], - }); - // IF THE MESSAGE STARTS WITH BOT MENTION, USE MENTION AS PREFIX - if (message.content.startsWith(this.client.botMention)) prefix = this.client.botMention; - } - - // IF NO PREFIX IS USED, CANCEL - if (!message.content.startsWith(prefix)) return; - - // `!ping testing` becomes `ping` - const [commandName, ...parameters] = message.content.slice(prefix.length).split(" "); - - // Check if this is a valid command - const command = this.parseCommand(commandName); - if (!command) return; - - this.logCommand(message, "Trigger", commandName); - - // TODO: implement a global user cooldown system - - void this.executeCommand(message, command, parameters); - } - - parsePrefix(serverId?: string | null): string { - const prefix = serverId ? this.client.prefixes.get(serverId) : this.client.prefix; - return prefix ?? this.client.prefix; - } - - parseCommand(commandName: string): Command | undefined { - commandName = commandName.toLowerCase(); - const command = this.client.commands.get(commandName); - if (command) return command; - - // Check aliases if the command wasn't found - return this.client.commands.find((cmd) => Boolean(cmd.aliases?.includes(commandName))); - } - - logCommand(message: Message, type: "Failure" | "Inhibit" | "Missing" | "Slowmode" | "Success" | "Trigger", commandName: string): void { - // TODO: use server name when available in api - const serverName = message.serverId ?? "DM"; - - const command = `[COMMAND: ${bgYellow(black(commandName ?? "Unknown"))} - ${bgBlack( - ["Failure", "Slowmode", "Missing"].includes(type) ? red(type) : type === "Success" ? green(type) : white(type), - )}]`; - - // TODO: use message author tag or name here - const user = bgGreen(black(`${""}(${message.createdById})`)); - const guild = bgMagenta(black(`${serverName}${message.serverId ? `(${message.serverId})` : ""}`)); - - console.log(`${bgBlue(`[${this.client.getTime()}]`)} ${command} by ${user} in ${guild} with Message ID: ${message.id}`); - } - - async executeCommand(message: Message, command: Command, parameters: string[]): Promise { - try { - // bot.slowmode.set(message.author.id, message.timestamp); - - // Parsed args and validated - const args = await this.parseArguments(message, command, parameters); - // Some arg that was required was missing and handled already - if (!args) { - this.logCommand(message, "Missing", command.name); - return; - } - - // If no subcommand execute the command - const [argument] = command.arguments ?? []; - const subcommand = argument ? (args[argument.name] as Command) : undefined; - - if (!argument || argument.type !== "subcommand" || !subcommand) { - // Check subcommand permissions and options - if (!(await this.commandAllowed(message, command))) return; - - await command.execute?.(message, args); - this.logCommand(message, "Success", command.parentCommand ? `${command.parentCommand}-${command.name}` : command.name); - return; - } - - // A subcommand was asked for in this command - if ([subcommand.name, ...(subcommand.aliases ?? [])].includes(parameters[0])) { - const subParameters = parameters.slice(1); - void this.executeCommand(message, subcommand, subParameters); - } else { - void this.executeCommand(message, subcommand, parameters); - } - } catch (error) { - console.error(error); - this.logCommand(message, "Failure", command.name); - } - } - - async parseArguments(message: Message, command: Command, parameters: string[]): Promise | false> { - const args: { - [key: string]: unknown; - } = {}; - if (!command.arguments) return args; - - let missingRequiredArg = false; - - // Clone the parameters so we can modify it without editing original array - const params = [...parameters]; - - // Loop over each argument and validate - for (const argument of command.arguments) { - const resolver = this.client.arguments.get(argument.type ?? "string"); - if (!resolver) continue; - - const result = await resolver.execute(argument, params, message, command); - if (result !== undefined) { - // Assign the valid argument - args[argument.name] = result; - // This will use up all args so immediately exist the loop. - if (argument.type && ["subcommands", "...strings", "...roles", "...emojis", "...snowflakes"].includes(argument.type)) { - break; - } - - // Remove a param for the next argument - params.shift(); - continue; - } - - // Invalid arg provided. - if (Object.hasOwn(argument, "defaultValue")) { - args[argument.name] = argument.defaultValue; - } else if (argument.required !== false) { - if (argument.missing) { - missingRequiredArg = true; - argument.missing?.(message); - break; - } - - // A REQUIRED ARG WAS MISSING TRY TO COLLECT IT - // TODO: perm check before sending - const question = await this.client.messages.send(message.channelId, { - content: `You were missing the **${argument.name}** argument which is required in that command. Please provide the **${ - argument.type === "subcommand" ? command.subcommands?.map((sub) => sub.name).join(", ") ?? "subcommand" : argument.type - }** now.`, - replyMessageIds: [message.id], - }); - if (question) { - const response = await this.client.needMessage(message.createdById, message.channelId); - if (response) { - const responseArg = await resolver.execute(argument, [response.content], message, command); - if (responseArg) { - args[argument.name] = responseArg; - params.shift(); - // TODO: perm checks to delete message - await Promise.all([this.client.messages.delete(message.channelId, message.id), this.client.messages.delete(message.channelId, response.id)]); - continue; - } - } - } - - missingRequiredArg = true; - // @ts-expect-error fix this dumb error. TODO: idk why this is erroring - argument.missing?.(message); - break; - } - } - - // If an arg was missing then return false so we can error out as an object {} will always be truthy - return missingRequiredArg ? false : args; - } - - async commandAllowed(message: Message, command: Command): Promise { - const inhibitorResults = await Promise.all([...this.client.inhibitors.values()].map((inhibitor) => inhibitor.execute(message, command))); - - if (inhibitorResults.includes(true)) { - this.logCommand(message, "Inhibit", command.name); - return false; - } - - return true; - } - - init(): unknown { - return; - } -} - -export default CommandsMonitor; diff --git a/packages/gil/lib/monitors/messageCollector.ts b/packages/gil/lib/monitors/messageCollector.ts deleted file mode 100644 index 98df6a56..00000000 --- a/packages/gil/lib/monitors/messageCollector.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Message } from "guilded.js"; -import { Monitor } from "../structures/Monitor"; - -export class MessageCollectorMonitor extends Monitor { - execute(message: Message): void { - const collector = this.client.messageCollectors.get(message.createdById); - // This user has no collectors pending or the message is in a different channel - if (!collector || message.channelId !== collector.channelId) return; - // This message is a response to a collector. Now running the filter function. - if (!collector.filter(message)) return; - - // If the necessary amount has been collected - if (collector.amount === 1 || collector.amount === collector.messages.length + 1) { - // Remove the collector - this.client.messageCollectors.delete(message.createdById); - // Resolve the collector - collector.resolve([...collector.messages, message]); - return; - } - - // More messages still need to be collected - collector.messages.push(message); - } - - init(): void { - // shut up eslint - } -} - -export default MessageCollectorMonitor; diff --git a/packages/gil/lib/structures/Argument.ts b/packages/gil/lib/structures/Argument.ts deleted file mode 100644 index 5322eaaf..00000000 --- a/packages/gil/lib/structures/Argument.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Message } from "guilded.js"; -import type { BotClient } from "../BotClient"; -import type { Command, CommandArgument } from "./Command"; - -export abstract class Argument { - constructor( - public readonly client: BotClient, - public name: string, - ) {} - - abstract execute(argument: CommandArgument, parameters: string[], message: Message, command: Command): Promise | unknown; - - abstract init(): Promise | unknown; -} - -export default Argument; diff --git a/packages/gil/lib/structures/Command.ts b/packages/gil/lib/structures/Command.ts index 8659b16e..49a8a63f 100644 --- a/packages/gil/lib/structures/Command.ts +++ b/packages/gil/lib/structures/Command.ts @@ -1,101 +1,87 @@ -import type { Collection } from "@discordjs/collection"; -import type { Message } from "guilded.js"; -import type { BotClient } from "../BotClient"; +import { Collection } from "@discordjs/collection"; +import glob from "fast-glob"; +import { Message } from "guilded.js"; +import { GilClient } from "../GilClient"; +import { StoredRoleType } from "../adapters/db/DatabaseAdapter"; +import { CommandArgument, CommandArgumentType } from "../arguments/ArgumentParser"; +import { CommandMessageParams } from "../events"; +import { Manager } from "./Manager"; -export abstract class Command { - /** - * The command aliases are stored here. - */ +export interface CommandOptions { + // The internal-safe name of the command + name: string; + // A brief description of the command + description?: string; + // The arguments this command takes + args?: { name: string; type: CommandArgumentType; optional?: boolean }[]; + // The category the command belongs to + category?: string; + // The command's aliases aliases?: string[]; + // The command's usage syntax + usage?: string; + // The command's cooldown in milliseconds + cooldownMS?: number; + // Whether the command can only be used by devs + operatorOnly?: boolean; + // A last-pass function that decides whether the user can run the command or not + additionalCheck?: (context: CommandMessageParams) => boolean; + // Whether the command can only be used by the server owner + serverOwnerOnly?: boolean; + // The role the executing user must have (or higher) to run this command + userRole?: StoredRoleType; + // The permissions the bot must have in this server to run this command + botPermissions?: string[]; + // The premium level the guild must have to run this command + serverPremiumLevel?: string; + // The premium level the user must have to run this command + premiumUserLevel?: string; +} +export abstract class Command { + public constructor( + public readonly gil: GilClient, + public readonly options: CommandOptions, + ) {} - /** - * The arguments you wish to request from the user. - */ - arguments?: readonly CommandArgument[]; - - /** - * Where is this command allowed to run in? By default, it is allowed to run in a server only! - */ - allowedIn?: ("dm" | "server")[] = ["server"]; - - /** - * The description of the command - */ - description?: string; + public abstract execute(commandContext: CommandExecuteContext): unknown | Promise; +} +export interface CommandExecuteContext> { + message: Message; + args: Args; +} - /** - * The cooldown settings for this command. - */ - cooldown?: { - allowedUses?: number; - seconds: number; - }; +export class CommandManager extends Manager { + public readonly commands = new Collection(); - /** - * The subcommands for this command. - */ - subcommands?: Collection; + public getCommand(name: string): Command | null { + const lookupByName = this.commands.get(name); + if (lookupByName) return lookupByName; - /** - * The name of the parent command. If nested subcommands, use `-` to separate the names. For example: `.settings staff modrole` would be parentCommand: "settings-staff" - */ - parentCommand?: string; + const lookupByAlias = this.commands.find((command) => command.options.aliases?.includes(name)); + if (lookupByAlias) return lookupByAlias; - constructor( - public readonly client: BotClient, - public name: string, - ) {} + return null; + } - abstract execute(message: Message, args: Record): Promise | unknown; + public async init(): Promise { + this.gil.logger.info("Loading commands..."); + const files = await glob(`${this.gil.options.commandDirectory}/**/*.ts`, { + dot: true, + absolute: true, + concurrency: 10, + }); + if (!files.length) return this.gil.logger.warn("Despite providing a command directory, no commands were found."); - abstract init(): Promise | unknown; + for (const file of files) { + const imported = await import(file); + if (!imported.default) { + this.gil.logger.warn(`Command file ${file} does not export a default export.`); + continue; + } - get fullName(): string { - return `${this.parentCommand ? `${this.parentCommand.split("-").join(" ")} ` : ""}${this.name}`; + const createdCommand: Command = new imported.default(this.gil); + this.gil.logger.info(`Command ${createdCommand.options.name} loaded.`); + this.commands.set(createdCommand.options.name, createdCommand); + } } } - -export type CommandArgument = { - /** - * If the type is a number, you can use this to allow/disable non-integers. By default this is false. - */ - allowDecimals?: boolean; - /** - * The default value for this argument/subcommand. - */ - defaultValue?: boolean | number | string; - /** - * If the type is string or subcommand you can provide literals. The argument MUST be exactly the same as the literals to be accepted. For example, you can list the subcommands here to make sure it matches. - */ - literals?: string[]; - /** - * If the type is string, this will force this argument to be lowercase. - */ - lowercase?: boolean; - /** - * If the type is a number set the maximum amount. By default this is disabled. - */ - maximum?: number; - /** - * If the type is number set the minimum amount. By default the minimum is 0 - */ - minimum?: number; - /** - * The function that runs if this argument is required and is missing. - */ - missing?(message: Message): unknown; - /** - * The name of the argument. Useful for when you need to alert the user X arg is missing. - */ - name: string; - /** - * Whether or not this argument is required. Defaults to true. - */ - required?: boolean; - /** - * The type of the argument you would like. Defaults to string. - */ - type?: "...string" | "boolean" | "duration" | "number" | "string" | "subcommand"; -}; - -export default Command; diff --git a/packages/gil/lib/structures/Inhibitor.ts b/packages/gil/lib/structures/Inhibitor.ts deleted file mode 100644 index bceb124f..00000000 --- a/packages/gil/lib/structures/Inhibitor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Message } from "guilded.js"; -import type { BotClient } from "../BotClient"; -import type { Command } from "./Command"; - -export abstract class Inhibitor { - constructor( - public readonly client: BotClient, - public name: string, - ) {} - - abstract execute(message: Message, command: Command): Promise | boolean; - - abstract init(): Promise | unknown; -} - -export default Inhibitor; diff --git a/packages/gil/lib/structures/Listener.ts b/packages/gil/lib/structures/Listener.ts new file mode 100644 index 00000000..99100f7d --- /dev/null +++ b/packages/gil/lib/structures/Listener.ts @@ -0,0 +1,17 @@ +import { GilClient } from "../GilClient"; + +interface ListenerOptions { + event: string; + emitter: "gjs" | "gil"; +} +export interface ListenerContext { + gil: GilClient; +} +export abstract class Listener { + public constructor( + public readonly gil: GilClient, + public readonly options: ListenerOptions, + ) {} + + public abstract execute(context: ListenerContext, ...args: unknown[]): unknown | Promise; +} diff --git a/packages/gil/lib/structures/Manager.ts b/packages/gil/lib/structures/Manager.ts new file mode 100644 index 00000000..38f87ca0 --- /dev/null +++ b/packages/gil/lib/structures/Manager.ts @@ -0,0 +1,7 @@ +import { GilClient } from "../GilClient"; + +export abstract class Manager { + public constructor(public readonly gil: GilClient) {} + + public abstract init(): void | Promise; +} diff --git a/packages/gil/lib/structures/Monitor.ts b/packages/gil/lib/structures/Monitor.ts deleted file mode 100644 index e5bb56a2..00000000 --- a/packages/gil/lib/structures/Monitor.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Message } from "guilded.js"; -import type { BotClient } from "../BotClient"; - -export abstract class Monitor { - /** - * Whether this monitor should ignore messages that are sent by bots. By default this is true. - */ - ignoreBots = true; - - /** - * Whether this monitor should ignore messages that are sent by others. By default this is false. - */ - ignoreOthers = false; - - /** - * Whether this monitor should ignore messages that are edited. By default this is false. - */ - ignoreEdits = false; - - /** - * Whether this monitor should ignore messages that are sent in DM. By default this is true. - */ - ignoreDM = true; - - constructor( - public readonly client: BotClient, - public name: string, - ) {} - - abstract execute(message: Message): Promise | unknown; - - abstract init(): Promise | unknown; -} - -export default Monitor; diff --git a/packages/gil/lib/structures/Task.ts b/packages/gil/lib/structures/Task.ts index f0a94d7a..e113191c 100644 --- a/packages/gil/lib/structures/Task.ts +++ b/packages/gil/lib/structures/Task.ts @@ -1,29 +1,53 @@ -import type { BotClient } from "../BotClient"; +import { Collection } from "@discordjs/collection"; +import glob from "fast-glob"; +import { GilClient } from "../GilClient"; +import { Manager } from "./Manager"; +interface TaskOptions { + // The internal-safe name of the task + name: string; + // The interval to run the task. You can put anything that https://github.com/breejs/bree supports. + // For example, you can use crons like "0 0 * * *" to run the task every day at midnight. + interval: string; +} export abstract class Task { - /** - * The amount of time this task should take to run. Defaults to 1 hour(3,600,000 ms) - */ - millisecondsInterval = 60 * 60 * 1_000; + public constructor( + public readonly gil: GilClient, + public readonly options: TaskOptions, + ) {} + + public abstract execute(): unknown | Promise; +} - /** - * Whether or not this task should run immediately on startup. Default to false. - */ - runOnStartup = false; +export class TaskManager extends Manager { + public tasks = new Collection(); - /** - * Whether this task requires the bot to be fully ready before running. Default to false. - */ - requireReady = false; + // TODO: start the tasks + // TODO: find library for running tasks on a schedule + public async init(): Promise { + if (!this.gil.options.taskDirectory) { + this.gil.logger.warn("No task directory provided, skipping task initialization."); + return; + } - constructor( - public readonly client: BotClient, - public name: string, - ) {} + this.gil.logger.info("Loading tasks..."); + const files = await glob(`${this.gil.options.taskDirectory}/**/*`, { + dot: true, + absolute: true, + concurrency: 10, + }); + if (!files.length) return this.gil.logger.warn("Despite providing a task directory, no tasks were found."); - abstract execute(): Promise | unknown; + for (const file of files) { + const imported = await import(file); + if (!imported.default) { + this.gil.logger.warn(`Task file ${file} does not export a default export.`); + continue; + } - abstract init(): Promise | unknown; + const createdTask: Task = new imported.default(this.gil); + this.gil.logger.info(`Task ${createdTask.options.name} loaded.`); + this.tasks.set(createdTask.options.name, createdTask); + } + } } - -export default Task; diff --git a/packages/gil/lib/tasks/README.md b/packages/gil/lib/tasks/README.md deleted file mode 100644 index ff595abd..00000000 --- a/packages/gil/lib/tasks/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Placeholder - -This file simply helps ignore the warning for internal tasks missing, until such a time as we need a task provided internally. diff --git a/packages/gil/lib/utils/prefix.ts b/packages/gil/lib/utils/prefix.ts new file mode 100644 index 00000000..c552a51a --- /dev/null +++ b/packages/gil/lib/utils/prefix.ts @@ -0,0 +1,5 @@ +import { StoredServer } from "../adapters/db/DatabaseAdapter"; + +export function getPrefix(server: StoredServer) { + return server.prefix ?? process.env.DEFAULT_PREFIX ?? "!"; +} diff --git a/packages/gil/lib/utils/uuid.ts b/packages/gil/lib/utils/uuid.ts new file mode 100644 index 00000000..00f92082 --- /dev/null +++ b/packages/gil/lib/utils/uuid.ts @@ -0,0 +1,6 @@ +// REGEX Matching +const regexUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const regexHashId = /^[0-9A-Za-z]{8,16}$/; + +export const isUUID = (str: string) => regexUUID.test(str); +export const isHashId = (str: string) => regexHashId.test(str); diff --git a/packages/gil/lib/utils/walk.ts b/packages/gil/lib/utils/walk.ts deleted file mode 100644 index 817bd1b5..00000000 --- a/packages/gil/lib/utils/walk.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { opendir } from "node:fs/promises"; -import path from "node:path"; -import { yellowBright } from "colorette"; - -/** - * Walks through a directory allowing you to process it with a for await loop - */ -export async function* walk(dir: string): AsyncGenerator { - const folder = await opendir(dir).catch((error) => { - if (error.message.startsWith("ENOENT: no such file or directory")) { - console.log(yellowBright(`[WARN] Missing folder: ${dir.slice(Math.max(0, dir.lastIndexOf("/")))}. To make this warning go away, simply create the folder in your src folder.`)); - } else console.log(error); - }); - if (!folder) return; - - const directories: string[] = []; - - for await (const d of folder) { - const entry = path.join(dir, d.name); - // if (d.isDirectory()) yield* walk(entry); - if (d.isDirectory()) directories.push(entry); - - // Skip any non-js/ts file - if (![".js", ".ts"].some((suffix) => entry.endsWith(suffix))) continue; - // since declaration files end with ts they need to be ignored as well - if (entry.endsWith(".d.ts")) continue; - // eslint-disable-next-line @typescript-eslint/no-require-imports - if (d.isFile()) yield [d.name, require(entry)]; - } - - // LOAD DIRECTORIES AFTER FILES TO ALLOW SUBCOMMANDS TO HAVE COMMAND FILES MADE FIRST! - for (const directory of directories) yield* walk(directory); -} - -export default walk; diff --git a/packages/gil/package.json b/packages/gil/package.json index 22788da4..a7f55eff 100644 --- a/packages/gil/package.json +++ b/packages/gil/package.json @@ -1,73 +1,78 @@ { - "name": "@guildedjs/gil", - "version": "0.5.0", - "description": "Framework for guilded.js that allows you to build bots with ease.", - "author": "Zaid \"Nico\" ", - "license": "MIT", - "main": "dist/index.js", - "types": "types/index.d.ts", - "scripts": { - "test": "ts-node test/index.ts", - "build": "tsc && gen-esm-wrapper . ./dist/index.mjs", - "build:typecheck": "tsc --noEmit", - "prepublishOnly": "rimraf dist/ && rimraf types/ && bun run build", - "release": "npm publish" + "name": "@guildedjs/gil", + "version": "0.5.0", + "description": "Framework for guilded.js that allows you to build bots with ease.", + "author": "Zaid \"Nico\" ", + "license": "MIT", + "main": "dist/index.js", + "types": "types/index.d.ts", + "scripts": { + "test": "ts-node test/index.ts", + "build": "tsc && gen-esm-wrapper . ./dist/index.mjs", + "build:typecheck": "tsc --noEmit", + "prepublishOnly": "rimraf dist/ && rimraf types/ && bun run build", + "release": "npm publish" + }, + "devDependencies": { + "@types/mongoose": "^5.11.97", + "dotenv": "^16.0.3", + "mongoose": "^8.2.3", + "typescript": "5.0.4" + }, + "dependencies": { + "@discordjs/collection": "^1.5.1", + "colorette": "^2.0.20", + "fast-glob": "^3.3.2", + "guilded.js": "workspace:*", + "lexure": "^0.17.0", + "typed-emitter": "^2.1.0" + }, + "contributors": [ + { + "name": "Zaid \"Nico\"", + "email": "contact@nico.engineer", + "url": "https://github.com/zaida04" }, - "devDependencies": { - "dotenv": "^16.0.3", - "typescript": "5.0.4" + { + "name": "Skillz4Killz", + "email": "guildedjs@drskillz.33mail.com", + "url": "https://github.com/Skillz4Killz" }, - "dependencies": { - "@discordjs/collection": "^1.5.1", - "colorette": "^2.0.20", - "guilded.js": "workspace:*" + { + "name": "Uhuh \"Dylan\"", + "email": "dylan@panku.io", + "url": "https://github.com/uhuh" }, - "contributors": [ - { - "name": "Zaid \"Nico\"", - "email": "contact@nico.engineer", - "url": "https://github.com/zaida04" - }, - { - "name": "Skillz4Killz", - "email": "guildedjs@drskillz.33mail.com", - "url": "https://github.com/Skillz4Killz" - }, - { - "name": "Uhuh \"Dylan\"", - "email": "dylan@panku.io", - "url": "https://github.com/uhuh" - }, - { - "name": "DaStormer", - "email": "dastormer@stormdevelopmentz.xyz", - "url": "https://github.com/DaStormer" - } - ], - "exports": { - ".": { - "require": "./dist/index.js", - "import": "./dist/index.mjs" - }, - "./": "./" + { + "name": "DaStormer", + "email": "dastormer@stormdevelopmentz.xyz", + "url": "https://github.com/DaStormer" + } + ], + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" }, - "keywords": [ - "guilded", - "guildedjs", - "guilded.js", - "guilded-api" - ], - "files": [ - "dist", - "types" - ], - "homepage": "https://github.com/zaida04/guilded.js/tree/main/packages/gil#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/zaida04/guilded.js.git" - }, - "bugs": { - "url": "https://github.com/zaida04/guilded.js/issues" - }, - "gitHead": "eee38a19e0bfa812d7136cc78a6bc99e0b402b0c" + "./": "./" + }, + "keywords": [ + "guilded", + "guildedjs", + "guilded.js", + "guilded-api" + ], + "files": [ + "dist", + "types" + ], + "homepage": "https://github.com/zaida04/guilded.js/tree/main/packages/gil#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/zaida04/guilded.js.git" + }, + "bugs": { + "url": "https://github.com/zaida04/guilded.js/issues" + }, + "gitHead": "eee38a19e0bfa812d7136cc78a6bc99e0b402b0c" } \ No newline at end of file