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,93 @@
1
+ import { formatErrorMessage } from '../../../utils/error.js';
2
+ import { Logger } from '../../../utils/logger.js';
3
+ import { errorResponse } from '../../document/tools/toolHelpers.js';
4
+ import { createTasks, listTasks, updateTask, deleteTasks } from '../toolApi/index.js';
5
+ import { TaskGuidSchema, TaskSummarySchema, TaskDescriptionSchema, TaskTimestampSchema, TaskIsAllDaySchema, TaskRepeatRuleSchema, TaskCompletedAtSchema, TaskModeSchema, TaskIsMilestoneSchema, TaskCreateBatchSchema, TaskAssigneeIdsSchema, TaskFollowerIdsSchema, TaskReminderRelativeMinutesSchema, TaskReminderIdsSchema, TaskListPageTokenSchema, TaskListCompletedSchema, TaskDeleteBatchSchema, } from '../../../types/taskSchema.js';
6
+ /**
7
+ * Registers Feishu task MCP tools.
8
+ */
9
+ export function registerTaskTools(server, feishuService) {
10
+ server.tool('list_feishu_tasks', 'Lists tasks assigned to the current user ("我负责的"). Returns up to 100 items per call (2 pages of 50). Optional pageToken for next page, optional completed (true=done only, false=todo only). Response includes slimmed task fields: guid, summary, description, due, reminders, creator, members, status, task_id, url, etc. Requires user_access_token.', {
11
+ pageToken: TaskListPageTokenSchema,
12
+ completed: TaskListCompletedSchema,
13
+ }, async ({ pageToken, completed }) => {
14
+ try {
15
+ const result = await listTasks({ pageToken, completed }, feishuService);
16
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
17
+ }
18
+ catch (error) {
19
+ Logger.error('列取任务失败:', error);
20
+ return errorResponse(`列取任务失败: ${formatErrorMessage(error)}`);
21
+ }
22
+ });
23
+ server.tool('create_feishu_task', 'Batch creates Feishu tasks or nested subtasks. Pass an array of task items; each item has summary (required), optional description/due/members/repeat_rule/start/mode/is_milestone, optional parentTaskGuid (create under existing task), and optional subTasks (nested subtasks, multi-level). Use open_id from get_feishu_users for assignees. Min 1, max 50 top-level items; max 50 subTasks per level. Returns nested results and path-indexed errors.', {
24
+ tasks: TaskCreateBatchSchema,
25
+ }, async ({ tasks: taskItems }) => {
26
+ try {
27
+ const result = await createTasks(taskItems, feishuService);
28
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
29
+ }
30
+ catch (error) {
31
+ Logger.error('批量创建任务失败:', error);
32
+ return errorResponse(`批量创建任务失败: ${formatErrorMessage(error)}`);
33
+ }
34
+ });
35
+ server.tool('update_feishu_task', 'Updates an existing Feishu task. Provide task_guid and the fields to change. Supports summary, description, due, completed_at (use "0" to restore to unfinished), repeat_rule, start, mode, is_milestone. Also supports addAssigneeIds/addFollowerIds and removeAssigneeIds/removeFollowerIds for members; addReminderRelativeMinutes to add a reminder (task must have due; only one per task—remove first if needed) and removeReminderIds (from task.reminders[].id) to remove reminders. At least one update is required.', {
36
+ taskGuid: TaskGuidSchema,
37
+ summary: TaskSummarySchema.optional(),
38
+ description: TaskDescriptionSchema,
39
+ dueTimestamp: TaskTimestampSchema,
40
+ isDueAllDay: TaskIsAllDaySchema,
41
+ completedAt: TaskCompletedAtSchema,
42
+ repeatRule: TaskRepeatRuleSchema,
43
+ startTimestamp: TaskTimestampSchema,
44
+ isStartAllDay: TaskIsAllDaySchema,
45
+ mode: TaskModeSchema,
46
+ isMilestone: TaskIsMilestoneSchema,
47
+ addAssigneeIds: TaskAssigneeIdsSchema,
48
+ addFollowerIds: TaskFollowerIdsSchema,
49
+ removeAssigneeIds: TaskAssigneeIdsSchema,
50
+ removeFollowerIds: TaskFollowerIdsSchema,
51
+ addReminderRelativeMinutes: TaskReminderRelativeMinutesSchema,
52
+ removeReminderIds: TaskReminderIdsSchema,
53
+ }, async ({ taskGuid, summary, description, dueTimestamp, isDueAllDay, completedAt, repeatRule, startTimestamp, isStartAllDay, mode, isMilestone, addAssigneeIds, addFollowerIds, removeAssigneeIds, removeFollowerIds, addReminderRelativeMinutes, removeReminderIds, }) => {
54
+ try {
55
+ const result = await updateTask({
56
+ taskGuid,
57
+ summary,
58
+ description,
59
+ dueTimestamp,
60
+ isDueAllDay,
61
+ completedAt,
62
+ repeatRule,
63
+ startTimestamp,
64
+ isStartAllDay,
65
+ mode,
66
+ isMilestone,
67
+ addAssigneeIds,
68
+ addFollowerIds,
69
+ removeAssigneeIds,
70
+ removeFollowerIds,
71
+ addReminderRelativeMinutes,
72
+ removeReminderIds,
73
+ }, feishuService);
74
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
75
+ }
76
+ catch (error) {
77
+ Logger.error('更新任务失败:', error);
78
+ return errorResponse(`更新任务失败: ${formatErrorMessage(error)}`);
79
+ }
80
+ });
81
+ server.tool('delete_feishu_task', 'Deletes one or more Feishu tasks by task_guid. Pass an array of task GUIDs (min 1, max 50). Requires edit permission on each task. Returns deleted guids and per-item errors. Deleted tasks cannot be retrieved.', {
82
+ taskGuids: TaskDeleteBatchSchema,
83
+ }, async ({ taskGuids }) => {
84
+ try {
85
+ const result = await deleteTasks(taskGuids, feishuService);
86
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
87
+ }
88
+ catch (error) {
89
+ Logger.error('删除任务失败:', error);
90
+ return errorResponse(`删除任务失败: ${formatErrorMessage(error)}`);
91
+ }
92
+ });
93
+ }
package/dist/server.js CHANGED
@@ -47,25 +47,20 @@ export class FeishuMcpServer {
47
47
  async connect(transport) {
48
48
  const server = new FeishuMcp();
49
49
  await server.connect(transport);
50
+ const shutdown = () => {
51
+ TokenRefreshManager.getInstance().stop();
52
+ this.stopCallbackServer();
53
+ process.exit(0);
54
+ };
50
55
  // 监听 transport 关闭事件,清理 callback 服务器
51
56
  // 对于 stdio 模式,监听 stdin 关闭事件
52
57
  if (process.stdin && typeof process.stdin.on === 'function') {
53
- process.stdin.on('close', () => {
54
- this.stopCallbackServer();
55
- });
56
- process.stdin.on('end', () => {
57
- this.stopCallbackServer();
58
- });
58
+ process.stdin.on('close', shutdown);
59
+ process.stdin.on('end', shutdown);
59
60
  }
60
61
  // 监听进程退出事件,确保清理资源
61
- process.on('SIGINT', () => {
62
- this.stopCallbackServer();
63
- process.exit(0);
64
- });
65
- process.on('SIGTERM', () => {
66
- this.stopCallbackServer();
67
- process.exit(0);
68
- });
62
+ process.on('SIGINT', shutdown);
63
+ process.on('SIGTERM', shutdown);
69
64
  // 注意:在 stdio 模式下,Logger 会自动禁用输出,避免污染 MCP 协议
70
65
  // 如果需要日志,可以通过 MCP 协议的 logging 消息传递
71
66
  Logger.info('Server connected and ready to process requests');
@@ -98,32 +93,41 @@ export class FeishuMcpServer {
98
93
  });
99
94
  // Check for existing session ID
100
95
  const sessionId = req.headers['mcp-session-id'];
96
+ // 优先使用客户端显式传入的 ?userKey=,若不传则在 session 初始化时 fallback 到 sessionId
97
+ const queryUserKey = req.query.userKey;
101
98
  let transport;
99
+ let userKey;
102
100
  if (sessionId && transports[sessionId]) {
103
- // Reuse existing transport
101
+ // Reuse existing transport,从 userAuthManager 取回持久化的 userKey
104
102
  Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId);
105
103
  transport = transports[sessionId];
104
+ userKey = this.userAuthManager.getUserKeyBySessionId(sessionId) || sessionId;
106
105
  }
