fix(discord): stabilize DM ACP binding identity

This commit is contained in:
Peter Steinberger
2026-04-07 16:14:28 +01:00
parent 57a3744f16
commit eb29782416
14 changed files with 214 additions and 24 deletions

View File

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

View File

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

View File

@@ -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<ResolvedDiscordAccount, DiscordProbe>
threadId,
threadParentId,
parentSessionKey,
from,
chatType,
originatingTo,
commandTo,
fallbackTo,
@@ -554,6 +559,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
threadId,
threadParentId,
parentSessionKey,
from,
chatType,
originatingTo,
commandTo,
fallbackTo,

View File

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

View File

@@ -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<ComponentInteractionContext, "isDirectMessage" | "userId" | "channelId">,
) {
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
? {

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,13 @@ let sendComponents: typeof import("../send.components.js");
let lastDispatchCtx: Record<string, unknown> | undefined;
function getLastRecordedCtx(): Record<string, unknown> | undefined {
const params = recordInboundSessionMock.mock.calls.at(-1)?.[0] as
| { ctx?: Record<string, unknown> }
| undefined;
return params?.ctx;
}
describe("discord component interactions", () => {
let editDiscordComponentMessageMock: ReturnType<typeof vi.spyOn>;
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" })],

View File

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

View File

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

View File

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

View File

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

View File

@@ -847,6 +847,8 @@ export type ChannelCommandConversationContext = {
senderId?: string;
sessionKey?: string;
parentSessionKey?: string;
from?: string;
chatType?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;