Remove bridge functionality

This commit is contained in:
Declan Chidlow 2024-07-12 16:18:14 +08:00
parent 9f1634061a
commit 84e09da269
23 changed files with 0 additions and 4165 deletions

7
bridge/.gitignore vendored
View file

@ -1,7 +0,0 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
yarnPath: .yarn/releases/yarn-3.2.1.cjs
nodeLinker: node-modules

View file

@ -1,31 +0,0 @@
FROM node:18 as build
WORKDIR /build/app
COPY bridge/package.json bridge/yarn.lock bridge/.yarnrc.yml ./
COPY bridge/.yarn ./.yarn
COPY lib ../lib
COPY revolt.js ../revolt.js
RUN yarn --cwd ../lib --immutable
RUN yarn --cwd ../lib build
WORKDIR /build/revolt.js
RUN corepack enable
RUN corepack prepare pnpm@7.14.2 --activate
RUN pnpm install
RUN pnpm run build
WORKDIR /build/app
RUN yarn install --immutable
COPY ./bridge .
RUN yarn build
FROM node:18 as prod
WORKDIR /app/bridge
COPY --from=build /build/app/package.json /build/app/yarn.lock /build/app/.yarnrc.yml ./
COPY --from=build /build/app/.yarn ./.yarn
COPY --from=build /build/app/dist ./dist
COPY --from=build /build/lib ../lib
COPY --from=build /build/revolt.js ../revolt.js
RUN yarn install --immutable
CMD ["yarn", "start"]

View file

@ -1,40 +0,0 @@
{
"name": "bridge",
"version": "1.0.0",
"description": "",
"type": "module",
"exports": "./index.js",
"scripts": {
"build": "rm -rf dist && tsc",
"start": "node --experimental-specifier-resolution=node dist/index",
"dev": "yarn build && yarn start"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@discordjs/rest": "^0.4.1",
"automod": "^0.1.0",
"axios": "^0.26.1",
"discord-api-types": "^0.31.2",
"discord.js": "^13.6.0",
"dotenv": "^16.0.0",
"form-data": "^4.0.0",
"json5": "^2.2.1",
"log75": "^2.2.0",
"monk": "^7.3.4",
"prom-client": "^14.0.1",
"revolt-api": "0.6.4",
"revolt.js": "^7.0.0",
"smart-replace": "^1.0.2",
"ulid": "^2.3.0"
},
"packageManager": "yarn@3.2.1",
"devDependencies": {
"typescript": "^4.7.4"
},
"resolutions": {
"automod": "portal:../lib",
"revolt.js": "portal:../revolt.js"
}
}

View file

@ -1,50 +0,0 @@
/**
* When executed, this file scans the entire `messages` database collection
* and deletes entries belonging to channels which do no longer have a bridge
* configuration associated. Reads mongo URI from $DB_STRING env var.
*/
import Mongo from 'mongodb';
if (!process.env.DB_STRING) {
console.error('$DB_STRING not provided.');
process.exit(1);
}
const mongo = new Mongo.MongoClient(process.env.DB_STRING);
(async () => {
await mongo.connect();
const client = mongo.db();
const messages = client.collection('bridged_messages');
const res = messages.aggregate([{
$lookup: {
from: 'bridge_config',
localField: 'channels.discord',
foreignField: 'discord',
as: 'bridgeConfig',
}
}]);
let buf: string[] = [];
const execute = async () => {
const ids = [ ...buf ];
buf.length = 0;
if (ids.length) {
console.log('Deleting ' + ids.length + ' entries');
await messages.deleteMany({ _id: { $in: ids } });
}
}
res.on('data', data => {
if (!data.bridgeConfig?.length) buf.push(data._id);
if (buf.length >= 500) execute();
});
res.on('end', () => {
execute().then(() => process.exit(0));
});
})();

View file

@ -1,9 +0,0 @@
import Monk from 'monk';
import { logger } from '.';
function getDb() {
const db = Monk(process.env['DB_STRING']!);
return db;
}
export { getDb }

View file

