From 3585d3e22656c682585ff36707f90fb46769a1e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 11:19:10 +0100 Subject: [PATCH] fix: apply Discord voice channel prompts --- CHANGELOG.md | 1 + docs/channels/discord.md | 1 + extensions/discord/src/voice/access.test.ts | 9 +++-- extensions/discord/src/voice/access.ts | 7 +++- .../discord/src/voice/manager.e2e.test.ts | 38 +++++++++++++++++++ extensions/discord/src/voice/segment.ts | 3 ++ 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ecfdcf2738..89a27186832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao. - Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram. - Plugins/runtime-deps: prune inactive same-package versioned runtime-deps roots after bundled dependency repair, so upgrades do not leave old `openclaw--` package caches behind after doctor runs. Thanks @vincentkoc. - Plugins/runtime-deps: prune legacy version-scoped plugin runtime-deps roots during bundled dependency repair and cover the path in Package Acceptance's upgrade-survivor matrix, so upgrades from 2026.4.x no longer leave stale per-plugin runtime trees after doctor runs. Thanks @vincentkoc. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e0883caa895..f4b365ebcc3 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1063,6 +1063,7 @@ Notes: - `voice.tts` overrides `messages.tts` for voice playback only. - `voice.model` overrides the LLM used for Discord voice channel responses only. Leave it unset to inherit the routed agent model. - STT uses `tools.media.audio`; `voice.model` does not affect transcription. +- Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel. - Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`). - Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable voice runtime and the `GuildVoiceStates` gateway intent. - `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow `voice.enabled`. diff --git a/extensions/discord/src/voice/access.test.ts b/extensions/discord/src/voice/access.test.ts index 3250c322309..dbcfb117649 100644 --- a/extensions/discord/src/voice/access.test.ts +++ b/extensions/discord/src/voice/access.test.ts @@ -62,7 +62,8 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); + expect(access.ok && access.channelConfig?.users).toEqual(["discord:u-owner"]); }); it("allows slug-keyed guild configs when manager context only has guild name", async () => { @@ -91,7 +92,7 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); }); it("allows wildcard guild configs when only the guild id is available", async () => { @@ -119,7 +120,7 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); }); it("blocks commands when channel id is unavailable for an allowlisted channel", async () => { @@ -211,6 +212,6 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); }); }); diff --git a/extensions/discord/src/voice/access.ts b/extensions/discord/src/voice/access.ts index ad07267ebfd..fa895b5685b 100644 --- a/extensions/discord/src/voice/access.ts +++ b/extensions/discord/src/voice/access.ts @@ -6,6 +6,7 @@ import type { Guild } from "../internal/discord.js"; import { isDiscordGroupAllowedByPolicy, resolveDiscordChannelConfigWithFallback, + type DiscordChannelConfigResolved, resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, @@ -30,7 +31,9 @@ export async function authorizeDiscordVoiceIngress(params: { memberRoleIds: string[]; ownerAllowFrom?: string[]; sender: { id: string; name?: string; tag?: string }; -}): Promise<{ ok: true } | { ok: false; message: string }> { +}): Promise< + { ok: true; channelConfig?: DiscordChannelConfigResolved | null } | { ok: false; message: string } +> { const groupPolicy = params.groupPolicy ?? resolveOpenProviderRuntimeGroupPolicy({ @@ -116,6 +119,6 @@ export async function authorizeDiscordVoiceIngress(params: { authorizers, modeWhenAccessGroupsOff: "configured", }) - ? { ok: true } + ? { ok: true, channelConfig } : { ok: false, message: "You are not authorized to use this command." }; } diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 2aeeda93c69..09079a2a33a 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -574,6 +574,44 @@ describe("DiscordVoiceManager", () => { ); }); + it("passes per-channel system prompt overrides to voice agent runs", async () => { + const client = createClient(); + client.fetchMember.mockResolvedValue({ + nickname: "Guest Nick", + user: { + id: "u-guest", + username: "guest", + globalName: "Guest", + discriminator: "4321", + }, + }); + const manager = createManager( + { + groupPolicy: "open", + guilds: { + g1: { + channels: { + "1001": { + systemPrompt: " Use short voice replies. ", + }, + }, + }, + }, + }, + client, + { + commands: { useAccessGroups: false }, + }, + ); + await processVoiceSegment(manager, "u-guest"); + + const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as + | { extraSystemPrompt?: string } + | undefined; + + expect(commandArgs?.extraSystemPrompt).toBe("Use short voice replies."); + }); + it("reuses speaker context cache for repeated segments from the same speaker", async () => { const client = createClient(); client.fetchMember.mockResolvedValue({ diff --git a/extensions/discord/src/voice/segment.ts b/extensions/discord/src/voice/segment.ts index 718db850690..68ee775231a 100644 --- a/extensions/discord/src/voice/segment.ts +++ b/extensions/discord/src/voice/segment.ts @@ -6,6 +6,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { formatMention } from "../mentions.js"; import { normalizeDiscordSlug } from "../monitor/allow-list.js"; +import { buildDiscordGroupSystemPrompt } from "../monitor/inbound-context.js"; import { authorizeDiscordVoiceIngress } from "./access.js"; import { formatVoiceIngressPrompt } from "./prompt.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; @@ -82,6 +83,7 @@ export async function processDiscordVoiceSegment(params: { ); const prompt = formatVoiceIngressPrompt(transcript, speaker.label); + const extraSystemPrompt = buildDiscordGroupSystemPrompt(access.channelConfig); const modelOverride = normalizeOptionalString(params.discordConfig.voice?.model); const result = await agentCommandFromIngress( @@ -91,6 +93,7 @@ export async function processDiscordVoiceSegment(params: { agentId: entry.route.agentId, messageChannel: "discord", messageProvider: DISCORD_VOICE_MESSAGE_PROVIDER, + extraSystemPrompt, senderIsOwner: speaker.senderIsOwner, allowModelOverride: Boolean(modelOverride), model: modelOverride,