107
106
  else if (!sessionId && isInitializeRequest(req.body)) {
108
107
  // New initialization request
109
108
  transport = new StreamableHTTPServerTransport({
110
109
  sessionIdGenerator: () => randomUUID(),
111
- onsessioninitialized: (sessionId) => {
112
- // Store the transport by session ID
113
- Logger.log(`[StreamableHTTP connection] ${sessionId}`);
114
- transports[sessionId] = transport;
110
+ onsessioninitialized: (newSessionId) => {
111
+ // 建立 sessionId userKey 持久映射:优先用客户端传入的,否则用 sessionId 兜底
112
+ const resolvedUserKey = queryUserKey || newSessionId;
113
+ this.userAuthManager.createSession(newSessionId, resolvedUserKey);
114
+ Logger.log(`[StreamableHTTP connection] ${newSessionId}, userKey: ${resolvedUserKey}`);
115
+ transports[newSessionId] = transport;
115
116
  }
116
117
  });
117
118
  // Clean up transport and server when closed
118
119
  transport.onclose = () => {
119
120
  if (transport.sessionId) {
120
- Logger.log(`[StreamableHTTP delete] ${transports[transport.sessionId]}`);
121
+ Logger.log(`[StreamableHTTP delete] ${transport.sessionId}`);
122
+ this.userAuthManager.removeSession(transport.sessionId);
121
123
  delete transports[transport.sessionId];
122
124
  }
123
125
  };
124
126
  // Create and connect server instance
125
127
  const server = new FeishuMcp();
126
128
  await server.connect(transport);
129
+ // 初始化握手阶段 sessionId 尚未分配,使用 queryUserKey(若有)或临时占位符
130
+ userKey = queryUserKey || 'http-client';
127
131
  }
128
132
  else {
129
133
  // Invalid request
@@ -137,8 +141,12 @@ export class FeishuMcpServer {
137
141
  });
138
142
  return;
139
143
  }
