diff --git a/src/bot/commands/ban.ts b/src/bot/commands/ban.ts new file mode 100644 index 0000000..c2fc0cd --- /dev/null +++ b/src/bot/commands/ban.ts @@ -0,0 +1,112 @@ +import { ulid } from "ulid"; +import { client } from "../.."; +import Infraction from "../../struct/antispam/Infraction"; +import InfractionType from "../../struct/antispam/InfractionType"; +import Command from "../../struct/Command"; +import MessageCommandContext from "../../struct/MessageCommandContext"; +import TempBan from "../../struct/TempBan"; +import { fetchUsername } from "../modules/mod_logs"; +import { storeTempBan } from "../modules/tempbans"; +import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util"; + +export default { + name: 'ban', + aliases: null, + description: 'Ban a member from the server', + syntax: '/ban @username [10m?] [reason?]', + removeEmptyArgs: true, + run: async (message: MessageCommandContext, args: string[]) => { + if (!await isModerator(message.member!, message.serverContext)) + return message.reply(NO_MANAGER_MSG); + + if (args.length == 0) + return message.reply(`You need to provide a target user!`); + + let targetUser = await parseUser(args.shift()!); + if (!targetUser) return message.reply('Sorry, I can\'t find that user.'); + + if (targetUser._id == message.author_id) { + return message.reply('nah'); + } + + if (targetUser._id == client.user!._id) { + return message.reply('lol no'); + } + + let banDuration = 0; + let durationStr = args.shift(); + if (durationStr && /([0-9]{1,3}[smhdwy])+/g.test(durationStr)) { + let pieces = durationStr.match(/([0-9]{1,3}[smhdwy])/g) ?? []; + + // Being able to specify the same letter multiple times + // (e.g. 1s1s) and having their values stack is a feature + for (const piece of pieces) { + let [ num, letter ] = [ Number(piece.slice(0, piece.length - 1)), piece.slice(piece.length - 1) ]; + let multiplier = 0; + + switch(letter) { + case 's': multiplier = 1000; break; + case 'm': multiplier = 1000 * 60; break; + case 'h': multiplier = 1000 * 60 * 60; break; + case 'd': multiplier = 1000 * 60 * 60 * 24; break; + case 'w': multiplier = 1000 * 60 * 60 * 24 * 7; break; + case 'y': multiplier = 1000 * 60 * 60 * 24 * 365; break; + } + + banDuration += num * multiplier; + } + } else if (durationStr) args.splice(0, 0, durationStr); + + let reason = args.join(' ') || 'No reason provided'; + + if (banDuration == 0) { + message.serverContext.banUser(targetUser._id, { + reason: reason + ` (by @${await fetchUsername(message.author_id)} ${message.author_id})` + }) + .catch(e => message.reply(`Failed to ban user: \`${e}\``)); + + let infId = ulid(); + let { userWarnCount } = await storeInfraction({ + _id: infId, + createdBy: message.author_id, + date: Date.now(), + reason: reason, + server: message.serverContext._id, + type: InfractionType.Manual, + user: targetUser._id, + actionType: 'ban', + } as Infraction); + + message.reply(`### @${targetUser.username} has bee banned.\n` + + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); + } else { + message.serverContext.banUser(targetUser._id, { + reason: reason + ` (by @${await fetchUsername(message.author_id)} ${message.author_id}) (${durationStr})` + }) + .catch(e => message.reply(`Failed to ban user: \`${e}\``)); + + let banUntil = Date.now() + banDuration; + let infId = ulid(); + let { userWarnCount } = await storeInfraction({ + _id: infId, + createdBy: message.author_id, + date: Date.now(), + reason: reason + ` (${durationStr})`, + server: message.serverContext._id, + type: InfractionType.Manual, + user: targetUser._id, + actionType: 'ban', + } as Infraction); + + await storeTempBan({ + id: infId, + bannedUser: targetUser._id, + server: message.serverContext._id, + until: banUntil, + } as TempBan); + + message.reply(`### ${targetUser.username} has been temporarily banned.\n` + + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); + } + } +} as Command; diff --git a/src/bot/commands/kick.ts b/src/bot/commands/kick.ts new file mode 100644 index 0000000..231f52d --- /dev/null +++ b/src/bot/commands/kick.ts @@ -0,0 +1,64 @@ +import { Member } from "@janderedev/revolt.js/dist/maps/Members"; +import { ulid } from "ulid"; +import { client } from "../.."; +import Infraction from "../../struct/antispam/Infraction"; +import InfractionType from "../../struct/antispam/InfractionType"; +import Command from "../../struct/Command"; +import MessageCommandContext from "../../struct/MessageCommandContext"; +import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util"; + +export default { + name: 'kick', + aliases: [ 'yeet', 'eject' ], + description: 'Eject a member from the server', + syntax: '/kick @username [reason?]', + removeEmptyArgs: true, + run: async (message: MessageCommandContext, args: string[]) => { + if (!await isModerator(message.member!, message.serverContext)) + return message.reply(NO_MANAGER_MSG); + + if (args.length == 0) + return message.reply(`You need to provide a target user!`); + + let targetUser = await parseUser(args.shift()!); + if (!targetUser) return message.reply('Sorry, I can\'t find that user.'); + + if (targetUser._id == message.author_id) { + return message.reply('nah'); + } + + if (targetUser._id == client.user!._id) { + return message.reply('lol no'); + } + + let reason = args.join(' ') || 'No reason provided'; + + let targetMember: Member; + try { + targetMember = await message.serverContext.fetchMember(targetUser._id); + } catch(e) { + return message.reply(`Failed to fetch member: \`${e}\``); + } + + try { + await targetMember.kick(); + } catch(e) { + return message.reply(`Failed to kick user: \`${e}\``); + } + + let infId = ulid(); + let { userWarnCount } = await storeInfraction({ + _id: infId, + createdBy: message.author_id, + date: Date.now(), + reason: reason, + server: message.serverContext._id, + type: InfractionType.Manual, + user: targetUser._id, + actionType: 'kick', + } as Infraction); + + message.reply(`### @${targetUser.username} has been ${Math.random() > 0.8 ? 'ejected' : 'kicked'}.\n` + + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); + } +} as Command; diff --git a/src/bot/commands/warns.ts b/src/bot/commands/warns.ts index 49ad767..5cc8e49 100644 --- a/src/bot/commands/warns.ts +++ b/src/bot/commands/warns.ts @@ -38,7 +38,7 @@ export default { for (let inf of Array.from(userInfractions.values()).sort((a, b) => b.length - a.length).slice(0, 9)) { inf = inf.sort((a, b) => b.date - a.date); msg += `**${await fetchUsername(inf[0].user)}** (${inf[0].user}): **${inf.length}** infractions\n`; - msg += `\u200b \u200b \u200b \u200b \u200b ↳ Most recent warning: \`${inf[0].reason}\` ` + msg += `\u200b \u200b \u200b \u200b \u200b ↳ Most recent infraction: ${getInfEmoji(inf[0])}\`${inf[0].reason}\` ` + `${inf[0].type == InfractionType.Manual ? `(${await fetchUsername(inf[0].createdBy ?? '')})` : ''}\n`; }; @@ -60,7 +60,7 @@ export default { message.reply(`## Infraction deleted\n\u200b\n` + `ID: \`${inf._id}\`\n` - + `Reason: \`${inf.reason}\` ` + + `Reason: ${getInfEmoji(inf)}\`${inf.reason}\` ` + `(${inf.type == InfractionType.Manual ? await fetchUsername(inf.createdBy ?? '') : 'System'})\n` + `Created ${Day(inf.date).fromNow()}`); break; @@ -73,10 +73,10 @@ export default { else { let msg = `## ${infs.length} infractions stored for @${user.username}\n\u200b\n`; let attachSpreadsheet = false; - for (const i in infs) { console.log(i) + for (const i in infs) { let inf = infs[i]; let toAdd = ''; - toAdd += `#${Number(i)+1}: \`${inf.reason}\` (${inf.type == InfractionType.Manual ? await fetchUsername(inf.createdBy!) : 'System'})\n`; + toAdd += `#${Number(i)+1}: ${getInfEmoji(inf)} \`${inf.reason}\` (${inf.type == InfractionType.Manual ? await fetchUsername(inf.createdBy!) : 'System'})\n`; toAdd += `\u200b \u200b \u200b \u200b \u200b ↳ ${Day(inf.date).fromNow()} (Infraction ID: \`${inf._id}\`)\n`; if ((msg + toAdd).length > 1900 || Number(i) > 5) { @@ -96,7 +96,7 @@ export default { let csv_data = [ [`Warns for @${user.username} (${user._id}) - ${Day().toString()}`], [], - ['Date', 'Reason', 'Created By', 'Type', 'ID'], + ['Date', 'Reason', 'Created By', 'Type', 'Action Type', 'ID'], ]; for (const inf of infs) { @@ -105,6 +105,7 @@ export default { inf.reason, inf.type == InfractionType.Manual ? `${await fetchUsername(inf.createdBy!)} (${inf.createdBy})` : 'SYSTEM', inf.type == InfractionType.Automatic ? 'Automatic' : 'Manual', + inf.actionType || 'warn', inf._id, ]); } @@ -124,3 +125,11 @@ export default { } } } as Command; + +function getInfEmoji(inf: Infraction) { + switch(inf.actionType) { + case 'kick': return ':mans_shoe: '; + case 'ban': return ':hammer: '; + default: return ''; + } +} diff --git a/src/bot/modules/event_handler.ts b/src/bot/modules/event_handler.ts new file mode 100644 index 0000000..c113d01 --- /dev/null +++ b/src/bot/modules/event_handler.ts @@ -0,0 +1,35 @@ +import { ulid } from "ulid"; +import { client } from "../.."; +import Infraction from "../../struct/antispam/Infraction"; +import InfractionType from "../../struct/antispam/InfractionType"; +import { storeInfraction } from "../util"; + +// Listen to system messages +client.on('message', message => { + if (typeof message.content != 'object') return; + + let sysMsg = message.asSystemMessage; + + switch(sysMsg.type) { + case 'user_kicked': + case 'user_banned': + if (message.channel && + sysMsg.user && + sysMsg.by && + sysMsg.by._id != client.user?._id) return; + + storeInfraction({ + _id: ulid(), + createdBy: sysMsg.by?._id, + reason: 'Unknown reason (caught system message)', + date: message.createdAt, + server: message.channel!.server_id, + type: InfractionType.Manual, + user: sysMsg.user!._id, + actionType: sysMsg.type == 'user_kicked' ? 'kick' : 'ban', + } as Infraction).catch(console.warn); + break; + case 'user_joined': break; + case 'user_left' : break; + } +}); diff --git a/src/bot/modules/tempbans.ts b/src/bot/modules/tempbans.ts new file mode 100644 index 0000000..2b9c3cb --- /dev/null +++ b/src/bot/modules/tempbans.ts @@ -0,0 +1,57 @@ +import { FindResult } from "monk"; +import { client } from "../.."; +import TempBan from "../../struct/TempBan"; +import logger from "../logger"; + +// Array of ban IDs which should not get processed in this session +let dontProcess: string[] = []; + +async function tick() { + let found: FindResult = await client.db.get('tempbans').find({ until: { $lt: Date.now() + 60000 } }); + + for (const ban of found) { + if (!dontProcess.includes(ban.id)) + setTimeout(() => processUnban(ban), ban.until - Date.now()); + } +} + +new Promise((r: (value: void) => void) => { + if (client.user) r(); + else client.once('ready', r); +}).then(() => { + tick(); + setInterval(tick, 60000); +}); + +async function processUnban(ban: TempBan) { + try { + let server = client.servers.get(ban.server) || await client.servers.fetch(ban.server); + let serverBans = await server.fetchBans(); + + if (serverBans.bans.find(b => b._id.user == ban.bannedUser)) { + logger.debug(`Unbanning user ${ban.bannedUser} from ${server._id}`); + + let promises = [ + server.unbanUser(ban.bannedUser), + client.db.get('tempbans').remove({ id: ban.id }), + ]; + + await Promise.allSettled(promises); + } + else client.db.get('tempbans').remove({ id: ban.id }); + } catch(e) { console.error(e) } +} + +async function storeTempBan(ban: TempBan): Promise { + if (Date.now() >= ban.until - 60000) { + dontProcess.push(ban.id); + setTimeout(() => { + processUnban(ban); + dontProcess = dontProcess.filter(id => id != ban.id); + }, ban.until - Date.now()); + } + + client.db.get('tempbans').insert(ban); +} + +export { storeTempBan }; diff --git a/src/index.ts b/src/index.ts index 07311a3..f573c19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,4 +21,6 @@ export { client } // Load modules import('./bot/modules/command_handler'); import('./bot/modules/mod_logs'); + import('./bot/modules/event_handler'); + import('./bot/modules/tempbans'); })(); diff --git a/src/struct/TempBan.ts b/src/struct/TempBan.ts new file mode 100644 index 0000000..4af3ceb --- /dev/null +++ b/src/struct/TempBan.ts @@ -0,0 +1,8 @@ +class TempBan { + id: string; + server: string; + bannedUser: string; + until: number; +} + +export default TempBan; diff --git a/src/struct/antispam/Infraction.ts b/src/struct/antispam/Infraction.ts index 5aa303e..cb569b8 100644 --- a/src/struct/antispam/Infraction.ts +++ b/src/struct/antispam/Infraction.ts @@ -3,6 +3,7 @@ import InfractionType from "./InfractionType"; class Infraction { _id: string; type: InfractionType; + actionType?: 'kick'|'ban'; user: string; createdBy: string|null; server: string;