feishu-mcp 0.0.19 → 0.1.1

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.
@@ -196,7 +196,7 @@ export function registerFeishuTools(server, feishuService) {
196
196
  }
197
197
  });
198
198
  // 添加获取画板内容工具
199
- server.tool('get_feishu_whiteboard_content', 'Retrieves the content and structure of a Feishu whiteboard. This tool fetches all nodes (elements) from a whiteboard, including shapes, text, mind maps, and other graphical elements. Use this to analyze whiteboard content, extract information, or understand the structure of collaborative diagrams. The whiteboard ID can be obtained from the board.token field when getting document blocks with block_type: 43.', {
199
+ server.tool('get_feishu_whiteboard_content', 'Retrieves the content and structure of a Feishu whiteboard. Use this to analyze whiteboard content, extract information, or understand the structure of collaborative diagrams. The whiteboard ID can be obtained from the board.token field when getting document blocks with block_type: 43.', {
200
200
  whiteboardId: WhiteboardIdSchema,
201
201
  }, async ({ whiteboardId }) => {
202
202
  try {
@@ -207,7 +207,28 @@ export function registerFeishuTools(server, feishuService) {
207
207
  }
208
208
  Logger.info(`开始获取飞书画板内容,画板ID: ${whiteboardId}`);
209
209
  const whiteboardContent = await feishuService.getWhiteboardContent(whiteboardId);
210
- Logger.info(`飞书画板内容获取成功,节点数量: ${whiteboardContent.nodes?.length || 0}`);
210
+ const nodeCount = whiteboardContent.nodes?.length || 0;
211
+ Logger.info(`飞书画板内容获取成功,节点数量: ${nodeCount}`);
212
+ // 检查节点数量是否超过100
213
+ if (nodeCount > 200) {
214
+ Logger.info(`画板节点数量过多 (${nodeCount} > 200),返回缩略图`);
215
+ try {
216
+ const thumbnailBuffer = await feishuService.getWhiteboardThumbnail(whiteboardId);
217
+ const thumbnailBase64 = thumbnailBuffer.toString('base64');
218
+ return {
219
+ content: [
220
+ {
221
+ type: 'image',
222
+ data: thumbnailBase64,
223
+ mimeType: 'image/png'
224
+ }
225
+ ],
226
+ };
227
+ }
228
+ catch (thumbnailError) {
229
+ Logger.warn(`获取画板缩略图失败,返回基本信息: ${thumbnailError}`);
230
+ }
231
+ }
211
232
  return {
212
233
  content: [{ type: 'text', text: JSON.stringify(whiteboardContent, null, 2) }],
213
234
  };
package/dist/server.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import express from 'express';
2
2
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
+ import { randomUUID } from 'node:crypto';
3
6
  import { Logger } from './utils/logger.js';
4
7
  import { SSEConnectionManager } from './manager/sseConnectionManager.js';
5
8
  import { FeishuMcp } from './mcp/feishuMcp.js';
@@ -27,6 +30,118 @@ export class FeishuMcpServer {
27
30
  }
28
31
  async startHttpServer(port) {
29
32
  const app = express();
33
+ const transports = {};
34
+ // Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint
35
+ app.use("/mcp", express.json());
36
+ app.post('/mcp', async (req, res) => {
37
+ try {
38
+ Logger.log("Received StreamableHTTP request", {
39
+ method: req.method,
40
+ url: req.url,
41
+ headers: req.headers,
42
+ body: req.body,
43
+ query: req.query,
44
+ params: req.params
45
+ });
46
+ // Check for existing session ID
47
+ const sessionId = req.headers['mcp-session-id'];
48
+ let transport;
49
+ if (sessionId && transports[sessionId]) {
50
+ // Reuse existing transport
51
+ Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId);
52
+ transport = transports[sessionId];
53
+ }
54
+ else if (!sessionId && isInitializeRequest(req.body)) {
55
+ // New initialization request
56
+ transport = new StreamableHTTPServerTransport({
57
+ sessionIdGenerator: () => randomUUID(),
58
+ onsessioninitialized: (sessionId) => {
59
+ // Store the transport by session ID
60
+ Logger.log(`[StreamableHTTP connection] ${sessionId}`);
61
+ transports[sessionId] = transport;
62
+ }
63
+ });
64
+ // Clean up transport and server when closed
65
+ transport.onclose = () => {
66
+ if (transport.sessionId) {
67
+ Logger.log(`[StreamableHTTP delete] ${transports[transport.sessionId]}`);
68
+ delete transports[transport.sessionId];
69
+ }
70
+ };
71
+ // Create and connect server instance
72
+ const server = new FeishuMcp();
73
+ await server.connect(transport);
74
+ }
75
+ else {
76
+ // Invalid request
77
+ res.status(400).json({
78
+ jsonrpc: '2.0',
79
+ error: {
80
+ code: -32000,
81
+ message: 'Bad Request: No valid session ID provided',
82
+ },
83
+ id: null,
84
+ });
85
+ return;
86
+ }
87
+ // Handle the request
88
+ await transport.handleRequest(req, res, req.body);
89
+ }
90
+ catch (error) {
91
+ console.error('Error handling MCP request:', error);
92
+ if (!res.headersSent) {
93
+ res.status(500).json({
94
+ jsonrpc: '2.0',
95
+ error: {
96
+ code: -32603,
97
+ message: 'Internal server error',
98
+ },
99
+ id: null,
100
+ });
101
+ }
102
+ }
103
+ });
104
+ // Handle GET requests for server-to-client notifications via Streamable HTTP
105
+ app.get('/mcp', async (req, res) => {
106
+ try {
107
+ Logger.log("Received StreamableHTTP request get");
108
+ const sessionId = req.headers['mcp-session-id'];
109
+ if (!sessionId || !transports[sessionId]) {
110
+ res.status(400).send('Invalid or missing session ID');
111
+ return;
112
+ }
113
+ const transport = transports[sessionId];
114
+ await transport.handleRequest(req, res);
115
+ }
116
+ catch (error) {
117
+ console.error('Error handling GET request:', error);
118
+ if (!res.headersSent) {
119
+ res.status(500).send('Internal server error');
120
+ }
121
+ }
122
+ });
123
+ // Handle DELETE requests for session termination
124
+ app.delete('/mcp', async (req, res) => {
125
+ try {
126
+ const sessionId = req.headers['mcp-session-id'];
127
+ if (!sessionId || !transports[sessionId]) {
128
+ res.status(400).send('Invalid or missing session ID');
129
+ return;
130
+ }
131
+ const transport = transports[sessionId];
132
+ await transport.handleRequest(req, res);
133
+ // Clean up resources after session termination
134
+ if (transport.sessionId) {
135
+ delete transports[transport.sessionId];
136
+ }
137
+ }
138
+ catch (error) {
139
+ console.error('Error handling DELETE request:', error);
140
+ if (!res.headersSent) {
141
+ res.status(500).send('Internal server error');
142
+ }
143
+ }
144
+ });
30
145
  app.get('/sse', async (req, res) => {
31
146
  const sseTransport = new SSEServerTransport('/messages', res);
32
147
  const sessionId = sseTransport.sessionId;
@@ -82,10 +197,11 @@ export class FeishuMcpServer {
82
197
  res.status(500).json({ code: 500, msg: e.message || '获取token失败' });
83
198
  }
84
199
  });
85
- app.listen(port, () => {
200
+ app.listen(port, '0.0.0.0', () => {
86
201
  Logger.info(`HTTP server listening on port ${port}`);
87
202
  Logger.info(`SSE endpoint available at http://localhost:${port}/sse`);
88
203
  Logger.info(`Message endpoint available at http://localhost:${port}/messages`);
204
+ Logger.info(`StreamableHTTP endpoint available at http://localhost:${port}/mcp`);
89
205
  });
90
206
  }
91
207
  }
@@ -9,6 +9,7 @@ export var BlockType;
9
9
  BlockType["HEADING"] = "heading";
10
10
  BlockType["LIST"] = "list";
11
11
  BlockType["IMAGE"] = "image";
12
+ BlockType["MERMAID"] = "mermaid";
12
13
  })(BlockType || (BlockType = {}));
13
14
  /**
14
15
  * 对齐方式枚举
@@ -75,6 +76,8 @@ export class BlockFactory {
75
76
  return this.createListBlock(options);
76
77
  case BlockType.IMAGE:
77
78
  return this.createImageBlock(options);
79
+ case BlockType.MERMAID:
80
+ return this.createMermaidBlock(options);
78
81
  default:
79
82
  Logger.error(`不支持的块类型: ${type}`);
80
83
  throw new Error(`不支持的块类型: ${type}`);
@@ -225,4 +228,22 @@ export class BlockFactory {
225
228
  }
226
229
  };
227
230
  }
231
+ /**
232
+ * 创建Mermaid
233
+ * @param options Mermaid块选项
234
+ * @returns Mermaid块内容对象
235
+ */
236
+ createMermaidBlock(options = {}) {
237
+ const { code } = options;
238
+ return {
239
+ block_type: 40,
240
+ add_ons: {
241
+ component_id: '',
242
+ component_type_id: 'blk_631fefbbae02400430b8f9f4',
243
+ record: JSON.stringify({
244
+ data: code,
245
+ }),
246
+ },
247
+ };
248
+ }
228
249
  }
@@ -382,6 +382,35 @@ export class FeishuApiService extends BaseApiService {
382
382
  });
383
383
  return this.createDocumentBlock(documentId, parentBlockId, blockContent, index);
384
384
  }
