// Interactive payload helpers normalize structured interactive UI payloads. import { asOptionalRecord as toRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { isWellFormedApprovalId } from "../../packages/gateway-protocol/src/schema/approval-id.js"; import type { ChannelApprovalKind } from "../infra/approval-types.js"; const PRESENTATION_FALLBACK_CONTINUATION = Symbol.for( "openclaw.presentation.fallback-continuation", ); export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; /** Visual tone for a portable message presentation. */ export type MessagePresentationTone = "info" | "success" | "warning" | "danger" | "neutral"; /** Button style hint for renderers that support styled actions. */ export type MessagePresentationButtonStyle = InteractiveButtonStyle; type QuestionPresentationAction = | { /** Resolve one declared choice. */ type: "question"; questionId: string; optionValue: string; } | { /** Switch this question to its free-text answer path. */ type: "question"; questionId: string; intent: "custom-input"; }; /** Core-owned model-picker action; channels serialize it only inside private envelopes. */ export type ModelPickerAction = ( | { type: "model-picker"; version: 1; snapshotToken: string; intent: "show-providers"; cursor?: string; } | { type: "model-picker"; version: 1; snapshotToken: string; intent: "show-models"; providerToken: string; cursor?: string; } | { type: "model-picker"; version: 1; snapshotToken: string; intent: "show-recents"; cursor?: string; } | { type: "model-picker"; version: 1; snapshotToken: string; intent: "choose-model"; providerToken: string; modelToken: string; } | { type: "model-picker"; version: 1; snapshotToken: string; intent: "choose-runtime"; providerToken: string; modelToken: string; runtimeToken: string; } | { type: "model-picker"; version: 1; snapshotToken: string; intent: "reset" } | { type: "model-picker"; version: 1; snapshotToken: string; intent: "cancel" } ) & { /** Legacy command/callback payload fields are deliberately unavailable on picker actions. */ readonly command?: never; readonly value?: never; }; /** Portable typed action behind a button or select option. */ export type MessagePresentationAction = | { /** Run a core/plugin slash command through the target channel's native command path. */ type: "command"; command: string; } | { /** Opaque callback value interpreted by the target channel/plugin. */ type: "callback"; value: string; } | ModelPickerAction | { /** Resolve one durable operator approval without exposing transport callback data. */ type: "approval"; approvalId: string; approvalKind: ChannelApprovalKind; decision: "allow-once" | "allow-always" | "deny"; } | QuestionPresentationAction | { /** Open a normal external link. */ type: "url"; url: string; } | { /** Launch a channel-native web app. */ type: "web-app"; /** External web app URL for channels that launch web apps by URL. */ url: string; /** OpenClaw hosted-widget ID whose launch mechanics are owned by the channel. */ widgetId?: string; } | { /** Launch a channel-native web app. */ type: "web-app"; /** External web app URL for channels that launch web apps by URL. */ url?: string; /** OpenClaw hosted-widget ID whose launch mechanics are owned by the channel. */ widgetId: string; }; type LegacyMessagePresentationAction = Exclude; /** Portable action control rendered as a button or link by channel adapters. */ export type MessagePresentationButton = { /** User-visible button label. */ label: string; /** Typed action sent when the button is pressed. */ action?: MessagePresentationAction; /** * Legacy opaque callback value sent when the button is pressed. * Prefer action for new presentation controls. * @deprecated Use action. */ value?: string; /** @deprecated Use an action with type "url". */ url?: string; /** @deprecated Use an action with type "web-app". */ webApp?: { url: string; }; /** * @deprecated Use an action with type "web-app". Accepted for legacy JSON payloads only. */ web_app?: { url: string; }; /** Higher-priority buttons are kept first when channel limits require truncation. */ priority?: number; /** Disable the button when the target channel supports disabled controls. */ disabled?: boolean; /** Keep this action available after a successful interaction when the target channel supports it. */ reusable?: boolean; /** Optional visual style hint; unsupported channels ignore or normalize it. */ style?: InteractiveButtonStyle; }; /** Portable select/menu option. */ export type MessagePresentationOption = { /** User-visible option label. */ label: string; /** Typed action sent when the option is selected. */ action?: Extract; /** @deprecated Use action. */ value?: string; }; export function resolveMessagePresentationActionValue( action: MessagePresentationAction | undefined, ): string | undefined { if (action?.type === "command") { return action.command; } if (action?.type === "callback") { return action.value; } return undefined; } export function resolveMessagePresentationControlValue(control: { action?: MessagePresentationAction; value?: string; }): string | undefined { if (control.action !== undefined) { const action = normalizePresentationAction(control.action); return action ? resolveMessagePresentationActionValue(action) : undefined; } return control.value; } /** Resolve a canonical button action, including deprecated boundary inputs. */ export function resolveMessagePresentationButtonAction( button: Pick, options: { modelPicker: true }, ): MessagePresentationAction | undefined; export function resolveMessagePresentationButtonAction( button: Pick, ): LegacyMessagePresentationAction | undefined; export function resolveMessagePresentationButtonAction( button: Pick, options?: { modelPicker?: boolean }, ): MessagePresentationAction | undefined { if (button.action !== undefined) { const action = normalizePresentationAction(button.action); return action?.type === "model-picker" && options?.modelPicker !== true ? undefined : action; } if (button.url) { return { type: "url", url: button.url }; } const webAppUrl = button.webApp?.url ?? button.web_app?.url; if (webAppUrl) { return { type: "web-app", url: webAppUrl }; } return button.value ? { type: "callback", value: button.value } : undefined; } /** Resolve a canonical select action, including the deprecated value input. */ export function resolveMessagePresentationOptionAction( option: Pick, options: { modelPicker: true }, ): | Extract | undefined; export function resolveMessagePresentationOptionAction( option: Pick, ): Extract | undefined; export function resolveMessagePresentationOptionAction( option: Pick, options?: { modelPicker?: boolean }, ): | Extract | undefined { if (option.action !== undefined) { const action = normalizePresentationAction(option.action); if (action?.type === "model-picker" && options?.modelPicker !== true) { return undefined; } return action?.type === "command" || action?.type === "callback" || action?.type === "model-picker" ? action : undefined; } return option.value ? { type: "callback", value: option.value } : undefined; } export type LegacyInteractiveReplyButton = MessagePresentationButton; /** @deprecated Use MessagePresentationButton. */ export type InteractiveReplyButton = LegacyInteractiveReplyButton; export type LegacyInteractiveReplyOption = MessagePresentationOption; /** @deprecated Use MessagePresentationOption. */ export type InteractiveReplyOption = LegacyInteractiveReplyOption; export type LegacyInteractiveReplyTextBlock = { type: "text"; text: string; }; export type LegacyInteractiveReplySelectBlock = { type: "select"; placeholder?: string; options: LegacyInteractiveReplyOption[]; }; export type LegacyInteractiveReplyBlock = | LegacyInteractiveReplyTextBlock | MessagePresentationButtonsBlock | LegacyInteractiveReplySelectBlock; /** @deprecated Use MessagePresentationBlock. */ export type InteractiveReplyBlock = LegacyInteractiveReplyBlock; export type LegacyInteractiveReply = { blocks: LegacyInteractiveReplyBlock[]; }; export function reduceLegacyInteractiveReply( interactive: LegacyInteractiveReply | undefined, initialState: TState, reduce: (state: TState, block: LegacyInteractiveReplyBlock, index: number) => TState, ): TState { let state = initialState; for (const [index, block] of (interactive?.blocks ?? []).entries()) { state = reduce(state, block, index); } return state; } /** @deprecated Use MessagePresentation. */ export type InteractiveReply = LegacyInteractiveReply; export type MessagePresentationTextBlock = { type: "text"; /** Primary markdown-ish text rendered in the message body. */ text: string; }; export type MessagePresentationContextBlock = { type: "context"; /** Lower-emphasis contextual text, or normal text on channels without context support. */ text: string; }; export type MessagePresentationDividerBlock = { type: "divider"; }; export type MessagePresentationButtonsBlock = { type: "buttons"; /** Button row candidates; core may split or truncate them for channel limits. */ buttons: MessagePresentationButton[]; }; export type MessagePresentationSelectBlock = { type: "select"; /** Optional prompt shown above or inside the select control. */ placeholder?: string; /** Menu options; core may truncate them for channel limits. */ options: MessagePresentationOption[]; }; export type MessagePresentationChartSegment = { /** Category label shown in the chart legend. */ label: string; /** Positive segment magnitude. */ value: number; }; export type MessagePresentationChartSeries = { /** Unique series name shown in the chart legend. */ name: string; /** One finite value for each chart category, in category order. */ values: number[]; }; export type MessagePresentationChartBlock = | { type: "chart"; chartType: "pie"; /** Short chart heading. */ title: string; segments: MessagePresentationChartSegment[]; } | { type: "chart"; chartType: "bar" | "area" | "line"; /** Short chart heading. */ title: string; /** Ordered categories shared by every series. */ categories: string[]; series: MessagePresentationChartSeries[]; xLabel?: string; yLabel?: string; }; /** Scalar cell value supported by portable table presentations. */ export type MessagePresentationTableCell = string | number; /** Portable table rendered natively where supported and linearly elsewhere. */ export type MessagePresentationTableBlock = { type: "table"; /** Short table heading used by native renderers and fallback text. */ caption: string; /** Unique ordered column labels shared by every row. */ headers: string[]; /** Rows whose width exactly matches the header count. */ rows: MessagePresentationTableCell[][]; /** Optional column whose cells should be rendered as row headers. */ rowHeaderColumnIndex?: number; }; export type MessagePresentationInteractiveBlock = | MessagePresentationButtonsBlock | MessagePresentationSelectBlock; export type MessagePresentationBlock = | MessagePresentationTextBlock | MessagePresentationContextBlock | MessagePresentationDividerBlock | MessagePresentationButtonsBlock | MessagePresentationSelectBlock | MessagePresentationChartBlock | MessagePresentationTableBlock; export type MessagePresentation = { /** Optional short heading rendered before blocks when the channel supports it. */ title?: string; /** Optional severity/status tone for renderers that support toned presentations. */ tone?: MessagePresentationTone; /** Ordered portable blocks rendered or downgraded by the target channel adapter. */ blocks: MessagePresentationBlock[]; }; export type ReplyPayloadDeliveryPin = { enabled: boolean; notify?: boolean; required?: boolean; }; export type ReplyPayloadDelivery = { pin?: boolean | ReplyPayloadDeliveryPin; }; function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined { const style = normalizeOptionalLowercaseString(value); return style === "primary" || style === "secondary" || style === "success" || style === "danger" ? style : undefined; } function normalizePresentationTone(value: unknown): MessagePresentationTone | undefined { const tone = normalizeOptionalLowercaseString(value); return tone === "info" || tone === "success" || tone === "warning" || tone === "danger" || tone === "neutral" ? tone : undefined; } const MODEL_PICKER_TOKEN_MAX_LENGTH = 128; const MODEL_PICKER_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/u; function normalizeModelPickerToken(value: unknown): string | undefined { const token = normalizeOptionalString(value); return token && token.length <= MODEL_PICKER_TOKEN_MAX_LENGTH && MODEL_PICKER_TOKEN_PATTERN.test(token) ? token : undefined; } function normalizeOptionalModelPickerCursor( record: Record, ): { valid: true; cursor?: string } | { valid: false } { if (record.cursor === undefined) { return { valid: true }; } const cursor = normalizeModelPickerToken(record.cursor); return cursor ? { valid: true, cursor } : { valid: false }; } function normalizeModelPickerAction( record: Record, ): ModelPickerAction | undefined { if (record.type !== "model-picker" || record.version !== 1) { return undefined; } const snapshotToken = normalizeModelPickerToken(record.snapshotToken); if (!snapshotToken) { return undefined; } const intent = record.intent; if (intent === "show-providers" || intent === "show-recents") { const cursor = normalizeOptionalModelPickerCursor(record); return cursor.valid ? { type: "model-picker", version: 1, snapshotToken, intent, ...(cursor.cursor ? { cursor: cursor.cursor } : {}), } : undefined; } if (intent === "show-models") { const providerToken = normalizeModelPickerToken(record.providerToken); const cursor = normalizeOptionalModelPickerCursor(record); return providerToken && cursor.valid ? { type: "model-picker", version: 1, snapshotToken, intent, providerToken, ...(cursor.cursor ? { cursor: cursor.cursor } : {}), } : undefined; } if (intent === "choose-model") { const providerToken = normalizeModelPickerToken(record.providerToken); const modelToken = normalizeModelPickerToken(record.modelToken); return providerToken && modelToken ? { type: "model-picker", version: 1, snapshotToken, intent, providerToken, modelToken } : undefined; } if (intent === "choose-runtime") { const providerToken = normalizeModelPickerToken(record.providerToken); const modelToken = normalizeModelPickerToken(record.modelToken); const runtimeToken = normalizeModelPickerToken(record.runtimeToken); return providerToken && modelToken && runtimeToken ? { type: "model-picker", version: 1, snapshotToken, intent, providerToken, modelToken, runtimeToken, } : undefined; } return intent === "reset" || intent === "cancel" ? { type: "model-picker", version: 1, snapshotToken, intent } : undefined; } function normalizePresentationAction(raw: unknown): MessagePresentationAction | undefined { const record = toRecord(raw); if (!record) { return undefined; } const type = normalizeOptionalLowercaseString(record.type); if (type === "command") { const command = normalizeOptionalString(record.command); return command ? { type: "command", command } : undefined; } if (type === "callback") { const value = normalizeOptionalString(record.value); return value ? { type: "callback", value } : undefined; } if (type === "model-picker") { return normalizeModelPickerAction(record); } if (type === "approval") { if (record.type !== "approval") { return undefined; } const approvalId = record.approvalId; const approvalKind = record.approvalKind; const decision = record.decision; if ( typeof approvalId !== "string" || !isWellFormedApprovalId(approvalId) || (approvalKind !== "exec" && approvalKind !== "plugin" && approvalKind !== "system-agent") || (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") ) { return undefined; } return { type: "approval", approvalId, approvalKind, decision }; } if (type === "question") { if (record.type !== "question") { return undefined; } const questionId = record.questionId; const optionValue = record.optionValue; if (typeof questionId !== "string" || !isWellFormedApprovalId(questionId)) { return undefined; } const intent = record.intent; if (intent === undefined) { return typeof optionValue === "string" && optionValue.trim() ? { type: "question", questionId, optionValue } : undefined; } if (intent === "custom-input") { return { type: "question", questionId, intent }; } return undefined; } if (type === "url") { const url = normalizeOptionalString(record.url); return url ? { type: "url", url } : undefined; } if (type === "web-app") { const url = normalizeOptionalString(record.url); const widgetId = normalizeOptionalString(record.widgetId); if (url) { return { type: "web-app", url, ...(widgetId ? { widgetId } : {}) }; } return widgetId ? { type: "web-app", widgetId } : undefined; } return undefined; } function normalizeButton(raw: unknown): InteractiveReplyButton | undefined { const record = toRecord(raw); if (!record) { return undefined; } const label = normalizeOptionalString(record.label) ?? normalizeOptionalString(record.text); const value = normalizeOptionalString(record.value) ?? normalizeOptionalString(record.callbackData) ?? normalizeOptionalString(record.callback_data); const url = normalizeOptionalString(record.url); const webAppRecord = toRecord(record.webApp) ?? toRecord(record.web_app); const webAppUrl = normalizeOptionalString(webAppRecord?.url); const action = record.action !== undefined ? normalizePresentationAction(record.action) : undefined; if ( !label || (record.action !== undefined && !action) || (!action && !value && !url && !webAppUrl) ) { return undefined; } const priority = typeof record.priority === "number" && Number.isFinite(record.priority) ? record.priority : undefined; return { label, ...(action ? { action } : {}), ...(value ? { value } : {}), ...(url ? { url } : {}), ...(webAppUrl ? { webApp: { url: webAppUrl } } : {}), ...(priority !== undefined ? { priority } : {}), ...(record.disabled === true ? { disabled: true } : {}), ...(record.reusable === true ? { reusable: true } : {}), style: normalizeButtonStyle(record.style), }; } function normalizeOption(raw: unknown): InteractiveReplyOption | undefined { const record = toRecord(raw); if (!record) { return undefined; } const label = normalizeOptionalString(record.label) ?? normalizeOptionalString(record.text); const value = normalizeOptionalString(record.value); const normalizedAction = record.action !== undefined ? normalizePresentationAction(record.action) : undefined; const action = normalizedAction?.type === "command" || normalizedAction?.type === "callback" || normalizedAction?.type === "model-picker" ? normalizedAction : undefined; if (!label || (record.action !== undefined && !action) || (!action && !value)) { return undefined; } return { label, ...(action ? { action } : {}), ...(value ? { value } : {}) }; } function normalizeList(value: unknown, normalizeEntry: (entry: unknown) => T | undefined): T[] { return Array.isArray(value) ? value.map((entry) => normalizeEntry(entry)).filter((entry): entry is T => Boolean(entry)) : []; } function normalizeInteractiveBlock(raw: unknown): InteractiveReplyBlock | undefined { const record = toRecord(raw); if (!record) { return undefined; } const type = normalizeOptionalLowercaseString(record.type); if (type === "text") { const text = normalizeOptionalString(record.text); return text ? { type: "text", text } : undefined; } if (type === "buttons") { const buttons = normalizeList(record.buttons, normalizeButton); return buttons.length > 0 ? { type: "buttons", buttons } : undefined; } if (type === "select") { const options = normalizeList(record.options, normalizeOption); return options.length > 0 ? { type: "select", placeholder: normalizeOptionalString(record.placeholder), options, } : undefined; } return undefined; } function normalizeChartSegments(value: unknown): MessagePresentationChartSegment[] | undefined { if (!Array.isArray(value) || value.length === 0) { return undefined; } const segments = value.map((entry) => { const record = toRecord(entry); const label = normalizeOptionalString(record?.label); const segmentValue = record?.value; return label && typeof segmentValue === "number" && Number.isFinite(segmentValue) ? { label, value: segmentValue } : undefined; }); return segments.every((segment): segment is MessagePresentationChartSegment => Boolean(segment && segment.value > 0), ) ? segments : undefined; } function normalizeChartCategories(value: unknown): string[] | undefined { if (!Array.isArray(value) || value.length === 0) { return undefined; } const categories = value.map((entry) => normalizeOptionalString(entry)); if (categories.some((entry) => !entry)) { return undefined; } const normalized = categories as string[]; return new Set(normalized).size === normalized.length ? normalized : undefined; } function normalizeChartSeries(params: { value: unknown; categoryCount: number; }): MessagePresentationChartSeries[] | undefined { if (!Array.isArray(params.value) || params.value.length === 0) { return undefined; } const series = params.value.map((entry) => { const record = toRecord(entry); const name = normalizeOptionalString(record?.name); const values = record?.values; if ( !name || !Array.isArray(values) || values.length !== params.categoryCount || !values.every((value) => typeof value === "number" && Number.isFinite(value)) ) { return undefined; } return { name, values: values as number[] }; }); if ( !series.every((entry): entry is MessagePresentationChartSeries => Boolean(entry)) || new Set(series.map((entry) => entry.name)).size !== series.length ) { return undefined; } return series; } function normalizeChartBlock( record: Record, ): MessagePresentationChartBlock | undefined { const title = normalizeOptionalString(record.title); const chartType = normalizeOptionalLowercaseString(record.chartType); if (!title) { return undefined; } if (chartType === "pie") { const segments = normalizeChartSegments(record.segments); return segments ? { type: "chart", chartType, title, segments } : undefined; } if (chartType !== "bar" && chartType !== "area" && chartType !== "line") { return undefined; } const categories = normalizeChartCategories(record.categories); if (!categories) { return undefined; } const series = normalizeChartSeries({ value: record.series, categoryCount: categories.length }); if (!series) { return undefined; } const xLabel = normalizeOptionalString(record.xLabel); const yLabel = normalizeOptionalString(record.yLabel); return { type: "chart", chartType, title, categories, series, ...(xLabel ? { xLabel } : {}), ...(yLabel ? { yLabel } : {}), }; } function normalizeTableBlock( record: Record, ): MessagePresentationTableBlock | undefined { const caption = normalizeOptionalString(record.caption); if (!caption || !Array.isArray(record.headers) || record.headers.length === 0) { return undefined; } const headers = record.headers.map((header) => normalizeOptionalString(header)); if ( !headers.every((header): header is string => Boolean(header)) || new Set(headers).size !== headers.length || !Array.isArray(record.rows) || record.rows.length === 0 ) { return undefined; } const rows = record.rows.map((row) => { if (!Array.isArray(row) || row.length !== headers.length) { return undefined; } const cells = row.map((cell) => { if (typeof cell === "number") { return Number.isFinite(cell) ? cell : undefined; } return normalizeOptionalString(cell); }); return cells.every((cell): cell is MessagePresentationTableCell => cell !== undefined) ? cells : undefined; }); if (!rows.every((row): row is MessagePresentationTableCell[] => Boolean(row))) { return undefined; } const rowHeaderColumnIndex = record.rowHeaderColumnIndex; if ( rowHeaderColumnIndex !== undefined && (typeof rowHeaderColumnIndex !== "number" || !Number.isInteger(rowHeaderColumnIndex) || rowHeaderColumnIndex < 0 || rowHeaderColumnIndex >= headers.length) ) { return undefined; } return { type: "table", caption, headers, rows, ...(typeof rowHeaderColumnIndex === "number" ? { rowHeaderColumnIndex } : {}), }; } export function normalizeLegacyInteractiveReply(raw: unknown): LegacyInteractiveReply | undefined { const record = toRecord(raw); if (!record) { return undefined; } const blocks = normalizeList(record.blocks, normalizeInteractiveBlock); return blocks.length > 0 ? { blocks } : undefined; } /** @deprecated Use normalizeMessagePresentation. */ export const normalizeInteractiveReply = normalizeLegacyInteractiveReply; function isPresentationContinuation(raw: unknown): boolean { const record = toRecord(raw); return Boolean( record && Object.getOwnPropertyDescriptor(record, PRESENTATION_FALLBACK_CONTINUATION)?.value === true, ); } function normalizePresentationBlock( raw: unknown, followedByContinuation: boolean, ): MessagePresentationBlock | undefined { const record = toRecord(raw); if (!record) { return undefined; } const type = normalizeOptionalLowercaseString(record.type); if (type === "text" || type === "context") { const continuation = isPresentationContinuation(record); // Adapted fragments share one authored paragraph; trimming either side loses // whitespace at the split, and dropping the marker changes fallback layout. const text = (continuation || followedByContinuation) && typeof record.text === "string" ? record.text : normalizeOptionalString(record.text); const block: MessagePresentationBlock | undefined = text ? { type, text } : undefined; if (block && continuation) { Object.defineProperty(block, PRESENTATION_FALLBACK_CONTINUATION, { value: true }); } return block; } if (type === "divider") { return { type: "divider" }; } if (type === "buttons") { const buttons = normalizeList(record.buttons, normalizeButton); return buttons.length > 0 ? { type: "buttons", buttons } : undefined; } if (type === "select") { const options = normalizeList(record.options, normalizeOption); return options.length > 0 ? { type: "select", placeholder: normalizeOptionalString(record.placeholder), options, } : undefined; } if (type === "chart") { return normalizeChartBlock(record); } if (type === "table") { return normalizeTableBlock(record); } return undefined; } export function normalizeMessagePresentation(raw: unknown): MessagePresentation | undefined { const record = toRecord(raw); if (!record) { return undefined; } const rawBlocks: unknown[] = Array.isArray(record.blocks) ? record.blocks : []; const blocks = rawBlocks.flatMap((block, index) => { const normalized = normalizePresentationBlock( block, isPresentationContinuation(rawBlocks[index + 1]), ); return normalized ? [normalized] : []; }); const title = isPresentationContinuation(rawBlocks[0]) && typeof record.title === "string" ? record.title : normalizeOptionalString(record.title); if (!title && blocks.length === 0) { return undefined; } return { ...(title ? { title } : {}), tone: normalizePresentationTone(record.tone), blocks, }; } /** * @deprecated Use hasMessagePresentationBlocks. */ export const hasInteractiveReplyBlocks = hasLegacyInteractiveReplyBlocks; export function hasLegacyInteractiveReplyBlocks(value: unknown): value is LegacyInteractiveReply { return Boolean(normalizeLegacyInteractiveReply(value)); } export function hasMessagePresentationBlocks(value: unknown): value is MessagePresentation { return Boolean(normalizeMessagePresentation(value)); } /** * @deprecated Avoid producing InteractiveReply payloads; send MessagePresentation directly. */ export function presentationToInteractiveReply( presentation: MessagePresentation, ): InteractiveReply | undefined { const blocks: InteractiveReplyBlock[] = []; if (presentation.title) { blocks.push({ type: "text", text: presentation.title }); } for (const block of presentation.blocks) { if (block.type === "text" || block.type === "context") { blocks.push({ type: "text", text: block.text }); continue; } if (block.type === "buttons") { const buttons = block.buttons .filter((button) => resolveMessagePresentationButtonAction(button, { modelPicker: true })) .map((button) => { const interactiveButton: InteractiveReplyButton = { label: button.label, style: button.style, }; if (button.action) { interactiveButton.action = button.action; const actionValue = resolveMessagePresentationActionValue(button.action); if (actionValue) { interactiveButton.value = actionValue; } else if (button.action.type === "url") { interactiveButton.url = button.action.url; } else if (button.action.type === "web-app" && button.action.url) { interactiveButton.webApp = { url: button.action.url }; } } else { if (button.value) { interactiveButton.value = button.value; } if (button.url) { interactiveButton.url = button.url; } const webApp = button.webApp ?? button.web_app; if (webApp) { interactiveButton.webApp = webApp; } } if (button.priority !== undefined) { interactiveButton.priority = button.priority; } if (button.disabled === true) { interactiveButton.disabled = true; } if (button.reusable === true) { interactiveButton.reusable = true; } return interactiveButton; }); if (buttons.length > 0) { blocks.push({ type: "buttons", buttons }); } continue; } if (block.type === "chart") { blocks.push({ type: "text", text: renderMessagePresentationChartFallbackText(block) }); continue; } if (block.type === "table") { blocks.push({ type: "text", text: renderMessagePresentationTableFallbackText(block) }); continue; } if (block.type === "select") { blocks.push({ type: "select", placeholder: block.placeholder, options: block.options.map((option) => { const interactiveOption: InteractiveReplyOption = { label: option.label, }; if (option.action !== undefined) { const action = resolveMessagePresentationOptionAction(option, { modelPicker: true }); if (action) { interactiveOption.action = action; const actionValue = resolveMessagePresentationActionValue(action); if (actionValue) { interactiveOption.value = actionValue; } } } else if (option.value) { interactiveOption.value = option.value; } return interactiveOption; }), }); } } return blocks.length > 0 ? { blocks } : undefined; } export function isMessagePresentationInteractiveBlock( block: MessagePresentationBlock, ): block is MessagePresentationInteractiveBlock { return block.type === "buttons" || block.type === "select"; } /** * @deprecated Avoid producing InteractiveReply payloads; send MessagePresentation directly. */ export function presentationToInteractiveControlsReply( presentation: MessagePresentation, ): InteractiveReply | undefined { return presentationToInteractiveReply({ blocks: presentation.blocks.filter(isMessagePresentationInteractiveBlock), }); } export function legacyInteractiveReplyToPresentation( interactive: LegacyInteractiveReply, ): MessagePresentation | undefined { const blocks = interactive.blocks.map((block): MessagePresentationBlock => { if (block.type === "text") { return { type: "text", text: block.text }; } if (block.type === "buttons") { return { type: "buttons", buttons: block.buttons }; } return { type: "select", placeholder: block.placeholder, options: block.options, }; }); return blocks.length > 0 ? { blocks } : undefined; } /** * @deprecated Legacy bridge for old InteractiveReply payloads. New producers should send MessagePresentation. */ export const interactiveReplyToPresentation = legacyInteractiveReplyToPresentation; /** * Render presentation blocks as plain-text fallback for channels that do not * support native interactive controls. * * Text and context blocks are rendered as-is. Buttons with a `command`-typed * action render as `label: \`command\`` so the value is copyable. URL and web * app actions include their user-facing URL. Approval, question, callback, * legacy value, and select actions render label-only to keep transport data * private. Disabled buttons render label-only regardless of action type. * * Downstream consumers should not claim a manual command is available unless * they verify one was actually rendered. * * Exported through the plugin SDK for channel adapters. */ export function renderMessagePresentationChartFallbackText( block: MessagePresentationChartBlock, ): string { const lines = [`${block.title} (${block.chartType} chart)`]; if (block.chartType === "pie") { lines.push(...block.segments.map((segment) => `- ${segment.label}: ${String(segment.value)}`)); return lines.join("\n"); } if (block.xLabel) { lines.push(`X axis: ${block.xLabel}`); } if (block.yLabel) { lines.push(`Y axis: ${block.yLabel}`); } lines.push( ...block.series.map( (series) => `- ${series.name}: ${block.categories .map((category, index) => `${category}: ${String(series.values[index])}`) .join("; ")}`, ), ); return lines.join("\n"); } function renderTableFallbackValue(value: MessagePresentationTableCell): string { return String(value).replace(/\s+/g, " ").trim(); } export function renderMessagePresentationTableFallbackText( block: MessagePresentationTableBlock, ): string { const headers = block.headers.map(renderTableFallbackValue); const lines = [`${renderTableFallbackValue(block.caption)} (table)`]; lines.push( ...block.rows.map( (row) => `- ${row .map((cell, index) => `${headers[index]}: ${renderTableFallbackValue(cell)}`) .join("; ")}`, ), ); return lines.join("\n"); } /** Keep only operator-visible navigation and public command text in control fallbacks. */ export function renderMessagePresentationControlFallbackLabel( control: Pick< MessagePresentationButton, "label" | "action" | "value" | "url" | "webApp" | "web_app" | "disabled" >, ): string { if (control.disabled) { return control.label; } const action = resolveMessagePresentationButtonAction(control); if (action?.type === "url" || (action?.type === "web-app" && action.url)) { return `${control.label}: ${action.url}`; } if (action?.type === "command") { return `${control.label}: \`${action.command}\``; } return control.label; } export function renderMessagePresentationFallbackText(params: { presentation?: MessagePresentation; emptyFallback?: string | null; text?: string | null; }): string { const lines: string[] = []; const text = normalizeOptionalString(params.text); if (text) { lines.push(text); } const presentation = params.presentation; if (!presentation) { return lines.join("\n\n"); } if (presentation.title) { lines.push(presentation.title); } for (const block of presentation.blocks) { if (block.type === "text" || block.type === "context") { // Generated continuation blocks are bounded native fragments, not new paragraphs. if (isPresentationContinuation(block) && lines.length) { lines[lines.length - 1] += block.text; } else { lines.push(block.text); } continue; } if (block.type === "buttons") { const labels = block.buttons .map(renderMessagePresentationControlFallbackLabel) .filter(Boolean); if (labels.length > 0) { lines.push(labels.map((label) => `- ${label}`).join("\n")); } continue; } if (block.type === "chart") { lines.push(renderMessagePresentationChartFallbackText(block)); continue; } if (block.type === "table") { lines.push(renderMessagePresentationTableFallbackText(block)); continue; } if (block.type === "select") { const labels = block.options .map(renderMessagePresentationControlFallbackLabel) .filter(Boolean); if (labels.length > 0) { const heading = block.placeholder ? `${block.placeholder}:` : "Options:"; lines.push(`${heading}\n${labels.map((label) => `- ${label}`).join("\n")}`); } } } const rendered = lines.join("\n\n"); return rendered || normalizeOptionalString(params.emptyFallback) || ""; } 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; presentation?: unknown; hasChannelData?: boolean; extraContent?: boolean; }): boolean { const text = normalizeOptionalString(params.text); const mediaUrl = normalizeOptionalString(params.mediaUrl); return Boolean( text || mediaUrl || params.mediaUrls?.some((entry) => Boolean(normalizeOptionalString(entry))) || hasMessagePresentationBlocks(params.presentation) || hasLegacyInteractiveReplyBlocks(params.interactive) || params.hasChannelData || params.extraContent, ); } export function hasReplyPayloadContent( payload: { text?: string | null; mediaUrl?: string | null; mediaUrls?: ReadonlyArray; interactive?: unknown; presentation?: unknown; channelData?: unknown; location?: unknown; }, options?: { trimText?: boolean; hasChannelData?: boolean; extraContent?: boolean; }, ): boolean { return hasReplyContent({ text: options?.trimText ? payload.text?.trim() : payload.text, mediaUrl: payload.mediaUrl, mediaUrls: payload.mediaUrls, interactive: payload.interactive, presentation: payload.presentation, hasChannelData: options?.hasChannelData ?? hasReplyChannelData(payload.channelData), extraContent: options?.extraContent ?? payload.location != null, }); } export function resolveLegacyInteractiveTextFallback(params: { text?: string; interactive?: LegacyInteractiveReply; }): string | undefined { const text = normalizeOptionalString(params.text); if (text) { return params.text; } const interactiveText = (params.interactive?.blocks ?? []) .filter((block): block is LegacyInteractiveReplyTextBlock => block.type === "text") .map((block) => block.text.trim()) .filter(Boolean) .join("\n\n"); return interactiveText || params.text; } /** @deprecated Use renderMessagePresentationFallbackText with MessagePresentation. */ export const resolveInteractiveTextFallback = resolveLegacyInteractiveTextFallback; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */