|
@@ -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
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }));
|
|
|
+ }
|
|
|
+}
|