feishu-mcp 0.0.15 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +257 -251
- package/dist/mcp/tools/feishuFolderTools.js +26 -28
- 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/utils/cache.js +96 -15
- package/dist/utils/config.js +39 -7
- package/dist/utils/document.js +154 -0
- package/package.json +75 -75
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
|
+
}
|
package/dist/utils/cache.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Config } from './config.js';
|
|
2
2
|
import { Logger } from './logger.js';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
3
5
|
/**
|
|
4
6
|
* 缓存管理器类
|
|
5
7
|
* 提供内存缓存功能,支持TTL和最大容量限制
|
|
@@ -22,8 +24,15 @@ export class CacheManager {
|
|
|
22
24
|
writable: true,
|
|
23
25
|
value: void 0
|
|
24
26
|
});
|
|
27
|
+
Object.defineProperty(this, "userTokenCacheFile", {
|
|
28
|
+
enumerable: true,
|
|
29
|
+
configurable: true,
|
|
30
|
+
writable: true,
|
|
31
|
+
value: path.resolve(process.cwd(), 'user_token_cache.json')
|
|
32
|
+
});
|
|
25
33
|
this.cache = new Map();
|
|
26
34
|
this.config = Config.getInstance();
|
|
35
|
+
this.loadUserTokenCache();
|
|
27
36
|
// 定期清理过期缓存
|
|
28
37
|
setInterval(() => {
|
|
29
38
|
this.cleanExpiredCache();
|
|
@@ -62,6 +71,9 @@ export class CacheManager {
|
|
|
62
71
|
expiresAt: now + (actualTtl * 1000)
|
|
63
72
|
});
|
|
64
73
|
Logger.debug(`缓存设置: ${key} (TTL: ${actualTtl}秒)`);
|
|
74
|
+
if (key.startsWith('user_access_token:')) {
|
|
75
|
+
this.saveUserTokenCache();
|
|
76
|
+
}
|
|
65
77
|
return true;
|
|
66
78
|
}
|
|
67
79
|
/**
|
|
@@ -99,6 +111,9 @@ export class CacheManager {
|
|
|
99
111
|
const result = this.cache.delete(key);
|
|
100
112
|
if (result) {
|
|
101
113
|
Logger.debug(`缓存删除: ${key}`);
|
|
114
|
+
if (key.startsWith('user_access_token:')) {
|
|
115
|
+
this.saveUserTokenCache();
|
|
116
|
+
}
|
|
102
117
|
}
|
|
103
118
|
return result;
|
|
104
119
|
}
|
|
@@ -185,6 +200,47 @@ export class CacheManager {
|
|
|
185
200
|
ttl: this.config.cache.ttl
|
|
186
201
|
};
|
|
187
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* 缓存Wiki到文档ID的转换结果
|
|
205
|
+
* @param wikiToken Wiki Token
|
|
206
|
+
* @param documentId 文档ID
|
|
207
|
+
* @returns 是否成功设置缓存
|
|
208
|
+
*/
|
|
209
|
+
cacheWikiToDocId(wikiToken, documentId) {
|
|
210
|
+
return this.set(`wiki:${wikiToken}`, documentId);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 获取缓存的Wiki转换结果
|
|
214
|
+
* @param wikiToken Wiki Token
|
|
215
|
+
* @returns 文档ID,如果未找到或已过期则返回null
|
|
216
|
+
*/
|
|
217
|
+
getWikiToDocId(wikiToken) {
|
|
218
|
+
return this.get(`wiki:${wikiToken}`);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 缓存tenant访问令牌
|
|
222
|
+
* @param token 访问令牌
|
|
223
|
+
* @param expiresInSeconds 过期时间(秒)
|
|
224
|
+
* @param key 缓存键,默认为'access_token'
|
|
225
|
+
* @returns 是否成功设置缓存
|
|
226
|
+
*/
|
|
227
|
+
cacheTenantToken(key, token, expiresInSeconds) {
|
|
228
|
+
return this.set(`tenant_access_token:${key}`, token, expiresInSeconds);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 获取tenant缓存的访问令牌
|
|
232
|
+
* @param key 缓存键,默认为'access_token'
|
|
233
|
+
* @returns 访问令牌,如果未找到或已过期则返回null
|
|
234
|
+
*/
|
|
235
|
+
getTenantToken(key) {
|
|
236
|
+
return this.get(`tenant_access_token:${key}`);
|
|
237
|
+
}
|
|
238
|
+
cacheUserToken(key, tokenObj, expiresIn) {
|
|
239
|
+
return this.set(`user_access_token:${key}`, tokenObj, expiresIn);
|
|
240
|
+
}
|
|
241
|
+
getUserToken(key) {
|
|
242
|
+
return this.get(`user_access_token:${key}`);
|
|
243
|
+
}
|
|
188
244
|
/**
|
|
189
245
|
* 缓存访问令牌
|
|
190
246
|
* @param token 访问令牌
|
|
@@ -192,30 +248,55 @@ export class CacheManager {
|
|
|
192
248
|
* @returns 是否成功设置缓存
|
|
193
249
|
*/
|
|
194
250
|
cacheToken(token, expiresInSeconds) {
|
|
195
|
-
return this.set(
|
|
251
|
+
return this.set(`access_token`, token, expiresInSeconds);
|
|
196
252
|
}
|
|
197
253
|
/**
|
|
198
254
|
* 获取缓存的访问令牌
|
|
199
255
|
* @returns 访问令牌,如果未找到或已过期则返回null
|
|
200
256
|
*/
|
|
201
257
|
getToken() {
|
|
202
|
-
return this.get(
|
|
258
|
+
return this.get(`access_token`);
|
|
203
259
|
}
|
|
204
260
|
/**
|
|
205
|
-
*
|
|
206
|
-
* @param
|
|
207
|
-
* @param
|
|
208
|
-
* @returns
|
|
261
|
+
* 生成client_id+client_secret签名
|
|
262
|
+
* @param client_id
|
|
263
|
+
* @param client_secret
|
|
264
|
+
* @returns 唯一key
|
|
209
265
|
*/
|
|
210
|
-
|
|
211
|
-
|
|
266
|
+
static async getClientKey(client_id, client_secret) {
|
|
267
|
+
const crypto = await import('crypto');
|
|
268
|
+
return crypto.createHash('sha256').update(client_id + ':' + client_secret).digest('hex');
|
|
212
269
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
270
|
+
loadUserTokenCache() {
|
|
271
|
+
if (fs.existsSync(this.userTokenCacheFile)) {
|
|
272
|
+
try {
|
|
273
|
+
const raw = fs.readFileSync(this.userTokenCacheFile, 'utf-8');
|
|
274
|
+
const obj = JSON.parse(raw);
|
|
275
|
+
for (const k in obj) {
|
|
276
|
+
if (k.startsWith('user_access_token:')) {
|
|
277
|
+
this.cache.set(k, obj[k]);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
Logger.info(`已加载本地 user_token_cache.json,共${Object.keys(obj).length}条`);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
Logger.warn('加载 user_token_cache.json 失败', e);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
saveUserTokenCache() {
|
|
288
|
+
const obj = {};
|
|
289
|
+
for (const [k, v] of this.cache.entries()) {
|
|
290
|
+
if (k.startsWith('user_access_token:')) {
|
|
291
|
+
obj[k] = v;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
fs.writeFileSync(this.userTokenCacheFile, JSON.stringify(obj, null, 2), 'utf-8');
|
|
296
|
+
Logger.debug('user_token_cache.json 已写入');
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
Logger.warn('写入 user_token_cache.json 失败', e);
|
|
300
|
+
}
|
|
220
301
|
}
|
|
221
302
|
}
|
package/dist/utils/config.js
CHANGED
|
@@ -110,6 +110,14 @@ export class Config {
|
|
|
110
110
|
'cache-ttl': {
|
|
111
111
|
type: 'number',
|
|
112
112
|
description: '缓存生存时间(秒)'
|
|
113
|
+
},
|
|
114
|
+
'feishu-auth-type': {
|
|
115
|
+
type: 'string',
|
|
116
|
+
description: '飞书认证类型 (tenant 或 user)'
|
|
117
|
+
},
|
|
118
|
+
'feishu-token-endpoint': {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: '获取token的接口地址,默认 http://localhost:3333/getToken'
|
|
113
121
|
}
|
|
114
122
|
})
|
|
115
123
|
.help()
|
|
@@ -144,11 +152,14 @@ export class Config {
|
|
|
144
152
|
* @returns 飞书配置
|
|
145
153
|
*/
|
|
146
154
|
initFeishuConfig(argv) {
|
|
155
|
+
// 先初始化serverConfig以获取端口
|
|
156
|
+
const serverConfig = this.server || this.initServerConfig(argv);
|
|
147
157
|
const feishuConfig = {
|
|
148
158
|
appId: '',
|
|
149
159
|
appSecret: '',
|
|
150
160
|
baseUrl: 'https://open.feishu.cn/open-apis',
|
|
151
|
-
|
|
161
|
+
authType: 'tenant', // 默认
|
|
162
|
+
tokenEndpoint: `http://127.0.0.1:${serverConfig.port}/getToken`, // 默认动态端口
|
|
152
163
|
};
|
|
153
164
|
// 处理App ID
|
|
154
165
|
if (argv['feishu-app-id']) {
|
|
@@ -180,13 +191,29 @@ export class Config {
|
|
|
180
191
|
else {
|
|
181
192
|
this.configSources['feishu.baseUrl'] = ConfigSource.DEFAULT;
|
|
182
193
|
}
|
|
183
|
-
// 处理
|
|
184
|
-
if (
|
|
185
|
-
feishuConfig.
|
|
186
|
-
this.configSources['feishu.
|
|
194
|
+
// 处理authType
|
|
195
|
+
if (argv['feishu-auth-type']) {
|
|
196
|
+
feishuConfig.authType = argv['feishu-auth-type'] === 'user' ? 'user' : 'tenant';
|
|
197
|
+
this.configSources['feishu.authType'] = ConfigSource.CLI;
|
|
198
|
+
}
|
|
199
|
+
else if (process.env.FEISHU_AUTH_TYPE) {
|
|
200
|
+
feishuConfig.authType = process.env.FEISHU_AUTH_TYPE === 'user' ? 'user' : 'tenant';
|
|
201
|
+
this.configSources['feishu.authType'] = ConfigSource.ENV;
|
|
187
202
|
}
|
|
188
203
|
else {
|
|
189
|
-
this.configSources['feishu.
|
|
204
|
+
this.configSources['feishu.authType'] = ConfigSource.DEFAULT;
|
|
205
|
+
}
|
|
206
|
+
// 处理tokenEndpoint
|
|
207
|
+
if (argv['feishu-token-endpoint']) {
|
|
208
|
+
feishuConfig.tokenEndpoint = argv['feishu-token-endpoint'];
|
|
209
|
+
this.configSources['feishu.tokenEndpoint'] = ConfigSource.CLI;
|
|
210
|
+
}
|
|
211
|
+
else if (process.env.FEISHU_TOKEN_ENDPOINT) {
|
|
212
|
+
feishuConfig.tokenEndpoint = process.env.FEISHU_TOKEN_ENDPOINT;
|
|
213
|
+
this.configSources['feishu.tokenEndpoint'] = ConfigSource.ENV;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
this.configSources['feishu.tokenEndpoint'] = ConfigSource.DEFAULT;
|
|
190
217
|
}
|
|
191
218
|
return feishuConfig;
|
|
192
219
|
}
|
|
@@ -319,7 +346,8 @@ export class Config {
|
|
|
319
346
|
Logger.info(`- App Secret: ${this.maskApiKey(this.feishu.appSecret)} (来源: ${this.configSources['feishu.appSecret']})`);
|
|
320
347
|
}
|
|
321
348
|
Logger.info(`- API URL: ${this.feishu.baseUrl} (来源: ${this.configSources['feishu.baseUrl']})`);
|
|
322
|
-
Logger.info(`-
|
|
349
|
+
Logger.info(`- 认证类型: ${this.feishu.authType} (来源: ${this.configSources['feishu.authType']})`);
|
|
350
|
+
Logger.info(`- Token获取地址: ${this.feishu.tokenEndpoint} (来源: ${this.configSources['feishu.tokenEndpoint']})`);
|
|
323
351
|
Logger.info('日志配置:');
|
|
324
352
|
Logger.info(`- 日志级别: ${LogLevel[this.log.level]} (来源: ${this.configSources['log.level']})`);
|
|
325
353
|
Logger.info(`- 显示时间戳: ${this.log.showTimestamp} (来源: ${this.configSources['log.showTimestamp']})`);
|
|
@@ -358,6 +386,10 @@ export class Config {
|
|
|
358
386
|
Logger.error('缺少飞书应用Secret,请通过环境变量FEISHU_APP_SECRET或命令行参数--feishu-app-secret提供');
|
|
359
387
|
return false;
|
|
360
388
|
}
|
|
389
|
+
if (!this.feishu.tokenEndpoint) {
|
|
390
|
+
Logger.error('缺少飞书Token获取地址,请通过环境变量FEISHU_TOKEN_ENDPOINT或命令行参数--feishu-token-endpoint提供');
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
361
393
|
return true;
|
|
362
394
|
}
|
|
363
395
|
}
|