mirror of
https://github.com/ReVanced/revanced-bots.git
synced 2026-01-11 13:56:15 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
88
bots/discord/docs/2_adding_autoresponses.md
Normal file
88
bots/discord/docs/2_adding_autoresponses.md
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
31
bots/discord/src/classes/CommandError.ts
Normal file
31
bots/discord/src/classes/CommandError.ts
Normal 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',
|
||||
}
|
||||
45
bots/discord/src/commands/development/exception-test.ts
Normal file
45
bots/discord/src/commands/development/exception-test.ts
Normal 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
|
||||
@@ -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,
|
||||
34
bots/discord/src/commands/fun/coinflip.ts
Normal file
34
bots/discord/src/commands/fun/coinflip.ts
Normal 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: '🐈',
|
||||
}
|
||||
43
bots/discord/src/commands/fun/reply.ts
Normal file
43
bots/discord/src/commands/fun/reply.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
58
bots/discord/src/commands/moderation/slowmode.ts
Normal file
58
bots/discord/src/commands/moderation/slowmode.ts
Normal 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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user