diff --git a/package-lock.json b/package-lock.json index 0d85d7e1..a316fff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2490,6 +2490,11 @@ "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=" }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "lodash.merge": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz", diff --git a/package.json b/package.json index c3e98f98..230a2855 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "knex": "^0.14.6", "knub": "^9.4.11", "lodash.at": "^4.6.0", + "lodash.difference": "^4.5.0", + "lodash.isequal": "^4.5.0", "mariasql": "^0.2.6", "moment-timezone": "^0.5.21", "ts-node": "^3.3.0", diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json index 5de4352c..43e386e8 100644 --- a/src/data/DefaultLogMessages.json +++ b/src/data/DefaultLogMessages.json @@ -3,10 +3,38 @@ "MEMBER_MUTE": "🔇 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was muted by {mod.user.username}#{mod.user.discriminator}", "MEMBER_UNMUTE": "🔉 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was unmuted by {mod.user.username}#{mod.user.discriminator}", "MEMBER_MUTE_EXPIRED": "🔉 **{member.user.username}#{member.user.discriminator}**'s mute expired", - "MEMBER_KICK": "👢 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was kicked by {mod.user.username}#{mod.user.discriminator}", - "MEMBER_BAN": "🔨 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was banned by {mod.user.username}#{mod.user.discriminator}", + "MEMBER_KICK": "👢 **{user.username}#{user.discriminator}** (`{user.id}`) was kicked by {mod.user.username}#{mod.user.discriminator}", + "MEMBER_BAN": "🔨 **{user.username}#{user.discriminator}** (`{user.id}`) was banned by {mod.user.username}#{mod.user.discriminator}", + "MEMBER_UNBAN": "🔓 **{user.username}#{user.discriminator}** (`{user.id}`) was unbanned by {mod.user.username}#{mod.user.discriminator}", + "MEMBER_FORCEBAN": "🔨 User `{userId}` was forcebanned by {mod.user.username}#{mod.user.discriminator}", "MEMBER_JOIN": "📥 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) joined{new} (created {account_age} ago)", - "MEMBER_LEAVE": "📤 **{member.user.username}#{member.user.discriminator}** left the server", - "MEMBER_ROLE_ADD": "🔑 **{member.user.username}#{member.user.discriminator}** role added **{role.name}** by {mod.user.username}#{mod.user.discriminator}", - "MEMBER_ROLE_REMOVE": "🔑 **{member.user.username}#{member.user.discriminator}** role removed **{role.name}** by {mod.user.username}#{mod.user.discriminator}" + "MEMBER_LEAVE": "📤 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) left the server", + "MEMBER_ROLE_ADD": "🔑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) role added **{role.name}** by {mod.user.username}#{mod.user.discriminator}", + "MEMBER_ROLE_REMOVE": "🔑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) role removed **{role.name}** by {mod.user.username}#{mod.user.discriminator}", + "MEMBER_NICK_CHANGE": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) changed their nickname from **{oldNick}** to **{newNick}**", + "MEMBER_USERNAME_CHANGE": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) changed their username from **{oldName}** to **{newName}**", + "MEMBER_ROLES_RESTORE": "💿 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) roles were restored", + + "CHANNEL_CREATE": "🖊 Channel **#{channel.name}** was created", + "CHANNEL_DELETE": "🗑 Channel **#{channel.name}** was deleted", + "CHANNEL_EDIT": "✏ Channel **#{channel.name}** was edited", + + "ROLE_CREATE": "🖊 Role **{role.name}** was created", + "ROLE_DELETE": "🖊 Role **{role.name}** was deleted", + "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_BULK": "🗑 **{count}** messages deleted in **{channel.name}**", + + "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}`", + + "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}`", + + "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 new file mode 100644 index 00000000..227bab51 --- /dev/null +++ b/src/data/GuildLogs.ts @@ -0,0 +1,57 @@ +import EventEmitter from "events"; +import { LogType } from "./LogType"; + +// Use the same instance for the same guild, even if a new instance is created +const guildInstances: Map = new Map(); + +interface IIgnoredLog { + type: LogType; + ignoreId: any; +} + +export class GuildLogs extends EventEmitter { + protected guildId: string; + protected ignoredLogs: IIgnoredLog[]; + + constructor(guildId) { + if (guildInstances.has(guildId)) { + // Return existing instance for this guild if one exists + return guildInstances.get(guildId); + } + + super(); + this.guildId = guildId; + + // 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); + } + + log(type: LogType, data: any, ignoreId = null) { + if (ignoreId && this.isLogIgnored(type, ignoreId)) { + this.clearIgnoredLog(type, ignoreId); + return; + } + + this.emit("log", { type, data }); + } + + ignoreLog(type: LogType, ignoreId: any, expiryTime: number = null) { + this.ignoredLogs.push({ type, ignoreId }); + + // Clear after expiry (15sec by default) + setTimeout(() => { + this.clearIgnoredLog(type, ignoreId); + }, expiryTime || 1000 * 15); + } + + isLogIgnored(type: LogType, ignoreId: any) { + return this.ignoredLogs.some(info => type === info.type && ignoreId === info.ignoreId); + } + + clearIgnoredLog(type: LogType, ignoreId: any) { + this.ignoredLogs.splice( + this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), + 1 + ); + } +} diff --git a/src/data/GuildServerLogs.ts b/src/data/GuildServerLogs.ts deleted file mode 100644 index 4f508c48..00000000 --- a/src/data/GuildServerLogs.ts +++ /dev/null @@ -1,26 +0,0 @@ -import EventEmitter from "events"; -import { LogType } from "./LogType"; - -// Use the same instance for the same guild, even if a new instance is created -const guildInstances: Map = new Map(); - -export class GuildServerLogs extends EventEmitter { - protected guildId: string; - - constructor(guildId) { - if (guildInstances.has(guildId)) { - // Return existing instance for this guild if one exists - return guildInstances.get(guildId); - } - - super(); - this.guildId = guildId; - - // 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); - } - - log(type: LogType, data: any) { - this.emit("log", { type, data }); - } -} diff --git a/src/data/LogType.ts b/src/data/LogType.ts index 8919b07e..0d6288e1 100644 --- a/src/data/LogType.ts +++ b/src/data/LogType.ts @@ -17,7 +17,6 @@ export enum LogType { CHANNEL_CREATE, CHANNEL_DELETE, - CHANNEL_EDIT, ROLE_CREATE, ROLE_DELETE, diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts index a57ab382..3e801741 100644 --- a/src/plugins/Logs.ts +++ b/src/plugins/Logs.ts @@ -1,11 +1,21 @@ import { decorators as d, Plugin } from "knub"; -import { GuildServerLogs } from "../data/GuildServerLogs"; +import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; -import { TextChannel } from "eris"; -import { formatTemplateString, stripObjectToScalars } from "../utils"; +import { + Channel, + Constants as ErisConstants, + Member, + Message, + PrivateChannel, + TextChannel, + User +} from "eris"; +import { findRelevantAuditLogEntry, formatTemplateString, stripObjectToScalars } from "../utils"; import DefaultLogMessages from "../data/DefaultLogMessages.json"; import moment from "moment-timezone"; import humanizeDuration from "humanize-duration"; +import isEqual from "lodash.isequal"; +import diff from "lodash.difference"; interface ILogChannel { include?: LogType[]; @@ -16,8 +26,17 @@ interface ILogChannelMap { [channelId: string]: ILogChannel; } +const unknownMember = { + id: 0, + user: { + id: 0, + username: "Unknown", + discriminator: "0000" + } +}; + export class LogsPlugin extends Plugin { - protected serverLogs: GuildServerLogs; + protected serverLogs: GuildLogs; protected logListener; getDefaultOptions() { @@ -33,7 +52,7 @@ export class LogsPlugin extends Plugin { } onLoad() { - this.serverLogs = new GuildServerLogs(this.guildId); + this.serverLogs = new GuildLogs(this.guildId); this.logListener = ({ type, data }) => this.log(type, data); this.serverLogs.on("log", this.logListener); @@ -88,4 +107,179 @@ export class LogsPlugin extends Plugin { account_age: accountAge }); } + + @d.event("guildMemberRemove") + onMemberLeave(_, member) { + this.log(LogType.MEMBER_LEAVE, { + member: stripObjectToScalars(member, ["user"]) + }); + } + + @d.event("guildBanAdd") + async onMemberBan(_, user) { + const relevantAuditLogEntry = await findRelevantAuditLogEntry( + this.bot, + ErisConstants.AuditLogActions.MEMBER_BAN_ADD, + user.id + ); + + if (relevantAuditLogEntry) { + this.log(LogType.MEMBER_BAN, { + user: stripObjectToScalars(user), + mod: relevantAuditLogEntry.member + }); + } else { + this.log(LogType.MEMBER_BAN, { + user: stripObjectToScalars(user), + mod: unknownMember + }); + } + } + + @d.event("guildBanRemove") + async onMemberUnban(_, user) { + const relevantAuditLogEntry = await findRelevantAuditLogEntry( + this.bot, + ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE, + user.id + ); + + if (relevantAuditLogEntry) { + this.log(LogType.MEMBER_UNBAN, { + user: stripObjectToScalars(user), + mod: relevantAuditLogEntry.member + }); + } else { + this.log(LogType.MEMBER_UNBAN, { + user: stripObjectToScalars(user), + mod: unknownMember + }); + } + } + + @d.event("guildMemberUpdate") + onMemberUpdate(_, member: Member, oldMember: Member) { + if (!oldMember) return; + + if (member.nick !== oldMember.nick) { + this.log(LogType.MEMBER_NICK_CHANGE, { + member, + oldNick: oldMember.nick, + newNick: member.nick + }); + } + + if (!isEqual(oldMember.roles, member.roles)) { + const addedRoles = diff(oldMember.roles, member.roles); + const removedRoles = diff(member.roles, oldMember.roles); + + if (addedRoles.length) { + this.log(LogType.MEMBER_ROLE_ADD, { + member, + role: this.guild.roles.get(addedRoles[0]) + }); + } else if (removedRoles.length) { + this.log(LogType.MEMBER_ROLE_REMOVE, { + member, + role: this.guild.roles.get(removedRoles[0]) + }); + } + } + } + + @d.event("userUpdate") + onUserUpdate(user: User, oldUser: User) { + if (!oldUser) return; + + 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, { + member: stripObjectToScalars(member, ["user"]), + oldName: `${oldUser.username}#${oldUser.discriminator}`, + newName: `${user.username}#${user.discriminator}` + }); + } + } + + @d.event("channelCreate") + onChannelCreate(channel) { + this.log(LogType.CHANNEL_CREATE, { + channel: stripObjectToScalars(channel) + }); + } + + @d.event("channelDelete") + onChannelDelete(channel) { + this.log(LogType.CHANNEL_DELETE, { + channel: stripObjectToScalars(channel) + }); + } + + @d.event("guildRoleCreate") + onRoleCreate(role) { + this.log(LogType.ROLE_CREATE, { + role: stripObjectToScalars(role) + }); + } + + @d.event("guildRoleDelete") + onRoleDelete(role) { + this.log(LogType.ROLE_DELETE, { + role: stripObjectToScalars(role) + }); + } + + @d.event("messageUpdate") + onMessageUpdate(msg: Message, oldMsg: Message) { + if (oldMsg && msg.content === oldMsg.content) return; + + this.log(LogType.MESSAGE_EDIT, { + member: stripObjectToScalars(msg.member, ["user"]), + channel: stripObjectToScalars(msg.channel), + before: oldMsg ? oldMsg.cleanContent || "" : "Unavailable due to restart", + after: msg.cleanContent || "" + }); + } + + @d.event("messageDelete") + onMessageDelete(msg: Message) { + this.log(LogType.MESSAGE_DELETE, { + member: stripObjectToScalars(msg.member, ["user"]), + channel: stripObjectToScalars(msg.channel), + messageText: msg.cleanContent || "" + }); + } + + @d.event("messageDeleteBulk") + onMessageDeleteBulk(messages: Message[]) { + this.log(LogType.MESSAGE_DELETE_BULK, { + count: messages.length, + channel: messages[0] ? messages[0].channel : null + }); + } + + @d.event("voiceChannelJoin") + onVoiceChannelJoin(member: Member, channel: Channel) { + this.log(LogType.VOICE_CHANNEL_JOIN, { + member: stripObjectToScalars(member, ["user"]), + channel: stripObjectToScalars(channel) + }); + } + + @d.event("voiceChannelLeave") + onVoiceChannelLeave(member: Member, channel: Channel) { + this.log(LogType.VOICE_CHANNEL_LEAVE, { + member: stripObjectToScalars(member, ["user"]), + channel: stripObjectToScalars(channel) + }); + } + + @d.event("voiceChannelSwitch") + onVoiceChannelSwitch(member: Member, oldChannel: Channel, newChannel: Channel) { + this.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 d9b3dfe4..926677ec 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -6,6 +6,7 @@ import { GuildCases } from "../data/GuildCases"; import { convertDelayStringToMS, errorMessage, + findRelevantAuditLogEntry, formatTemplateString, stripObjectToScalars, successMessage @@ -14,7 +15,7 @@ import { GuildMutes } from "../data/GuildMutes"; import Timer = NodeJS.Timer; import Case from "../models/Case"; import { CaseType } from "../data/CaseType"; -import { GuildServerLogs } from "../data/GuildServerLogs"; +import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; const sleep = (ms: number): Promise => { @@ -26,14 +27,14 @@ const sleep = (ms: number): Promise => { export class ModActionsPlugin extends Plugin { protected cases: GuildCases; protected mutes: GuildMutes; - protected serverLogs: GuildServerLogs; + protected serverLogs: GuildLogs; protected muteClearIntervalId: Timer; async onLoad() { this.cases = new GuildCases(this.guildId); this.mutes = new GuildMutes(this.guildId); - this.serverLogs = new GuildServerLogs(this.guildId); + this.serverLogs = new GuildLogs(this.guildId); // Check for expired mutes every 5s this.clearExpiredMutes(); @@ -100,7 +101,11 @@ export class ModActionsPlugin extends Plugin { @d.event("guildBanAdd") async onGuildBanAdd(guild: Guild, user: User) { await sleep(1000); // Wait a moment for the audit log to update - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry("MEMBER_BAN_ADD", user.id); + const relevantAuditLogEntry = await findRelevantAuditLogEntry( + this.bot, + "MEMBER_BAN_ADD", + user.id + ); if (relevantAuditLogEntry) { const modId = relevantAuditLogEntry.user.id; @@ -125,7 +130,8 @@ export class ModActionsPlugin extends Plugin { */ @d.event("guildBanRemove") async onGuildBanRemove(guild: Guild, user: User) { - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( + const relevantAuditLogEntry = await findRelevantAuditLogEntry( + this.bot, "MEMBER_BAN_REMOVE", user.id ); @@ -251,6 +257,7 @@ export class ModActionsPlugin extends Plugin { } // Apply "muted" role + this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); await args.member.addRole(this.configValue("mute_role"), args.reason); await this.mutes.addOrUpdateMute(args.member.id, muteTime); @@ -318,6 +325,7 @@ export class ModActionsPlugin extends Plugin { } // Remove "muted" role + this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); await args.member.removeRole(this.configValue("mute_role"), args.reason); await this.mutes.clear(args.member.id); @@ -360,6 +368,7 @@ export class ModActionsPlugin extends Plugin { } // Kick the user + this.serverLogs.ignoreLog(LogType.MEMBER_KICK, args.member.id); args.member.kick(args.reason); // Create a case for this action @@ -403,6 +412,7 @@ export class ModActionsPlugin extends Plugin { } // Ban the user + this.serverLogs.ignoreLog(LogType.MEMBER_BAN, args.member.id); args.member.ban(1, args.reason); // Create a case for this action @@ -423,6 +433,8 @@ export class ModActionsPlugin extends Plugin { @d.command("unban", " [reason:string$]") @d.permission("ban") async unbanCmd(msg: Message, args: any) { + this.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, args.member.id); + try { await this.guild.unbanMember(args.userId, args.reason); } catch (e) { @@ -455,6 +467,8 @@ export class ModActionsPlugin extends Plugin { return; } + this.serverLogs.ignoreLog(LogType.MEMBER_FORCEBAN, args.member.id); + try { await this.guild.banMember(args.userId, 1, args.reason); } catch (e) { @@ -501,13 +515,22 @@ export class ModActionsPlugin extends Plugin { } // Create the case - await this.createCase(args.userId, msg.author.id, CaseType[type], null, args.reason); + const caseId = await this.createCase( + args.userId, + msg.author.id, + CaseType[type], + null, + args.reason + ); + const theCase = await this.cases.find(caseId); // Log the action msg.channel.createMessage(successMessage("Case created!")); this.serverLogs.log(LogType.CASE_CREATE, { mod: stripObjectToScalars(msg.member, ["user"]), - userId: args.userId + userId: args.userId, + caseNum: theCase.case_number, + caseType: type.toUpperCase() }); } @@ -665,30 +688,6 @@ export class ModActionsPlugin extends Plugin { return this.displayCase(caseOrCaseId, caseLogChannelId); } - /** - * Attempts to find a relevant audit log entry for the given user and action. Only accepts audit log entries from the past 10 minutes. - */ - protected async findRelevantAuditLogEntry( - actionType: string, - userId: string - ): Promise { - const auditLogEntries = await this.bot.getGuildAuditLogs(this.guildId, 5, actionType); - - auditLogEntries.entries.sort((a, b) => { - if (a.createdAt > b.createdAt) return -1; - if (a.createdAt > b.createdAt) return 1; - return 0; - }); - - const cutoffDate = new Date(); - cutoffDate.setTime(cutoffDate.getTime() - 1000 * 15); - const cutoffTS = cutoffDate.getTime(); - - return auditLogEntries.entries.find(entry => { - return entry.target.id === userId && entry.createdAt >= cutoffTS; - }); - } - protected async createCase( userId: string, modId: string, diff --git a/src/utils.ts b/src/utils.ts index fbf5700b..315794a5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import at = require("lodash.at"); +import { GuildAuditLogEntry } from "eris"; /** * Turns a "delay string" such as "1h30m" to milliseconds @@ -42,7 +43,7 @@ export function uclower(str) { return str[0].toLowerCase() + str.slice(1); } -export function stripObjectToScalars(obj, includedNested: string[]) { +export function stripObjectToScalars(obj, includedNested: string[] = []) { const result = {}; for (const key in obj) { @@ -78,3 +79,36 @@ export function formatTemplateString(str: string, values) { export function isSnowflake(v: string): boolean { return /^\d{17,20}$/.test(v); } + +/** + * Attempts to find a relevant audit log entry for the given user and action + */ +export async function findRelevantAuditLogEntry( + bot, + actionType: number, + userId: string, + attempts: number = 3, + attemptDelay: number = 1500 +): Promise { + const auditLogEntries = await this.bot.getGuildAuditLogs(this.guildId, 5, null, actionType); + + auditLogEntries.entries.sort((a, b) => { + if (a.createdAt > b.createdAt) return -1; + if (a.createdAt > b.createdAt) return 1; + return 0; + }); + + const cutoffTS = Date.now() - 1000 * 60 * 2; + + const relevantEntry = auditLogEntries.entries.find(entry => { + return entry.target.id === userId && entry.createdAt >= cutoffTS; + }); + + if (relevantEntry) { + return relevantEntry; + } else if (attempts > 0) { + return findRelevantAuditLogEntry(bot, actionType, userId, attempts - 1, attemptDelay); + } else { + return null; + } +}