fix(discord): parse provider-prefixed channel targets (#78625)

* fix(discord): parse provider-prefixed channel targets

* fix(discord): resolve allowlisted numeric dm targets
This commit is contained in:
Patrick Erichsen
2026-05-06 14:26:54 -07:00
committed by GitHub
parent eb3de95025
commit 51356620e9
8 changed files with 109 additions and 0 deletions

View File

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- Discord: preserve `filePath` and `path` attachments when replying to a thread with the message tool.
- 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.
- 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.
- 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.
- 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.

View File

@@ -172,6 +172,33 @@ describe("discordPlugin outbound", () => {
});
});
it("resolves bare allowlisted Discord user IDs as message-tool DM targets", async () => {
const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
if (!resolveTarget) {
throw new Error(
"Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
);
}
await expect(
resolveTarget({
cfg: {
channels: {
discord: {
allowFrom: ["1439091261670948987"],
},
},
} as OpenClawConfig,
input: "1439091261670948987",
normalized: "channel:1439091261670948987",
preferredKind: "channel",
}),
).resolves.toMatchObject({
to: "user:1439091261670948987",
kind: "user",
});
});
it("honors per-account replyToMode overrides", () => {
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {

View File

@@ -79,6 +79,7 @@ import { discordSetupAdapter } from "./setup-adapter.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./target-parsing.js";
import { resolveDiscordTarget } from "./target-resolver.js";
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
@@ -326,6 +327,21 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
targetResolver: {
looksLikeId: looksLikeDiscordTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input, preferredKind }) => {
const target = await resolveDiscordTarget(
input,
{ cfg, accountId: accountId ?? undefined },
{ defaultKind: preferredKind === "user" ? "user" : "channel" },
);
return target
? {
to: target.normalized,
kind: target.kind,
display: target.raw,
source: "normalized",
}
: null;
},
},
},
approvalCapability: getDiscordApprovalCapability(),

View File

@@ -31,6 +31,9 @@ export function normalizeDiscordOutboundTarget(
}
return { ok: true, to: `channel:${trimmed}` };
}
if (/^discord:(?:channel|user):/i.test(trimmed)) {
return { ok: true, to: normalizeDiscordMessagingTarget(trimmed) ?? trimmed };
}
return { ok: true, to: trimmed };
}

View File

@@ -30,6 +30,13 @@ describe("normalizeDiscordOutboundTarget", () => {
expect(normalizeDiscordOutboundTarget("channel:123")).toEqual({ ok: true, to: "channel:123" });
});
it("normalizes provider-prefixed channel targets", () => {
expect(normalizeDiscordOutboundTarget("discord:channel:123")).toEqual({
ok: true,
to: "channel:123",
});
});
it("passes through user: prefixed targets", () => {
expect(normalizeDiscordOutboundTarget("user:123")).toEqual({ ok: true, to: "user:123" });
});

View File

@@ -31,4 +31,36 @@ describe("resolveDiscordOutboundSessionRoute", () => {
});
expect(route?.threadId).toBeUndefined();
});
it("routes provider-prefixed channel targets as channels", () => {
const route = resolveDiscordOutboundSessionRoute({
cfg: {},
agentId: "main",
target: "discord:channel:123",
});
expect(route).toMatchObject({
sessionKey: "agent:main:discord:channel:123",
baseSessionKey: "agent:main:discord:channel:123",
chatType: "channel",
from: "discord:channel:123",
to: "channel:123",
});
});
it("keeps legacy provider-prefixed numeric targets as direct messages", () => {
const route = resolveDiscordOutboundSessionRoute({
cfg: {},
agentId: "main",
target: "discord:123",
});
expect(route).toMatchObject({
sessionKey: "agent:main:main",
baseSessionKey: "agent:main:main",
chatType: "direct",
from: "discord:123",
to: "user:123",
});
});
});

View File

@@ -21,6 +21,10 @@ export function parseDiscordTarget(
if (!trimmed) {
return undefined;
}
const providerPrefixedTarget = parseDiscordProviderPrefixedTarget(trimmed);
if (providerPrefixedTarget) {
return providerPrefixedTarget;
}
const userTarget = parseMentionPrefixOrAtUserTarget({
raw: trimmed,
mentionPattern: /^<@!?(\d+)>$/,
@@ -47,6 +51,19 @@ export function parseDiscordTarget(
return buildMessagingTarget("channel", trimmed, trimmed);
}
function parseDiscordProviderPrefixedTarget(raw: string): DiscordTarget | undefined {
const match = /^discord:(channel|user):(.+)$/i.exec(raw);
if (!match) {
return undefined;
}
const kind = match[1]?.toLowerCase() as "channel" | "user" | undefined;
const id = match[2]?.trim();
if (!kind || !id) {
return undefined;
}
return buildMessagingTarget(kind, id, `${kind}:${id}`);
}
export function resolveDiscordChannelId(raw: string): string {
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
return requireTargetKind({ platform: "Discord", target, kind: "channel" });

View File

@@ -18,6 +18,7 @@ describe("parseDiscordTarget", () => {
{ input: "<@123>", id: "123", normalized: "user:123" },
{ input: "<@!456>", id: "456", normalized: "user:456" },
{ input: "user:789", id: "789", normalized: "user:789" },
{ input: "discord:user:789", id: "789", normalized: "user:789" },
{ input: "discord:987", id: "987", normalized: "user:987" },
] as const;
for (const testCase of cases) {
@@ -32,6 +33,7 @@ describe("parseDiscordTarget", () => {
it("parses channel targets", () => {
const cases = [
{ input: "channel:555", id: "555", normalized: "channel:555" },
{ input: "discord:channel:555", id: "555", normalized: "channel:555" },
{ input: "general", id: "general", normalized: "channel:general" },
] as const;
for (const testCase of cases) {
@@ -225,6 +227,10 @@ describe("normalizeDiscordMessagingTarget", () => {
it("defaults raw numeric ids to channels", () => {
expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123");
});
it("normalizes provider-prefixed channel targets as channels", () => {
expect(normalizeDiscordMessagingTarget("discord:channel:123")).toBe("channel:123");
});
});
describe("discord group policy", () => {