feat(bots/discord)!: read commit description

FEATURES:
- Updated documentation
- Improved configuration format
- Allow filter overriding for each response config (closes #29)
- Improved commands directory structure
- Improved slash command reload script
- New commands
- New command exception handling
This commit is contained in:
PalmDevs
2024-04-02 19:34:45 +07:00
parent a9add9ea9a
commit 7e5f6481c5
21 changed files with 542 additions and 558 deletions

View File

@@ -0,0 +1,31 @@
import { createErrorEmbed } from '$/utils/discord/embeds'
export default class CommandError extends Error {
type: CommandErrorType
constructor(type: CommandErrorType, message?: string) {
super(message)
this.name = 'CommandError'
this.type = type
}
toEmbed() {
return createErrorEmbed(ErrorTitleMap[this.type], this.message ?? '')
}
}
export enum CommandErrorType {
Generic,
MissingArgument,
InvalidUser,
InvalidChannel,
InvalidDuration,
}
const ErrorTitleMap: Record<CommandErrorType, string> = {
[CommandErrorType.Generic]: 'An exception was thrown',
[CommandErrorType.MissingArgument]: 'Missing argument',
[CommandErrorType.InvalidUser]: 'Invalid user',
[CommandErrorType.InvalidChannel]: 'Invalid channel',
[CommandErrorType.InvalidDuration]: 'Invalid duration',
}

View File

@@ -0,0 +1,45 @@
import { SlashCommandBuilder } from 'discord.js'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder()
.setName('exception-test')
.setDescription('throw up pls')
.addStringOption(option =>
option
.setName('type')
.setDescription('The type of exception to throw')
.addChoices({
name: 'generic error',
value: 'Generic',
})
.addChoices({
name: 'invalid argument',
value: 'InvalidArgument',
})
.addChoices({
name: 'invalid channel',
value: 'InvalidChannel',
})
.addChoices({
name: 'invalid user',
value: 'InvalidUser',
})
.addChoices({
name: 'invalid duration',
value: 'InvalidDuration',
})
.setRequired(true),
)
.setDMPermission(true)
.toJSON(),
global: true,
async execute(_, interaction) {
const type = interaction.options.getString('type', true)
throw new CommandError(CommandErrorType[type as keyof typeof CommandErrorType], '[INTENTIONAL BOT DESIGN]')
},
} satisfies Command

View File

@@ -1,8 +1,9 @@
import { SlashCommandBuilder } from 'discord.js'
import type { Command } from '.'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder().setName('stop').setDescription('Stops the bot').toJSON(),
data: new SlashCommandBuilder().setName('stop').setDescription('Stops the bot').setDMPermission(true).toJSON(),
ownerOnly: true,
global: true,

View File

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

View File

@@ -0,0 +1,43 @@
import { SlashCommandBuilder, type TextBasedChannel } from 'discord.js'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder()
.setName('reply')
.setDescription('Send a message as the bot')
.addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true))
.addStringOption(option =>
option
.setName('reference')
.setDescription('The message ID to reply to (use `latest` to reply to the latest message)')
.setRequired(false),
)
.toJSON(),
memberRequirements: {
roles: ['955220417969262612', '973886585294704640'],
},
global: false,
async execute({ logger }, interaction) {
const msg = interaction.options.getString('message', true)
const ref = interaction.options.getString('reference')
const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel
const refMsg = ref?.startsWith('latest') ? (await channel.messages.fetch({ limit: 1 })).at(0)?.id : ref
await channel.send({
content: msg,
reply: refMsg ? { messageReference: refMsg, failIfNotExists: true } : undefined,
})
logger.info(`User ${interaction.user.tag} made the bot say: ${msg}`)
await interaction.reply({
content: 'OK!',
ephemeral: true,
})
},
} satisfies Command

View File