@ -1,147 +0,0 @@
import axios from "axios";
import { GuildEmoji } from "discord.js";
import JSON5 from 'json5';
import { BRIDGED_EMOJIS, logger } from "..";
import { client } from "./client";
const EMOJI_DICT_URL = 'https://raw.githubusercontent.com/revoltchat/revite/master/src/assets/emojis.ts';
const EMOJI_URL_BASE = 'https://dl.insrt.uk/projects/revolt/emotes/';
const EMOJI_SERVERS = process.env.EMOJI_SERVERS?.split(',') || [];
async function fetchEmojiList(): Promise<Record<string, string>> {
const file: string = (await axios.get(EMOJI_DICT_URL)).data;
const start = file.indexOf('...{') + 3;
const end = file.indexOf('},') + 1;
return JSON5.parse(
file.substring(start, end)
.replace(/^\s*[0-9]+:/gm, (match) => `"${match.replace(/(^\s+)|(:$)/g, '')}":`)
.trim()
);
}
const emojiUpdate = async () => {
try {
if (!EMOJI_SERVERS.length) return logger.info('$EMOJI_SERVERS not set, not bridging emojis.');
if (!client.readyAt) await new Promise(r => client.once('ready', r));
logger.info('Updating bridged emojis. Due to Discord rate limits, this can take a few hours to complete.');
const emojis = await fetchEmojiList();
logger.info(`Downloaded emoji list: ${Object.keys(emojis).length} emojis.`);
const servers = await Promise.all(EMOJI_SERVERS.map(id => client.guilds.fetch(id)));
await Promise.all(servers.map(server => server.emojis.fetch())); // Make sure all emojis are cached
const findFreeServer = (animated: boolean) => servers.find(
server => server.emojis.cache
.filter(e => e.animated == animated)
.size < 50
);
// Remove unknown emojis from servers
for (const server of servers) {
for (const emoji of server.emojis.cache) {
const dbEmoji = await BRIDGED_EMOJIS.findOne({
emojiid: emoji[1].id,
});
if (!dbEmoji) {
try {
logger.info('Found unknown emoji; deleting.');
await emoji[1].delete('Unknown emoji');
} catch(e) {
logger.warn('Failed to delete emoji: ' + e);
}
}
}
}
for (const emoji of Object.entries(emojis)) {
const dbEmoji = await BRIDGED_EMOJIS.findOne({
$or: [
{ name: emoji[0] },
{ originalFileUrl: emoji[1] },
],
});
if (!dbEmoji) {
// Upload to Discord
logger.debug('Uploading emoji: ' + emoji[1]);
const fileurl = EMOJI_URL_BASE + emoji[1].replace('custom:', '');
const server = findFreeServer(emoji[1].endsWith('.gif'));
if (!server) {
logger.warn('Could not find a server with free emoji slots for ' + emoji[1]);
continue;
}
let e: GuildEmoji;
try {
e = await server.emojis.create(fileurl, emoji[0], { reason: 'Bridged Emoji' });
} catch(e) {
logger.warn(emoji[0] + ': Failed to upload emoji: ' + e);
continue;
}
await BRIDGED_EMOJIS.insert({
animated: e.animated || false,
emojiid: e.id,
name: emoji[0],
originalFileUrl: fileurl,
server: e.guild.id,
});
}
else {
// Double check if emoji exists
let exists = false;
for (const server of servers) {
if (server.emojis.cache.find(e => e.id == dbEmoji.emojiid)) {
exists = true;
break;
}
}
if (!exists) {
logger.info(`Emoji ${emoji[0]} does not exist; reuploading.`);
await BRIDGED_EMOJIS.remove({ emojiid: dbEmoji.emojiid });
const fileurl = EMOJI_URL_BASE + emoji[1].replace('custom:', '');
const server = findFreeServer(emoji[1].endsWith('.gif'));
if (!server) {
logger.warn('Could not find a server with free emoji slots for ' + emoji[1]);
continue;
}
let e: GuildEmoji;
try {
e = await server.emojis.create(fileurl, emoji[0], { reason: 'Bridged Emoji' });
} catch(e) {
logger.warn(emoji[0] + ': Failed to upload emoji: ' + e);
continue;
}
await BRIDGED_EMOJIS.insert({
animated: e.animated || false,
emojiid: e.id,
name: emoji[0],
originalFileUrl: fileurl,
server: e.guild.id,
});
}
}
};
logger.done('Emoji update finished.');
} catch(e) {
logger.error('Updating bridged emojis failed');
console.error(e);
}
};
emojiUpdate();
setInterval(emojiUpdate, 1000 * 60 * 60 * 6); // Every 6h
export { fetchEmojiList }

View file

@ -1,28 +0,0 @@
import Discord from "discord.js";
import { logger } from "..";
const client = new Discord.Client({
intents: [
'GUILDS',
'GUILD_EMOJIS_AND_STICKERS',
'GUILD_MESSAGES',
'GUILD_WEBHOOKS',
],
partials: [
'MESSAGE', // Allows us to receive message updates for uncached messages
],
allowedMentions: { parse: [ ] }, // how the hell does this work
});
const login = () => new Promise((resolve: (value: Discord.Client) => void) => {
client.login(process.env['DISCORD_TOKEN']!);
client.once('ready', () => {
logger.info(`Discord: ${client.user?.username} ready - ${client.guilds.cache.size} servers`);
});
});
import('./events');
import('./commands');
import('./bridgeEmojis');
export { client, login }

View file

