diff --git a/package.json b/package.json index 681eef4..46456f4 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,12 @@ "license": "ISC", "dependencies": { "@types/monk": "^6.0.0", + "axios": "^0.22.0", "dotenv": "^10.0.0", "log75": "2.0.1", "monk": "^7.3.4", - "revolt.js": "^5.1.0-alpha.6" + "revolt.js": "^5.1.0-alpha.6", + "ulid": "^2.3.0" }, "devDependencies": { "typescript": "^4.4.3" diff --git a/src/bot/commands/bot_managers.ts b/src/bot/commands/bot_managers.ts new file mode 100644 index 0000000..72623ea --- /dev/null +++ b/src/bot/commands/bot_managers.ts @@ -0,0 +1,66 @@ +import Command from "../../struct/Command"; +import { Message } from "revolt.js/dist/maps/Messages"; +import { hasPerm, parseUser } from "../util"; +import ServerConfig from "../../struct/ServerConfig"; +import { client } from "../.."; +import { User } from "revolt.js/dist/maps/Users"; + +const SYNTAX = '/admin add @user; /admin remove @user; /admin list'; + +export default { + name: 'admin', + aliases: [ 'admins', 'manager', 'managers' ], + description: 'Allow users to control the bot\'s configuration', + syntax: SYNTAX, + serverOnly: true, + run: async (message: Message, args: string[]) => { + if (!hasPerm(message.member!, 'ManageServer')) + return message.reply('You need **ManageServer** permission to use this command.'); + + let config: ServerConfig = (await client.db.get('servers').findOne({ id: message.channel?.server_id })) ?? {}; + let admins = config.botManagers ?? []; + let user: User|null; + + switch(args[0]?.toLowerCase()) { + case 'add': + case 'new': + if (!args[1]) return message.reply('No user specified.'); + user = await parseUser(args[1]); + if (!user) return message.reply('I can\'t find that user.'); + + if (admins.indexOf(user._id) > -1) return message.reply('This user is already added as bot admin.'); + + admins.push(user._id); + await client.db.get('servers').update({ id: message.channel?.server_id }, { $set: { botManagers: admins } }); + + message.reply(`✅ Added \`@${user.username}\` to bot admins.`); + break; + case 'remove': + case 'delete': + case 'rm': + case 'del': + if (!args[1]) return message.reply('No user specified.'); + user = await parseUser(args[1]); + if (!user) return message.reply('I can\'t find that user.'); + + if (admins.indexOf(user._id) == -1) return message.reply('This user is not added as bot admin.'); + + admins = admins.filter(a => a != user?._id); + await client.db.get('servers').update({ id: message.channel?.server_id }, { $set: { botManagers: admins } }); + + message.reply(`✅ Removed \`@${user.username}\` from bot admins.`); + break; + case 'list': + case 'ls': + case 'show': + message.reply(`# Bot admins\n` + + `Users with **ManageServer** permission can add or remove admins.\n\n` + + `${admins.map(a => `* <@${a}>`).join('\n')}\n\n` + + `${admins.length} user${admins.length == 1 ? '' : 's'}.`) + ?.catch(e => message.reply(e)); + break; + default: + message.reply(`Available subcommands: ${SYNTAX}`); + } + } +} as Command; diff --git a/src/bot/commands/debug.ts b/src/bot/commands/debug.ts index eae9355..15813e2 100644 --- a/src/bot/commands/debug.ts +++ b/src/bot/commands/debug.ts @@ -11,7 +11,5 @@ export default { message.reply(`Server ID: ${message.channel?.server_id || 'None'}\n` + `Channel ID: ${message.channel_id}\n` + `User ID: ${message.author_id}`); - - console.log(hasPerm(message.member!, 'BanMembers')); } } as Command; diff --git a/src/bot/commands/eval.ts b/src/bot/commands/eval.ts new file mode 100644 index 0000000..86e52fd --- /dev/null +++ b/src/bot/commands/eval.ts @@ -0,0 +1,42 @@ +import Command from "../../struct/Command"; +import { Message } from "revolt.js/dist/maps/Messages"; +import { inspect } from 'util'; + +export default { + name: 'eval', + aliases: [ 'e' ], + description: 'Evaluate JS code', + restrict: 'BOTOWNER', + removeEmptyArgs: false, + serverOnly: false, + run: async (message: Message, args: string[]) => { + let cmd = `let { client } = require("../..");` + + `let axios = require("axios").default;` + + `let crypto = require("crypto");` + + args.join(' '); + + let m = await message.channel?.sendMessage(`Executing...`); + + try { + let e = eval(cmd); + + if (e instanceof Promise) { + await m?.edit({ content: `## **Promise**` }); + e.then((res) => { + m?.edit({ + content: `## **Promise**\n\`\`\`js\n${`${inspect(res)}`.substr(0, 1960)}\n\`\`\`` + }); + }) + .catch((res) => { + m?.edit({ + content: `## **Promise**\n\`\`\`js\n${`${inspect(res)}`.substr(0, 1960)}\n\`\`\`` + }); + }); + } else { + message.channel?.sendMessage(`\`\`\`js\n${inspect(e).substr(0, 1980)}\n\`\`\``); + } + } catch(e) { + m?.edit({ content: `## Execution failed\n\`\`\`js\n${inspect(e).substr(0, 1960)}\n\`\`\`` }); + } + } +} as Command; diff --git a/src/bot/commands/prefix.ts b/src/bot/commands/prefix.ts index b97f3a6..a3a461a 100644 --- a/src/bot/commands/prefix.ts +++ b/src/bot/commands/prefix.ts @@ -3,7 +3,7 @@ import { Message } from "revolt.js/dist/maps/Messages"; import { client } from "../.."; import ServerConfig from "../../struct/ServerConfig"; import { DEFAULT_PREFIX } from "../modules/command_handler"; -import { hasPerm } from "../util"; +import { hasPerm, isBotManager, NO_MANAGER_MSG } from "../util"; const SYNTAX = '/prefix set [new prefix]; /prefix get; prefix clear'; const MENTION_TEXT = 'You can also @mention me instead of using the prefix.'; @@ -16,9 +16,10 @@ export default { serverOnly: true, run: async (message: Message, args: string[]) => { let config: ServerConfig = (await client.db.get('servers').findOne({ id: message.channel?.server_id })) ?? {}; + switch(args[0]?.toLowerCase()) { case 'set': - if (!hasPerm(message.member!, 'ManageServer')) return message.reply('You need ManageServer permission for this.'); + if (!await isBotManager(message.member!)) return message.reply(NO_MANAGER_MSG); args.shift(); if (args.length == 0) return message.reply('You need to specify a prefix.'); @@ -41,7 +42,7 @@ export default { break; case 'clear': case 'reset': - if (!hasPerm(message.member!, 'ManageServer')) return message.reply('You need ManageServer permission for this.'); + if (!await isBotManager(message.member!)) 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/settings.ts b/src/bot/commands/settings.ts new file mode 100644 index 0000000..a3a0107 --- /dev/null +++ b/src/bot/commands/settings.ts @@ -0,0 +1,33 @@ +import Command from "../../struct/Command"; +import { Message } from "revolt.js/dist/maps/Messages"; +import { client } from "../.."; +import AutomodSettings from "../../struct/antispam/AutomodSettings"; +import AntispamRule from "../../struct/antispam/AntispamRule"; +import ModerationAction from "../../struct/antispam/ModerationAction"; +import { isBotManager, NO_MANAGER_MSG } from "../util"; +import { ulid } from 'ulid'; + +export default { + name: 'settings', + aliases: [ 'setting' ], + description: 'change antispam settings', + serverOnly: false, + run: async (message: Message, args: string[]) => { + if (!isBotManager(message.member!)) return message.reply(NO_MANAGER_MSG); + + let settings = { + spam: [ + { + id: ulid(), + max_msg: 5, + timeframe: 3, + action: ModerationAction.Delete, + channels: [ '01FHJD5D2PBRTEVPNFM1FRY85J' ], + } as AntispamRule + ] + } as AutomodSettings; + + client.db.get('servers') + .update({ id: message.channel?.server_id }, { $set: { automodSettings: settings } }); + } +} as Command; diff --git a/src/bot/commands/shell_eval.ts b/src/bot/commands/shell_eval.ts new file mode 100644 index 0000000..8cc010f --- /dev/null +++ b/src/bot/commands/shell_eval.ts @@ -0,0 +1,46 @@ +import Command from "../../struct/Command"; +import { Message } from "revolt.js/dist/maps/Messages"; +import { exec } from 'child_process'; + +export default { + name: 'shell', + aliases: [ 'exec', 'sh' ], + description: 'Run code in a shell', + restrict: 'BOTOWNER', + removeEmptyArgs: false, + serverOnly: false, + run: async (message: Message, args: string[]) => { + let cmd = args.join(' '); + + let m = await message.channel?.sendMessage(`Executing...`); + + try { + let editMsg = () => { + if (str != '' && str != oldStr) { + if (str.length > 2000) { + str = str.substr(str.length - 2000); + } + + m?.edit({ content: str }) + .catch(e => console.warn('Failed to edit message')); + } + } + + let str = '', oldStr = ''; + let e = exec(cmd); + let i = setInterval(editMsg, 1000); + + e.stdout?.on('data', m => { + str += m; + }); + + e.on('exit', (code) => { + clearInterval(i); + str += `\n\n**Exit code:** ${code}`; + editMsg(); + }); + } catch(e) { + message.channel?.sendMessage(`${e}`); + } + } +} as Command; diff --git a/src/bot/modules/antispam.ts b/src/bot/modules/antispam.ts new file mode 100644 index 0000000..980f042 --- /dev/null +++ b/src/bot/modules/antispam.ts @@ -0,0 +1,65 @@ +import { Message } from "revolt.js/dist/maps/Messages"; +import { client } from "../.."; +import ModerationAction from "../../struct/antispam/ModerationAction"; +import ServerConfig from "../../struct/ServerConfig"; +import logger from "../logger"; + +let msgCountStore: Map = new Map(); + +/** + * + * @param message + * @returns true if ok, false if spam rule triggered + */ +async function antispam(message: Message): Promise { + let serverRules: ServerConfig = await client.db.get('servers').findOne({ id: message.channel?.server_id }) ?? {}; + if (!serverRules.automodSettings) return true; + + let ruleTriggered = false; + + for (const rule of serverRules.automodSettings.spam) { + if (msgCountStore.get(rule.id) == null) { + msgCountStore.set(rule.id, { users: {} }); + } + + if (rule.channels?.indexOf(message.channel_id) == -1) break; + + let store = msgCountStore.get(rule.id)!; + if (!store.users[message.channel_id]) store.users[message.channel_id] = {} + let userStore = store.users[message.channel_id]; + + if (!userStore.count) userStore.count = 1; + else userStore.count++; + + setTimeout(() => userStore.count--, rule.timeframe * 1000); + + if (userStore.count > rule.max_msg) { + logger.info(`Antispam rule triggered: ${rule.max_msg}/${rule.timeframe} -> ${ModerationAction[rule.action]}`); + ruleTriggered = true; + + switch(rule.action) { + case ModerationAction.Delete: + message.delete() + .catch(() => logger.warn('Antispam: Failed to delete message') ); + break; + case ModerationAction.Warn: + if (!userStore.warnTriggered) { + userStore.warnTriggered = true; + setTimeout(() => userStore.warnTriggered = false, 5000); + message.channel?.sendMessage(`<@${message.author_id}>, stop spamming (placeholder warn message)`); + } + break; + case ModerationAction.Kick: + message.reply('(Kick user)'); + break; + case ModerationAction.Ban: + message.reply('(Ban user)'); + break; + } + } + } + + return !ruleTriggered; +} + +export { antispam } diff --git a/src/bot/modules/command_handler.ts b/src/bot/modules/command_handler.ts index d74f4e0..1dd6c96 100644 --- a/src/bot/modules/command_handler.ts +++ b/src/bot/modules/command_handler.ts @@ -4,6 +4,7 @@ import { client } from "../../index"; import fs from 'fs'; import path from 'path'; import ServerConfig from "../../struct/ServerConfig"; +import { antispam } from "./antispam"; const DEFAULT_PREFIX = process.env['PREFIX'] ?? '/'; @@ -15,6 +16,9 @@ client.on('message', async message => { logger.debug(`Message -> ${message.content}`); if (typeof message.content != 'string' || message.author_id == client.user?._id || !message.channel) return; + // Send message through anti spam check + if (!antispam(message)) return; + let config: ServerConfig = (await client.db.get('servers').findOne({ 'id': message.channel?.server_id })) ?? {}; let guildPrefix = config.prefix ?? DEFAULT_PREFIX; @@ -34,12 +38,26 @@ client.on('message', async message => { let cmd = commands.find(c => c.name == cmdName || (c.aliases?.indexOf(cmdName!) ?? -1) > -1); if (!cmd) return; + let ownerIDs = process.env['BOT_OWNERS'] ? process.env['BOT_OWNERS'].split(',') : []; + if (cmd.restrict == 'BOTOWNER' && ownerIDs.indexOf(message.author_id) == -1) { + logger.warn(`User ${message.author?.username} tried to run owner-only command: ${cmdName}`); + message.reply('🔒 Access denied'); + return; + } + logger.info(`Command: ${message.author?.username} in ${message.channel?.server?.name}: ${message.content}`); + // Create document for server in DB, if not already present + if (JSON.stringify(config) == '{}') await client.db.get('servers').insert({ id: message.channel?.server_id }); + if (cmd.serverOnly && !message.channel?.server) { return message.reply('This command is not available in direct messages.'); } + if (cmd.removeEmptyArgs !== false) { + args = args.filter(a => a.length > 0); + } + try { cmd.run(message, args); } catch(e) { diff --git a/src/bot/util.ts b/src/bot/util.ts index fe007f5..72fd09d 100644 --- a/src/bot/util.ts +++ b/src/bot/util.ts @@ -1,4 +1,7 @@ import { Member } from "revolt.js/dist/maps/Members"; +import { User } from "revolt.js/dist/maps/Users"; +import { client } from ".."; +import ServerConfig from "../struct/ServerConfig"; let ServerPermissions = { ['View' as string]: 1 << 0, @@ -13,15 +16,66 @@ let ServerPermissions = { ['RemoveAvatars' as string]: 1 << 15, } +const NO_MANAGER_MSG = '🔒 Missing permission'; +const USER_MENTION_REGEX = /^<@[0-9A-HJ-KM-NP-TV-Z]{26}>$/i; +const CHANNEL_MENTION_REGEX = /^<#[0-9A-HJ-KM-NP-TV-Z]{26}>$/i; + +/** + * Parses user input and returns an user object. + * Supports: `userID`, `<@userID>` (mention), `username`, `@username` (if user is cached). + * @param text + * @returns null if not found, otherwise user object + */ +async function parseUser(text: string): Promise { + if (!text) return null; + + let uid: string|null = null; + if (USER_MENTION_REGEX.test(text)) { + uid = text.replace(/<@|>/g, '').toUpperCase(); + } else if (/^[0-9A-HJ-KM-NP-TV-Z]{26}$/gi.test(text)) { + uid = text.toUpperCase(); + } else { + if (text.startsWith('@')) text = text.substr(1); + + // Why is there no .find() or .filter() + let user: User|null = null; + client.users.forEach(u => { + if (u.username?.toLowerCase() == text.toLowerCase()) { + user = u; + } + }); + + if (user) return user; + } + + if (uid) return await client.users.fetch(uid) || null; + else return null; +} + +async function isBotManager(member: Member) { + return hasPerm(member, 'ManageServer') + || (((await client.db.get('servers').findOne({ id: member.server?._id }) || {}) as ServerConfig) + .botManagers?.indexOf(member.user?._id!) ?? -1) > -1; +} function hasPerm(member: Member, perm: 'View'|'ManageRoles'|'ManageChannels'|'ManageServer'| // its late and im tired 'KickMembers'|'BanMembers'|'ChangeNickname'| // dont judge my code - 'ManageNicknames'|'ChangeAvatar'|'RemoveAvatars') { + 'ManageNicknames'|'ChangeAvatar'|'RemoveAvatars'): boolean { let p = ServerPermissions[perm]; if (member.server?.owner == member.user?._id) return true; + + // this should work but im not 100% certain + let userPerm = member.roles?.map(id => member.server?.roles?.[id]) + .reduce((sum: number, cur: any) => sum | cur.permissions[0], member.server?.default_permissions[0]) ?? 0; - // TODO how the fuck do bitfields work - return false; + return !!(userPerm & p); } -export { hasPerm } +export { + hasPerm, + isBotManager, + parseUser, + NO_MANAGER_MSG, + USER_MENTION_REGEX, + CHANNEL_MENTION_REGEX +} diff --git a/src/struct/Command.ts b/src/struct/Command.ts index 92dba19..84aa34b 100644 --- a/src/struct/Command.ts +++ b/src/struct/Command.ts @@ -3,6 +3,8 @@ class Command { aliases: string[] | null; description: string | null; syntax?: string | null; + restrict?: 'BOTOWNER' | null; + removeEmptyArgs?: boolean | null; run: Function; serverOnly: boolean; } diff --git a/src/struct/ServerConfig.ts b/src/struct/ServerConfig.ts index 0cd1226..b02447c 100644 --- a/src/struct/ServerConfig.ts +++ b/src/struct/ServerConfig.ts @@ -1,7 +1,11 @@ +import AutomodSettings from "./antispam/AutomodSettings"; + class ServerConfig { id: string | undefined; prefix: string | undefined; spaceAfterPrefix: boolean | undefined; + automodSettings: AutomodSettings | undefined; + botManagers: string[] | undefined; } export default ServerConfig; diff --git a/src/struct/antispam/AntispamRule.ts b/src/struct/antispam/AntispamRule.ts new file mode 100644 index 0000000..6c3e0a7 --- /dev/null +++ b/src/struct/antispam/AntispamRule.ts @@ -0,0 +1,19 @@ +import ModerationAction from "./ModerationAction"; + +/** + * Allow a maximum of X messages per X seconds. + * Example: max_msg = 5, timeframe = 3, action: Delete + * Allows a maximum of 5 messages within 3 seconds, + * and will delete any additional messages. + * + * `channels` optionally limits the rule to specific channels. + */ +class AntispamRule { + id: string; + max_msg: number; + timeframe: number; + action: ModerationAction; + channels: string[] | null; +} + +export default AntispamRule; diff --git a/src/struct/antispam/AutomodSettings.ts b/src/struct/antispam/AutomodSettings.ts new file mode 100644 index 0000000..ee135b5 --- /dev/null +++ b/src/struct/antispam/AutomodSettings.ts @@ -0,0 +1,7 @@ +import AntispamRule from "./AntispamRule"; + +class AutomodSettings { + spam: AntispamRule[]; +} + +export default AutomodSettings; diff --git a/src/struct/antispam/ModerationAction.ts b/src/struct/antispam/ModerationAction.ts new file mode 100644 index 0000000..4a0492a --- /dev/null +++ b/src/struct/antispam/ModerationAction.ts @@ -0,0 +1,8 @@ +enum ModerationAction { + Delete = 0, + Warn = 1, + Kick = 2, + Ban = 3, +} + +export default ModerationAction; diff --git a/yarn.lock b/yarn.lock index cd67b27..3dab908 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41,6 +41,13 @@ axios@^0.21.4: dependencies: follow-redirects "^1.14.0" +axios@^0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.22.0.tgz#bf702c41fb50fbca4539589d839a077117b79b25" + integrity sha512-Z0U3uhqQeg1oNcihswf4ZD57O3NrR1+ZXhxaROaWpDmsDTx7T2HNBV2ulBtie2hwJptu8UvgnJoK+BIqdzh/1w== + dependencies: + follow-redirects "^1.14.4" + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -106,7 +113,7 @@ exponential-backoff@^3.1.0: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.0.tgz#9409c7e579131f8bd4b32d7d8094a911040f2e68" integrity sha512-oBuz5SYz5zzyuHINoe9ooePwSu0xApKWgeNzok4hZ5YKXFh9zrQBEM15CXqoZkJJPuI2ArvqjPQd8UKJA753XA== -follow-redirects@^1.14.0: +follow-redirects@^1.14.0, follow-redirects@^1.14.4: version "1.14.4" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==