feishu-mcp 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +212 -115
- package/dist/cli.js +0 -0
- package/dist/manager/sseConnectionManager.js +0 -1
- package/dist/mcp/tools/feishuBlockTools.js +120 -9
- package/dist/mcp/tools/feishuFolderTools.js +4 -6
- package/dist/mcp/tools/feishuTools.js +30 -1
- package/dist/services/baseService.js +18 -4
- package/dist/services/blockFactory.js +16 -5
- package/dist/services/feishuApiService.js +283 -31
- package/dist/types/feishuSchema.js +38 -11
- package/dist/utils/logger.js +2 -2
- package/package.json +3 -2
- package/dist/config.js +0 -26
- package/dist/services/feishu.js +0 -495
- package/dist/services/feishuBlockService.js +0 -179
- package/dist/services/feishuBlocks.js +0 -135
- package/dist/services/feishuService.js +0 -475
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { formatErrorMessage } from '../../utils/error.js';
|
|
3
3
|
import { Logger } from '../../utils/logger.js';
|
|
4
|
-
import { DocumentIdSchema, BlockIdSchema, } from '../../types/feishuSchema.js';
|
|
4
|
+
import { DocumentIdSchema, BlockIdSchema, SearchKeySchema, } from '../../types/feishuSchema.js';
|
|
5
5
|
/**
|
|
6
6
|
* 注册飞书相关的MCP工具
|
|
7
7
|
* @param server MCP服务器实例
|
|
@@ -134,4 +134,33 @@ export function registerFeishuTools(server, feishuService) {
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
});
|
|
137
|
+
// 添加搜索文档工具
|
|
138
|
+
server.tool('search_feishu_documents', 'Searches for documents in Feishu. Supports keyword-based search and returns document information including title, type, and owner. Use this tool to find specific content or related documents in your document library.', {
|
|
139
|
+
searchKey: SearchKeySchema,
|
|
140
|
+
}, async ({ searchKey }) => {
|
|
141
|
+
try {
|
|
142
|
+
if (!feishuService) {
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration.' }],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
Logger.info(`开始搜索飞书文档,关键字: ${searchKey},`);
|
|
148
|
+
const searchResult = await feishuService.searchDocuments(searchKey);
|
|
149
|
+
Logger.info(`文档搜索完成,找到 ${searchResult.size} 个结果`);
|
|
150
|
+
return {
|
|
151
|
+
content: [
|
|
152
|
+
{ type: 'text', text: JSON.stringify(searchResult, null, 2) },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
Logger.error(`搜索飞书文档失败:`, error);
|
|
158
|
+
const errorMessage = formatErrorMessage(error);
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{ type: 'text', text: `搜索飞书文档失败: ${errorMessage}` },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
});
|
|
137
166
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import axios, { AxiosError } from 'axios';
|
|
2
|
+
import FormData from 'form-data';
|
|
2
3
|
import { Logger } from '../utils/logger.js';
|
|
3
4
|
import { formatErrorMessage } from '../utils/error.js';
|
|
4
5
|
/**
|
|
@@ -74,17 +75,25 @@ export class BaseApiService {
|
|
|
74
75
|
* @param data 请求数据
|
|
75
76
|
* @param needsAuth 是否需要认证
|
|
76
77
|
* @param additionalHeaders 附加请求头
|
|
78
|
+
* @param responseType 响应类型
|
|
77
79
|
* @returns 响应数据
|
|
78
80
|
*/
|
|
79
|
-
async request(endpoint, method = 'GET', data, needsAuth = true, additionalHeaders) {
|
|
81
|
+
async request(endpoint, method = 'GET', data, needsAuth = true, additionalHeaders, responseType) {
|
|
80
82
|
try {
|
|
81
83
|
// 构建请求URL
|
|
82
84
|
const url = `${this.getBaseUrl()}${endpoint}`;
|
|
83
85
|
// 准备请求头
|
|
84
86
|
const headers = {
|
|
85
|
-
'Content-Type': 'application/json',
|
|
86
87
|
...additionalHeaders
|
|
87
88
|
};
|
|
89
|
+
// 如果数据是FormData,合并FormData的headers
|
|
90
|
+
// 否则设置为application/json
|
|
91
|
+
if (data instanceof FormData) {
|
|
92
|
+
Object.assign(headers, data.getHeaders());
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
headers['Content-Type'] = 'application/json';
|
|
96
|
+
}
|
|
88
97
|
// 添加认证令牌
|
|
89
98
|
if (needsAuth) {
|
|
90
99
|
const accessToken = await this.getAccessToken();
|
|
@@ -103,7 +112,8 @@ export class BaseApiService {
|
|
|
103
112
|
url,
|
|
104
113
|
headers,
|
|
105
114
|
data: method !== 'GET' ? data : undefined,
|
|
106
|
-
params: method === 'GET' ? data : undefined
|
|
115
|
+
params: method === 'GET' ? data : undefined,
|
|
116
|
+
responseType: responseType || 'json'
|
|
107
117
|
};
|
|
108
118
|
// 发送请求
|
|
109
119
|
const response = await axios(config);
|
|
@@ -112,7 +122,11 @@ export class BaseApiService {
|
|
|
112
122
|
Logger.debug(`响应状态码: ${response.status}`);
|
|
113
123
|
Logger.debug(`响应头:`, response.headers);
|
|
114
124
|
Logger.debug(`响应数据:`, response.data);
|
|
115
|
-
//
|
|
125
|
+
// 对于非JSON响应,直接返回数据
|
|
126
|
+
if (responseType && responseType !== 'json') {
|
|
127
|
+
return response.data;
|
|
128
|
+
}
|
|
129
|
+
// 检查API错误(仅对JSON响应)
|
|
116
130
|
if (response.data && typeof response.data.code === 'number' && response.data.code !== 0) {
|
|
117
131
|
Logger.error(`API返回错误码: ${response.data.code}, 错误消息: ${response.data.msg}`);
|
|
118
132
|
throw {
|
|
@@ -8,6 +8,7 @@ export var BlockType;
|
|
|
8
8
|
BlockType["CODE"] = "code";
|
|
9
9
|
BlockType["HEADING"] = "heading";
|
|
10
10
|
BlockType["LIST"] = "list";
|
|
11
|
+
BlockType["IMAGE"] = "image";
|
|
11
12
|
})(BlockType || (BlockType = {}));
|
|
12
13
|
/**
|
|
13
14
|
* 对齐方式枚举
|
|
@@ -72,6 +73,8 @@ export class BlockFactory {
|
|
|
72
73
|
return this.createHeadingBlock(options);
|
|
73
74
|
case BlockType.LIST:
|
|
74
75
|
return this.createListBlock(options);
|
|
76
|
+
case BlockType.IMAGE:
|
|
77
|
+
return this.createImageBlock(options);
|
|
75
78
|
default:
|
|
76
79
|
Logger.error(`不支持的块类型: ${type}`);
|
|
77
80
|
throw new Error(`不支持的块类型: ${type}`);
|
|
@@ -192,11 +195,19 @@ export class BlockFactory {
|
|
|
192
195
|
return blockContent;
|
|
193
196
|
}
|
|
194
197
|
/**
|
|
195
|
-
*
|
|
196
|
-
* @param
|
|
197
|
-
* @returns
|
|
198
|
+
* 创建图片块内容(空图片块,需要后续设置图片资源)
|
|
199
|
+
* @param options 图片块选项
|
|
200
|
+
* @returns 图片块内容对象
|
|
198
201
|
*/
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
createImageBlock(options = {}) {
|
|
203
|
+
const { width = 100, height = 100 } = options;
|
|
204
|
+
return {
|
|
205
|
+
block_type: 27, // 27表示图片块
|
|
206
|
+
image: {
|
|
207
|
+
width: width,
|
|
208
|
+
height: height,
|
|
209
|
+
token: "" // 空token,需要后续通过API设置
|
|
210
|
+
}
|
|
211
|
+
};
|
|
201
212
|
}
|
|
202
213
|
}
|
|
@@ -5,6 +5,9 @@ import { CacheManager } from '../utils/cache.js';
|
|
|
5
5
|
import { ParamUtils } from '../utils/paramUtils.js';
|
|
6
6
|
import { BlockFactory, BlockType } from './blockFactory.js';
|
|
7
7
|
import axios from 'axios';
|
|
8
|
+
import FormData from 'form-data';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
8
11
|
/**
|
|
9
12
|
* 飞书API服务类
|
|
10
13
|
* 提供飞书API的所有基础操作,包括认证、请求和缓存管理
|
|
@@ -373,18 +376,6 @@ export class FeishuApiService extends BaseApiService {
|
|
|
373
376
|
});
|
|
374
377
|
return this.createDocumentBlock(documentId, parentBlockId, blockContent, index);
|
|
375
378
|
}
|
|
376
|
-
/**
|
|
377
|
-
* 创建混合块
|
|
378
|
-
* @param documentId 文档ID或URL
|
|
379
|
-
* @param parentBlockId 父块ID
|
|
380
|
-
* @param blocks 块配置数组
|
|
381
|
-
* @param index 插入位置索引
|
|
382
|
-
* @returns 创建结果
|
|
383
|
-
*/
|
|
384
|
-
async createMixedBlocks(documentId, parentBlockId, blocks, index = 0) {
|
|
385
|
-
const blockContents = blocks.map(block => this.blockFactory.createBlock(block.type, block.options));
|
|
386
|
-
return this.createDocumentBlocks(documentId, parentBlockId, blockContents, index);
|
|
387
|
-
}
|
|
388
379
|
/**
|
|
389
380
|
* 删除文档中的块,支持批量删除
|
|
390
381
|
* @param documentId 文档ID或URL
|
|
@@ -501,7 +492,7 @@ export class FeishuApiService extends BaseApiService {
|
|
|
501
492
|
// 使用枚举类型来避免字符串错误
|
|
502
493
|
const blockTypeEnum = blockType;
|
|
503
494
|
// 构建块配置
|
|
504
|
-
|
|
495
|
+
const blockConfig = {
|
|
505
496
|
type: blockTypeEnum,
|
|
506
497
|
options: {}
|
|
507
498
|
};
|
|
@@ -554,6 +545,22 @@ export class FeishuApiService extends BaseApiService {
|
|
|
554
545
|
};
|
|
555
546
|
}
|
|
556
547
|
break;
|
|
548
|
+
case BlockType.IMAGE:
|
|
549
|
+
if ('image' in options && options.image) {
|
|
550
|
+
const imageOptions = options.image;
|
|
551
|
+
blockConfig.options = {
|
|
552
|
+
width: imageOptions.width || 100,
|
|
553
|
+
height: imageOptions.height || 100
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
// 默认图片块选项
|
|
558
|
+
blockConfig.options = {
|
|
559
|
+
width: 100,
|
|
560
|
+
height: 100
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
break;
|
|
557
564
|
default:
|
|
558
565
|
Logger.warn(`未知的块类型: ${blockType},尝试作为标准类型处理`);
|
|
559
566
|
if ('text' in options) {
|
|
@@ -599,6 +606,14 @@ export class FeishuApiService extends BaseApiService {
|
|
|
599
606
|
? listOptions.align : 1
|
|
600
607
|
};
|
|
601
608
|
}
|
|
609
|
+
else if ('image' in options) {
|
|
610
|
+
blockConfig.type = BlockType.IMAGE;
|
|
611
|
+
const imageOptions = options.image;
|
|
612
|
+
blockConfig.options = {
|
|
613
|
+
width: imageOptions.width || 100,
|
|
614
|
+
height: imageOptions.height || 100
|
|
615
|
+
};
|
|
616
|
+
}
|
|
602
617
|
break;
|
|
603
618
|
}
|
|
604
619
|
// 记录调试信息
|
|
@@ -628,24 +643,9 @@ export class FeishuApiService extends BaseApiService {
|
|
|
628
643
|
if (extra) {
|
|
629
644
|
params.extra = extra;
|
|
630
645
|
}
|
|
631
|
-
//
|
|
632
|
-
const
|
|
633
|
-
const
|
|
634
|
-
const headers = {
|
|
635
|
-
'Authorization': `Bearer ${token}`
|
|
636
|
-
};
|
|
637
|
-
Logger.debug(`请求图片资源URL: ${url}`);
|
|
638
|
-
// 使用axios直接获取二进制响应
|
|
639
|
-
const response = await axios.get(url, {
|
|
640
|
-
params,
|
|
641
|
-
headers,
|
|
642
|
-
responseType: 'arraybuffer'
|
|
643
|
-
});
|
|
644
|
-
// 检查响应状态
|
|
645
|
-
if (response.status !== 200) {
|
|
646
|
-
throw new Error(`获取图片资源失败,状态码: ${response.status}`);
|
|
647
|
-
}
|
|
648
|
-
const imageBuffer = Buffer.from(response.data);
|
|
646
|
+
// 使用通用的request方法获取二进制响应
|
|
647
|
+
const response = await this.request(endpoint, 'GET', params, true, {}, 'arraybuffer');
|
|
648
|
+
const imageBuffer = Buffer.from(response);
|
|
649
649
|
Logger.info(`图片资源获取成功,大小: ${imageBuffer.length} 字节`);
|
|
650
650
|
return imageBuffer;
|
|
651
651
|
}
|
|
@@ -714,4 +714,256 @@ export class FeishuApiService extends BaseApiService {
|
|
|
714
714
|
this.handleApiError(error, '创建文件夹失败');
|
|
715
715
|
}
|
|
716
716
|
}
|
|
717
|
+
/**
|
|
718
|
+
* 搜索飞书文档
|
|
719
|
+
* @param searchKey 搜索关键字
|
|
720
|
+
* @param count 每页数量,默认50
|
|
721
|
+
* @returns 搜索结果,包含所有页的数据
|
|
722
|
+
*/
|
|
723
|
+
async searchDocuments(searchKey, count = 50) {
|
|
724
|
+
try {
|
|
725
|
+
Logger.info(`开始搜索文档,关键字: ${searchKey}`);
|
|
726
|
+
const endpoint = `//suite/docs-api/search/object`;
|
|
727
|
+
let offset = 0;
|
|
728
|
+
let allResults = [];
|
|
729
|
+
let hasMore = true;
|
|
730
|
+
// 循环获取所有页的数据
|
|
731
|
+
while (hasMore && offset + count < 200) {
|
|
732
|
+
const payload = {
|
|
733
|
+
search_key: searchKey,
|
|
734
|
+
docs_types: ["doc"],
|
|
735
|
+
count: count,
|
|
736
|
+
offset: offset
|
|
737
|
+
};
|
|
738
|
+
Logger.debug(`请求搜索,offset: ${offset}, count: ${count}`);
|
|
739
|
+
const response = await this.post(endpoint, payload);
|
|
740
|
+
Logger.debug('搜索响应:', JSON.stringify(response, null, 2));
|
|
741
|
+
if (response && response.docs_entities) {
|
|
742
|
+
const newDocs = response.docs_entities;
|
|
743
|
+
allResults = [...allResults, ...newDocs];
|
|
744
|
+
hasMore = response.has_more || false;
|
|
745
|
+
offset += count;
|
|
746
|
+
Logger.debug(`当前页获取到 ${newDocs.length} 条数据,累计 ${allResults.length} 条,总计 ${response.total} 条,hasMore: ${hasMore}`);
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
hasMore = false;
|
|
750
|
+
Logger.warn('搜索响应格式异常:', JSON.stringify(response, null, 2));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const resultCount = allResults.length;
|
|
754
|
+
Logger.info(`文档搜索完成,找到 ${resultCount} 个结果`);
|
|
755
|
+
return {
|
|
756
|
+
data: allResults
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
this.handleApiError(error, '搜索文档失败');
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* 上传图片素材到飞书
|
|
765
|
+
* @param imageBase64 图片的Base64编码
|
|
766
|
+
* @param fileName 图片文件名,如果不提供则自动生成
|
|
767
|
+
* @param parentBlockId 图片块ID
|
|
768
|
+
* @returns 上传结果,包含file_token
|
|
769
|
+
*/
|
|
770
|
+
async uploadImageMedia(imageBase64, fileName, parentBlockId) {
|
|
771
|
+
try {
|
|
772
|
+
const endpoint = '/drive/v1/medias/upload_all';
|
|
773
|
+
// 将Base64转换为Buffer
|
|
774
|
+
const imageBuffer = Buffer.from(imageBase64, 'base64');
|
|
775
|
+
const imageSize = imageBuffer.length;
|
|
776
|
+
// 如果没有提供文件名,根据Base64数据生成默认文件名
|
|
777
|
+
if (!fileName) {
|
|
778
|
+
// 简单检测图片格式
|
|
779
|
+
if (imageBase64.startsWith('/9j/')) {
|
|
780
|
+
fileName = `image_${Date.now()}.jpg`;
|
|
781
|
+
}
|
|
782
|
+
else if (imageBase64.startsWith('iVBORw0KGgo')) {
|
|
783
|
+
fileName = `image_${Date.now()}.png`;
|
|
784
|
+
}
|
|
785
|
+
else if (imageBase64.startsWith('R0lGODlh')) {
|
|
786
|
+
fileName = `image_${Date.now()}.gif`;
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
fileName = `image_${Date.now()}.png`; // 默认PNG格式
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
Logger.info(`开始上传图片素材,文件名: ${fileName},大小: ${imageSize} 字节,关联块ID: ${parentBlockId}`);
|
|
793
|
+
// 验证图片大小(可选的业务检查)
|
|
794
|
+
if (imageSize > 20 * 1024 * 1024) {
|
|
795
|
+
// 20MB限制
|
|
796
|
+
Logger.warn(`图片文件过大: ${imageSize} 字节,建议小于20MB`);
|
|
797
|
+
}
|
|
798
|
+
// 使用FormData构建multipart/form-data请求
|
|
799
|
+
const formData = new FormData();
|
|
800
|
+
// file字段传递图片的二进制数据流
|
|
801
|
+
// Buffer是Node.js中的二进制数据类型,form-data库会将其作为文件流处理
|
|
802
|
+
formData.append('file', imageBuffer, {
|
|
803
|
+
filename: fileName,
|
|
804
|
+
contentType: this.getMimeTypeFromFileName(fileName),
|
|
805
|
+
knownLength: imageSize, // 明确指定文件大小,避免流读取问题
|
|
806
|
+
});
|
|
807
|
+
// 飞书API要求的其他表单字段
|
|
808
|
+
formData.append('file_name', fileName);
|
|
809
|
+
formData.append('parent_type', 'docx_image'); // 固定值:文档图片类型
|
|
810
|
+
formData.append('parent_node', parentBlockId); // 关联的图片块ID
|
|
811
|
+
formData.append('size', imageSize.toString()); // 文件大小(字节,字符串格式)
|
|
812
|
+
// 使用通用的post方法发送请求
|
|
813
|
+
const response = await this.post(endpoint, formData);
|
|
814
|
+
Logger.info(`图片素材上传成功,file_token: ${response.file_token}`);
|
|
815
|
+
return response;
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
this.handleApiError(error, '上传图片素材失败');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* 设置图片块的素材内容
|
|
823
|
+
* @param documentId 文档ID
|
|
824
|
+
* @param imageBlockId 图片块ID
|
|
825
|
+
* @param fileToken 图片素材的file_token
|
|
826
|
+
* @returns 设置结果
|
|
827
|
+
*/
|
|
828
|
+
async setImageBlockContent(documentId, imageBlockId, fileToken) {
|
|
829
|
+
try {
|
|
830
|
+
const normalizedDocId = ParamUtils.processDocumentId(documentId);
|
|
831
|
+
const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${imageBlockId}`;
|
|
832
|
+
const payload = {
|
|
833
|
+
replace_image: {
|
|
834
|
+
token: fileToken,
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
Logger.info(`开始设置图片块内容,文档ID: ${normalizedDocId},块ID: ${imageBlockId},file_token: ${fileToken}`);
|
|
838
|
+
const response = await this.patch(endpoint, payload);
|
|
839
|
+
Logger.info('图片块内容设置成功');
|
|
840
|
+
return response;
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
this.handleApiError(error, '设置图片块内容失败');
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* 创建完整的图片块(包括创建空块、上传图片、设置内容的完整流程)
|
|
848
|
+
* @param documentId 文档ID
|
|
849
|
+
* @param parentBlockId 父块ID
|
|
850
|
+
* @param imagePathOrUrl 图片路径或URL
|
|
851
|
+
* @param options 图片选项
|
|
852
|
+
* @returns 创建结果
|
|
853
|
+
*/
|
|
854
|
+
async createImageBlock(documentId, parentBlockId, imagePathOrUrl, options = {}) {
|
|
855
|
+
try {
|
|
856
|
+
const { fileName: providedFileName, width, height, index = 0 } = options;
|
|
857
|
+
Logger.info(`开始创建图片块,文档ID: ${documentId},父块ID: ${parentBlockId},图片源: ${imagePathOrUrl},插入位置: ${index}`);
|
|
858
|
+
// 从路径或URL获取图片的Base64编码
|
|
859
|
+
const { base64: imageBase64, fileName: detectedFileName } = await this.getImageBase64FromPathOrUrl(imagePathOrUrl);
|
|
860
|
+
// 使用提供的文件名或检测到的文件名
|
|
861
|
+
const finalFileName = providedFileName || detectedFileName;
|
|
862
|
+
// 第1步:创建空图片块
|
|
863
|
+
Logger.info('第1步:创建空图片块');
|
|
864
|
+
const imageBlockContent = this.blockFactory.createImageBlock({
|
|
865
|
+
width,
|
|
866
|
+
height,
|
|
867
|
+
});
|
|
868
|
+
const createBlockResult = await this.createDocumentBlock(documentId, parentBlockId, imageBlockContent, index);
|
|
869
|
+
if (!createBlockResult?.children?.[0]?.block_id) {
|
|
870
|
+
throw new Error('创建空图片块失败:无法获取块ID');
|
|
871
|
+
}
|
|
872
|
+
const imageBlockId = createBlockResult.children[0].block_id;
|
|
873
|
+
Logger.info(`空图片块创建成功,块ID: ${imageBlockId}`);
|
|
874
|
+
// 第2步:上传图片素材
|
|
875
|
+
Logger.info('第2步:上传图片素材');
|
|
876
|
+
const uploadResult = await this.uploadImageMedia(imageBase64, finalFileName, imageBlockId);
|
|
877
|
+
if (!uploadResult?.file_token) {
|
|
878
|
+
throw new Error('上传图片素材失败:无法获取file_token');
|
|
879
|
+
}
|
|
880
|
+
Logger.info(`图片素材上传成功,file_token: ${uploadResult.file_token}`);
|
|
881
|
+
// 第3步:设置图片块内容
|
|
882
|
+
Logger.info('第3步:设置图片块内容');
|
|
883
|
+
const setContentResult = await this.setImageBlockContent(documentId, imageBlockId, uploadResult.file_token);
|
|
884
|
+
Logger.info('图片块创建完成');
|
|
885
|
+
// 返回综合结果
|
|
886
|
+
return {
|
|
887
|
+
imageBlock: createBlockResult.children[0],
|
|
888
|
+
imageBlockId: imageBlockId,
|
|
889
|
+
fileToken: uploadResult.file_token,
|
|
890
|
+
uploadResult: uploadResult,
|
|
891
|
+
setContentResult: setContentResult,
|
|
892
|
+
documentRevisionId: setContentResult.document_revision_id ||
|
|
893
|
+
createBlockResult.document_revision_id,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
this.handleApiError(error, '创建图片块失败');
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* 根据文件名获取MIME类型
|
|
902
|
+
* @param fileName 文件名
|
|
903
|
+
* @returns MIME类型
|
|
904
|
+
*/
|
|
905
|
+
getMimeTypeFromFileName(fileName) {
|
|
906
|
+
const extension = fileName.toLowerCase().split('.').pop();
|
|
907
|
+
switch (extension) {
|
|
908
|
+
case 'jpg':
|
|
909
|
+
case 'jpeg':
|
|
910
|
+
return 'image/jpeg';
|
|
911
|
+
case 'png':
|
|
912
|
+
return 'image/png';
|
|
913
|
+
case 'gif':
|
|
914
|
+
return 'image/gif';
|
|
915
|
+
case 'webp':
|
|
916
|
+
return 'image/webp';
|
|
917
|
+
case 'bmp':
|
|
918
|
+
return 'image/bmp';
|
|
919
|
+
case 'svg':
|
|
920
|
+
return 'image/svg+xml';
|
|
921
|
+
default:
|
|
922
|
+
return 'image/png'; // 默认PNG
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* 从路径或URL获取图片的Base64编码
|
|
927
|
+
* @param imagePathOrUrl 图片路径或URL
|
|
928
|
+
* @returns 图片的Base64编码和文件名
|
|
929
|
+
*/
|
|
930
|
+
async getImageBase64FromPathOrUrl(imagePathOrUrl) {
|
|
931
|
+
try {
|
|
932
|
+
let imageBuffer;
|
|
933
|
+
let fileName;
|
|
934
|
+
// 判断是否为HTTP/HTTPS URL
|
|
935
|
+
if (imagePathOrUrl.startsWith('http://') || imagePathOrUrl.startsWith('https://')) {
|
|
936
|
+
Logger.info(`从URL获取图片: ${imagePathOrUrl}`);
|
|
937
|
+
// 从URL下载图片
|
|
938
|
+
const response = await axios.get(imagePathOrUrl, {
|
|
939
|
+
responseType: 'arraybuffer',
|
|
940
|
+
timeout: 30000, // 30秒超时
|
|
941
|
+
});
|
|
942
|
+
imageBuffer = Buffer.from(response.data);
|
|
943
|
+
// 从URL中提取文件名
|
|
944
|
+
const urlPath = new URL(imagePathOrUrl).pathname;
|
|
945
|
+
fileName = path.basename(urlPath) || `image_${Date.now()}.png`;
|
|
946
|
+
Logger.info(`从URL成功获取图片,大小: ${imageBuffer.length} 字节,文件名: ${fileName}`);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
// 本地文件路径
|
|
950
|
+
Logger.info(`从本地路径读取图片: ${imagePathOrUrl}`);
|
|
951
|
+
// 检查文件是否存在
|
|
952
|
+
if (!fs.existsSync(imagePathOrUrl)) {
|
|
953
|
+
throw new Error(`图片文件不存在: ${imagePathOrUrl}`);
|
|
954
|
+
}
|
|
955
|
+
// 读取文件
|
|
956
|
+
imageBuffer = fs.readFileSync(imagePathOrUrl);
|
|
957
|
+
fileName = path.basename(imagePathOrUrl);
|
|
958
|
+
Logger.info(`从本地路径成功读取图片,大小: ${imageBuffer.length} 字节,文件名: ${fileName}`);
|
|
959
|
+
}
|
|
960
|
+
// 转换为Base64
|
|
961
|
+
const base64 = imageBuffer.toString('base64');
|
|
962
|
+
return { base64, fileName };
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
Logger.error(`获取图片失败: ${error}`);
|
|
966
|
+
throw new Error(`获取图片失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
717
969
|
}
|
|
@@ -11,14 +11,26 @@ export const ParentBlockIdSchema = z.string().describe('Parent block ID (require
|
|
|
11
11
|
// 块ID参数定义
|
|
12
12
|
export const BlockIdSchema = z.string().describe('Block ID (required). The ID of the specific block to get content from. You can obtain block IDs using the get_feishu_document_blocks tool.');
|
|
13
13
|
// 插入位置索引参数定义
|
|
14
|
-
export const IndexSchema = z.number().describe('Insertion position index (required).
|
|
14
|
+
export const IndexSchema = z.number().describe('Insertion position index (required). This index is relative to the children array of the specified parentBlockId block (not the whole document).\n' +
|
|
15
|
+
'If parentBlockId is the document root (i.e., the document ID), index refers to the position among the document content blocks (excluding the title block itself).\n' +
|
|
16
|
+
'0 means to insert as the first content block after the title.\n' +
|
|
17
|
+
'If children is empty or missing, use 0 to insert the first content block.\n' +
|
|
18
|
+
'For nested blocks, index is relative to the parent block\'s children.\n' +
|
|
19
|
+
'Note: The title block itself is not part of the children array and cannot be operated on with index.' +
|
|
20
|
+
'Specifies where the block should be inserted. Use 0 to insert at the beginning. ' +
|
|
15
21
|
'Use get_feishu_document_blocks tool to understand document structure if unsure. ' +
|
|
16
22
|
'For consecutive insertions, calculate next index as previous index + 1.');
|
|
17
23
|
// 起始插入位置索引参数定义
|
|
18
|
-
export const StartIndexSchema = z.number().describe('Starting insertion position index (required).
|
|
24
|
+
export const StartIndexSchema = z.number().describe('Starting insertion position index (required). This index is relative to the children array of the specified parentBlockId block.\n' +
|
|
25
|
+
'For the document root, this means the content blocks after the title. For other blocks, it means the sub-blocks under that block.\n' +
|
|
26
|
+
'The index does not include the title block itself.' +
|
|
27
|
+
'Specifies where the first block should be inserted or deleted. Use 0 to insert at the beginning. ' +
|
|
19
28
|
'Use get_feishu_document_blocks tool to understand document structure if unsure.');
|
|
20
29
|
// 结束位置索引参数定义
|
|
21
|
-
export const EndIndexSchema = z.number().describe('Ending position index (required).
|
|
30
|
+
export const EndIndexSchema = z.number().describe('Ending position index (required). This index is relative to the children array of the specified parentBlockId block.\n' +
|
|
31
|
+
'For the document root, this means the content blocks after the title. For other blocks, it means the sub-blocks under that block.\n' +
|
|
32
|
+
'The index does not include the title block itself.' +
|
|
33
|
+
'Specifies the end of the range for deletion (exclusive). ' +
|
|
22
34
|
'For example, to delete blocks 2, 3, and 4, use startIndex=2, endIndex=5. ' +
|
|
23
35
|
'To delete a single block at position 2, use startIndex=2, endIndex=3.');
|
|
24
36
|
// 文本对齐方式参数定义
|
|
@@ -87,8 +99,14 @@ export const ListBlockSchema = z.object({
|
|
|
87
99
|
align: AlignSchemaWithValidation,
|
|
88
100
|
});
|
|
89
101
|
// 块类型枚举 - 用于批量创建块工具
|
|
90
|
-
export const BlockTypeEnum = z.string().describe("Block type (required). Supports: 'text', 'code', 'heading', 'list', as well as 'heading1' through 'heading9'. " +
|
|
91
|
-
"For headings, we recommend using 'heading' with level property, but 'heading1'-'heading9' are also supported."
|
|
102
|
+
export const BlockTypeEnum = z.string().describe("Block type (required). Supports: 'text', 'code', 'heading', 'list', 'image', as well as 'heading1' through 'heading9'. " +
|
|
103
|
+
"For headings, we recommend using 'heading' with level property, but 'heading1'-'heading9' are also supported. " +
|
|
104
|
+
"For images, use 'image' to create empty image blocks that can be filled later.");
|
|
105
|
+
// 图片块内容定义 - 用于批量创建块工具
|
|
106
|
+
export const ImageBlockSchema = z.object({
|
|
107
|
+
width: z.number().optional().describe('Image width in pixels (optional). If not provided, default width will be used.'),
|
|
108
|
+
height: z.number().optional().describe('Image height in pixels (optional). If not provided, default height will be used.'),
|
|
109
|
+
});
|
|
92
110
|
// 块配置定义 - 用于批量创建块工具
|
|
93
111
|
export const BlockConfigSchema = z.object({
|
|
94
112
|
blockType: BlockTypeEnum,
|
|
@@ -97,6 +115,7 @@ export const BlockConfigSchema = z.object({
|
|
|
97
115
|
z.object({ code: CodeBlockSchema }).describe("Code block options. Used when blockType is 'code'."),
|
|
98
116
|
z.object({ heading: HeadingBlockSchema }).describe("Heading block options. Used with both 'heading' and 'headingN' formats."),
|
|
99
117
|
z.object({ list: ListBlockSchema }).describe("List block options. Used when blockType is 'list'."),
|
|
118
|
+
z.object({ image: ImageBlockSchema }).describe("Image block options. Used when blockType is 'image'. Creates empty image blocks."),
|
|
100
119
|
z.record(z.any()).describe("Fallback for any other block options")
|
|
101
120
|
]).describe('Options for the specific block type. Provide the corresponding options object based on blockType.'),
|
|
102
121
|
});
|
|
@@ -112,9 +131,17 @@ export const FolderTokenSchema = z.string().describe('Folder token (required). T
|
|
|
112
131
|
'Format is an alphanumeric string like "FWK2fMleClICfodlHHWc4Mygnhb".');
|
|
113
132
|
// 文件夹名称参数定义
|
|
114
133
|
export const FolderNameSchema = z.string().describe('Folder name (required). The name for the new folder to be created.');
|
|
115
|
-
//
|
|
116
|
-
export const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
'
|
|
134
|
+
// 搜索关键字参数定义
|
|
135
|
+
export const SearchKeySchema = z.string().describe('Search keyword (required). The keyword to search for in documents.');
|
|
136
|
+
// 图片路径或URL参数定义
|
|
137
|
+
export const ImagePathOrUrlSchema = z.string().describe('Image path or URL (required). Supports the following formats:\n' +
|
|
138
|
+
'1. Local file absolute path: e.g., "C:\\path\\to\\image.jpg"\n' +
|
|
139
|
+
'2. HTTP/HTTPS URL: e.g., "https://example.com/image.png"\n' +
|
|
140
|
+
'The tool will automatically detect the format and handle accordingly.');
|
|
141
|
+
// 图片文件名参数定义
|
|
142
|
+
export const ImageFileNameSchema = z.string().optional().describe('Image file name (optional). If not provided, a default name will be generated based on the source. ' +
|
|
143
|
+
'Should include the file extension, e.g., "image.png" or "photo.jpg".');
|
|
144
|
+
// 图片宽度参数定义
|
|
145
|
+
export const ImageWidthSchema = z.number().optional().describe('Image width in pixels (optional). If not provided, the original image width will be used.');
|
|
146
|
+
// 图片高度参数定义
|
|
147
|
+
export const ImageHeightSchema = z.number().optional().describe('Image height in pixels (optional). If not provided, the original image height will be used.');
|
package/dist/utils/logger.js
CHANGED
|
@@ -265,9 +265,9 @@ Object.defineProperty(Logger, "config", {
|
|
|
265
265
|
showTimestamp: true,
|
|
266
266
|
showLevel: true,
|
|
267
267
|
timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS',
|
|
268
|
-
logToFile:
|
|
268
|
+
logToFile: false,
|
|
269
269
|
logFilePath: 'log/log.txt',
|
|
270
|
-
maxObjectDepth:
|
|
270
|
+
maxObjectDepth: 2, // 限制对象序列化深度
|
|
271
271
|
maxObjectStringLength: 5000000 // 限制序列化后字符串长度
|
|
272
272
|
}
|
|
273
273
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "Model Context Protocol server for Feishu integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -41,12 +41,13 @@
|
|
|
41
41
|
"author": "cso1z",
|
|
42
42
|
"license": "MIT",
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.13.1",
|
|
45
45
|
"@types/yargs": "^17.0.33",
|
|
46
46
|
"axios": "^1.7.9",
|
|
47
47
|
"cross-env": "^7.0.3",
|
|
48
48
|
"dotenv": "^16.4.7",
|
|
49
49
|
"express": "^4.21.2",
|
|
50
|
+
"form-data": "^4.0.3",
|
|
50
51
|
"remeda": "^2.20.1",
|
|
51
52
|
"yargs": "^17.7.2",
|
|
52
53
|
"zod": "^3.24.2"
|
package/dist/config.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { Config, ConfigSource } from './utils/config.js';
|
|
2
|
-
/**
|
|
3
|
-
* 为了向后兼容,保留getServerConfig函数
|
|
4
|
-
* 但内部使用Config类
|
|
5
|
-
* @param isStdioMode 是否在stdio模式下
|
|
6
|
-
* @returns 服务器配置
|
|
7
|
-
*/
|
|
8
|
-
export function getServerConfig(isStdioMode) {
|
|
9
|
-
const config = Config.getInstance();
|
|
10
|
-
if (!isStdioMode) {
|
|
11
|
-
config.printConfig(isStdioMode);
|
|
12
|
-
}
|
|
13
|
-
// 为了向后兼容,返回旧格式的配置对象
|
|
14
|
-
return {
|
|
15
|
-
port: config.server.port,
|
|
16
|
-
feishuAppId: config.feishu.appId,
|
|
17
|
-
feishuAppSecret: config.feishu.appSecret,
|
|
18
|
-
configSources: {
|
|
19
|
-
port: config.configSources['server.port'].toLowerCase(),
|
|
20
|
-
feishuAppId: config.configSources['feishu.appId']?.toLowerCase(),
|
|
21
|
-
feishuAppSecret: config.configSources['feishu.appSecret']?.toLowerCase()
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
// 导出Config类
|
|
26
|
-
export { Config, ConfigSource };
|