UNPKG

feishu-mcp

Version:

Model Context Protocol server for Feishu integration

764 lines (763 loc) 31.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 axios from 'axios'; /** * 飞书API服务类 * 提供飞书API的所有基础操作,包括认证、请求和缓存管理 */ export class FeishuApiService extends BaseApiService { /** * 私有构造函数,用于单例模式 */ constructor() { super(); Object.defineProperty(this, "cacheManager", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "blockFactory", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "config", { enumerable: true, configurable: true, writable: true, value: void 0 }); this.cacheManager = CacheManager.getInstance(); this.blockFactory = BlockFactory.getInstance(); this.config = Config.getInstance(); } /** * 获取飞书API服务实例 * @returns 飞书API服务实例 */ static getInstance() { if (!FeishuApiService.instance) { FeishuApiService.instance = new FeishuApiService(); } return FeishuApiService.instance; } /** * 获取API基础URL * @returns API基础URL */ getBaseUrl() { return this.config.feishu.baseUrl; } /** * 获取API认证端点 * @returns 认证端点URL */ getAuthEndpoint() { return '/auth/v3/tenant_access_token/internal'; } /** * 获取访问令牌 * @returns 访问令牌 * @throws 如果获取令牌失败则抛出错误 */ async getAccessToken() { // 尝试从缓存获取 const cachedToken = this.cacheManager.getToken(); if (cachedToken) { Logger.debug('使用缓存的访问令牌'); return cachedToken; } try { const requestData = { app_id: this.config.feishu.appId, app_secret: this.config.feishu.appSecret, }; Logger.info('开始获取新的飞书访问令牌...'); Logger.debug('认证请求参数:', requestData); // 不使用通用的request方法,因为这个请求不需要认证 // 为了确保正确处理响应,我们直接使用axios const url = `${this.getBaseUrl()}${this.getAuthEndpoint()}`; const headers = { 'Content-Type': 'application/json' }; Logger.debug(`发送认证请求到: ${url}`); const response = await axios.post(url, requestData, { headers }); Logger.debug('认证响应:', response.data); if (!response.data || typeof response.data !== 'object') { throw new Error('获取飞书访问令牌失败:响应格式无效'); } // 检查错误码 if (response.data.code !== 0) { throw new Error(`获取飞书访问令牌失败:${response.data.msg || '未知错误'} (错误码: ${response.data.code})`); } if (!response.data.tenant_access_token) { throw new Error('获取飞书访问令牌失败:响应中没有token'); } this.accessToken = response.data.tenant_access_token; this.tokenExpireTime = Date.now() + Math.min(response.data.expire * 1000, this.config.feishu.tokenLifetime); // 缓存令牌 this.cacheManager.cacheToken(this.accessToken, response.data.expire); Logger.info(`成功获取新的飞书访问令牌,有效期: ${response.data.expire} 秒`); return this.accessToken; } catch (error) { Logger.error('获取访问令牌失败:', error); this.handleApiError(error, '获取飞书访问令牌失败'); } } /** * 创建飞书文档 * @param title 文档标题 * @param folderToken 文件夹Token * @returns 创建的文档信息 */ async createDocument(title, folderToken) { 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 文档信息 */ async getDocumentInfo(documentId) { 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 文档内容 */ async getDocumentContent(documentId, lang = 0) { 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 文档块数组 */ async getDocumentBlocks(documentId, pageSize = 500) { try { const normalizedDocId = ParamUtils.processDocumentId(documentId); const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks`; let pageToken = ''; let allBlocks = []; // 分页获取所有块 do { const params = { 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 块内容 */ async getBlockContent(documentId, blockId) { 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 更新结果 */ async updateBlockTextContent(documentId, blockId, textElements) { 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 => ({ 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 创建结果 */ async createDocumentBlock(documentId, parentBlockId, blockContent, index = 0) { 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 创建结果 */ async createDocumentBlocks(documentId, parentBlockId, blockContents, index = 0) { 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 创建结果 */ async createTextBlock(documentId, parentBlockId, textContents, align = 1, index = 0) { // 处理文本内容样式 const processedTextContents = textContents.map(item => ({ 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 创建结果 */ async createCodeBlock(documentId, parentBlockId, code, language = 0, wrap = false, index = 0) { 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 创建结果 */ async createHeadingBlock(documentId, parentBlockId, text, level = 1, index = 0, align = 1) { 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 创建结果 */ async createListBlock(documentId, parentBlockId, text, isOrdered = false, index = 0, align = 1) { const blockContent = this.blockFactory.createListBlock({ text, isOrdered, align }); return this.createDocumentBlock(documentId, parentBlockId, blockContent, index); } /** * 创建混合块 * @param documentId 文档ID或URL * @param parentBlockId 父块ID * @param blocks 块配置数组 * @param index 插入位置索引 * @returns 创建结果 */ async createMixedBlocks(documentId, parentBlockId, blocks, index = 0) { const blockContents = blocks.map(block => this.blockFactory.createBlock(block.type, block.options)); return this.createDocumentBlocks(documentId, parentBlockId, blockContents, index); } /** * 删除文档中的块,支持批量删除 * @param documentId 文档ID或URL * @param parentBlockId 父块ID(通常是文档ID) * @param startIndex 起始索引 * @param endIndex 结束索引 * @returns 操作结果 */ async deleteDocumentBlocks(documentId, parentBlockId, startIndex, endIndex) { 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 操作结果 */ async deleteDocumentBlock(documentId, parentBlockId, blockIndex) { return this.deleteDocumentBlocks(documentId, parentBlockId, blockIndex, blockIndex + 1); } /** * 将飞书Wiki链接转换为文档ID * @param wikiUrl Wiki链接或Token * @returns 文档ID */ async convertWikiToDocumentId(wikiUrl) { 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实例 */ getBlockFactory() { return this.blockFactory; } /** * 创建块内容对象 * @param blockType 块类型 * @param options 块选项 * @returns 块内容对象 */ createBlockContent(blockType, options) { 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; // 构建块配置 let 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) => ({ 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; 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) => ({ 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 }; } 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 图片二进制数据 */ async getImageResource(mediaId, extra = '') { try { Logger.info(`开始获取图片资源,媒体ID: ${mediaId}`); if (!mediaId) { throw new Error('媒体ID不能为空'); } const endpoint = `/drive/v1/medias/${mediaId}/download`; const params = {}; if (extra) { params.extra = extra; } // 这里需要特殊处理,因为返回的是二进制数据,不是JSON const token = await this.getAccessToken(); const url = `${this.getBaseUrl()}${endpoint}`; const headers = { 'Authorization': `Bearer ${token}` }; Logger.debug(`请求图片资源URL: ${url}`); // 使用axios直接获取二进制响应 const response = await axios.get(url, { params, headers, responseType: 'arraybuffer' }); // 检查响应状态 if (response.status !== 200) { throw new Error(`获取图片资源失败,状态码: ${response.status}`); } const imageBuffer = Buffer.from(response.data); Logger.info(`图片资源获取成功,大小: ${imageBuffer.length} 字节`); return imageBuffer; } catch (error) { this.handleApiError(error, '获取图片资源失败'); return Buffer.from([]); // 永远不会执行到这里 } } /** * 获取飞书根文件夹信息 * 获取用户的根文件夹的元数据信息,包括token、id和用户id * @returns 根文件夹信息 */ async getRootFolderInfo() { 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 文件清单信息 */ async getFolderFileList(folderToken, orderBy = 'EditedTime', direction = 'DESC') { 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 创建的文件夹信息 */ async createFolder(folderToken, name) { 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 搜索结果,包含所有页的数据 */ async searchDocuments(searchKey, count = 50) { try { Logger.info(`开始搜索文档,关键字: ${searchKey}`); const endpoint = `//suite/docs-api/search/object`; let offset = 0; let allResults = []; 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, '搜索文档失败'); } } }