fix(discord): prefer latest voice auto-join channel

This commit is contained in:
Peter Steinberger
2026-05-08 05:35:04 +01:00
parent 1f88cb2ce5
commit 8dcc2ff1d2
4 changed files with 44 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip.
- Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11.
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
- Discord/voice: make duplicate same-guild auto-join entries resolve to the last configured channel so moving an agent between voice channels does not keep joining the stale channel.
- Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply.
- Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/codex@0.129.0`, defer OpenClaw integration dynamic tools behind Codex tool search by default, and accept current Codex service-tier values so legacy `fast` settings survive the stable harness upgrade as `priority`.
- Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes.

View File

@@ -1206,6 +1206,7 @@ Notes:
- 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`).
- Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the 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 effective voice enablement.
- If `voice.autoJoin` has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild.
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
- `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`.

View File

@@ -389,6 +389,29 @@ describe("DiscordVoiceManager", () => {
expectConnectedStatus(manager, "1001");
});
it("autoJoin uses the last configured channel for duplicate guild entries", async () => {
const manager = createManager({
voice: {
enabled: true,
autoJoin: [
{ guildId: "g1", channelId: "1001" },
{ guildId: "g1", channelId: "1002" },
],
},
});
await manager.autoJoin();
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
expect(joinVoiceChannelMock).toHaveBeenCalledWith(
expect.objectContaining({
guildId: "g1",
channelId: "1002",
}),
);
expectConnectedStatus(manager, "1002");
});
it("does not throw when stale tracked voice connections are already destroyed", async () => {
const staleConnection = createConnectionMock();
staleConnection.state.status = "destroyed";

View File

@@ -134,21 +134,32 @@ export class DiscordVoiceManager {
}
this.autoJoinTask = (async () => {
const entries = this.params.discordConfig.voice?.autoJoin ?? [];
logVoiceVerbose(`autoJoin: ${entries.length} entries`);
const seenGuilds = new Set<string>();
const entriesByGuild = new Map<string, { guildId: string; channelId: string }>();
const duplicateGuilds = new Set<string>();
for (const entry of entries) {
const guildId = entry.guildId.trim();
if (!guildId) {
const channelId = entry.channelId.trim();
if (!guildId || !channelId) {
continue;
}
if (seenGuilds.has(guildId)) {
if (entriesByGuild.has(guildId)) {
duplicateGuilds.add(guildId);
}
entriesByGuild.set(guildId, { guildId, channelId });
}
logVoiceVerbose(`autoJoin: ${entries.length} entries, ${entriesByGuild.size} guilds`);
for (const guildId of duplicateGuilds) {
const selected = entriesByGuild.get(guildId);
if (selected) {
logger.warn(
`discord voice: autoJoin has multiple entries for guild ${guildId}; skipping`,
`discord voice: autoJoin has multiple entries for guild ${guildId}; using channel ${selected.channelId}`,
);
continue;
}
seenGuilds.add(guildId);
logVoiceVerbose(`autoJoin: joining guild ${guildId} channel ${entry.channelId}`);
}
for (const entry of entriesByGuild.values()) {
logVoiceVerbose(`autoJoin: joining guild ${entry.guildId} channel ${entry.channelId}`);
await this.join({
guildId: entry.guildId,
channelId: entry.channelId,