diff --git a/src/Makibot.ts b/src/Makibot.ts index a588533..318b688 100644 --- a/src/Makibot.ts +++ b/src/Makibot.ts @@ -8,6 +8,7 @@ import { HookManager } from "./lib/hook"; import { KarmaDatabase, openKarmaDatabase } from "./lib/karma/database"; import { SettingProvider } from "./lib/provider"; import { installCommandInteractionHandler } from "./lib/interaction"; +import { ModerationRepository, newModRepository } from "./lib/modlog/database"; export default class Makibot extends Client { readonly antiraid: AntiRaid; @@ -18,6 +19,8 @@ export default class Makibot extends Client { private _manager: HookManager; + private _modrepo: ModerationRepository; + public get manager(): HookManager { return this._manager; } @@ -45,7 +48,8 @@ export default class Makibot extends Client { this.once("ready", () => { getDatabase() - .then((db) => { + .then(async (db) => { + this._modrepo = await newModRepository(db); this._provider = new SettingProvider(db, this); return this._provider.init(); }) @@ -81,6 +85,10 @@ export default class Makibot extends Client { return this._provider; } + get modrepo(): ModerationRepository { + return this._modrepo; + } + shutdown(exitCode = 0): void { console.log("The bot was asked to shutdown."); this.destroy(); diff --git a/src/hooks/mod.ts b/src/hooks/mod.ts new file mode 100644 index 0000000..859bf3b --- /dev/null +++ b/src/hooks/mod.ts @@ -0,0 +1,44 @@ +import { Hook } from "../lib/hook"; +import { applyAction } from "../lib/modlog/actions"; +import { notifyPublicModlog } from "../lib/modlog/notifications"; +import { ModEvent, ModEventType } from "../lib/modlog/types"; +import Makibot from "../Makibot"; + +function castRevertType(event: ModEvent): ModEventType { + const types = { + WARN: "UNWARN", + MUTE: "UNMUTE", + }; + return types[event.type]; +} + +function revertEvent(event: ModEvent): ModEvent { + return { + createdAt: new Date(), + expired: false, + guild: event.guild, + type: castRevertType(event), + mod: event.mod, + reason: "(expiración automática)", + target: event.target, + expiresAt: null, + }; +} + +export default class ModService implements Hook { + name = "moderation"; + + constructor(private client: Makibot) { + setInterval(() => this.cleanExpired(), 10000); + this.cleanExpired(); + } + + async cleanExpired(): Promise { + const expired = await this.client.modrepo.retrieveExpired(); + expired.forEach(async (event) => { + const reverseEvent = revertEvent(event); + const persisted = await applyAction(this.client, reverseEvent); + await notifyPublicModlog(this.client, persisted); + }); + } +} diff --git a/src/interactions/commands/mod.ts b/src/interactions/commands/mod.ts new file mode 100644 index 0000000..18befd1 --- /dev/null +++ b/src/interactions/commands/mod.ts @@ -0,0 +1,134 @@ +import { CommandInteraction } from "discord.js"; +import { CommandInteractionHandler } from "../../lib/interaction"; +import { applyAction } from "../../lib/modlog/actions"; +import { notifyPublicModlog } from "../../lib/modlog/notifications"; +import { ModEvent, ModEventType } from "../../lib/modlog/types"; +import { createToast } from "../../lib/response"; +import Server from "../../lib/server"; +import Makibot from "../../Makibot"; + +/** + * Will coerce the subcommand of this command interaction into a valid type. + * While it should not happen, it is useful to test that the given subcommand + * name is one of the approved ones. Also, it is useful to cast this into a + * valid ModEventType for static typing purposes. If an invalid subcommand was + * given for some reason, this function will return null. + * + * @param command the received interaction + * @returns the coerced mod event type, or null if cannot match + */ +function castModEventType(command: CommandInteraction): ModEventType { + const modActions: { [type: string]: ModEventType } = { + warn: "WARN", + unwarn: "UNWARN", + mute: "MUTE", + unmute: "UNMUTE", + ban: "BAN", + kick: "KICK", + }; + const subcommandName = command.options.getSubcommand(); + return modActions[subcommandName] || null; +} + +/** + * Will possibly get the given expiration timestamp parsing the command option. + * Some commands such as WARN and MUTE have an additional parameter regarding + * how much should the affected member wait until the moderation event expires. + * If this interaction had one of these parameters, the function will retrieve + * the proper value and convert it into the real expiration date. + * + * @param command the received interaction + * @returns either the Date object with the expiration date, or null + */ +function castExpirationDate(command: CommandInteraction): Date { + const keys = { + dia: 86400, + hora: 3600, + semana: 86400 * 7, + }; + const givenDuration = command.options.getString("duracion", false) || "dia"; + const duration = keys[givenDuration] || keys["dia"]; + const expiresAt = Date.now() + duration * 1000; + return new Date(expiresAt); +} + +/** Code to execute when invalid preconditions are met (user is not mod, etc). */ +function replyNonModerator(interaction: CommandInteraction): Promise { + return interaction.reply({ + ephemeral: true, + embeds: [ + createToast({ + title: "Acción no permitida", + description: "No se puede aplicar el comando de moderación en este caso", + severity: "error", + target: interaction.user, + }), + ], + }); +} + +function replyModerator(interaction: CommandInteraction): Promise { + return interaction.reply({ + ephemeral: true, + embeds: [ + createToast({ + title: "Acción de moderación aplicada correctamente", + target: interaction.user, + severity: "success", + }), + ], + }); +} + +async function isValidModInteraction(event: CommandInteraction): Promise { + const server = new Server(event.guild); + const originator = await server.member(event.user.id); + const target = await server.member(event.options.get("cuenta", true).value as string); + return event.inGuild() && originator.moderator && !target.moderator && !target.user.bot; +} + +function translateInteractionToModEvent(event: CommandInteraction): ModEvent { + return { + createdAt: new Date(), + expired: false, + guild: event.guildId, + type: castModEventType(event), + mod: event.user.id, + reason: event.options.getString("razon", false) || null, + target: event.options.getUser("cuenta", true).id, + expiresAt: castExpirationDate(event), + }; +} + +function replyValidationError(event: CommandInteraction, error: string): Promise { + return event.reply({ + ephemeral: true, + embeds: [ + createToast({ + title: "Error al aplicar la acción", + description: error, + severity: "error", + target: event.user, + }), + ], + }); +} + +export default class ModCommand implements CommandInteractionHandler { + name = "mod"; + + async handle(event: CommandInteraction): Promise { + const valid = await isValidModInteraction(event); + if (valid) { + const modEvent = translateInteractionToModEvent(event); + await applyAction(event.client as Makibot, modEvent) + .then(async (persisted) => { + await notifyPublicModlog(event.client as Makibot, persisted); + replyModerator(event); + }) + .catch((e: Error) => replyValidationError(event, e.message)); + } else { + replyNonModerator(event); + } + } +} diff --git a/src/interactions/commands/unwarn.ts b/src/interactions/commands/unwarn.ts deleted file mode 100644 index 2987f75..0000000 --- a/src/interactions/commands/unwarn.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CommandInteraction } from "discord.js"; -import Server from "../../lib/server"; -import { CommandInteractionHandler } from "../../lib/interaction"; -import { removeWarn } from "../../lib/warn"; -import { createToast } from "../../lib/response"; - -export default class UnwarnCommand implements CommandInteractionHandler { - name = "unwarn"; - - async handle(event: CommandInteraction): Promise { - if (event.inGuild()) { - const server = new Server(event.guild); - const target = String(event.options.get("target", true).value); - const member = await server.member(target); - - if (member.warned) { - await removeWarn(server, member); - const toast = createToast({ - title: "Warn retirado", - description: `Le has retirado el warn a @${member.user.username}`, - target: member.user, - severity: "success", - }); - return event.reply({ embeds: [toast], ephemeral: true }); - } else { - const toast = createToast({ - title: "Este usuario no tiene warn", - description: "No se le puede quitar un warn", - target: member.user, - severity: "info", - }); - return event.reply({ embeds: [toast], ephemeral: true }); - } - } else { - const toast = createToast({ - title: "Este comando no se puede usar fuera de un servidor", - severity: "error", - }); - (event as CommandInteraction).reply({ - embeds: [toast], - ephemeral: true, - }); - } - } -} diff --git a/src/interactions/commands/warn.ts b/src/interactions/commands/warn.ts deleted file mode 100644 index 59dd21e..0000000 --- a/src/interactions/commands/warn.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { CommandInteraction } from "discord.js"; -import applyWarn from "../../lib/warn"; -import { createToast } from "../../lib/response"; -import { CommandInteractionHandler } from "../../lib/interaction"; -import Server from "../../lib/server"; - -function getDuration(value: string): number { - switch (value) { - case "hour": - return 3600 * 1000; - case "week": - return 86400 * 1000 * 7; - case "day": - default: - return 86400 * 1000; - } -} - -export default class WarnCommand implements CommandInteractionHandler { - name = "warn"; - - async handle(event: CommandInteraction): Promise { - if (event.inGuild()) { - const server = new Server(event.guild); - const target = String(event.options.get("target", true).value); - const member = await server.member(target); - - const reason = event.options.getString("reason", false) || null; - const duration = event.options.getString("duration", false) || "day"; - const realDuration = getDuration(duration); - - if (member.moderator) { - const toast = createToast({ - title: "No se puede aplicar un warn", - description: `@${member.user.username} es un moderador.`, - target: member.user, - severity: "error", - }); - return event.reply({ embeds: [toast], ephemeral: true }); - } else if (member.user.bot) { - const toast = createToast({ - title: "No se puede aplicar un warn", - description: `@${member.user.username} es un bot.`, - target: member.user, - severity: "error", - }); - return event.reply({ embeds: [toast], ephemeral: true }); - } else { - await applyWarn(event.guild, { - user: member.user, - reason, - duration: realDuration, - }); - const toast = createToast({ - title: "Warn aplicado", - description: `Le has aplicado un warn a @${member.user.username}`, - target: member.user, - severity: "success", - }); - return event.reply({ embeds: [toast], ephemeral: true }); - } - } else { - const toast = createToast({ - title: "Este comando no se puede usar fuera de un servidor", - severity: "error", - }); - (event as CommandInteraction).reply({ - embeds: [toast], - ephemeral: true, - }); - } - } -} diff --git a/src/lib/modlog/actions.ts b/src/lib/modlog/actions.ts new file mode 100644 index 0000000..e9896f7 --- /dev/null +++ b/src/lib/modlog/actions.ts @@ -0,0 +1,129 @@ +import Makibot from "../../Makibot"; +import Server from "../server"; +import { ModEvent, ModEventType } from "./types"; + +interface ActionHooks { + /** + * Validates that an action can be applied. (For instance, you cannot + * unmute someone who is not muted). If this function returns null, + * it means it is possible to trigger the action. Otherwise, it returns + * the string to present in the error message. + * @param client the client (in order to fetch stuff) + * @param event the event being tried to apply + */ + validate(client: Makibot, event: ModEvent): Promise; + + /** + * Runs the action. + * @param client the client (in order to fetch stuff) + * @param event the event being tried to apply + */ + apply(client: Makibot, event: ModEvent): Promise; +} + +const actions: { [type in ModEventType]: ActionHooks } = { + WARN: { + async validate(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + return member.warned ? "Esta cuenta ya tiene una llamada de atención" : null; + }, + async apply(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + await member.setWarned(true); + await member.setHelper(false); // revoke helper + }, + }, + UNWARN: { + async validate(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + return !member.warned ? "Esta cuenta no tiene ninguna llamada de atención" : null; + }, + async apply(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + await member.setWarned(false); + await client.modrepo.evictAny(event.target, "WARN"); + }, + }, + MUTE: { + async validate(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + return member.muted ? "Esta cuenta ya está silenciada" : null; + }, + async apply(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + await member.setMuted(true); + }, + }, + UNMUTE: { + async validate(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + return !member.muted ? "Esta cuenta no está silenciada" : null; + }, + async apply(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + await member.setMuted(false); + await client.modrepo.evictAny(event.target, "MUTE"); + }, + }, + KICK: { + async validate() { + return null; + }, + async apply(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + await member.kick(); + }, + }, + BAN: { + async validate() { + return null; + }, + async apply(client, event) { + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const member = await server.member(event.target); + await member.ban(); + }, + }, +}; + +/** + * Use this function to trigger a moderation event. This is the facade + * function that will act on behalf of the real action, something + * that depends on the kind of action that was taken. + * @param event the moderation event to apply. + * @return the persisted event. + */ +export async function applyAction(client: Makibot, event: ModEvent): Promise { + const hooks = actions[event.type]; + + /* Validate that the action can be applied. */ + const validationResult = await hooks.validate(client, event); + if (validationResult != null) { + throw new Error(validationResult); + } + + /* So we are in appliable land. Persist and apply the event. */ + const eventId = await client.modrepo.persistEvent(event); + const cleanEvent = { ...event, id: eventId }; + hooks.apply(client, cleanEvent); + return cleanEvent; +} diff --git a/src/lib/modlog/database.ts b/src/lib/modlog/database.ts new file mode 100644 index 0000000..1edca81 --- /dev/null +++ b/src/lib/modlog/database.ts @@ -0,0 +1,156 @@ +import { ModEvent, ModEventType } from "./types"; +import type { Database } from "sqlite"; + +interface EventRow { + id: number; + guild_id: string; + target_id: string; + mod_id: string; + kind: string; + reason?: string; + created_at: string; + expires_at?: string; + evicted: false; +} + +/** The SQL code containing the definition of the moderation table. */ +const MIGRATION = ` + CREATE TABLE IF NOT EXISTS events ( + id INTEGER NOT NULL PRIMARY KEY, + guild_id VARCHAR(64) NOT NULL, + target_id VARCHAR(64) NOT NULL, + mod_id VARCHAR(64) NOT NULL, + kind VARCHAR(32) NOT NULL, + reason VARCHAR(256), + created_at DATETIME NOT NULL, + expires_at DATETIME, + evicted BOOLEAN + ); +`; + +const INSERT_EVENT = ` + INSERT INTO events ( + guild_id, target_id, mod_id, kind, reason, created_at, expires_at, evicted + ) + VALUES (?, ?, ?, ?, ?, datetime('now'), ?, false); +`; + +const RETRIEVE_EVENT = ` + SELECT created_at, evicted, expires_at, guild_id, mod_id, target_id, kind, id, reason + FROM events + WHERE id = ? +`; + +const RETRIEVE_EXPIRED = ` + SELECT created_at, evicted, expires_at, guild_id, mod_id, target_id, kind, id, reason + FROM events + WHERE expires_at < datetime('now') + AND kind in ('WARN', 'MUTE') + AND evicted = false +`; + +const EXPIRE_EVENT = ` + UPDATE events + SET evicted = true + WHERE id = ? +`; + +const EXPIRE_ANY = ` + UPDATE events + SET evicted = true + WHERE id = ( + SELECT id + FROM events + WHERE target_id = ? + AND kind = ? + AND evicted = 0 + ) +`; + +export interface ModerationRepository { + /** Save a moderation event, return the event number as callback. */ + persistEvent(event: ModEvent): Promise; + + /** Retrieve a moderation event by event number. */ + retrieveEvent(id: number): Promise; + + /** Retrieve events that are pending to be evicted. */ + retrieveExpired(): Promise; + + /** Evict an expirable event (mark it as done). */ + evict(id: number): Promise; + + evictAny(member: string, kind: string): Promise; +} + +function isValidModEventType(type: string): type is ModEventType { + const types: string[] = ["WARN", "UNWARN", "MUTE", "UNMUTE", "KICK", "BAN"]; + return types.indexOf(type) >= 0; +} + +function coerceModEventType(type: string): ModEventType { + return isValidModEventType(type) ? type : null; +} + +function rowToModEvent(row: EventRow): ModEvent { + return { + createdAt: new Date(row.created_at), + expired: row.evicted, + expiresAt: new Date(row.expires_at), + guild: row.guild_id, + mod: row.mod_id, + target: row.target_id, + type: coerceModEventType(row.kind), + id: row.id, + reason: row.reason, + }; +} + +class SqliteBaseModerationRepository implements ModerationRepository { + constructor(private db: Database) {} + + async persistEvent(event: ModEvent): Promise { + /* Place the moderation event in the system. */ + const values = [ + event.guild, + event.target, + event.mod, + event.type, + event.reason || null, + event.expiresAt ? event.expiresAt.toISOString().replace("T", " ") : null, + ]; + await this.db.run(INSERT_EVENT, values); + + /* Return rowid. */ + return this.db.get("select last_insert_rowid() AS id").then((row: { id: number }) => row.id); + } + + async retrieveEvent(id: number): Promise { + const row: EventRow = await this.db.get(RETRIEVE_EVENT, [id]); + return rowToModEvent(row); + } + + async retrieveExpired(): Promise { + const rows: EventRow[] = await this.db.all(RETRIEVE_EXPIRED); + return rows.map(rowToModEvent); + } + + async evict(id: number): Promise { + await this.db.run(EXPIRE_EVENT, [id]); + } + + async evictAny(target: string, type: string): Promise { + console.log(EXPIRE_ANY, [target, type]); + await this.db.run(EXPIRE_ANY, [target, type]); + } + + async initializeDatabase(): Promise { + return this.db.exec(MIGRATION); + } +} + +export async function newModRepository(db: Database): Promise { + const repo = new SqliteBaseModerationRepository(db); + await repo.initializeDatabase(); + return repo; +} diff --git a/src/lib/modlog/notifications.ts b/src/lib/modlog/notifications.ts new file mode 100644 index 0000000..7406d5d --- /dev/null +++ b/src/lib/modlog/notifications.ts @@ -0,0 +1,57 @@ +import { userMention, time } from "@discordjs/builders"; +import { APIMessage } from "@discordjs/builders/node_modules/discord-api-types"; +import { WebhookClient } from "discord.js"; + +import type Makibot from "../../Makibot"; +import Server from "../server"; +import type { ModEvent } from "./types"; + +const TEMPLATES = { + WARN: ":warning: Se llamó la atención a $TARGET$. Razón: `$REASON$`. Expira: $EXP$", + UNWARN: ":ballot_box_with_check: Ha expirado la llamada de atención a $TARGET$", + MUTE: ":mute: Se ha silenciado a $TARGET$. Razón: `$REASON$`. Expira: $EXP$", + UNMUTE: ":speaker: Ha expirado el silencio a $TARGET$", + KICK: ":athletic_shoe: Se echó a $TARGET$ del servidor, Razón: `$REASON$`.", + BAN: ":hammer: Se baneó a $TARGET$ del servidor. Razón: `$REASON$`.", +}; + +/** + * Converts an event into the formatted string that should be sent to the modlog. + * @param event the event to transform into a string to be used in modlogs. + * @returns the string to be sent to the proper modlog channel + */ +function composeModlogMessage(event: ModEvent) { + const target = userMention(event.target); + const reason = event.reason || "(no se especificó razón)"; + const expiration = event.expiresAt ? time(event.expiresAt, "R") : ""; + const eventId = event.id ? ` - [#${event.id}]` : ""; + return ( + TEMPLATES[event.type] + .replace("$TARGET$", target) + .replace("$REASON$", reason) + .replace("$EXP$", expiration) + eventId + ); +} + +/** + * If the guild in which the event is defined has a public modlog, this + * function will send + * @param client the client (to fetch the guilds and parameters) + * @param event the event to submit to the public modlog + */ +export async function notifyPublicModlog( + client: Makibot, + event: ModEvent +): Promise { + const message = composeModlogMessage(event); + const guild = await client.guilds.fetch(event.guild); + const server = new Server(guild); + const webhookURL = server.tagbag.tag("webhook:publicmod").get(null); + console.log({ webhookURL }); + if (webhookURL) { + const webhookClient = new WebhookClient({ url: webhookURL }); + return webhookClient.send({ content: message }); + } else { + return null; + } +} diff --git a/src/lib/modlog/types.ts b/src/lib/modlog/types.ts new file mode 100644 index 0000000..dc777a2 --- /dev/null +++ b/src/lib/modlog/types.ts @@ -0,0 +1,35 @@ +import { Snowflake } from "discord-api-types"; + +export type ModEventType = "WARN" | "UNWARN" | "MUTE" | "UNMUTE" | "KICK" | "BAN"; + +/** + * A moderation event as created and managed by the system. + */ +export interface ModEvent { + /** The moderation event ID. */ + id?: number; + + /** The guild in which the moderation event happened. */ + guild: Snowflake; + + /** The account that receives the moderation action. */ + target: Snowflake; + + /** The account that issues the moderation action. */ + mod: Snowflake; + + /** The type of action that was issued. */ + type: ModEventType; + + /** The reason behind the moderation action. */ + reason?: string; + + /** The datetime at which the moderation event happened. */ + createdAt: Date; + + /** The datetime at which the action will expire, if ever. */ + expiresAt?: Date; + + /** True if the expired action has already been handled. */ + expired: boolean; +}