Files
openclaw/extensions/feishu/src/comment-shared.ts
2026-05-02 05:52:46 +01:00

407 lines
12 KiB
TypeScript

import {
isRecord as sharedIsRecord,
normalizeOptionalString,
readStringValue,
} from "openclaw/plugin-sdk/text-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),
});
}
export 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 });
}
export async function requestFeishuApi<T>(
request: () => Promise<T>,
errorPrefix: string,
options: {
includeConfigParams?: boolean;
includeNestedErrorLogId?: boolean;
} = {},
): Promise<T> {
try {
return await request();
} catch (error) {
throw createFeishuApiError(error, 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 = pathname
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
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;
}