Compare commits

...

16 Commits

Author SHA1 Message Date
semantic-release-bot
887ee85e41 chore(release): 1.0.0-dev.7 [skip ci]
# @revanced/bot-websocket-api [1.0.0-dev.7](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.6...@revanced/bot-websocket-api@1.0.0-dev.7) (2024-07-30)
2024-07-30 19:13:45 +00:00
PalmDevs
c9b788dc51 build(Needs bump): do not minify builds 2024-07-31 02:12:31 +07:00
PalmDevs
ab62e55e76 fix(bots/discord): broken regex when prefix set to special characters 2024-07-31 02:12:30 +07:00
semantic-release-bot
8f83687b7c chore(release): 1.0.0-dev.12 [skip ci]
# @revanced/discord-bot [1.0.0-dev.12](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.11...@revanced/discord-bot@1.0.0-dev.12) (2024-07-30)

### Bug Fixes

* **bots/discord:** deployment runtime errors due to minification ([a60c60c](a60c60c0f9))
2024-07-30 18:42:20 +00:00
PalmDevs
a60c60c0f9 fix(bots/discord): deployment runtime errors due to minification 2024-07-31 01:40:45 +07:00
semantic-release-bot
95a122a225 chore(release): 1.0.0-dev.11 [skip ci]
# @revanced/discord-bot [1.0.0-dev.11](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.10...@revanced/discord-bot@1.0.0-dev.11) (2024-07-30)

### Bug Fixes

* **bots/discord:** reset counter when reconnected to api, redo message scan filter logic ([d234d79](d234d79310))
2024-07-30 17:23:25 +00:00
PalmDevs
d234d79310 fix(bots/discord): reset counter when reconnected to api, redo message scan filter logic 2024-07-31 00:22:02 +07:00
semantic-release-bot
3188f8dbed chore(release): 1.0.0-dev.10 [skip ci]
# @revanced/discord-bot [1.0.0-dev.10](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.9...@revanced/discord-bot@1.0.0-dev.10) (2024-07-30)

### Bug Fixes

* **bots/discord:** hanging process when disconnecting from API too many times ([d31616e](d31616ebcb))
2024-07-30 14:32:44 +00:00
semantic-release-bot
9b9bb1e1e6 chore(release): 1.0.0-dev.6 [skip ci]
# @revanced/bot-websocket-api [1.0.0-dev.6](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.5...@revanced/bot-websocket-api@1.0.0-dev.6) (2024-07-30)

### Bug Fixes

* **bots/discord:** hanging process when disconnecting from API too many times ([d31616e](d31616ebcb))
2024-07-30 14:32:03 +00:00
PalmDevs
d31616ebcb fix(bots/discord): hanging process when disconnecting from API too many times 2024-07-30 21:30:54 +07:00
semantic-release-bot
2efedc47df chore(release): 1.0.0-dev.9 [skip ci]
# @revanced/discord-bot [1.0.0-dev.9](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.8...@revanced/discord-bot@1.0.0-dev.9) (2024-07-30)

### Features

* **bots/discord:** framework changes and new features ([646ec8d](646ec8da87))
2024-07-30 14:17:08 +00:00
PalmDevs
646ec8da87 feat(bots/discord): framework changes and new features
- Migrated to a new command framework which looks better and works better
- Fixed commands not being bundled correctly
- Added message (prefix) commands with argument validation
- Added a new CommandErrorType, for invalid arguments
- `/eval` is now a bit safer
- Corrected colors for the coinflip embed
- `/stop` now works even when the bot is not connected to the API
2024-07-30 21:15:36 +07:00
PalmDevs
a848a9c896 feat(packages/api): add force disconnecting and disconnected getter in APIClient 2024-07-30 21:14:20 +07:00
semantic-release-bot
8168f79ac6 chore(release): 1.0.0-dev.8 [skip ci]
# @revanced/discord-bot [1.0.0-dev.8](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.7...@revanced/discord-bot@1.0.0-dev.8) (2024-07-28)

### Bug Fixes

* **bots/discord:** cross-device link build errors ([38c0699](38c06997b4))

### Features

* **bots/discord:** blacklist and whitelist for filters ([cdb6001](cdb6001955))
2024-07-28 14:15:40 +00:00
PalmDevs
38c06997b4 fix(bots/discord): cross-device link build errors 2024-07-28 21:14:07 +07:00
PalmDevs
cdb6001955 feat(bots/discord): blacklist and whitelist for filters 2024-07-28 20:43:25 +07:00
50 changed files with 1316 additions and 681 deletions

View File

@@ -1,3 +1,12 @@
# @revanced/bot-websocket-api [1.0.0-dev.7](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.6...@revanced/bot-websocket-api@1.0.0-dev.7) (2024-07-30)
# @revanced/bot-websocket-api [1.0.0-dev.6](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.5...@revanced/bot-websocket-api@1.0.0-dev.6) (2024-07-30)
### Bug Fixes
* **bots/discord:** hanging process when disconnecting from API too many times ([d31616e](https://github.com/revanced/revanced-helper/commit/d31616ebcba6f1dcd8bde183bcb8d1adb1501b61))
# @revanced/bot-websocket-api [1.0.0-dev.5](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.4...@revanced/bot-websocket-api@1.0.0-dev.5) (2024-07-23) # @revanced/bot-websocket-api [1.0.0-dev.5](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.4...@revanced/bot-websocket-api@1.0.0-dev.5) (2024-07-23)
# @revanced/bot-websocket-api [1.0.0-dev.4](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.3...@revanced/bot-websocket-api@1.0.0-dev.4) (2024-07-23) # @revanced/bot-websocket-api [1.0.0-dev.4](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.3...@revanced/bot-websocket-api@1.0.0-dev.4) (2024-07-23)

View File

@@ -2,7 +2,7 @@
"name": "@revanced/bot-websocket-api", "name": "@revanced/bot-websocket-api",
"type": "module", "type": "module",
"private": true, "private": true,
"version": "1.0.0-dev.5", "version": "1.0.0-dev.7",
"description": "🧦 WebSocket API server for bots assisting ReVanced", "description": "🧦 WebSocket API server for bots assisting ReVanced",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {

View File

@@ -11,7 +11,6 @@ await Bun.build({
entrypoints: ['./src/index.ts'], entrypoints: ['./src/index.ts'],
outdir: './dist', outdir: './dist',
target: 'bun', target: 'bun',
minify: true,
sourcemap: 'external', sourcemap: 'external',
}) })
@@ -21,7 +20,6 @@ await Bun.build({
external: ['tesseract.js-core/*'], external: ['tesseract.js-core/*'],
target: 'bun', target: 'bun',
outdir: './dist/worker', outdir: './dist/worker',
minify: true,
sourcemap: 'external', sourcemap: 'external',
}) })

View File

@@ -21,6 +21,9 @@
}, },
"useNodejsImportProtocol": { "useNodejsImportProtocol": {
"level": "off" "level": "off"
},
"useNumberNamespace": {
"level": "off"
} }
} }
} }

View File

