From 77a8a253a98b41ebd76479a7970e5f2b0062370c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 22:15:17 +0000 Subject: [PATCH] refactor(discord): dedupe voice command runtime checks --- src/discord/voice/command.test.ts | 99 ++++++++++++++++++++++++++++ src/discord/voice/command.ts | 103 +++++++++++++++++++----------- 2 files changed, 165 insertions(+), 37 deletions(-) create mode 100644 src/discord/voice/command.test.ts diff --git a/src/discord/voice/command.test.ts b/src/discord/voice/command.test.ts new file mode 100644 index 00000000000..8d3dc5f5a88 --- /dev/null +++ b/src/discord/voice/command.test.ts @@ -0,0 +1,99 @@ +import type { CommandInteraction, CommandWithSubcommands } from "@buape/carbon"; +import { describe, expect, it, vi } from "vitest"; +import { createDiscordVoiceCommand } from "./command.js"; +import type { DiscordVoiceManager } from "./manager.js"; + +function findVoiceSubcommand(command: CommandWithSubcommands, name: string) { + const subcommands = ( + command as unknown as { subcommands?: Array<{ name: string; run: unknown }> } + ).subcommands; + const subcommand = subcommands?.find((entry) => entry.name === name) as + | { run: (interaction: CommandInteraction) => Promise } + | undefined; + if (!subcommand) { + throw new Error(`Missing vc ${name} subcommand`); + } + return subcommand; +} + +function createVoiceCommandHarness(manager: DiscordVoiceManager | null = null) { + const command = createDiscordVoiceCommand({ + cfg: {}, + discordConfig: {}, + accountId: "default", + groupPolicy: "open", + useAccessGroups: false, + getManager: () => manager, + ephemeralDefault: true, + }); + return { + command, + leave: findVoiceSubcommand(command, "leave"), + status: findVoiceSubcommand(command, "status"), + }; +} + +function createInteraction(overrides?: Partial): { + interaction: CommandInteraction; + reply: ReturnType; +} { + const reply = vi.fn(async () => undefined); + const interaction = { + guild: undefined, + user: { id: "u1", username: "tester" }, + rawData: { member: { roles: [] } }, + reply, + ...overrides, + } as unknown as CommandInteraction; + return { interaction, reply }; +} + +describe("createDiscordVoiceCommand", () => { + it("vc leave reports missing guild before manager lookup", async () => { + const { leave } = createVoiceCommandHarness(null); + const { interaction, reply } = createInteraction(); + + await leave.run(interaction); + + expect(reply).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ + content: "Unable to resolve guild for this command.", + ephemeral: true, + }); + }); + + it("vc status reports unavailable voice manager", async () => { + const { status } = createVoiceCommandHarness(null); + const { interaction, reply } = createInteraction({ + guild: { id: "g1" } as CommandInteraction["guild"], + }); + + await status.run(interaction); + + expect(reply).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ + content: "Voice manager is not available yet.", + ephemeral: true, + }); + }); + + it("vc status reports no active sessions when manager has none", async () => { + const statusSpy = vi.fn(() => []); + const manager = { + status: statusSpy, + } as unknown as DiscordVoiceManager; + const { status } = createVoiceCommandHarness(manager); + const { interaction, reply } = createInteraction({ + guild: { id: "g1", name: "Guild" } as CommandInteraction["guild"], + }); + + await status.run(interaction); + + expect(statusSpy).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ + content: "No active voice sessions.", + ephemeral: true, + }); + }); +}); diff --git a/src/discord/voice/command.ts b/src/discord/voice/command.ts index dabfff10f1d..7731b903d0e 100644 --- a/src/discord/voice/command.ts +++ b/src/discord/voice/command.ts @@ -48,6 +48,11 @@ type VoiceCommandChannelOverride = { parentId?: string; }; +type VoiceCommandRuntimeContext = { + guildId: string; + manager: DiscordVoiceManager; +}; + async function authorizeVoiceCommand( interaction: CommandInteraction, params: VoiceCommandContext, @@ -185,6 +190,47 @@ async function authorizeVoiceCommand( return { ok: true, guildId: interaction.guild.id }; } +async function resolveVoiceCommandRuntimeContext( + interaction: CommandInteraction, + params: Pick, +): Promise { + const guildId = interaction.guild?.id; + if (!guildId) { + await interaction.reply({ + content: "Unable to resolve guild for this command.", + ephemeral: true, + }); + return null; + } + const manager = params.getManager(); + if (!manager) { + await interaction.reply({ + content: "Voice manager is not available yet.", + ephemeral: true, + }); + return null; + } + return { guildId, manager }; +} + +async function ensureVoiceCommandAccess(params: { + interaction: CommandInteraction; + context: VoiceCommandContext; + channelOverride?: VoiceCommandChannelOverride; +}): Promise { + const access = await authorizeVoiceCommand(params.interaction, params.context, { + channelOverride: params.channelOverride, + }); + if (access.ok) { + return true; + } + await params.interaction.reply({ + content: access.message ?? "Not authorized.", + ephemeral: true, + }); + return false; +} + export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandWithSubcommands { const resolveSessionChannelId = (manager: DiscordVoiceManager, guildId: string) => manager.status().find((entry) => entry.guildId === guildId)?.channelId; @@ -259,31 +305,23 @@ export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandW ephemeral = params.ephemeralDefault; async run(interaction: CommandInteraction) { - const guildId = interaction.guild?.id; - if (!guildId) { - await interaction.reply({ - content: "Unable to resolve guild for this command.", - ephemeral: true, - }); + const runtimeContext = await resolveVoiceCommandRuntimeContext(interaction, params); + if (!runtimeContext) { return; } - const manager = params.getManager(); - if (!manager) { - await interaction.reply({ - content: "Voice manager is not available yet.", - ephemeral: true, - }); - return; - } - const sessionChannelId = resolveSessionChannelId(manager, guildId); - const access = await authorizeVoiceCommand(interaction, params, { + const sessionChannelId = resolveSessionChannelId( + runtimeContext.manager, + runtimeContext.guildId, + ); + const authorized = await ensureVoiceCommandAccess({ + interaction, + context: params, channelOverride: sessionChannelId ? { id: sessionChannelId } : undefined, }); - if (!access.ok) { - await interaction.reply({ content: access.message ?? "Not authorized.", ephemeral: true }); + if (!authorized) { return; } - const result = await manager.leave({ guildId }); + const result = await runtimeContext.manager.leave({ guildId: runtimeContext.guildId }); await interaction.reply({ content: result.message, ephemeral: true }); } } @@ -295,29 +333,20 @@ export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandW ephemeral = params.ephemeralDefault; async run(interaction: CommandInteraction) { - const guildId = interaction.guild?.id; - if (!guildId) { - await interaction.reply({ - content: "Unable to resolve guild for this command.", - ephemeral: true, - }); + const runtimeContext = await resolveVoiceCommandRuntimeContext(interaction, params); + if (!runtimeContext) { return; } - const manager = params.getManager(); - if (!manager) { - await interaction.reply({ - content: "Voice manager is not available yet.", - ephemeral: true, - }); - return; - } - const sessions = manager.status().filter((entry) => entry.guildId === guildId); + const sessions = runtimeContext.manager + .status() + .filter((entry) => entry.guildId === runtimeContext.guildId); const sessionChannelId = sessions[0]?.channelId; - const access = await authorizeVoiceCommand(interaction, params, { + const authorized = await ensureVoiceCommandAccess({ + interaction, + context: params, channelOverride: sessionChannelId ? { id: sessionChannelId } : undefined, }); - if (!access.ok) { - await interaction.reply({ content: access.message ?? "Not authorized.", ephemeral: true }); + if (!authorized) { return; } if (sessions.length === 0) {