feishu-mcp 0.2.2 → 0.2.7

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.
Files changed (77) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +356 -266
  3. package/dist/cli/auth.js +117 -0
  4. package/dist/cli/commands/auth.js +141 -0
  5. package/dist/cli/commands/config.js +86 -0
  6. package/dist/cli/commands/guide.js +68 -0
  7. package/dist/cli/dispatcher.js +96 -0
  8. package/dist/cli/index.js +95 -0
  9. package/dist/manager/sseConnectionManager.js +2 -0
  10. package/dist/mcp/feishuMcp.js +25 -25
  11. package/dist/mcp/tools/blockTools.js +295 -0
  12. package/dist/mcp/tools/documentTools.js +105 -0
  13. package/dist/mcp/tools/feishuBlockTools.js +1 -285
  14. package/dist/mcp/tools/feishuTools.js +1 -67
  15. package/dist/mcp/tools/folderTools.js +99 -0
  16. package/dist/mcp/tools/toolHelpers.js +155 -0
  17. package/dist/modules/FeatureModule.js +1 -0
  18. package/dist/modules/ModuleRegistry.js +63 -0
  19. package/dist/modules/calendar/index.js +11 -0
  20. package/dist/modules/calendar/services/FeishuCalendarService.js +6 -0
  21. package/dist/modules/calendar/tools/calendarTools.js +6 -0
  22. package/dist/modules/document/index.js +15 -0
  23. package/dist/modules/document/services/FeishuBlockService.js +410 -0
  24. package/dist/modules/document/services/FeishuDocumentService.js +110 -0
  25. package/dist/modules/document/services/FeishuFoldService.js +187 -0
  26. package/dist/modules/document/services/FeishuSearchService.js +232 -0
  27. package/dist/modules/document/services/FeishuWhiteboardService.js +80 -0
  28. package/dist/modules/document/services/blockFactory.js +521 -0
  29. package/dist/modules/document/toolApi/blockToolApi.js +160 -0
  30. package/dist/modules/document/toolApi/documentToolApi.js +65 -0
  31. package/dist/modules/document/toolApi/folderToolApi.js +73 -0
  32. package/dist/modules/document/toolApi/index.js +3 -0
  33. package/dist/modules/document/tools/blockTools.js +138 -0
  34. package/dist/modules/document/tools/documentTools.js +64 -0
  35. package/dist/modules/document/tools/folderTools.js +46 -0
  36. package/dist/modules/document/tools/toolHelpers.js +155 -0
  37. package/dist/modules/index.js +5 -0
  38. package/dist/modules/member/index.js +11 -0
  39. package/dist/modules/member/services/FeishuMemberService.js +41 -0
  40. package/dist/modules/member/toolApi/index.js +1 -0
  41. package/dist/modules/member/toolApi/memberToolApi.js +54 -0
  42. package/dist/modules/member/tools/memberTools.js +26 -0
  43. package/dist/modules/task/index.js +11 -0
  44. package/dist/modules/task/services/FeishuTaskService.js +271 -0
  45. package/dist/modules/task/toolApi/__tests__/taskToolApi.test.js +98 -0
  46. package/dist/modules/task/toolApi/index.js +1 -0
  47. package/dist/modules/task/toolApi/taskToolApi.js +128 -0
  48. package/dist/modules/task/tools/taskTools.js +93 -0
  49. package/dist/server.js +43 -24
  50. package/dist/services/baseService.js +11 -2
  51. package/dist/services/blockFactory.js +167 -0
  52. package/dist/services/callbackService.js +1 -1
  53. package/dist/services/constants/feishuScopes.js +94 -0
  54. package/dist/services/feishu/FeishuBaseApiService.js +47 -0
  55. package/dist/services/feishu/FeishuBlockService.js +410 -0
  56. package/dist/services/feishu/FeishuDocumentBlockService.js +403 -0
  57. package/dist/services/feishu/FeishuDocumentService.js +110 -0
  58. package/dist/services/feishu/FeishuDriveService.js +62 -0
  59. package/dist/services/feishu/FeishuFoldService.js +187 -0
  60. package/dist/services/feishu/FeishuImageService.js +219 -0
  61. package/dist/services/feishu/FeishuScopeValidator.js +177 -0
  62. package/dist/services/feishu/FeishuSearchService.js +232 -0
  63. package/dist/services/feishu/FeishuWhiteboardService.js +80 -0
  64. package/dist/services/feishu/FeishuWikiService.js +134 -0
  65. package/dist/services/feishuApiService.js +246 -1760
  66. package/dist/services/feishuAuthService.js +43 -0
  67. package/dist/types/documentSchema.js +232 -0
  68. package/dist/types/feishuSchema.js +54 -77
  69. package/dist/types/memberSchema.js +35 -0
  70. package/dist/types/taskSchema.js +166 -0
  71. package/dist/utils/auth/tokenCacheManager.js +16 -3
  72. package/dist/utils/auth/tokenRefreshManager.js +2 -1
  73. package/dist/utils/config.js +47 -0
  74. package/dist/utils/document.js +116 -116
  75. package/dist/utils/error.js +0 -11
  76. package/dist/utils/paramUtils.js +0 -31
  77. package/package.json +77 -76
