diff --git a/src/components/moderation/moderation-common.ts b/src/components/moderation/moderation-common.ts new file mode 100644 index 00000000..9e6acccc --- /dev/null +++ b/src/components/moderation/moderation-common.ts @@ -0,0 +1,163 @@ +import * as Discord from "discord.js"; + +import { strict as assert } from "assert"; + +import { critical_error, 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"; + +export type moderation_type = "mute" | "warn" | "ban" | "kick" | "no off-topic" | "rolepersist"; + +export type moderation_entry = { + case_number: number; + user: string; + user_name: string; + moderator: string; + moderator_name: string; + type: moderation_type; + reason: string | null; + issued_at: number; // milliseconds since epoch + duration: number; // milliseconds + active: boolean; + removal_mod?: string; + removal_mod_name?: string; + removal_timestamp?: number; // milliseconds since epoch + removal_reason?: string | null; +}; + +export const duration_regex = /(?:perm\b|\d+\s*[mhdwMy])/; + +const INT_MAX = 0x7fffffff; + +export function parse_duration(duration: string) { + // TODO + return 0; +} + +export abstract class ModerationComponent extends BotComponent { + abstract get type(): moderation_type; + + // Sorted by moderation end time + sleep_list: mongo.WithId[] = []; + timer: NodeJS.Timer | null = null; + + constructor(wheatley: Wheatley) { + super(wheatley); + } + + override async on_ready() { + // TODO: Implement catch-up / ensuring moderations are in place + const moderations = await this.wheatley.database.moderations.find({ type: "mute", active: true }).toArray(); + if (moderations.length > 0) { + this.sleep_list = moderations.sort((a, b) => a.issued_at + a.duration - (b.issued_at + b.duration)); + this.set_timer(); + } + } + + async handle_timer() { + this.timer = null; + try { + // sanity checks + assert(this.sleep_list.length > 0); + if (this.sleep_list[0].issued_at + this.sleep_list[0].duration > Date.now()) { + // can happen under excessively long sleeps + assert(this.sleep_list[0].duration > INT_MAX); + this.set_timer(); // set next timer + return; + } + // pop entry and remove role + const entry = this.sleep_list.shift()!; + await this.remove_moderation(entry); + // remove database entry + await this.wheatley.database.moderations.updateOne( + { _id: entry._id }, + { + $set: { + active: false, + removal_mod: this.wheatley.id, + removal_mod_name: "Wheatley", + removal_reason: "Auto", + removal_timestamp: Date.now(), + }, + }, + ); + // reschedule, intentionally not rescheduling + if (this.sleep_list.length > 0) { + this.set_timer(); + } + } catch (e) { + critical_error(e); + } + } + + abstract add_moderation(entry: mongo.WithId): Promise; + abstract remove_moderation(entry: mongo.WithId): Promise; + + set_timer() { + assert(this.timer == null); + assert(this.sleep_list.length > 0); + const next = this.sleep_list[0]; + // next.issued_at + next.duration - Date.now() but make sure overflow is prevented + const sleep_time = next.issued_at - Date.now() + next.duration; + this.timer = setTimeout( + () => { + this.handle_timer().catch(critical_error); + }, + Math.min(sleep_time, INT_MAX), + ); + } + + async register_moderation(moderation: mongo.WithId) { + // TODO + void 0; + } + + async moderation_handler(command: TextBasedCommand, user: Discord.User, duration: string, reason: string) { + // TODO: Permissions? + try { + await this.wheatley.database.lock(); + const case_number = unwrap( + ( + await this.wheatley.database.wheatley.findOneAndUpdate( + { id: "main" }, + { + $inc: { + moderation_case_number: 1, + }, + }, + { + returnDocument: "after", + }, + ) + ).value, + ).moderation_case_number; + const member = await this.wheatley.TCCPP.members.fetch(command.user.id); + const document: moderation_entry = { + case_number, + user: user.id, + user_name: user.displayName, + moderator: command.user.id, + moderator_name: member.displayName, + type: this.type, + reason, + issued_at: Date.now(), + duration: parse_duration(duration), + active: true, + }; + const res = await this.wheatley.database.moderations.insertOne(document); + await this.add_moderation({ + _id: res.insertedId, + ...document, + }); + await this.register_moderation({ + _id: res.insertedId, + ...document, + }); + } finally { + this.wheatley.database.unlock(); + } + } +} diff --git a/src/components/moderation/mute.ts b/src/components/moderation/mute.ts index 867d8bc4..20195f9a 100644 --- a/src/components/moderation/mute.ts +++ b/src/components/moderation/mute.ts @@ -2,18 +2,19 @@ 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 { M, unwrap } from "../../utils.js"; import { Wheatley } from "../../wheatley.js"; -import { TextBasedCommand, TextBasedCommandBuilder } from "../../command.js"; +import { TextBasedCommandBuilder } from "../../command.js"; +import { ModerationComponent, duration_regex, moderation_entry, moderation_type } from "./moderation-common.js"; + +import * as mongo from "mongodb"; /** * Implements !mute */ -export default class Mute extends BotComponent { - static override get is_freestanding() { - return true; +export default class Mute extends ModerationComponent { + get type(): moderation_type { + return "mute"; } constructor(wheatley: Wheatley) { @@ -30,7 +31,7 @@ export default class Mute extends BotComponent { .add_string_option({ title: "duration", description: "Duration", - regex: /(?:perm\b|\d+\s*[mhdwMy])/, + regex: duration_regex, required: true, }) .add_string_option({ @@ -38,11 +39,15 @@ export default class Mute extends BotComponent { description: "Reason", required: true, }) - .set_handler(this.handler.bind(this)), + .set_handler(this.moderation_handler.bind(this)), ); } - async handler(command: TextBasedCommand, user: Discord.User, time: string, reason: string) { - await command.reply(JSON.stringify([user.displayName, time, reason])); + async add_moderation(entry: mongo.WithId) { + // TODO + } + + async remove_moderation(entry: mongo.WithId) { + // TODO } } 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/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/wheatley.ts b/src/wheatley.ts index 624e7abc..ca898553 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -96,6 +96,7 @@ export type wheatley_db_info = { ignored_emojis: string[]; negative_emojis: string[]; }; + moderation_case_number: number; }; export class Wheatley extends EventEmitter {