Skip to content

Commit 5135662

Browse files
fix(discord): parse provider-prefixed channel targets (#78625)
* fix(discord): parse provider-prefixed channel targets * fix(discord): resolve allowlisted numeric dm targets
1 parent eb3de95 commit 5135662

8 files changed

Lines changed: 109 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
5252
- QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts.
5353
- QA/Mantis: add an opt-in Discord thread attachment before/after scenario that creates a real thread, calls `message.thread-reply` with `filePath`, and captures baseline/candidate screenshot evidence.
5454
- Discord: preserve `filePath` and `path` attachments when replying to a thread with the message tool.
55+
- Discord/message: parse provider-prefixed targets like `discord:channel:<id>` as channel sends instead of legacy Discord DM targets, so cross-channel agent `message(action="send")` calls no longer misroute channel IDs into misleading `Unknown Channel` failures. Fixes #78572.
5556
- QA/Mantis: add visual desktop tasks with Crabbox MP4 recording, screenshot capture, and optional image-understanding assertions, and preserve video artifacts in Mantis before/after reports.
5657
- QA/WhatsApp: add `pnpm openclaw qa whatsapp` for live DM canary and pairing-gate coverage using two pre-linked WhatsApp Web sessions from the QA credential pool.
5758
- QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc.

extensions/discord/src/channel.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,33 @@ describe("discordPlugin outbound", () => {
172172
});
173173
});
174174

175+
it("resolves bare allowlisted Discord user IDs as message-tool DM targets", async () => {
176+
const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
177+
if (!resolveTarget) {
178+
throw new Error(
179+
"Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
180+
);
181+
}
182+
183+
await expect(
184+
resolveTarget({
185+
cfg: {
186+
channels: {
187+
discord: {
188+
allowFrom: ["1439091261670948987"],
189+
},
190+
},
191+
} as OpenClawConfig,
192+
input: "1439091261670948987",
193+
normalized: "channel:1439091261670948987",
194+
preferredKind: "channel",
195+
}),
196+
).resolves.toMatchObject({
197+
to: "user:1439091261670948987",
198+
kind: "user",
199+
});
200+
});
201+
175202
it("honors per-account replyToMode overrides", () => {
176203
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
177204
if (!resolveReplyToMode) {

extensions/discord/src/channel.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import { discordSetupAdapter } from "./setup-adapter.js";
7979
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
8080
import { collectDiscordStatusIssues } from "./status-issues.js";
8181
import { parseDiscordTarget } from "./target-parsing.js";
82+
import { resolveDiscordTarget } from "./target-resolver.js";
8283

8384
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
8485
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
@@ -326,6 +327,21 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
326327
targetResolver: {
327328
looksLikeId: looksLikeDiscordTargetId,
328329
hint: "<channelId|user:ID|channel:ID>",
330+
resolveTarget: async ({ cfg, accountId, input, preferredKind }) => {
331+
const target = await resolveDiscordTarget(
332+
input,
333+
{ cfg, accountId: accountId ?? undefined },
334+
{ defaultKind: preferredKind === "user" ? "user" : "channel" },
335+
);
336+
return target
337+
? {
338+
to: target.normalized,
339+
kind: target.kind,
340+
display: target.raw,
341+
source: "normalized",
342+
}
343+
: null;
344+
},
329345
},
330346
},
331347
approvalCapability: getDiscordApprovalCapability(),

extensions/discord/src/normalize.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export function normalizeDiscordOutboundTarget(
3131
}
3232
return { ok: true, to: `channel:${trimmed}` };
3333
}
34+
if (/^discord:(?:channel|user):/i.test(trimmed)) {
35+
return { ok: true, to: normalizeDiscordMessagingTarget(trimmed) ?? trimmed };
36+
}
3437
return { ok: true, to: trimmed };
3538
}
3639

extensions/discord/src/outbound-adapter.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ describe("normalizeDiscordOutboundTarget", () => {
3030
expect(normalizeDiscordOutboundTarget("channel:123")).toEqual({ ok: true, to: "channel:123" });
3131
});
3232

