From cf564dbb6435886f97e2e9870363144386af368d Mon Sep 17 00:00:00 2001
From: IRONM00N <64110067+IRONM00N@users.noreply.github.com>
Date: Sun, 4 Jul 2021 15:25:32 -0400
Subject: mute command

---
 src/lib/extensions/discord-akairo/BushClient.ts    |  14 ++-
 .../extensions/discord-akairo/BushClientUtil.ts    | 106 +++++++++++++++++----
 src/lib/extensions/discord.js/BushGuildMember.ts   |  62 +++++++++++-
 src/lib/models/Ban.ts                              |  10 --
 src/lib/models/Guild.ts                            |   8 +-
 src/lib/models/ModLog.ts                           |  11 ++-
 src/lib/models/Mute.ts                             |  10 --
 src/lib/models/PunishmentRole.ts                   |  10 --
 src/lib/utils/BushConstants.ts                     |  24 ++---
 9 files changed, 181 insertions(+), 74 deletions(-)

(limited to 'src/lib')

diff --git a/src/lib/extensions/discord-akairo/BushClient.ts b/src/lib/extensions/discord-akairo/BushClient.ts
index 6911573..ed5c90a 100644
--- a/src/lib/extensions/discord-akairo/BushClient.ts
+++ b/src/lib/extensions/discord-akairo/BushClient.ts
@@ -216,7 +216,11 @@ export class BushClient extends AkairoClient {
 				loaders[loader].loadAll();
 				await this.logger.success('Startup', `Successfully loaded <<${loader}>>.`, false);
 			} catch (e) {
-				await this.logger.error('Startup', `Unable to load loader <<${loader}>> with error:\n${e?.stack}`, false);
+				await this.logger.error(
+					'Startup',
+					`Unable to load loader <<${loader}>> with error:\n${typeof e === 'object' ? e?.stack : e}`,
+					false
+				);
 			}
 		}
 		await this.dbPreInit();
@@ -237,8 +241,12 @@ export class BushClient extends AkairoClient {
 			Models.StickyRole.initModel(this.db);
 			await this.db.sync({ alter: true }); // Sync all tables to fix everything if updated
 			await this.console.success('Startup', `Successfully connected to <<database>>.`, false);
-		} catch (error) {
-			await this.console.error('Startup', `Failed to connect to <<database>> with error:\n` + error?.stack, false);
+		} catch (e) {
+			await this.console.error(
+				'Startup',
+				`Failed to connect to <<database>> with error:\n` + typeof e === 'object' ? e?.stack : e,
+				false
+			);
 		}
 	}
 
diff --git a/src/lib/extensions/discord-akairo/BushClientUtil.ts b/src/lib/extensions/discord-akairo/BushClientUtil.ts
index 9289598..5a22efc 100644
--- a/src/lib/extensions/discord-akairo/BushClientUtil.ts
+++ b/src/lib/extensions/discord-akairo/BushClientUtil.ts
@@ -24,8 +24,9 @@ import {
 	WebhookEditMessageOptions
 } from 'discord.js';
 import got from 'got';
+import humanizeDuration from 'humanize-duration';
 import { promisify } from 'util';
-import { Global, Guild, ModLog, ModLogType } from '../../models';
+import { Ban, Global, Guild, ModLog, ModLogType, Mute, PunishmentRole } from '../../models';
 import { BushCache } from '../../utils/BushCache';
 import { BushConstants } from '../../utils/BushConstants';
 import { BushGuildResolvable } from '../discord.js/BushCommandInteraction';
@@ -302,7 +303,7 @@ export class BushClientUtil extends ClientUtil {
 		});
 		const filter = (interaction: ButtonInteraction) =>
 			interaction.customID.startsWith('paginate_') && interaction.message == msg;
