diff --git a/.gitignore b/.gitignore index 04abe32b..2d5e4afe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Created by .ignore support plugin (hsz.mobi) ### Node template # Logs -logs +/logs *.log npm-debug.log* yarn-debug.log* diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts new file mode 100644 index 00000000..d2c3c380 --- /dev/null +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -0,0 +1,91 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, LogsPluginType } from "./types"; +import DefaultLogMessages from "../../data/DefaultLogMessages.json"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildArchives } from "src/data/GuildArchives"; +import { GuildCases } from "src/data/GuildCases"; +import { onMessageDelete } from "./util/onMessageDelete"; +import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk"; +import { onMessageUpdate } from "./util/onMessageUpdate"; +import { LogsGuildMemberAddEvt } from "./events/LogsGuildMemberAddEvt"; +import { LogsGuildMemberRemoveEvt } from "./events/LogsGuildMemberRemoveEvt"; +import { LogsGuildBanAddEvt, LogsGuildBanRemoveEvt } from "./events/LogsGuildBanEvts"; +import { LogsGuildMemberUpdateEvt, LogsUserUpdateEvt } from "./events/LogsUserUpdateEvts"; +import { LogsChannelCreateEvt, LogsChannelDeleteEvt } from "./events/LogsChannelModifyEvts"; +import { LogsRoleCreateEvt, LogsRoleDeleteEvt } from "./events/LogsRoleModifyEvts"; +import { LogsVoiceJoinEvt, LogsVoiceLeaveEvt, LogsVoiceSwitchEvt } from "./events/LogsVoiceChannelEvts"; +import { log } from "./util/log"; + +const defaultOptions: PluginOptions = { + config: { + channels: {}, + format: { + timestamp: "YYYY-MM-DD HH:mm:ss", + ...DefaultLogMessages, + }, + ping_user: true, + }, + + overrides: [ + { + level: ">=50", + config: { + ping_user: false, + }, + }, + ], +}; + +export const LogsPlugin = zeppelinPlugin()("logs", { + configSchema: ConfigSchema, + defaultOptions, + + events: [ + LogsGuildMemberAddEvt, + LogsGuildMemberRemoveEvt, + LogsGuildBanAddEvt, + LogsGuildBanRemoveEvt, + LogsGuildMemberUpdateEvt, + LogsUserUpdateEvt, + LogsChannelCreateEvt, + LogsChannelDeleteEvt, + LogsRoleCreateEvt, + LogsRoleDeleteEvt, + LogsVoiceJoinEvt, + LogsVoiceLeaveEvt, + LogsVoiceSwitchEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.guildLogs = new GuildLogs(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.archives = GuildArchives.getGuildInstance(guild.id); + state.cases = GuildCases.getGuildInstance(guild.id); + + state.logListener = ({ type, data }) => log(pluginData, type, data); + state.guildLogs.on("log", state.logListener); + + state.batches = new Map(); + + state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.savedMessages.events.on("delete", state.onMessageDeleteFn); + + state.onMessageDeleteBulkFn = msg => onMessageDeleteBulk(pluginData, msg); + state.savedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn); + + state.onMessageUpdateFn = (newMsg, oldMsg) => onMessageUpdate(pluginData, newMsg, oldMsg); + state.savedMessages.events.on("update", state.onMessageUpdateFn); + }, + + onUnload(pluginData) { + pluginData.state.guildLogs.removeListener("log", pluginData.state.logListener); + + pluginData.state.savedMessages.events.off("delete", pluginData.state.onMessageDeleteFn); + pluginData.state.savedMessages.events.off("deleteBulk", pluginData.state.onMessageDeleteBulkFn); + pluginData.state.savedMessages.events.off("update", pluginData.state.onMessageUpdateFn); + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts b/backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts new file mode 100644 index 00000000..e5e57bfe --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts @@ -0,0 +1,23 @@ +import { logsEvent } from "../types"; +import { stripObjectToScalars } from "src/utils"; +import { LogType } from "src/data/LogType"; + +export const LogsChannelCreateEvt = logsEvent({ + event: "channelCreate", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.CHANNEL_CREATE, { + channel: stripObjectToScalars(meta.args.channel), + }); + }, +}); + +export const LogsChannelDeleteEvt = logsEvent({ + event: "channelDelete", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.CHANNEL_DELETE, { + channel: stripObjectToScalars(meta.args.channel), + }); + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts b/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts new file mode 100644 index 00000000..e5805364 --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts @@ -0,0 +1,54 @@ +import { logsEvent } from "../types"; +import { stripObjectToScalars, findRelevantAuditLogEntry, UnknownUser } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { Constants as ErisConstants } from "eris"; + +export const LogsGuildBanAddEvt = logsEvent({ + event: "guildBanAdd", + + async listener(meta) { + const pluginData = meta.pluginData; + const user = meta.args.user; + + const relevantAuditLogEntry = await findRelevantAuditLogEntry( + pluginData.guildConfig, + ErisConstants.AuditLogActions.MEMBER_BAN_ADD, + user.id, + ); + const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); + + pluginData.state.guildLogs.log( + LogType.MEMBER_BAN, + { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(user), + }, + user.id, + ); + }, +}); + +export const LogsGuildBanRemoveEvt = logsEvent({ + event: "guildBanRemove", + + async listener(meta) { + const pluginData = meta.pluginData; + const user = meta.args.user; + + const relevantAuditLogEntry = await findRelevantAuditLogEntry( + pluginData.guild, + ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE, + user.id, + ); + const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); + + pluginData.state.guildLogs.log( + LogType.MEMBER_UNBAN, + { + mod: stripObjectToScalars(mod), + userId: user.id, + }, + user.id, + ); + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts b/backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts new file mode 100644 index 00000000..16c542a7 --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts @@ -0,0 +1,52 @@ +import { logsEvent } from "../types"; +import { stripObjectToScalars } from "src/utils"; +import { LogType } from "src/data/LogType"; +import moment from "moment-timezone"; +import humanizeDuration from "humanize-duration"; + +export const LogsGuildMemberAddEvt = logsEvent({ + event: "guildMemberAdd", + + async listener(meta) { + const pluginData = meta.pluginData; + const member = meta.args.member; + + const newThreshold = moment().valueOf() - 1000 * 60 * 60; + const accountAge = humanizeDuration(moment().valueOf() - member.createdAt, { + largest: 2, + round: true, + }); + + pluginData.state.guildLogs.log(LogType.MEMBER_JOIN, { + member: stripObjectToScalars(member, ["user", "roles"]), + new: member.createdAt >= newThreshold ? " :new:" : "", + account_age: accountAge, + }); + + const cases = (await pluginData.state.cases.with("notes").getByUserId(member.id)).filter(c => !c.is_hidden); + cases.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)); + + if (cases.length) { + const recentCaseLines = []; + const recentCases = cases.slice(0, 2); + for (const theCase of recentCases) { + recentCaseLines.push(pluginData.state.cases.getSummaryText(theCase)); + } + + let recentCaseSummary = recentCaseLines.join("\n"); + if (recentCases.length < cases.length) { + const remaining = cases.length - recentCases.length; + if (remaining === 1) { + recentCaseSummary += `\n*+${remaining} case*`; + } else { + recentCaseSummary += `\n*+${remaining} cases*`; + } + } + + pluginData.state.guildLogs.log(LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS, { + member: stripObjectToScalars(member, ["user", "roles"]), + recentCaseSummary, + }); + } + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts b/backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts new file mode 100644 index 00000000..c5274a78 --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts @@ -0,0 +1,13 @@ +import { logsEvent } from "../types"; +import { stripObjectToScalars } from "src/utils"; +import { LogType } from "src/data/LogType"; + +export const LogsGuildMemberRemoveEvt = logsEvent({ + event: "guildMemberRemove", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.MEMBER_LEAVE, { + member: stripObjectToScalars(meta.args.member, ["user", "roles"]), + }); + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts b/backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts new file mode 100644 index 00000000..944f408d --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts @@ -0,0 +1,23 @@ +import { logsEvent } from "../types"; +import { stripObjectToScalars } from "src/utils"; +import { LogType } from "src/data/LogType"; + +export const LogsRoleCreateEvt = logsEvent({ + event: "guildRoleCreate", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.ROLE_CREATE, { + role: stripObjectToScalars(meta.args.role), + }); + }, +}); + +export const LogsRoleDeleteEvt = logsEvent({ + event: "guildRoleDelete", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.ROLE_DELETE, { + role: stripObjectToScalars(meta.args.role), + }); + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts b/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts new file mode 100644 index 00000000..ee479b55 --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts @@ -0,0 +1,129 @@ +import { logsEvent } from "../types"; +import { stripObjectToScalars, findRelevantAuditLogEntry, UnknownUser } from "src/utils"; +import { Constants as ErisConstants } from "eris"; +import { LogType } from "src/data/LogType"; +import isEqual from "lodash.isequal"; +import diff from "lodash.difference"; + +export const LogsGuildMemberUpdateEvt = logsEvent({ + event: "guildMemberUpdate", + + async listener(meta) { + const pluginData = meta.pluginData; + const oldMember = meta.args.oldMember; + const member = meta.args.member; + + if (!oldMember) return; + + const logMember = stripObjectToScalars(member, ["user", "roles"]); + + if (member.nick !== oldMember.nick) { + pluginData.state.guildLogs.log(LogType.MEMBER_NICK_CHANGE, { + member: logMember, + oldNick: oldMember.nick != null ? oldMember.nick : "", + newNick: member.nick != null ? member.nick : "", + }); + } + + if (!isEqual(oldMember.roles, member.roles)) { + const addedRoles = diff(member.roles, oldMember.roles); + const removedRoles = diff(oldMember.roles, member.roles); + let skip = false; + + if ( + addedRoles.length && + removedRoles.length && + pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_CHANGES, member.id) + ) { + skip = true; + } else if (addedRoles.length && pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_ADD, member.id)) { + skip = true; + } else if ( + removedRoles.length && + pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_REMOVE, member.id) + ) { + skip = true; + } + + if (!skip) { + const relevantAuditLogEntry = await findRelevantAuditLogEntry( + pluginData.guild, + ErisConstants.AuditLogActions.MEMBER_ROLE_UPDATE, + member.id, + ); + const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); + + if (addedRoles.length && removedRoles.length) { + // Roles added *and* removed + pluginData.state.guildLogs.log( + LogType.MEMBER_ROLE_CHANGES, + { + member: logMember, + addedRoles: addedRoles + .map(roleId => pluginData.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + removedRoles: removedRoles + .map(roleId => pluginData.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + mod: stripObjectToScalars(mod), + }, + member.id, + ); + } else if (addedRoles.length) { + // Roles added + pluginData.state.guildLogs.log( + LogType.MEMBER_ROLE_ADD, + { + member: logMember, + roles: addedRoles + .map(roleId => pluginData.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + mod: stripObjectToScalars(mod), + }, + member.id, + ); + } else if (removedRoles.length && !addedRoles.length) { + // Roles removed + pluginData.state.guildLogs.log( + LogType.MEMBER_ROLE_REMOVE, + { + member: logMember, + roles: removedRoles + .map(roleId => pluginData.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + mod: stripObjectToScalars(mod), + }, + member.id, + ); + } + } + } + }, +}); + +export const LogsUserUpdateEvt = logsEvent({ + event: "userUpdate", + allowSelf: true, + + async listener(meta) { + const pluginData = meta.pluginData; + const oldUser = meta.args.oldUser; + const user = meta.args.user; + + if (!oldUser) return; + + if (!pluginData.guild.members.has(user.id)) return; + + if (user.username !== oldUser.username || user.discriminator !== oldUser.discriminator) { + pluginData.state.guildLogs.log(LogType.MEMBER_USERNAME_CHANGE, { + user: stripObjectToScalars(user), + oldName: `${oldUser.username}#${oldUser.discriminator}`, + newName: `${user.username}#${user.discriminator}`, + }); + } + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts b/backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts new file mode 100644 index 00000000..051b03a7 --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts @@ -0,0 +1,37 @@ +import { logsEvent } from "../types"; +import { stripObjectToScalars } from "src/utils"; +import { LogType } from "src/data/LogType"; + +export const LogsVoiceJoinEvt = logsEvent({ + event: "voiceChannelJoin", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.VOICE_CHANNEL_JOIN, { + member: stripObjectToScalars(meta.args.member, ["user", "roles"]), + channel: stripObjectToScalars(meta.args.newChannel), + }); + }, +}); + +export const LogsVoiceLeaveEvt = logsEvent({ + event: "voiceChannelLeave", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.VOICE_CHANNEL_LEAVE, { + member: stripObjectToScalars(meta.args.member, ["user", "roles"]), + channel: stripObjectToScalars(meta.args.oldChannel), + }); + }, +}); + +export const LogsVoiceSwitchEvt = logsEvent({ + event: "voiceChannelSwitch", + + async listener(meta) { + meta.pluginData.state.guildLogs.log(LogType.VOICE_CHANNEL_MOVE, { + member: stripObjectToScalars(meta.args.member, ["user", "roles"]), + oldChannel: stripObjectToScalars(meta.args.oldChannel), + newChannel: stripObjectToScalars(meta.args.newChannel), + }); + }, +}); diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts new file mode 100644 index 00000000..2a13e334 --- /dev/null +++ b/backend/src/plugins/Logs/types.ts @@ -0,0 +1,55 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener } from "knub"; +import { TSafeRegex } from "src/validatorUtils"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildArchives } from "src/data/GuildArchives"; +import { GuildCases } from "src/data/GuildCases"; + +const LogChannel = t.partial({ + include: t.array(t.string), + exclude: t.array(t.string), + batched: t.boolean, + batch_time: t.number, + excluded_users: t.array(t.string), + excluded_message_regexes: t.array(TSafeRegex), + excluded_channels: t.array(t.string), +}); +export type TLogChannel = t.TypeOf; + +const LogChannelMap = t.record(t.string, LogChannel); +export type TLogChannelMap = t.TypeOf; + +export const ConfigSchema = t.type({ + channels: LogChannelMap, + format: t.intersection([ + t.record(t.string, t.string), + t.type({ + timestamp: t.string, + }), + ]), + ping_user: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface LogsPluginType extends BasePluginType { + config: TConfigSchema; + state: { + guildLogs: GuildLogs; + savedMessages: GuildSavedMessages; + archives: GuildArchives; + cases: GuildCases; + + logListener; + + batches: Map; + + onMessageDeleteFn; + onMessageDeleteBulkFn; + onMessageUpdateFn; + + excludedUserProps: string[]; + }; +} + +export const logsEvent = eventListener(); diff --git a/backend/src/plugins/Logs/util/getLogMessage.ts b/backend/src/plugins/Logs/util/getLogMessage.ts new file mode 100644 index 00000000..f7501fd9 --- /dev/null +++ b/backend/src/plugins/Logs/util/getLogMessage.ts @@ -0,0 +1,80 @@ +import { PluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "src/data/LogType"; +import { verboseUserMention, verboseUserName, verboseChannelMention, messageSummary, resolveMember } from "src/utils"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { renderTemplate, TemplateParseError } from "src/templateFormatter"; +import { logger } from "src/logger"; +import moment from "moment-timezone"; + +export async function getLogMessage(pluginData: PluginData, type, data): Promise { + const config = pluginData.config.get(); + const format = config.format[LogType[type]] || ""; + if (format === "") return; + + let formatted; + try { + const values = { + ...data, + userMention: async inputUserOrMember => { + if (!inputUserOrMember) return ""; + + const usersOrMembers = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember]; + + const mentions = []; + for (const userOrMember of usersOrMembers) { + let user; + let member; + + if (userOrMember.user) { + member = await resolveMember(pluginData.client, pluginData.guild, userOrMember.id); + user = member.user; + } else { + user = userOrMember; + member = await resolveMember(pluginData.client, pluginData.guild, user.id); + } + + const memberConfig = pluginData.config.getMatchingConfig({ member, userId: user.id }) || ({} as any); + + mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user)); + } + + return mentions.join(", "); + }, + channelMention: channel => { + if (!channel) return ""; + return verboseChannelMention(channel); + }, + messageSummary: (msg: SavedMessage) => { + if (!msg) return ""; + return messageSummary(msg); + }, + }; + + if (type === LogType.BOT_ALERT) { + const valuesWithoutTmplEval = { ...values }; + values.tmplEval = str => { + return renderTemplate(str, valuesWithoutTmplEval); + }; + } + + formatted = await renderTemplate(format, values); + } catch (e) { + if (e instanceof TemplateParseError) { + logger.error(`Error when parsing template:\nError: ${e.message}\nTemplate: ${format}`); + return; + } else { + throw e; + } + } + + formatted = formatted.trim(); + + const timestampFormat = config.format.timestamp; + if (timestampFormat) { + const timestamp = moment().format(timestampFormat); + return `\`[${timestamp}]\` ${formatted}`; + } else { + return formatted; + } +} diff --git a/backend/src/plugins/Logs/util/log.ts b/backend/src/plugins/Logs/util/log.ts new file mode 100644 index 00000000..2c365967 --- /dev/null +++ b/backend/src/plugins/Logs/util/log.ts @@ -0,0 +1,84 @@ +import { PluginData } from "knub"; +import { LogsPluginType, TLogChannelMap } from "../types"; +import { LogType } from "src/data/LogType"; +import { TextChannel } from "eris"; +import { createChunkedMessage, noop } from "src/utils"; +import { getLogMessage } from "./getLogMessage"; + +export async function log(pluginData: PluginData, type, data) { + const logChannels: TLogChannelMap = pluginData.config.get().channels; + const typeStr = LogType[type]; + + logChannelLoop: for (const [channelId, opts] of Object.entries(logChannels)) { + const channel = pluginData.guild.channels.get(channelId); + if (!channel || !(channel instanceof TextChannel)) continue; + + if ((opts.include && opts.include.includes(typeStr)) || (opts.exclude && !opts.exclude.includes(typeStr))) { + // If this log entry is about an excluded user, skip it + // TODO: Quick and dirty solution, look into changing at some point + if (opts.excluded_users) { + for (const prop of pluginData.state.excludedUserProps) { + if (data && data[prop] && opts.excluded_users.includes(data[prop].id)) { + continue logChannelLoop; + } + } + } + + // If this entry is from an excluded channel, skip it + if (opts.excluded_channels) { + if ( + type === LogType.MESSAGE_DELETE || + type === LogType.MESSAGE_DELETE_BARE || + type === LogType.MESSAGE_EDIT || + type === LogType.MESSAGE_SPAM_DETECTED || + type === LogType.CENSOR || + type === LogType.CLEAN + ) { + if (opts.excluded_channels.includes(data.channel.id)) { + continue logChannelLoop; + } + } + } + + // If this entry contains a message with an excluded regex, skip it + if (type === LogType.MESSAGE_DELETE && opts.excluded_message_regexes && data.message.data.content) { + for (const regex of opts.excluded_message_regexes) { + if (regex.test(data.message.data.content)) { + continue logChannelLoop; + } + } + } + + if (type === LogType.MESSAGE_EDIT && opts.excluded_message_regexes && data.before.data.content) { + for (const regex of opts.excluded_message_regexes) { + if (regex.test(data.before.data.content)) { + continue logChannelLoop; + } + } + } + + const message = await getLogMessage(pluginData, type, data); + if (message) { + const batched = opts.batched ?? true; // Default to batched unless explicitly disabled + const batchTime = opts.batch_time ?? 1000; + + if (batched) { + // If we're batching log messages, gather all log messages within the set batch_time into a single message + if (!pluginData.state.batches.has(channel.id)) { + pluginData.state.batches.set(channel.id, []); + setTimeout(async () => { + const batchedMessage = pluginData.state.batches.get(channel.id).join("\n"); + pluginData.state.batches.delete(channel.id); + createChunkedMessage(channel, batchedMessage).catch(noop); + }, batchTime); + } + + pluginData.state.batches.get(channel.id).push(message); + } else { + // If we're not batching log messages, just send them immediately + await createChunkedMessage(channel, message).catch(noop); + } + } + } + } +} diff --git a/backend/src/plugins/Logs/util/onMessageDelete.ts b/backend/src/plugins/Logs/util/onMessageDelete.ts new file mode 100644 index 00000000..70e2b65c --- /dev/null +++ b/backend/src/plugins/Logs/util/onMessageDelete.ts @@ -0,0 +1,41 @@ +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { Attachment } from "eris"; +import { useMediaUrls, stripObjectToScalars, resolveUser } from "src/utils"; +import { LogType } from "src/data/LogType"; +import moment from "moment-timezone"; +import { PluginData } from "knub"; +import { LogsPluginType } from "../types"; + +export async function onMessageDelete(pluginData: PluginData, savedMessage: SavedMessage) { + const user = await resolveUser(pluginData.client, savedMessage.user_id); + const channel = pluginData.guild.channels.get(savedMessage.channel_id); + + if (user) { + // Replace attachment URLs with media URLs + if (savedMessage.data.attachments) { + for (const attachment of savedMessage.data.attachments as Attachment[]) { + attachment.url = useMediaUrls(attachment.url); + } + } + + pluginData.state.guildLogs.log( + LogType.MESSAGE_DELETE, + { + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + messageDate: moment(savedMessage.data.timestamp, "x").format(pluginData.config.get().format.timestamp), + message: savedMessage, + }, + savedMessage.id, + ); + } else { + pluginData.state.guildLogs.log( + LogType.MESSAGE_DELETE_BARE, + { + messageId: savedMessage.id, + channel: stripObjectToScalars(channel), + }, + savedMessage.id, + ); + } +} diff --git a/backend/src/plugins/Logs/util/onMessageDeleteBulk.ts b/backend/src/plugins/Logs/util/onMessageDeleteBulk.ts new file mode 100644 index 00000000..a8038425 --- /dev/null +++ b/backend/src/plugins/Logs/util/onMessageDeleteBulk.ts @@ -0,0 +1,21 @@ +import { PluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { LogType } from "src/data/LogType"; +import { getBaseUrl } from "src/pluginUtils"; + +export async function onMessageDeleteBulk(pluginData: PluginData, savedMessages: SavedMessage[]) { + const channel = pluginData.guild.channels.get(savedMessages[0].channel_id); + const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild); + const archiveUrl = pluginData.state.archives.getUrl(getBaseUrl, archiveId); + + pluginData.state.guildLogs.log( + LogType.MESSAGE_DELETE_BULK, + { + count: savedMessages.length, + channel, + archiveUrl, + }, + savedMessages[0].id, + ); +} diff --git a/backend/src/plugins/Logs/util/onMessageUpdate.ts b/backend/src/plugins/Logs/util/onMessageUpdate.ts new file mode 100644 index 00000000..bd5bc849 --- /dev/null +++ b/backend/src/plugins/Logs/util/onMessageUpdate.ts @@ -0,0 +1,58 @@ +import { PluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { Embed } from "eris"; +import { LogType } from "src/data/LogType"; +import { stripObjectToScalars, resolveUser } from "src/utils"; +import cloneDeep from "lodash.clonedeep"; + +export async function onMessageUpdate( + pluginData: PluginData, + savedMessage: SavedMessage, + oldSavedMessage: SavedMessage, +) { + // To log a message update, either the message content or a rich embed has to change + let logUpdate = false; + + const oldEmbedsToCompare = ((oldSavedMessage.data.embeds || []) as Embed[]) + .map(e => cloneDeep(e)) + .filter(e => (e as Embed).type === "rich"); + + const newEmbedsToCompare = ((savedMessage.data.embeds || []) as Embed[]) + .map(e => cloneDeep(e)) + .filter(e => (e as Embed).type === "rich"); + + for (const embed of [...oldEmbedsToCompare, ...newEmbedsToCompare]) { + if (embed.thumbnail) { + delete embed.thumbnail.width; + delete embed.thumbnail.height; + } + + if (embed.image) { + delete embed.image.width; + delete embed.image.height; + } + } + + if ( + oldSavedMessage.data.content !== savedMessage.data.content || + oldEmbedsToCompare.length !== newEmbedsToCompare.length || + JSON.stringify(oldEmbedsToCompare) !== JSON.stringify(newEmbedsToCompare) + ) { + logUpdate = true; + } + + if (!logUpdate) { + return; + } + + const user = await resolveUser(pluginData.client, savedMessage.user_id); + const channel = pluginData.guild.channels.get(savedMessage.channel_id); + + pluginData.state.guildLogs.log(LogType.MESSAGE_EDIT, { + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + before: oldSavedMessage, + after: savedMessage, + }); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index e5b2ffa6..2b7cc9ed 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -22,6 +22,7 @@ import { RolesPlugin } from "./Roles/RolesPlugin"; import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; import { StarboardPlugin } from "./Starboard/StarboardPlugin"; import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; +import { LogsPlugin } from "./Logs/LogsPlugin"; import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin"; import { SpamPlugin } from "./Spam/SpamPlugin"; import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin"; @@ -35,6 +36,7 @@ export const guildPlugins: Array> = [ CensorPlugin, ChannelArchiverPlugin, LocateUserPlugin, + LogsPlugin, PersistPlugin, PingableRolesPlugin, PostPlugin,