@ -1,515 +0,0 @@
// fuck slash commands
import { client } from "./client";
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v9';
import { BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_REQUESTS, BRIDGE_USER_CONFIG, logger } from "..";
import { MessageEmbed, TextChannel } from "discord.js";
import { revoltFetchMessage, revoltFetchUser } from "../util";
import { client as revoltClient } from "../revolt/client";
import { CONFIG_KEYS } from "automod/dist/misc/bridge_config_keys";
const PRIVACY_POLICY_URL =
"https://github.com/sussycatgirl/automod/wiki/Privacy-Policy";
const COMMANDS: any[] = [
{
name: "bridge",
description: "Confirm or delete Revolt bridges",
type: 1, // Slash command
options: [
{
name: "confirm",
description: "Confirm a bridge initiated from Revolt",
type: 1, // Subcommand
options: [
{
name: "id",
description: "The bridge request ID",
required: true,
type: 3,
},
],
},
{
name: "unlink",
description: "Unbridge the current channel",
type: 1,
},
{
name: "help",
description: "Usage instructions",
type: 1,
},
{
name: "opt_out",
description: "Opt out of having your messages bridged",
type: 1,
options: [
{
name: "opt_out",
description:
"Whether you wish to opt out of having your messages bridged",
optional: true,
type: 5, // Boolean
},
],
},
{
name: "status",
description:
"Find out whether this channel is bridged to Revolt",
type: 1,
},
{
name: "config",
description: "Bridge configuration options for this channel",
type: 1,
options: [
{
name: "key",
description: "The configuration option to change",
type: 3, // String
required: true,
choices: Object.entries(CONFIG_KEYS).map((conf) => ({
name: conf[1].friendlyName,
value: conf[0],
})),
},
{
name: "value",
description:
"The new value for the option. Leave empty to get current state",
type: 5, // Boolean
required: false,
},
],
},
],
},
{
name: "Message Info",
description: "",
type: 3, // Message context menu
},
];
const rest = new REST({ version: "9" }).setToken(process.env["DISCORD_TOKEN"]!);
client.once("ready", async () => {
try {
logger.info(`Refreshing application commands.`);
if (process.env.NODE_ENV != "production" && process.env.DEV_GUILD) {
await rest.put(
Routes.applicationGuildCommands(
client.user!.id,
process.env.DEV_GUILD
),
{ body: COMMANDS }
);
logger.done(
`Application commands for ${process.env.DEV_GUILD} have been updated.`
);
} else {
await rest.put(Routes.applicationCommands(client.user!.id), {
body: COMMANDS,
});
logger.done(`Global application commands have been updated.`);
}
} catch (e) {
console.error(e);
}
});
client.on("interactionCreate", async (interaction) => {
try {
if (interaction.isCommand()) {
logger.debug(`Command received: /${interaction.commandName}`);
// The revolutionary Jan command handler
switch (interaction.commandName) {
case "bridge":
if (
!interaction.memberPermissions?.has("MANAGE_GUILD") &&
["confirm", "unlink"].includes(
interaction.options.getSubcommand(true)
)
) {
return await interaction.reply({
content: `\`MANAGE_GUILD\` permission is required for this.`,
ephemeral: true,
});
}
const ownPerms = (
interaction.channel as TextChannel
).permissionsFor(client.user!)!;
switch (interaction.options.getSubcommand(true)) {
case "confirm": {
if (!ownPerms.has("MANAGE_WEBHOOKS"))
return interaction.reply(
"Sorry, I lack permission to manage webhooks in this channel."
);
const id = interaction.options.getString(
"id",
true
);
const request = await BRIDGE_REQUESTS.findOne({
id: id,
});
if (!request || request.expires < Date.now())
return await interaction.reply("Unknown ID.");
const bridgedCount = await BRIDGE_CONFIG.count({
discord: interaction.channelId,
});
if (bridgedCount > 0)
return await interaction.reply(
"This channel is already bridged."
);
const webhook = await(
interaction.channel as TextChannel
).createWebhook("AutoMod Bridge", {
avatar: client.user?.avatarURL(),
});
await BRIDGE_REQUESTS.remove({ id: id });
await BRIDGE_CONFIG.insert({
discord: interaction.channelId,
revolt: request.revolt,
discordWebhook: {
id: webhook.id,
token: webhook.token || "",
},
});
return await interaction.reply(
`✅ Channel bridged!`
);
}
case "unlink": {
const res = await BRIDGE_CONFIG.findOneAndDelete({
discord: interaction.channelId,
});
if (res?._id) {
await interaction.reply("Channel unbridged.");
if (
ownPerms.has("MANAGE_WEBHOOKS") &&
res.discordWebhook
) {
try {
const hooks = await(
interaction.channel as TextChannel
).fetchWebhooks();
if (hooks.get(res?.discordWebhook?.id))
await hooks
.get(res?.discordWebhook?.id)
?.delete(
"Channel has been unbridged"
);
} catch (_) {}
}
} else
await interaction.reply(
"This channel is not bridged."
);
break;
}
case "config": {
const configKey = interaction.options.getString(
"key",
true
) as keyof typeof CONFIG_KEYS;
const newValue = interaction.options.getBoolean(
"value",
false
);
if (newValue == null) {
const currentState =
(
await BRIDGE_CONFIG.findOne({
discord: interaction.channelId,
})
)?.config?.[configKey] ?? false;
return await interaction.reply({
ephemeral: true,
embeds: [
new MessageEmbed()
.setAuthor({
name: "Bridge Configuration",
})
.setTitle(configKey)
.setDescription(
`${CONFIG_KEYS[configKey].description}\n\nCurrent state: \`${currentState}\``
)
.toJSON(),
],
});
}
await BRIDGE_CONFIG.update(
{ discord: interaction.channelId },
{
$set: { [`config.${configKey}`]: newValue },
$setOnInsert: {
discord: interaction.channelId,
},
},
{ upsert: true }
);
return await interaction.reply({
ephemeral: true,
content: `Option \`${configKey}\` has been updated to \`${newValue}\`.`,
});
}
case "help": {
const isPrivileged =
!!interaction.memberPermissions?.has(
"MANAGE_GUILD"
);
const INVITE_URL = `https://discord.com/api/oauth2/authorize?client_id=${client.user?.id}&permissions=536996864&scope=bot%20applications.commands`;
const embed = new MessageEmbed()
.setColor("#ff6e6d")
.setAuthor({
name: "AutoMod Revolt Bridge",
iconURL: client.user?.displayAvatarURL(),
});
embed.setDescription(
"[AutoMod](https://automod.me) is a utility and moderation bot for [Revolt](https://revolt.chat). " +
"This Discord bot allows you to link your Discord servers to your Revolt servers " +
"by mirroring messages between text channels."
);
embed.addField(
"Setting up a bridge",
isPrivileged
? "The bridge process is initialized by running the `/bridge link` command in the Revolt " +
"channel you wish to bridge.\n" +
"Afterwards you can run the `/bridge confirm` command in the correct Discord channel to finish the link."
: "You don't have `Manage Messages` permission - Please ask a moderator to configure the bridge."
);
embed.addField(
"Adding AutoMod to your server",
`You can add the Revolt bot to your server ` +
`[here](https://app.revolt.chat/bot/${revoltClient.user?.id} "Open Revolt"). To add the Discord counterpart, ` +
`click [here](${INVITE_URL} "Add Discord bot").`
);
embed.addField(
"Contact",
`If you have any questions regarding this bot or the Revolt counterpart, feel free to join ` +
`[this](https://discord.gg/4pZgvqgYJ8) Discord server or [this](https://rvlt.gg/jan) Revolt server.\n` +
`If you want to report a bug, suggest a feature or browse the source code, ` +
`feel free to do so [on GitHub](https://github.com/sussycatgirl/automod).\n` +
`For other inquiries, please contact \`contact@automod.me\`.\n\n` +
`Before using this bot, please read the [Privacy Policy](${PRIVACY_POLICY_URL})!`
);
await interaction.reply({
embeds: [embed],
ephemeral: true,
});
break;
}
case "opt_out": {
const optOut = interaction.options.getBoolean(
"opt_out",
false
);
if (optOut == null) {
const userConfig =
await BRIDGE_USER_CONFIG.findOne({
id: interaction.user.id,
});
if (userConfig?.optOut) {
return await interaction.reply({
ephemeral: true,
content:
"You are currently **opted out** of message bridging. " +
"Users on Revolt **will not** see your username, avatar or message content.",
});
} else {
return await interaction.reply({
ephemeral: true,
content:
"You are currently **not** opted out of message bridging. " +
"All your messages in a bridged channel will be sent to the associated Revolt channel.",
});
}
} else {
await BRIDGE_USER_CONFIG.update(
{ id: interaction.user.id },
{
$setOnInsert: {
id: interaction.user.id,
},
$set: { optOut },
},
{ upsert: true }
);
return await interaction.reply({
ephemeral: true,
content:
`You have **opted ${
optOut ? "out of" : "into"
}** message bridging. ` +
(optOut
? "Your username, avatar and message content will no longer be visible on Revolt.\n" +
"Please note that some servers may be configured to automatically delete your messages."
: "All your messages in a bridged channel will be sent to the associated Revolt channel."),
});
}
}
case "status": {
const bridgeConfig = await BRIDGE_CONFIG.findOne({
discord: interaction.channelId,
});
if (!bridgeConfig?.revolt) {
return await interaction.reply({
ephemeral: true,
content:
"This channel is **not** bridged. No message content data will be processed.",
});
} else {
return await interaction.reply({
ephemeral: true,
content:
"This channel is **bridged to Revolt**. Your messages will " +
"be processed and sent to [Revolt](<https://revolt.chat>) according to AutoMod's " +
`[Privacy Policy](<${PRIVACY_POLICY_URL}>).`,
});
}
break;
}
default:
await interaction.reply("Unknown subcommand");
}
break;
}
} else if (interaction.isMessageContextMenu()) {
logger.debug(
`Received context menu: ${interaction.targetMessage.id}`
);
switch (interaction.commandName) {
case "Message Info":
const message = interaction.targetMessage;
const bridgeInfo = await BRIDGED_MESSAGES.findOne({
"discord.messageId": message.id,
});
const messageUrl = `https://discord.com/channels/${interaction.guildId}/${interaction.channelId}/${message.id}`;
if (!bridgeInfo)
return await interaction.reply({
ephemeral: true,
embeds: [
new MessageEmbed()
.setAuthor({
name: "Message info",
url: messageUrl,
})
.setDescription(
"This message has not been bridged."
)
.setColor("#7e96ff"),
],
});
else {
const embed = new MessageEmbed();
embed.setColor("#7e96ff");
embed.setAuthor({
name: "Message info",
url: messageUrl,
});
embed.addField(
"Origin",
bridgeInfo.origin == "discord"
? "Discord"
: "Revolt",
true
);
if (bridgeInfo.origin == "discord") {
embed.addField(
"Bridge Status",
bridgeInfo.revolt.messageId
? "Bridged"
: bridgeInfo.revolt.nonce
? "ID unknown"
: "Unbridged",
true
);
} else {
embed.addField(
"Bridge Status",
bridgeInfo.discord.messageId
? "Bridged"
: "Unbridged",
true
);
if (bridgeInfo.channels?.revolt) {
const channel = await revoltClient.channels.get(
bridgeInfo.channels.revolt
);
const revoltMsg = await revoltFetchMessage(
bridgeInfo.revolt.messageId,
channel
);
if (revoltMsg) {
const author = await revoltFetchUser(
revoltMsg.authorId
);
embed.addField(
"Message Author",
`**@${author?.username}** (${revoltMsg.authorId})`
);
}
}
}
embed.addField(
"Bridge Data",
`Origin: \`${bridgeInfo.origin}\`\n` +
`Discord ID: \`${bridgeInfo.discord.messageId}\`\n` +
`Revolt ID: \`${bridgeInfo.revolt.messageId}\`\n` +
`Revolt Nonce: \`${bridgeInfo.revolt.nonce}\`\n` +
`Discord Channel: \`${bridgeInfo.channels?.discord}\`\n` +
`Revolt Channel: \`${bridgeInfo.channels?.revolt}\``
);
return await interaction.reply({
ephemeral: true,
embeds: [embed],
});
}
}
}
} catch (e) {
console.error(e);
if (interaction.isCommand())
interaction.reply("An error has occurred: " + e).catch(() => {});
}
});

