fix: apply Discord voice channel prompts

This commit is contained in:
Peter Steinberger
2026-05-01 11:19:10 +01:00
parent 15adc741ff
commit 3585d3e226
6 changed files with 53 additions and 6 deletions

View File

@@ -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-<version>-<hash>` 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.

View File

@@ -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`.

View File

@@ -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 });
});
});

View File

@@ -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." };
}

View File

@@ -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({

View File

@@ -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,