UNPKG

feishu-mcp

Version:

Model Context Protocol server for Feishu integration

220 lines (219 loc) 10.8 kB
import axios from 'axios'; import FormData from 'form-data'; import fs from 'fs'; import path from 'path'; import { Logger } from '../../utils/logger.js'; import { ParamUtils } from '../../utils/paramUtils.js'; import { FeishuBaseApiService } from './FeishuBaseApiService.js'; /** * 飞书图片服务 * 负责图片的获取、上传和块创建 */ export class FeishuImageService extends FeishuBaseApiService { constructor(authService, blockFactory, blockService) { super(authService); Object.defineProperty(this, "blockFactory", { enumerable: true, configurable: true, writable: true, value: blockFactory }); Object.defineProperty(this, "blockService", { enumerable: true, configurable: true, writable: true, value: blockService }); } /** * 下载飞书云文档中的图片素材,返回二进制数据 * @param mediaId 图片的媒体 ID(media_id / file_token) * @param extra 额外参数,部分场景需要传递(如加密图片),默认为空字符串 * @returns 图片的二进制 Buffer 数据 */ 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; const response = await this.request(endpoint, 'GET', params, true, {}, 'arraybuffer'); const imageBuffer = Buffer.from(response); Logger.info(`图片资源获取成功,大小: ${imageBuffer.length} 字节`); return imageBuffer; } catch (error) { this.handleApiError(error, '获取图片资源失败'); return Buffer.from([]); } } /** * 将图片素材上传到飞书云端,关联到指定的图片块 * @param imageBase64 图片的 Base64 编码字符串(不含 data:image/xxx;base64, 前缀) * @param fileName 图片文件名(含扩展名),若为空字符串则根据 Base64 内容自动检测格式并生成文件名 * @param parentBlockId 关联的图片块 ID(parent_node),用于告知飞书该素材归属的块 * @returns 上传结果,包含 file_token 字段,用于后续 setImageBlockContent 调用 */ async uploadImageMedia(imageBase64, fileName, parentBlockId) { try { const endpoint = '/drive/v1/medias/upload_all'; const imageBuffer = Buffer.from(imageBase64, 'base64'); const imageSize = imageBuffer.length; 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`; } } Logger.info(`开始上传图片素材,文件名: ${fileName},大小: ${imageSize} 字节,关联块ID: ${parentBlockId}`); if (imageSize > 20 * 1024 * 1024) { Logger.warn(`图片文件过大: ${imageSize} 字节,建议小于20MB`); } const formData = new FormData(); formData.append('file', imageBuffer, { filename: fileName, contentType: this.getMimeTypeFromFileName(fileName), knownLength: imageSize }); formData.append('file_name', fileName); formData.append('parent_type', 'docx_image'); formData.append('parent_node', parentBlockId); formData.append('size', imageSize.toString()); const response = await this.post(endpoint, formData); Logger.info(`图片素材上传成功,file_token: ${response.file_token}`); return response; } catch (error) { this.handleApiError(error, '上传图片素材失败'); } } /** * 将已上传的图片素材绑定到指定的图片块,完成图片块的最终渲染 * @param documentId 文档 ID 或 URL * @param imageBlockId 目标图片块的 block_id * @param fileToken 图片素材的 file_token(由 uploadImageMedia 返回) * @returns 更新结果,包含 document_revision_id 等字段 */ async setImageBlockContent(documentId, imageBlockId, fileToken) { 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, '设置图片块内容失败'); } } /** * 完整创建图片块的三步流程:创建空块 → 上传素材 → 绑定素材 * 支持本地文件路径和 HTTP/HTTPS URL 两种图片来源 * @param documentId 文档 ID 或 URL * @param parentBlockId 父块 ID,图片块将插入到该块的子节点列表中 * @param imagePathOrUrl 图片来源,支持本地绝对路径或 HTTP/HTTPS URL * @param options 可选配置 * @param options.fileName 自定义文件名(含扩展名),不传则从路径/URL 自动提取 * @param options.width 图片显示宽度(像素),不传则使用默认值 * @param options.height 图片显示高度(像素),不传则使用默认值 * @param options.index 插入位置索引,默认 0 * @returns 综合结果对象,包含 imageBlockId、fileToken、imageBlock、uploadResult、setContentResult、documentRevisionId */ async createImageBlock(documentId, parentBlockId, imagePathOrUrl, options = {}) { try { const { fileName: providedFileName, width, height, index = 0 } = options; Logger.info(`开始创建图片块,文档ID: ${documentId},父块ID: ${parentBlockId},图片源: ${imagePathOrUrl},插入位置: ${index}`); const { base64: imageBase64, fileName: detectedFileName } = await this.getImageBase64FromPathOrUrl(imagePathOrUrl); const finalFileName = providedFileName || detectedFileName; Logger.info('第1步:创建空图片块'); const imageBlockContent = this.blockFactory.createImageBlock({ width, height }); const createBlockResult = await this.blockService.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}`); 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}`); Logger.info('第3步:设置图片块内容'); const setContentResult = await this.setImageBlockContent(documentId, imageBlockId, uploadResult.file_token); Logger.info('图片块创建完成'); return { imageBlock: createBlockResult.children[0], imageBlockId, fileToken: uploadResult.file_token, uploadResult, setContentResult, documentRevisionId: setContentResult.document_revision_id || createBlockResult.document_revision_id }; } catch (error) { this.handleApiError(error, '创建图片块失败'); } } getMimeTypeFromFileName(fileName) { 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'; } } async getImageBase64FromPathOrUrl(imagePathOrUrl) { try { let imageBuffer; let fileName; if (imagePathOrUrl.startsWith('http://') || imagePathOrUrl.startsWith('https://')) { Logger.info(`从URL获取图片: ${imagePathOrUrl}`); const response = await axios.get(imagePathOrUrl, { responseType: 'arraybuffer', timeout: 30000 }); imageBuffer = Buffer.from(response.data); 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}`); } return { base64: imageBuffer.toString('base64'), fileName }; } catch (error) { Logger.error(`获取图片失败: ${error}`); throw new Error(`获取图片失败: ${error instanceof Error ? error.message : String(error)}`); } } }