Compare commits

..

22 Commits

Author SHA1 Message Date
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
semantic-release-bot
e748a4da92 chore(release): 1.0.0-dev.7 [skip ci]
# @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)

### Bug Fixes

* **bot/discord:** start remove preset timeout for `role-preset` command ([cbf9116](cbf91162e2))
* **bots/discord:** only check for member permissions when specified while correcting responses ([b79a1c7](b79a1c7575))
* **bots/discord:** set timeout for eligible mutes to unmute faster ([1f5c5a9](1f5c5a92a6))

### Features

* **bots/discord:** add `replyToReplied` option in response config ([27662ed](27662ed91a))
2024-07-25 18:38:09 +00:00
PalmDevs
300e5cff3b ci(bots/discord): fix freezing after generating db schemas 2024-07-26 01:36:52 +07:00
PalmDevs
6685ffb855 chore: update lockfile 2024-07-26 01:30:33 +07:00
PalmDevs
cbf91162e2 fix(bot/discord): start remove preset timeout for role-preset command 2024-07-26 01:25:52 +07:00
PalmDevs
bd906fbf54 fix(bots/discord)!: remove guilds config in favor of upcoming impl 2024-07-26 01:25:51 +07:00
PalmDevs
27662ed91a feat(bots/discord): add replyToReplied option in response config 2024-07-26 01:25:50 +07:00
PalmDevs
d0acab1915 feat(bots/discord)!: add admin config 2024-07-26 01:25:49 +07:00
PalmDevs
e86180fe29 feat(bots/discord)!: allow message scan response to be message payloads 2024-07-26 01:25:48 +07:00
PalmDevs
1f5c5a92a6 fix(bots/discord): set timeout for eligible mutes to unmute faster 2024-07-26 01:25:47 +07:00
PalmDevs
b79a1c7575 fix(bots/discord): only check for member permissions when specified while correcting responses 2024-07-26 01:25:46 +07:00
PalmDevs
3559ed1cb5 ci(bots/discord): patch drizzle-kit to stop using node, decreases image size 2024-07-26 01:25:45 +07:00
semantic-release-bot
042b155b5e chore(release): 1.0.0-dev.6 [skip ci]
# @revanced/discord-bot [1.0.0-dev.6](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.5...@revanced/discord-bot@1.0.0-dev.6) (2024-07-23)

### Bug Fixes

* **bots/discord:** ci issues causing database to not be auto generated ([673aa18](673aa189be))
2024-07-23 20:59:49 +00:00
PalmDevs
673aa189be fix(bots/discord): ci issues causing database to not be auto generated 2024-07-24 03:57:33 +07:00
PalmDevs
c503a86c53 ci(release): also update bun lockfile to prevent install freezes 2024-07-24 03:36:32 +07:00
51 changed files with 1382 additions and 728 deletions

View File

@@ -1,3 +1,10 @@
# @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.6",
"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

