From 648fe39fe63df9f275681944d795d59d661d946f Mon Sep 17 00:00:00 2001 From: janderedev Date: Tue, 4 Jan 2022 14:56:01 +0100 Subject: [PATCH] Rework logging, various minor fixes - /kick and /ban are now logged - Log messages can be received via Discord - Revolt log messages have 3 different styles - Requires manual database migration lol --- src/bot/commands/ban.ts | 34 +++++--- src/bot/commands/botctl.ts | 2 +- src/bot/commands/kick.ts | 13 ++- src/bot/commands/ping.ts | 2 +- src/bot/commands/warn.ts | 2 +- src/bot/commands/warns.ts | 4 +- src/bot/modules/mod_logs.ts | 152 +++++++++++++++++++---------------- src/bot/modules/user_scan.ts | 66 +++++++-------- src/bot/util.ts | 110 +++++++++++++++++++++++++ src/struct/LogConfig.ts | 14 ++++ src/struct/LogMessage.ts | 21 +++++ src/struct/ServerConfig.ts | 14 ++-- 12 files changed, 298 insertions(+), 136 deletions(-) create mode 100644 src/struct/LogConfig.ts create mode 100644 src/struct/LogMessage.ts diff --git a/src/bot/commands/ban.ts b/src/bot/commands/ban.ts index 1a1a3fc..cca3cfb 100644 --- a/src/bot/commands/ban.ts +++ b/src/bot/commands/ban.ts @@ -5,9 +5,13 @@ import InfractionType from "../../struct/antispam/InfractionType"; import Command from "../../struct/Command"; import MessageCommandContext from "../../struct/MessageCommandContext"; import TempBan from "../../struct/TempBan"; -import { fetchUsername } from "../modules/mod_logs"; +import { fetchUsername, logModAction } from "../modules/mod_logs"; import { storeTempBan } from "../modules/tempbans"; import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util"; +import Day from 'dayjs'; +import RelativeTime from 'dayjs/plugin/relativeTime'; + +Day.extend(RelativeTime); export default { name: 'ban', @@ -18,10 +22,10 @@ export default { run: async (message: MessageCommandContext, args: string[]) => { if (!await isModerator(message.member!, message.serverContext)) return message.reply(NO_MANAGER_MSG); - + if (args.length == 0) return message.reply(`You need to provide a target user!`); - + let targetUser = await parseUser(args.shift()!); if (!targetUser) return message.reply('Sorry, I can\'t find that user.'); @@ -61,7 +65,7 @@ export default { if (banDuration == 0) { let infId = ulid(); - let { userWarnCount } = await storeInfraction({ + let infraction: Infraction = { _id: infId, createdBy: message.author_id, date: Date.now(), @@ -70,19 +74,23 @@ export default { type: InfractionType.Manual, user: targetUser._id, actionType: 'ban', - } as Infraction); + } + let { userWarnCount } = await storeInfraction(infraction); message.serverContext.banUser(targetUser._id, { reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id})` }) .catch(e => message.reply(`Failed to ban user: \`${e}\``)); - message.reply(`### @${targetUser.username} has been banned.\n` - + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); + await Promise.all([ + message.reply(`### @${targetUser.username} has been banned.\n` + + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`), + logModAction('ban', message.serverContext, message.member!, targetUser._id, reason, infraction, `Ban duration: **Permanent**`), + ]); } else { let banUntil = Date.now() + banDuration; let infId = ulid(); - let { userWarnCount } = await storeInfraction({ + let infraction: Infraction = { _id: infId, createdBy: message.author_id, date: Date.now(), @@ -91,7 +99,8 @@ export default { type: InfractionType.Manual, user: targetUser._id, actionType: 'ban', - } as Infraction); + } + let { userWarnCount } = await storeInfraction(infraction); message.serverContext.banUser(targetUser._id, { reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id}) (${durationStr})` @@ -105,8 +114,11 @@ export default { until: banUntil, } as TempBan); - message.reply(`### ${targetUser.username} has been temporarily banned.\n` - + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); + await Promise.all([ + message.reply(`### ${targetUser.username} has been temporarily banned.\n` + + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`), + logModAction('ban', message.serverContext, message.member!, targetUser._id, reason, infraction, `Ban duration: **${Day(banUntil).fromNow(true)}**`), + ]); } } } as Command; diff --git a/src/bot/commands/botctl.ts b/src/bot/commands/botctl.ts index d8ca4c3..a8846b8 100644 --- a/src/bot/commands/botctl.ts +++ b/src/bot/commands/botctl.ts @@ -21,7 +21,7 @@ export default { try { let serverConf: FindOneResult = await client.db.get('servers').findOne({ id: message.serverContext._id }); - if (!serverConf?.userScan?.enable) return message.reply(`User scanning is not enabled for this server.`); + if (!serverConf?.enableUserScan) return message.reply(`User scanning is not enabled for this server.`); if (userscans.includes(message.serverContext._id)) return message.reply(`There is already a scan running for this server.`); userscans.push(message.serverContext._id); diff --git a/src/bot/commands/kick.ts b/src/bot/commands/kick.ts index 93af771..271374b 100644 --- a/src/bot/commands/kick.ts +++ b/src/bot/commands/kick.ts @@ -5,6 +5,7 @@ import Infraction from "../../struct/antispam/Infraction"; import InfractionType from "../../struct/antispam/InfractionType"; import Command from "../../struct/Command"; import MessageCommandContext from "../../struct/MessageCommandContext"; +import { logModAction } from "../modules/mod_logs"; import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util"; export default { @@ -41,7 +42,7 @@ export default { } let infId = ulid(); - let { userWarnCount } = await storeInfraction({ + let infraction: Infraction = { _id: infId, createdBy: message.author_id, date: Date.now(), @@ -50,7 +51,8 @@ export default { type: InfractionType.Manual, user: targetUser._id, actionType: 'kick', - } as Infraction); + } + let { userWarnCount } = await storeInfraction(infraction); try { await targetMember.kick(); @@ -58,7 +60,10 @@ export default { return message.reply(`Failed to kick user: \`${e}\``); } - message.reply(`### @${targetUser.username} has been ${Math.random() > 0.8 ? 'ejected' : 'kicked'}.\n` - + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); + await Promise.all([ + message.reply(`### @${targetUser.username} has been ${Math.random() > 0.8 ? 'ejected' : 'kicked'}.\n` + + `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`), + logModAction('kick', message.serverContext, message.member!, targetUser._id, reason, infraction), + ]); } } as Command; diff --git a/src/bot/commands/ping.ts b/src/bot/commands/ping.ts index 89e9021..59cadf5 100644 --- a/src/bot/commands/ping.ts +++ b/src/bot/commands/ping.ts @@ -11,7 +11,7 @@ export default { message.reply(`Measuring...`) ?.catch(console.error) .then(msg => { - msg?.edit({ content: `## Ping Pong!\n` + if (msg) msg.edit({ content: `## Ping Pong!\n` + `WS: \`${client.websocket.ping ?? '--'}ms\`\n` + `Msg: \`${Math.round(Date.now() - now) / 2}ms\`` }); }); diff --git a/src/bot/commands/warn.ts b/src/bot/commands/warn.ts index b45fa9c..4732994 100644 --- a/src/bot/commands/warn.ts +++ b/src/bot/commands/warn.ts @@ -42,7 +42,7 @@ export default { + ` for ${await fetchUsername(user._id)}.\n` + `**Infraction ID:** \`${infraction._id}\`\n` + `**Reason:** \`${infraction.reason}\``), - logModAction('warn', message.serverContext, message.member!, user._id, reason, `This is warn number **${userWarnCount}** for this user.`), + logModAction('warn', message.serverContext, message.member!, user._id, reason, infraction, `This is warn number ${userWarnCount} for this user.`), ]); } } as Command; diff --git a/src/bot/commands/warns.ts b/src/bot/commands/warns.ts index c9e5e6f..452cb7d 100644 --- a/src/bot/commands/warns.ts +++ b/src/bot/commands/warns.ts @@ -39,7 +39,7 @@ export default { + `${inf[0].type == InfractionType.Manual ? `(${await fetchUsername(inf[0].createdBy ?? '')})` : ''}\n`; }; - message.reply(msg.substr(0, 1999)); + message.reply(msg.substring(0, 1999)); } else { switch(args[0]?.toLowerCase()) { case 'delete': @@ -55,7 +55,7 @@ export default { if (!inf) return message.reply('I can\'t find that ID.'); - message.reply(`## Infraction deleted\n\u200b\n` + message.reply(`## Infraction deleted\n` + `ID: \`${inf._id}\`\n` + `Reason: ${getInfEmoji(inf)}\`${inf.reason}\` ` + `(${inf.type == InfractionType.Manual ? await fetchUsername(inf.createdBy ?? '') : 'System'})\n` diff --git a/src/bot/modules/mod_logs.ts b/src/bot/modules/mod_logs.ts index 12bf9c7..6d36bef 100644 --- a/src/bot/modules/mod_logs.ts +++ b/src/bot/modules/mod_logs.ts @@ -1,9 +1,11 @@ import { Member } from "@janderedev/revolt.js/dist/maps/Members"; import { Server } from "@janderedev/revolt.js/dist/maps/Servers"; import { client } from "../.."; +import Infraction from "../../struct/antispam/Infraction"; +import LogMessage from "../../struct/LogMessage"; import ServerConfig from "../../struct/ServerConfig"; import logger from "../logger"; -import { getAutumnURL, sanitizeMessageContent, uploadFile } from "../util"; +import { getAutumnURL, sanitizeMessageContent, sendLogMessage } from "../util"; // the `packet` event is emitted before the client's cache // is updated, which allows us to get the old message content @@ -13,8 +15,6 @@ client.on('packet', async (packet) => { try { if (!packet.data.content) return; - logger.debug('Message updated'); - let m = client.messages.get(packet.id); if (m?.author_id == client.user?._id) return; @@ -29,31 +29,36 @@ client.on('packet', async (packet) => { if (!server || !channel) return logger.warn('Received message update in unknown channel or server'); let config: ServerConfig = await client.db.get('servers').findOne({ id: server._id }) ?? {}; - if (!config?.logs?.messageUpdate) return; - let logChannelID = config.logs.messageUpdate; - let logChannel = client.channels.get(logChannelID); - if (!logChannel) return logger.debug('Log channel deleted or not cached: ' + logChannelID); + if (config?.logs?.messageUpdate) { + const attachFullMessage = oldMsg.length > 800 || newMsg.length > 800; + let embed: LogMessage = { + title: `Message edited in ${server.name}`, + description: `[\\[#${channel.name}\\]](/server/${server._id}/channel/${channel._id}) | ` + + `[\\[Author\\]](/@${m?.author_id}) | ` + + `[\\[Jump to message\\]](/server/${server._id}/channel/${channel._id}/${packet.id})`, + fields: [], + color: '#829dff', + overrides: { + discord: { + description: `Author: @${m?.author?.username || m?.author_id || "Unknown"} | Channel: ${channel?.name || channel?._id}` + }, + revoltRvembed: { + description: `Author: @${m?.author?.username || m?.author_id || "Unknown"} | Channel: ${channel?.name || channel?._id}` + } + } + } - let attachFullMessage = oldMsg.length > 800 || newMsg.length > 800; + if (attachFullMessage) { + embed.attachments = [ + { name: 'old_message.txt', content: Buffer.from(oldMsgRaw) }, + { name: 'new_message.txt', content: Buffer.from(newMsgRaw) }, + ]; + } else { + embed.fields!.push({ title: 'Old content', content: oldMsg }); + embed.fields!.push({ title: 'New content', content: newMsg }); + } - if (attachFullMessage) { - logChannel.sendMessage({ - content: `### Message edited in ${server.name}\n` - + `[\\[Jump to message\\]](/server/${server._id}/channel/${channel._id}/${packet.id})\n`, - attachments: await Promise.all([ - uploadFile(oldMsgRaw, 'Old message'), - uploadFile(newMsgRaw, 'New message'), - ]), - }); - } else { - let logMsg = `### Message edited in ${server.name}\n` - + `[\\[Jump to message\\]](/server/${server._id}/channel/${channel._id}/${packet.id}) | ` - + `[\\[Author\\]](/@${m?.author_id})\n`; - logMsg += `#### Old Content\n${oldMsg}\n`; - logMsg += `#### New Content\n${newMsg}`; - - logChannel.sendMessage(logMsg) - .catch(() => logger.warn(`Failed to send log message`)); + await sendLogMessage(config.logs.messageUpdate, embed); } } catch(e) { console.error(e); @@ -71,42 +76,37 @@ client.on('packet', async (packet) => { let msg = sanitizeMessageContent(msgRaw); let config: ServerConfig = await client.db.get('servers').findOne({ id: message.channel?.server?._id }) ?? {}; - if (!config?.logs?.messageUpdate) return; - let logChannelID = config.logs.messageUpdate; - let logChannel = client.channels.get(logChannelID); - if (!logChannel) return logger.debug('Log channel deleted or not cached: ' + logChannelID); + if (config.logs?.messageUpdate) { + let embed: LogMessage = { + title: `Message deleted in ${message.channel?.server?.name}`, + description: `[\\[#${channel.name}\\]](/server/${channel.server_id}/channel/${channel._id}) | ` + + `[\\[Author\\]](/@${message.author_id}) | ` + + `[\\[Jump to context\\]](/server/${channel.server_id}/channel/${channel._id}/${packet.id})`, + fields: [], + color: '#ff6b6b', + overrides: { + discord: { + description: `Author: @${message.author?.username || message.author_id} | Channel: ${message.channel?.name || message.channel_id}` + }, + revoltRvembed: { + description: `Author: @${message.author?.username || message.author_id} | Channel: ${message.channel?.name || message.channel_id}` + } + } + } - if (msg.length > 1000) { - let logMsg = `### Message deleted in ${message.channel?.server?.name}\n`; + if (msg.length > 1000) { + embed.attachments?.push({ name: 'message.txt', content: Buffer.from(msgRaw) }); + } else { + embed.fields!.push({ title: 'Content', content: msg || "(Empty)" }); + } if (message.attachments?.length) { let autumnURL = await getAutumnURL(); - - logMsg += `\n\u200b\n#### Attachments\n` + message.attachments.map(a => - `[\\[${a.filename}\\]](<${autumnURL}/${a.tag}/${a._id}/${a.filename}>)`).join(' | '); + embed.fields!.push({ title: 'Attachments', content: message.attachments.map(a => + `[\\[${a.filename}\\]](<${autumnURL}/${a.tag}/${a._id}/${a.filename}>)`).join(' | ') }) } - logChannel.sendMessage({ - content: logMsg, - attachments: [ await uploadFile(msgRaw, 'Message content') ], - }) - .catch(() => logger.warn(`Failed to send log message`)); - } else { - let logMsg = `### Message deleted in ${channel.server?.name}\n` - + `[\\[Jump to channel\\]](/server/${channel.server?._id}/channel/${channel._id}) | ` - + `[\\[Author\\]](/@${message.author_id})\n` - + `#### Message content\n` - + msg; - - if (message.attachments?.length) { - let autumnURL = await getAutumnURL(); - - logMsg += `\n\u200b\n#### Attachments\n` + message.attachments.map(a => - `[\\[${a.filename}\\]](<${autumnURL}/${a.tag}/${a._id}/${a.filename}>)`).join(' | '); - } - - logChannel.sendMessage(logMsg) - .catch(() => logger.warn(`Failed to send log message`)); + await sendLogMessage(config.logs.messageUpdate, embed); } } catch(e) { console.error(e); @@ -114,22 +114,36 @@ client.on('packet', async (packet) => { } }); -async function logModAction(type: 'warn'|'kick'|'ban', server: Server, mod: Member, target: string, reason: string|null, extraText?: string|null): Promise { +async function logModAction(type: 'warn'|'kick'|'ban', server: Server, mod: Member, target: string, reason: string|null, infraction: Infraction, extraText?: string): Promise { try { let config: ServerConfig = await client.db.get('servers').findOne({ id: server._id }) ?? {}; - let logChannelID = config.logs?.modAction; - if (!logChannelID) return; - let logChannel = client.channels.get(logChannelID); - let aType = type == 'ban' ? 'banned' : type + 'ed'; - let msg = `User ${aType}\n` - + `\`@${mod.user?.username}\` **${aType}** \`@` - + `${await fetchUsername(target)}\`${type == 'warn' ? '.' : ` from ${server.name}.`}\n` - + `**Reason**: \`${reason ? reason : 'No reason provided.'}\`\n` - + (extraText ?? ''); - - logChannel?.sendMessage(msg) - .catch(() => logger.warn('Failed to send log message')); + if (config.logs?.modAction) { + let aType = type == 'ban' ? 'banned' : type + 'ed'; + let embedColor = '#0576ff'; + if (type == 'kick') embedColor = '#ff861d'; + if (type == 'ban') embedColor = '#ff2f05'; + + + sendLogMessage(config.logs.modAction, { + title: `User ${aType}`, + description: `\`@${mod.user?.username}\` **${aType}** \`` + + `${await fetchUsername(target)}\`${type == 'warn' ? '.' : ` from ${server.name}.`}\n` + + `**Reason**: \`${reason ? reason : 'No reason provided.'}\`\n` + + `**Warn ID**: \`${infraction._id}\`\n` + + (extraText ?? ''), + color: embedColor, + overrides: { + revoltRvembed: { + description: `@${mod.user?.username} ${aType} ` + + `${await fetchUsername(target)}${type == 'warn' ? '.' : ` from ${server.name}.`}\n` + + `Reason: ${reason ? reason : 'No reason provided.'}\n` + + `Warn ID: ${infraction._id}\n` + + (extraText ?? ''), + } + } + }); + } } catch(e) { console.error(e); } diff --git a/src/bot/modules/user_scan.ts b/src/bot/modules/user_scan.ts index 6a23d84..14b5be1 100644 --- a/src/bot/modules/user_scan.ts +++ b/src/bot/modules/user_scan.ts @@ -4,8 +4,8 @@ import { FindOneResult } from "monk"; import ScannedUser from "../../struct/ScannedUser"; import { Member } from "@janderedev/revolt.js/dist/maps/Members"; import ServerConfig from "../../struct/ServerConfig"; -import { MessageEmbed, WebhookClient } from "discord.js"; import logger from "../logger"; +import { sendLogMessage } from "../util"; let { USERSCAN_WORDLIST_PATH } = process.env; @@ -16,6 +16,8 @@ let wordlist = USERSCAN_WORDLIST_PATH .filter(word => word.length > 0) : null; +if (wordlist) logger.info("Found word list; user scanning enabled"); + let scannedUsers = client.db.get('scanned_users'); let serverConfig: Map = new Map(); let userScanTimeout: Map = new Map(); @@ -24,7 +26,7 @@ async function scanServer(id: string, userScanned: () => void, done: () => void) if (!wordlist) return; let conf: FindOneResult = await client.db.get('servers').findOne({ id: id }); serverConfig.set(id, conf as ServerConfig); - if (!conf?.userScan?.enable) return; + if (!conf?.enableUserScan) return; try { logger.debug(`Scanning user list for ${id}`); @@ -101,44 +103,32 @@ async function scanUser(member: Member) { async function logUser(member: Member, profile: any) { // `Profile` type doesn't seem to be exported by revolt.js try { let conf = serverConfig.get(member.server!._id); - if (!conf || !conf.userScan?.enable) return; + if (!conf || !conf.enableUserScan) return; - if (conf.userScan.discordWebhook) { - try { - let embed = new MessageEmbed() - .setTitle('Potentially suspicious user found') - .setAuthor(`${member.user?.username ?? 'Unknown user'} | ${member._id.user}`, member.generateAvatarURL()); + logger.debug(`User ${member._id} matched word list; reporting`); - if (member.nickname) embed.addField('Nickname', member.nickname || 'None', true); - if (member.user?.status?.text) embed.addField('Status', member.user.status.text || 'None', true); - embed.addField('Profile', ((profile?.content || 'No about me text') as string).substr(0, 1000)); + if (conf.enableUserScan && conf.logs?.userScan) { + let bannerUrl = client.generateFileURL({ + _id: profile.background._id, + tag: profile.background.tag, + content_type: profile.background.content_type, + }, undefined, true); + let embedFields: { title: string, content: string, inline?: boolean }[] = []; + if (member.nickname) embedFields.push({ title: 'Nickname', content: member.nickname || 'None', inline: true }); + if (member.user?.status?.text) embedFields.push({ title: 'Status', content: member.user.status.text || 'None', inline: true }); + embedFields.push({ title: 'Profile', content: ((profile?.content || 'No about me text') as string).substring(0, 1000), inline: true }); - if (profile.background) { - let url = client.generateFileURL({ - _id: profile.background._id, - tag: profile.background.tag, - content_type: profile.background.content_type, - }, undefined, true); + sendLogMessage(conf.logs.userScan, { + title: 'Potentially suspicious user found', + description: `${member.user?.username ?? 'Unknown user'} | [${member._id.user}](/@${member._id.user}) | [Avatar](<${member.generateAvatarURL()}>)`, + color: '#ff9c11', + fields: embedFields, + image: bannerUrl ? { + type: 'BIG', + url: bannerUrl + } : undefined, + }); - if (url) embed.setImage(url); - } - - let whClient = new WebhookClient({ url: conf.userScan.discordWebhook }); - await whClient.send({ embeds: [ embed ] }); - whClient.destroy(); - } catch(e) { console.error(e) } - } - - if (conf.userScan.logChannel) { - try { - let channel = client.channels.get(conf.userScan.logChannel) - || await client.channels.fetch(conf.userScan.logChannel); - - let msg = `## Potentially suspicious user found\n` - + `The profile <@${member._id.user}> (${member._id.user}) might contain abusive content.`; - - await channel.sendMessage(msg); - } catch(e) { console.error(e) } } } catch(e) { console.error(e) } } @@ -166,7 +156,7 @@ new Promise((res: (value: void) => void) => client.user ? res() : client.once('r let conf: FindOneResult = await client.db.get('servers').findOne({ id: server._id }); serverConfig.set(server._id, conf as ServerConfig); - if (conf?.userScan?.enable) { + if (conf?.enableUserScan) { let member = await server.fetchMember(packet.id); let t = userScanTimeout.get(member._id.user); if (t && t > (Date.now() - 10000)) return; @@ -191,7 +181,7 @@ new Promise((res: (value: void) => void) => client.user ? res() : client.once('r let conf: FindOneResult = await client.db.get('servers').findOne({ id: server._id }); serverConfig.set(server._id, conf as ServerConfig); - if (conf?.userScan?.enable) { + if (conf?.enableUserScan) { let t = userScanTimeout.get(member._id.user); if (t && t > (Date.now() - 10000)) return; userScanTimeout.set(member._id.user, Date.now()); diff --git a/src/bot/util.ts b/src/bot/util.ts index 247b536..ea5b9c6 100644 --- a/src/bot/util.ts +++ b/src/bot/util.ts @@ -6,6 +6,11 @@ import ServerConfig from "../struct/ServerConfig"; import FormData from 'form-data'; import axios from 'axios'; import { Server } from "@janderedev/revolt.js/dist/maps/Servers"; +import LogConfig from "../struct/LogConfig"; +import LogMessage from "../struct/LogMessage"; +import { ColorResolvable, MessageAttachment, MessageEmbed, WebhookClient } from "discord.js"; +import logger from "./logger"; +import { ulid } from "ulid"; let ServerPermissions = { ['View' as string]: 1 << 0, @@ -127,6 +132,110 @@ async function uploadFile(file: any, filename: string): Promise { return (req.data as any)['id'] as string; } +async function sendLogMessage(config: LogConfig, content: LogMessage) { + if (config.discord?.webhookUrl) { + let c = { ...content, ...content.overrides?.discord } + + const embed = new MessageEmbed(); + if (c.title) embed.setTitle(content.title); + if (c.description) embed.setDescription(c.description); + if (c.color) embed.setColor(c.color as ColorResolvable); + if (c.fields?.length) { + for (const field of c.fields) { + embed.addField(field.title, field.content.trim() || "\u200b", field.inline); + } + } + if (content.image) { + if (content.image.type == 'THUMBNAIL') embed.setThumbnail(content.image.url); + else if (content.image.type == 'BIG') embed.setImage(content.image.url); + } + + if (content.attachments?.length) { + embed.setFooter(`Attachments: ${content.attachments.map(a => a.name).join(', ')}`); + } + + let data = new FormData(); + content.attachments?.forEach(a => { + data.append(`files[${ulid()}]`, a.content, { filename: a.name }); + }); + + data.append("payload_json", JSON.stringify({ embeds: [ embed.toJSON() ] }), { contentType: 'application/json' }); + + axios.post(config.discord.webhookUrl, data, {headers: data.getHeaders() }) + .catch(e => { + logger.error('Failed to fire Discord webhook: ' + e); + }); + } + + if (config.revolt?.channel) { + let c = { ...content, ...content.overrides?.revolt }; + try { + const channel = client.channels.get(config.revolt.channel) || await client.channels.fetch(config.revolt.channel); + + let message = ''; + switch(config.revolt.type) { + case 'RVEMBED': + case 'DYNAMIC': + c = { ...c, ...content.overrides?.revoltRvembed }; + let url = `https://rvembed.janderedev.xyz/embed`; + let args = []; + + let description = (c.description ?? ''); + if (c.fields?.length) { + for (const field of c.fields) { + description += `\n${field.title}\n` + + `${field.content}`; + } + } + + description = description.trim(); + + if (c.title) args.push(`title=${encodeURIComponent(c.title)}`); + if (description) args.push(`description=${encodeURIComponent(description)}`); + if (c.color) args.push(`color=${encodeURIComponent(c.color)}`); + if (c.image) { + args.push(`image=${encodeURIComponent(c.image.url)}`); + args.push(`image_large=true`); + } + + if (!(config.revolt.type == 'DYNAMIC' && (description.length > 1000 || description.split('\n').length > 6))) { + for (const i in args) url += `${i == '0' ? '?' : '&'}${args[i]}`; + message = `[\u200b](${url})`; + break; + } + default: // QUOTEBLOCK, PLAIN or unspecified + + // please disregard this mess + + c = { ...c, ...content.overrides?.revoltQuoteblock }; + const quote = config.revolt.type == 'PLAIN' ? '' : '>'; + + if (c.title) message += `## ${c.title}\n`; + if (c.description) message += `${c.description}\n`; + if (c.fields?.length) { + for (const field of c.fields) { + message += `${quote ? '\u200b\n' : ''}${quote}### ${field.title}\n` + + `${quote}${field.content.trim().split('\n').join('\n' + quote)}\n${quote ? '\n' : ''}`; + } + } + + message = message.trim().split('\n').join('\n' + quote); // Wrap entire message in quotes + if (c.image?.url) message += `\n[Attachment](${c.image.url})`; + break; + } + + await channel.sendMessage({ + content: message, + attachments: content.attachments ? + await Promise.all(content.attachments?.map(a => uploadFile(a.content, a.name))) : + undefined + }); + } catch(e) { + logger.error(`Failed to send log message in ${config.revolt.channel}: ${e}`); + } + } +} + /** * Attempts to escape a message's markdown content (qoutes, headers, **bold** / *italic*, etc) */ @@ -175,6 +284,7 @@ export { storeInfraction, uploadFile, sanitizeMessageContent, + sendLogMessage, NO_MANAGER_MSG, ULID_REGEX, USER_MENTION_REGEX, diff --git a/src/struct/LogConfig.ts b/src/struct/LogConfig.ts new file mode 100644 index 0000000..69fafef --- /dev/null +++ b/src/struct/LogConfig.ts @@ -0,0 +1,14 @@ +export default class LogConfig { + revolt?: { + channel?: string, + + // RVEMBED uses https://rvembed.janderedev.xyz to send a discord style embed, which doesn't + // work properly with longer messages. + // PLAIN is like QUOTEBLOCK but without the quotes. + // DYNAMIC uses RVEMBED if the message is short enough, otherwise defaults to QUOTEBLOCK. + type?: 'QUOTEBLOCK'|'PLAIN'|'RVEMBED'|'DYNAMIC'; + } + discord?: { + webhookUrl?: string, + } +} diff --git a/src/struct/LogMessage.ts b/src/struct/LogMessage.ts new file mode 100644 index 0000000..b203228 --- /dev/null +++ b/src/struct/LogMessage.ts @@ -0,0 +1,21 @@ +type Override = { + description?: string|null; +}; + +export default class LogMessage { + title: string; + description?: string; + fields?: { title: string, content: string, inline?: boolean }[]; + color?: string; + image?: { type: 'BIG'|'THUMBNAIL', url: string }; + attachments?: { name: string, content: Buffer }[]; + overrides?: { + // These take priority over `revolt` + revoltRvembed?: Override, + revoltQuoteblock?: Override, + + revolt?: Override, + + discord?: Override, + } +} \ No newline at end of file diff --git a/src/struct/ServerConfig.ts b/src/struct/ServerConfig.ts index ccb1d9c..2740637 100644 --- a/src/struct/ServerConfig.ts +++ b/src/struct/ServerConfig.ts @@ -1,4 +1,5 @@ import AutomodSettings from "./antispam/AutomodSettings"; +import LogConfig from "./LogConfig"; class ServerConfig { id: string | undefined; @@ -14,16 +15,11 @@ class ServerConfig { managers: boolean | undefined, } | undefined; logs: { - automod: string | undefined, // automod rule triggered - messageUpdate: string | undefined, // Message edited or deleted - modAction: string | undefined, // User warned, kicked or banned - userUpdate: string | undefined, // Username/nickname/avatar changes - } | undefined; - userScan: { - enable?: boolean; - logChannel?: string; - discordWebhook?: string; + messageUpdate?: LogConfig, // Message edited or deleted + modAction?: LogConfig, // User warned, kicked or banned + userScan?: LogConfig // User profile matched word list } | undefined; + enableUserScan?: boolean; } export default ServerConfig;