diff --git a/CHANGELOG.md b/CHANGELOG.md index 719b934a322..3c38f13c89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Telegram/messages: derive fallback text from interactive button/select labels before sending button-only payloads, so Telegram replies are not rejected as empty messages. Thanks @vincentkoc. - LINE/messages: send quick-reply-only payloads with fallback option text instead of accepting the payload and returning an empty delivery. Thanks @vincentkoc. - Auto-reply/docking: require `/dock-*` route switches to start from direct chats, so group or channel participants cannot reroute a shared session's future replies into a linked DM. Thanks @vincentkoc. +- Discord: keep text-DM main-session route updates pinned to the configured DM owner, matching component interactions so another direct-message sender cannot redirect future main-session replies. Thanks @vincentkoc. - Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc. - Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc. - Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram. diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index 2875082494d..6adc02e21bd 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -3,6 +3,7 @@ import { resolveEnvelopeFormatOptions, } from "openclaw/plugin-sdk/channel-inbound"; import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/conversation-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { buildPendingHistoryContextFromMap } from "openclaw/plugin-sdk/reply-history"; @@ -13,7 +14,7 @@ import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/sess import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import { ChannelType } from "../internal/discord.js"; -import { normalizeDiscordSlug } from "./allow-list.js"; +import { normalizeDiscordAllowList, normalizeDiscordSlug } from "./allow-list.js"; import { resolveTimestampMs } from "./format.js"; import { buildDiscordInboundAccessContext, @@ -28,6 +29,12 @@ import { import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js"; import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js"; +function normalizeDiscordDmOwnerEntry(entry: string): string | undefined { + const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]); + const candidate = normalized?.ids.values().next().value; + return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined; +} + export async function buildDiscordMessageProcessContext(params: { ctx: DiscordMessagePreflightContext; text: string; @@ -104,6 +111,13 @@ export async function buildDiscordMessageProcessContext(params: { channelTopic: channelInfo?.topic, messageBody: text, }); + const pinnedMainDmOwner = isDirectMessage + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom: channelConfig?.users ?? guildInfo?.users, + normalizeEntry: normalizeDiscordDmOwnerEntry, + }) + : null; const contextVisibilityMode = resolveChannelContextVisibilityMode({ cfg, channel: "discord", @@ -347,6 +361,24 @@ export async function buildDiscordMessageProcessContext(params: { channel: "discord", to: lastRouteTo, accountId: route.accountId, + mainDmOwnerPin: + isDirectMessage && persistedSessionKey === route.mainSessionKey && pinnedMainDmOwner + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: author.id, + onSkip: ({ + ownerRecipient, + senderRecipient, + }: { + ownerRecipient: string; + senderRecipient: string; + }) => { + logVerbose( + `discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, }, onRecordError: (err: unknown) => { logVerbose(`discord: failed updating session meta: ${String(err)}`); diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 1ae3f132262..861ee864945 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -198,6 +198,27 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ recordInboundSession: (...args: unknown[]) => recordInboundSession(...args), + resolvePinnedMainDmOwnerFromAllowlist: (params: { + dmScope?: string | null; + allowFrom?: Array | null; + normalizeEntry: (entry: string) => string | undefined; + }) => { + if ((params.dmScope ?? "main") !== "main") { + return null; + } + const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : []; + if (allowFrom.some((entry) => String(entry).trim() === "*")) { + return null; + } + const owners = Array.from( + new Set( + allowFrom + .map((entry) => params.normalizeEntry(String(entry))) + .filter((entry): entry is string => Boolean(entry)), + ), + ); + return owners.length === 1 ? owners[0] : null; + }, registerSessionBindingAdapter: vi.fn(), unregisterSessionBindingAdapter: vi.fn(), resolveThreadBindingConversationIdFromBindingId: (bindingId: string) => @@ -306,7 +327,13 @@ beforeEach(() => { }); function getLastRouteUpdate(): - | { sessionKey?: string; channel?: string; to?: string; accountId?: string } + | { + sessionKey?: string; + channel?: string; + to?: string; + accountId?: string; + mainDmOwnerPin?: { ownerRecipient?: string; senderRecipient?: string }; + } | undefined { const callArgs = recordInboundSession.mock.calls.at(-1) as unknown[] | undefined; const params = callArgs?.[0] as @@ -316,6 +343,7 @@ function getLastRouteUpdate(): channel?: string; to?: string; accountId?: string; + mainDmOwnerPin?: { ownerRecipient?: string; senderRecipient?: string }; }; } | undefined; @@ -782,6 +810,48 @@ describe("processDiscordMessage session routing", () => { }); }); + it("pins Discord text DM main-route updates to the single configured DM owner", async () => { + const ctx = await createBaseContext({ + ...createDirectMessageContextOverrides(), + cfg: { + messages: { ackReaction: "👀" }, + session: { + store: "/tmp/openclaw-discord-process-test-sessions.json", + dmScope: "main", + }, + }, + channelConfig: { users: ["user:111"] }, + baseSessionKey: "agent:main:main", + author: { + id: "222", + username: "bob", + discriminator: "0", + globalName: "Bob", + }, + sender: { id: "222", label: "bob" }, + route: { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:main", + mainSessionKey: "agent:main:main", + }, + }); + + await runProcessDiscordMessage(ctx); + + expect(getLastRouteUpdate()).toMatchObject({ + sessionKey: "agent:main:main", + channel: "discord", + to: "user:222", + accountId: "default", + mainDmOwnerPin: { + ownerRecipient: "111", + senderRecipient: "222", + }, + }); + }); + it("stores group lastRoute with channel target", async () => { const ctx = await createBaseContext({ baseSessionKey: "agent:main:discord:channel:c1",