From 51356620e968a3cc460b6cdec03eaedc28686640 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 6 May 2026 14:26:54 -0700 Subject: [PATCH] fix(discord): parse provider-prefixed channel targets (#78625) * fix(discord): parse provider-prefixed channel targets * fix(discord): resolve allowlisted numeric dm targets --- CHANGELOG.md | 1 + extensions/discord/src/channel.test.ts | 27 ++++++++++++++++ extensions/discord/src/channel.ts | 16 ++++++++++ extensions/discord/src/normalize.ts | 3 ++ .../discord/src/outbound-adapter.test.ts | 7 ++++ .../src/outbound-session-route.test.ts | 32 +++++++++++++++++++ extensions/discord/src/target-parsing.ts | 17 ++++++++++ extensions/discord/src/targets.test.ts | 6 ++++ 8 files changed, 109 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b23362e1ed1..404370297f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` 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. diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 54e448f2183..ac397d4acb7 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -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) { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 94d1509233b..503403fd73a 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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 targetResolver: { looksLikeId: looksLikeDiscordTargetId, hint: "", + 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(), diff --git a/extensions/discord/src/normalize.ts b/extensions/discord/src/normalize.ts index 2a5dddf4822..b755fa27650 100644 --- a/extensions/discord/src/normalize.ts +++ b/extensions/discord/src/normalize.ts @@ -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 }; } diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 96719ca5502..fc552152e79 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -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" }); }); diff --git a/extensions/discord/src/outbound-session-route.test.ts b/extensions/discord/src/outbound-session-route.test.ts index 1d492163df6..66cf2925fcf 100644 --- a/extensions/discord/src/outbound-session-route.test.ts +++ b/extensions/discord/src/outbound-session-route.test.ts @@ -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", + }); + }); }); diff --git a/extensions/discord/src/target-parsing.ts b/extensions/discord/src/target-parsing.ts index b76ec3dc496..2f73b6d9b74 100644 --- a/extensions/discord/src/target-parsing.ts +++ b/extensions/discord/src/target-parsing.ts @@ -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" }); diff --git a/extensions/discord/src/targets.test.ts b/extensions/discord/src/targets.test.ts index fbac86c248d..2ab69a5f10f 100644 --- a/extensions/discord/src/targets.test.ts +++ b/extensions/discord/src/targets.test.ts @@ -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", () => {