mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix(discord): stabilize DM ACP binding identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
55
extensions/discord/src/conversation-identity.ts
Normal file
55
extensions/discord/src/conversation-identity.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" })],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -847,6 +847,8 @@ export type ChannelCommandConversationContext = {
|
||||
senderId?: string;
|
||||
sessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
from?: string;
|
||||
chatType?: string;
|
||||
originatingTo?: string;
|
||||
commandTo?: string;
|
||||
fallbackTo?: string;
|
||||
|
||||
Reference in New Issue
Block a user