Files
openclaw/src/feishu/format.ts
2026-02-03 14:27:39 -08:00

268 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { MarkdownTableMode } from "../config/types.base.js";
import {
chunkMarkdownIR,
markdownToIR,
type MarkdownIR,
type MarkdownLinkSpan,
type MarkdownStyleSpan,
} from "../markdown/ir.js";
/**
* Feishu Post (rich text) format
* Reference: https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json#c9e08671
*/
export type FeishuPostElement =
| { tag: "text"; text: string; style?: string[] }
| { tag: "a"; text: string; href: string; style?: string[] }
| { tag: "at"; user_id: string }
| { tag: "img"; image_key: string }
| { tag: "media"; file_key: string }
| { tag: "emotion"; emoji_type: string };
export type FeishuPostLine = FeishuPostElement[];
export type FeishuPostContent = {
zh_cn?: {
title?: string;
content: FeishuPostLine[];
};
en_us?: {
title?: string;
content: FeishuPostLine[];
};
};
export type FeishuFormattedChunk = {
post: FeishuPostContent;
text: string;
};
type StyleState = {
bold: boolean;
italic: boolean;
strikethrough: boolean;
code: boolean;
};
/**
* Convert MarkdownIR to Feishu Post format
*/
function renderFeishuPost(ir: MarkdownIR): FeishuPostContent {
const lines: FeishuPostLine[] = [];
const text = ir.text;
if (!text) {
return { zh_cn: { content: [[{ tag: "text", text: "" }]] } };
}
// Build a map of style ranges for quick lookup
const styleRanges = buildStyleRanges(ir.styles, text.length);
const linkMap = buildLinkMap(ir.links);
// Split text into lines
const textLines = text.split("\n");
let charIndex = 0;
for (const line of textLines) {
const lineElements: FeishuPostElement[] = [];
if (line.length === 0) {
// Empty line - add empty text element
lineElements.push({ tag: "text", text: "" });
} else {
// Process each character segment with consistent styling
let segmentStart = charIndex;
let currentStyles = getStylesAt(styleRanges, segmentStart);
let currentLink = getLinkAt(linkMap, segmentStart);
for (let i = 0; i < line.length; i++) {
const pos = charIndex + i;
const newStyles = getStylesAt(styleRanges, pos);
const newLink = getLinkAt(linkMap, pos);
// Check if style or link changed
const stylesChanged = !stylesEqual(currentStyles, newStyles);
const linkChanged = currentLink !== newLink;
if (stylesChanged || linkChanged) {
// Emit previous segment
const segmentText = text.slice(segmentStart, pos);
if (segmentText) {
lineElements.push(createPostElement(segmentText, currentStyles, currentLink));
}
segmentStart = pos;
currentStyles = newStyles;
currentLink = newLink;
}
}
// Emit final segment of the line
const finalText = text.slice(segmentStart, charIndex + line.length);
if (finalText) {
lineElements.push(createPostElement(finalText, currentStyles, currentLink));
}
}
lines.push(lineElements.length > 0 ? lineElements : [{ tag: "text", text: "" }]);
charIndex += line.length + 1; // +1 for newline
}
return {
zh_cn: {
content: lines,
},
};
}
function buildStyleRanges(styles: MarkdownStyleSpan[], textLength: number): StyleState[] {
const ranges: StyleState[] = Array(textLength)
.fill(null)
.map(() => ({
bold: false,
italic: false,
strikethrough: false,
code: false,
}));
for (const span of styles) {
for (let i = span.start; i < span.end && i < textLength; i++) {
switch (span.style) {
case "bold":
ranges[i].bold = true;
break;
case "italic":
ranges[i].italic = true;
break;
case "strikethrough":
ranges[i].strikethrough = true;
break;
case "code":
case "code_block":
ranges[i].code = true;
break;
}
}
}
return ranges;
}
function buildLinkMap(links: MarkdownLinkSpan[]): Map<number, string> {
const map = new Map<number, string>();
for (const link of links) {
for (let i = link.start; i < link.end; i++) {
map.set(i, link.href);
}
}
return map;
}
function getStylesAt(ranges: StyleState[], pos: number): StyleState {
return ranges[pos] ?? { bold: false, italic: false, strikethrough: false, code: false };
}
function getLinkAt(linkMap: Map<number, string>, pos: number): string | undefined {
return linkMap.get(pos);
}
function stylesEqual(a: StyleState, b: StyleState): boolean {
return (
a.bold === b.bold &&
a.italic === b.italic &&
a.strikethrough === b.strikethrough &&
a.code === b.code
);
}
function createPostElement(text: string, styles: StyleState, link?: string): FeishuPostElement {
const styleArray: string[] = [];
if (styles.bold) {
styleArray.push("bold");
}
if (styles.italic) {
styleArray.push("italic");
}
if (styles.strikethrough) {
styleArray.push("lineThrough");
}
if (styles.code) {
styleArray.push("code");
}
if (link) {
return {
tag: "a",
text,
href: link,
...(styleArray.length > 0 ? { style: styleArray } : {}),
};
}
return {
tag: "text",
text,
...(styleArray.length > 0 ? { style: styleArray } : {}),
};
}
/**
* Convert Markdown to Feishu Post format
*/
export function markdownToFeishuPost(
markdown: string,
options: { tableMode?: MarkdownTableMode } = {},
): FeishuPostContent {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "bold",
blockquotePrefix: " ",
tableMode: options.tableMode,
});
return renderFeishuPost(ir);
}
/**
* Convert Markdown to Feishu Post chunks (for long messages)
*/
export function markdownToFeishuChunks(
markdown: string,
limit: number,
options: { tableMode?: MarkdownTableMode } = {},
): FeishuFormattedChunk[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "bold",
blockquotePrefix: " ",
tableMode: options.tableMode,
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => ({
post: renderFeishuPost(chunk),
text: chunk.text,
}));
}
/**
* Check if text contains Markdown formatting
*/
export function containsMarkdown(text: string): boolean {
if (!text) {
return false;
}
// Check for common Markdown patterns
const markdownPatterns = [
/\*\*[^*]+\*\*/, // bold
/\*[^*]+\*/, // italic
/~~[^~]+~~/, // strikethrough
/`[^`]+`/, // inline code
/```[\s\S]*```/, // code block
/\[.+\]\(.+\)/, // links
/^#{1,6}\s/m, // headings
/^[-*]\s/m, // unordered list
/^\d+\.\s/m, // ordered list
];
return markdownPatterns.some((pattern) => pattern.test(text));
}