mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 10:33:36 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,5 +68,5 @@ function resolveDiscordOutboundTargetKindHint(params: {
|
||||
if (/^(user:|discord:|@|<@!?)/i.test(target)) {
|
||||
return "user";
|
||||
}
|
||||
return undefined;
|
||||
return "channel";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
63
extensions/discord/src/send.voice.test.ts
Normal file
63
extensions/discord/src/send.voice.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user