diff --git a/package-lock.json b/package-lock.json index a316fff3..08acac55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2176,9 +2176,9 @@ } }, "knub": { - "version": "9.4.11", - "resolved": "https://registry.npmjs.org/knub/-/knub-9.4.11.tgz", - "integrity": "sha512-C/Ps3jegzgVfaKfcyumUhPdFd269t4yuAUWnXf71S3d/6MCLhVMfGehG0Ma2AMb5M85DqN8ge9n3OME3k1zwJw==", + "version": "9.4.13", + "resolved": "https://registry.npmjs.org/knub/-/knub-9.4.13.tgz", + "integrity": "sha512-4m5IMbctg1xAe6DoYSkk1jdQNWpUb6ZkjKxJPxHEmbXtIZm11qt/AmIcASgG5pvZOM7Q/PnsbLfRyzlUTbvOLA==", "requires": { "escape-string-regexp": "^1.0.5", "js-yaml": "^3.9.1", @@ -3670,6 +3670,11 @@ "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", "dev": true }, + "tlds": { + "version": "1.203.1", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.203.1.tgz", + "integrity": "sha512-7MUlYyGJ6rSitEZ3r1Q1QNV8uSIzapS8SmmhSusBuIc7uIxPPwsKllEP0GRp1NS6Ik6F+fRZvnjDWm3ecv2hDw==" + }, "to-object-path": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", diff --git a/package.json b/package.json index 230a2855..89f72124 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,16 @@ "@types/node": "^8.0.50", "dotenv": "^4.0.0", "eris": "^0.8.6", + "escape-string-regexp": "^1.0.5", "humanize-duration": "^3.15.0", "knex": "^0.14.6", - "knub": "^9.4.11", + "knub": "^9.4.13", "lodash.at": "^4.6.0", "lodash.difference": "^4.5.0", "lodash.isequal": "^4.5.0", "mariasql": "^0.2.6", "moment-timezone": "^0.5.21", + "tlds": "^1.203.1", "ts-node": "^3.3.0", "typescript": "^2.9.2" }, diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json index 52c90d51..13f3e2aa 100644 --- a/src/data/DefaultLogMessages.json +++ b/src/data/DefaultLogMessages.json @@ -24,17 +24,18 @@ "ROLE_EDIT": "🖊 Role **{role.name}** was edited", "MESSAGE_EDIT": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message edited in **{channel.name}**:\n`B:` {before}\n`A:` {after}", - "MESSAGE_DELETE": "🗑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message deleted in **{channel.name}**:\n{messageText}{attachments}", + "MESSAGE_DELETE": "🗑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message deleted in **{channel.name}**:\n```{messageText}```", "MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in **{channel.name}**", + "MESSAGE_DELETE_BARE": "🗑 message (id `{messageId}`) deleted in **{channel.name}** (no more info available due to restart)", "VOICE_CHANNEL_JOIN": "🔸 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) joined **{channel.name}**", "VOICE_CHANNEL_MOVE": "🔹 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) moved **{oldChannel.name}** ➞ **{newChannel.name}**", "VOICE_CHANNEL_LEAVE": "♦ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) left **{channel.name}**", - "COMMAND": "🤖 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) used command in **{channel.name}**:\n`{command}`", + "COMMAND": "🤖 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) used command in **#{channel.name}**:\n`{command}`", "SPAM_DELETE": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) triggered spam filter: **{filterName}**", - "CENSOR": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) censored message in **{channel.name}**:\n`{messageText}`", + "CENSOR": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) censored message in **#{channel.name}** (`{channel.id}`) {reason}:\n```{messageText}```", "CASE_CREATE": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) manually created new **{caseType}** case (#{caseNum})" } diff --git a/src/data/GuildLogs.ts b/src/data/GuildLogs.ts index 227bab51..48fe9727 100644 --- a/src/data/GuildLogs.ts +++ b/src/data/GuildLogs.ts @@ -21,6 +21,7 @@ export class GuildLogs extends EventEmitter { super(); this.guildId = guildId; + this.ignoredLogs = []; // Store the instance for this guild so it can be returned later if a new instance for this guild is requested guildInstances.set(guildId, this); diff --git a/src/data/LogType.ts b/src/data/LogType.ts index 0d6288e1..017695e5 100644 --- a/src/data/LogType.ts +++ b/src/data/LogType.ts @@ -24,6 +24,7 @@ export enum LogType { MESSAGE_EDIT, MESSAGE_DELETE, MESSAGE_DELETE_BULK, + MESSAGE_DELETE_BARE, VOICE_CHANNEL_JOIN, VOICE_CHANNEL_LEAVE, diff --git a/src/index.ts b/src/index.ts index 9cfc4de7..cae27e00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { UtilityPlugin } from "./plugins/Utility"; import { LogsPlugin } from "./plugins/Logs"; import { PostPlugin } from "./plugins/Post"; import { ReactionRolesPlugin } from "./plugins/ReactionRoles"; +import { CensorPlugin } from "./plugins/Censor"; import knex from "./knex"; // Run latest database migrations @@ -32,7 +33,8 @@ knex.migrate.latest().then(() => { mod_actions: ModActionsPlugin, logs: LogsPlugin, post: PostPlugin, - reaction_roles: ReactionRolesPlugin + reaction_roles: ReactionRolesPlugin, + censor: CensorPlugin }, globalPlugins: { bot_control: BotControlPlugin diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts new file mode 100644 index 00000000..0aaaee71 --- /dev/null +++ b/src/plugins/Censor.ts @@ -0,0 +1,212 @@ +import { Plugin, decorators as d } from "knub"; +import { Invite, Message } from "eris"; +import url from "url"; +import tlds from "tlds"; +import escapeStringRegexp from "escape-string-regexp"; +import { GuildLogs } from "../data/GuildLogs"; +import { LogType } from "../data/LogType"; +import { stripObjectToScalars } from "../utils"; + +const urlRegex = /(\S+\.\S+)/g; +const protocolRegex = /^[a-z]+:\/\//; + +const getInviteCodesInString = (str: string): string[] => { + const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi; + const inviteCodes = []; + let match; + + // tslint:disable-next-line + while ((match = inviteCodeRegex.exec(str)) !== null) { + inviteCodes.push(match[1]); + } + + return inviteCodes; +}; + +const getUrlsInString = (str: string): url.URL[] => { + const matches = str.match(urlRegex) || []; + return matches.reduce((urls, match) => { + if (!protocolRegex.test(match)) { + match = `https://${match}`; + } + + let matchUrl: url.URL; + try { + matchUrl = new url.URL(match); + } catch (e) { + return urls; + } + + const hostnameParts = matchUrl.hostname.split("."); + const tld = hostnameParts[hostnameParts.length - 1]; + if (tlds.includes(tld)) { + urls.push(matchUrl); + } + + return urls; + }, []); +}; + +export class CensorPlugin extends Plugin { + protected serverLogs: GuildLogs; + + getDefaultOptions() { + return { + config: { + filter_invites: false, + invite_guild_whitelist: null, + invite_guild_blacklist: null, + invite_code_whitelist: null, + invite_code_blacklist: null, + + filter_domains: false, + domain_whitelist: null, + domain_blacklist: null, + + blocked_tokens: null, + blocked_words: null, + blocked_regex: null + }, + + overrides: [ + { + level: ">=50", + config: { + filter_invites: false, + filter_domains: false, + blocked_tokens: null, + blocked_words: null, + blocked_regex: null + } + } + ] + }; + } + + onLoad() { + this.serverLogs = new GuildLogs(this.guildId); + } + + async censorMessage(msg: Message, reason: string) { + this.serverLogs.ignoreLog(LogType.MESSAGE_DELETE, msg.id); + + try { + await msg.delete("Censored"); + } catch (e) { + return; + } + + this.serverLogs.log(LogType.CENSOR, { + member: stripObjectToScalars(msg.member, ["user"]), + channel: stripObjectToScalars(msg.channel), + reason, + messageText: msg.cleanContent + }); + } + + async applyFiltersToMsg(msg: Message) { + if (msg.author.bot) return; + if (msg.type !== 0) return; + if (!msg.content) return; + + // Filter invites + if (this.configValueForMsg(msg, "filter_invites")) { + const inviteGuildWhitelist: string[] = this.configValueForMsg(msg, "invite_guild_whitelist"); + const inviteGuildBlacklist: string[] = this.configValueForMsg(msg, "invite_guild_blacklist"); + const inviteCodeWhitelist: string[] = this.configValueForMsg(msg, "invite_code_whitelist"); + const inviteCodeBlacklist: string[] = this.configValueForMsg(msg, "invite_code_blacklist"); + + const inviteCodes = getInviteCodesInString(msg.content); + + const invites: Invite[] = await Promise.all( + inviteCodes.map(code => this.bot.getInvite(code)) + ); + + for (const invite of invites) { + if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild.id)) { + this.censorMessage( + msg, + `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) not found in whitelist` + ); + return; + } + + if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild.id)) { + this.censorMessage( + msg, + `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) found in blacklist` + ); + return; + } + + if (inviteCodeWhitelist && !inviteCodeWhitelist.includes(invite.code)) { + this.censorMessage(msg, `invite code (\`${invite.code}\`) not found in whitelist`); + return; + } + + if (inviteCodeBlacklist && inviteCodeBlacklist.includes(invite.code)) { + this.censorMessage(msg, `invite code (\`${invite.code}\`) found in blacklist`); + return; + } + } + } + + // Filter domains + if (this.configValueForMsg(msg, "filter_domains")) { + const domainWhitelist: string[] = this.configValueForMsg(msg, "domain_whitelist"); + const domainBlacklist: string[] = this.configValueForMsg(msg, "domain_blacklist"); + + const urls = getUrlsInString(msg.content); + for (const thisUrl of urls) { + if (domainWhitelist && !domainWhitelist.includes(thisUrl.hostname)) { + this.censorMessage(msg, `domain (\`${thisUrl.hostname}\`) not found in whitelist`); + return; + } + + if (domainBlacklist && domainBlacklist.includes(thisUrl.hostname)) { + this.censorMessage(msg, `domain (\`${thisUrl.hostname}\`) found in blacklist`); + return; + } + } + } + + // Filter tokens + const blockedTokens = this.configValueForMsg(msg, "blocked_tokens") || []; + for (const token of blockedTokens) { + if (msg.content.includes(token)) { + this.censorMessage(msg, `blocked token (\`${token}\`) found`); + return; + } + } + + // Filter words + const blockedWords = this.configValueForMsg(msg, "blocked_words") || []; + for (const word of blockedWords) { + const regex = new RegExp(`\\b${escapeStringRegexp(word)}\\b`, "i"); + if (regex.test(msg.content)) { + this.censorMessage(msg, `blocked word (\`${word}\`) found`); + return; + } + } + + // Filter regex + const blockedRegex = this.configValueForMsg(msg, "blocked_regex") || []; + for (const regexStr of blockedRegex) { + const regex = new RegExp(regexStr); + if (regex.test(msg.content)) { + this.censorMessage(msg, `blocked regex (\`${regexStr}\`) found`); + return; + } + } + } + + @d.event("messageCreate") + async onMessageCreate(msg: Message) { + this.applyFiltersToMsg(msg); + } + + @d.event("messageUpdate") + async onMessageUpdate(msg: Message) { + this.applyFiltersToMsg(msg); + } +} diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts index 974b78cb..19f2dfbb 100644 --- a/src/plugins/Logs.ts +++ b/src/plugins/Logs.ts @@ -1,15 +1,7 @@ import { decorators as d, Plugin } from "knub"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; -import { - Channel, - Constants as ErisConstants, - Member, - Message, - PrivateChannel, - TextChannel, - User -} from "eris"; +import { Channel, Constants as ErisConstants, Member, Message, TextChannel, User } from "eris"; import { findRelevantAuditLogEntry, formatTemplateString, stripObjectToScalars } from "../utils"; import DefaultLogMessages from "../data/DefaultLogMessages.json"; import moment from "moment-timezone"; @@ -98,7 +90,7 @@ export class LogsPlugin extends Plugin { round: true }); - this.log(LogType.MEMBER_JOIN, { + this.serverLogs.log(LogType.MEMBER_JOIN, { member: stripObjectToScalars(member, ["user"]), new: member.createdAt >= newThreshold ? " :new:" : "", account_age: accountAge @@ -107,7 +99,7 @@ export class LogsPlugin extends Plugin { @d.event("guildMemberRemove") onMemberLeave(_, member) { - this.log(LogType.MEMBER_LEAVE, { + this.serverLogs.log(LogType.MEMBER_LEAVE, { member: stripObjectToScalars(member, ["user"]) }); } @@ -121,7 +113,7 @@ export class LogsPlugin extends Plugin { ); const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser; - this.log(LogType.MEMBER_BAN, { + this.serverLogs.log(LogType.MEMBER_BAN, { user: stripObjectToScalars(user), mod: stripObjectToScalars(mod) }); @@ -136,7 +128,7 @@ export class LogsPlugin extends Plugin { ); const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser; - this.log(LogType.MEMBER_UNBAN, { + this.serverLogs.log(LogType.MEMBER_UNBAN, { user: stripObjectToScalars(user), mod: stripObjectToScalars(mod) }); @@ -147,7 +139,7 @@ export class LogsPlugin extends Plugin { if (!oldMember) return; if (member.nick !== oldMember.nick) { - this.log(LogType.MEMBER_NICK_CHANGE, { + this.serverLogs.log(LogType.MEMBER_NICK_CHANGE, { member, oldNick: oldMember.nick, newNick: member.nick @@ -166,13 +158,13 @@ export class LogsPlugin extends Plugin { const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser; if (addedRoles.length) { - this.log(LogType.MEMBER_ROLE_ADD, { + this.serverLogs.log(LogType.MEMBER_ROLE_ADD, { member, role: this.guild.roles.get(addedRoles[0]), mod: stripObjectToScalars(mod) }); } else if (removedRoles.length) { - this.log(LogType.MEMBER_ROLE_REMOVE, { + this.serverLogs.log(LogType.MEMBER_ROLE_REMOVE, { member, role: this.guild.roles.get(removedRoles[0]), mod: stripObjectToScalars(mod) @@ -187,7 +179,7 @@ export class LogsPlugin extends Plugin { if (user.username !== oldUser.username || user.discriminator !== oldUser.discriminator) { const member = this.guild.members.get(user.id) || { id: user.id, user }; - this.log(LogType.MEMBER_USERNAME_CHANGE, { + this.serverLogs.log(LogType.MEMBER_USERNAME_CHANGE, { member: stripObjectToScalars(member, ["user"]), oldName: `${oldUser.username}#${oldUser.discriminator}`, newName: `${user.username}#${user.discriminator}` @@ -197,28 +189,28 @@ export class LogsPlugin extends Plugin { @d.event("channelCreate") onChannelCreate(channel) { - this.log(LogType.CHANNEL_CREATE, { + this.serverLogs.log(LogType.CHANNEL_CREATE, { channel: stripObjectToScalars(channel) }); } @d.event("channelDelete") onChannelDelete(channel) { - this.log(LogType.CHANNEL_DELETE, { + this.serverLogs.log(LogType.CHANNEL_DELETE, { channel: stripObjectToScalars(channel) }); } @d.event("guildRoleCreate") onRoleCreate(_, role) { - this.log(LogType.ROLE_CREATE, { + this.serverLogs.log(LogType.ROLE_CREATE, { role: stripObjectToScalars(role) }); } @d.event("guildRoleDelete") onRoleDelete(_, role) { - this.log(LogType.ROLE_DELETE, { + this.serverLogs.log(LogType.ROLE_DELETE, { role: stripObjectToScalars(role) }); } @@ -226,8 +218,9 @@ export class LogsPlugin extends Plugin { @d.event("messageUpdate") onMessageUpdate(msg: Message, oldMsg: Message) { if (oldMsg && msg.content === oldMsg.content) return; + if (msg.type !== 0) return; - this.log(LogType.MESSAGE_EDIT, { + this.serverLogs.log(LogType.MESSAGE_EDIT, { member: stripObjectToScalars(msg.member, ["user"]), channel: stripObjectToScalars(msg.channel), before: oldMsg ? oldMsg.content || "" : "Unavailable due to restart", @@ -237,16 +230,33 @@ export class LogsPlugin extends Plugin { @d.event("messageDelete") onMessageDelete(msg: Message) { - this.log(LogType.MESSAGE_DELETE, { - member: stripObjectToScalars(msg.member, ["user"]), - channel: stripObjectToScalars(msg.channel), - messageText: msg.cleanContent || "" - }); + if (msg.type !== 0) return; + + if (msg.member) { + this.serverLogs.log( + LogType.MESSAGE_DELETE, + { + member: stripObjectToScalars(msg.member, ["user"]), + channel: stripObjectToScalars(msg.channel), + messageText: msg.cleanContent || "" + }, + msg.id + ); + } else { + this.serverLogs.log( + LogType.MESSAGE_DELETE_BARE, + { + messageId: msg.id, + channel: stripObjectToScalars(msg.channel) + }, + msg.id + ); + } } @d.event("messageDeleteBulk") onMessageDeleteBulk(messages: Message[]) { - this.log(LogType.MESSAGE_DELETE_BULK, { + this.serverLogs.log(LogType.MESSAGE_DELETE_BULK, { count: messages.length, channel: messages[0] ? messages[0].channel : null }); @@ -254,7 +264,7 @@ export class LogsPlugin extends Plugin { @d.event("voiceChannelJoin") onVoiceChannelJoin(member: Member, channel: Channel) { - this.log(LogType.VOICE_CHANNEL_JOIN, { + this.serverLogs.log(LogType.VOICE_CHANNEL_JOIN, { member: stripObjectToScalars(member, ["user"]), channel: stripObjectToScalars(channel) }); @@ -262,7 +272,7 @@ export class LogsPlugin extends Plugin { @d.event("voiceChannelLeave") onVoiceChannelLeave(member: Member, channel: Channel) { - this.log(LogType.VOICE_CHANNEL_LEAVE, { + this.serverLogs.log(LogType.VOICE_CHANNEL_LEAVE, { member: stripObjectToScalars(member, ["user"]), channel: stripObjectToScalars(channel) }); @@ -270,7 +280,7 @@ export class LogsPlugin extends Plugin { @d.event("voiceChannelSwitch") onVoiceChannelSwitch(member: Member, newChannel: Channel, oldChannel: Channel) { - this.log(LogType.VOICE_CHANNEL_MOVE, { + this.serverLogs.log(LogType.VOICE_CHANNEL_MOVE, { member: stripObjectToScalars(member, ["user"]), oldChannel: stripObjectToScalars(oldChannel), newChannel: stripObjectToScalars(newChannel) diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index b3fc8aaf..b3a09d43 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -220,6 +220,12 @@ export class ModActionsPlugin extends Plugin { @d.command("warn", " ") @d.permission("warn") async warnCmd(msg: Message, args: any) { + // Make sure we're allowed to warn this member + if (!this.canActOn(msg.member, args.member)) { + msg.channel.createMessage(errorMessage("Cannot warn: insufficient permissions")); + return; + } + const warnMessage = this.configValue("warn_message") .replace("{guildName}", this.guild.name) .replace("{reason}", args.reason); @@ -235,7 +241,7 @@ export class ModActionsPlugin extends Plugin { const failedMsg = await msg.channel.createMessage( "Failed to message the user. Log the warning anyway?" ); - const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"]); + const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"], msg.author.id); failedMsg.delete(); if (!reply || reply.name === "❌") { return; @@ -508,11 +514,11 @@ export class ModActionsPlugin extends Plugin { }); } - @d.command("addcase", " [reason:string$]") + @d.command("addcase", " [reason:string$]") @d.permission("addcase") async addcaseCmd(msg: Message, args: any) { // Verify the user id is a valid snowflake-ish - if (!args.type.match(/^[0-9]{17,20}$/)) { + if (!args.target.match(/^[0-9]{17,20}$/)) { msg.channel.createMessage(errorMessage("Cannot add case: invalid user id")); return; } @@ -535,7 +541,7 @@ export class ModActionsPlugin extends Plugin { // Create the case const caseId = await this.createCase( - args.userId, + args.target, msg.author.id, CaseType[type], null,