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/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
- try {
80
- const requestData = {
81
- app_id: this.config.feishu.appId,
82
- app_secret: this.config.feishu.appSecret,
83
- };
84
- Logger.info('开始获取新的飞书访问令牌...');
85
- Logger.debug('认证请求参数:', requestData);
86
- // 不使用通用的request方法,因为这个请求不需要认证
87
- // 为了确保正确处理响应,我们直接使用axios
88
- const url = `${this.getBaseUrl()}${this.getAuthEndpoint()}`;
89
- const headers = { 'Content-Type': 'application/json' };
90
- Logger.debug(`发送认证请求到: ${url}`);
91
- const response = await axios.post(url, requestData, { headers });
92
- Logger.debug('认证响应:', response.data);
93
- if (!response.data || typeof response.data !== 'object') {
94
- throw new Error('获取飞书访问令牌失败:响应格式无效');
95
- }
96
- // 检查错误码
97
- if (response.data.code !== 0) {
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
+ }
@@ -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('access_token', token, expiresInSeconds);
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('access_token');
258
+ return this.get(`access_token`);
203
259
  }
204
260
  /**
205
- * 缓存Wiki到文档ID的转换结果
206
- * @param wikiToken Wiki Token
207
- * @param documentId 文档ID
208
- * @returns 是否成功设置缓存
261
+ * 生成client_id+client_secret签名
262
+ * @param client_id
263
+ * @param client_secret
264
+ * @returns 唯一key
209
265
  */
210
- cacheWikiToDocId(wikiToken, documentId) {
211
- return this.set(`wiki:${wikiToken}`, documentId);
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
- * 获取缓存的Wiki转换结果
215
- * @param wikiToken Wiki Token
216
- * @returns 文档ID,如果未找到或已过期则返回null
217
- */
218
- getWikiToDocId(wikiToken) {
219
- return this.get(`wiki:${wikiToken}`);
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
  }
@@ -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
- tokenLifetime: 7200000 // 2小时,单位:毫秒
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
- // 处理token生命周期
184
- if (process.env.FEISHU_TOKEN_LIFETIME) {
185
- feishuConfig.tokenLifetime = parseInt(process.env.FEISHU_TOKEN_LIFETIME, 10) * 1000;
186
- this.configSources['feishu.tokenLifetime'] = ConfigSource.ENV;
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.tokenLifetime'] = ConfigSource.DEFAULT;
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(`- Token生命周期: ${this.feishu.tokenLifetime / 1000} (来源: ${this.configSources['feishu.tokenLifetime']})`);
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
  }