feishu-mcp 0.0.16 → 0.0.18
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/dist/cli.js +0 -0
- package/dist/mcp/tools/feishuBlockTools.js +252 -202
- package/dist/mcp/tools/feishuFolderTools.js +23 -19
- package/dist/mcp/tools/feishuTools.js +68 -54
- package/dist/server.js +20 -0
- package/dist/services/baseService.js +0 -10
- package/dist/services/callbackService.js +80 -0
- package/dist/services/feishuApiService.js +19 -35
- package/dist/services/feishuAuthService.js +185 -0
- package/dist/types/feishuSchema.js +15 -6
- package/dist/utils/cache.js +96 -15
- package/dist/utils/config.js +39 -7
- package/dist/utils/document.js +154 -0
- package/package.json +1 -1
- package/dist/config.js +0 -26
- package/dist/services/feishu.js +0 -495
- package/dist/services/feishuBlockService.js +0 -179
- package/dist/services/feishuBlocks.js +0 -135
- package/dist/services/feishuService.js +0 -475
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
1
|
+
// import { z } from 'zod';
|
|
2
2
|
import { formatErrorMessage } from '../../utils/error.js';
|
|
3
3
|
import { Logger } from '../../utils/logger.js';
|
|
4
|
-
import { DocumentIdSchema,
|
|
4
|
+
import { DocumentIdSchema,
|
|
5
|
+
// BlockIdSchema,
|
|
6
|
+
SearchKeySchema, WhiteboardIdSchema, DocumentTitleSchema, FolderTokenSchema, } from '../../types/feishuSchema.js';
|
|
5
7
|
/**
|
|
6
8
|
* 注册飞书相关的MCP工具
|
|
7
9
|
* @param server MCP服务器实例
|
|
@@ -10,8 +12,8 @@ import { DocumentIdSchema, BlockIdSchema, SearchKeySchema, WhiteboardIdSchema, }
|
|
|
10
12
|
export function registerFeishuTools(server, feishuService) {
|
|
11
13
|
// 添加创建飞书文档工具
|
|
12
14
|
server.tool('create_feishu_document', 'Creates a new Feishu document and returns its information. Use this tool when you need to create a document from scratch with a specific title and folder location.', {
|
|
13
|
-
title:
|
|
14
|
-
folderToken:
|
|
15
|
+
title: DocumentTitleSchema,
|
|
16
|
+
folderToken: FolderTokenSchema,
|
|
15
17
|
}, async ({ title, folderToken }) => {
|
|
16
18
|
try {
|
|
17
19
|
Logger.info(`开始创建飞书文档,标题: ${title}${folderToken ? `,文件夹Token: ${folderToken}` : ',使用默认文件夹'}`);
|
|
@@ -58,31 +60,37 @@ export function registerFeishuTools(server, feishuService) {
|
|
|
58
60
|
}
|
|
59
61
|
});
|
|
60
62
|
// 添加获取飞书文档内容工具
|
|
61
|
-
server.tool(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
63
|
+
// server.tool(
|
|
64
|
+
// 'get_feishu_document_content',
|
|
65
|
+
// 'Retrieves the plain text content of a Feishu document. Ideal for content analysis, processing, or when you need to extract text without formatting. The content maintains the document structure but without styling. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.',
|
|
66
|
+
// {
|
|
67
|
+
// documentId: DocumentIdSchema,
|
|
68
|
+
// lang: z.number().optional().default(0).describe('Language code (optional). Default is 0 (Chinese). Use 1 for English if available.'),
|
|
69
|
+
// },
|
|
70
|
+
// async ({ documentId, lang }) => {
|
|
71
|
+
// try {
|
|
72
|
+
// if (!feishuService) {
|
|
73
|
+
// return {
|
|
74
|
+
// content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }],
|
|
75
|
+
// };
|
|
76
|
+
// }
|
|
77
|
+
//
|
|
78
|
+
// Logger.info(`开始获取飞书文档内容,文档ID: ${documentId},语言: ${lang}`);
|
|
79
|
+
// const content = await feishuService.getDocumentContent(documentId, lang);
|
|
80
|
+
// Logger.info(`飞书文档内容获取成功,内容长度: ${content.length}字符`);
|
|
81
|
+
//
|
|
82
|
+
// return {
|
|
83
|
+
// content: [{ type: 'text', text: content }],
|
|
84
|
+
// };
|
|
85
|
+
// } catch (error) {
|
|
86
|
+
// Logger.error(`获取飞书文档内容失败:`, error);
|
|
87
|
+
// const errorMessage = formatErrorMessage(error);
|
|
88
|
+
// return {
|
|
89
|
+
// content: [{ type: 'text', text: `获取飞书文档内容失败: ${errorMessage}` }],
|
|
90
|
+
// };
|
|
91
|
+
// }
|
|
92
|
+
// },
|
|
93
|
+
// );
|
|
86
94
|
// 添加获取飞书文档块工具
|
|
87
95
|
server.tool('get_feishu_document_blocks', 'Retrieves the block structure information of a Feishu document. Essential to use before inserting content to understand document structure and determine correct insertion positions. Returns a detailed hierarchy of blocks with their IDs, types, and content. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.', {
|
|
88
96
|
documentId: DocumentIdSchema,
|
|
@@ -127,31 +135,37 @@ export function registerFeishuTools(server, feishuService) {
|
|
|
127
135
|
}
|
|
128
136
|
});
|
|
129
137
|
// 添加获取块内容工具
|
|
130
|
-
server.tool(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
138
|
+
// server.tool(
|
|
139
|
+
// 'get_feishu_block_content',
|
|
140
|
+
// 'Retrieves the detailed content and structure of a specific block in a Feishu document. Useful for inspecting block properties, formatting, and content, especially before making updates or for debugging purposes. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.',
|
|
141
|
+
// {
|
|
142
|
+
// documentId: DocumentIdSchema,
|
|
143
|
+
// blockId: BlockIdSchema,
|
|
144
|
+
// },
|
|
145
|
+
// async ({ documentId, blockId }) => {
|
|
146
|
+
// try {
|
|
147
|
+
// if (!feishuService) {
|
|
148
|
+
// return {
|
|
149
|
+
// content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }],
|
|
150
|
+
// };
|
|
151
|
+
// }
|
|
152
|
+
//
|
|
153
|
+
// Logger.info(`开始获取飞书块内容,文档ID: ${documentId},块ID: ${blockId}`);
|
|
154
|
+
// const blockContent = await feishuService.getBlockContent(documentId, blockId);
|
|
155
|
+
// Logger.info(`飞书块内容获取成功,块类型: ${blockContent.block_type}`);
|
|
156
|
+
//
|
|
157
|
+
// return {
|
|
158
|
+
// content: [{ type: 'text', text: JSON.stringify(blockContent, null, 2) }],
|
|
159
|
+
// };
|
|
160
|
+
// } catch (error) {
|
|
161
|
+
// Logger.error(`获取飞书块内容失败:`, error);
|
|
162
|
+
// const errorMessage = formatErrorMessage(error);
|
|
163
|
+
// return {
|
|
164
|
+
// content: [{ type: 'text', text: `获取飞书块内容失败: ${errorMessage}` }],
|
|
165
|
+
// };
|
|
166
|
+
// }
|
|
167
|
+
// },
|
|
168
|
+
// );
|
|
155
169
|
// 添加搜索文档工具
|
|
156
170
|
server.tool('search_feishu_documents', 'Searches for documents in Feishu. Supports keyword-based search and returns document information including title, type, and owner. Use this tool to find specific content or related documents in your document library.', {
|
|
157
171
|
searchKey: SearchKeySchema,
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
|
3
3
|
import { Logger } from './utils/logger.js';
|
|
4
4
|
import { SSEConnectionManager } from './manager/sseConnectionManager.js';
|
|
5
5
|
import { FeishuMcp } from './mcp/feishuMcp.js';
|
|
6
|
+
import { callback, getTokenByParams } from './services/callbackService.js';
|
|
6
7
|
export class FeishuMcpServer {
|
|
7
8
|
constructor() {
|
|
8
9
|
Object.defineProperty(this, "connectionManager", {
|
|
@@ -62,6 +63,25 @@ export class FeishuMcpServer {
|
|
|
62
63
|
}
|
|
63
64
|
await transport.handlePostMessage(req, res);
|
|
64
65
|
});
|
|
66
|
+
app.get('/callback', callback);
|
|
67
|
+
app.get('/getToken', async (req, res) => {
|
|
68
|
+
const { client_id, client_secret, token_type } = req.query;
|
|
69
|
+
if (!client_id || !client_secret) {
|
|
70
|
+
res.status(400).json({ code: 400, msg: '缺少 client_id 或 client_secret' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const tokenResult = await getTokenByParams({
|
|
75
|
+
client_id: client_id,
|
|
76
|
+
client_secret: client_secret,
|
|
77
|
+
token_type: token_type
|
|
78
|
+
});
|
|
79
|
+
res.json({ code: 0, msg: 'success', data: tokenResult });
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
res.status(500).json({ code: 500, msg: e.message || '获取token失败' });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
65
85
|
app.listen(port, () => {
|
|
66
86
|
Logger.info(`HTTP server listening on port ${port}`);
|
|
67
87
|
Logger.info(`SSE endpoint available at http://localhost:${port}/sse`);
|
|
@@ -21,16 +21,6 @@ export class BaseApiService {
|
|
|
21
21
|
value: null
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
* 检查访问令牌是否过期
|
|
26
|
-
* @returns 是否过期
|
|
27
|
-
*/
|
|
28
|
-
isTokenExpired() {
|
|
29
|
-
if (!this.accessToken || !this.tokenExpireTime)
|
|
30
|
-
return true;
|
|
31
|
-
// 预留5分钟的缓冲时间
|
|
32
|
-
return Date.now() >= (this.tokenExpireTime - 5 * 60 * 1000);
|
|
33
|
-
}
|
|
34
24
|
/**
|
|
35
25
|
* 处理API错误
|
|
36
26
|
* @param error 错误对象
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AuthService } from './feishuAuthService.js';
|
|
2
|
+
import { Config } from '../utils/config.js';
|
|
3
|
+
import { CacheManager } from '../utils/cache.js';
|
|
4
|
+
import { renderFeishuAuthResultHtml } from '../utils/document.js';
|
|
5
|
+
// 通用响应码
|
|
6
|
+
const CODE = {
|
|
7
|
+
SUCCESS: 0,
|
|
8
|
+
PARAM_ERROR: 400,
|
|
9
|
+
CUSTOM: 500,
|
|
10
|
+
};
|
|
11
|
+
// 封装响应方法
|
|
12
|
+
function sendSuccess(res, data) {
|
|
13
|
+
const html = renderFeishuAuthResultHtml(data);
|
|
14
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
15
|
+
res.status(200).send(html);
|
|
16
|
+
}
|
|
17
|
+
function sendFail(res, msg, code = CODE.CUSTOM) {
|
|
18
|
+
const html = renderFeishuAuthResultHtml({ error: msg, code });
|
|
19
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
20
|
+
res.status(200).send(html);
|
|
21
|
+
}
|
|
22
|
+
const authService = new AuthService();
|
|
23
|
+
const config = Config.getInstance();
|
|
24
|
+
export async function callback(req, res) {
|
|
25
|
+
const code = req.query.code;
|
|
26
|
+
const state = req.query.state;
|
|
27
|
+
console.log(`[callback] query:`, req.query);
|
|
28
|
+
if (!code) {
|
|
29
|
+
console.log('[callback] 缺少code参数');
|
|
30
|
+
return sendFail(res, '缺少code参数', CODE.PARAM_ERROR);
|
|
31
|
+
}
|
|
32
|
+
// 校验state(clientKey)
|
|
33
|
+
const client_id = config.feishu.appId;
|
|
34
|
+
const client_secret = config.feishu.appSecret;
|
|
35
|
+
const expectedClientKey = await CacheManager.getClientKey(client_id, client_secret);
|
|
36
|
+
if (state !== expectedClientKey) {
|
|
37
|
+
console.log('[callback] state(clientKey)不匹配');
|
|
38
|
+
return sendFail(res, 'state(clientKey)不匹配', CODE.PARAM_ERROR);
|
|
39
|
+
}
|
|
40
|
+
const redirect_uri = `http://localhost:${config.server.port}/callback`;
|
|
41
|
+
const session = req.session;
|
|
42
|
+
const code_verifier = session?.code_verifier || undefined;
|
|
43
|
+
try {
|
|
44
|
+
// 获取 user_access_token
|
|
45
|
+
const tokenResp = await authService.getUserTokenByCode({
|
|
46
|
+
client_id,
|
|
47
|
+
client_secret,
|
|
48
|
+
code,
|
|
49
|
+
redirect_uri,
|
|
50
|
+
code_verifier
|
|
51
|
+
});
|
|
52
|
+
const data = (tokenResp && typeof tokenResp === 'object') ? tokenResp : undefined;
|
|
53
|
+
console.log('[callback] feishu response:', data);
|
|
54
|
+
if (!data || data.code !== 0 || !data.access_token) {
|
|
55
|
+
return sendFail(res, `获取 access_token 失败,飞书返回: ${JSON.stringify(tokenResp)}`, CODE.CUSTOM);
|
|
56
|
+
}
|
|
57
|
+
// 获取用户信息
|
|
58
|
+
const access_token = data.access_token;
|
|
59
|
+
let userInfo = null;
|
|
60
|
+
if (access_token) {
|
|
61
|
+
userInfo = await authService.getUserInfo(access_token);
|
|
62
|
+
console.log('[callback] feishu userInfo:', userInfo);
|
|
63
|
+
}
|
|
64
|
+
return sendSuccess(res, { ...data, userInfo });
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
console.error('[callback] 请求飞书token或用户信息失败:', e);
|
|
68
|
+
return sendFail(res, `请求飞书token或用户信息失败: ${e}`, CODE.CUSTOM);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function getTokenByParams({ client_id, client_secret, token_type }) {
|
|
72
|
+
const authService = new AuthService();
|
|
73
|
+
if (client_id)
|
|
74
|
+
authService.config.feishu.appId = client_id;
|
|
75
|
+
if (client_secret)
|
|
76
|
+
authService.config.feishu.appSecret = client_secret;
|
|
77
|
+
if (token_type)
|
|
78
|
+
authService.config.feishu.authType = token_type === 'user' ? 'user' : 'tenant';
|
|
79
|
+
return await authService.getToken();
|
|
80
|
+
}
|
|
@@ -76,41 +76,25 @@ export class FeishuApiService extends BaseApiService {
|
|
|
76
76
|
Logger.debug('使用缓存的访问令牌');
|
|
77
77
|
return cachedToken;
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
Logger.debug(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
throw new Error(`获取飞书访问令牌失败:${response.data.msg || '未知错误'} (错误码: ${response.data.code})`);
|
|
99
|
-
}
|
|
100
|
-
if (!response.data.tenant_access_token) {
|
|
101
|
-
throw new Error('获取飞书访问令牌失败:响应中没有token');
|
|
102
|
-
}
|
|
103
|
-
this.accessToken = response.data.tenant_access_token;
|
|
104
|
-
this.tokenExpireTime = Date.now() + Math.min(response.data.expire * 1000, this.config.feishu.tokenLifetime);
|
|
105
|
-
// 缓存令牌
|
|
106
|
-
this.cacheManager.cacheToken(this.accessToken, response.data.expire);
|
|
107
|
-
Logger.info(`成功获取新的飞书访问令牌,有效期: ${response.data.expire} 秒`);
|
|
108
|
-
return this.accessToken;
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
Logger.error('获取访问令牌失败:', error);
|
|
112
|
-
this.handleApiError(error, '获取飞书访问令牌失败');
|
|
113
|
-
}
|
|
79
|
+
// 通过HTTP请求调用配置的tokenEndpoint接口
|
|
80
|
+
const { appId, appSecret, authType, tokenEndpoint } = this.config.feishu;
|
|
81
|
+
const params = new URLSearchParams({
|
|
82
|
+
client_id: appId,
|
|
83
|
+
client_secret: appSecret,
|
|
84
|
+
token_type: authType
|
|
85
|
+
});
|
|
86
|
+
const url = `${tokenEndpoint}?${params.toString()}`;
|
|
87
|
+
const response = await axios.get(url);
|
|
88
|
+
const tokenResult = response.data?.data;
|
|
89
|
+
if (tokenResult && tokenResult.access_token) {
|
|
90
|
+
Logger.debug('使用Http的访问令牌');
|
|
91
|
+
CacheManager.getInstance().cacheToken(tokenResult.access_token, tokenResult.expires_in);
|
|
92
|
+
return tokenResult.access_token;
|
|
93
|
+
}
|
|
94
|
+
if (tokenResult && tokenResult.needAuth && tokenResult.url) {
|
|
95
|
+
throw new Error(`请在浏览器打开以下链接进行授权:\n\n[点击授权](${tokenResult.url})`);
|
|
96
|
+
}
|
|
97
|
+
throw new Error('无法获取有效的access_token');
|
|
114
98
|
}
|
|
115
99
|
/**
|
|
116
100
|
* 创建飞书文档
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { Config } from '../utils/config.js';
|
|
3
|
+
import { CacheManager } from '../utils/cache.js';
|
|
4
|
+
import { Logger } from '../utils/logger.js';
|
|
5
|
+
export class AuthService {
|
|
6
|
+
constructor() {
|
|
7
|
+
Object.defineProperty(this, "config", {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
value: Config.getInstance()
|
|
12
|
+
});
|
|
13
|
+
Object.defineProperty(this, "cache", {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
value: CacheManager.getInstance()
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
// 获取token主入口
|
|
21
|
+
async getToken(options) {
|
|
22
|
+
Logger.warn('[AuthService] getToken called', options);
|
|
23
|
+
const config = this.config.feishu;
|
|
24
|
+
const client_id = options?.client_id || config.appId;
|
|
25
|
+
const client_secret = options?.client_secret || config.appSecret;
|
|
26
|
+
const authType = options?.authType || config.authType;
|
|
27
|
+
const clientKey = await CacheManager.getClientKey(client_id, client_secret);
|
|
28
|
+
Logger.warn('[AuthService] getToken resolved clientKey', clientKey, 'authType', authType);
|
|
29
|
+
if (authType === 'tenant') {
|
|
30
|
+
return this.getTenantToken(client_id, client_secret, clientKey);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
let tokenObj = this.cache.getUserToken(clientKey);
|
|
34
|
+
const now = Date.now() / 1000;
|
|
35
|
+
if (!tokenObj || tokenObj.refresh_token_expires_at < now) {
|
|
36
|
+
Logger.warn('[AuthService] No user token in cache, need user auth', clientKey);
|
|
37
|
+
// 返回授权链接
|
|
38
|
+
const redirect_uri = encodeURIComponent(`http://localhost:${this.config.server.port}/callback`);
|
|
39
|
+
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');
|
|
40
|
+
const state = clientKey;
|
|
41
|
+
const url = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}`;
|
|
42
|
+
return { needAuth: true, url };
|
|
43
|
+
}
|
|
44
|
+
Logger.debug('[AuthService] User token found in cache', tokenObj);
|
|
45
|
+
if (tokenObj.expires_at && tokenObj.expires_at < now) {
|
|
46
|
+
Logger.warn('[AuthService] User token expired, try refresh', tokenObj);
|
|
47
|
+
if (tokenObj.refresh_token) {
|
|
48
|
+
tokenObj = await this.refreshUserToken(tokenObj.refresh_token, clientKey, client_id, client_secret);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
Logger.warn('[AuthService] No refresh_token, clear cache and require re-auth', clientKey);
|
|
52
|
+
this.cache.cacheUserToken(clientKey, null, 0);
|
|
53
|
+
return { needAuth: true, url: '请重新授权' };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
Logger.warn('[AuthService] Return user access_token', tokenObj.access_token);
|
|
57
|
+
// 计算剩余有效期(秒)
|
|
58
|
+
const expires_in = tokenObj.expires_at ? Math.max(tokenObj.expires_at - now, 0) : undefined;
|
|
59
|
+
return { access_token: tokenObj.access_token, expires_in, ...tokenObj };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 获取tenant_access_token
|
|
63
|
+
async getTenantToken(client_id, client_secret, clientKey) {
|
|
64
|
+
Logger.warn('[AuthService] getTenantToken called', { client_id, clientKey });
|
|
65
|
+
// 尝试从缓存获取
|
|
66
|
+
const cacheKey = clientKey;
|
|
67
|
+
const cachedTokenObj = this.cache.getTenantToken(cacheKey);
|
|
68
|
+
if (cachedTokenObj) {
|
|
69
|
+
Logger.warn('[AuthService] Tenant token cache hit', cacheKey);
|
|
70
|
+
const { tenant_access_token, expire_at } = cachedTokenObj;
|
|
71
|
+
const now = Math.floor(Date.now() / 1000);
|
|
72
|
+
const expires_in = expire_at ? Math.max(expire_at - now, 0) : undefined;
|
|
73
|
+
return { access_token: tenant_access_token, expires_in };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const requestData = {
|
|
77
|
+
app_id: client_id,
|
|
78
|
+
app_secret: client_secret,
|
|
79
|
+
};
|
|
80
|
+
const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
|
|
81
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
82
|
+
Logger.debug('[AuthService] Requesting tenant_access_token', url, requestData);
|
|
83
|
+
const response = await axios.post(url, requestData, { headers });
|
|
84
|
+
const data = response.data;
|
|
85
|
+
Logger.debug('[AuthService] tenant_access_token response', data);
|
|
86
|
+
if (!data || typeof data !== 'object') {
|
|
87
|
+
Logger.error('[AuthService] tenant_access_token invalid response', data);
|
|
88
|
+
throw new Error('获取飞书访问令牌失败:响应格式无效');
|
|
89
|
+
}
|
|
90
|
+
if (data.code !== 0) {
|
|
91
|
+
Logger.error('[AuthService] tenant_access_token error', data);
|
|
92
|
+
throw new Error(`获取飞书访问令牌失败:${data.msg || '未知错误'} (错误码: ${data.code})`);
|
|
93
|
+
}
|
|
94
|
+
if (!data.tenant_access_token) {
|
|
95
|
+
Logger.error('[AuthService] tenant_access_token missing in response', data);
|
|
96
|
+
throw new Error('获取飞书访问令牌失败:响应中没有token');
|
|
97
|
+
}
|
|
98
|
+
// 计算绝对过期时间戳
|
|
99
|
+
const expire_at = Math.floor(Date.now() / 1000) + (data.expire || 0);
|
|
100
|
+
const tokenObj = {
|
|
101
|
+
tenant_access_token: data.tenant_access_token,
|
|
102
|
+
expire_at
|
|
103
|
+
};
|
|
104
|
+
this.cache.cacheTenantToken(cacheKey, tokenObj, data.expire);
|
|
105
|
+
Logger.warn('[AuthService] tenant_access_token cached', cacheKey);
|
|
106
|
+
// 返回token对象和expires_in
|
|
107
|
+
return { access_token: data.tenant_access_token, expires_in: data.expire, expire_at };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
Logger.error('[AuthService] getTenantToken error', error);
|
|
111
|
+
throw new Error('获取飞书访问令牌失败: ' + (error instanceof Error ? error.message : String(error)));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// 刷新user_access_token
|
|
115
|
+
async refreshUserToken(refresh_token, clientKey, client_id, client_secret) {
|
|
116
|
+
Logger.warn('[AuthService] refreshUserToken called', { clientKey });
|
|
117
|
+
const body = {
|
|
118
|
+
grant_type: 'refresh_token',
|
|
119
|
+
client_id,
|
|
120
|
+
client_secret,
|
|
121
|
+
refresh_token
|
|
122
|
+
};
|
|
123
|
+
Logger.debug('[AuthService] refreshUserToken request', body);
|
|
124
|
+
const response = await axios.post('https://open.feishu.cn/open-apis/authen/v2/oauth/token', body, { headers: { 'Content-Type': 'application/json' } });
|
|
125
|
+
const data = response.data;
|
|
126
|
+
Logger.debug('[AuthService] refreshUserToken response', data);
|
|
127
|
+
if (data && data.access_token && data.expires_in) {
|
|
128
|
+
data.expires_in = Math.floor(Date.now() / 1000) + data.expires_in;
|
|
129
|
+
this.cache.cacheUserToken(clientKey, data, data.expires_in);
|
|
130
|
+
Logger.warn('[AuthService] Refreshed user_access_token cached', clientKey);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
Logger.warn('[AuthService] refreshUserToken failed', data);
|
|
134
|
+
}
|
|
135
|
+
return data;
|
|
136
|
+
}
|
|
137
|
+
// 获取用户信息
|
|
138
|
+
async getUserInfo(access_token) {
|
|
139
|
+
Logger.warn('[AuthService] getUserInfo called');
|
|
140
|
+
try {
|
|
141
|
+
const response = await axios.get('https://open.feishu.cn/open-apis/authen/v1/user_info', { headers: { Authorization: `Bearer ${access_token}` } });
|
|
142
|
+
Logger.debug('[AuthService] getUserInfo response', response.data);
|
|
143
|
+
return response.data;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
Logger.error('[AuthService] getUserInfo error', error);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// 通过授权码换取user_access_token
|
|
151
|
+
async getUserTokenByCode({ client_id, client_secret, code, redirect_uri, code_verifier }) {
|
|
152
|
+
Logger.warn('[AuthService] getUserTokenByCode called', { client_id, code, redirect_uri });
|
|
153
|
+
const clientKey = await CacheManager.getClientKey(client_id, client_secret);
|
|
154
|
+
const body = {
|
|
155
|
+
grant_type: 'authorization_code',
|
|
156
|
+
client_id,
|
|
157
|
+
client_secret,
|
|
158
|
+
code,
|
|
159
|
+
redirect_uri
|
|
160
|
+
};
|
|
161
|
+
if (code_verifier)
|
|
162
|
+
body.code_verifier = code_verifier;
|
|
163
|
+
Logger.debug('[AuthService] getUserTokenByCode request', body);
|
|
164
|
+
const response = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify(body)
|
|
168
|
+
});
|
|
169
|
+
const data = await response.json();
|
|
170
|
+
Logger.debug('[AuthService] getUserTokenByCode response', data);
|
|
171
|
+
// 缓存user_access_token
|
|
172
|
+
if (data && data.access_token && data.expires_in) {
|
|
173
|
+
data.expires_at = Math.floor(Date.now() / 1000) + data.expires_in;
|
|
174
|
+
data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in;
|
|
175
|
+
// 缓存时间应为 refresh_token 的有效期,防止缓存被提前清理
|
|
176
|
+
const refreshTtl = data.refresh_expires_in || 3600 * 24 * 365; // 默认1年
|
|
177
|
+
this.cache.cacheUserToken(clientKey, data, refreshTtl);
|
|
178
|
+
Logger.warn('[AuthService] user_access_token cached', clientKey, 'refreshTtl', refreshTtl);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
Logger.warn('[AuthService] getUserTokenByCode failed', data);
|
|
182
|
+
}
|
|
183
|
+
return data;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -16,6 +16,7 @@ export const IndexSchema = z.number().describe('Insertion position index (requir
|
|
|
16
16
|
'0 means to insert as the first content block after the title.\n' +
|
|
17
17
|
'If children is empty or missing, use 0 to insert the first content block.\n' +
|
|
18
18
|
'For nested blocks, index is relative to the parent block\'s children.\n' +
|
|
19
|
+
'**index must satisfy 0 ≤ index ≤ parentBlock.children.length, otherwise the API will return an error.**\n' +
|
|
19
20
|
'Note: The title block itself is not part of the children array and cannot be operated on with index.' +
|
|
20
21
|
'Specifies where the block should be inserted. Use 0 to insert at the beginning. ' +
|
|
21
22
|
'Use get_feishu_document_blocks tool to understand document structure if unsure. ' +
|
|
@@ -106,10 +107,14 @@ export const BlockTypeEnum = z.string().describe("Block type (required). Support
|
|
|
106
107
|
"For headings, we recommend using 'heading' with level property, but 'heading1'-'heading9' are also supported. " +
|
|
107
108
|
"For images, use 'image' to create empty image blocks that can be filled later. " +
|
|
108
109
|
"For text blocks, you can include both regular text and equation elements in the same block.");
|
|
110
|
+
// 图片宽度参数定义
|
|
111
|
+
export const ImageWidthSchema = z.number().optional().describe('Image width in pixels (optional). If not provided, the original image width will be used.');
|
|
112
|
+
// 图片高度参数定义
|
|
113
|
+
export const ImageHeightSchema = z.number().optional().describe('Image height in pixels (optional). If not provided, the original image height will be used.');
|
|
109
114
|
// 图片块内容定义 - 用于批量创建块工具
|
|
110
115
|
export const ImageBlockSchema = z.object({
|
|
111
|
-
width:
|
|
112
|
-
height:
|
|
116
|
+
width: ImageWidthSchema,
|
|
117
|
+
height: ImageHeightSchema
|
|
113
118
|
});
|
|
114
119
|
// 块配置定义 - 用于批量创建块工具
|
|
115
120
|
export const BlockConfigSchema = z.object({
|
|
@@ -145,11 +150,15 @@ export const ImagePathOrUrlSchema = z.string().describe('Image path or URL (requ
|
|
|
145
150
|
// 图片文件名参数定义
|
|
146
151
|
export const ImageFileNameSchema = z.string().optional().describe('Image file name (optional). If not provided, a default name will be generated based on the source. ' +
|
|
147
152
|
'Should include the file extension, e.g., "image.png" or "photo.jpg".');
|
|
148
|
-
//
|
|
149
|
-
export const
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
// 批量图片上传绑定参数定义
|
|
154
|
+
export const ImagesArraySchema = z.array(z.object({
|
|
155
|
+
blockId: BlockIdSchema,
|
|
156
|
+
imagePathOrUrl: ImagePathOrUrlSchema,
|
|
157
|
+
fileName: ImageFileNameSchema.optional(),
|
|
158
|
+
})).describe('Array of image binding objects (required). Each object must include: blockId (target image block ID), imagePathOrUrl (local path or URL of the image), and optionally fileName (image file name, e.g., "image.png").');
|
|
152
159
|
// 画板ID参数定义
|
|
153
160
|
export const WhiteboardIdSchema = z.string().describe('Whiteboard ID (required). This is the token value from the board.token field when getting document blocks.\n' +
|
|
154
161
|
'When you find a block with block_type: 43, the whiteboard ID is located in board.token field.\n' +
|
|
155
162
|
'Example: "EPJKwvY5ghe3pVbKj9RcT2msnBX"');
|
|
163
|
+
// 文档标题参数定义
|
|
164
|
+
export const DocumentTitleSchema = z.string().describe('Document title (required). This will be displayed in the Feishu document list and document header.');
|