feishu-mcp
Version:
Model Context Protocol server for Feishu integration
346 lines (345 loc) • 14.8 kB
JavaScript
import axios, { AxiosError } from 'axios';
import FormData from 'form-data';
import { Logger } from '../utils/logger.js';
import { formatErrorMessage, AuthRequiredError, ScopeInsufficientError } from '../utils/error.js';
import { Config } from '../utils/config.js';
import { TokenCacheManager, UserContextManager, AuthUtils } from '../utils/auth/index.js';
import { getRequiredScopes } from './constants/feishuScopes.js';
import { ModuleRegistry } from '../modules/index.js';
/**
* API服务基类
* 提供通用的HTTP请求处理和认证功能
*/
export class BaseApiService {
/**
* 检查是否是 stdio 模式
* @returns 如果是 stdio 模式返回 true
*/
isStdioMode() {
return process.env.NODE_ENV === "cli" || process.argv.includes("--stdio");
}
/**
* 处理API错误
* @param error 错误对象
* @param message 错误上下文消息
* @throws 标准化的API错误
*/
handleApiError(error, message) {
Logger.error(`${message}:`, error);
// 如果已经是格式化的API错误,直接重新抛出
if (error && typeof error === 'object' && 'status' in error && 'err' in error) {
throw error;
}
// 处理Axios错误
if (error instanceof AxiosError && error.response) {
const responseData = error.response.data;
const apiError = {
status: error.response.status,
err: formatErrorMessage(error, message),
apiError: responseData,
logId: responseData?.log_id
};
throw apiError;
}
// 处理其他类型的错误
const errorMessage = error instanceof Error
? error.message
: (typeof error === 'string' ? error : '未知错误');
throw {
status: 500,
err: formatErrorMessage(error, message),
apiError: {
code: -1,
msg: errorMessage,
error
}
};
}
/**
* 执行API请求
* @param endpoint 请求端点
* @param method 请求方法
* @param data 请求数据
* @param needsAuth 是否需要认证
* @param additionalHeaders 附加请求头
* @param responseType 响应类型
* @param retry 是否允许重试,默认为false
* @returns 响应数据
*/
async request(endpoint, method = 'GET', data, needsAuth = true, additionalHeaders, responseType, retry = false) {
// 获取用户上下文
let userKey;
let baseUrl;
if (this.isStdioMode()) {
// stdio 模式下直接使用默认值
const config = Config.getInstance();
userKey = config.feishu.userKey || 'stdio';
baseUrl = `http://localhost:${config.server.port}`;
}
else {
// HTTP 模式下从 UserContextManager 读取
const userContextManager = UserContextManager.getInstance();
userKey = userContextManager.getUserKey();
baseUrl = userContextManager.getBaseUrl();
}
const clientKey = AuthUtils.generateClientKey(userKey);
Logger.debug(`[BaseService] Request context - userKey: ${userKey}, baseUrl: ${baseUrl}`);
try {
// 构建请求URL
const url = `${this.getBaseUrl()}${endpoint}`;
// 准备请求头
const headers = {
...additionalHeaders
};
// 如果数据是FormData,合并FormData的headers
// 否则设置为application/json
if (data instanceof FormData) {
Object.assign(headers, data.getHeaders());
}
else {
headers['Content-Type'] = 'application/json';
}
// 添加认证令牌
if (needsAuth) {
const accessToken = await this.getAccessToken(userKey);
headers['Authorization'] = `Bearer ${accessToken}`;
}
// 记录请求信息
Logger.debug('准备发送请求:');
Logger.debug(`请求URL: ${url}`);
Logger.debug(`请求方法: ${method}`);
if (data) {
Logger.debug(`请求数据:`, data);
}
// 构建请求配置
const config = {
method,
url,
headers,
data: method !== 'GET' ? data : undefined,
params: method === 'GET' ? data : undefined,
responseType: responseType || 'json'
};
// 发送请求
const response = await axios(config);
// 记录响应信息
Logger.debug('收到响应:');
Logger.debug(`响应状态码: ${response.status}`);
Logger.debug(`响应头:`, response.headers);
Logger.debug(`响应数据:`, response.data);
// 对于非JSON响应,直接返回数据
if (responseType && responseType !== 'json') {
return response.data;
}
// 检查API错误(仅对JSON响应)
if (response.data && typeof response.data.code === 'number' && response.data.code !== 0) {
Logger.error(`API返回错误码: ${response.data.code}, 错误消息: ${response.data.msg}`);
throw {
status: response.status,
err: response.data.msg || 'API返回错误码',
apiError: response.data,
logId: response.data.log_id
};
}
// 返回数据
return response.data.data;
}
catch (error) {
const config = Config.getInstance().feishu;
// 优先处理权限不足异常
if (error instanceof ScopeInsufficientError) {
return this.handleScopeInsufficientError(error);
}
// 处理授权异常
if (error instanceof AuthRequiredError) {
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
}
const tokenError = new Set([
4001, // Invalid token, please refresh
20006, // 过期 User Access Token
20013, // get tenant access token fail
99991663, // Invalid access token for authorization (often tenant token)
99991668, // Invalid access token for authorization (user token)
99991677, // user token expire
99991669, // invalid user refresh token
99991664, // invalid app token
99991665 // invalid tenant code
]);
// 处理认证相关错误(401, 403等 或 明确的 token 错误码)
if (error instanceof AxiosError && error.response && tokenError.has(Number(error.response.data?.code))) {
Logger.warn(`认证失败 (${error.response.status}): ${endpoint} ${JSON.stringify(error.response.data)}`);
// 获取配置和token缓存管理器
const tokenCacheManager = TokenCacheManager.getInstance();
// 如果已经重试过,直接处理认证失败
if (retry) {
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
}
// 根据认证类型处理token过期
if (config.authType === 'tenant') {
return this.handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType);
}
else {
return this.handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType, baseUrl, userKey);
}
}
// 处理其他错误
this.handleApiError(error, `API请求失败 (${endpoint})`);
}
}
/**
* GET请求
* @param endpoint 请求端点
* @param params 请求参数
* @param needsAuth 是否需要认证
* @returns 响应数据
*/
async get(endpoint, params, needsAuth = true) {
return this.request(endpoint, 'GET', params, needsAuth);
}
/**
* POST请求
* @param endpoint 请求端点
* @param data 请求数据
* @param needsAuth 是否需要认证
* @returns 响应数据
*/
async post(endpoint, data, needsAuth = true) {
return this.request(endpoint, 'POST', data, needsAuth);
}
/**
* PUT请求
* @param endpoint 请求端点
* @param data 请求数据
* @param needsAuth 是否需要认证
* @returns 响应数据
*/
async put(endpoint, data, needsAuth = true) {
return this.request(endpoint, 'PUT', data, needsAuth);
}
/**
* PATCH请求
* @param endpoint 请求端点
* @param data 请求数据
* @param needsAuth 是否需要认证
* @returns 响应数据
*/
async patch(endpoint, data, needsAuth = true) {
return this.request(endpoint, 'PATCH', data, needsAuth);
}
/**
* DELETE请求
* @param endpoint 请求端点
* @param data 请求数据
* @param needsAuth 是否需要认证
* @returns 响应数据
*/
async delete(endpoint, data, needsAuth = true) {
return this.request(endpoint, 'DELETE', data, needsAuth);
}
/**
* 处理权限不足异常
* @param error 权限不足错误
*/
handleScopeInsufficientError(error) {
throw error;
}
/**
* 处理认证失败
* @param tenant 是否是tenant
* @param clientKey 客户端键
* @param baseUrl 基础URL
* @param userKey 用户键
*/
handleAuthFailure(tenant, clientKey, baseUrl, userKey) {
const tokenCacheManager = TokenCacheManager.getInstance();
if (tenant) {
// 租户模式:清除租户token缓存
Logger.info('租户模式:清除租户token缓存');
tokenCacheManager.removeTenantToken(clientKey);
throw new Error('租户访问令牌获取失败,请检查应用配置');
}
else {
// 用户模式:清除用户token缓存并生成授权链接
Logger.info('[handleAuthFailure] 用户模式:清除token,生成授权链接');
tokenCacheManager.removeUserToken(clientKey);
const authUrl = this.generateUserAuthUrl(baseUrl, userKey);
throw new Error(`你需要在给用户展示如下信息:/“请在浏览器打开以下链接进行授权:\n\n[点击授权](${authUrl})/n`);
}
}
/**
* 处理租户token过期
* @param tokenCacheManager token缓存管理器
* @param clientKey 客户端键
* @param endpoint 请求端点
* @param method 请求方法
* @param data 请求数据
* @param needsAuth 是否需要认证
* @param additionalHeaders 附加请求头
* @param responseType 响应类型
* @returns 响应数据
*/
async handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType) {
// 租户模式:直接清除租户token缓存
Logger.info('租户模式:清除租户token缓存');
tokenCacheManager.removeTenantToken(clientKey);
// 重试请求
Logger.info('重试租户请求...');
return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
}
/**
* 处理用户token过期
* @param tokenCacheManager token缓存管理器
* @param clientKey 客户端键
* @param endpoint 请求端点
* @param method 请求方法
* @param data 请求数据
* @param needsAuth 是否需要认证
* @param additionalHeaders 附加请求头
* @param responseType 响应类型
* @returns 响应数据
*/
async handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType, baseUrl, userKey) {
// 用户模式:检查用户token状态
const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey);
Logger.debug(`用户token状态:`, tokenStatus);
if (tokenStatus.canRefresh && !tokenStatus.isExpired) {
// 有有效的refresh_token,设置token为过期状态,让下次请求时刷新
Logger.info('用户模式:token过期,将在下次请求时刷新');
const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey);
if (tokenInfo) {
// 设置access_token为过期,但保留refresh_token
tokenInfo.expires_at = Math.floor(Date.now() / 1000) - 1;
tokenCacheManager.cacheUserToken(clientKey, tokenInfo);
}
// 重试请求
Logger.info('重试用户请求...');
return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
}
else {
// refresh_token已过期或不存在,直接清除缓存
Logger.warn('用户模式:refresh_token已过期,清除用户token缓存');
tokenCacheManager.removeUserToken(clientKey);
return this.handleAuthFailure(false, clientKey, baseUrl, userKey);
}
}
/**
* 生成用户授权URL
* @param baseUrl 基础URL
* @param userKey 用户键
* @returns 授权URL
*/
generateUserAuthUrl(baseUrl, userKey) {
const config = Config.getInstance();
const { appId, appSecret } = config.feishu;
const clientKey = AuthUtils.generateClientKey(userKey);
const redirect_uri = `${baseUrl}/callback`;
const authType = config.feishu.authType;
const enabledIds = config.features.enabledModules;
const effectiveModules = ModuleRegistry.getEnabledModules(enabledIds, authType).map(m => m.id);
const scopeList = getRequiredScopes(effectiveModules, authType);
Logger.info(`[generateUserAuthUrl] enabledModules=${effectiveModules.join(',')} authType=${authType} scopes=${scopeList.join(',')}`);
const scope = encodeURIComponent(scopeList.join(' '));
const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri);
return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`;
}
}