diff --git a/src/bot/commands/ban.ts b/src/bot/commands/ban.ts index 8be40d2..316d4c4 100644 --- a/src/bot/commands/ban.ts +++ b/src/bot/commands/ban.ts @@ -21,7 +21,7 @@ export default { removeEmptyArgs: true, category: 'moderation', run: async (message: MessageCommandContext, args: string[]) => { - if (!await isModerator(message.member!, message.serverContext)) + if (!await isModerator(message)) return message.reply(NO_MANAGER_MSG); if (args.length == 0) diff --git a/src/bot/commands/botadm.ts b/src/bot/commands/botadm.ts new file mode 100644 index 0000000..14761c4 --- /dev/null +++ b/src/bot/commands/botadm.ts @@ -0,0 +1,136 @@ +import Command from "../../struct/Command"; +import MessageCommandContext from "../../struct/MessageCommandContext"; +import { client } from "../.."; +import { commands, DEFAULT_PREFIX, ownerIDs } from "../modules/command_handler"; +import child_process from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { wordlist } from "../modules/user_scan"; +import { User } from "revolt.js/dist/maps/Users"; +import { adminBotLog } from "../logging"; + +// id: expireDate +const sudoOverrides: { [key: string]: number|null } = {} + +const isSudo = (user: User): boolean => { + console.log(sudoOverrides[user._id]) + return !!(sudoOverrides[user._id] && sudoOverrides[user._id]! > Date.now()); +} + +const updateSudoTimeout = (user: User) => { + sudoOverrides[user._id] = Date.now() + (1000 * 60 * 5); +} + +const getCommitHash = (): Promise => new Promise((resolve) => { + child_process.exec('git rev-parse HEAD', (err, stdout) => { + if (err?.code) resolve(null); else resolve(stdout); + }); +}); + +const SUBCOMMANDS: string[] = [ + 'stats', + 'sudo', +]; + +export default { + name: 'botadm', + aliases: [ 'botadmin' ], + description: 'Bot administration', + removeEmptyArgs: true, + restrict: 'BOTOWNER', + category: 'moderation', + run: async (message: MessageCommandContext, args: string[]) => { + if (!args.length) return message.reply('No subcommand specified. Available subcommands: ' + SUBCOMMANDS.join(', ')); + + try { + switch(args.shift()?.toLowerCase()) { + case 'stats': { + const pjson = JSON.parse((await fs.promises.readFile(path.join(process.cwd(), 'package.json'))).toString()); + let msg = `# AutoMod stats\n` + + `### Cache\n` + + `Servers: \`${client.servers.size}\`\n` + + `Channels: \`${client.channels.size}\`\n` + + `Users: \`${client.users.size}\`\n` + + `### Misc\n` + + `Command count: \`${commands.length}\`\n` + + `Environment: \`${process.env.NODE_ENV || 'testing'}\`\n` + + `Commit hash: \`${await getCommitHash() || 'Unknown'}\`\n` + + `### Packages\n` + + `revolt.js: \`${pjson.dependencies['revolt.js']}\`\n` + + `discord.js: \`${pjson.dependencies['discord.js']}\`\n` + + `axios: \`${pjson.dependencies['axios']}\`\n` + + `log75: \`${pjson.dependencies['log75']}\`\n` + + `typescript: \`${pjson.devDependencies['typescript']}\`\n` + + `### Connection\n` + + `API Endpoint: \`${client.apiURL}\`\n` + + `Heartbeat: \`${client.heartbeat}\`\n` + + `Ping: \`${client.websocket.ping ?? 'Unknown'}\`\n` + + `### Bot configuration\n` + + `Owners: \`${ownerIDs.length}\` (${ownerIDs.join(', ')})\n` + + `Wordlist loaded: \`${wordlist ? `Yes (${wordlist.length} line${wordlist.length == 1 ? '' : 's'})` : 'No'}\`\n`; + + await message.reply(msg, false); + break; + } + + case 'sudo': { + switch(args[0]?.toLowerCase()) { + case 'enable': + case 'on': { + if (isSudo(message.author!)) return message.reply('You are already in sudo mode!'); + + sudoOverrides[message.author_id] = Date.now() + (1000 * 60 * 5); + + let msg = `# %emoji% Sudo mode enabled\n` + + `In sudo mode, you will be able to run any command regardless of your server permissions.\n` + + `Sudo mode will automatically expire **5 minutes** after your last bot interaction. ` + + `To disable now, run \`${DEFAULT_PREFIX}botadm sudo disable\`.`; + + const sentMsg = await message.reply(msg.replace('%emoji%', ':lock:'), false); + setTimeout(() => sentMsg?.edit({ content: msg.replace('%emoji%', ':unlock:') }).catch(()=>{}), 200); + + await adminBotLog({ type: 'WARN', message: `@${message.author!.username} has enabled sudo mode.` }); + + break; + } + + case 'disable': + case 'off': { + if (!isSudo(message.author!)) return message.reply('You currently not in sudo mode.'); + + sudoOverrides[message.author_id] = null; + + let msg = `# %emoji% Sudo mode disabled.`; + const sentMsg = await message.reply(msg.replace('%emoji%', ':unlock:'), false); + setTimeout(() => sentMsg?.edit({ content: msg.replace('%emoji%', ':lock:') }).catch(()=>{}), 200); + break; + } + + case null: + case undefined: + case '': { + let msg = `# :unlock: Sudo mode\n` + + `Sudo mode allows bot owners to bypass all permission checks for a limited time. ` + + `After activating, you will be able to run any command regardless of your server permissions.\n\n` + + `To enable, run \`${DEFAULT_PREFIX}botadm sudo enable\`.\n` + + `It will automatically be deactivated **5 minutes** after your last bot interaction.`; + + await message.reply(msg, false); + break; + } + + default: + await message.reply('sudo: Unknown subcommand'); + } + + break; + } + + default: + message.reply('Unknown subcommand. Available subcommands: ' + SUBCOMMANDS.join(', ')); + } + } catch(e) { console.error(e) } + } +} as Command; + +export { isSudo, updateSudoTimeout } diff --git a/src/bot/commands/botctl.ts b/src/bot/commands/botctl.ts index dc7023d..696478a 100644 --- a/src/bot/commands/botctl.ts +++ b/src/bot/commands/botctl.ts @@ -14,7 +14,7 @@ export default { description: 'Perform administrative actions', category: 'configuration', run: async (message: MessageCommandContext, args: string[]) => { - if (!isBotManager(message.member!, message.serverContext)) return message.reply(NO_MANAGER_MSG); + if (!isBotManager(message)) return message.reply(NO_MANAGER_MSG); let action = args.shift(); switch(action) { diff --git a/src/bot/commands/kick.ts b/src/bot/commands/kick.ts index 965a389..2cf43ab 100644 --- a/src/bot/commands/kick.ts +++ b/src/bot/commands/kick.ts @@ -16,7 +16,7 @@ export default { removeEmptyArgs: true, category: 'moderation', run: async (message: MessageCommandContext, args: string[]) => { - if (!await isModerator(message.member!, message.serverContext)) + if (!await isModerator(message)) return message.reply(NO_MANAGER_MSG); if (args.length == 0) diff --git a/src/bot/commands/moderator.ts b/src/bot/commands/moderator.ts index 751a270..9279768 100644 --- a/src/bot/commands/moderator.ts +++ b/src/bot/commands/moderator.ts @@ -17,7 +17,7 @@ export default { syntax: SYNTAX, category: 'configuration', run: async (message: MessageCommandContext, args: string[]) => { - if (!await isBotManager(message.member!, message.channel?.server!)) return message.reply(NO_MANAGER_MSG); + if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG); let config: ServerConfig = (await client.db.get('servers').findOne({ id: message.serverContext._id })) ?? {}; let mods = config.moderators ?? []; diff --git a/src/bot/commands/prefix.ts b/src/bot/commands/prefix.ts index 3010ee2..fd6539f 100644 --- a/src/bot/commands/prefix.ts +++ b/src/bot/commands/prefix.ts @@ -20,7 +20,7 @@ export default { switch(args[0]?.toLowerCase()) { case 'set': - if (!await isBotManager(message.member!, message.channel?.server!)) return message.reply(NO_MANAGER_MSG); + if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG); args.shift(); if (args.length == 0) return message.reply('You need to specify a prefix.'); @@ -43,7 +43,7 @@ export default { break; case 'clear': case 'reset': - if (!await isBotManager(message.member!, message.channel?.server!)) return message.reply(NO_MANAGER_MSG); + if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG); if (config.prefix != null) { await client.db.get('servers').update({ 'id': message.channel?.server_id }, { $set: { 'prefix': null } }); diff --git a/src/bot/commands/purge.ts b/src/bot/commands/purge.ts index 29f2dce..0ede4f6 100644 --- a/src/bot/commands/purge.ts +++ b/src/bot/commands/purge.ts @@ -15,7 +15,7 @@ export default { category: 'moderation', run: async (message: MessageCommandContext, args: string[]) => { try { - if (!message.member || !await isModerator(message.member!, message.channel?.server!)) return message.reply('🔒 Access denied'); + if (!message.member || !await isModerator(message)) return message.reply('🔒 Access denied'); let messages: Array = []; // X amount of messages from bottom diff --git a/src/bot/commands/settings.ts b/src/bot/commands/settings.ts index 451baf1..c9963cf 100644 --- a/src/bot/commands/settings.ts +++ b/src/bot/commands/settings.ts @@ -14,7 +14,7 @@ export default { description: 'Manage AutoMod\'s configuration', category: 'configuration', run: async (message: MessageCommandContext, args: string[]) => { - if (!isBotManager(message.member!, message.serverContext)) return message.reply(NO_MANAGER_MSG); + if (!isBotManager(message)) return message.reply(NO_MANAGER_MSG); return 'This feature is currently disabled'; diff --git a/src/bot/commands/unban.ts b/src/bot/commands/unban.ts index 81dfab6..8adf332 100644 --- a/src/bot/commands/unban.ts +++ b/src/bot/commands/unban.ts @@ -13,7 +13,7 @@ export default { syntax: '/unban [@user or ID]', category: 'moderation', run: async (message: MessageCommandContext, args: string[]) => { - if (!isModerator(message.member!, message.serverContext)) return message.reply(NO_MANAGER_MSG); + if (!isModerator(message)) return message.reply(NO_MANAGER_MSG); let checkTempBans = async (id: string): Promise => { let tempbans: FindResult = await client.db.get('tempbans').find({ bannedUser: id, server: message.serverContext._id }); diff --git a/src/bot/commands/warn.ts b/src/bot/commands/warn.ts index 5d35e48..d914d8b 100644 --- a/src/bot/commands/warn.ts +++ b/src/bot/commands/warn.ts @@ -13,7 +13,7 @@ export default { description: 'add an infraction to an user\'s record', category: 'moderation', run: async (message: MessageCommandContext, args: string[]) => { - if (!await isModerator(message.member!, message.serverContext)) return message.reply(NO_MANAGER_MSG); + if (!await isModerator(message)) return message.reply(NO_MANAGER_MSG); let user = await parseUserOrId(args.shift() ?? ''); if (!user) return message.reply('I can\'t find that user.'); if ((user as any)?.bot != null) return message.reply('You cannot warn bots.'); diff --git a/src/bot/commands/warns.ts b/src/bot/commands/warns.ts index 82aa3ed..6d2d4a7 100644 --- a/src/bot/commands/warns.ts +++ b/src/bot/commands/warns.ts @@ -18,7 +18,7 @@ export default { syntax: '/warns; /warns @username ["export-csv"]; /warns rm [ID]', category: 'moderation', run: async (message: MessageCommandContext, args: string[]) => { - if (!await isModerator(message.member!, message.serverContext)) return message.reply(NO_MANAGER_MSG); + if (!await isModerator(message)) return message.reply(NO_MANAGER_MSG); let collection = client.db.get('infractions'); let infractions: Array = await collection.find({ diff --git a/src/bot/commands/whitelist.ts b/src/bot/commands/whitelist.ts index bf80cae..9f224cc 100644 --- a/src/bot/commands/whitelist.ts +++ b/src/bot/commands/whitelist.ts @@ -18,7 +18,7 @@ export default { let config: ServerConfig = await client.db.get('servers').findOne({ id: message.serverContext._id }) || {} if (!config.whitelist) config.whitelist = { users: [], roles: [], managers: true } - if (!isBotManager(message.member!, message.serverContext)) return message.reply(NO_MANAGER_MSG); + if (!isBotManager(message)) return message.reply(NO_MANAGER_MSG); let user: User|null, role: string|undefined; switch(args[0]?.toLowerCase()) { diff --git a/src/bot/modules/antispam.ts b/src/bot/modules/antispam.ts index 70cd276..d0cda7a 100644 --- a/src/bot/modules/antispam.ts +++ b/src/bot/modules/antispam.ts @@ -30,7 +30,7 @@ async function antispam(message: Message): Promise { if (message.author?.bot != null) break; if (serverRules.whitelist?.users?.includes(message.author_id)) break; if (message.member?.roles?.filter(r => serverRules.whitelist?.roles?.includes(r)).length) break; - if (serverRules.whitelist?.managers !== false && await isModerator(message.member!, message.channel?.server!)) break; + if (serverRules.whitelist?.managers !== false && await isModerator(message)) break; if (rule.channels?.indexOf(message.channel_id) == -1) break; let store = msgCountStore.get(rule.id)!; diff --git a/src/bot/modules/command_handler.ts b/src/bot/modules/command_handler.ts index bfc16ea..1ff387e 100644 --- a/src/bot/modules/command_handler.ts +++ b/src/bot/modules/command_handler.ts @@ -10,6 +10,7 @@ import MessageCommandContext from "../../struct/MessageCommandContext"; import { fileURLToPath } from 'url'; import { getOwnMemberInServer, hasPermForChannel } from "../util"; import { prepareMessage } from "./prepare_message"; +import { isSudo, updateSudoTimeout } from "../commands/botadm"; // thanks a lot esm const filename = fileURLToPath(import.meta.url); @@ -68,6 +69,8 @@ let commands: Command[]; let cmd = commands.find(c => c.name == cmdName || (c.aliases?.indexOf(cmdName!) ?? -1) > -1); if (!cmd) return; + if (isSudo(msg.author!)) updateSudoTimeout(msg.author!); + if (cmd.restrict == 'BOTOWNER' && ownerIDs.indexOf(msg.author_id) == -1) { logger.warn(`User ${msg.author?.username} tried to run owner-only command: ${cmdName}`); msg.reply('🔒 Access denied'); diff --git a/src/bot/modules/user_scan.ts b/src/bot/modules/user_scan.ts index 2602d50..440eeb6 100644 --- a/src/bot/modules/user_scan.ts +++ b/src/bot/modules/user_scan.ts @@ -191,4 +191,4 @@ new Promise((res: (value: void) => void) => client.user ? res() : client.once('r }); }); -export { scanServer }; +export { scanServer, USERSCAN_WORDLIST_PATH, wordlist }; diff --git a/src/bot/util.ts b/src/bot/util.ts index 8917fa0..b183033 100644 --- a/src/bot/util.ts +++ b/src/bot/util.ts @@ -13,6 +13,8 @@ import logger from "./logger"; import { ulid } from "ulid"; import { Channel } from "revolt.js/dist/maps/Channels"; import { ChannelPermission, ServerPermission } from "revolt.js"; +import { Message } from "revolt.js/dist/maps/Messages"; +import { isSudo } from "./commands/botadm"; const NO_MANAGER_MSG = '🔒 Missing permission'; @@ -73,16 +75,30 @@ async function parseUserOrId(text: string): Promise { return null; } -async function isModerator(member: Member, server: Server) { +async function isModerator(message: Message) { + let member = message.member!, server = message.channel!.server!; return hasPerm(member, 'KickMembers') - || await isBotManager(member, server) + || await isBotManager(message) || (((await client.db.get('servers').findOne({ id: server._id }) || {}) as ServerConfig) - .moderators?.indexOf(member.user?._id!) ?? -1) > -1; + .moderators?.indexOf(member.user?._id!) ?? -1) > -1 + || await checkSudoPermission(message); } -async function isBotManager(member: Member, server: Server) { +async function isBotManager(message: Message) { + let member = message.member!, server = message.channel!.server!; return hasPerm(member, 'ManageServer') || (((await client.db.get('servers').findOne({ id: server._id }) || {}) as ServerConfig) - .botManagers?.indexOf(member.user?._id!) ?? -1) > -1; + .botManagers?.indexOf(member.user?._id!) ?? -1) > -1 + || await checkSudoPermission(message); +} +async function checkSudoPermission(message: Message): Promise { + const hasPerm = isSudo(message.author!); + console.log(hasPerm) + if (!hasPerm) return false; + else { + await message.reply(`# :unlock: Bypassed permission check\n` + + `Sudo mode is enabled for @${message.author!.username}.\n`); + return true; + } } function hasPerm(member: Member, perm: keyof typeof ServerPermission): boolean {