Compare commits

...

25 Commits

Author SHA1 Message Date
semantic-release-bot
e0e40237fa chore(release): 1.0.0-dev.29 [skip ci]
# @revanced/discord-bot [1.0.0-dev.29](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.28...@revanced/discord-bot@1.0.0-dev.29) (2024-09-21)

### Bug Fixes

* **bots/discord:** fix get response logic ([3261294](3261294822))
2024-09-21 21:03:36 +00:00
PalmDevs
d3c56222be chore: update dependencies 2024-09-22 04:02:20 +07:00
PalmDevs
3261294822 fix(bots/discord): fix get response logic 2024-09-22 04:01:36 +07:00
semantic-release-bot
f035994f9e chore(release): 1.0.0-dev.28 [skip ci]
# @revanced/discord-bot [1.0.0-dev.28](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.27...@revanced/discord-bot@1.0.0-dev.28) (2024-09-20)

### Bug Fixes

* **bots/discord:** don't refresh timer if force timer is active for sticky messages ([4abac0c](4abac0c890))
* **bots/discord:** filter out text triggers correctly from image-only scans ([8c0dd67](8c0dd67d03))
2024-09-20 06:53:32 +00:00
PalmDevs
4abac0c890 fix(bots/discord): don't refresh timer if force timer is active for sticky messages 2024-09-20 13:51:56 +07:00
PalmDevs
8c0dd67d03 fix(bots/discord): filter out text triggers correctly from image-only scans 2024-09-20 13:49:38 +07:00
semantic-release-bot
7a379a2cae chore(release): 1.0.0-dev.27 [skip ci]
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-09-05)

### Bug Fixes

* **bots/discord:** correct permission check logic ([dd8872c](dd8872c027))
* **bots/discord:** give only removed roles for role presets ([522ad28](522ad28fd8))
* **bots/discord:** replace duration parser with a library ([94c4fed](94c4fedc06))
2024-09-05 10:34:11 +00:00
Palm
dd8872c027 fix(bots/discord): correct permission check logic
Members were being previously treated as users and some requirements are passing by default when they must not.
2024-09-05 17:33:06 +07:00
semantic-release-bot
33a0e18e7b chore(release): 1.0.0-dev.27 [skip ci]
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-08-23)

### Bug Fixes

* **bots/discord:** give only removed roles for role presets ([522ad28](522ad28fd8))
* **bots/discord:** replace duration parser with a library ([94c4fed](94c4fedc06))
2024-08-23 11:07:18 +00:00
PalmDevs
522ad28fd8 fix(bots/discord): give only removed roles for role presets 2024-08-23 18:05:48 +07:00
PalmDevs
94c4fedc06 fix(bots/discord): replace duration parser with a library 2024-08-23 17:59:02 +07:00
semantic-release-bot
e3dcbab508 chore(release): 1.0.0-dev.26 [skip ci]
# @revanced/discord-bot [1.0.0-dev.26](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.25...@revanced/discord-bot@1.0.0-dev.26) (2024-08-15)

### Bug Fixes

* **bots/discord:** correct timer active condition for sticky messages ([96065ff](96065ff175))
2024-08-15 15:34:29 +00:00
Palm
96065ff175 fix(bots/discord): correct timer active condition for sticky messages 2024-08-15 22:33:12 +07:00
semantic-release-bot
845dd5d914 chore(release): 1.0.0-dev.25 [skip ci]
# @revanced/discord-bot [1.0.0-dev.25](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.24...@revanced/discord-bot@1.0.0-dev.25) (2024-08-15)

### Bug Fixes

* **bots/discord:** allow access to `context` in `/eval` and await result ([99f65f0](99f65f07f5))
2024-08-15 05:11:31 +00:00
Palm
99f65f07f5 fix(bots/discord): allow access to context in /eval and await result 2024-08-15 12:10:04 +07:00
semantic-release-bot
51f877f321 chore(release): 1.0.0-dev.24 [skip ci]
# @revanced/discord-bot [1.0.0-dev.24](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.23...@revanced/discord-bot@1.0.0-dev.24) (2024-08-14)

### Bug Fixes

* **bots/discord:** do not remove unrelated reactions ([031fd26](031fd26b26))
2024-08-14 04:54:54 +00:00
PalmDevs
031fd26b26 fix(bots/discord): do not remove unrelated reactions 2024-08-14 11:53:29 +07:00
semantic-release-bot
ef07039083 chore(release): 1.0.0-dev.23 [skip ci]
# @revanced/discord-bot [1.0.0-dev.23](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.22...@revanced/discord-bot@1.0.0-dev.23) (2024-08-13)

### Features

* **bots/discord:** add `train` commands ([ee90ef2](ee90ef247b))
* **bots/discord:** update to newer command definition framework ([97f2795](97f2795df4))
2024-08-13 14:20:22 +00:00
PalmDevs
6dc7f0211e chore: update lockfile 2024-08-13 21:19:18 +07:00
PalmDevs
ee90ef247b feat(bots/discord): add train commands 2024-08-13 21:16:28 +07:00
PalmDevs
97f2795df4 feat(bots/discord): update to newer command definition framework 2024-08-13 21:16:26 +07:00
semantic-release-bot
82fac783ea chore(release): 1.0.0-dev.22 [skip ci]
# @revanced/discord-bot [1.0.0-dev.22](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.21...@revanced/discord-bot@1.0.0-dev.22) (2024-08-10)

### Bug Fixes

* **bots/discord:** parse larger units of durations, fix wrong timestamp in mod embed ([6c8dce0](6c8dce0593))
* **bots/discord:** provide discord token for `reload` command ([dd21a5a](dd21a5abad))

### Features

* **bots/discord:** add code to actually scan text files correctly ([80aeb19](80aeb19020))
2024-08-10 15:31:57 +00:00
PalmDevs
dd21a5abad fix(bots/discord): provide discord token for reload command 2024-08-10 22:30:45 +07:00
PalmDevs
80aeb19020 feat(bots/discord): add code to actually scan text files correctly 2024-08-10 22:30:45 +07:00
PalmDevs
6c8dce0593 fix(bots/discord): parse larger units of durations, fix wrong timestamp in mod embed 2024-08-10 22:30:44 +07:00
33 changed files with 760 additions and 255 deletions

View File

