diff --git a/CHANGELOG.md b/CHANGELOG.md index 2377f696583..584dcf42c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -325,6 +325,7 @@ Docs: https://docs.openclaw.ai - Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan. - Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot. - Discord/DM session-key normalization: rewrite legacy `discord:dm:*` and phantom direct-message `discord:channel:` session keys to `discord:direct:*` when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly. +- Discord/native slash session fallback: treat empty configured bound-session keys as missing so `/status` and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings. ## 2026.3.2 diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts index c7e81afe298..bcb6be36c21 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -230,6 +230,61 @@ describe("Discord native plugin command dispatch", () => { expectBoundSessionDispatch(dispatchSpy, boundSessionKey); }); + it("falls back to the routed slash and channel session keys when no bound session exists", async () => { + const guildId = "1459246755253325866"; + const channelId = "1478836151241412759"; + const cfg = { + commands: { + useAccessGroups: false, + }, + bindings: [ + { + agentId: "qwen", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: channelId }, + guildId, + }, + }, + ], + channels: { + discord: { + guilds: { + [guildId]: { + channels: { + [channelId]: { allow: true, requireMention: false }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + const command = createStatusCommand(cfg); + const interaction = createInteraction({ + channelType: ChannelType.GuildText, + channelId, + guildId, + guildName: "Ops", + }); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { + ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; + }; + expect(dispatchCall.ctx?.SessionKey).toBe("agent:qwen:discord:slash:owner"); + expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe( + "agent:qwen:discord:channel:1478836151241412759", + ); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + }); + it("routes Discord DM native slash commands through configured ACP bindings", async () => { const channelId = "dm-1"; const boundSessionKey = "agent:codex:acp:binding:discord:default:dmfeedface"; diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 1ac9d582a86..71c3008f88d 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -1644,7 +1644,7 @@ async function dispatchDiscordCommandInteraction(params: { return; } } - const configuredBoundSessionKey = configuredRoute?.boundSessionKey ?? ""; + const configuredBoundSessionKey = configuredRoute?.boundSessionKey?.trim() || undefined; const boundSessionKey = threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey; const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; const effectiveRoute = boundSessionKey