feat(bots/discord)!: read commit description

FEATURES:
- Updated documentation
- Improved configuration format
- Allow filter overriding for each response config (closes #29)
- Improved commands directory structure
- Improved slash command reload script
- New commands
- New command exception handling
This commit is contained in:
PalmDevs
2024-04-02 19:34:45 +07:00
parent a9add9ea9a
commit 7e5f6481c5
21 changed files with 542 additions and 558 deletions

View File

@@ -1,23 +1,28 @@
export default {
owners: ['USER_ID_HERE'],
allowedGuilds: ['GUILD_ID_HERE'],
guilds: ['GUILD_ID_HERE'],
messageScan: {
channels: ['CHANNEL_ID_HERE'],
roles: ['ROLE_ID_HERE'],
users: ['USER_ID_HERE'],
whitelist: false,
filter: {
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'],
allow: {
members: {
permissions: 8n,
roles: ['ROLE_ID_HERE'],
},
},
},
allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
responses: [
{
triggers: [/^regexp?$/, { label: 'label', threshold: 0.85 }],
triggers: {
text: [/^regexp?$/, { label: 'label', threshold: 0.85 }],
},
response: {
title: 'Embed title',
description: 'Embed description',
@@ -35,28 +40,31 @@ export default {
api: {
websocketUrl: 'ws://127.0.0.1:3000',
},
} as Config
} satisfies Config as Config
export type Config = {
owners: string[]
allowedGuilds: string[]
messageScan?: Partial<{
roles: string[]
users: string[]
channels: string[]
guilds: string[]
messageScan?: {
allowedAttachmentMimeTypes: string[]
filter: {
roles?: string[]
users?: string[]
channels?: string[]
whitelist: boolean
}
humanCorrections: {
falsePositiveLabel: string
allowUsers?: string[]
/**
* Match mode is set to Any
*/
memberRequirements?: {
permissions?: bigint
roles?: string[]
allow?: {
users?: string[]
members?: {
permissions?: bigint
roles?: string[]
}
}
}
responses: ConfigMessageScanResponse[]
}> & { whitelist: boolean; allowedAttachmentMimeTypes: string[] }
}
logLevel: 'none' | 'error' | 'warn' | 'info' | 'log' | 'trace' | 'debug'
api: {
websocketUrl: string
@@ -64,11 +72,11 @@ export type Config = {
}
export type ConfigMessageScanResponse = {
triggers: Array<RegExp | ConfigMessageScanResponseLabelConfig>
/**
* Extra triggers for text done via OCR
*/
ocrTriggers?: Array<RegExp>
triggers: {
text?: Array<RegExp | ConfigMessageScanResponseLabelConfig>
image?: Array<RegExp>
}
filterOverride?: NonNullable<Config['messageScan']>['filter']
response: ConfigMessageScanResponseMessage | null
}

View File

@@ -3,249 +3,32 @@ import type { Config } from './config.example'
export default {
owners: ['629368283354628116', '737323631117598811', '282584705218510848'],
allowedGuilds: ['952946952348270622'],
guilds: ['952946952348270622'],
messageScan: {
// Team, Mod, Immunity
roles: ['952987191401926697', '955220417969262612', '1027874293192863765'],
users: [],
// Team, Development
channels: ['952987428786941952', '953965039105232906'],
whitelist: false,
filter: {
// Team, Mod, Immunity
roles: ['952987191401926697', '955220417969262612', '1027874293192863765'],
users: [],
// Team, Development
channels: ['952987428786941952', '953965039105232906'],
whitelist: false,
},
humanCorrections: {
falsePositiveLabel: 'false_positive',
memberRequirements: {
// Team, Supporter
roles: ['952987191401926697', '1019903194941362198'],
permissions: PermissionFlagsBits.ManageMessages,
allow: {
members: {
// Team, Supporter
roles: ['952987191401926697', '1019903194941362198'],
permissions: PermissionFlagsBits.ManageMessages,
},
},
},
allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
responses: [
{
triggers: [
{
label: 'suggested_version',
threshold: 0.85,
},
],
reply: {
title: 'Which version is suggested ❓',
fields: [
{
name: '🔸 Regarding your question',
value: 'The suggested version can be seen in ReVanced Manager in the app selector screen. Refer to the ReVanced Manager documentation in <#953993848374325269> `3`.',
},
],
triggers: {
text: [{ label: 'false_positive', threshold: 0 }],
},
},
{
triggers: [
/(re)?v[ae]nced? crash/i,
{
label: 'revanced_crash',
threshold: 0.85,
},
],
response: {
title: 'Why am I experiencing crashes ❓',
fields: [
{
name: '🔸 Regarding your question',
value: 'You may have patched an unsuggested version of the app, changed the selection of patches or used a faulty APK. Refer to the documentation in <#953993848374325269> `3` in order to correctly patch your app correctly using ReVanced CLI or ReVanced Manager.',
},
],
},
},
{
triggers: [
/manager abort(ed)?/i,
{
label: 'rvmanager_abort',
threshold: 0.85,
},
],
response: {
title: 'Why is ReVanced Manager aborting ❓',
fields: [
{
name: '🔸 Regarding your question',
value: 'Your device may be unsupported by ReVanced Manager. Refer to the documentation in <#953993848374325269> `3` in order to use ReVanced CLI or check if your device is supported by ReVanced Manager.',
},
],
},
},
{
triggers: [
/(how|where|what).{0,15}(download|install|get) (re)?v[ae]nced?/i,
{
label: 'revanced_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: [
/(re)?v[ae]nced?( on)?( android)? tv/i,
{
label: 'androidtv_support',
threshold: 0.85,
},
],
response: {
title: 'Does ReVanced support YouTube for Android TVs ❓',
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: 'Please refer to <#953993848374325269> `5`. Alternative, there is [SmartTubeNext](https://github.com/yuliskov/SmartTubeNext#smarttube).',
},
],
},
},
{
triggers: [
{
label: 'revanced_nodownloader',
threshold: 0.85,
},
],
response: {
title: 'How do I download videos on YouTube ❓',
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: 'In order to be able to download videos on YouTube without YouTube Premium, you can patch YouTube with the `External downloads` patch. You can configure the downloader in the settings of the patched app. NewPipe is the default downloader. Please refer to <#953993848374325269> `24`.',
},
],
},
},
{
triggers: [
{
label: 'revanced_casting',
threshold: 0.85,
},
],
response: {
title: 'Why can I not cast videos on YouTube ❓',
fields: [
{
name: '🔸 Regarding your question',
value: 'You may have patched YouTube with the `GmsCore support` patch which makes YouTube use Vanced MicroG instead of Google Services, but Vanced MicroG does not reliably support casting. In order to be able to cast videos on the patched app, you should not patch the app with the `GmsCore support` patch, but then you are forced to mount the patched app with root permissions, because you will not be able to install the app in normal circumstances and Google Services will reject the patched app.',
},
],
},
},
{
triggers: [
/(where|what|how).{0,15}(get|install|download) ((vanced )?microg|gms(core)?)/i,
{
label: 'microg_download',
threshold: 0.85,
},
],
response: {
title: 'Where can I get GmsCore ❓',
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: 'If you patched YouTube using the `GmsCore support` patch, the patched app will redirect you to the download link of GmsCore if you open it. In case it does not, please refer to <#953993848374325269> `17`.',
},
],
},
},
{
triggers: [
{
label: 'microg_nointernet',
threshold: 0.85,
},
],
ocrTriggers: [/is not installed/],
response: {
title: 'Why does YouTube say, I am offline ❓',
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: 'Please refer to <#953993848374325269> `15`.',
},
],
},
},
{
triggers: [
/revanced\.[^a][^p]?[^p]?/i,
{
label: 'rvdownload_unofficial',
threshold: 0.85,
},
],
response: {
title: 'What are the official links of ReVanced ❓',
description: 'A list of official links can be found in <#954066838856273960>.',
fields: [
{
name: '🔸 Regarding your question',
value: 'ReVanced is always available at [revanced.app](https://revanced.app).',
},
],
},
},
{
triggers: [
/(re)?v[ae]nced?( videos?)? ((not )?loading|buffering)/i,
{
label: 'yt_buffering',
threshold: 0.85,
},
],
response: {
title: 'Why do videos fail to play❓',
description:
'You might have asked a question that has been answered in the <#953993848374325269> channel already. Make sure to read it as it will answer a lot of your questions, guaranteed.',
fields: [
{
name: '🔸 Regarding your question',
value: 'Please refer to <#953993848374325269> `32`.',
},
],
},
},
{
triggers: [],
ocrTriggers: [/You're offline|Please check your/],
response: {
title: 'Why does YouTube say, I am offline ❓',
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: 'Please refer to <#953993848374325269> `15`.',
},
],
},
},
{
triggers: [{ label: 'false_positive', threshold: 0 }],
response: null,
},
],
@@ -254,4 +37,4 @@ export default {
api: {
websocketUrl: 'ws://127.0.0.1:3000',
},
} as Config
} satisfies Config as Config

View File

@@ -1,59 +1,16 @@
# ⚙️ Configuration
This is the default configuration (provided in [config.ts](../config.ts)):
```ts
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;
```
This may look very overwhelming but configurating it is pretty easy.
You will need to copy `config.example.ts` to `config.ts` to be able to start the bot, as it is the default configuration.
---
### `config.owners`
User IDs of the owners of the bot. They'll be able to execute specific commands that others can't and take control of the bot.
User IDs of the owners of the bot. Only add owners when needed.
### `config.allowedGuilds`
### `config.guilds`
Servers the bot is allowed to be and register commands in. The bot will leave servers that are not in this list automatically once detected.
Servers the bot is allowed to be and register commands in.
### `config.logLevel`
@@ -71,57 +28,14 @@ The possible levels (sorted by their importance descendingly) are:
### `config.api.websocketUrl`
The WebSocket URL to connect to (including port).
The WebSocket URL to connect to (including port). Soon auto-discovery will be implemented.
### `config.messageScan`
Message scan configuration.
##### `config.messageScan.roles` & `config.messageScan.users` & `config.messageScan.channels`
Roles, users, and channels which will be affected by the blacklist/whitelist rule.
##### `config.messageScan.whitelist`
Whether to use whitelist (`true`) or blacklist (`false`) mode.
- Blacklist mode **will refuse** to scan messages of any roles or users who **are** in the list above.
- Whitelist mode **will refuse** to scan messages of any roles or users who **aren't** in the list above.
##### `config.messageScan.responses`
An array containing response configurations. A response can be triggered by multiple ways[^1], which can be specified in the `response.triggers` field.
The `response` field contains the embed data that the bot should send. If it is set to `null`, the bot will not send a response or delete the current response if editing.
> [!NOTE]
> If you want only OCR results to match a certain regular expression, you can put them into the `response.ocrTriggers` array.
```ts
{
triggers: [
/cool regex/i,
{
label: 'some_label',
threshold: 0.8,
},
],
response: {
title: 'Embed title',
description: 'Embed description',
fields: [
{
name: 'Field name',
value: 'Field value',
},
],
}
}
```
[^1]: Possible triggers are regular expressions or [label configurations](../config.example.ts#68).
[Please see the next page.](./2_adding_autoresponses.md)
## ⏭️ What's next
The next page will tell you how to run and bundle the bot.
The next page will tell you how to configure auto-responses.
Continue: [🏃🏻‍♂️ Running the bot](./2_running.md)
Continue: [🗣️ Adding auto-responses](./2_adding_autoresponses.md)

View File

@@ -0,0 +1,88 @@
# 🗣️ Adding auto-responses
This is referring to `config.messageScan`.
## 🧱 Filters
You can add filters to blacklist or whitelist a user from message scanning preventing auto-responses.
### `filter.roles` & `filter.users` & `filter.channels`
Roles, users, and channels which will be affected by the blacklist/whitelist rule.
### `filter.whitelist`
Whether to use whitelist (`true`) or blacklist (`false`) mode.
- Blacklist mode **will refuse** to scan messages that match any of the filters above
- Whitelist mode **will refuse** to scan messages that match any of the filters above.
## 💬 Responses
The `responses` field is array containing response configurations.
### Adding a message response
The `responses[n].response` field contains the embed data that the bot should send. If it is set to `null`, the bot will not send a response or delete the current response if editing (useful for catching false positives).
```ts
response: {
title: 'Embed title',
description: 'Embed description',
fields: [
{
name: 'Field name',
value: 'Field value',
},
],
}
// or if it's a false positive label (for example)
response: null
```
### Adding triggers
A response can be triggered by multiple ways[^1], which can be specified in the `response[n].triggers` object.
You can add a trigger for text messages which can either be a regular expression, or a label match config (NLP) into the `responses.triggers.text` array.
However, if you want **only OCR results** to match a certain regular expression, you can put them into the `response.triggers.image` array instead.
```ts
triggers: {
// Text messages
text: [
/cool regex/i,
{
label: 'some_label',
threshold: 0.8,
},
],
// Text messages with image attachments (OCR results)
image: [
/image regex/i
]
},
```
### Override a filter
You can also override the filter of the current response by supplying the [filter object](#configmessagescanfilter) into the `response.filterOverride` field.
```ts
filterOverride: {
// will only respond to members with this role
roles: ['ROLE_ID'],
// or in this channel
channels: ['CHANNEL_ID'],
whitelist: true,
},
```
[^1]: Possible triggers are regular expressions or [label configurations](../config.example.ts#68).
## ⏭️ What's next
The next page will tell you how to run and bundle the bot.
Continue: [🏃🏻‍♂️ Running the bot](./3_running.md)

View File

@@ -21,4 +21,4 @@ As a workaround, you can zip up the whole project, unzip, and run it in developm
The next page will tell you how to add commands and listen to events to the bot.
Continue: [✨ Adding commands and listening to events](./3_commands_and_events.md)
Continue: [✨ Adding commands and listening to events](./4_commands_and_events.md)

View File

@@ -107,4 +107,4 @@ API events are stored in [`src/events/api`](../src/events/api), and Discord even
The next page will tell you how to create and interact with a database.
Continue: [🫙 Storing data](./4_databases.md)
Continue: [🫙 Storing data](./5_databases.md)

View File

@@ -4,11 +4,12 @@ This documentation explains how to start developing, and how to configure the bo
## 📖 Table of contents
0. [🏗️ Set up the development environment (if you haven't already)](../../../docs/0_development_environment.md)
0. [🏗️ Set up the development environment (if you haven't already)](../../../docs/0_development_environment.md)
1. [⚙️ Configuration](./1_configuration.md)
2. [🏃🏻‍♂️ Running the server](./2_running.md)
3. [🗣️ Command and events](./3_commands_and_events.md)
4. [🫙 Storing data](./4_databases.md)
2. [🗣️ Adding auto-responses](./2_adding_autoresponses.md)
3. [🏃🏻‍♂️ Running the bot](./3_running.md)
4. [✨ Command and events](./4_commands_and_events.md)
5. [🫙 Storing data](./5_databases.md)
## ⏭️ Start here

View File

@@ -1,42 +1,50 @@
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'
import type {
RESTGetCurrentApplicationResult,
RESTPutAPIApplicationCommandsResult,
RESTPutAPIApplicationGuildCommandsResult,
} from 'discord.js'
import { config, discord, logger } 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`)
for (const env of missingEnvs) logger.fatal(`${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)
// Group commands by global and guild
const { global: globalCommands = [], guild: guildCommands = [] } = Object.groupBy(Object.values(discord.commands), c =>
c.global ? 'global' : 'guild',
)
// Set commands
const rest = new REST({ version: '10' }).setToken(process.env['DISCORD_TOKEN']!)
try {
const app = (await rest.get(Routes.currentApplication())) as RESTGetCurrentApplicationResult
const data = (await rest.put(Routes.applicationCommands(app.id), {
body: globalCommands.map(({ data }) => {
if (!data.dm_permission) data.dm_permission = true
logger.warn(`Command ${data.name} has no dm_permission set, forcing to true as it is a global command`)
return data
}),
})) as RESTPutAPIApplicationCommandsResult
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
logger.info(`Reloaded ${data.length} global commands`)
console.info(`Reloaded ${data.length} global commands.`)
for (const guildId of config.guilds) {
const data = (await rest.put(Routes.applicationGuildCommands(app.id, guildId), {
body: guildCommands.map(x => x.data),
})) as RESTPutAPIApplicationGuildCommandsResult
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}.`)
}
logger.info(`Reloaded ${data.length} guild commands for guild ${guildId}`)
}
} catch (error) {
console.error(error)
} catch (e) {
logger.fatal(e)
}

View File

@@ -0,0 +1,31 @@
import { createErrorEmbed } from '$/utils/discord/embeds'
export default class CommandError extends Error {
type: CommandErrorType
constructor(type: CommandErrorType, message?: string) {
super(message)
this.name = 'CommandError'
this.type = type
}
toEmbed() {
return createErrorEmbed(ErrorTitleMap[this.type], this.message ?? '')
}
}
export enum CommandErrorType {
Generic,
MissingArgument,
InvalidUser,
InvalidChannel,
InvalidDuration,
}
const ErrorTitleMap: Record<CommandErrorType, string> = {
[CommandErrorType.Generic]: 'An exception was thrown',
[CommandErrorType.MissingArgument]: 'Missing argument',
[CommandErrorType.InvalidUser]: 'Invalid user',
[CommandErrorType.InvalidChannel]: 'Invalid channel',
[CommandErrorType.InvalidDuration]: 'Invalid duration',
}

View File

@@ -0,0 +1,45 @@
import { SlashCommandBuilder } from 'discord.js'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder()
.setName('exception-test')
.setDescription('throw up pls')
.addStringOption(option =>
option
.setName('type')
.setDescription('The type of exception to throw')
.addChoices({
name: 'generic error',
value: 'Generic',
})
.addChoices({
name: 'invalid argument',
value: 'InvalidArgument',
})
.addChoices({
name: 'invalid channel',
value: 'InvalidChannel',
})
.addChoices({
name: 'invalid user',
value: 'InvalidUser',
})
.addChoices({
name: 'invalid duration',
value: 'InvalidDuration',
})
.setRequired(true),
)
.setDMPermission(true)
.toJSON(),
global: true,
async execute(_, interaction) {
const type = interaction.options.getString('type', true)
throw new CommandError(CommandErrorType[type as keyof typeof CommandErrorType], '[INTENTIONAL BOT DESIGN]')
},
} satisfies Command

View File

@@ -1,8 +1,9 @@
import { SlashCommandBuilder } from 'discord.js'
import type { Command } from '.'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder().setName('stop').setDescription('Stops the bot').toJSON(),
data: new SlashCommandBuilder().setName('stop').setDescription('Stops the bot').setDMPermission(true).toJSON(),
ownerOnly: true,
global: true,

View File

@@ -0,0 +1,34 @@
import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder().setName('coinflip').setDescription('Do a coinflip!').setDMPermission(true).toJSON(),
global: true,
async execute(_, interaction) {
const result = Math.random() < 0.5 ? ('heads' as const) : ('tails' as const)
const embed = applyCommonEmbedStyles(new EmbedBuilder().setTitle('Flipping... 🪙'), true, false, false)
await interaction.reply({
embeds: [embed.toJSON()],
})
embed.setTitle(`The coin landed on... **${result.toUpperCase()}**! ${EmojiMap[result]}`)
setTimeout(
() =>
interaction.editReply({
embeds: [embed.toJSON()],
}),
1500,
)
},
} satisfies Command
const EmojiMap: Record<'heads' | 'tails', string> = {
heads: '🤯',
tails: '🐈',
}

View File

@@ -0,0 +1,43 @@
import { 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: {
roles: ['955220417969262612', '973886585294704640'],
},
global: false,
async execute({ logger }, interaction) {
const msg = interaction.options.getString('message', true)
const ref = interaction.options.getString('reference')
const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel
const refMsg = ref?.startsWith('latest') ? (await channel.messages.fetch({ limit: 1 })).at(0)?.id : ref
await channel.send({
content: msg,
reply: refMsg ? { messageReference: refMsg, failIfNotExists: true } : undefined,
})
logger.info(`User ${interaction.user.tag} made the bot say: ${msg}`)
await interaction.reply({
content: 'OK!',
ephemeral: true,
})
},
} satisfies Command

View File

@@ -22,7 +22,6 @@ export type Command = {
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.
@@ -37,13 +36,12 @@ export type Command = {
}
/**
* 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
* @default false
*/
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.
* This is set to `false` and commands will be registered in allowed guilds only by default.
* @default false
*/
global?: boolean

View File

@@ -0,0 +1,58 @@
import { createSuccessEmbed } from '$/utils/discord/embeds'
import { durationToString, parseDuration } from '$/utils/duration'
import { SlashCommandBuilder } from 'discord.js'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder()
.setName('slowmode')
.setDescription('Set a slowmode for the current channel')
.addStringOption(option => option.setName('duration').setDescription('The duration to set').setRequired(true))
.addStringOption(option =>
option
.setName('channel')
.setDescription('The channel to set the slowmode on (defaults to current channel)')
.setRequired(false),
)
.toJSON(),
memberRequirements: {
roles: ['955220417969262612', '973886585294704640'],
},
global: false,
async execute({ logger }, interaction) {
const durationStr = interaction.options.getString('duration', true)
const id = interaction.options.getChannel('channel')?.id ?? interaction.channelId
const duration = parseDuration(durationStr)
const channel = await interaction.guild!.channels.fetch(id)
if (!channel?.isTextBased())
throw new CommandError(
CommandErrorType.InvalidChannel,
'The supplied channel is not a text channel or does not exist.',
)
if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidDuration, 'Invalid duration.')
if (duration < 0 || duration > 36e4)
throw new CommandError(
CommandErrorType.InvalidDuration,
'Duration out of range, must be between 0s and 6h.',
)
logger.info(`Setting slowmode to ${duration}ms on ${channel.id}`)
await channel.setRateLimitPerUser(
duration / 1000,
`Slowmode set by @${interaction.user.username} (${interaction.user.id})`,
)
await interaction.reply({
embeds: [createSuccessEmbed(`Slowmode set to ${durationToString(duration)} on ${channel.toString()}`)],
})
},
} satisfies Command

View File

@@ -1,57 +0,0 @@
import { createStackTraceEmbed } from '$/utils/discord/embeds'
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({
embeds: [createStackTraceEmbed(e)],
})
}
},
} satisfies Command

View File

@@ -1,3 +1,4 @@
import CommandError from '$/classes/CommandError'
import { createErrorEmbed, createStackTraceEmbed } from '$utils/discord/embeds'
import { on } from '$utils/discord/events'
@@ -8,40 +9,46 @@ export default on('interactionCreate', async (context, interaction) => {
const command = discord.commands[interaction.commandName]
logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag}`)
if (!command) return void logger.error(`Command ${interaction.commandName} not implemented but registered!!!`)
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 isOwner = config.owners.includes(interaction.user.id)
const userIsOwner = config.owners.includes(interaction.user.id)
if ((command.ownerOnly ?? true) && !userIsOwner)
/**
* Owner check
*/
if (command.ownerOnly && !isOwner)
return void (await interaction.reply({
embeds: [createErrorEmbed('Massive skill issue', 'This command can only be used by the bot owners.')],
ephemeral: true,
}))
/**
* Sanity check
*/
if (!command.global && !interaction.inGuild()) {
logger.error(`Command ${interaction.commandName} cannot be run in DMs, but was registered as global`)
await interaction.reply({
embeds: [createErrorEmbed('Cannot run that here', 'This command can only be used in a server.')],
ephemeral: true,
})
return
}
/**
* Permission checks
*/
if (interaction.inGuild()) {
// Bot owners get bypass
if (command.memberRequirements && !userIsOwner) {
const { permissions = -1n, roles = [], mode } = command.memberRequirements
if (command.memberRequirements && !isOwner) {
const { permissions = 0n, 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 ||
permissions <= 0n ||
// or the user doesn't have the required permissions
(permissions >= 0n && !interaction.memberPermissions.has(permissions)),
(permissions > 0n && !interaction.memberPermissions.has(permissions)),
// If not:
!roles.some(x => member.roles.cache.has(x)),
@@ -66,7 +73,7 @@ export default on('interactionCreate', async (context, interaction) => {
} catch (err) {
logger.error(`Error while executing command ${interaction.commandName}:`, err)
await interaction.reply({
embeds: [createStackTraceEmbed(err)],
embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)],
ephemeral: true,
})
}

View File

@@ -1,5 +1,5 @@
import { MessageScanLabeledResponseReactions } from '$/constants'
import { getResponseFromContent, shouldScanMessage } from '$/utils/discord/messageScan'
import { getResponseFromText, shouldScanMessage } from '$/utils/discord/messageScan'
import { createMessageScanResponseEmbed } from '$utils/discord/embeds'
import { on } from '$utils/discord/events'
@@ -12,35 +12,41 @@ on('messageCreate', async (ctx, msg) => {
} = ctx
if (!config || !config.responses) return
if (!shouldScanMessage(msg, config)) return
const filteredResponses = config.responses.filter(x => shouldScanMessage(msg, x.filterOverride ?? config.filter))
if (!filteredResponses.length) return
if (msg.content.length) {
logger.debug(`Classifying message ${msg.id}`)
try {
logger.debug(`Classifying message ${msg.id}`)
const { response, label } = await getResponseFromContent(msg.content, ctx)
const { response, label } = await getResponseFromText(msg.content, filteredResponses, ctx)
if (response) {
logger.debug('Response found')
if (response) {
logger.debug('Response found')
const reply = await msg.reply({
embeds: [createMessageScanResponseEmbed(response, label ? 'nlp' : 'match')],
})
if (label)
db.labeledResponses.save({
reply: reply.id,
channel: reply.channel.id,
guild: reply.guild.id,
referenceMessage: msg.id,
label,
text: msg.content,
const reply = await msg.reply({
embeds: [createMessageScanResponseEmbed(response, label ? 'nlp' : 'match')],
})
if (label) {
for (const reaction of Object.values(MessageScanLabeledResponseReactions)) {
await reply.react(reaction)
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)
}
}
}
} catch (e) {
logger.error('Failed to classify message:', e)
}
}
@@ -52,7 +58,7 @@ on('messageCreate', async (ctx, msg) => {
try {
const { text: content } = await api.client.parseImage(attachment.url)
const { response } = await getResponseFromContent(content, ctx, true)
const { response } = await getResponseFromText(content, filteredResponses, ctx, true)
if (response) {
logger.debug(`Response found for attachment: ${attachment.url}`)

View File

@@ -30,27 +30,31 @@ on('messageReactionAdd', async (context, rct, user) => {
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 (!config.owners.includes(user.id)) {
// User is in guild, and config has member requirements
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
reactionMessage.inGuild() &&
(msConfig.humanCorrections.allow?.members || msConfig.humanCorrections.allow?.users)
) {
const {
allow: { users: allowedUsers, members: allowedMembers },
} = msConfig.humanCorrections
if (allowedMembers) {
const member = await reactionMessage.guild.members.fetch(user.id)
const { permissions, roles } = allowedMembers
if (!(member.permissions.has(permissions ?? 0n) || roles?.some(role => member.roles.cache.has(role))))
return
} else if (allowedUsers) {
if (!allowedUsers.includes(user.id)) return
} else {
return void logger.warn(
'No member or user requirements set for human corrections, all requests will be ignored',
)
}
}
}
// Sanity check
const response = db.labeledResponses.get(rct.message.id)
@@ -69,7 +73,9 @@ on('messageReactionAdd', async (context, rct, user) => {
// Bot is wrong :(
const labels = msConfig.responses!.flatMap(r =>
r.triggers.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label),
r.triggers
.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t)
.map(t => t.label),
)
const componentPrefix = `cr_${reactionMessage.id}`

View File

@@ -1,55 +1,59 @@
import type { LabeledResponse } from '$/classes/Database'
import type { Config, ConfigMessageScanResponseLabelConfig, ConfigMessageScanResponseMessage } from 'config.example'
import type {
Config,
ConfigMessageScanResponse,
ConfigMessageScanResponseLabelConfig,
ConfigMessageScanResponseMessage,
} from 'config.example'
import type { Message, PartialUser, User } from 'discord.js'
import { createMessageScanResponseEmbed } from './embeds'
export const getResponseFromContent = async (
export const getResponseFromText = async (
content: string,
{ api, logger, config: { messageScan: config } }: typeof import('src/context'),
responses: ConfigMessageScanResponse[],
// Just to be safe that we will never use data from the context parameter
{ api, logger }: Omit<typeof import('src/context'), 'config'>,
ocrMode = false,
) => {
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 (let i = 0; i < config.responses.length; i++) {
const trigger = config.responses[i]!
for (let i = 0; i < responses.length; i++) {
const trigger = responses[i]!
const { triggers, ocrTriggers, response: resp } = trigger
// Filter override check is not neccessary here, we are already passing responses that match the filter
// from the messageCreate handler
const {
triggers: { text: textTriggers, image: imageTriggers },
response: resp,
} = trigger
if (response) break
if (ocrMode && ocrTriggers)
for (const regex of ocrTriggers)
if (regex.test(content)) {
logger.debug(`Message matched regex (OCR mode): ${regex.source}`)
response = resp
if (ocrMode) {
if (imageTriggers)
for (const regex of imageTriggers)
if (regex.test(content)) {
logger.debug(`Message matched regex (OCR mode): ${regex.source}`)
response = resp
break
}
} else
for (let j = 0; j < textTriggers!.length; j++) {
const trigger = textTriggers![j]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
logger.debug(`Message matched regex (before mode): ${trigger.source}`)
response = resp
break
}
} else {
firstLabelIndexes[i] = j
break
}
for (let j = 0; j < triggers.length; j++) {
const trigger = triggers[j]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
logger.debug(`Message matched regex (before mode): ${trigger.source}`)
response = resp
break
}
} else {
firstLabelIndexes[i] = j
break
}
}
}
// If none of the regexes match, we can search for labels immediately
@@ -61,8 +65,8 @@ export const getResponseFromContent = async (
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(
const labelConfig = responses.find(x => {
const config = x.triggers.text!.find(
(x): x is ConfigMessageScanResponseLabelConfig => 'label' in x && x.label === matchedLabel.name,
)
if (config) triggerConfig = config
@@ -85,12 +89,15 @@ export const getResponseFromContent = async (
// If we still don't have a label, we can match all regexes after the initial label trigger
if (!response) {
logger.debug('No match from NLP, doing after regexes')
for (let i = 0; i < config.responses.length; i++) {
const { triggers, response: resp } = config.responses[i]!
for (let i = 0; i < responses.length; i++) {
const {
triggers: { text: textTriggers },
response: resp,
} = responses[i]!
const firstLabelIndex = firstLabelIndexes[i] ?? -1
for (let i = firstLabelIndex + 1; i < triggers.length; i++) {
const trigger = triggers[i]!
for (let i = firstLabelIndex + 1; i < textTriggers!.length; i++) {
const trigger = textTriggers![i]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
@@ -111,19 +118,20 @@ export const getResponseFromContent = async (
export const shouldScanMessage = (
message: Message,
config: NonNullable<Config['messageScan']>,
filter: NonNullable<Config['messageScan']>['filter'],
): message is Message<true> => {
if (message.author.bot) return false
if (!message.guild) return false
if (!filter) return true
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),
filter.users?.includes(message.author.id),
message.member?.roles.cache.some(x => filter.roles?.includes(x.id)),
filter.channels?.includes(message.channel.id),
]
if (config.whitelist && filters.every(x => !x)) return false
if (!config.whitelist && filters.some(x => x)) return false
if (filter.whitelist && filters.every(x => !x)) return false
if (!filter.whitelist && filters.some(x => x)) return false
return true
}
@@ -135,7 +143,9 @@ export const handleUserResponseCorrection = async (
label: string,
user: User | PartialUser,
) => {
const correctLabelResponse = msConfig!.responses!.find(r => r.triggers.some(t => 'label' in t && t.label === label))
const correctLabelResponse = msConfig!.responses!.find(r =>
r.triggers.text!.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())