Browse Source

Rename mute plugin to violation; make code more reusable

ghorsington 4 years ago
parent
commit
1a719b99fd

+ 0 - 90
bot/src/plugins/mute.ts

@@ -1,90 +0,0 @@
-import { Plugin, ICommandData, Command } from "src/model/plugin";
-import { parseArgs, tryDo, parseDuration, UNIT_MEASURES } from "src/util";
-import { GuildMember, Guild, MessageEmbed } from "discord.js";
-import { logger } from "src/logging";
-import { client } from "src/client";
-import humanizeDuration from "humanize-duration";
-
-const MENTION_PATTERN = /<@!?(\d+)>/;
-
-@Plugin
-export class MutePlugin {
-    @Command({
-        type: "prefix",
-        pattern: "mute",
-        auth: true
-    })
-    async muteUser({ message }: ICommandData): Promise<void> {
-        if (!message.guild) {
-            await message.reply("cannot do in DMs!");
-            return;
-        }
-        const [, userId, duration, ...rest] = parseArgs(message.content);
-
-        if (!userId) {
-            await message.reply("no user specified!");
-            return;
-        }
-
-        const user = await this.resolveUser(message.guild, userId);
-
-        if (!user) {
-            await message.reply("couldn't find the given user!");
-            logger.error("Tried to mute user %s but couldn't find them by id!", userId);
-            return;
-        }
-
-        let durationMs = parseDuration(duration);
-        let reasonArray = rest;
-
-        if (!durationMs) {
-            durationMs = UNIT_MEASURES.d as number;
-            reasonArray = [duration, ...reasonArray];
-        }
-
-        let reason = reasonArray.join(" ");
-
-        if (!reason)
-            reason = "None given";
-
-        message.channel.send(new MessageEmbed({
-            title: "User has been muted for server violation",
-            color: 4944347,
-            timestamp: new Date(),
-            footer: {
-                text: client.botUser.username
-            },
-            author: {
-                name: client.botUser.username,
-                iconURL: client.botUser.avatarURL() ?? undefined
-            },
-            fields: [
-                {
-                    name: "Username",
-                    value: user.toString()
-                },
-                {
-                    name: "Duration",
-                    value: humanizeDuration(durationMs, { unitMeasures: UNIT_MEASURES })
-                },
-                {
-                    name: "Reason",
-                    value: reason
-                }
-            ]
-        }));
-    }
-
-    private async resolveUser(guild: Guild, id: string): Promise<GuildMember | undefined> {
-        const result = MENTION_PATTERN.exec(id);
-        if (result) {
-            const userId = result[1];
-            const fetchResult = await tryDo(guild.members.fetch(userId));
-            if (fetchResult.ok)
-                return fetchResult.result;
-        }
-        
-        const fetchResult = await tryDo(guild.members.fetch(id));
-        return fetchResult.result;
-    }
-}

+ 249 - 0
bot/src/plugins/violation.ts

