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
This commit is contained in:
PalmDevs
2024-07-30 21:05:12 +07:00
parent a848a9c896
commit 646ec8da87
36 changed files with 1153 additions and 616 deletions

View File

@@ -4,6 +4,7 @@
* @type {import('./config.schema').Config} * @type {import('./config.schema').Config}
*/ */
export default { export default {
prefix: '!',
admin: { admin: {
users: ['USER_ID_HERE'], users: ['USER_ID_HERE'],
roles: { roles: {

View File

@@ -1,6 +1,7 @@
import type { BaseMessageOptions } from 'discord.js' import type { BaseMessageOptions } from 'discord.js'
export type Config = { export type Config = {
prefix?: string
admin?: { admin?: {
users?: string[] users?: string[]
roles?: Record<string, string[]> roles?: Record<string, string[]>

View File

@@ -43,4 +43,4 @@
"discord-api-types": "^0.37.92", "discord-api-types": "^0.37.92",
"drizzle-kit": "^0.22.8" "drizzle-kit": "^0.22.8"
} }
} }

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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,19 +6,19 @@ import { createLogger } from '@revanced/bot-shared'
import { 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: {
@@ -81,8 +81,8 @@ export const discord = {
}, },
partials: [Partials.Message, Partials.Reaction], partials: [Partials.Message, Partials.Reaction],
}), }),
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

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

View File

@@ -0,0 +1,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

@@ -13,7 +13,7 @@ withContext(on, 'messageCreate', async (context, msg) => {
} = context } = context
if (!config || !config.responses) return if (!config || !config.responses) return
if (msg.author.bot && !config.scanBots) if (msg.author.bot && !config.scanBots) return
if (!msg.inGuild() && !config.scanOutsideGuilds) return if (!msg.inGuild() && !config.scanOutsideGuilds) return
if (msg.inGuild() && msg.member?.partial) await msg.member.fetch() if (msg.inGuild() && msg.member?.partial) await msg.member.fetch()
@@ -24,7 +24,11 @@ withContext(on, 'messageCreate', async (context, msg) => {
try { try {
logger.debug(`Classifying message ${msg.id}`) logger.debug(`Classifying message ${msg.id}`)
const { response, label, replyToReplied } = await getResponseFromText(msg.content, filteredResponses, context) const { response, label, replyToReplied } = await getResponseFromText(
msg.content,
filteredResponses,
context,
)
if (response) { if (response) {
logger.debug('Response found') logger.debug('Response found')

View File

@@ -13,8 +13,8 @@ import {
import type { ConfigMessageScanResponseLabelConfig } from '$/../config.schema' import type { ConfigMessageScanResponseLabelConfig } from '$/../config.schema'
import { responses } from '$/database/schemas' import { responses } from '$/database/schemas'
import { handleUserResponseCorrection } from '$/utils/discord/messageScan' import { handleUserResponseCorrection } from '$/utils/discord/messageScan'
import { eq } from 'drizzle-orm'
import { isAdmin } from '$/utils/discord/permissions' import { isAdmin } from '$/utils/discord/permissions'
import { eq } from 'drizzle-orm'
const PossibleReactions = Object.values(Reactions) as string[] const PossibleReactions = Object.values(Reactions) as string[]

View File

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

View File

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

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

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

View File

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

View File

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