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.
@@ -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
- const responseText = `所有飞书块创建成功,共分 ${totalBatches} 批创建了 ${createdBlocksCount} 个块。\n\n` +
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
- (hasImageBlocks ? `\n\n⚠️ 检测到 ${allImageBlocks.length} 个图片块已创建!\n` +
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.');