refactor: unify reply content checks

This commit is contained in:
Peter Steinberger 2026-03-16 05:32:41 +00:00
parent 3963408871
commit 7bea559166
8 changed files with 195 additions and 50 deletions

View File

@ -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;
}

View File

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

View File

@ -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<RouteReplyRe
? [externalPayload.mediaUrl]
: [];
const replyToId = externalPayload.replyToId;
const hasInteractive = (externalPayload.interactive?.blocks.length ?? 0) > 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 };
}

View File

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

View File

@ -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<MessageActi
const hasButtons = Array.isArray(params.buttons) && params.buttons.length > 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<MessageActi
}
}
if (
!message.trim() &&
!mediaUrl &&
mergedMediaUrls.length === 0 &&
!hasButtons &&
!hasCard &&
!hasComponents &&
!hasInteractive &&
!hasBlocks
!hasReplyContent({
text: message,
mediaUrl,
mediaUrls: mergedMediaUrls,
interactive: params.interactive,
extraContent: hasButtons || hasCard || hasComponents || hasBlocks,
})
) {
throw new Error("send requires text or media");
}

View File

@ -5,7 +5,12 @@ import {
shouldSuppressReasoningPayload,
} from "../../auto-reply/reply/reply-payloads.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { InteractiveReply } from "../../interactive/payload.js";
import {
hasInteractiveReplyBlocks,
hasReplyChannelData,
hasReplyContent,
type InteractiveReply,
} from "../../interactive/payload.js";
export type NormalizedOutboundPayload = {
text: string;
@ -94,10 +99,17 @@ export function normalizeOutboundPayloads(
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const interactive = payload.interactive;
const channelData = payload.channelData;
const hasChannelData = Boolean(channelData && Object.keys(channelData).length > 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({

View File

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

View File

@ -136,6 +136,30 @@ export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveR
return Boolean(normalizeInteractiveReply(value));
}
export function hasReplyChannelData(value: unknown): value is Record<string, unknown> {
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<string | null | undefined>;
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;