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
287 lines
7.9 KiB
TypeScript
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: [] };
|
|
}
|
|
}
|