33+
it("normalizes provider-prefixed channel targets", () => {
34+
expect(normalizeDiscordOutboundTarget("discord:channel:123")).toEqual({
35+
ok: true,
36+
to: "channel:123",
37+
});
38+
});
39+
3340
it("passes through user: prefixed targets", () => {
3441
expect(normalizeDiscordOutboundTarget("user:123")).toEqual({ ok: true, to: "user:123" });
3542
});

extensions/discord/src/outbound-session-route.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,36 @@ describe("resolveDiscordOutboundSessionRoute", () => {
3131
});
3232
expect(route?.threadId).toBeUndefined();
3333
});
34+
35+
it("routes provider-prefixed channel targets as channels", () => {
36+
const route = resolveDiscordOutboundSessionRoute({
37+
cfg: {},
38+
agentId: "main",
39+
target: "discord:channel:123",
40+
});
41+
42+
expect(route).toMatchObject({
43+
sessionKey: "agent:main:discord:channel:123",
44+
baseSessionKey: "agent:main:discord:channel:123",
45+
chatType: "channel",
46+
from: "discord:channel:123",
47+
to: "channel:123",
48+
});
49+
});
50+
51+
it("keeps legacy provider-prefixed numeric targets as direct messages", () => {
52+
const route = resolveDiscordOutboundSessionRoute({
53+
cfg: {},
54+
agentId: "main",
55+
target: "discord:123",
56+
});
57+
58+
expect(route).toMatchObject({
59+
sessionKey: "agent:main:main",
60+
baseSessionKey: "agent:main:main",
61+
chatType: "direct",
62+
from: "discord:123",
63+
to: "user:123",
64+
});
65+
});
3466
});

extensions/discord/src/target-parsing.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export function parseDiscordTarget(
2121
if (!trimmed) {
2222
return undefined;
2323
}
24+
const providerPrefixedTarget = parseDiscordProviderPrefixedTarget(trimmed);
25+
if (providerPrefixedTarget) {
26+
return providerPrefixedTarget;
27+
}
2428
const userTarget = parseMentionPrefixOrAtUserTarget({
2529
raw: trimmed,
2630
mentionPattern: /^<@!?(\d+)>$/,
@@ -47,6 +51,19 @@ export function parseDiscordTarget(
4751
return buildMessagingTarget("channel", trimmed, trimmed);
4852
}
4953

54+
function parseDiscordProviderPrefixedTarget(raw: string): DiscordTarget | undefined {
55+
const match = /^discord:(channel|user):(.+)$/i.exec(raw);
56+
if (!match) {
57+
return undefined;
58+
}
59+
const kind = match[1]?.toLowerCase() as "channel" | "user" | undefined;
60+
const id = match[2]?.trim();
61+
if (!kind || !id) {
62+
return undefined;
63+
}
64+
return buildMessagingTarget(kind, id, `${kind}:${id}`);
65+
}
66+
5067
export function resolveDiscordChannelId(raw: string): string {
5168
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
5269
return requireTargetKind({ platform: "Discord", target, kind: "channel" });

extensions/discord/src/targets.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe("parseDiscordTarget", () => {
1818
{ input: "<@123>", id: "123", normalized: "user:123" },
1919
{ input: "<@!456>", id: "456", normalized: "user:456" },
2020
{ input: "user:789", id: "789", normalized: "user:789" },
21+
{ input: "discord:user:789", id: "789", normalized: "user:789" },
2122
{ input: "discord:987", id: "987", normalized: "user:987" },
2223
] as const;
2324
for (const testCase of cases) {
@@ -32,6 +33,7 @@ describe("parseDiscordTarget", () => {
3233
it("parses channel targets", () => {
3334
const cases = [
3435
{ input: "channel:555", id: "555", normalized: "channel:555" },
36+
{ input: "discord:channel:555", id: "555", normalized: "channel:555" },
3537
{ input: "general", id: "general", normalized: "channel:general" },
3638
] as const;
3739
for (const testCase of cases) {
@@ -225,6 +227,10 @@ describe("normalizeDiscordMessagingTarget", () => {
225227
it("defaults raw numeric ids to channels", () => {
226228
expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123");
227229
});
230+
231+
it("normalizes provider-prefixed channel targets as channels", () => {
232+
expect(normalizeDiscordMessagingTarget("discord:channel:123")).toBe("channel:123");
233+
});
228234
});
229235

230236
describe("discord group policy", () => {

0 commit comments

Comments
 (0)