View file

@ -1,555 +0,0 @@
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;
}
}

View file

@ -1,38 +0,0 @@
import { config } from 'dotenv';
import Log75, { LogLevel } from 'log75';
import { getDb } from './db';
import { login as loginRevolt } from './revolt/client';
import { login as loginDiscord } from './discord/client';
import { ICollection } from 'monk';
import BridgeConfig from "automod/dist/types/BridgeConfig";
import BridgedMessage from './types/BridgedMessage';
import BridgeRequest from './types/BridgeRequest';
import DiscordBridgedEmoji from './types/DiscordBridgedEmoji';
import BridgeUserConfig from './types/BridgeUserConfig';
config();
const logger: Log75 = new (Log75 as any).default(LogLevel.Debug);
const db = getDb();
const BRIDGED_MESSAGES: ICollection<BridgedMessage> = db.get('bridged_messages');
const BRIDGE_CONFIG: ICollection<BridgeConfig> = db.get('bridge_config');
const BRIDGE_REQUESTS: ICollection<BridgeRequest> = db.get('bridge_requests');
const BRIDGED_EMOJIS: ICollection<DiscordBridgedEmoji> = db.get('bridged_emojis');
const BRIDGE_USER_CONFIG: ICollection<BridgeUserConfig> = db.get('bridge_user_config');
for (const v of [ 'REVOLT_TOKEN', 'DISCORD_TOKEN', 'DB_STRING' ]) {
if (!process.env[v]) {
logger.error(`Env var $${v} expected but not set`);
process.exit(1);
}
}
(async () => {
import('./metrics');
const [ revolt, discord ] = await Promise.allSettled([
loginRevolt(),
loginDiscord(),
]);
})();
export { logger, db, BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_REQUESTS, BRIDGED_EMOJIS, BRIDGE_USER_CONFIG }

