From df2a6b167270d2e62b462c8c99b89a820315cfd1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:42:27 -0700 Subject: [PATCH] Interactive: add shared payload model --- src/interactive/payload.ts | 153 +++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/interactive/payload.ts diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts new file mode 100644 index 00000000000..6fad12e1f1b --- /dev/null +++ b/src/interactive/payload.ts @@ -0,0 +1,153 @@ +export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; + +export type InteractiveReplyButton = { + label: string; + value: string; + style?: InteractiveButtonStyle; +}; + +export type InteractiveReplyOption = { + label: string; + value: string; +}; + +export type InteractiveReplyTextBlock = { + type: "text"; + text: string; +}; + +export type InteractiveReplyButtonsBlock = { + type: "buttons"; + buttons: InteractiveReplyButton[]; +}; + +export type InteractiveReplySelectBlock = { + type: "select"; + placeholder?: string; + options: InteractiveReplyOption[]; +}; + +export type InteractiveReplyBlock = + | InteractiveReplyTextBlock + | InteractiveReplyButtonsBlock + | InteractiveReplySelectBlock; + +export type InteractiveReply = { + blocks: InteractiveReplyBlock[]; +}; + +function readTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined { + const style = readTrimmedString(value)?.toLowerCase(); + return style === "primary" || style === "secondary" || style === "success" || style === "danger" + ? style + : undefined; +} + +function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); + const value = + readTrimmedString(record.value) ?? + readTrimmedString(record.callbackData) ?? + readTrimmedString(record.callback_data); + if (!label || !value) { + return undefined; + } + return { + label, + value, + style: normalizeButtonStyle(record.style), + }; +} + +function normalizeInteractiveOption(raw: unknown): InteractiveReplyOption | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); + const value = readTrimmedString(record.value); + if (!label || !value) { + return undefined; + } + return { label, value }; +} + +function normalizeInteractiveBlock(raw: unknown): InteractiveReplyBlock | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + const type = readTrimmedString(record.type)?.toLowerCase(); + if (type === "text") { + const text = readTrimmedString(record.text); + return text ? { type: "text", text } : undefined; + } + if (type === "buttons") { + const buttons = Array.isArray(record.buttons) + ? record.buttons + .map((entry) => normalizeInteractiveButton(entry)) + .filter((entry): entry is InteractiveReplyButton => Boolean(entry)) + : []; + return buttons.length > 0 ? { type: "buttons", buttons } : undefined; + } + if (type === "select") { + const options = Array.isArray(record.options) + ? record.options + .map((entry) => normalizeInteractiveOption(entry)) + .filter((entry): entry is InteractiveReplyOption => Boolean(entry)) + : []; + return options.length > 0 + ? { + type: "select", + placeholder: readTrimmedString(record.placeholder), + options, + } + : undefined; + } + return undefined; +} + +export function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + const blocks = Array.isArray(record.blocks) + ? record.blocks + .map((entry) => normalizeInteractiveBlock(entry)) + .filter((entry): entry is InteractiveReplyBlock => Boolean(entry)) + : []; + return blocks.length > 0 ? { blocks } : undefined; +} + +export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply { + return Boolean(normalizeInteractiveReply(value)); +} + +export function resolveInteractiveTextFallback(params: { + text?: string; + interactive?: InteractiveReply; +}): string | undefined { + const text = readTrimmedString(params.text); + if (text) { + return params.text; + } + const interactiveText = (params.interactive?.blocks ?? []) + .filter((block): block is InteractiveReplyTextBlock => block.type === "text") + .map((block) => block.text.trim()) + .filter(Boolean) + .join("\n\n"); + return interactiveText || params.text; +}