feishu-mcp 0.1.6 → 0.1.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.
- package/LICENSE +21 -21
- package/README.md +251 -221
- package/dist/mcp/feishuMcp.js +1 -1
- package/dist/mcp/tools/feishuBlockTools.js +127 -6
- package/dist/services/baseService.js +13 -2
- package/dist/services/blockFactory.js +17 -0
- package/dist/services/feishuApiService.js +272 -2
- package/dist/types/feishuSchema.js +30 -1
- package/dist/utils/auth/tokenCacheManager.js +112 -0
- package/dist/utils/document.js +114 -114
- package/dist/utils/error.js +24 -0
- package/package.json +75 -75
|
@@ -12,7 +12,7 @@ BlockConfigSchema, MediaIdSchema, MediaExtraSchema, ImagesArraySchema,
|
|
|
12
12
|
// MermaidCodeSchema,
|
|
13
13
|
// ImageWidthSchema,
|
|
14
14
|
// ImageHeightSchema
|
|
15
|
-
TableCreateSchema } from '../../types/feishuSchema.js';
|
|
15
|
+
TableCreateSchema, WhiteboardFillArraySchema } from '../../types/feishuSchema.js';
|
|
16
16
|
/**
|
|
17
17
|
* 注册飞书块相关的MCP工具
|
|
18
18
|
* @param server MCP服务器实例
|
|
@@ -47,7 +47,7 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
49
|
// 添加通用飞书块创建工具(支持文本、代码、标题)
|
|
50
|
-
server.tool('batch_create_feishu_blocks', 'PREFERRED: Efficiently creates multiple blocks (text, code, heading, list) in a single API call. USE THIS TOOL when creating multiple consecutive blocks at the same position - reduces API calls by up to 90%. KEY FEATURES: (1) Handles any number of blocks by auto-batching large requests (>50 blocks), (2) Creates blocks at consecutive positions in a document, (3) Supports direct heading level format (e.g. "heading1", "heading2") or standard "heading" type with level in options. CORRECT FORMAT: mcp_feishu_batch_create_feishu_blocks({documentId:"doc123",parentBlockId:"para123",startIndex:0,blocks:[{blockType:"text",options:{...}},{blockType:"heading1",options:{heading:{content:"Title"}}}]}). For separate positions, use individual block creation tools instead. For wiki links (https://xxx.feishu.cn/wiki/xxx), first convert with convert_feishu_wiki_to_document_id tool.', {
|
|
50
|
+
server.tool('batch_create_feishu_blocks', 'PREFERRED: Efficiently creates multiple blocks (text, code, heading, list, image, mermaid, whiteboard) in a single API call. USE THIS TOOL when creating multiple consecutive blocks at the same position - reduces API calls by up to 90%. KEY FEATURES: (1) Handles any number of blocks by auto-batching large requests (>50 blocks), (2) Creates blocks at consecutive positions in a document, (3) Supports direct heading level format (e.g. "heading1", "heading2") or standard "heading" type with level in options. CORRECT FORMAT: mcp_feishu_batch_create_feishu_blocks({documentId:"doc123",parentBlockId:"para123",startIndex:0,blocks:[{blockType:"text",options:{...}},{blockType:"heading1",options:{heading:{content:"Title"}}}]}). For whiteboard blocks, use blockType:"whiteboard" with options:{whiteboard:{align:1}}. After creating a whiteboard block, you will receive a token in the response (board.token field) which can be used with fill_whiteboard_with_plantuml tool. The fill_whiteboard_with_plantuml tool supports both PlantUML (syntax_type: 1) and Mermaid (syntax_type: 2) formats. For separate positions, use individual block creation tools instead. For wiki links (https://xxx.feishu.cn/wiki/xxx), first convert with convert_feishu_wiki_to_document_id tool.', {
|
|
51
51
|
documentId: DocumentIdSchema,
|
|
52
52
|
parentBlockId: ParentBlockIdSchema,
|
|
53
53
|
index: IndexSchema,
|
|
@@ -110,6 +110,9 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
110
110
|
// 检查是否有图片块(block_type=27)
|
|
111
111
|
const imageBlocks = result.children?.filter((child) => child.block_type === 27) || [];
|
|
112
112
|
const hasImageBlocks = imageBlocks.length > 0;
|
|
113
|
+
// 检查是否有画板块(block_type=43)
|
|
114
|
+
const whiteboardBlocks = result.children?.filter((child) => child.block_type === 43) || [];
|
|
115
|
+
const hasWhiteboardBlocks = whiteboardBlocks.length > 0;
|
|
113
116
|
const responseData = {
|
|
114
117
|
...result,
|
|
115
118
|
nextIndex: index + blockContents.length,
|
|
@@ -120,6 +123,17 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
120
123
|
blockIds: imageBlocks.map((block) => block.block_id),
|
|
121
124
|
reminder: "检测到图片块已创建!请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID。"
|
|
122
125
|
}
|
|
126
|
+
}),
|
|
127
|
+
...(hasWhiteboardBlocks && {
|
|
128
|
+
whiteboardBlocksInfo: {
|
|
129
|
+
count: whiteboardBlocks.length,
|
|
130
|
+
blocks: whiteboardBlocks.map((block) => ({
|
|
131
|
+
blockId: block.block_id,
|
|
132
|
+
token: block.board?.token,
|
|
133
|
+
align: block.board?.align
|
|
134
|
+
})),
|
|
135
|
+
reminder: "检测到画板块已创建!请使用 fill_whiteboard_with_plantuml 工具填充画板内容,使用返回的 token 作为 whiteboardId 参数。支持 PlantUML (syntax_type: 1) 和 Mermaid (syntax_type: 2) 两种格式。"
|
|
136
|
+
}
|
|
123
137
|
})
|
|
124
138
|
};
|
|
125
139
|
return {
|
|
@@ -201,12 +215,26 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
201
215
|
allImageBlocks.push(...imageBlocks);
|
|
202
216
|
});
|
|
203
217
|
const hasImageBlocks = allImageBlocks.length > 0;
|
|
204
|
-
|
|
218
|
+
// 检查所有批次中是否有画板块(block_type=43)
|
|
219
|
+
const allWhiteboardBlocks = [];
|
|
220
|
+
results.forEach(batchResult => {
|
|
221
|
+
const whiteboardBlocks = batchResult.children?.filter((child) => child.block_type === 43) || [];
|
|
222
|
+
allWhiteboardBlocks.push(...whiteboardBlocks);
|
|
223
|
+
});
|
|
224
|
+
const hasWhiteboardBlocks = allWhiteboardBlocks.length > 0;
|
|
225
|
+
let responseText = `所有飞书块创建成功,共分 ${totalBatches} 批创建了 ${createdBlocksCount} 个块。\n\n` +
|
|
205
226
|
`最后一批结果: ${JSON.stringify(results[results.length - 1], null, 2)}\n\n` +
|
|
206
|
-
`下一个索引位置: ${currentStartIndex},总创建块数: ${createdBlocksCount}
|
|
207
|
-
|
|
227
|
+
`下一个索引位置: ${currentStartIndex},总创建块数: ${createdBlocksCount}`;
|
|
228
|
+
if (hasImageBlocks) {
|
|
229
|
+
responseText += `\n\n⚠️ 检测到 ${allImageBlocks.length} 个图片块已创建!\n` +
|
|
208
230
|
`图片块IDs: ${allImageBlocks.map(block => block.block_id).join(', ')}\n` +
|
|
209
|
-
`请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID
|
|
231
|
+
`请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID。`;
|
|
232
|
+
}
|
|
233
|
+
if (hasWhiteboardBlocks) {
|
|
234
|
+
responseText += `\n\n⚠️ 检测到 ${allWhiteboardBlocks.length} 个画板块已创建!\n` +
|
|
235
|
+
`画板块信息:\n${allWhiteboardBlocks.map((block) => ` - blockId: ${block.block_id}, token: ${block.board?.token || 'N/A'}\n`).join('')}` +
|
|
236
|
+
`请使用 fill_whiteboard_with_plantuml 工具填充画板内容,使用返回的 token 作为 whiteboardId 参数。支持 PlantUML (syntax_type: 1) 和 Mermaid (syntax_type: 2) 两种格式。`;
|
|
237
|
+
}
|
|
210
238
|
return {
|
|
211
239
|
content: [
|
|
212
240
|
{
|
|
@@ -659,4 +687,97 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
659
687
|
};
|
|
660
688
|
}
|
|
661
689
|
});
|
|
690
|
+
// 添加批量填充画板工具(支持 PlantUML 和 Mermaid)
|
|
691
|
+
server.tool('fill_whiteboard_with_plantuml', 'Batch fills multiple whiteboard blocks with diagram content (PlantUML or Mermaid). Use this tool after creating whiteboard blocks with batch_create_feishu_blocks tool. Each item in the array should contain whiteboardId (the token from board.token field), code and syntax_type. Supports both PlantUML (syntax_type: 1) and Mermaid (syntax_type: 2) formats. Returns detailed results including which whiteboards were filled successfully and which failed, along with failure reasons. The same whiteboard can be filled multiple times.', {
|
|
692
|
+
whiteboards: WhiteboardFillArraySchema,
|
|
693
|
+
}, async ({ whiteboards }) => {
|
|
694
|
+
try {
|
|
695
|
+
if (!feishuService) {
|
|
696
|
+
return {
|
|
697
|
+
content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }],
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
if (!whiteboards || whiteboards.length === 0) {
|
|
701
|
+
return {
|
|
702
|
+
content: [{ type: 'text', text: '错误:画板数组不能为空' }],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
Logger.info(`开始批量填充画板内容,共 ${whiteboards.length} 个画板`);
|
|
706
|
+
const results = [];
|
|
707
|
+
let successCount = 0;
|
|
708
|
+
let failCount = 0;
|
|
709
|
+
// 逐个处理每个画板
|
|
710
|
+
for (let i = 0; i < whiteboards.length; i++) {
|
|
711
|
+
const item = whiteboards[i];
|
|
712
|
+
const { whiteboardId, code, syntax_type } = item;
|
|
713
|
+
const syntaxTypeName = syntax_type === 1 ? 'PlantUML' : 'Mermaid';
|
|
714
|
+
Logger.info(`处理第 ${i + 1}/${whiteboards.length} 个画板,画板ID: ${whiteboardId},语法类型: ${syntaxTypeName}`);
|
|
715
|
+
try {
|
|
716
|
+
const result = await feishuService.createDiagramNode(whiteboardId, code, syntax_type);
|
|
717
|
+
Logger.info(`画板填充成功,画板ID: ${whiteboardId}`);
|
|
718
|
+
successCount++;
|
|
719
|
+
results.push({
|
|
720
|
+
whiteboardId: whiteboardId,
|
|
721
|
+
syntaxType: syntaxTypeName,
|
|
722
|
+
status: 'success',
|
|
723
|
+
nodeId: result.node_id,
|
|
724
|
+
result: result
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
Logger.error(`画板填充失败,画板ID: ${whiteboardId}`, error);
|
|
729
|
+
failCount++;
|
|
730
|
+
// 提取详细的错误信息
|
|
731
|
+
let errorMessage = formatErrorMessage(error);
|
|
732
|
+
let errorCode;
|
|
733
|
+
let logId;
|
|
734
|
+
if (error?.apiError) {
|
|
735
|
+
const apiError = error.apiError;
|
|
736
|
+
if (apiError.code !== undefined && apiError.msg) {
|
|
737
|
+
errorCode = apiError.code;
|
|
738
|
+
errorMessage = apiError.msg;
|
|
739
|
+
if (apiError.log_id) {
|
|
740
|
+
logId = apiError.log_id;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else if (error?.err) {
|
|
745
|
+
errorMessage = error.err;
|
|
746
|
+
}
|
|
747
|
+
else if (error?.message) {
|
|
748
|
+
errorMessage = error.message;
|
|
749
|
+
}
|
|
750
|
+
results.push({
|
|
751
|
+
whiteboardId: whiteboardId,
|
|
752
|
+
syntaxType: syntaxTypeName,
|
|
753
|
+
status: 'failed',
|
|
754
|
+
error: {
|
|
755
|
+
message: errorMessage,
|
|
756
|
+
code: errorCode,
|
|
757
|
+
logId: logId,
|
|
758
|
+
details: error
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// 构建返回结果
|
|
764
|
+
const summary = {
|
|
765
|
+
total: whiteboards.length,
|
|
766
|
+
success: successCount,
|
|
767
|
+
failed: failCount,
|
|
768
|
+
results: results
|
|
769
|
+
};
|
|
770
|
+
Logger.info(`批量填充画板完成,成功: ${successCount},失败: ${failCount}`);
|
|
771
|
+
return {
|
|
772
|
+
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
Logger.error(`批量填充画板内容失败:`, error);
|
|
777
|
+
const errorMessage = formatErrorMessage(error);
|
|
778
|
+
return {
|
|
779
|
+
content: [{ type: 'text', text: `批量填充画板内容失败: ${errorMessage}\n\n错误详情: ${JSON.stringify(error, null, 2)}` }],
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
});
|
|
662
783
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import axios, { AxiosError } from 'axios';
|
|
2
2
|
import FormData from 'form-data';
|
|
3
3
|
import { Logger } from '../utils/logger.js';
|
|
4
|
-
import { formatErrorMessage, AuthRequiredError } from '../utils/error.js';
|
|
4
|
+
import { formatErrorMessage, AuthRequiredError, ScopeInsufficientError } from '../utils/error.js';
|
|
5
5
|
import { Config } from '../utils/config.js';
|
|
6
6
|
import { TokenCacheManager, UserContextManager, AuthUtils } from '../utils/auth/index.js';
|
|
7
7
|
/**
|
|
@@ -126,6 +126,10 @@ export class BaseApiService {
|
|
|
126
126
|
}
|
|
127
127
|
catch (error) {
|
|
128
128
|
const config = Config.getInstance().feishu;
|
|
129
|
+
// 优先处理权限不足异常
|
|
130
|
+
if (error instanceof ScopeInsufficientError) {
|
|
131
|
+
return this.handleScopeInsufficientError(error);
|
|
132
|
+
}
|
|
129
133
|
// 处理授权异常
|
|
130
134
|
if (error instanceof AuthRequiredError) {
|
|
131
135
|
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
|
|
@@ -212,6 +216,13 @@ export class BaseApiService {
|
|
|
212
216
|
async delete(endpoint, data, needsAuth = true) {
|
|
213
217
|
return this.request(endpoint, 'DELETE', data, needsAuth);
|
|
214
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* 处理权限不足异常
|
|
221
|
+
* @param error 权限不足错误
|
|
222
|
+
*/
|
|
223
|
+
handleScopeInsufficientError(error) {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
215
226
|
/**
|
|
216
227
|
* 处理认证失败
|
|
217
228
|
* @param tenant 是否是tenant
|
|
@@ -300,7 +311,7 @@ export class BaseApiService {
|
|
|
300
311
|
const { appId, appSecret } = Config.getInstance().feishu;
|
|
301
312
|
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
302
313
|
const redirect_uri = `${baseUrl}/callback`;
|
|
303
|
-
const scope = encodeURIComponent('base:app:read bitable:app bitable:app:readonly board:whiteboard:node:read contact:user.employee_id:readonly docs:document.content:read docx:document docx:document.block:convert docx:document:create docx:document:readonly drive:drive drive:drive:readonly drive:file drive:file:upload sheets:spreadsheet sheets:spreadsheet:readonly space:document:retrieve space:folder:create wiki:space:read wiki:space:retrieve wiki:wiki wiki:wiki:readonly offline_access');
|
|
314
|
+
const scope = encodeURIComponent('base:app:read bitable:app bitable:app:readonly board:whiteboard:node:read board:whiteboard:node:create contact:user.employee_id:readonly docs:document.content:read docx:document docx:document.block:convert docx:document:create docx:document:readonly drive:drive drive:drive:readonly drive:file drive:file:upload sheets:spreadsheet sheets:spreadsheet:readonly space:document:retrieve space:folder:create wiki:space:read wiki:space:retrieve wiki:wiki wiki:wiki:readonly offline_access');
|
|
304
315
|
const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri);
|
|
305
316
|
return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`;
|
|
306
317
|
}
|
|
@@ -10,6 +10,7 @@ export var BlockType;
|
|
|
10
10
|
BlockType["LIST"] = "list";
|
|
11
11
|
BlockType["IMAGE"] = "image";
|
|
12
12
|
BlockType["MERMAID"] = "mermaid";
|
|
13
|
+
BlockType["WHITEBOARD"] = "whiteboard";
|
|
13
14
|
})(BlockType || (BlockType = {}));
|
|
14
15
|
/**
|
|
15
16
|
* 对齐方式枚举
|
|
@@ -78,6 +79,8 @@ export class BlockFactory {
|
|
|
78
79
|
return this.createImageBlock(options);
|
|
79
80
|
case BlockType.MERMAID:
|
|
80
81
|
return this.createMermaidBlock(options);
|
|
82
|
+
case BlockType.WHITEBOARD:
|
|
83
|
+
return this.createWhiteboardBlock(options);
|
|
81
84
|
default:
|
|
82
85
|
Logger.error(`不支持的块类型: ${type}`);
|
|
83
86
|
throw new Error(`不支持的块类型: ${type}`);
|
|
@@ -246,6 +249,20 @@ export class BlockFactory {
|
|
|
246
249
|
},
|
|
247
250
|
};
|
|
248
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* 创建画板块内容(空画板块,需要后续填充内容)
|
|
254
|
+
* @param options 画板块选项
|
|
255
|
+
* @returns 画板块内容对象
|
|
256
|
+
*/
|
|
257
|
+
createWhiteboardBlock(options = {}) {
|
|
258
|
+
const { align = AlignType.CENTER } = options;
|
|
259
|
+
return {
|
|
260
|
+
block_type: 43, // 43表示画板块
|
|
261
|
+
board: {
|
|
262
|
+
align: align // 1 居左,2 居中,3 居右
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
249
266
|
/**
|
|
250
267
|
* 创建表格块
|
|
251
268
|
* @param options 表格块选项
|
|
@@ -6,6 +6,7 @@ import { ParamUtils } from '../utils/paramUtils.js';
|
|
|
6
6
|
import { BlockFactory, BlockType } from './blockFactory.js';
|
|
7
7
|
import { AuthUtils, TokenCacheManager } from '../utils/auth/index.js';
|
|
8
8
|
import { AuthService } from './feishuAuthService.js';
|
|
9
|
+
import { ScopeInsufficientError } from '../utils/error.js';
|
|
9
10
|
import axios from 'axios';
|
|
10
11
|
import FormData from 'form-data';
|
|
11
12
|
import fs from 'fs';
|
|
@@ -84,13 +85,229 @@ export class FeishuApiService extends BaseApiService {
|
|
|
84
85
|
// 生成客户端缓存键
|
|
85
86
|
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
86
87
|
Logger.debug(`[FeishuApiService] 获取访问令牌,userKey: ${userKey}, clientKey: ${clientKey}, authType: ${authType}`);
|
|
88
|
+
// 在使用token之前先校验scope(使用appId+appSecret获取临时tenant token来调用scope接口)
|
|
89
|
+
await this.validateScopeWithVersion(appId, appSecret, authType);
|
|
90
|
+
// 校验通过后,获取实际的token
|
|
87
91
|
if (authType === 'tenant') {
|
|
88
92
|
// 租户模式:获取租户访问令牌
|
|
89
|
-
return this.getTenantAccessToken(appId, appSecret, clientKey);
|
|
93
|
+
return await this.getTenantAccessToken(appId, appSecret, clientKey);
|
|
90
94
|
}
|
|
91
95
|
else {
|
|
92
96
|
// 用户模式:获取用户访问令牌
|
|
93
|
-
return this.authService.getUserAccessToken(clientKey, appId, appSecret);
|
|
97
|
+
return await this.authService.getUserAccessToken(clientKey, appId, appSecret);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 获取应用权限范围
|
|
102
|
+
* @param accessToken 访问令牌
|
|
103
|
+
* @param authType 认证类型(tenant或user)
|
|
104
|
+
* @returns 应用权限范围列表
|
|
105
|
+
*/
|
|
106
|
+
async getApplicationScopes(accessToken, authType) {
|
|
107
|
+
try {
|
|
108
|
+
const endpoint = '/application/v6/scopes';
|
|
109
|
+
const headers = {
|
|
110
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
111
|
+
'Content-Type': 'application/json'
|
|
112
|
+
};
|
|
113
|
+
Logger.debug('请求应用权限范围:', endpoint);
|
|
114
|
+
const response = await axios.get(`${this.getBaseUrl()}${endpoint}`, { headers });
|
|
115
|
+
const data = response.data;
|
|
116
|
+
if (data.code !== 0) {
|
|
117
|
+
throw new Error(`获取应用权限范围失败:${data.msg || '未知错误'} (错误码: ${data.code})`);
|
|
118
|
+
}
|
|
119
|
+
// 提取权限列表
|
|
120
|
+
// API返回格式: { "data": { "scopes": [{ "grant_status": 1, "scope_name": "...", "scope_type": "tenant"|"user" }] } }
|
|
121
|
+
const scopes = [];
|
|
122
|
+
if (data.data && Array.isArray(data.data.scopes)) {
|
|
123
|
+
// 根据authType过滤,只取已授权的scope(grant_status === 1)
|
|
124
|
+
for (const scopeItem of data.data.scopes) {
|
|
125
|
+
if (scopeItem.grant_status === 1 && scopeItem.scope_type === authType && scopeItem.scope_name) {
|
|
126
|
+
scopes.push(scopeItem.scope_name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
Logger.debug(`获取应用权限范围成功,共 ${scopes.length} 个${authType}权限`);
|
|
131
|
+
return scopes;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
Logger.error('获取应用权限范围失败:', error);
|
|
135
|
+
throw new Error('获取应用权限范围失败: ' + (error instanceof Error ? error.message : String(error)));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 校验scope权限是否充足
|
|
140
|
+
* @param requiredScopes 所需的权限列表
|
|
141
|
+
* @param actualScopes 实际的权限列表
|
|
142
|
+
* @returns 是否权限充足,以及缺失的权限列表
|
|
143
|
+
*/
|
|
144
|
+
validateScopes(requiredScopes, actualScopes) {
|
|
145
|
+
const actualScopesSet = new Set(actualScopes);
|
|
146
|
+
const missingScopes = [];
|
|
147
|
+
for (const requiredScope of requiredScopes) {
|
|
148
|
+
if (!actualScopesSet.has(requiredScope)) {
|
|
149
|
+
missingScopes.push(requiredScope);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
isValid: missingScopes.length === 0,
|
|
154
|
+
missingScopes
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 获取所需的scope列表(根据认证类型)
|
|
159
|
+
* @param authType 认证类型
|
|
160
|
+
* @returns 所需的scope列表
|
|
161
|
+
*/
|
|
162
|
+
getRequiredScopes(authType) {
|
|
163
|
+
// 根据FEISHU_CONFIG.md中定义的权限列表,与用户提供的配置保持一致
|
|
164
|
+
const tenantScopes = [
|
|
165
|
+
"docx:document.block:convert",
|
|
166
|
+
"base:app:read",
|
|
167
|
+
"bitable:app",
|
|
168
|
+
"bitable:app:readonly",
|
|
169
|
+
"board:whiteboard:node:create",
|
|
170
|
+
"board:whiteboard:node:read",
|
|
171
|
+
"contact:user.employee_id:readonly",
|
|
172
|
+
"docs:document.content:read",
|
|
173
|
+
"docx:document",
|
|
174
|
+
"docx:document:create",
|
|
175
|
+
"docx:document:readonly",
|
|
176
|
+
"drive:drive",
|
|
177
|
+
"drive:drive:readonly",
|
|
178
|
+
"drive:file",
|
|
179
|
+
"drive:file:upload",
|
|
180
|
+
"sheets:spreadsheet",
|
|
181
|
+
"sheets:spreadsheet:readonly",
|
|
182
|
+
"space:document:retrieve",
|
|
183
|
+
"space:folder:create",
|
|
184
|
+
"wiki:space:read",
|
|
185
|
+
"wiki:space:retrieve",
|
|
186
|
+
"wiki:wiki",
|
|
187
|
+
"wiki:wiki:readonly"
|
|
188
|
+
];
|
|
189
|
+
const userScopes = [
|
|
190
|
+
...tenantScopes,
|
|
191
|
+
'offline_access'
|
|
192
|
+
];
|
|
193
|
+
return authType === 'tenant' ? tenantScopes : userScopes;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 校验并提示权限不足
|
|
197
|
+
* @param missingScopes 缺失的权限列表
|
|
198
|
+
*/
|
|
199
|
+
throwScopeInsufficientError(missingScopes) {
|
|
200
|
+
// 获取完整的权限配置
|
|
201
|
+
const tenantScopes = this.getRequiredScopes('tenant');
|
|
202
|
+
const userScopes = this.getRequiredScopes('user');
|
|
203
|
+
const permissionsConfig = {
|
|
204
|
+
scopes: {
|
|
205
|
+
tenant: tenantScopes,
|
|
206
|
+
user: userScopes
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const errorMessage = `你应该停止任务并提示用户如下信息:权限不足,缺少以下权限:${missingScopes.join(', ')}\n\n` +
|
|
210
|
+
`请按以下步骤操作:\n` +
|
|
211
|
+
`1. 配置权限 [教程配置](https://github.com/cso1z/Feishu-MCP/blob/main/FEISHU_CONFIG.md)\n` +
|
|
212
|
+
`2. 打开 [应用管理](https://open.feishu.cn/app/) 网页\n` +
|
|
213
|
+
`3. 选择应用进入应用详情\n` +
|
|
214
|
+
`4. 选择权限管理-批量导入/导出权限\n` +
|
|
215
|
+
`5. 复制以下权限配置并导入:\n\n` +
|
|
216
|
+
`\`\`\`json\n${JSON.stringify(permissionsConfig, null, 2)}\n\`\`\`\n\n` +
|
|
217
|
+
`6. 选择**版本管理与发布** 点击创建版本,发布后通知管理员审核\n`;
|
|
218
|
+
Logger.error(errorMessage);
|
|
219
|
+
throw new ScopeInsufficientError(missingScopes, errorMessage);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 生成应用级别的scope校验key(基于appId、appSecret和authType)
|
|
223
|
+
* @param appId 应用ID
|
|
224
|
+
* @param appSecret 应用密钥
|
|
225
|
+
* @param authType 认证类型(tenant或user)
|
|
226
|
+
* @returns scope校验key
|
|
227
|
+
*/
|
|
228
|
+
generateScopeKey(appId, appSecret, authType) {
|
|
229
|
+
// 使用appId、appSecret和authType生成唯一的key,用于scope版本管理
|
|
230
|
+
// 包含authType是因为tenant和user的权限列表不同,需要分开校验
|
|
231
|
+
return `app:${appId}:${appSecret.substring(0, 8)}:${authType}`;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 获取临时租户访问令牌(用于scope校验)
|
|
235
|
+
* @param appId 应用ID
|
|
236
|
+
* @param appSecret 应用密钥
|
|
237
|
+
* @returns 租户访问令牌
|
|
238
|
+
*/
|
|
239
|
+
async getTempTenantTokenForScope(appId, appSecret) {
|
|
240
|
+
try {
|
|
241
|
+
const requestData = {
|
|
242
|
+
app_id: appId,
|
|
243
|
+
app_secret: appSecret,
|
|
244
|
+
};
|
|
245
|
+
const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
|
|
246
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
247
|
+
Logger.debug('获取临时租户token用于scope校验:', url);
|
|
248
|
+
const response = await axios.post(url, requestData, { headers });
|
|
249
|
+
const data = response.data;
|
|
250
|
+
if (data.code !== 0) {
|
|
251
|
+
throw new Error(`获取临时租户访问令牌失败:${data.msg || '未知错误'} (错误码: ${data.code})`);
|
|
252
|
+
}
|
|
253
|
+
if (!data.tenant_access_token) {
|
|
254
|
+
throw new Error('获取临时租户访问令牌失败:响应中没有token');
|
|
255
|
+
}
|
|
256
|
+
Logger.debug('临时租户token获取成功,用于scope校验');
|
|
257
|
+
return data.tenant_access_token;
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
Logger.error('获取临时租户访问令牌失败:', error);
|
|
261
|
+
throw new Error('获取临时租户访问令牌失败: ' + (error instanceof Error ? error.message : String(error)));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* 校验scope权限(带版本管理)
|
|
266
|
+
* @param appId 应用ID
|
|
267
|
+
* @param appSecret 应用密钥
|
|
268
|
+
* @param authType 认证类型
|
|
269
|
+
*/
|
|
270
|
+
async validateScopeWithVersion(appId, appSecret, authType) {
|
|
271
|
+
const tokenCacheManager = TokenCacheManager.getInstance();
|
|
272
|
+
// 生成应用级别的scope校验key(包含authType,因为tenant和user权限不同)
|
|
273
|
+
const scopeKey = this.generateScopeKey(appId, appSecret, authType);
|
|
274
|
+
const scopeVersion = '1.0.0'; // 当前scope版本号,可以根据需要更新
|
|
275
|
+
// 检查是否需要校验
|
|
276
|
+
if (!tokenCacheManager.shouldValidateScope(scopeKey, scopeVersion)) {
|
|
277
|
+
Logger.debug(`Scope版本已校验过,跳过校验: ${scopeKey}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
Logger.info(`开始校验scope权限,版本: ${scopeVersion}, scopeKey: ${scopeKey}`);
|
|
281
|
+
try {
|
|
282
|
+
// 使用appId和appSecret获取临时tenant token来调用scope接口
|
|
283
|
+
const tempTenantToken = await this.getTempTenantTokenForScope(appId, appSecret);
|
|
284
|
+
// 获取实际权限范围(使用tenant token,但根据authType过滤scope_type)
|
|
285
|
+
const actualScopes = await this.getApplicationScopes(tempTenantToken, authType);
|
|
286
|
+
// 获取当前版本所需的scope列表
|
|
287
|
+
const requiredScopes = this.getRequiredScopes(authType);
|
|
288
|
+
// 校验权限
|
|
289
|
+
const validationResult = this.validateScopes(requiredScopes, actualScopes);
|
|
290
|
+
if (!validationResult.isValid) {
|
|
291
|
+
// 权限不足,抛出错误
|
|
292
|
+
this.throwScopeInsufficientError(validationResult.missingScopes);
|
|
293
|
+
}
|
|
294
|
+
// 权限充足,保存版本信息
|
|
295
|
+
const scopeVersionInfo = {
|
|
296
|
+
scopeVersion,
|
|
297
|
+
scopeList: requiredScopes,
|
|
298
|
+
validatedAt: Math.floor(Date.now() / 1000),
|
|
299
|
+
validatedVersion: scopeVersion
|
|
300
|
+
};
|
|
301
|
+
tokenCacheManager.saveScopeVersionInfo(scopeKey, scopeVersionInfo);
|
|
302
|
+
Logger.info(`Scope权限校验成功,版本: ${scopeVersion}`);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
// 如果是权限不足错误,需要重新抛出,中断流程
|
|
306
|
+
if (error instanceof ScopeInsufficientError) {
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
// 如果获取权限范围失败(网络错误、API调用失败等),记录警告但不阻止token使用
|
|
310
|
+
Logger.warn(`Scope权限校验失败,但继续使用token: ${error instanceof Error ? error.message : String(error)}`);
|
|
94
311
|
}
|
|
95
312
|
}
|
|
96
313
|
/**
|
|
@@ -745,6 +962,21 @@ export class FeishuApiService extends BaseApiService {
|
|
|
745
962
|
};
|
|
746
963
|
}
|
|
747
964
|
break;
|
|
965
|
+
case BlockType.WHITEBOARD:
|
|
966
|
+
if ('whiteboard' in options && options.whiteboard) {
|
|
967
|
+
const whiteboardOptions = options.whiteboard;
|
|
968
|
+
blockConfig.options = {
|
|
969
|
+
align: (whiteboardOptions.align === 1 || whiteboardOptions.align === 2 || whiteboardOptions.align === 3)
|
|
970
|
+
? whiteboardOptions.align : 1
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
// 默认画板块选项
|
|
975
|
+
blockConfig.options = {
|
|
976
|
+
align: 1
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
break;
|
|
748
980
|
default:
|
|
749
981
|
Logger.warn(`未知的块类型: ${blockType},尝试作为标准类型处理`);
|
|
750
982
|
if ('text' in options) {
|
|
@@ -815,6 +1047,14 @@ export class FeishuApiService extends BaseApiService {
|
|
|
815
1047
|
code: mermaidConfig.code,
|
|
816
1048
|
};
|
|
817
1049
|
}
|
|
1050
|
+
else if ("whiteboard" in options) {
|
|
1051
|
+
blockConfig.type = BlockType.WHITEBOARD;
|
|
1052
|
+
const whiteboardConfig = options.whiteboard;
|
|
1053
|
+
blockConfig.options = {
|
|
1054
|
+
align: (whiteboardConfig.align === 1 || whiteboardConfig.align === 2 || whiteboardConfig.align === 3)
|
|
1055
|
+
? whiteboardConfig.align : 1
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
818
1058
|
break;
|
|
819
1059
|
}
|
|
820
1060
|
// 记录调试信息
|
|
@@ -1162,6 +1402,36 @@ export class FeishuApiService extends BaseApiService {
|
|
|
1162
1402
|
return Buffer.from([]); // 永远不会执行到这里
|
|
1163
1403
|
}
|
|
1164
1404
|
}
|
|
1405
|
+
/**
|
|
1406
|
+
* 在画板中创建图表节点(支持 PlantUML 和 Mermaid)
|
|
1407
|
+
* @param whiteboardId 画板ID(token)
|
|
1408
|
+
* @param code 图表代码(PlantUML 或 Mermaid)
|
|
1409
|
+
* @param syntaxType 语法类型:1=PlantUML, 2=Mermaid
|
|
1410
|
+
* @returns 创建结果
|
|
1411
|
+
*/
|
|
1412
|
+
async createDiagramNode(whiteboardId, code, syntaxType) {
|
|
1413
|
+
try {
|
|
1414
|
+
const normalizedWhiteboardId = ParamUtils.processWhiteboardId(whiteboardId);
|
|
1415
|
+
const endpoint = `/board/v1/whiteboards/${normalizedWhiteboardId}/nodes/plantuml`;
|
|
1416
|
+
const syntaxTypeName = syntaxType === 1 ? 'PlantUML' : 'Mermaid';
|
|
1417
|
+
Logger.info(`开始在画板中创建 ${syntaxTypeName} 节点,画板ID: ${normalizedWhiteboardId}`);
|
|
1418
|
+
Logger.debug(`${syntaxTypeName} 代码: ${code.substring(0, 200)}...`);
|
|
1419
|
+
const payload = {
|
|
1420
|
+
plant_uml_code: code,
|
|
1421
|
+
style_type: 1, // 画板样式(默认为2 经典样式) 示例值:1 可选值有: 1:画板样式(解析之后为多个画板节点,粘贴到画板中,不可对语法进行二次编辑) 2:经典样式(解析之后为一张图片,粘贴到画板中,可对语法进行二次编辑)(只有PlantUml语法支持经典样式
|
|
1422
|
+
syntax_type: syntaxType
|
|
1423
|
+
};
|
|
1424
|
+
Logger.debug(`请求载荷: ${JSON.stringify(payload, null, 2)}`);
|
|
1425
|
+
const response = await this.post(endpoint, payload);
|
|
1426
|
+
Logger.info(`${syntaxTypeName} 节点创建成功`);
|
|
1427
|
+
return response;
|
|
1428
|
+
}
|
|
1429
|
+
catch (error) {
|
|
1430
|
+
const syntaxTypeName = syntaxType === 1 ? 'PlantUML' : 'Mermaid';
|
|
1431
|
+
Logger.error(`创建 ${syntaxTypeName} 节点失败,画板ID: ${whiteboardId}`, error);
|
|
1432
|
+
this.handleApiError(error, `创建 ${syntaxTypeName} 节点失败`);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1165
1435
|
/**
|
|
1166
1436
|
* 从路径或URL获取图片的Base64编码
|
|
1167
1437
|
* @param imagePathOrUrl 图片路径或URL
|
|
@@ -103,9 +103,10 @@ export const ListBlockSchema = z.object({
|
|
|
103
103
|
align: AlignSchemaWithValidation,
|
|
104
104
|
});
|
|
105
105
|
// 块类型枚举 - 用于批量创建块工具
|
|
106
|
-
export const BlockTypeEnum = z.string().describe("Block type (required). Supports: 'text', 'code', 'heading', 'list', 'image','mermaid',as well as 'heading1' through 'heading9'. " +
|
|
106
|
+
export const BlockTypeEnum = z.string().describe("Block type (required). Supports: 'text', 'code', 'heading', 'list', 'image','mermaid','whiteboard',as well as 'heading1' through 'heading9'. " +
|
|
107
107
|
"For headings, we recommend using 'heading' with level property, but 'heading1'-'heading9' are also supported. " +
|
|
108
108
|
"For images, use 'image' to create empty image blocks that can be filled later. " +
|
|
109
|
+
"For whiteboards, use 'whiteboard' to create empty whiteboard blocks that return a token for filling content. " +
|
|
109
110
|
"For text blocks, you can include both regular text and equation elements in the same block.");
|
|
110
111
|
// 图片宽度参数定义
|
|
111
112
|
export const ImageWidthSchema = z.number().optional().describe('Image width in pixels (optional). If not provided, the original image width will be used.');
|
|
@@ -124,6 +125,12 @@ export const MermaidCodeSchema = z.string().describe('Mermaid code (required). T
|
|
|
124
125
|
export const MermaidBlockSchema = z.object({
|
|
125
126
|
code: MermaidCodeSchema,
|
|
126
127
|
});
|
|
128
|
+
// 画板对齐方式参数定义
|
|
129
|
+
export const WhiteboardAlignSchema = z.number().optional().default(2).describe('Whiteboard alignment: 1 for left, 2 for center (default), 3 for right.');
|
|
130
|
+
// 画板块内容定义 - 用于批量创建块工具
|
|
131
|
+
export const WhiteboardBlockSchema = z.object({
|
|
132
|
+
align: WhiteboardAlignSchema,
|
|
133
|
+
});
|
|
127
134
|
// 块配置定义 - 用于批量创建块工具
|
|
128
135
|
export const BlockConfigSchema = z.object({
|
|
129
136
|
blockType: BlockTypeEnum,
|
|
@@ -134,6 +141,7 @@ export const BlockConfigSchema = z.object({
|
|
|
134
141
|
z.object({ list: ListBlockSchema }).describe("List block options. Used when blockType is 'list'."),
|
|
135
142
|
z.object({ image: ImageBlockSchema }).describe("Image block options. Used when blockType is 'image'. Creates empty image blocks."),
|
|
136
143
|
z.object({ mermaid: MermaidBlockSchema }).describe("Mermaid block options. Used when blockType is 'mermaid'."),
|
|
144
|
+
z.object({ whiteboard: WhiteboardBlockSchema }).describe("Whiteboard block options. Used when blockType is 'whiteboard'. Creates empty whiteboard blocks that return a token for filling content."),
|
|
137
145
|
z.record(z.any()).describe("Fallback for any other block options")
|
|
138
146
|
]).describe('Options for the specific block type. Provide the corresponding options object based on blockType.'),
|
|
139
147
|
});
|
|
@@ -196,5 +204,26 @@ export const ImagesArraySchema = z.array(z.object({
|
|
|
196
204
|
export const WhiteboardIdSchema = z.string().describe('Whiteboard ID (required). This is the token value from the board.token field when getting document blocks.\n' +
|
|
197
205
|
'When you find a block with block_type: 43, the whiteboard ID is located in board.token field.\n' +
|
|
198
206
|
'Example: "EPJKwvY5ghe3pVbKj9RcT2msnBX"');
|
|
207
|
+
// 画板代码参数定义(支持 PlantUML 和 Mermaid)
|
|
208
|
+
export const WhiteboardCodeSchema = z.string().describe('Diagram code (required). The complete diagram code to create in the whiteboard.\n' +
|
|
209
|
+
'Supports both PlantUML and Mermaid formats.\n' +
|
|
210
|
+
'PlantUML example: "@startuml\nAlice -> Bob: Hello\n@enduml"\n' +
|
|
211
|
+
'Mermaid example: "graph TD\nA[Start] --> B[End]"');
|
|
212
|
+
// 语法类型参数定义
|
|
213
|
+
export const SyntaxTypeSchema = z.number().describe('Syntax type (required). Specifies the diagram syntax format.\n' +
|
|
214
|
+
'1: PlantUML syntax\n' +
|
|
215
|
+
'2: Mermaid syntax');
|
|
216
|
+
// 画板内容配置定义(包含画板ID和内容配置)
|
|
217
|
+
export const WhiteboardContentSchema = z.object({
|
|
218
|
+
whiteboardId: WhiteboardIdSchema,
|
|
219
|
+
code: WhiteboardCodeSchema,
|
|
220
|
+
syntax_type: SyntaxTypeSchema,
|
|
221
|
+
}).describe('Whiteboard content configuration. Contains the whiteboard ID, diagram code and syntax type.\n' +
|
|
222
|
+
'whiteboardId: The token value from board.token field when creating whiteboard block (required)\n' +
|
|
223
|
+
'code: The diagram code (PlantUML or Mermaid format) (required)\n' +
|
|
224
|
+
'syntax_type: 1 for PlantUML, 2 for Mermaid (required)');
|
|
225
|
+
// 批量填充画板数组定义
|
|
226
|
+
export const WhiteboardFillArraySchema = z.array(WhiteboardContentSchema).describe('Array of whiteboard fill items (required). Each item must include whiteboardId, code and syntax_type.\n' +
|
|
227
|
+
'Example: [{whiteboardId:"token1", code:"@startuml...", syntax_type:1}, {whiteboardId:"token2", code:"graph TD...", syntax_type:2}]');
|
|
199
228
|
// 文档标题参数定义
|
|
200
229
|
export const DocumentTitleSchema = z.string().describe('Document title (required). This will be displayed in the Feishu document list and document header.');
|