Remove bridge functionality
This commit is contained in:
parent
9f1634061a
commit
84e09da269
23 changed files with 0 additions and 4165 deletions
7
bridge/.gitignore
vendored
7
bridge/.gitignore
vendored
|
@ -1,7 +0,0 @@
|
|||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
786
bridge/.yarn/releases/yarn-3.2.1.cjs
vendored
786
bridge/.yarn/releases/yarn-3.2.1.cjs
vendored
File diff suppressed because one or more lines are too long
|
@ -1,2 +0,0 @@
|
|||
yarnPath: .yarn/releases/yarn-3.2.1.cjs
|
||||
nodeLinker: node-modules
|
|
@ -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"]
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
})();
|
|
@ -1,9 +0,0 @@
|
|||
import Monk from 'monk';
|
||||
import { logger } from '.';
|
||||
|
||||
function getDb() {
|
||||
const db = Monk(process.env['DB_STRING']!);
|
||||
return db;
|
||||
}
|
||||
|
||||
export { getDb }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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(() => {});
|
||||
}
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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(", ");
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
export default class {
|
||||
platform: 'discord'; // Todo: Revolt users too?
|
||||
id: string;
|
||||
optOut?: boolean;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
export default class {
|
||||
name: string;
|
||||
emojiid: string;
|
||||
server: string;
|
||||
animated: boolean;
|
||||
originalFileUrl: string;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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. */
|
||||
}
|
||||
}
|
1050
bridge/yarn.lock
1050
bridge/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue