Skip to main content
Glama

Feishu MCP Server

feishuApiService.ts50.3 kB
import { BaseApiService } from './baseService.js'; import { Logger } from '../utils/logger.js'; import { Config } from '../utils/config.js'; import { CacheManager } from '../utils/cache.js'; import { ParamUtils } from '../utils/paramUtils.js'; import { BlockFactory, BlockType } from './blockFactory.js'; import { AuthUtils,TokenCacheManager } from '../utils/auth/index.js'; import { AuthRequiredError } from '../utils/error.js'; import axios from 'axios'; import FormData from 'form-data'; import fs from 'fs'; import path from 'path'; /** * 飞书API服务类 * 提供飞书API的所有基础操作,包括认证、请求和缓存管理 */ export class FeishuApiService extends BaseApiService { private static instance: FeishuApiService; private readonly cacheManager: CacheManager; private readonly blockFactory: BlockFactory; private readonly config: Config; /** * 私有构造函数,用于单例模式 */ private constructor() { super(); this.cacheManager = CacheManager.getInstance(); this.blockFactory = BlockFactory.getInstance(); this.config = Config.getInstance(); } /** * 获取飞书API服务实例 * @returns 飞书API服务实例 */ public static getInstance(): FeishuApiService { if (!FeishuApiService.instance) { FeishuApiService.instance = new FeishuApiService(); } return FeishuApiService.instance; } /** * 获取API基础URL * @returns API基础URL */ protected getBaseUrl(): string { return this.config.feishu.baseUrl; } /** * 获取API认证端点 * @returns 认证端点URL */ protected getAuthEndpoint(): string { return '/auth/v3/tenant_access_token/internal'; } /** * 获取访问令牌 * @param userKey 用户标识(可选) * @returns 访问令牌 * @throws 如果获取令牌失败则抛出错误 */ protected async getAccessToken(userKey?: string): Promise<string> { const { appId, appSecret, authType } = this.config.feishu; // 生成客户端缓存键 const clientKey = AuthUtils.generateClientKey(userKey); Logger.debug(`[FeishuApiService] 获取访问令牌,userKey: ${userKey}, clientKey: ${clientKey}, authType: ${authType}`); if (authType === 'tenant') { // 租户模式:获取租户访问令牌 return this.getTenantAccessToken(appId, appSecret, clientKey); } else { // 用户模式:获取用户访问令牌 return this.getUserAccessToken(appId, appSecret, clientKey, userKey); } } /** * 获取租户访问令牌 * @param appId 应用ID * @param appSecret 应用密钥 * @param clientKey 客户端缓存键 * @returns 租户访问令牌 */ private async getTenantAccessToken(appId: string, appSecret: string, clientKey: string): Promise<string> { const tokenCacheManager = TokenCacheManager.getInstance(); // 尝试从缓存获取租户token const cachedToken = tokenCacheManager.getTenantToken(clientKey); if (cachedToken) { Logger.debug('使用缓存的租户访问令牌'); return cachedToken; } // 缓存中没有token,请求新的租户token Logger.info('缓存中没有租户token,请求新的租户访问令牌'); try { const requestData = { app_id: appId, app_secret: appSecret, }; const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal'; const headers = { 'Content-Type': 'application/json' }; Logger.debug('请求租户访问令牌:', url, requestData); const response = await axios.post(url, requestData, { headers }); const data = response.data; if (data.code !== 0) { throw new Error(`获取租户访问令牌失败:${data.msg || '未知错误'} (错误码: ${data.code})`); } if (!data.tenant_access_token) { throw new Error('获取租户访问令牌失败:响应中没有token'); } // 计算绝对过期时间戳 const expire_at = Math.floor(Date.now() / 1000) + (data.expire || 0); const tokenInfo = { app_access_token: data.tenant_access_token, expires_at: expire_at }; // 缓存租户token tokenCacheManager.cacheTenantToken(clientKey, tokenInfo, data.expire); Logger.info('租户访问令牌获取并缓存成功'); return data.tenant_access_token; } catch (error) { Logger.error('获取租户访问令牌失败:', error); throw new Error('获取租户访问令牌失败: ' + (error instanceof Error ? error.message : String(error))); } } /** * 获取用户访问令牌 * @param appId 应用ID * @param appSecret 应用密钥 * @param clientKey 客户端缓存键 * @param userKey 用户标识 * @returns 用户访问令牌 */ private async getUserAccessToken(appId: string, appSecret: string, clientKey: string, _userKey?: string): Promise<string> { const tokenCacheManager = TokenCacheManager.getInstance(); // 检查用户token状态 const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey); Logger.debug(`用户token状态:`, tokenStatus); if (tokenStatus.isValid && !tokenStatus.shouldRefresh) { // token有效且不需要刷新,直接返回 const cachedToken = tokenCacheManager.getUserToken(clientKey); if (cachedToken) { Logger.debug('使用缓存的用户访问令牌'); return cachedToken; } } if (tokenStatus.canRefresh && (tokenStatus.isExpired || tokenStatus.shouldRefresh)) { // 可以刷新token Logger.info('尝试刷新用户访问令牌'); try { const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey); if (tokenInfo && tokenInfo.refresh_token) { const refreshedToken = await this.refreshUserToken( tokenInfo.refresh_token, clientKey, appId, appSecret ); if (refreshedToken && refreshedToken.access_token) { Logger.info('用户访问令牌刷新成功'); return refreshedToken.access_token; } } } catch (error) { Logger.warn('刷新用户访问令牌失败:', error); // 刷新失败,清除缓存,需要重新授权 tokenCacheManager.removeUserToken(clientKey); } } // 没有有效的token或刷新失败,需要用户授权 Logger.warn('没有有效的用户token,需要用户授权'); throw new AuthRequiredError('user', '需要用户授权'); } /** * 刷新用户访问令牌 * @param refreshToken 刷新令牌 * @param clientKey 客户端缓存键 * @param appId 应用ID * @param appSecret 应用密钥 * @returns 刷新后的token信息 */ private async refreshUserToken(refreshToken: string, clientKey: string, appId: string, appSecret: string): Promise<any> { const tokenCacheManager = TokenCacheManager.getInstance(); const body = { grant_type: 'refresh_token', client_id: appId, client_secret: appSecret, refresh_token: refreshToken }; Logger.debug('刷新用户访问令牌请求:', body); const response = await axios.post('https://open.feishu.cn/open-apis/authen/v2/oauth/token', body, { headers: { 'Content-Type': 'application/json' } }); const data = response.data; if (data && data.access_token && data.expires_in) { // 计算过期时间戳 data.expires_at = Math.floor(Date.now() / 1000) + data.expires_in; if (data.refresh_token_expires_in) { data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in; } // 缓存新的token信息 const refreshTtl = data.refresh_token_expires_in || 3600 * 24 * 365; // 默认1年 tokenCacheManager.cacheUserToken(clientKey, data, refreshTtl); Logger.info('用户访问令牌刷新并缓存成功'); return data; } else { Logger.warn('刷新用户访问令牌失败:', data); throw new Error('刷新用户访问令牌失败'); } } /** * 创建飞书文档 * @param title 文档标题 * @param folderToken 文件夹Token * @returns 创建的文档信息 */ public async createDocument(title: string, folderToken: string): Promise<any> { try { const endpoint = '/docx/v1/documents'; const payload = { title, folder_token: folderToken }; const response = await this.post(endpoint, payload); return response; } catch (error) { this.handleApiError(error, '创建飞书文档失败'); } } /** * 获取文档信息 * @param documentId 文档ID或URL * @returns 文档信息 */ public async getDocumentInfo(documentId: string): Promise<any> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}`; const response = await this.get(endpoint); return response; } catch (error) { this.handleApiError(error, '获取文档信息失败'); } } /** * 获取文档内容 * @param documentId 文档ID或URL * @param lang 语言代码,0为中文,1为英文 * @returns 文档内容 */ public async getDocumentContent(documentId: string, lang: number = 0): Promise<string> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/raw_content`; const params = { lang }; const response = await this.get(endpoint, params); return response.content; } catch (error) { this.handleApiError(error, '获取文档内容失败'); } } /** * 获取文档块结构 * @param documentId 文档ID或URL * @param pageSize 每页块数量 * @returns 文档块数组 */ public async getDocumentBlocks(documentId: string, pageSize: number = 500): Promise<any[]> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks`; let pageToken = ''; let allBlocks: any[] = []; // 分页获取所有块 do { const params: any = { page_size: pageSize, document_revision_id: -1 }; if (pageToken) { params.page_token = pageToken; } const response = await this.get(endpoint, params); const blocks = response.items || []; allBlocks = [...allBlocks, ...blocks]; pageToken = response.page_token; } while (pageToken); return allBlocks; } catch (error) { this.handleApiError(error, '获取文档块结构失败'); } } /** * 获取块内容 * @param documentId 文档ID或URL * @param blockId 块ID * @returns 块内容 */ public async getBlockContent(documentId: string, blockId: string): Promise<any> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const safeBlockId = ParamUtils.processBlockId(blockId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${safeBlockId}`; const params = { document_revision_id: -1 }; const response = await this.get(endpoint, params); return response; } catch (error) { this.handleApiError(error, '获取块内容失败'); } } /** * 更新块文本内容 * @param documentId 文档ID或URL * @param blockId 块ID * @param textElements 文本元素数组,支持普通文本和公式元素 * @returns 更新结果 */ public async updateBlockTextContent(documentId: string, blockId: string, textElements: Array<{text?: string, equation?: string, style?: any}>): Promise<any> { try { const docId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${docId}/blocks/${blockId}?document_revision_id=-1`; Logger.debug(`准备请求API端点: ${endpoint}`); const elements = textElements.map(item => { if (item.equation !== undefined) { return { equation: { content: item.equation, text_element_style: BlockFactory.applyDefaultTextStyle(item.style) } }; } else { return { text_run: { content: item.text || '', text_element_style: BlockFactory.applyDefaultTextStyle(item.style) } }; } }); const data = { update_text_elements: { elements: elements } }; Logger.debug(`请求数据: ${JSON.stringify(data, null, 2)}`); const response = await this.patch(endpoint, data); return response; } catch (error) { this.handleApiError(error, '更新块文本内容失败'); return null; // 永远不会执行到这里 } } /** * 创建文档块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param blockContent 块内容 * @param index 插入位置索引 * @returns 创建结果 */ public async createDocumentBlock(documentId: string, parentBlockId: string, blockContent: any, index: number = 0): Promise<any> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children?document_revision_id=-1`; Logger.debug(`准备请求API端点: ${endpoint}`); const payload = { children: [blockContent], index }; Logger.debug(`请求数据: ${JSON.stringify(payload, null, 2)}`); const response = await this.post(endpoint, payload); return response; } catch (error) { this.handleApiError(error, '创建文档块失败'); return null; // 永远不会执行到这里 } } /** * 批量创建文档块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param blockContents 块内容数组 * @param index 起始插入位置索引 * @returns 创建结果 */ public async createDocumentBlocks(documentId: string, parentBlockId: string, blockContents: any[], index: number = 0): Promise<any> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children?document_revision_id=-1`; Logger.debug(`准备请求API端点: ${endpoint}`); const payload = { children: blockContents, index }; Logger.debug(`请求数据: ${JSON.stringify(payload, null, 2)}`); const response = await this.post(endpoint, payload); return response; } catch (error) { this.handleApiError(error, '批量创建文档块失败'); return null; // 永远不会执行到这里 } } /** * 创建文本块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param textContents 文本内容数组,支持普通文本和公式元素 * @param align 对齐方式,1为左对齐,2为居中,3为右对齐 * @param index 插入位置索引 * @returns 创建结果 */ public async createTextBlock(documentId: string, parentBlockId: string, textContents: Array<{text?: string, equation?: string, style?: any}>, align: number = 1, index: number = 0): Promise<any> { // 处理文本内容样式,支持普通文本和公式元素 const processedTextContents = textContents.map(item => { if (item.equation !== undefined) { return { equation: item.equation, style: BlockFactory.applyDefaultTextStyle(item.style) }; } else { return { text: item.text || '', style: BlockFactory.applyDefaultTextStyle(item.style) }; } }); const blockContent = this.blockFactory.createTextBlock({ textContents: processedTextContents, align }); return this.createDocumentBlock(documentId, parentBlockId, blockContent, index); } /** * 创建代码块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param code 代码内容 * @param language 语言代码 * @param wrap 是否自动换行 * @param index 插入位置索引 * @returns 创建结果 */ public async createCodeBlock(documentId: string, parentBlockId: string, code: string, language: number = 0, wrap: boolean = false, index: number = 0): Promise<any> { const blockContent = this.blockFactory.createCodeBlock({ code, language, wrap }); return this.createDocumentBlock(documentId, parentBlockId, blockContent, index); } /** * 创建标题块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param text 标题文本 * @param level 标题级别,1-9 * @param index 插入位置索引 * @param align 对齐方式,1为左对齐,2为居中,3为右对齐 * @returns 创建结果 */ public async createHeadingBlock(documentId: string, parentBlockId: string, text: string, level: number = 1, index: number = 0, align: number = 1): Promise<any> { const blockContent = this.blockFactory.createHeadingBlock({ text, level, align }); return this.createDocumentBlock(documentId, parentBlockId, blockContent, index); } /** * 创建列表块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param text 列表项文本 * @param isOrdered 是否是有序列表 * @param index 插入位置索引 * @param align 对齐方式,1为左对齐,2为居中,3为右对齐 * @returns 创建结果 */ public async createListBlock(documentId: string, parentBlockId: string, text: string, isOrdered: boolean = false, index: number = 0, align: number = 1): Promise<any> { const blockContent = this.blockFactory.createListBlock({ text, isOrdered, align }); return this.createDocumentBlock(documentId, parentBlockId, blockContent, index); } /** * 创建Mermaid块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param mermaidCode Mermaid代码 * @param index 插入位置索引 * @returns 创建结果 */ public async createMermaidBlock( documentId: string, parentBlockId: string, mermaidCode: string, index: number = 0 ): Promise<any> { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children?document_revision_id=-1`; const blockContent = { block_type: 40, add_ons: { component_id: "", component_type_id: "blk_631fefbbae02400430b8f9f4", record: JSON.stringify({ data: mermaidCode, }) } }; const payload = { children: [blockContent], index }; Logger.info(`请求创建Mermaid块: ${JSON.stringify(payload).slice(0, 500)}...`); const response = await this.post(endpoint, payload); return response; } /** * 创建表格块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param tableConfig 表格配置 * @param index 插入位置索引 * @returns 创建结果 */ public async createTableBlock( documentId: string, parentBlockId: string, tableConfig: { columnSize: number; rowSize: number; cells?: Array<{ coordinate: { row: number; column: number }; content: any; }>; }, index: number = 0 ): Promise<any> { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/descendant?document_revision_id=-1`; // 处理表格配置,为每个单元格创建正确的内容块 const processedTableConfig = { ...tableConfig, cells: tableConfig.cells?.map(cell => ({ ...cell, content: this.createBlockContent(cell.content.blockType, cell.content.options) })) }; // 使用 BlockFactory 创建表格块内容 const tableStructure = this.blockFactory.createTableBlock(processedTableConfig); const payload = { children_id: tableStructure.children_id, descendants: tableStructure.descendants, index }; Logger.info(`请求创建表格块: ${tableConfig.rowSize}x${tableConfig.columnSize},单元格数量: ${tableConfig.cells?.length || 0}`); const response = await this.post(endpoint, payload); // 创建表格成功后,获取单元格中的图片token const imageTokens = await this.extractImageTokensFromTable( response, tableStructure.imageBlocks ); return { ...response, imageTokens: imageTokens }; } /** * 从表格中提取图片块信息(优化版本) * @param tableResponse 创建表格的响应数据 * @param cells 表格配置,包含原始cells信息 * @returns 图片块信息数组,包含坐标和块ID信息 */ private async extractImageTokensFromTable( tableResponse: any, cells?: Array<{ coordinate: { row: number; column: number }; localBlockId: string; }> ): Promise<Array<{row: number, column: number, blockId: string}>> { try { const imageTokens: Array<{row: number, column: number, blockId: string}> = []; Logger.info(`tableResponse: ${JSON.stringify(tableResponse)}`); // 判断 cells 是否为空 if (!cells || cells.length === 0) { Logger.info('表格中没有图片单元格,跳过图片块信息提取'); return imageTokens; } // 创建 localBlockId 到 block_id 的映射 const blockIdMap = new Map<string, string>(); if (tableResponse && tableResponse.block_id_relations) { for (const relation of tableResponse.block_id_relations) { blockIdMap.set(relation.temporary_block_id, relation.block_id); } Logger.debug(`创建了 ${blockIdMap.size} 个块ID映射关系`); } // 遍历所有图片单元格 for (const cell of cells) { const { coordinate, localBlockId } = cell; const { row, column } = coordinate; // 根据 localBlockId 在创建表格的返回数据中找到 block_id const blockId = blockIdMap.get(localBlockId); if (!blockId) { Logger.warn(`未找到 localBlockId ${localBlockId} 对应的 block_id`); continue; } Logger.debug(`处理单元格 (${row}, ${column}),localBlockId: ${localBlockId},blockId: ${blockId}`); // 直接添加块信息 imageTokens.push({ row, column, blockId }); Logger.info(`提取到图片块信息: 位置(${row}, ${column}),blockId: ${blockId}`); } Logger.info(`成功提取 ${imageTokens.length} 个图片块信息`); return imageTokens; } catch (error) { Logger.error(`提取表格图片块信息失败: ${error}`); return []; } } /** * 删除文档中的块,支持批量删除 * @param documentId 文档ID或URL * @param parentBlockId 父块ID(通常是文档ID) * @param startIndex 起始索引 * @param endIndex 结束索引 * @returns 操作结果 */ public async deleteDocumentBlocks(documentId: string, parentBlockId: string, startIndex: number, endIndex: number): Promise<any> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children/batch_delete`; // 确保索引有效 if (startIndex < 0 || endIndex < startIndex) { throw new Error('无效的索引范围:起始索引必须大于等于0,结束索引必须大于等于起始索引'); } const payload = { start_index: startIndex, end_index: endIndex }; Logger.info(`开始删除文档块,文档ID: ${normalizedDocId},父块ID: ${parentBlockId},索引范围: ${startIndex}-${endIndex}`); const response = await this.delete(endpoint, payload); Logger.info('文档块删除成功'); return response; } catch (error) { this.handleApiError(error, '删除文档块失败'); } } /** * 删除单个文档块(通过创建起始和结束索引相同的批量删除请求) * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param blockIndex 块索引 * @returns 操作结果 */ public async deleteDocumentBlock(documentId: string, parentBlockId: string, blockIndex: number): Promise<any> { return this.deleteDocumentBlocks(documentId, parentBlockId, blockIndex, blockIndex + 1); } /** * 将飞书Wiki链接转换为文档ID * @param wikiUrl Wiki链接或Token * @returns 文档ID */ public async convertWikiToDocumentId(wikiUrl: string): Promise<string> { try { const wikiToken = ParamUtils.processWikiToken(wikiUrl); // 尝试从缓存获取 const cachedDocId = this.cacheManager.getWikiToDocId(wikiToken); if (cachedDocId) { Logger.debug(`使用缓存的Wiki转换结果: ${wikiToken} -> ${cachedDocId}`); return cachedDocId; } // 获取Wiki节点信息 const endpoint = `/wiki/v2/spaces/get_node`; const params = { token: wikiToken, obj_type: 'wiki' }; const response = await this.get(endpoint, params); if (!response.node || !response.node.obj_token) { throw new Error(`无法从Wiki节点获取文档ID: ${wikiToken}`); } const documentId = response.node.obj_token; // 缓存结果 this.cacheManager.cacheWikiToDocId(wikiToken, documentId); Logger.debug(`Wiki转换为文档ID: ${wikiToken} -> ${documentId}`); return documentId; } catch (error) { this.handleApiError(error, 'Wiki转换为文档ID失败'); return ''; // 永远不会执行到这里 } } /** * 获取BlockFactory实例 * @returns BlockFactory实例 */ public getBlockFactory() { return this.blockFactory; } /** * 创建块内容对象 * @param blockType 块类型 * @param options 块选项 * @returns 块内容对象 */ public createBlockContent(blockType: string, options: any): any { try { // 处理特殊的heading标题格式,如heading1, heading2等 if (typeof blockType === 'string' && blockType.startsWith('heading')) { // 使用正则表达式匹配"heading"后跟1-9的数字格式 const headingMatch = blockType.match(/^heading([1-9])$/); if (headingMatch) { // 提取数字部分,例如从"heading1"中提取"1" const level = parseInt(headingMatch[1], 10); // 额外的安全检查,确保level在1-9范围内 if (level >= 1 && level <= 9) { // 使用level参数创建标题块 if (!options || Object.keys(options).length === 0) { // 没有提供选项时创建默认选项 options = { heading: { level, content: '', align: 1 } }; } else if (!('heading' in options)) { // 提供了选项但没有heading字段 options = { heading: { level, content: '', align: 1 } }; } else if (options.heading && !('level' in options.heading)) { // 提供了heading但没有level字段 options.heading.level = level; } blockType = BlockType.HEADING; // 将blockType转为标准的heading类型 Logger.info(`转换特殊标题格式: ${blockType}${level} -> standard heading with level=${level}`); } } } // 使用枚举类型来避免字符串错误 const blockTypeEnum = blockType as BlockType; // 构建块配置 const blockConfig = { type: blockTypeEnum, options: {} }; // 根据块类型处理不同的选项 switch (blockTypeEnum) { case BlockType.TEXT: if ('text' in options && options.text) { const textOptions = options.text; // 处理文本样式,应用默认样式,支持普通文本和公式元素 const textStyles = textOptions.textStyles || []; const processedTextStyles = textStyles.map((item: any) => { if (item.equation !== undefined) { return { equation: item.equation, style: BlockFactory.applyDefaultTextStyle(item.style) }; } else { return { text: item.text || '', style: BlockFactory.applyDefaultTextStyle(item.style) }; } }); blockConfig.options = { textContents: processedTextStyles, align: textOptions.align || 1 }; } break; case BlockType.CODE: if ('code' in options && options.code) { const codeOptions = options.code; blockConfig.options = { code: codeOptions.code || '', language: codeOptions.language === 0 ? 0 : (codeOptions.language || 0), wrap: codeOptions.wrap || false }; } break; case BlockType.HEADING: if ('heading' in options && options.heading) { const headingOptions = options.heading; blockConfig.options = { text: headingOptions.content || '', level: headingOptions.level || 1, align: (headingOptions.align === 1 || headingOptions.align === 2 || headingOptions.align === 3) ? headingOptions.align : 1 }; } break; case BlockType.LIST: if ('list' in options && options.list) { const listOptions = options.list; blockConfig.options = { text: listOptions.content || '', isOrdered: listOptions.isOrdered || false, align: (listOptions.align === 1 || listOptions.align === 2 || listOptions.align === 3) ? listOptions.align : 1 }; } break; case BlockType.IMAGE: if ('image' in options && options.image) { const imageOptions = options.image; blockConfig.options = { width: imageOptions.width || 100, height: imageOptions.height || 100 }; } else { // 默认图片块选项 blockConfig.options = { width: 100, height: 100 }; } break; case BlockType.MERMAID: if ('mermaid' in options && options.mermaid) { const mermaidOptions = options.mermaid; blockConfig.options = { code: mermaidOptions.code, }; } break; default: Logger.warn(`未知的块类型: ${blockType},尝试作为标准类型处理`); if ('text' in options) { blockConfig.type = BlockType.TEXT; const textOptions = options.text; // 处理文本样式,应用默认样式,支持普通文本和公式元素 const textStyles = textOptions.textStyles || []; const processedTextStyles = textStyles.map((item: any) => { if (item.equation !== undefined) { return { equation: item.equation, style: BlockFactory.applyDefaultTextStyle(item.style) }; } else { return { text: item.text || '', style: BlockFactory.applyDefaultTextStyle(item.style) }; } }); blockConfig.options = { textContents: processedTextStyles, align: textOptions.align || 1 }; } else if ('code' in options) { blockConfig.type = BlockType.CODE; const codeOptions = options.code; blockConfig.options = { code: codeOptions.code || '', language: codeOptions.language === 0 ? 0 : (codeOptions.language || 0), wrap: codeOptions.wrap || false }; } else if ('heading' in options) { blockConfig.type = BlockType.HEADING; const headingOptions = options.heading; blockConfig.options = { text: headingOptions.content || '', level: headingOptions.level || 1, align: (headingOptions.align === 1 || headingOptions.align === 2 || headingOptions.align === 3) ? headingOptions.align : 1 }; } else if ('list' in options) { blockConfig.type = BlockType.LIST; const listOptions = options.list; blockConfig.options = { text: listOptions.content || '', isOrdered: listOptions.isOrdered || false, align: (listOptions.align === 1 || listOptions.align === 2 || listOptions.align === 3) ? listOptions.align : 1 }; } else if ('image' in options) { blockConfig.type = BlockType.IMAGE; const imageOptions = options.image; blockConfig.options = { width: imageOptions.width || 100, height: imageOptions.height || 100 }; } else if ("mermaid" in options){ blockConfig.type = BlockType.MERMAID; const mermaidConfig = options.mermaid; blockConfig.options = { code: mermaidConfig.code, }; } break; } // 记录调试信息 Logger.debug(`创建块内容: 类型=${blockConfig.type}, 选项=${JSON.stringify(blockConfig.options)}`); // 使用BlockFactory创建块 return this.blockFactory.createBlock(blockConfig.type, blockConfig.options); } catch (error) { Logger.error(`创建块内容对象失败: ${error}`); return null; } } /** * 获取飞书图片资源 * @param mediaId 图片媒体ID * @param extra 额外参数,可选 * @returns 图片二进制数据 */ public async getImageResource(mediaId: string, extra: string = ''): Promise<Buffer> { try { Logger.info(`开始获取图片资源,媒体ID: ${mediaId}`); if (!mediaId) { throw new Error('媒体ID不能为空'); } const endpoint = `/drive/v1/medias/${mediaId}/download`; const params: any = {}; if (extra) { params.extra = extra; } // 使用通用的request方法获取二进制响应 const response = await this.request<ArrayBuffer>(endpoint, 'GET', params, true, {}, 'arraybuffer'); const imageBuffer = Buffer.from(response); Logger.info(`图片资源获取成功,大小: ${imageBuffer.length} 字节`); return imageBuffer; } catch (error) { this.handleApiError(error, '获取图片资源失败'); return Buffer.from([]); // 永远不会执行到这里 } } /** * 获取飞书根文件夹信息 * 获取用户的根文件夹的元数据信息,包括token、id和用户id * @returns 根文件夹信息 */ public async getRootFolderInfo(): Promise<any> { try { const endpoint = '/drive/explorer/v2/root_folder/meta'; const response = await this.get(endpoint); Logger.debug('获取根文件夹信息成功:', response); return response; } catch (error) { this.handleApiError(error, '获取飞书根文件夹信息失败'); } } /** * 获取文件夹中的文件清单 * @param folderToken 文件夹Token * @param orderBy 排序方式,默认按修改时间排序 * @param direction 排序方向,默认降序 * @returns 文件清单信息 */ public async getFolderFileList( folderToken: string, orderBy: string = 'EditedTime', direction: string = 'DESC' ): Promise<any> { try { const endpoint = '/drive/v1/files'; const params = { folder_token: folderToken, order_by: orderBy, direction: direction }; const response = await this.get(endpoint, params); Logger.debug(`获取文件夹(${folderToken})中的文件清单成功,文件数量: ${response.files?.length || 0}`); return response; } catch (error) { this.handleApiError(error, '获取文件夹中的文件清单失败'); } } /** * 创建文件夹 * @param folderToken 父文件夹Token * @param name 文件夹名称 * @returns 创建的文件夹信息 */ public async createFolder(folderToken: string, name: string): Promise<any> { try { const endpoint = '/drive/v1/files/create_folder'; const payload = { folder_token: folderToken, name: name }; const response = await this.post(endpoint, payload); Logger.debug(`文件夹创建成功, token: ${response.token}, url: ${response.url}`); return response; } catch (error) { this.handleApiError(error, '创建文件夹失败'); } } /** * 搜索飞书文档 * @param searchKey 搜索关键字 * @param count 每页数量,默认50 * @returns 搜索结果,包含所有页的数据 */ public async searchDocuments(searchKey: string, count: number = 50): Promise<any> { try { Logger.info(`开始搜索文档,关键字: ${searchKey}`); const endpoint = `/suite/docs-api/search/object`; let offset = 0; let allResults: any[] = []; let hasMore = true; // 循环获取所有页的数据 while (hasMore && offset + count < 200) { const payload = { search_key: searchKey, docs_types: ["doc"], count: count, offset: offset }; Logger.debug(`请求搜索,offset: ${offset}, count: ${count}`); const response = await this.post(endpoint, payload); Logger.debug('搜索响应:', JSON.stringify(response, null, 2)); if (response && response.docs_entities) { const newDocs = response.docs_entities; allResults = [...allResults, ...newDocs]; hasMore = response.has_more || false; offset += count; Logger.debug(`当前页获取到 ${newDocs.length} 条数据,累计 ${allResults.length} 条,总计 ${response.total} 条,hasMore: ${hasMore}`); } else { hasMore = false; Logger.warn('搜索响应格式异常:', JSON.stringify(response, null, 2)); } } const resultCount = allResults.length; Logger.info(`文档搜索完成,找到 ${resultCount} 个结果`); return { data: allResults }; } catch (error) { this.handleApiError(error, '搜索文档失败'); } } /** * 上传图片素材到飞书 * @param imageBase64 图片的Base64编码 * @param fileName 图片文件名,如果不提供则自动生成 * @param parentBlockId 图片块ID * @returns 上传结果,包含file_token */ public async uploadImageMedia( imageBase64: string, fileName: string, parentBlockId: string, ): Promise<any> { try { const endpoint = '/drive/v1/medias/upload_all'; // 将Base64转换为Buffer const imageBuffer = Buffer.from(imageBase64, 'base64'); const imageSize = imageBuffer.length; // 如果没有提供文件名,根据Base64数据生成默认文件名 if (!fileName) { // 简单检测图片格式 if (imageBase64.startsWith('/9j/')) { fileName = `image_${Date.now()}.jpg`; } else if (imageBase64.startsWith('iVBORw0KGgo')) { fileName = `image_${Date.now()}.png`; } else if (imageBase64.startsWith('R0lGODlh')) { fileName = `image_${Date.now()}.gif`; } else { fileName = `image_${Date.now()}.png`; // 默认PNG格式 } } Logger.info( `开始上传图片素材,文件名: ${fileName},大小: ${imageSize} 字节,关联块ID: ${parentBlockId}`, ); // 验证图片大小(可选的业务检查) if (imageSize > 20 * 1024 * 1024) { // 20MB限制 Logger.warn(`图片文件过大: ${imageSize} 字节,建议小于20MB`); } // 使用FormData构建multipart/form-data请求 const formData = new FormData(); // file字段传递图片的二进制数据流 // Buffer是Node.js中的二进制数据类型,form-data库会将其作为文件流处理 formData.append('file', imageBuffer, { filename: fileName, contentType: this.getMimeTypeFromFileName(fileName), knownLength: imageSize, // 明确指定文件大小,避免流读取问题 }); // 飞书API要求的其他表单字段 formData.append('file_name', fileName); formData.append('parent_type', 'docx_image'); // 固定值:文档图片类型 formData.append('parent_node', parentBlockId); // 关联的图片块ID formData.append('size', imageSize.toString()); // 文件大小(字节,字符串格式) // 使用通用的post方法发送请求 const response = await this.post(endpoint, formData); Logger.info( `图片素材上传成功,file_token: ${response.file_token}`, ); return response; } catch (error) { this.handleApiError(error, '上传图片素材失败'); } } /** * 设置图片块的素材内容 * @param documentId 文档ID * @param imageBlockId 图片块ID * @param fileToken 图片素材的file_token * @returns 设置结果 */ public async setImageBlockContent( documentId: string, imageBlockId: string, fileToken: string, ): Promise<any> { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${imageBlockId}`; const payload = { replace_image: { token: fileToken, }, }; Logger.info( `开始设置图片块内容,文档ID: ${normalizedDocId},块ID: ${imageBlockId},file_token: ${fileToken}`, ); const response = await this.patch(endpoint, payload); Logger.info('图片块内容设置成功'); return response; } catch (error) { this.handleApiError(error, '设置图片块内容失败'); } } /** * 创建完整的图片块(包括创建空块、上传图片、设置内容的完整流程) * @param documentId 文档ID * @param parentBlockId 父块ID * @param imagePathOrUrl 图片路径或URL * @param options 图片选项 * @returns 创建结果 */ public async createImageBlock( documentId: string, parentBlockId: string, imagePathOrUrl: string, options: { fileName?: string; width?: number; height?: number; index?: number; } = {}, ): Promise<any> { try { const { fileName: providedFileName, width, height, index = 0 } = options; Logger.info( `开始创建图片块,文档ID: ${documentId},父块ID: ${parentBlockId},图片源: ${imagePathOrUrl},插入位置: ${index}`, ); // 从路径或URL获取图片的Base64编码 const { base64: imageBase64, fileName: detectedFileName } = await this.getImageBase64FromPathOrUrl(imagePathOrUrl); // 使用提供的文件名或检测到的文件名 const finalFileName = providedFileName || detectedFileName; // 第1步:创建空图片块 Logger.info('第1步:创建空图片块'); const imageBlockContent = this.blockFactory.createImageBlock({ width, height, }); const createBlockResult = await this.createDocumentBlock( documentId, parentBlockId, imageBlockContent, index, ); if (!createBlockResult?.children?.[0]?.block_id) { throw new Error('创建空图片块失败:无法获取块ID'); } const imageBlockId = createBlockResult.children[0].block_id; Logger.info(`空图片块创建成功,块ID: ${imageBlockId}`); // 第2步:上传图片素材 Logger.info('第2步:上传图片素材'); const uploadResult = await this.uploadImageMedia( imageBase64, finalFileName, imageBlockId, ); if (!uploadResult?.file_token) { throw new Error('上传图片素材失败:无法获取file_token'); } Logger.info(`图片素材上传成功,file_token: ${uploadResult.file_token}`); // 第3步:设置图片块内容 Logger.info('第3步:设置图片块内容'); const setContentResult = await this.setImageBlockContent( documentId, imageBlockId, uploadResult.file_token, ); Logger.info('图片块创建完成'); // 返回综合结果 return { imageBlock: createBlockResult.children[0], imageBlockId: imageBlockId, fileToken: uploadResult.file_token, uploadResult: uploadResult, setContentResult: setContentResult, documentRevisionId: setContentResult.document_revision_id || createBlockResult.document_revision_id, }; } catch (error) { this.handleApiError(error, '创建图片块失败'); } } /** * 根据文件名获取MIME类型 * @param fileName 文件名 * @returns MIME类型 */ private getMimeTypeFromFileName(fileName: string): string { const extension = fileName.toLowerCase().split('.').pop(); switch (extension) { case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'gif': return 'image/gif'; case 'webp': return 'image/webp'; case 'bmp': return 'image/bmp'; case 'svg': return 'image/svg+xml'; default: return 'image/png'; // 默认PNG } } /** * 获取画板内容 * @param whiteboardId 画板ID或URL * @returns 画板节点数据 */ public async getWhiteboardContent(whiteboardId: string): Promise<any> { try { const normalizedWhiteboardId = ParamUtils.processWhiteboardId(whiteboardId); const endpoint = `/board/v1/whiteboards/${normalizedWhiteboardId}/nodes`; Logger.info(`开始获取画板内容,画板ID: ${normalizedWhiteboardId}`); const response = await this.get(endpoint); Logger.info(`画板内容获取成功,节点数量: ${response.nodes?.length || 0}`); return response; } catch (error) { this.handleApiError(error, '获取画板内容失败'); } } /** * 获取画板缩略图 * @param whiteboardId 画板ID或URL * @returns 画板缩略图的二进制数据 */ public async getWhiteboardThumbnail(whiteboardId: string): Promise<Buffer> { try { const normalizedWhiteboardId = ParamUtils.processWhiteboardId(whiteboardId); const endpoint = `/board/v1/whiteboards/${normalizedWhiteboardId}/download_as_image`; Logger.info(`开始获取画板缩略图,画板ID: ${normalizedWhiteboardId}`); // 使用通用的request方法获取二进制响应 const response = await this.request<ArrayBuffer>(endpoint, 'GET', {}, true, {}, 'arraybuffer'); const thumbnailBuffer = Buffer.from(response); Logger.info(`画板缩略图获取成功,大小: ${thumbnailBuffer.length} 字节`); return thumbnailBuffer; } catch (error) { this.handleApiError(error, '获取画板缩略图失败'); return Buffer.from([]); // 永远不会执行到这里 } } /** * 从路径或URL获取图片的Base64编码 * @param imagePathOrUrl 图片路径或URL * @returns 图片的Base64编码和文件名 */ private async getImageBase64FromPathOrUrl(imagePathOrUrl: string): Promise<{ base64: string; fileName: string }> { try { let imageBuffer: Buffer; let fileName: string; // 判断是否为HTTP/HTTPS URL if (imagePathOrUrl.startsWith('http://') || imagePathOrUrl.startsWith('https://')) { Logger.info(`从URL获取图片: ${imagePathOrUrl}`); // 从URL下载图片 const response = await axios.get(imagePathOrUrl, { responseType: 'arraybuffer', timeout: 30000, // 30秒超时 }); imageBuffer = Buffer.from(response.data); // 从URL中提取文件名 const urlPath = new URL(imagePathOrUrl).pathname; fileName = path.basename(urlPath) || `image_${Date.now()}.png`; Logger.info(`从URL成功获取图片,大小: ${imageBuffer.length} 字节,文件名: ${fileName}`); } else { // 本地文件路径 Logger.info(`从本地路径读取图片: ${imagePathOrUrl}`); // 检查文件是否存在 if (!fs.existsSync(imagePathOrUrl)) { throw new Error(`图片文件不存在: ${imagePathOrUrl}`); } // 读取文件 imageBuffer = fs.readFileSync(imagePathOrUrl); fileName = path.basename(imagePathOrUrl); Logger.info(`从本地路径成功读取图片,大小: ${imageBuffer.length} 字节,文件名: ${fileName}`); } // 转换为Base64 const base64 = imageBuffer.toString('base64'); return { base64, fileName }; } catch (error) { Logger.error(`获取图片失败: ${error}`); throw new Error(`获取图片失败: ${error instanceof Error ? error.message : String(error)}`); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cso1z/Feishu-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server