import type { messagingApi } from "@line/bot-sdk"; import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js"; type FlexMessage = messagingApi.FlexMessage; type FlexComponent = messagingApi.FlexComponent; type FlexText = messagingApi.FlexText; type FlexBox = messagingApi.FlexBox; export interface ProcessedLineMessage { /** The processed text with markdown stripped */ text: string; /** Flex messages extracted from tables/code blocks */ flexMessages: FlexMessage[]; } /** * Regex patterns for markdown detection */ const MARKDOWN_TABLE_REGEX = /^\|(.+)\|[\r\n]+\|[-:\s|]+\|[\r\n]+((?:\|.+\|[\r\n]*)+)/gm; const MARKDOWN_CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g; const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; /** * Detect and extract markdown tables from text */ export function extractMarkdownTables(text: string): { tables: MarkdownTable[]; textWithoutTables: string; } { const tables: MarkdownTable[] = []; let textWithoutTables = text; // Reset regex state MARKDOWN_TABLE_REGEX.lastIndex = 0; let match: RegExpExecArray | null; const matches: { fullMatch: string; table: MarkdownTable }[] = []; while ((match = MARKDOWN_TABLE_REGEX.exec(text)) !== null) { const fullMatch = match[0]; const headerLine = match[1]; const bodyLines = match[2]; const headers = parseTableRow(headerLine); const rows = bodyLines .trim() .split(/[\r\n]+/) .filter((line) => line.trim()) .map(parseTableRow); if (headers.length > 0 && rows.length > 0) { matches.push({ fullMatch, table: { headers, rows }, }); } } // Remove tables from text in reverse order to preserve indices for (let i = matches.length - 1; i >= 0; i--) { const { fullMatch, table } = matches[i]; tables.unshift(table); textWithoutTables = textWithoutTables.replace(fullMatch, ""); } return { tables, textWithoutTables }; } export interface MarkdownTable { headers: string[]; rows: string[][]; } /** * Parse a single table row (pipe-separated values) */ function parseTableRow(row: string): string[] { return row .split("|") .map((cell) => cell.trim()) .filter((cell, index, arr) => { // Filter out empty cells at start/end (from leading/trailing pipes) if (index === 0 && cell === "") return false; if (index === arr.length - 1 && cell === "") return false; return true; }); } /** * Convert a markdown table to a LINE Flex Message bubble */ export function convertTableToFlexBubble(table: MarkdownTable): FlexBubble { const parseCell = ( value: string | undefined, ): { text: string; bold: boolean; hasMarkup: boolean } => { const raw = value?.trim() ?? ""; if (!raw) return { text: "-", bold: false, hasMarkup: false }; let hasMarkup = false; const stripped = raw.replace(/\*\*(.+?)\*\*/g, (_, inner) => { hasMarkup = true; return String(inner); }); const text = stripped.trim() || "-"; const bold = /^\*\*.+\*\*$/.test(raw); return { text, bold, hasMarkup }; }; const headerCells = table.headers.map((header) => parseCell(header)); const rowCells = table.rows.map((row) => row.map((cell) => parseCell(cell))); const hasInlineMarkup = headerCells.some((cell) => cell.hasMarkup) || rowCells.some((row) => row.some((cell) => cell.hasMarkup)); // For simple 2-column tables, use receipt card format if (table.headers.length === 2 && !hasInlineMarkup) { const items = rowCells.map((row) => ({ name: row[0]?.text ?? "-", value: row[1]?.text ?? "-", })); return createReceiptCard({ title: headerCells.map((cell) => cell.text).join(" / "), items, }); } // For multi-column tables, create a custom layout const headerRow: FlexComponent = { type: "box", layout: "horizontal", contents: headerCells.map((cell) => ({ type: "text", text: cell.text, weight: "bold", size: "sm", color: "#333333", flex: 1, wrap: true, })) as FlexText[], paddingBottom: "sm", } as FlexBox; const dataRows: FlexComponent[] = rowCells.slice(0, 10).map((row, rowIndex) => { const rowContents = table.headers.map((_, colIndex) => { const cell = row[colIndex] ?? { text: "-", bold: false, hasMarkup: false }; return { type: "text", text: cell.text, size: "sm", color: "#666666", flex: 1, wrap: true, weight: cell.bold ? "bold" : undefined, }; }) as FlexText[]; return { type: "box", layout: "horizontal", contents: rowContents, margin: rowIndex === 0 ? "md" : "sm", } as FlexBox; }); return { type: "bubble", body: { type: "box", layout: "vertical", contents: [headerRow, { type: "separator", margin: "sm" }, ...dataRows], paddingAll: "lg", }, }; } /** * Detect and extract code blocks from text */ export function extractCodeBlocks(text: string): { codeBlocks: CodeBlock[]; textWithoutCode: string; } { const codeBlocks: CodeBlock[] = []; let textWithoutCode = text; // Reset regex state MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0; let match: RegExpExecArray | null; const matches: { fullMatch: string; block: CodeBlock }[] = []; while ((match = MARKDOWN_CODE_BLOCK_REGEX.exec(text)) !== null) { const fullMatch = match[0]; const language = match[1] || undefined; const code = match[2]; matches.push({ fullMatch, block: { language, code: code.trim() }, }); } // Remove code blocks in reverse order for (let i = matches.length - 1; i >= 0; i--) { const { fullMatch, block } = matches[i]; codeBlocks.unshift(block); textWithoutCode = textWithoutCode.replace(fullMatch, ""); } return { codeBlocks, textWithoutCode }; } export interface CodeBlock { language?: string; code: string; } /** * Convert a code block to a LINE Flex Message bubble */ export function convertCodeBlockToFlexBubble(block: CodeBlock): FlexBubble { const titleText = block.language ? `Code (${block.language})` : "Code"; // Truncate very long code to fit LINE's limits const displayCode = block.code.length > 2000 ? block.code.slice(0, 2000) + "\n..." : block.code; return { type: "bubble", body: { type: "box", layout: "vertical", contents: [ { type: "text", text: titleText, weight: "bold", size: "sm", color: "#666666", } as FlexText, { type: "box", layout: "vertical", contents: [ { type: "text", text: displayCode, size: "xs", color: "#333333", wrap: true, } as FlexText, ], backgroundColor: "#F5F5F5", paddingAll: "md", cornerRadius: "md", margin: "sm", } as FlexBox, ], paddingAll: "lg", }, }; } /** * Extract markdown links from text */ export function extractLinks(text: string): { links: MarkdownLink[]; textWithLinks: string } { const links: MarkdownLink[] = []; // Reset regex state MARKDOWN_LINK_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = MARKDOWN_LINK_REGEX.exec(text)) !== null) { links.push({ text: match[1], url: match[2], }); } // Replace markdown links with just the text (for plain text output) const textWithLinks = text.replace(MARKDOWN_LINK_REGEX, "$1"); return { links, textWithLinks }; } export interface MarkdownLink { text: string; url: string; } /** * Create a Flex Message with tappable link buttons */ export function convertLinksToFlexBubble(links: MarkdownLink[]): FlexBubble { const buttons: FlexComponent[] = links.slice(0, 4).map((link, index) => ({ type: "button", action: { type: "uri", label: link.text.slice(0, 20), // LINE button label limit uri: link.url, }, style: index === 0 ? "primary" : "secondary", margin: index > 0 ? "sm" : undefined, })); return { type: "bubble", body: { type: "box", layout: "vertical", contents: [ { type: "text", text: "Links", weight: "bold", size: "md", color: "#333333", } as FlexText, ], paddingAll: "lg", paddingBottom: "sm", }, footer: { type: "box", layout: "vertical", contents: buttons, paddingAll: "md", }, }; } /** * Strip markdown formatting from text (for plain text output) * Handles: bold, italic, strikethrough, headers, blockquotes, horizontal rules */ export function stripMarkdown(text: string): string { let result = text; // Remove bold: **text** or __text__ result = result.replace(/\*\*(.+?)\*\*/g, "$1"); result = result.replace(/__(.+?)__/g, "$1"); // Remove italic: *text* or _text_ (but not already processed) result = result.replace(/(? text result = result.replace(/^>\s?(.*)$/gm, "$1"); // Remove horizontal rules: ---, ***, ___ result = result.replace(/^[-*_]{3,}$/gm, ""); // Remove inline code: `code` result = result.replace(/`([^`]+)`/g, "$1"); // Clean up extra whitespace result = result.replace(/\n{3,}/g, "\n\n"); result = result.trim(); return result; } /** * Main function: Process text for LINE output * - Extracts tables → Flex Messages * - Extracts code blocks → Flex Messages * - Strips remaining markdown * - Returns processed text + Flex Messages */ export function processLineMessage(text: string): ProcessedLineMessage { const flexMessages: FlexMessage[] = []; let processedText = text; // 1. Extract and convert tables const { tables, textWithoutTables } = extractMarkdownTables(processedText); processedText = textWithoutTables; for (const table of tables) { const bubble = convertTableToFlexBubble(table); flexMessages.push(toFlexMessage("Table", bubble)); } // 2. Extract and convert code blocks const { codeBlocks, textWithoutCode } = extractCodeBlocks(processedText); processedText = textWithoutCode; for (const block of codeBlocks) { const bubble = convertCodeBlockToFlexBubble(block); flexMessages.push(toFlexMessage("Code", bubble)); } // 3. Handle links - convert [text](url) to plain text for display // (We could also create link buttons, but that can get noisy) const { textWithLinks } = extractLinks(processedText); processedText = textWithLinks; // 4. Strip remaining markdown formatting processedText = stripMarkdown(processedText); return { text: processedText, flexMessages, }; } /** * Check if text contains markdown that needs conversion */ export function hasMarkdownToConvert(text: string): boolean { // Check for tables MARKDOWN_TABLE_REGEX.lastIndex = 0; if (MARKDOWN_TABLE_REGEX.test(text)) return true; // Check for code blocks MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0; if (MARKDOWN_CODE_BLOCK_REGEX.test(text)) return true; // Check for other markdown patterns if (/\*\*[^*]+\*\*/.test(text)) return true; // bold if (/~~[^~]+~~/.test(text)) return true; // strikethrough if (/^#{1,6}\s+/m.test(text)) return true; // headers if (/^>\s+/m.test(text)) return true; // blockquotes return false; }