-		const collector = msg.createMessageComponentInteractionCollector({ filter, time: 300000 });
+		const collector = msg.createMessageComponentCollector({ filter, time: 300000 });
 		collector.on('collect', async (interaction: MessageComponentInteraction) => {
 			if (interaction.user.id == message.author.id || this.client.config.owners.includes(interaction.user.id)) {
 				switch (interaction.customID) {
@@ -391,7 +392,7 @@ export class BushClientUtil extends ClientUtil {
 		updateOptions();
 		const msg = await message.util.reply(options as MessageOptions & { split?: false });
 		const filter = (interaction: ButtonInteraction) => interaction.customID == 'paginate__stop' && interaction.message == msg;
-		const collector = msg.createMessageComponentInteractionCollector({ filter, time: 300000 });
+		const collector = msg.createMessageComponentCollector({ filter, time: 300000 });
 		collector.on('collect', async (interaction: MessageComponentInteraction) => {
 			if (interaction.user.id == message.author.id || this.client.config.owners.includes(interaction.user.id)) {
 				await interaction.deferUpdate().catch(() => undefined);
@@ -530,23 +531,36 @@ export class BushClientUtil extends ClientUtil {
 		return newArray;
 	}
 
-	public parseDuration(content: string): { duration: number; contentWithoutTime: string } {
+	public parseDuration(content: string, remove = true): { duration: number; contentWithoutTime: string } {
 		if (!content) return { duration: 0, contentWithoutTime: null };
 
-		let duration = 0,
-			contentWithoutTime = content;
+		let duration = 0;
+		// Try to reduce false positives by requiring a space before the duration, this makes sure it still matches if it is
+		// in the beginning of the argument
+		let contentWithoutTime = ` ${content}`;
 
-		const regexString = Object.entries(BushConstants.TimeUnits)
-			.map(([name, { label }]) => String.raw`(?:(?<${name}>-?(?:\d+)?\.?\d+) *${label})?`)
-			.join('\\s*');
-		const match = new RegExp(`^${regexString}$`, 'im').exec(content);
-		if (!match) return null;
+		for (const unit in BushConstants.TimeUnits) {
+			const regex = BushConstants.TimeUnits[unit].match;
+			const match = regex.exec(contentWithoutTime);
+			const value = Number(match?.groups?.[unit] || 0);
+			// this.client.console.debug(unit + ': ' + value);
+			duration += value * BushConstants.TimeUnits[unit].value;
 
-		for (const key in match.groups) {
-			contentWithoutTime = contentWithoutTime.replace(match.groups[key], '');
-			const value = Number(match.groups[key] || 0);
-			duration += value * BushConstants.TimeUnits[key].value;
+			if (remove) contentWithoutTime = contentWithoutTime.replace(regex, '');
+			// this.client.console.debug(contentWithoutTime);
 		}
+		//^(?:(?<years>-?(?:\d+)?\.?\d+) *(?:years?|y))?\s*(?:(?<months>-?(?:\d+)?\.?\d+) *(?:months?|mon|mo?))?\s*(?:(?<weeks>-?(?:\d+)?\.?\d+) *(?:weeks?|w))?\s*(?:(?<days>-?(?:\d+)?\.?\d+) *(?:days?|d))?\s*(?:(?<hours>-?(?:\d+)?\.?\d+) *(?:hours?|hrs?|h))?\s*(?:(?<minutes>-?(?:\d+)?\\.?\\d+) *(?:minutes?|mins?))?\s*(?:(?<seconds>-?(?:\d+)?\\.?\d+) *(?:seconds?|secs?|s))?\s*(?:(?<milliseconds>-?(?:\d+)?\.?\d+) *(?:milliseconds?|msecs?|ms))?$
+		// const regexString = Object.entries(BushConstants.TimeUnits)
+		// 	.map(([name, { label }]) => String.raw`(?: (?<${name}>-?(?:\d+)?\.?\d+) *${label})`)
+		// 	.join(' |');
+		// const match = new RegExp(`^${regexString}$`, 'img').exec(' ' + content + ' ');
+		// if (!match) return null;
+		// console.
+		// const contentWithoutTime = content.replace(new RegExp(`^${regexString}$`, 'img'), '');
+		// for (const key in match.groups) {
+		// 	const value = Number(match.groups[key] || 0);
+		// 	duration += value * BushConstants.TimeUnits[key].value;
+		// }
 
 		return { duration, contentWithoutTime };
 	}
@@ -555,11 +569,20 @@ export class BushClientUtil extends ClientUtil {
 	 * Checks if a moderator can perform a moderation action on another user.
 	 * @param moderator - The person trying to perform the action.
 	 * @param victim - The person getting punished.
+	 * @param checkModerator - Whether or not to check if the victim is a moderator.
 	 */
-	public moderatorCanModerateUser(moderator: BushGuildMember, victim: BushGuildMember): boolean {
-		throw 'not implemented';
+	public moderationPermissionCheck(
+		moderator: BushGuildMember,
+		victim: BushGuildMember,
+		checkModerator = true
+	): true | 'user hierarchy' | 'client hierarchy' | 'moderator' | 'self' {
 		if (moderator.guild.id !== victim.guild.id) throw 'wtf';
-		if (moderator.guild.ownerID === moderator.id) return true;
+		const isOwner = moderator.guild.ownerID === moderator.id;
+		if (moderator.id === victim.id) return 'self';
+		if (moderator.roles.highest.position <= victim.roles.highest.position && !isOwner) return 'user hierarchy';
+		if (victim.roles.highest.position >= victim.guild.me.roles.highest.position) return 'client hierarchy';
+		if (checkModerator && victim.permissions.has('MANAGE_MESSAGES')) return 'moderator';
+		return true;
 	}
 
 	public async createModLogEntry(options: {
@@ -569,10 +592,11 @@ export class BushClientUtil extends ClientUtil {
 		reason: string;
 		duration: number;
 		guild: BushGuildResolvable;
-	}): Promise<void> {
+	}): Promise<ModLog> {
 		const user = this.client.users.resolveID(options.user);
 		const moderator = this.client.users.resolveID(options.moderator);
 		const guild = this.client.guilds.resolveID(options.guild);
+		const duration = options.duration || null;
 
 		// If guild does not exist create it so the modlog can reference a guild.
 		await Guild.findOrCreate({
@@ -589,9 +613,49 @@ export class BushClientUtil extends ClientUtil {
 			user,
 			moderator,
 			reason: options.reason,
-			duration: options.duration,
+			duration: duration,
 			guild
 		});
-		await modLogEntry.save();
+		return modLogEntry.save().catch((err) => {
+			this.client.console.error('createModLogEntry', err);
+			return null;
+		});
+	}
+
+	public async createPunishmentEntry(options: {
+		type: 'mute' | 'ban' | 'role';
+		user: BushGuildMemberResolvable;
+		duration: number;
+		guild: BushGuildResolvable;
+		modlog: string;
+	}): Promise<Mute | Ban | PunishmentRole> {
+		let dbModel: typeof Mute | typeof Ban | typeof PunishmentRole;
+		switch (options.type) {
+			case 'mute':
+				dbModel = Mute;
+				break;
+			case 'ban':
+				dbModel = Ban;
+				break;
+			case 'role':
+				dbModel = PunishmentRole;
+				break;
+			default:
+				throw 'choose a valid punishment entry type';
+		}
+
+		const expires = options.duration ? new Date(new Date().getTime() + options.duration) : null;
+		const user = this.client.users.resolveID(options.user);
+		const guild = this.client.guilds.resolveID(options.guild);
+
+		const entry = dbModel.build({ user, guild, expires, modlog: options.modlog });
+		return await entry.save().catch((err) => {
+			this.client.console.error('createPunishmentEntry', err);
+			return null;
+		});
+	}
+
+	public humanizeDuration(duration: number): string {
+		return humanizeDuration(duration, { language: 'en', maxDecimalPoints: 2 });
 	}
 }
diff --git a/src/lib/extensions/discord.js/BushGuildMember.ts b/src/lib/extensions/discord.js/BushGuildMember.ts
index 59dc777..2fefcdd 100644
--- a/src/lib/extensions/discord.js/BushGuildMember.ts
+++ b/src/lib/extensions/discord.js/BushGuildMember.ts
@@ -1,5 +1,6 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
 import { GuildMember } from 'discord.js';
+import { ModLogType } from '../../models';
 import { BushClient, BushUserResolvable } from '../discord-akairo/BushClient';
 import { BushGuild } from './BushGuild';
 import { BushUser } from './BushUser';
@@ -7,7 +8,6 @@ import { BushUser } from './BushUser';
 interface BushPunishmentOptions {
 	reason?: string;
 	moderator: BushUserResolvable;
-	createModLogEntry?: boolean;
 }
 
 interface BushTimedPunishmentOptions extends BushPunishmentOptions {
@@ -18,7 +18,16 @@ type PunishmentResponse = 'success';
 
 type WarnResponse = PunishmentResponse;
 
-type MuteResponse = PunishmentResponse | 'no mute role';
+type MuteResponse =
+	| PunishmentResponse
+	| 'missing permissions'
+	| 'no mute role'
+	| 'invalid mute role'
+	| 'mute role not manageable'
+	| 'error giving mute role'
+	| 'error creating modlog entry'
+	| 'error creating mute entry'
+	| 'failed to dm';
 
 type UnmuteResponse = PunishmentResponse;
 
@@ -44,7 +53,54 @@ export class BushGuildMember extends GuildMember {
 	}
 
 	public async mute(options: BushTimedPunishmentOptions): Promise<MuteResponse> {
-		throw 'not implemented';
+		//checks
+		if (!this.guild.me.permissions.has('MANAGE_ROLES')) return 'missing permissions';
+		const muteRoleID = await this.guild.getSetting('muteRole');
+		if (!muteRoleID) return 'no mute role';
+		const muteRole = this.guild.roles.cache.get(muteRoleID);
+		if (!muteRole) return 'invalid mute role';
+		if (muteRole.position >= this.guild.me.roles.highest.position || muteRole.managed) return 'mute role not manageable';
+
+		//add role
+		const success = await this.roles.add(muteRole).catch(() => null);
+		if (!success) return 'error giving mute role';
+
+		//add modlog entry
+		const modlog = await this.client.util
+			.createModLogEntry({
+				type: options.duration ? ModLogType.TEMP_MUTE : ModLogType.PERM_MUTE,
+				user: this,
+				moderator: options.moderator,
+				reason: options.reason,
+				duration: options.duration,
+				guild: this.guild
+			})
+			.catch(() => null);
+		if (!modlog) return 'error creating modlog entry';
+
+		// add punishment entry so they can be unmuted later
+		const mute = await this.client.util
+			.createPunishmentEntry({
+				type: 'mute',
+				user: this,
+				guild: this.guild,
+				duration: options.duration,
+				modlog: modlog.id
+			})
+			.catch(() => null);
+		if (!mute) return 'error creating mute entry';
+
+		//dm user
+		const ending = this.guild.getSetting('punishmentEnding');
+		const dmSuccess = await this.send({
+			content: `You have been muted ${
+				options.duration ? 'for ' + this.client.util.humanizeDuration(options.duration) : 'permanently'
+			} in **${this.guild}** for **${options.reason || 'No reason provided'}**.${ending ? `\n\n${ending}` : ''}`
+		}).catch(() => null);
+
+		if (!dmSuccess) return 'failed to dm';
+
+		return 'success';
 	}
 
 	public async unmute(options: BushPunishmentOptions): Promise<UnmuteResponse> {
diff --git a/src/lib/models/Ban.ts b/src/lib/models/Ban.ts
index f4463b8..54ca6ae 100644
--- a/src/lib/models/Ban.ts
+++ b/src/lib/models/Ban.ts
@@ -7,7 +7,6 @@ export interface BanModel {
 	id: string;
 	user: string;
 	guild: string;
-	reason: string;
 	expires: Date;
 	modlog: string;
 }
@@ -15,7 +14,6 @@ export interface BanModelCreationAttributes {
 	id?: string;
 	user: string;
 	guild: string;
-	reason?: string;
 	expires?: Date;
 	modlog: string;
 }
@@ -33,10 +31,6 @@ export class Ban extends BaseModel<BanModel, BanModelCreationAttributes> impleme
 	 * The guild they are banned from
 	 */
 	guild: Snowflake;
-	/**
-	 * The reason they are banned (optional)
-	 */
-	reason: string | null;
 	/**
 	 * The date at which this ban expires and should be unbanned (optional)
 	 */
@@ -71,10 +65,6 @@ export class Ban extends BaseModel<BanModel, BanModelCreationAttributes> impleme
 					type: DataTypes.DATE,
 					allowNull: true
 				},
-				reason: {
-					type: DataTypes.STRING,
-					allowNull: true
-				},
 				modlog: {
 					type: DataTypes.STRING,
 					allowNull: false,
diff --git a/src/lib/models/Guild.ts b/src/lib/models/Guild.ts
index 303335b..0fc3413 100644
--- a/src/lib/models/Guild.ts
+++ b/src/lib/models/Guild.ts
@@ -10,11 +10,12 @@ export interface GuildModel {
 	blacklistedChannels: Snowflake[];
 	welcomeChannel: Snowflake;
 	muteRole: Snowflake;
+	punishmentEnding: string;
 }
 
 export type GuildModelCreationAttributes = Optional<
 	GuildModel,
-	'prefix' | 'autoPublishChannels' | 'blacklistedChannels' | 'welcomeChannel' | 'muteRole'
+	'prefix' | 'autoPublishChannels' | 'blacklistedChannels' | 'welcomeChannel' | 'muteRole' | 'punishmentEnding'
 >;
 
 export class Guild extends BaseModel<GuildModel, GuildModelCreationAttributes> implements GuildModel {
@@ -24,6 +25,7 @@ export class Guild extends BaseModel<GuildModel, GuildModelCreationAttributes> i
 	blacklistedChannels: Snowflake[];
 	welcomeChannel: Snowflake;
 	muteRole: Snowflake;
+	punishmentEnding: string;
 
 	static initModel(sequelize: Sequelize, client: BushClient): void {
 		Guild.init(
@@ -64,6 +66,10 @@ export class Guild extends BaseModel<GuildModel, GuildModelCreationAttributes> i
 				muteRole: {
 					type: DataTypes.STRING,
 					allowNull: true
+				},
+				punishmentEnding: {
+					type: DataTypes.TEXT,
+					allowNull: true
 				}
 			},
 			{ sequelize: sequelize }
diff --git a/src/lib/models/ModLog.ts b/src/lib/models/ModLog.ts
index 1d850d9..6261794 100644
--- a/src/lib/models/ModLog.ts
+++ b/src/lib/models/ModLog.ts
@@ -4,14 +4,17 @@ import { v4 as uuidv4 } from 'uuid';
 import { BaseModel } from './BaseModel';
 
 export enum ModLogType {
-	BAN = 'BAN',
+	PERM_BAN = 'PERM_BAN',
 	TEMP_BAN = 'TEMP_BAN',
+	UNBAN = 'UNBAN',
 	KICK = 'KICK',
-	MUTE = 'MUTE',
+	PERM_MUTE = 'PERM_MUTE',
 	TEMP_MUTE = 'TEMP_MUTE',
+	UNMUTE = 'UNMUTE',
 	WARN = 'WARN',
-	PUNISHMENT_ROLE = 'PUNISHMENT_ROLE',
-	TEMP_PUNISHMENT_ROLE = 'TEMP_PUNISHMENT_ROLE'
+	PERM_PUNISHMENT_ROLE = 'PERM_PUNISHMENT_ROLE',
+	TEMP_PUNISHMENT_ROLE = 'TEMP_PUNISHMENT_ROLE',
+	REMOVE_PUNISHMENT_ROLE = 'REMOVE_PUNISHMENT_ROLE'
 }
 
 export interface ModLogModel {
diff --git a/src/lib/models/Mute.ts b/src/lib/models/Mute.ts
index 273d5b1..71a32e3 100644
--- a/src/lib/models/Mute.ts
+++ b/src/lib/models/Mute.ts
@@ -7,7 +7,6 @@ export interface MuteModel {
 	id: string;
 	user: string;
 	guild: string;
-	reason: string;
 	expires: Date;
 	modlog: string;
 }
@@ -15,7 +14,6 @@ export interface MuteModelCreationAttributes {
 	id?: string;
 	user: string;
 	guild: string;
-	reason?: string;
 	expires?: Date;
 	modlog: string;
 }
@@ -33,10 +31,6 @@ export class Mute extends BaseModel<MuteModel, MuteModelCreationAttributes> impl
 	 * The guild they are muted in
 	 */
 	guild: Snowflake;
-	/**
-	 * The reason they are muted (optional)
-	 */
-	reason: string | null;
 	/**
 	 * The date at which this Mute expires and should be unmuted (optional)
 	 */
@@ -71,10 +65,6 @@ export class Mute extends BaseModel<MuteModel, MuteModelCreationAttributes> impl
 					type: DataTypes.DATE,
 					allowNull: true
 				},
-				reason: {
-					type: DataTypes.STRING,
-					allowNull: true
-				},
 				modlog: {
 					type: DataTypes.STRING,
 					allowNull: false,
diff --git a/src/lib/models/PunishmentRole.ts b/src/lib/models/PunishmentRole.ts
index 3326dca..927cf28 100644
--- a/src/lib/models/PunishmentRole.ts
+++ b/src/lib/models/PunishmentRole.ts
@@ -7,7 +7,6 @@ export interface PunishmentRoleModel {
 	id: string;
 	user: string;
 	guild: string;
-	reason: string;
 	expires: Date;
 	modlog: string;
 }
@@ -15,7 +14,6 @@ export interface PunishmentRoleModelCreationAttributes {
 	id?: string;
 	user: string;
 	guild: string;
-	reason?: string;
 	expires?: Date;
 	modlog: string;
 }
@@ -36,10 +34,6 @@ export class PunishmentRole
 	 * The guild they received a role in
 	 */
 	guild: Snowflake;
-	/**
-	 * The reason they received a role (optional)
-	 */
-	reason: string | null;
 	/**
 	 * The date at which this role expires and should be removed (optional)
 	 */
@@ -74,10 +68,6 @@ export class PunishmentRole
 					type: DataTypes.DATE,
 					allowNull: true
 				},
-				reason: {
-					type: DataTypes.STRING,
-					allowNull: true
-				},
 				modlog: {
 					type: DataTypes.STRING,
 					allowNull: false,
diff --git a/src/lib/utils/BushConstants.ts b/src/lib/utils/BushConstants.ts
index 0e3f6bb..7e6013d 100644
--- a/src/lib/utils/BushConstants.ts
+++ b/src/lib/utils/BushConstants.ts
@@ -1,36 +1,36 @@
 export class BushConstants {
-	// Stolen from @Mzato0001 (pr to discord akairo that hasn't been merged yet)
-	public static TimeUnits = {
+	// Somewhat stolen from @Mzato0001
+	public static TimeUnits: { [key: string]: { match: RegExp; value: number } } = {
 		years: {
-			label: '(?:years?|y)',
-			value: 1000 * 60 * 60 * 24 * 365
+			match: / (?:(?<years>-?(?:\d+)?\.?\d+) *(?:years?|y))/im,
+			value: 1000 * 60 * 60 * 24 * 365.25 //leap years
 		},
 		months: {
-			label: '(?:months?|mon|mo?)',
-			value: 1000 * 60 * 60 * 24 * 30
+			match: / (?:(?<months>-?(?:\d+)?\.?\d+) *(?:months?|mon|mo?))/im,
+			value: 1000 * 60 * 60 * 24 * 30.4375 // average of days in months including leap years
 		},
 		weeks: {
-			label: '(?:weeks?|w)',
+			match: / (?:(?<weeks>-?(?:\d+)?\.?\d+) *(?:weeks?|w))/im,
 			value: 1000 * 60 * 60 * 24 * 7
 		},
 		days: {
-			label: '(?:days?|d)',
+			match: / (?:(?<days>-?(?:\d+)?\.?\d+) *(?:days?|d))/im,
 			value: 1000 * 60 * 60 * 24
 		},
 		hours: {
-			label: '(?:hours?|hrs?|h)',
+			match: / (?:(?<hours>-?(?:\d+)?\.?\d+) *(?:hours?|hrs?|h))/im,
 			value: 1000 * 60 * 60
 		},
 		minutes: {
-			label: '(?:minutes?|mins?)',
+			match: / (?:(?<minutes>-?(?:\d+)?\.?\d+) *(?:minutes?|mins?))/im,
 			value: 1000 * 60
 		},
 		seconds: {
-			label: '(?:seconds?|secs?|s)',
+			match: / (?:(?<seconds>-?(?:\d+)?\.?\d+) *(?:seconds?|secs?|s))/im,
 			value: 1000
 		},
 		milliseconds: {
-			label: '(?:milliseconds?|msecs?|ms)',
+			match: / (?:(?<milliseconds>-?(?:\d+)?\.?\d+) *(?:milliseconds?|msecs?|ms))/im,
 			value: 1
 		}
 	};
-- 
cgit