Compare commits

..

10 Commits

Author SHA1 Message Date
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
24 changed files with 566 additions and 177 deletions

View File

@@ -1,3 +1,32 @@
# @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)

View File

@@ -2,16 +2,15 @@
"name": "@revanced/discord-bot",
"type": "module",
"private": true,
"version": "1.0.0-dev.22",
"version": "1.0.0-dev.26",
"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",

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() ? null : (executor as GuildMember)
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

@@ -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,7 +53,7 @@ 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.',
)

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.',
)

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

@@ -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

@@ -7,12 +7,13 @@ 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 (!timerPreviouslyActive) 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`)

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

BIN
bun.lockb

Binary file not shown.