Files
adolf/extensions/feishu/src/post.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

287 lines
7.9 KiB
TypeScript

// Feishu plugin module implements post behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isRecord } from "./comment-shared.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
const FALLBACK_POST_TEXT = "[Rich text message]";
const MARKDOWN_SPECIAL_CHARS = /([\\`*_{}[\]()#+\-!|>~])/g;
type PostParseResult = {
textContent: string;
imageKeys: string[];
mediaKeys: Array<{ fileKey: string; fileName?: string }>;
mentionedOpenIds: string[];
};
type PostPayload = {
title: string;
content: unknown[];
};
function toStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value : "";
}
function escapeMarkdownText(text: string): string {
return text.replace(MARKDOWN_SPECIAL_CHARS, "\\$1");
}
function toBoolean(value: unknown): boolean {
return value === true || value === 1 || value === "true";
}
function isStyleEnabled(style: Record<string, unknown> | undefined, key: string): boolean {
if (!style) {
return false;
}
return toBoolean(style[key]);
}
function wrapInlineCode(text: string): string {
const maxRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
const fence = "`".repeat(maxRun + 1);
const needsPadding = text.startsWith("`") || text.endsWith("`");
const body = needsPadding ? ` ${text} ` : text;
return `${fence}${body}${fence}`;
}
function sanitizeFenceLanguage(language: string): string {
return language.trim().replace(/[^A-Za-z0-9_+#.-]/g, "");
}
function renderTextElement(element: Record<string, unknown>): string {
const text = toStringOrEmpty(element.text);
const style = isRecord(element.style) ? element.style : undefined;
if (isStyleEnabled(style, "code")) {
return wrapInlineCode(text);
}
let rendered = escapeMarkdownText(text);
if (!rendered) {
return "";
}
if (isStyleEnabled(style, "bold")) {
rendered = `**${rendered}**`;
}
if (isStyleEnabled(style, "italic")) {
rendered = `*${rendered}*`;
}
if (isStyleEnabled(style, "underline")) {
rendered = `<u>${rendered}</u>`;
}
if (
isStyleEnabled(style, "strikethrough") ||
isStyleEnabled(style, "line_through") ||
isStyleEnabled(style, "lineThrough")
) {
rendered = `~~${rendered}~~`;
}
return rendered;
}
function renderLinkElement(element: Record<string, unknown>): string {
const href = toStringOrEmpty(element.href).trim();
const rawText = toStringOrEmpty(element.text);
const text = rawText || href;
if (!text) {
return "";
}
if (!href) {
return escapeMarkdownText(text);
}
return `[${escapeMarkdownText(text)}](${href})`;
}
function renderMentionElement(element: Record<string, unknown>): string {
const mention =
toStringOrEmpty(element.user_name) ||
toStringOrEmpty(element.user_id) ||
toStringOrEmpty(element.open_id);
if (!mention) {
return "";
}
return `@${escapeMarkdownText(mention)}`;
}
function renderEmotionElement(element: Record<string, unknown>): string {
const text =
toStringOrEmpty(element.emoji) ||
toStringOrEmpty(element.text) ||
toStringOrEmpty(element.emoji_type);
return escapeMarkdownText(text);
}
function renderCodeBlockElement(element: Record<string, unknown>): string {
const language = sanitizeFenceLanguage(
toStringOrEmpty(element.language) || toStringOrEmpty(element.lang),
);
const code = (toStringOrEmpty(element.text) || toStringOrEmpty(element.content)).replace(
/\r\n/g,
"\n",
);
const trailingNewline = code.endsWith("\n") ? "" : "\n";
return `\`\`\`${language}\n${code}${trailingNewline}\`\`\``;
}
function renderElement(
element: unknown,
imageKeys: string[],
mediaKeys: Array<{ fileKey: string; fileName?: string }>,
mentionedOpenIds: string[],
renderMediaPlaceholders: boolean,
): string {
if (!isRecord(element)) {
return escapeMarkdownText(toStringOrEmpty(element));
}
const tag = normalizeLowercaseStringOrEmpty(toStringOrEmpty(element.tag));
switch (tag) {
case "text":
return renderTextElement(element);
case "a":
return renderLinkElement(element);
case "at":
{
const mentioned = toStringOrEmpty(element.open_id) || toStringOrEmpty(element.user_id);
const normalizedMention = normalizeFeishuExternalKey(mentioned);
if (normalizedMention) {
mentionedOpenIds.push(normalizedMention);
}
}
return renderMentionElement(element);
case "img": {
const imageKey = normalizeFeishuExternalKey(toStringOrEmpty(element.image_key));
if (imageKey) {
imageKeys.push(imageKey);
}
return renderMediaPlaceholders ? "![image]" : "";
}
case "media": {
const fileKey = normalizeFeishuExternalKey(toStringOrEmpty(element.file_key));
if (fileKey) {
const fileName = toStringOrEmpty(element.file_name) || undefined;
mediaKeys.push({ fileKey, fileName });
}
return renderMediaPlaceholders ? "[media]" : "";
}
case "emotion":
return renderEmotionElement(element);
case "md":
case "lark_md":
return toStringOrEmpty(element.text) || toStringOrEmpty(element.content);
case "br":
return "\n";
case "hr":
return "\n\n---\n\n";
case "code": {
const code = toStringOrEmpty(element.text) || toStringOrEmpty(element.content);
return code ? wrapInlineCode(code) : "";
}
case "code_block":
case "pre":
return renderCodeBlockElement(element);
default:
return escapeMarkdownText(toStringOrEmpty(element.text));
}
}
function toPostPayload(candidate: unknown): PostPayload | null {
if (!isRecord(candidate) || !Array.isArray(candidate.content)) {
return null;
}
return {
title: toStringOrEmpty(candidate.title),
content: candidate.content,
};
}
function resolveLocalePayload(candidate: unknown): PostPayload | null {
const direct = toPostPayload(candidate);
if (direct) {
return direct;
}
if (!isRecord(candidate)) {
return null;
}
for (const value of Object.values(candidate)) {
const localePayload = toPostPayload(value);
if (localePayload) {
return localePayload;
}
}
return null;
}
function resolvePostPayload(parsed: unknown): PostPayload | null {
const direct = toPostPayload(parsed);
if (direct) {
return direct;
}
if (!isRecord(parsed)) {
return null;
}
const wrappedPost = resolveLocalePayload(parsed.post);
if (wrappedPost) {
return wrappedPost;
}
return resolveLocalePayload(parsed);
}
export function parsePostContent(
content: string,
options: { renderMediaPlaceholders?: boolean; emptyTextFallback?: string } = {},
): PostParseResult {
try {
const parsed = JSON.parse(content);
const payload = resolvePostPayload(parsed);
if (!payload) {
return {
textContent: FALLBACK_POST_TEXT,
imageKeys: [],
mediaKeys: [],
mentionedOpenIds: [],
};
}
const imageKeys: string[] = [];
const mediaKeys: Array<{ fileKey: string; fileName?: string }> = [];
const mentionedOpenIds: string[] = [];
const paragraphs: string[] = [];
for (const paragraph of payload.content) {
if (!Array.isArray(paragraph)) {
continue;
}
let renderedParagraph = "";
for (const element of paragraph) {
renderedParagraph += renderElement(
element,
imageKeys,
mediaKeys,
mentionedOpenIds,
options.renderMediaPlaceholders !== false,
);
}
paragraphs.push(renderedParagraph);
}
const title = escapeMarkdownText(payload.title.trim());
const body = paragraphs.join("\n").trim();
const textContent = [title, body].filter(Boolean).join("\n\n").trim();
return {
textContent: textContent || (options.emptyTextFallback ?? FALLBACK_POST_TEXT),
imageKeys,
mediaKeys,
mentionedOpenIds,
};
} catch {
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mediaKeys: [], mentionedOpenIds: [] };
}
}