@@ -0,0 +1,249 @@
+import { Plugin, ICommandData, Command } from "src/model/plugin";
+import { parseArgs, tryDo, parseDuration, UNIT_MEASURES, Option } from "src/util";
+import { GuildMember, Guild, MessageEmbed, Message, TextChannel } from "discord.js";
+import { logger } from "src/logging";
+import { client } from "src/client";
+import humanizeDuration from "humanize-duration";
+import { getRepository, ObjectType, FindConditions, DeepPartial } from "typeorm";
+import { GuildViolationSettings } from "@shared/db/entity/GuildViolationSettings";
+import { Mute, TimedViolation } from "@shared/db/entity/Violation";
+import { scheduleJob, Job, rescheduleJob } from "node-schedule";
+import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
+
+const MENTION_PATTERN = /<@!?(\d+)>/;
+
+interface ViolationInfo {
+    member: GuildMember;
+    endDate: Date;
+    duration: number;
+    guild: Guild;
+    reason: string;
+    settings: GuildViolationSettings;
+}
+
+@Plugin
+export class ViolationPlugin {
+    jobs: Record<number, Job> = {};
+
+    @Command({
+        type: "prefix",
+        pattern: "mute",
+        auth: true
+    })
+    async muteUser({ message }: ICommandData): Promise<void> {
+        const info = await this.parseCommand(message);
+        if (!info.ok)
+            return;
+
+        const muteRoleId = info.settings.muteRoleId;
+        const muteRoleResolve = await tryDo(info.guild.roles.fetch(muteRoleId));
+
+        if (!muteRoleResolve.ok || !muteRoleResolve.result) {
+            await message.reply("the mute role ID is invalid! Ping the bot manager!");
+            logger.error(
+                "mute: Tried to mute user %s#%s (%s) but mute role ID %s is invalid!",
+                message.author.username,
+                message.author.discriminator,
+                message.author.id,
+                muteRoleId);
+            return;
+        }
+        const muteRole = muteRoleResolve.result;
+        
+        const apply = async (member: GuildMember) => {
+            await member.roles.add(muteRole);
+        };
+
+        const remove = async (member: GuildMember) => {
+            const muteRoleResolve = await tryDo(member.guild.roles.fetch(muteRoleId));
+
+            if (!muteRoleResolve.ok || !muteRoleResolve.result) {
+                logger.warning("mute: couldn't find mute role id %s (removed from server?)", muteRoleId);
+                return;
+            }
+            const muteRole = muteRoleResolve.result;
+
+            await member.roles.remove(muteRole);
+        };
+
+        await this.applyTimedViolation(Mute, info, "mute", apply, remove);
+        await this.sendViolationMessage(message, info, "User has been muted for server violation");
+    }
+
+    private async applyTimedViolation<T extends TimedViolation>(type: ObjectType<T>, info: ViolationInfo, command = "violation", apply: (user: GuildMember) => Promise<void>, remove: (user: GuildMember) => Promise<void>) {
+        const violationRepo = getRepository(type);
+        const existingViolation = await violationRepo.findOne({
+            where: {
+                userId: info.member.id,
+                guildId: info.guild.id,
+                valid: true
+            }
+        });
+
+        if (existingViolation) {
+            logger.warning("%s: trying to reapply on user %s#%s (%s)", command, info.member.user.id, info.member.user.discriminator, info.member.id);
+            await violationRepo.update({ id: existingViolation.id } as unknown as FindConditions<T>, { endsAt: info.endDate } as unknown as QueryDeepPartialEntity<T>);
+            const job = this.jobs[existingViolation.id];
+            rescheduleJob(job, info.endDate);
+        } else {
+            const newViolation = await violationRepo.save({
+                guildId: info.guild.id,
+                userId: info.member.id,
+                reason: info.reason,
+                endsAt: info.endDate,
+                valid: true,
+            } as unknown as DeepPartial<T>);
+            const job = scheduleJob(info.endDate, this.scheduleRemoveViolation(type, info.guild.id, info.member.id, remove, command));
+            this.jobs[newViolation.id] = job;
+        }
+        await apply(info.member);
+    }
+
+    private scheduleRemoveViolation<T extends TimedViolation>(type: ObjectType<T>, guildId: string, userId: string, handle: (member: GuildMember) => Promise<void>, command = "violation") {
+        return async () => {
+            const repo = getRepository(type);
+            const violation = await repo.findOne({
+                where: {
+                    guildId: guildId,
+                    userId: userId,
+                    valid: true
+                }
+            });
+
+            if (!violation) {
+                logger.warning("un-%s: no violation found for user ID %s in guild %s", command, userId, guildId);
+                return;
+            }
+
+            await repo.update({ id: violation.id } as unknown as FindConditions<T>, { valid: true } as unknown as QueryDeepPartialEntity<T>);
+            delete this.jobs[violation.id];
+
+            const guild = client.bot.guilds.resolve(guildId);
+            if (!guild) {
+                logger.warning("un-%s: couldn't find guild %s", command, guildId);
+                return;
+            }
+
+            const userResolve = await tryDo(guild.members.fetch(userId));
+            if (!userResolve.ok || !userResolve.result) {
+                logger.warning("un-%s: couldn't find user %s (possibly left the server?)", command, userId);
+                return;
+            }
+            const user = userResolve.result;
+
+            await handle(user);
+        };
+    }
+
+    private async resolveUser(guild: Guild, id: string): Promise<GuildMember | undefined> {
+        const result = MENTION_PATTERN.exec(id);
+        if (result) {
+            const userId = result[1];
+            const fetchResult = await tryDo(guild.members.fetch(userId));
+            if (fetchResult.ok)
+                return fetchResult.result;
+        }
+
+        const fetchResult = await tryDo(guild.members.fetch(id));
+        if (!fetchResult.ok)
+            return undefined;
+        return fetchResult.result;
+    }
+
+    private async parseCommand(message: Message, command = "violation"): Promise<Option<ViolationInfo>> {
+        if (!message.guild) {
+            await message.reply("cannot do in DMs!");
+            return { ok: false };
+        }
+        const violationSettingsRepo = getRepository(GuildViolationSettings);
+        const settings = await violationSettingsRepo.findOne(message.guild.id);
+
+        if (!settings) {
+            await message.reply("sorry, this server doesn't have violation settings set up.");
+            logger.error(
+                "%s was called in guild %s (%s) on user %s which doesn't have config set up!",
+                command,
+                message.guild.name,
+                message.guild.id,
+                message.author.id);
+            return { ok: false };
+        }
+
+        const [, userId, duration, ...rest] = parseArgs(message.content);
+
+        if (!userId) {
+            await message.reply("no user specified!");
+            return { ok: false };
+        }
+
+        const member = await this.resolveUser(message.guild, userId);
+
+        if (!member) {
+            await message.reply("couldn't find the given user!");
+            logger.error("Tried to %s user %s but couldn't find them by id!", command, userId);
+            return { ok: false };
+        }
+
+        let durationMs = parseDuration(duration);
+        let reasonArray = rest;
+
+        if (!durationMs) {
+            durationMs = UNIT_MEASURES.d as number;
+            reasonArray = [duration, ...reasonArray];
+        }
+
+        const endDate = new Date(Date.now() + durationMs);
+        let reason = reasonArray.join(" ");
+
+        if (!reason)
+            reason = "None given";
+
+        return {
+            ok: true,
+            duration: durationMs,
+            endDate: endDate,
+            guild: message.guild,
+            member: member,
+            reason: reason,
+            settings: settings,
+        };
+    }
+
+    private async sendViolationMessage(message: Message, info: ViolationInfo, title: string) {
+        let announceChannel: TextChannel | null = null;
+        if (info.settings.violationInfoChannelId) {
+            const ch = info.guild.channels.resolve(info.settings.violationInfoChannelId);
+            if (ch && ch.type == "text")
+                announceChannel = ch as TextChannel;
+            else if (message.channel.type == "text") {
+                announceChannel = message.channel;
+            }
+        }
+        await announceChannel?.send(new MessageEmbed({
+            title: title,
+            color: 4944347,
+            timestamp: new Date(),
+            footer: {
+                text: client.botUser.username
+            },
+            author: {
+                name: client.botUser.username,
+                iconURL: client.botUser.avatarURL() ?? undefined
+            },
+            fields: [
+                {
+                    name: "Username",
+                    value: info.member.toString()
+                },
+                {
+                    name: "Duration",
+                    value: humanizeDuration(info.duration, { unitMeasures: UNIT_MEASURES })
+                },
+                {
+                    name: "Reason",
+                    value: info.reason
+                }
+            ]
+        }));
+    }
+}

+ 4 - 1
shared/src/db/entity/GuildViolationSettings.ts

@@ -6,5 +6,8 @@ export class GuildViolationSettings {
     guildId: string;
 
     @Column({ nullable: true })
-    muteChannelId?: string;
+    violationInfoChannelId?: string;
+
+    @Column({ nullable: true })
+    muteRoleId: string;
 }

+ 8 - 6
shared/src/db/entity/Violation.ts

@@ -14,19 +14,21 @@ export abstract class Violation {
 
     @Column({ type: "text", nullable: true })
     reason?: string;
-}
 
-@ChildEntity()
-export class Mute extends Violation {
     @Column()
-    endsAt: Date;
+    valid: boolean;
 }
 
-@ChildEntity()
-export class Ban extends Violation {
+export abstract class TimedViolation extends Violation {
     @Column()
     endsAt: Date;
 }
 
 @ChildEntity()
+export class Mute extends TimedViolation {}
+
+@ChildEntity()
+export class Ban extends TimedViolation {}
+
+@ChildEntity()
 export class Kick extends Violation {}