385
+ /**
386
+ * 创建Mermaid块
387
+ * @param documentId 文档ID或URL
388
+ * @param parentBlockId 父块ID
389
+ * @param mermaidCode Mermaid代码
390
+ * @param index 插入位置索引
391
+ * @returns 创建结果
392
+ */
393
+ async createMermaidBlock(documentId, parentBlockId, mermaidCode, index = 0) {
394
+ const normalizedDocId = ParamUtils.processDocumentId(documentId);
395
+ const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children?document_revision_id=-1`;
396
+ const blockContent = {
397
+ block_type: 40,
398
+ add_ons: {
399
+ component_id: "",
400
+ component_type_id: "blk_631fefbbae02400430b8f9f4",
401
+ record: JSON.stringify({
402
+ data: mermaidCode,
403
+ })
404
+ }
405
+ };
406
+ const payload = {
407
+ children: [blockContent],
408
+ index
409
+ };
410
+ Logger.info(`请求创建Mermaid块: ${JSON.stringify(payload).slice(0, 500)}...`);
411
+ const response = await this.post(endpoint, payload);
412
+ return response;
413
+ }
385
414
  /**
386
415
  * 删除文档中的块,支持批量删除
387
416
  * @param documentId 文档ID或URL
@@ -577,6 +606,14 @@ export class FeishuApiService extends BaseApiService {
577
606
  };
578
607
  }
579
608
  break;
609
+ case BlockType.MERMAID:
610
+ if ('mermaid' in options && options.mermaid) {
611
+ const mermaidOptions = options.mermaid;
612
+ blockConfig.options = {
613
+ code: mermaidOptions.code,
614
+ };
615
+ }
616
+ break;
580
617
  default:
581
618
  Logger.warn(`未知的块类型: ${blockType},尝试作为标准类型处理`);
582
619
  if ('text' in options) {
@@ -640,6 +677,13 @@ export class FeishuApiService extends BaseApiService {
640
677
  height: imageOptions.height || 100
641
678
  };
642
679
  }
680
+ else if ("mermaid" in options) {
681
+ blockConfig.type = BlockType.MERMAID;
682
+ const mermaidConfig = options.mermaid;
683
+ blockConfig.options = {
684
+ code: mermaidConfig.code,
685
+ };
686
+ }
643
687
  break;
644
688
  }
645
689
  // 记录调试信息
@@ -955,15 +999,7 @@ export class FeishuApiService extends BaseApiService {
955
999
  */
956
1000
  async getWhiteboardContent(whiteboardId) {
957
1001
  try {
958
- // 从URL中提取画板ID
959
- let normalizedWhiteboardId = whiteboardId;
960
- if (whiteboardId.includes('feishu.cn/board/')) {
961
- // 从URL中提取画板ID
962
- const matches = whiteboardId.match(/board\/([^\/\?]+)/);
963
- if (matches) {
964
- normalizedWhiteboardId = matches[1];
965
- }
966
- }
1002
+ const normalizedWhiteboardId = ParamUtils.processWhiteboardId(whiteboardId);
967
1003
  const endpoint = `/board/v1/whiteboards/${normalizedWhiteboardId}/nodes`;
968
1004
  Logger.info(`开始获取画板内容,画板ID: ${normalizedWhiteboardId}`);
969
1005
  const response = await this.get(endpoint);
@@ -974,6 +1010,27 @@ export class FeishuApiService extends BaseApiService {
974
1010
  this.handleApiError(error, '获取画板内容失败');
975
1011
  }
976
1012
  }
1013
+ /**
1014
+ * 获取画板缩略图
1015
+ * @param whiteboardId 画板ID或URL
1016
+ * @returns 画板缩略图的二进制数据
1017
+ */
1018
+ async getWhiteboardThumbnail(whiteboardId) {
1019
+ try {
1020
+ const normalizedWhiteboardId = ParamUtils.processWhiteboardId(whiteboardId);
1021
+ const endpoint = `/board/v1/whiteboards/${normalizedWhiteboardId}/download_as_image`;
1022
+ Logger.info(`开始获取画板缩略图,画板ID: ${normalizedWhiteboardId}`);
1023
+ // 使用通用的request方法获取二进制响应
1024
+ const response = await this.request(endpoint, 'GET', {}, true, {}, 'arraybuffer');
1025
+ const thumbnailBuffer = Buffer.from(response);
1026
+ Logger.info(`画板缩略图获取成功,大小: ${thumbnailBuffer.length} 字节`);
1027
+ return thumbnailBuffer;
1028
+ }
1029
+ catch (error) {
1030
+ this.handleApiError(error, '获取画板缩略图失败');
1031
+ return Buffer.from([]); // 永远不会执行到这里
1032
+ }
1033
+ }
977
1034
  /**
978
1035
  * 从路径或URL获取图片的Base64编码
979
1036
  * @param imagePathOrUrl 图片路径或URL
@@ -103,7 +103,7 @@ 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', as well as 'heading1' through 'heading9'. " +
106
+ export const BlockTypeEnum = z.string().describe("Block type (required). Supports: 'text', 'code', 'heading', 'list', 'image','mermaid',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
109
  "For text blocks, you can include both regular text and equation elements in the same block.");
@@ -116,6 +116,14 @@ export const ImageBlockSchema = z.object({
116
116
  width: ImageWidthSchema,
117
117
  height: ImageHeightSchema
118
118
  });
119
+ // Mermaid代码参数定义
120
+ export const MermaidCodeSchema = z.string().describe('Mermaid code (required). The complete Mermaid chart code, e.g. \'graph TD; A-->B;\'. ' +
121
+ 'IMPORTANT: When node text contains special characters like parentheses (), brackets [], or arrows -->, ' +
122
+ 'wrap the entire text in double quotes to prevent parsing errors. ' +
123
+ 'Example: A["finish()/返回键"] instead of A[finish()/返回键].');
124
+ export const MermaidBlockSchema = z.object({
125
+ code: MermaidCodeSchema,
126
+ });
119
127
  // 块配置定义 - 用于批量创建块工具
120
128
  export const BlockConfigSchema = z.object({
121
129
  blockType: BlockTypeEnum,
@@ -125,6 +133,7 @@ export const BlockConfigSchema = z.object({
125
133
  z.object({ heading: HeadingBlockSchema }).describe("Heading block options. Used with both 'heading' and 'headingN' formats."),
126
134
  z.object({ list: ListBlockSchema }).describe("List block options. Used when blockType is 'list'."),
127
135
  z.object({ image: ImageBlockSchema }).describe("Image block options. Used when blockType is 'image'. Creates empty image blocks."),
136
+ z.object({ mermaid: MermaidBlockSchema }).describe("Mermaid block options. Used when blockType is 'mermaid'."),
128
137
  z.record(z.any()).describe("Fallback for any other block options")
129
138
  ]).describe('Options for the specific block type. Provide the corresponding options object based on blockType.'),
130
139
  });
@@ -136,127 +136,127 @@ export function renderFeishuAuthResultHtml(data) {
136
136
  expiresIn = expiresIn - now;
137
137
  if (refreshExpiresIn && refreshExpiresIn > 1000000000)
138
138
  refreshExpiresIn = refreshExpiresIn - now;
139
- const tokenBlock = data && !isError ? `
140
- <div class="card">
141
- <h3>Token 信息</h3>
142
- <ul class="kv-list">
143
- <li><b>token_type:</b> <span>${data.token_type || ''}</span></li>
144
- <li><b>access_token:</b> <span class="foldable" onclick="toggleFold(this)">点击展开/收起</span><pre class="fold scrollable">${data.access_token || ''}</pre></li>
145
- <li><b>expires_in:</b> <span>${formatExpire(expiresIn)}</span></li>
146
- <li><b>refresh_token:</b> <span class="foldable" onclick="toggleFold(this)">点击展开/收起</span><pre class="fold scrollable">${data.refresh_token || ''}</pre></li>
147
- <li><b>refresh_token_expires_in:</b> <span>${formatExpire(refreshExpiresIn)}</span></li>
148
- <li><b>scope:</b> <pre class="scope">${(data.scope || '').replace(/ /g, '\n')}</pre></li>
149
- </ul>
150
- <div class="success-action">
151
- <span class="success-msg">授权成功,继续完成任务</span>
152
- <button class="copy-btn" onclick="copySuccessMsg(this)">点击复制到粘贴板</button>
153
- </div>
154
- </div>
139
+ const tokenBlock = data && !isError ? `
140
+ <div class="card">
141
+ <h3>Token 信息</h3>
142
+ <ul class="kv-list">
143
+ <li><b>token_type:</b> <span>${data.token_type || ''}</span></li>
144
+ <li><b>access_token:</b> <span class="foldable" onclick="toggleFold(this)">点击展开/收起</span><pre class="fold scrollable">${data.access_token || ''}</pre></li>
145
+ <li><b>expires_in:</b> <span>${formatExpire(expiresIn)}</span></li>
146
+ <li><b>refresh_token:</b> <span class="foldable" onclick="toggleFold(this)">点击展开/收起</span><pre class="fold scrollable">${data.refresh_token || ''}</pre></li>
147
+ <li><b>refresh_token_expires_in:</b> <span>${formatExpire(refreshExpiresIn)}</span></li>
148
+ <li><b>scope:</b> <pre class="scope">${(data.scope || '').replace(/ /g, '\n')}</pre></li>
149
+ </ul>
150
+ <div class="success-action">
151
+ <span class="success-msg">授权成功,继续完成任务</span>
152
+ <button class="copy-btn" onclick="copySuccessMsg(this)">点击复制到粘贴板</button>
153
+ </div>
154
+ </div>
155
155
  ` : '';
156
156
  let userBlock = '';
157
157
  const userInfo = data && data.userInfo && data.userInfo.data;
158
158
  if (userInfo) {
159
- userBlock = `
160
- <div class="card user-card">
161
- <div class="avatar-wrap">
162
- <img src="${userInfo.avatar_big || userInfo.avatar_thumb || userInfo.avatar_url || ''}" class="avatar" />
163
- </div>
164
- <div class="user-info">
165
- <div class="user-name">${userInfo.name || ''}</div>
166
- <div class="user-en">${userInfo.en_name || ''}</div>
167
- </div>
168
- </div>
159
+ userBlock = `
160
+ <div class="card user-card">
161
+ <div class="avatar-wrap">
162
+ <img src="${userInfo.avatar_big || userInfo.avatar_thumb || userInfo.avatar_url || ''}" class="avatar" />
163
+ </div>
164
+ <div class="user-info">
165
+ <div class="user-name">${userInfo.name || ''}</div>
166
+ <div class="user-en">${userInfo.en_name || ''}</div>
167
+ </div>
168
+ </div>
169
169
  `;
170
170
  }
171
- const errorBlock = isError ? `
172
- <div class="card error-card">
173
- <h3>授权失败</h3>
174
- <div class="error-msg">${escapeHtml(data.error || '')}</div>
175
- <div class="error-code">错误码: ${data.code || ''}</div>
176
- </div>
171
+ const errorBlock = isError ? `
172
+ <div class="card error-card">
173
+ <h3>授权失败</h3>
174
+ <div class="error-msg">${escapeHtml(data.error || '')}</div>
175
+ <div class="error-code">错误码: ${data.code || ''}</div>
176
+ </div>
177
177
  ` : '';
178
- return `
179
- <html>
180
- <head>
181
- <title>飞书授权结果</title>
182
- <meta charset="utf-8"/>
183
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
184
- <style>
185
- body { background: #f7f8fa; font-family: 'Segoe UI', Arial, sans-serif; margin:0; padding:0; }
186
- .container { max-width: 600px; margin: 40px auto; padding: 16px; }
187
- .card { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px #0001; margin-bottom: 24px; padding: 24px 20px; }
188
- .user-card { display: flex; align-items: center; gap: 24px; }
189
- .avatar-wrap { flex-shrink: 0; }
190
- .avatar { width: 96px; height: 96px; border-radius: 50%; box-shadow: 0 2px 8px #0002; display: block; margin: 0 auto; }
191
- .user-info { flex: 1; }
192
- .user-name { font-size: 1.5em; font-weight: bold; margin-bottom: 4px; }
193
- .user-en { color: #888; margin-bottom: 10px; }
194
- .kv-list { list-style: none; padding: 0; margin: 0; }
195
- .kv-list li { margin-bottom: 6px; word-break: break-all; }
196
- .kv-list b { color: #1976d2; }
197
- .scope { background: #f0f4f8; border-radius: 4px; padding: 6px; font-size: 0.95em; white-space: pre-line; }
198
- .foldable { color: #1976d2; cursor: pointer; text-decoration: underline; margin-left: 8px; }
199
- .fold { display: none; background: #f6f6f6; border-radius: 4px; padding: 6px; margin: 4px 0; font-size: 0.92em; max-width: 100%; overflow-x: auto; word-break: break-all; }
200
- .scrollable { max-width: 100%; overflow-x: auto; font-family: 'Fira Mono', 'Consolas', 'Menlo', monospace; font-size: 0.93em; }
201
- .success-action { margin-top: 18px; display: flex; align-items: center; gap: 16px; }
202
- .success-msg { color: #388e3c; font-weight: bold; }
203
- .copy-btn { background: #1976d2; color: #fff; border: none; border-radius: 4px; padding: 6px 16px; font-size: 1em; cursor: pointer; transition: background 0.2s; }
204
- .copy-btn:hover { background: #125ea2; }
205
- .error-card { border-left: 6px solid #e53935; background: #fff0f0; color: #b71c1c; }
206
- .error-msg { font-size: 1.1em; margin-bottom: 8px; }
207
- .error-code { color: #b71c1c; font-size: 0.95em; }
208
- .raw-block { margin-top: 24px; }
209
- .raw-toggle { color: #1976d2; cursor: pointer; text-decoration: underline; margin-bottom: 8px; display: inline-block; }
210
- .raw-pre { display: none; background: #23272e; color: #fff; border-radius: 6px; padding: 12px; font-size: 0.95em; overflow-x: auto; max-width: 100%; }
211
- @media (max-width: 700px) {
212
- .container { max-width: 98vw; padding: 4vw; }
213
- .card { padding: 4vw 3vw; }
214
- .avatar { width: 64px; height: 64px; }
215
- }
216
- </style>
217
- <script>
218
- function toggleFold(el) {
219
- var pre = el.nextElementSibling;
220
- if (pre.style.display === 'block') {
221
- pre.style.display = 'none';
222
- } else {
223
- pre.style.display = 'block';
224
- }
225
- }
226
- function toggleRaw() {
227
- var pre = document.getElementById('raw-pre');
228
- if (pre.style.display === 'block') {
229
- pre.style.display = 'none';
230
- } else {
231
- pre.style.display = 'block';
232
- }
233
- }
234
- function copySuccessMsg(btn) {
235
- var text = '授权成功,继续完成任务';
236
- navigator.clipboard.writeText(text).then(function() {
237
- btn.innerText = '已复制';
238
- btn.disabled = true;
239
- setTimeout(function() {
240
- btn.innerText = '点击复制到粘贴板';
241
- btn.disabled = false;
242
- }, 2000);
243
- });
244
- }
245
- </script>
246
- </head>
247
- <body>
248
- <div class="container">
249
- <h2 style="margin-bottom:24px;">飞书授权结果</h2>
250
- ${errorBlock}
251
- ${tokenBlock}
252
- ${userBlock}
253
- <div class="card raw-block">
254
- <span class="raw-toggle" onclick="toggleRaw()">点击展开/收起原始数据</span>
255
- <pre id="raw-pre" class="raw-pre">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
256
- </div>
257
- </div>
258
- </body>
259
- </html>
178
+ return `
179
+ <html>
180
+ <head>
181
+ <title>飞书授权结果</title>
182
+ <meta charset="utf-8"/>
183
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
184
+ <style>
185
+ body { background: #f7f8fa; font-family: 'Segoe UI', Arial, sans-serif; margin:0; padding:0; }
186
+ .container { max-width: 600px; margin: 40px auto; padding: 16px; }
187
+ .card { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px #0001; margin-bottom: 24px; padding: 24px 20px; }
188
+ .user-card { display: flex; align-items: center; gap: 24px; }
189
+ .avatar-wrap { flex-shrink: 0; }
190
+ .avatar { width: 96px; height: 96px; border-radius: 50%; box-shadow: 0 2px 8px #0002; display: block; margin: 0 auto; }
191
+ .user-info { flex: 1; }
192
+ .user-name { font-size: 1.5em; font-weight: bold; margin-bottom: 4px; }
193
+ .user-en { color: #888; margin-bottom: 10px; }
194
+ .kv-list { list-style: none; padding: 0; margin: 0; }
195
+ .kv-list li { margin-bottom: 6px; word-break: break-all; }
196
+ .kv-list b { color: #1976d2; }
197
+ .scope { background: #f0f4f8; border-radius: 4px; padding: 6px; font-size: 0.95em; white-space: pre-line; }
198
+ .foldable { color: #1976d2; cursor: pointer; text-decoration: underline; margin-left: 8px; }
199
+ .fold { display: none; background: #f6f6f6; border-radius: 4px; padding: 6px; margin: 4px 0; font-size: 0.92em; max-width: 100%; overflow-x: auto; word-break: break-all; }
200
+ .scrollable { max-width: 100%; overflow-x: auto; font-family: 'Fira Mono', 'Consolas', 'Menlo', monospace; font-size: 0.93em; }
201
+ .success-action { margin-top: 18px; display: flex; align-items: center; gap: 16px; }
202
+ .success-msg { color: #388e3c; font-weight: bold; }
203
+ .copy-btn { background: #1976d2; color: #fff; border: none; border-radius: 4px; padding: 6px 16px; font-size: 1em; cursor: pointer; transition: background 0.2s; }
204
+ .copy-btn:hover { background: #125ea2; }
205
+ .error-card { border-left: 6px solid #e53935; background: #fff0f0; color: #b71c1c; }
206
+ .error-msg { font-size: 1.1em; margin-bottom: 8px; }
207
+ .error-code { color: #b71c1c; font-size: 0.95em; }
208
+ .raw-block { margin-top: 24px; }
209
+ .raw-toggle { color: #1976d2; cursor: pointer; text-decoration: underline; margin-bottom: 8px; display: inline-block; }
210
+ .raw-pre { display: none; background: #23272e; color: #fff; border-radius: 6px; padding: 12px; font-size: 0.95em; overflow-x: auto; max-width: 100%; }
211
+ @media (max-width: 700px) {
212
+ .container { max-width: 98vw; padding: 4vw; }
213
+ .card { padding: 4vw 3vw; }
214
+ .avatar { width: 64px; height: 64px; }
215
+ }
216
+ </style>
217
+ <script>
218
+ function toggleFold(el) {
219
+ var pre = el.nextElementSibling;
220
+ if (pre.style.display === 'block') {
221
+ pre.style.display = 'none';
222
+ } else {
223
+ pre.style.display = 'block';
224
+ }
225
+ }
226
+ function toggleRaw() {
227
+ var pre = document.getElementById('raw-pre');
228
+ if (pre.style.display === 'block') {
229
+ pre.style.display = 'none';
230
+ } else {
231
+ pre.style.display = 'block';
232
+ }
233
+ }
234
+ function copySuccessMsg(btn) {
235
+ var text = '授权成功,继续完成任务';
236
+ navigator.clipboard.writeText(text).then(function() {
237
+ btn.innerText = '已复制';
238
+ btn.disabled = true;
239
+ setTimeout(function() {
240
+ btn.innerText = '点击复制到粘贴板';
241
+ btn.disabled = false;
242
+ }, 2000);
243
+ });
244
+ }
245
+ </script>
246
+ </head>
247
+ <body>
248
+ <div class="container">
249
+ <h2 style="margin-bottom:24px;">飞书授权结果</h2>
250
+ ${errorBlock}
251
+ ${tokenBlock}
252
+ ${userBlock}
253
+ <div class="card raw-block">
254
+ <span class="raw-toggle" onclick="toggleRaw()">点击展开/收起原始数据</span>
255
+ <pre id="raw-pre" class="raw-pre">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
256
+ </div>
257
+ </div>
258
+ </body>
259
+ </html>
260
260
  `;
261
261
  }
262
262
  function escapeHtml(str) {
@@ -159,6 +159,44 @@ export class ParamUtils {
159
159
  // 限制在1-9范围内
160
160
  return Math.max(1, Math.min(9, level));
161
161
  }
162
+ /**
163
+ * 处理画板ID参数
164
+ * 验证并规范化画板ID,支持从URL中提取
165
+ *
166
+ * @param whiteboardId 画板ID或URL
167
+ * @returns 规范化的画板ID
168
+ * @throws 如果画板ID无效则抛出错误
169
+ */
170
+ static processWhiteboardId(whiteboardId) {
171
+ if (!whiteboardId) {
172
+ throw new ParamValidationError('whiteboardId', '画板ID不能为空');
173
+ }
174
+ try {
175
+ // 从URL中提取画板ID
176
+ let normalizedWhiteboardId = whiteboardId;
177
+ if (whiteboardId.includes('feishu.cn/board/')) {
178
+ // 从URL中提取画板ID
179
+ const matches = whiteboardId.match(/board\/([^\/\?]+)/);
180
+ if (matches) {
181
+ normalizedWhiteboardId = matches[1];
182
+ }
183
+ else {
184
+ throw new ParamValidationError('whiteboardId', '无法从URL中提取画板ID');
185
+ }
186
+ }
187
+ // 验证画板ID格式(基本格式检查)
188
+ if (!/^[a-zA-Z0-9_-]{5,}$/.test(normalizedWhiteboardId)) {
189
+ throw new ParamValidationError('whiteboardId', '画板ID格式无效');
190
+ }
191
+ return normalizedWhiteboardId;
192
+ }
193
+ catch (error) {
194
+ if (error instanceof ParamValidationError) {
195
+ throw error;
196
+ }
197
+ throw new ParamValidationError('whiteboardId', formatErrorMessage(error));
198
+ }
199
+ }
162
200
  /**
163
201
  * 批量处理通用参数
164
202
  * 验证并规范化常用参数集