diff --git a/src/command-abstractions/text-based-command-builder.ts b/src/command-abstractions/text-based-command-builder.ts index dab18b98..fe697925 100644 --- a/src/command-abstractions/text-based-command-builder.ts +++ b/src/command-abstractions/text-based-command-builder.ts @@ -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; @@ -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 = TextBasedCommandParameterOptions & ( | { @@ -58,7 +76,7 @@ export class TextBasedCommandBuilder< readonly names: string[]; early_reply_mode: EarlyReplyMode; descriptions!: ConditionalOptional; - options = new Discord.Collection(); + options = new Discord.Collection(); slash_config: boolean[]; permissions: bigint | undefined = undefined; category: CommandCategory | undefined = undefined; @@ -214,6 +232,29 @@ export class TextBasedCommandBuilder< >; } + add_channel_option< + O extends TextBasedCommandParameterOptions & { channel_types?: TextBasedCommandSlashChannelType[] }, + >( + option: O, + ): TextBasedCommandBuilder< + Append>, + HasDescriptions, + HasHandler, + HasSubcommands + > { + assert(!this.options.has(option.title)); + this.options.set(option.title, { + ...option, + type: "channel", + }); + return this as unknown as TextBasedCommandBuilder< + Append>, + HasDescriptions, + HasHandler, + HasSubcommands + >; + } + set_handler( handler: (x: TextBasedCommand, ...args: Args) => Promise, ): TextBasedCommandBuilder { diff --git a/src/command-abstractions/text-based-command-descriptor.ts b/src/command-abstractions/text-based-command-descriptor.ts index 1a049713..0a89a781 100644 --- a/src/command-abstractions/text-based-command-descriptor.ts +++ b/src/command-abstractions/text-based-command-descriptor.ts @@ -10,6 +10,7 @@ import { TextBasedCommandParameterOptions, TextBasedCommandParameterOptionsWithChoices, TextBasedCommandOptionType, + TextBasedCommandStoredOption, TextBasedCommandBuilder, EarlyReplyMode, CommandCategory, @@ -34,10 +35,7 @@ class ParseError extends Error { } export class BotTextBasedCommand extends BaseBotInteraction<[TextBasedCommand, ...Args]> { - public readonly options = new Discord.Collection< - string, - TextBasedCommandParameterOptions & { type: TextBasedCommandOptionType } - >(); + public readonly options = new Discord.Collection(); public readonly subcommands: Discord.Collection> | null = null; public readonly display_name: string; public readonly all_names: string[]; @@ -98,6 +96,8 @@ export class BotTextBasedCommand 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": @@ -145,6 +145,14 @@ export class BotTextBasedCommand 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"); } @@ -318,32 +326,57 @@ export class BotTextBasedCommand 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) { diff --git a/src/command-abstractions/text-based-command.ts b/src/command-abstractions/text-based-command.ts index 1bdd5184..5329bf16 100644 --- a/src/command-abstractions/text-based-command.ts +++ b/src/command-abstractions/text-based-command.ts @@ -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, ) { diff --git a/src/command-handler.ts b/src/command-handler.ts index f74a4428..989213c7 100644 --- a/src/command-handler.ts +++ b/src/command-handler.ts @@ -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 ?? "", + "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:", @@ -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 ""; } @@ -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"); } @@ -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 ?? "", "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( diff --git a/src/modules/tccpp/components/starboard.ts b/src/modules/tccpp/components/starboard.ts index c5613f53..3d771cfa 100644 --- a/src/modules/tccpp/components/starboard.ts +++ b/src/modules/tccpp/components/starboard.ts @@ -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 { diff --git a/src/modules/wheatley/components/guild-lookup.ts b/src/modules/wheatley/components/guild-lookup.ts index da35a9ca..1b867d65 100644 --- a/src/modules/wheatley/components/guild-lookup.ts +++ b/src/modules/wheatley/components/guild-lookup.ts @@ -7,12 +7,19 @@ import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import { EarlyReplyMode, TextBasedCommandBuilder } from "../../../command-abstractions/text-based-command-builder.js"; import { TextBasedCommand } from "../../../command-abstractions/text-based-command.js"; +import { BotButton, ButtonInteractionBuilder } from "../../../command-abstractions/button.js"; +import { colors } from "../../../common.js"; +import { create_error_reply } from "../../../wheatley.js"; + +const CHANNELS_PER_PAGE = 10; export default class GuildLookup extends BotComponent { static override get is_freestanding() { return true; } + private guild_lookup_page_button!: BotButton<[string, number]>; + override async setup(commands: CommandSetBuilder) { commands.add( new TextBasedCommandBuilder("guild", EarlyReplyMode.none) @@ -44,15 +51,99 @@ export default class GuildLookup extends BotComponent { .set_handler(this.invite.bind(this)), ), ); + + this.guild_lookup_page_button = commands.add( + new ButtonInteractionBuilder("guild_lookup_page") + // guild_id: string, page: number + .add_string_metadata() + .add_number_metadata() + .set_permissions(Discord.PermissionFlagsBits.BanMembers) + .set_handler(this.handle_lookup_page.bind(this)), + ); + } + + private async build_lookup_message(guild_id: string, page: number): Promise { + const guild = await this.wheatley.client.guilds.fetch(guild_id); + await guild.channels.fetch(); + + // guild.channels.cache can include threads (which don't have a position). + // Sort primarily by position when available otherwise push to the end. + const channels = [...guild.channels.cache.values()].sort((a, b) => { + const pos_a = + "rawPosition" in a && typeof (a as any).rawPosition === "number" + ? ((a as any).rawPosition as number) + : Number.MAX_SAFE_INTEGER; + const pos_b = + "rawPosition" in b && typeof (b as any).rawPosition === "number" + ? ((b as any).rawPosition as number) + : Number.MAX_SAFE_INTEGER; + return pos_a - pos_b || a.id.localeCompare(b.id); + }); + const pages = Math.ceil(channels.length / CHANNELS_PER_PAGE); + const clamped_page = Math.min(Math.max(page, 0), Math.max(pages - 1, 0)); + + const page_channels = channels.slice(clamped_page * CHANNELS_PER_PAGE, (clamped_page + 1) * CHANNELS_PER_PAGE); + const lines = page_channels.map(channel => `${channel.id} ${channel.name}`); + + const embed = new Discord.EmbedBuilder() + .setColor(colors.wheatley) + .setTitle( + pages > 1 + ? `Guild lookup: ${guild.name} (page ${clamped_page + 1} of ${pages})` + : `Guild lookup: ${guild.name}`, + ) + .setDescription(lines.length > 0 ? lines.join("\n") : "(no channels)") + .setFooter({ text: `Guild ID: ${guild.id} | ${channels.length} channel(s)` }); + + const buttons: Discord.ButtonBuilder[] = []; + if (pages > 1 && clamped_page > 0) { + buttons.push( + this.guild_lookup_page_button + .create_button(guild.id, clamped_page - 1) + .setLabel("←") + .setStyle(Discord.ButtonStyle.Primary), + ); + } + if (pages > 1 && clamped_page < pages - 1) { + buttons.push( + this.guild_lookup_page_button + .create_button(guild.id, clamped_page + 1) + .setLabel("→") + .setStyle(Discord.ButtonStyle.Primary), + ); + } + + return { + embeds: [embed], + components: + buttons.length > 0 + ? [ + new Discord.ActionRowBuilder().addComponents( + ...buttons, + ), + ] + : undefined, + allowedMentions: { parse: [] }, + }; } async lookup(command: TextBasedCommand, id: string) { const guild = await this.wheatley.client.guilds.fetch(id); - await command.reply(`${guild.id} \`${guild.name}\``, true); - await command.replyOrFollowUp( - [...guild.channels.cache.values().map(channel => `${channel.id} ${channel.name}`)].join("\n"), - true, - ); + await command.replyOrFollowUp(await this.build_lookup_message(guild.id, 0), true); + } + + private async handle_lookup_page(interaction: Discord.ButtonInteraction, guild_id: string, page: number) { + try { + await interaction.deferUpdate(); + await interaction.message.edit(await this.build_lookup_message(guild_id, page)); + } catch (e) { + const { embeds } = create_error_reply(`Error: ${e}`); + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ ephemeral: true, embeds }); + } else { + await interaction.reply({ ephemeral: true, embeds }); + } + } } async invite(command: TextBasedCommand, id: string) { diff --git a/src/modules/wheatley/components/username-manager.ts b/src/modules/wheatley/components/username-manager.ts index 54dda6c7..a5517076 100644 --- a/src/modules/wheatley/components/username-manager.ts +++ b/src/modules/wheatley/components/username-manager.ts @@ -22,7 +22,7 @@ function is_all_ascii(str: string) { function has_three_continuous_valid_asciis(str: string) { let index: number; let consecutive_count = 0; - for (index = 0; index < str.length;) { + for (index = 0; index < str.length; ) { let point = str.codePointAt(index); assert(point !== undefined); if (point > 32 && point < 127) { diff --git a/src/modules/wheatley/components/voice/voice-log.ts b/src/modules/wheatley/components/voice/voice-log.ts new file mode 100644 index 00000000..f4b06c73 --- /dev/null +++ b/src/modules/wheatley/components/voice/voice-log.ts @@ -0,0 +1,577 @@ +import * as Discord from "discord.js"; + +import { colors, WEEK } from "../../../../common.js"; +import { BotComponent } from "../../../../bot-component.js"; +import { CommandSetBuilder } from "../../../../command-abstractions/command-set-builder.js"; +import { + EarlyReplyMode, + TextBasedCommandBuilder, +} from "../../../../command-abstractions/text-based-command-builder.js"; +import { + CommandAbstractionReplyOptions, + TextBasedCommand, +} from "../../../../command-abstractions/text-based-command.js"; +import { BotButton, ButtonInteractionBuilder } from "../../../../command-abstractions/button.js"; +import { SelfClearingMap } from "../../../../utils/containers.js"; +import { discord_timestamp } from "../../../../utils/discord.js"; +import { create_error_reply } from "../../../../wheatley.js"; + +type voice_log_event_kind = "join" | "leave"; + +type voice_log_event = { + kind: voice_log_event_kind; + guild_id: string; + channel_id: string; + user_id: string; + at_ms: number; + other_channel_id: string | null; + display_name: string; + username: string; +}; + +const JOIN_HISTORY_WINDOW = WEEK; +const JOIN_HISTORY_TTL = 2 * WEEK; +const JOIN_HISTORY_MAX_ENTRIES_PER_CHANNEL = 200; +const JOIN_HISTORY_MAX_N_OUTPUT = 200; +const JOIN_HISTORY_PAGE_SIZE = 10; +const DEV_VOICE_BOUNCE_MAX_COUNT = 100; + +type dev_voice_bounce_task = { + keep_running: boolean; +}; + +type dev_voice_bounce_context = { + task_id: string; + issuer_id: string; + target_member: Discord.GuildMember; + first: Discord.VoiceBasedChannel; + second: Discord.VoiceBasedChannel; + count: number; +}; + +export default class VoiceLog extends BotComponent { + private readonly event_history = new SelfClearingMap(JOIN_HISTORY_TTL); + private voice_log_page_button!: BotButton<[string, number, number, string]>; + private voice_log_delete_button!: BotButton<[string]>; + private readonly dev_voice_bounce_tasks = new SelfClearingMap(WEEK); + private dev_voice_bounce_stop_button!: BotButton<[string, string]>; + + static override get is_freestanding() { + return true; + } + + override async setup(commands: CommandSetBuilder) { + commands.add( + new TextBasedCommandBuilder("voice", EarlyReplyMode.ephemeral) + .set_description("Voice moderation") + .set_permissions(Discord.PermissionFlagsBits.MuteMembers) + .add_subcommand( + new TextBasedCommandBuilder("log", EarlyReplyMode.none) + .set_description("Show recent voice events (join/leave) for a voice channel") + .add_channel_option({ + title: "channel", + description: "Voice channel (defaults to your current voice channel)", + required: false, + channel_types: [Discord.ChannelType.GuildVoice, Discord.ChannelType.GuildStageVoice], + }) + .add_number_option({ + title: "n", + description: `Number of most recent events to show (1-${JOIN_HISTORY_MAX_N_OUTPUT})`, + required: false, + }) + .set_handler(this.handle_log.bind(this)), + ), + ); + + if (this.wheatley.devmode_enabled) { + commands.add( + new TextBasedCommandBuilder("dev-voice-bounce", EarlyReplyMode.ephemeral) + .set_category("Hidden") + .set_description("Dev helper: move a member between two voice channels repeatedly") + .set_permissions(Discord.PermissionFlagsBits.MoveMembers) + .add_channel_option({ + title: "first", + description: "First voice channel", + required: true, + channel_types: [Discord.ChannelType.GuildVoice, Discord.ChannelType.GuildStageVoice], + }) + .add_channel_option({ + title: "second", + description: "Second voice channel", + required: true, + channel_types: [Discord.ChannelType.GuildVoice, Discord.ChannelType.GuildStageVoice], + }) + .add_number_option({ + title: "count", + description: `Number of round trips to run (1-${DEV_VOICE_BOUNCE_MAX_COUNT})`, + required: true, + }) + .add_user_option({ + title: "user", + description: "Member to move (defaults to yourself)", + required: false, + }) + .set_handler(this.handle_dev_voice_bounce.bind(this)), + ); + } + + this.voice_log_page_button = commands.add( + new ButtonInteractionBuilder("voice_log_page") + // channel_id: string, n: number, page: number, issuer_id: string + .add_string_metadata() + .add_number_metadata() + .add_number_metadata() + .add_user_id_metadata() + .set_permissions(Discord.PermissionFlagsBits.MuteMembers) + .set_handler(this.handle_log_page.bind(this)), + ); + + this.voice_log_delete_button = commands.add( + new ButtonInteractionBuilder("voice_log_delete") + // issuer_id: string + .add_user_id_metadata() + .set_permissions(Discord.PermissionFlagsBits.MuteMembers) + .set_handler(this.handle_delete_log.bind(this)), + ); + + this.dev_voice_bounce_stop_button = commands.add( + new ButtonInteractionBuilder("dev_voice_bounce_stop") + .add_string_metadata() + .add_user_id_metadata() + .set_permissions(Discord.PermissionFlagsBits.MoveMembers) + .set_handler(this.handle_dev_voice_bounce_stop.bind(this)), + ); + } + + override async on_voice_state_update(old_state: Discord.VoiceState, new_state: Discord.VoiceState) { + if (new_state.guild.id !== this.wheatley.guild.id) { + return; + } + const member = new_state.member ?? old_state.member; + if (!member || member.user.bot) { + return; + } + + // Ignore no-ops + if (new_state.channelId === old_state.channelId) { + return; + } + + const now = Date.now(); + const cutoff = now - JOIN_HISTORY_WINDOW; + + const record = (channel_id: string, kind: voice_log_event_kind, other_channel_id: string | null) => { + const key = `${new_state.guild.id}:${channel_id}`; + const prev = this.event_history.get(key) ?? []; + const next = prev.filter(e => e.at_ms >= cutoff); + next.push({ + kind, + guild_id: new_state.guild.id, + channel_id, + user_id: member.id, + at_ms: now, + other_channel_id, + display_name: member.displayName, + username: member.user.username, + }); + if (next.length > JOIN_HISTORY_MAX_ENTRIES_PER_CHANNEL) { + next.splice(0, next.length - JOIN_HISTORY_MAX_ENTRIES_PER_CHANNEL); + } + this.event_history.set(key, next); + }; + + // Join + if (old_state.channelId == null && new_state.channelId != null) { + record(new_state.channelId, "join", null); + return; + } + + // Leave + if (old_state.channelId != null && new_state.channelId == null) { + record(old_state.channelId, "leave", null); + return; + } + + // Move: record leave in old channel and join in new channel + if (old_state.channelId != null && new_state.channelId != null) { + record(old_state.channelId, "leave", new_state.channelId); + record(new_state.channelId, "join", old_state.channelId); + } + } + + private get_recent_events(target_channel: Discord.VoiceBasedChannel, effective_n: number): voice_log_event[] { + const key = `${this.wheatley.guild.id}:${target_channel.id}`; + const events = this.event_history.get(key) ?? []; + const cutoff = Date.now() - JOIN_HISTORY_WINDOW; + const recent = events.filter(e => e.at_ms >= cutoff); + // Display newest events first so the log reads top-to-bottom from newest to oldest. + return recent.slice(-effective_n).reverse(); + } + + private build_log_message( + target_channel: Discord.VoiceBasedChannel, + effective_n: number, + page: number, + issuer_id: string, + ): Discord.BaseMessageOptions { + const newest_first = this.get_recent_events(target_channel, effective_n); + const delete_button = this.voice_log_delete_button + .create_button(issuer_id) + .setLabel("Delete") + .setEmoji("šŸ—‘ļø") + .setStyle(Discord.ButtonStyle.Danger); + + if (newest_first.length === 0) { + return { + embeds: [ + new Discord.EmbedBuilder() + .setColor(colors.wheatley) + .setDescription(`No voice history recorded for **${target_channel.name}**.`), + ], + components: [ + new Discord.ActionRowBuilder().addComponents( + delete_button, + ), + ], + allowedMentions: { parse: [] }, + }; + } + + const entries = newest_first.map(e => { + const name = e.display_name || e.username || e.user_id; + let move = ""; + if (e.other_channel_id != null) { + move = e.kind === "join" ? ` • from <#${e.other_channel_id}>` : ` • to <#${e.other_channel_id}>`; + } + const kind = e.kind === "join" ? "🟩 **JOIN**" : "🟄 **LEAVE**"; + const when = `${discord_timestamp(e.at_ms, "f")} (${discord_timestamp(e.at_ms, "T")})`; + + // Two-line layout for easier scanning. + // Note: `allowedMentions: { parse: [] }` keeps the mention clickable without pinging. + return [`${kind} — ${when}`, `**${name}** (\`${e.username}\`) • <@${e.user_id}>${move}`].join("\n"); + }); + + const pages = Math.ceil(entries.length / JOIN_HISTORY_PAGE_SIZE); + const clamped_page = Math.min(Math.max(page, 0), pages - 1); + const page_entries = entries.slice( + clamped_page * JOIN_HISTORY_PAGE_SIZE, + clamped_page * JOIN_HISTORY_PAGE_SIZE + JOIN_HISTORY_PAGE_SIZE, + ); + const separator = "\n━━━━━━━━━━━━━━━━━━━━\n"; + + const embed = new Discord.EmbedBuilder() + .setColor(colors.wheatley) + .setTitle( + pages > 1 + ? `Voice log for ${target_channel.name} (page ${clamped_page + 1} of ${pages})` + : `Voice log for ${target_channel.name}`, + ) + .setDescription(page_entries.join(separator)) + .setFooter({ + text: `${entries.length} event${entries.length === 1 ? "" : "s"} shown (max ${effective_n})`, + }); + + const page_buttons: Discord.ButtonBuilder[] = []; + if (pages > 1 && clamped_page > 1) { + page_buttons.push( + this.voice_log_page_button + .create_button(target_channel.id, effective_n, 0, issuer_id) + .setLabel("Start") + .setStyle(Discord.ButtonStyle.Secondary), + ); + } + if (pages > 1 && clamped_page > 0) { + page_buttons.push( + this.voice_log_page_button + .create_button(target_channel.id, effective_n, clamped_page - 1, issuer_id) + .setLabel("Previous") + .setStyle(Discord.ButtonStyle.Primary), + ); + } + if (pages > 1 && clamped_page < pages - 1) { + page_buttons.push( + this.voice_log_page_button + .create_button(target_channel.id, effective_n, clamped_page + 1, issuer_id) + .setLabel("Next") + .setStyle(Discord.ButtonStyle.Primary), + ); + } + + const buttons = [...page_buttons, delete_button]; + + return { + embeds: [embed], + components: + buttons.length > 0 + ? [ + new Discord.ActionRowBuilder().addComponents( + ...buttons, + ), + ] + : undefined, + allowedMentions: { parse: [] }, + }; + } + + private async handle_log(command: TextBasedCommand, channel: Discord.Channel | null, n: number | null) { + const guild = await command.get_guild(); + + let target_channel: Discord.VoiceBasedChannel | null = null; + + if (channel) { + if (!channel.isVoiceBased()) { + await command.reply(create_error_reply("Error: `channel` must be a voice channel or stage channel")); + return; + } + target_channel = channel; + } else { + const member = await command.get_member(guild); + target_channel = member.voice.channel; + if (!target_channel) { + await command.reply(create_error_reply("Error: you must specify `channel` or be in a voice channel")); + return; + } + } + + const requested_n = n ?? 1; + if (n !== null) { + if (!Number.isInteger(n) || n < 1) { + await command.reply(create_error_reply("Error: if provided, `n` must be at least 1")); + return; + } + } + const effective_n = Math.min(requested_n, JOIN_HISTORY_MAX_N_OUTPUT); + + await command.reply(this.build_log_message(target_channel, effective_n, 0, command.user.id)); + } + + private async handle_dev_voice_bounce( + command: TextBasedCommand, + first: Discord.Channel, + second: Discord.Channel, + count: number, + user: Discord.User | null, + ) { + const task_id = command.get_command_invocation_snowflake(); + try { + if (!this.wheatley.devmode_enabled) { + await command.replyOrFollowUp( + create_error_reply("Error: this command is only available in dev mode"), + true, + ); + return; + } + if (!first.isVoiceBased() || !second.isVoiceBased()) { + await command.replyOrFollowUp( + create_error_reply("Error: both channels must be voice channels or stage channels"), + true, + ); + return; + } + if (first.id === second.id) { + await command.replyOrFollowUp(create_error_reply("Error: channels must be different"), true); + return; + } + if (!Number.isInteger(count) || count < 1 || count > DEV_VOICE_BOUNCE_MAX_COUNT) { + await command.replyOrFollowUp( + create_error_reply(`Error: count must be an integer from 1 to ${DEV_VOICE_BOUNCE_MAX_COUNT}`), + true, + ); + return; + } + + const target_user = user ?? command.user; + const target_member = await this.wheatley.try_fetch_guild_member(target_user); + if (!target_member) { + await command.replyOrFollowUp(create_error_reply("Error: target user is not in the server"), true); + return; + } + if (!target_member.voice.channel) { + await command.replyOrFollowUp( + create_error_reply("Error: target user must already be connected to a voice channel"), + true, + ); + return; + } + + let completed_round_trips = 0; + const bounce_context: dev_voice_bounce_context = { + task_id, + issuer_id: command.user.id, + target_member, + first, + second, + count, + }; + this.dev_voice_bounce_tasks.set(task_id, { keep_running: true }); + await command.replyOrFollowUp( + this.build_dev_voice_bounce_message(bounce_context, completed_round_trips, "Running...", true), + true, + ); + + for (let i = 0; i < count; i++) { + if (!this.dev_voice_bounce_tasks.get(task_id)?.keep_running) { + break; + } + await target_member.voice.setChannel(first); + if (!this.dev_voice_bounce_tasks.get(task_id)?.keep_running) { + break; + } + await target_member.voice.setChannel(second); + completed_round_trips++; + const keep_running = this.dev_voice_bounce_tasks.get(task_id)?.keep_running ?? false; + await command.edit( + this.build_dev_voice_bounce_message( + bounce_context, + completed_round_trips, + keep_running ? "Running..." : "Stopping...", + keep_running, + ), + ); + } + + const stopped_early = !this.dev_voice_bounce_tasks.get(task_id)?.keep_running; + this.dev_voice_bounce_tasks.remove(task_id); + await command.edit( + this.build_dev_voice_bounce_message( + bounce_context, + completed_round_trips, + stopped_early ? "Stopped" : "Finished", + false, + ), + ); + } catch (e) { + this.dev_voice_bounce_tasks.remove(task_id); + await command.replyOrFollowUp(create_error_reply(`Error: ${e}`), true); + } + } + + private build_dev_voice_bounce_message( + context: dev_voice_bounce_context, + completed_round_trips: number, + status: string, + active: boolean, + ): Discord.BaseMessageOptions & CommandAbstractionReplyOptions { + const { task_id, issuer_id, target_member, first, second, count } = context; + return { + embeds: [ + new Discord.EmbedBuilder() + .setColor(colors.wheatley) + .setTitle("Dev voice bounce") + .setDescription( + [ + `Target: <@${target_member.id}>`, + `Route: <#${first.id}> -> <#${second.id}>`, + `Progress: ${completed_round_trips}/${count} round trip${count === 1 ? "" : "s"}`, + ].join("\n"), + ) + .setFooter({ text: status }), + ], + components: active + ? [ + new Discord.ActionRowBuilder().addComponents( + this.dev_voice_bounce_stop_button + .create_button(task_id, issuer_id) + .setLabel("Stop") + .setStyle(Discord.ButtonStyle.Danger), + ), + ] + : [], + allowedMentions: { parse: [] }, + }; + } + + private async handle_dev_voice_bounce_stop( + interaction: Discord.ButtonInteraction, + task_id: string, + issuer_id: string, + ) { + if (interaction.user.id !== issuer_id) { + const { embeds } = create_error_reply("Only the command issuer can stop this task."); + await interaction.reply({ + embeds, + ephemeral: true, + }); + return; + } + const task = this.dev_voice_bounce_tasks.get(task_id); + if (!task) { + const { embeds } = create_error_reply("This task is no longer running."); + await interaction.reply({ + embeds, + ephemeral: true, + }); + return; + } + task.keep_running = false; + await interaction.update({ + components: [ + new Discord.ActionRowBuilder().addComponents( + this.dev_voice_bounce_stop_button + .create_button(task_id, issuer_id) + .setLabel("Stopping...") + .setStyle(Discord.ButtonStyle.Secondary) + .setDisabled(true), + ), + ], + }); + } + + private async handle_log_page( + interaction: Discord.ButtonInteraction, + channel_id: string, + n: number, + page: number, + issuer_id: string, + ) { + try { + if (interaction.user.id !== issuer_id) { + const { embeds } = create_error_reply("Only the command issuer can use these controls."); + await interaction.reply({ + ephemeral: true, + embeds, + }); + return; + } + + // Acknowledge quickly to avoid "This interaction failed" on slower API calls. + await interaction.deferUpdate(); + + const channel = await this.wheatley.guild.channels.fetch(channel_id); + if (!channel?.isVoiceBased()) { + const { embeds } = create_error_reply("Error: voice channel no longer exists"); + await interaction.followUp({ ephemeral: true, embeds }); + return; + } + + const effective_n = Math.min(Math.max(1, Math.floor(n)), JOIN_HISTORY_MAX_N_OUTPUT); + await interaction.message.edit(this.build_log_message(channel, effective_n, page, issuer_id)); + } catch (e) { + const { embeds } = create_error_reply(`Error: ${e}`); + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ ephemeral: true, embeds }); + } else { + await interaction.reply({ ephemeral: true, embeds }); + } + } + } + + private async handle_delete_log(interaction: Discord.ButtonInteraction, issuer_id: string) { + if (interaction.user.id !== issuer_id) { + const { embeds } = create_error_reply("Only the command issuer can delete this log."); + await interaction.reply({ + ephemeral: true, + embeds, + }); + return; + } + try { + await interaction.deferUpdate(); + await interaction.message.delete(); + } catch (e) { + const { embeds } = create_error_reply(`Error: ${e}`); + // `deferUpdate()` means we must follow-up on failure. + await interaction.followUp({ ephemeral: true, embeds }); + } + } +} diff --git a/src/modules/wheatley/components/wiki.ts b/src/modules/wheatley/components/wiki.ts index 51e365ac..d377ff5c 100644 --- a/src/modules/wheatley/components/wiki.ts +++ b/src/modules/wheatley/components/wiki.ts @@ -641,12 +641,14 @@ export default class Wiki extends BotComponent { async wiki_preview(command: TextBasedCommand, content: string) { const channel = await command.get_channel(); - if (!( - this.wheatley.freestanding || - channel.id === this.channels.bot_spam.id || - (channel.isThread() && channel.parentId === this.channels.bot_spam.id) || - channel.isDMBased() - )) { + if ( + !( + this.wheatley.freestanding || + channel.id === this.channels.bot_spam.id || + (channel.isThread() && channel.parentId === this.channels.bot_spam.id) || + channel.isDMBased() + ) + ) { await command.reply( `!wiki-preview must be used in <#${this.channels.bot_spam.id}>, a bot-spam thread, or a DM`, true,