import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN, stripSilentToken, } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; import { resolveResponsePrefixTemplate, type ResponsePrefixContext, } from "./response-prefix-template.js"; import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js"; export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat"; export type NormalizeReplyOptions = { responsePrefix?: string; enableSlackInteractiveReplies?: boolean; /** Context for template variable interpolation in responsePrefix */ responsePrefixContext?: ResponsePrefixContext; onHeartbeatStrip?: () => void; stripHeartbeat?: boolean; silentToken?: string; onSkip?: (reason: NormalizeReplySkipReason) => void; }; export function normalizeReplyPayload( payload: ReplyPayload, opts: NormalizeReplyOptions = {}, ): ReplyPayload | null { const hasContent = (text: string | undefined) => hasReplyPayloadContent( { ...payload, text, }, { trimText: true, }, ); const trimmed = payload.text?.trim() ?? ""; if (!hasContent(trimmed)) { opts.onSkip?.("empty"); return null; } const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { if (!hasContent("")) { opts.onSkip?.("silent"); return null; } text = ""; } // Strip NO_REPLY from mixed-content messages (e.g. "😄 NO_REPLY") so the // token never leaks to end users. If stripping leaves nothing, treat it as // silent just like the exact-match path above. (#30916, #30955) if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) { text = stripSilentToken(text, silentToken); if (!hasContent(text)) { opts.onSkip?.("silent"); return null; } } if (text && !trimmed) { // Keep empty text when media exists so media-only replies still send. text = ""; } const shouldStripHeartbeat = opts.stripHeartbeat ?? true; if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) { const stripped = stripHeartbeatToken(text, { mode: "message" }); if (stripped.didStrip) { opts.onHeartbeatStrip?.(); } if (stripped.shouldSkip && !hasContent(stripped.text)) { opts.onSkip?.("heartbeat"); return null; } text = stripped.text; } if (text) { text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); } if (!hasContent(text)) { opts.onSkip?.("empty"); return null; } // Parse LINE-specific directives from text (quick_replies, location, confirm, buttons) let enrichedPayload: ReplyPayload = { ...payload, text }; if (text && hasLineDirectives(text)) { enrichedPayload = parseLineDirectives(enrichedPayload); text = enrichedPayload.text; } // Resolve template variables in responsePrefix if context is provided const effectivePrefix = opts.responsePrefixContext ? resolveResponsePrefixTemplate(opts.responsePrefix, opts.responsePrefixContext) : opts.responsePrefix; if ( effectivePrefix && text && text.trim() !== HEARTBEAT_TOKEN && !text.startsWith(effectivePrefix) ) { text = `${effectivePrefix} ${text}`; } enrichedPayload = { ...enrichedPayload, text }; if (opts.enableSlackInteractiveReplies && text && hasSlackDirectives(text)) { enrichedPayload = parseSlackDirectives(enrichedPayload); } return enrichedPayload; }