diff --git a/bridge/src/discord/commands.ts b/bridge/src/discord/commands.ts index bb4d159..a53b0eb 100644 --- a/bridge/src/discord/commands.ts +++ b/bridge/src/discord/commands.ts @@ -7,18 +7,20 @@ import { BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_REQUESTS, BRIDGE_USER_CONFIG, l import { MessageEmbed, TextChannel } from "discord.js"; import { revoltFetchMessage, revoltFetchUser } from "../util"; import { client as revoltClient } from "../revolt/client"; +import { CONFIG_KEYS } from "../types/ConfigKeys"; -const PRIVACY_POLICY_URL = 'https://github.com/janderedev/automod/wiki/Privacy-Policy'; +const PRIVACY_POLICY_URL = + "https://github.com/janderedev/automod/wiki/Privacy-Policy"; const COMMANDS: any[] = [ { - name: 'bridge', - description: 'Confirm or delete Revolt bridges', + name: "bridge", + description: "Confirm or delete Revolt bridges", type: 1, // Slash command options: [ { - name: 'confirm', - description: 'Confirm a bridge initiated from Revolt', + name: "confirm", + description: "Confirm a bridge initiated from Revolt", type: 1, // Subcommand options: [ { @@ -26,99 +28,153 @@ const COMMANDS: any[] = [ 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: "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: '', + name: "Message Info", + description: "", type: 3, // Message context menu - } + }, ]; -const rest = new REST({ version: '9' }).setToken(process.env['DISCORD_TOKEN']!); +const rest = new REST({ version: "9" }).setToken(process.env["DISCORD_TOKEN"]!); -client.once('ready', async () => { +client.once("ready", async () => { try { logger.info(`Refreshing application commands.`); - if (process.env.NODE_ENV != 'production' && process.env.DEV_GUILD) { + if (process.env.NODE_ENV != "production" && process.env.DEV_GUILD) { await rest.put( - Routes.applicationGuildCommands(client.user!.id, process.env.DEV_GUILD), - { body: COMMANDS }, + Routes.applicationGuildCommands( + client.user!.id, + process.env.DEV_GUILD + ), + { body: COMMANDS } + ); + logger.done( + `Application commands for ${process.env.DEV_GUILD} have been updated.` ); - logger.done(`Application commands for ${process.env.DEV_GUILD} have been updated.`); } else { - await rest.put( - Routes.applicationCommands(client.user!.id), - { body: COMMANDS }, - ); + await rest.put(Routes.applicationCommands(client.user!.id), { + body: COMMANDS, + }); logger.done(`Global application commands have been updated.`); } - } catch(e) { + } catch (e) { console.error(e); } }); -client.on('interactionCreate', async interaction => { +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)) + 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 }); + 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 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 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 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() }); + const webhook = await( + interaction.channel as TextChannel + ).createWebhook("AutoMod Bridge", { + avatar: client.user?.avatarURL(), + }); await BRIDGE_REQUESTS.remove({ id: id }); await BRIDGE_CONFIG.insert({ @@ -126,91 +182,180 @@ client.on('interactionCreate', async interaction => { revolt: request.revolt, discordWebhook: { id: webhook.id, - token: webhook.token || '', - } + token: webhook.token || "", + }, }); - return await interaction.reply(`✅ Channel bridged!`); - case 'unlink': - const res = await BRIDGE_CONFIG.findOneAndDelete({ discord: interaction.channelId }); + 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.'); + 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 'help': - const isPrivileged = !!interaction.memberPermissions?.has('MANAGE_GUILD'); + 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() }); + .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.' + "[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', + "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.' + ? "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', + "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").` + `[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', + "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/janderedev/automod).\n` + - `For other inquiries, please contact \`contact@automod.me\`.\n\n` + - `Before using this bot, please read the [Privacy Policy](${PRIVACY_POLICY_URL})!` + `[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/janderedev/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 }); + await interaction.reply({ + embeds: [embed], + ephemeral: true, + }); break; + } - case 'opt_out': - const optOut = interaction.options.getBoolean('opt_out', false); + 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 }); + 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.' + 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.' + 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 }, + $setOnInsert: { + id: interaction.user.id, + }, $set: { optOut }, }, { upsert: true } @@ -218,121 +363,153 @@ client.on('interactionCreate', async interaction => { 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.' - ), + 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."), }); } - break; + } - case 'status': - const bridgeConfig = await BRIDGE_CONFIG.findOne({ discord: interaction.channelId }); + 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.', + content: + "This channel is **not** bridged. No message content data will be processed.", }); - } - else { + } else { return await interaction.reply({ ephemeral: true, - content: 'This channel is **bridged to Revolt**. Your messages will ' + - 'be processed and sent to [Revolt]() according to AutoMod\'s ' + + content: + "This channel is **bridged to Revolt**. Your messages will " + + "be processed and sent to [Revolt]() according to AutoMod's " + `[Privacy Policy](<${PRIVACY_POLICY_URL}>).`, }); } break; + } - default: await interaction.reply('Unknown subcommand'); + default: + await interaction.reply("Unknown subcommand"); } break; } - } - else if (interaction.isMessageContextMenu()) { - logger.debug(`Received context menu: ${interaction.targetMessage.id}`); + } else if (interaction.isMessageContextMenu()) { + logger.debug( + `Received context menu: ${interaction.targetMessage.id}` + ); - switch(interaction.commandName) { - case 'Message Info': + switch (interaction.commandName) { + case "Message Info": const message = interaction.targetMessage; - const bridgeInfo = await BRIDGED_MESSAGES.findOne({ "discord.messageId": message.id }); + 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'), - ], - }); + 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.setColor("#7e96ff"); + embed.setAuthor({ + name: "Message info", + url: messageUrl, + }); - embed.addField('Origin', bridgeInfo.origin == 'discord' ? 'Discord' : 'Revolt', true); + embed.addField( + "Origin", + bridgeInfo.origin == "discord" + ? "Discord" + : "Revolt", + true + ); - if (bridgeInfo.origin == 'discord') { + if (bridgeInfo.origin == "discord") { embed.addField( - 'Bridge Status', + "Bridge Status", bridgeInfo.revolt.messageId - ? 'Bridged' + ? "Bridged" : bridgeInfo.revolt.nonce - ? 'ID unknown' - : 'Unbridged', + ? "ID unknown" + : "Unbridged", true ); } else { embed.addField( - 'Bridge Status', + "Bridge Status", bridgeInfo.discord.messageId - ? 'Bridged' - : 'Unbridged', + ? "Bridged" + : "Unbridged", true ); if (bridgeInfo.channels?.revolt) { - const channel = await revoltClient.channels.get(bridgeInfo.channels.revolt); - const revoltMsg = await revoltFetchMessage(bridgeInfo.revolt.messageId, channel); + const channel = await revoltClient.channels.get( + bridgeInfo.channels.revolt + ); + const revoltMsg = await revoltFetchMessage( + bridgeInfo.revolt.messageId, + channel + ); if (revoltMsg) { - const author = await revoltFetchUser(revoltMsg.author_id); + const author = await revoltFetchUser( + revoltMsg.author_id + ); embed.addField( - 'Message Author', - `**@${author?.username}** (${revoltMsg.author_id})`, + "Message Author", + `**@${author?.username}** (${revoltMsg.author_id})` ); } } } embed.addField( - 'Bridge Data', + "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}\`` + `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 ], + embeds: [embed], }); } } } - } catch(e) { + } catch (e) { console.error(e); - if (interaction.isCommand()) interaction.reply('An error has occurred: ' + e).catch(() => {}); + if (interaction.isCommand()) + interaction.reply("An error has occurred: " + e).catch(() => {}); } }); diff --git a/bridge/src/discord/events.ts b/bridge/src/discord/events.ts index 5a3cf23..e6f5fc4 100644 --- a/bridge/src/discord/events.ts +++ b/bridge/src/discord/events.ts @@ -192,18 +192,24 @@ client.on('messageCreate', async message => { //attachments: [], //embeds: [], nonce: nonce, - replies: reply ? [ { id: reply, mention: !!message.mentions.repliedUser } ] : undefined, + replies: reply + ? [{ id: reply, mention: !!message.mentions.repliedUser }] + : undefined, masquerade: { - name: message.author.username, - avatar: message.author.displayAvatarURL({ size: 128 }), - colour: channel.server?.havePermission('ManageRole') + name: bridgeCfg.config?.bridge_nicknames + ? message.member?.nickname ?? message.author.username + : message.author.username, + avatar: bridgeCfg.config?.bridge_nicknames + ? message.member?.displayAvatarURL({ size: 128 }) + : message.author.displayAvatarURL({ size: 128 }), + colour: channel.server?.havePermission("ManageRole") ? message.member?.displayColor // Discord.js returns black or 0 instead of undefined when no role color is set ? message.member?.displayHexColor - : 'var(--foreground)' + : "var(--foreground)" : undefined, }, embeds: message.embeds.length - ? message.embeds.map(e => new GenericEmbed(e).toRevolt()) + ? message.embeds.map((e) => new GenericEmbed(e).toRevolt()) : undefined, attachments: autumnUrls.length ? autumnUrls : undefined, }; diff --git a/bridge/src/revolt/events.ts b/bridge/src/revolt/events.ts index e5a6805..6676e64 100644 --- a/bridge/src/revolt/events.ts +++ b/bridge/src/revolt/events.ts @@ -150,16 +150,29 @@ client.on('message', async message => { token: bridgeCfg.discordWebhook!.token, }); - const payload: MessagePayload|WebhookMessageOptions = { - content: message.content ? await renderMessageBody(message.content) : undefined, - username: message.author?.username ?? 'Unknown user', - avatarURL: message.author?.generateAvatarURL({ max_side: 128 }), + const payload: MessagePayload | WebhookMessageOptions = { + content: message.content + ? await renderMessageBody(message.content) + : undefined, + username: + (bridgeCfg.config?.bridge_nicknames + ? message.masquerade?.name ?? + message.member?.nickname ?? + message.author?.username + : message.author?.username) ?? "Unknown user", + avatarURL: bridgeCfg.config?.bridge_nicknames + ? message.masquerade?.avatar ?? + message.member?.generateAvatarURL({ max_side: 128 }) ?? + message.author?.generateAvatarURL({ max_side: 128 }) + : message.author?.generateAvatarURL({ max_side: 128 }), embeds: message.embeds?.length ? message.embeds - .filter(e => e.type == "Text") - .map(e => new GenericEmbed(e as SendableEmbed).toDiscord()) + .filter((e) => e.type == "Text") + .map((e) => + new GenericEmbed(e as SendableEmbed).toDiscord() + ) : undefined, - allowedMentions: { parse: [ ] }, + allowedMentions: { parse: [] }, }; if (repliedMessages.length) { diff --git a/bridge/src/types/BridgeConfig.ts b/bridge/src/types/BridgeConfig.ts index 017be83..df0ec54 100644 --- a/bridge/src/types/BridgeConfig.ts +++ b/bridge/src/types/BridgeConfig.ts @@ -1,3 +1,5 @@ +import { CONFIG_KEYS } from "./ConfigKeys"; + export default class { // Revolt channel ID revolt?: string; @@ -9,7 +11,9 @@ export default class { discordWebhook?: { id: string; token: string; - } + }; + + config?: { [key in keyof typeof CONFIG_KEYS]: boolean | undefined }; // If true, messages by users who have opted out of bridging will be deleted. disallowIfOptedOut?: boolean; diff --git a/bridge/src/types/ConfigKeys.ts b/bridge/src/types/ConfigKeys.ts new file mode 100644 index 0000000..0afec86 --- /dev/null +++ b/bridge/src/types/ConfigKeys.ts @@ -0,0 +1,13 @@ +export const CONFIG_KEYS = { + bridge_nicknames: { + friendlyName: "Bridge Nicknames", + description: + "If enabled, nicknames and avatar overrides will be bridged.", + }, + // disallow_opt_out: { + // friendlyName: "Disallow users who opted out of message bridging", + // description: + // "If enabled, all messages by users who opted out of their messages being bridged (`/bridge opt_out`) will be deleted. " + + // "You should enable this if your Revolt server is bridged to a mostly unmoderated Discord server.", + // }, +};