View file

@ -1,64 +0,0 @@
import prom from 'prom-client';
import http from 'http';
import { BRIDGED_MESSAGES, BRIDGE_CONFIG, logger } from ".";
const PORT = Number(process.env.BRIDGE_METRICS_PORT);
prom.collectDefaultMetrics({ prefix: "automod_bridge_" });
const metrics = {
messages: new prom.Counter({
name: "messages",
help: "Bridged message events",
labelNames: ["source", "type"],
}),
bridged_channels: new prom.Gauge({
name: "bridged_channels",
help: "How many channels are bridged",
}),
db_messages: new prom.Gauge({
name: "db_messages",
help: "Number of bridged message documents in the database",
labelNames: ["source"],
}),
};
if (!isNaN(PORT)) {
logger.info(`Enabling Prometheus metrics on :${PORT}`);
const server = new http.Server();
server.on("request", async (req, res) => {
if (req.url == "/metrics") {
res.write(await prom.register.metrics());
res.end();
} else {
res.statusCode = 404;
res.write("404 not found");
res.end();
}
});
server.listen(PORT, () => logger.done(`Prometheus metrics ready`));
async function updateMetrics() {
const now = Date.now();
metrics.bridged_channels.set(await BRIDGE_CONFIG.count({}));
const [revolt, discord] = await Promise.all([
BRIDGED_MESSAGES.count({ origin: "revolt" }),
BRIDGED_MESSAGES.count({ origin: "discord" }),
]);
metrics.db_messages.set({ source: "revolt" }, revolt);
metrics.db_messages.set({ source: "discord" }, discord);
logger.debug(`Fetching database metrics took ${Date.now() - now} ms`);
}
updateMetrics();
setInterval(updateMetrics, 1000 * 60);
}
export { metrics }

View file

@ -1,23 +0,0 @@
import { Client } from 'revolt.js';
import { logger } from '..';
let AUTUMN_URL: string = '';
const client = new Client({
baseURL: process.env.REVOLT_API_URL || 'https://api.revolt.chat',
autoReconnect: true,
});
const login = () => new Promise((resolve: (value: Client) => void) => {
client.loginBot(process.env['REVOLT_TOKEN']!);
client.once('ready', async () => {
logger.info(`Revolt: ${client.user?.username} ready - ${client.servers.size()} servers`);
AUTUMN_URL = client.configuration?.features.autumn.url ?? '';
resolve(client);
});
});
import('./events');
export { client, login, AUTUMN_URL }

View file

