Vendor OpenClaw source as Adolf fork baseline
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
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
This commit is contained in:
495
extensions/feishu/src/comment-shared.ts
Normal file
495
extensions/feishu/src/comment-shared.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
// Feishu plugin module implements comment shared behavior.
|
||||
import {
|
||||
isRecord as sharedIsRecord,
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
readStringValue,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { FEISHU_COMMENT_FILE_TYPES, type CommentFileType } from "./comment-target.js";
|
||||
|
||||
export function encodeQuery(params: Record<string, string | undefined>): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) {
|
||||
query.set(key, trimmed);
|
||||
}
|
||||
}
|
||||
const queryString = query.toString();
|
||||
return queryString ? `?${queryString}` : "";
|
||||
}
|
||||
|
||||
export const readString = readStringValue;
|
||||
|
||||
export const normalizeString = normalizeOptionalString;
|
||||
|
||||
export const isRecord = sharedIsRecord;
|
||||
|
||||
export function formatFeishuApiError(
|
||||
error: unknown,
|
||||
options: {
|
||||
includeConfigParams?: boolean;
|
||||
includeNestedErrorLogId?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
if (!isRecord(error)) {
|
||||
return typeof error === "string" ? error : JSON.stringify(error);
|
||||
}
|
||||
const config = isRecord(error.config) ? error.config : undefined;
|
||||
const response = isRecord(error.response) ? error.response : undefined;
|
||||
const responseData = isRecord(response?.data) ? response?.data : undefined;
|
||||
const feishuLogId =
|
||||
readString(responseData?.log_id) ||
|
||||
(options.includeNestedErrorLogId
|
||||
? readString(isRecord(responseData?.error) ? responseData.error.log_id : undefined)
|
||||
: undefined);
|
||||
const nestedError = isRecord(responseData?.error) ? responseData.error : undefined;
|
||||
|
||||
return JSON.stringify({
|
||||
message:
|
||||
typeof error.message === "string"
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: JSON.stringify(error),
|
||||
code: readString(error.code),
|
||||
method: readString(config?.method),
|
||||
url: readString(config?.url),
|
||||
...(options.includeConfigParams ? { params: config?.params } : {}),
|
||||
http_status: typeof response?.status === "number" ? response.status : undefined,
|
||||
feishu_code:
|
||||
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
|
||||
feishu_msg: readString(responseData?.msg),
|
||||
feishu_log_id: feishuLogId,
|
||||
feishu_troubleshooter:
|
||||
readString(responseData?.troubleshooter) || readString(nestedError?.troubleshooter),
|
||||
});
|
||||
}
|
||||
|
||||
function formatFeishuApiFailure(
|
||||
error: unknown,
|
||||
errorPrefix: string,
|
||||
options: {
|
||||
includeConfigParams?: boolean;
|
||||
includeNestedErrorLogId?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const details = formatFeishuApiError(error, options);
|
||||
return `${errorPrefix}: ${details || "unknown error"}`;
|
||||
}
|
||||
|
||||
export function createFeishuApiError(
|
||||
error: unknown,
|
||||
errorPrefix: string,
|
||||
options: {
|
||||
includeConfigParams?: boolean;
|
||||
includeNestedErrorLogId?: boolean;
|
||||
} = {},
|
||||
): Error {
|
||||
return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error });
|
||||
}
|
||||
|
||||
// Feishu message-API error codes that signal a transient rate limit; safe to retry with backoff.
|
||||
// 230020: per-chat rate limit (ext=chat rate limit) — confirmed by real concurrent load test.
|
||||
// 11232: tenant-level "create message service trigger rate limit" (100/min, 5/sec per app/bot).
|
||||
// Distinct from FEISHU_BACKOFF_CODES in typing.ts, which covers the reaction API (99991400+).
|
||||
const FEISHU_SEND_RATE_LIMIT_CODES = new Set([230020, 11232]);
|
||||
const FEISHU_SEND_MAX_RETRIES = 2;
|
||||
const FEISHU_SEND_RETRY_BASE_MS = 500;
|
||||
|
||||
/**
|
||||
* Returns a numeric rate-limit signal when an AxiosError indicates a retryable
|
||||
* Feishu message-API rate limit. Sources, in priority order:
|
||||
* 1. Gateway-level HTTP 429 (app-wide quota; `x-ogw-ratelimit-reset` header)
|
||||
* 2. Business-level `code` in `error.response.data.code` matching
|
||||
* FEISHU_SEND_RATE_LIMIT_CODES (e.g. 230020 per-chat, 11232 tenant-level).
|
||||
* Returns `undefined` for all other errors so they propagate without retry.
|
||||
*/
|
||||
export function getFeishuSendRateLimitCode(error: unknown): number | undefined {
|
||||
if (!isRecord(error)) {
|
||||
return undefined;
|
||||
}
|
||||
const response = isRecord(error.response) ? error.response : undefined;
|
||||
// HTTP 429: Feishu Open API gateway-level rate limit, always retry.
|
||||
if (typeof response?.status === "number" && response.status === 429) {
|
||||
return 429;
|
||||
}
|
||||
const data = isRecord(response?.data) ? response.data : undefined;
|
||||
const code = data?.code;
|
||||
return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a retryable rate-limit code when a fulfilled (non-throwing) Feishu
|
||||
* SDK response embeds it in the response body. The Feishu node SDK can resolve
|
||||
* with `{ code: 11232, msg: "..." }` instead of throwing — see typing.ts
|
||||
* (getBackoffCodeFromResponse) and issue #28157 for the same behavior on
|
||||
* messageReaction.create. Without this classification, requestFeishuApi would
|
||||
* `return` the rate-limited body and downstream `assertFeishuMessageApiSuccess`
|
||||
* would fail once with no retry.
|
||||
*/
|
||||
export function getFeishuSendRateLimitCodeFromResponse(response: unknown): number | undefined {
|
||||
if (!isRecord(response)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = (response as { code?: unknown }).code;
|
||||
return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export async function requestFeishuApi<T>(
|
||||
request: () => Promise<T>,
|
||||
errorPrefix: string,
|
||||
options: {
|
||||
includeConfigParams?: boolean;
|
||||
includeNestedErrorLogId?: boolean;
|
||||
/** Base delay per retry attempt in ms; multiplied by attempt index. @internal */
|
||||
retryDelayMs?: number;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const retryDelayMs = options.retryDelayMs ?? FEISHU_SEND_RETRY_BASE_MS;
|
||||
let lastFulfilledRateLimit: { response: unknown; code: number } | undefined;
|
||||
for (let attempt = 0; attempt <= FEISHU_SEND_MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
// Linear backoff: delay grows with each attempt to give the rate-limit window time to reset.
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, attempt * retryDelayMs);
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await request();
|
||||
// Feishu SDK may fulfill with a rate-limit body (e.g. { code: 11232, ... })
|
||||
// instead of throwing. Classify before returning so retry covers both shapes.
|
||||
const fulfilledRateLimit = getFeishuSendRateLimitCodeFromResponse(result);
|
||||
if (fulfilledRateLimit !== undefined) {
|
||||
// Capture for the synthetic-error path below; on a non-final attempt
|
||||
// continue retrying, on the final attempt fall through so the loop
|
||||
// exits and the wrapped exhaustion error is thrown.
|
||||
lastFulfilledRateLimit = { response: result, code: fulfilledRateLimit };
|
||||
if (attempt < FEISHU_SEND_MAX_RETRIES) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const isRetryable =
|
||||
attempt < FEISHU_SEND_MAX_RETRIES && getFeishuSendRateLimitCode(error) !== undefined;
|
||||
if (!isRetryable) {
|
||||
throw createFeishuApiError(error, errorPrefix, options);
|
||||
}
|
||||
// Rate-limit on a non-final attempt — loop continues to next retry.
|
||||
}
|
||||
}
|
||||
// Exhausted retries while the SDK kept fulfilling rate-limit bodies. Surface
|
||||
// the last response as an error so callers see the same wrapped shape they
|
||||
// would have seen if the SDK had thrown.
|
||||
if (lastFulfilledRateLimit) {
|
||||
const synthetic = Object.assign(
|
||||
new Error(`Request fulfilled with rate-limit code ${lastFulfilledRateLimit.code}`),
|
||||
{ response: { status: 200, data: lastFulfilledRateLimit.response } },
|
||||
);
|
||||
throw createFeishuApiError(synthetic, errorPrefix, options);
|
||||
}
|
||||
// Unreachable: every iteration either returns or throws. Required for TypeScript exhaustiveness.
|
||||
throw createFeishuApiError(new Error("unreachable"), errorPrefix, options);
|
||||
}
|
||||
|
||||
type ParsedCommentDocumentRef = {
|
||||
fileType?: CommentFileType;
|
||||
fileToken?: string;
|
||||
};
|
||||
|
||||
type ParsedCommentMention = {
|
||||
userId: string;
|
||||
displayText: string;
|
||||
isBotMention: boolean;
|
||||
};
|
||||
|
||||
type ParsedCommentLinkedDocumentKind =
|
||||
| CommentFileType
|
||||
| "wiki"
|
||||
| "mindnote"
|
||||
| "bitable"
|
||||
| "base"
|
||||
| "unknown";
|
||||
|
||||
type ParsedCommentResolvedDocumentType = Exclude<
|
||||
ParsedCommentLinkedDocumentKind,
|
||||
"wiki" | "unknown"
|
||||
>;
|
||||
|
||||
export type ParsedCommentLinkedDocument = {
|
||||
rawUrl: string;
|
||||
urlKind: ParsedCommentLinkedDocumentKind;
|
||||
wikiNodeToken?: string;
|
||||
resolvedObjType?: ParsedCommentResolvedDocumentType;
|
||||
resolvedObjToken?: string;
|
||||
isCurrentDocument?: boolean;
|
||||
};
|
||||
|
||||
export type ParsedCommentContent = {
|
||||
plainText?: string;
|
||||
semanticText?: string;
|
||||
mentions: ParsedCommentMention[];
|
||||
linkedDocuments: ParsedCommentLinkedDocument[];
|
||||
botMentioned: boolean;
|
||||
};
|
||||
|
||||
function readDocsLinkUrl(element: Record<string, unknown>): string | undefined {
|
||||
const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined;
|
||||
return (
|
||||
normalizeString(docsLink?.url) ||
|
||||
normalizeString(docsLink?.link) ||
|
||||
normalizeString(element.url) ||
|
||||
normalizeString(element.link) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function readMentionUserId(element: Record<string, unknown>): string | undefined {
|
||||
const mention = isRecord(element.mention) ? element.mention : undefined;
|
||||
const person = isRecord(element.person) ? element.person : undefined;
|
||||
return (
|
||||
normalizeString(person?.user_id) ||
|
||||
normalizeString(mention?.user_id) ||
|
||||
normalizeString(mention?.open_id) ||
|
||||
normalizeString(element.mention_user) ||
|
||||
normalizeString(element.user_id) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function readMentionDisplayText(element: Record<string, unknown>, userId: string): string {
|
||||
const mention = isRecord(element.mention) ? element.mention : undefined;
|
||||
const mentionName =
|
||||
normalizeString(mention?.name) ||
|
||||
normalizeString(mention?.display_name) ||
|
||||
normalizeString(element.name);
|
||||
return mentionName ? `@${mentionName}` : `@${userId}`;
|
||||
}
|
||||
|
||||
function normalizeCommentText(parts: string[]): string | undefined {
|
||||
const text = parts.join("").trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function normalizeCommentSemanticText(parts: string[]): string | undefined {
|
||||
const text = parts.join("").replace(/\s+/g, " ").trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function readElementTextPreservingWhitespace(element: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
(isRecord(element.text_run)
|
||||
? readString(element.text_run.content) || readString(element.text_run.text)
|
||||
: undefined) ||
|
||||
readString(element.text) ||
|
||||
readString(element.content) ||
|
||||
readString(element.name) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
const FEISHU_LINK_TOKEN_MIN_LENGTH = 22;
|
||||
const FEISHU_LINK_TOKEN_MAX_LENGTH = 28;
|
||||
const COMMENT_LINK_KIND_ALIASES = new Map<string, ParsedCommentResolvedDocumentType | "wiki">([
|
||||
["doc", "doc"],
|
||||
["docs", "doc"],
|
||||
["docx", "docx"],
|
||||
["sheet", "sheet"],
|
||||
["sheets", "sheet"],
|
||||
["slide", "slides"],
|
||||
["slides", "slides"],
|
||||
["file", "file"],
|
||||
["files", "file"],
|
||||
["wiki", "wiki"],
|
||||
["mindnote", "mindnote"],
|
||||
["mindnotes", "mindnote"],
|
||||
["bitable", "bitable"],
|
||||
["base", "base"],
|
||||
]);
|
||||
|
||||
function isCommentFileType(
|
||||
value: ParsedCommentResolvedDocumentType | "wiki" | undefined,
|
||||
): value is CommentFileType {
|
||||
return (
|
||||
typeof value === "string" && (FEISHU_COMMENT_FILE_TYPES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isReasonableFeishuLinkToken(token: string | undefined): token is string {
|
||||
return (
|
||||
typeof token === "string" &&
|
||||
token.length >= FEISHU_LINK_TOKEN_MIN_LENGTH &&
|
||||
token.length <= FEISHU_LINK_TOKEN_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
function parseCommentLinkedDocumentPath(pathname: string): {
|
||||
urlKind: ParsedCommentResolvedDocumentType | "wiki";
|
||||
token: string;
|
||||
} | null {
|
||||
const segments = normalizeStringEntries(pathname.split("/"));
|
||||
const offset = segments[0]?.toLowerCase() === "space" ? 1 : 0;
|
||||
const kind = COMMENT_LINK_KIND_ALIASES.get(segments[offset]?.toLowerCase() ?? "");
|
||||
const token = normalizeString(segments[offset + 1]);
|
||||
if (!kind || !isReasonableFeishuLinkToken(token)) {
|
||||
return null;
|
||||
}
|
||||
return { urlKind: kind, token };
|
||||
}
|
||||
|
||||
function hasResolvedLinkedDocumentReference(link: ParsedCommentLinkedDocument): boolean {
|
||||
return (
|
||||
link.urlKind !== "unknown" && (Boolean(link.resolvedObjToken) || Boolean(link.wikiNodeToken))
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCommentLinkedDocumentFromUrl(params: {
|
||||
rawUrl: string;
|
||||
currentDocument?: ParsedCommentDocumentRef;
|
||||
}): ParsedCommentLinkedDocument {
|
||||
const link: ParsedCommentLinkedDocument = {
|
||||
rawUrl: params.rawUrl,
|
||||
urlKind: "unknown",
|
||||
};
|
||||
try {
|
||||
const parsed = new URL(params.rawUrl);
|
||||
const parsedPath = parseCommentLinkedDocumentPath(parsed.pathname);
|
||||
if (!parsedPath) {
|
||||
return link;
|
||||
}
|
||||
const { urlKind, token } = parsedPath;
|
||||
link.urlKind = urlKind;
|
||||
if (urlKind === "wiki") {
|
||||
link.urlKind = "wiki";
|
||||
link.wikiNodeToken = token;
|
||||
} else {
|
||||
link.resolvedObjType = urlKind;
|
||||
link.resolvedObjToken = token;
|
||||
}
|
||||
if (
|
||||
link.resolvedObjType &&
|
||||
link.resolvedObjToken &&
|
||||
isCommentFileType(link.resolvedObjType) &&
|
||||
params.currentDocument?.fileType === link.resolvedObjType &&
|
||||
params.currentDocument.fileToken === link.resolvedObjToken
|
||||
) {
|
||||
link.isCurrentDocument = true;
|
||||
} else if (
|
||||
link.resolvedObjType &&
|
||||
link.resolvedObjToken &&
|
||||
isCommentFileType(link.resolvedObjType)
|
||||
) {
|
||||
link.isCurrentDocument = false;
|
||||
}
|
||||
} catch {
|
||||
return link;
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
export function parseCommentContentElements(params: {
|
||||
elements?: unknown[];
|
||||
botOpenIds?: Iterable<string | undefined>;
|
||||
currentDocument?: ParsedCommentDocumentRef;
|
||||
}): ParsedCommentContent {
|
||||
const elements = Array.isArray(params.elements) ? params.elements : [];
|
||||
const plainTextParts: string[] = [];
|
||||
const semanticTextParts: string[] = [];
|
||||
const mentions: ParsedCommentMention[] = [];
|
||||
const linkedDocuments: ParsedCommentLinkedDocument[] = [];
|
||||
const botIds = new Set(
|
||||
Array.from(params.botOpenIds ?? [])
|
||||
.map((value) => normalizeString(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
const linkedDocumentKeys = new Set<string>();
|
||||
let botMentioned = false;
|
||||
|
||||
for (const rawElement of elements) {
|
||||
if (!isRecord(rawElement)) {
|
||||
continue;
|
||||
}
|
||||
const element = rawElement;
|
||||
const type = normalizeString(element.type);
|
||||
const text =
|
||||
(type === "text_run" ? readElementTextPreservingWhitespace(element) : undefined) ||
|
||||
(type === "text" ? readElementTextPreservingWhitespace(element) : undefined) ||
|
||||
(type === "docs_link" || type === "link" ? readDocsLinkUrl(element) : undefined) ||
|
||||
(type === "mention" || type === "mention_user" || type === "person"
|
||||
? (() => {
|
||||
const userId = readMentionUserId(element);
|
||||
return userId ? readMentionDisplayText(element, userId) : undefined;
|
||||
})()
|
||||
: undefined) ||
|
||||
readElementTextPreservingWhitespace(element) ||
|
||||
undefined;
|
||||
|
||||
if (type === "mention" || type === "mention_user" || type === "person") {
|
||||
const userId = readMentionUserId(element);
|
||||
if (userId) {
|
||||
const displayText = readMentionDisplayText(element, userId);
|
||||
const isBotMention = botIds.has(userId);
|
||||
mentions.push({ userId, displayText, isBotMention });
|
||||
plainTextParts.push(displayText);
|
||||
if (!isBotMention) {
|
||||
semanticTextParts.push(displayText);
|
||||
} else {
|
||||
botMentioned = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "docs_link" || type === "link") {
|
||||
const rawUrl = readDocsLinkUrl(element);
|
||||
if (rawUrl) {
|
||||
plainTextParts.push(rawUrl);
|
||||
semanticTextParts.push(rawUrl);
|
||||
const linkedDocument = resolveCommentLinkedDocumentFromUrl({
|
||||
rawUrl,
|
||||
currentDocument: params.currentDocument,
|
||||
});
|
||||
if (hasResolvedLinkedDocumentReference(linkedDocument)) {
|
||||
const key = [
|
||||
linkedDocument.rawUrl,
|
||||
linkedDocument.urlKind,
|
||||
linkedDocument.resolvedObjType,
|
||||
linkedDocument.resolvedObjToken,
|
||||
linkedDocument.wikiNodeToken,
|
||||
].join(":");
|
||||
if (!linkedDocumentKeys.has(key)) {
|
||||
linkedDocumentKeys.add(key);
|
||||
linkedDocuments.push(linkedDocument);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (text) {
|
||||
plainTextParts.push(text);
|
||||
semanticTextParts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plainText: normalizeCommentText(plainTextParts),
|
||||
semanticText: normalizeCommentSemanticText(semanticTextParts),
|
||||
mentions,
|
||||
linkedDocuments,
|
||||
botMentioned,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractReplyText(
|
||||
reply: { content?: { elements?: unknown[] } } | undefined,
|
||||
): string | undefined {
|
||||
if (!reply || !isRecord(reply.content)) {
|
||||
return undefined;
|
||||
}
|
||||
return parseCommentContentElements({
|
||||
elements: Array.isArray(reply.content.elements) ? reply.content.elements : [],
|
||||
}).plainText;
|
||||
}
|
||||
Reference in New Issue
Block a user