fix(discord): preserve non-text payloads in reply scrub

This commit is contained in:
Satoshi
2026-05-04 20:49:38 +01:00
committed by Peter Steinberger
parent e259938e96
commit 5572c8137c
3 changed files with 102 additions and 2 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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<string, unknown> {
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<string, unknown>;
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);