diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 9ef5a7a9d90..52faa463bdb 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,4 +1,5 @@ import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, @@ -31,13 +32,17 @@ export function normalizeReplyPayload( payload: ReplyPayload, opts: NormalizeReplyOptions = {}, ): ReplyPayload | null { - const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0); - const hasInteractive = (payload.interactive?.blocks.length ?? 0) > 0; - const hasChannelData = Boolean( - payload.channelData && Object.keys(payload.channelData).length > 0, - ); + const hasChannelData = hasReplyChannelData(payload.channelData); const trimmed = payload.text?.trim() ?? ""; - if (!trimmed && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text: trimmed, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("empty"); return null; } @@ -45,7 +50,14 @@ export function normalizeReplyPayload( const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if (!hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("silent"); return null; } @@ -56,7 +68,15 @@ export function normalizeReplyPayload( // silent just like the exact-match path above. (#30916, #30955) if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) { text = stripSilentToken(text, silentToken); - if (!text && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("silent"); return null; } @@ -72,7 +92,16 @@ export function normalizeReplyPayload( if (stripped.didStrip) { opts.onHeartbeatStrip?.(); } - if (stripped.shouldSkip && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + stripped.shouldSkip && + !hasReplyContent({ + text: stripped.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("heartbeat"); return null; } @@ -82,7 +111,15 @@ export function normalizeReplyPayload( if (text) { text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); } - if (!text?.trim() && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("empty"); return null; } diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index ab7586f1664..f5f409e2900 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -4,6 +4,7 @@ import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -74,14 +75,14 @@ export function applyReplyTagsToPayload( } export function isRenderablePayload(payload: ReplyPayload): boolean { - return Boolean( - payload.text || - payload.mediaUrl || - (payload.mediaUrls && payload.mediaUrls.length > 0) || - payload.audioAsVoice || - payload.interactive || - payload.channelData, - ); + return hasReplyContent({ + text: payload.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData: hasReplyChannelData(payload.channelData), + extraContent: payload.audioAsVoice, + }); } export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 8dc7499526a..3836ceb5ab6 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -12,6 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; +import { hasReplyContent } from "../../interactive/payload.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -119,13 +120,19 @@ export async function routeReply(params: RouteReplyParams): Promise 0; const hasChannelData = plugin?.messaging?.hasStructuredReplyPayload?.({ payload: externalPayload, }); // Skip empty replies. - if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrls, + interactive: externalPayload.interactive, + hasChannelData, + }) + ) { return { ok: true }; } diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 9d661b38c45..9e10f525cb0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -30,6 +30,7 @@ import { toPluginMessageContext, toPluginMessageSentEvent, } from "../../hooks/message-hook-mappers.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -238,30 +239,24 @@ type MessageSentEvent = { messageId?: string; }; -function hasMediaPayload(payload: ReplyPayload): boolean { - return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; -} - -function hasChannelDataPayload(payload: ReplyPayload): boolean { - return Boolean(payload.channelData && Object.keys(payload.channelData).length > 0); -} - -function hasInteractivePayload(payload: ReplyPayload): boolean { - return (payload.interactive?.blocks.length ?? 0) > 0; -} - function normalizePayloadForChannelDelivery( payload: ReplyPayload, channelId: string, ): ReplyPayload | null { - const hasMedia = hasMediaPayload(payload); - const hasChannelData = hasChannelDataPayload(payload); - const hasInteractive = hasInteractivePayload(payload); + const hasChannelData = hasReplyChannelData(payload.channelData); const rawText = typeof payload.text === "string" ? payload.text : ""; const normalizedText = channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText; if (!normalizedText.trim()) { - if (!hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text: normalizedText, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { return null; } return { @@ -713,7 +708,10 @@ async function deliverOutboundPayloadsCore( }; if ( handler.sendPayload && - (effectivePayload.channelData || hasInteractivePayload(effectivePayload)) + (effectivePayload.channelData || + hasReplyContent({ + interactive: effectivePayload.interactive, + })) ) { const delivery = await handler.sendPayload(effectivePayload, sendOverrides); results.push(delivery); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index aa53f7398f4..8480b962544 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,7 @@ import type { ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { hasInteractiveReplyBlocks, hasReplyContent } from "../../interactive/payload.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js"; import { resolvePollMaxSelections } from "../../polls.js"; @@ -407,7 +408,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0; const hasCard = params.card != null && typeof params.card === "object"; const hasComponents = params.components != null && typeof params.components === "object"; - const hasInteractive = params.interactive != null && typeof params.interactive === "object"; + const hasInteractive = hasInteractiveReplyBlocks(params.interactive); const hasBlocks = (Array.isArray(params.blocks) && params.blocks.length > 0) || (typeof params.blocks === "string" && params.blocks.trim().length > 0); @@ -482,14 +483,13 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0); - const hasInteractive = Boolean(interactive?.blocks.length); + const hasChannelData = hasReplyChannelData(channelData); + const hasInteractive = hasInteractiveReplyBlocks(interactive); const text = payload.text ?? ""; - if (!text && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrls, + interactive, + hasChannelData, + }) + ) { continue; } normalizedPayloads.push({ diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts new file mode 100644 index 00000000000..3000716cd2e --- /dev/null +++ b/src/interactive/payload.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + hasReplyChannelData, + hasReplyContent, + normalizeInteractiveReply, + resolveInteractiveTextFallback, +} from "./payload.js"; + +describe("hasReplyChannelData", () => { + it("accepts non-empty objects only", () => { + expect(hasReplyChannelData(undefined)).toBe(false); + expect(hasReplyChannelData({})).toBe(false); + expect(hasReplyChannelData([])).toBe(false); + expect(hasReplyChannelData({ slack: { blocks: [] } })).toBe(true); + }); +}); + +describe("hasReplyContent", () => { + it("treats whitespace-only text and empty structured payloads as empty", () => { + expect( + hasReplyContent({ + text: " ", + mediaUrls: ["", " "], + interactive: { blocks: [] }, + hasChannelData: false, + }), + ).toBe(false); + }); + + it("accepts shared interactive blocks and explicit extra content", () => { + expect( + hasReplyContent({ + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }], + }, + }), + ).toBe(true); + expect( + hasReplyContent({ + text: " ", + extraContent: true, + }), + ).toBe(true); + }); +}); + +describe("interactive payload helpers", () => { + it("normalizes interactive replies and resolves text fallbacks", () => { + const interactive = normalizeInteractiveReply({ + blocks: [ + { type: "text", text: "First" }, + { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, + { type: "text", text: "Second" }, + ], + }); + + expect(interactive).toEqual({ + blocks: [ + { type: "text", text: "First" }, + { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, + { type: "text", text: "Second" }, + ], + }); + expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond"); + }); +}); diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 6fad12e1f1b..5ccd55d0eff 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -136,6 +136,30 @@ export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveR return Boolean(normalizeInteractiveReply(value)); } +export function hasReplyChannelData(value: unknown): value is Record { + return Boolean( + value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0, + ); +} + +export function hasReplyContent(params: { + text?: string | null; + mediaUrl?: string | null; + mediaUrls?: ReadonlyArray; + interactive?: unknown; + hasChannelData?: boolean; + extraContent?: boolean; +}): boolean { + return Boolean( + params.text?.trim() || + params.mediaUrl?.trim() || + params.mediaUrls?.some((entry) => Boolean(entry?.trim())) || + hasInteractiveReplyBlocks(params.interactive) || + params.hasChannelData || + params.extraContent, + ); +} + export function resolveInteractiveTextFallback(params: { text?: string; interactive?: InteractiveReply;