555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
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 =
|
|
/<meta class="dynamic" property="og:url" content="[^\s]+">/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 (<https://revolt.chat>) 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: <https://github.com/sussycatgirl/automod/wiki/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<string> {
|
|
// 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(
|
|
'<meta class="dynamic" property="og:url" content="',
|
|
""
|
|
)
|
|
.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<string | undefined> {
|
|
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;
|
|
}
|
|
}
|