import type { messagingApi } from "@line/bot-sdk" ;
import { stripMarkdown } from "openclaw/plugin-sdk/text-runtime" ;
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js" ;
export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime" ;
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" ,
},
};
}
/**
* 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 ;
}
Messung V0.5 in Prozent C=92 H=90 G=90
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-04)
¤
*© Formatika GbR, Deutschland