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.
@@ -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
  }
@@ -110,3 +110,157 @@ export function detectMimeType(buffer) {
110
110
  return 'application/octet-stream';
111
111
  }
112
112
  }
113
+ function formatExpire(seconds) {
114
+ if (!seconds || isNaN(seconds))
115
+ return '';
116
+ if (seconds < 0)
117
+ return `<span style='color:#e53935'>已过期</span> (${seconds}s)`;
118
+ const h = Math.floor(seconds / 3600);
119
+ const m = Math.floor((seconds % 3600) / 60);
120
+ const s = seconds % 60;
121
+ let str = '';
122
+ if (h)
123
+ str += h + '小时';
124
+ if (m)
125
+ str += m + '分';
126
+ if (s || (!h && !m))
127
+ str += s + '秒';
128
+ return `${str} (${seconds}s)`;
129
+ }
130
+ export function renderFeishuAuthResultHtml(data) {
131
+ const isError = data && data.error;
132
+ const now = Math.floor(Date.now() / 1000);
133
+ let expiresIn = data && data.expires_in;
134
+ let refreshExpiresIn = data && (data.refresh_token_expires_in || data.refresh_expires_in);
135
+ if (expiresIn && expiresIn > 1000000000)
136
+ expiresIn = expiresIn - now;
137
+ if (refreshExpiresIn && refreshExpiresIn > 1000000000)
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>
155
+ ` : '';
156
+ let userBlock = '';
157
+ const userInfo = data && data.userInfo && data.userInfo.data;
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>
169
+ `;
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>
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>
260
+ `;
261
+ }
262
+ function escapeHtml(str) {
263
+ return str.replace(/[&<>"]|'/g, function (c) {
264
+ return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c;
265
+ });
266
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feishu-mcp",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Model Context Protocol server for Feishu integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/dist/config.js DELETED
@@ -1,26 +0,0 @@
1
- import { Config, ConfigSource } from './utils/config.js';
2
- /**
3
- * 为了向后兼容,保留getServerConfig函数
4
- * 但内部使用Config类
5
- * @param isStdioMode 是否在stdio模式下
6
- * @returns 服务器配置
7
- */
8
- export function getServerConfig(isStdioMode) {
9
- const config = Config.getInstance();
10
- if (!isStdioMode) {
11
- config.printConfig(isStdioMode);
12
- }
13
- // 为了向后兼容,返回旧格式的配置对象
14
- return {
15
- port: config.server.port,
16
- feishuAppId: config.feishu.appId,
17
- feishuAppSecret: config.feishu.appSecret,
18
- configSources: {
19
- port: config.configSources['server.port'].toLowerCase(),
20
- feishuAppId: config.configSources['feishu.appId']?.toLowerCase(),
21
- feishuAppSecret: config.configSources['feishu.appSecret']?.toLowerCase()
22
- }
23
- };
24
- }
25
- // 导出Config类
26
- export { Config, ConfigSource };