diff --git a/bots/discord/.env.example b/bots/discord/.env.example new file mode 100644 index 0000000..2a9d1fb --- /dev/null +++ b/bots/discord/.env.example @@ -0,0 +1 @@ +DISCORD_TOKEN="YOUR-TOKEN-HERE" \ No newline at end of file diff --git a/bots/discord/.gitignore b/bots/discord/.gitignore new file mode 100644 index 0000000..e036eb1 --- /dev/null +++ b/bots/discord/.gitignore @@ -0,0 +1,178 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# DB +*.db \ No newline at end of file diff --git a/bots/discord/config.example.ts b/bots/discord/config.example.ts new file mode 100644 index 0000000..c4d1787 --- /dev/null +++ b/bots/discord/config.example.ts @@ -0,0 +1,80 @@ +export default { + owners: ['USER_ID_HERE'], + allowedGuilds: ['GUILD_ID_HERE'], + messageScan: { + channels: ['CHANNEL_ID_HERE'], + roles: ['ROLE_ID_HERE'], + users: ['USER_ID_HERE'], + whitelist: false, + humanCorrections: { + falsePositiveLabel: 'false_positive', + allowUsers: ['USER_ID_HERE'], + memberRequirements: { + permissions: 8n, + roles: ['ROLE_ID_HERE'], + }, + }, + allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + responses: [ + { + triggers: [/^regexp?$/, { label: 'label', threshold: 0.85 }], + response: { + title: 'Embed title', + description: 'Embed description', + fields: [ + { + name: 'Field name', + value: 'Field value', + }, + ], + }, + }, + ], + }, + logLevel: 'log', + api: { + websocketUrl: 'ws://127.0.0.1:3000', + }, +} as Config + +export type Config = { + owners: string[] + allowedGuilds: string[] + messageScan?: Partial<{ + roles: string[] + users: string[] + channels: string[] + humanCorrections: { + falsePositiveLabel: string + allowUsers?: string[] + memberRequirements?: { + permissions?: bigint + roles?: string[] + } + } + responses: ConfigMessageScanResponse[] + }> & { whitelist: boolean; allowedAttachmentMimeTypes: string[] } + logLevel: 'none' | 'error' | 'warn' | 'info' | 'log' | 'trace' | 'debug' + api: { + websocketUrl: string + } +} + +export type ConfigMessageScanResponse = { + triggers: Array + response: ConfigMessageScanResponseMessage | null +} + +export type ConfigMessageScanResponseLabelConfig = { + label: string + threshold: number +} + +export type ConfigMessageScanResponseMessage = { + title: string + description?: string + fields?: Array<{ + name: string + value: string + }> +} diff --git a/bots/discord/config.ts b/bots/discord/config.ts new file mode 100644 index 0000000..9e3eeff --- /dev/null +++ b/bots/discord/config.ts @@ -0,0 +1,80 @@ +import type { Config } from './config.example' + +export default { + owners: ['629368283354628116'], + allowedGuilds: ['1205207689832038522'], + messageScan: { + roles: [], + users: ['629368283354628116'], + channels: [], + whitelist: true, + humanCorrections: { + falsePositiveLabel: 'false_positive', + memberRequirements: { + permissions: 8n, + }, + }, + allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + responses: [ + { + triggers: [ + { + label: 'apps_youtube_buffer', + threshold: 0.85, + }, + ], + response: { + title: 'buffering :jawdroppinbro:', + }, + }, + { + triggers: [/eol/], + response: { + title: 'revenge eol', + description: 'eol', + }, + }, + { + triggers: [/free robux/i], + response: { + title: 'OMG FREE ROBUX????', + }, + }, + { + triggers: [/(hello|hi) world/], + response: { + title: 'Hello, World!', + description: 'This is a test response.', + }, + }, + { + triggers: [ + /how to download revanced/i, + { + label: 'download', + threshold: 0.85, + }, + ], + response: { + title: 'Where or how to get ReVanced ❓', + description: + 'You might have asked a question that has already been answered in <#953993848374325269>. Make sure to read it as it will answer a lot of your questions, guaranteed.', + fields: [ + { + name: '🔸 Regarding your question', + value: 'You can use ReVanced CLI or ReVanced Manager to get ReVanced. Refer to the documentation in <#953993848374325269> `3`.', + }, + ], + }, + }, + { + triggers: [{ label: 'false_positive', threshold: 0 }], + response: null, + }, + ], + }, + logLevel: 'debug', + api: { + websocketUrl: 'ws://127.0.0.1:3000', + }, +} as Config diff --git a/bots/discord/package.json b/bots/discord/package.json new file mode 100644 index 0000000..fd4ac1e --- /dev/null +++ b/bots/discord/package.json @@ -0,0 +1,36 @@ +{ + "name": "@revanced/discord-bot", + "type": "module", + "private": true, + "version": "0.1.0", + "description": "🤖 Discord bot assisting ReVanced", + "main": "dist/index.js", + "scripts": { + "register": "bun run scripts/reload-slash-commands.ts", + "dev": "bun --watch src/index.ts", + "reload-slash": "bun run scripts/reload-slash-commands.ts", + "build": "echo This command is not available yet && exit 1", + "watch": "bun dev" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/revanced/revanced-helper.git", + "directory": "bots/discord" + }, + "author": "Palm (https://github.com/PalmDevs)", + "contributors": [ + "Palm (https://github.com/PalmDevs)", + "ReVanced (https://github.com/revanced)" + ], + "license": "GPL-3.0-or-later", + "bugs": { + "url": "https://github.com/revanced/revanced-helper/issues" + }, + "homepage": "https://github.com/revanced/revanced-helper#readme", + "dependencies": { + "@revanced/bot-api": "workspace:*", + "@revanced/bot-shared": "workspace:*", + "chalk": "^5.3.0", + "discord.js": "^14.14.1" + } +} diff --git a/bots/discord/scripts/reload-slash-commands.ts b/bots/discord/scripts/reload-slash-commands.ts new file mode 100644 index 0000000..ec53ea6 --- /dev/null +++ b/bots/discord/scripts/reload-slash-commands.ts @@ -0,0 +1,42 @@ +import { REST } from '@discordjs/rest' +import { getMissingEnvironmentVariables } from '@revanced/bot-shared' +import { Routes } from 'discord-api-types/v9' +import type { RESTGetCurrentApplicationResult, RESTPutAPIApplicationCommandsResult } from 'discord.js' +import { config, discord } from '../src/context' + +// Check if token exists + +const missingEnvs = getMissingEnvironmentVariables(['DISCORD_TOKEN']) +if (missingEnvs.length) { + for (const env of missingEnvs) console.error(`${env} is not defined in environment variables`) + process.exit(1) +} + +const commands = Object.values(discord.commands) +const globalCommands = commands.filter(x => x.global && x.data.dm_permission) +const guildCommands = commands.filter(x => !x.global) + +const rest = new REST({ version: '10' }).setToken(process.env['DISCORD_TOKEN']!) + +try { + const app = (await rest.get(Routes.currentApplication())) as RESTGetCurrentApplicationResult + + if (typeof app === 'object' && app && 'id' in app && typeof app.id === 'string') { + const data = (await rest.put(Routes.applicationCommands(app.id), { + body: globalCommands.map(x => x.data), + })) as RESTPutAPIApplicationCommandsResult + + console.info(`Reloaded ${data.length} global commands.`) + + const guildCommandsMapped = guildCommands.map(x => x.data) + for (const guildId of config.allowedGuilds) { + const data = (await rest.put(Routes.applicationGuildCommands(app.id, guildId), { + body: guildCommandsMapped, + })) as RESTPutAPIApplicationCommandsResult + + console.info(`Reloaded ${data.length} guild commands for guild ${guildId}.`) + } + } +} catch (error) { + console.error(error) +} diff --git a/bots/discord/src/classes/Database.ts b/bots/discord/src/classes/Database.ts new file mode 100644 index 0000000..46db0bc --- /dev/null +++ b/bots/discord/src/classes/Database.ts @@ -0,0 +1,102 @@ +import { Database, type SQLQueryBindings, type Statement } from 'bun:sqlite' + +export class LabeledResponseDatabase { + readonly tableName = 'labeledResponses' + readonly tableStruct = ` + reply TEXT PRIMARY KEY NOT NULL, + channel TEXT NOT NULL, + guild TEXT NOT NULL, + referenceMessage TEXT NOT NULL, + label TEXT NOT NULL, + text TEXT NOT NULL, + correctedBy TEXT, + CHECK ( + typeof("text") = 'text' AND + length("text") > 0 AND + length("text") <= 280 + ) + ` + + #statements: { + save: Statement + edit: Statement + get: Statement + } + + constructor() { + // TODO: put in config + const db = new Database('responses.db', { + create: true, + readwrite: true, + }) + + db.run(`CREATE TABLE IF NOT EXISTS ${this.tableName} ( + ${this.tableStruct} + );`) + + this.#statements = { + save: db.prepare( + `INSERT INTO ${this.tableName} VALUES ($reply, $channel, $guild, $reference, $label, $text, NULL);`, + ), + edit: db.prepare( + `UPDATE ${this.tableName} SET label = $label, correctedBy = $correctedBy WHERE reply = $reply`, + ), + get: db.prepare(`SELECT * FROM ${this.tableName} WHERE reply = $reply`), + } as const + } + + save({ reply, channel, guild, referenceMessage, label, text }: Omit) { + const actualText = text.slice(0, 280) + this.#statements.save.run({ + $reply: reply, + $channel: channel, + $guild: guild, + $reference: referenceMessage, + $label: label, + $text: actualText, + }) + } + + get(reply: string) { + return this.#statements.get.get({ $reply: reply }) + } + + edit(reply: string, { label, correctedBy }: Pick) { + this.#statements.edit.run({ + $reply: reply, + $label: label, + $correctedBy: correctedBy, + }) + } +} + +export type LabeledResponse = { + /** + * The label of the response + */ + label: string + /** + * The ID of the user who corrected the response + */ + correctedBy: string | null + /** + * The text content of the response + */ + text: string + /** + * The ID of the message that triggered the response + */ + referenceMessage: string + /** + * The ID of the channel where the response was sent + */ + channel: string + /** + * The ID of the guild where the response was sent + */ + guild: string + /** + * The ID of the reply + */ + reply: string +} diff --git a/bots/discord/src/commands/index.ts b/bots/discord/src/commands/index.ts new file mode 100644 index 0000000..d9c2f87 --- /dev/null +++ b/bots/discord/src/commands/index.ts @@ -0,0 +1,50 @@ +import type { SlashCommandBuilder } from '@discordjs/builders' +import type { ChatInputCommandInteraction } from 'discord.js' + +// Temporary system +export type Command = { + data: ReturnType + // The function has to return void or Promise + // because TS may complain about some code paths not returning a value + /** + * The function to execute when this command is triggered + * @param interaction The interaction that triggered this command + */ + execute: (context: typeof import('../context'), interaction: ChatInputCommandInteraction) => Promise | void + memberRequirements?: { + /** + * The mode to use when checking for requirements. + * - `all` means that the user needs meet all requirements specified. + * - `any` means that the user needs to meet any of the requirements specified. + * + * @default "all" + */ + mode?: 'all' | 'any' + /** + * The permissions required to use this command (in BitFields). + * For safety reasons, this is set to `-1n` and only bot owners can use this command unless explicitly specified. + * + * - **0n** means that everyone can use this command. + * - **-1n** means that only bot owners can use this command. + * @default -1n + */ + permissions?: bigint + /** + * The roles required to use this command. + * By default, this is set to `[]`. + */ + roles?: string[] + } + /** + * Whether this command can only be used by bot owners. + * For safety reasons, this is set to `true` and only bot owners can use this command unless explicitly specified. + * @default true + */ + ownerOnly?: boolean + /** + * Whether to register this command as a global slash command. + * For safety reasons, this is set to `false` and commands will be registered in allowed guilds only. + * @default false + */ + global?: boolean +} diff --git a/bots/discord/src/commands/reply.ts b/bots/discord/src/commands/reply.ts new file mode 100644 index 0000000..b73e1c2 --- /dev/null +++ b/bots/discord/src/commands/reply.ts @@ -0,0 +1,54 @@ +import { PermissionFlagsBits, SlashCommandBuilder, type TextBasedChannel } from 'discord.js' +import type { Command } from '.' + +export default { + data: new SlashCommandBuilder() + .setName('reply') + .setDescription('Send a message as the bot') + .addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true)) + .addStringOption(option => + option + .setName('reference') + .setDescription('The message ID to reply to (use `latest` to reply to the latest message)') + .setRequired(false), + ) + .toJSON(), + + memberRequirements: { + mode: 'all', + roles: ['955220417969262612', '973886585294704640'], + permissions: PermissionFlagsBits.ManageMessages, + }, + + global: false, + + async execute({ logger }, interaction) { + const msg = interaction.options.getString('message', true) + const ref = interaction.options.getString('reference') + + const resolvedRef = ref?.startsWith('latest') + ? (await interaction.channel?.messages.fetch({ limit: 1 }))?.at(0)?.id + : ref + + try { + const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel | null + if (!channel) throw new Error('Channel not found (or not cached)') + + await channel.send({ + content: msg, + reply: { + messageReference: resolvedRef!, + failIfNotExists: true, + }, + }) + + logger.warn(`User ${interaction.user.tag} made the bot say: ${msg}`) + await interaction.reply({ + content: 'OK!', + ephemeral: true, + }) + } catch (e) { + await interaction.reply({}) + } + }, +} satisfies Command diff --git a/bots/discord/src/commands/stop.ts b/bots/discord/src/commands/stop.ts new file mode 100644 index 0000000..14199a6 --- /dev/null +++ b/bots/discord/src/commands/stop.ts @@ -0,0 +1,28 @@ +import { SlashCommandBuilder } from 'discord.js' +import type { Command } from '.' + +export default { + data: new SlashCommandBuilder().setName('stop').setDescription('Stops the bot').toJSON(), + + ownerOnly: true, + global: true, + + async execute({ api, logger }, interaction) { + api.isStopping = true + + logger.fatal('Stopping bot...') + await interaction.reply({ + content: 'Stopping... (I will go offline once done)', + ephemeral: true, + }) + + api.client.disconnect() + logger.warn('Disconnected from API') + + await interaction.client.destroy() + logger.warn('Disconnected from Discord API') + + logger.info(`Bot stopped, requested by ${interaction.user.id}`) + process.exit(0) + }, +} satisfies Command diff --git a/bots/discord/src/constants.ts b/bots/discord/src/constants.ts new file mode 100644 index 0000000..8e25126 --- /dev/null +++ b/bots/discord/src/constants.ts @@ -0,0 +1,9 @@ +export const MessageScanLabeledResponseReactions = { + train: '👍', + edit: '🔧', + delete: '❌', +} as const + +export const DefaultEmbedColor = '#4E98F0' +export const ReVancedLogoURL = + 'https://media.discordapp.net/attachments/1095487869923119144/1115436493050224660/revanced-logo.png' diff --git a/bots/discord/src/context.ts b/bots/discord/src/context.ts new file mode 100644 index 0000000..c09b7d0 --- /dev/null +++ b/bots/discord/src/context.ts @@ -0,0 +1,56 @@ +import { loadCommands } from '$utils/discord/commands' +import { Client as APIClient } from '@revanced/bot-api' +import { createLogger } from '@revanced/bot-shared' +import { ActivityType, Client as DiscordClient, Partials } from 'discord.js' +import config from '../config' +import { LabeledResponseDatabase } from './classes/Database' + +export { config } +export const logger = createLogger({ + level: config.logLevel === 'none' ? Number.MAX_SAFE_INTEGER : config.logLevel, +}) + +export const api = { + client: new APIClient({ + api: { + websocket: { + url: config.api.websocketUrl, + }, + }, + }), + isStopping: false, + disconnectCount: 0, +} + +export const database = { + labeledResponses: new LabeledResponseDatabase(), +} as const + +export const discord = { + client: new DiscordClient({ + intents: [ + 'Guilds', + 'GuildMembers', + 'GuildModeration', + 'GuildMessages', + 'GuildMessageReactions', + 'DirectMessages', + 'DirectMessageReactions', + 'MessageContent', + ], + allowedMentions: { + parse: ['users'], + repliedUser: true, + }, + partials: [Partials.Message, Partials.Reaction], + presence: { + activities: [ + { + type: ActivityType.Watching, + name: 'cat videos', + }, + ], + }, + }), + commands: await loadCommands(), +} as const diff --git a/bots/discord/src/events/api/disconnect.ts b/bots/discord/src/events/api/disconnect.ts new file mode 100644 index 0000000..98a3c2c --- /dev/null +++ b/bots/discord/src/events/api/disconnect.ts @@ -0,0 +1,30 @@ +import { on } from '$utils/api/events' +import { DisconnectReason, HumanizedDisconnectReason } from '@revanced/bot-shared' +import { api, logger } from 'src/context' + +on('disconnect', (reason, msg) => { + if (reason === DisconnectReason.PlannedDisconnect && api.isStopping) return + + const ws = api.client.ws + if (!ws.disconnected) ws.disconnect() + + logger.fatal( + `Disconnected from the bot API ${ + reason in HumanizedDisconnectReason + ? `because ${HumanizedDisconnectReason[reason as keyof typeof HumanizedDisconnectReason]}` + : 'for an unknown reason' + }`, + ) + + // TODO: move to config + if (api.disconnectCount >= 3) { + console.error(new Error('Disconnected from bot API too many times')) + // We don't want the process hanging + process.exit(1) + } + + logger.info( + `Disconnected from bot API ${++api.disconnectCount} times (this time because: ${reason}, ${msg}), reconnecting again...`, + ) + setTimeout(() => ws.connect(), 10000) +}) diff --git a/bots/discord/src/events/api/ready.ts b/bots/discord/src/events/api/ready.ts new file mode 100644 index 0000000..4df8ab9 --- /dev/null +++ b/bots/discord/src/events/api/ready.ts @@ -0,0 +1,6 @@ +import { on } from '$utils/api/events' +import { logger } from 'src/context' + +on('ready', () => { + logger.info('Connected to the bot API') +}) diff --git a/bots/discord/src/events/discord/guildCreate.ts b/bots/discord/src/events/discord/guildCreate.ts new file mode 100644 index 0000000..5d04774 --- /dev/null +++ b/bots/discord/src/events/discord/guildCreate.ts @@ -0,0 +1,7 @@ +import { on } from '$utils/discord/events' +import { leaveDisallowedGuild } from '$utils/discord/security' + +on('guildCreate', async ({ config }, guild) => { + if (config.allowedGuilds.includes(guild.id)) return + await leaveDisallowedGuild(guild) +}) diff --git a/bots/discord/src/events/discord/interactionCreate/chat-commmand.ts b/bots/discord/src/events/discord/interactionCreate/chat-commmand.ts new file mode 100644 index 0000000..bd1536d --- /dev/null +++ b/bots/discord/src/events/discord/interactionCreate/chat-commmand.ts @@ -0,0 +1,78 @@ +import { createErrorEmbed } from '$utils/discord/embeds' +import { on } from '$utils/discord/events' + +export default on('interactionCreate', async (context, interaction) => { + if (!interaction.isChatInputCommand()) return + + const { logger, discord, config } = context + const command = discord.commands[interaction.commandName] + + logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag}`) + + if (!command) { + logger.error(`Command ${interaction.commandName} not implemented but registered!!!`) + return void interaction.reply({ + embeds: [ + createErrorEmbed( + 'Command not implemented', + 'This command has not been implemented yet. Please report this to the developers.', + ), + ], + ephemeral: true, + }) + } + + const userIsOwner = config.owners.includes(interaction.user.id) + + if ((command.ownerOnly ?? true) && !userIsOwner) + return void (await interaction.reply({ + embeds: [createErrorEmbed('Massive skill issue', 'This command can only be used by the bot owners.')], + ephemeral: true, + })) + + if (interaction.inGuild()) { + // Bot owners get bypass + if (command.memberRequirements && !userIsOwner) { + const { permissions = -1n, roles = [], mode } = command.memberRequirements + + const member = await interaction.guild!.members.fetch(interaction.user.id) + + const [missingPermissions, missingRoles] = [ + // This command is an owner-only command (the user is not an owner, checked above) + permissions < 0n || + // or the user doesn't have the required permissions + (permissions >= 0n && !interaction.memberPermissions.has(permissions)), + + // If not: + !roles.some(x => member.roles.cache.has(x)), + ] + + if ((mode === 'any' && missingPermissions && missingRoles) || missingPermissions || missingRoles) + return void interaction.reply({ + embeds: [ + createErrorEmbed( + 'Missing roles or permissions', + "You don't have the required roles or permissions to use this command.", + ), + ], + ephemeral: true, + }) + } + } + + try { + logger.debug(`Command ${interaction.commandName} being executed`) + await command.execute(context, interaction) + } catch (err) { + logger.error(`Error while executing command ${interaction.commandName}:`, err) + await interaction.reply({ + embeds: [ + createErrorEmbed( + 'An error occurred while executing this command', + 'Please report this to the developers.', + ), + ], + ephemeral: true, + }) + } +}) diff --git a/bots/discord/src/events/discord/interactionCreate/correct-response.ts b/bots/discord/src/events/discord/interactionCreate/correct-response.ts new file mode 100644 index 0000000..9ded5dc --- /dev/null +++ b/bots/discord/src/events/discord/interactionCreate/correct-response.ts @@ -0,0 +1,111 @@ +import { handleUserResponseCorrection } from '$/utils/discord/messageScan' +import { createErrorEmbed, createStackTraceEmbed, createSuccessEmbed } from '$utils/discord/embeds' +import { on } from '$utils/discord/events' + +import type { ButtonInteraction, StringSelectMenuInteraction, TextBasedChannel } from 'discord.js' + +// No permission check required as it is already done when the user reacts to a bot response +export default on('interactionCreate', async (context, interaction) => { + const { + logger, + database: db, + config: { messageScan: msConfig }, + } = context + + if (!msConfig?.humanCorrections) return + if (!interaction.isStringSelectMenu() && !interaction.isButton()) return + if (!interaction.customId.startsWith('cr_')) return + + const [, key, action] = interaction.customId.split('_') as ['cr', string, 'select' | 'cancel' | 'delete'] + if (!key || !action) return + + const response = db.labeledResponses.get(key) + // If the message isn't saved in my DB (unrelated message) + if (!response) + return void (await interaction.reply({ + content: "I don't recall having sent this response, so I cannot correct it.", + ephemeral: true, + })) + + try { + // We're gonna pretend reactionChannel is a text-based channel, but it can be many more + // But `messages` should always exist as a property + const reactionGuild = await interaction.client.guilds.fetch(response.guild) + const reactionChannel = (await reactionGuild.channels.fetch(response.channel)) as TextBasedChannel | null + const reactionMessage = await reactionChannel?.messages.fetch(key) + + if (!reactionMessage) { + await interaction.deferUpdate() + await interaction.message.edit({ + content: null, + embeds: [ + createErrorEmbed( + 'Response not found', + 'Thank you for your feedback! Unfortunately, the response message could not be found (most likely deleted).', + ), + ], + components: [], + }) + + return + } + + const editMessage = (content: string, description?: string) => + editInteractionMessage(interaction, reactionMessage.url, content, description) + const handleCorrection = (label: string) => + handleUserResponseCorrection(context, response, reactionMessage, label, interaction.user) + + if (response.correctedBy) + return await editMessage( + 'Response already corrected', + 'Thank you for your feedback! Unfortunately, this response has already been corrected by someone else.', + ) + + // We immediately know that the action is `select` + if (interaction.isStringSelectMenu()) { + const selectedLabel = interaction.values[0]! + + await handleCorrection(selectedLabel) + await editMessage( + 'Message being trained', + `Thank you for your feedback! I've edited the response according to the selected label (\`${selectedLabel}\`). The message is now being trained. 🎉`, + ) + } else { + switch (action) { + case 'cancel': + await editMessage('Canceled', 'You canceled this interaction. 😞') + break + case 'delete': + await handleCorrection(msConfig.humanCorrections.falsePositiveLabel) + await editMessage( + 'Marked as false positive', + 'The response has been deleted and marked as a false positive. Thank you for your feedback. 🎉', + ) + + break + } + } + } catch (e) { + logger.error('Failed to handle correct response interaction:', e) + await interaction.reply({ + embeds: [createStackTraceEmbed(e)], + ephemeral: true, + }) + } +}) + +const editInteractionMessage = async ( + interaction: StringSelectMenuInteraction | ButtonInteraction, + replyUrl: string, + title: string, + description?: string, +) => { + if (!interaction.deferred) await interaction.deferUpdate() + await interaction.message.edit({ + content: null, + embeds: [ + createSuccessEmbed(title, `${description ?? ''}\n\n**⬅️ Back to bot response**: ${replyUrl}`.trimStart()), + ], + components: [], + }) +} diff --git a/bots/discord/src/events/discord/messageCreate/scan.ts b/bots/discord/src/events/discord/messageCreate/scan.ts new file mode 100644 index 0000000..67bd10e --- /dev/null +++ b/bots/discord/src/events/discord/messageCreate/scan.ts @@ -0,0 +1,70 @@ +import { MessageScanLabeledResponseReactions } from '$/constants' +import { getResponseFromContent, shouldScanMessage } from '$/utils/discord/messageScan' +import { createMessageScanResponseEmbed } from '$utils/discord/embeds' +import { on } from '$utils/discord/events' + +on('messageCreate', async (ctx, msg) => { + const { + api, + config: { messageScan: config }, + database: db, + logger, + } = ctx + + if (!config || !config.responses) return + if (!shouldScanMessage(msg, config)) return + + if (msg.content.length) { + logger.debug(`Classifying message ${msg.id}`) + + const { response, label } = await getResponseFromContent(msg.content, ctx) + + if (response) { + logger.debug('Response found') + + const reply = await msg.reply({ + embeds: [createMessageScanResponseEmbed(response)], + }) + + if (label) + db.labeledResponses.save({ + reply: reply.id, + channel: reply.channel.id, + guild: reply.guild.id, + referenceMessage: msg.id, + label, + text: msg.content, + }) + + if (label) { + for (const reaction of Object.values(MessageScanLabeledResponseReactions)) { + await reply.react(reaction) + } + } + } + } + + if (msg.attachments.size > 0) { + logger.debug(`Classifying message attachments for ${msg.id}`) + + for (const attachment of msg.attachments.values()) { + if (attachment.contentType && !config.allowedAttachmentMimeTypes.includes(attachment.contentType)) continue + + try { + const { text: content } = await api.client.parseImage(attachment.url) + const { response } = await getResponseFromContent(content, ctx) + + if (response) { + logger.debug(`Response found for attachment: ${attachment.url}`) + await msg.reply({ + embeds: [createMessageScanResponseEmbed(response)], + }) + + break + } + } catch { + logger.error(`Failed to parse image: ${attachment.url}`) + } + } + } +}) diff --git a/bots/discord/src/events/discord/messageReactionAdd/correct-response.ts b/bots/discord/src/events/discord/messageReactionAdd/correct-response.ts new file mode 100644 index 0000000..5ef962b --- /dev/null +++ b/bots/discord/src/events/discord/messageReactionAdd/correct-response.ts @@ -0,0 +1,130 @@ +import { MessageScanLabeledResponseReactions as Reactions } from '$/constants' +import { createErrorEmbed, createStackTraceEmbed, createSuccessEmbed } from '$/utils/discord/embeds' +import { on } from '$/utils/discord/events' + +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, +} from 'discord.js' + +import { handleUserResponseCorrection } from '$/utils/discord/messageScan' +import type { ConfigMessageScanResponseLabelConfig } from 'config.example' + +const PossibleReactions = Object.values(Reactions) as string[] + +on('messageReactionAdd', async (context, rct, user) => { + if (user.bot) return + + const { database: db, logger, config } = context + const { messageScan: msConfig } = config + + // If there's no config, we can't do anything + if (!msConfig?.humanCorrections) return + + const reaction = await rct.fetch() + const reactionMessage = await reaction.message.fetch() + + if (reactionMessage.author.id !== reaction.client.user!.id) return + if (!PossibleReactions.includes(reaction.emoji.name!)) return + + if (reactionMessage.inGuild() && msConfig.humanCorrections.memberRequirements) { + const { + memberRequirements: { roles, permissions }, + } = msConfig.humanCorrections + + if (!roles && !permissions) + return void logger.warn( + 'No member requirements specified for human corrections, ignoring this request for security reasons', + ) + + const member = await reactionMessage.guild.members.fetch(user.id) + + if ( + permissions && + !member.permissions.has(permissions) && + roles && + !roles.some(role => member.roles.cache.has(role)) + ) + return + // User is not owner, and not included in allowUsers + } else if (!config.owners.includes(user.id) && !msConfig.humanCorrections.allowUsers?.includes(user.id)) return + + // Sanity check + const response = db.labeledResponses.get(rct.message.id) + if (!response || response.correctedBy) return + + const handleCorrection = (label: string) => + handleUserResponseCorrection(context, response, reactionMessage, label, user) + + try { + if (reaction.emoji.name === Reactions.train) { + // Bot is right, nice! + + await handleCorrection(response.label) + await user.send({ embeds: [createSuccessEmbed('Trained message', 'Thank you for your feedback.')] }) + } else if (reaction.emoji.name === Reactions.edit) { + // Bot is wrong :( + + const labels = msConfig.responses!.flatMap(r => + r.triggers.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label), + ) + + const componentPrefix = `cr_${reactionMessage.id}` + const select = new StringSelectMenuBuilder().setCustomId(`${componentPrefix}_select`) + + for (const label of labels) { + const opt = new StringSelectMenuOptionBuilder().setLabel(label).setValue(label) + + if (label === response.label) { + opt.setDefault(true) + opt.setLabel(`${label} (current)`) + opt.setDescription('This is the current label of the message') + } + + select.addOptions(opt) + } + + const rows = [ + new ActionRowBuilder().addComponents(select), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setEmoji('⬅️') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + .setCustomId(`${componentPrefix}_cancel`), + new ButtonBuilder() + .setEmoji(Reactions.delete) + .setLabel('Delete (mark as false positive)') + .setStyle(ButtonStyle.Danger) + .setCustomId(`${componentPrefix}_delete`), + ), + ] + + await user.send({ + content: 'Please pick the right label for the message (you can only do this once!)', + components: rows, + }) + } else if (reaction.emoji.name === Reactions.delete) { + await handleCorrection(msConfig.humanCorrections.falsePositiveLabel) + await user.send({ content: 'The response has been deleted and marked as a false positive.' }) + } + } catch (e) { + logger.error('Failed to correct response:', e) + user.send({ + embeds: [createStackTraceEmbed(e)], + }).catch(() => { + reactionMessage.reply({ + content: `<@${user.id}>`, + embeds: [ + createErrorEmbed( + 'Enable your DMs!', + 'I cannot send you messages. Please enable your DMs to use this feature.', + ), + ], + }) + }) + } +}) diff --git a/bots/discord/src/events/discord/ready.ts b/bots/discord/src/events/discord/ready.ts new file mode 100644 index 0000000..72f4bf1 --- /dev/null +++ b/bots/discord/src/events/discord/ready.ts @@ -0,0 +1,8 @@ +import { on } from 'src/utils/discord/events' + +export default on('ready', ({ logger }, client) => { + logger.info(`Connected to Discord API, logged in as ${client.user.displayName} (@${client.user.tag})!`) + logger.info( + `Bot is in ${client.guilds.cache.size} guilds, if this is not expected, please run the /leave-unknowns command`, + ) +}) diff --git a/bots/discord/src/index.ts b/bots/discord/src/index.ts new file mode 100644 index 0000000..239a244 --- /dev/null +++ b/bots/discord/src/index.ts @@ -0,0 +1,25 @@ +import { listAllFilesRecursive } from '$utils/fs' +import { getMissingEnvironmentVariables } from '@revanced/bot-shared' +import { api, discord, logger } from './context' + +for (const apiEvents of await listAllFilesRecursive('src/events/api')) { + await import(apiEvents) +} + +const { client: apiClient } = api +await apiClient.ws.connect() + +for (const discordEvents of await listAllFilesRecursive('src/events/discord')) { + await import(discordEvents) +} + +const { client: discordClient } = discord + +// Check if token exists +const missingEnvs = getMissingEnvironmentVariables(['DISCORD_TOKEN']) +if (missingEnvs.length) { + for (const env of missingEnvs) logger.fatal(`${env} is not defined in environment variables`) + process.exit(1) +} + +await discordClient.login() diff --git a/bots/discord/src/types.d.ts b/bots/discord/src/types.d.ts new file mode 100644 index 0000000..5e2602f --- /dev/null +++ b/bots/discord/src/types.d.ts @@ -0,0 +1,4 @@ +type IfExtends = T extends U ? True : False +type IfTrue = IfExtends +type EmptyObject = Record +type ValuesOf = T[keyof T] diff --git a/bots/discord/src/utils/api/events.ts b/bots/discord/src/utils/api/events.ts new file mode 100644 index 0000000..140fbf9 --- /dev/null +++ b/bots/discord/src/utils/api/events.ts @@ -0,0 +1,15 @@ +import type { ClientWebSocketEvents } from '@revanced/bot-api' +import { api } from '../../context' + +const { client } = api + +export function on(event: Event, listener: ListenerOf) { + client.on(event, listener) +} + +export function once(event: Event, listener: ListenerOf) { + client.once(event, listener) +} + +export type EventName = keyof ClientWebSocketEvents +export type ListenerOf = ClientWebSocketEvents[Event] diff --git a/bots/discord/src/utils/discord/commands.ts b/bots/discord/src/utils/discord/commands.ts new file mode 100644 index 0000000..008d80c --- /dev/null +++ b/bots/discord/src/utils/discord/commands.ts @@ -0,0 +1,19 @@ +import type { Command } from '$commands' +import { listAllFilesRecursive } from '$utils/fs' + +export const loadCommands = async () => { + const commandsMap: Record = {} + const files = await listAllFilesRecursive('src/commands') + const commands = await Promise.all( + files.map(async file => { + const command = await import(file) + return command.default + }), + ) + + for (const command of commands) { + if (command) commandsMap[command.data.name] = command + } + + return commandsMap +} diff --git a/bots/discord/src/utils/discord/embeds.ts b/bots/discord/src/utils/discord/embeds.ts new file mode 100644 index 0000000..035fd11 --- /dev/null +++ b/bots/discord/src/utils/discord/embeds.ts @@ -0,0 +1,48 @@ +import { DefaultEmbedColor, ReVancedLogoURL } from '$/constants' +import { EmbedBuilder } from 'discord.js' +import type { ConfigMessageScanResponseMessage } from '../../../config.example' + +export const createErrorEmbed = (title: string, description?: string) => + applyCommonStyles( + new EmbedBuilder() + .setTitle(title) + .setDescription(description ?? null) + .setAuthor({ name: 'Error' }) + .setColor('Red'), + false, + ) + +export const createStackTraceEmbed = (stack: unknown) => + // biome-ignore lint/style/useTemplate: shut + createErrorEmbed('An exception was thrown', '```js' + stack + '```') + +export const createSuccessEmbed = (title: string, description?: string) => + applyCommonStyles( + new EmbedBuilder() + .setTitle(title) + .setDescription(description ?? null) + .setAuthor({ name: 'Success' }) + .setColor('Green'), + false, + ) + +export const createMessageScanResponseEmbed = (response: ConfigMessageScanResponseMessage) => { + const embed = new EmbedBuilder().setTitle(response.title) + + if (response.description) embed.setDescription(response.description) + if (response.fields) embed.addFields(response.fields) + + return applyCommonStyles(embed) +} + +const applyCommonStyles = (embed: EmbedBuilder, setColor = true, setThumbnail = true) => { + embed.setFooter({ + text: 'ReVanced', + iconURL: ReVancedLogoURL, + }) + + if (setColor) embed.setColor(DefaultEmbedColor) + if (setThumbnail) embed.setThumbnail(ReVancedLogoURL) + + return embed +} diff --git a/bots/discord/src/utils/discord/events.ts b/bots/discord/src/utils/discord/events.ts new file mode 100644 index 0000000..97e0205 --- /dev/null +++ b/bots/discord/src/utils/discord/events.ts @@ -0,0 +1,19 @@ +import * as context from '$/context' +import type { ClientEvents } from 'discord.js' + +const { client } = context.discord + +export const on = (event: Event, listener: ListenerOf) => + client.on(event, (...args) => listener(context, ...args)) + +export const once = (event: Event, listener: ListenerOf) => + client.once(event, (...args) => listener(context, ...args)) + +export type EventName = keyof ClientEvents +export type EventMap = { + [K in EventName]: ListenerOf +} + +type ListenerOf = ( + ...args: [typeof import('$/context'), ...ClientEvents[Event]] +) => void | Promise diff --git a/bots/discord/src/utils/discord/messageScan.ts b/bots/discord/src/utils/discord/messageScan.ts new file mode 100644 index 0000000..4cd025a --- /dev/null +++ b/bots/discord/src/utils/discord/messageScan.ts @@ -0,0 +1,140 @@ +import type { LabeledResponse } from '$/classes/Database' +import type { Config, ConfigMessageScanResponseLabelConfig, ConfigMessageScanResponseMessage } from 'config.example' +import type { Message, PartialUser, User } from 'discord.js' +import { createMessageScanResponseEmbed } from './embeds' + +export const getResponseFromContent = async ( + content: string, + { api, logger, config: { messageScan: config } }: typeof import('src/context'), +) => { + if (!config || !config.responses) { + logger.warn('No message scan config found') + + return { + response: null, + label: undefined, + } + } + + let label: string | undefined + let response: ConfigMessageScanResponseMessage | undefined | null + const firstLabelIndexes: number[] = [] + + // Test if all regexes before a label trigger is matched + for (const trigger of config.responses) { + const { triggers, response: resp } = trigger + if (response) break + + for (let i = 0; i < triggers.length; i++) { + const trigger = triggers[i]! + + if (trigger instanceof RegExp) { + if (trigger.test(content)) { + logger.debug(`Message matched regex (before mode): ${trigger.source}`) + response = resp + break + } + } else { + firstLabelIndexes.push(i) + break + } + } + } + + // If none of the regexes match, we can search for labels immediately + if (!response) { + const scan = await api.client.parseText(content) + if (scan.labels.length) { + const matchedLabel = scan.labels[0]! + logger.debug(`Message matched label with confidence: ${matchedLabel.name}, ${matchedLabel.confidence}`) + + let triggerConfig: ConfigMessageScanResponseLabelConfig | undefined + const labelConfig = config.responses.find(x => { + const config = x.triggers.find( + (x): x is ConfigMessageScanResponseLabelConfig => 'label' in x && x.label === matchedLabel.name, + ) + if (config) triggerConfig = config + return config + }) + + if (!labelConfig) { + logger.warn(`No label config found for label ${matchedLabel.name}`) + return { response: null, label: undefined } + } + + if (matchedLabel.confidence >= triggerConfig!.threshold) { + logger.debug('Label confidence is enough') + label = matchedLabel.name + response = labelConfig.response + } + } + } + + // If we still don't have a label, we can match all regexes after the initial label trigger + if (!response) + for (let i = 0; i < config.responses.length; i++) { + const { triggers, response: resp } = config.responses[i]! + const firstLabelIndex = firstLabelIndexes[i] ?? -1 + + for (let i = firstLabelIndex + 1; i < triggers.length; i++) { + const trigger = triggers[i]! + + if (trigger instanceof RegExp) { + if (trigger.test(content)) { + logger.debug(`Message matched regex (after mode): ${trigger.source}`) + response = resp + break + } + } + } + } + + return { + response, + label, + } +} + +export const shouldScanMessage = ( + message: Message, + config: NonNullable, +): message is Message => { + if (message.author.bot) return false + if (!message.guild) return false + + const filters = [ + config.users?.includes(message.author.id), + message.member?.roles.cache.some(x => config.roles?.includes(x.id)), + config.channels?.includes(message.channel.id), + ] + + if (config.whitelist && filters.every(x => !x)) return false + if (!config.whitelist && filters.some(x => x)) return false + + return true +} + +export const handleUserResponseCorrection = async ( + { api, database: db, config: { messageScan: msConfig }, logger }: typeof import('$/context'), + response: LabeledResponse, + reply: Message, + label: string, + user: User | PartialUser, +) => { + const correctLabelResponse = msConfig!.responses!.find(r => r.triggers.some(t => 'label' in t && t.label === label)) + + if (!correctLabelResponse) throw new Error('Cannot find label config for the selected label') + if (!correctLabelResponse.response) return void (await reply.delete()) + + if (response.label !== label) { + db.labeledResponses.edit(response.reply, { label, correctedBy: user.id }) + await reply.edit({ + embeds: [createMessageScanResponseEmbed(correctLabelResponse.response)], + }) + } + + await api.client.trainMessage(response.text, label) + logger.debug(`User ${user.id} trained message ${response.reply} as ${label} (positive)`) + + await reply.reactions.removeAll() +} diff --git a/bots/discord/src/utils/discord/security.ts b/bots/discord/src/utils/discord/security.ts new file mode 100644 index 0000000..a99ddfc --- /dev/null +++ b/bots/discord/src/utils/discord/security.ts @@ -0,0 +1,14 @@ +import type { Guild, GuildManager } from 'discord.js' +import { config, logger } from '../../context' + +export function leaveDisallowedGuild(guild: Guild) { + logger.warn(`Server ${guild.name} (${guild.id}) is not allowed to use this bot.`) + return guild.leave().then(() => logger.debug(`Left guild ${guild.name} (${guild.id})`)) +} + +export async function leaveDisallowedGuilds(guildManager: GuildManager) { + const guilds = await guildManager.fetch() + for (const [id, guild] of guilds) { + if (!config.allowedGuilds.includes(id)) await leaveDisallowedGuild(await guild.fetch()) + } +} diff --git a/bots/discord/src/utils/fs.ts b/bots/discord/src/utils/fs.ts new file mode 100644 index 0000000..6bc0e4f --- /dev/null +++ b/bots/discord/src/utils/fs.ts @@ -0,0 +1,17 @@ +import { join } from 'path' +import { readdir, stat } from 'fs/promises' + +export async function listAllFilesRecursive(dir: string): Promise { + const files = await readdir(dir) + const result: string[] = [] + for (const file of files) { + const filePath = join(dir, file) + const fileStat = await stat(filePath) + if (fileStat.isDirectory()) { + result.push(...(await listAllFilesRecursive(filePath))) + } else { + result.push(filePath) + } + } + return result +} diff --git a/bots/discord/tsconfig.json b/bots/discord/tsconfig.json new file mode 100755 index 0000000..4729956 --- /dev/null +++ b/bots/discord/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext"], + "composite": false, + "exactOptionalPropertyTypes": false, + "esModuleInterop": true, + "paths": { + "$/*": ["./src/*"], + "$constants": ["./src/constants"], + "$utils/*": ["./src/utils/*"], + "$classes": ["./src/classes/index.ts"], + "$classes/*": ["./src/classes/*"], + "$commands": ["./src/commands/index.ts"], + "$commands/*": ["./src/commands/*"] + }, + "skipLibCheck": true + }, + "exclude": ["node_modules", "dist"], + "include": ["./*.json", "src/**/*.ts", "scripts/**/*.ts"] +}