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
This commit is contained in:
janderedev 2022-01-04 14:56:01 +01:00
parent 8ae2533ca1
commit 648fe39fe6
Signed by: Lea
GPG key ID: 5D5E18ACB990F57A
12 changed files with 298 additions and 136 deletions

View file

@ -5,9 +5,13 @@ import InfractionType from "../../struct/antispam/InfractionType";
import Command from "../../struct/Command"; import Command from "../../struct/Command";
import MessageCommandContext from "../../struct/MessageCommandContext"; import MessageCommandContext from "../../struct/MessageCommandContext";
import TempBan from "../../struct/TempBan"; import TempBan from "../../struct/TempBan";
import { fetchUsername } from "../modules/mod_logs"; import { fetchUsername, logModAction } from "../modules/mod_logs";
import { storeTempBan } from "../modules/tempbans"; import { storeTempBan } from "../modules/tempbans";
import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util"; import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util";
import Day from 'dayjs';
import RelativeTime from 'dayjs/plugin/relativeTime';
Day.extend(RelativeTime);
export default { export default {
name: 'ban', name: 'ban',
@ -18,10 +22,10 @@ export default {
run: async (message: MessageCommandContext, args: string[]) => { run: async (message: MessageCommandContext, args: string[]) => {
if (!await isModerator(message.member!, message.serverContext)) if (!await isModerator(message.member!, message.serverContext))
return message.reply(NO_MANAGER_MSG); return message.reply(NO_MANAGER_MSG);
if (args.length == 0) if (args.length == 0)
return message.reply(`You need to provide a target user!`); return message.reply(`You need to provide a target user!`);
let targetUser = await parseUser(args.shift()!); let targetUser = await parseUser(args.shift()!);
if (!targetUser) return message.reply('Sorry, I can\'t find that user.'); if (!targetUser) return message.reply('Sorry, I can\'t find that user.');
@ -61,7 +65,7 @@ export default {
if (banDuration == 0) { if (banDuration == 0) {
let infId = ulid(); let infId = ulid();
let { userWarnCount } = await storeInfraction({ let infraction: Infraction = {
_id: infId, _id: infId,
createdBy: message.author_id, createdBy: message.author_id,
date: Date.now(), date: Date.now(),
@ -70,19 +74,23 @@ export default {
type: InfractionType.Manual, type: InfractionType.Manual,
user: targetUser._id, user: targetUser._id,
actionType: 'ban', actionType: 'ban',
} as Infraction); }
let { userWarnCount } = await storeInfraction(infraction);
message.serverContext.banUser(targetUser._id, { message.serverContext.banUser(targetUser._id, {
reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id})` reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id})`
}) })
.catch(e => message.reply(`Failed to ban user: \`${e}\``)); .catch(e => message.reply(`Failed to ban user: \`${e}\``));
message.reply(`### @${targetUser.username} has been banned.\n` await Promise.all([
+ `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); 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 { } else {
let banUntil = Date.now() + banDuration; let banUntil = Date.now() + banDuration;
let infId = ulid(); let infId = ulid();
let { userWarnCount } = await storeInfraction({ let infraction: Infraction = {
_id: infId, _id: infId,
createdBy: message.author_id, createdBy: message.author_id,
date: Date.now(), date: Date.now(),
@ -91,7 +99,8 @@ export default {
type: InfractionType.Manual, type: InfractionType.Manual,
user: targetUser._id, user: targetUser._id,
actionType: 'ban', actionType: 'ban',
} as Infraction); }
let { userWarnCount } = await storeInfraction(infraction);
message.serverContext.banUser(targetUser._id, { message.serverContext.banUser(targetUser._id, {
reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id}) (${durationStr})` reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id}) (${durationStr})`
@ -105,8 +114,11 @@ export default {
until: banUntil, until: banUntil,
} as TempBan); } as TempBan);
message.reply(`### ${targetUser.username} has been temporarily banned.\n` await Promise.all([
+ `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); 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; } as Command;

View file

@ -21,7 +21,7 @@ export default {
try { try {
let serverConf: FindOneResult<ServerConfig> = await client.db.get('servers').findOne({ id: message.serverContext._id }); let serverConf: FindOneResult<ServerConfig> = 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.`); if (userscans.includes(message.serverContext._id)) return message.reply(`There is already a scan running for this server.`);
userscans.push(message.serverContext._id); userscans.push(message.serverContext._id);

View file

@ -5,6 +5,7 @@ import Infraction from "../../struct/antispam/Infraction";
import InfractionType from "../../struct/antispam/InfractionType"; import InfractionType from "../../struct/antispam/InfractionType";
import Command from "../../struct/Command"; import Command from "../../struct/Command";
import MessageCommandContext from "../../struct/MessageCommandContext"; import MessageCommandContext from "../../struct/MessageCommandContext";
import { logModAction } from "../modules/mod_logs";
import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util"; import { isModerator, NO_MANAGER_MSG, parseUser, storeInfraction } from "../util";
export default { export default {
@ -41,7 +42,7 @@ export default {
} }
let infId = ulid(); let infId = ulid();
let { userWarnCount } = await storeInfraction({ let infraction: Infraction = {
_id: infId, _id: infId,
createdBy: message.author_id, createdBy: message.author_id,
date: Date.now(), date: Date.now(),
@ -50,7 +51,8 @@ export default {
type: InfractionType.Manual, type: InfractionType.Manual,
user: targetUser._id, user: targetUser._id,
actionType: 'kick', actionType: 'kick',
} as Infraction); }
let { userWarnCount } = await storeInfraction(infraction);
try { try {
await targetMember.kick(); await targetMember.kick();
@ -58,7 +60,10 @@ export default {
return message.reply(`Failed to kick user: \`${e}\``); return message.reply(`Failed to kick user: \`${e}\``);
} }
message.reply(`### @${targetUser.username} has been ${Math.random() > 0.8 ? 'ejected' : 'kicked'}.\n` await Promise.all([
+ `Infraction ID: \`${infId}\` (**#${userWarnCount}** for this user)`); 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; } as Command;

View file

@ -11,7 +11,7 @@ export default {
message.reply(`Measuring...`) message.reply(`Measuring...`)
?.catch(console.error) ?.catch(console.error)
.then(msg => { .then(msg => {
msg?.edit({ content: `## Ping Pong!\n` if (msg) msg.edit({ content: `## Ping Pong!\n`
+ `WS: \`${client.websocket.ping ?? '--'}ms\`\n` + `WS: \`${client.websocket.ping ?? '--'}ms\`\n`
+ `Msg: \`${Math.round(Date.now() - now) / 2}ms\`` }); + `Msg: \`${Math.round(Date.now() - now) / 2}ms\`` });
}); });

View file

@ -42,7 +42,7 @@ export default {
+ ` for ${await fetchUsername(user._id)}.\n` + ` for ${await fetchUsername(user._id)}.\n`
+ `**Infraction ID:** \`${infraction._id}\`\n` + `**Infraction ID:** \`${infraction._id}\`\n`
+ `**Reason:** \`${infraction.reason}\``), + `**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; } as Command;

View file

@ -39,7 +39,7 @@ export default {
+ `${inf[0].type == InfractionType.Manual ? `(${await fetchUsername(inf[0].createdBy ?? '')})` : ''}\n`; + `${inf[0].type == InfractionType.Manual ? `(${await fetchUsername(inf[0].createdBy ?? '')})` : ''}\n`;
}; };
message.reply(msg.substr(0, 1999)); message.reply(msg.substring(0, 1999));
} else { } else {
switch(args[0]?.toLowerCase()) { switch(args[0]?.toLowerCase()) {
case 'delete': case 'delete':
@ -55,7 +55,7 @@ export default {
if (!inf) return message.reply('I can\'t find that ID.'); 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` + `ID: \`${inf._id}\`\n`
+ `Reason: ${getInfEmoji(inf)}\`${inf.reason}\` ` + `Reason: ${getInfEmoji(inf)}\`${inf.reason}\` `
+ `(${inf.type == InfractionType.Manual ? await fetchUsername(inf.createdBy ?? '') : 'System'})\n` + `(${inf.type == InfractionType.Manual ? await fetchUsername(inf.createdBy ?? '') : 'System'})\n`

View file

@ -1,9 +1,11 @@
import { Member } from "@janderedev/revolt.js/dist/maps/Members"; import { Member } from "@janderedev/revolt.js/dist/maps/Members";
import { Server } from "@janderedev/revolt.js/dist/maps/Servers"; import { Server } from "@janderedev/revolt.js/dist/maps/Servers";
import { client } from "../.."; import { client } from "../..";
import Infraction from "../../struct/antispam/Infraction";
import LogMessage from "../../struct/LogMessage";
import ServerConfig from "../../struct/ServerConfig"; import ServerConfig from "../../struct/ServerConfig";
import logger from "../logger"; 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 // the `packet` event is emitted before the client's cache
// is updated, which allows us to get the old message content // is updated, which allows us to get the old message content
@ -13,8 +15,6 @@ client.on('packet', async (packet) => {
try { try {
if (!packet.data.content) return; if (!packet.data.content) return;
logger.debug('Message updated');
let m = client.messages.get(packet.id); let m = client.messages.get(packet.id);
if (m?.author_id == client.user?._id) return; 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'); 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 }) ?? {}; let config: ServerConfig = await client.db.get('servers').findOne({ id: server._id }) ?? {};
if (!config?.logs?.messageUpdate) return; if (config?.logs?.messageUpdate) {
let logChannelID = config.logs.messageUpdate; const attachFullMessage = oldMsg.length > 800 || newMsg.length > 800;
let logChannel = client.channels.get(logChannelID); let embed: LogMessage = {
if (!logChannel) return logger.debug('Log channel deleted or not cached: ' + logChannelID); 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) { await sendLogMessage(config.logs.messageUpdate, embed);
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`));
} }
} catch(e) { } catch(e) {
console.error(e); console.error(e);
@ -71,42 +76,37 @@ client.on('packet', async (packet) => {
let msg = sanitizeMessageContent(msgRaw); let msg = sanitizeMessageContent(msgRaw);
let config: ServerConfig = await client.db.get('servers').findOne({ id: message.channel?.server?._id }) ?? {}; let config: ServerConfig = await client.db.get('servers').findOne({ id: message.channel?.server?._id }) ?? {};
if (!config?.logs?.messageUpdate) return; if (config.logs?.messageUpdate) {
let logChannelID = config.logs.messageUpdate; let embed: LogMessage = {
let logChannel = client.channels.get(logChannelID); title: `Message deleted in ${message.channel?.server?.name}`,
if (!logChannel) return logger.debug('Log channel deleted or not cached: ' + logChannelID); 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) { if (msg.length > 1000) {
let logMsg = `### Message deleted in ${message.channel?.server?.name}\n`; embed.attachments?.push({ name: 'message.txt', content: Buffer.from(msgRaw) });
} else {
embed.fields!.push({ title: 'Content', content: msg || "(Empty)" });
}
if (message.attachments?.length) { if (message.attachments?.length) {
let autumnURL = await getAutumnURL(); let autumnURL = await getAutumnURL();
embed.fields!.push({ title: 'Attachments', content: message.attachments.map(a =>
logMsg += `\n\u200b\n#### Attachments\n` + message.attachments.map(a => `[\\[${a.filename}\\]](<${autumnURL}/${a.tag}/${a._id}/${a.filename}>)`).join(' | ') })
`[\\[${a.filename}\\]](<${autumnURL}/${a.tag}/${a._id}/${a.filename}>)`).join(' | ');
} }
logChannel.sendMessage({ await sendLogMessage(config.logs.messageUpdate, embed);
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`));
} }
} catch(e) { } catch(e) {
console.error(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<void> { async function logModAction(type: 'warn'|'kick'|'ban', server: Server, mod: Member, target: string, reason: string|null, infraction: Infraction, extraText?: string): Promise<void> {
try { try {
let config: ServerConfig = await client.db.get('servers').findOne({ id: server._id }) ?? {}; 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'; if (config.logs?.modAction) {
let msg = `User ${aType}\n` let aType = type == 'ban' ? 'banned' : type + 'ed';
+ `\`@${mod.user?.username}\` **${aType}** \`@` let embedColor = '#0576ff';
+ `${await fetchUsername(target)}\`${type == 'warn' ? '.' : ` from ${server.name}.`}\n` if (type == 'kick') embedColor = '#ff861d';
+ `**Reason**: \`${reason ? reason : 'No reason provided.'}\`\n` if (type == 'ban') embedColor = '#ff2f05';
+ (extraText ?? '');
logChannel?.sendMessage(msg) sendLogMessage(config.logs.modAction, {
.catch(() => logger.warn('Failed to send log message')); 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) { } catch(e) {
console.error(e); console.error(e);
} }

View file

@ -4,8 +4,8 @@ import { FindOneResult } from "monk";
import ScannedUser from "../../struct/ScannedUser"; import ScannedUser from "../../struct/ScannedUser";
import { Member } from "@janderedev/revolt.js/dist/maps/Members"; import { Member } from "@janderedev/revolt.js/dist/maps/Members";
import ServerConfig from "../../struct/ServerConfig"; import ServerConfig from "../../struct/ServerConfig";
import { MessageEmbed, WebhookClient } from "discord.js";
import logger from "../logger"; import logger from "../logger";
import { sendLogMessage } from "../util";
let { USERSCAN_WORDLIST_PATH } = process.env; let { USERSCAN_WORDLIST_PATH } = process.env;
@ -16,6 +16,8 @@ let wordlist = USERSCAN_WORDLIST_PATH
.filter(word => word.length > 0) .filter(word => word.length > 0)
: null; : null;
if (wordlist) logger.info("Found word list; user scanning enabled");
let scannedUsers = client.db.get('scanned_users'); let scannedUsers = client.db.get('scanned_users');
let serverConfig: Map<string, ServerConfig> = new Map(); let serverConfig: Map<string, ServerConfig> = new Map();
let userScanTimeout: Map<string, number> = new Map(); let userScanTimeout: Map<string, number> = new Map();
@ -24,7 +26,7 @@ async function scanServer(id: string, userScanned: () => void, done: () => void)
if (!wordlist) return; if (!wordlist) return;
let conf: FindOneResult<ServerConfig> = await client.db.get('servers').findOne({ id: id }); let conf: FindOneResult<ServerConfig> = await client.db.get('servers').findOne({ id: id });
serverConfig.set(id, conf as ServerConfig); serverConfig.set(id, conf as ServerConfig);
if (!conf?.userScan?.enable) return; if (!conf?.enableUserScan) return;
try { try {
logger.debug(`Scanning user list for ${id}`); 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 async function logUser(member: Member, profile: any) { // `Profile` type doesn't seem to be exported by revolt.js
try { try {
let conf = serverConfig.get(member.server!._id); let conf = serverConfig.get(member.server!._id);
if (!conf || !conf.userScan?.enable) return; if (!conf || !conf.enableUserScan) return;
if (conf.userScan.discordWebhook) { logger.debug(`User ${member._id} matched word list; reporting`);
try {
let embed = new MessageEmbed()
.setTitle('Potentially suspicious user found')
.setAuthor(`${member.user?.username ?? 'Unknown user'} | ${member._id.user}`, member.generateAvatarURL());
if (member.nickname) embed.addField('Nickname', member.nickname || 'None', true); if (conf.enableUserScan && conf.logs?.userScan) {
if (member.user?.status?.text) embed.addField('Status', member.user.status.text || 'None', true); let bannerUrl = client.generateFileURL({
embed.addField('Profile', ((profile?.content || 'No about me text') as string).substr(0, 1000)); _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) { sendLogMessage(conf.logs.userScan, {
let url = client.generateFileURL({ title: 'Potentially suspicious user found',
_id: profile.background._id, description: `${member.user?.username ?? 'Unknown user'} | [${member._id.user}](/@${member._id.user}) | [Avatar](<${member.generateAvatarURL()}>)`,
tag: profile.background.tag, color: '#ff9c11',
content_type: profile.background.content_type, fields: embedFields,
}, undefined, true); 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) } } catch(e) { console.error(e) }
} }
@ -166,7 +156,7 @@ new Promise((res: (value: void) => void) => client.user ? res() : client.once('r
let conf: FindOneResult<ServerConfig> = await client.db.get('servers').findOne({ id: server._id }); let conf: FindOneResult<ServerConfig> = await client.db.get('servers').findOne({ id: server._id });
serverConfig.set(server._id, conf as ServerConfig); serverConfig.set(server._id, conf as ServerConfig);
if (conf?.userScan?.enable) { if (conf?.enableUserScan) {
let member = await server.fetchMember(packet.id); let member = await server.fetchMember(packet.id);
let t = userScanTimeout.get(member._id.user); let t = userScanTimeout.get(member._id.user);
if (t && t > (Date.now() - 10000)) return; 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<ServerConfig> = await client.db.get('servers').findOne({ id: server._id }); let conf: FindOneResult<ServerConfig> = await client.db.get('servers').findOne({ id: server._id });
serverConfig.set(server._id, conf as ServerConfig); serverConfig.set(server._id, conf as ServerConfig);
if (conf?.userScan?.enable) { if (conf?.enableUserScan) {
let t = userScanTimeout.get(member._id.user); let t = userScanTimeout.get(member._id.user);
if (t && t > (Date.now() - 10000)) return; if (t && t > (Date.now() - 10000)) return;
userScanTimeout.set(member._id.user, Date.now()); userScanTimeout.set(member._id.user, Date.now());

View file

@ -6,6 +6,11 @@ import ServerConfig from "../struct/ServerConfig";
import FormData from 'form-data'; import FormData from 'form-data';
import axios from 'axios'; import axios from 'axios';
import { Server } from "@janderedev/revolt.js/dist/maps/Servers"; 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 = { let ServerPermissions = {
['View' as string]: 1 << 0, ['View' as string]: 1 << 0,
@ -127,6 +132,110 @@ async function uploadFile(file: any, filename: string): Promise<string> {
return (req.data as any)['id'] as string; 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) * Attempts to escape a message's markdown content (qoutes, headers, **bold** / *italic*, etc)
*/ */
@ -175,6 +284,7 @@ export {
storeInfraction, storeInfraction,
uploadFile, uploadFile,
sanitizeMessageContent, sanitizeMessageContent,
sendLogMessage,
NO_MANAGER_MSG, NO_MANAGER_MSG,
ULID_REGEX, ULID_REGEX,
USER_MENTION_REGEX, USER_MENTION_REGEX,

14
src/struct/LogConfig.ts Normal file
View file

@ -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,
}
}

21
src/struct/LogMessage.ts Normal file
View file

@ -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,
}
}

View file

@ -1,4 +1,5 @@
import AutomodSettings from "./antispam/AutomodSettings"; import AutomodSettings from "./antispam/AutomodSettings";
import LogConfig from "./LogConfig";
class ServerConfig { class ServerConfig {
id: string | undefined; id: string | undefined;
@ -14,16 +15,11 @@ class ServerConfig {
managers: boolean | undefined, managers: boolean | undefined,
} | undefined; } | undefined;
logs: { logs: {
automod: string | undefined, // automod rule triggered messageUpdate?: LogConfig, // Message edited or deleted
messageUpdate: string | undefined, // Message edited or deleted modAction?: LogConfig, // User warned, kicked or banned
modAction: string | undefined, // User warned, kicked or banned userScan?: LogConfig // User profile matched word list
userUpdate: string | undefined, // Username/nickname/avatar changes
} | undefined;
userScan: {
enable?: boolean;
logChannel?: string;
discordWebhook?: string;
} | undefined; } | undefined;
enableUserScan?: boolean;
} }
export default ServerConfig; export default ServerConfig;