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
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
417 lines
11 KiB
TypeScript
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;
|
|
}
|