@@ -22,7 +22,6 @@ export type Command = {
mode?: 'all' | 'any'
/**
* The permissions required to use this command (in BitFields).
* For safety reasons, this is set to `-1n` and only bot owners can use this command unless explicitly specified.
*
* - **0n** means that everyone can use this command.
* - **-1n** means that only bot owners can use this command.
@@ -37,13 +36,12 @@ export type Command = {
}
/**
* Whether this command can only be used by bot owners.
* For safety reasons, this is set to `true` and only bot owners can use this command unless explicitly specified.
* @default true
* @default false
*/
ownerOnly?: boolean
/**
* Whether to register this command as a global slash command.
* For safety reasons, this is set to `false` and commands will be registered in allowed guilds only.
* This is set to `false` and commands will be registered in allowed guilds only by default.
* @default false
*/
global?: boolean

View File

@@ -0,0 +1,58 @@
import { createSuccessEmbed } from '$/utils/discord/embeds'
import { durationToString, parseDuration } from '$/utils/duration'
import { SlashCommandBuilder } from 'discord.js'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import type { Command } from '..'
export default {
data: new SlashCommandBuilder()
.setName('slowmode')
.setDescription('Set a slowmode for the current channel')
.addStringOption(option => option.setName('duration').setDescription('The duration to set').setRequired(true))
.addStringOption(option =>
option
.setName('channel')
.setDescription('The channel to set the slowmode on (defaults to current channel)')
.setRequired(false),
)
.toJSON(),
memberRequirements: {
roles: ['955220417969262612', '973886585294704640'],
},
global: false,
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(
CommandErrorType.InvalidChannel,
'The supplied channel is not a text channel or does not exist.',
)
if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidDuration, 'Invalid duration.')
if (duration < 0 || duration > 36e4)
throw new CommandError(
CommandErrorType.InvalidDuration,
'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,
`Slowmode set by @${interaction.user.username} (${interaction.user.id})`,
)
await interaction.reply({
embeds: [createSuccessEmbed(`Slowmode set to ${durationToString(duration)} on ${channel.toString()}`)],
})
},
} satisfies Command

View File

@@ -1,57 +0,0 @@
import { createStackTraceEmbed } from '$/utils/discord/embeds'
import { PermissionFlagsBits, SlashCommandBuilder, type TextBasedChannel } from 'discord.js'
import type { Command } from '.'
export default {
data: new SlashCommandBuilder()
.setName('reply')
.setDescription('Send a message as the bot')
.addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true))
.addStringOption(option =>
option
.setName('reference')
.setDescription('The message ID to reply to (use `latest` to reply to the latest message)')
.setRequired(false),
)
.toJSON(),
memberRequirements: {
mode: 'all',
roles: ['955220417969262612', '973886585294704640'],
permissions: PermissionFlagsBits.ManageMessages,
},
global: false,
async execute({ logger }, interaction) {
const msg = interaction.options.getString('message', true)
const ref = interaction.options.getString('reference')
const resolvedRef = ref?.startsWith('latest')
? (await interaction.channel?.messages.fetch({ limit: 1 }))?.at(0)?.id
: ref
try {
const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel | null
if (!channel) throw new Error('Channel not found (or not cached)')
await channel.send({
content: msg,
reply: {
messageReference: resolvedRef!,
failIfNotExists: true,
},
})
logger.warn(`User ${interaction.user.tag} made the bot say: ${msg}`)
await interaction.reply({
content: 'OK!',
ephemeral: true,
})
} catch (e) {
await interaction.reply({
embeds: [createStackTraceEmbed(e)],
})
}
},
} satisfies Command

View File

