From ea9602dce3e2df7eb366f83ea3b3a160ca01ca41 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 9 Feb 2026 03:35:16 -0500 Subject: [PATCH 1/2] Add context menu to stop auto-recording --- src/bot.ts | 17 ++ src/commands/dismissAutoRecord.ts | 146 ++++++++++++++++++ src/commands/endMeeting.ts | 17 +- src/commands/startMeeting.ts | 2 +- src/config/keys.ts | 4 + src/config/registry.ts | 17 ++ src/frontend/components/ConfigValueField.tsx | 20 ++- src/types/meetingLifecycle.ts | 1 + src/utils/meetingLifecycle.ts | 1 + test/commands/endMeeting.test.ts | 83 ++++++++++ ...tings-experimental-full-chromium-win32.png | Bin 332887 -> 332895 bytes .../settings-full-chromium-win32.png | Bin 250036 -> 250041 bytes 12 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 src/commands/dismissAutoRecord.ts diff --git a/src/bot.ts b/src/bot.ts index d6000b19..34beed0c 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -112,6 +112,11 @@ import { MEETING_START_REASONS, type AutoRecordRule, } from "./types/meetingLifecycle"; +import { + DISMISS_AUTORECORD_COMMAND_NAME, + dismissAutoRecordCommand, + handleDismissAutoRecord, +} from "./commands/dismissAutoRecord"; const TOKEN = config.discord.botToken; const CLIENT_ID = config.discord.clientId; @@ -336,6 +341,17 @@ const handleInteractionCreate = async (interaction: RepliableInteraction) => { await handleCommandInteraction(interaction); return; } + if (interaction.isUserContextMenuCommand()) { + if (interaction.commandName === DISMISS_AUTORECORD_COMMAND_NAME) { + await handleDismissAutoRecord(client, interaction); + return; + } + await interaction.reply({ + content: "Unknown context menu command.", + ephemeral: true, + }); + return; + } if (interaction.isModalSubmit()) { await handleModalInteraction(interaction); return; @@ -1038,6 +1054,7 @@ async function setupApplicationCommands() { .setName("clear") .setDescription("Remove all dictionary entries for this server"), ), + dismissAutoRecordCommand, ]; if (config.server.onboardingEnabled) { diff --git a/src/commands/dismissAutoRecord.ts b/src/commands/dismissAutoRecord.ts new file mode 100644 index 00000000..ee7743bf --- /dev/null +++ b/src/commands/dismissAutoRecord.ts @@ -0,0 +1,146 @@ +import { + ApplicationCommandType, + ContextMenuCommandBuilder, + PermissionFlagsBits, + type Client, + type UserContextMenuCommandInteraction, +} from "discord.js"; +import { CONFIG_KEYS } from "../config/keys"; +import { getMeeting } from "../meetings"; +import { resolveConfigEnum } from "../services/unifiedConfigService"; +import { MEETING_END_REASONS } from "../types/meetingLifecycle"; +import { handleEndMeetingOther } from "./endMeeting"; + +export const DISMISS_AUTORECORD_COMMAND_NAME = "Stop recording"; + +const DISMISS_POLICY_OPTIONS = [ + "solo_or_admin", + "trigger_or_admin", + "anyone_in_channel", +] as const; + +type DismissPolicy = (typeof DISMISS_POLICY_OPTIONS)[number]; + +const DEFAULT_DISMISS_POLICY: DismissPolicy = "solo_or_admin"; + +export const dismissAutoRecordCommand = new ContextMenuCommandBuilder() + .setName(DISMISS_AUTORECORD_COMMAND_NAME) + .setType(ApplicationCommandType.User) + .setDMPermission(false); + +function hasAdminPermissions(interaction: UserContextMenuCommandInteraction) { + return ( + interaction.memberPermissions?.any([ + PermissionFlagsBits.ModerateMembers, + PermissionFlagsBits.Administrator, + PermissionFlagsBits.ManageMessages, + ]) ?? false + ); +} + +export async function handleDismissAutoRecord( + client: Client, + interaction: UserContextMenuCommandInteraction, +) { + if (!interaction.inGuild()) { + await interaction.reply({ + content: "This command can only be used in a server.", + ephemeral: true, + }); + return; + } + + const botUserId = client.user?.id; + if (!botUserId) { + await interaction.reply({ + content: "Bot is not ready yet.", + ephemeral: true, + }); + return; + } + + if (interaction.targetUser.id !== botUserId) { + await interaction.reply({ + content: `Use this command on <@${botUserId}>.`, + ephemeral: true, + }); + return; + } + + const meeting = getMeeting(interaction.guildId); + if (!meeting) { + await interaction.reply({ + content: "No active recording to stop right now.", + ephemeral: true, + }); + return; + } + + if (!meeting.isAutoRecording) { + await interaction.reply({ + content: "This command only applies to auto-recorded meetings.", + ephemeral: true, + }); + return; + } + + if (meeting.finishing) { + await interaction.reply({ + content: "This meeting is already ending.", + ephemeral: true, + }); + return; + } + + const invokerId = interaction.user.id; + const invokerMember = meeting.voiceChannel.members.get(invokerId); + if (!invokerMember) { + await interaction.reply({ + content: "Join the meeting voice channel to stop recording.", + ephemeral: true, + }); + return; + } + + const admin = hasAdminPermissions(interaction); + const nonBotMembers = meeting.voiceChannel.members.filter( + (member) => !member.user.bot, + ); + const soloNonBot = nonBotMembers.size === 1 && nonBotMembers.has(invokerId); + + const policy = + (await resolveConfigEnum( + { guildId: interaction.guildId }, + CONFIG_KEYS.autorecord.dismissPolicy, + DISMISS_POLICY_OPTIONS, + DEFAULT_DISMISS_POLICY, + { logLabel: "Failed to resolve auto-record dismiss policy" }, + )) ?? DEFAULT_DISMISS_POLICY; + + const allowedByPolicy = + policy === "anyone_in_channel" || + (policy === "trigger_or_admin" && + meeting.startTriggeredByUserId === invokerId); + + if (!(admin || soloNonBot || allowedByPolicy)) { + const policyHint = + policy === "trigger_or_admin" + ? "Ask an admin, or the user who triggered auto-record." + : "Ask an admin."; + await interaction.reply({ + content: `You do not have permission to stop this auto-record. ${policyHint}`, + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + meeting.endReason = MEETING_END_REASONS.DISMISSED; + meeting.endTriggeredByUserId = invokerId; + meeting.cancelled = true; + meeting.cancellationReason = `Stopped by <@${invokerId}>`; + + await handleEndMeetingOther(client, meeting); + await interaction.editReply("Stopped recording."); +} diff --git a/src/commands/endMeeting.ts b/src/commands/endMeeting.ts index c10d5366..c08fc9dd 100644 --- a/src/commands/endMeeting.ts +++ b/src/commands/endMeeting.ts @@ -192,6 +192,16 @@ async function runEndMeetingFlow(options: EndMeetingFlowOptions) { closeOutputFile(meeting), ); + if (meeting.cancelled) { + await runMeetingEndStep(meeting, "auto-record-cancel-flow", () => + handleAutoRecordCancellation(meeting, chatLogFilePath), + ); + await runMeetingEndStep(meeting, "cleanup-speaker-tracks", () => + cleanupSpeakerTracks(meeting), + ); + return; + } + const cancellationDecision = await runMeetingEndStep( meeting, "auto-record-cancellation", @@ -326,7 +336,8 @@ function maybeSuppressAutoRecordRejoin( if ( endReason !== MEETING_END_REASONS.BUTTON && endReason !== MEETING_END_REASONS.WEB_UI && - endReason !== MEETING_END_REASONS.LIVE_VOICE + endReason !== MEETING_END_REASONS.LIVE_VOICE && + endReason !== MEETING_END_REASONS.DISMISSED ) { return; } @@ -391,9 +402,7 @@ async function updateAutoRecordCancelledMessage(meeting: MeetingData) { const embed = new EmbedBuilder() .setTitle("Auto-Recording Cancelled") - .setDescription( - "Auto-recording started and was cancelled due to lack of content.", - ) + .setDescription("Auto-recording started and was cancelled.") .addFields( { name: "Triggered by", value: triggerLabel }, { name: "Rule", value: ruleLabel }, diff --git a/src/commands/startMeeting.ts b/src/commands/startMeeting.ts index ba6a15b6..28448655 100644 --- a/src/commands/startMeeting.ts +++ b/src/commands/startMeeting.ts @@ -420,7 +420,7 @@ export async function handleAutoStartMeeting( .addFields({ name: "Tip", value: - 'Right click the bot in voice and choose "Disconnect" to end the meeting.', + 'Right click Chronote in voice and choose "Stop recording" to stop recording.', }) .setColor(0xff0000) .setTimestamp(); diff --git a/src/config/keys.ts b/src/config/keys.ts index 321bf277..f53a22ce 100644 --- a/src/config/keys.ts +++ b/src/config/keys.ts @@ -51,9 +51,13 @@ export const CONFIG_KEYS = { channelId: "notes.channelId", tags: "notes.tags", }, + meetings: { + attendeeAccessEnabled: "meetings.attendeeAccess.enabled", + }, autorecord: { enabled: "autorecord.enabled", cancelEnabled: "autorecord.cancel.enabled", + dismissPolicy: "autorecord.dismiss.policy", }, liveVoice: { enabled: "liveVoice.enabled", diff --git a/src/config/registry.ts b/src/config/registry.ts index 4f83e240..088710df 100644 --- a/src/config/registry.ts +++ b/src/config/registry.ts @@ -728,6 +728,23 @@ export const CONFIG_REGISTRY: ConfigEntry[] = [ }, ui: { type: "toggle" }, }, + { + key: "autorecord.dismiss.policy", + label: "Auto-record dismiss policy", + description: + "Controls who can dismiss an auto-recorded meeting from the voice channel context menu.", + category: "Auto-record", + group: "Advanced", + valueType: "select", + defaultValue: "solo_or_admin", + scopes: { + server: scope(true, true, "admin", "select"), + }, + ui: { + type: "select", + options: ["solo_or_admin", "trigger_or_admin", "anyone_in_channel"], + }, + }, { key: "liveVoice.enabled", label: "Live voice responder", diff --git a/src/frontend/components/ConfigValueField.tsx b/src/frontend/components/ConfigValueField.tsx index 30e67cb0..cdd7f13a 100644 --- a/src/frontend/components/ConfigValueField.tsx +++ b/src/frontend/components/ConfigValueField.tsx @@ -11,6 +11,7 @@ import type { ConfigEntryInput } from "../types/configEntry"; import FormSelect from "./FormSelect"; import type { ChannelOption } from "../utils/settingsChannels"; import { clampNumberValue, resolveNumberRange } from "../../config/validation"; +import { CONFIG_KEYS } from "../../config/keys"; type ConfigValueFieldProps = { entry: ConfigEntryInput; @@ -50,6 +51,16 @@ const formatOptionLabel = (option: string) => .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); +const formatSelectOptionLabelForKey = (key: string, option: string) => { + if (key === CONFIG_KEYS.autorecord.dismissPolicy) { + if (option === "solo_or_admin") return "Solo or admin"; + if (option === "trigger_or_admin") return "Trigger or admin"; + if (option === "anyone_in_channel") return "Anyone in channel"; + } + + return option; +}; + const AskSharingPolicySegment: CustomRenderer = ({ entry, value, @@ -268,7 +279,14 @@ export function ConfigValueField({ return (