diff --git a/bot/src/bot/commands/bridge.ts b/bot/src/bot/commands/bridge.ts new file mode 100644 index 0000000..233eac4 --- /dev/null +++ b/bot/src/bot/commands/bridge.ts @@ -0,0 +1,109 @@ +import { ulid } from "ulid"; +import { dbs } from "../.."; +import CommandCategory from "../../struct/commands/CommandCategory"; +import SimpleCommand from "../../struct/commands/SimpleCommand"; +import MessageCommandContext from "../../struct/MessageCommandContext"; +import { DEFAULT_PREFIX } from "../modules/command_handler"; +import { isBotManager, NO_MANAGER_MSG } from "../util"; + +const DISCORD_INVITE_URL = 'https://discord.com/api/oauth2/authorize?client_id=965692929643524136&permissions=536996864&scope=bot%20applications.commands'; // todo: read this from env or smth + +export default { + name: 'bridge', + aliases: null, + description: 'Bridge a channel with Discord', + category: CommandCategory.Misc, + run: async (message: MessageCommandContext, args: string[]) => { + if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG); + + switch(args[0]?.toLowerCase()) { + case 'link': { + const count = await dbs.BRIDGE_CONFIG.count({ revolt: message.channel_id }); + if (count) return message.reply(`This channel is already bridged.`); + + // Invalidate previous bridge request + await dbs.BRIDGE_REQUESTS.remove({ revolt: message.channel_id }); + + const reqId = ulid(); + await dbs.BRIDGE_REQUESTS.insert({ + id: reqId, + revolt: message.channel_id, + expires: Date.now() + (1000 * 60 * 15), + }); + + await message.reply(`### Link request created.\n` + + `Request ID: \`${reqId}\`\n\n` + + `[Invite the bridge bot to your Discord server](<${DISCORD_INVITE_URL}>) ` + + `and run \`/bridge confirm ${reqId}\` in the channel you wish to link.\n` + + `This request expires in 15 minutes.`); + + break; + } + case 'unlink': { + const res = await dbs.BRIDGE_CONFIG.remove({ revolt: message.channel_id }); + if (res.deletedCount) await message.reply(`Channel unlinked!`); + else await message.reply(`Unable to unlink; no channel linked.`); + break; + } + case 'unlink_all': { + const query = { revolt: { $in: message.channel?.server?.channel_ids || [] } }; + if (args[1] == 'CONFIRM') { + const res = await dbs.BRIDGE_CONFIG.remove(query); + if (res.deletedCount) { + await message.reply(`All channels have been unlinked. (Count: **${res.deletedCount}**)`); + } else { + await message.reply(`No bridged channels found; nothing to delete.`); + } + } else { + const res = await dbs.BRIDGE_CONFIG.count(query); + if (!res) await message.reply(`No bridged channels found; nothing to delete.`); + else { + await message.reply(`${res} bridged channels found. ` + + `Run \`${DEFAULT_PREFIX}bridge unlink_all CONFIRM\` to confirm deletion.`); + } + } + break; + } + case 'list': { + const links = await dbs.BRIDGE_CONFIG.find({ revolt: { $in: message.channel?.server?.channel_ids || [] } }); + + await message.reply({ + content: '#', + embeds: [ + { + type: 'Text', + title: `Bridges in ${message.channel?.server?.name}`, + description: `**${links.length}** bridged channels found.\n\n` + + links.map(l => `<#${l.revolt}> **->** ${l.discord}\n`), + } + ] + }); + break; + } + case 'help': { + await message.reply({ + content: '#', + embeds: [ + { + type: 'Text', + title: 'Discord Bridge', + description: `Bridges allow you to link your Revolt server to a Discord server ` + + `by relaying all messages.\n\n` + + `To link a channel, first run \`${DEFAULT_PREFIX}bridge link\` on Revolt. ` + + `This will provide you with a link ID.\n` + + `On Discord, first [add the Bridge bot to your server](<${DISCORD_INVITE_URL}>), ` + + `then run the command: \`/bridge confirm [ID]\`.\n\n` + + `You can list all bridges in a Revolt server by running \`${DEFAULT_PREFIX}bridge list\`\n\n` + + `To unlink a channel, run \`/bridge unlink\` from either Discord or Revolt. If you wish to ` + + `unbridge all channels in a Revolt server, run \`${DEFAULT_PREFIX}bridge unlink_all\`.` + } + ] + }); + break; + } + default: { + await message.reply(`Run \`${DEFAULT_PREFIX}bridge help\` for help.`); + } + } + } +} as SimpleCommand; diff --git a/bot/src/index.ts b/bot/src/index.ts index 48b2518..48657e0 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -11,6 +11,8 @@ import PendingLogin from './struct/PendingLogin'; import TempBan from './struct/TempBan'; import { VoteEntry } from './bot/commands/votekick'; import ScannedUser from './struct/ScannedUser'; +import BridgeRequest from './struct/BridgeRequest'; +import BridgeConfig from './struct/BridgeConfig'; logger.info('Initializing client'); @@ -32,6 +34,8 @@ const dbs = { TEMPBANS: db.get('tempbans'), VOTEKICKS: db.get('votekicks'), SCANNED_USERS: db.get('scanned_users'), + BRIDGE_CONFIG: db.get('bridge_config'), + BRIDGE_REQUESTS: db.get('bridge_requests'), } export { client, dbs } diff --git a/bot/src/struct/BridgeConfig.ts b/bot/src/struct/BridgeConfig.ts new file mode 100644 index 0000000..03a1fde --- /dev/null +++ b/bot/src/struct/BridgeConfig.ts @@ -0,0 +1,13 @@ +export default class { + // Revolt channel ID + revolt?: string; + + // Discord channel ID + discord?: string; + + // Discord webhook + discordWebhook?: { + id: string; + token: string; + } +} diff --git a/bot/src/struct/BridgeRequest.ts b/bot/src/struct/BridgeRequest.ts new file mode 100644 index 0000000..9e22787 --- /dev/null +++ b/bot/src/struct/BridgeRequest.ts @@ -0,0 +1,9 @@ +export default class { + // Bridge request ID, needed to confirm link from Discord side + id: string; + + // The Revolt channel ID + revolt: string; + + expires: number; +} \ No newline at end of file diff --git a/bridge/package.json b/bridge/package.json index 97b3d9e..ff8f4d0 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -13,8 +13,10 @@ "author": "", "license": "ISC", "dependencies": { + "@discordjs/rest": "^0.4.1", "@janderedev/revolt.js": "^5.2.8-patch.2", "axios": "^0.26.1", + "discord-api-types": "^0.31.2", "discord.js": "^13.6.0", "dotenv": "^16.0.0", "form-data": "^4.0.0", diff --git a/bridge/src/discord/client.ts b/bridge/src/discord/client.ts index bc16536..8751ed7 100644 --- a/bridge/src/discord/client.ts +++ b/bridge/src/discord/client.ts @@ -23,5 +23,6 @@ const login = () => new Promise((resolve: (value: Discord.Client) => void) => { }); import('./events'); +import('./commands'); export { client, login } diff --git a/bridge/src/discord/commands.ts b/bridge/src/discord/commands.ts new file mode 100644 index 0000000..550701c --- /dev/null +++ b/bridge/src/discord/commands.ts @@ -0,0 +1,121 @@ +// fuck slash commands + +import { client } from "./client"; +import { REST } from '@discordjs/rest'; +import { Routes } from 'discord-api-types/v9'; +import { BRIDGE_CONFIG, BRIDGE_REQUESTS, logger } from ".."; +import { TextChannel } from "discord.js"; + +const COMMANDS: any[] = [ + { + name: 'bridge', + description: 'Confirm or delete Revolt bridges', + 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, + }, + ], + } +]; + +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()) return; + + logger.debug(`Command received: /${interaction.commandName}`); + + // The revolutionary Jan command handler + switch(interaction.commandName) { + case 'bridge': + if (!interaction.memberPermissions?.has('MANAGE_GUILD')) { + return await interaction.reply(`\`MANAGE_GUILD\` permission is required for this.`); + } + + 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 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; + default: await interaction.reply('Unknown subcommand'); + } + + break; + } + } catch(e) { + console.error(e); + if (interaction.isCommand()) interaction.reply('An error has occurred: ' + e).catch(() => {}); + } +}); diff --git a/bridge/src/index.ts b/bridge/src/index.ts index c83316c..4634126 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -6,6 +6,7 @@ import { login as loginDiscord } from './discord/client'; import { ICollection } from 'monk'; import BridgeConfig from './types/BridgeConfig'; import BridgedMessage from './types/BridgedMessage'; +import BridgeRequest from './types/BridgeRequest'; config(); @@ -13,6 +14,7 @@ const logger: Log75 = new (Log75 as any).default(LogLevel.Debug); const db = getDb(); const BRIDGED_MESSAGES: ICollection = db.get('bridged_messages'); const BRIDGE_CONFIG: ICollection = db.get('bridge_config'); +const BRIDGE_REQUESTS: ICollection = db.get('bridge_requests'); for (const v of [ 'REVOLT_TOKEN', 'DISCORD_TOKEN', 'DB_STRING' ]) { if (!process.env[v]) { @@ -28,4 +30,4 @@ for (const v of [ 'REVOLT_TOKEN', 'DISCORD_TOKEN', 'DB_STRING' ]) { ]); })(); -export { logger, db, BRIDGED_MESSAGES, BRIDGE_CONFIG } +export { logger, db, BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_REQUESTS } diff --git a/bridge/src/revolt/events.ts b/bridge/src/revolt/events.ts index c450a80..b2a4996 100644 --- a/bridge/src/revolt/events.ts +++ b/bridge/src/revolt/events.ts @@ -77,8 +77,36 @@ client.on('message', async message => { if (bridgedMsg) return logger.debug(`Revolt: Message has already been bridged; ignoring`); if (!bridgeCfg?.discord) return logger.debug(`Revolt: No Discord channel associated`); if (!bridgeCfg.discordWebhook) { - // Todo: Create a new webhook instead of exiting - return logger.debug(`Revolt: No Discord webhook stored`); + logger.debug(`Revolt: No Discord webhook stored; Creating new Webhook`); + + try { + const channel = await discordClient.channels.fetch(bridgeCfg.discord); + 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.channel_id }, + { + $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.channel_id }); + 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( @@ -103,8 +131,8 @@ client.on('message', async message => { ); const client = new WebhookClient({ - id: bridgeCfg.discordWebhook.id, - token: bridgeCfg.discordWebhook.token, + id: bridgeCfg.discordWebhook!.id, + token: bridgeCfg.discordWebhook!.token, }); const payload: MessagePayload|WebhookMessageOptions = { @@ -176,7 +204,15 @@ client.on('message', async message => { }); }) .catch(async e => { - console.error('Failed to execute webhook', e?.response?.data ?? e); + console.error('Failed to execute webhook:', e?.response?.data ?? e); + if (`${e}` == 'DiscordAPIError: Unknown Webhook') { + try { + logger.warn('Revolt: Got Unknown Webhook error, deleting webhook config'); + await BRIDGE_CONFIG.update({ revolt: message.channel_id }, { $set: { discordWebhook: undefined } }); + } catch(e) { + console.error(e); + } + } }); } catch(e) { console.error(e); diff --git a/bridge/src/types/BridgeRequest.ts b/bridge/src/types/BridgeRequest.ts new file mode 100644 index 0000000..9e22787 --- /dev/null +++ b/bridge/src/types/BridgeRequest.ts @@ -0,0 +1,9 @@ +export default class { + // Bridge request ID, needed to confirm link from Discord side + id: string; + + // The Revolt channel ID + revolt: string; + + expires: number; +} \ No newline at end of file diff --git a/bridge/yarn.lock b/bridge/yarn.lock index 688ea93..ca3507a 100644 --- a/bridge/yarn.lock +++ b/bridge/yarn.lock @@ -18,6 +18,25 @@ resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.4.0.tgz#b6488286a1cc7b41b644d7e6086f25a1c1e6f837" integrity sha512-zmjq+l/rV35kE6zRrwe8BHqV78JvIh2ybJeZavBi5NySjWXqN3hmmAKg7kYMMXSeiWtSsMoZ/+MQi0DiQWy2lw== +"@discordjs/collection@^0.7.0-dev": + version "0.7.0-dev.1650672508-3617093" + resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.7.0-dev.1650672508-3617093.tgz#2b418f650922b1e52b057b481558bb6b377bd4d2" + integrity sha512-Got8gPiFFEwY0tJo6hK/ZGvg8LFEYMyopchL/l5WjvN5YXDSKqlcSfWk3SqA9F8Eb2ZloauUoXY2B3uMMJUUBA== + +"@discordjs/rest@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@discordjs/rest/-/rest-0.4.1.tgz#d0a7e79df7a7f59bd01630013b3c70231e22a31d" + integrity sha512-rtWy+AIfNlfjGkAgA2TJLASdqli07aTNQceVGT6RQQiQaEqV0nsfBO4WtDlDzk7PmO3w+InP3dpwEolJI5jz0A== + dependencies: + "@discordjs/collection" "^0.7.0-dev" + "@sapphire/async-queue" "^1.3.1" + "@sapphire/snowflake" "^3.2.1" + "@types/node-fetch" "^2.6.1" + discord-api-types "^0.29.0" + form-data "^4.0.0" + node-fetch "^2.6.7" + tslib "^2.3.1" + "@insertish/exponential-backoff@3.1.0-patch.0": version "3.1.0-patch.0" resolved "https://registry.yarnpkg.com/@insertish/exponential-backoff/-/exponential-backoff-3.1.0-patch.0.tgz#1fff134f70fc0906d11d09069d51183b542e42cf" @@ -55,11 +74,16 @@ ulid "^2.3.0" ws "^8.2.2" -"@sapphire/async-queue@^1.1.9": +"@sapphire/async-queue@^1.1.9", "@sapphire/async-queue@^1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@sapphire/async-queue/-/async-queue-1.3.1.tgz#9d861e626dbffae02d808e13f823d4510e450a78" integrity sha512-FFTlPOWZX1kDj9xCAsRzH5xEJfawg1lNoYAA+ecOWJMHOfiZYb1uXOI3ne9U4UILSEPwfE68p3T9wUHwIQfR0g== +"@sapphire/snowflake@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@sapphire/snowflake/-/snowflake-3.2.1.tgz#027f217779ec7fd324d35cf941b3de49b4f67877" + integrity sha512-vmZq1I6J6iNRQVXP+N9HzOMOY4ORB3MunoFeWCw/aBnZTf1cDgDvP0RZFQS53B1TN95AIgFY9T+ItQ/fWAUYWQ== + "@sindresorhus/is@^4.2.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" @@ -80,7 +104,7 @@ "@types/bson" "*" "@types/node" "*" -"@types/node-fetch@^2.5.12": +"@types/node-fetch@^2.5.12", "@types/node-fetch@^2.6.1": version "2.6.1" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" integrity sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA== @@ -196,6 +220,16 @@ discord-api-types@^0.26.0: resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.26.1.tgz#726f766ddc37d60da95740991d22cb6ef2ed787b" integrity sha512-T5PdMQ+Y1MEECYMV5wmyi9VEYPagEDEi4S0amgsszpWY0VB9JJ/hEvM6BgLhbdnKky4gfmZEXtEEtojN8ZKJQQ== +discord-api-types@^0.29.0: + version "0.29.0" + resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.29.0.tgz#8346352b623ddd8d8eed386b6eb758e2d82d6005" + integrity sha512-Ekq1ICNpOTVajXKZguNFrsDeTmam+ZeA38txsNLZnANdXUjU6QBPIZLUQTC6MzigFGb0Tt8vk4xLnXmzv0shNg== + +discord-api-types@^0.31.2: + version "0.31.2" + resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.31.2.tgz#8d131e25340bd695815af3bb77128a6993c1b516" + integrity sha512-gpzXTvFVg7AjKVVJFH0oJGC0q0tO34iJGSHZNz9u3aqLxlD6LfxEs9wWVVikJqn9gra940oUTaPFizCkRDcEiA== + discord.js@^13.6.0: version "13.6.0" resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.6.0.tgz#d8a8a591dbf25cbcf9c783d5ddf22c4694860475" @@ -389,7 +423,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -node-fetch@^2.6.1: +node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==