140
- // Handle the request
141
- await transport.handleRequest(req, res, req.body);
144
+ // 获取 baseUrl 并在用户上下文中处理请求
145
+ const baseUrl = getBaseUrl(req);
146
+ // Handle the request with user context
147
+ await this.userContextManager.run({ userKey, baseUrl }, async () => {
148
+ await transport.handleRequest(req, res, req.body);
149
+ });
142
150
  }
143
151
  catch (error) {
144
152
  Logger.error('Error handling MCP request:', error);
@@ -164,7 +172,12 @@ export class FeishuMcpServer {
164
172
  return;
165
173
  }
166
174
  const transport = transports[sessionId];
167
- await transport.handleRequest(req, res);
175
+ // 获取 baseUrl 并在用户上下文中处理请求
176
+ const baseUrl = getBaseUrl(req);
177
+ const userKey = this.userAuthManager.getUserKeyBySessionId(sessionId) || sessionId;
178
+ await this.userContextManager.run({ userKey, baseUrl }, async () => {
179
+ await transport.handleRequest(req, res);
180
+ });
168
181
  }
169
182
  catch (error) {
170
183
  Logger.error('Error handling GET request:', error);
@@ -182,9 +195,15 @@ export class FeishuMcpServer {
182
195
  return;
183
196
  }
184
197
  const transport = transports[sessionId];
185
- await transport.handleRequest(req, res);
198
+ // 获取 baseUrl 并在用户上下文中处理请求
199
+ const baseUrl = getBaseUrl(req);
200
+ const userKey = this.userAuthManager.getUserKeyBySessionId(sessionId) || sessionId;
201
+ await this.userContextManager.run({ userKey, baseUrl }, async () => {
202
+ await transport.handleRequest(req, res);
203
+ });
186
204
  // Clean up resources after session termination
187
205
  if (transport.sessionId) {
206
+ this.userAuthManager.removeSession(transport.sessionId);
188
207
  delete transports[transport.sessionId];
189
208
  }
190
209
  }
