From 8dcc2ff1d2402bb65e7c1b0b3026b68e0951fc08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 05:35:04 +0100 Subject: [PATCH] fix(discord): prefer latest voice auto-join channel --- CHANGELOG.md | 1 + docs/channels/discord.md | 1 + .../discord/src/voice/manager.e2e.test.ts | 23 ++++++++++++++++ extensions/discord/src/voice/manager.ts | 27 +++++++++++++------ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27551655a27..66826cb9dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index de3a0d853a0..aec9b12eaba 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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`. diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 667983d1fff..c8108019654 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -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"; diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index f429bf38807..426dd406a13 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -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(); + const entriesByGuild = new Map(); + const duplicateGuilds = new Set(); 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,