@@ -1,3 +1,43 @@
# @revanced/discord-bot [1.0.0-dev.12](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.11...@revanced/discord-bot@1.0.0-dev.12) (2024-07-30)
### Bug Fixes
* **bots/discord:** deployment runtime errors due to minification ([a60c60c](https://github.com/revanced/revanced-helper/commit/a60c60c0f994a4c256b7d0582e99a1731209cf49))
# @revanced/discord-bot [1.0.0-dev.11](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.10...@revanced/discord-bot@1.0.0-dev.11) (2024-07-30)
### Bug Fixes
* **bots/discord:** reset counter when reconnected to api, redo message scan filter logic ([d234d79](https://github.com/revanced/revanced-helper/commit/d234d79310caed9c43e14a905f9ef46a110e071d))
# @revanced/discord-bot [1.0.0-dev.10](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.9...@revanced/discord-bot@1.0.0-dev.10) (2024-07-30)
### Bug Fixes
* **bots/discord:** hanging process when disconnecting from API too many times ([d31616e](https://github.com/revanced/revanced-helper/commit/d31616ebcba6f1dcd8bde183bcb8d1adb1501b61))
# @revanced/discord-bot [1.0.0-dev.9](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.8...@revanced/discord-bot@1.0.0-dev.9) (2024-07-30)
### Features
* **bots/discord:** framework changes and new features ([646ec8d](https://github.com/revanced/revanced-helper/commit/646ec8da87617e6c8f48a89e8054e2cba91da549))
# @revanced/discord-bot [1.0.0-dev.8](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.7...@revanced/discord-bot@1.0.0-dev.8) (2024-07-28)
### Bug Fixes
* **bots/discord:** cross-device link build errors ([38c0699](https://github.com/revanced/revanced-helper/commit/38c06997b4d0f7bb3f1e62618a5e3f088c522e30))
### Features
* **bots/discord:** blacklist and whitelist for filters ([cdb6001](https://github.com/revanced/revanced-helper/commit/cdb600195520dba33110c40841629259e317055e))
# @revanced/discord-bot [1.0.0-dev.7](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.6...@revanced/discord-bot@1.0.0-dev.7) (2024-07-25) # @revanced/discord-bot [1.0.0-dev.7](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.6...@revanced/discord-bot@1.0.0-dev.7) (2024-07-25)

View File

@@ -4,6 +4,7 @@
* @type {import('./config.schema').Config} * @type {import('./config.schema').Config}
*/ */
export default { export default {
prefix: '!',
admin: { admin: {
users: ['USER_ID_HERE'], users: ['USER_ID_HERE'],
roles: { roles: {
@@ -37,11 +38,19 @@ export default {
checkExpiredEvery: 3600, checkExpiredEvery: 3600,
}, },
messageScan: { messageScan: {
scanBots: false,
scanOutsideGuilds: false,
filter: { filter: {
channels: ['CHANNEL_ID_HERE'], whitelist: {
roles: ['ROLE_ID_HERE'], channels: ['CHANNEL_ID_HERE'],
users: ['USER_ID_HERE'], roles: ['ROLE_ID_HERE'],
whitelist: false, users: ['USER_ID_HERE'],
},
blacklist: {
channels: ['CHANNEL_ID_HERE'],
roles: ['ROLE_ID_HERE'],
users: ['USER_ID_HERE'],
},
}, },
humanCorrections: { humanCorrections: {
falsePositiveLabel: 'false_positive', falsePositiveLabel: 'false_positive',
@@ -55,6 +64,18 @@ export default {
allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
responses: [ responses: [
{ {
filterOverride: {
whitelist: {
channels: ['CHANNEL_ID_HERE'],
roles: ['ROLE_ID_HERE'],
users: ['USER_ID_HERE'],
},
blacklist: {
channels: ['CHANNEL_ID_HERE'],
roles: ['ROLE_ID_HERE'],
users: ['USER_ID_HERE'],
},
},
triggers: { triggers: {
text: [/^regexp?$/, { label: 'label', threshold: 0.85 }], text: [/^regexp?$/, { label: 'label', threshold: 0.85 }],
}, },

View File

@@ -1,6 +1,7 @@
import type { BaseMessageOptions } from 'discord.js' import type { BaseMessageOptions } from 'discord.js'
export type Config = { export type Config = {
prefix?: string
admin?: { admin?: {
users?: string[] users?: string[]
roles?: Record<string, string[]> roles?: Record<string, string[]>
@@ -20,12 +21,12 @@ export type Config = {
guilds: Record<string, Record<string, RolePresetConfig>> guilds: Record<string, Record<string, RolePresetConfig>>
} }
messageScan?: { messageScan?: {
scanBots?: boolean
scanOutsideGuilds?: boolean
allowedAttachmentMimeTypes: string[] allowedAttachmentMimeTypes: string[]
filter: { filter?: {
roles?: string[] whitelist?: Filter
users?: string[] blacklist?: Filter
channels?: string[]
whitelist: boolean
} }
humanCorrections: { humanCorrections: {
falsePositiveLabel: string falsePositiveLabel: string
@@ -73,4 +74,10 @@ export type ConfigMessageScanResponseLabelConfig = {
threshold: number threshold: number
} }
export type Filter = {
roles?: string[]
users?: string[]
channels?: string[]
}
export type ConfigMessageScanResponseMessage = BaseMessageOptions export type ConfigMessageScanResponseMessage = BaseMessageOptions

View File

@@ -35,8 +35,6 @@ export default {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("my-command") .setName("my-command")
.setDescription("My cool command") .setDescription("My cool command")
// Allowing this command to be used in DMs
.setDMPermission(true)
// DO NOT forget this line! // DO NOT forget this line!
.toJSON(), .toJSON(),

View File

@@ -2,7 +2,7 @@
"name": "@revanced/discord-bot", "name": "@revanced/discord-bot",
"type": "module", "type": "module",
"private": true, "private": true,
"version": "1.0.0-dev.7", "version": "1.0.0-dev.12",
"description": "🤖 Discord bot assisting ReVanced", "description": "🤖 Discord bot assisting ReVanced",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@revanced/bot-shared' import { createLogger } from '@revanced/bot-shared'
import { cp, rename, rm } from 'fs/promises' import { cp, rm } from 'fs/promises'
const logger = createLogger() const logger = createLogger()
@@ -12,12 +12,12 @@ await Bun.build({
outdir: './dist/src', outdir: './dist/src',
target: 'bun', target: 'bun',
external: ['./config.js'], external: ['./config.js'],
minify: true,
sourcemap: 'external', sourcemap: 'external',
}) })
logger.info('Copying config...') logger.info('Copying config...')
await cp('config.js', 'dist/config.js') await cp('./config.js', './dist/config.js')
logger.info('Copying database schema...') logger.info('Copying database schema...')
await rename('.drizzle', 'dist/.drizzle') await cp('./.drizzle', './dist/.drizzle', { recursive: true })
await rm('./.drizzle', { recursive: true })

View File

@@ -1,53 +0,0 @@
console.log('Deprecated. New implementation to be done.')
process.exit(1)
// import { REST } from '@discordjs/rest'
// import { getMissingEnvironmentVariables } from '@revanced/bot-shared'
// import { Routes } from 'discord-api-types/v9'
// import type {
// RESTGetCurrentApplicationResult,
// RESTPutAPIApplicationCommandsResult,
// RESTPutAPIApplicationGuildCommandsResult,
// } from 'discord.js'
// import { config, discord, logger } from '../src/context'
// // Check if token exists
// const missingEnvs = getMissingEnvironmentVariables(['DISCORD_TOKEN'])
// if (missingEnvs.length) {
// for (const env of missingEnvs) logger.fatal(`${env} is not defined in environment variables`)
// process.exit(1)
// }
// // Group commands by global and guild
// const { global: globalCommands = [], guild: guildCommands = [] } = Object.groupBy(Object.values(discord.commands), c =>
// c.global ? 'global' : 'guild',
// )
// // Set commands
// const rest = new REST({ version: '10' }).setToken(process.env['DISCORD_TOKEN']!)
// try {
// const app = (await rest.get(Routes.currentApplication())) as RESTGetCurrentApplicationResult
// const data = (await rest.put(Routes.applicationCommands(app.id), {
// body: globalCommands.map(({ data }) => {
// if (!data.dm_permission) data.dm_permission = true
// logger.warn(`Command ${data.name} has no dm_permission set, forcing to true as it is a global command`)
// return data
// }),
// })) as RESTPutAPIApplicationCommandsResult
// logger.info(`Reloaded ${data.length} global commands`)
// for (const guildId of config.guilds) {
// const data = (await rest.put(Routes.applicationGuildCommands(app.id, guildId), {
// body: guildCommands.map(x => x.data),
// })) as RESTPutAPIApplicationGuildCommandsResult
// logger.info(`Reloaded ${data.length} guild commands for guild ${guildId}`)
// }
// } catch (e) {
// logger.fatal(e)
// }

View File

@@ -0,0 +1,591 @@
import { ApplicationCommandOptionType } from 'discord.js'
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,
CommandInteractionOption,
GuildMember,
Message,
RESTPostAPIChatInputApplicationCommandsJSONBody,
Role,
User,
} from 'discord.js'
export default class Command<
Global extends boolean = false,
Options extends CommandOptionsOptions | undefined = undefined,
AllowMessageCommand extends boolean = false,
> {
name: string
description: string
requirements?: CommandRequirements
options?: Options
global?: Global
#execute: CommandExecuteFunction<Global, Options, AllowMessageCommand>
static OptionType = ApplicationCommandOptionType
constructor({
name,
description,
requirements,
options,
global,
execute,
}: CommandOptions<Global, Options, AllowMessageCommand>) {
this.name = name
this.description = description
this.requirements = requirements
this.options = options
this.global = global
this.#execute = execute
}
async onMessage(
context: typeof import('../context'),
msg: Message<If<Global, false, true>>,
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.')],
})
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 options = this.options
? ((await this.#resolveMessageOptions(msg, this.options, args)) as CommandExecuteFunctionOptionsParameter<
NonNullable<Options>
>)
: undefined
// @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough
return await this.#execute({ ...context, executor }, msg, options)
}
async #resolveMessageOptions(msg: Message, options: CommandOptionsOptions, args: CommandArguments) {
const iterableOptions = Object.entries(options)
const _options = {} as unknown
for (let i = 0; i < iterableOptions.length; i++) {
const [name, option] = iterableOptions[i]!
const { type, required, description } = option
const isSubcommandLikeOption =
type === ApplicationCommandOptionType.Subcommand ||
type === ApplicationCommandOptionType.SubcommandGroup
const arg = args[i]
const expectedType = `${ApplicationCommandOptionType[type]}${required ? '' : '?'}`
const argExplainationString = `\n-# **${name}**: ${description}`
const choicesString =
'choices' in option && option.choices
? `\n\n-# **AVAILABLE CHOICES**\n${option.choices.map(({ value }) => `- ${value}`).join('\n')}`
: ''
if (isSubcommandLikeOption && !arg)
throw new CommandError(
CommandErrorType.MissingArgument,
`Missing required subcommand.\n\n-# **AVAILABLE SUBCOMMANDS**\n${iterableOptions.map(([name, { description }]) => `- **${name}**: ${description}`).join('\n')}`,
)
if (required && !arg)
throw new CommandError(
CommandErrorType.MissingArgument,
`Missing required argument **${name}** with type **${expectedType}**.${argExplainationString}${choicesString}`,
)
if (typeof arg === 'object' && arg.type !== type)
throw new CommandError(
CommandErrorType.InvalidArgument,
`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))
throw new CommandError(
CommandErrorType.InvalidArgument,
`Invalid choice for argument **${name}**.\n${argExplainationString}\n\n${choicesString}\n`,
)
const argValue = typeof arg === 'string' ? arg : arg?.id
if (argValue && arg) {
if (isSubcommandLikeOption) {
const [subcommandName, subcommandOption] = iterableOptions.find(([name]) => name === argValue)!
// @ts-expect-error: Not smart enough, TypeScript :(
_options[subcommandName] = await this.#resolveMessageOptions(
msg,
(subcommandOption as CommandSubcommandLikeOption).options,
args.slice(i + 1),
)
break
}
if (
(type === ApplicationCommandOptionType.Channel ||
type === ApplicationCommandOptionType.User ||
type === ApplicationCommandOptionType.Role) &&
Number.isNaN(Number(argValue))
)
throw new CommandError(
CommandErrorType.InvalidArgument,
`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.Boolean &&
!['true', 'false', 'yes', 'no', 'y', 'n', 't', 'f'].includes(argValue)
)
throw new CommandError(
CommandErrorType.InvalidArgument,
`Invalid boolean for argument **${name}**.${argExplainationString}`,
)
// @ts-expect-error: Not smart enough, TypeScript :(
_options[name] =
type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer
? Number(argValue)
: type === ApplicationCommandOptionType.Boolean
? argValue[0] === 't' || argValue[0] === 'y'
: type === ApplicationCommandOptionType.Channel
? await msg.client.channels.fetch(argValue)
: type === ApplicationCommandOptionType.User
? await msg.client.users.fetch(argValue)
: type === ApplicationCommandOptionType.Role
? await msg.guild?.roles.fetch(argValue)
: argValue
}
}
return _options
}
async onInteraction(
context: typeof import('../context'),
interaction: ChatInputCommandInteraction,
): Promise<unknown> {
const { logger } = context
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.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 options = this.options
? ((await this.#resolveInteractionOptions(interaction)) as CommandExecuteFunctionOptionsParameter<
NonNullable<Options>
>)
: 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.',
),
],
})
// @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough
return await this.#execute({ ...context, executor }, interaction, options)
}
async #resolveInteractionOptions(
interaction: ChatInputCommandInteraction,
options: readonly CommandInteractionOption[] = interaction.options.data,
) {
const _options = {} as unknown
if (this.options)
for (const { name, type, value } of options) {
if (this.options[name]?.type !== type) return null
if (
type === ApplicationCommandOptionType.Subcommand ||
type === ApplicationCommandOptionType.SubcommandGroup
) {
const subOptions = Object.entries((this.options[name] as CommandSubcommandLikeOption).options)
// @ts-expect-error: Not smart enough, TypeScript :(
_options[name] = await this.#resolveInteractionOptions(interaction, subOptions)
break
}
if (!value) continue
// @ts-expect-error: Not smart enough, TypeScript :(
_options[name] =
type === ApplicationCommandOptionType.Channel
? await interaction.client.channels.fetch(value as string)
: type === ApplicationCommandOptionType.User
? await interaction.client.users.fetch(value as string)
: type === ApplicationCommandOptionType.Role
? await interaction.guild?.roles.fetch(value as string)
: value
}
return _options
}
async canExecute(executor: User | GuildMember, channelId: string): Promise<boolean> {
if (!this.requirements) return false
const {
adminOnly,
channels,
roles,
permissions,
users,
mode = 'all',
defaultCondition = 'fail',
memberRequirementsForUsers = 'pass',
} = this.requirements
const member = this.global ? null : (executor as GuildMember)
const bDefCond = defaultCondition !== 'fail'
const bMemReqForUsers = memberRequirementsForUsers !== 'fail'
const conditions = [
adminOnly ? isAdmin(executor) : 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,
]
if (mode === 'all' && conditions.some(condition => !condition)) return false
if (mode === 'any' && conditions.every(condition => !condition)) return false
return true
}
get json(): RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> } {
return {
name: this.name,
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],
}
}
#transformOptions(optionsObject: Record<string, CommandOption>) {
const options: RESTPostAPIChatInputApplicationCommandsJSONBody['options'] = []
for (const [name, option] of Object.entries(optionsObject)) {
options.push({
// biome-ignore lint/suspicious/noExplicitAny: Good enough work here
type: option.type as any,
name,
description: option.description,
required: option.required,
...(option.type === ApplicationCommandOptionType.Subcommand ||
option.type === ApplicationCommandOptionType.SubcommandGroup
? {
options: this.#transformOptions((option as CommandSubcommandLikeOption).options),
}
: {}),
...(option.type === ApplicationCommandOptionType.Channel ? { channel_types: option.types } : {}),
...(option.type === ApplicationCommandOptionType.Integer ||
option.type === ApplicationCommandOptionType.Number
? {
min_value: option.min,
max_value: option.max,
choices: option.choices,
autocomplete: option.autocomplete,
}
: {}),
...(option.type === ApplicationCommandOptionType.String
? {
min_length: option.minLength,
max_length: option.maxLength,
choices: option.choices,
autocomplete: option.autocomplete,
}
: {}),
})
}
return options
}
}
export class ModerationCommand<
Options extends CommandOptionsOptions,
AllowMessageCommand extends boolean = true,
> extends Command<false, Options, AllowMessageCommand> {
constructor(options: ExtendedCommandOptions<false, Options, AllowMessageCommand>) {
super({
...options,
requirements: {
...options.requirements,
defaultCondition: 'pass',
roles: (config.moderation?.roles ?? []).concat(options.requirements?.roles ?? []),
},
// @ts-expect-error: No thanks
allowMessageCommand: options.allowMessageCommand ?? true,
global: false,
})
}
}
export class AdminCommand<Options extends CommandOptionsOptions, AllowMessageCommand extends boolean> extends Command<
true,
Options,
AllowMessageCommand
> {
constructor(options: ExtendedCommandOptions<true, Options, AllowMessageCommand>) {
super({
...options,
requirements: {
...options.requirements,
adminOnly: true,
defaultCondition: 'pass',
},
global: true,
})
}
}
/* TODO:
APIApplicationCommandAttachmentOption
APIApplicationCommandMentionableOption
APIApplicationCommandRoleOption
*/
export interface CommandOptions<
Global extends boolean,
Options extends CommandOptionsOptions | undefined,
AllowMessageCommand extends boolean,
> {
name: string
description: string
requirements?: CommandRequirements
options?: Options
execute: CommandExecuteFunction<Global, Options, AllowMessageCommand>
global?: Global
allowMessageCommand?: AllowMessageCommand
}
export type CommandArguments = Array<string | CommandSpecialArgument>
export type CommandSpecialArgument = {
type: (typeof CommandSpecialArgumentType)[keyof typeof CommandSpecialArgumentType]
id: string
}
export const CommandSpecialArgumentType = {
Channel: ApplicationCommandOptionType.Channel,
Role: ApplicationCommandOptionType.Role,
User: ApplicationCommandOptionType.User,
}
type ExtendedCommandOptions<
Global extends boolean,
Options extends CommandOptionsOptions,
AllowMessageCommand extends boolean,
> = Omit<CommandOptions<Global, Options, AllowMessageCommand>, 'global'> & {
requirements?: Omit<CommandOptions<false, Options, AllowMessageCommand>['requirements'], 'defaultCondition'>
}
export type CommandOptionsOptions = Record<string, CommandOption>
type CommandExecuteFunction<
Global extends boolean,
Options extends CommandOptionsOptions | undefined,
AllowMessageCommand extends boolean,
> = (
context: CommandContext<Global>,
trigger: If<
AllowMessageCommand,
Message<InvertBoolean<Global>> | ChatInputCommandInteraction<If<Global, CacheType, 'raw' | 'cached'>>,
ChatInputCommandInteraction<If<Global, CacheType, 'raw' | 'cached'>>
>,
options: Options extends CommandOptionsOptions ? CommandExecuteFunctionOptionsParameter<Options> : never,
) => Promise<unknown> | unknown
type If<T extends boolean | undefined, U, V> = T extends true ? U : V
type InvertBoolean<T extends boolean> = If<T, false, true>
type CommandExecuteFunctionOptionsParameter<Options extends CommandOptionsOptions> = {
[K in keyof Options]: Options[K]['type'] extends
| ApplicationCommandOptionType.Subcommand
| ApplicationCommandOptionType.SubcommandGroup
? // @ts-expect-error: Shut up, it works
CommandExecuteFunctionOptionsParameter<Options[K]['options']> | undefined
: If<
Options[K]['required'],
CommandOptionValueMap[Options[K]['type']],
CommandOptionValueMap[Options[K]['type']] | undefined
>
}
type CommandContext<Global extends boolean> = typeof import('../context') & {
executor: CommandExecutor<Global>
}
type CommandOptionValueMap = {
[ApplicationCommandOptionType.Boolean]: boolean
[ApplicationCommandOptionType.Channel]: Channel
[ApplicationCommandOptionType.Integer]: number
[ApplicationCommandOptionType.Number]: number
[ApplicationCommandOptionType.String]: string
[ApplicationCommandOptionType.User]: User
[ApplicationCommandOptionType.Role]: Role
[ApplicationCommandOptionType.Subcommand]: never
[ApplicationCommandOptionType.SubcommandGroup]: never
}
type CommandOption =
| CommandBooleanOption
| CommandChannelOption
| CommandIntegerOption
| CommandNumberOption
| CommandStringOption
| CommandUserOption
| CommandRoleOption
| CommandSubcommandOption
| CommandSubcommandGroupOption
type CommandExecutor<Global extends boolean> = If<Global, User, GuildMember>
type CommandOptionBase<Type extends ApplicationCommandOptionType> = {
type: Type
description: string
required?: boolean
}
type CommandBooleanOption = CommandOptionBase<ApplicationCommandOptionType.Boolean>
type CommandChannelOption = CommandOptionBase<ApplicationCommandOptionType.Channel> & {
types: APIApplicationCommandChannelOption['channel_types']
}
interface CommandOptionChoice<ValueType = number | string> {
name: string
value: ValueType
}
type CommandOptionWithAutocompleteOrChoicesWrapper<
Base extends CommandOptionBase<ApplicationCommandOptionType>,
ChoiceType extends CommandOptionChoice,
> =
| (Base & {
autocomplete: true
choices?: never
})
| (Base & {
autocomplete?: false
choices?: ChoiceType[] | readonly ChoiceType[]
})
type CommandIntegerOption = CommandOptionWithAutocompleteOrChoicesWrapper<
CommandOptionBase<ApplicationCommandOptionType.Integer>,
CommandOptionChoice<number>
> & {
min?: number
max?: number
}
type CommandNumberOption = CommandOptionWithAutocompleteOrChoicesWrapper<
CommandOptionBase<ApplicationCommandOptionType.Number>,
CommandOptionChoice<number>
> & {
min?: number
max?: number
}
type CommandStringOption = CommandOptionWithAutocompleteOrChoicesWrapper<
CommandOptionBase<ApplicationCommandOptionType.String>,
CommandOptionChoice<string>
> & {
minLength?: number
maxLength?: number
}
type CommandUserOption = CommandOptionBase<ApplicationCommandOptionType.User>
type CommandRoleOption = CommandOptionBase<ApplicationCommandOptionType.Role>
type SubcommandLikeApplicationCommandOptionType =
| ApplicationCommandOptionType.Subcommand
| ApplicationCommandOptionType.SubcommandGroup
interface CommandSubcommandLikeOption<
Type extends SubcommandLikeApplicationCommandOptionType = SubcommandLikeApplicationCommandOptionType,
> extends CommandOptionBase<Type> {
options: CommandOptionsOptions
required?: never
}
type CommandSubcommandOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.Subcommand>
type CommandSubcommandGroupOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.SubcommandGroup>
export type CommandRequirements = Filter & {
mode?: 'all' | 'any'
adminOnly?: boolean
permissions?: bigint
defaultCondition?: 'fail' | 'pass'
memberRequirementsForUsers?: 'pass' | 'fail'
}

View File

@@ -17,6 +17,7 @@ export default class CommandError extends Error {
export enum CommandErrorType { export enum CommandErrorType {
Generic, Generic,
MissingArgument, MissingArgument,
InvalidArgument,
InvalidUser, InvalidUser,
InvalidChannel, InvalidChannel,
InvalidDuration, InvalidDuration,
@@ -25,6 +26,7 @@ export enum CommandErrorType {
const ErrorTitleMap: Record<CommandErrorType, string> = { const ErrorTitleMap: Record<CommandErrorType, string> = {
[CommandErrorType.Generic]: 'An exception was thrown', [CommandErrorType.Generic]: 'An exception was thrown',
[CommandErrorType.MissingArgument]: 'Missing argument', [CommandErrorType.MissingArgument]: 'Missing argument',
[CommandErrorType.InvalidArgument]: 'Invalid argument',
[CommandErrorType.InvalidUser]: 'Invalid user', [CommandErrorType.InvalidUser]: 'Invalid user',
[CommandErrorType.InvalidChannel]: 'Invalid channel', [CommandErrorType.InvalidChannel]: 'Invalid channel',
[CommandErrorType.InvalidDuration]: 'Invalid duration', [CommandErrorType.InvalidDuration]: 'Invalid duration',

View File

@@ -0,0 +1,34 @@
import { inspect } from 'util'
import { runInNewContext } from 'vm'
import { ApplicationCommandOptionType } from 'discord.js'
import { AdminCommand } from '$/classes/Command'
import { createSuccessEmbed } from '$/utils/discord/embeds'
export default new AdminCommand({
name: 'eval',
description: 'Make the bot less sentient by evaluating code',
options: {
code: {
description: 'The code to evaluate',
type: ApplicationCommandOptionType.String,
required: true,
},
['show-hidden']: {
description: 'Show hidden properties',
type: ApplicationCommandOptionType.Boolean,
required: false,
},
},
async execute(context, trigger, { code, 'show-hidden': showHidden }) {
await trigger.reply({
ephemeral: true,
embeds: [
createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({
name: 'Result',
value: `\`\`\`js\n${inspect(runInNewContext(code, { client: trigger.client, context, trigger }), { depth: 1, showHidden, getters: true, numericSeparator: true, showProxy: true })}\`\`\``,
}),
],
})
},
})

View File

@@ -0,0 +1,21 @@
import { ApplicationCommandOptionType } from 'discord.js'
import { AdminCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
export default new AdminCommand({
name: 'exception-test',
description: 'Makes the bot intentionally hate you by throwing an exception',
options: {
type: {
description: 'The type of exception to throw',
type: ApplicationCommandOptionType.String,
required: true,
choices: Object.keys(CommandErrorType).map(k => ({ name: k, value: k })),
},
},
async execute(_, __, { type }) {
if (type === 'Process') throw new Error('Intentional process exception')
throw new CommandError(CommandErrorType[type as keyof typeof CommandErrorType], 'Intentional bot design') // ;)
},
})

View File

@@ -0,0 +1,93 @@
import { ApplicationCommandOptionType, Routes } from 'discord.js'
import { AdminCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { createSuccessEmbed } from '$/utils/discord/embeds'
const SubcommandOptions = {
where: {
description: 'Where to register the commands',
type: ApplicationCommandOptionType.String,
choices: [
{ name: 'globally', value: 'global' },
{ name: 'this server', value: 'server' },
],
required: true,
},
} as const
export default new AdminCommand({
name: 'slash-commands',
description: 'Register or delete slash commands',
options: {
register: {
description: 'Register slash commands',
type: ApplicationCommandOptionType.Subcommand,
options: SubcommandOptions,
},
delete: {
description: 'Delete slash commands',
type: ApplicationCommandOptionType.Subcommand,
options: SubcommandOptions,
},
},
allowMessageCommand: true,
async execute(context, trigger, { delete: deleteOption, register }) {
const action = register ? 'register' : 'delete'
const { where } = (deleteOption ?? register)!
if (!trigger.inGuild())
throw new CommandError(CommandErrorType.Generic, 'This command can only be used in a server.')
const { global: globalCommands, guild: guildCommands } = Object.groupBy(
Object.values(context.discord.commands),
cmd => (cmd.global ? 'global' : 'guild'),
)
const {
client,
client: { rest },
} = trigger
let response: string | undefined
switch (action) {
case 'register':
if (where === 'global') {
response = 'Registered global slash commands'
await rest.put(Routes.applicationCommands(client.application.id), {
body: globalCommands?.map(c => c.json),
})
} else {
response = 'Registered slash commands on this server'
await rest.put(Routes.applicationGuildCommands(client.application.id, trigger.guildId), {
body: guildCommands?.map(c => c.json),
})
}
break
case 'delete':
if (where === 'global') {
response = 'Deleted global slash commands'
await rest.put(Routes.applicationCommands(client.application.id), {
body: [],
})
} else {
response = 'Deleted slash commands on this server'
await rest.put(Routes.applicationGuildCommands(client.application.id, trigger.guildId), {
body: [],
})
}
break
}
await trigger.reply({ embeds: [createSuccessEmbed(response!)] })
},
})

View File

@@ -0,0 +1,24 @@
import { AdminCommand } from '$/classes/Command'
export default new AdminCommand({
name: 'stop',
description: "You don't want to run this unless the bot starts to go insane, and like, you really need to stop it.",
async execute({ api, logger, executor }, trigger) {
api.intentionallyDisconnecting = true
logger.fatal('Stopping bot...')
trigger.reply({
content: 'Stopping... (I will go offline once done)',
ephemeral: true,
})
if (!api.client.disconnected) api.client.disconnect()
logger.warn('Disconnected from API')
trigger.client.destroy()
logger.warn('Disconnected from Discord API')
logger.info(`Bot stopped, requested by ${executor.id}`)
process.exit(0)
},
})

View File

@@ -1,32 +0,0 @@
import { inspect } from 'util'
import { SlashCommandBuilder } from 'discord.js'
import { createSuccessEmbed } from '$/utils/discord/embeds'
import type { Command } from '../types'
export default {
data: new SlashCommandBuilder()
.setName('eval')
.setDescription('Make the bot less sentient by evaluating code')
.addStringOption(option => option.setName('code').setDescription('The code to evaluate').setRequired(true))
.setDMPermission(true)
.toJSON(),
adminOnly: true,
global: true,
async execute(_, interaction) {
const code = interaction.options.getString('code', true)
await interaction.reply({
ephemeral: true,
embeds: [
createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({
name: 'Result',
// biome-ignore lint/security/noGlobalEval: Deal with it
value: `\`\`\`js\n${inspect(eval(code), { depth: 1 })}\`\`\``,
}),
],
})
},
} satisfies Command

View File

@@ -1,36 +0,0 @@
import { SlashCommandBuilder } from 'discord.js'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import type { Command } from '../types'
export default {
data: new SlashCommandBuilder()
.setName('exception-test')
.setDescription('Makes the bot intentionally hate you by throwing an exception')
.addStringOption(option =>
option
.setName('type')
.setDescription('The type of exception to throw')
.setRequired(true)
.addChoices(
Object.keys(CommandErrorType).map(
k =>
({
name: k,
value: k,
}) as const,
),
),
)
.setDMPermission(true)
.toJSON(),
adminOnly: true,
global: true,
async execute(_, interaction) {
const type = interaction.options.getString('type', true)
if (type === 'Process') throw new Error('Intentional process exception')
throw new CommandError(CommandErrorType[type as keyof typeof CommandErrorType], 'Intentional bot design') // ;)
},
} satisfies Command

View File

@@ -1,35 +0,0 @@
import { SlashCommandBuilder } from 'discord.js'
import type { Command } from '../types'
export default {
data: new SlashCommandBuilder()
.setName('stop')
.setDescription(
"You don't want to run this unless the bot starts to go insane, and like, you really need to stop it.",
)
.setDMPermission(true)
.toJSON(),
adminOnly: true,
global: true,
async execute({ api, logger }, interaction) {
api.isStopping = true
logger.fatal('Stopping bot...')
await interaction.reply({
content: 'Stopping... (I will go offline once done)',
ephemeral: true,
})
api.client.disconnect()
logger.warn('Disconnected from API')
await interaction.client.destroy()
logger.warn('Disconnected from Discord API')
logger.info(`Bot stopped, requested by ${interaction.user.id}`)
process.exit(0)
},
} satisfies Command

View File

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

View File

@@ -1,44 +1,46 @@
import { SlashCommandBuilder, type TextBasedChannel } from 'discord.js' import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { ApplicationCommandOptionType, Message } from 'discord.js'
import { ModerationCommand } from '../../classes/Command'
import { config } from '$/context' export default new ModerationCommand({
import type { Command } from '../types' name: 'reply',
description: 'Send a message as the bot',
export default { options: {
data: new SlashCommandBuilder() message: {
.setName('reply') description: 'The message to send',
.setDescription('Send a message as the bot') required: true,
.addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true)) type: ApplicationCommandOptionType.String,
.addStringOption(option => },
option reference: {
.setName('reference') description: 'The message ID to reply to (use `latest` to reply to the latest message)',
.setDescription('The message ID to reply to (use `latest` to reply to the latest message)') required: false,
.setRequired(false), type: ApplicationCommandOptionType.String,
) },
.toJSON(),
memberRequirements: {
roles: config.moderation?.roles ?? [],
}, },
allowMessageCommand: false,
async execute({ logger, executor }, trigger, { reference: ref, message: msg }) {
if (trigger instanceof Message) return
global: false, const channel = await trigger.guild!.channels.fetch(trigger.channelId)
if (!channel?.isTextBased())
async execute({ logger }, interaction) { throw new CommandError(
const msg = interaction.options.getString('message', true) CommandErrorType.InvalidArgument,
const ref = interaction.options.getString('reference') 'This command can only be used in or on text channels',
)
const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel const refMsg = ref?.startsWith('latest')
const refMsg = ref?.startsWith('latest') ? (await channel.messages.fetch({ limit: 1 })).at(0)?.id : ref ? await channel.messages.fetch({ limit: 1 }).then(it => it.first())
: ref
await channel.send({ await channel.send({
content: msg, content: msg,
reply: refMsg ? { messageReference: refMsg, failIfNotExists: true } : undefined, reply: refMsg ? { messageReference: refMsg, failIfNotExists: true } : undefined,
}) })
logger.info(`User ${interaction.user.tag} made the bot say: ${msg}`) logger.info(`User ${executor.user.tag} made the bot say: ${msg}`)
await interaction.reply({ await trigger.reply({
content: 'OK!', content: 'OK!',
ephemeral: true, ephemeral: true,
}) })
}, },
} satisfies Command })

View File

@@ -1,58 +1,58 @@
import { SlashCommandBuilder } from 'discord.js' import { ModerationCommand } from '$/classes/Command'
import type { Command } from '../types'
import CommandError, { CommandErrorType } from '$/classes/CommandError' import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '$/context'
import { createModerationActionEmbed } from '$/utils/discord/embeds' import { createModerationActionEmbed } from '$/utils/discord/embeds'
import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation'
import { parseDuration } from '$/utils/duration' import { parseDuration } from '$/utils/duration'
export default { export default new ModerationCommand({
data: new SlashCommandBuilder() name: 'ban',
.setName('ban') description: 'Ban a user',
.setDescription('Ban a user') options: {
.addUserOption(option => option.setName('user').setRequired(true).setDescription('The user to ban')) user: {
.addStringOption(option => option.setName('reason').setDescription('The reason for banning the user')) description: 'The user to ban',
.addStringOption(option => required: true,
option.setName('dmd').setDescription('Duration to delete messages (must be from 0 to 7 days)'), type: ModerationCommand.OptionType.User,
) },
.toJSON(), reason: {
description: 'The reason for banning the user',
memberRequirements: { required: false,
roles: config.moderation?.roles ?? [], type: ModerationCommand.OptionType.String,
},
dmd: {
description: 'Duration to delete messages (must be from 0 to 7 days)',
required: false,
type: ModerationCommand.OptionType.String,
},
}, },
async execute({ logger, executor }, interaction, { user, reason, dmd }) {
const guild = await interaction.client.guilds.fetch(interaction.guildId)
const member = await guild.members.fetch(user).catch(() => {})
const moderator = await guild.members.fetch(executor.user)
global: false, if (member) {
if (!member.bannable)
throw new CommandError(CommandErrorType.Generic, 'This user cannot be banned by the bot.')
async execute({ logger }, interaction) { if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
const user = interaction.options.getUser('user', true) throw new CommandError(
const reason = interaction.options.getString('reason') ?? 'No reason provided' CommandErrorType.InvalidUser,
const dmd = interaction.options.getString('dmd') 'You cannot ban a user with a role equal to or higher than yours.',
)
const member = await interaction.guild!.members.fetch(user.id) }
const moderator = await interaction.guild!.members.fetch(interaction.user.id)
if (member.bannable) throw new CommandError(CommandErrorType.Generic, 'This user cannot be banned by the bot.')
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError(
CommandErrorType.InvalidUser,
'You cannot ban a user with a role equal to or higher than yours.',
)
const dms = Math.floor(dmd ? parseDuration(dmd) : 0 / 1000) const dms = Math.floor(dmd ? parseDuration(dmd) : 0 / 1000)
await interaction.guild!.members.ban(user, { await interaction.guild!.members.ban(user, {
reason: `Banned by moderator ${interaction.user.tag} (${interaction.user.id}): ${reason}`, reason: `Banned by moderator ${executor.user.tag} (${executor.id}): ${reason}`,
deleteMessageSeconds: dms, deleteMessageSeconds: dms,
}) })
await sendModerationReplyAndLogs( await sendModerationReplyAndLogs(
interaction, interaction,
createModerationActionEmbed('Banned', user, interaction.user, reason), createModerationActionEmbed('Banned', user, executor.user, reason),
) )
logger.info( logger.info(
`${interaction.user.tag} (${interaction.user.id}) banned ${user.tag} (${user.id}) because ${reason}, deleting their messages sent in the previous ${dms}s`, `${executor.user.tag} (${executor.id}) banned ${user.tag} (${user.id}) because ${reason}, deleting their messages sent in the previous ${dms}s`,
) )
}, },
} satisfies Command })

View File

@@ -1,31 +1,24 @@
import { SlashCommandBuilder } from 'discord.js' import { ModerationCommand } from '$/classes/Command'
import type { Command } from '../types'
import { config } from '$/context'
import { createSuccessEmbed } from '$/utils/discord/embeds' import { createSuccessEmbed } from '$/utils/discord/embeds'
import { cureNickname } from '$/utils/discord/moderation' import { cureNickname } from '$/utils/discord/moderation'
export default { export default new ModerationCommand({
data: new SlashCommandBuilder() name: 'cure',
.setName('cure') description: "Cure a member's nickname",
.setDescription("Cure a member's nickname") options: {
.addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to cure')) member: {
.toJSON(), description: 'The member to cure',
required: true,
memberRequirements: { type: ModerationCommand.OptionType.User,
roles: config.moderation?.roles ?? [], },
}, },
async execute(_, interaction, { member: user }) {
global: false, const guild = await interaction.client.guilds.fetch(interaction.guildId)
const member = await guild.members.fetch(user)
async execute(_, interaction) {
const user = interaction.options.getUser('member', true)
const member = await interaction.guild!.members.fetch(user.id)
await cureNickname(member) await cureNickname(member)
await interaction.reply({ await interaction.reply({
embeds: [createSuccessEmbed(null, `Cured nickname for ${member.toString()}`)], embeds: [createSuccessEmbed(null, `Cured nickname for ${member.toString()}`)],
ephemeral: true, ephemeral: true,
}) })
}, },
} satisfies Command })

View File

@@ -1,44 +1,47 @@
import { SlashCommandBuilder } from 'discord.js' import { ModerationCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError' import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { applyRolePreset, removeRolePreset } from '$/utils/discord/rolePresets'
import type { Command } from '../types'
import { config } from '$/context'
import { createModerationActionEmbed } from '$/utils/discord/embeds' import { createModerationActionEmbed } from '$/utils/discord/embeds'
import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation'
import { applyRolePreset, removeRolePreset } from '$/utils/discord/rolePresets'
import { parseDuration } from '$/utils/duration' import { parseDuration } from '$/utils/duration'
export default { export default new ModerationCommand({
data: new SlashCommandBuilder() name: 'mute',
.setName('mute') description: 'Mute a member',
.setDescription('Mute a member') options: {
.addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to mute')) member: {
.addStringOption(option => option.setName('reason').setDescription('The reason for muting the member')) description: 'The member to mute',
.addStringOption(option => option.setName('duration').setDescription('The duration of the mute')) required: true,
.toJSON(), type: ModerationCommand.OptionType.User,
},
memberRequirements: { reason: {
roles: config.moderation?.roles ?? [], description: 'The reason for muting the member',
required: false,
type: ModerationCommand.OptionType.String,
},
duration: {
description: 'The duration of the mute',
required: false,
type: ModerationCommand.OptionType.String,
},
}, },
async execute(
{ logger, executor },
interaction,
{ member: user, reason = 'No reason provided', duration: durationInput },
) {
const guild = await interaction.client.guilds.fetch(interaction.guildId)
const member = await guild.members.fetch(user.id)
const moderator = await guild.members.fetch(executor.id)
const duration = durationInput ? parseDuration(durationInput) : Infinity
global: false, if (Number.isInteger(duration) && duration! < 1)
async execute({ logger }, interaction, { isExecutorBotAdmin: isExecutorAdmin }) {
const user = interaction.options.getUser('member', true)
const reason = interaction.options.getString('reason') ?? 'No reason provided'
const duration = interaction.options.getString('duration')
const durationMs = duration ? parseDuration(duration) : null
if (Number.isInteger(durationMs) && durationMs! < 1)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidDuration, CommandErrorType.InvalidDuration,
'The duration must be at least 1 millisecond long.', 'The duration must be at least 1 millisecond long.',
) )
const expires = durationMs ? Date.now() + durationMs : null const expires = Math.max(duration, Date.now() + duration)
const moderator = await interaction.guild!.members.fetch(interaction.user.id)
const member = await interaction.guild!.members.fetch(user.id)
if (!member) if (!member)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidUser,
@@ -48,25 +51,25 @@ export default {
if (!member.manageable) if (!member.manageable)
throw new CommandError(CommandErrorType.Generic, 'This user cannot be managed by the bot.') throw new CommandError(CommandErrorType.Generic, 'This user cannot be managed by the bot.')
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0 && !isExecutorAdmin) if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidUser,
'You cannot mute a user with a role equal to or higher than yours.', 'You cannot mute a user with a role equal to or higher than yours.',
) )
await applyRolePreset(member, 'mute', durationMs ? Date.now() + durationMs : null) await applyRolePreset(member, 'mute', expires)
await sendModerationReplyAndLogs( await sendModerationReplyAndLogs(
interaction, interaction,
createModerationActionEmbed('Muted', user, interaction.user, reason, durationMs), createModerationActionEmbed('Muted', user, executor.user, reason, duration),
) )
if (durationMs) if (duration)
setTimeout(() => { setTimeout(() => {
removeRolePreset(member, 'mute') removeRolePreset(member, 'mute')
}, durationMs) }, duration)
logger.info( logger.info(
`Moderator ${interaction.user.tag} (${interaction.user.id}) muted ${user.tag} (${user.id}) until ${expires} because ${reason}`, `Moderator ${executor.user.tag} (${executor.user.id}) muted ${user.tag} (${user.id}) until ${expires} because ${reason}`,
) )
}, },
} satisfies Command })

View File

@@ -1,37 +1,32 @@
import { EmbedBuilder, GuildChannel, SlashCommandBuilder } from 'discord.js' import { EmbedBuilder, GuildChannel } from 'discord.js'
import { ModerationCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError' import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '$/context'
import { applyCommonEmbedStyles } from '$/utils/discord/embeds' import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
import type { Command } from '../types' export default new ModerationCommand({
name: 'purge',
export default { description: 'Purge messages from a channel',
data: new SlashCommandBuilder() options: {
.setName('purge') amount: {
.setDescription('Purge messages from a channel') description: 'The amount of messages to remove',
.addIntegerOption(option => required: false,
option.setName('amount').setDescription('The amount of messages to remove').setMaxValue(100).setMinValue(1), type: ModerationCommand.OptionType.Integer,
) min: 1,
.addUserOption(option => max: 100,
option.setName('user').setDescription('The user to remove messages from (needs `until`)'), },
) user: {
.addStringOption(option => description: 'The user to remove messages from (needs `until`)',
option.setName('until').setDescription('The message ID to remove messages until (overrides `amount`)'), required: false,
) type: ModerationCommand.OptionType.User,
.toJSON(), },
until: {
memberRequirements: { description: 'The message ID to remove messages until (overrides `amount`)',
roles: config.moderation?.roles ?? [], required: false,
type: ModerationCommand.OptionType.String,
},
}, },
async execute({ logger, executor }, interaction, { amount, user, until }) {
global: false,
async execute({ logger }, interaction) {
const amount = interaction.options.getInteger('amount')
const user = interaction.options.getUser('user')
const until = interaction.options.getString('until')
if (!amount && !until) if (!amount && !until)
throw new CommandError(CommandErrorType.MissingArgument, 'Either `amount` or `until` must be provided.') throw new CommandError(CommandErrorType.MissingArgument, 'Either `amount` or `until` must be provided.')
@@ -59,8 +54,9 @@ export default {
await channel.bulkDelete(messages, true) await channel.bulkDelete(messages, true)
logger.info( logger.info(
`Moderator ${interaction.user.tag} (${interaction.user.id}) purged ${messages.size} messages in #${channel.name} (${channel.id})`, `Moderator ${executor.user.tag} (${executor.id}) purged ${messages.size} messages in #${channel.name} (${channel.id})`,
) )
await reply.edit({ await reply.edit({
embeds: [ embeds: [
embed.setTitle('Purged messages').setDescription(null).addFields({ embed.setTitle('Purged messages').setDescription(null).addFields({
@@ -70,4 +66,4 @@ export default {
], ],
}) })
}, },
} satisfies Command })

View File

@@ -1,49 +1,48 @@
import { SlashCommandBuilder } from 'discord.js' import { ModerationCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError' import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { sendPresetReplyAndLogs } from '$/utils/discord/moderation' import { sendPresetReplyAndLogs } from '$/utils/discord/moderation'
import { applyRolePreset, removeRolePreset } from '$/utils/discord/rolePresets' import { applyRolePreset, removeRolePreset } from '$/utils/discord/rolePresets'
import { parseDuration } from '$/utils/duration' import { parseDuration } from '$/utils/duration'
import type { Command } from '../types'
export default { const SubcommandOptions = {
data: new SlashCommandBuilder() member: {
.setName('role-preset') description: 'The member to manage',
.setDescription('Manage role presets for a member') required: true,
.addStringOption(option => type: ModerationCommand.OptionType.User,
option
.setName('action')
.setRequired(true)
.setDescription('The action to perform')
.addChoices([
{ name: 'apply', value: 'apply' },
{ name: 'remove', value: 'remove' },
]),
)
.addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to manage'))
.addStringOption(option =>
option.setName('preset').setRequired(true).setDescription('The preset to apply or remove'),
)
.addStringOption(option =>
option.setName('duration').setDescription('The duration to apply the preset for (only for apply action)'),
)
.toJSON(),
memberRequirements: {
roles: ['955220417969262612', '973886585294704640'],
}, },
preset: {
description: 'The preset to apply or remove',
required: true,
type: ModerationCommand.OptionType.String,
},
duration: {
description: 'The duration to apply the preset for (only for apply action)',
required: false,
type: ModerationCommand.OptionType.String,
},
} as const
global: false, export default new ModerationCommand({
name: 'role-preset',
description: 'Manage role presets for a member',
options: {
apply: {
description: 'Apply a role preset to a member',
type: ModerationCommand.OptionType.Subcommand,
options: SubcommandOptions,
},
remove: {
description: 'Remove a role preset from a member',
type: ModerationCommand.OptionType.Subcommand,
options: SubcommandOptions,
},
},
async execute({ logger, executor }, trigger, { apply, remove }) {
let expires: number | undefined
const { member: user, duration: durationInput, preset } = (apply ?? remove)!
const moderator = await trigger.guild!.members.fetch(executor.user.id)
const member = await trigger.guild!.members.fetch(user.id)
async execute({ logger }, interaction, { isExecutorBotAdmin: isExecutorAdmin }) {
const action = interaction.options.getString('action', true) as 'apply' | 'remove'
const user = interaction.options.getUser('member', true)
const preset = interaction.options.getString('preset', true)
const duration = interaction.options.getString('duration')
let expires: number | null | undefined = undefined
const moderator = await interaction.guild!.members.fetch(interaction.user.id)
const member = await interaction.guild!.members.fetch(user.id)
if (!member) if (!member)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidUser,
@@ -53,29 +52,29 @@ export default {
if (!member.manageable) if (!member.manageable)
throw new CommandError(CommandErrorType.Generic, 'This user cannot be managed by the bot.') throw new CommandError(CommandErrorType.Generic, 'This user cannot be managed by the bot.')
if (action === 'apply') { if (apply) {
const durationMs = duration ? parseDuration(duration) : null const duration = durationInput ? parseDuration(durationInput) : Infinity
if (Number.isInteger(durationMs) && durationMs! < 1) if (Number.isInteger(duration) && duration! < 1)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidDuration, CommandErrorType.InvalidDuration,
'The duration must be at least 1 millisecond long.', 'The duration must be at least 1 millisecond long.',
) )
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0 && !isExecutorAdmin) if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidUser,
'You cannot apply a role preset to a user with a role equal to or higher than yours.', 'You cannot apply a role preset to a user with a role equal to or higher than yours.',
) )
expires = durationMs ? Date.now() + durationMs : null expires = Math.max(duration, Date.now() + duration)
await applyRolePreset(member, preset, expires) await applyRolePreset(member, preset, expires)
logger.info( logger.info(
`Moderator ${interaction.user.tag} (${interaction.user.id}) applied role preset ${preset} to ${user.id} until ${expires}`, `Moderator ${executor.user.tag} (${executor.user.id}) applied role preset ${preset} to ${user.id} until ${expires}`,
) )
} else if (action === 'remove') { } else if (remove) {
await removeRolePreset(member, preset) await removeRolePreset(member, preset)
logger.info( logger.info(
`Moderator ${interaction.user.tag} (${interaction.user.id}) removed role preset ${preset} from ${user.id}`, `Moderator ${executor.user.tag} (${executor.user.id}) removed role preset ${preset} from ${user.id}`,
) )
} }
@@ -84,6 +83,6 @@ export default {
removeRolePreset(member, preset) removeRolePreset(member, preset)
}, expires) }, expires)
await sendPresetReplyAndLogs(action, interaction, user, preset, expires) await sendPresetReplyAndLogs(apply ? 'apply' : 'remove', trigger, executor, user, preset, expires)
}, },
} satisfies Command })

View File

@@ -1,39 +1,31 @@
import { createSuccessEmbed } from '$/utils/discord/embeds' import { createSuccessEmbed } from '$/utils/discord/embeds'
import { durationToString, parseDuration } from '$/utils/duration' import { durationToString, parseDuration } from '$/utils/duration'
import { SlashCommandBuilder } from 'discord.js' import { ModerationCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError' import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '$/context' import { ChannelType } from 'discord.js'
import type { Command } from '../types'
export default { export default new ModerationCommand({
data: new SlashCommandBuilder() name: 'slowmode',
.setName('slowmode') description: 'Set a slowmode for a channel',
.setDescription('Set a slowmode for the current channel') options: {
.addStringOption(option => option.setName('duration').setDescription('The duration to set').setRequired(true)) duration: {
.addStringOption(option => description: 'The duration to set',
option required: true,
.setName('channel') type: ModerationCommand.OptionType.String,
.setDescription('The channel to set the slowmode on (defaults to current channel)') },
.setRequired(false), channel: {
) description: 'The channel to set the slowmode on (defaults to current channel)',
.toJSON(), required: false,
type: ModerationCommand.OptionType.Channel,
memberRequirements: { types: [ChannelType.GuildText],
roles: config.moderation?.roles ?? [], },
}, },
async execute({ logger, executor }, interaction, { duration: durationInput, channel: channelInput }) {
const channel = channelInput ?? (await interaction.guild!.channels.fetch(interaction.channelId))
const duration = parseDuration(durationInput)
global: false, if (!channel?.isTextBased() || channel.isDMBased())
async execute({ logger }, interaction) {
const durationStr = interaction.options.getString('duration', true)
const id = interaction.options.getChannel('channel')?.id ?? interaction.channelId
const duration = parseDuration(durationStr)
const channel = await interaction.guild!.channels.fetch(id)
if (!channel?.isTextBased())
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidChannel, CommandErrorType.InvalidChannel,
'The supplied channel is not a text channel or does not exist.', 'The supplied channel is not a text channel or does not exist.',
@@ -46,10 +38,7 @@ export default {
'Duration out of range, must be between 0s and 6h.', 'Duration out of range, must be between 0s and 6h.',
) )
logger.info(`Setting slowmode to ${duration}ms on ${channel.id}`) await channel.setRateLimitPerUser(duration / 1000, `Set by ${executor.user.tag} (${executor.id})`)
await channel.setRateLimitPerUser(duration / 1000, `Set by ${interaction.user.tag} (${interaction.user.id})`)
await interaction.reply({ await interaction.reply({
embeds: [ embeds: [
createSuccessEmbed( createSuccessEmbed(
@@ -59,7 +48,7 @@ export default {
}) })
logger.info( logger.info(
`${interaction.user.tag} (${interaction.user.id}) set the slowmode on ${channel.name} (${channel.id}) to ${duration}ms`, `${executor.user.tag} (${executor.id}) set the slowmode on ${channel.name} (${channel.id}) to ${duration}ms`,
) )
}, },
} satisfies Command })

View File

@@ -1,33 +1,21 @@
import { SlashCommandBuilder } from 'discord.js' import { ModerationCommand } from '$/classes/Command'
import type { Command } from '../types'
import { config } from '$/context'
import { createModerationActionEmbed } from '$/utils/discord/embeds' import { createModerationActionEmbed } from '$/utils/discord/embeds'
import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation'
export default { export default new ModerationCommand({
data: new SlashCommandBuilder() name: 'unban',
.setName('unban') description: 'Unban a user',
.setDescription('Unban a user') options: {
.addUserOption(option => option.setName('user').setRequired(true).setDescription('The user to unban')) user: {
.toJSON(), description: 'The user to unban',
required: true,
memberRequirements: { type: ModerationCommand.OptionType.User,
roles: config.moderation?.roles ?? [], },
}, },
async execute({ logger, executor }, interaction, { user }) {
await interaction.guild!.members.unban(user, `Unbanned by moderator ${executor.user.tag} (${executor.id})`)
global: false, await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unbanned', user, executor.user))
logger.info(`${executor.user.tag} (${executor.id}) unbanned ${user.tag} (${user.id})`)
async execute({ logger }, interaction) {
const user = interaction.options.getUser('user', true)
await interaction.guild!.members.unban(
user,
`Unbanned by moderator ${interaction.user.tag} (${interaction.user.id})`,
)
await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unbanned', user, interaction.user))
logger.info(`${interaction.user.tag} (${interaction.user.id}) unbanned ${user.tag} (${user.id})`)
}, },
} satisfies Command })

View File

@@ -1,29 +1,22 @@
import { SlashCommandBuilder } from 'discord.js' import { ModerationCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError' import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '$/context'
import { appliedPresets } from '$/database/schemas' import { appliedPresets } from '$/database/schemas'
import { createModerationActionEmbed } from '$/utils/discord/embeds' import { createModerationActionEmbed } from '$/utils/discord/embeds'
import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation'
import { removeRolePreset } from '$/utils/discord/rolePresets' import { removeRolePreset } from '$/utils/discord/rolePresets'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import type { Command } from '../types'
export default { export default new ModerationCommand({
data: new SlashCommandBuilder() name: 'unmute',
.setName('unmute') description: 'Unmute a member',
.setDescription('Unmute a member') options: {
.addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to unmute')) member: {
.toJSON(), description: 'The member to unmute',
required: true,
memberRequirements: { type: ModerationCommand.OptionType.User,
roles: config.moderation?.roles ?? [], },
}, },
async execute({ logger, database, executor }, interaction, { member: user }) {
global: false,
async execute({ logger, database }, interaction) {
const user = interaction.options.getUser('member', true)
const member = await interaction.guild!.members.fetch(user.id) const member = await interaction.guild!.members.fetch(user.id)
if (!member) if (!member)
throw new CommandError( throw new CommandError(
@@ -39,8 +32,8 @@ export default {
throw new CommandError(CommandErrorType.Generic, 'This user is not muted.') throw new CommandError(CommandErrorType.Generic, 'This user is not muted.')
await removeRolePreset(member, 'mute') await removeRolePreset(member, 'mute')
await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unmuted', user, interaction.user)) await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unmuted', user, executor.user))
logger.info(`Moderator ${interaction.user.tag} (${interaction.user.id}) unmuted ${user.tag} (${user.id})`) logger.info(`Moderator ${executor.user.tag} (${executor.id}) unmuted ${user.tag} (${user.id})`)
}, },
} satisfies Command })

View File

@@ -1,56 +0,0 @@
import type { SlashCommandBuilder } from '@discordjs/builders'
import type { ChatInputCommandInteraction } from 'discord.js'
// Temporary system
export type Command = {
data: ReturnType<SlashCommandBuilder['toJSON']>
// The function has to return void or Promise<void>
// because TS may complain about some code paths not returning a value
/**
* The function to execute when this command is triggered
* @param interaction The interaction that triggered this command
*/
execute: (
context: typeof import('../context'),
interaction: ChatInputCommandInteraction,
info: Info,
) => Promise<void> | void
memberRequirements?: {
/**
* The mode to use when checking for requirements.
* - `all` means that the user needs meet all requirements specified.
* - `any` means that the user needs to meet any of the requirements specified.
*
* @default "all"
*/
mode?: 'all' | 'any'
/**
* The permissions required to use this command (in BitFields).
*
* - **0n** means that everyone can use this command.
* - **-1n** means that only bot owners can use this command.
* @default -1n
*/
permissions?: bigint
/**
* The roles required to use this command.
* By default, this is set to `[]`.
*/
roles?: string[]
}
/**
* Whether this command can only be used by bot admins.
* @default false
*/
adminOnly?: boolean
/**
* Whether to register this command as a global slash command.
* This is set to `false` and commands will be registered in allowed guilds only by default.
* @default false
*/
global?: boolean
}
export interface Info {
isExecutorBotAdmin: boolean
}

View File

@@ -3,22 +3,22 @@ import { existsSync, readFileSync, readdirSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { Client as APIClient } from '@revanced/bot-api' import { Client as APIClient } from '@revanced/bot-api'
import { createLogger } from '@revanced/bot-shared' import { createLogger } from '@revanced/bot-shared'
import { ActivityType, Client as DiscordClient, Partials } from 'discord.js' import { Client as DiscordClient, Partials } from 'discord.js'
import { drizzle } from 'drizzle-orm/bun-sqlite' import { drizzle } from 'drizzle-orm/bun-sqlite'
// Export config first, as commands require them // Export some things first, as commands require them
import config from '../config.js' import config from '../config.js'
export { config } export { config }
import * as commands from './commands'
import * as schemas from './database/schemas'
import type { Command } from './commands/types'
export const logger = createLogger({ export const logger = createLogger({
level: config.logLevel === 'none' ? Number.MAX_SAFE_INTEGER : config.logLevel, level: config.logLevel === 'none' ? Number.MAX_SAFE_INTEGER : config.logLevel,
}) })
import * as commands from './commands'
import * as schemas from './database/schemas'
import type { default as Command, CommandOptionsOptions } from './classes/Command'
export const api = { export const api = {
client: new APIClient({ client: new APIClient({
api: { api: {
@@ -27,7 +27,7 @@ export const api = {
}, },
}, },
}), }),
isStopping: false, intentionallyDisconnecting: false,
disconnectCount: 0, disconnectCount: 0,
} }
@@ -80,17 +80,9 @@ export const discord = {
repliedUser: true, repliedUser: true,
}, },
partials: [Partials.Message, Partials.Reaction], partials: [Partials.Message, Partials.Reaction],
presence: {
activities: [
{
type: ActivityType.Watching,
name: 'cat videos',
},
],
},
}), }),
commands: Object.fromEntries(Object.values<Command>(commands).map(cmd => [cmd.data.name, cmd])) as Record< commands: Object.fromEntries(Object.values(commands).map(cmd => [cmd.name, cmd])) as Record<
string, string,
Command Command<boolean, CommandOptionsOptions | undefined, boolean>
>, >,
} as const } as const

View File

@@ -2,7 +2,7 @@ import { on, withContext } from '$utils/api/events'
import { DisconnectReason, HumanizedDisconnectReason } from '@revanced/bot-shared' import { DisconnectReason, HumanizedDisconnectReason } from '@revanced/bot-shared'
withContext(on, 'disconnect', ({ api, config, logger }, reason, msg) => { withContext(on, 'disconnect', ({ api, config, logger }, reason, msg) => {
if (reason === DisconnectReason.PlannedDisconnect && api.isStopping) return if (reason === DisconnectReason.PlannedDisconnect && api.intentionallyDisconnecting) return
const ws = api.client.ws const ws = api.client.ws
if (!ws.disconnected) ws.disconnect() if (!ws.disconnected) ws.disconnect()
@@ -16,7 +16,7 @@ withContext(on, 'disconnect', ({ api, config, logger }, reason, msg) => {
) )
if (api.disconnectCount >= (config.api.disconnectLimit ?? 3)) { if (api.disconnectCount >= (config.api.disconnectLimit ?? 3)) {
console.error('Disconnected from bot API too many times') logger.fatal('Disconnected from bot API too many times')
// We don't want the process hanging // We don't want the process hanging
process.exit(1) process.exit(1)
} }

View File

@@ -1,3 +1,7 @@
import { on, withContext } from '$utils/api/events' import { on, withContext } from '$utils/api/events'
withContext(on, 'ready', ({ logger }) => void logger.info('Connected to the bot API')) withContext(on, 'ready', ({ api, logger }) => {
// Reset disconnect count, so it doesn't meet the threshold for an accidental disconnect
api.disconnectCount = 0
logger.info('Connected to the bot API')
})

View File

@@ -1,78 +1,22 @@
import CommandError from '$/classes/CommandError' import CommandError from '$/classes/CommandError'
import { isAdmin } from '$/utils/discord/permissions' import { createStackTraceEmbed } from '$utils/discord/embeds'
import { createErrorEmbed, createStackTraceEmbed } from '$utils/discord/embeds'
import { on, withContext } from '$utils/discord/events' import { on, withContext } from '$utils/discord/events'
withContext(on, 'interactionCreate', async (context, interaction) => { withContext(on, 'interactionCreate', async (context, interaction) => {
if (!interaction.isChatInputCommand()) return if (!interaction.isChatInputCommand()) return
const { logger, discord, config } = context const { logger, discord } = context
const command = discord.commands[interaction.commandName] 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}`)
if (!command) return void logger.error(`Command ${interaction.commandName} not implemented but registered!!!`) if (!command) return void logger.error(`Command ${interaction.commandName} not implemented but registered!!!`)
const isExecutorBotAdmin = isAdmin(await interaction.guild?.members.fetch(interaction.user.id) || interaction.user, config.admin)
/**
* Admin check
*/
if (command.adminOnly && !isExecutorBotAdmin)
return void (await interaction.reply({
embeds: [createErrorEmbed('Massive skill issue', 'This command can only be used by the bot admins.')],
ephemeral: true,
}))
/**
* Sanity check
*/
if (!command.global && !interaction.inGuild()) {
logger.error(`Command ${interaction.commandName} cannot be run in DMs, but was registered as global`)
await interaction.reply({
embeds: [createErrorEmbed('Cannot run that here', 'This command can only be used in a server.')],
ephemeral: true,
})
return
}
/**
* Permission checks
*/
if (interaction.inGuild()) {
// Bot owners get bypass
if (command.memberRequirements && !isExecutorBotAdmin) {
const { permissions = 0n, roles = [], mode } = command.memberRequirements
const member = await interaction.guild!.members.fetch(interaction.user.id)
const [missingPermissions, missingRoles] = [
// This command is an owner-only command (the user is not an owner, checked above)
permissions <= 0n ||
// or the user doesn't have the required permissions
(permissions > 0n && !interaction.memberPermissions.has(permissions)),
// If not:
!roles.some(x => member.roles.cache.has(x)),
]
if ((mode === 'any' && missingPermissions && missingRoles) || missingPermissions || missingRoles)
return void interaction.reply({
embeds: [
createErrorEmbed(
'Missing roles or permissions',
"You don't have the required roles or permissions to use this command.",
),
],
ephemeral: true,
})
}
}
try { try {
logger.debug(`Command ${interaction.commandName} being executed`) logger.debug(`Command ${interaction.commandName} being executed`)
await command.execute(context, interaction, { isExecutorBotAdmin }) await command.onInteraction(context, interaction)
} catch (err) { } catch (err) {
logger.error(`Error while executing command ${interaction.commandName}:`, err) if (!(err instanceof CommandError))
logger.error(`Error while executing command ${interaction.commandName}:`, err)
await interaction[interaction.replied ? 'followUp' : 'reply']({ await interaction[interaction.replied ? 'followUp' : 'reply']({
embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)], embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)],
ephemeral: true, ephemeral: true,

View File

@@ -0,0 +1,64 @@
import { type CommandArguments, CommandSpecialArgumentType } from '$/classes/Command'
import CommandError from '$/classes/CommandError'
import { createStackTraceEmbed } from '$utils/discord/embeds'
import { on, withContext } from '$utils/discord/events'
withContext(on, 'messageCreate', async (context, msg) => {
const { logger, discord, config } = context
if (msg.author.bot) return
const regex = new RegExp(
`^(?:${config.prefix ? `${escapeRegexSpecials(config.prefix)}|` : ''}${msg.client.user.toString()}\\s*)([a-zA-Z-_]+)(?:\\s+)?(.+)?`,
)
const matches = msg.content.match(regex)
if (!matches) return
const [, commandName, argsString] = matches
if (!commandName) return
const command = discord.commands[commandName]
logger.debug(`Command ${commandName} being invoked by ${msg.author.id}`)
if (!command) return void logger.error(`Command ${commandName} not implemented`)
const argsRegex: RegExp = /[^\s"]+|"([^"]*)"/g
const args: CommandArguments = []
let match: RegExpExecArray | null
// biome-ignore lint/suspicious/noAssignInExpressions: nuh uh
while ((match = argsRegex.exec(argsString ?? '')) !== null) {
const arg = match[1] ? match[1] : match[0]
const mentionMatch = arg.match(/<(@(?:!|&)?|#)(.+?)>/)
if (mentionMatch) {
const [, prefix, id] = mentionMatch
if (!id || !prefix) {
args.push('')
continue
}
args.push({
type: CommandSpecialArgumentType[prefix[1] === '&' ? 'Role' : prefix[0] === '#' ? 'Channel' : 'User'],
id,
})
} else args.push(arg)
}
try {
logger.debug(`Command ${commandName} being executed`)
await command.onMessage(context, msg, args)
} catch (err) {
if (!(err instanceof CommandError)) logger.error(`Error while executing command ${commandName}:`, err)
await msg.reply({ embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)] })
}
})
const escapeRegexSpecials = (str: string): string => {
let escapedStr = ''
for (const char of str) {
if (['.', '+', '*', '?', '$', '(', ')', '[', ']', '{', '}', '|', '\\'].includes(char)) escapedStr += `\\${char}`
else escapedStr += char
}
return escapedStr
}

View File

@@ -1,6 +1,6 @@
import { MessageScanLabeledResponseReactions } from '$/constants' import { MessageScanLabeledResponseReactions } from '$/constants'
import { responses } from '$/database/schemas' import { responses } from '$/database/schemas'
import { getResponseFromText, shouldScanMessage } from '$/utils/discord/messageScan' import { getResponseFromText, messageMatchesFilter } from '$/utils/discord/messageScan'
import { createMessageScanResponseEmbed } from '$utils/discord/embeds' import { createMessageScanResponseEmbed } from '$utils/discord/embeds'
import { on, withContext } from '$utils/discord/events' import { on, withContext } from '$utils/discord/events'
@@ -13,15 +13,22 @@ withContext(on, 'messageCreate', async (context, msg) => {
} = context } = context
if (!config || !config.responses) return if (!config || !config.responses) return
if (msg.author.bot && !config.scanBots) return
if (!msg.inGuild() && !config.scanOutsideGuilds) return
if (msg.inGuild() && msg.member?.partial) await msg.member.fetch()
const filteredResponses = config.responses.filter(x => shouldScanMessage(msg, x.filterOverride ?? config.filter)) const filteredResponses = config.responses.filter(x => messageMatchesFilter(msg, x.filterOverride ?? config.filter))
if (!filteredResponses.length) return if (!filteredResponses.length) return
if (msg.content.length) { if (msg.content.length) {
try { try {
logger.debug(`Classifying message ${msg.id}`) logger.debug(`Classifying message ${msg.id}`)
const { response, label, replyToReplied } = await getResponseFromText(msg.content, filteredResponses, context) const { response, label, replyToReplied } = await getResponseFromText(
msg.content,
filteredResponses,
context,
)
if (response) { if (response) {
logger.debug('Response found') logger.debug('Response found')

View File

@@ -13,8 +13,8 @@ import {
import type { ConfigMessageScanResponseLabelConfig } from '$/../config.schema' import type { ConfigMessageScanResponseLabelConfig } from '$/../config.schema'
import { responses } from '$/database/schemas' import { responses } from '$/database/schemas'
import { handleUserResponseCorrection } from '$/utils/discord/messageScan' import { handleUserResponseCorrection } from '$/utils/discord/messageScan'
import { eq } from 'drizzle-orm'
import { isAdmin } from '$/utils/discord/permissions' import { isAdmin } from '$/utils/discord/permissions'
import { eq } from 'drizzle-orm'
const PossibleReactions = Object.values(Reactions) as string[] const PossibleReactions = Object.values(Reactions) as string[]
@@ -33,7 +33,7 @@ withContext(on, 'messageReactionAdd', async (context, rct, user) => {
if (reactionMessage.author.id !== reaction.client.user!.id) return if (reactionMessage.author.id !== reaction.client.user!.id) return
if (!PossibleReactions.includes(reaction.emoji.name!)) return if (!PossibleReactions.includes(reaction.emoji.name!)) return
if (!isAdmin(reactionMessage.member || reactionMessage.author, config.admin)) { if (!isAdmin(reactionMessage.member || reactionMessage.author)) {
// User is in guild, and config has member requirements // User is in guild, and config has member requirements
if ( if (
reactionMessage.inGuild() && reactionMessage.inGuild() &&

View File

@@ -7,9 +7,7 @@ import { on, withContext } from 'src/utils/discord/events'
export default withContext(on, 'ready', ({ config, logger }, client) => { export default withContext(on, 'ready', ({ config, logger }, client) => {
logger.info(`Connected to Discord API, logged in as ${client.user.displayName} (@${client.user.tag})!`) logger.info(`Connected to Discord API, logged in as ${client.user.displayName} (@${client.user.tag})!`)
logger.info( logger.info(`Bot is in ${client.guilds.cache.size} guilds`)
`Bot is in ${client.guilds.cache.size} guilds, if this is not expected, please run the /leave-unknowns command`,
)
if (config.rolePresets) { if (config.rolePresets) {
removeExpiredPresets(client) removeExpiredPresets(client)

View File

@@ -1,9 +1,5 @@
import { type Response, responses } from '$/database/schemas' import { type Response, responses } from '$/database/schemas'
import type { import type { Config, ConfigMessageScanResponse, ConfigMessageScanResponseLabelConfig } from 'config.schema'
Config,
ConfigMessageScanResponse,
ConfigMessageScanResponseLabelConfig
} from 'config.schema'
import type { Message, PartialUser, User } from 'discord.js' import type { Message, PartialUser, User } from 'discord.js'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { createMessageScanResponseEmbed } from './embeds' import { createMessageScanResponseEmbed } from './embeds'
@@ -17,7 +13,7 @@ export const getResponseFromText = async (
): Promise<ConfigMessageScanResponse & { label?: string }> => { ): Promise<ConfigMessageScanResponse & { label?: string }> => {
let responseConfig: Awaited<ReturnType<typeof getResponseFromText>> = { let responseConfig: Awaited<ReturnType<typeof getResponseFromText>> = {
triggers: {}, triggers: {},
response: null response: null,
} }
const firstLabelIndexes: number[] = [] const firstLabelIndexes: number[] = []
@@ -29,7 +25,7 @@ export const getResponseFromText = async (
// Filter override check is not neccessary here, we are already passing responses that match the filter // Filter override check is not neccessary here, we are already passing responses that match the filter
// from the messageCreate handler, see line 17 of messageCreate handler // from the messageCreate handler, see line 17 of messageCreate handler
const { const {
triggers: { text: textTriggers, image: imageTriggers } triggers: { text: textTriggers, image: imageTriggers },
} = trigger } = trigger
if (responseConfig) break if (responseConfig) break
@@ -92,7 +88,7 @@ export const getResponseFromText = async (
logger.debug('No match from NLP, doing after regexes') logger.debug('No match from NLP, doing after regexes')
for (let i = 0; i < responses.length; i++) { for (let i = 0; i < responses.length; i++) {
const { const {
triggers: { text: textTriggers } triggers: { text: textTriggers },
} = responses[i]! } = responses[i]!
const firstLabelIndex = firstLabelIndexes[i] ?? -1 const firstLabelIndex = firstLabelIndexes[i] ?? -1
@@ -113,24 +109,29 @@ export const getResponseFromText = async (
return responseConfig return responseConfig
} }
export const shouldScanMessage = ( export const messageMatchesFilter = (message: Message, filter: NonNullable<Config['messageScan']>['filter']) => {
message: Message,
filter: NonNullable<Config['messageScan']>['filter'],
): message is Message<true> => {
if (message.author.bot) return false
if (!message.guild) return false
if (!filter) return true if (!filter) return true
const filters = [ const memberRoles = new Set(message.member?.roles.cache.keys())
filter.users?.includes(message.author.id), const { blacklist, whitelist } = filter
message.member?.roles.cache.some(x => filter.roles?.includes(x.id)),
filter.channels?.includes(message.channel.id),
]
if (filter.whitelist && filters.every(x => !x)) return false // If matches only blacklist, will return false
if (!filter.whitelist && filters.some(x => x)) return false // If matches whitelist but also matches blacklist, will return false
// If matches only whitelist, will return true
return true // If matches neither, will return true
return (
(whitelist
? whitelist.channels?.includes(message.channelId) ||
whitelist.roles?.some(role => memberRoles.has(role)) ||
whitelist.users?.includes(message.author.id)
: true) &&
!(
blacklist &&
(blacklist.channels?.includes(message.channelId) ||
blacklist.roles?.some(role => memberRoles.has(role)) ||
blacklist.users?.includes(message.author.id))
)
)
} }
export const handleUserResponseCorrection = async ( export const handleUserResponseCorrection = async (

View File

@@ -1,6 +1,6 @@
import { config, logger } from '$/context' import { config, logger } from '$/context'
import decancer from 'decancer' import decancer from 'decancer'
import type { ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, User } from 'discord.js' import type { ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, Message, User } from 'discord.js'
import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from './embeds' import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from './embeds'
const PresetLogAction = { const PresetLogAction = {
@@ -10,19 +10,23 @@ const PresetLogAction = {
export const sendPresetReplyAndLogs = ( export const sendPresetReplyAndLogs = (
action: keyof typeof PresetLogAction, action: keyof typeof PresetLogAction,
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction | Message,
executor: GuildMember,
user: User, user: User,
preset: string, preset: string,
expires?: number | null, expires?: number | null,
) => ) =>
sendModerationReplyAndLogs( sendModerationReplyAndLogs(
interaction, interaction,
createModerationActionEmbed(PresetLogAction[action], user, interaction.user, undefined, expires, [ createModerationActionEmbed(PresetLogAction[action], user, executor.user, undefined, expires, [
[{ name: 'Preset', value: preset, inline: true }], [{ name: 'Preset', value: preset, inline: true }],
]), ]),
) )
export const sendModerationReplyAndLogs = async (interaction: ChatInputCommandInteraction, embed: EmbedBuilder) => { export const sendModerationReplyAndLogs = async (
interaction: ChatInputCommandInteraction | Message,
embed: EmbedBuilder,
) => {
const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch()) const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch())
const logChannel = await getLogChannel(interaction.guild!) const logChannel = await getLogChannel(interaction.guild!)
await logChannel?.send({ embeds: [applyReferenceToModerationActionEmbed(embed, reply.url)] }) await logChannel?.send({ embeds: [applyReferenceToModerationActionEmbed(embed, reply.url)] })
@@ -46,7 +50,7 @@ export const getLogChannel = async (guild: Guild) => {
} }
export const cureNickname = async (member: GuildMember) => { export const cureNickname = async (member: GuildMember) => {
if (!member.manageable) throw new Error('Member is not manageable') if (!member.manageable) return
const name = member.displayName const name = member.displayName
let cured = decancer(name) let cured = decancer(name)
.toString() .toString()

View File

@@ -1,11 +1,14 @@
import { GuildMember, type User } from 'discord.js' import { GuildMember, type User } from 'discord.js'
import type { Config } from 'config.schema' import config from '../../../config'
export const isAdmin = (userOrMember: User | GuildMember, adminConfig: Config['admin']) => { export const isAdmin = (userOrMember: User | GuildMember) => {
return adminConfig?.users?.includes(userOrMember.id) || (userOrMember instanceof GuildMember && isMemberAdmin(userOrMember, adminConfig)) return (
config.admin?.users?.includes(userOrMember.id) ||
(userOrMember instanceof GuildMember && isMemberAdmin(userOrMember))
)
} }
export const isMemberAdmin = (member: GuildMember, adminConfig: Config['admin']) => { export const isMemberAdmin = (member: GuildMember) => {
const roles = new Set(member.roles.cache.keys()) const roles = new Set(member.roles.cache.keys())
return Boolean(adminConfig?.roles?.[member.guild.id]?.some(role => roles.has(role))) return Boolean(config?.admin?.roles?.[member.guild.id]?.some(role => roles.has(role)))
} }

View File

@@ -6,9 +6,9 @@ import { and, eq } from 'drizzle-orm'
// TODO: Fix this type // TODO: Fix this type
type PresetKey = string type PresetKey = string
export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, untilMs: number | null) => { export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, untilMs: number) => {
const afterInsert = await applyRolesUsingPreset(presetName, member, true) const afterInsert = await applyRolesUsingPreset(presetName, member, true)
const until = untilMs ? Math.ceil(untilMs / 1000) : null const until = untilMs === Infinity ? null : Math.ceil(untilMs / 1000)
await database await database
.insert(appliedPresets) .insert(appliedPresets)

View File

@@ -7,17 +7,27 @@ export const listAllFilesRecursive = (dir: string): string[] =>
.filter(x => x.isFile()) .filter(x => x.isFile())
.map(x => join(x.parentPath, x.name).replaceAll(pathSep, posixPathSep)) .map(x => join(x.parentPath, x.name).replaceAll(pathSep, posixPathSep))
export const generateCommandsIndex = (dirPath: string) => generateIndexes(dirPath, x => !x.endsWith('types.ts')) export const generateCommandsIndex = (dirPath: string) =>
generateIndexes(dirPath, (x, i) => `export { default as C${i} } from './${x}'`)
export const generateEventsIndex = (dirPath: string) => generateIndexes(dirPath) export const generateEventsIndex = (dirPath: string) => generateIndexes(dirPath)
const generateIndexes = async (dirPath: string, pathFilter?: (path: string) => boolean) => { const generateIndexes = async (
dirPath: string,
customMap?: (path: string, index: number) => string,
pathFilter?: (path: string) => boolean,
) => {
const files = listAllFilesRecursive(dirPath) const files = listAllFilesRecursive(dirPath)
.filter(x => (x.endsWith('.ts') && !x.endsWith('index.ts') && pathFilter ? pathFilter(x) : true)) .filter(x => x.endsWith('.ts') && !x.endsWith('index.ts') && (pathFilter ? pathFilter(x) : true))
.map(x => relative(dirPath, x).replaceAll(pathSep, posixPathSep)) .map(x => relative(dirPath, x).replaceAll(pathSep, posixPathSep))
writeFileSync( writeFileSync(
join(dirPath, 'index.ts'), join(dirPath, 'index.ts'),
`// AUTO-GENERATED BY A SCRIPT, DON'T TOUCH\n\n${files.map(c => `import './${c.split('.').at(-2)}'`).join('\n')}`, `// AUTO-GENERATED BY A SCRIPT, DON'T TOUCH\n\n${files
.map((c, i) => {
const path = c.split('.').at(-2)!
return customMap ? customMap(path, i) : `import './${path}'`
})
.join('\n')}`,
) )
} }

BIN
bun.lockb

Binary file not shown.

View File

@@ -55,6 +55,7 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@semantic-release/npm@12.0.1": "patches/@semantic-release%2Fnpm@12.0.1.patch", "@semantic-release/npm@12.0.1": "patches/@semantic-release%2Fnpm@12.0.1.patch",
"drizzle-kit@0.22.8": "patches/drizzle-kit@0.22.8.patch" "drizzle-kit@0.22.8": "patches/drizzle-kit@0.22.8.patch",
"decancer@3.2.3": "patches/decancer@3.2.3.patch"
} }
} }

View File

@@ -163,13 +163,17 @@ export default class Client {
/** /**
* Disconnects the client from the API * Disconnects the client from the API
*/ */
disconnect() { disconnect(force?: boolean) {
this.ws.disconnect() this.ws.disconnect(force)
} }
#throwIfNotReady() { #throwIfNotReady() {
if (!this.isReady()) throw new Error('Client is not ready') if (!this.isReady()) throw new Error('Client is not ready')
} }
get disconnected() {
return this.ws.disconnected
}
} }
export class ClientWebSocketPacketAwaiter { export class ClientWebSocketPacketAwaiter {

View File

@@ -49,19 +49,13 @@ export class ClientWebSocketManager {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!this.ready) { if (!this.ready) {
this.#socket?.close(DisconnectReason.TooSlow) this.#socket?.close(DisconnectReason.TooSlow)
throw new Error('WebSocket connection was not readied in time') this._handleDisconnect(DisconnectReason.TooSlow, 'WebSocket connection was not readied in time')
} }
}, this.timeout) }, this.timeout)
const errorBeforeReadyHandler = (err: Error) => {
cleanup()
throw err
}
const closeBeforeReadyHandler = (code: number, reason: Buffer) => { const closeBeforeReadyHandler = (code: number, reason: Buffer) => {
clearTimeout(timeout)
this._handleDisconnect(code, reason.toString()) this._handleDisconnect(code, reason.toString())
throw new Error('WebSocket connection closed before ready') cleanup()
} }
const readyHandler = () => { const readyHandler = () => {
@@ -71,15 +65,14 @@ export class ClientWebSocketManager {
rs() rs()
} }
const socket = this.#socket
const cleanup = () => { const cleanup = () => {
this.#socket.off('open', readyHandler) socket.off('open', readyHandler)
this.#socket.off('close', closeBeforeReadyHandler) socket.off('close', closeBeforeReadyHandler)
this.#socket.off('error', errorBeforeReadyHandler)
clearTimeout(timeout) clearTimeout(timeout)
} }
this.#socket.on('open', readyHandler) this.#socket.on('open', readyHandler)
this.#socket.on('error', errorBeforeReadyHandler)
this.#socket.on('close', closeBeforeReadyHandler) this.#socket.on('close', closeBeforeReadyHandler)
} catch (e) { } catch (e) {
rj(e) rj(e)
@@ -137,8 +130,8 @@ export class ClientWebSocketManager {
/** /**
* Disconnects from the WebSocket API * Disconnects from the WebSocket API
*/ */
disconnect() { disconnect(force?: boolean) {
this.#throwIfDisconnected('Cannot disconnect when already disconnected from the server') if (!force) this.#throwIfDisconnected('Cannot disconnect when already disconnected from the server')
this._handleDisconnect(DisconnectReason.PlannedDisconnect) this._handleDisconnect(DisconnectReason.PlannedDisconnect)
} }

View File

@@ -0,0 +1,13 @@
diff --git a/src/lib.js b/src/lib.js
index de45d7dbe82975b09eff3742d0718accae2107fc..0575daa03dfabdd5c96928458ff4270cb8f7188a 100644
--- a/src/lib.js
+++ b/src/lib.js
@@ -42,7 +42,7 @@ function isMusl() {
}
function getBinding(name) {
- const path = join(__dirname, '..', `decancer.${name}.node`)
+ const path = join(import.meta.dir, '..', `decancer.${name}.node`)
return require(existsSync(path) ? path : `@vierofernando/decancer-${name}`)
}

View File

@@ -25,8 +25,8 @@ const Options = {
[ [
'@semantic-release/npm', '@semantic-release/npm',
{ {
npmPublish: false, npmPublish: false,
} },
], ],
[ [
'@semantic-release/git', '@semantic-release/git',