diff --git a/CHANGELOG.md b/CHANGELOG.md index e181da2be0c..c1acf36f617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana. - TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin. - Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model. +- Discord/ACP bindings: canonicalize DM conversation identity across inbound messages, component interactions, native commands, and current-conversation binding resolution so `--bind here` in Discord DMs keeps routing follow-up replies to the bound agent instead of falling back to the default agent. - iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman. - Nodes/exec approvals: keep `host=node` POSIX transport shell wrappers (`/bin/sh -lc ...`) aligned with inner-command allowlist analysis so allowlisted scripts stop prompting unnecessarily, while Windows `cmd.exe` wrapper runs stay approval-gated. (#62401) Thanks @ngutman. - Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman. diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 8dfe6a8a4d3..027f6cf424f 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -359,6 +359,20 @@ describe("discordPlugin outbound", () => { }); describe("discordPlugin bindings", () => { + it("derives DM current conversation ids from direct sender context", () => { + const result = discordPlugin.bindings?.resolveCommandConversation?.({ + accountId: "default", + chatType: "direct", + from: "discord:123456789012345678", + originatingTo: "channel:dm-channel-1", + fallbackTo: "channel:dm-channel-1", + }); + + expect(result).toEqual({ + conversationId: "user:123456789012345678", + }); + }); + it("preserves user-prefixed current conversation ids for DM binds", () => { const result = discordPlugin.bindings?.resolveCommandConversation?.({ accountId: "default", diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 828c2f317a9..5452f49d11e 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -42,6 +42,7 @@ import { type ChannelPlugin, type OpenClawConfig, } from "./channel-api.js"; +import { resolveDiscordCurrentConversationIdentity } from "./conversation-identity.js"; import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js"; import { resolveDiscordGroupRequireMention, @@ -357,6 +358,8 @@ function resolveDiscordCommandConversation(params: { threadId?: string; threadParentId?: string; parentSessionKey?: string; + from?: string; + chatType?: string; originatingTo?: string; commandTo?: string; fallbackTo?: string; @@ -374,7 +377,13 @@ function resolveDiscordCommandConversation(params: { : {}), }; } - const conversationId = resolveDiscordConversationIdFromTargets(targets); + const conversationId = resolveDiscordCurrentConversationIdentity({ + from: params.from, + chatType: params.chatType, + originatingTo: params.originatingTo, + commandTo: params.commandTo, + fallbackTo: params.fallbackTo, + }); return conversationId ? { conversationId } : null; } @@ -384,19 +393,13 @@ function resolveDiscordInboundConversation(params: { conversationId?: string; isGroup: boolean; }) { - const rawSender = params.from?.trim() || ""; - if (!params.isGroup && rawSender) { - const senderTarget = parseDiscordTarget(rawSender, { defaultKind: "user" }); - if (senderTarget?.kind === "user") { - return { conversationId: `user:${senderTarget.id}` }; - } - } - const rawTarget = params.to?.trim() || params.conversationId?.trim() || ""; - if (!rawTarget) { - return null; - } - const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" }); - return target ? { conversationId: `${target.kind}:${target.id}` } : null; + const conversationId = resolveDiscordCurrentConversationIdentity({ + from: params.from, + chatType: params.isGroup ? "group" : "direct", + originatingTo: params.to, + fallbackTo: params.conversationId, + }); + return conversationId ? { conversationId } : null; } function toConversationLifecycleBinding(binding: { @@ -546,6 +549,8 @@ export const discordPlugin: ChannelPlugin threadId, threadParentId, parentSessionKey, + from, + chatType, originatingTo, commandTo, fallbackTo, @@ -554,6 +559,8 @@ export const discordPlugin: ChannelPlugin threadId, threadParentId, parentSessionKey, + from, + chatType, originatingTo, commandTo, fallbackTo, diff --git a/extensions/discord/src/conversation-identity.ts b/extensions/discord/src/conversation-identity.ts new file mode 100644 index 00000000000..ad63d1ed78e --- /dev/null +++ b/extensions/discord/src/conversation-identity.ts @@ -0,0 +1,55 @@ +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { parseDiscordTarget } from "./target-parsing.js"; + +function normalizeDiscordTarget( + raw: string | null | undefined, + defaultKind: "user" | "channel", +): string | undefined { + const trimmed = normalizeOptionalString(raw); + if (!trimmed) { + return undefined; + } + return parseDiscordTarget(trimmed, { defaultKind })?.normalized; +} + +function buildDiscordConversationIdentity( + kind: "user" | "channel", + rawId: string | null | undefined, +): string | undefined { + const trimmed = normalizeOptionalString(rawId); + return trimmed ? `${kind}:${trimmed}` : undefined; +} + +export function resolveDiscordConversationIdentity(params: { + isDirectMessage: boolean; + userId?: string | null; + channelId?: string | null; +}): string | undefined { + return params.isDirectMessage + ? buildDiscordConversationIdentity("user", params.userId) + : buildDiscordConversationIdentity("channel", params.channelId); +} + +export function resolveDiscordCurrentConversationIdentity(params: { + chatType?: string | null; + from?: string | null; + originatingTo?: string | null; + commandTo?: string | null; + fallbackTo?: string | null; +}): string | undefined { + if (normalizeOptionalString(params.chatType)?.toLowerCase() === "direct") { + const senderTarget = normalizeDiscordTarget(params.from, "user"); + if (senderTarget?.startsWith("user:")) { + return senderTarget; + } + } + + for (const candidate of [params.originatingTo, params.commandTo, params.fallbackTo]) { + const target = normalizeDiscordTarget(candidate, "channel"); + if (target) { + return target; + } + } + + return undefined; +} diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index f7ab0acaaa8..2843a795018 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -36,6 +36,7 @@ import { parseDiscordModalCustomIdForCarbon, } from "../component-custom-id.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; +import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import { dispatchDiscordPluginInteractiveHandler, type DiscordInteractiveHandlerContext, @@ -155,6 +156,16 @@ function resolveDiscordComponentChatType(interactionCtx: ComponentInteractionCon return "channel"; } +export function resolveDiscordComponentOriginatingTo( + interactionCtx: Pick, +) { + return resolveDiscordConversationIdentity({ + isDirectMessage: interactionCtx.isDirectMessage, + userId: interactionCtx.userId, + channelId: interactionCtx.channelId, + }); +} + async function dispatchPluginDiscordInteractiveEvent(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; @@ -464,7 +475,8 @@ async function dispatchDiscordComponentEvent(params: { MessageSid: interaction.rawData.id, Timestamp: timestamp, OriginatingChannel: "discord" as const, - OriginatingTo: `channel:${interactionCtx.channelId}`, + OriginatingTo: + resolveDiscordComponentOriginatingTo(interactionCtx) ?? `channel:${interactionCtx.channelId}`, }); await recordInboundSession({ @@ -475,7 +487,8 @@ async function dispatchDiscordComponentEvent(params: { ? { sessionKey: route.mainSessionKey, channel: "discord", - to: `user:${interactionCtx.userId}`, + to: + resolveDiscordComponentOriginatingTo(interactionCtx) ?? `user:${interactionCtx.userId}`, accountId, mainDmOwnerPin: pinnedMainDmOwner ? { diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 80c7740c69c..d6a4e97ad53 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -21,6 +21,7 @@ import { import { getChildLogger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { logDebug } from "openclaw/plugin-sdk/text-runtime"; import { resolveDefaultDiscordAccountId } from "../accounts.js"; +import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import { isDiscordGroupAllowedByPolicy, normalizeDiscordSlug, @@ -624,7 +625,12 @@ export async function preflightDiscordMessage( }), parentConversationId: earlyThreadParentId, }); - const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId; + const bindingConversationId = isDirectMessage + ? (resolveDiscordConversationIdentity({ + isDirectMessage, + userId: author.id, + }) ?? `user:${author.id}`) + : messageChannelId; let threadBinding: SessionBindingRecord | undefined; threadBinding = conversationRuntime.getSessionBindingService().resolveByConversation({ diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index ee65c09d1a3..06f511d9938 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -46,6 +46,7 @@ import { import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { createDiscordRestClient } from "../client.js"; +import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; import { createDiscordDraftStream } from "../draft-stream.js"; import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js"; @@ -442,8 +443,14 @@ export async function processDiscordMessage( runtime.error?.(danger("discord: missing reply target")); return; } + const dmConversationTarget = isDirectMessage + ? resolveDiscordConversationIdentity({ + isDirectMessage, + userId: author.id, + }) + : undefined; // Keep DM routes user-addressed so follow-up sends resolve direct session keys. - const lastRouteTo = isDirectMessage ? `user:${author.id}` : effectiveTo; + const lastRouteTo = dmConversationTarget ?? effectiveTo; const inboundHistory = shouldIncludeChannelHistory && historyLimit > 0 @@ -454,6 +461,8 @@ export async function processDiscordMessage( })) : undefined; + const originatingTo = autoThreadContext?.OriginatingTo ?? dmConversationTarget ?? replyTarget; + const ctxPayload = finalizeInboundContext({ Body: combinedBody, BodyForAgent: baseText ?? text, @@ -493,7 +502,7 @@ export async function processDiscordMessage( CommandSource: "text" as const, // Originating channel for reply routing. OriginatingChannel: "discord" as const, - OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, + OriginatingTo: originatingTo, }); const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey; observer?.onReplyPlanResolved?.({ diff --git a/extensions/discord/src/monitor/monitor.agent-components.test.ts b/extensions/discord/src/monitor/monitor.agent-components.test.ts index 3a37c6fa161..6c6e6e35cfa 100644 --- a/extensions/discord/src/monitor/monitor.agent-components.test.ts +++ b/extensions/discord/src/monitor/monitor.agent-components.test.ts @@ -13,7 +13,11 @@ import { upsertPairingRequestMock, } from "../test-support/component-runtime.js"; import { resolveComponentInteractionContext } from "./agent-components-helpers.js"; -import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js"; +import { + createAgentComponentButton, + createAgentSelectMenu, + resolveDiscordComponentOriginatingTo, +} from "./agent-components.js"; describe("agent components", () => { const defaultDmSessionKey = buildAgentSessionKey({ @@ -259,6 +263,23 @@ describe("agent components", () => { expect(readAllowFromStoreMock).not.toHaveBeenCalled(); }); + it("uses user conversation ids for direct-message component originating targets", () => { + expect( + resolveDiscordComponentOriginatingTo({ + isDirectMessage: true, + userId: "123456789", + channelId: "dm-channel", + }), + ).toBe("user:123456789"); + expect( + resolveDiscordComponentOriginatingTo({ + isDirectMessage: false, + userId: "123456789", + channelId: "guild-channel", + }), + ).toBe("channel:guild-channel"); + }); + it("blocks DM component interactions in disabled mode without reading pairing store", async () => { readAllowFromStoreMock.mockResolvedValue(["123456789"]); const button = createAgentComponentButton({ diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index d60f0e5fc04..b08d6c64145 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -42,6 +42,13 @@ let sendComponents: typeof import("../send.components.js"); let lastDispatchCtx: Record | undefined; +function getLastRecordedCtx(): Record | undefined { + const params = recordInboundSessionMock.mock.calls.at(-1)?.[0] as + | { ctx?: Record } + | undefined; + return params?.ctx; +} + describe("discord component interactions", () => { let editDiscordComponentMessageMock: ReturnType; const createCfg = (): OpenClawConfig => @@ -301,6 +308,23 @@ describe("discord component interactions", () => { expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull(); }); + it("records DM component interactions with user originating targets", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry()], + modals: [], + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction } = createComponentButtonInteraction(); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(lastDispatchCtx?.OriginatingTo).toBe("user:123456789"); + expect(lastDispatchCtx?.To).toBe("channel:dm-channel"); + expect(getLastRecordedCtx()?.OriginatingTo).toBe("user:123456789"); + expect(getLastRecordedCtx()?.To).toBe("channel:dm-channel"); + }); + it("uses raw callbackData for built-in fallback when no plugin handler matches", async () => { registerDiscordComponentEntries({ entries: [createButtonEntry({ callbackData: "/codex_resume --browse-projects" })], diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 26840c7e107..81083ee6152 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -1,5 +1,6 @@ import type { CommandArgs } from "openclaw/plugin-sdk/command-auth"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; @@ -85,9 +86,12 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma // For follow-up delivery (for example subagent completion announces), // preserve the real Discord target separately. OriginatingChannel: "discord" as const, - OriginatingTo: params.isDirectMessage - ? `user:${params.user.id}` - : `channel:${params.channelId}`, + OriginatingTo: + resolveDiscordConversationIdentity({ + isDirectMessage: params.isDirectMessage, + userId: params.user.id, + channelId: params.channelId, + }) ?? (params.isDirectMessage ? `user:${params.user.id}` : `channel:${params.channelId}`), ThreadParentId: params.isThreadChannel ? params.threadParentId : undefined, }); } diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index ad591588b30..6fa8b76a2c0 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -190,6 +190,8 @@ function setMinimalAcpContextRegistryForTests(): void { threadId, threadParentId, parentSessionKey, + from, + chatType, originatingTo, commandTo, fallbackTo, @@ -197,6 +199,8 @@ function setMinimalAcpContextRegistryForTests(): void { threadId?: string; threadParentId?: string; parentSessionKey?: string; + from?: string; + chatType?: string; originatingTo?: string; commandTo?: string; fallbackTo?: string; @@ -215,6 +219,15 @@ function setMinimalAcpContextRegistryForTests(): void { : {}), }; } + if (chatType === "direct") { + const directSenderId = from + ?.trim() + .replace(/^discord:/i, "") + .replace(/^user:/i, ""); + if (directSenderId) { + return { conversationId: `user:${directSenderId}` }; + } + } const conversationId = parseDiscordConversationIdForTest([ originatingTo, commandTo, @@ -448,6 +461,25 @@ describe("commands-acp context", () => { }); }); + it("resolves discord DM current conversation ids from direct sender context", () => { + const params = buildCommandTestParams("/acp sessions", baseCfg, { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + From: "discord:U1", + To: "channel:dm-1", + OriginatingTo: "channel:dm-1", + ChatType: "direct", + AccountId: "work", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "discord", + accountId: "work", + conversationId: "user:U1", + }); + }); + it("resolves discord thread parent from ParentSessionKey when targets point at the thread", () => { const params = buildCommandTestParams("/acp sessions", baseCfg, { Provider: "discord", diff --git a/src/auto-reply/reply/conversation-binding-input.ts b/src/auto-reply/reply/conversation-binding-input.ts index 1af631c0907..cc9f2c5ff1f 100644 --- a/src/auto-reply/reply/conversation-binding-input.ts +++ b/src/auto-reply/reply/conversation-binding-input.ts @@ -73,10 +73,10 @@ export function resolveConversationBindingContextFromMessage(params: { senderId: params.senderId ?? params.ctx.SenderId, sessionKey: params.sessionKey ?? params.ctx.SessionKey, parentSessionKey: params.parentSessionKey ?? params.ctx.ParentSessionKey, + from: params.ctx.From, originatingTo: params.ctx.OriginatingTo, commandTo: params.commandTo, fallbackTo: params.ctx.To, - from: params.ctx.From, nativeChannelId: params.ctx.NativeChannelId, }); } diff --git a/src/channels/conversation-binding-context.ts b/src/channels/conversation-binding-context.ts index ac5509e287d..2188c4f1b2b 100644 --- a/src/channels/conversation-binding-context.ts +++ b/src/channels/conversation-binding-context.ts @@ -153,6 +153,8 @@ export function resolveConversationBindingContext( senderId: normalizeOptionalString(params.senderId), sessionKey: normalizeOptionalString(params.sessionKey), parentSessionKey: normalizeOptionalString(params.parentSessionKey), + from: normalizeOptionalString(params.from), + chatType: normalizeOptionalString(params.chatType), originatingTo: params.originatingTo ?? undefined, commandTo: params.commandTo ?? undefined, fallbackTo: params.fallbackTo ?? undefined, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index d564a8c650c..acfe5248b26 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -847,6 +847,8 @@ export type ChannelCommandConversationContext = { senderId?: string; sessionKey?: string; parentSessionKey?: string; + from?: string; + chatType?: string; originatingTo?: string; commandTo?: string; fallbackTo?: string;