Files
openclaw/extensions/feishu/src/monitor.comment.ts
2026-05-01 16:20:36 +01:00

1387 lines
48 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 { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { ClawdbotConfig } from "../runtime-api.js";
import { raceWithTimeoutAndAbort } from "./async.js";
import { createFeishuClient } from "./client.js";
import {
encodeQuery,
extractReplyText,
isRecord,
normalizeString,
parseCommentContentElements,
type ParsedCommentContent,
type ParsedCommentLinkedDocument,
readString,
} from "./comment-shared.js";
import { normalizeCommentFileType, type CommentFileType } from "./comment-target.js";
import type { ResolvedFeishuAccount } from "./types.js";
const FEISHU_COMMENT_VERIFY_TIMEOUT_MS = 3_000;
const FEISHU_COMMENT_LIST_PAGE_SIZE = 100;
const FEISHU_COMMENT_LIST_PAGE_LIMIT = 5;
const FEISHU_COMMENT_REPLY_PAGE_SIZE = 100;
const FEISHU_COMMENT_REPLY_PAGE_LIMIT = 5;
const FEISHU_COMMENT_REPLY_MISS_RETRY_DELAY_MS = 1_000;
const FEISHU_COMMENT_REPLY_MISS_RETRY_LIMIT = 6;
const FEISHU_COMMENT_THREAD_PROMPT_LIMIT = 20;
const FEISHU_WHOLE_COMMENT_PROMPT_LIMIT = 12;
const FEISHU_PROMPT_TEXT_LIMIT = 220;
type FeishuDriveCommentUserId = {
open_id?: string;
user_id?: string;
union_id?: string;
};
export type FeishuDriveCommentNoticeEvent = {
comment_id?: string;
event_id?: string;
is_mentioned?: boolean;
notice_meta?: {
file_token?: string;
file_type?: string;
from_user_id?: FeishuDriveCommentUserId;
notice_type?: string;
to_user_id?: FeishuDriveCommentUserId;
};
reply_id?: string;
timestamp?: string;
type?: string;
};
type ResolveDriveCommentEventParams = {
cfg: ClawdbotConfig;
accountId: string;
event: FeishuDriveCommentNoticeEvent;
account?: ResolvedFeishuAccount;
botOpenId?: string;
createClient?: (account: ResolvedFeishuAccount) => FeishuRequestClient;
verificationTimeoutMs?: number;
logger?: (message: string) => void;
waitMs?: (ms: number) => Promise<void>;
};
type ResolvedDriveCommentEventTurn = {
eventId: string;
messageId: string;
commentId: string;
replyId?: string;
noticeType: "add_comment" | "add_reply";
fileToken: string;
fileType: CommentFileType;
isWholeComment?: boolean;
senderId: string;
senderUserId?: string;
timestamp?: string;
isMentioned?: boolean;
documentTitle?: string;
documentUrl?: string;
quoteText?: string;
rootCommentText?: string;
targetReplyText?: string;
prompt: string;
preview: string;
};
type FeishuRequestClient = ReturnType<typeof createFeishuClient> & {
request(params: {
method: "GET" | "POST";
url: string;
data: unknown;
timeout: number;
}): Promise<unknown>;
};
type FeishuOpenApiResponse<T> = {
code?: number;
log_id?: string;
msg?: string;
data?: T;
};
type FeishuDriveMetaBatchQueryResponse = FeishuOpenApiResponse<{
metas?: Array<{
doc_token?: string;
title?: string;
url?: string;
}>;
}>;
type FeishuDriveCommentReply = {
reply_id?: string;
user_id?: string;
create_time?: number;
update_time?: number;
content?: {
elements?: unknown[];
};
};
type FeishuDriveCommentCard = {
comment_id?: string;
user_id?: string;
create_time?: number;
update_time?: number;
is_whole?: boolean;
has_more?: boolean;
page_token?: string;
quote?: string;
reply_list?: {
replies?: FeishuDriveCommentReply[];
};
};
type FeishuDriveCommentBatchQueryResponse = FeishuOpenApiResponse<{
items?: FeishuDriveCommentCard[];
}>;
type FeishuDriveCommentListResponse = FeishuOpenApiResponse<{
has_more?: boolean;
items?: FeishuDriveCommentCard[];
page_token?: string;
}>;
type FeishuDriveCommentRepliesListResponse = FeishuOpenApiResponse<{
has_more?: boolean;
items?: FeishuDriveCommentReply[];
page_token?: string;
}>;
type ResolvedCommentReplyContext = {
replyId?: string;
userId?: string;
createTime?: number;
isBotAuthored: boolean;
content: ParsedCommentContent;
};
type ResolvedWholeCommentTimelineEntry = {
commentId: string;
userId?: string;
createTime?: number;
isCurrentComment: boolean;
isBotAuthored: boolean;
content: ParsedCommentContent;
};
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch (error) {
return JSON.stringify({
error: formatErrorMessage(error),
});
}
}
function truncatePromptText(
text: string | undefined,
maxLength = FEISHU_PROMPT_TEXT_LIMIT,
): string {
const normalized = normalizeString(text);
if (!normalized) {
return "";
}
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}` : normalized;
}
function formatPromptTextValue(text: string | undefined): string {
return safeJsonStringify(truncatePromptText(text) || "");
}
function formatPromptBoolean(value: boolean | undefined): string {
return value === true ? "yes" : "no";
}
function buildDriveCommentsListUrl(params: {
fileToken: string;
fileType: CommentFileType;
pageToken?: string;
isWholeOnly?: boolean;
}): string {
return (
`/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments` +
encodeQuery({
file_type: params.fileType,
is_whole: params.isWholeOnly === true ? "true" : undefined,
page_size: String(FEISHU_COMMENT_LIST_PAGE_SIZE),
page_token: params.pageToken,
user_id_type: "open_id",
})
);
}
function compareCommentTimelineEntries(
left: { createTime?: number; stableId?: string },
right: { createTime?: number; stableId?: string },
): number {
const leftTime = left.createTime ?? Number.MAX_SAFE_INTEGER;
const rightTime = right.createTime ?? Number.MAX_SAFE_INTEGER;
if (leftTime !== rightTime) {
return leftTime - rightTime;
}
return (left.stableId ?? "").localeCompare(right.stableId ?? "");
}
function formatLinkedDocumentInline(link: ParsedCommentLinkedDocument): string {
const parts = [
`raw_url=${link.rawUrl}`,
`url_kind=${link.urlKind}`,
link.wikiNodeToken ? `wiki_node_token=${link.wikiNodeToken}` : null,
`resolved_type=${link.resolvedObjType ?? "UNKNOWN"}`,
`resolved_token=${link.resolvedObjToken ?? "UNKNOWN"}`,
`same_as_current_document=${formatPromptBoolean(link.isCurrentDocument)}`,
].filter((part): part is string => Boolean(part));
return parts.join(" ");
}
function formatLinkedDocumentsPromptLines(params: {
title: string;
linkedDocuments: ParsedCommentLinkedDocument[];
}): string[] {
if (params.linkedDocuments.length === 0) {
return [];
}
return [
params.title,
...params.linkedDocuments.map(
(link, index) => `- [${index + 1}] ${formatLinkedDocumentInline(link)}`,
),
];
}
function formatLinkedDocumentsInlineSummary(
linkedDocuments: ParsedCommentLinkedDocument[],
): string {
if (linkedDocuments.length === 0) {
return "none";
}
return linkedDocuments
.map(
(link) =>
`${link.resolvedObjType ?? link.urlKind}:${link.resolvedObjToken ?? link.wikiNodeToken ?? "UNKNOWN"}`,
)
.join(",");
}
function summarizeCommentRepliesForLog(replies: FeishuDriveCommentReply[]): string {
return safeJsonStringify(
replies.map((reply) => ({
reply_id: reply.reply_id,
text_len: extractReplyText(reply)?.length ?? 0,
})),
);
}
async function resolveParsedCommentContent(params: {
elements?: unknown[];
botOpenIds?: Iterable<string | undefined>;
currentDocument: {
fileType: CommentFileType;
fileToken: string;
};
client: FeishuRequestClient;
wikiCache: Map<
string,
Promise<{
resolvedObjType?: CommentFileType;
resolvedObjToken?: string;
} | null>
>;
logger?: (message: string) => void;
accountId: string;
}): Promise<ParsedCommentContent> {
const parsed = parseCommentContentElements({
elements: params.elements,
botOpenIds: params.botOpenIds,
currentDocument: params.currentDocument,
});
if (!parsed.linkedDocuments.some((link) => link.urlKind === "wiki" && link.wikiNodeToken)) {
return parsed;
}
const resolvedLinkedDocuments = await Promise.all(
parsed.linkedDocuments.map(async (link) => {
if (link.urlKind !== "wiki" || !link.wikiNodeToken) {
return link;
}
let pending = params.wikiCache.get(link.wikiNodeToken);
if (!pending) {
pending = params.client.wiki.space
.getNode({
params: {
token: link.wikiNodeToken,
},
})
.then((response) => {
if (response.code !== 0) {
params.logger?.(
`feishu[${params.accountId}]: wiki link resolution failed token=${link.wikiNodeToken} ` +
`code=${response.code ?? "unknown"} msg=${response.msg ?? "unknown"}`,
);
return null;
}
const objType = normalizeCommentFileType(response.data?.node?.obj_type);
const objToken = normalizeString(response.data?.node?.obj_token);
if (!objType || !objToken) {
return null;
}
return {
resolvedObjType: objType,
resolvedObjToken: objToken,
};
})
.catch((error) => {
params.logger?.(
`feishu[${params.accountId}]: wiki link resolution threw token=${link.wikiNodeToken} error=${formatErrorMessage(error)}`,
);
return null;
});
params.wikiCache.set(link.wikiNodeToken, pending);
}
const resolved = await pending;
if (!resolved) {
return link;
}
return {
...link,
resolvedObjType: resolved.resolvedObjType,
resolvedObjToken: resolved.resolvedObjToken,
isCurrentDocument:
resolved.resolvedObjType === params.currentDocument.fileType &&
resolved.resolvedObjToken === params.currentDocument.fileToken,
};
}),
);
return {
...parsed,
linkedDocuments: resolvedLinkedDocuments,
};
}
async function delayMs(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function buildDriveCommentTargetUrl(params: {
fileToken: string;
fileType: CommentFileType;
}): string {
return (
`/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments/batch_query` +
encodeQuery({
file_type: params.fileType,
user_id_type: "open_id",
})
);
}
function buildDriveCommentRepliesUrl(params: {
fileToken: string;
commentId: string;
fileType: CommentFileType;
pageToken?: string;
}): string {
return (
`/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments/${encodeURIComponent(
params.commentId,
)}/replies` +
encodeQuery({
file_type: params.fileType,
page_token: params.pageToken,
page_size: String(FEISHU_COMMENT_REPLY_PAGE_SIZE),
user_id_type: "open_id",
})
);
}
async function fetchDriveComments(params: {
client: FeishuRequestClient;
fileToken: string;
fileType: CommentFileType;
isWholeOnly?: boolean;
timeoutMs: number;
logger?: (message: string) => void;
accountId: string;
}): Promise<FeishuDriveCommentCard[]> {
const comments: FeishuDriveCommentCard[] = [];
let pageToken: string | undefined;
for (let page = 0; page < FEISHU_COMMENT_LIST_PAGE_LIMIT; page += 1) {
const response = await requestFeishuOpenApi<FeishuDriveCommentListResponse>({
client: params.client,
method: "GET",
url: buildDriveCommentsListUrl({
fileToken: params.fileToken,
fileType: params.fileType,
isWholeOnly: params.isWholeOnly,
pageToken,
}),
timeoutMs: params.timeoutMs,
logger: params.logger,
errorLabel: `feishu[${params.accountId}]: failed to list drive comments for ${params.fileToken}`,
});
if (response?.code !== 0) {
if (response) {
params.logger?.(
`feishu[${params.accountId}]: failed to list drive comments for ${params.fileToken}: ` +
`${response.msg ?? "unknown error"} log_id=${response.log_id?.trim() || "unknown"}`,
);
}
break;
}
comments.push(...(response.data?.items ?? []));
if (response.data?.has_more !== true || !response.data.page_token?.trim()) {
break;
}
pageToken = response.data.page_token.trim();
}
return comments;
}
async function requestFeishuOpenApi<T>(params: {
client: FeishuRequestClient;
method: "GET" | "POST";
url: string;
data?: unknown;
timeoutMs: number;
logger?: (message: string) => void;
errorLabel: string;
}): Promise<T | null> {
const formatErrorDetails = (error: unknown): string => {
if (!isRecord(error)) {
return typeof error === "string" ? error : JSON.stringify(error);
}
const response = isRecord(error.response) ? error.response : undefined;
const responseData = isRecord(response?.data) ? response?.data : undefined;
const details = {
message:
typeof error.message === "string"
? error.message
: typeof error === "string"
? error
: JSON.stringify(error),
code: readString(error.code),
method: readString(isRecord(error.config) ? error.config.method : undefined),
url: readString(isRecord(error.config) ? error.config.url : undefined),
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: readString(responseData?.log_id),
};
return safeJsonStringify(details);
};
const result = await raceWithTimeoutAndAbort(
params.client.request({
method: params.method,
url: params.url,
data: params.data ?? {},
timeout: params.timeoutMs,
}),
{ timeoutMs: params.timeoutMs },
)
.then((resolved) => (resolved.status === "resolved" ? resolved.value : null))
.catch((error) => {
params.logger?.(`${params.errorLabel}: ${formatErrorDetails(error)}`);
return null;
});
if (!result) {
params.logger?.(`${params.errorLabel}: request timed out or returned no data`);
}
return result;
}
async function fetchDriveCommentReplies(params: {
client: FeishuRequestClient;
fileToken: string;
fileType: CommentFileType;
commentId: string;
timeoutMs: number;
logger?: (message: string) => void;
accountId: string;
}): Promise<{ replies: FeishuDriveCommentReply[]; logIds: string[] }> {
const replies: FeishuDriveCommentReply[] = [];
const logIds: string[] = [];
let pageToken: string | undefined;
for (let page = 0; page < FEISHU_COMMENT_REPLY_PAGE_LIMIT; page += 1) {
const response = await requestFeishuOpenApi<FeishuDriveCommentRepliesListResponse>({
client: params.client,
method: "GET",
url: buildDriveCommentRepliesUrl({
fileToken: params.fileToken,
commentId: params.commentId,
fileType: params.fileType,
pageToken,
}),
timeoutMs: params.timeoutMs,
logger: params.logger,
errorLabel: `feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}`,
});
if (response?.log_id?.trim()) {
logIds.push(response.log_id.trim());
}
if (response?.code !== 0) {
if (response) {
params.logger?.(
`feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}: ` +
`${response.msg ?? "unknown error"} ` +
`log_id=${response.log_id?.trim() || "unknown"}`,
);
}
break;
}
replies.push(...(response.data?.items ?? []));
if (response.data?.has_more !== true || !response.data.page_token?.trim()) {
break;
}
pageToken = response.data.page_token.trim();
}
return { replies, logIds };
}
async function resolveCommentReplyContext(params: {
reply: FeishuDriveCommentReply;
botOpenIds?: Iterable<string | undefined>;
currentDocument: {
fileType: CommentFileType;
fileToken: string;
};
client: FeishuRequestClient;
wikiCache: Map<
string,
Promise<{
resolvedObjType?: CommentFileType;
resolvedObjToken?: string;
} | null>
>;
logger?: (message: string) => void;
accountId: string;
}): Promise<ResolvedCommentReplyContext> {
const userId = normalizeString(params.reply.user_id);
const normalizedBotOpenIds = new Set(
Array.from(params.botOpenIds ?? [])
.map((botId) => normalizeString(botId))
.filter((botId): botId is string => Boolean(botId)),
);
return {
replyId: normalizeString(params.reply.reply_id),
userId,
createTime: typeof params.reply.create_time === "number" ? params.reply.create_time : undefined,
isBotAuthored: typeof userId === "string" && normalizedBotOpenIds.has(userId),
content: await resolveParsedCommentContent({
elements: isRecord(params.reply.content) ? params.reply.content.elements : undefined,
botOpenIds: params.botOpenIds,
currentDocument: params.currentDocument,
client: params.client,
wikiCache: params.wikiCache,
logger: params.logger,
accountId: params.accountId,
}),
};
}
function selectCommentThreadPromptReplies(
replies: ResolvedCommentReplyContext[],
targetReplyId?: string,
): ResolvedCommentReplyContext[] {
if (replies.length <= FEISHU_COMMENT_THREAD_PROMPT_LIMIT) {
return replies;
}
const targetIndex = replies.findIndex((reply) => reply.replyId === targetReplyId);
const currentIndex = targetIndex >= 0 ? targetIndex : replies.length - 1;
const selected = new Set<number>([0, currentIndex, replies.length - 1]);
for (let radius = 1; selected.size < FEISHU_COMMENT_THREAD_PROMPT_LIMIT; radius += 1) {
const before = currentIndex - radius;
const after = currentIndex + radius;
if (before >= 0) {
selected.add(before);
}
if (selected.size >= FEISHU_COMMENT_THREAD_PROMPT_LIMIT) {
break;
}
if (after < replies.length) {
selected.add(after);
}
if (before < 0 && after >= replies.length) {
break;
}
}
return [...selected]
.toSorted((left, right) => left - right)
.map((index) => replies[index])
.filter((reply): reply is ResolvedCommentReplyContext => Boolean(reply));
}
function formatCommentThreadPromptLines(params: {
replies: ResolvedCommentReplyContext[];
targetReplyId?: string;
}): string[] {
const promptReplies = selectCommentThreadPromptReplies(params.replies, params.targetReplyId);
return promptReplies.map((reply, index) => {
const text = reply.content.semanticText ?? reply.content.plainText;
return (
`- [${index + 1}] author=${reply.isBotAuthored ? "assistant" : "user"} ` +
`user_id=${reply.userId ?? "UNKNOWN"} ` +
`reply_id=${reply.replyId ?? "UNKNOWN"} ` +
`current_event=${reply.replyId === params.targetReplyId ? "yes" : "no"} ` +
`text=${formatPromptTextValue(text)} ` +
`referenced_docs=${formatLinkedDocumentsInlineSummary(reply.content.linkedDocuments)}`
);
});
}
function findNearestBotTimelineEntry(params: {
entries: ResolvedWholeCommentTimelineEntry[];
currentIndex: number;
direction: "before" | "after";
}): ResolvedWholeCommentTimelineEntry | undefined {
const step = params.direction === "after" ? 1 : -1;
for (
let index = params.currentIndex + step;
index >= 0 && index < params.entries.length;
index += step
) {
const candidate = params.entries[index];
if (candidate?.isBotAuthored) {
return candidate;
}
}
return undefined;
}
function selectWholeCommentTimelineEntries(params: {
entries: ResolvedWholeCommentTimelineEntry[];
currentCommentId: string;
}): ResolvedWholeCommentTimelineEntry[] {
if (params.entries.length <= FEISHU_WHOLE_COMMENT_PROMPT_LIMIT) {
return params.entries;
}
const currentIndex = params.entries.findIndex(
(entry) => entry.commentId === params.currentCommentId,
);
if (currentIndex < 0) {
return params.entries.slice(-FEISHU_WHOLE_COMMENT_PROMPT_LIMIT);
}
const selected = new Set<number>([currentIndex]);
const nearestBotAfter = params.entries.findIndex(
(entry, index) => index > currentIndex && entry.isBotAuthored,
);
if (nearestBotAfter >= 0) {
selected.add(nearestBotAfter);
}
for (let index = currentIndex - 1; index >= 0; index -= 1) {
if (params.entries[index]?.isBotAuthored) {
selected.add(index);
break;
}
}
for (let radius = 1; selected.size < FEISHU_WHOLE_COMMENT_PROMPT_LIMIT; radius += 1) {
const before = currentIndex - radius;
const after = currentIndex + radius;
if (before >= 0) {
selected.add(before);
}
if (selected.size >= FEISHU_WHOLE_COMMENT_PROMPT_LIMIT) {
break;
}
if (after < params.entries.length) {
selected.add(after);
}
if (before < 0 && after >= params.entries.length) {
break;
}
}
return [...selected]
.toSorted((left, right) => left - right)
.map((index) => params.entries[index])
.filter((entry): entry is ResolvedWholeCommentTimelineEntry => Boolean(entry));
}
function formatWholeCommentTimelinePromptLines(params: {
entries: ResolvedWholeCommentTimelineEntry[];
currentCommentId: string;
}): string[] {
return selectWholeCommentTimelineEntries(params).map((entry, index) => {
const text = entry.content.semanticText ?? entry.content.plainText;
return (
`- [${index + 1}] create_time=${entry.createTime ?? "UNKNOWN"} ` +
`comment_id=${entry.commentId} ` +
`author=${entry.isBotAuthored ? "assistant" : "user"} ` +
`user_id=${entry.userId ?? "UNKNOWN"} ` +
`current_comment=${entry.commentId === params.currentCommentId ? "yes" : "no"} ` +
`text=${formatPromptTextValue(text)} ` +
`referenced_docs=${formatLinkedDocumentsInlineSummary(entry.content.linkedDocuments)}`
);
});
}
async function fetchDriveCommentContext(params: {
client: FeishuRequestClient;
fileToken: string;
fileType: CommentFileType;
commentId: string;
replyId?: string;
botOpenIds?: Iterable<string | undefined>;
timeoutMs: number;
logger?: (message: string) => void;
accountId: string;
waitMs: (ms: number) => Promise<void>;
}): Promise<{
documentTitle?: string;
documentUrl?: string;
isWholeComment?: boolean;
quoteText?: string;
rootCommentText?: string;
targetReplyText?: string;
rootCommentContent?: ParsedCommentContent;
targetReplyContent?: ParsedCommentContent;
currentCommentThreadReplies: ResolvedCommentReplyContext[];
wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[];
nearestBotWholeCommentAfter?: ResolvedWholeCommentTimelineEntry;
nearestBotWholeCommentBefore?: ResolvedWholeCommentTimelineEntry;
}> {
const [metaResponse, commentResponse] = await Promise.all([
requestFeishuOpenApi<FeishuDriveMetaBatchQueryResponse>({
client: params.client,
method: "POST",
url: "/open-apis/drive/v1/metas/batch_query",
data: {
request_docs: [{ doc_token: params.fileToken, doc_type: params.fileType }],
with_url: true,
},
timeoutMs: params.timeoutMs,
logger: params.logger,
errorLabel: `feishu[${params.accountId}]: failed to fetch drive metadata for ${params.fileToken}`,
}),
requestFeishuOpenApi<FeishuDriveCommentBatchQueryResponse>({
client: params.client,
method: "POST",
url: buildDriveCommentTargetUrl({
fileToken: params.fileToken,
fileType: params.fileType,
}),
data: {
comment_ids: [params.commentId],
},
timeoutMs: params.timeoutMs,
logger: params.logger,
errorLabel: `feishu[${params.accountId}]: failed to fetch drive comment ${params.commentId}`,
}),
]);
const wikiCache = new Map<
string,
Promise<{
resolvedObjType?: CommentFileType;
resolvedObjToken?: string;
} | null>
>();
const commentCard =
commentResponse?.code === 0
? (commentResponse.data?.items ?? []).find(
(item) => item.comment_id?.trim() === params.commentId,
)
: undefined;
const embeddedReplies = commentCard?.reply_list?.replies ?? [];
params.logger?.(
`feishu[${params.accountId}]: embedded comment replies comment=${params.commentId} ` +
`count=${embeddedReplies.length} summary=${summarizeCommentRepliesForLog(embeddedReplies)}`,
);
const embeddedTargetReply = params.replyId
? embeddedReplies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: embeddedReplies.at(-1);
let replies = embeddedReplies;
let fetchedMatchedReply = params.replyId
? replies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: undefined;
const needsExtraReplies =
!embeddedTargetReply || replies.length === 0 || commentCard?.has_more === true;
if (needsExtraReplies) {
params.logger?.(
`feishu[${params.accountId}]: fetching extra comment replies comment=${params.commentId} ` +
`requested_reply=${params.replyId ?? "none"} ` +
`embedded_count=${embeddedReplies.length} ` +
`embedded_hit=${embeddedTargetReply ? "yes" : "no"} ` +
`embedded_has_more=${commentCard?.has_more === true ? "yes" : "no"}`,
);
const fetched = await fetchDriveCommentReplies(params);
if (fetched.replies.length > 0) {
params.logger?.(
`feishu[${params.accountId}]: fetched extra comment replies comment=${params.commentId} ` +
`count=${fetched.replies.length} ` +
`log_ids=${safeJsonStringify(fetched.logIds)} ` +
`summary=${summarizeCommentRepliesForLog(fetched.replies)}`,
);
replies = fetched.replies;
fetchedMatchedReply = params.replyId
? replies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: undefined;
}
if (params.replyId && !embeddedTargetReply && !fetchedMatchedReply) {
for (let attempt = 1; attempt <= FEISHU_COMMENT_REPLY_MISS_RETRY_LIMIT; attempt += 1) {
params.logger?.(
`feishu[${params.accountId}]: retrying comment reply lookup comment=${params.commentId} ` +
`requested_reply=${params.replyId} attempt=${attempt}/${FEISHU_COMMENT_REPLY_MISS_RETRY_LIMIT} ` +
`delay_ms=${FEISHU_COMMENT_REPLY_MISS_RETRY_DELAY_MS}`,
);
await params.waitMs(FEISHU_COMMENT_REPLY_MISS_RETRY_DELAY_MS);
const retried = await fetchDriveCommentReplies(params);
if (retried.replies.length > 0) {
params.logger?.(
`feishu[${params.accountId}]: fetched retried comment replies comment=${params.commentId} ` +
`attempt=${attempt} count=${retried.replies.length} ` +
`log_ids=${safeJsonStringify(retried.logIds)} ` +
`summary=${summarizeCommentRepliesForLog(retried.replies)}`,
);
replies = retried.replies;
}
fetchedMatchedReply = replies.find((reply) => reply.reply_id?.trim() === params.replyId);
if (fetchedMatchedReply) {
break;
}
}
}
}
const rootReply = replies[0] ?? embeddedReplies[0];
const targetReply = params.replyId
? (embeddedTargetReply ?? fetchedMatchedReply ?? undefined)
: (replies.at(-1) ?? embeddedTargetReply ?? rootReply);
const matchSource = params.replyId
? embeddedTargetReply
? "embedded"
: fetchedMatchedReply
? "fetched"
: "miss"
: targetReply === rootReply
? "fallback_root"
: targetReply === embeddedTargetReply
? "embedded_latest"
: "fetched_latest";
params.logger?.(
`feishu[${params.accountId}]: comment reply resolution comment=${params.commentId} ` +
`requested_reply=${params.replyId ?? "none"} match_source=${matchSource} ` +
`root=${safeJsonStringify({ reply_id: rootReply?.reply_id, text_len: extractReplyText(rootReply)?.length ?? 0 })} ` +
`target=${safeJsonStringify({ reply_id: targetReply?.reply_id, text_len: extractReplyText(targetReply)?.length ?? 0 })}`,
);
const meta = metaResponse?.code === 0 ? metaResponse.data?.metas?.[0] : undefined;
const currentDocument = {
fileType: params.fileType,
fileToken: params.fileToken,
};
const resolvedReplies = await Promise.all(
replies.map((reply) =>
resolveCommentReplyContext({
reply,
botOpenIds: params.botOpenIds,
currentDocument,
client: params.client,
wikiCache,
logger: params.logger,
accountId: params.accountId,
}),
),
);
resolvedReplies.sort((left, right) =>
compareCommentTimelineEntries(
{
createTime: left.createTime,
stableId: left.replyId,
},
{
createTime: right.createTime,
stableId: right.replyId,
},
),
);
const rootReplyContext =
resolvedReplies.find((reply) => reply.replyId === normalizeString(rootReply?.reply_id)) ??
resolvedReplies[0];
const targetReplyContext =
resolvedReplies.find((reply) => reply.replyId === normalizeString(targetReply?.reply_id)) ??
(params.replyId ? undefined : (resolvedReplies.at(-1) ?? rootReplyContext));
let wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[] = [];
if (commentCard?.is_whole === true) {
const allComments = await fetchDriveComments({
client: params.client,
fileToken: params.fileToken,
fileType: params.fileType,
isWholeOnly: true,
timeoutMs: params.timeoutMs,
logger: params.logger,
accountId: params.accountId,
});
const wholeComments = allComments.filter((comment) => comment.is_whole === true);
wholeCommentTimeline = await Promise.all(
wholeComments.map(async (comment) => {
const rootWholeReply = comment.reply_list?.replies?.[0];
const normalizedBotOpenIds = new Set(
Array.from(params.botOpenIds ?? [])
.map((botId) => normalizeString(botId))
.filter((botId): botId is string => Boolean(botId)),
);
const content = await resolveParsedCommentContent({
elements: isRecord(rootWholeReply?.content) ? rootWholeReply.content.elements : undefined,
botOpenIds: params.botOpenIds,
currentDocument,
client: params.client,
wikiCache,
logger: params.logger,
accountId: params.accountId,
});
const commentUserId =
normalizeString(rootWholeReply?.user_id) || normalizeString(comment.user_id);
return {
commentId: normalizeString(comment.comment_id) ?? "",
userId: commentUserId,
createTime:
typeof comment.create_time === "number"
? comment.create_time
: typeof rootWholeReply?.create_time === "number"
? rootWholeReply.create_time
: undefined,
isCurrentComment: normalizeString(comment.comment_id) === params.commentId,
isBotAuthored:
typeof commentUserId === "string" && normalizedBotOpenIds.has(commentUserId),
content,
};
}),
);
wholeCommentTimeline = wholeCommentTimeline
.filter((entry) => Boolean(entry.commentId))
.toSorted((left, right) =>
compareCommentTimelineEntries(
{
createTime: left.createTime,
stableId: left.commentId,
},
{
createTime: right.createTime,
stableId: right.commentId,
},
),
);
}
const currentWholeCommentIndex = wholeCommentTimeline.findIndex(
(entry) => entry.commentId === params.commentId,
);
return {
documentTitle: normalizeString(meta?.title),
documentUrl: normalizeString(meta?.url),
isWholeComment: commentCard?.is_whole,
quoteText: normalizeString(commentCard?.quote),
rootCommentText: rootReplyContext?.content.semanticText ?? rootReplyContext?.content.plainText,
targetReplyText:
targetReplyContext?.content.semanticText ?? targetReplyContext?.content.plainText,
rootCommentContent: rootReplyContext?.content,
targetReplyContent: targetReplyContext?.content,
currentCommentThreadReplies: resolvedReplies,
wholeCommentTimeline,
nearestBotWholeCommentAfter:
currentWholeCommentIndex >= 0
? findNearestBotTimelineEntry({
entries: wholeCommentTimeline,
currentIndex: currentWholeCommentIndex,
direction: "after",
})
: undefined,
nearestBotWholeCommentBefore:
currentWholeCommentIndex >= 0
? findNearestBotTimelineEntry({
entries: wholeCommentTimeline,
currentIndex: currentWholeCommentIndex,
direction: "before",
})
: undefined,
};
}
function buildDriveCommentSurfacePrompt(params: {
noticeType: "add_comment" | "add_reply";
fileType: CommentFileType;
fileToken: string;
commentId: string;
replyId?: string;
isWholeComment?: boolean;
isMentioned?: boolean;
documentTitle?: string;
documentUrl?: string;
quoteText?: string;
rootCommentText?: string;
targetReplyText?: string;
rootCommentContent?: ParsedCommentContent;
targetReplyContent?: ParsedCommentContent;
currentCommentThreadReplies: ResolvedCommentReplyContext[];
wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[];
nearestBotWholeCommentAfter?: ResolvedWholeCommentTimelineEntry;
nearestBotWholeCommentBefore?: ResolvedWholeCommentTimelineEntry;
}): string {
const documentLabel = params.documentTitle
? `"${params.documentTitle}"`
: `${params.fileType} document ${params.fileToken}`;
const actionLabel = params.noticeType === "add_reply" ? "reply" : "comment";
const firstLine = `The user added a ${actionLabel} in ${documentLabel}.`;
const lines = [firstLine];
if (params.targetReplyText) {
lines.push(`Current user comment text: ${formatPromptTextValue(params.targetReplyText)}`);
}
if (
params.noticeType === "add_reply" &&
params.rootCommentText &&
params.rootCommentText !== params.targetReplyText
) {
lines.push(`Original comment text: ${formatPromptTextValue(params.rootCommentText)}`);
}
if (params.quoteText) {
lines.push(`Quoted content: ${formatPromptTextValue(params.quoteText)}`);
}
if (params.isMentioned === true) {
lines.push("This comment mentioned you.");
}
if (params.documentUrl) {
lines.push(`Document link: ${params.documentUrl}`);
}
lines.push(
"Current commented document:",
`- file_type=${params.fileType}`,
`- file_token=${params.fileToken}`,
);
if (params.documentTitle) {
lines.push(`- title=${params.documentTitle}`);
}
if (params.documentUrl) {
lines.push(`- url=${params.documentUrl}`);
}
lines.push(
`Event type: ${params.noticeType}`,
`file_token: ${params.fileToken}`,
`file_type: ${params.fileType}`,
`comment_id: ${params.commentId}`,
);
if (params.isWholeComment === true) {
lines.push("This is a whole-document comment.");
}
if (params.replyId?.trim()) {
lines.push(`reply_id: ${params.replyId.trim()}`);
}
if (params.targetReplyContent?.semanticText) {
lines.push(
`Current user comment semantic text: ${formatPromptTextValue(
params.targetReplyContent.semanticText,
)}`,
);
}
if (params.targetReplyContent?.botMentioned) {
lines.push(
"Bot routing mention detected in the current user comment. Treat that mention as routing only, not task content.",
);
}
const nonBotMentions = (params.targetReplyContent?.mentions ?? [])
.filter((mention) => !mention.isBotMention)
.map((mention) => mention.displayText);
if (nonBotMentions.length > 0) {
lines.push(`Other mentioned users in current comment: ${nonBotMentions.join(", ")}`);
}
lines.push(
...formatLinkedDocumentsPromptLines({
title: "Referenced documents from current user comment:",
linkedDocuments: params.targetReplyContent?.linkedDocuments ?? [],
}),
);
if (!params.isWholeComment && params.currentCommentThreadReplies.length > 0) {
lines.push(
"Current comment card timeline (primary context for follow-ups on this comment card):",
...formatCommentThreadPromptLines({
replies: params.currentCommentThreadReplies,
targetReplyId: params.replyId,
}),
"For this non-whole comment, use the current comment card timeline above as the primary source for phrases like 'above', 'previous result', 'that summary', or 'insert it'.",
"Document-level session history is auxiliary background only. Do not use another comment card's recent output as the primary referent.",
);
}
if (params.isWholeComment && params.wholeCommentTimeline.length > 0) {
lines.push(
"Whole-document comment timeline (primary context for whole-comment follow-ups):",
...formatWholeCommentTimelinePromptLines({
entries: params.wholeCommentTimeline,
currentCommentId: params.commentId,
}),
);
if (params.nearestBotWholeCommentAfter) {
lines.push(
`Nearest bot-authored whole-comment after the current comment: comment_id=${params.nearestBotWholeCommentAfter.commentId} text=${formatPromptTextValue(
params.nearestBotWholeCommentAfter.content.semanticText ??
params.nearestBotWholeCommentAfter.content.plainText,
)}`,
);
}
if (params.nearestBotWholeCommentBefore) {
lines.push(
`Nearest bot-authored whole-comment before the current comment: comment_id=${params.nearestBotWholeCommentBefore.commentId} text=${formatPromptTextValue(
params.nearestBotWholeCommentBefore.content.semanticText ??
params.nearestBotWholeCommentBefore.content.plainText,
)}`,
);
}
lines.push(
"For this whole-document comment, use the whole-comment timeline above as the primary source for phrases like 'just now', 'previous result', 'that summary', or 'write it back'.",
"Document-level session history is auxiliary background only. Do not resolve whole-comment follow-ups by blindly using the most recent document-session output.",
);
}
lines.push(
"This is a Feishu document comment thread.",
"It is not a Feishu IM chat.",
"Your final text reply will be posted to the current comment thread automatically.",
"Use the thread timeline above as the main context for follow-up requests.",
"Do not use another comment card or document-session output as the main reference.",
"If you need comment thread context, use feishu_drive.list_comments or feishu_drive.list_comment_replies.",
"If you modify the document, post a user-visible follow-up in the comment thread.",
"Use feishu_drive.reply_comment or feishu_drive.add_comment for that follow-up.",
"Whole-document comments do not support direct replies.",
"For whole-document comments, use feishu_drive.add_comment.",
'Only treat URLs listed under "Referenced documents from current user comment" as structured Feishu document references.',
"URLs that appear only in comment text are plain links unless you verify them.",
"If the user asks about a linked Feishu document or wiki page, treat that linked document as the read target.",
"If the user asks you to use a linked document as guidance, treat the linked document as the reference source and the current commented document as the edit target.",
"If a referenced document resolves to the same file_token and file_type as the current commented document, treat it as the current document.",
"If the user asks you to modify document content, you must use feishu_doc to make the change.",
'Do not reply with only "done", "I\'ll handle it", or a restated plan without calling tools.',
"If the comment quotes document content, treat the quoted content as the main anchor.",
'For requests like "insert xxx below this content", locate the quoted content first, then edit the document.',
'For requests like "summarize the content below", "explain this section", or "continue writing from here", use the quoted content as the main target.',
"If the quote is not enough, use feishu_doc.read or feishu_doc.list_blocks to read nearby context.",
"Do not guess document content from the comment alone.",
"Do not give a vague answer before reading enough context.",
"Unless the user asks for the whole document, handle only the local content around the quoted anchor.",
"If document edits are involved, read the anchor first, then edit.",
"If the edit fails or the anchor cannot be found, say so clearly.",
"If this is a reading task, such as summarization, explanation, or extraction, you may output the final answer directly after confirming the context.",
"Use the same language as the user's comment or reply, unless the user asks for another language.",
"Use plain text only.",
"Do not use Markdown.",
"Do not use headings.",
"Do not use bullet lists.",
"Do not use numbered lists.",
"Do not use tables.",
"Do not use blockquotes.",
"Do not use code blocks.",
"Do not show reasoning.",
"Do not show analysis.",
"Do not show chain-of-thought.",
"Do not show scratch work.",
"Do not describe your plan.",
"Do not describe your steps.",
"Do not describe tool use.",
'Do not start with phrases like "I will", "Ill first", "I need to", "The user wants", or "I have updated".',
"Output only the final user-facing reply.",
"If you already sent the user-visible reply with feishu_drive.reply_comment or feishu_drive.add_comment, output exactly NO_REPLY.",
"If no user-visible reply is needed, output exactly NO_REPLY.",
"Be concise.",
"Do not omit requested content.",
);
lines.push(
"Choose one outcome: output the final plain-text reply, edit the document and then post a user-visible follow-up in the comment thread, or output exactly NO_REPLY.",
);
return lines.join("\n");
}
async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventParams): Promise<{
eventId: string;
commentId: string;
replyId?: string;
noticeType: "add_comment" | "add_reply";
fileToken: string;
fileType: CommentFileType;
isWholeComment?: boolean;
senderId: string;
senderUserId?: string;
timestamp?: string;
isMentioned?: boolean;
context: {
documentTitle?: string;
documentUrl?: string;
quoteText?: string;
rootCommentText?: string;
targetReplyText?: string;
rootCommentContent?: ParsedCommentContent;
targetReplyContent?: ParsedCommentContent;
currentCommentThreadReplies: ResolvedCommentReplyContext[];
wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[];
nearestBotWholeCommentAfter?: ResolvedWholeCommentTimelineEntry;
nearestBotWholeCommentBefore?: ResolvedWholeCommentTimelineEntry;
};
} | null> {
const {
cfg,
accountId,
event,
account,
botOpenId,
createClient,
verificationTimeoutMs = FEISHU_COMMENT_VERIFY_TIMEOUT_MS,
logger,
waitMs = delayMs,
} = params;
const eventId = event.event_id?.trim();
const commentId = event.comment_id?.trim();
const replyId = event.reply_id?.trim();
const noticeType = event.notice_meta?.notice_type?.trim();
const fileToken = event.notice_meta?.file_token?.trim();
const fileType = normalizeCommentFileType(event.notice_meta?.file_type);
const senderId = event.notice_meta?.from_user_id?.open_id?.trim();
const senderUserId = normalizeString(event.notice_meta?.from_user_id?.user_id);
if (!eventId || !commentId || !noticeType || !fileToken || !fileType || !senderId) {
logger?.(
`feishu[${accountId}]: drive comment notice missing required fields event=${eventId ?? "unknown"} comment=${commentId ?? "unknown"}`,
);
return null;
}
if (noticeType !== "add_comment" && noticeType !== "add_reply") {
logger?.(`feishu[${accountId}]: unsupported drive comment notice type ${noticeType}`);
return null;
}
if (!botOpenId) {
logger?.(
`feishu[${accountId}]: skipping drive comment notice because bot open_id is unavailable ` +
`event=${eventId}`,
);
return null;
}
if (senderId === botOpenId) {
logger?.(
`feishu[${accountId}]: ignoring self-authored drive comment notice event=${eventId} sender=${senderId}`,
);
return null;
}
const client = createClient
? createClient(account ?? ({ accountId } as ResolvedFeishuAccount))
: (createFeishuClient(
(await import("./accounts.js")).resolveFeishuAccount({ cfg, accountId }),
) as FeishuRequestClient);
const context = await fetchDriveCommentContext({
client,
fileToken,
fileType,
commentId,
replyId,
botOpenIds: [botOpenId, event.notice_meta?.to_user_id?.open_id],
timeoutMs: verificationTimeoutMs,
logger,
accountId,
waitMs,
});
return {
eventId,
commentId,
replyId,
noticeType,
fileToken,
fileType,
isWholeComment: context.isWholeComment,
senderId,
senderUserId,
timestamp: event.timestamp,
isMentioned: event.is_mentioned,
context,
};
}
export function parseFeishuDriveCommentNoticeEventPayload(
value: unknown,
): FeishuDriveCommentNoticeEvent | null {
if (!isRecord(value) || !isRecord(value.notice_meta)) {
return null;
}
const noticeMeta = value.notice_meta;
const fromUserId = isRecord(noticeMeta.from_user_id) ? noticeMeta.from_user_id : undefined;
const toUserId = isRecord(noticeMeta.to_user_id) ? noticeMeta.to_user_id : undefined;
return {
comment_id: readString(value.comment_id),
event_id: readString(value.event_id),
is_mentioned: readBoolean(value.is_mentioned),
notice_meta: {
file_token: readString(noticeMeta.file_token),
file_type: readString(noticeMeta.file_type),
from_user_id: fromUserId
? {
open_id: readString(fromUserId.open_id),
user_id: readString(fromUserId.user_id),
union_id: readString(fromUserId.union_id),
}
: undefined,
notice_type: readString(noticeMeta.notice_type),
to_user_id: toUserId
? {
open_id: readString(toUserId.open_id),
user_id: readString(toUserId.user_id),
union_id: readString(toUserId.union_id),
}
: undefined,
},
reply_id: readString(value.reply_id),
timestamp: readString(value.timestamp),
type: readString(value.type),
};
}
export async function resolveDriveCommentEventTurn(
params: ResolveDriveCommentEventParams,
): Promise<ResolvedDriveCommentEventTurn | null> {
const resolved = await resolveDriveCommentEventCore(params);
if (!resolved) {
return null;
}
const prompt = buildDriveCommentSurfacePrompt({
noticeType: resolved.noticeType,
fileType: resolved.fileType,
fileToken: resolved.fileToken,
commentId: resolved.commentId,
replyId: resolved.replyId,
isWholeComment: resolved.isWholeComment,
isMentioned: resolved.isMentioned,
documentTitle: resolved.context.documentTitle,
documentUrl: resolved.context.documentUrl,
quoteText: resolved.context.quoteText,
rootCommentText: resolved.context.rootCommentText,
targetReplyText: resolved.context.targetReplyText,
rootCommentContent: resolved.context.rootCommentContent,
targetReplyContent: resolved.context.targetReplyContent,
currentCommentThreadReplies: resolved.context.currentCommentThreadReplies,
wholeCommentTimeline: resolved.context.wholeCommentTimeline,
nearestBotWholeCommentAfter: resolved.context.nearestBotWholeCommentAfter,
nearestBotWholeCommentBefore: resolved.context.nearestBotWholeCommentBefore,
});
const preview = prompt.replace(/\s+/g, " ").slice(0, 160);
return {
eventId: resolved.eventId,
messageId: `drive-comment:${resolved.eventId}`,
commentId: resolved.commentId,
replyId: resolved.replyId,
noticeType: resolved.noticeType,
fileToken: resolved.fileToken,
fileType: resolved.fileType,
isWholeComment: resolved.isWholeComment,
senderId: resolved.senderId,
senderUserId: resolved.senderUserId,
timestamp: resolved.timestamp,
isMentioned: resolved.isMentioned,
documentTitle: resolved.context.documentTitle,
documentUrl: resolved.context.documentUrl,
quoteText: resolved.context.quoteText,
rootCommentText: resolved.context.rootCommentText,
targetReplyText: resolved.context.targetReplyText,
prompt,
preview,
};
}