@@ -4,6 +4,8 @@ import { Logger } from '../utils/logger.js';
4
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
+ import { getRequiredScopes } from './constants/feishuScopes.js';
8
+ import { ModuleRegistry } from '../modules/index.js';
7
9
  /**
8
10
  * API服务基类
9
11
  * 提供通用的HTTP请求处理和认证功能
@@ -258,6 +260,7 @@ export class BaseApiService {
258
260
  }
259
261
  else {
260
262
  // 用户模式:清除用户token缓存并生成授权链接
263
+ Logger.info('[handleAuthFailure] 用户模式:清除token,生成授权链接');
261
264
  tokenCacheManager.removeUserToken(clientKey);
262
265
  const authUrl = this.generateUserAuthUrl(baseUrl, userKey);
263
266
  throw new Error(`你需要在给用户展示如下信息:/“请在浏览器打开以下链接进行授权:\n\n[点击授权](${authUrl})/n`);
@@ -326,10 +329,16 @@ export class BaseApiService {
326
329
  * @returns 授权URL
327
330
  */
328
331
  generateUserAuthUrl(baseUrl, userKey) {
329
- const { appId, appSecret } = Config.getInstance().feishu;
332
+ const config = Config.getInstance();
333
+ const { appId, appSecret } = config.feishu;
330
334
  const clientKey = AuthUtils.generateClientKey(userKey);
331
335
  const redirect_uri = `${baseUrl}/callback`;
332
- 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');
336
+ const authType = config.feishu.authType;
337
+ const enabledIds = config.features.enabledModules;
338
+ const effectiveModules = ModuleRegistry.getEnabledModules(enabledIds, authType).map(m => m.id);
339
+ const scopeList = getRequiredScopes(effectiveModules, authType);
340
+ Logger.info(`[generateUserAuthUrl] enabledModules=${effectiveModules.join(',')} authType=${authType} scopes=${scopeList.join(',')}`);
341
+ const scope = encodeURIComponent(scopeList.join(' '));
333
342
  const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri);
