Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions src/command-abstractions/text-based-command-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Wheatley } from "../wheatley.js";
import { zip } from "../utils/iterables.js";
import { intersection } from "../utils/arrays.js";

export type TextBasedCommandOptionType = "string" | "number" | "boolean" | "user" | "users" | "role";
export type TextBasedCommandOptionType = "string" | "number" | "boolean" | "user" | "users" | "role" | "channel";

export type TextBasedCommandParameterOptions = {
title: string;
Expand All @@ -21,6 +21,24 @@ export type TextBasedCommandParameterOptions = {
autocomplete?: (partial: string, command_name: string) => { name: string; value: string }[];
};

export type TextBasedCommandSlashChannelType =
| Discord.ChannelType.GuildText
| Discord.ChannelType.GuildVoice
| Discord.ChannelType.GuildCategory
| Discord.ChannelType.GuildAnnouncement
| Discord.ChannelType.AnnouncementThread
| Discord.ChannelType.PublicThread
| Discord.ChannelType.PrivateThread
| Discord.ChannelType.GuildStageVoice
| Discord.ChannelType.GuildForum
| Discord.ChannelType.GuildMedia;

export type TextBasedCommandStoredOption = TextBasedCommandParameterOptions & {
type: TextBasedCommandOptionType;
// Only applicable for `type: "channel"` (slash commands only)
channel_types?: TextBasedCommandSlashChannelType[];
};

export type TextBasedCommandParameterOptionsWithChoices<T> = TextBasedCommandParameterOptions &
(
| {
Expand Down Expand Up @@ -58,7 +76,7 @@ export class TextBasedCommandBuilder<
readonly names: string[];
early_reply_mode: EarlyReplyMode;
descriptions!: ConditionalOptional<HasDescriptions, string[]>;
options = new Discord.Collection<string, TextBasedCommandParameterOptions & { type: TextBasedCommandOptionType }>();
options = new Discord.Collection<string, TextBasedCommandStoredOption>();
slash_config: boolean[];
permissions: bigint | undefined = undefined;
category: CommandCategory | undefined = undefined;
Expand Down Expand Up @@ -214,6 +232,29 @@ export class TextBasedCommandBuilder<
>;
}

add_channel_option<
O extends TextBasedCommandParameterOptions & { channel_types?: TextBasedCommandSlashChannelType[] },
>(
option: O,
): TextBasedCommandBuilder<
Append<Args, ConditionalNull<O["required"], Discord.Channel>>,
HasDescriptions,
HasHandler,
HasSubcommands
> {
assert(!this.options.has(option.title));
this.options.set(option.title, {
...option,
type: "channel",
});
return this as unknown as TextBasedCommandBuilder<
Append<Args, ConditionalNull<O["required"], Discord.Channel>>,
HasDescriptions,
HasHandler,
HasSubcommands
>;
}

set_handler(
handler: (x: TextBasedCommand, ...args: Args) => Promise<void>,
): TextBasedCommandBuilder<Args, HasDescriptions, true> {
Expand Down
91 changes: 62 additions & 29 deletions src/command-abstractions/text-based-command-descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TextBasedCommandParameterOptions,
TextBasedCommandParameterOptionsWithChoices,
TextBasedCommandOptionType,
TextBasedCommandStoredOption,
TextBasedCommandBuilder,
EarlyReplyMode,
CommandCategory,
Expand All @@ -34,10 +35,7 @@ class ParseError extends Error {
}

export class BotTextBasedCommand<Args extends unknown[] = []> extends BaseBotInteraction<[TextBasedCommand, ...Args]> {
public readonly options = new Discord.Collection<
string,
TextBasedCommandParameterOptions & { type: TextBasedCommandOptionType }
>();
public readonly options = new Discord.Collection<string, TextBasedCommandStoredOption>();
public readonly subcommands: Discord.Collection<string, BotTextBasedCommand<any>> | null = null;
public readonly display_name: string;
public readonly all_names: string[];
Expand Down Expand Up @@ -98,6 +96,8 @@ export class BotTextBasedCommand<Args extends unknown[] = []> extends BaseBotInt
case "user":
case "users":
return /\s(?:<@\d{10,}>|\d{10,})/;
case "channel":
return /\s(?:<#\d{10,}>|\d{10,})/;
case "number":
return /\s\d/;
case "boolean":
Expand Down Expand Up @@ -145,6 +145,14 @@ export class BotTextBasedCommand<Args extends unknown[] = []> extends BaseBotInt
djs_command.addUserOption(slash_option => apply_options(slash_option));
} else if (option.type == "role") {
djs_command.addRoleOption(slash_option => apply_options(slash_option));
} else if (option.type == "channel") {
djs_command.addChannelOption(slash_option => {
const opt = apply_options(slash_option);
if (option.channel_types && option.channel_types.length > 0) {
opt.addChannelTypes(...option.channel_types);
}
return opt;
});
} else {
assert(false, "unhandled option type");
}
Expand Down Expand Up @@ -318,32 +326,57 @@ export class BotTextBasedCommand<Args extends unknown[] = []> extends BaseBotInt
throw required_arg_error();
}
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (option.type == "role") {
const re = new RegExp(
this.wheatley.guild.roles.cache
.map(role => escape_regex(role.name))
.filter(name => name !== "@everyone")
.join("|"),
"i",
);
const match = command_body.match(re);
if (match) {
command_options.push(
unwrap(
this.wheatley.guild.roles.cache.find(
role => role.name.toLowerCase() === match[0].toLowerCase(),
),
),
);
command_body = command_body.slice(match[0].length).trim();
} else if (!option.required) {
command_options.push(null);
} else {
throw required_arg_error();
}
} else {
assert(false, "unhandled option type");
switch (option.type) {
case "role": {
const re = new RegExp(
this.wheatley.guild.roles.cache
.map(role => escape_regex(role.name))
.filter(name => name !== "@everyone")
.join("|"),
"i",
);
const match = command_body.match(re);
if (match) {
command_options.push(
unwrap(
this.wheatley.guild.roles.cache.find(
role => role.name.toLowerCase() === match[0].toLowerCase(),
),
),
);
command_body = command_body.slice(match[0].length).trim();
} else if (!option.required) {
command_options.push(null);
} else {
throw required_arg_error();
}
break;
}
case "channel": {
const re = /^(?:<#(\d{10,})>|(\d{10,}))/;
const match = re.exec(command_body);
if (!match) {
if (option.required) {
throw required_arg_error();
}
command_options.push(null);
} else {
const channel_id = match[1] || match[2];
const guild = await command_obj.get_guild();
const channel = await guild.channels.fetch(channel_id).catch(() => null);
if (!channel) {
await reply_with_error(`Unable to find channel`, true);
return;
}
command_options.push(channel);
command_body = command_body.slice(match[0].length).trim();
}
break;
}
default:
assert(false, "unhandled option type");
}
}
} catch (e) {
if (e instanceof ParseError) {
Expand Down
4 changes: 3 additions & 1 deletion src/command-abstractions/text-based-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ export class TextBasedCommand {

make_message_options(
raw_message_options:
string | (Discord.BaseMessageOptions & CommandAbstractionReplyOptions) | Discord.MessageEditOptions,
| string
| (Discord.BaseMessageOptions & CommandAbstractionReplyOptions)
| Discord.MessageEditOptions,
positional_ephemeral_if_possible = false,
positional_should_text_reply = false,
) {
Expand Down
107 changes: 73 additions & 34 deletions src/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,25 +191,53 @@ export class CommandHandler {
return true;
}

private async handle_slash_comand(interaction: Discord.ChatInputCommandInteraction) {
if (!(interaction.commandName in this.text_commands)) {
// unknown command
private resolve_slash_command(command_name: string, subcommand_name: string | null) {
if (!Object.hasOwn(this.text_commands, command_name)) {
return null;
}
const command = this.text_commands[command_name];
if (subcommand_name) {
const subcommand = command.subcommands?.get(subcommand_name);
if (!subcommand) {
return null;
}
return {
command: subcommand,
command_log_name: `${command_name} ${subcommand_name}`,
};
}
return {
command,
command_log_name: command_name,
};
}

private async handle_slash_comand(interaction: Discord.ChatInputCommandInteraction) {
const subcommand_name = interaction.options.getSubcommand(false);
const resolved_command = this.resolve_slash_command(interaction.commandName, subcommand_name);
if (!resolved_command) {
M.warn(
"Received unknown slash command interaction:",
interaction.commandName,
subcommand_name ?? "<no subcommand>",
"From:",
interaction.user.tag,
interaction.user.id,
);
if (this.wheatley.devmode_enabled) {
await interaction.reply({
content: `Unknown slash command: ${interaction.commandName}`,
flags: Discord.MessageFlags.Ephemeral,
});
return;
}

await interaction.reply({
...create_error_reply("This command is out of date. Please wait a moment and try again."),
flags: Discord.MessageFlags.Ephemeral,
});
return;
}
let command = this.text_commands[interaction.commandName];
let command_log_name = interaction.commandName;
if (interaction.options.getSubcommand(false)) {
command_log_name = `${interaction.commandName} ${interaction.options.getSubcommand()}`;
command = unwrap(unwrap(command.subcommands).get(interaction.options.getSubcommand()));
}
const { command, command_log_name } = resolved_command;
M.log(
`Received /${command_log_name}`,
"From:",
Expand All @@ -230,6 +258,8 @@ export class CommandHandler {
return interaction.options.getNumber(opt.title)?.toString();
} else if (opt.type == "boolean") {
return interaction.options.getBoolean(opt.title)?.toString();
} else if (opt.type == "channel") {
return interaction.options.getChannel(opt.title)?.id;
} else {
return "<unknown>";
}
Expand Down Expand Up @@ -270,6 +300,8 @@ export class CommandHandler {
command_options.push(interaction.options.getNumber(option.title));
} else if (option.type == "boolean") {
command_options.push(interaction.options.getBoolean(option.title));
} else if (option.type == "channel") {
command_options.push(interaction.options.getChannel(option.title));
} else {
assert(false, "unhandled option type");
}
Expand Down Expand Up @@ -397,35 +429,42 @@ export class CommandHandler {
if (interaction.isChatInputCommand()) {
await this.handle_slash_comand(interaction);
} else if (interaction.isAutocomplete()) {
if (interaction.commandName in this.text_commands) {
let command = this.text_commands[interaction.commandName];
if (interaction.options.getSubcommand(false)) {
command = unwrap(unwrap(command.subcommands).get(interaction.options.getSubcommand()));
}
// TODO: permissions sanity check?
const field = interaction.options.getFocused(true);
M.log(
`Received autocomplete interaction for /${interaction.commandName}`,
const subcommand_name = interaction.options.getSubcommand(false);
const resolved_command = this.resolve_slash_command(interaction.commandName, subcommand_name);
if (!resolved_command) {
M.warn(
"Received unknown autocomplete interaction:",
interaction.commandName,
subcommand_name ?? "<no subcommand>",
"From:",
interaction.user.tag,
interaction.user.id,
"Field:",
field.name,
"Text:",
JSON.stringify(field.value),
);
assert(command.options.has(field.name), `${interaction.commandName} ${field.name}`);
const option = command.options.get(field.name)!;
assert(option.autocomplete, `${interaction.commandName} ${field.name}`);
await interaction.respond(
option.autocomplete(field.value, interaction.commandName).map(({ name, value }) => ({
name: name.substring(0, 100),
value: value.substring(0, 100),
})),
);
} else {
// TODO unknown command
await interaction.respond([]);
return;
}
const { command } = resolved_command;
// TODO: permissions sanity check?
const field = interaction.options.getFocused(true);
M.log(
`Received autocomplete interaction for /${interaction.commandName}`,
"From:",
interaction.user.tag,
interaction.user.id,
"Field:",
field.name,
"Text:",
JSON.stringify(field.value),
);
assert(command.options.has(field.name), `${interaction.commandName} ${field.name}`);
const option = command.options.get(field.name)!;
assert(option.autocomplete, `${interaction.commandName} ${field.name}`);
await interaction.respond(
option.autocomplete(field.value, interaction.commandName).map(({ name, value }) => ({
name: name.substring(0, 100),
value: value.substring(0, 100),
})),
);
} else if (interaction.isMessageContextMenuCommand()) {
assert(interaction.commandName in this.other_commands, interaction.commandName);
M.log(
Expand Down
6 changes: 3 additions & 3 deletions src/modules/tccpp/components/starboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,9 @@ export default class Starboard extends BotComponent {
} else {
return reaction.count >= star_threshold;
}
} else if (!(
this.negative_emojis.includes(reaction.emoji.name) || this.ignored_emojis.includes(reaction.emoji.name)
)) {
} else if (
!(this.negative_emojis.includes(reaction.emoji.name) || this.ignored_emojis.includes(reaction.emoji.name))
) {
if (parent_channel_id == this.channels.memes.id) {
return reaction.count >= memes_other_threshold;
} else {
Expand Down
Loading
Loading