@@ -1,3 +1,4 @@
import CommandError from '$/classes/CommandError'
import { createErrorEmbed, createStackTraceEmbed } from '$utils/discord/embeds'
import { on } from '$utils/discord/events'
@@ -8,40 +9,46 @@ export default on('interactionCreate', async (context, interaction) => {
const command = discord.commands[interaction.commandName]
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) {
logger.error(`Command ${interaction.commandName} not implemented but registered!!!`)
return void interaction.reply({
embeds: [
createErrorEmbed(
'Command not implemented',
'This command has not been implemented yet. Please report this to the developers.',
),
],
ephemeral: true,
})
}
const isOwner = config.owners.includes(interaction.user.id)
const userIsOwner = config.owners.includes(interaction.user.id)
if ((command.ownerOnly ?? true) && !userIsOwner)
/**
* 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 && !userIsOwner) {
const { permissions = -1n, roles = [], mode } = command.memberRequirements
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 ||
permissions <= 0n ||
// or the user doesn't have the required permissions
(permissions >= 0n && !interaction.memberPermissions.has(permissions)),
(permissions > 0n && !interaction.memberPermissions.has(permissions)),
// If not:
!roles.some(x => member.roles.cache.has(x)),
@@ -66,7 +73,7 @@ export default on('interactionCreate', async (context, interaction) => {
} catch (err) {
logger.error(`Error while executing command ${interaction.commandName}:`, err)
await interaction.reply({
embeds: [createStackTraceEmbed(err)],
embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)],
ephemeral: true,
})
}

View File

@@ -1,5 +1,5 @@
import { MessageScanLabeledResponseReactions } from '$/constants'
import { getResponseFromContent, shouldScanMessage } from '$/utils/discord/messageScan'
import { getResponseFromText, shouldScanMessage } from '$/utils/discord/messageScan'
import { createMessageScanResponseEmbed } from '$utils/discord/embeds'
import { on } from '$utils/discord/events'
@@ -12,35 +12,41 @@ on('messageCreate', async (ctx, msg) => {
} = ctx
if (!config || !config.responses) return
if (!shouldScanMessage(msg, config)) return
const filteredResponses = config.responses.filter(x => shouldScanMessage(msg, x.filterOverride ?? config.filter))
if (!filteredResponses.length) return
if (msg.content.length) {
logger.debug(`Classifying message ${msg.id}`)
try {
logger.debug(`Classifying message ${msg.id}`)
const { response, label } = await getResponseFromContent(msg.content, ctx)
const { response, label } = await getResponseFromText(msg.content, filteredResponses, ctx)
if (response) {
logger.debug('Response found')
if (response) {
logger.debug('Response found')
const reply = await msg.reply({
embeds: [createMessageScanResponseEmbed(response, label ? 'nlp' : 'match')],
})
if (label)
db.labeledResponses.save({
reply: reply.id,
channel: reply.channel.id,
guild: reply.guild.id,
referenceMessage: msg.id,
label,
text: msg.content,
const reply = await msg.reply({
embeds: [createMessageScanResponseEmbed(response, label ? 'nlp' : 'match')],
})
if (label) {
for (const reaction of Object.values(MessageScanLabeledResponseReactions)) {
await reply.react(reaction)
if (label)
db.labeledResponses.save({
reply: reply.id,
channel: reply.channel.id,
guild: reply.guild!.id,
referenceMessage: msg.id,
label,
text: msg.content,
})
if (label) {
for (const reaction of Object.values(MessageScanLabeledResponseReactions)) {
await reply.react(reaction)
}
}
}
} catch (e) {
logger.error('Failed to classify message:', e)
}
}
@@ -52,7 +58,7 @@ on('messageCreate', async (ctx, msg) => {
try {
const { text: content } = await api.client.parseImage(attachment.url)
const { response } = await getResponseFromContent(content, ctx, true)
const { response } = await getResponseFromText(content, filteredResponses, ctx, true)
if (response) {
logger.debug(`Response found for attachment: ${attachment.url}`)

View File

@@ -30,27 +30,31 @@ on('messageReactionAdd', async (context, rct, user) => {
if (reactionMessage.author.id !== reaction.client.user!.id) return
if (!PossibleReactions.includes(reaction.emoji.name!)) return
if (reactionMessage.inGuild() && msConfig.humanCorrections.memberRequirements) {
const {
memberRequirements: { roles, permissions },
} = msConfig.humanCorrections
if (!roles && !permissions)
return void logger.warn(
'No member requirements specified for human corrections, ignoring this request for security reasons',
)
const member = await reactionMessage.guild.members.fetch(user.id)
if (!config.owners.includes(user.id)) {
// User is in guild, and config has member requirements
if (
permissions &&
!member.permissions.has(permissions) &&
roles &&
!roles.some(role => member.roles.cache.has(role))
)
return
// User is not owner, and not included in allowUsers
} else if (!config.owners.includes(user.id) && !msConfig.humanCorrections.allowUsers?.includes(user.id)) return
reactionMessage.inGuild() &&
(msConfig.humanCorrections.allow?.members || msConfig.humanCorrections.allow?.users)
) {
const {
allow: { users: allowedUsers, members: allowedMembers },
} = msConfig.humanCorrections
if (allowedMembers) {
const member = await reactionMessage.guild.members.fetch(user.id)
const { permissions, roles } = allowedMembers
if (!(member.permissions.has(permissions ?? 0n) || roles?.some(role => member.roles.cache.has(role))))
return
} else if (allowedUsers) {
if (!allowedUsers.includes(user.id)) return
} else {
return void logger.warn(
'No member or user requirements set for human corrections, all requests will be ignored',
)
}
}
}
// Sanity check
const response = db.labeledResponses.get(rct.message.id)
@@ -69,7 +73,9 @@ on('messageReactionAdd', async (context, rct, user) => {
// Bot is wrong :(
const labels = msConfig.responses!.flatMap(r =>
r.triggers.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label),
r.triggers
.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t)
.map(t => t.label),
)
const componentPrefix = `cr_${reactionMessage.id}`

View File

@@ -1,55 +1,59 @@
import type { LabeledResponse } from '$/classes/Database'
import type { Config, ConfigMessageScanResponseLabelConfig, ConfigMessageScanResponseMessage } from 'config.example'
import type {
Config,
ConfigMessageScanResponse,
ConfigMessageScanResponseLabelConfig,
ConfigMessageScanResponseMessage,
} from 'config.example'
import type { Message, PartialUser, User } from 'discord.js'
import { createMessageScanResponseEmbed } from './embeds'
export const getResponseFromContent = async (
export const getResponseFromText = async (
content: string,
{ api, logger, config: { messageScan: config } }: typeof import('src/context'),
responses: ConfigMessageScanResponse[],
// Just to be safe that we will never use data from the context parameter
{ api, logger }: Omit<typeof import('src/context'), 'config'>,
ocrMode = false,
) => {
if (!config || !config.responses) {
logger.warn('No message scan config found')
return {
response: null,
label: undefined,
}
}
let label: string | undefined
let response: ConfigMessageScanResponseMessage | undefined | null
const firstLabelIndexes: number[] = []
// Test if all regexes before a label trigger is matched
for (let i = 0; i < config.responses.length; i++) {
const trigger = config.responses[i]!
for (let i = 0; i < responses.length; i++) {
const trigger = responses[i]!
const { triggers, ocrTriggers, response: resp } = trigger
// Filter override check is not neccessary here, we are already passing responses that match the filter
// from the messageCreate handler
const {
triggers: { text: textTriggers, image: imageTriggers },
response: resp,
} = trigger
if (response) break
if (ocrMode && ocrTriggers)
for (const regex of ocrTriggers)
if (regex.test(content)) {
logger.debug(`Message matched regex (OCR mode): ${regex.source}`)
response = resp
if (ocrMode) {
if (imageTriggers)
for (const regex of imageTriggers)
if (regex.test(content)) {
logger.debug(`Message matched regex (OCR mode): ${regex.source}`)
response = resp
break
}
} else
for (let j = 0; j < textTriggers!.length; j++) {
const trigger = textTriggers![j]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
logger.debug(`Message matched regex (before mode): ${trigger.source}`)
response = resp
break
}
} else {
firstLabelIndexes[i] = j
break
}
for (let j = 0; j < triggers.length; j++) {
const trigger = triggers[j]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
logger.debug(`Message matched regex (before mode): ${trigger.source}`)
response = resp
break
}
} else {
firstLabelIndexes[i] = j
break
}
}
}
// If none of the regexes match, we can search for labels immediately
@@ -61,8 +65,8 @@ export const getResponseFromContent = async (
logger.debug(`Message matched label with confidence: ${matchedLabel.name}, ${matchedLabel.confidence}`)
let triggerConfig: ConfigMessageScanResponseLabelConfig | undefined
const labelConfig = config.responses.find(x => {
const config = x.triggers.find(
const labelConfig = responses.find(x => {
const config = x.triggers.text!.find(
(x): x is ConfigMessageScanResponseLabelConfig => 'label' in x && x.label === matchedLabel.name,
)
if (config) triggerConfig = config
@@ -85,12 +89,15 @@ export const getResponseFromContent = async (
// If we still don't have a label, we can match all regexes after the initial label trigger
if (!response) {
logger.debug('No match from NLP, doing after regexes')
for (let i = 0; i < config.responses.length; i++) {
const { triggers, response: resp } = config.responses[i]!
for (let i = 0; i < responses.length; i++) {
const {
triggers: { text: textTriggers },
response: resp,
} = responses[i]!
const firstLabelIndex = firstLabelIndexes[i] ?? -1
for (let i = firstLabelIndex + 1; i < triggers.length; i++) {
const trigger = triggers[i]!
for (let i = firstLabelIndex + 1; i < textTriggers!.length; i++) {
const trigger = textTriggers![i]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
@@ -111,19 +118,20 @@ export const getResponseFromContent = async (
export const shouldScanMessage = (
message: Message,
config: NonNullable<Config['messageScan']>,
filter: NonNullable<Config['messageScan']>['filter'],
): message is Message<true> => {
if (message.author.bot) return false
if (!message.guild) return false
if (!filter) return true
const filters = [
config.users?.includes(message.author.id),
message.member?.roles.cache.some(x => config.roles?.includes(x.id)),
config.channels?.includes(message.channel.id),
filter.users?.includes(message.author.id),
message.member?.roles.cache.some(x => filter.roles?.includes(x.id)),
filter.channels?.includes(message.channel.id),
]
if (config.whitelist && filters.every(x => !x)) return false
if (!config.whitelist && filters.some(x => x)) return false
if (filter.whitelist && filters.every(x => !x)) return false
if (!filter.whitelist && filters.some(x => x)) return false
return true
}
@@ -135,7 +143,9 @@ export const handleUserResponseCorrection = async (
label: string,
user: User | PartialUser,
) => {
const correctLabelResponse = msConfig!.responses!.find(r => r.triggers.some(t => 'label' in t && t.label === label))
const correctLabelResponse = msConfig!.responses!.find(r =>
r.triggers.text!.some(t => 'label' in t && t.label === label),
)
if (!correctLabelResponse) throw new Error('Cannot find label config for the selected label')
if (!correctLabelResponse.response) return void (await reply.delete())