@@ -1,3 +1,77 @@
# @revanced/discord-bot [1.0.0-dev.29](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.28...@revanced/discord-bot@1.0.0-dev.29) (2024-09-21)
### Bug Fixes
* **bots/discord:** fix get response logic ([3261294](https://github.com/revanced/revanced-helper/commit/3261294822b0a9faec094536ed5be2d3e1d5e17b))
# @revanced/discord-bot [1.0.0-dev.28](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.27...@revanced/discord-bot@1.0.0-dev.28) (2024-09-20)
### Bug Fixes
* **bots/discord:** don't refresh timer if force timer is active for sticky messages ([4abac0c](https://github.com/revanced/revanced-helper/commit/4abac0c890c0548e14cb56723cae919353a8e726))
* **bots/discord:** filter out text triggers correctly from image-only scans ([8c0dd67](https://github.com/revanced/revanced-helper/commit/8c0dd67d03d5a1747993da08a5bf82a39de43789))
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-09-05)
### Bug Fixes
* **bots/discord:** correct permission check logic ([dd8872c](https://github.com/revanced/revanced-helper/commit/dd8872c027c7e7e1a00f38d659b4d6e79274238c))
* **bots/discord:** give only removed roles for role presets ([522ad28](https://github.com/revanced/revanced-helper/commit/522ad28fd83565e9ca411dbce86c8447574288fd))
* **bots/discord:** replace duration parser with a library ([94c4fed](https://github.com/revanced/revanced-helper/commit/94c4fedc06e20051e4123508e3134b97eb84782a))
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-08-23)
### Bug Fixes
* **bots/discord:** give only removed roles for role presets ([522ad28](https://github.com/revanced/revanced-helper/commit/522ad28fd83565e9ca411dbce86c8447574288fd))
* **bots/discord:** replace duration parser with a library ([94c4fed](https://github.com/revanced/revanced-helper/commit/94c4fedc06e20051e4123508e3134b97eb84782a))
# @revanced/discord-bot [1.0.0-dev.26](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.25...@revanced/discord-bot@1.0.0-dev.26) (2024-08-15)
### Bug Fixes
* **bots/discord:** correct timer active condition for sticky messages ([96065ff](https://github.com/revanced/revanced-helper/commit/96065ff17584ff99a56ca5008327863ca5a7852b))
# @revanced/discord-bot [1.0.0-dev.25](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.24...@revanced/discord-bot@1.0.0-dev.25) (2024-08-15)
### Bug Fixes
* **bots/discord:** allow access to `context` in `/eval` and await result ([99f65f0](https://github.com/revanced/revanced-helper/commit/99f65f07f5f8830c6e8ea4ae171e986af4d3f1f6))
# @revanced/discord-bot [1.0.0-dev.24](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.23...@revanced/discord-bot@1.0.0-dev.24) (2024-08-14)
### Bug Fixes
* **bots/discord:** do not remove unrelated reactions ([031fd26](https://github.com/revanced/revanced-helper/commit/031fd26b2619ecafeff3964e50accacb87de6108))
# @revanced/discord-bot [1.0.0-dev.23](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.22...@revanced/discord-bot@1.0.0-dev.23) (2024-08-13)
### Features
* **bots/discord:** add `train` commands ([ee90ef2](https://github.com/revanced/revanced-helper/commit/ee90ef247b4bf2b3c0698606b947116f2dc1b868))
* **bots/discord:** update to newer command definition framework ([97f2795](https://github.com/revanced/revanced-helper/commit/97f2795df4ede4d12a08193dba453c1bc765a4c2))
# @revanced/discord-bot [1.0.0-dev.22](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.21...@revanced/discord-bot@1.0.0-dev.22) (2024-08-10)
### Bug Fixes
* **bots/discord:** parse larger units of durations, fix wrong timestamp in mod embed ([6c8dce0](https://github.com/revanced/revanced-helper/commit/6c8dce059366a6ef85f5b8b1794c056515b9f5b6))
* **bots/discord:** provide discord token for `reload` command ([dd21a5a](https://github.com/revanced/revanced-helper/commit/dd21a5abad560f3d00b8c58912786d4b6bd520e9))
### Features
* **bots/discord:** add code to actually scan text files correctly ([80aeb19](https://github.com/revanced/revanced-helper/commit/80aeb1902063140a2e78cfaed9424e5101ab03f1))
# @revanced/discord-bot [1.0.0-dev.21](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.20...@revanced/discord-bot@1.0.0-dev.21) (2024-08-04)

View File

@@ -2,16 +2,15 @@
"name": "@revanced/discord-bot",
"type": "module",
"private": true,
"version": "1.0.0-dev.21",
"version": "1.0.0-dev.29",
"description": "🤖 Discord bot assisting ReVanced",
"main": "src/index.ts",
"scripts": {
"register": "bun run scripts/reload-slash-commands.ts",
"start": "bun prepare && bun run src/index.ts",
"dev": "bun prepare && bun --watch src/index.ts",
"build": "bun prepare && bun run scripts/build.ts",
"watch": "bun dev",
"prepare": "bun run scripts/generate-indexes.ts && bunx drizzle-kit generate --name=schema"
"prepare": "bun run scripts/generate-indexes.ts && bunx --bun drizzle-kit generate --name=schema"
},
"repository": {
"type": "git",
@@ -34,13 +33,14 @@
"@revanced/bot-api": "workspace:*",
"@revanced/bot-shared": "workspace:*",
"chalk": "^5.3.0",
"decancer": "^3.2.3",
"decancer": "^3.2.4",
"discord.js": "^14.15.3",
"drizzle-orm": "^0.31.4"
"drizzle-orm": "^0.31.4",
"parse-duration": "^1.1.0"
},
"devDependencies": {
"@libsql/client": "^0.7.0",
"discord-api-types": "^0.37.92",
"discord-api-types": "^0.37.97",
"drizzle-kit": "^0.22.8"
}
}

View File

@@ -1,76 +1,145 @@
import { ApplicationCommandOptionType } from 'discord.js'
import { ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js'
import { isAdmin } from '../utils/discord/permissions'
import { createErrorEmbed } from '$/utils/discord/embeds'
import { isAdmin } from '$/utils/discord/permissions'
import { config } from '../context'
import CommandError, { CommandErrorType } from './CommandError'
import type { Filter } from 'config.schema'
import type {
APIApplicationCommandChannelOption,
CacheType,
Channel,
ChatInputCommandInteraction,
CommandInteraction,
CommandInteractionOption,
GuildMember,
Message,
MessageContextMenuCommandInteraction,
RESTPostAPIApplicationCommandsJSONBody,
RESTPostAPIChatInputApplicationCommandsJSONBody,
Role,
User,
UserContextMenuCommandInteraction,
UserResolvable,
} from 'discord.js'
import { config } from '../context'
export enum CommandType {
ChatGlobal = 1,
ChatGuild,
ContextMenuUser,
ContextMenuMessage,
ContextMenuGuildMessage,
ContextMenuGuildMember,
}
export default class Command<
Global extends boolean = false,
Options extends CommandOptionsOptions | undefined = undefined,
AllowMessageCommand extends boolean = false,
const Type extends CommandType = CommandType.ChatGuild,
const Options extends If<IsContextMenu<Type>, undefined, CommandOptionsOptions | undefined> = undefined,
const AllowMessageCommand extends If<IsContextMenu<Type>, false, boolean> = false,
> {
name: string
description: string
requirements?: CommandRequirements
options?: Options
global?: Global
#execute: CommandExecuteFunction<Global, Options, AllowMessageCommand>
type: Type
allowMessageCommand: AllowMessageCommand
#execute: CommandExecuteFunction<Type, Options, AllowMessageCommand>
static OptionType = ApplicationCommandOptionType
static Type = CommandType
constructor({
name,
description,
requirements,
options,
global,
type,
allowMessageCommand,
execute,
}: CommandOptions<Global, Options, AllowMessageCommand>) {
}: CommandOptions<Type, Options, AllowMessageCommand>) {
this.name = name
this.description = description
this.description = description!
this.requirements = requirements
this.options = options
this.global = global
// @ts-expect-error: Default is `CommandType.GuildOnly`, it makes sense
this.type = type ?? CommandType.ChatGuild
// @ts-expect-error: Default is `false`, it makes sense
this.allowMessageCommand = allowMessageCommand ?? false
this.#execute = execute
}
isGuildSpecific(): this is Command<
CommandType.ChatGuild | CommandType.ContextMenuGuildMember | CommandType.ContextMenuGuildMessage,
Options,
AllowMessageCommand
> {
return [
CommandType.ChatGuild,
CommandType.ContextMenuGuildMessage,
CommandType.ContextMenuGuildMember,
].includes(this.type)
}
isContextMenuSpecific(): this is Command<
| CommandType.ContextMenuGuildMessage
| CommandType.ContextMenuGuildMember
| CommandType.ContextMenuUser
| CommandType.ContextMenuMessage,
undefined,
false
> {
return [
CommandType.ContextMenuMessage,
CommandType.ContextMenuUser,
CommandType.ContextMenuGuildMessage,
CommandType.ContextMenuGuildMember,
].includes(this.type)
}
isGuildContextMenuSpecific(): this is Command<
CommandType.ContextMenuGuildMessage | CommandType.ContextMenuGuildMember,
undefined,
false
> {
return [CommandType.ContextMenuGuildMessage, CommandType.ContextMenuGuildMember].includes(this.type)
}
async onContextMenuInteraction(
context: typeof import('../context'),
interaction: If<
Extends<Type, CommandType.ContextMenuGuildMessage>,
MessageContextMenuCommandInteraction<ToCacheType<Type>>,
UserContextMenuCommandInteraction<ToCacheType<Type>>
>,
): Promise<unknown> {
if (!this.isGuildSpecific() && !interaction.inGuild())
throw new CommandError(CommandErrorType.InteractionNotInGuild)
const executor = await this.#fetchInteractionExecutor(interaction)
const target =
this.type === CommandType.ContextMenuGuildMember
? this.isGuildSpecific()
? fetchMember(interaction as CommandInteraction<'raw' | 'cached'>, interaction.targetId)
: interaction.client.users.fetch(interaction.targetId)
: interaction.channel?.messages.fetch(interaction.targetId)
if (!target) throw new CommandError(CommandErrorType.FetchManagerNotFound)
// @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough
return await this.#execute({ ...context, executor, target }, interaction, undefined)
}
async onMessage(
context: typeof import('../context'),
msg: Message<If<Global, false, true>>,
msg: Message<IsGuildSpecific<Type>>,
args: CommandArguments,
): Promise<unknown> {
if (!this.global && !msg.inGuild())
return await msg.reply({
embeds: [createErrorEmbed('Cannot run this command', 'This command can only be used in a server.')],
})
if (!this.allowMessageCommand) return
if (!this.isGuildSpecific() && !msg.guildId) throw new CommandError(CommandErrorType.InteractionNotInGuild)
const executor = this.global ? msg.author : await msg.guild?.members.fetch(msg.author.id)!
if (!(await this.canExecute(executor, msg.channelId)))
return await msg.reply({
embeds: [
createErrorEmbed(
'Cannot run this command',
'You do not meet the requirements to run this command.',
),
],
})
const executor = this.isGuildSpecific()
? await msg.guild?.members.fetch(msg.author)!
: await msg.client.users.fetch(msg.author)
if (!(await this.canExecute(executor))) throw new CommandError(CommandErrorType.RequirementsNotMet)
const options = this.options
? ((await this.#resolveMessageOptions(msg, this.options, args)) as CommandExecuteFunctionOptionsParameter<
@@ -120,14 +189,18 @@ export default class Command<
`Invalid type for argument **${name}**.${argExplainationString}\n\nExpected type: **${expectedType}**\nGot type: **${ApplicationCommandOptionType[arg.type]}**${choicesString}`,
)
if ('choices' in option && option.choices && !option.choices.some(({ value }) => value === arg))
const argValue = typeof arg === 'string' ? arg : arg?.id
if (
'choices' in option &&
option.choices &&
!option.choices.some(({ value }) => value === (typeof value === 'number' ? Number(argValue) : argValue))
)
throw new CommandError(
CommandErrorType.InvalidArgument,
`Invalid choice for argument **${name}**.\n${argExplainationString}\n\n${choicesString}\n`,
`Invalid choice for argument **${name}**.\n${argExplainationString}${choicesString}\n`,
)
const argValue = typeof arg === 'string' ? arg : arg?.id
if (argValue && arg) {
if (isSubcommandLikeOption) {
const [subcommandName, subcommandOption] = iterableOptions.find(([name]) => name === argValue)!
@@ -142,6 +215,16 @@ export default class Command<
break
}
if (
type === ApplicationCommandOptionType.String &&
((typeof option.minLength === 'number' && argValue.length < option.minLength) ||
(typeof option.maxLength === 'number' && argValue.length > option.maxLength))
)
throw new CommandError(
CommandErrorType.InvalidArgument,
`Invalid string length for argument **${name}**.\nLengths allowed: ${option.minLength ?? '(any)'} - ${option.maxLength ?? '(any)'}.${argExplainationString}`,
)
if (
(type === ApplicationCommandOptionType.Channel ||
type === ApplicationCommandOptionType.User ||
@@ -153,14 +236,21 @@ export default class Command<
`Malformed ID for argument **${name}**.${argExplainationString}`,
)
if (
(type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer) &&
Number.isNaN(Number(argValue))
) {
throw new CommandError(
CommandErrorType.InvalidArgument,
`Invalid number for argument **${name}**.${argExplainationString}`,
if (type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer) {
if (Number.isNaN(Number(argValue)))
throw new CommandError(
CommandErrorType.InvalidArgument,
`Invalid number for argument **${name}**.${argExplainationString}`,
)
if (
(typeof option.min === 'number' && Number(argValue) < option.min) ||
(typeof option.max === 'number' && Number(argValue) > option.max)
)
throw new CommandError(
CommandErrorType.InvalidArgument,
`Number out of range for argument **${name}**.\nRange allowed: ${option.min ?? '(any)'} - ${option.max ?? '(any)'}.${argExplainationString}`,
)
}
if (
@@ -177,7 +267,7 @@ export default class Command<
type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer
? Number(argValue)
: type === ApplicationCommandOptionType.Boolean
? argValue[0] === 't' || argValue[0] === 'y'
? ['t', 'y', 'yes', 'true'].some(value => value === argValue.toLowerCase())
: type === ApplicationCommandOptionType.Channel
? await msg.client.channels.fetch(argValue)
: type === ApplicationCommandOptionType.User
@@ -191,44 +281,27 @@ export default class Command<
return _options
}
#fetchInteractionExecutor(interaction: CommandInteraction) {
return this.isGuildSpecific()
? fetchMember(interaction as CommandInteraction<'raw' | 'cached'>)
: fetchUser(interaction)
}
async onInteraction(
context: typeof import('../context'),
interaction: ChatInputCommandInteraction,
): Promise<unknown> {
const { logger } = context
if (interaction.commandName !== this.name)
throw new CommandError(
CommandErrorType.InteractionDataMismatch,
'The interaction command name does not match the expected command name.',
)
if (interaction.commandName !== this.name) {
logger.warn(`Command name mismatch, expected ${this.name}, but got ${interaction.commandName}!`)
return await interaction.reply({
embeds: [
createErrorEmbed(
'Internal command name mismatch',
'The interaction command name does not match the expected command name.',
),
],
})
}
if (!this.isGuildSpecific() && !interaction.inGuild())
throw new CommandError(CommandErrorType.InteractionNotInGuild)
if (!this.global && !interaction.inGuild()) {
logger.error(`Command ${this.name} cannot be run in DMs, but was registered as global`)
return await interaction.reply({
embeds: [createErrorEmbed('Cannot run this command', 'This command can only be used in a server.')],
ephemeral: true,
})
}
const executor = this.global ? interaction.user : await interaction.guild?.members.fetch(interaction.user.id)!
if (!(await this.canExecute(executor, interaction.channelId)))
return await interaction.reply({
embeds: [
createErrorEmbed(
'Cannot run this command',
'You do not meet the requirements to run this command.',
),
],
ephemeral: true,
})
const executor = await this.#fetchInteractionExecutor(interaction)
if (!(await this.canExecute(executor))) throw new CommandError(CommandErrorType.RequirementsNotMet)
const options = this.options
? ((await this.#resolveInteractionOptions(interaction)) as CommandExecuteFunctionOptionsParameter<
@@ -237,14 +310,10 @@ export default class Command<
: undefined
if (options === null)
return await interaction.reply({
embeds: [
createErrorEmbed(
'Internal command option type mismatch',
'The interaction command option type does not match the expected command option type.',
),
],
})
throw new CommandError(
CommandErrorType.InteractionDataMismatch,
'The registered interaction command option type does not match the expected command option type.',
)
// @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough
return await this.#execute({ ...context, executor }, interaction, options)
@@ -288,7 +357,7 @@ export default class Command<
return _options
}
async canExecute(executor: User | GuildMember, channelId: string): Promise<boolean> {
async canExecute(executor: User | GuildMember): Promise<boolean> {
if (!this.requirements) return false
const isExecutorAdmin = isAdmin(executor)
@@ -296,7 +365,6 @@ export default class Command<
const {
adminOnly,
channels,
roles,
permissions,
users,
@@ -305,16 +373,23 @@ export default class Command<
memberRequirementsForUsers = 'pass',
} = this.requirements
const member = this.global ? null : (executor as GuildMember)
const bDefCond = defaultCondition !== 'fail'
const bMemReqForUsers = memberRequirementsForUsers !== 'fail'
const member = this.isGuildSpecific() ? (executor as GuildMember) : null
const boolDefaultCondition = defaultCondition !== 'fail'
const boolMemberRequirementsForUsers = memberRequirementsForUsers !== 'fail'
const conditions = [
adminOnly ? isExecutorAdmin : bDefCond,
channels ? channels.includes(channelId) : bDefCond,
member ? (roles ? roles.some(role => member.roles.cache.has(role)) : bDefCond) : bMemReqForUsers,
member ? (permissions ? member.permissions.has(permissions) : bDefCond) : bMemReqForUsers,
users ? users.includes(executor.id) : bDefCond,
adminOnly ? isExecutorAdmin : boolDefaultCondition,
users ? users.includes(executor.id) : boolDefaultCondition,
member
? roles
? roles.some(role => member.roles.cache.has(role))
: boolDefaultCondition
: boolMemberRequirementsForUsers,
member
? permissions
? member.permissions.has(permissions)
: boolDefaultCondition
: boolMemberRequirementsForUsers,
]
if (mode === 'all' && conditions.some(condition => !condition)) return false
@@ -323,14 +398,27 @@ export default class Command<
return true
}
get json(): RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> } {
return {
get json(): RESTPostAPIApplicationCommandsJSONBody {
// @ts-expect-error: I hate union types in TypeScript
const base: RESTPostAPIApplicationCommandsJSONBody = {
name: this.name,
type:
this.type === CommandType.ContextMenuGuildMessage || this.type === CommandType.ContextMenuMessage
? ApplicationCommandType.Message
: this.type === CommandType.ContextMenuGuildMember || this.type === CommandType.ContextMenuUser
? ApplicationCommandType.User
: ApplicationCommandType.ChatInput,
}
if (this.isContextMenuSpecific()) return base
return {
...base,
description: this.description,
options: this.options ? this.#transformOptions(this.options) : undefined,
// https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-context-types
contexts: this.global ? [0] : [0, 1],
}
contexts: this.isGuildSpecific() ? [0] : [0, 1, 2],
} as RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> }
}
#transformOptions(optionsObject: Record<string, CommandOption>) {
@@ -377,8 +465,8 @@ export default class Command<
export class ModerationCommand<
Options extends CommandOptionsOptions,
AllowMessageCommand extends boolean = true,
> extends Command<false, Options, AllowMessageCommand> {
constructor(options: ExtendedCommandOptions<false, Options, AllowMessageCommand>) {
> extends Command<CommandType.ChatGuild, Options, AllowMessageCommand> {
constructor(options: ExtendedCommandOptions<CommandType.ChatGuild, Options, AllowMessageCommand>) {
super({
...options,
requirements: {
@@ -388,17 +476,16 @@ export class ModerationCommand<
},
// @ts-expect-error: No thanks
allowMessageCommand: options.allowMessageCommand ?? true,
global: false,
type: CommandType.ChatGuild,
})
}
}
export class AdminCommand<Options extends CommandOptionsOptions, AllowMessageCommand extends boolean> extends Command<
true,
Options,
AllowMessageCommand
> {
constructor(options: ExtendedCommandOptions<true, Options, AllowMessageCommand>) {
export class AdminCommand<
Options extends CommandOptionsOptions,
AllowMessageCommand extends boolean = true,
> extends Command<CommandType.ChatGlobal, Options, AllowMessageCommand> {
constructor(options: ExtendedCommandOptions<CommandType.ChatGlobal, Options, AllowMessageCommand>) {
super({
...options,
requirements: {
@@ -406,38 +493,52 @@ export class AdminCommand<Options extends CommandOptionsOptions, AllowMessageCom
adminOnly: true,
defaultCondition: 'pass',
},
global: true,
allowMessageCommand: options.allowMessageCommand ?? (true as AllowMessageCommand),
type: CommandType.ChatGlobal,
})
}
}
const fetchMember = async (
interaction: CommandInteraction<'raw' | 'cached'>,
source: UserResolvable = interaction.user,
manager = interaction.guild?.members,
) => {
const _manager = manager ?? (await interaction.client.guilds.fetch(interaction.guildId).then(it => it.members))
if (!_manager) throw new CommandError(CommandErrorType.FetchManagerNotFound, 'Cannot fetch member.')
return await _manager.fetch(source)
}
const fetchUser = (interaction: CommandInteraction, source: UserResolvable = interaction.user) => {
return interaction.client.users.fetch(source)
}
/* TODO:
APIApplicationCommandAttachmentOption
APIApplicationCommandMentionableOption
APIApplicationCommandRoleOption
*/
export interface CommandOptions<
Global extends boolean,
export type CommandOptions<
Type extends CommandType,
Options extends CommandOptionsOptions | undefined,
AllowMessageCommand extends boolean,
> {
> = {
name: string
description: string
requirements?: CommandRequirements
options?: Options
execute: CommandExecuteFunction<Global, Options, AllowMessageCommand>
global?: Global
execute: CommandExecuteFunction<Type, Options, AllowMessageCommand>
type?: Type
allowMessageCommand?: AllowMessageCommand
}
} & If<IsContextMenu<Type>, { description?: never }, { description: string }>
export type CommandArguments = Array<string | CommandSpecialArgument>
export type CommandSpecialArgument = {
type: (typeof CommandSpecialArgumentType)[keyof typeof CommandSpecialArgumentType]
id: string
}
//! If things ever get minified, this will most likely break property access via string names
export const CommandSpecialArgumentType = {
Channel: ApplicationCommandOptionType.Channel,
Role: ApplicationCommandOptionType.Role,
@@ -445,31 +546,56 @@ export const CommandSpecialArgumentType = {
}
type ExtendedCommandOptions<
Global extends boolean,
Type extends CommandType,
Options extends CommandOptionsOptions,
AllowMessageCommand extends boolean,
> = Omit<CommandOptions<Global, Options, AllowMessageCommand>, 'global'> & {
requirements?: Omit<CommandOptions<false, Options, AllowMessageCommand>['requirements'], 'defaultCondition'>
> = Omit<CommandOptions<Type, Options, AllowMessageCommand>, 'type'> & {
requirements?: Omit<CommandOptions<Type, Options, AllowMessageCommand>['requirements'], 'defaultCondition'>
}
export type CommandOptionsOptions = Record<string, CommandOption>
type ToCacheType<Type extends CommandType> = If<IsGuildSpecific<Type>, 'raw' | 'cached', CacheType>
type CommandExecuteFunction<
Global extends boolean,
Type extends CommandType,
Options extends CommandOptionsOptions | undefined,
AllowMessageCommand extends boolean,
> = (
context: CommandContext<Global>,
context: CommandContext<Type>,
trigger: If<
AllowMessageCommand,
Message<InvertBoolean<Global>> | ChatInputCommandInteraction<If<Global, CacheType, 'raw' | 'cached'>>,
ChatInputCommandInteraction<If<Global, CacheType, 'raw' | 'cached'>>
Message<IsGuildSpecific<Type>> | CommandTypeToInteractionMap<ToCacheType<Type>>[Type],
CommandTypeToInteractionMap<ToCacheType<Type>>[Type]
>,
options: Options extends CommandOptionsOptions ? CommandExecuteFunctionOptionsParameter<Options> : never,
) => Promise<unknown> | unknown
type CommandTypeToInteractionMap<CT extends CacheType> = {
[CommandType.ChatGlobal]: ChatInputCommandInteraction<CT>
[CommandType.ChatGuild]: ChatInputCommandInteraction<CT>
[CommandType.ContextMenuUser]: UserContextMenuCommandInteraction<CT>
[CommandType.ContextMenuMessage]: MessageContextMenuCommandInteraction<CT>
[CommandType.ContextMenuGuildMessage]: MessageContextMenuCommandInteraction<CT>
[CommandType.ContextMenuGuildMember]: MessageContextMenuCommandInteraction<CT>
}
type IsContextMenu<Type extends CommandType> = Extends<
Type,
| CommandType.ContextMenuGuildMessage
| CommandType.ContextMenuGuildMember
| CommandType.ContextMenuMessage
| CommandType.ContextMenuUser
>
type IsGuildSpecific<Type extends CommandType> = Extends<
Type,
CommandType.ChatGuild | CommandType.ContextMenuGuildMember | CommandType.ContextMenuGuildMessage
>
type Extends<T, U> = T extends U ? true : false
type If<T extends boolean | undefined, U, V> = T extends true ? U : V
type InvertBoolean<T extends boolean> = If<T, false, true>
// type InvertBoolean<T extends boolean> = If<T, false, true>
type CommandExecuteFunctionOptionsParameter<Options extends CommandOptionsOptions> = {
[K in keyof Options]: Options[K]['type'] extends
@@ -484,8 +610,13 @@ type CommandExecuteFunctionOptionsParameter<Options extends CommandOptionsOption
>
}
type CommandContext<Global extends boolean> = typeof import('../context') & {
executor: CommandExecutor<Global>
type CommandContext<Type extends CommandType> = typeof import('../context') & {
executor: CommandExecutor<Type>
target: If<
Extends<Type, CommandType.ContextMenuGuildMember>,
GuildMember,
If<Extends<Type, CommandType.ContextMenuGuildMessage>, Message<true>, never>
>
}
type CommandOptionValueMap = {
@@ -511,7 +642,7 @@ type CommandOption =
| CommandSubcommandOption
| CommandSubcommandGroupOption
type CommandExecutor<Global extends boolean> = If<Global, User, GuildMember>
type CommandExecutor<Type extends CommandType> = If<IsGuildSpecific<Type>, GuildMember, User>
type CommandOptionBase<Type extends ApplicationCommandOptionType> = {
type: Type
@@ -585,10 +716,12 @@ interface CommandSubcommandLikeOption<
type CommandSubcommandOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.Subcommand>
type CommandSubcommandGroupOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.SubcommandGroup>
export type CommandRequirements = Filter & {
mode?: 'all' | 'any'
adminOnly?: boolean
export type CommandRequirements = {
users?: string[]
roles?: string[]
permissions?: bigint
adminOnly?: boolean
defaultCondition?: 'fail' | 'pass'
memberRequirementsForUsers?: 'pass' | 'fail'
memberRequirementsForUsers?: 'fail' | 'pass'
mode?: 'all' | 'any'
}

View File

@@ -1,9 +1,9 @@
import { createErrorEmbed } from '$/utils/discord/embeds'
import { createErrorEmbed } from '../utils/discord/embeds'
export default class CommandError extends Error {
type: CommandErrorType
constructor(type: CommandErrorType, message?: string) {
constructor(type: CommandErrorType, message: string = ErrorMessageMap[type]) {
super(message)
this.name = 'CommandError'
this.type = type
@@ -15,19 +15,34 @@ export default class CommandError extends Error {
}
export enum CommandErrorType {
Generic,
Generic = 1,
InteractionNotInGuild,
InteractionDataMismatch,
FetchManagerNotFound,
FetchNotFound,
RequirementsNotMet = 100,
MissingArgument,
InvalidArgument,
InvalidUser,
InvalidChannel,
InvalidDuration,
}
const ErrorTitleMap: Record<CommandErrorType, string> = {
[CommandErrorType.Generic]: 'An exception was thrown',
[CommandErrorType.InteractionNotInGuild]: 'This command can only be used in servers',
[CommandErrorType.InteractionDataMismatch]: 'Command data mismatch',
[CommandErrorType.FetchManagerNotFound]: 'Cannot fetch data (manager not found)',
[CommandErrorType.FetchNotFound]: 'Cannot fetch data (source not found)',
[CommandErrorType.RequirementsNotMet]: 'Command requirements not met',
[CommandErrorType.MissingArgument]: 'Missing argument',
[CommandErrorType.InvalidArgument]: 'Invalid argument',
[CommandErrorType.InvalidUser]: 'Invalid user',
[CommandErrorType.InvalidChannel]: 'Invalid channel',
[CommandErrorType.InvalidDuration]: 'Invalid duration',
}
const ErrorMessageMap: Record<CommandErrorType, string> = {
[CommandErrorType.Generic]: 'An generic exception was thrown.',
[CommandErrorType.InteractionNotInGuild]: 'This command can only be used in servers.',
[CommandErrorType.InteractionDataMismatch]: 'Interaction command data does not match the expected command data.',
[CommandErrorType.FetchManagerNotFound]: 'Cannot fetch required data.',
[CommandErrorType.FetchNotFound]: 'Cannot fetch target.',
[CommandErrorType.RequirementsNotMet]: 'You do not meet the requirements to use this command.',
[CommandErrorType.MissingArgument]: 'You are missing a required argument.',
[CommandErrorType.InvalidArgument]: 'You provided an invalid argument.',
}

View File

@@ -19,14 +19,17 @@ export default new AdminCommand({
required: false,
},
},
async execute(_, trigger, { code, 'show-hidden': showHidden }) {
async execute(context, trigger, { code, 'show-hidden': showHidden }) {
// So it doesn't show up as unused, and we can use it in `code`
context
await trigger.reply({
ephemeral: true,
embeds: [
createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({
name: 'Result',
// biome-ignore lint/security/noGlobalEval: This is fine as it's an admin command
value: `\`\`\`js\n${inspect(eval(code), {
value: `\`\`\`js\n${inspect(await eval(code), {
depth: 1,
showHidden,
getters: true,

View File

@@ -27,7 +27,9 @@ export default new AdminCommand({
logger.info('Reinitializing Discord client to reload configuration...')
await discord.client.destroy()
await discord.client.login()
// discord.client.token only gets set whenever a new Client is intialized
// so that's why we need to provide the token here :/
await discord.client.login(process.env['DISCORD_TOKEN'])
// @ts-expect-error: TypeScript dum
await trigger[('deferReply' in trigger ? 'editReply' : 'reply')]({ content: 'Reloaded configuration' })

View File

@@ -42,7 +42,7 @@ export default new AdminCommand({
const { global: globalCommands, guild: guildCommands } = Object.groupBy(
Object.values(context.discord.commands),
cmd => (cmd.global ? 'global' : 'guild'),
cmd => (cmd.isGuildSpecific() ? 'guild' : 'global'),
)
const {

View File

@@ -6,7 +6,7 @@ import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
export default new Command({
name: 'coinflip',
description: 'Do a coinflip!',
global: true,
type: Command.Type.ChatGlobal,
requirements: {
defaultCondition: 'pass',
},

View File

@@ -35,7 +35,7 @@ export default new ModerationCommand({
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError(
CommandErrorType.InvalidUser,
CommandErrorType.InvalidArgument,
'You cannot ban a user with a role equal to or higher than yours.',
)
}

View File

@@ -37,14 +37,14 @@ export default new ModerationCommand({
if (Number.isInteger(duration) && duration! < 1)
throw new CommandError(
CommandErrorType.InvalidDuration,
CommandErrorType.InvalidArgument,
'The duration must be at least 1 millisecond long.',
)
const expires = Math.max(duration, Date.now() + duration)
if (!member)
throw new CommandError(
CommandErrorType.InvalidUser,
CommandErrorType.InvalidArgument,
'The provided member is not in the server or does not exist.',
)
@@ -53,14 +53,14 @@ export default new ModerationCommand({
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError(
CommandErrorType.InvalidUser,
CommandErrorType.InvalidArgument,
'You cannot mute a user with a role equal to or higher than yours.',
)
await applyRolePreset(member, 'mute', expires)
await sendModerationReplyAndLogs(
interaction,
createModerationActionEmbed('Muted', user, executor.user, reason, duration),
createModerationActionEmbed('Muted', user, executor.user, reason, Math.ceil(expires / 1000)),
)
if (duration)

View File

@@ -32,7 +32,7 @@ export default new ModerationCommand({
const channel = interaction.channel!
if (!channel.isTextBased())
throw new CommandError(CommandErrorType.InvalidChannel, 'The supplied channel is not a text channel.')
throw new CommandError(CommandErrorType.InvalidArgument, 'The supplied channel is not a text channel.')
const embed = applyCommonEmbedStyles(
new EmbedBuilder({

View File

@@ -45,7 +45,7 @@ export default new ModerationCommand({
if (!member)
throw new CommandError(
CommandErrorType.InvalidUser,
CommandErrorType.InvalidArgument,
'The provided member is not in the server or does not exist.',
)
@@ -56,13 +56,13 @@ export default new ModerationCommand({
const duration = durationInput ? parseDuration(durationInput) : Infinity
if (Number.isInteger(duration) && duration! < 1)
throw new CommandError(
CommandErrorType.InvalidDuration,
CommandErrorType.InvalidArgument,
'The duration must be at least 1 millisecond long.',
)
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError(
CommandErrorType.InvalidUser,
CommandErrorType.InvalidArgument,
'You cannot apply a role preset to a user with a role equal to or higher than yours.',
)
@@ -83,6 +83,6 @@ export default new ModerationCommand({
removeRolePreset(member, preset)
}, expires)
await sendPresetReplyAndLogs(apply ? 'apply' : 'remove', trigger, executor, user, preset, expires)
await sendPresetReplyAndLogs(apply ? 'apply' : 'remove', trigger, executor, user, preset, expires ? Math.ceil(expires / 1000) : undefined)
},
})

View File

@@ -27,14 +27,14 @@ export default new ModerationCommand({
if (!channel?.isTextBased() || channel.isDMBased())
throw new CommandError(
CommandErrorType.InvalidChannel,
CommandErrorType.InvalidArgument,
'The supplied channel is not a text channel.',
)
if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidDuration, 'Invalid duration.')
if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidArgument, 'Invalid duration.')
if (duration < 0 || duration > 36e4)
throw new CommandError(
CommandErrorType.InvalidDuration,
CommandErrorType.InvalidArgument,
'Duration out of range, must be between 0s and 6h.',
)

View File

@@ -20,7 +20,7 @@ export default new ModerationCommand({
const member = await interaction.guild!.members.fetch(user.id)
if (!member)
throw new CommandError(
CommandErrorType.InvalidUser,
CommandErrorType.InvalidArgument,
'The provided member is not in the server or does not exist.',
)

View File

@@ -0,0 +1,76 @@
import Command from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '../../../context'
import type { FetchMessageOptions, MessageResolvable } from 'discord.js'
import type { ConfigMessageScanResponseLabelConfig } from 'config.schema'
import { createSuccessEmbed } from '$/utils/discord/embeds'
const msRcConfig = config.messageScan?.humanCorrections?.allow
export default new Command({
name: 'train',
description: 'Train a specific message or text to a specific label',
type: Command.Type.ChatGuild,
requirements: {
users: msRcConfig?.users,
roles: msRcConfig?.members?.roles,
permissions: msRcConfig?.members?.permissions,
mode: 'any',
memberRequirementsForUsers: 'fail',
defaultCondition: 'fail',
},
options: {
message: {
description: 'The message to train (use `latest` to train the latest message)',
type: Command.OptionType.String,
required: true,
},
label: {
description: 'The label to train the message as',
type: Command.OptionType.String,
required: true,
},
},
allowMessageCommand: true,
async execute(context, trigger, { label, message: ref }) {
const { logger, config } = context
const { messageScan: msConfig } = config
// If there's no config, we can't do anything
if (!msConfig?.humanCorrections) throw new CommandError(CommandErrorType.Generic, 'Response correction is off.')
const labels = msConfig.responses?.flatMap(r =>
r.triggers.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label),
)
const channel = await trigger.guild!.channels.fetch(trigger.channelId)
if (!channel?.isTextBased())
throw new CommandError(
CommandErrorType.InvalidArgument,
'This command can only be used in or on text channels',
)
if (!labels.includes(label))
throw new CommandError(
CommandErrorType.InvalidArgument,
`The provided label is invalid.\nValid labels are:${labels.map(l => `\n- \`${l}\``).join('')}`,
)
const refMsg = await channel.messages.fetch(
(ref.startsWith('latest') ? { limit: 1 } : ref) as MessageResolvable | FetchMessageOptions,
)
if (!refMsg) throw new CommandError(CommandErrorType.InvalidArgument, 'The provided message does not exist.')
logger.debug(`User ${context.executor.id} is training message ${refMsg?.id} as ${label}`)
await context.api.client.trainMessage(refMsg.content, label)
await trigger.reply({
embeds: [
createSuccessEmbed(
'Message trained',
`The provided message has been trained as \`${label}\`. Thank you for your contribution!`,
),
],
ephemeral: true,
})
},
})

View File

@@ -0,0 +1,50 @@
import Command from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '../../../context'
import { type APIStringSelectComponent, ComponentType } from 'discord.js'
import type { ConfigMessageScanResponseLabelConfig } from 'config.schema'
const msRcConfig = config.messageScan?.humanCorrections?.allow
export default new Command({
name: 'Train Message',
type: Command.Type.ContextMenuGuildMessage,
requirements: {
users: msRcConfig?.users,
roles: msRcConfig?.members?.roles,
permissions: msRcConfig?.members?.permissions,
mode: 'any',
memberRequirementsForUsers: 'fail',
defaultCondition: 'fail',
},
async execute(context, trigger) {
const { logger, config } = context
const { messageScan: msConfig } = config
// If there's no config, we can't do anything
if (!msConfig?.humanCorrections) throw new CommandError(CommandErrorType.Generic, 'Response correction is off.')
logger.debug(`User ${context.executor.id} is training message ${trigger.targetId}`)
const labels = msConfig.responses.flatMap(r =>
r.triggers.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label),
)
await trigger.reply({
content: 'Select a label to train this message as:',
components: [
{
components: [
{
custom_id: `tr_${trigger.targetMessage.channelId}_${trigger.targetId}`,
options: labels.map(label => ({ label, value: label })),
type: ComponentType.StringSelect,
} satisfies APIStringSelectComponent,
],
type: ComponentType.ActionRow,
},
],
ephemeral: true,
})
},
})

View File

@@ -17,7 +17,7 @@ export const logger = createLogger({
import * as commands from './commands'
import * as schemas from './database/schemas'
import type { default as Command, CommandOptionsOptions } from './classes/Command'
import type { default as Command, CommandOptionsOptions, CommandType } from './classes/Command'
export const api = {
client: new APIClient({
@@ -83,7 +83,7 @@ export const discord = {
}),
commands: Object.fromEntries(Object.values(commands).map(cmd => [cmd.name, cmd])) as Record<
string,
Command<boolean, CommandOptionsOptions | undefined, boolean>
Command<CommandType, CommandOptionsOptions | undefined, boolean>
>,
stickyMessages: {} as Record<
string,

View File

@@ -16,6 +16,7 @@ export const appliedPresets = sqliteTable(
{
memberId: text('member').notNull(),
guildId: text('guild').notNull(),
removedRoles: text('roles', { mode: 'json' }).notNull().$type<string[]>().default([]),
preset: text('preset').notNull(),
until: integer('until'),
},

View File

@@ -12,5 +12,5 @@ withContext(on, 'guildMemberAdd', async ({ database }, member) => {
),
})
for (const { preset } of applieds) await applyRolesUsingPreset(preset, member, true)
for (const { preset } of applieds) await applyRolesUsingPreset(preset, member)
})

View File

@@ -8,19 +8,24 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
const { logger, discord } = context
const command = discord.commands[interaction.commandName]
logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag}`)
logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag} via chat`)
if (!command)
return void logger.error(`Interaction command ${interaction.commandName} not implemented but registered!!!`)
return void logger.error(`Chat command ${interaction.commandName} not implemented but registered!!!`)
try {
logger.debug(`Command ${interaction.commandName} being executed`)
logger.debug(`Command ${interaction.commandName} being executed via chat`)
await command.onInteraction(context, interaction)
} catch (err) {
if (!(err instanceof CommandError))
logger.error(`Error while executing command ${interaction.commandName}:`, err)
await interaction[interaction.replied ? 'followUp' : 'reply']({
embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)],
ephemeral: true,
})
// 100 and up are user errors
if (err instanceof CommandError && err.type < 100)
logger.error(`Command ${interaction.commandName} internally failed with error:`, err)
}
})

View File

@@ -0,0 +1,26 @@
import CommandError from '$/classes/CommandError'
import { createStackTraceEmbed } from '$utils/discord/embeds'
import { on, withContext } from '$utils/discord/events'
withContext(on, 'interactionCreate', async (context, interaction) => {
if (!interaction.isContextMenuCommand()) return
const { logger, discord } = context
const command = discord.commands[interaction.commandName]
logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag} via context menu`)
if (!command)
return void logger.error(`Context menu command ${interaction.commandName} not implemented but registered!!!`)
try {
logger.debug(`Command ${interaction.commandName} being executed via context menu`)
await command.onContextMenuInteraction(context, interaction)
} catch (err) {
if (!(err instanceof CommandError))
logger.error(`Error while executing command ${interaction.commandName}:`, err)
await interaction[interaction.replied ? 'followUp' : 'reply']({
embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)],
ephemeral: true,
})
}
})

View File

@@ -18,10 +18,10 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
if (!interaction.isStringSelectMenu() && !interaction.isButton()) return
if (!interaction.customId.startsWith('cr_')) return
const [, key, action] = interaction.customId.split('_') as ['cr', string, 'select' | 'cancel' | 'delete']
if (!key || !action) return
const [, msgId, action] = interaction.customId.split('_') as ['cr', string, 'select' | 'cancel' | 'delete']
if (!msgId || !action) return
const response = await db.query.responses.findFirst({ where: eq(responses.replyId, key) })
const response = await db.query.responses.findFirst({ where: eq(responses.replyId, msgId) })
// If the message isn't saved in my DB (unrelated message)
if (!response)
return void (await interaction.reply({
@@ -32,11 +32,11 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
try {
// We're gonna pretend reactionChannel is a text-based channel, but it can be many more
// But `messages` should always exist as a property
const reactionGuild = await interaction.client.guilds.fetch(response.guildId)
const reactionChannel = (await reactionGuild.channels.fetch(response.channelId)) as TextBasedChannel | null
const reactionMessage = await reactionChannel?.messages.fetch(key)
const guild = await interaction.client.guilds.fetch(response.guildId)
const channel = (await guild.channels.fetch(response.channelId)) as TextBasedChannel | null
const msg = await channel?.messages.fetch(msgId)
if (!reactionMessage) {
if (!msg) {
await interaction.deferUpdate()
await interaction.message.edit({
content: null,
@@ -53,9 +53,9 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
}
const editMessage = (content: string, description?: string) =>
editInteractionMessage(interaction, reactionMessage.url, content, description)
editInteractionMessage(interaction, msg.url, content, description)
const handleCorrection = (label: string) =>
handleUserResponseCorrection(context, response, reactionMessage, label, interaction.user)
handleUserResponseCorrection(context, response, msg, label, interaction.user)
if (response.correctedById)
return await editMessage(

View File

@@ -0,0 +1,52 @@
import { createErrorEmbed, createStackTraceEmbed, createSuccessEmbed } from '$utils/discord/embeds'
import { on, withContext } from '$utils/discord/events'
import type { TextBasedChannel } from 'discord.js'
withContext(on, 'interactionCreate', async (context, interaction) => {
const {
logger,
config: { messageScan: msConfig },
} = context
if (!msConfig?.humanCorrections) return
if (!interaction.isStringSelectMenu()) return
if (!interaction.customId.startsWith('tr_')) return
const [, channelId, msgId] = interaction.customId.split('_') as ['tr', string, string]
if (!channelId || !msgId) return
try {
const channel = (await interaction.client.channels.fetch(channelId)) as TextBasedChannel | null
const msg = await channel?.messages.fetch(msgId)
if (!msg)
return void (await interaction.reply({
embeds: [
createErrorEmbed(
'Message not found',
'Thank you for your contribution! Unfortunately, the message could not be found.',
),
],
ephemeral: true,
}))
const selectedLabel = interaction.values[0]!
await context.api.client.trainMessage(msg.content, selectedLabel)
await interaction.reply({
embeds: [
createSuccessEmbed(
'Message being trained',
`Thank you for your contribution! The selected message is being trained as \`${selectedLabel}\`. 🎉`,
),
],
ephemeral: true,
})
} catch (e) {
logger.error('Failed to handle train message interaction:', e)
await interaction.reply({
embeds: [createStackTraceEmbed(e)],
ephemeral: true,
})
}
})

View File

@@ -18,7 +18,7 @@ withContext(on, 'messageCreate', async (context, msg) => {
if (!commandName) return
const command = discord.commands[commandName]
logger.debug(`Command ${commandName} being invoked by ${msg.author.id}`)
logger.debug(`Command ${commandName} being invoked by ${msg.author.id} via message`)
if (!command) return void logger.debug(`Message command ${commandName} not implemented`)
const argsRegex: RegExp = /[^\s"]+|"([^"]*)"/g
@@ -46,7 +46,7 @@ withContext(on, 'messageCreate', async (context, msg) => {
}
try {
logger.debug(`Command ${commandName} being executed`)
logger.debug(`Command ${commandName} being executed via message`)
await command.onMessage(context, msg, args)
} catch (err) {
if (!(err instanceof CommandError)) logger.error(`Error while executing command ${commandName}:`, err)

View File

@@ -59,26 +59,39 @@ withContext(on, 'messageCreate', async (context, msg) => {
}
}
if (msg.attachments.size > 0 && config.attachments?.scanAttachments) {
if (msg.attachments.size && config.attachments?.scanAttachments) {
logger.debug(`Classifying message attachments for ${msg.id}`)
for (const attachment of msg.attachments.values()) {
if (
config.attachments.allowedMimeTypes &&
!config.attachments.allowedMimeTypes.includes(attachment.contentType!)
) {
logger.debug(`Disallowed MIME type for attachment: ${attachment.url}, ${attachment.contentType}`)
const mimeType = attachment.contentType?.split(';')?.[0]
if (!mimeType) return void logger.warn(`No MIME type for attachment: ${attachment.url}`)
if (config.attachments.allowedMimeTypes && !config.attachments.allowedMimeTypes.includes(mimeType)) {
logger.debug(`Disallowed MIME type for attachment: ${attachment.url}, ${mimeType}`)
continue
}
if (attachment.contentType?.startsWith('text/') && attachment.size > (config.attachments.maxTextFileSize ?? 512 * 1000)) {
logger.debug(`Attachment ${attachment.url} is too large be to scanned, size is ${attachment.size}`)
const isTextFile = mimeType.startsWith('text/')
if (isTextFile && attachment.size > (config.attachments.maxTextFileSize ?? 512 * 1000)) {
logger.debug(`Attachment ${attachment.url} is too large be to scanned, size is ${attachment.size}`)
continue
}
try {
const { text: content } = await api.client.parseImage(attachment.url)
const { response } = await getResponseFromText(content, filteredResponses, context, true)
let response: Awaited<ReturnType<typeof getResponseFromText>>['response'] | undefined
if (isTextFile) {
const content = await (await fetch(attachment.url)).text()
response = await getResponseFromText(content, filteredResponses, context, {
textRegexesOnly: true,
}).then(it => it.response)
} else {
const { text: content } = await api.client.parseImage(attachment.url)
response = await getResponseFromText(content, filteredResponses, context, {
imageTriggersOnly: true,
}).then(it => it.response)
}
if (response) {
logger.debug(`Response found for attachment: ${attachment.url}`)
@@ -89,8 +102,8 @@ withContext(on, 'messageCreate', async (context, msg) => {
break
}
} catch {
logger.error(`Failed to parse image: ${attachment.url}`)
} catch (e) {
logger.error(`Failed to parse attachment: ${attachment.url}`, e)
}
}
}

View File

@@ -7,22 +7,39 @@ withContext(on, 'messageCreate', async ({ discord, logger }, msg) => {
const store = discord.stickyMessages[msg.guildId]?.[msg.channelId]
if (!store) return
const timerPreviouslyActive = store.timerActive
// If there isn't a timer, start it up
store.timerActive = true
if (!store.timer) store.timer = setTimeout(store.send, store.timerMs) as NodeJS.Timeout
else {
// If there is a timer, but it isn't active, restart it
if (!store.timerActive) store.timer.refresh()
// If there is a timer and it is active, but the force timer isn't active...
else if (!store.forceTimerActive && store.forceTimerMs) {
logger.debug(`Channel ${msg.channelId} in guild ${msg.guildId} is active, starting force send timer and clearing existing timer`)
/*
If:
- (negate carried) There's a timer
- The timer is not active
- The force timer is not active
Then:
- Restart the timer
*/
if (!timerPreviouslyActive && !store.forceTimerActive) store.timer.refresh()
/*
If:
- Any of:
- (negate carried) The timer is active
- (negate carried) The force timer is active
- The force timer is not active
Then:
- Start the force timer and clear the existing timer
*/ else if (!store.forceTimerActive && store.forceTimerMs) {
logger.debug(
`Channel ${msg.channelId} in guild ${msg.guildId} is active, starting force send timer and clearing existing timer`,
)
// Clear the timer
clearTimeout(store.timer)
store.timerActive = false
store.forceTimerActive = true
// (Re)start the force timer
store.forceTimerActive = true
if (!store.forceTimer)
store.forceTimer = setTimeout(
() =>

View File

@@ -20,11 +20,10 @@ const PossibleReactions = Object.values(Reactions) as string[]
withContext(on, 'messageReactionAdd', async (context, rct, user) => {
if (user.bot) return
await rct.users.remove(user.id)
const { database: db, logger, config } = context
const { messageScan: msConfig } = config
// If there's no config, we can't do anything
if (!msConfig?.humanCorrections) return
@@ -33,6 +32,7 @@ withContext(on, 'messageReactionAdd', async (context, rct, user) => {
if (reactionMessage.author.id !== reaction.client.user!.id) return
if (!PossibleReactions.includes(reaction.emoji.name!)) return
await rct.users.remove(user.id)
if (!isAdmin(reactionMessage.member || reactionMessage.author)) {
// User is in guild, and config has member requirements

View File

@@ -1,6 +1,7 @@
import { type Response, responses } from '$/database/schemas'
import type { Config, ConfigMessageScanResponse, ConfigMessageScanResponseLabelConfig } from 'config.schema'
import type { Message, PartialUser, User } from 'discord.js'
import { ButtonStyle, ComponentType } from 'discord.js'
import type { APIActionRowComponent, APIButtonComponent, Message, PartialUser, User } from 'discord.js'
import { eq } from 'drizzle-orm'
import { createMessageScanResponseEmbed } from './embeds'
@@ -9,7 +10,7 @@ export const getResponseFromText = async (
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,
flags: { imageTriggersOnly?: boolean; textRegexesOnly?: boolean } = {},
): Promise<
Omit<ConfigMessageScanResponse, 'triggers'> & { label?: string; triggers?: ConfigMessageScanResponse['triggers'] }
> => {
@@ -31,7 +32,7 @@ export const getResponseFromText = async (
triggers: { text: textTriggers, image: imageTriggers },
} = trigger
if (ocrMode) {
if (flags.imageTriggersOnly) {
if (imageTriggers)
for (const regex of imageTriggers)
if (regex.test(content)) {
@@ -39,7 +40,7 @@ export const getResponseFromText = async (
responseConfig = trigger
break
}
} else
} else {
for (let j = 0; j < textTriggers!.length; j++) {
const regex = textTriggers![j]!
@@ -54,10 +55,11 @@ export const getResponseFromText = async (
break
}
}
}
}
// If none of the regexes match, we can search for labels immediately
if (!responseConfig.triggers && !ocrMode) {
if (!responseConfig.triggers && !flags.textRegexesOnly) {
logger.debug('No match from before regexes, doing NLP')
const scan = await api.client.parseText(content)
if (scan.labels.length) {
@@ -87,7 +89,7 @@ export const getResponseFromText = async (
}
// If we still don't have a response config, we can match all regexes after the initial label trigger
if (!responseConfig.triggers && ocrMode) {
if (!responseConfig.triggers && !flags.imageTriggersOnly) {
logger.debug('No match from NLP, doing after regexes')
for (let i = 0; i < responses.length; i++) {
const {
@@ -159,14 +161,41 @@ export const handleUserResponseCorrection = async (
})
.where(eq(responses.replyId, response.replyId))
await reply.edit({
return void (await reply.edit({
...correctLabelResponse.response,
embeds: correctLabelResponse.response.embeds?.map(createMessageScanResponseEmbed),
})
components: [],
}))
}
await api.client.trainMessage(response.content, label)
logger.debug(`User ${user.id} trained message ${response.replyId} as ${label} (positive)`)
await reply.reactions.removeAll()
await reply.edit({
components: [],
})
}
export const createMessageScanResponseComponents = (reply: Message<true>) => [
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
emoji: {
id: '👍',
},
custom_id: `train:${reply.id}`,
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
emoji: {
id: '🔧',
},
custom_id: `edit:${reply.id}`,
},
],
} as APIActionRowComponent<APIButtonComponent>,
]

View File

@@ -1,6 +1,6 @@
import { config, logger } from '$/context'
import decancer from 'decancer'
import type { ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, Message, User } from 'discord.js'
import type { CommandInteraction, EmbedBuilder, Guild, GuildMember, Message, User } from 'discord.js'
import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from './embeds'
const PresetLogAction = {
@@ -10,7 +10,7 @@ const PresetLogAction = {
export const sendPresetReplyAndLogs = (
action: keyof typeof PresetLogAction,
interaction: ChatInputCommandInteraction | Message,
interaction: CommandInteraction | Message,
executor: GuildMember,
user: User,
preset: string,
@@ -23,10 +23,7 @@ export const sendPresetReplyAndLogs = (
]),
)
export const sendModerationReplyAndLogs = async (
interaction: ChatInputCommandInteraction | Message,
embed: EmbedBuilder,
) => {
export const sendModerationReplyAndLogs = async (interaction: CommandInteraction | Message, embed: EmbedBuilder) => {
const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch())
const logChannel = await getLogChannel(interaction.guild!)
await logChannel?.send({ embeds: [applyReferenceToModerationActionEmbed(embed, reply.url)] })

View File

@@ -6,9 +6,9 @@ import { and, eq } from 'drizzle-orm'
// TODO: Fix this type
type PresetKey = string
export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, untilMs: number) => {
const afterInsert = await applyRolesUsingPreset(presetName, member, true)
const until = untilMs === Infinity ? null : Math.ceil(untilMs / 1000)
export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, expires: number) => {
const { removed, callback } = await applyRolesUsingPreset(presetName, member)
const until = expires === Infinity ? null : Math.ceil(expires / 1000)
await database
.insert(appliedPresets)
@@ -16,39 +16,60 @@ export const applyRolePreset = async (member: GuildMember, presetName: PresetKey
memberId: member.id,
guildId: member.guild.id,
preset: presetName,
removedRoles: removed,
until,
})
.onConflictDoUpdate({
target: [appliedPresets.memberId, appliedPresets.preset, appliedPresets.guildId],
set: { until },
})
.then(afterInsert)
.then(callback)
}
export const removeRolePreset = async (member: GuildMember, presetName: PresetKey) => {
const afterDelete = await applyRolesUsingPreset(presetName, member, false)
const where = and(
eq(appliedPresets.memberId, member.id),
eq(appliedPresets.preset, presetName),
eq(appliedPresets.guildId, member.guild.id),
)
await database
.delete(appliedPresets)
.where(
and(
eq(appliedPresets.memberId, member.id),
eq(appliedPresets.preset, presetName),
eq(appliedPresets.guildId, member.guild.id),
),
)
.execute()
.then(afterDelete)
const data = await database.query.appliedPresets.findFirst({ where })
if (!data) return false
const { callback } = await applyRolesUsingPreset(presetName, member, data.removedRoles)
await database.delete(appliedPresets).where(where).execute().then(callback)
return true
}
export const applyRolesUsingPreset = async (presetName: string, member: GuildMember, applying: boolean) => {
export const applyRolesUsingPreset = async (
presetName: string,
member: GuildMember,
removePresetGiveRoles?: string[],
) => {
const preset = config.rolePresets?.guilds[member.guild.id]?.[presetName]
if (!preset) throw new Error(`The preset "${presetName}" does not exist for this server`)
const roles = new Set(member.roles.cache.keys())
const removed: string[] = []
for (const role of preset.give) roles[applying ? 'add' : 'delete'](role)
for (const role of preset.take) roles[applying ? 'delete' : 'add'](role)
// If removePresetGiveRoles is not provided, we're applying a preset
if (!removePresetGiveRoles) {
for (const role of preset.give) roles.add(role)
for (const role of preset.take) {
if (roles.has(role)) {
roles.delete(role)
removed.push(role)
}
}
} else {
const guildRoles = await member.guild.roles.fetch()
for (const role of preset.give) roles.delete(role)
for (const role of removePresetGiveRoles) if (guildRoles.has(role)) roles.add(role)
}
return () => member.roles.set(Array.from(roles))
return {
removed,
callback: () => member.roles.set(Array.from(roles)),
}
}

View File

@@ -1,15 +1,6 @@
export const parseDuration = (duration: string) => {
if (!duration.length) return Number.NaN
const matches = duration.match(/(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?/)!
import parse from 'parse-duration'
const [, days, hours, minutes, seconds] = matches.map(Number)
return (
(days || 0) * 24 * 60 * 60 * 1000 +
(hours || 0) * 60 * 60 * 1000 +
(minutes || 0) * 60 * 1000 +
(seconds || 0) * 1000
)
}
export const parseDuration = (duration: string) => parse(duration, 'ms') ?? Number.NaN
export const durationToString = (duration: number) => {
if (duration === 0) return '0s'

BIN
bun.lockb

Binary file not shown.

View File

@@ -30,22 +30,22 @@
"packageManager": "bun@1.1.20",
"devDependencies": {
"@anolilab/multi-semantic-release": "^1.1.3",
"@biomejs/biome": "^1.8.3",
"@biomejs/biome": "^1.9.2",
"@codedependant/semantic-release-docker": "^5.0.3",
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@saithodev/semantic-release-backmerge": "^4.0.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@tsconfig/strictest": "^2.0.5",
"@types/bun": "^1.1.6",
"@types/bun": "^1.1.10",
"conventional-changelog-conventionalcommits": "^7.0.2",
"lefthook": "^1.7.5",
"lefthook": "^1.7.15",
"portainer-service-webhook": "https://github.com/newarifrh/portainer-service-webhook#v1",
"semantic-release": "^24.0.0",
"turbo": "^2.0.9",
"typescript": "^5.5.4"
"semantic-release": "^24.1.1",
"turbo": "^2.1.2",
"typescript": "^5.6.2"
},
"trustedDependencies": [
"@biomejs/biome",