import { BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_USER_CONFIG, logger } from ".."; import { client } from "./client"; import { AUTUMN_URL, client as revoltClient } from "../revolt/client"; import axios from 'axios'; import { ulid } from "ulid"; import GenericEmbed from "../types/GenericEmbed"; import FormData from 'form-data'; import { discordFetchUser, revoltFetchMessage } from "../util"; import { MessageEmbed, TextChannel } from "discord.js"; import { smartReplace } from "smart-replace"; import { metrics } from "../metrics"; const MAX_BRIDGED_FILE_SIZE = 8_000_000; // 8 MB const RE_MENTION_USER = /<@!*[0-9]+>/g; const RE_MENTION_CHANNEL = /<#[0-9]+>/g; const RE_EMOJI = /<(a?)?:\w+:\d{18}?>/g; const RE_TENOR = /^https:\/\/tenor.com\/view\/[^\s]+$/g; const RE_TENOR_META = //g client.on('messageDelete', async message => { try { logger.debug(`[D] Discord: ${message.id}`); const [ bridgeCfg, bridgedMsg ] = await Promise.all([ BRIDGE_CONFIG.findOne({ discord: message.channelId }), BRIDGED_MESSAGES.findOne({ "discord.messageId": message.id }), ]); if (!bridgedMsg?.revolt) return logger.debug(`Discord: Message has not been bridged; ignoring deletion`); if (bridgedMsg.ignore) return logger.debug(`Discord: Message marked as ignore`); if (!bridgeCfg?.revolt) return logger.debug(`Discord: No Revolt channel associated`); const targetMsg = await revoltFetchMessage(bridgedMsg.revolt.messageId, revoltClient.channels.get(bridgeCfg.revolt)); if (!targetMsg) return logger.debug(`Discord: Could not fetch message from Revolt`); await targetMsg.delete(); metrics.messages.inc({ source: 'discord', type: 'delete' }); } catch(e) { console.error(e); } }); client.on('messageUpdate', async (oldMsg, newMsg) => { if (oldMsg.content && newMsg.content == oldMsg.content) return; // Let's not worry about embeds here for now try { logger.debug(`[E] Discord: ${newMsg.content}`); const [ bridgeCfg, bridgedMsg ] = await Promise.all([ BRIDGE_CONFIG.findOne({ discord: newMsg.channel.id }), BRIDGED_MESSAGES.findOne({ "discord.messageId": newMsg.id }), ]); if (!bridgedMsg) return logger.debug(`Discord: Message has not been bridged; ignoring edit`); if (bridgedMsg.ignore) return logger.debug(`Discord: Message marked as ignore`); if (!bridgeCfg?.revolt) return logger.debug(`Discord: No Revolt channel associated`); if (newMsg.webhookId && newMsg.webhookId == bridgeCfg.discordWebhook?.id) { return logger.debug(`Discord: Message was sent by bridge; ignoring edit`); } const targetMsg = await revoltFetchMessage(bridgedMsg.revolt.messageId, revoltClient.channels.get(bridgeCfg.revolt)); if (!targetMsg) return logger.debug(`Discord: Could not fetch message from Revolt`); await targetMsg.edit({ content: newMsg.content ? await renderMessageBody(newMsg.content) : undefined }); metrics.messages.inc({ source: 'discord', type: 'edit' }); } catch(e) { console.error(e); } }); client.on('messageCreate', async message => { try { logger.debug(`[M] Discord: ${message.content}`); const [ bridgeCfg, bridgedReply, userConfig ] = await Promise.all([ BRIDGE_CONFIG.findOne({ discord: message.channelId }), (message.reference?.messageId ? BRIDGED_MESSAGES.findOne({ "discord.messageId": message.reference.messageId }) : undefined ), BRIDGE_USER_CONFIG.findOne({ id: message.author.id }), ]); if (message.webhookId && bridgeCfg?.discordWebhook?.id == message.webhookId) { return logger.debug(`Discord: Message has already been bridged; ignoring`); } if (!bridgeCfg?.revolt) return logger.debug(`Discord: No Revolt channel associated`); const channel = revoltClient.channels.get(bridgeCfg.revolt); if (!channel) return logger.debug(`Discord: Cannot find associated channel`); if (!(channel.havePermission('SendMessage'))) { return logger.debug(`Discord: Lacking SendMessage permission; refusing to send`); } for (const perm of [ 'SendEmbeds', 'UploadFiles', 'Masquerade' ]) { if (!(channel.havePermission(perm as any))) { // todo: maybe don't spam this on every message? await channel.sendMessage(`Missing permission: I don't have the \`${perm}\` permission ` + `which is required to bridge a message sent by \`${message.author.tag}\` on Discord.`); return logger.debug(`Discord: Lacking ${perm} permission; refusing to send`); } } if (bridgeCfg.disallowIfOptedOut && userConfig?.optOut && message.deletable) { await message.delete(); return; } // Setting a known nonce allows us to ignore bridged // messages while still letting other AutoMod messages pass. const nonce = ulid(); await BRIDGED_MESSAGES.update( { "discord.messageId": message.id }, { $setOnInsert: userConfig?.optOut ? {} : { origin: 'discord', discord: { messageId: message.id, }, }, $set: { 'revolt.nonce': nonce, channels: { discord: message.channelId, revolt: bridgeCfg.revolt, }, ignore: userConfig?.optOut, } }, { upsert: true } ); if (userConfig?.optOut) { const msg = await channel.sendMessage({ content: `$\\color{#565656}\\small{\\textsf{Message content redacted}}$`, masquerade: { name: 'AutoMod Bridge', }, nonce: nonce, }); await BRIDGED_MESSAGES.update( { "discord.messageId": message.id }, { $set: { "revolt.messageId": msg._id }, } ); return; } const autumnUrls: string[] = []; // todo: upload all attachments at once instead of sequentially for (const a of message.attachments) { try { if (a[1].size > MAX_BRIDGED_FILE_SIZE) { logger.debug(`Skipping attachment ${a[0]} ${a[1].name}: Size ${a[1].size} > max (${MAX_BRIDGED_FILE_SIZE})`); continue; } logger.debug(`Downloading attachment ${a[0]} ${a[1].name} (Size ${a[1].size})`); const formData = new FormData(); const file = await axios.get(a[1].url, { responseType: 'arraybuffer' }); logger.debug(`Downloading attachment ${a[0]} finished, uploading to autumn`); formData.append( a[0], file.data, { filename: a[1].name || a[0], contentType: a[1].contentType || undefined } ); const res = await axios.post( `${AUTUMN_URL}/attachments`, formData, { headers: formData.getHeaders() } ); logger.debug(`Uploading attachment ${a[0]} finished`); autumnUrls.push(res.data.id); } catch(e) { console.error(e) } } const sendBridgeMessage = async (reply?: string) => { const payload = { content: await renderMessageBody(message.content), //attachments: [], //embeds: [], nonce: nonce, replies: reply ? [ { id: reply, mention: !!message.mentions.repliedUser } ] : undefined, masquerade: { name: message.author.username, avatar: message.author.displayAvatarURL({ size: 128 }), colour: channel.server?.havePermission('ManageRole') ? message.member?.displayColor // Discord.js returns black or 0 instead of undefined when no role color is set ? message.member?.displayHexColor : 'var(--foreground)' : undefined, }, embeds: message.embeds.length ? message.embeds.map(e => new GenericEmbed(e).toRevolt()) : undefined, attachments: autumnUrls.length ? autumnUrls : undefined, }; await axios.post( `${revoltClient.apiURL}/channels/${channel._id}/messages`, payload, { headers: { 'x-bot-token': process.env['REVOLT_TOKEN']! } } ) .then(async res => { await BRIDGED_MESSAGES.update( { "discord.messageId": message.id }, { $set: { "revolt.messageId": res.data._id }, } ); metrics.messages.inc({ source: 'discord', type: 'create' }); }) .catch(async e => { console.error(`Failed to send message: ${e}`); if (reply) { console.info('Reytring without reply'); await sendBridgeMessage(undefined); } }); } await sendBridgeMessage(bridgedReply?.revolt?.messageId); } catch(e) { console.error(e); } }); client.on('guildCreate', async server => { try { const me = server.me || await server.members.fetch({ user: client.user!.id }); const channels = Array.from(server.channels.cache.filter( c => c.permissionsFor(me).has('SEND_MESSAGES') && c.isText() )); if (!channels.length) return; const channel = (channels.find(c => c[0] == server.systemChannel?.id) || channels[0])?.[1] as TextChannel; const message = ':wave: Hi there!\n\n' + 'Thanks for adding AutoMod to this server! Please note that despite it\'s name, this bot only provides ' + 'bridge integration with the AutoMod bot on Revolt () and does not offer any moderation ' + 'features on Discord. To get started, run the `/bridge help` command!\n\n' + 'Before using AutoMod, please make sure you have read the privacy policy: \n\n' + 'A note to this server\'s administrators: When using the bridge, please make sure to also provide your members ' + 'with a link to AutoMod\'s privacy policy in an accessible place like your rules channel.'; if (channel.permissionsFor(me).has('EMBED_LINKS')) { await channel.send({ embeds: [ new MessageEmbed() .setDescription(message) .setColor('#ff6e6d') ] }); } else { await channel.send(message); } } catch(e) { console.error(e); } }); // Replaces @mentions and #channel mentions async function renderMessageBody(message: string): Promise { // Replace Tenor URLs so they render properly. // We have to download the page first, then extract // the `c.tenor.com` URL from the meta tags. // Could query autumn but that's too much effort and I already wrote this. if (RE_TENOR.test(message)) { try { logger.debug('Replacing tenor URL'); const res = await axios.get( message, { headers: { 'User-Agent': 'AutoMod/1.0; https://github.com/janderedev/automod', } } ); const metaTag = RE_TENOR_META.exec(res.data as string)?.[0]; if (metaTag) { return metaTag .replace('', ''); } } catch(e) { logger.warn(`Replacing tenor URL failed: ${e}`) } } // @mentions message = await smartReplace(message, RE_MENTION_USER, async (match: string) => { const id = match.replace('<@!', '').replace('<@', '').replace('>', ''); const user = await discordFetchUser(id); return `@${user?.username || id}`; }, { cacheMatchResults: true, maxMatches: 10 }); // #channels message = await smartReplace(message, RE_MENTION_CHANNEL, async (match: string) => { const id = match.replace('<#', '').replace('>', ''); const channel = client.channels.cache.get(id); const bridgeCfg = channel ? await BRIDGE_CONFIG.findOne({ discord: channel.id }) : undefined; const revoltChannel = bridgeCfg?.revolt ? revoltClient.channels.get(bridgeCfg.revolt) : undefined; return revoltChannel ? `<#${revoltChannel._id}>` : `#${(channel as TextChannel)?.name || id}`; }, { cacheMatchResults: true, maxMatches: 10 }); // :emojis: message = await smartReplace(message, RE_EMOJI, async (match: string) => { return match .replace(/<(a?)?:/, ':\u200b') // We don't want to accidentally send an unrelated emoji, so we add a zero width space here .replace(/(:\d{18}?>)/, ':'); }, { cacheMatchResults: true }); return message; }