feishu-mcp 0.1.2 → 0.1.4

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.
@@ -4,6 +4,8 @@ import { Config } from '../utils/config.js';
4
4
  import { CacheManager } from '../utils/cache.js';
5
5
  import { ParamUtils } from '../utils/paramUtils.js';
6
6
  import { BlockFactory, BlockType } from './blockFactory.js';
7
+ import { AuthUtils, TokenCacheManager } from '../utils/auth/index.js';
8
+ import { AuthRequiredError } from '../utils/error.js';
7
9
  import axios from 'axios';
8
10
  import FormData from 'form-data';
9
11
  import fs from 'fs';
@@ -66,35 +68,154 @@ export class FeishuApiService extends BaseApiService {
66
68
  }
67
69
  /**
68
70
  * 获取访问令牌
71
+ * @param userKey 用户标识(可选)
69
72
  * @returns 访问令牌
70
73
  * @throws 如果获取令牌失败则抛出错误
71
74
  */
72
- async getAccessToken() {
73
- // 尝试从缓存获取
74
- const cachedToken = this.cacheManager.getToken();
75
+ async getAccessToken(userKey) {
76
+ const { appId, appSecret, authType } = this.config.feishu;
77
+ // 生成客户端缓存键
78
+ const clientKey = AuthUtils.generateClientKey(userKey);
79
+ Logger.debug(`[FeishuApiService] 获取访问令牌,userKey: ${userKey}, clientKey: ${clientKey}, authType: ${authType}`);
80
+ if (authType === 'tenant') {
81
+ // 租户模式:获取租户访问令牌
82
+ return this.getTenantAccessToken(appId, appSecret, clientKey);
83
+ }
84
+ else {
85
+ // 用户模式:获取用户访问令牌
86
+ return this.getUserAccessToken(appId, appSecret, clientKey, userKey);
87
+ }
88
+ }
89
+ /**
90
+ * 获取租户访问令牌
91
+ * @param appId 应用ID
92
+ * @param appSecret 应用密钥
93
+ * @param clientKey 客户端缓存键
94
+ * @returns 租户访问令牌
95
+ */
96
+ async getTenantAccessToken(appId, appSecret, clientKey) {
97
+ const tokenCacheManager = TokenCacheManager.getInstance();
98
+ // 尝试从缓存获取租户token
99
+ const cachedToken = tokenCacheManager.getTenantToken(clientKey);
75
100
  if (cachedToken) {
76
- Logger.debug('使用缓存的访问令牌');
101
+ Logger.debug('使用缓存的租户访问令牌');
77
102
  return cachedToken;
78
103
  }
79
- // 通过HTTP请求调用配置的tokenEndpoint接口
80
- const { appId, appSecret, authType, tokenEndpoint } = this.config.feishu;
81
- const params = new URLSearchParams({
104
+ // 缓存中没有token,请求新的租户token
105
+ Logger.info('缓存中没有租户token,请求新的租户访问令牌');
106
+ try {
107
+ const requestData = {
108
+ app_id: appId,
109
+ app_secret: appSecret,
110
+ };
111
+ const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
112
+ const headers = { 'Content-Type': 'application/json' };
113
+ Logger.debug('请求租户访问令牌:', url, requestData);
114
+ const response = await axios.post(url, requestData, { headers });
115
+ const data = response.data;
116
+ if (data.code !== 0) {
117
+ throw new Error(`获取租户访问令牌失败:${data.msg || '未知错误'} (错误码: ${data.code})`);
118
+ }
119
+ if (!data.tenant_access_token) {
120
+ throw new Error('获取租户访问令牌失败:响应中没有token');
121
+ }
122
+ // 计算绝对过期时间戳
123
+ const expire_at = Math.floor(Date.now() / 1000) + (data.expire || 0);
124
+ const tokenInfo = {
125
+ app_access_token: data.tenant_access_token,
126
+ expires_at: expire_at
127
+ };
128
+ // 缓存租户token
129
+ tokenCacheManager.cacheTenantToken(clientKey, tokenInfo, data.expire);
130
+ Logger.info('租户访问令牌获取并缓存成功');
131
+ return data.tenant_access_token;
132
+ }
133
+ catch (error) {
134
+ Logger.error('获取租户访问令牌失败:', error);
135
+ throw new Error('获取租户访问令牌失败: ' + (error instanceof Error ? error.message : String(error)));
136
+ }
137
+ }
138
+ /**
139
+ * 获取用户访问令牌
140
+ * @param appId 应用ID
141
+ * @param appSecret 应用密钥
142
+ * @param clientKey 客户端缓存键
143
+ * @param userKey 用户标识
144
+ * @returns 用户访问令牌
145
+ */
146
+ async getUserAccessToken(appId, appSecret, clientKey, _userKey) {
147
+ const tokenCacheManager = TokenCacheManager.getInstance();
148
+ // 检查用户token状态
149
+ const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey);
150
+ Logger.debug(`用户token状态:`, tokenStatus);
151
+ if (tokenStatus.isValid && !tokenStatus.shouldRefresh) {
152
+ // token有效且不需要刷新,直接返回
153
+ const cachedToken = tokenCacheManager.getUserToken(clientKey);
154
+ if (cachedToken) {
155
+ Logger.debug('使用缓存的用户访问令牌');
156
+ return cachedToken;
157
+ }
158
+ }
159
+ if (tokenStatus.canRefresh && (tokenStatus.isExpired || tokenStatus.shouldRefresh)) {
160
+ // 可以刷新token
161
+ Logger.info('尝试刷新用户访问令牌');
162
+ try {
163
+ const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey);
164
+ if (tokenInfo && tokenInfo.refresh_token) {
165
+ const refreshedToken = await this.refreshUserToken(tokenInfo.refresh_token, clientKey, appId, appSecret);
166
+ if (refreshedToken && refreshedToken.access_token) {
167
+ Logger.info('用户访问令牌刷新成功');
168
+ return refreshedToken.access_token;
169
+ }
170
+ }
171
+ }
172
+ catch (error) {
173
+ Logger.warn('刷新用户访问令牌失败:', error);
174
+ // 刷新失败,清除缓存,需要重新授权
175
+ tokenCacheManager.removeUserToken(clientKey);
176
+ }
177
+ }
178
+ // 没有有效的token或刷新失败,需要用户授权
179
+ Logger.warn('没有有效的用户token,需要用户授权');
180
+ throw new AuthRequiredError('user', '需要用户授权');
181
+ }
182
+ /**
183
+ * 刷新用户访问令牌
184
+ * @param refreshToken 刷新令牌
185
+ * @param clientKey 客户端缓存键
186
+ * @param appId 应用ID
187
+ * @param appSecret 应用密钥
188
+ * @returns 刷新后的token信息
189
+ */
190
+ async refreshUserToken(refreshToken, clientKey, appId, appSecret) {
191
+ const tokenCacheManager = TokenCacheManager.getInstance();
192
+ const body = {
193
+ grant_type: 'refresh_token',
82
194
  client_id: appId,
83
195
  client_secret: appSecret,
84
- token_type: authType
196
+ refresh_token: refreshToken
197
+ };
198
+ Logger.debug('刷新用户访问令牌请求:', body);
199
+ const response = await axios.post('https://open.feishu.cn/open-apis/authen/v2/oauth/token', body, {
200
+ headers: { 'Content-Type': 'application/json' }
85
201
  });
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;
202
+ const data = response.data;
203
+ if (data && data.access_token && data.expires_in) {
204
+ // 计算过期时间戳
205
+ data.expires_at = Math.floor(Date.now() / 1000) + data.expires_in;
206
+ if (data.refresh_token_expires_in) {
207
+ data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in;
208
+ }
209
+ // 缓存新的token信息
210
+ const refreshTtl = data.refresh_token_expires_in || 3600 * 24 * 365; // 默认1年
211
+ tokenCacheManager.cacheUserToken(clientKey, data, refreshTtl);
212
+ Logger.info('用户访问令牌刷新并缓存成功');
213
+ return data;
93
214
  }
94
- if (tokenResult && tokenResult.needAuth && tokenResult.url) {
95
- throw new Error(`请在浏览器打开以下链接进行授权:\n\n[点击授权](${tokenResult.url})`);
215
+ else {
216
+ Logger.warn('刷新用户访问令牌失败:', data);
217
+ throw new Error('刷新用户访问令牌失败');
96
218
  }
97
- throw new Error('无法获取有效的access_token');
98
219
  }
99
220
  /**
100
221
  * 创建飞书文档
@@ -878,7 +999,7 @@ export class FeishuApiService extends BaseApiService {
878
999
  async searchDocuments(searchKey, count = 50) {
879
1000
  try {
880
1001
  Logger.info(`开始搜索文档,关键字: ${searchKey}`);
881
- const endpoint = `//suite/docs-api/search/object`;
1002
+ const endpoint = `/suite/docs-api/search/object`;
882
1003
  let offset = 0;
883
1004
  let allResults = [];
884
1005
  let hasMore = true;
@@ -1,6 +1,5 @@
1
1
  import axios from 'axios';
2
2
  import { Config } from '../utils/config.js';
3
- import { CacheManager } from '../utils/cache.js';
4
3
  import { Logger } from '../utils/logger.js';
5
4
  export class AuthService {
6
5
  constructor() {
@@ -10,129 +9,6 @@ export class AuthService {
10
9
  writable: true,
11
10
  value: Config.getInstance()
12
11
  });
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
12
  }
137
13
  // 获取用户信息
138
14
  async getUserInfo(access_token) {
@@ -150,7 +26,6 @@ export class AuthService {
150
26
  // 通过授权码换取user_access_token
151
27
  async getUserTokenByCode({ client_id, client_secret, code, redirect_uri, code_verifier }) {
152
28
  Logger.warn('[AuthService] getUserTokenByCode called', { client_id, code, redirect_uri });
153
- const clientKey = await CacheManager.getClientKey(client_id, client_secret);
154
29
  const body = {
155
30
  grant_type: 'authorization_code',
156
31
  client_id,
@@ -168,18 +43,6 @@ export class AuthService {
168
43
  });
169
44
  const data = await response.json();
170
45
  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
46
  return data;
184
47
  }
185
48
  }
@@ -0,0 +1,71 @@
1
+ import * as crypto from 'crypto';
2
+ import { Config } from '../config.js';
3
+ /**
4
+ * 认证工具类
5
+ * 提供认证相关的加密和哈希工具方法
6
+ */
7
+ export class AuthUtils {
8
+ /**
9
+ * 生成客户端缓存键
10
+ * @param userKey 用户标识(可选)
11
+ * @returns 生成的客户端键
12
+ */
13
+ static generateClientKey(userKey) {
14
+ const feishuConfig = Config.getInstance().feishu;
15
+ const userPart = userKey ? `:${userKey}` : '';
16
+ let source = '';
17
+ if (feishuConfig.authType === "tenant") {
18
+ source = `${feishuConfig.appId}:${feishuConfig.appSecret}`;
19
+ }
20
+ else {
21
+ source = `${feishuConfig.appId}:${feishuConfig.appSecret}${userPart}`;
22
+ }
23
+ return crypto.createHash('sha256').update(source).digest('hex');
24
+ }
25
+ /**
26
+ * 生成时间戳
27
+ * @returns 当前时间戳(秒)
28
+ */
29
+ static timestamp() {
30
+ return Math.floor(Date.now() / 1000);
31
+ }
32
+ /**
33
+ * 生成时间戳(毫秒)
34
+ * @returns 当前时间戳(毫秒)
35
+ */
36
+ static timestampMs() {
37
+ return Date.now();
38
+ }
39
+ /**
40
+ * 编码state参数
41
+ * @param appId 应用ID
42
+ * @param appSecret 应用密钥
43
+ * @param clientKey 客户端缓存键
44
+ * @param redirectUri 重定向URI(可选)
45
+ * @returns Base64编码的state字符串
46
+ */
47
+ static encodeState(appId, appSecret, clientKey, redirectUri) {
48
+ const stateData = {
49
+ appId,
50
+ appSecret,
51
+ clientKey,
52
+ redirectUri,
53
+ timestamp: this.timestamp()
54
+ };
55
+ return Buffer.from(JSON.stringify(stateData)).toString('base64');
56
+ }
57
+ /**
58
+ * 解码state参数
59
+ * @param encodedState Base64编码的state字符串
60
+ * @returns 解码后的state数据
61
+ */
62
+ static decodeState(encodedState) {
63
+ try {
64
+ const decoded = Buffer.from(encodedState, 'base64').toString('utf-8');
65
+ return JSON.parse(decoded);
66
+ }
67
+ catch (error) {
68
+ return null;
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,4 @@
1
+ export { UserContextManager, getBaseUrl } from './userContextManager.js';
2
+ export { UserAuthManager } from './userAuthManager.js';
3
+ export { TokenCacheManager } from './tokenCacheManager.js';
4
+ export { AuthUtils } from './authUtils.js';