From c5b987274ae98a1f8a9db0f06828f9d2b3202c8a Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Mon, 25 May 2026 10:24:20 -0700 Subject: [PATCH] fix(discord): restore bare numeric channel sends (#86571) * fix(discord): restore bare numeric channel sends * docs: add Discord channel send changelog --- CHANGELOG.md | 1 + .../src/outbound-session-route.test.ts | 17 +++++ .../discord/src/outbound-session-route.ts | 2 +- .../discord/src/recipient-resolution.ts | 10 +++ .../discord/src/send.components.test.ts | 58 +++++++++++++++++ extensions/discord/src/send.components.ts | 6 +- extensions/discord/src/send.outbound.ts | 6 +- .../send.sends-basic-channel-messages.test.ts | 40 +++++------- extensions/discord/src/send.voice.test.ts | 63 +++++++++++++++++++ extensions/discord/src/send.voice.ts | 4 +- 10 files changed, 175 insertions(+), 32 deletions(-) create mode 100644 extensions/discord/src/send.voice.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f219a94ca2..046508950e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - xAI/LM Studio: avoid buffering ordinary bracketed or `final` prose until stream completion while watching for plain-text tool-call fallbacks. - Doctor: warn and continue when the cron job store exists but cannot be read so later health checks still run. Fixes #86102. (#86384) Thanks @1052326311. - Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev. +- Discord: restore bare numeric channel IDs for outbound message-tool sends while keeping explicit DM targets unambiguous. (#86571) Thanks @joshavant. - Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs. - Tests: avoid rebuilding the Control UI twice during the installer Docker smoke now that `pnpm build` includes `ui:build`. - Tests: give QA config mutation RPCs enough native Windows budget to finish gateway config writes and restart settle after hot scenario runs. diff --git a/extensions/discord/src/outbound-session-route.test.ts b/extensions/discord/src/outbound-session-route.test.ts index b95c54b49f0..3911f3a3ad0 100644 --- a/extensions/discord/src/outbound-session-route.test.ts +++ b/extensions/discord/src/outbound-session-route.test.ts @@ -39,4 +39,21 @@ describe("resolveDiscordOutboundSessionRoute", () => { }); expect(route?.threadId).toBeUndefined(); }); + + it("treats bare numeric outbound targets as channel routes", () => { + const route = resolveDiscordOutboundSessionRoute({ + cfg: {}, + agentId: "main", + target: "123", + }); + + expect(route).toMatchObject({ + baseSessionKey: "agent:main:discord:channel:123", + chatType: "channel", + from: "discord:channel:123", + peer: { kind: "channel", id: "123" }, + sessionKey: "agent:main:discord:channel:123", + to: "channel:123", + }); + }); }); diff --git a/extensions/discord/src/outbound-session-route.ts b/extensions/discord/src/outbound-session-route.ts index 7fa83db47c0..01aa7dfbf76 100644 --- a/extensions/discord/src/outbound-session-route.ts +++ b/extensions/discord/src/outbound-session-route.ts @@ -68,5 +68,5 @@ function resolveDiscordOutboundTargetKindHint(params: { if (/^(user:|discord:|@|<@!?)/i.test(target)) { return "user"; } - return undefined; + return "channel"; } diff --git a/extensions/discord/src/recipient-resolution.ts b/extensions/discord/src/recipient-resolution.ts index ce9aa496a4f..5ad60f68133 100644 --- a/extensions/discord/src/recipient-resolution.ts +++ b/extensions/discord/src/recipient-resolution.ts @@ -37,3 +37,13 @@ export async function parseAndResolveRecipient( ); return { kind: resolved.kind, id: resolved.id }; } + +export async function parseAndResolveChannelRecipient( + raw: string, + cfg: OpenClawConfig, + accountId?: string, +): Promise { + return await parseAndResolveRecipient(raw, cfg, accountId, { + defaultKind: "channel", + }); +} diff --git a/extensions/discord/src/send.components.test.ts b/extensions/discord/src/send.components.test.ts index a062d149de8..32dc23fde4e 100644 --- a/extensions/discord/src/send.components.test.ts +++ b/extensions/discord/src/send.components.test.ts @@ -170,6 +170,34 @@ describe("sendDiscordComponentMessage", () => { ); }); + it("treats bare numeric component edit targets as channels", async () => { + const { rest, patchMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ + type: ChannelType.GuildText, + id: "273512430271856640", + }); + patchMock.mockResolvedValueOnce({ id: "msg1", channel_id: "273512430271856640" }); + + await editDiscordComponentMessage( + "273512430271856640", + "msg1", + { + text: "Updated picker", + blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }], + }, + { + cfg: DISCORD_TEST_CFG, + rest, + token: "t", + sessionKey: "agent:main:discord:channel:273512430271856640", + agentId: "main", + }, + ); + + expect(patchMock).toHaveBeenCalledTimes(1); + expect(readMockCall(patchMock, 0)[0]).toContain("/channels/273512430271856640/messages/msg1"); + }); + it("registers a prebuilt component message against an edited message id", () => { registerBuiltDiscordComponentMessage({ messageId: "msg1", @@ -312,6 +340,36 @@ describe("sendDiscordComponentMessage classic message downgrade", () => { expect(modals[0]?.fields?.[0]?.label).toBe("Notes"); }); + it("treats bare numeric component send targets as channels", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ + type: ChannelType.GuildText, + id: "273512430271856640", + }); + postMock.mockResolvedValueOnce({ id: "msg1", channel_id: "273512430271856640" }); + + await sendDiscordComponentMessage( + "273512430271856640", + { + text: "report", + modal: { + title: "Feedback", + fields: [{ type: "text", label: "Notes" }], + }, + }, + { + cfg: DISCORD_TEST_CFG, + rest, + token: "t", + mediaUrl: "https://example.com/report.pdf", + }, + ); + + expect(sendMessageDiscordMock).not.toHaveBeenCalled(); + expect(postMock).toHaveBeenCalledTimes(1); + expect(readMockCall(postMock, 0)[0]).toContain("/channels/273512430271856640/messages"); + }); + it("keeps spoiler file blocks on the component path", async () => { const { rest, postMock, getMock } = makeDiscordRest(); getMock.mockResolvedValueOnce({ diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 47caaea816e..b32a938c660 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -21,7 +21,7 @@ import { type MessagePayloadObject, type RequestClient, } from "./internal/discord.js"; -import { parseAndResolveRecipient } from "./recipient-resolution.js"; +import { parseAndResolveChannelRecipient } from "./recipient-resolution.js"; import { loadOutboundMediaFromUrl } from "./runtime-api.js"; import { sendMessageDiscord } from "./send.outbound.js"; import { createDiscordSendResult } from "./send.receipt.js"; @@ -290,7 +290,7 @@ export async function sendDiscordComponentMessage( const cfg = requireRuntimeConfig(opts.cfg, "Discord component send"); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId }); const { token, rest, request } = createDiscordClient({ ...opts, cfg }); - const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId); + const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); const channelType = await resolveDiscordChannelType(rest, channelId); @@ -353,7 +353,7 @@ export async function editDiscordComponentMessage( const cfg = requireRuntimeConfig(opts.cfg, "Discord component edit"); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId }); const { token, rest, request } = createDiscordClient({ ...opts, cfg }); - const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId); + const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); const { body, buildResult } = await buildDiscordComponentPayload({ spec, diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index d71d19bbd21..2ad2cc51179 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -11,7 +11,7 @@ import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking"; import { resolveDiscordAccount } from "./accounts.js"; import { createChannelMessage, createThread, type RequestClient } from "./internal/discord.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; -import { parseAndResolveRecipient } from "./recipient-resolution.js"; +import { parseAndResolveChannelRecipient } from "./recipient-resolution.js"; import { createDiscordSendResult, type DiscordReceiptResultSource } from "./send.receipt.js"; import { buildDiscordMessageRequest, @@ -140,7 +140,7 @@ async function resolveDiscordSendTarget( ): Promise<{ rest: RequestClient; request: DiscordClientRequest; channelId: string }> { const cfg = requireRuntimeConfig(opts.cfg, "Discord send target resolution"); const { rest, request } = createDiscordClient({ ...opts, cfg }); - const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId); + const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); return { rest, request, channelId }; } @@ -181,7 +181,7 @@ export async function sendMessageDiscord( mentionAliases: accountInfo.config.mentionAliases, }); const { token, rest, request } = createDiscordClient({ ...opts, cfg }); - const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId); + const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); // Forum/Media channels reject POST /messages; auto-create a thread post instead. diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 72feb9d4646..f4484e54848 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -469,29 +469,23 @@ describe("sendMessageDiscord", () => { expect(res.channelId).toBe("chan1"); }); - it("rejects bare numeric IDs as ambiguous", async () => { - const { rest } = makeDiscordRest(); - await expect( - sendMessageDiscord("273512430271856640", "hello", { - rest, - token: "t", - cfg: DISCORD_TEST_CFG, - }), - ).rejects.toThrow(/Ambiguous Discord recipient/); - await expect( - sendMessageDiscord("273512430271856640", "hello", { - rest, - token: "t", - cfg: DISCORD_TEST_CFG, - }), - ).rejects.toThrow(/user:273512430271856640/); - await expect( - sendMessageDiscord("273512430271856640", "hello", { - rest, - token: "t", - cfg: DISCORD_TEST_CFG, - }), - ).rejects.toThrow(/channel:273512430271856640/); + it("treats bare numeric outbound IDs as channels", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); + postMock.mockResolvedValueOnce({ + id: "msg1", + channel_id: "273512430271856640", + }); + + const result = await sendMessageDiscord("273512430271856640", "hello", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }); + + expect(result.channelId).toBe("273512430271856640"); + expectRestRoute(postMock, 0, Routes.channelMessages("273512430271856640")); + expect(requireRestBody(postMock).content).toBe("hello"); }); it("adds missing permission hints on 50013", async () => { diff --git a/extensions/discord/src/send.voice.test.ts b/extensions/discord/src/send.voice.test.ts new file mode 100644 index 00000000000..b8251d9ae92 --- /dev/null +++ b/extensions/discord/src/send.voice.test.ts @@ -0,0 +1,63 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeDiscordRest } from "./send.test-harness.js"; + +const loadWebMediaRawMock = vi.hoisted(() => vi.fn()); +vi.mock("openclaw/plugin-sdk/web-media", () => ({ + loadWebMediaRaw: loadWebMediaRawMock, +})); + +const voiceMocks = vi.hoisted(() => ({ + ensureOggOpus: vi.fn(), + getVoiceMessageMetadata: vi.fn(), + sendDiscordVoiceMessage: vi.fn(), +})); +vi.mock("./voice-message.js", () => voiceMocks); + +const DISCORD_TEST_CFG = { + channels: { discord: { token: "t" } }, +}; + +let sendVoiceMessageDiscord: typeof import("./send.voice.js").sendVoiceMessageDiscord; + +describe("sendVoiceMessageDiscord", () => { + beforeAll(async () => { + ({ sendVoiceMessageDiscord } = await import("./send.voice.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + loadWebMediaRawMock.mockResolvedValue({ + buffer: Buffer.from("voice"), + fileName: "voice.ogg", + contentType: "audio/ogg", + kind: "audio", + }); + voiceMocks.ensureOggOpus.mockImplementation(async (inputPath: string) => ({ + path: inputPath, + cleanup: false, + })); + voiceMocks.getVoiceMessageMetadata.mockResolvedValue({ duration_secs: 1, waveform: "" }); + voiceMocks.sendDiscordVoiceMessage.mockResolvedValue({ + id: "msg1", + channel_id: "273512430271856640", + }); + }); + + it("treats bare numeric voice targets as channels", async () => { + const { rest } = makeDiscordRest(); + + const result = await sendVoiceMessageDiscord( + "273512430271856640", + "https://example.com/voice.ogg", + { + cfg: DISCORD_TEST_CFG, + rest, + token: "t", + }, + ); + + expect(result.channelId).toBe("273512430271856640"); + expect(voiceMocks.sendDiscordVoiceMessage).toHaveBeenCalledTimes(1); + expect(voiceMocks.sendDiscordVoiceMessage.mock.calls[0]?.[1]).toBe("273512430271856640"); + }); +}); diff --git a/extensions/discord/src/send.voice.ts b/extensions/discord/src/send.voice.ts index b1448e552cb..336455d0990 100644 --- a/extensions/discord/src/send.voice.ts +++ b/extensions/discord/src/send.voice.ts @@ -13,7 +13,7 @@ import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-s import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import type { RequestClient } from "./internal/discord.js"; -import { parseAndResolveRecipient } from "./recipient-resolution.js"; +import { parseAndResolveChannelRecipient } from "./recipient-resolution.js"; import { createDiscordSendResult } from "./send.receipt.js"; import { buildDiscordSendError, createDiscordClient, resolveChannelId } from "./send.shared.js"; import type { DiscordSendResult } from "./send.types.js"; @@ -95,7 +95,7 @@ export async function sendVoiceMessageDiscord( token = client.token; rest = client.rest; const request = client.request; - const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId); + const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId); channelId = (await resolveChannelId(rest, recipient, request)).channelId; const ogg = await ensureOggOpus(localInputPath);