@@ -0,0 +1,187 @@
1
+ import { Logger } from '../../utils/logger.js';
2
+ import { FeishuBaseApiService } from './FeishuBaseApiService.js';
3
+ /**
4
+ * 飞书文件夹与知识空间服务
5
+ * 整合云盘文件夹(Drive)和知识空间(Wiki)的查询与创建操作
6
+ */
7
+ export class FeishuFoldService extends FeishuBaseApiService {
8
+ constructor(authService) {
9
+ super(authService);
10
+ }
11
+ // ─── 云盘文件夹(Drive)─────────────────────────────────────────
12
+ /**
13
+ * 获取当前用户根文件夹的元数据信息
14
+ * @returns 根文件夹信息,包含 token(文件夹 Token)、id 和 user_id 字段
15
+ */
16
+ async getRootFolderInfo() {
17
+ try {
18
+ const response = await this.get('/drive/explorer/v2/root_folder/meta');
19
+ Logger.debug('获取根文件夹信息成功:', response);
20
+ return response;
21
+ }
22
+ catch (error) {
23
+ this.handleApiError(error, '获取飞书根文件夹信息失败');
24
+ }
25
+ }
26
+ /**
27
+ * 获取指定文件夹内的文件和子文件夹列表
28
+ * @param folderToken 目标文件夹的 Token
29
+ * @param orderBy 排序字段,可选 'EditedTime'(编辑时间)、'CreatedTime'(创建时间)等,默认 'EditedTime'
30
+ * @param direction 排序方向,'ASC' 升序 / 'DESC' 降序,默认 'DESC'
31
+ * @returns 文件夹文件清单,包含 files 数组,每项含文件名、类型、token 等信息
32
+ */
33
+ async getFolderFileList(folderToken, orderBy = 'EditedTime', direction = 'DESC') {
34
+ try {
35
+ const response = await this.get('/drive/v1/files', {
36
+ folder_token: folderToken,
37
+ order_by: orderBy,
38
+ direction
39
+ });
40
+ Logger.debug(`获取文件夹(${folderToken})中的文件清单成功,文件数量: ${response.files?.length || 0}`);
41
+ return response;
42
+ }
43
+ catch (error) {
44
+ this.handleApiError(error, '获取文件夹中的文件清单失败');
45
+ }
46
+ }
47
+ /**
48
+ * 在指定文件夹下创建子文件夹
49
+ * @param folderToken 父文件夹的 Token
50
+ * @param name 新文件夹的名称
51
+ * @returns 创建结果,包含新文件夹的 token 和访问 url
52
+ */
53
+ async createFolder(folderToken, name) {
54
+ try {
55
+ const response = await this.post('/drive/v1/files/create_folder', { folder_token: folderToken, name });
56
+ Logger.debug(`文件夹创建成功, token: ${response.token}, url: ${response.url}`);
57
+ return response;
58
+ }
59
+ catch (error) {
60
+ this.handleApiError(error, '创建文件夹失败');
61
+ }
62
+ }
63
+ // ─── 知识空间(Wiki)────────────────────────────────────────────
64
+ /**
65
+ * 获取当前用户可见的所有知识空间列表,自动处理分页直到获取全部数据
66
+ * @param pageSize 每次分页请求的数量,默认 20
67
+ * @returns 知识空间对象数组,每项包含 space_id、name、description 等字段
68
+ */
69
+ async getAllWikiSpacesList(pageSize = 20) {
70
+ try {
71
+ Logger.info(`开始获取所有知识空间列表,每页数量: ${pageSize}`);
72
+ const endpoint = '/wiki/v2/spaces';
73
+ let allItems = [];
74
+ let pageToken = undefined;
75
+ let hasMore = true;
76
+ while (hasMore) {
77
+ const params = { page_size: pageSize };
78
+ if (pageToken)
79
+ params.page_token = pageToken;
80
+ Logger.debug(`请求知识空间列表,page_token: ${pageToken || 'null'}, page_size: ${pageSize}`);
81
+ const response = await this.get(endpoint, params);
82
+ if (response?.items) {
83
+ allItems = [...allItems, ...response.items];
84
+ hasMore = response.has_more || false;
85
+ pageToken = response.page_token;
86
+ Logger.debug(`当前页获取到 ${response.items.length} 个知识空间,累计 ${allItems.length} 个,hasMore: ${hasMore}`);
87
+ }
88
+ else {
89
+ hasMore = false;
90
+ Logger.warn('知识空间列表响应格式异常:', JSON.stringify(response, null, 2));
91
+ }
92
+ }
93
+ Logger.info(`知识空间列表获取完成,共 ${allItems.length} 个空间`);
94
+ return allItems;
95
+ }
96
+ catch (error) {
97
+ this.handleApiError(error, '获取知识空间列表失败');
98
+ }
99
+ }
100
+ /**
101
+ * 获取指定知识空间下的所有子节点,自动处理分页直到获取全部数据
102
+ * @param spaceId 知识空间 ID
103
+ * @param parentNodeToken 父节点 Token,不传则获取根节点下的直属子节点
104
+ * @param pageSize 每次分页请求的数量,默认 20
105
+ * @returns 节点对象数组,每项包含 node_token、obj_token、title、obj_type 等字段
106
+ */
107
+ async getAllWikiSpaceNodes(spaceId, parentNodeToken, pageSize = 20) {
108
+ try {
109
+ Logger.info(`开始获取知识空间子节点列表,space_id: ${spaceId}, parent_node_token: ${parentNodeToken || 'null'}`);
110
+ const endpoint = `/wiki/v2/spaces/${spaceId}/nodes`;
111
+ let allItems = [];
112
+ let pageToken = undefined;
113
+ let hasMore = true;
114
+ while (hasMore) {
115
+ const params = { page_size: pageSize };
116
+ if (parentNodeToken)
117
+ params.parent_node_token = parentNodeToken;
118
+ if (pageToken)
119
+ params.page_token = pageToken;
120
+ Logger.debug(`请求知识空间子节点列表,page_token: ${pageToken || 'null'}`);
121
+ const response = await this.get(endpoint, params);
122
+ if (response?.items) {
123
+ allItems = [...allItems, ...response.items];
124
+ hasMore = response.has_more || false;
125
+ pageToken = response.page_token;
126
+ Logger.debug(`当前页获取到 ${response.items.length} 个子节点,累计 ${allItems.length} 个,hasMore: ${hasMore}`);
127
+ }
128
+ else {
129
+ hasMore = false;
130
+ Logger.warn('知识空间子节点列表响应格式异常:', JSON.stringify(response, null, 2));
131
+ }
132
+ }
133
+ Logger.info(`知识空间子节点列表获取完成,共 ${allItems.length} 个节点`);
134
+ return allItems;
135
+ }
136
+ catch (error) {
137
+ this.handleApiError(error, '获取知识空间子节点列表失败');
138
+ }
139
+ }
140
+ /**
141
+ * 获取指定知识空间的详细信息
142
+ * 当 spaceId 传入 'my_library' 时,获取当前用户的"我的知识库"信息
143
+ * @param spaceId 知识空间 ID,或传入 'my_library' 获取个人知识库
144
+ * @param lang 语言参数,仅当 spaceId 为 'my_library' 时有效,默认 'en'
145
+ * @returns 知识空间信息对象,包含 space_id、name、description 等字段
146
+ */
147
+ async getWikiSpaceInfo(spaceId, lang = 'en') {
148
+ try {
149
+ const params = {};
150
+ if (spaceId === 'my_library')
151
+ params.lang = lang;
152
+ const response = await this.get(`/wiki/v2/spaces/${spaceId}`, params);
153
+ Logger.debug(`获取知识空间信息成功 (space_id: ${spaceId}):`, response);
154
+ return response?.space || response;
155
+ }
156
+ catch (error) {
157
+ this.handleApiError(error, `获取知识空间信息失败 (space_id: ${spaceId})`);
158
+ }
159
+ }
160
+ /**
161
+ * 在知识空间中创建一个新的文档节点(类型固定为 docx)
162
+ * @param spaceId 目标知识空间 ID
163
+ * @param title 节点标题
164
+ * @param parentNodeToken 父节点 Token,不传则在根目录下创建
165
+ * @returns 创建的节点信息,包含 node_token(节点 ID)和 obj_token(关联文档 ID)
166
+ */
167
+ async createWikiSpaceNode(spaceId, title, parentNodeToken) {
168
+ try {
169
+ Logger.info(`开始创建知识空间节点,space_id: ${spaceId}, title: ${title}, parent_node_token: ${parentNodeToken || 'null(根节点)'}`);
170
+ const endpoint = `/wiki/v2/spaces/${spaceId}/nodes`;
171
+ const payload = { title, obj_type: 'docx', node_type: 'origin' };
172
+ if (parentNodeToken)
173
+ payload.parent_node_token = parentNodeToken;
174
+ const response = await this.post(endpoint, payload);
175
+ if (response?.data?.node) {
176
+ const node = response.data.node;
177
+ Logger.info(`知识空间节点创建成功,node_token: ${node.node_token}, obj_token: ${node.obj_token}`);
178
+ return node;
179
+ }
180
+ Logger.info('知识空间节点创建成功');
181
+ return response;
182
+ }
183
+ catch (error) {
184
+ this.handleApiError(error, '创建知识空间节点失败');
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,219 @@
1
+ import axios from 'axios';
2
+ import FormData from 'form-data';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { Logger } from '../../utils/logger.js';
6
+ import { ParamUtils } from '../../utils/paramUtils.js';
7
+ import { FeishuBaseApiService } from './FeishuBaseApiService.js';
8
+ /**
9
+ * 飞书图片服务
10
+ * 负责图片的获取、上传和块创建
11
+ */
12
+ export class FeishuImageService extends FeishuBaseApiService {
13
+ constructor(authService, blockFactory, blockService) {
14
+ super(authService);
15
+ Object.defineProperty(this, "blockFactory", {
16
+ enumerable: true,
17
+ configurable: true,
18
+ writable: true,
19
+ value: blockFactory
20
+ });
21
+ Object.defineProperty(this, "blockService", {
22
+ enumerable: true,
23
+ configurable: true,
24
+ writable: true,
25
+ value: blockService
26
+ });
27
+ }
28
+ /**
29
+ * 下载飞书云文档中的图片素材,返回二进制数据
30
+ * @param mediaId 图片的媒体 ID(media_id / file_token)
31
+ * @param extra 额外参数,部分场景需要传递(如加密图片),默认为空字符串
32
+ * @returns 图片的二进制 Buffer 数据
33
+ */
34
+ async getImageResource(mediaId, extra = '') {
35
+ try {
36
+ Logger.info(`开始获取图片资源,媒体ID: ${mediaId}`);
37
+ if (!mediaId)
38
+ throw new Error('媒体ID不能为空');
39
+ const endpoint = `/drive/v1/medias/${mediaId}/download`;
40
+ const params = {};
41
+ if (extra)
42
+ params.extra = extra;
43
+ const response = await this.request(endpoint, 'GET', params, true, {}, 'arraybuffer');
44
+ const imageBuffer = Buffer.from(response);
45
+ Logger.info(`图片资源获取成功,大小: ${imageBuffer.length} 字节`);
46
+ return imageBuffer;
47
+ }
48
+ catch (error) {
49
+ this.handleApiError(error, '获取图片资源失败');
50
+ return Buffer.from([]);
51
+ }
52
+ }
53
+ /**
54
+ * 将图片素材上传到飞书云端,关联到指定的图片块
55
+ * @param imageBase64 图片的 Base64 编码字符串(不含 data:image/xxx;base64, 前缀)
56
+ * @param fileName 图片文件名(含扩展名),若为空字符串则根据 Base64 内容自动检测格式并生成文件名
57
+ * @param parentBlockId 关联的图片块 ID(parent_node),用于告知飞书该素材归属的块
58
+ * @returns 上传结果,包含 file_token 字段,用于后续 setImageBlockContent 调用
59
+ */
60
+ async uploadImageMedia(imageBase64, fileName, parentBlockId) {
61
+ try {
62
+ const endpoint = '/drive/v1/medias/upload_all';
63
+ const imageBuffer = Buffer.from(imageBase64, 'base64');
64
+ const imageSize = imageBuffer.length;
65
+ if (!fileName) {
66
+ if (imageBase64.startsWith('/9j/')) {
67
+ fileName = `image_${Date.now()}.jpg`;
68
+ }
69
+ else if (imageBase64.startsWith('iVBORw0KGgo')) {
70
+ fileName = `image_${Date.now()}.png`;
71
+ }
72
+ else if (imageBase64.startsWith('R0lGODlh')) {
73
+ fileName = `image_${Date.now()}.gif`;
74
+ }
75
+ else {
76
+ fileName = `image_${Date.now()}.png`;
77
+ }
78
+ }
79
+ Logger.info(`开始上传图片素材,文件名: ${fileName},大小: ${imageSize} 字节,关联块ID: ${parentBlockId}`);
80
+ if (imageSize > 20 * 1024 * 1024) {
81
+ Logger.warn(`图片文件过大: ${imageSize} 字节,建议小于20MB`);
82
+ }
83
+ const formData = new FormData();
84
+ formData.append('file', imageBuffer, {
85
+ filename: fileName,
86
+ contentType: this.getMimeTypeFromFileName(fileName),
87
+ knownLength: imageSize
88
+ });
89
+ formData.append('file_name', fileName);
90
+ formData.append('parent_type', 'docx_image');
91
+ formData.append('parent_node', parentBlockId);
92
+ formData.append('size', imageSize.toString());
93
+ const response = await this.post(endpoint, formData);
94
+ Logger.info(`图片素材上传成功,file_token: ${response.file_token}`);
95
+ return response;
96
+ }
97
+ catch (error) {
98
+ this.handleApiError(error, '上传图片素材失败');
99
+ }
100
+ }
101
+ /**
102
+ * 将已上传的图片素材绑定到指定的图片块,完成图片块的最终渲染
103
+ * @param documentId 文档 ID 或 URL
104
+ * @param imageBlockId 目标图片块的 block_id
105
+ * @param fileToken 图片素材的 file_token(由 uploadImageMedia 返回)
106
+ * @returns 更新结果,包含 document_revision_id 等字段
107
+ */
108
+ async setImageBlockContent(documentId, imageBlockId, fileToken) {
109
+ try {
110
+ const normalizedDocId = ParamUtils.processDocumentId(documentId);
111
+ const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${imageBlockId}`;
112
+ const payload = { replace_image: { token: fileToken } };
113
+ Logger.info(`开始设置图片块内容,文档ID: ${normalizedDocId},块ID: ${imageBlockId},file_token: ${fileToken}`);
114
+ const response = await this.patch(endpoint, payload);
115
+ Logger.info('图片块内容设置成功');
116
+ return response;
117
+ }
118
+ catch (error) {
119
+ this.handleApiError(error, '设置图片块内容失败');
120
+ }
121
+ }
122
+ /**
123
+ * 完整创建图片块的三步流程:创建空块 → 上传素材 → 绑定素材
124
+ * 支持本地文件路径和 HTTP/HTTPS URL 两种图片来源
125
+ * @param documentId 文档 ID 或 URL
126
+ * @param parentBlockId 父块 ID,图片块将插入到该块的子节点列表中
127
+ * @param imagePathOrUrl 图片来源,支持本地绝对路径或 HTTP/HTTPS URL
128
+ * @param options 可选配置
129
+ * @param options.fileName 自定义文件名(含扩展名),不传则从路径/URL 自动提取
130
+ * @param options.width 图片显示宽度(像素),不传则使用默认值
131
+ * @param options.height 图片显示高度(像素),不传则使用默认值
132
+ * @param options.index 插入位置索引,默认 0
133
+ * @returns 综合结果对象,包含 imageBlockId、fileToken、imageBlock、uploadResult、setContentResult、documentRevisionId
134
+ */
135
+ async createImageBlock(documentId, parentBlockId, imagePathOrUrl, options = {}) {
136
+ try {
137
+ const { fileName: providedFileName, width, height, index = 0 } = options;
138
+ Logger.info(`开始创建图片块,文档ID: ${documentId},父块ID: ${parentBlockId},图片源: ${imagePathOrUrl},插入位置: ${index}`);
139
+ const { base64: imageBase64, fileName: detectedFileName } = await this.getImageBase64FromPathOrUrl(imagePathOrUrl);
140
+ const finalFileName = providedFileName || detectedFileName;
141
+ Logger.info('第1步:创建空图片块');
142
+ const imageBlockContent = this.blockFactory.createImageBlock({ width, height });
143
+ const createBlockResult = await this.blockService.createDocumentBlock(documentId, parentBlockId, imageBlockContent, index);
144
+ if (!createBlockResult?.children?.[0]?.block_id) {
145
+ throw new Error('创建空图片块失败:无法获取块ID');
146
+ }
147
+ const imageBlockId = createBlockResult.children[0].block_id;
148
+ Logger.info(`空图片块创建成功,块ID: ${imageBlockId}`);
149
+ Logger.info('第2步:上传图片素材');
150
+ const uploadResult = await this.uploadImageMedia(imageBase64, finalFileName, imageBlockId);
151
+ if (!uploadResult?.file_token) {
152
+ throw new Error('上传图片素材失败:无法获取file_token');
153
+ }
154
+ Logger.info(`图片素材上传成功,file_token: ${uploadResult.file_token}`);
155
+ Logger.info('第3步:设置图片块内容');
156
+ const setContentResult = await this.setImageBlockContent(documentId, imageBlockId, uploadResult.file_token);
157
+ Logger.info('图片块创建完成');
158
+ return {
159
+ imageBlock: createBlockResult.children[0],
160
+ imageBlockId,
161
+ fileToken: uploadResult.file_token,
162
+ uploadResult,
163
+ setContentResult,
164
+ documentRevisionId: setContentResult.document_revision_id || createBlockResult.document_revision_id
165
+ };
166
+ }
167
+ catch (error) {
168
+ this.handleApiError(error, '创建图片块失败');
169
+ }
170
+ }
171
+ getMimeTypeFromFileName(fileName) {
172
+ const extension = fileName.toLowerCase().split('.').pop();
173
+ switch (extension) {
174
+ case 'jpg':
175
+ case 'jpeg':
176
+ return 'image/jpeg';
177
+ case 'png':
178
+ return 'image/png';
179
+ case 'gif':
180
+ return 'image/gif';
181
+ case 'webp':
182
+ return 'image/webp';
183
+ case 'bmp':
184
+ return 'image/bmp';
185
+ case 'svg':
186
+ return 'image/svg+xml';
187
+ default:
188
+ return 'image/png';
189
+ }
190
+ }
191
+ async getImageBase64FromPathOrUrl(imagePathOrUrl) {
192
+ try {
193
+ let imageBuffer;
194
+ let fileName;
195
+ if (imagePathOrUrl.startsWith('http://') || imagePathOrUrl.startsWith('https://')) {
196
+ Logger.info(`从URL获取图片: ${imagePathOrUrl}`);
197
+ const response = await axios.get(imagePathOrUrl, { responseType: 'arraybuffer', timeout: 30000 });
198
+ imageBuffer = Buffer.from(response.data);
199
+ const urlPath = new URL(imagePathOrUrl).pathname;
200
+ fileName = path.basename(urlPath) || `image_${Date.now()}.png`;
201
+ Logger.info(`从URL成功获取图片,大小: ${imageBuffer.length} 字节,文件名: ${fileName}`);
202
+ }
203
+ else {
204
+ Logger.info(`从本地路径读取图片: ${imagePathOrUrl}`);
205
+ if (!fs.existsSync(imagePathOrUrl)) {
206
+ throw new Error(`图片文件不存在: ${imagePathOrUrl}`);
207
+ }
208
+ imageBuffer = fs.readFileSync(imagePathOrUrl);
209
+ fileName = path.basename(imagePathOrUrl);
210
+ Logger.info(`从本地路径成功读取图片,大小: ${imageBuffer.length} 字节,文件名: ${fileName}`);
211
+ }
212
+ return { base64: imageBuffer.toString('base64'), fileName };
213
+ }
214
+ catch (error) {
215
+ Logger.error(`获取图片失败: ${error}`);
216
+ throw new Error(`获取图片失败: ${error instanceof Error ? error.message : String(error)}`);
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,177 @@
1
+ import axios from 'axios';
2
+ import { Logger } from '../../utils/logger.js';
3
+ import { Config } from '../../utils/config.js';
4
+ import { TokenCacheManager } from '../../utils/auth/tokenCacheManager.js';
5
+ import { ScopeInsufficientError } from '../../utils/error.js';
6
+ import { getRequiredScopes, MODULE_SCOPES } from '../constants/feishuScopes.js';
7
+ import { ModuleRegistry } from '../../modules/ModuleRegistry.js';
8
+ /**
9
+ * 飞书应用权限 Scope 校验器
10
+ * 根据已启用的功能模块动态计算所需权限,仅校验最小权限集
11
+ */
12
+ export class FeishuScopeValidator {
13
+ async getApplicationScopes(accessToken, authType) {
14
+ try {
15
+ const baseUrl = 'https://open.feishu.cn/open-apis';
16
+ const endpoint = '/application/v6/scopes';
17
+ const headers = {
18
+ 'Authorization': `Bearer ${accessToken}`,
19
+ 'Content-Type': 'application/json'
20
+ };
21
+ Logger.debug('请求应用权限范围:', endpoint);
22
+ const response = await axios.get(`${baseUrl}${endpoint}`, { headers });
23
+ const data = response.data;
24
+ if (data.code !== 0) {
25
+ throw new Error(`获取应用权限范围失败:${data.msg || '未知错误'} (错误码: ${data.code})`);
26
+ }
27
+ const scopes = [];
28
+ if (data.data && Array.isArray(data.data.scopes)) {
29
+ for (const scopeItem of data.data.scopes) {
30
+ if (scopeItem.grant_status === 1 && scopeItem.scope_type === authType && scopeItem.scope_name) {
31
+ scopes.push(scopeItem.scope_name);
32
+ }
33
+ }
34
+ }
35
+ Logger.debug(`获取应用权限范围成功,共 ${scopes.length} 个${authType}权限`);
36
+ return scopes;
37
+ }
38
+ catch (error) {
39
+ Logger.error('获取应用权限范围失败:', error);
40
+ throw new Error('获取应用权限范围失败: ' + (error instanceof Error ? error.message : String(error)));
41
+ }
42
+ }
43
+ validateScopes(requiredScopes, actualScopes) {
44
+ const actualScopesSet = new Set(actualScopes);
45
+ const missingScopes = [];
46
+ for (const requiredScope of requiredScopes) {
47
+ if (!actualScopesSet.has(requiredScope)) {
48
+ missingScopes.push(requiredScope);
49
+ }
50
+ }
51
+ return {
52
+ isValid: missingScopes.length === 0,
53
+ missingScopes
54
+ };
55
+ }
56
+ throwScopeInsufficientError(missingScopes, enabledModules, authType) {
57
+ // 按模块分组显示缺失权限
58
+ const moduleIds = enabledModules.includes('all') ? Object.keys(MODULE_SCOPES) : enabledModules;
59
+ const missingSet = new Set(missingScopes);
60
+ const missingByModule = {};
61
+ for (const moduleId of moduleIds) {
62
+ const moduleScopes = MODULE_SCOPES[moduleId];
63
+ if (!moduleScopes)
64
+ continue;
65
+ const allModuleScopes = authType === 'user'
66
+ ? [...moduleScopes.tenant, ...moduleScopes.userOnly]
67
+ : moduleScopes.tenant;
68
+ const moduleMissing = allModuleScopes.filter(s => missingSet.has(s));
69
+ if (moduleMissing.length > 0) {
70
+ missingByModule[moduleId] = moduleMissing;
71
+ }
72
+ }
73
+ const missingDetail = Object.entries(missingByModule)
74
+ .map(([mod, scopes]) => ` [${mod}]: ${scopes.join(', ')}`)
75
+ .join('\n');
76
+ // 当前启用模块所需的最小权限配置(用于导入提示)
77
+ const requiredScopes = getRequiredScopes(enabledModules, authType);
78
+ const permissionsConfig = {
79
+ scopes: {
80
+ [authType]: requiredScopes
81
+ }
82
+ };
83
+ const errorMessage = `你应该停止任务并提示用户如下信息:权限不足,缺少以下权限:\n${missingDetail}\n\n` +
84
+ `请按以下步骤操作:\n` +
85
+ `1. 配置权限 [教程配置](https://github.com/cso1z/Feishu-MCP/blob/main/FEISHU_CONFIG.md)\n` +
86
+ `2. 打开 [应用管理](https://open.feishu.cn/app/) 网页\n` +
87
+ `3. 选择应用进入应用详情\n` +
88
+ `4. 选择权限管理-批量导入/导出权限\n` +
89
+ `5. 复制以下权限配置并导入(当前启用模块所需最小权限):\n\n` +
90
+ `\`\`\`json\n${JSON.stringify(permissionsConfig, null, 2)}\n\`\`\`\n\n` +
91
+ `6. 选择**版本管理与发布** 点击创建版本,发布后通知管理员审核\n\n` +
92
+ `**提示**:如果您仅使用部分mcp功能,可以通过以下方式关闭权限检查以确保正常使用该mcp:\n` +
93
+ `- 设置环境变量:\`FEISHU_SCOPE_VALIDATION=false\`\n` +
94
+ `- 或使用命令行参数:\`--feishu-scope-validation=false\`\n`;
95
+ Logger.error(errorMessage);
96
+ throw new ScopeInsufficientError(missingScopes, errorMessage);
97
+ }
98
+ /**
99
+ * 生成 scope 校验缓存 key,包含已启用的模块信息
100
+ * 模块列表变更时 key 变化,自动触发重新校验
101
+ */
102
+ generateScopeKey(appId, appSecret, authType, enabledModules) {
103
+ const modulesKey = [...enabledModules].sort().join(',');
104
+ return `app:${appId}:${appSecret.substring(0, 8)}:${authType}:modules(${modulesKey})`;
105
+ }
106
+ async getTempTenantTokenForScope(appId, appSecret) {
107
+ try {
108
+ const requestData = {
109
+ app_id: appId,
110
+ app_secret: appSecret,
111
+ };
112
+ const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
113
+ const headers = { 'Content-Type': 'application/json' };
114
+ Logger.debug('获取临时租户token用于scope校验:', url);
115
+ const response = await axios.post(url, requestData, { headers });
116
+ const data = response.data;
117
+ if (data.code !== 0) {
118
+ throw new Error(`获取临时租户访问令牌失败:${data.msg || '未知错误'} (错误码: ${data.code})`);
119
+ }
120
+ if (!data.tenant_access_token) {
121
+ throw new Error('获取临时租户访问令牌失败:响应中没有token');
122
+ }
123
+ Logger.debug('临时租户token获取成功,用于scope校验');
124
+ return data.tenant_access_token;
125
+ }
126
+ catch (error) {
127
+ Logger.error('获取临时租户访问令牌失败:', error);
128
+ throw new Error('获取临时租户访问令牌失败: ' + (error instanceof Error ? error.message : String(error)));
129
+ }
130
+ }
131
+ async validateScopeWithVersion(appId, appSecret, authType, clientKey) {
132
+ const config = Config.getInstance();
133
+ const enabledIds = config.features.enabledModules;
134
+ const effectiveModules = ModuleRegistry.getEnabledModules(enabledIds, authType).map(m => m.id);
135
+ const tokenCacheManager = TokenCacheManager.getInstance();
136
+ const scopeKey = this.generateScopeKey(appId, appSecret, authType, effectiveModules);
137
+ const scopeVersion = '3.0.0';
138
+ if (!tokenCacheManager.shouldValidateScope(scopeKey, scopeVersion)) {
139
+ Logger.debug(`Scope版本已校验过,跳过校验: ${scopeKey}`);
140
+ return;
141
+ }
142
+ Logger.info(`开始校验scope权限,版本: ${scopeVersion}, 有效模块: ${effectiveModules.join(', ')}`);
143
+ try {
144
+ const tempTenantToken = await this.getTempTenantTokenForScope(appId, appSecret);
145
+ const actualScopes = await this.getApplicationScopes(tempTenantToken, authType);
146
+ const requiredScopes = getRequiredScopes(effectiveModules, authType);
147
+ const validationResult = this.validateScopes(requiredScopes, actualScopes);
148
+ if (!validationResult.isValid) {
149
+ if (clientKey) {
150
+ if (authType === 'user') {
151
+ tokenCacheManager.removeUserToken(clientKey);
152
+ Logger.info(`已清除当前用户 token(scope 不足): ${clientKey}`);
153
+ }
154
+ else {
155
+ tokenCacheManager.removeTenantToken(clientKey);
156
+ Logger.info(`已清除当前租户 token(scope 不足): ${clientKey}`);
157
+ }
158
+ }
159
+ this.throwScopeInsufficientError(validationResult.missingScopes, effectiveModules, authType);
160
+ }
161
+ const scopeVersionInfo = {
162
+ scopeVersion,
163
+ scopeList: requiredScopes,
164
+ validatedAt: Math.floor(Date.now() / 1000),
165
+ validatedVersion: scopeVersion
166
+ };
167
+ tokenCacheManager.saveScopeVersionInfo(scopeKey, scopeVersionInfo);
168
+ Logger.info(`Scope权限校验成功,版本: ${scopeVersion},共 ${requiredScopes.length} 个权限`);
169
+ }
170
+ catch (error) {
171
+ if (error instanceof ScopeInsufficientError) {
172
+ throw error;
173
+ }
174
+ Logger.warn(`Scope权限校验失败,但继续使用token: ${error instanceof Error ? error.message : String(error)}`);
175
+ }
176
+ }
177
+ }