diff --git a/CHANGELOG.md b/CHANGELOG.md index 3150ebf30dd..7a37de2127a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,8 @@ Docs: https://docs.openclaw.ai - Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. - Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus. - Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. -- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow. +- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. +- Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow. - Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. - Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow. diff --git a/src/discord/send.components.test.ts b/src/discord/send.components.test.ts new file mode 100644 index 00000000000..ccfef118a13 --- /dev/null +++ b/src/discord/send.components.test.ts @@ -0,0 +1,53 @@ +import { ChannelType } from "discord-api-types/v10"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerDiscordComponentEntries } from "./components-registry.js"; +import { sendDiscordComponentMessage } from "./send.components.js"; +import { makeDiscordRest } from "./send.test-harness.js"; + +const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } }))); + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: (...args: unknown[]) => loadConfigMock(...args), + }; +}); + +vi.mock("./components-registry.js", () => ({ + registerDiscordComponentEntries: vi.fn(), +})); + +describe("sendDiscordComponentMessage", () => { + const registerMock = vi.mocked(registerDiscordComponentEntries); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps DM channel targets to direct-session component entries", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ + type: ChannelType.DM, + recipients: [{ id: "user-1" }], + }); + postMock.mockResolvedValueOnce({ id: "msg1", channel_id: "dm-1" }); + + await sendDiscordComponentMessage( + "channel:dm-1", + { + blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }], + }, + { + rest, + token: "t", + sessionKey: "agent:main:discord:channel:dm-1", + agentId: "main", + }, + ); + + expect(registerMock).toHaveBeenCalledTimes(1); + const args = registerMock.mock.calls[0]?.[0]; + expect(args?.entries[0]?.sessionKey).toBe("agent:main:main"); + }); +}); diff --git a/src/discord/send.components.ts b/src/discord/send.components.ts index 0afd1f83379..85ace3d4520 100644 --- a/src/discord/send.components.ts +++ b/src/discord/send.components.ts @@ -8,6 +8,7 @@ import type { APIChannel } from "discord-api-types/v10"; import { ChannelType, Routes } from "discord-api-types/v10"; import { loadConfig } from "../config/config.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; +import { buildAgentSessionKey } from "../routing/resolve-route.js"; import { loadWebMedia } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; @@ -29,6 +30,50 @@ import type { DiscordSendResult } from "./send.types.js"; const DISCORD_FORUM_LIKE_TYPES = new Set([ChannelType.GuildForum, ChannelType.GuildMedia]); +type DiscordRecipient = Awaited>; + +function resolveDiscordDmRecipientId(channel?: APIChannel): string | undefined { + if (!channel || channel.type !== ChannelType.DM) { + return undefined; + } + const recipients = (channel as { recipients?: Array<{ id?: string }> }).recipients; + const recipientId = recipients?.[0]?.id; + if (typeof recipientId !== "string") { + return undefined; + } + const trimmed = recipientId.trim(); + return trimmed ? trimmed : undefined; +} + +function resolveDiscordComponentSessionKey(params: { + cfg: ReturnType; + accountId: string; + agentId?: string; + sessionKey?: string; + recipient: DiscordRecipient; + channel?: APIChannel; +}): string | undefined { + if (!params.sessionKey || !params.agentId) { + return params.sessionKey; + } + if (params.recipient.kind !== "channel") { + return params.sessionKey; + } + const recipientId = resolveDiscordDmRecipientId(params.channel); + if (!recipientId) { + return params.sessionKey; + } + // DM channel IDs should map back to the user session for component interactions. + return buildAgentSessionKey({ + agentId: params.agentId, + channel: "discord", + accountId: params.accountId, + peer: { kind: "direct", id: recipientId }, + dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, + }); +} + function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] { const names: string[] = []; for (const block of spec.blocks ?? []) { @@ -63,9 +108,10 @@ export async function sendDiscordComponentMessage( const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); + let channel: APIChannel | undefined; let channelType: number | undefined; try { - const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined; + channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined; channelType = channel?.type; } catch { channelType = undefined; @@ -75,9 +121,18 @@ export async function sendDiscordComponentMessage( throw new Error("Discord components are not supported in forum-style channels"); } + const componentSessionKey = resolveDiscordComponentSessionKey({ + cfg, + accountId: accountInfo.accountId, + agentId: opts.agentId, + sessionKey: opts.sessionKey, + recipient, + channel, + }); + const buildResult = buildDiscordComponentMessage({ spec, - sessionKey: opts.sessionKey, + sessionKey: componentSessionKey, agentId: opts.agentId, accountId: accountInfo.accountId, });