fix(discord): restore bare numeric channel sends (#86571)

* fix(discord): restore bare numeric channel sends

* docs: add Discord channel send changelog
This commit is contained in:
Josh Avant
2026-05-25 10:24:20 -07:00
committed by GitHub
parent b83dfcb953
commit c5b987274a
10 changed files with 175 additions and 32 deletions

View File

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

View File

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

View File

@@ -68,5 +68,5 @@ function resolveDiscordOutboundTargetKindHint(params: {
if (/^(user:|discord:|@|<@!?)/i.test(target)) {
return "user";
}
return undefined;
return "channel";
}

View File

@@ -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<DiscordRecipient> {
return await parseAndResolveRecipient(raw, cfg, accountId, {
defaultKind: "channel",
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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