mirror of
https://github.com/ReVanced/revanced-bots.git
synced 2026-01-18 00:33:59 +00:00
feat(bots/discord): add source
This commit is contained in:
1
bots/discord/.env.example
Normal file
1
bots/discord/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DISCORD_TOKEN="YOUR-TOKEN-HERE"
|
||||||
178
bots/discord/.gitignore
vendored
Normal file
178
bots/discord/.gitignore
vendored
Normal file
@@ -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
|
||||||
80
bots/discord/config.example.ts
Normal file
80
bots/discord/config.example.ts
Normal file
@@ -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<RegExp | ConfigMessageScanResponseLabelConfig>
|
||||||
|
response: ConfigMessageScanResponseMessage | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigMessageScanResponseLabelConfig = {
|
||||||
|
label: string
|
||||||
|
threshold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigMessageScanResponseMessage = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
fields?: Array<{
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
80
bots/discord/config.ts
Normal file
80
bots/discord/config.ts
Normal file
@@ -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
|
||||||
36
bots/discord/package.json
Normal file
36
bots/discord/package.json
Normal file
@@ -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 <palmpasuthorn@gmail.com> (https://github.com/PalmDevs)",
|
||||||
|
"contributors": [
|
||||||
|
"Palm <palmpasuthorn@gmail.com> (https://github.com/PalmDevs)",
|
||||||
|
"ReVanced <nosupport@revanced.app> (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"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
bots/discord/scripts/reload-slash-commands.ts
Normal file
42
bots/discord/scripts/reload-slash-commands.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
102
bots/discord/src/classes/Database.ts
Normal file
102
bots/discord/src/classes/Database.ts
Normal file
@@ -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<LabeledResponse, SQLQueryBindings[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LabeledResponse, 'correctedBy'>) {
|
||||||
|
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<LabeledResponse, 'label' | 'correctedBy'>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
50
bots/discord/src/commands/index.ts
Normal file
50
bots/discord/src/commands/index.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { SlashCommandBuilder } from '@discordjs/builders'
|
||||||
|
import type { ChatInputCommandInteraction } from 'discord.js'
|
||||||
|
|
||||||
|
// Temporary system
|
||||||
|
export type Command = {
|
||||||
|
data: ReturnType<SlashCommandBuilder['toJSON']>
|
||||||
|
// The function has to return void or Promise<void>
|
||||||
|
// 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> | 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
|
||||||
|
}
|
||||||
54
bots/discord/src/commands/reply.ts
Normal file
54
bots/discord/src/commands/reply.ts
Normal file
@@ -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
|
||||||
28
bots/discord/src/commands/stop.ts
Normal file
28
bots/discord/src/commands/stop.ts
Normal file
@@ -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
|
||||||
9
bots/discord/src/constants.ts
Normal file
9
bots/discord/src/constants.ts
Normal file
@@ -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'
|
||||||
56
bots/discord/src/context.ts
Normal file
56
bots/discord/src/context.ts
Normal file
@@ -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
|
||||||
30
bots/discord/src/events/api/disconnect.ts
Normal file
30
bots/discord/src/events/api/disconnect.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
6
bots/discord/src/events/api/ready.ts
Normal file
6
bots/discord/src/events/api/ready.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { on } from '$utils/api/events'
|
||||||
|
import { logger } from 'src/context'
|
||||||
|
|
||||||
|
on('ready', () => {
|
||||||
|
logger.info('Connected to the bot API')
|
||||||
|
})
|
||||||
7
bots/discord/src/events/discord/guildCreate.ts
Normal file
7
bots/discord/src/events/discord/guildCreate.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
70
bots/discord/src/events/discord/messageCreate/scan.ts
Normal file
70
bots/discord/src/events/discord/messageCreate/scan.ts
Normal file
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<StringSelectMenuBuilder>().addComponents(select),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().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.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
8
bots/discord/src/events/discord/ready.ts
Normal file
8
bots/discord/src/events/discord/ready.ts
Normal file
@@ -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`,
|
||||||
|
)
|
||||||
|
})
|
||||||
25
bots/discord/src/index.ts
Normal file
25
bots/discord/src/index.ts
Normal file
@@ -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()
|
||||||
4
bots/discord/src/types.d.ts
vendored
Normal file
4
bots/discord/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type IfExtends<T, U, True, False> = T extends U ? True : False
|
||||||
|
type IfTrue<Condition, True, False> = IfExtends<Condition, true, True, False>
|
||||||
|
type EmptyObject<K = PropertyKey> = Record<K, never>
|
||||||
|
type ValuesOf<T> = T[keyof T]
|
||||||
15
bots/discord/src/utils/api/events.ts
Normal file
15
bots/discord/src/utils/api/events.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ClientWebSocketEvents } from '@revanced/bot-api'
|
||||||
|
import { api } from '../../context'
|
||||||
|
|
||||||
|
const { client } = api
|
||||||
|
|
||||||
|
export function on<Event extends EventName>(event: Event, listener: ListenerOf<Event>) {
|
||||||
|
client.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function once<Event extends EventName>(event: Event, listener: ListenerOf<Event>) {
|
||||||
|
client.once(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventName = keyof ClientWebSocketEvents
|
||||||
|
export type ListenerOf<Event extends EventName> = ClientWebSocketEvents[Event]
|
||||||
19
bots/discord/src/utils/discord/commands.ts
Normal file
19
bots/discord/src/utils/discord/commands.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Command } from '$commands'
|
||||||
|
import { listAllFilesRecursive } from '$utils/fs'
|
||||||
|
|
||||||
|
export const loadCommands = async () => {
|
||||||
|
const commandsMap: Record<string, Command> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
48
bots/discord/src/utils/discord/embeds.ts
Normal file
48
bots/discord/src/utils/discord/embeds.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
19
bots/discord/src/utils/discord/events.ts
Normal file
19
bots/discord/src/utils/discord/events.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as context from '$/context'
|
||||||
|
import type { ClientEvents } from 'discord.js'
|
||||||
|
|
||||||
|
const { client } = context.discord
|
||||||
|
|
||||||
|
export const on = <Event extends EventName>(event: Event, listener: ListenerOf<Event>) =>
|
||||||
|
client.on(event, (...args) => listener(context, ...args))
|
||||||
|
|
||||||
|
export const once = <Event extends EventName>(event: Event, listener: ListenerOf<Event>) =>
|
||||||
|
client.once(event, (...args) => listener(context, ...args))
|
||||||
|
|
||||||
|
export type EventName = keyof ClientEvents
|
||||||
|
export type EventMap = {
|
||||||
|
[K in EventName]: ListenerOf<K>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListenerOf<Event extends EventName> = (
|
||||||
|
...args: [typeof import('$/context'), ...ClientEvents[Event]]
|
||||||
|
) => void | Promise<void>
|
||||||
140
bots/discord/src/utils/discord/messageScan.ts
Normal file
140
bots/discord/src/utils/discord/messageScan.ts
Normal file
@@ -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<Config['messageScan']>,
|
||||||
|
): message is Message<true> => {
|
||||||
|
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()
|
||||||
|
}
|
||||||
14
bots/discord/src/utils/discord/security.ts
Normal file
14
bots/discord/src/utils/discord/security.ts
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
17
bots/discord/src/utils/fs.ts
Normal file
17
bots/discord/src/utils/fs.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { readdir, stat } from 'fs/promises'
|
||||||
|
|
||||||
|
export async function listAllFilesRecursive(dir: string): Promise<string[]> {
|
||||||
|
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
|
||||||
|
}
|
||||||
25
bots/discord/tsconfig.json
Executable file
25
bots/discord/tsconfig.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user