mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:11:10 +00:00
417 lines
11 KiB
TypeScript
417 lines
11 KiB
TypeScript
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;
|
|
}
|