@@ -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.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)
### Bug Fixes
* **bot/discord:** start remove preset timeout for `role-preset` command ([cbf9116](https://github.com/revanced/revanced-helper/commit/cbf91162e27dd4c1ecb976927ab708f1d882abca))
* **bots/discord:** only check for member permissions when specified while correcting responses ([b79a1c7](https://github.com/revanced/revanced-helper/commit/b79a1c7575e94c3e62654c87775cac497be4a50a))
* **bots/discord:** set timeout for eligible mutes to unmute faster ([1f5c5a9](https://github.com/revanced/revanced-helper/commit/1f5c5a92a639973b83a1204355538936e69a4454))
### Features
* **bots/discord:** add `replyToReplied` option in response config ([27662ed](https://github.com/revanced/revanced-helper/commit/27662ed91a79bfac7d3f091834e859a7b57366ce))
# @revanced/discord-bot [1.0.0-dev.6](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.5...@revanced/discord-bot@1.0.0-dev.6) (2024-07-23)
### Bug Fixes
* **bots/discord:** ci issues causing database to not be auto generated ([673aa18](https://github.com/revanced/revanced-helper/commit/673aa189bef1009a3e32ba3b1291a5ee84f2def3))
# @revanced/discord-bot [1.0.0-dev.5](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.4...@revanced/discord-bot@1.0.0-dev.5) (2024-07-23) # @revanced/discord-bot [1.0.0-dev.5](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.4...@revanced/discord-bot@1.0.0-dev.5) (2024-07-23)
# @revanced/discord-bot [1.0.0-dev.4](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.3...@revanced/discord-bot@1.0.0-dev.4) (2024-07-23) # @revanced/discord-bot [1.0.0-dev.4](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.3...@revanced/discord-bot@1.0.0-dev.4) (2024-07-23)

View File

@@ -4,8 +4,13 @@
* @type {import('./config.schema').Config} * @type {import('./config.schema').Config}
*/ */
export default { export default {
owners: ['USER_ID_HERE'], prefix: '!',
guilds: ['GUILD_ID_HERE'], admin: {
users: ['USER_ID_HERE'],
roles: {
GUILD_ID_HERE: ['ROLE_ID_HERE'],
},
},
moderation: { moderation: {
cure: { cure: {
defaultName: 'Server member', defaultName: 'Server member',
@@ -33,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',
@@ -51,16 +64,32 @@ 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 }],
}, },
response: { response: {
title: 'Embed title', embeds: [
description: 'Embed description',
fields: [
{ {
name: 'Field name', title: 'Embed title',
value: 'Field value', description: 'Embed description',
fields: [
{
name: 'Field name',
value: 'Field value',
},
],
}, },
], ],
}, },

View File

@@ -1,8 +1,11 @@
import type { APIEmbed } from 'discord.js' import type { BaseMessageOptions } from 'discord.js'
export type Config = { export type Config = {
owners: string[] prefix?: string
guilds: string[] admin?: {
users?: string[]
roles?: Record<string, string[]>
}
moderation?: { moderation?: {
roles: string[] roles: string[]
cure?: { cure?: {
@@ -18,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
@@ -57,6 +60,7 @@ export type ConfigMessageScanResponse = {
} }
filterOverride?: NonNullable<Config['messageScan']>['filter'] filterOverride?: NonNullable<Config['messageScan']>['filter']
response: ConfigMessageScanResponseMessage | null response: ConfigMessageScanResponseMessage | null
replyToReplied?: boolean
} }
export type ConfigMessageScanResponseLabelConfig = { export type ConfigMessageScanResponseLabelConfig = {
@@ -70,4 +74,10 @@ export type ConfigMessageScanResponseLabelConfig = {
threshold: number threshold: number
} }
export type ConfigMessageScanResponseMessage = APIEmbed export type Filter = {
roles?: string[]
users?: string[]
channels?: string[]
}
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.5", "version": "1.0.0-dev.9",
"description": "🤖 Discord bot assisting ReVanced", "description": "🤖 Discord bot assisting ReVanced",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"dev": "bun prepare && bun --watch src/index.ts", "dev": "bun prepare && bun --watch src/index.ts",
"build": "bun prepare && bun run scripts/build.ts", "build": "bun prepare && bun run scripts/build.ts",
"watch": "bun dev", "watch": "bun dev",
"prepare": "bun run scripts/generate-indexes.ts && drizzle-kit generate --name=schema" "prepare": "bun run scripts/generate-indexes.ts && bunx drizzle-kit generate --name=schema"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

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()
@@ -17,7 +17,8 @@ await Bun.build({
}) })
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,50 +0,0 @@
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(),
ownerOnly: 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(),
ownerOnly: 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(),
ownerOnly: 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 } 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, { userIsOwner }) {
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,20 +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 && !userIsOwner) 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 (duration)
setTimeout(() => {
removeRolePreset(member, 'mute')
}, 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, { userIsOwner }) {
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,32 +52,37 @@ 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 && !userIsOwner) 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}`,
) )
} }
await sendPresetReplyAndLogs(action, interaction, user, preset, expires) if (expires)
setTimeout(() => {
removeRolePreset(member, 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 owners.
* @default false
*/
ownerOnly?: 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 {
userIsOwner: 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,77 +1,22 @@
import CommandError from '$/classes/CommandError' import CommandError from '$/classes/CommandError'
import { createErrorEmbed, createStackTraceEmbed } from '$utils/discord/embeds' import { 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 isOwner = config.owners.includes(interaction.user.id)
/**
* Owner check
*/
if (command.ownerOnly && !isOwner)
return void (await interaction.reply({
embeds: [createErrorEmbed('Massive skill issue', 'This command can only be used by the bot owners.')],
ephemeral: true,
}))
/**
* Sanity check
*/
if (!command.global && !interaction.inGuild()) {
logger.error(`Command ${interaction.commandName} cannot be run in DMs, but was registered as global`)
await interaction.reply({
embeds: [createErrorEmbed('Cannot run that here', 'This command can only be used in a server.')],
ephemeral: true,
})
return
}
/**
* Permission checks
*/
if (interaction.inGuild()) {
// Bot owners get bypass
if (command.memberRequirements && !isOwner) {
const { permissions = 0n, roles = [], mode } = command.memberRequirements
const member = await interaction.guild!.members.fetch(interaction.user.id)
const [missingPermissions, missingRoles] = [
// This command is an owner-only command (the user is not an owner, checked above)
permissions <= 0n ||
// 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, { userIsOwner: isOwner }) 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,53 @@
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}|${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)] })
}
})

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,21 +13,30 @@ 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 } = 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')
const reply = await msg.reply({ const toReply = replyToReplied ? await msg.fetchReference() : msg
embeds: [createMessageScanResponseEmbed(response, label ? 'nlp' : 'match')], const reply = await toReply.reply({
...response,
embeds: response.embeds?.map(it => createMessageScanResponseEmbed(it, label ? 'nlp' : 'match')),
}) })
if (label) if (label)
@@ -64,7 +73,8 @@ withContext(on, 'messageCreate', async (context, msg) => {
if (response) { if (response) {
logger.debug(`Response found for attachment: ${attachment.url}`) logger.debug(`Response found for attachment: ${attachment.url}`)
await msg.reply({ await msg.reply({
embeds: [createMessageScanResponseEmbed(response, 'ocr')], ...response,
embeds: response.embeds?.map(it => createMessageScanResponseEmbed(it, 'ocr')),
}) })
break break

View File

@@ -13,6 +13,7 @@ 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 { isAdmin } from '$/utils/discord/permissions'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
const PossibleReactions = Object.values(Reactions) as string[] const PossibleReactions = Object.values(Reactions) as string[]
@@ -32,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 (!config.owners.includes(user.id)) { 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() &&
@@ -46,7 +47,12 @@ withContext(on, 'messageReactionAdd', async (context, rct, user) => {
const member = await reactionMessage.guild.members.fetch(user.id) const member = await reactionMessage.guild.members.fetch(user.id)
const { permissions, roles } = allowedMembers const { permissions, roles } = allowedMembers
if (!(member.permissions.has(permissions ?? 0n) || roles?.some(role => member.roles.cache.has(role)))) if (
!(
(permissions ? member.permissions.has(permissions) : false) ||
roles?.some(role => member.roles.cache.has(role))
)
)
return return
} else if (allowedUsers) { } else if (allowedUsers) {
if (!allowedUsers.includes(user.id)) return if (!allowedUsers.includes(user.id)) return

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,19 +0,0 @@
import type { Command } from '$commands/types'
import { listAllFilesRecursive } from '$utils/fs'
export const loadCommands = async (dir: string) => {
const commandsMap: Record<string, Command> = {}
const files = listAllFilesRecursive(dir)
const commands = await Promise.all(
files.map(async file => {
const command = await import(file)
return command.default
}),
)
for (const command of commands) {
if (command) commandsMap[command.data.name] = command
}
return commandsMap
}

View File

@@ -24,16 +24,19 @@ export const createSuccessEmbed = (title: string | null, description?: string) =
) )
export const createMessageScanResponseEmbed = ( export const createMessageScanResponseEmbed = (
response: ConfigMessageScanResponseMessage, response: NonNullable<ConfigMessageScanResponseMessage['embeds']>[number],
mode: 'ocr' | 'nlp' | 'match', mode: 'ocr' | 'nlp' | 'match',
) => { ) => {
// biome-ignore lint/style/noParameterAssign: While this is confusing, it is fine for this purpose
if ('toJSON' in response) response = response.toJSON()
const embed = new EmbedBuilder().setTitle(response.title ?? null) const embed = new EmbedBuilder().setTitle(response.title ?? null)
if (response.description) embed.setDescription(response.description) if (response.description) embed.setDescription(response.description)
if (response.fields) embed.addFields(response.fields) if (response.fields) embed.addFields(response.fields)
embed.setFooter({ embed.setFooter({
text: `ReVanced • Done via ${MessageScanHumanizedMode[mode]}`, text: `ReVanced • Via ${MessageScanHumanizedMode[mode]}`,
iconURL: ReVancedLogoURL, iconURL: ReVancedLogoURL,
}) })

View File

@@ -1,10 +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,
ConfigMessageScanResponseMessage,
} 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'
@@ -15,9 +10,12 @@ export const getResponseFromText = async (
// Just to be safe that we will never use data from the context parameter // Just to be safe that we will never use data from the context parameter
{ api, logger }: Omit<typeof import('src/context'), 'config'>, { api, logger }: Omit<typeof import('src/context'), 'config'>,
ocrMode = false, ocrMode = false,
) => { ): Promise<ConfigMessageScanResponse & { label?: string }> => {
let label: string | undefined let responseConfig: Awaited<ReturnType<typeof getResponseFromText>> = {
let response: ConfigMessageScanResponseMessage | undefined | null triggers: {},
response: null,
}
const firstLabelIndexes: number[] = [] const firstLabelIndexes: number[] = []
// Test if all regexes before a label trigger is matched // Test if all regexes before a label trigger is matched
@@ -25,29 +23,28 @@ export const getResponseFromText = async (
const trigger = responses[i]! const trigger = responses[i]!
// 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 // from the messageCreate handler, see line 17 of messageCreate handler
const { const {
triggers: { text: textTriggers, image: imageTriggers }, triggers: { text: textTriggers, image: imageTriggers },
response: resp,
} = trigger } = trigger
if (response) break if (responseConfig) break
if (ocrMode) { if (ocrMode) {
if (imageTriggers) if (imageTriggers)
for (const regex of imageTriggers) for (const regex of imageTriggers)
if (regex.test(content)) { if (regex.test(content)) {
logger.debug(`Message matched regex (OCR mode): ${regex.source}`) logger.debug(`Message matched regex (OCR mode): ${regex.source}`)
response = resp responseConfig = trigger
break break
} }
} else } else
for (let j = 0; j < textTriggers!.length; j++) { for (let j = 0; j < textTriggers!.length; j++) {
const trigger = textTriggers![j]! const regex = textTriggers![j]!
if (trigger instanceof RegExp) { if (regex instanceof RegExp) {
if (trigger.test(content)) { if (regex.test(content)) {
logger.debug(`Message matched regex (before mode): ${trigger.source}`) logger.debug(`Message matched regex (before mode): ${regex.source}`)
response = resp responseConfig = trigger
break break
} }
} else { } else {
@@ -58,7 +55,7 @@ export const getResponseFromText = async (
} }
// If none of the regexes match, we can search for labels immediately // If none of the regexes match, we can search for labels immediately
if (!response && !ocrMode) { if (!responseConfig && !ocrMode) {
logger.debug('No match from before regexes, doing NLP') logger.debug('No match from before regexes, doing NLP')
const scan = await api.client.parseText(content) const scan = await api.client.parseText(content)
if (scan.labels.length) { if (scan.labels.length) {
@@ -76,24 +73,22 @@ export const getResponseFromText = async (
if (!labelConfig) { if (!labelConfig) {
logger.warn(`No label config found for label ${matchedLabel.name}`) logger.warn(`No label config found for label ${matchedLabel.name}`)
return { response: null, label: undefined } return responseConfig
} }
if (matchedLabel.confidence >= triggerConfig!.threshold) { if (matchedLabel.confidence >= triggerConfig!.threshold) {
logger.debug('Label confidence is enough') logger.debug('Label confidence is enough')
label = matchedLabel.name responseConfig = labelConfig
response = labelConfig.response
} }
} }
} }
// If we still don't have a label, we can match all regexes after the initial label trigger // If we still don't have a response config, we can match all regexes after the initial label trigger
if (!response) { if (!responseConfig) {
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 },
response: resp,
} = responses[i]! } = responses[i]!
const firstLabelIndex = firstLabelIndexes[i] ?? -1 const firstLabelIndex = firstLabelIndexes[i] ?? -1
@@ -103,7 +98,7 @@ export const getResponseFromText = async (
if (trigger instanceof RegExp) { if (trigger instanceof RegExp) {
if (trigger.test(content)) { if (trigger.test(content)) {
logger.debug(`Message matched regex (after mode): ${trigger.source}`) logger.debug(`Message matched regex (after mode): ${trigger.source}`)
response = resp responseConfig = responses[i]!
break break
} }
} }
@@ -111,30 +106,23 @@ export const getResponseFromText = async (
} }
} }
return { return responseConfig
response,
label,
}
} }
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 blFilter = filter.blacklist
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 blacklist, will return false
if (!filter.whitelist && filters.some(x => x)) return false // Any other case, will return true
return !(
return true blFilter &&
(blFilter.channels?.includes(message.channelId) ||
blFilter.roles?.some(role => memberRoles.has(role)) ||
blFilter.users?.includes(message.author.id))
)
} }
export const handleUserResponseCorrection = async ( export const handleUserResponseCorrection = async (
@@ -158,8 +146,10 @@ export const handleUserResponseCorrection = async (
correctedById: user.id, correctedById: user.id,
}) })
.where(eq(responses.replyId, response.replyId)) .where(eq(responses.replyId, response.replyId))
await reply.edit({ await reply.edit({
embeds: [createMessageScanResponseEmbed(correctLabelResponse.response, 'nlp')], ...correctLabelResponse.response,
embeds: correctLabelResponse.response.embeds?.map(it => createMessageScanResponseEmbed(it, 'nlp')),
}) })
} }

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

@@ -0,0 +1,14 @@
import { GuildMember, type User } from 'discord.js'
import config from '../../../config'
export const isAdmin = (userOrMember: User | GuildMember) => {
return (
config.admin?.users?.includes(userOrMember.id) ||
(userOrMember instanceof GuildMember && isMemberAdmin(userOrMember))
)
}
export const isMemberAdmin = (member: GuildMember) => {
const roles = new Set(member.roles.cache.keys())
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

@@ -54,6 +54,7 @@
"lefthook" "lefthook"
], ],
"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"
} }
} }

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

@@ -1,8 +1,14 @@
diff --git a/node_modules/@semantic-release/npm/.bun-tag-3853154e196b7721 b/.bun-tag-3853154e196b7721
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@semantic-release/npm/.bun-tag-550461f23a8ec245 b/.bun-tag-550461f23a8ec245
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@semantic-release/npm/.bun-tag-c9c8130945517add b/.bun-tag-c9c8130945517add diff --git a/node_modules/@semantic-release/npm/.bun-tag-c9c8130945517add b/.bun-tag-c9c8130945517add
new file mode 100644 new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lib/prepare.js b/lib/prepare.js diff --git a/lib/prepare.js b/lib/prepare.js
index 3e76bec44cf595a1b4141728336bed904d4d518d..c6baf4e8de9bdf7536f9ad2e9eb9c360e031c7b5 100644 index 3e76bec44cf595a1b4141728336bed904d4d518d..4b25ca64879bbee2a600f2b23b738c86136ad9c6 100644
--- a/lib/prepare.js --- a/lib/prepare.js
+++ b/lib/prepare.js +++ b/lib/prepare.js
@@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
@@ -14,7 +20,7 @@ index 3e76bec44cf595a1b4141728336bed904d4d518d..c6baf4e8de9bdf7536f9ad2e9eb9c360
export default async function ( export default async function (
npmrc, npmrc,
@@ -11,19 +12,12 @@ export default async function ( @@ -11,19 +12,13 @@ export default async function (
logger.log("Write version %s to package.json in %s", version, basePath); logger.log("Write version %s to package.json in %s", version, basePath);
@@ -36,10 +42,11 @@ index 3e76bec44cf595a1b4141728336bed904d4d518d..c6baf4e8de9bdf7536f9ad2e9eb9c360
- await versionResult; - await versionResult;
+ await writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, detectIndent(pkgJsonRaw).indent)) + await writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, detectIndent(pkgJsonRaw).indent))
+ await execa("bun", ["install"]);
if (tarballDir) { if (tarballDir) {
logger.log("Creating npm package version %s", version); logger.log("Creating npm package version %s", version);
@@ -38,7 +32,7 @@ export default async function ( @@ -38,7 +33,7 @@ export default async function (
// Only move the tarball if we need to // Only move the tarball if we need to
// Fixes: https://github.com/semantic-release/npm/issues/169 // Fixes: https://github.com/semantic-release/npm/issues/169
if (tarballSource !== tarballDestination) { if (tarballSource !== tarballDestination) {

View File

@@ -0,0 +1,21 @@
diff --git a/bin.cjs b/bin.cjs
index 142ed9c20f28dc1080bebfb52325fa308c6cb771..9d3bea0787f6c05df11567c6821bc85743286340 100644
--- a/bin.cjs
+++ b/bin.cjs
@@ -22053,7 +22053,7 @@ var init_sqliteImports = __esm({
const { unregister } = await safeRegister();
for (let i2 = 0; i2 < imports.length; i2++) {
const it = imports[i2];
- const i0 = require(`${it}`);
+ const i0 = await import(`${it}`);
const prepared = prepareFromExports3(i0);
tables.push(...prepared.tables);
}
@@ -129572,6 +129572,7 @@ var generateCommand = new Command("generate").option("--dialect <dialect>", "Dat
} else {
assertUnreachable(dialect7);
}
+ process.exit(0);
});
var migrateCommand = new Command("migrate").option(
"--config <config>",

View File

@@ -25,13 +25,13 @@ const Options = {
[ [
'@semantic-release/npm', '@semantic-release/npm',
{ {
npmPublish: false, npmPublish: false,
} },
], ],
[ [
'@semantic-release/git', '@semantic-release/git',
{ {
assets: ['CHANGELOG.md', 'package.json'], assets: ['CHANGELOG.md', 'package.json', '../../bun.lockb'],
}, },
], ],
[ [