/** * Feishu (Lark) API Client * * Handles authentication and API calls to Feishu Open Platform. * API Base: https://open.feishu.cn/open-apis */ import type { FeishuCredentials } from "./token.js"; const FEISHU_API_BASE = "https://open.feishu.cn/open-apis"; export type FeishuApiError = { code: number; msg: string; }; export type FeishuApiResponse = { code: number; msg: string; data?: T; }; export type FeishuTenantAccessToken = { tenant_access_token: string; expire: number; }; export type FeishuMessageContent = { text?: string; post?: FeishuPostContent; image_key?: string; file_key?: string; }; export type FeishuPostContent = { zh_cn?: { title?: string; content: FeishuPostElement[][]; }; en_us?: { title?: string; content: FeishuPostElement[][]; }; }; export type FeishuPostElement = | { tag: "text"; text: string } | { tag: "a"; text: string; href: string } | { tag: "at"; user_id: string; user_name?: string } | { tag: "img"; image_key: string } | { tag: "media"; file_key: string }; export type FeishuSendMessageParams = { receive_id: string; receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id" | "email"; msg_type: "text" | "post" | "image" | "file" | "audio" | "media" | "sticker" | "interactive"; content: string; // JSON stringified content uuid?: string; // Idempotency key }; export type FeishuSendMessageResult = { message_id: string; root_id?: string; parent_id?: string; thread_id?: string; msg_type: string; create_time: string; update_time: string; deleted: boolean; updated: boolean; chat_id: string; sender: { id: string; id_type: string; sender_type: string; }; body: { content: string; }; }; export type FeishuChatInfo = { chat_id: string; name?: string; description?: string; avatar?: string; owner_id?: string; owner_id_type?: string; chat_mode?: string; chat_type?: string; chat_tag?: string; external?: boolean; tenant_key?: string; }; export type FeishuUser = { user_id?: string; open_id?: string; union_id?: string; name?: string; en_name?: string; nickname?: string; email?: string; mobile?: string; avatar?: { avatar_72?: string; avatar_240?: string; avatar_640?: string; avatar_origin?: string; }; }; // Token cache for tenant access tokens const tokenCache = new Map(); /** * Get tenant access token for a Feishu app */ export async function getTenantAccessToken(credentials: FeishuCredentials): Promise { const cacheKey = `${credentials.appId}`; const cached = tokenCache.get(cacheKey); // Return cached token if still valid (with 60s buffer) if (cached && cached.expiresAt > Date.now() + 60_000) { return cached.token; } const url = `${FEISHU_API_BASE}/auth/v3/tenant_access_token/internal`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ app_id: credentials.appId, app_secret: credentials.appSecret, }), }); if (!response.ok) { throw new Error(`Failed to get tenant access token: ${response.status} ${response.statusText}`); } const result = (await response.json()) as FeishuApiResponse & { tenant_access_token?: string; expire?: number; }; if (result.code !== 0) { throw new Error(`Feishu API error: ${result.code} ${result.msg}`); } // Handle both nested and flat response formats const token = result.data?.tenant_access_token ?? result.tenant_access_token; const expire = result.data?.expire ?? result.expire ?? 7200; if (!token) { throw new Error("No tenant_access_token in response"); } // Cache the token tokenCache.set(cacheKey, { token, expiresAt: Date.now() + expire * 1000, }); return token; } /** * Feishu API client instance */ export class FeishuClient { private credentials: FeishuCredentials; private timeoutMs: number; constructor(credentials: FeishuCredentials, opts?: { timeoutMs?: number }) { this.credentials = credentials; this.timeoutMs = opts?.timeoutMs ?? 30_000; } /** * Make an authenticated API request */ async request( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, opts?: { params?: Record; body?: unknown; headers?: Record; }, ): Promise> { const token = await getTenantAccessToken(this.credentials); let url = `${FEISHU_API_BASE}${path}`; if (opts?.params && Object.keys(opts.params).length > 0) { const searchParams = new URLSearchParams(opts.params); url += `?${searchParams.toString()}`; } const headers: Record = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...opts?.headers, }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); try { const fetchOpts: RequestInit = { method, headers, signal: controller.signal, }; // Only include body for methods that support it if (opts?.body && method !== "GET") { fetchOpts.body = JSON.stringify(opts.body); } const response = await fetch(url, fetchOpts); if (!response.ok) { // Try to read error details from response body let errorDetail = ""; try { const errorBody = (await response.json()) as { code?: number; msg?: string }; if (errorBody.code !== undefined || errorBody.msg) { errorDetail = ` (code: ${errorBody.code}, msg: ${errorBody.msg})`; } } catch { // Ignore JSON parse errors } throw new Error( `Feishu API request failed: ${response.status} ${response.statusText}${errorDetail}`, ); } return (await response.json()) as FeishuApiResponse; } finally { clearTimeout(timeoutId); } } /** * Send a message to a chat */ async sendMessage(params: FeishuSendMessageParams): Promise { const result = await this.request("POST", "/im/v1/messages", { params: { receive_id_type: params.receive_id_type }, body: { receive_id: params.receive_id, msg_type: params.msg_type, content: params.content, ...(params.uuid ? { uuid: params.uuid } : {}), }, }); if (result.code !== 0) { throw new Error(`Failed to send message: ${result.code} ${result.msg}`); } if (!result.data) { throw new Error("No data in send message response"); } return result.data; } /** * Send a text message */ async sendTextMessage( receiveId: string, text: string, receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id", ): Promise { return this.sendMessage({ receive_id: receiveId, receive_id_type: receiveIdType, msg_type: "text", content: JSON.stringify({ text }), }); } /** * Send a post (rich text) message */ async sendPostMessage( receiveId: string, post: FeishuPostContent, receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id", ): Promise { return this.sendMessage({ receive_id: receiveId, receive_id_type: receiveIdType, msg_type: "post", content: JSON.stringify({ post }), }); } /** * Send an interactive card message with full markdown support * * Interactive cards support rich markdown formatting: * - Basic: **bold**, *italic*, ~~strikethrough~~, [link](url) * - Headings: # H1, ## H2 * - Lists: - item, 1. item * - Code blocks: ```lang code``` * - Images: ![alt](url) * - Colors: text * - @mention: * * @param receiveId - Target chat/user ID * @param card - Interactive card JSON structure * @param receiveIdType - ID type (chat_id, open_id, etc.) */ async sendInteractiveMessage( receiveId: string, card: unknown, receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id", ): Promise { return this.sendMessage({ receive_id: receiveId, receive_id_type: receiveIdType, msg_type: "interactive", content: JSON.stringify(card), }); } /** * Upload an image to Feishu * API: POST /im/v1/images * @param image - Image data as Buffer or Uint8Array * @param imageType - Image type: "message" (for chat) or "avatar" (for profile) * @returns image_key to use when sending image messages */ async uploadImage( image: Buffer | Uint8Array, imageType: "message" | "avatar" = "message", ): Promise { const token = await getTenantAccessToken(this.credentials); // Create form data for multipart upload // Convert to ArrayBuffer for Blob compatibility const arrayBuffer = image.buffer.slice( image.byteOffset, image.byteOffset + image.byteLength, ) as ArrayBuffer; const formData = new FormData(); formData.append("image_type", imageType); formData.append("image", new Blob([arrayBuffer]), "image.png"); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); try { const response = await fetch(`${FEISHU_API_BASE}/im/v1/images`, { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: formData, signal: controller.signal, }); if (!response.ok) { throw new Error(`Failed to upload image: ${response.status} ${response.statusText}`); } const result = (await response.json()) as FeishuApiResponse<{ image_key: string }>; if (result.code !== 0) { throw new Error(`Failed to upload image: ${result.code} ${result.msg}`); } if (!result.data?.image_key) { throw new Error("No image_key in upload response"); } return result.data.image_key; } finally { clearTimeout(timeoutId); } } /** * Send an image message * @param receiveId - Target chat/user ID * @param imageKey - Image key from uploadImage() * @param receiveIdType - ID type (chat_id, open_id, etc.) */ async sendImageMessage( receiveId: string, imageKey: string, receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id", ): Promise { return this.sendMessage({ receive_id: receiveId, receive_id_type: receiveIdType, msg_type: "image", content: JSON.stringify({ image_key: imageKey }), }); } /** * Upload and send an image in one call * @param receiveId - Target chat/user ID * @param image - Image data as Buffer or Uint8Array * @param receiveIdType - ID type (chat_id, open_id, etc.) */ async uploadAndSendImage( receiveId: string, image: Buffer | Uint8Array, receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id", ): Promise { const imageKey = await this.uploadImage(image, "message"); return this.sendImageMessage(receiveId, imageKey, receiveIdType); } /** * Reply to a message */ async replyMessage( messageId: string, msgType: FeishuSendMessageParams["msg_type"], content: string, ): Promise { const result = await this.request( "POST", `/im/v1/messages/${messageId}/reply`, { body: { msg_type: msgType, content, }, }, ); if (result.code !== 0) { throw new Error(`Failed to reply to message: ${result.code} ${result.msg}`); } if (!result.data) { throw new Error("No data in reply message response"); } return result.data; } /** * List chats the bot has joined */ async listChats(opts?: { pageSize?: number; pageToken?: string; }): Promise<{ items: FeishuChatInfo[]; has_more: boolean; page_token?: string }> { const params: Record = {}; if (opts?.pageSize) params.page_size = String(opts.pageSize); if (opts?.pageToken) params.page_token = opts.pageToken; const result = await this.request<{ items: FeishuChatInfo[]; has_more: boolean; page_token?: string; }>("GET", "/im/v1/chats", { params }); if (result.code !== 0) { throw new Error(`Failed to list chats: ${result.code} ${result.msg}`); } return result.data ?? { items: [], has_more: false }; } /** * Get chat info by chat_id */ async getChatInfo(chatId: string): Promise { const result = await this.request("GET", `/im/v1/chats/${chatId}`); if (result.code !== 0) { throw new Error(`Failed to get chat info: ${result.code} ${result.msg}`); } if (!result.data) { throw new Error("No data in chat info response"); } return result.data; } /** * Get chat members */ async getChatMembers( chatId: string, opts?: { pageSize?: number; pageToken?: string }, ): Promise<{ items: FeishuUser[]; has_more: boolean; page_token?: string }> { const params: Record = { member_id_type: "open_id" }; if (opts?.pageSize) params.page_size = String(opts.pageSize); if (opts?.pageToken) params.page_token = opts.pageToken; const result = await this.request<{ items: Array<{ member_id: string; member_id_type: string; name?: string }>; has_more: boolean; page_token?: string; }>("GET", `/im/v1/chats/${chatId}/members`, { params }); if (result.code !== 0) { throw new Error(`Failed to get chat members: ${result.code} ${result.msg}`); } const items: FeishuUser[] = (result.data?.items ?? []).map((item) => ({ open_id: item.member_id, name: item.name, })); return { items, has_more: result.data?.has_more ?? false, page_token: result.data?.page_token, }; } /** * Delete a message */ async deleteMessage(messageId: string): Promise { const result = await this.request("DELETE", `/im/v1/messages/${messageId}`); if (result.code !== 0) { throw new Error(`Failed to delete message: ${result.code} ${result.msg}`); } } /** * Mark a message as read by the bot * * NOTE: Feishu's API does not support bots marking messages as read. * The message_read event exists for receiving notifications when users read messages, * but there is no corresponding API for bots to mark messages as read. * This method is a no-op kept for interface compatibility. */ async markMessageRead(_messageId: string): Promise { // No-op: Feishu doesn't have an API for bots to mark messages as read // Bots receive messages via webhooks and don't have read status like human users } /** * Get message read information * Returns users who have read a specific message */ async getMessageReadUsers( messageId: string, opts?: { pageSize?: number; pageToken?: string }, ): Promise<{ items: Array<{ user_id_type: string; user_id: string; timestamp: string; }>; has_more: boolean; page_token?: string; }> { const params: Record = { user_id_type: "open_id" }; if (opts?.pageSize) params.page_size = String(opts.pageSize); if (opts?.pageToken) params.page_token = opts.pageToken; const result = await this.request<{ items: Array<{ user_id_type: string; user_id: string; timestamp: string }>; has_more: boolean; page_token?: string; }>("GET", `/im/v1/messages/${messageId}/read_users`, { params }); if (result.code !== 0) { throw new Error(`Failed to get message read users: ${result.code} ${result.msg}`); } return result.data ?? { items: [], has_more: false }; } /** * Update a message */ async updateMessage(messageId: string, msgType: "text" | "post", content: string): Promise { const result = await this.request("PATCH", `/im/v1/messages/${messageId}`, { body: { msg_type: msgType, content, }, }); if (result.code !== 0) { throw new Error(`Failed to update message: ${result.code} ${result.msg}`); } } /** * Get bot info */ async getBotInfo(): Promise<{ app_name: string; open_id: string; activate_status?: number; }> { // The /bot/v3/info endpoint returns bot at top level, not in data type BotInfoResponse = { code: number; msg: string; bot?: { app_name: string; open_id: string; activate_status?: number; avatar_url?: string; }; }; const token = await getTenantAccessToken(this.credentials); const url = `${FEISHU_API_BASE}/bot/v3/info`; const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); if (!response.ok) { throw new Error(`Failed to get bot info: ${response.status} ${response.statusText}`); } const result = (await response.json()) as BotInfoResponse; if (result.code !== 0) { throw new Error(`Failed to get bot info: ${result.code} ${result.msg}`); } if (!result.bot) { throw new Error("No bot info in response"); } return result.bot; } } /** * Create a Feishu client from credentials */ export function createFeishuClient( credentials: FeishuCredentials, opts?: { timeoutMs?: number }, ): FeishuClient { return new FeishuClient(credentials, opts); }