diff --git a/extensions/feishu/src/bot-content.ts b/extensions/feishu/src/bot-content.ts index 8f0f90f1c14..22212ca67aa 100644 --- a/extensions/feishu/src/bot-content.ts +++ b/extensions/feishu/src/bot-content.ts @@ -38,6 +38,10 @@ type FeishuMessageLike = { export type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; +type FeishuLogger = { + (...args: unknown[]): void; +}; + export type ResolvedFeishuGroupSession = { peerId: string; parentPeer: { kind: "group"; id: string } | null; @@ -182,10 +186,7 @@ function formatSubMessageContent(content: string, contentType: string): string { } } -export function parseMergeForwardContent(params: { - content: string; - log?: (...args: any[]) => void; -}): string { +export function parseMergeForwardContent(params: { content: string; log?: FeishuLogger }): string { const { content, log } = params; const maxMessages = 50; log?.("feishu: parsing merge_forward sub-messages from API response"); diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index c4498dcffc3..30621e7a4e7 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -31,6 +31,11 @@ export const FEISHU_HTTP_TIMEOUT_MS = 30_000; export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS"; +type FeishuHttpInstanceLike = Pick< + typeof feishuClientSdk.defaultHttpInstance, + "request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options" +>; + function getWsProxyAgent(): HttpsProxyAgent | undefined { const proxyUrl = process.env.https_proxy || @@ -66,8 +71,7 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { * (e.g. when the Feishu API is slow, causing per-chat queue deadlocks). */ function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance { - const base: Lark.HttpInstance = - feishuClientSdk.defaultHttpInstance as unknown as Lark.HttpInstance; + const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance; function injectTimeout(opts?: Lark.HttpRequestOptions): Lark.HttpRequestOptions { return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions; diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index bac8e75bb44..1e14abfcbde 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -159,6 +159,142 @@ type RegisterEventHandlersContext = { fireAndForget?: boolean; }; +type FeishuBotMenuEvent = { + event_key?: string; + timestamp?: string | number; + operator?: { + operator_name?: string; + operator_id?: { open_id?: string; user_id?: string; union_id?: string }; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readStringOrNumber(value: unknown): string | number | undefined { + return typeof value === "string" || typeof value === "number" ? value : undefined; +} + +function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null { + if (!isRecord(value)) { + return null; + } + const sender = value.sender; + const message = value.message; + if (!isRecord(sender) || !isRecord(message)) { + return null; + } + const senderId = sender.sender_id; + if (!isRecord(senderId)) { + return null; + } + const messageId = readString(message.message_id); + const chatId = readString(message.chat_id); + const chatType = normalizeFeishuChatType(message.chat_type); + const messageType = readString(message.message_type); + const content = readString(message.content); + if (!messageId || !chatId || !chatType || !messageType || !content) { + return null; + } + return value as FeishuMessageEvent; +} + +function parseFeishuBotAddedEventPayload(value: unknown): FeishuBotAddedEvent | null { + if (!isRecord(value) || !readString(value.chat_id) || !isRecord(value.operator_id)) { + return null; + } + return value as FeishuBotAddedEvent; +} + +function parseFeishuBotRemovedChatId(value: unknown): string | null { + if (!isRecord(value)) { + return null; + } + return readString(value.chat_id) ?? null; +} + +function parseFeishuBotMenuEvent(value: unknown): FeishuBotMenuEvent | null { + if (!isRecord(value)) { + return null; + } + const operator = value.operator; + if (operator !== undefined && !isRecord(operator)) { + return null; + } + return { + event_key: readString(value.event_key), + timestamp: readStringOrNumber(value.timestamp), + operator: operator + ? { + operator_name: readString(operator.operator_name), + operator_id: isRecord(operator.operator_id) + ? { + open_id: readString(operator.operator_id.open_id), + user_id: readString(operator.operator_id.user_id), + union_id: readString(operator.operator_id.union_id), + } + : undefined, + } + : undefined, + }; +} + +function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEvent | null { + if (!isRecord(value)) { + return null; + } + const operator = value.operator; + const action = value.action; + const context = value.context; + if (!isRecord(operator) || !isRecord(action) || !isRecord(context)) { + return null; + } + const token = readString(value.token); + const openId = readString(operator.open_id); + const userId = readString(operator.user_id); + const unionId = readString(operator.union_id); + const tag = readString(action.tag); + const actionValue = action.value; + const contextOpenId = readString(context.open_id); + const contextUserId = readString(context.user_id); + const chatId = readString(context.chat_id); + if ( + !token || + !openId || + !userId || + !unionId || + !tag || + !isRecord(actionValue) || + !contextOpenId || + !contextUserId || + !chatId + ) { + return null; + } + return { + operator: { + open_id: openId, + user_id: userId, + union_id: unionId, + }, + token, + action: { + value: actionValue, + tag, + }, + context: { + open_id: contextOpenId, + user_id: contextUserId, + chat_id: chatId, + }, + }; +} + /** * Per-chat serial queue that ensures messages from the same chat are processed * in arrival order while allowing different chats to run concurrently. @@ -410,7 +546,11 @@ function registerEventHandlers( eventDispatcher.register({ "im.message.receive_v1": async (data) => { - const event = data as unknown as FeishuMessageEvent; + const event = parseFeishuMessageEventPayload(data); + if (!event) { + error(`feishu[${accountId}]: ignoring malformed message event payload`); + return; + } const messageId = event.message?.message_id?.trim(); if (!tryBeginFeishuMessageProcessing(messageId, accountId)) { log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`); @@ -438,7 +578,10 @@ function registerEventHandlers( }, "im.chat.member.bot.added_v1": async (data) => { try { - const event = data as unknown as FeishuBotAddedEvent; + const event = parseFeishuBotAddedEventPayload(data); + if (!event) { + return; + } log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`); } catch (err) { error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`); @@ -446,8 +589,11 @@ function registerEventHandlers( }, "im.chat.member.bot.deleted_v1": async (data) => { try { - const event = data as unknown as { chat_id: string }; - log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`); + const chatId = parseFeishuBotRemovedChatId(data); + if (!chatId) { + return; + } + log(`feishu[${accountId}]: bot removed from chat ${chatId}`); } catch (err) { error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`); } @@ -513,14 +659,10 @@ function registerEventHandlers( }, "application.bot.menu_v6": async (data) => { try { - const event = data as { - event_key?: string; - timestamp?: string | number; - operator?: { - operator_name?: string; - operator_id?: { open_id?: string; user_id?: string; union_id?: string }; - }; - }; + const event = parseFeishuBotMenuEvent(data); + if (!event) { + return; + } const operatorOpenId = event.operator?.operator_id?.open_id?.trim(); const eventKey = event.event_key?.trim(); if (!operatorOpenId || !eventKey) { @@ -598,7 +740,11 @@ function registerEventHandlers( }, "card.action.trigger": async (data: unknown) => { try { - const event = data as unknown as FeishuCardActionEvent; + const event = parseFeishuCardActionEventPayload(data); + if (!event) { + error(`feishu[${accountId}]: ignoring malformed card action payload`); + return; + } const promise = handleFeishuCardAction({ cfg, event, diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts index e4b8d76f0c1..0fcbc5f14e8 100644 --- a/extensions/feishu/src/probe.ts +++ b/extensions/feishu/src/probe.ts @@ -24,6 +24,15 @@ type FeishuBotInfoResponse = { data?: { bot?: { bot_name?: string; open_id?: string } }; }; +type FeishuRequestClient = ReturnType & { + request(params: { + method: "GET"; + url: string; + data: Record; + timeout: number; + }): Promise; +}; + function setCachedProbeResult( cacheKey: string, result: FeishuProbeResult, @@ -70,16 +79,15 @@ export async function probeFeishu( } try { - const client = createFeishuClient(creds); + const client = createFeishuClient(creds) as FeishuRequestClient; // Use bot/v3/info API to get bot information - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK generic request method const responseResult = await raceWithTimeoutAndAbort( - (client as any).request({ + client.request({ method: "GET", url: "/open-apis/bot/v3/info", data: {}, timeout: timeoutMs, - }) as Promise, + }), { timeoutMs, abortSignal: options.abortSignal,