334
343
  return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`;
335
344
  }
@@ -263,6 +263,173 @@ export class BlockFactory {
263
263
  }
264
264
  };
265
265
  }
266
+ /**
267
+ * 根据 blockType 字符串和 options 对象创建块内容
268
+ * 将 MCP 工具传入的高级选项转换为 BlockFactory.createBlock 所需格式
269
+ */
270
+ createBlockContentFromOptions(blockType, options) {
271
+ try {
272
+ // 处理特殊的 heading 格式,如 heading1, heading2 等
273
+ if (typeof blockType === 'string' && blockType.startsWith('heading')) {
274
+ const headingMatch = blockType.match(/^heading([1-9])$/);
275
+ if (headingMatch) {
276
+ const level = parseInt(headingMatch[1], 10);
277
+ if (level >= 1 && level <= 9) {
278
+ if (!options || Object.keys(options).length === 0) {
279
+ options = { heading: { level, content: '', align: 1 } };
280
+ }
281
+ else if (!('heading' in options)) {
282
+ options = { heading: { level, content: '', align: 1 } };
283
+ }
284
+ else if (options.heading && !('level' in options.heading)) {
285
+ options.heading.level = level;
286
+ }
287
+ blockType = BlockType.HEADING;
288
+ Logger.info(`转换特殊标题格式: heading${level} -> standard heading with level=${level}`);
289
+ }
290
+ }
291
+ }
292
+ const blockTypeEnum = blockType;
293
+ const blockConfig = {
294
+ type: blockTypeEnum,
295
+ options: {}
296
+ };
297
+ switch (blockTypeEnum) {
298
+ case BlockType.TEXT:
299
+ if ('text' in options && options.text) {
300
+ const textOptions = options.text;
301
+ const textStyles = textOptions.textStyles || [];
302
+ const processedTextStyles = textStyles.map((item) => {
303
+ if (item.equation !== undefined) {
304
+ return { equation: item.equation, style: BlockFactory.applyDefaultTextStyle(item.style) };
305
+ }
306
+ return { text: item.text || '', style: BlockFactory.applyDefaultTextStyle(item.style) };
307
+ });
308
+ blockConfig.options = { textContents: processedTextStyles, align: textOptions.align || 1 };
309
+ }
310
+ break;
311
+ case BlockType.CODE:
312
+ if ('code' in options && options.code) {
313
+ const codeOptions = options.code;
314
+ blockConfig.options = {
315
+ code: codeOptions.code || '',
316
+ language: codeOptions.language === 0 ? 0 : (codeOptions.language || 0),
317
+ wrap: codeOptions.wrap || false
318
+ };
319
+ }
320
+ break;
321
+ case BlockType.HEADING:
322
+ if ('heading' in options && options.heading) {
323
+ const headingOptions = options.heading;
324
+ blockConfig.options = {
325
+ text: headingOptions.content || '',
326
+ level: headingOptions.level || 1,
327
+ align: [1, 2, 3].includes(headingOptions.align) ? headingOptions.align : 1
328
+ };
329
+ }
330
+ break;
331
+ case BlockType.LIST:
332
+ if ('list' in options && options.list) {
333
+ const listOptions = options.list;
334
+ blockConfig.options = {
335
+ text: listOptions.content || '',
336
+ isOrdered: listOptions.isOrdered || false,
337
+ align: [1, 2, 3].includes(listOptions.align) ? listOptions.align : 1
338
+ };
339
+ }
340
+ break;
341
+ case BlockType.IMAGE:
342
+ if ('image' in options && options.image) {
343
+ const imageOptions = options.image;
344
+ blockConfig.options = { width: imageOptions.width || 100, height: imageOptions.height || 100 };
345
+ }
346
+ else {
347
+ blockConfig.options = { width: 100, height: 100 };
348
+ }
349
+ break;
350
+ case BlockType.MERMAID:
351
+ if ('mermaid' in options && options.mermaid) {
352
+ blockConfig.options = { code: options.mermaid.code };
353
+ }
354
+ break;
355
+ case BlockType.WHITEBOARD:
356
+ if ('whiteboard' in options && options.whiteboard) {
357
+ const whiteboardOptions = options.whiteboard;
358
+ blockConfig.options = {
359
+ align: [1, 2, 3].includes(whiteboardOptions.align) ? whiteboardOptions.align : 1
360
+ };
361
+ }
362
+ else {
363
+ blockConfig.options = { align: 1 };
364
+ }
365
+ break;
366
+ default:
367
+ Logger.warn(`未知的块类型: ${blockType},尝试作为标准类型处理`);
368
+ if ('text' in options) {
369
+ blockConfig.type = BlockType.TEXT;
370
+ const textOptions = options.text;
371
+ const textStyles = textOptions.textStyles || [];
372
+ const processedTextStyles = textStyles.map((item) => {
373
+ if (item.equation !== undefined) {
374
+ return { equation: item.equation, style: BlockFactory.applyDefaultTextStyle(item.style) };
375
+ }
376
+ return { text: item.text || '', style: BlockFactory.applyDefaultTextStyle(item.style) };
377
+ });
378
+ blockConfig.options = { textContents: processedTextStyles, align: textOptions.align || 1 };
379
+ }
380
+ else if ('code' in options) {
381
+ blockConfig.type = BlockType.CODE;
382
+ const codeOptions = options.code;
383
+ blockConfig.options = {
384
+ code: codeOptions.code || '',
385
+ language: codeOptions.language === 0 ? 0 : (codeOptions.language || 0),
386
+ wrap: codeOptions.wrap || false
387
+ };
388
+ }
389
+ else if ('heading' in options) {
390
+ blockConfig.type = BlockType.HEADING;
391
+ const headingOptions = options.heading;
392
+ blockConfig.options = {
393
+ text: headingOptions.content || '',
394
+ level: headingOptions.level || 1,
395
+ align: [1, 2, 3].includes(headingOptions.align) ? headingOptions.align : 1
396
+ };
397
+ }
398
+ else if ('list' in options) {
399
+ blockConfig.type = BlockType.LIST;
400
+ const listOptions = options.list;
401
+ blockConfig.options = {
402
+ text: listOptions.content || '',
403
+ isOrdered: listOptions.isOrdered || false,
404
+ align: [1, 2, 3].includes(listOptions.align) ? listOptions.align : 1
405
+ };
406
+ }
407
+ else if ('image' in options) {
408
+ blockConfig.type = BlockType.IMAGE;
409
+ const imageOptions = options.image;
410
+ blockConfig.options = { width: imageOptions.width || 100, height: imageOptions.height || 100 };
411
+ }
412
+ else if ('mermaid' in options) {
413
+ blockConfig.type = BlockType.MERMAID;
414
+ blockConfig.options = { code: options.mermaid.code };
415
+ }
416
+ else if ('whiteboard' in options) {
417
+ blockConfig.type = BlockType.WHITEBOARD;
418
+ const whiteboardConfig = options.whiteboard;
419
+ blockConfig.options = {
420
+ align: [1, 2, 3].includes(whiteboardConfig.align) ? whiteboardConfig.align : 1
421
+ };
422
+ }
423
+ break;
424
+ }
425
+ Logger.debug(`创建块内容: 类型=${blockConfig.type}, 选项=${JSON.stringify(blockConfig.options)}`);
426
+ return this.createBlock(blockConfig.type, blockConfig.options);
427
+ }
428
+ catch (error) {
429
+ Logger.error(`创建块内容对象失败: ${error}`);
430
+ return null;
431
+ }
432
+ }
266
433
  /**
267
434
  * 创建表格块
268
435
  * @param options 表格块选项
@@ -64,7 +64,7 @@ export async function callback(req, res) {
64
64
  });
65
65
  const data = (tokenResp && typeof tokenResp === 'object') ? tokenResp : undefined;
66
66
  Logger.debug('[callback] feishu response:', data);
67
- if (!data || data.code !== 0 || !data.access_token) {
67
+ if (!data || !data.access_token) {
68
68
  return sendFail(res, `获取 access_token 失败,飞书返回: ${JSON.stringify(tokenResp)}`, CODE.CUSTOM);
69
69
  }
70
70
  // 使用TokenCacheManager缓存token信息
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 飞书各功能模块所需权限 Scope 定义
3
+ * 按模块拆分,通过 getRequiredScopes 动态计算最小权限集
4
+ */
5
+ export const MODULE_SCOPES = {
6
+ document: {
7
+ tenant: [
8
+ "docx:document.block:convert",
9
+ "base:app:read",
10
+ "bitable:app",
11
+ "bitable:app:readonly",
12
+ "board:whiteboard:node:create",
13
+ "board:whiteboard:node:read",
14
+ "contact:user.employee_id:readonly",
15
+ "docs:document.content:read",
16
+ "docx:document",
17
+ "docx:document:create",
18
+ "docx:document:readonly",
19
+ "drive:drive",
20
+ "drive:drive:readonly",
21
+ "drive:file",
22
+ "drive:file:upload",
23
+ "sheets:spreadsheet",
24
+ "sheets:spreadsheet:readonly",
25
+ "space:document:retrieve",
26
+ "space:folder:create",
27
+ "wiki:space:read",
28
+ "wiki:space:retrieve",
29
+ "wiki:wiki",
30
+ "wiki:wiki:readonly",
31
+ ],
32
+ userOnly: [
33
+ "search:docs:read",
34
+ "offline_access",
35
+ ],
36
+ },
37
+ task: {
38
+ tenant: [
39
+ "task:task:write",
40
+ ],
41
+ userOnly: [],
42
+ },
43
+ calendar: {
44
+ tenant: [],
45
+ userOnly: [],
46
+ },
47
+ member: {
48
+ tenant: [
49
+ // 调用批量获取用户信息 API 所需(任选其一即可)
50
+ "contact:contact.base:readonly",
51
+ // 字段权限:返回完整用户信息
52
+ // "contact:user.assign_info:read",
53
+ "contact:user.base:readonly",
54
+ // "contact:user.department:readonly",
55
+ // "contact:user.department_path:readonly",
56
+ // "contact:user.dotted_line_leader_info.read",
57
+ // "contact:user.employee:readonly",
58
+ // "contact:user.employee_id:readonly",
59
+ // "contact:user.employee_number:read",
60
+ // "contact:user.gender:readonly",
61
+ // "contact:user.user_geo",
62
+ // "contact:user.phone:readonly",
63
+ // "contact:user.email:readonly",
64
+ // "contact:user.job_family:readonly",
65
+ // "contact:user.job_level:readonly",
66
+ ],
67
+ userOnly: [
68
+ "contact:user:search",
69
+ "contact:contact.base:readonly",
70
+ "contact:user.employee_id:readonly",
71
+ ],
72
+ },
73
+ };
74
+ /**
75
+ * 根据已启用模块和认证类型,计算所需的最小 Scope 集合
76
+ * @param enabledModules 已启用的模块 ID 列表(含 'all' 则返回全量)
77
+ * @param authType 认证类型
78
+ */
79
+ export function getRequiredScopes(enabledModules, authType) {
80
+ const moduleIds = enabledModules.includes('all')
81
+ ? Object.keys(MODULE_SCOPES)
82
+ : enabledModules;
83
+ const scopes = new Set();
84
+ for (const moduleId of moduleIds) {
85
+ const moduleScopes = MODULE_SCOPES[moduleId];
86
+ if (!moduleScopes)
87
+ continue;
88
+ moduleScopes.tenant.forEach(s => scopes.add(s));
89
+ if (authType === 'user') {
90
+ moduleScopes.userOnly.forEach(s => scopes.add(s));
91
+ }
92
+ }
93
+ return Array.from(scopes);
94
+ }
@@ -0,0 +1,47 @@
1
+ import { BaseApiService } from '../baseService.js';
2
+ import { Config } from '../../utils/config.js';
3
+ import { Logger } from '../../utils/logger.js';
4
+ import { AuthUtils } from '../../utils/auth/index.js';
5
+ import { FeishuScopeValidator } from './FeishuScopeValidator.js';
6
+ /**
7
+ * 飞书 API 服务基类
8
+ * 封装 getBaseUrl 和 getAccessToken(委托给 AuthService),供各领域服务继承
9
+ */
10
+ export class FeishuBaseApiService extends BaseApiService {
11
+ constructor(authService) {
12
+ super();
13
+ Object.defineProperty(this, "authService", {
14
+ enumerable: true,
15
+ configurable: true,
16
+ writable: true,
17
+ value: authService
18
+ });
19
+ Object.defineProperty(this, "scopeValidator", {
20
+ enumerable: true,
21
+ configurable: true,
22
+ writable: true,
23
+ value: void 0
24
+ });
25
+ this.scopeValidator = new FeishuScopeValidator();
26
+ }
27
+ getBaseUrl() {
28
+ return Config.getInstance().feishu.baseUrl;
29
+ }
30
+ async getAccessToken(userKey) {
31
+ const { appId, appSecret, authType, enableScopeValidation } = Config.getInstance().feishu;
32
+ const clientKey = AuthUtils.generateClientKey(userKey);
33
+ Logger.debug(`[FeishuBaseApiService] 获取访问令牌,authType: ${authType}, clientKey: ${clientKey}`);
34
+ if (enableScopeValidation) {
35
+ await this.scopeValidator.validateScopeWithVersion(appId, appSecret, authType, clientKey);
36
+ }
37
+ else {
38
+ Logger.debug('权限检查已禁用,跳过scope校验');
39
+ }
40
+ if (authType === 'tenant') {
41
+ return this.authService.getTenantAccessToken(appId, appSecret, clientKey);
42
+ }
43
+ else {
44
+ return this.authService.getUserAccessToken(clientKey, appId, appSecret);
45
+ }
46
+ }
47
+ }