import { BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_USER_CONFIG, logger } from ".."; import { client } from "./client"; import { 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 { Message, MessageEmbed, TextChannel } from "discord.js"; import { smartReplace } from "smart-replace"; import { metrics } from "../metrics"; import { SendableEmbed } from "revolt-api"; 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`); if (message.system && bridgeCfg.config?.disable_system_messages) return logger.debug(`Discord: Not bridging system message`); if (bridgeCfg.config?.read_only_discord) return logger.debug(`Discord: Channel is marked as read only`); 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.config?.disallow_opt_out && 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[] = []; const stickerEmbeds: SendableEmbed[] = []; if (message.stickers.size) { for (const sticker of message.stickers) { try { logger.debug( `Downloading sticker ${sticker[0]} ${sticker[1].name}` ); const formData = new FormData(); const file = await axios.get(sticker[1].url, { responseType: "arraybuffer", }); logger.debug( `Downloading sticker ${sticker[0]} finished, uploading to autumn` ); formData.append(sticker[0], file.data, { filename: sticker[1].name || sticker[0], contentType: file.headers["content-type"] ?? // I have no clue what "LOTTIE" is so I'll pretend it doesn't exist sticker[1].format == "PNG" ? "image/png" : "image/vnd.mozilla.apng", }); const res = await axios.post( `${revoltClient.configuration?.features.autumn.url}/attachments`, formData, { headers: formData.getHeaders() } ); logger.debug(`Uploading attachment ${sticker[0]} finished`); stickerEmbeds.push({ colour: "var(--primary-header)", title: sticker[1].name, media: res.data.id, }); } catch (e) { console.error(e); } } } // 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( `${revoltClient.configuration?.features.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: message.system ? await renderSystemMessage(message) : await renderMessageBody(message.content), //attachments: [], //embeds: [], nonce: nonce, replies: reply ? [{ id: reply, mention: !!message.mentions.repliedUser }] : undefined, masquerade: { name: message.system ? "Discord" : bridgeCfg.config?.bridge_nicknames ? message.member?.nickname ?? message.author.username : message.author.username, avatar: message.system ? "https://discord.com/assets/847541504914fd33810e70a0ea73177e.ico" : bridgeCfg.config?.bridge_nicknames ? message.member?.displayAvatarURL({ size: 128 }) : message.author.displayAvatarURL({ size: 128 }), colour: channel.server?.havePermission("ManageRole") && !message.system ? 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: [ ...stickerEmbeds, ...(message.embeds.length ? message.embeds.map((e) => new GenericEmbed(e).toRevolt() ) : []), ], attachments: autumnUrls.length ? autumnUrls : undefined, }; if (!payload.embeds.length) payload.embeds = undefined as any; await axios .post( `${revoltClient.options.baseURL}/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 its 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 and modifies body to make markdown render on Revolt 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/sussycatgirl/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 } ); message = message // "Escape" !!Revite style spoilers!! .replace( /!!.+!!/g, (match) => `!\u200b!${match.substring(2, match.length - 2)}!!` ) // Translate ||Discord spoilers|| to !!Revite spoilers!!, while making sure multiline spoilers continue working .replace(/\|\|.+\|\|/gs, (match) => { return match .substring(2, match.length - 2) .split("\n") .map((line) => `!!${line.replace(/!!/g, "!\u200b!")}!!`) .join("\n"); }); return message; } async function renderSystemMessage( message: Message ): Promise { switch (message.type) { case "GUILD_MEMBER_JOIN": return `:01GJ3854QM6VGMY5D6E9T0DV7X: **${message.author.username.replace( /\*/g, "\\*" )}** joined the server`; case "USER_PREMIUM_GUILD_SUBSCRIPTION": return `:01GJ39CX4H8KJEFF63ZT744S24: **${message.author.username.replace( /\*/g, "\\*" )}** just boosted the server!`; case "USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1": return `:01GJ39CX4H8KJEFF63ZT744S24: **${message.author.username.replace( /\*/g, "\\*" )}** just boosted the server! ${message.guild?.name.replace( /\*/g, "\\*" )} has achieved **Level 1!**`; case "USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2": return `:01GJ39CX4H8KJEFF63ZT744S24: **${message.author.username.replace( /\*/g, "\\*" )}** just boosted the server! ${message.guild?.name.replace( /\*/g, "\\*" )} has achieved **Level 2!**`; case "USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3": return `:01GJ39CX4H8KJEFF63ZT744S24: **${message.author.username.replace( /\*/g, "\\*" )}** just boosted the server! ${message.guild?.name.replace( /\*/g, "\\*" )} has achieved **Level 3!**`; default: return undefined; } }