Files
adolf/extensions/line/src/markdown-to-line.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

417 lines
11 KiB
TypeScript

// Line plugin module implements markdown to line behavior.
import type { messagingApi } from "@line/bot-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { uriAction } from "./actions.js";
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
export { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
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 ? truncateUtf16Safe(block.code, 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: uriAction(link.text, 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;
}