From 5572c8137cb3deac71cf05174474d4a03c276d4a Mon Sep 17 00:00:00 2001 From: Satoshi Date: Mon, 4 May 2026 20:49:38 +0100 Subject: [PATCH] fix(discord): preserve non-text payloads in reply scrub --- CHANGELOG.md | 1 + .../src/monitor/reply-delivery.test.ts | 74 +++++++++++++++++++ .../discord/src/monitor/reply-safety.ts | 29 +++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98500da708f..afea0101f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. - Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan. +- Discord/reply-safety: preserve component-only channel payloads (for example `channelData.discord.components` and presentation/interactive-only replies) when final text scrubbing removes all text, so Discord still delivers non-text outbound payloads instead of dropping them. (#77478) Thanks @NikolaFC. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index 7192b1971e3..17fd63f6e39 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -155,6 +155,80 @@ describe("deliverDiscordReply", () => { ); }); + it("preserves component-only channelData payloads when text scrubs empty", async () => { + const channelData = { + discord: { + components: [ + { + type: 1, + components: [ + { + type: 2, + style: 1, + label: "Open", + custom_id: "open", + }, + ], + }, + ], + }, + }; + + await deliverDiscordReply({ + replies: [ + { + text: "analysis: internal only", + channelData, + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ channelData, text: undefined }], + }), + ); + }); + + it("preserves presentation-only payloads when text scrubs empty", async () => { + const presentation = { + title: "Action required", + blocks: [ + { + type: "buttons" as const, + buttons: [{ label: "Approve", value: "approve", style: "primary" as const }], + }, + ], + }; + + await deliverDiscordReply({ + replies: [ + { + text: "commentary: hidden", + presentation, + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ presentation, text: undefined }], + }), + ); + }); + it("does not strip ordinary code-fenced examples of tool-call labels", async () => { const text = ["Example:", "```", "🛠️ Exec: run ls", "```"].join("\n"); diff --git a/extensions/discord/src/monitor/reply-safety.ts b/extensions/discord/src/monitor/reply-safety.ts index d21079e7371..0cb0d6e7610 100644 --- a/extensions/discord/src/monitor/reply-safety.ts +++ b/extensions/discord/src/monitor/reply-safety.ts @@ -7,6 +7,32 @@ const DISCORD_INTERNAL_TRACE_LINE_RE = const DISCORD_INTERNAL_CHANNEL_LINE_RE = /^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i; +function hasNonEmptyRecord(value: unknown): value is Record { + return Boolean( + value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0, + ); +} + +function hasInteractiveOrPresentationBlocks(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + if (typeof record.title === "string" && record.title.trim().length > 0) { + return true; + } + return Array.isArray(record.blocks) && record.blocks.length > 0; +} + +function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean { + return ( + payload.audioAsVoice === true || + hasNonEmptyRecord(payload.channelData) || + hasInteractiveOrPresentationBlocks(payload.interactive) || + hasInteractiveOrPresentationBlocks(payload.presentation) + ); +} + function stripDiscordInternalTraceLines(text: string): string { let inFence = false; const kept: string[] = []; @@ -45,7 +71,6 @@ export function sanitizeDiscordFrontChannelReplyPayloads( ): ReplyPayload[] { const safePayloads: ReplyPayload[] = []; for (const payload of payloads) { - const originalParts = resolveSendableOutboundReplyParts(payload); const safeText = typeof payload.text === "string" ? sanitizeDiscordFrontChannelText(payload.text) @@ -55,7 +80,7 @@ export function sanitizeDiscordFrontChannelReplyPayloads( ? payload : ({ ...payload, text: safeText || undefined } as ReplyPayload); const nextParts = resolveSendableOutboundReplyParts(nextPayload); - if (!nextParts.hasText && !originalParts.hasMedia) { + if (!nextParts.hasContent && !hasNonTextReplyPayloadContent(nextPayload)) { continue; } safePayloads.push(nextPayload);