bridge system messages revolt -> discord

This commit is contained in:
Jan 2022-11-17 17:23:20 +01:00
parent e71a02eb9b
commit 1ab0dff37d
Signed by: Lea
GPG key ID: 5D5E18ACB990F57A
2 changed files with 301 additions and 112 deletions

View file

@ -3,8 +3,13 @@ import { AUTUMN_URL, client } from "./client";
import { client as discordClient } from "../discord/client"; import { client as discordClient } from "../discord/client";
import { Channel as DiscordChannel, Message as DiscordMessage, MessageEmbed, MessagePayload, TextChannel, WebhookClient, WebhookMessageOptions } from "discord.js"; import { Channel as DiscordChannel, Message as DiscordMessage, MessageEmbed, MessagePayload, TextChannel, WebhookClient, WebhookMessageOptions } from "discord.js";
import GenericEmbed from "../types/GenericEmbed"; import GenericEmbed from "../types/GenericEmbed";
import { SendableEmbed } from "revolt-api"; import { SendableEmbed, SystemMessage } from "revolt-api";
import { clipText, discordFetchMessage, revoltFetchMessage, revoltFetchUser } from "../util"; import {
clipText,
discordFetchMessage,
revoltFetchMessage,
revoltFetchUser,
} from "../util";
import { smartReplace } from "smart-replace"; import { smartReplace } from "smart-replace";
import { metrics } from "../metrics"; import { metrics } from "../metrics";
import { fetchEmojiList } from "../discord/bridgeEmojis"; import { fetchEmojiList } from "../discord/bridgeEmojis";
@ -16,115 +21,190 @@ const RE_EMOJI = /:[^\s]+/g;
const KNOWN_EMOJI_NAMES: string[] = []; const KNOWN_EMOJI_NAMES: string[] = [];
fetchEmojiList() fetchEmojiList()
.then(emojis => Object.keys(emojis).forEach(name => KNOWN_EMOJI_NAMES.push(name))) .then((emojis) =>
.catch(e => console.error(e)); Object.keys(emojis).forEach((name) => KNOWN_EMOJI_NAMES.push(name))
)
.catch((e) => console.error(e));
client.on('message/delete', async id => { client.on("message/delete", async (id) => {
try { try {
logger.debug(`[D] Revolt: ${id}`); logger.debug(`[D] Revolt: ${id}`);
const bridgedMsg = await BRIDGED_MESSAGES.findOne({ "revolt.messageId": id }); const bridgedMsg = await BRIDGED_MESSAGES.findOne({
if (!bridgedMsg?.discord.messageId) return logger.debug(`Revolt: Message has not been bridged; ignoring delete`); "revolt.messageId": id,
if (!bridgedMsg.channels?.discord) return logger.debug(`Revolt: Channel for deleted message is unknown`); });
if (!bridgedMsg?.discord.messageId)
return logger.debug(
`Revolt: Message has not been bridged; ignoring delete`
);
if (!bridgedMsg.channels?.discord)
return logger.debug(
`Revolt: Channel for deleted message is unknown`
);
const bridgeCfg = await BRIDGE_CONFIG.findOne({ revolt: bridgedMsg.channels.revolt }); const bridgeCfg = await BRIDGE_CONFIG.findOne({
if (!bridgeCfg?.discordWebhook) return logger.debug(`Revolt: No Discord webhook stored`); revolt: bridgedMsg.channels.revolt,
if (!bridgeCfg.discord || bridgeCfg.discord != bridgedMsg.channels.discord) { });
return logger.debug(`Revolt: Discord channel is no longer linked; ignoring delete`); if (!bridgeCfg?.discordWebhook)
return logger.debug(`Revolt: No Discord webhook stored`);
if (
!bridgeCfg.discord ||
bridgeCfg.discord != bridgedMsg.channels.discord
) {
return logger.debug(
`Revolt: Discord channel is no longer linked; ignoring delete`
);
} }
const targetMsg = await discordFetchMessage(bridgedMsg.discord.messageId, bridgeCfg.discord); const targetMsg = await discordFetchMessage(
if (!targetMsg) return logger.debug(`Revolt: Could not fetch message from Discord`); bridgedMsg.discord.messageId,
bridgeCfg.discord
);
if (!targetMsg)
return logger.debug(`Revolt: Could not fetch message from Discord`);
if (targetMsg.webhookId && targetMsg.webhookId == bridgeCfg.discordWebhook.id) { if (
const client = new WebhookClient({ id: bridgeCfg.discordWebhook.id, token: bridgeCfg.discordWebhook.token }); targetMsg.webhookId &&
targetMsg.webhookId == bridgeCfg.discordWebhook.id
) {
const client = new WebhookClient({
id: bridgeCfg.discordWebhook.id,
token: bridgeCfg.discordWebhook.token,
});
await client.deleteMessage(bridgedMsg.discord.messageId); await client.deleteMessage(bridgedMsg.discord.messageId);
client.destroy(); client.destroy();
metrics.messages.inc({ source: 'revolt', type: 'delete' }); metrics.messages.inc({ source: "revolt", type: "delete" });
} else if (targetMsg.deletable) { } else if (targetMsg.deletable) {
targetMsg.delete(); targetMsg.delete();
metrics.messages.inc({ source: 'revolt', type: 'delete' }); metrics.messages.inc({ source: "revolt", type: "delete" });
} else logger.debug(`Revolt: Unable to delete Discord message`); } else logger.debug(`Revolt: Unable to delete Discord message`);
} catch(e) { } catch (e) {
console.error(e); console.error(e);
} }
}); });
client.on('message/update', async message => { client.on("message/update", async (message) => {
if (!message.content || typeof message.content != 'string') return; if (!message.content || typeof message.content != "string") return;
if (message.author_id == client.user?._id) return; if (message.author_id == client.user?._id) return;
try { try {
logger.debug(`[E] Revolt: ${message.content}`); logger.debug(`[E] Revolt: ${message.content}`);
const [ bridgeCfg, bridgedMsg ] = await Promise.all([ const [bridgeCfg, bridgedMsg] = await Promise.all([
BRIDGE_CONFIG.findOne({ revolt: message.channel_id }), BRIDGE_CONFIG.findOne({ revolt: message.channel_id }),
BRIDGED_MESSAGES.findOne({ "revolt.nonce": message.nonce }), BRIDGED_MESSAGES.findOne({ "revolt.nonce": message.nonce }),
]); ]);
if (!bridgedMsg) return logger.debug(`Revolt: Message has not been bridged; ignoring edit`); if (!bridgedMsg)
if (!bridgeCfg?.discord) return logger.debug(`Revolt: No Discord channel associated`); return logger.debug(
if (!bridgeCfg.discordWebhook) return logger.debug(`Revolt: No Discord webhook stored`); `Revolt: Message has not been bridged; ignoring edit`
);
if (!bridgeCfg?.discord)
return logger.debug(`Revolt: No Discord channel associated`);
if (!bridgeCfg.discordWebhook)
return logger.debug(`Revolt: No Discord webhook stored`);
const targetMsg = await discordFetchMessage(bridgedMsg.discord.messageId, bridgeCfg.discord); const targetMsg = await discordFetchMessage(
if (!targetMsg) return logger.debug(`Revolt: Could not fetch message from Discord`); bridgedMsg.discord.messageId,
bridgeCfg.discord
);
if (!targetMsg)
return logger.debug(`Revolt: Could not fetch message from Discord`);
const client = new WebhookClient({ id: bridgeCfg.discordWebhook.id, token: bridgeCfg.discordWebhook.token }); const client = new WebhookClient({
await client.editMessage(targetMsg, { content: await renderMessageBody(message.content), allowedMentions: { parse: [ ] } }); id: bridgeCfg.discordWebhook.id,
token: bridgeCfg.discordWebhook.token,
});
await client.editMessage(targetMsg, {
content: await renderMessageBody(message.content),
allowedMentions: { parse: [] },
});
client.destroy(); client.destroy();
metrics.messages.inc({ source: 'revolt', type: 'edit' }); metrics.messages.inc({ source: "revolt", type: "edit" });
} catch(e) { console.error(e) } } catch (e) {
console.error(e);
}
}); });
client.on('message', async message => { client.on("message", async (message) => {
try { try {
if (message.content && typeof message.content != 'string') return; logger.debug(`[M] Revolt: ${message._id} ${message.content}`);
logger.debug(`[M] Revolt: ${message.content}`);
const [ bridgeCfg, bridgedMsg, ...repliedMessages ] = await Promise.all([ const [bridgeCfg, bridgedMsg, ...repliedMessages] = await Promise.all([
BRIDGE_CONFIG.findOne({ revolt: message.channel_id }), BRIDGE_CONFIG.findOne({ revolt: message.channel_id }),
BRIDGED_MESSAGES.findOne({ "revolt.nonce": message.nonce }), BRIDGED_MESSAGES.findOne(
...(message.reply_ids?.map(id => BRIDGED_MESSAGES.findOne({ "revolt.messageId": id })) ?? []) message.nonce
? { "revolt.nonce": message.nonce }
: { "revolt.messageId": message._id }
),
...(message.reply_ids?.map((id) =>
BRIDGED_MESSAGES.findOne({ "revolt.messageId": id })
) ?? []),
]); ]);
if (bridgedMsg) return logger.debug(`Revolt: Message has already been bridged; ignoring`); if (bridgedMsg)
if (!bridgeCfg?.discord) return logger.debug(`Revolt: No Discord channel associated`); return logger.debug(
`Revolt: Message has already been bridged; ignoring`
);
if (message.system && bridgeCfg?.config?.disable_system_messages)
return logger.debug(
`Revolt: System message bridging disabled; ignoring`
);
if (!bridgeCfg?.discord)
return logger.debug(`Revolt: No Discord channel associated`);
if (!bridgeCfg.discordWebhook) { if (!bridgeCfg.discordWebhook) {
logger.debug(`Revolt: No Discord webhook stored; Creating new Webhook`); logger.debug(
`Revolt: No Discord webhook stored; Creating new Webhook`
);
try { try {
const channel = await discordClient.channels.fetch(bridgeCfg.discord) as TextChannel; const channel = (await discordClient.channels.fetch(
if (!channel || !channel.isText()) throw 'Error: Unable to fetch channel'; bridgeCfg.discord
const ownPerms = (channel as TextChannel).permissionsFor(discordClient.user!); )) as TextChannel;
if (!ownPerms?.has('MANAGE_WEBHOOKS')) throw 'Error: Bot user does not have MANAGE_WEBHOOKS permission'; if (!channel || !channel.isText())
throw "Error: Unable to fetch channel";
const ownPerms = (channel as TextChannel).permissionsFor(
discordClient.user!
);
if (!ownPerms?.has("MANAGE_WEBHOOKS"))
throw "Error: Bot user does not have MANAGE_WEBHOOKS permission";
const hook = await (channel as TextChannel).createWebhook('AutoMod Bridge', { avatar: discordClient.user?.avatarURL() }); const hook = await (channel as TextChannel).createWebhook(
"AutoMod Bridge",
{ avatar: discordClient.user?.avatarURL() }
);
bridgeCfg.discordWebhook = { bridgeCfg.discordWebhook = {
id: hook.id, id: hook.id,
token: hook.token || '', token: hook.token || "",
}; };
await BRIDGE_CONFIG.update( await BRIDGE_CONFIG.update(
{ revolt: message.channel_id }, { revolt: message.channel_id },
{ {
$set: { $set: {
discordWebhook: bridgeCfg.discordWebhook, discordWebhook: bridgeCfg.discordWebhook,
} },
} }
); );
} catch(e) { } catch (e) {
logger.warn(`Unable to create new webhook for channel ${bridgeCfg.discord}; Deleting link\n${e}`); logger.warn(
`Unable to create new webhook for channel ${bridgeCfg.discord}; Deleting link\n${e}`
);
await BRIDGE_CONFIG.remove({ revolt: message.channel_id }); await BRIDGE_CONFIG.remove({ revolt: message.channel_id });
await message.channel?.sendMessage(':warning: I was unable to create a webhook in the bridged Discord channel. ' await message.channel
+ `The bridge has been removed; if you wish to rebridge, use the \`/bridge\` command.`).catch(() => {}); ?.sendMessage(
":warning: I was unable to create a webhook in the bridged Discord channel. " +
`The bridge has been removed; if you wish to rebridge, use the \`/bridge\` command.`
)
.catch(() => {});
return; return;
} }
} }
await BRIDGED_MESSAGES.update( await BRIDGED_MESSAGES.update(
{ 'revolt.messageId': message._id }, { "revolt.messageId": message._id },
{ {
$set: { $set: {
revolt: { revolt: {
@ -134,33 +214,41 @@ client.on('message', async message => {
channels: { channels: {
revolt: message.channel_id, revolt: message.channel_id,
discord: bridgeCfg.discord, discord: bridgeCfg.discord,
} },
}, },
$setOnInsert: { $setOnInsert: {
discord: {}, discord: {},
origin: 'revolt', origin: "revolt",
} },
}, },
{ upsert: true } { upsert: true }
); );
const channel = await discordClient.channels.fetch(bridgeCfg.discord) as TextChannel; const channel = (await discordClient.channels.fetch(
bridgeCfg.discord
)) as TextChannel;
const client = new WebhookClient({ const client = new WebhookClient({
id: bridgeCfg.discordWebhook!.id, id: bridgeCfg.discordWebhook!.id,
token: bridgeCfg.discordWebhook!.token, token: bridgeCfg.discordWebhook!.token,
}); });
const payload: MessagePayload | WebhookMessageOptions = { const payload: MessagePayload | WebhookMessageOptions = {
content: message.content content:
? await renderMessageBody(message.content) typeof message.content == "string"
: undefined, ? await renderMessageBody(message.content)
username: : message.system
(bridgeCfg.config?.bridge_nicknames ? await renderSystemMessage(message.system)
? message.masquerade?.name ?? : undefined,
message.member?.nickname ?? username: message.system
message.author?.username ? "Revolt"
: message.author?.username) ?? "Unknown user", : (bridgeCfg.config?.bridge_nicknames
avatarURL: bridgeCfg.config?.bridge_nicknames ? message.masquerade?.name ??
message.member?.nickname ??
message.author?.username
: message.author?.username) ?? "Unknown user",
avatarURL: message.system
? "https://app.revolt.chat/assets/logo_round.png"
: bridgeCfg.config?.bridge_nicknames
? message.masquerade?.avatar ?? ? message.masquerade?.avatar ??
message.member?.generateAvatarURL({ max_side: 128 }) ?? message.member?.generateAvatarURL({ max_side: 128 }) ??
message.author?.generateAvatarURL({ max_side: 128 }) message.author?.generateAvatarURL({ max_side: 128 })
@ -176,60 +264,100 @@ client.on('message', async message => {
}; };
if (repliedMessages.length) { if (repliedMessages.length) {
const embed = new MessageEmbed().setColor('#2f3136'); const embed = new MessageEmbed().setColor("#2f3136");
if (repliedMessages.length == 1) { if (repliedMessages.length == 1) {
const replyMsg = repliedMessages[0]?.origin == 'discord' const replyMsg =
? await discordFetchMessage(repliedMessages[0]?.discord.messageId, bridgeCfg.discord) repliedMessages[0]?.origin == "discord"
: undefined; ? await discordFetchMessage(
repliedMessages[0]?.discord.messageId,
bridgeCfg.discord
)
: undefined;
const author = replyMsg?.author; const author = replyMsg?.author;
if (replyMsg) { if (replyMsg) {
embed.setAuthor({ embed.setAuthor({
name: `@${author?.username ?? 'Unknown'}`, // todo: check if @pinging was enabled for reply name: `@${author?.username ?? "Unknown"}`, // todo: check if @pinging was enabled for reply
iconURL: author?.displayAvatarURL({ size: 64, dynamic: true }), iconURL: author?.displayAvatarURL({
size: 64,
dynamic: true,
}),
url: replyMsg?.url, url: replyMsg?.url,
}); });
if (replyMsg?.content) embed.setDescription('>>> ' + clipText(replyMsg.content, 200)); if (replyMsg?.content)
embed.setDescription(
">>> " + clipText(replyMsg.content, 200)
);
} else { } else {
const msg = await revoltFetchMessage(message.reply_ids?.[0], message.channel); const msg = await revoltFetchMessage(
const brMsg = repliedMessages.find(m => m?.revolt.messageId == msg?._id); message.reply_ids?.[0],
message.channel
);
const brMsg = repliedMessages.find(
(m) => m?.revolt.messageId == msg?._id
);
embed.setAuthor({ embed.setAuthor({
name: `@${msg?.author?.username ?? 'Unknown'}`, name: `@${msg?.author?.username ?? "Unknown"}`,
iconURL: msg?.author?.generateAvatarURL({ size: 64 }), iconURL: msg?.author?.generateAvatarURL({ size: 64 }),
url: brMsg ? `https://discord.com/channels/${channel.guildId}/${brMsg.channels?.discord || channel.id}/${brMsg.discord.messageId}` : undefined, url: brMsg
? `https://discord.com/channels/${
channel.guildId
}/${brMsg.channels?.discord || channel.id}/${
brMsg.discord.messageId
}`
: undefined,
}); });
if (msg?.content) embed.setDescription('>>> ' + clipText(msg.content, 200)); if (msg?.content)
embed.setDescription(
">>> " + clipText(msg.content, 200)
);
} }
} else { } else {
const replyMsgs = await Promise.all( const replyMsgs = await Promise.all(
repliedMessages.map(m => m?.origin == 'discord' repliedMessages.map((m) =>
? discordFetchMessage(m?.discord.messageId, bridgeCfg.discord) m?.origin == "discord"
: revoltFetchMessage(m?.revolt.messageId, message.channel)) ? discordFetchMessage(
m?.discord.messageId,
bridgeCfg.discord
)
: revoltFetchMessage(
m?.revolt.messageId,
message.channel
)
)
); );
embed.setAuthor({ name: repliedMessages.length + ' replies' }); embed.setAuthor({ name: repliedMessages.length + " replies" });
for (const msg of replyMsgs) { for (const msg of replyMsgs) {
let msgUrl = ''; let msgUrl = "";
if (msg instanceof DiscordMessage) { if (msg instanceof DiscordMessage) {
msgUrl = msg.url; msgUrl = msg.url;
} else { } else {
const brMsg = repliedMessages.find(m => m?.revolt.messageId == msg?._id); const brMsg = repliedMessages.find(
if (brMsg) msgUrl = `https://discord.com/channels/${channel.guildId}/${brMsg.channels?.discord || channel.id}/${brMsg.discord.messageId}`; (m) => m?.revolt.messageId == msg?._id
);
if (brMsg)
msgUrl = `https://discord.com/channels/${
channel.guildId
}/${brMsg.channels?.discord || channel.id}/${
brMsg.discord.messageId
}`;
} }
embed.addField( embed.addField(
`@${msg?.author?.username ?? 'Unknown'}`, `@${msg?.author?.username ?? "Unknown"}`,
(msg ? `[Link](${msgUrl})\n` : '') + (msg ? `[Link](${msgUrl})\n` : "") +
'>>> ' + clipText(msg?.content ?? '\u200b', 100), ">>> " +
true, clipText(msg?.content ?? "\u200b", 100),
true
); );
} }
} }
if (payload.embeds) payload.embeds.unshift(embed); if (payload.embeds) payload.embeds.unshift(embed);
else payload.embeds = [ embed ]; else payload.embeds = [embed];
} }
if (message.attachments?.length) { if (message.attachments?.length) {
@ -243,30 +371,42 @@ client.on('message', async message => {
} }
} }
client.send(payload) client
.then(async res => { .send(payload)
await BRIDGED_MESSAGES.update({ .then(async (res) => {
"revolt.messageId": message._id await BRIDGED_MESSAGES.update(
}, { {
$set: { "revolt.messageId": message._id,
"discord.messageId": res.id },
{
$set: {
"discord.messageId": res.id,
},
}
);
metrics.messages.inc({ source: "revolt", type: "create" });
})
.catch(async (e) => {
console.error(
"Failed to execute webhook:",
e?.response?.data ?? e
);
if (`${e}` == "DiscordAPIError: Unknown Webhook") {
try {
logger.warn(
"Revolt: Got Unknown Webhook error, deleting webhook config"
);
await BRIDGE_CONFIG.update(
{ revolt: message.channel_id },
{ $set: { discordWebhook: undefined } }
);
} catch (e) {
console.error(e);
}
} }
}); });
} catch (e) {
metrics.messages.inc({ source: 'revolt', type: 'create' });
})
.catch(async e => {
console.error('Failed to execute webhook:', e?.response?.data ?? e);
if (`${e}` == 'DiscordAPIError: Unknown Webhook') {
try {
logger.warn('Revolt: Got Unknown Webhook error, deleting webhook config');
await BRIDGE_CONFIG.update({ revolt: message.channel_id }, { $set: { discordWebhook: undefined } });
} catch(e) {
console.error(e);
}
}
});
} catch(e) {
console.error(e); console.error(e);
} }
}); });
@ -352,3 +492,47 @@ async function renderMessageBody(message: string): Promise<string> {
return message; return message;
} }
async function renderSystemMessage(message: SystemMessage): Promise<string> {
const getUsername = async (id: string) =>
`**@${(await revoltFetchUser(id))?.username.replace(/\*/g, "\\*")}**`;
switch (message.type) {
case "user_joined":
case "user_added":
return `<:joined:1042831832888127509> ${await getUsername(
message.id
)} joined`;
case "user_left":
case "user_remove":
return `<:left:1042831834259652628> ${await getUsername(
message.id
)} left`;
case "user_kicked":
return `<:kicked:1042831835421483050> ${await getUsername(
message.id
)} was kicked`;
case "user_banned":
return `<:banned:1042831836675588146> ${await getUsername(
message.id
)} was banned`;
case "channel_renamed":
return `<:channel_renamed:1042831837912891392> ${await getUsername(
message.by
)} renamed the channel to **${message.name}**`;
case "channel_icon_changed":
return `<:channel_icon:1042831840538542222> ${await getUsername(
message.by
)} changed the channel icon`;
case "channel_description_changed":
return `<:channel_description:1042831839217328228> ${await getUsername(
message.by
)} changed the channel description`;
case "text":
return message.content;
default:
return Object.entries(message)
.map((e) => `${e[0]}: ${e[1]}`)
.join(", ");
}
}

View file

@ -10,4 +10,9 @@ export const CONFIG_KEYS = {
"If enabled, all messages by users who opted out of their messages being bridged (`/bridge opt_out`) will be deleted. " + "If enabled, all messages by users who opted out of their messages being bridged (`/bridge opt_out`) will be deleted. " +
"You should enable this if your Revolt server is bridged to a mostly unmoderated Discord server.", "You should enable this if your Revolt server is bridged to a mostly unmoderated Discord server.",
}, },
disable_system_messages: {
friendlyName: "Don't bridge system messages",
description:
"If enabled, system messages (e.g. join/leave events) won't be bridged.",
},
}; };