diff --git a/.eslintrc.yml b/.eslintrc.yml index 4e280376..f41479d7 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -64,7 +64,6 @@ rules: - destructuring: all curly: - error - "prettier/prettier": "error" "@typescript-eslint/no-unused-vars": - warn - destructuredArrayIgnorePattern: ^_ 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/common.ts b/src/common.ts index 96ee32bc..3d7aac73 100644 --- a/src/common.ts +++ b/src/common.ts @@ -5,6 +5,10 @@ import { is_string, M } from "./utils.js"; // Common constants export const MINUTE = 1000 * 60; +export const HOUR = 60 * MINUTE; +export const DAY = 24 * HOUR; +export const MONTH = 30 * DAY; +export const YEAR = 365 * DAY; export const pepereally = "<:pepereally:643881257624666112>"; export const stackoverflow_emote = "<:stackoverflow:1074747016644661258>"; diff --git a/src/components/moderation/moderation-common.ts b/src/components/moderation/moderation-common.ts new file mode 100644 index 00000000..a24a293a --- /dev/null +++ b/src/components/moderation/moderation-common.ts @@ -0,0 +1,249 @@ +import * as Discord from "discord.js"; + +import { strict as assert } from "assert"; + +import { SleepList, critical_error, time_to_human, unwrap } from "../../utils.js"; +import { BotComponent } from "../../bot-component.js"; +import { TextBasedCommand } from "../../command.js"; +import { Wheatley } from "../../wheatley.js"; + +import * as mongo from "mongodb"; +import { colors } from "../../common.js"; + +/* + * !mute !unmute + * !ban !unban + * !kick + * !rolepersist add/remove + * !temprole + * !warn + * !noofftopic + * + * !reason + * !duration + * !expunge !unexpunge + * !modlogs + * !case + * + * !purge + * !lockdown + * !note + * + * Notifications + * Buttons for !case + * Link users to modmail for appeals, also include appeal info in dm notifications + * + */ + +export type moderation_type = "mute" | "warn" | "ban" | "kick" | "no off-topic" | "rolepersist"; + +export type moderation_edit_info = { + moderator: string; + moderator_name: string; + timestamp: number; + reason: string | null; +}; + +export type basic_moderation = + | { + type: "mute" | "warn" | "ban" | "kick" | "no off-topic"; + user: string; // snowflake + } + | { + type: "rolepersist"; + user: string; // snowflake + role: string; // snowflake + }; + +export type moderation_entry = basic_moderation & { + case_number: number; + user_name: string; + moderator: string; // snowflake + moderator_name: string; + reason: string | null; + issued_at: number; // milliseconds since epoch + duration: number | null; // milliseconds + active: boolean; // active and can be deactivated at some point + removed: moderation_edit_info | null; + expunged: moderation_edit_info | null; +}; + +export const duration_regex = /(?:perm\b|(\d+)\s*([mhdwMy]))/; + +function parse_unit(u: string) { + let factor = 1000; // in ms + switch (u) { + case "y": + factor *= 365; // 365 days, fallthrough + case "d": + factor *= 24; // 24 hours, fallthrough + case "h": + factor *= 60; // 60 minutes, fallthrough + case "m": + factor *= 60; // 60 seconds + break; + // Weeks and months can't be folded into the above as nicely + case "w": + factor *= 7 * parse_unit("d"); + break; + case "M": + factor *= 30 * parse_unit("d"); + break; + default: + assert(false, "Unexpected unit"); + } + return factor; +} + +export function parse_duration(duration: string) { + const match = duration.match(duration_regex); + assert(match); + if (duration == "perm") { + return null; + } else { + const [_, n, unit] = match; + return parseInt(n) * parse_unit(unit); + } +} + +export abstract class ModerationComponent extends BotComponent { + abstract get type(): moderation_type; + + // Sorted by moderation end time + sleep_list: SleepList, mongo.BSON.ObjectId>; + timer: NodeJS.Timer | null = null; + + constructor(wheatley: Wheatley) { + super(wheatley); + this.sleep_list = new SleepList(this.handle_moderation_expire.bind(this), item => item._id); + } + + override async on_ready() { + const moderations = await this.wheatley.database.moderations.find({ type: this.type, active: true }).toArray(); + // Any catch up will be done in order + this.sleep_list.bulk_insert( + moderations + .filter(entry => entry.duration !== null) + .map(entry => [entry.issued_at + unwrap(entry.duration), entry]), + ); + // Ensure moderations are in place + for (const moderation of moderations.sort( + (a, b) => a.issued_at + unwrap(a.duration) - (b.issued_at + unwrap(b.duration)), + )) { + try { + if (!(await this.is_moderation_applied(moderation))) { + await this.apply_moderation(moderation); + } + } catch (e) { + critical_error(e); + } + } + } + + // Address users trying to leave and rejoin + override async on_guild_member_add(member: Discord.GuildMember) { + const moderations = await this.wheatley.database.moderations + .find({ user: member.user.id, type: this.type, active: true }) + .toArray(); + for (const moderation of moderations) { + if (!(await this.is_moderation_applied(moderation))) { + await this.apply_moderation(moderation); + } + } + } + + abstract apply_moderation(entry: mongo.WithId): Promise; + abstract remove_moderation(entry: mongo.WithId): Promise; + abstract is_moderation_applied(moderation: basic_moderation): Promise; + + async add_new_moderation(entry: mongo.WithId) { + await this.apply_moderation(entry); + if (entry.duration) { + this.sleep_list.insert([entry.issued_at + entry.duration, entry]); + } + } + + async handle_moderation_expire(entry: mongo.WithId) { + if (await this.is_moderation_applied(entry)) { + await this.remove_moderation(entry); + // remove database entry + await this.wheatley.database.moderations.updateOne( + { _id: entry._id }, + { + $set: { + active: false, + removed: { + moderator: this.wheatley.id, + moderator_name: "Wheatley", + reason: "Auto", + timestamp: Date.now(), + }, + }, + }, + ); + } + } + + async get_case_id() { + return unwrap( + ( + await this.wheatley.database.wheatley.findOneAndUpdate( + { id: "main" }, + { + $inc: { + moderation_case_number: 1, + }, + }, + { + returnDocument: "after", + }, + ) + ).value, + ).moderation_case_number; + } + + async reply_with_error(command: TextBasedCommand, message: string) { + await command.reply({ + embeds: [ + new Discord.EmbedBuilder() + .setColor(colors.alert_color) + .setTitle("Error") + .setDescription(`<:error:1138616562958483496> ***${message}***`), + ], + }); + } + + async notify( + command: TextBasedCommand, + user: Discord.User, + action: string, + document: moderation_entry, + show_appeal_info = true, + ) { + await ( + await user.createDM() + ).send({ + embeds: [ + new Discord.EmbedBuilder() + .setColor(colors.color) + .setDescription( + `You have been ${action} in Together C & C++.\n` + + `Duration: ${document.duration ? time_to_human(document.duration) : "Permanent"}` + + `Reason: ${document.reason}` + + (show_appeal_info + ? "\n" + + `To appeal this you may open a modmail in Server Guide -> #rules ` + + `or reach out to a staff member.` + : ""), + ), + ], + }); + await command.reply({ + embeds: [ + new Discord.EmbedBuilder() + .setColor(colors.color) + .setDescription(`<:success:1138616548630745088> ***${user.displayName} was ${action}***`), + ], + }); + } +} diff --git a/src/components/moderation/mute.ts b/src/components/moderation/mute.ts new file mode 100644 index 00000000..77e46e42 --- /dev/null +++ b/src/components/moderation/mute.ts @@ -0,0 +1,152 @@ +import * as Discord from "discord.js"; + +import { strict as assert } from "assert"; + +import { M, critical_error, unwrap } from "../../utils.js"; +import { Wheatley } from "../../wheatley.js"; +import { TextBasedCommand, TextBasedCommandBuilder } from "../../command.js"; +import { + ModerationComponent, + basic_moderation, + duration_regex, + moderation_entry, + moderation_type, + parse_duration, +} from "./moderation-common.js"; + +import * as mongo from "mongodb"; + +/** + * Implements !mute + */ +export default class Mute extends ModerationComponent { + get type(): moderation_type { + return "mute"; + } + + constructor(wheatley: Wheatley) { + super(wheatley); + + this.add_command( + new TextBasedCommandBuilder("wmute") + .set_permissions(Discord.PermissionFlagsBits.BanMembers) + .set_description("wmute") + .add_user_option({ + title: "user", + description: "User to mute", + required: true, + }) + .add_string_option({ + title: "duration", + description: "Duration", + regex: duration_regex, + required: true, + }) + .add_string_option({ + title: "reason", + description: "Reason", + required: true, + }) + .set_handler(this.mute_handler.bind(this)), + ); + + this.add_command( + new TextBasedCommandBuilder("wunmute") + .set_permissions(Discord.PermissionFlagsBits.BanMembers) + .set_description("wunmute") + .add_user_option({ + title: "user", + description: "User to unmute", + required: true, + }) + .add_string_option({ + title: "reason", + description: "Reason", + required: true, + }) + .set_handler(this.unmute_handler.bind(this)), + ); + } + + async apply_moderation(entry: mongo.WithId) { + M.info(`Applying mute to ${entry.user_name}`); + const member = await this.wheatley.TCCPP.members.fetch(entry.user); + await member.roles.add(this.wheatley.muted_role); + } + + async remove_moderation(entry: mongo.WithId) { + M.info(`Removing mute from ${entry.user_name}`); + const member = await this.wheatley.TCCPP.members.fetch(entry.user); + await member.roles.remove(this.wheatley.muted_role); + this.sleep_list.remove(entry._id); + } + + async is_moderation_applied(moderation: basic_moderation) { + assert(moderation.type == this.type); + const member = await this.wheatley.TCCPP.members.fetch(moderation.user); + return member.roles.cache.filter(role => role.id == this.wheatley.muted_role.id).size > 0; + } + + async mute_handler(command: TextBasedCommand, user: Discord.User, duration: string, reason: string) { + try { + const base_moderation: basic_moderation = { type: "mute", user: user.id }; + if (await this.is_moderation_applied(base_moderation)) { + await this.reply_with_error(command, "User is already muted"); + } + await this.wheatley.database.lock(); + const document: moderation_entry = { + case_number: await this.get_case_id(), + user: user.id, + user_name: user.displayName, + moderator: command.user.id, + moderator_name: (await command.get_member()).displayName, + type: "mute", + reason, + issued_at: Date.now(), + duration: parse_duration(duration), + active: true, + removed: null, + expunged: null, + }; + const res = await this.wheatley.database.moderations.insertOne(document); + await this.add_new_moderation({ + _id: res.insertedId, + ...document, + }); + await this.notify(command, user, "muted", document); + } catch (e) { + await this.reply_with_error(command, "Error applying mute"); + critical_error(e); + } finally { + this.wheatley.database.unlock(); + } + } + + async unmute_handler(command: TextBasedCommand, user: Discord.User, reason: string) { + try { + const res = await this.wheatley.database.moderations.findOneAndUpdate( + { user: user.id, type: "mute", active: true }, + { + $set: { + active: false, + removed: { + moderator: command.user.id, + moderator_name: (await command.get_member()).displayName, + reason: reason, + timestamp: Date.now(), + }, + }, + }, + ); + if (!res.value || !(await this.is_moderation_applied(res.value))) { + await this.reply_with_error(command, "User is not muted"); + } else { + await this.remove_moderation(res.value); + await this.notify(command, user, "unmuted", res.value, false); + } + } catch (e) { + await this.reply_with_error(command, "Error unmuting"); + critical_error(e); + } + } +} diff --git a/src/components/nodistractions.ts b/src/components/nodistractions.ts index d81eaeff..c4a781ee 100644 --- a/src/components/nodistractions.ts +++ b/src/components/nodistractions.ts @@ -23,6 +23,8 @@ import { TextBasedCommand, TextBasedCommandBuilder } from "../command.js"; * - Remove role and database entry */ +// TODO: Rephrase in terms of a moderation component + function parse_unit(u: string) { let factor = 1000; // in ms switch (u) { diff --git a/src/components/notify-about-brand-new-users.ts b/src/components/notify-about-brand-new-users.ts index 4053e036..672ac273 100644 --- a/src/components/notify-about-brand-new-users.ts +++ b/src/components/notify-about-brand-new-users.ts @@ -1,6 +1,6 @@ import * as Discord from "discord.js"; import { strict as assert } from "assert"; -import { critical_error, diff_to_human, M } from "../utils.js"; +import { critical_error, time_to_human, M } from "../utils.js"; import { colors, MINUTE } from "../common.js"; import { BotComponent } from "../bot-component.js"; import { Wheatley } from "../wheatley.js"; @@ -25,7 +25,7 @@ export default class NotifyAboutBrandNewUsers extends BotComponent { .setDescription( `User <@${member.user.id}>'s account was created at created at:` + ` \n` + - `Account age: ${diff_to_human(Date.now() - member.user.createdTimestamp)}`, + `Account age: ${time_to_human(Date.now() - member.user.createdTimestamp)}`, ) .setFooter({ text: `ID: ${member.id}`, diff --git a/src/components/speedrun.ts b/src/components/speedrun.ts index 064ecdf1..fc1546be 100644 --- a/src/components/speedrun.ts +++ b/src/components/speedrun.ts @@ -1,6 +1,6 @@ import * as Discord from "discord.js"; import { strict as assert } from "assert"; -import { critical_error, diff_to_human, M } from "../utils.js"; +import { critical_error, time_to_human, M } from "../utils.js"; import { colors } from "../common.js"; import { BotComponent } from "../bot-component.js"; import { Wheatley } from "../wheatley.js"; @@ -32,7 +32,7 @@ export default class Speedrun extends BotComponent { // the tracker already (i.e. longer than 30 minutes, not a speedrun) return; } - M.log("Ban speedrun", diff_to_human(now - entry.joined_at), user.id, user.tag); + M.log("Ban speedrun", time_to_human(now - entry.joined_at), user.id, user.tag); // .purged set by raidpurge (yes I know it's checked above), currently_banning used by anti-scambot // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const is_auto_ban = entry.purged || this.wheatley.tracker.currently_banning.has(user.id); @@ -46,7 +46,7 @@ export default class Speedrun extends BotComponent { .setDescription( `User <@${user.id}> joined at and` + ` banned at .\n` + - `Final timer: ${diff_to_human(now - entry.joined_at)}.` + + `Final timer: ${time_to_human(now - entry.joined_at)}.` + (is_auto_ban ? "\n**AUTO BAN**" : ""), ) .setFooter({ diff --git a/src/components/the-button.ts b/src/components/the-button.ts index ebb1e116..f5c54ca3 100644 --- a/src/components/the-button.ts +++ b/src/components/the-button.ts @@ -1,7 +1,7 @@ import * as Discord from "discord.js"; import { strict as assert } from "assert"; -import { M, critical_error, diff_to_human, floor, round, unwrap } from "../utils.js"; -import { MINUTE, colors, is_authorized_admin } from "../common.js"; +import { M, critical_error, time_to_human, floor, round, unwrap } from "../utils.js"; +import { DAY, MINUTE, colors, is_authorized_admin } from "../common.js"; import { BotComponent } from "../bot-component.js"; import { Wheatley } from "../wheatley.js"; @@ -38,8 +38,6 @@ function F(ms: number) { return sum; } -const DAY = 24 * 60 * MINUTE; - const BUTTON_EPOCH = 1675142409000; const PRESS_TIMEOUT = DAY; @@ -305,7 +303,7 @@ export default class TheButton extends BotComponent { `Total presses of The Button: \`${this.button_presses}\`\n` + `Total points collected: \`${round(total_points_assigned, 1)}\`\n` + `Players: \`${count}\`\n` + - `Longest time since reset: \`${diff_to_human(this.longest_time_without_reset)}\``, + `Longest time since reset: \`${time_to_human(this.longest_time_without_reset)}\``, ); await interaction.reply({ embeds: [embed], 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/infra/database-interface.ts b/src/infra/database-interface.ts index e587e8d1..bb9461ef 100644 --- a/src/infra/database-interface.ts +++ b/src/infra/database-interface.ts @@ -10,6 +10,7 @@ import { auto_delete_threshold_notifications, starboard_entry } from "../compone import { button_scoreboard_entry } from "../components/the-button.js"; import { TRACKER_START_TIME, suggestion_entry } from "../components/server-suggestion-tracker.js"; import { link_blacklist_entry } from "../private-types.js"; +import { moderation_entry } from "../components/moderation/moderation-common.js"; export class WheatleyDatabase { private mutex = new Mutex(); @@ -74,6 +75,7 @@ export class WheatleyDatabase { ignored_emojis: [], negative_emojis: [], }, + moderation_case_number: 0, }; const ires = await wheatley.insertOne(document); assert(ires.acknowledged); @@ -116,6 +118,7 @@ export type WheatleyDatabaseProxy = WheatleyDatabase & { server_suggestions: mongo.Collection; starboard_entries: mongo.Collection; wheatley: mongo.Collection; + moderations: mongo.Collection; }; // & { // [key: string] : Promise diff --git a/src/utils.ts b/src/utils.ts index da54ff0a..93ba6823 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,9 +4,10 @@ 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"; +import { DAY, HOUR, MINUTE, MONTH, YEAR, zelis_id } from "./common.js"; import { strict as assert } from "assert"; function get_caller_location() { @@ -95,20 +96,30 @@ function pluralize(n: number, word: string) { } } -export function diff_to_human(diff: number) { - if (diff >= 60 * MINUTE) { - const hours = Math.floor(diff / (60 * MINUTE)); - diff %= 60 * MINUTE; - const minutes = Math.floor(diff / MINUTE); - diff %= MINUTE; - const seconds = Math.round(diff / 1000); - return `${pluralize(hours, "hour")} ${pluralize(minutes, "minute")} ${pluralize(seconds, "second")}`; +export function time_to_human(diff: number, seconds_with_higher_precision = true): string { + if (diff >= YEAR) { + const years = Math.floor(diff / YEAR); + return `${pluralize(years, "year")} ${time_to_human(diff % YEAR, false)}`; + } + if (diff >= MONTH) { + const months = Math.floor(diff / MONTH); + return `${pluralize(months, "month")} ${time_to_human(diff % MONTH, false)}`; + } + if (diff >= DAY) { + const days = Math.floor(diff / DAY); + return `${pluralize(days, "day")} ${time_to_human(diff % DAY, false)}`; + } + if (diff >= HOUR) { + const hours = Math.floor(diff / HOUR); + return `${pluralize(hours, "hour")} ${time_to_human(diff % HOUR, false)}`; } if (diff >= MINUTE) { - return `${pluralize(Math.floor(diff / MINUTE), "minute")} ${pluralize((diff % MINUTE) / 1000, "second")}`; - } else { - return `${pluralize(diff / 1000, "second")}`; + return `${pluralize(Math.floor(diff / MINUTE), "minute")} ${time_to_human( + diff % MINUTE, + seconds_with_higher_precision && true, + )}`; } + return `${pluralize(round(diff / 1000, seconds_with_higher_precision ? 1 : 0), "second")}`; } const code_re = /`[^`]+`(?!`)/gi; @@ -554,3 +565,97 @@ 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; + } + } +} + +const INT_MAX = 0x7fffffff; + +export class SleepList { + // timestamp to fire at, T + list: [number, T][] = []; + timer: NodeJS.Timer | null = null; + handler: (item: T) => Promise; + get_id: (item: T) => ID; + + constructor(handler: (item: T) => Promise, get_id: (item: T) => ID) { + this.handler = handler; + this.get_id = get_id; + } + + destroy() { + if (this.timer) { + clearTimeout(this.timer); + } + } + + // Must be called from the timeout's callback + async handle_timer() { + this.timer = null; + try { + assert(this.list.length > 0, "Sleep list empty??"); + const [target_time, item] = this.list[0]; + // Make sure we're actually supposed to run. 100ms buffer, just to be generous. + // This can happen for excessively long sleeps > INT_MAX ms + if (target_time <= Date.now() + 100) { + this.list.shift(); + await this.handler(item); + } + } catch (e) { + critical_error(e); + } finally { + this.reset_timer(); + } + } + + reset_timer() { + if (this.timer !== null) { + clearTimeout(this.timer); + } + if (this.list.length > 0) { + const delta = Math.max(this.list[0][0] - Date.now(), 0); + this.timer = setTimeout( + () => { + this.handle_timer().catch(critical_error).finally(this.reset_timer.bind(this)); + }, + Math.min(delta, INT_MAX), + ); + } + } + + bulk_insert(items: [number, T][]) { + this.list.push(...items); + this.list = this.list.sort((a, b) => a[0] - b[0]); + this.reset_timer(); + } + + insert(item: [number, T]) { + this.list.push(item); + let i = 0; + for (; i < this.list.length; i++) { + if (this.list[i][0] >= item[0]) { + break; + } + } + this.list.splice(i, 0, item); + this.reset_timer(); + } + + remove(id: ID) { + this.list = this.list.filter(([_, entry]) => this.get_id(entry) !== id); + this.reset_timer(); + } + + replace(id: ID, item: [number, T]) { + this.list = this.list.filter(([_, entry]) => this.get_id(entry) !== id); + this.insert(item); + } +} diff --git a/src/wheatley.ts b/src/wheatley.ts index 711a64c5..80deb03b 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -39,12 +39,15 @@ import { SelfClearingMap, string_split, zip, + walk_dir, + unwrap, } from "./utils.js"; import { BotComponent } from "./bot-component.js"; import { BotCommand, BotModalHandler, BotTextBasedCommand, + CommandAbstractionReplyOptions, MessageContextMenuCommandBuilder, ModalHandler, TextBasedCommand, @@ -65,6 +68,13 @@ function create_basic_embed(title: string | undefined, color: number, content: s return embed; } +function create_error_reply(message: string): Discord.BaseMessageOptions & CommandAbstractionReplyOptions { + return { + embeds: [create_basic_embed(undefined, colors.red, message)], + should_text_reply: true, + }; +} + type text_command_map_target = { command: TextBasedCommand; deletable: boolean; @@ -95,6 +105,7 @@ export type wheatley_db_info = { ignored_emojis: string[]; negative_emojis: string[]; }; + moderation_case_number: number; }; export class Wheatley extends EventEmitter { @@ -120,6 +131,7 @@ export class Wheatley extends EventEmitter { skill_role_suggestion_log: Discord.TextChannel; starboard_channel: Discord.TextChannel; staff_action_log_channel: Discord.TextChannel; + muted_role: Discord.Role; database: WheatleyDatabaseProxy; @@ -185,17 +197,21 @@ 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")) { + const default_export = (await import(`../${file.replace(".ts", ".js")}`)).default; + if (default_export !== undefined) { + await this.add_component(default_export); + } } 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, - ); - if (file.endsWith("link-blacklist.ts")) { - this.link_blacklist = component; + for await (const file of walk_dir("src/wheatley-private/components")) { + const default_export = (await import(`../${file.replace(".ts", ".js")}`)).default; + if (default_export !== undefined) { + const component = await this.add_component(default_export); + if (file.endsWith("link-blacklist.ts")) { + this.link_blacklist = component; + } } } } @@ -224,6 +240,7 @@ export class Wheatley extends EventEmitter { })(), (async () => { this.TCCPP = await this.client.guilds.fetch(TCCPP_ID); + this.muted_role = unwrap(this.TCCPP.roles.cache.find(role => role.name === "Muted")); })(), (async () => { this.cpp_help = await fetch_forum_channel(cpp_help_id); @@ -384,6 +401,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,45 +437,83 @@ 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); this.register_text_command(message, command_obj); if (command.permissions !== undefined) { if (!(await command_obj.get_member()).permissions.has(command.permissions)) { - await command_obj.reply({ - embeds: [create_basic_embed(undefined, colors.red, "Invalid permissions")], - should_text_reply: true, - }); + await command_obj.reply(create_error_reply("Invalid permissions")); return; } } // 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( + create_error_reply(`Required argument "${option.title}" not found`), + ); + return; + } + } else if (i == command.options.size - 1) { + if (command_body == "") { + await command_obj.reply( + create_error_reply(`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( + create_error_reply(`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(create_error_reply(`Unable to find user`)); + return; + } + } else { + await command_obj.reply( + create_error_reply(`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(create_error_reply(`Unexpected parameters provided`)); + return; } /*for(const option of command.options.values()) { // NOTE: Temp for now @@ -565,14 +628,21 @@ export class Wheatley extends EventEmitter { if (option.type == "string") { const option_value = interaction.options.getString(option.title); if (!option_value && option.required) { - await command_object.reply({ - embeds: [create_basic_embed(undefined, colors.red, "Required argument not found")], - ephemeral_if_possible: true, - }); + await command_object.reply(create_error_reply("Required argument not found"), true); critical_error("this shouldn't happen"); return; } + if (option_value && option.regex && !option_value.trim().match(option.regex)) { + await command_object.reply( + create_error_reply(`Argument ${option.title} doesn't match expected format`), + 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"); } diff --git a/test/utils.ts b/test/utils.ts index 0017777c..fd97731d 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,23 +1,23 @@ import { assert, expect } from "chai"; -import { diff_to_human, string_split } from "../src/utils.js"; +import { time_to_human, string_split } from "../src/utils.js"; describe("Diff to Human Tests", () => { it("should compute the right values for sub-minute diffs", done => { - expect(diff_to_human(5500)).to.equal("5.5 seconds"); - expect(diff_to_human(1000)).to.equal("1 second"); + expect(time_to_human(5500)).to.equal("5.5 seconds"); + expect(time_to_human(1000)).to.equal("1 second"); done(); }); it("should compute the right values for sub-hours diffs", done => { - expect(diff_to_human(60_000 + 5500)).to.equal("1 minute 5.5 seconds"); - expect(diff_to_human(2 * 60_000 + 10_000)).to.equal("2 minutes 10 seconds"); + expect(time_to_human(60_000 + 5500)).to.equal("1 minute 5.5 seconds"); + expect(time_to_human(2 * 60_000 + 10_000)).to.equal("2 minutes 10 seconds"); done(); }); it("should compute the right values for >hour diffs", done => { - expect(diff_to_human(61 * 60_000 + 5700)).to.equal("1 hour 1 minute 6 seconds"); - expect(diff_to_human(135 * 60_000 + 10_000)).to.equal("2 hours 15 minutes 10 seconds"); + expect(time_to_human(61 * 60_000 + 5700)).to.equal("1 hour 1 minute 6 seconds"); + expect(time_to_human(135 * 60_000 + 10_000)).to.equal("2 hours 15 minutes 10 seconds"); done(); }); });