Skip to content

Commit

Permalink
Improvements to command abstraction and support loading components fr…
Browse files Browse the repository at this point in the history
…om subdirectories
  • Loading branch information
jeremy-rifkin committed Aug 6, 2023
1 parent 0ce42c0 commit 6f6c1e0
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 44 deletions.
16 changes: 14 additions & 2 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
};

Expand Down Expand Up @@ -81,6 +82,17 @@ export class TextBasedCommandBuilder<
return this as unknown as TextBasedCommandBuilder<Append<Args, string>, HasDescriptions, HasHandler>;
}

add_user_option(
option: Omit<TextBasedCommandOption, "autocomplete" | "regex">,
): TextBasedCommandBuilder<Append<Args, Discord.User>, HasDescriptions, HasHandler> {
assert(!this.options.has(option.title));
this.options.set(option.title, {
...option,
type: "user",
});
return this as unknown as TextBasedCommandBuilder<Append<Args, Discord.User>, HasDescriptions, HasHandler>;
}

set_handler(
handler: (x: TextBasedCommand, ...args: Args) => any,
): TextBasedCommandBuilder<Args, HasDescriptions, true> {
Expand Down
48 changes: 48 additions & 0 deletions src/components/moderation/mute.ts
Original file line number Diff line number Diff line change
@@ -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]));
}
}
14 changes: 1 addition & 13 deletions src/components/wiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,14 @@ 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";
import { TextBasedCommand, TextBasedCommandBuilder } from "../command.js";

export const wiki_dir = "wiki_articles";

async function* walk_dir(dir: string): AsyncGenerator<string> {
// 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;
Expand Down
12 changes: 12 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -554,3 +555,14 @@ export type JSONValue =
| undefined
| {[x: string]: JSONValue}
| Array<JSONValue>;

export async function* walk_dir(dir: string): AsyncGenerator<string> {
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;
}
}
}
150 changes: 121 additions & 29 deletions src/wheatley.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
SelfClearingMap,
string_split,
zip,
walk_dir,
} from "./utils.js";
import { BotComponent } from "./bot-component.js";
import {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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");
}
Expand Down

0 comments on commit 6f6c1e0

Please sign in to comment.