From 4bd356d03a09faa78cf053084de0e56eb1fe3ffb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 13:17:57 +0100 Subject: [PATCH] fix(channels): clarify message target syntax --- CHANGELOG.md | 1 + .../discord/src/recipient-resolution.ts | 7 +- extensions/discord/src/target-parsing.ts | 2 +- extensions/discord/src/targets.test.ts | 6 ++ src/agents/tools/message-tool.test.ts | 13 ++++ src/agents/tools/message-tool.ts | 2 +- .../server/ws-connection/message-handler.ts | 77 ++++++++++--------- src/infra/outbound/channel-target.ts | 2 +- 8 files changed, 63 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31651cc261c..472105e88c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe. +- Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354. - Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319. - Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou. - Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk. diff --git a/extensions/discord/src/recipient-resolution.ts b/extensions/discord/src/recipient-resolution.ts index d87c5cdf2f8..3757bcd01ee 100644 --- a/extensions/discord/src/recipient-resolution.ts +++ b/extensions/discord/src/recipient-resolution.ts @@ -26,18 +26,13 @@ export async function parseAndResolveRecipient( } const resolvedCfg = requireRuntimeConfig(cfg, "Discord recipient resolution"); const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId }); - const trimmed = raw.trim(); - const resolvedParseOptions = { - ...parseOptions, - ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, - }; const resolved = await parseAndResolveDiscordTarget( raw, { cfg: resolvedCfg, accountId: accountInfo.accountId, }, - resolvedParseOptions, + parseOptions, ); return { kind: resolved.kind, id: resolved.id }; } diff --git a/extensions/discord/src/target-parsing.ts b/extensions/discord/src/target-parsing.ts index a56d7f8573c..b76ec3dc496 100644 --- a/extensions/discord/src/target-parsing.ts +++ b/extensions/discord/src/target-parsing.ts @@ -41,7 +41,7 @@ export function parseDiscordTarget( } throw new Error( options.ambiguousMessage ?? - `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, + `Ambiguous Discord recipient "${trimmed}". For DMs use "user:${trimmed}" or "<@${trimmed}>"; for channels use "channel:${trimmed}".`, ); } return buildMessagingTarget("channel", trimmed, trimmed); diff --git a/extensions/discord/src/targets.test.ts b/extensions/discord/src/targets.test.ts index 9aa99a495c3..033e5d9ba6e 100644 --- a/extensions/discord/src/targets.test.ts +++ b/extensions/discord/src/targets.test.ts @@ -62,6 +62,12 @@ describe("parseDiscordTarget", () => { ); } }); + + it("guides ambiguous numeric recipients with all supported explicit formats", () => { + expect(() => parseDiscordTarget("123456789")).toThrow( + 'Ambiguous Discord recipient "123456789". For DMs use "user:123456789" or "<@123456789>"; for channels use "channel:123456789".', + ); + }); }); describe("resolveDiscordChannelId", () => { diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index b46f052dcdc..a05c540aa30 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -749,6 +749,19 @@ describe("message tool description", () => { }, }); + it("surfaces explicit cross-channel target syntax in the target schema", () => { + const tool = createMessageTool({ + config: {} as never, + }); + const properties = getToolProperties(tool); + const target = properties.target as { description?: string } | undefined; + + expect(target?.description).toContain( + "Discord/Slack/Mattermost ", + ); + expect(target?.description).toContain("Telegram chat id/@username"); + }); + it("hides BlueBubbles group actions for DM targets", () => { setActivePluginRegistry( createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]), diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index f72d61484cb..bd5ea435f9b 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -48,7 +48,7 @@ function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean { function buildRoutingSchema() { return { channel: Type.Optional(Type.String()), - target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })), + target: Type.Optional(channelTargetSchema()), targets: Type.Optional(channelTargetsSchema()), accountId: Type.Optional(Type.String()), dryRun: Type.Optional(Type.Boolean()), diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index de3ad4f955c..deb2144a4b2 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1343,44 +1343,6 @@ export function attachGatewayWsMessageHandler(params: { }); incrementPresenceVersion(); } - const snapshot = buildGatewaySnapshot({ - includeSensitive: scopes.includes(ADMIN_SCOPE), - }); - const cachedHealth = getHealthCache(); - if (cachedHealth) { - snapshot.health = cachedHealth; - snapshot.stateVersion.health = getHealthVersion(); - } - const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes; - const helloOk = { - type: "hello-ok", - protocol: PROTOCOL_VERSION, - server: { - version: resolveRuntimeServiceVersion(process.env), - connId, - }, - features: { methods: gatewayMethods, events }, - snapshot, - canvasHostUrl: scopedCanvasHostUrl, - auth: { - role, - scopes: helloOkAuthScopes, - ...(deviceToken - ? { - deviceToken: deviceToken.token, - issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, - ...(bootstrapDeviceTokens.length > 1 - ? { deviceTokens: bootstrapDeviceTokens.slice(1) } - : {}), - } - : {}), - }, - policy: { - maxPayload: MAX_PAYLOAD_BYTES, - maxBufferedBytes: MAX_BUFFERED_BYTES, - tickIntervalMs: TICK_INTERVAL_MS, - }, - }; if (role === "node") { const context = buildRequestContext(); const nodeSession = context.nodeRegistry.register(nextClient, { @@ -1442,6 +1404,45 @@ export function attachGatewayWsMessageHandler(params: { ); } + const snapshot = buildGatewaySnapshot({ + includeSensitive: scopes.includes(ADMIN_SCOPE), + }); + const cachedHealth = getHealthCache(); + if (cachedHealth) { + snapshot.health = cachedHealth; + snapshot.stateVersion.health = getHealthVersion(); + } + const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes; + const helloOk = { + type: "hello-ok", + protocol: PROTOCOL_VERSION, + server: { + version: resolveRuntimeServiceVersion(process.env), + connId, + }, + features: { methods: gatewayMethods, events }, + snapshot, + canvasHostUrl: scopedCanvasHostUrl, + auth: { + role, + scopes: helloOkAuthScopes, + ...(deviceToken + ? { + deviceToken: deviceToken.token, + issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, + ...(bootstrapDeviceTokens.length > 1 + ? { deviceTokens: bootstrapDeviceTokens.slice(1) } + : {}), + } + : {}), + }, + policy: { + maxPayload: MAX_PAYLOAD_BYTES, + maxBufferedBytes: MAX_BUFFERED_BYTES, + tickIntervalMs: TICK_INTERVAL_MS, + }, + }; + try { await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk }); } catch (err) { diff --git a/src/infra/outbound/channel-target.ts b/src/infra/outbound/channel-target.ts index 9de5deeb2ad..f52eb2d6c27 100644 --- a/src/infra/outbound/channel-target.ts +++ b/src/infra/outbound/channel-target.ts @@ -7,7 +7,7 @@ import { MESSAGE_ACTION_TARGET_MODE } from "./message-action-spec.js"; export const hasNonEmptyString = sharedHasNonEmptyString; export const CHANNEL_TARGET_DESCRIPTION = - "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id"; + "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack/Mattermost , or iMessage handle/chat_id"; export const CHANNEL_TARGETS_DESCRIPTION = "Recipient/channel targets (same format as --target); accepts ids or names when the directory is available.";