diff --git a/src/command.ts b/src/command.ts index 31ff31ec..fb6d38ce 100644 --- a/src/command.ts +++ b/src/command.ts @@ -8,12 +8,13 @@ import { forge_snowflake } from "./components/snowflake.js"; import { unwrap, is_string, critical_error } from "./utils.js"; import { Wheatley } from "./wheatley.js"; -export type TextBasedCommandOptionType = "string"; +export type TextBasedCommandOptionType = "string" | "user"; export type TextBasedCommandOption = { title: string; description: string; - required?: boolean; + required?: boolean; // TODO: Currently not implemented for text commands + regex?: RegExp; autocomplete?: (partial: string, command_name: string) => { name: string; value: string }[]; }; @@ -81,6 +82,17 @@ export class TextBasedCommandBuilder< return this as unknown as TextBasedCommandBuilder, HasDescriptions, HasHandler>; } + add_user_option( + option: Omit, + ): TextBasedCommandBuilder, HasDescriptions, HasHandler> { + assert(!this.options.has(option.title)); + this.options.set(option.title, { + ...option, + type: "user", + }); + return this as unknown as TextBasedCommandBuilder, HasDescriptions, HasHandler>; + } + set_handler( handler: (x: TextBasedCommand, ...args: Args) => any, ): TextBasedCommandBuilder { diff --git a/src/components/moderation/mute.ts b/src/components/moderation/mute.ts new file mode 100644 index 00000000..867d8bc4 --- /dev/null +++ b/src/components/moderation/mute.ts @@ -0,0 +1,48 @@ +import * as Discord from "discord.js"; + +import { strict as assert } from "assert"; + +import { M } from "../../utils.js"; +import { colors } from "../../common.js"; +import { BotComponent } from "../../bot-component.js"; +import { Wheatley } from "../../wheatley.js"; +import { TextBasedCommand, TextBasedCommandBuilder } from "../../command.js"; + +/** + * Implements !mute + */ +export default class Mute extends BotComponent { + static override get is_freestanding() { + return true; + } + + constructor(wheatley: Wheatley) { + super(wheatley); + + this.add_command( + new TextBasedCommandBuilder("wmute") + .set_description("wmute") + .add_user_option({ + title: "user", + description: "User to mute", + required: true, + }) + .add_string_option({ + title: "duration", + description: "Duration", + regex: /(?:perm\b|\d+\s*[mhdwMy])/, + required: true, + }) + .add_string_option({ + title: "reason", + description: "Reason", + required: true, + }) + .set_handler(this.handler.bind(this)), + ); + } + + async handler(command: TextBasedCommand, user: Discord.User, time: string, reason: string) { + await command.reply(JSON.stringify([user.displayName, time, reason])); + } +} diff --git a/src/components/wiki.ts b/src/components/wiki.ts index 33699933..aaaa0c85 100644 --- a/src/components/wiki.ts +++ b/src/components/wiki.ts @@ -4,7 +4,7 @@ import * as Discord from "discord.js"; import * as fs from "fs"; import * as path from "path"; -import { M, unwrap } from "../utils.js"; +import { M, unwrap, walk_dir } from "../utils.js"; import { bot_spam_id, colors, resources_channel_id, rules_channel_id, stackoverflow_emote } from "../common.js"; import { BotComponent } from "../bot-component.js"; import { Wheatley } from "../wheatley.js"; @@ -12,18 +12,6 @@ import { TextBasedCommand, TextBasedCommandBuilder } from "../command.js"; export const wiki_dir = "wiki_articles"; -async function* walk_dir(dir: string): AsyncGenerator { - // todo: duplicate - for (const f of await fs.promises.readdir(dir)) { - const file_path = path.join(dir, f).replace(/\\/g, "/"); - if ((await fs.promises.stat(file_path)).isDirectory()) { - yield* walk_dir(file_path); - } else { - yield file_path; - } - } -} - type WikiArticle = { title: string; body?: string; diff --git a/src/utils.ts b/src/utils.ts index da54ff0a..1c9fcb66 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import chalk from "chalk"; import XXH from "xxhashjs"; import * as fs from "fs"; +import * as path from "path"; import { execFile, ExecFileOptions } from "child_process"; import { MINUTE, zelis_id } from "./common.js"; @@ -554,3 +555,14 @@ export type JSONValue = | undefined | {[x: string]: JSONValue} | Array; + +export async function* walk_dir(dir: string): AsyncGenerator { + for (const f of await fs.promises.readdir(dir)) { + const file_path = path.join(dir, f).replace(/\\/g, "/"); + if ((await fs.promises.stat(file_path)).isDirectory()) { + yield* walk_dir(file_path); + } else { + yield file_path; + } + } +} diff --git a/src/wheatley.ts b/src/wheatley.ts index 711a64c5..624e7abc 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -39,6 +39,7 @@ import { SelfClearingMap, string_split, zip, + walk_dir, } from "./utils.js"; import { BotComponent } from "./bot-component.js"; import { @@ -185,15 +186,13 @@ export class Wheatley extends EventEmitter { } }); - for (const file of await fs.readdir("src/components")) { - await this.add_component((await import(`./components/${file.replace(".ts", ".js")}`)).default); + for await (const file of walk_dir("src/components")) { + await this.add_component((await import(`../${file.replace(".ts", ".js")}`)).default); } if (await directory_exists("src/wheatley-private/components")) { - for (const file of await fs.readdir("src/wheatley-private/components")) { - const component = await this.add_component( - (await import(`./wheatley-private/components/${file.replace(".ts", ".js")}`)).default, - ); + for await (const file of walk_dir("src/wheatley-private/components")) { + const component = await this.add_component((await import(`../${file.replace(".ts", ".js")}`)).default); if (file.endsWith("link-blacklist.ts")) { this.link_blacklist = component; } @@ -384,6 +383,14 @@ export class Wheatley extends EventEmitter { .setAutocomplete(!!option.autocomplete) .setRequired(!!option.required), ); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (option.type == "user") { + djs_command.addUserOption(slash_option => + slash_option + .setName(option.title) + .setDescription(option.description) + .setRequired(!!option.required), + ); } else { assert(false, "unhandled option type"); } @@ -412,7 +419,6 @@ export class Wheatley extends EventEmitter { const command_name = match[1]; if (command_name in this.text_commands) { const command = this.text_commands[command_name]; - const command_options: unknown[] = []; const command_obj = prev_command_obj ? new TextBasedCommand(prev_command_obj, command_name, message) : new TextBasedCommand(command_name, message, this); @@ -427,30 +433,100 @@ export class Wheatley extends EventEmitter { } } // TODO: Handle unexpected input? - // NOTE: For now only able to take text input - assert( - [...command.options.values()].every(option => (option.type as any) == "string"), - "unhandled option type", - ); - const parts = string_split( - message.content.substring(match[0].length).trim(), - " ", - command.options.size, - ); + // NOTE: For now only able to take text and user input + // TODO: Handle `required` + let command_body = message.content.substring(match[0].length).trim(); + const command_options: unknown[] = []; for (const [i, option] of [...command.options.values()].entries()) { - if (i >= parts.length && option.required) { - await command_obj.reply({ - embeds: [ - create_basic_embed( - undefined, - colors.red, - `Required argument "${option.title}" not found`, - ), - ], - }); - return; + if (option.type == "string") { + if (option.regex) { + const match = command_body.match(option.regex); + if (match) { + command_options.push(match[0]); + command_body = command_body.slice(match[0].length).trim(); + } else { + await command_obj.reply({ + embeds: [ + create_basic_embed( + undefined, + colors.red, + `Required argument "${option.title}" not found`, + ), + ], + }); + return; + } + } else if (i == command.options.size - 1) { + if (command_body == "") { + await command_obj.reply({ + embeds: [ + create_basic_embed( + undefined, + colors.red, + `Required argument "${option.title}" not found`, + ), + ], + }); + return; + } else { + command_options.push(command_body); + command_body = ""; + } + } else { + const re = /^\S+/; + const match = command_body.match(re); + if (match) { + command_options.push(match[0]); + command_body = command_body.slice(match[0].length).trim(); + } else { + await command_obj.reply({ + embeds: [ + create_basic_embed( + undefined, + colors.red, + `Required argument "${option.title}" not found`, + ), + ], + }); + return; + } + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (option.type == "user") { + const re = /^(?:<@(\d{10,})>|(\d{10,}))/; + const match = command_body.match(re); + if (match) { + try { + const user = await this.client.users.fetch(match[1]); + command_options.push(user); + command_body = command_body.slice(match[0].length).trim(); + } catch (e) { + await command_obj.reply({ + embeds: [create_basic_embed(undefined, colors.red, `Unable to find user`)], + }); + return; + } + } else { + await command_obj.reply({ + embeds: [ + create_basic_embed( + undefined, + colors.red, + `Required argument "${option.title}" not found`, + ), + ], + }); + return; + } + } else { + assert(false, "unhandled option type"); } - command_options.push(parts[i]); + } + if (command_body != "") { + await command_obj.reply({ + embeds: [create_basic_embed(undefined, colors.red, `Unexpected parameters provided`)], + }); + return; } /*for(const option of command.options.values()) { // NOTE: Temp for now @@ -572,7 +648,23 @@ export class Wheatley extends EventEmitter { critical_error("this shouldn't happen"); return; } + if (option_value && option.regex && !option_value.trim().match(option.regex)) { + await command_object.reply({ + embeds: [ + create_basic_embed( + undefined, + colors.red, + `Argument ${option.title} doesn't match expected format`, + ), + ], + ephemeral_if_possible: true, + }); + return; + } command_options.push(option_value ?? ""); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (option.type == "user") { + command_options.push(interaction.options.getUser(option.title)); } else { assert(false, "unhandled option type"); }