@ -1,545 +0,0 @@
import { BRIDGED_EMOJIS, BRIDGED_MESSAGES, BRIDGE_CONFIG, logger } from "..";
import { AUTUMN_URL, client } from "./client";
import { client as discordClient } from "../discord/client";
import { Message as DiscordMessage, MessageEmbed, MessagePayload, TextChannel, WebhookClient, WebhookMessageOptions } from "discord.js";
import GenericEmbed from "../types/GenericEmbed";
import { SendableEmbed } from "revolt-api";
import {
clipText,
discordFetchMessage,
revoltFetchMessage,
revoltFetchUser,
} from "../util";
import { smartReplace } from "smart-replace";
import { metrics } from "../metrics";
import { fetchEmojiList } from "../discord/bridgeEmojis";
import { ChannelRenamedSystemMessage, SystemMessage, TextSystemMessage, UserSystemMessage } from "revolt.js";
const RE_MENTION_USER = /<@[0-9A-HJ-KM-NP-TV-Z]{26}>/g;
const RE_MENTION_CHANNEL = /<#[0-9A-HJ-KM-NP-TV-Z]{26}>/g;
const RE_EMOJI = /:[^\s]+/g;
const KNOWN_EMOJI_NAMES: string[] = [];
fetchEmojiList()
.then((emojis) =>
Object.keys(emojis).forEach((name) => KNOWN_EMOJI_NAMES.push(name))
)
.catch((e) => console.error(e));
client.on("messageDelete", async (id) => {
try {
logger.debug(`[D] Revolt: ${id}`);
const bridgedMsg = await BRIDGED_MESSAGES.findOne({
"revolt.messageId": id,
});
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,
});
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
);
if (!targetMsg)
return logger.debug(`Revolt: Could not fetch message from Discord`);
if (
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);
client.destroy();
metrics.messages.inc({ source: "revolt", type: "delete" });
} else if (targetMsg.deletable) {
targetMsg.delete();
metrics.messages.inc({ source: "revolt", type: "delete" });
} else logger.debug(`Revolt: Unable to delete Discord message`);
} catch (e) {
console.error(e);
}
});
client.on("messageUpdate", async (message) => {
if (!message.content || typeof message.content != "string") return;
if (message.authorId == client.user?.id) return;
try {
logger.debug(`[E] Revolt: ${message.content}`);
if (!message.author) await client.users.fetch(message.authorId!);
const [bridgeCfg, bridgedMsg] = await Promise.all([
BRIDGE_CONFIG.findOne({ revolt: message.channelId }),
BRIDGED_MESSAGES.findOne({ "revolt.nonce": message.nonce }),
]);
if (!bridgedMsg)
return logger.debug(
`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
);
if (!targetMsg)
return logger.debug(`Revolt: Could not fetch message from Discord`);
const webhookClient = new WebhookClient({
id: bridgeCfg.discordWebhook.id,
token: bridgeCfg.discordWebhook.token,
});
await webhookClient.editMessage(targetMsg, {
content: await renderMessageBody(message.content),
allowedMentions: { parse: [] },
});
webhookClient.destroy();
metrics.messages.inc({ source: "revolt", type: "edit" });
} catch (e) {
console.error(e);
}
});
client.on("messageCreate", async (message) => {
try {
logger.debug(`[M] Revolt: ${message.id} ${message.content}`);
if (!message.author) await client.users.fetch(message.authorId!);
const [bridgeCfg, bridgedMsg, ...repliedMessages] = await Promise.all([
BRIDGE_CONFIG.findOne({ revolt: message.channelId }),
BRIDGED_MESSAGES.findOne(
message.nonce
? { "revolt.nonce": message.nonce }
: { "revolt.messageId": message.id }
),
...(message.replyIds?.map((id) =>
BRIDGED_MESSAGES.findOne({ "revolt.messageId": id })
) ?? []),
]);
if (bridgedMsg)
return logger.debug(
`Revolt: Message has already been bridged; ignoring`
);
if (message.systemMessage && bridgeCfg?.config?.disable_system_messages)
return logger.debug(
`Revolt: System message bridging disabled; ignoring`
);
if (bridgeCfg?.config?.read_only_revolt)
return logger.debug(`Revolt: Channel is marked as read only`);
if (!bridgeCfg?.discord)
return logger.debug(`Revolt: No Discord channel associated`);
if (!bridgeCfg.discordWebhook) {
logger.debug(
`Revolt: No Discord webhook stored; Creating new Webhook`
);
try {
const channel = (await discordClient.channels.fetch(
bridgeCfg.discord
)) as TextChannel;
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() }
);
bridgeCfg.discordWebhook = {
id: hook.id,
token: hook.token || "",
};
await BRIDGE_CONFIG.update(
{ revolt: message.channelId },
{
$set: {
discordWebhook: bridgeCfg.discordWebhook,
},
}
);
} catch (e) {
logger.warn(
`Unable to create new webhook for channel ${bridgeCfg.discord}; Deleting link\n${e}`
);
await BRIDGE_CONFIG.remove({ revolt: message.channelId });
await message.channel
?.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;
}
}
await BRIDGED_MESSAGES.update(
{ "revolt.messageId": message.id },
{
$set: {
revolt: {
messageId: message.id,
nonce: message.nonce,
},
channels: {
revolt: message.channelId,
discord: bridgeCfg.discord,
},
},
$setOnInsert: {
discord: {},
origin: "revolt",
},
},
{ upsert: true }
);
const channel = (await discordClient.channels.fetch(
bridgeCfg.discord
)) as TextChannel;
const webhookClient = new WebhookClient({
id: bridgeCfg.discordWebhook!.id,
token: bridgeCfg.discordWebhook!.token,
});
const payload: MessagePayload | WebhookMessageOptions = {
content:
message.content
? await renderMessageBody(message.content)
: message.systemMessage
? await renderSystemMessage(message.systemMessage)
: undefined,
username: message.systemMessage
? "Revolt"
: (bridgeCfg.config?.bridge_nicknames
? message.masquerade?.name ??
message.member?.nickname ??
message.author?.username
: message.author?.username) ?? "Unknown user",
avatarURL: message.systemMessage
? "https://app.revolt.chat/assets/logo_round.png"
: bridgeCfg.config?.bridge_nicknames
? message.masquerade?.avatar ??
message.member?.avatarURL ??
message.author?.avatarURL
: message.author?.avatarURL,
embeds: message.embeds?.length
? message.embeds
.filter((e) => e.type == "Text")
.map((e) =>
new GenericEmbed(e as SendableEmbed).toDiscord()
)
: undefined,
allowedMentions: { parse: [] },
};
if (repliedMessages.length) {
const embed = new MessageEmbed().setColor("#2f3136");
if (repliedMessages.length == 1) {
const replyMsg =
repliedMessages[0]?.origin == "discord"
? await discordFetchMessage(
repliedMessages[0]?.discord.messageId,
bridgeCfg.discord
)
: undefined;
const author = replyMsg?.author;
if (replyMsg) {
embed.setAuthor({
name: `@${author?.username ?? "Unknown"}`, // todo: check if @pinging was enabled for reply
iconURL: author?.displayAvatarURL({
size: 64,
dynamic: true,
}),
url: replyMsg?.url,
});
if (replyMsg?.content)
embed.setDescription(
">>> " + clipText(replyMsg.content, 200)
);
} else {
const msg = await revoltFetchMessage(
message.replyIds?.[0],
message.channel
);
const brMsg = repliedMessages.find(
(m) => m?.revolt.messageId == msg?.id
);
embed.setAuthor({
name: `@${msg?.author?.username ?? "Unknown"}`,
iconURL: msg?.author?.avatarURL,
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)
);
}
} else {
const replyMsgs = await Promise.all(
repliedMessages.map((m) =>
m?.origin == "discord"
? discordFetchMessage(
m?.discord.messageId,
bridgeCfg.discord
)
: revoltFetchMessage(
m?.revolt.messageId,
message.channel
)
)
);
embed.setAuthor({ name: repliedMessages.length + " replies" });
for (const msg of replyMsgs) {
let msgUrl = "";
if (msg instanceof DiscordMessage) {
msgUrl = msg.url;
} else {
const brMsg = repliedMessages.find(
(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(
`@${msg?.author?.username ?? "Unknown"}`,
(msg ? `[Link](${msgUrl})\n` : "") +
">>> " +
clipText(msg?.content ?? "\u200b", 100),
true
);
}
}
if (payload.embeds) payload.embeds.unshift(embed);
else payload.embeds = [embed];
}
if (message.attachments?.length) {
payload.files = [];
for (const attachment of message.attachments) {
payload.files.push({
attachment: `${AUTUMN_URL}/attachments/${attachment.id}/${attachment.filename}`,
name: attachment.filename,
});
}
}
webhookClient
.send(payload)
.then(async (res) => {
await BRIDGED_MESSAGES.update(
{
"revolt.messageId": message.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.channelId },
{ $set: { discordWebhook: undefined } }
);
} catch (e) {
console.error(e);
}
}
});
} catch (e) {
console.error(e);
}
});
// Replaces @mentions, #channel mentions, :emojis: and makes markdown features work on Discord
async function renderMessageBody(message: string): Promise<string> {
// @mentions
message = await smartReplace(
message,
RE_MENTION_USER,
async (match) => {
const id = match.replace("<@", "").replace(">", "");
const user = await revoltFetchUser(id);
return `@${user?.username || id}`;
},
{ cacheMatchResults: true, maxMatches: 10 }
);
// #channels
message = await smartReplace(
message,
RE_MENTION_CHANNEL,
async (match) => {
const id = match.replace("<#", "").replace(">", "");
const channel = client.channels.get(id);
const bridgeCfg = channel
? await BRIDGE_CONFIG.findOne({ revolt: channel.id })
: undefined;
const discordChannel = bridgeCfg?.discord
? discordClient.channels.cache.get(bridgeCfg.discord)
: undefined;
return discordChannel
? `<#${discordChannel.id}>`
: `#${channel?.name || id}`;
},
{ cacheMatchResults: true, maxMatches: 10 }
);
message = await smartReplace(
message,
RE_EMOJI,
async (match) => {
const emojiName = match.replace(/(^:)|(:$)/g, "");
if (!KNOWN_EMOJI_NAMES.includes(emojiName)) return match;
const dbEmoji = await BRIDGED_EMOJIS.findOne({ name: emojiName });
if (!dbEmoji) return match;
return `<${dbEmoji.animated ? "a" : ""}:${emojiName}:${
dbEmoji.emojiid
}>`;
},
{ cacheMatchResults: true, maxMatches: 40 }
);
message = message
// Escape ||Discord style spoilers|| since Revite doesn't support them
.replace(/\|\|.+\|\|/gs, (match) => "\\" + match)
// Translate !!Revite spoilers!! to ||Discord spoilers||
.replace(
/!!.+!!/g,
(match) => `||${match.substring(2, match.length - 2)}||`
)
// KaTeX blocks
.replace(/(\$\$[^$]+\$\$)|(\$[^$]+\$)/g, (match) => {
const dollarCount =
match.startsWith("$$") && match.endsWith("$$") ? 2 : 1;
const tex = match.substring(
dollarCount,
match.length - dollarCount
);
const output = `[\`${tex}\`](<https://automod.me/tex/?tex=${encodeURI(
tex
)}>)`;
// Make sure we don't blow through the message length limit
const newLength = message.length - match.length + output.length;
return newLength <= 2000 ? output : `\`${tex}\``;
});
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 as UserSystemMessage).userId
)} joined`;
case "user_left":
case "user_remove":
return `<:left:1042831834259652628> ${await getUsername(
(message as UserSystemMessage).userId
)} left`;
case "user_kicked":
return `<:kicked:1042831835421483050> ${await getUsername(
(message as UserSystemMessage).userId
)} was kicked`;
case "user_banned":
return `<:banned:1042831836675588146> ${await getUsername(
(message as UserSystemMessage).userId
)} was banned`;
case "channel_renamed":
return `<:channel_renamed:1042831837912891392> ${await getUsername(
(message as ChannelRenamedSystemMessage).byId
)} renamed the channel to **${(message as ChannelRenamedSystemMessage).name}**`;
case "channel_icon_changed":
return `<:channel_icon:1042831840538542222> ${await getUsername(
(message as ChannelRenamedSystemMessage).byId
)} changed the channel icon`;
case "channel_description_changed":
return `<:channel_description:1042831839217328228> ${await getUsername(
(message as ChannelRenamedSystemMessage).byId
)} changed the channel description`;
case "text":
return (message as TextSystemMessage).content;
default:
return Object.entries(message)
.map((e) => `${e[0]}: ${e[1]}`)
.join(", ");
}
}

View file

@ -1,9 +0,0 @@
export default class {
// Bridge request ID, needed to confirm link from Discord side
id: string;
// The Revolt channel ID
revolt: string;
expires: number;
}

View file

@ -1,5 +0,0 @@
export default class {
platform: 'discord'; // Todo: Revolt users too?
id: string;
optOut?: boolean;
}

View file

@ -1,20 +0,0 @@
export default class {
origin: 'discord'|'revolt';
discord: {
messageId?: string;
}
revolt: {
messageId?: string;
nonce?: string;
}
// Required to sync message deletions
channels?: {
discord: string;
revolt: string;
}
ignore?: boolean;
}

View file

@ -1,7 +0,0 @@
export default class {
name: string;
emojiid: string;
server: string;
animated: boolean;
originalFileUrl: string;
}

View file

@ -1,66 +0,0 @@
import { MessageEmbed } from "discord.js"
import { SendableEmbed } from "revolt-api";
export default class GenericEmbed {
constructor(embed?: MessageEmbed|SendableEmbed) {
if (embed instanceof MessageEmbed) {
if (embed.title) this.title = embed.title;
else if (embed.author?.name) this.title = embed.author.name;
if (embed.url) this.url = embed.url;
else if (embed.author?.url) this.url = embed.author.url;
if (this.title && embed.author?.iconURL) this.icon = embed.author.iconURL;
if (embed.description) this.description = embed.description;
if (embed.hexColor) this.color = `${embed.hexColor}`;
} else if (embed) {
if (embed.title) this.title = embed.title;
if (embed.description) this.description = embed.description;
if (embed.icon_url?.match(/^http(s)?\:\/\//)) this.icon = embed.icon_url;
if (embed.colour?.match(/^#[0-9a-fA-F]+$/)) this.color = (embed.colour as any);
if (embed.url) this.url = embed.url;
}
}
// Embed title. Set to the author name on Discord for consistency
title?: string;
// Embed description
description?: string;
// Displayed as the author icon on Discord and next to the title on Revolt
icon?: string;
// Not sure how this works on Revolt
url?: string;
// Embed color
color?: `#${string}`;
toDiscord = (): MessageEmbed => {
const embed = new MessageEmbed();
if (this.description) embed.setDescription(this.description);
if (this.title) embed.setAuthor({ name: this.title, iconURL: this.icon, url: this.url });
if (this.color) embed.setColor(this.color);
return embed;
}
toRevolt = (): SendableEmbed => {
const embed: SendableEmbed = {}
embed.title = this.title;
embed.description = this.description;
embed.icon_url = this.icon;
embed.url = this.url;
embed.colour = this.color?.toString();
// todo: embed.media needs to be an autumn url. we might
// want to download and reupload the attachment.
return embed;
}
}

