refactor: unify reply content checks
This commit is contained in:
parent
3963408871
commit
7bea559166
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
66
src/interactive/payload.test.ts
Normal file
66
src/interactive/payload.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user