View file

@ -1,68 +0,0 @@
import { Channel } from "revolt.js";
import { Message } from "revolt.js";
import { User } from "revolt.js";
import { Message as DiscordMessage, TextChannel, User as DiscordUser } from "discord.js";
import { client as discordClient } from "./discord/client";
import { client as revoltClient } from "./revolt/client"
// Grab user from cache or fetch, return undefined if error
async function revoltFetchUser(id?: string): Promise<User|undefined> {
if (!id) return undefined;
let user = revoltClient.users.get(id);
if (user) return user;
try { user = await revoltClient.users.fetch(id) } catch(_) { }
return user;
}
async function revoltFetchMessage(id?: string, channel?: Channel): Promise<Message|undefined> {
if (!id || !channel) return undefined;
let message = revoltClient.messages.get(id);
if (message) return message;
try { message = await channel.fetchMessage(id) } catch(_) { }
return message;
}
async function discordFetchMessage(id?: string, channelId?: string): Promise<DiscordMessage|undefined> {
if (!id || !channelId) return undefined;
const channel = discordClient.channels.cache.get(channelId);
if (!channel || !(channel instanceof TextChannel)) return undefined;
let message = channel.messages.cache.get(id);
if (message) return message;
try { message = await channel.messages.fetch(id) } catch(_) { }
return message;
}
// doesnt seem to work idk
async function discordFetchUser(id?: string): Promise<DiscordUser|undefined> {
if (!id) return undefined;
let user = discordClient.users.cache.get(id);
if (user) return user;
try { user = await discordClient.users.fetch(id) } catch(_) { }
return user;
}
function clipText(text: string, limit: number) {
if (text.length < limit) return text;
else return text.substring(0, limit-4) + ' ...';
}
export {
revoltFetchUser,
revoltFetchMessage,
discordFetchMessage,
discordFetchUser,
clipText,
}

View file

@ -1,100 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "ES2020", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
"strictPropertyInitialization": false, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

File diff suppressed because it is too large Load diff