├── .nvmrc ├── image ├── appid.png ├── demo.png ├── demo_1.png ├── group_qr.jpg ├── release.png ├── add_robot.png ├── redirect_uri.png ├── Import_permissions.png ├── add_edit_permission.png ├── complete_permissions.png ├── register_application.png ├── add_file_permission_1.png ├── change_permission_range.png ├── share_folder_to_group.png ├── entry_application_detail.png └── create_group_and_add_application.gif ├── .prettierrc ├── .gitmodules ├── src ├── utils │ ├── auth │ │ ├── index.ts │ │ ├── authUtils.ts │ │ ├── userContextManager.ts │ │ ├── userAuthManager.ts │ │ ├── tokenRefreshManager.ts │ │ └── tokenCacheManager.ts │ ├── cache.ts │ ├── error.ts │ ├── paramUtils.ts │ ├── logger.ts │ ├── document.ts │ └── config.ts ├── cli.ts ├── index.ts ├── mcp │ ├── feishuMcp.ts │ └── tools │ │ ├── feishuFolderTools.ts │ │ └── feishuTools.ts ├── manager │ └── sseConnectionManager.ts ├── services │ ├── callbackService.ts │ ├── feishuAuthService.ts │ ├── blockFactory.ts │ └── baseService.ts ├── server.ts └── types │ └── feishuSchema.ts ├── Dockerfile ├── .gitignore ├── .eslintrc ├── .env.example ├── docker-compose.yaml ├── tsconfig.json ├── LICENSE ├── package.json ├── FEISHU_CONFIG.md ├── .github └── workflows │ └── codeql.yml ├── README.md └── doc └── MCP服务实现分享.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.17.0 -------------------------------------------------------------------------------- /image/appid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/appid.png -------------------------------------------------------------------------------- /image/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/demo.png -------------------------------------------------------------------------------- /image/demo_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/demo_1.png -------------------------------------------------------------------------------- /image/group_qr.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/group_qr.jpg -------------------------------------------------------------------------------- /image/release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/release.png -------------------------------------------------------------------------------- /image/add_robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/add_robot.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /image/redirect_uri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/redirect_uri.png -------------------------------------------------------------------------------- /image/Import_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/Import_permissions.png -------------------------------------------------------------------------------- /image/add_edit_permission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/add_edit_permission.png -------------------------------------------------------------------------------- /image/complete_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/complete_permissions.png -------------------------------------------------------------------------------- /image/register_application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/register_application.png -------------------------------------------------------------------------------- /image/add_file_permission_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/add_file_permission_1.png -------------------------------------------------------------------------------- /image/change_permission_range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/change_permission_range.png -------------------------------------------------------------------------------- /image/share_folder_to_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/share_folder_to_group.png -------------------------------------------------------------------------------- /image/entry_application_detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/entry_application_detail.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "doc/wiki-repo"] 2 | path = doc/wiki-repo 3 | url = https://github.com/cso1z/Feishu-MCP.wiki.git 4 | -------------------------------------------------------------------------------- /image/create_group_and_add_application.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cso1z/Feishu-MCP/HEAD/image/create_group_and_add_application.gif -------------------------------------------------------------------------------- /src/utils/auth/index.ts: -------------------------------------------------------------------------------- 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'; 5 | export { TokenRefreshManager } from './tokenRefreshManager.js'; 6 | export type { UserTokenInfo, TenantTokenInfo, TokenStatus } from './tokenCacheManager.js'; 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用官方Node.js运行时作为父镜像 2 | FROM docker.1ms.run/node:20.17.0 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 全局安装 pnpm 8 | RUN npm install -g pnpm --registry=https://registry.npmmirror.com 9 | 10 | # 复制 package 文件 11 | COPY package*.json ./ 12 | 13 | # 禁用 prepare 脚本来避免在安装依赖时构建 14 | RUN pnpm install --registry=https://registry.npmmirror.com --ignore-scripts 15 | 16 | # 复制源代码 17 | COPY . . 18 | 19 | # 手动运行构建步骤 20 | RUN pnpm run build 21 | 22 | # 暴露端口 23 | EXPOSE 3333 24 | 25 | # 启动命令 26 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnpm-store 4 | 5 | # Build output 6 | dist 7 | 8 | # Environment variables 9 | .env 10 | .env.local 11 | .env.*.local 12 | 13 | # IDE 14 | .vscode/* 15 | !.vscode/extensions.json 16 | !.vscode/settings.json 17 | .idea 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | # Logs 25 | logs 26 | *.log 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | pnpm-debug.log* 31 | 32 | # Testing 33 | coverage 34 | 35 | # OS 36 | .DS_Store 37 | Thumbs.db -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { resolve } from "path"; 4 | import { config } from "dotenv"; 5 | import { startServer } from "./index.js"; 6 | 7 | // Load .env from the current working directory 8 | config({ path: resolve(process.cwd(), ".env") }); 9 | 10 | startServer().catch((error: unknown) => { 11 | if (error instanceof Error) { 12 | console.error("Failed to start server:", error.message); 13 | } else { 14 | console.error("Failed to start server with unknown error:", error); 15 | } 16 | process.exit(1); 17 | }); 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" 7 | ], 8 | "plugins": ["@typescript-eslint"], 9 | "parserOptions": { 10 | "ecmaVersion": 2022, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "@typescript-eslint/explicit-function-return-type": "warn", 15 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 16 | "@typescript-eslint/no-explicit-any": "warn" 17 | } 18 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 飞书应用凭证 2 | FEISHU_APP_ID=your_feishu_app_id_here 3 | FEISHU_APP_SECRET=your_feishu_app_secret_here 4 | FEISHU_BASE_URL=https://open.feishu.cn/open-apis 5 | 6 | # 认证凭证类型,支持 tenant(应用级,默认)或 user(用户级,需OAuth授权)注意:只有本地运行服务时支持user凭证,否则就需要配置FEISHU_TOKEN_ENDPOINT,自己实现获取token管理(可以参考 callbackService、feishuAuthService) 7 | FEISHU_AUTH_TYPE=tenant # 可选值:tenant 或 user 8 | 9 | # 服务器配置 10 | PORT=3333 11 | 12 | # 日志配置 13 | LOG_LEVEL=info 14 | LOG_SHOW_TIMESTAMP=true 15 | LOG_SHOW_LEVEL=true 16 | LOG_TIMESTAMP_FORMAT=yyyy-MM-dd HH:mm:ss.SSS 17 | 18 | # 缓存配置 19 | CACHE_ENABLED=true 20 | CACHE_TTL=300 21 | CACHE_MAX_SIZE=100 -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | feishu-mcp: 3 | build: . 4 | ports: 5 | - "3333:3333" 6 | environment: 7 | - FEISHU_APP_ID=${FEISHU_APP_ID} 8 | - FEISHU_APP_SECRET=${FEISHU_APP_SECRET} 9 | - FEISHU_BASE_URL=${FEISHU_BASE_URL:-https://open.feishu.cn/open-apis} 10 | - FEISHU_AUTH_TYPE=${FEISHU_AUTH_TYPE:-tenant} 11 | - PORT=${PORT:-3333} 12 | - LOG_LEVEL=${LOG_LEVEL:-info} 13 | - CACHE_ENABLED=${CACHE_ENABLED:-true} 14 | - CACHE_TTL=${CACHE_TTL:-300} 15 | volumes: 16 | - ./logs:/app/logs 17 | - ./cache:/app/cache 18 | restart: unless-stopped -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": false, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": false, 15 | "outDir": "dist", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 cso1z 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 2 | import { FeishuMcpServer } from "./server.js"; 3 | import { Config } from "./utils/config.js"; 4 | import { fileURLToPath } from 'url'; 5 | import { resolve } from 'path'; 6 | 7 | export async function startServer(): Promise { 8 | // Check if we're running in stdio mode (e.g., via CLI) 9 | const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio"); 10 | 11 | // 获取配置实例 12 | const config = Config.getInstance(); 13 | 14 | // 打印配置信息 15 | config.printConfig(isStdioMode); 16 | 17 | // 验证配置 18 | if (!config.validate()) { 19 | console.error("配置验证失败,无法启动服务器"); 20 | process.exit(1); 21 | } 22 | 23 | // 创建MCP服务器 24 | const server = new FeishuMcpServer(); 25 | 26 | console.log(`isStdioMode:${isStdioMode}`) 27 | 28 | if (isStdioMode) { 29 | const transport = new StdioServerTransport(); 30 | await server.connect(transport); 31 | } else { 32 | console.log(`Initializing Feishu MCP Server in HTTP mode on port ${config.server.port}...`); 33 | await server.startHttpServer(config.server.port); 34 | } 35 | } 36 | 37 | // 跨平台兼容的方式检查是否直接运行 38 | const currentFilePath = fileURLToPath(import.meta.url); 39 | const executedFilePath = resolve(process.argv[1]); 40 | 41 | console.log(`meta.url:${currentFilePath} argv:${executedFilePath}` ); 42 | 43 | if (currentFilePath === executedFilePath) { 44 | console.log(`startServer`); 45 | startServer().catch((error) => { 46 | console.error('Failed to start server:', error); 47 | process.exit(1); 48 | }); 49 | } else { 50 | console.log(`not startServer`); 51 | } 52 | -------------------------------------------------------------------------------- /src/mcp/feishuMcp.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { FeishuApiService } from '../services/feishuApiService.js'; 3 | import { Logger } from '../utils/logger.js'; 4 | import { registerFeishuTools } from './tools/feishuTools.js'; 5 | import { registerFeishuBlockTools } from './tools/feishuBlockTools.js'; 6 | import { registerFeishuFolderTools } from './tools/feishuFolderTools.js'; 7 | 8 | const serverInfo = { 9 | name: "Feishu MCP Server", 10 | version: "0.1.6", 11 | }; 12 | 13 | const serverOptions = { 14 | capabilities: { logging: {}, tools: {} }, 15 | }; 16 | 17 | /** 18 | * 飞书MCP服务类 19 | * 继承自McpServer,提供飞书工具注册和初始化功能 20 | */ 21 | export class FeishuMcp extends McpServer { 22 | private feishuService: FeishuApiService | null = null; 23 | 24 | /** 25 | * 构造函数 26 | */ 27 | constructor() { 28 | super(serverInfo,serverOptions); 29 | 30 | // 初始化飞书服务 31 | this.initFeishuService(); 32 | 33 | // 注册所有工具 34 | if (this.feishuService) { 35 | this.registerAllTools(); 36 | } else { 37 | Logger.error('无法注册飞书工具: 飞书服务初始化失败'); 38 | throw new Error('飞书服务初始化失败'); 39 | } 40 | } 41 | 42 | /** 43 | * 初始化飞书API服务 44 | */ 45 | private initFeishuService(): void { 46 | try { 47 | // 使用单例模式获取飞书服务实例 48 | this.feishuService = FeishuApiService.getInstance(); 49 | Logger.info('飞书服务初始化成功'); 50 | } catch (error) { 51 | Logger.error('飞书服务初始化失败:', error); 52 | this.feishuService = null; 53 | } 54 | } 55 | 56 | /** 57 | * 注册所有飞书MCP工具 58 | */ 59 | private registerAllTools(): void { 60 | if (!this.feishuService) { 61 | return; 62 | } 63 | 64 | // 注册所有工具 65 | registerFeishuTools(this, this.feishuService); 66 | registerFeishuBlockTools(this, this.feishuService); 67 | registerFeishuFolderTools(this, this.feishuService); 68 | } 69 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feishu-mcp", 3 | "version": "0.1.6", 4 | "description": "Model Context Protocol server for Feishu integration", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "bin": { 8 | "feishu-mcp": "./dist/cli.js" 9 | }, 10 | "files": [ 11 | "dist", 12 | "README.md" 13 | ], 14 | "scripts": { 15 | "build": "tsc && tsc-alias", 16 | "type-check": "tsc --noEmit", 17 | "start": "node dist/index.js", 18 | "start:cli": "cross-env NODE_ENV=cli node dist/index.js", 19 | "start:http": "node dist/index.js", 20 | "dev": "cross-env NODE_ENV=development tsx watch src/index.ts", 21 | "dev:cli": "cross-env NODE_ENV=development tsx watch src/index.ts --stdio", 22 | "lint": "eslint . --ext .ts", 23 | "format": "prettier --write \"src/**/*.ts\"", 24 | "inspect": "pnpx @modelcontextprotocol/inspector", 25 | "prepare": "pnpm run build", 26 | "pub:release": "pnpm build && npm publish" 27 | }, 28 | "engines": { 29 | "node": "^20.17.0" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/cso1z/Feishu-MCP.git" 34 | }, 35 | "keywords": [ 36 | "feishu", 37 | "lark", 38 | "mcp", 39 | "typescript" 40 | ], 41 | "author": "cso1z", 42 | "license": "MIT", 43 | "dependencies": { 44 | "@modelcontextprotocol/sdk": "^1.17.5", 45 | "@types/yargs": "^17.0.33", 46 | "axios": "^1.7.9", 47 | "cross-env": "^7.0.3", 48 | "dotenv": "^16.4.7", 49 | "express": "^4.21.2", 50 | "form-data": "^4.0.3", 51 | "remeda": "^2.20.1", 52 | "yargs": "^17.7.2", 53 | "zod": "^3.24.2" 54 | }, 55 | "devDependencies": { 56 | "@types/express": "^5.0.0", 57 | "@types/jest": "^29.5.11", 58 | "@types/node": "^20.17.0", 59 | "@typescript-eslint/eslint-plugin": "^8.24.0", 60 | "@typescript-eslint/parser": "^8.24.0", 61 | "eslint": "^9.20.1", 62 | "eslint-config-prettier": "^10.0.1", 63 | "jest": "^29.7.0", 64 | "prettier": "^3.5.0", 65 | "ts-jest": "^29.2.5", 66 | "tsc-alias": "^1.8.10", 67 | "tsx": "^4.19.2", 68 | "typescript": "^5.7.3" 69 | }, 70 | "pnpm": { 71 | "overrides": { 72 | "feishu-mcp": "link:" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/auth/authUtils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import { Config } from '../config.js'; 3 | 4 | /** 5 | * 认证工具类 6 | * 提供认证相关的加密和哈希工具方法 7 | */ 8 | export class AuthUtils { 9 | 10 | 11 | /** 12 | * 生成客户端缓存键 13 | * @param userKey 用户标识(可选) 14 | * @returns 生成的客户端键 15 | */ 16 | public static generateClientKey(userKey?: string | null): string { 17 | const feishuConfig = Config.getInstance().feishu; 18 | const userPart = userKey ? `:${userKey}` : ''; 19 | let source = '' 20 | if (feishuConfig.authType==="tenant"){ 21 | source = `${feishuConfig.appId}:${feishuConfig.appSecret}`; 22 | }else { 23 | source = `${feishuConfig.appId}:${feishuConfig.appSecret}${userPart}`; 24 | } 25 | return crypto.createHash('sha256').update(source).digest('hex'); 26 | } 27 | 28 | /** 29 | * 生成时间戳 30 | * @returns 当前时间戳(秒) 31 | */ 32 | public static timestamp(): number { 33 | return Math.floor(Date.now() / 1000); 34 | } 35 | 36 | /** 37 | * 生成时间戳(毫秒) 38 | * @returns 当前时间戳(毫秒) 39 | */ 40 | public static timestampMs(): number { 41 | return Date.now(); 42 | } 43 | 44 | /** 45 | * 编码state参数 46 | * @param appId 应用ID 47 | * @param appSecret 应用密钥 48 | * @param clientKey 客户端缓存键 49 | * @param redirectUri 重定向URI(可选) 50 | * @returns Base64编码的state字符串 51 | */ 52 | public static encodeState(appId: string, appSecret: string, clientKey: string, redirectUri?: string): string { 53 | const stateData = { 54 | appId, 55 | appSecret, 56 | clientKey, 57 | redirectUri, 58 | timestamp: this.timestamp() 59 | }; 60 | return Buffer.from(JSON.stringify(stateData)).toString('base64'); 61 | } 62 | 63 | /** 64 | * 解码state参数 65 | * @param encodedState Base64编码的state字符串 66 | * @returns 解码后的state数据 67 | */ 68 | public static decodeState(encodedState: string): { 69 | appId: string; 70 | appSecret: string; 71 | clientKey: string; 72 | redirectUri?: string; 73 | timestamp: number; 74 | } | null { 75 | try { 76 | const decoded = Buffer.from(encodedState, 'base64').toString('utf-8'); 77 | return JSON.parse(decoded); 78 | } catch (error) { 79 | return null; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/auth/userContextManager.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'async_hooks'; 2 | import { Request } from 'express'; 3 | 4 | /** 5 | * 用户上下文接口 6 | */ 7 | interface UserContext { 8 | userKey: string; 9 | baseUrl: string; 10 | } 11 | 12 | /** 13 | * 用户上下文管理器 14 | * 使用 AsyncLocalStorage 在异步调用链中传递用户信息 15 | */ 16 | export class UserContextManager { 17 | private static instance: UserContextManager; 18 | private readonly asyncLocalStorage: AsyncLocalStorage; 19 | 20 | private constructor() { 21 | this.asyncLocalStorage = new AsyncLocalStorage(); 22 | } 23 | 24 | /** 25 | * 获取单例实例 26 | */ 27 | public static getInstance(): UserContextManager { 28 | if (!UserContextManager.instance) { 29 | UserContextManager.instance = new UserContextManager(); 30 | } 31 | return UserContextManager.instance; 32 | } 33 | 34 | /** 35 | * 在指定上下文中运行回调函数 36 | * @param context 用户上下文 37 | * @param callback 回调函数 38 | * @returns 回调函数的返回值 39 | */ 40 | public run(context: UserContext, callback: () => T): T { 41 | return this.asyncLocalStorage.run(context, callback); 42 | } 43 | 44 | /** 45 | * 获取当前上下文中的用户密钥 46 | * @returns 用户密钥,如果不存在则返回空字符串 47 | */ 48 | public getUserKey(): string { 49 | const context = this.asyncLocalStorage.getStore(); 50 | return context?.userKey || ''; 51 | } 52 | 53 | /** 54 | * 获取当前上下文中的基础URL 55 | * @returns 基础URL,如果不存在则返回空字符串 56 | */ 57 | public getBaseUrl(): string { 58 | const context = this.asyncLocalStorage.getStore(); 59 | return context?.baseUrl || ''; 60 | } 61 | 62 | /** 63 | * 获取当前完整的用户上下文 64 | * @returns 用户上下文,如果不存在则返回 undefined 65 | */ 66 | public getContext(): UserContext | undefined { 67 | return this.asyncLocalStorage.getStore(); 68 | } 69 | 70 | /** 71 | * 检查是否存在用户上下文 72 | * @returns 如果存在用户上下文则返回 true 73 | */ 74 | public hasContext(): boolean { 75 | return this.asyncLocalStorage.getStore() !== undefined; 76 | } 77 | } 78 | 79 | /** 80 | * 获取协议 81 | */ 82 | function getProtocol(req: Request): string { 83 | if (req.secure || req.get('X-Forwarded-Proto') === 'https') { 84 | return 'https'; 85 | } 86 | return 'http'; 87 | } 88 | 89 | /** 90 | * 获取基础URL 91 | */ 92 | export function getBaseUrl(req: Request): string { 93 | const protocol = getProtocol(req); 94 | const host = req.get('X-Forwarded-Host') || req.get('host'); 95 | return `${protocol}://${host}`; 96 | } 97 | -------------------------------------------------------------------------------- /src/utils/auth/userAuthManager.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../logger.js'; 2 | 3 | /** 4 | * 用户认证管理器 5 | * 管理 sessionId 与 userKey 的映射关系 6 | */ 7 | export class UserAuthManager { 8 | private static instance: UserAuthManager; 9 | private sessionToUserKey: Map; // sessionId -> userKey 10 | 11 | /** 12 | * 私有构造函数,用于单例模式 13 | */ 14 | private constructor() { 15 | this.sessionToUserKey = new Map(); 16 | } 17 | 18 | /** 19 | * 获取用户认证管理器实例 20 | * @returns 用户认证管理器实例 21 | */ 22 | public static getInstance(): UserAuthManager { 23 | if (!UserAuthManager.instance) { 24 | UserAuthManager.instance = new UserAuthManager(); 25 | } 26 | return UserAuthManager.instance; 27 | } 28 | 29 | /** 30 | * 创建用户会话 31 | * @param sessionId 会话ID 32 | * @param userKey 用户密钥 33 | * @returns 是否创建成功 34 | */ 35 | public createSession(sessionId: string, userKey: string): boolean { 36 | if (!sessionId || !userKey) { 37 | Logger.warn('创建会话失败:sessionId 或 userKey 为空'); 38 | return false; 39 | } 40 | 41 | this.sessionToUserKey.set(sessionId, userKey); 42 | 43 | Logger.info(`创建用户会话:sessionId=${sessionId}, userKey=${userKey}`); 44 | return true; 45 | } 46 | 47 | /** 48 | * 根据 sessionId 获取 userKey 49 | * @param sessionId 会话ID 50 | * @returns 用户密钥,如果未找到则返回 null 51 | */ 52 | public getUserKeyBySessionId(sessionId: string): string | null { 53 | if (!sessionId) { 54 | return null; 55 | } 56 | 57 | const userKey = this.sessionToUserKey.get(sessionId); 58 | if (!userKey) { 59 | Logger.debug(`未找到会话:${sessionId}`); 60 | return null; 61 | } 62 | 63 | Logger.debug(`获取用户密钥:sessionId=${sessionId}, userKey=${userKey}`); 64 | return userKey; 65 | } 66 | 67 | /** 68 | * 删除会话 69 | * @param sessionId 会话ID 70 | * @returns 是否删除成功 71 | */ 72 | public removeSession(sessionId: string): boolean { 73 | if (!sessionId) { 74 | return false; 75 | } 76 | 77 | const userKey = this.sessionToUserKey.get(sessionId); 78 | if (!userKey) { 79 | Logger.debug(`会话不存在:${sessionId}`); 80 | return false; 81 | } 82 | 83 | this.sessionToUserKey.delete(sessionId); 84 | 85 | Logger.info(`删除用户会话:sessionId=${sessionId}, userKey=${userKey}`); 86 | return true; 87 | } 88 | 89 | /** 90 | * 检查会话是否存在 91 | * @param sessionId 会话ID 92 | * @returns 会话是否存在 93 | */ 94 | public hasSession(sessionId: string): boolean { 95 | return this.sessionToUserKey.has(sessionId); 96 | } 97 | 98 | /** 99 | * 获取所有会话统计信息 100 | * @returns 会话统计信息 101 | */ 102 | public getStats(): { 103 | totalSessions: number; 104 | } { 105 | return { 106 | totalSessions: this.sessionToUserKey.size 107 | }; 108 | } 109 | 110 | /** 111 | * 清空所有会话 112 | */ 113 | public clearAllSessions(): void { 114 | const count = this.sessionToUserKey.size; 115 | this.sessionToUserKey.clear(); 116 | Logger.info(`清空所有会话,删除了 ${count} 个会话`); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/manager/sseConnectionManager.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 3 | import { Logger } from '../utils/logger.js'; 4 | 5 | /** 6 | * SSE连接管理器 - 负责管理所有的SSE长连接和心跳机制 7 | */ 8 | export class SSEConnectionManager { 9 | private transports: { [sessionId: string]: SSEServerTransport } = {}; 10 | private connections: Map = new Map(); 11 | private keepAliveIntervalId: NodeJS.Timeout | null = null; 12 | private readonly KEEP_ALIVE_INTERVAL_MS = 1000 * 25; // 25秒心跳间隔 13 | 14 | constructor() { 15 | this.startGlobalKeepAlive(); 16 | } 17 | 18 | /** 19 | * 启动全局心跳管理 20 | */ 21 | private startGlobalKeepAlive(): void { 22 | if (this.keepAliveIntervalId) { 23 | clearInterval(this.keepAliveIntervalId); 24 | } 25 | 26 | this.keepAliveIntervalId = setInterval(() => { 27 | for (const [sessionId, connection] of this.connections.entries()) { 28 | if (!connection.res.writableEnded) { 29 | connection.res.write(': keepalive\n\n'); 30 | } else { 31 | // 移除已关闭的连接 32 | this.removeConnection(sessionId); 33 | } 34 | } 35 | }, this.KEEP_ALIVE_INTERVAL_MS); 36 | } 37 | 38 | /** 39 | * 添加新的SSE连接 40 | */ 41 | public addConnection( 42 | sessionId: string, 43 | transport: SSEServerTransport, 44 | req: Request, 45 | res: Response, 46 | ): void { 47 | this.transports[sessionId] = transport; 48 | this.connections.set(sessionId, { res }); 49 | console.info(`[SSE Connection] Client connected: ${sessionId}`); 50 | req.on('close', () => { 51 | this.removeConnection(sessionId); 52 | }); 53 | } 54 | 55 | /** 56 | * 移除SSE连接 57 | */ 58 | public removeConnection(sessionId: string): void { 59 | const transport = this.transports[sessionId]; 60 | if (transport) { 61 | try { 62 | transport.close(); 63 | Logger.info(`[SSE Connection] Transport closed for: ${sessionId}`); 64 | } catch (error) { 65 | Logger.error(`[SSE Connection] Error closing transport for: ${sessionId}`, error); 66 | } 67 | } 68 | delete this.transports[sessionId]; 69 | this.connections.delete(sessionId); 70 | console.info(`[SSE Connection] Client disconnected: ${sessionId}`); 71 | } 72 | 73 | /** 74 | * 获取指定sessionId的传输实例 75 | */ 76 | public getTransport(sessionId: string): SSEServerTransport | undefined { 77 | console.info(`[SSE Connection] Getting transport for sessionId: ${sessionId}`); 78 | return this.transports[sessionId]; 79 | } 80 | 81 | /** 82 | * 关闭连接管理器 83 | */ 84 | public shutdown() { 85 | if (this.keepAliveIntervalId) { 86 | clearInterval(this.keepAliveIntervalId); 87 | this.keepAliveIntervalId = null; 88 | } 89 | 90 | // 关闭所有连接 91 | Logger.info(`[SSE Connection] Shutting down all connections (${this.connections.size} active)`); 92 | for (const sessionId of this.connections.keys()) { 93 | this.removeConnection(sessionId); 94 | } 95 | Logger.info(`[SSE Connection] All connections closed`); 96 | } 97 | } -------------------------------------------------------------------------------- /FEISHU_CONFIG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 详细步骤 3 | ### 一、注册飞书应用 4 | * url:https://open.feishu.cn/app?lang=zh-CN 5 | 6 | ![注册飞书应用](image/register_application.png) 7 | ### 二、为应用添加权限 8 | 创建飞书应用完成后,我们需要为该应用添加飞书文档相关的权限,让应用拥有访问创建文档相关的能力 9 | #### 1. 点击我们第一步创建的应用 10 | ![进入应用详情](image/entry_application_detail.png) 11 | #### 2. 导入权限 12 | ![导入权限](image/Import_permissions.png) 13 | 全下如下 14 | ``` 15 | { 16 | "scopes": { 17 | "tenant": [ 18 | "docx:document.block:convert", 19 | "base:app:read", 20 | "bitable:app", 21 | "bitable:app:readonly", 22 | "board:whiteboard:node:read", 23 | "contact:user.employee_id:readonly", 24 | "docs:document.content:read", 25 | "docx:document", 26 | "docx:document:create", 27 | "docx:document:readonly", 28 | "drive:drive", 29 | "drive:drive:readonly", 30 | "drive:file", 31 | "drive:file:upload", 32 | "sheets:spreadsheet", 33 | "sheets:spreadsheet:readonly", 34 | "space:document:retrieve", 35 | "space:folder:create", 36 | "wiki:space:read", 37 | "wiki:space:retrieve", 38 | "wiki:wiki", 39 | "wiki:wiki:readonly" 40 | ], 41 | "user": [ 42 | "docx:document.block:convert", 43 | "base:app:read", 44 | "bitable:app", 45 | "bitable:app:readonly", 46 | "board:whiteboard:node:read", 47 | "contact:user.employee_id:readonly", 48 | "docs:document.content:read", 49 | "docx:document", 50 | "docx:document:create", 51 | "docx:document:readonly", 52 | "drive:drive", 53 | "drive:drive:readonly", 54 | "drive:file", 55 | "drive:file:upload", 56 | "sheets:spreadsheet", 57 | "sheets:spreadsheet:readonly", 58 | "space:document:retrieve", 59 | "space:folder:create", 60 | "wiki:space:read", 61 | "wiki:space:retrieve", 62 | "wiki:wiki", 63 | "wiki:wiki:readonly", 64 | "offline_access" 65 | ] 66 | } 67 | } 68 | ``` 69 | #### 3. 发布审批应用(注:**可用范围选择全部**) 70 | ![发布审批应用](image/release.png) 71 | #### 4. 等待管理员审批通过 72 | ![发布审批应用完成](image/complete_permissions.png) 73 | 74 | ### 三、为应用添加访问文件的权限 75 | 要添加应用为文档协作者,主要有以下两种方式: 76 | #### 方式一:直接添加应用为云文档的协作者(作用于单个文档) 77 | 该方式要求操作者为云文档所有者、拥有文档管理权限的协作者或知识库管理员。操作者可通过云文档网页页面右上方「...」->「...更多」-> 「添加文档应用」入口添加。 78 | > 1. 在 添加文档应用 前,你需确保发布版本的[可用范围](https://open.feishu.cn/document/develop-process/test-and-release-app/availability)包含节点云文档的所有者。否则你将无法在文档应用窗口搜索到目标应用。 79 | > 2. 在 添加文档应用 前,你需确保目标应用至少开通了任意一个云文档 [API 权限](https://open.feishu.cn/document/server-docs/application-scope/scope-list)。否则你将无法在文档应用窗口搜索到目标应用。 80 | 81 | ![直接添加应用为云文档的协作者](image/add_file_permission_1.png) 82 | 83 | #### 方式二:添加包含应用的群组为云文档资源的协作者 84 | #### 1. 访问[开发者后台](https://open.feishu.cn/app),选择目标应用 85 | 86 | #### 2. 在应用管理页面,点击添加应用能力,找到机器人卡片,点击 +添加。 87 | ![添加机器人](image/add_robot.png) 88 | 89 | #### 3. 发布当前应用版本,并确保发布版本的可用范围包含云文档资源的所有者。 90 | ![发布当前应用版本,并确保发布版本的可用范围包含云文档资源的所有者](image/change_permission_range.png) 91 | 注:每次发布都需要管理员审核通过 92 | 93 | #### 4. 在飞书客户端,创建一个新的群组,将应用添加为群机器人。 94 | >注意 此处要添加应用作为机器人,而不是添加“自定义机器人”。 95 | 96 | ![创建一个新的群组,将应用添加为群机器人](image/create_group_and_add_application.gif) 97 | 98 | 注:如果找不到应用,可以排查下上面第三条 99 | 100 | #### 5. 在目标云文档页面的 分享 入口,邀请刚刚新建的群组作为协作者,并设置权限。 101 | ![赋予应用文件夹权限](image/share_folder_to_group.png) 102 | 103 | ![赋予编辑权限](image/add_edit_permission.png) 104 | 105 | ### 四、添加redirect_uri回调地址:http://localhost:3333/callback (3333为mcp server默认端口) 106 | * 注意如果是部署在服务器上时对应的host和port是需要更换 107 | ![安全设置](image/redirect_uri.png) 108 | 109 | ### 五、查看应用app Id与app Secret 110 | ![应用详情](image/appid.png) 111 | 112 | ### 六、配置cursor 113 | ``` 114 | { 115 | "mcpServers": { 116 | "feishu": { 117 | "url": "http://localhost:3333/sse?userKey=123456789" 118 | } 119 | } 120 | } 121 | ``` 122 | 123 | ### 六、注 124 | 1. 具体可参见[官方云文档常见问题](https://open.feishu.cn/document/server-docs/docs/faq) 125 | 1. 具体可参见[知识库常见问题](https://open.feishu.cn/document/server-docs/docs/wiki-v2/wiki-qa) 126 | -------------------------------------------------------------------------------- /src/mcp/tools/feishuFolderTools.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { formatErrorMessage } from '../../utils/error.js'; 3 | import { FeishuApiService } from '../../services/feishuApiService.js'; 4 | import { Logger } from '../../utils/logger.js'; 5 | import { 6 | FolderTokenSchema, 7 | FolderNameSchema, 8 | } from '../../types/feishuSchema.js'; 9 | import { Config } from '../../utils/config.js'; 10 | 11 | /** 12 | * 注册飞书文件夹相关的MCP工具 13 | * @param server MCP服务器实例 14 | * @param feishuService 飞书API服务实例 15 | */ 16 | export function registerFeishuFolderTools(server: McpServer, feishuService: FeishuApiService | null): void { 17 | 18 | const config = Config.getInstance(); 19 | 20 | // 添加获取根文件夹信息工具 21 | if (config.feishu.authType === 'user') { 22 | server.tool( 23 | 'get_feishu_root_folder_info', 24 | 'Retrieves basic information about the root folder in Feishu Drive. Returns the token, ID and user ID of the root folder, which can be used for subsequent folder operations.', 25 | {}, 26 | async () => { 27 | try { 28 | if (!feishuService) { 29 | return { 30 | content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 31 | }; 32 | } 33 | 34 | Logger.info(`开始获取飞书根文件夹信息`); 35 | const folderInfo = await feishuService.getRootFolderInfo(); 36 | Logger.info(`飞书根文件夹信息获取成功,token: ${folderInfo.token}`); 37 | 38 | return { 39 | content: [{ type: 'text', text: JSON.stringify(folderInfo, null, 2) }], 40 | }; 41 | } catch (error) { 42 | Logger.error(`获取飞书根文件夹信息失败:`, error); 43 | const errorMessage = formatErrorMessage(error, '获取飞书根文件夹信息失败'); 44 | return { 45 | content: [{ type: 'text', text: errorMessage }], 46 | }; 47 | } 48 | }, 49 | ); 50 | } 51 | 52 | 53 | // 添加获取文件夹中的文件清单工具 54 | server.tool( 55 | 'get_feishu_folder_files', 56 | 'Retrieves a list of files and subfolders in a specified folder. Use this to explore folder contents, view file metadata, and get URLs and tokens for further operations.', 57 | { 58 | folderToken: FolderTokenSchema, 59 | }, 60 | async ({ folderToken, }) => { 61 | try { 62 | if (!feishuService) { 63 | return { 64 | content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 65 | }; 66 | } 67 | 68 | Logger.info(`开始获取飞书文件夹中的文件清单,文件夹Token: ${folderToken}`); 69 | const fileList = await feishuService.getFolderFileList(folderToken); 70 | Logger.info(`飞书文件夹中的文件清单获取成功,共 ${fileList.files?.length || 0} 个文件`); 71 | 72 | return { 73 | content: [{ type: 'text', text: JSON.stringify(fileList, null, 2) }], 74 | }; 75 | } catch (error) { 76 | Logger.error(`获取飞书文件夹中的文件清单失败:`, error); 77 | const errorMessage = formatErrorMessage(error); 78 | return { 79 | content: [{ type: 'text', text: `获取飞书文件夹中的文件清单失败: ${errorMessage}` }], 80 | }; 81 | } 82 | }, 83 | ); 84 | 85 | // 添加创建文件夹工具 86 | server.tool( 87 | 'create_feishu_folder', 88 | 'Creates a new folder in a specified parent folder. Use this to organize documents and files within your Feishu Drive structure. Returns the token and URL of the newly created folder.', 89 | { 90 | folderToken: FolderTokenSchema, 91 | folderName: FolderNameSchema, 92 | }, 93 | async ({ folderToken, folderName }) => { 94 | try { 95 | if (!feishuService) { 96 | return { 97 | content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 98 | }; 99 | } 100 | 101 | Logger.info(`开始创建飞书文件夹,父文件夹Token: ${folderToken},文件夹名称: ${folderName}`); 102 | const result = await feishuService.createFolder(folderToken, folderName); 103 | Logger.info(`飞书文件夹创建成功,token: ${result.token},URL: ${result.url}`); 104 | 105 | return { 106 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 107 | }; 108 | } catch (error) { 109 | Logger.error(`创建飞书文件夹失败:`, error); 110 | const errorMessage = formatErrorMessage(error); 111 | return { 112 | content: [{ type: 'text', text: `创建飞书文件夹失败: ${errorMessage}` }], 113 | }; 114 | } 115 | }, 116 | ); 117 | } -------------------------------------------------------------------------------- /src/services/callbackService.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { AuthService } from './feishuAuthService.js'; 3 | import { Config } from '../utils/config.js'; 4 | import { renderFeishuAuthResultHtml } from '../utils/document.js'; 5 | import { AuthUtils,TokenCacheManager } from '../utils/auth/index.js'; 6 | 7 | // 通用响应码 8 | const CODE = { 9 | SUCCESS: 0, 10 | PARAM_ERROR: 400, 11 | CUSTOM: 500, 12 | }; 13 | 14 | // 封装响应方法 15 | function sendSuccess(res: Response, data: any) { 16 | const html = renderFeishuAuthResultHtml(data); 17 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 18 | res.status(200).send(html); 19 | } 20 | function sendFail(res: Response, msg: string, code: number = CODE.CUSTOM) { 21 | const html = renderFeishuAuthResultHtml({ error: msg, code }); 22 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 23 | res.status(200).send(html); 24 | } 25 | 26 | const authService = new AuthService(); 27 | const config = Config.getInstance(); 28 | 29 | export async function callback(req: Request, res: Response) { 30 | const code = req.query.code as string; 31 | const state = req.query.state as string; 32 | console.log(`[callback] query:`, req.query); 33 | 34 | if (!code) { 35 | console.log('[callback] 缺少code参数'); 36 | return sendFail(res, '缺少code参数', CODE.PARAM_ERROR); 37 | } 38 | 39 | if (!state) { 40 | console.log('[callback] 缺少state参数'); 41 | return sendFail(res, '缺少state参数', CODE.PARAM_ERROR); 42 | } 43 | 44 | // 解析state参数 45 | const stateData = AuthUtils.decodeState(state); 46 | if (!stateData) { 47 | console.log('[callback] state参数解析失败'); 48 | return sendFail(res, 'state参数格式错误', CODE.PARAM_ERROR); 49 | } 50 | 51 | const { appId, appSecret, clientKey, redirectUri } = stateData; 52 | console.log(`[callback] 解析state成功:`, { appId, clientKey, redirectUri }); 53 | 54 | // 验证state中的appId和appSecret是否与配置匹配 55 | const configAppId = config.feishu.appId; 56 | const configAppSecret = config.feishu.appSecret; 57 | if (appId !== configAppId || appSecret !== configAppSecret) { 58 | console.log('[callback] state中的appId或appSecret与配置不匹配'); 59 | return sendFail(res, 'state参数验证失败', CODE.PARAM_ERROR); 60 | } 61 | 62 | // 使用从state中解析的redirect_uri,如果没有则使用默认值 63 | const redirect_uri = redirectUri || `http://localhost:${config.server.port}/callback`; 64 | const session = (req as any).session; 65 | const code_verifier = session?.code_verifier || undefined; 66 | 67 | try { 68 | // 获取 user_access_token 69 | const tokenResp = await authService.getUserTokenByCode({ 70 | client_id: appId, 71 | client_secret: appSecret, 72 | code, 73 | redirect_uri, 74 | code_verifier 75 | }); 76 | const data = (tokenResp && typeof tokenResp === 'object') ? tokenResp : undefined; 77 | console.log('[callback] feishu response:', data); 78 | 79 | if (!data || data.code !== 0 || !data.access_token) { 80 | return sendFail(res, `获取 access_token 失败,飞书返回: ${JSON.stringify(tokenResp)}`, CODE.CUSTOM); 81 | } 82 | 83 | // 使用TokenCacheManager缓存token信息 84 | const tokenCacheManager = TokenCacheManager.getInstance(); 85 | if (data.access_token && data.expires_in) { 86 | // 计算过期时间戳 87 | data.expires_at = Math.floor(Date.now() / 1000) + data.expires_in; 88 | if (data.refresh_token_expires_in) { 89 | data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in; 90 | } 91 | 92 | // 添加client_id和client_secret,用于后续刷新token 93 | data.client_id = appId; 94 | data.client_secret = appSecret; 95 | 96 | // 缓存token信息 97 | const refreshTtl = data.refresh_token_expires_in || 3600 * 24 * 365; // 默认1年 98 | tokenCacheManager.cacheUserToken(clientKey, data, refreshTtl); 99 | console.log(`[callback] token已缓存到clientKey: ${clientKey}`); 100 | } 101 | 102 | // 获取用户信息 103 | const access_token = data.access_token; 104 | let userInfo = null; 105 | if (access_token) { 106 | userInfo = await authService.getUserInfo(access_token); 107 | console.log('[callback] feishu userInfo:', userInfo); 108 | } 109 | 110 | return sendSuccess(res, { ...data, userInfo, clientKey }); 111 | } catch (e) { 112 | console.error('[callback] 请求飞书token或用户信息失败:', e); 113 | return sendFail(res, `请求飞书token或用户信息失败: ${e}`, CODE.CUSTOM); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '20 5 * * 4' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: javascript-typescript 47 | build-mode: none 48 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 49 | # Use `c-cpp` to analyze code written in C, C++ or both 50 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 51 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 52 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 53 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 54 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 55 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | # Add any setup steps before running the `github/codeql-action/init` action. 61 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 62 | # or others). This is typically only required for manual builds. 63 | # - name: Setup runtime (example) 64 | # uses: actions/setup-example@v1 65 | 66 | # Initializes the CodeQL tools for scanning. 67 | - name: Initialize CodeQL 68 | uses: github/codeql-action/init@v3 69 | with: 70 | languages: ${{ matrix.language }} 71 | build-mode: ${{ matrix.build-mode }} 72 | # If you wish to specify custom queries, you can do so here or in a config file. 73 | # By default, queries listed here will override any specified in a config file. 74 | # Prefix the list here with "+" to use these queries and those in the config file. 75 | 76 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 77 | # queries: security-extended,security-and-quality 78 | 79 | # If the analyze step fails for one of the languages you are analyzing with 80 | # "We were unable to automatically build your code", modify the matrix above 81 | # to set the build mode to "manual" for that language. Then modify this step 82 | # to build your code. 83 | # ℹ️ Command-line programs to run using the OS shell. 84 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 85 | - if: matrix.build-mode == 'manual' 86 | shell: bash 87 | run: | 88 | echo 'If you are using a "manual" build mode for one or more of the' \ 89 | 'languages you are analyzing, replace this with the commands to build' \ 90 | 'your code, for example:' 91 | echo ' make bootstrap' 92 | echo ' make release' 93 | exit 1 94 | 95 | - name: Perform CodeQL Analysis 96 | uses: github/codeql-action/analyze@v3 97 | with: 98 | category: "/language:${{matrix.language}}" 99 | -------------------------------------------------------------------------------- /src/utils/auth/tokenRefreshManager.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../logger.js'; 2 | import { TokenCacheManager } from './tokenCacheManager.js'; 3 | import { AuthService } from '../../services/feishuAuthService.js'; 4 | 5 | /** 6 | * Token自动刷新管理器 7 | * 定期检查并自动刷新即将过期的用户token 8 | */ 9 | export class TokenRefreshManager { 10 | private static instance: TokenRefreshManager; 11 | private intervalId: NodeJS.Timeout | null = null; 12 | private readonly checkInterval: number = 5 * 60 * 1000; // 5分钟 13 | private isRunning: boolean = false; 14 | 15 | /** 16 | * 私有构造函数,用于单例模式 17 | */ 18 | private constructor() { 19 | Logger.info('Token刷新管理器已初始化'); 20 | } 21 | 22 | /** 23 | * 获取TokenRefreshManager实例 24 | */ 25 | public static getInstance(): TokenRefreshManager { 26 | if (!TokenRefreshManager.instance) { 27 | TokenRefreshManager.instance = new TokenRefreshManager(); 28 | } 29 | return TokenRefreshManager.instance; 30 | } 31 | 32 | /** 33 | * 启动自动刷新检查 34 | */ 35 | public start(): void { 36 | if (this.isRunning) { 37 | Logger.warn('Token刷新管理器已在运行中'); 38 | return; 39 | } 40 | 41 | Logger.info(`启动Token自动刷新管理器,检查间隔: ${this.checkInterval / 1000}秒`); 42 | 43 | // 立即执行一次检查 44 | this.checkAndRefreshTokens(); 45 | 46 | // 设置定时器 47 | this.intervalId = setInterval(() => { 48 | this.checkAndRefreshTokens(); 49 | }, this.checkInterval); 50 | 51 | this.isRunning = true; 52 | Logger.info('Token自动刷新管理器已启动'); 53 | } 54 | 55 | /** 56 | * 停止自动刷新检查 57 | */ 58 | public stop(): void { 59 | if (!this.isRunning) { 60 | Logger.warn('Token刷新管理器未在运行'); 61 | return; 62 | } 63 | 64 | if (this.intervalId) { 65 | clearInterval(this.intervalId); 66 | this.intervalId = null; 67 | } 68 | 69 | this.isRunning = false; 70 | Logger.info('Token自动刷新管理器已停止'); 71 | } 72 | 73 | /** 74 | * 检查并刷新即将过期的token 75 | */ 76 | private async checkAndRefreshTokens(): Promise { 77 | try { 78 | Logger.debug('开始检查需要刷新的token'); 79 | const tokenCacheManager = TokenCacheManager.getInstance(); 80 | 81 | // 获取所有用户token 82 | const allCacheKeys = this.getAllUserTokenKeys(); 83 | let checkedCount = 0; 84 | let refreshedCount = 0; 85 | let failedCount = 0; 86 | 87 | for (const clientKey of allCacheKeys) { 88 | checkedCount++; 89 | 90 | try { 91 | const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey); 92 | 93 | // 检查是否需要刷新:token即将过期(5分钟内)且可以刷新 94 | if (tokenStatus.shouldRefresh || (tokenStatus.canRefresh && tokenStatus.isExpired)) { 95 | Logger.info(`检测到需要刷新的token: ${clientKey}`); 96 | 97 | const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey); 98 | if (!tokenInfo) { 99 | Logger.warn(`无法获取token信息: ${clientKey}`); 100 | failedCount++; 101 | continue; 102 | } 103 | 104 | // 验证是否有刷新所需的必要信息 105 | if (!tokenInfo.refresh_token) { 106 | Logger.warn(`token没有refresh_token,无法刷新: ${clientKey}`); 107 | failedCount++; 108 | continue; 109 | } 110 | 111 | if (!tokenInfo.client_id || !tokenInfo.client_secret) { 112 | Logger.warn(`token缺少client_id或client_secret,无法刷新: ${clientKey}`); 113 | failedCount++; 114 | continue; 115 | } 116 | 117 | // 执行刷新,使用AuthService的统一刷新方法 118 | try { 119 | const authService = new AuthService(); 120 | await authService.refreshUserToken(clientKey); 121 | refreshedCount++; 122 | Logger.info(`token刷新成功: ${clientKey}`); 123 | } catch (error: any) { 124 | failedCount++; 125 | Logger.warn(`token刷新失败: ${clientKey}`, error); 126 | 127 | // 如果刷新失败是因为refresh_token无效,清除缓存 128 | if (error?.response?.data?.code === 99991669 || error?.message?.includes('refresh_token')) { 129 | Logger.warn(`refresh_token无效,清除缓存: ${clientKey}`); 130 | tokenCacheManager.removeUserToken(clientKey); 131 | } 132 | } 133 | } else { 134 | Logger.debug(`token状态正常,无需刷新: ${clientKey}`, { 135 | isValid: tokenStatus.isValid, 136 | isExpired: tokenStatus.isExpired, 137 | canRefresh: tokenStatus.canRefresh, 138 | shouldRefresh: tokenStatus.shouldRefresh 139 | }); 140 | } 141 | } catch (error) { 142 | Logger.error(`检查token时发生错误: ${clientKey}`, error); 143 | failedCount++; 144 | } 145 | } 146 | 147 | if (refreshedCount > 0 || failedCount > 0) { 148 | Logger.info(`Token刷新检查完成: 检查${checkedCount}个,刷新${refreshedCount}个,失败${failedCount}个`); 149 | } else { 150 | Logger.debug(`Token刷新检查完成: 检查${checkedCount}个,无需刷新`); 151 | } 152 | } catch (error) { 153 | Logger.error('检查并刷新token时发生错误:', error); 154 | } 155 | } 156 | 157 | /** 158 | * 获取所有用户token的key列表 159 | */ 160 | private getAllUserTokenKeys(): string[] { 161 | try { 162 | const tokenCacheManager = TokenCacheManager.getInstance(); 163 | return tokenCacheManager.getAllUserTokenKeys(); 164 | } catch (error) { 165 | Logger.error('获取所有用户token key时发生错误:', error); 166 | return []; 167 | } 168 | } 169 | 170 | 171 | /** 172 | * 获取运行状态 173 | */ 174 | public isRunningStatus(): boolean { 175 | return this.isRunning; 176 | } 177 | } 178 | 179 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './config.js'; 2 | import { Logger } from './logger.js'; 3 | 4 | /** 5 | * 缓存项接口 6 | */ 7 | interface CacheItem { 8 | data: T; 9 | timestamp: number; 10 | expiresAt: number; 11 | } 12 | 13 | /** 14 | * 缓存管理器类 15 | * 提供内存缓存功能,支持TTL和最大容量限制 16 | */ 17 | export class CacheManager { 18 | private static instance: CacheManager; 19 | private cache: Map>; 20 | private readonly config: Config; 21 | 22 | /** 23 | * 私有构造函数,用于单例模式 24 | */ 25 | private constructor() { 26 | this.cache = new Map(); 27 | this.config = Config.getInstance(); 28 | 29 | // 定期清理过期缓存 30 | setInterval(() => { 31 | this.cleanExpiredCache(); 32 | }, 60000); // 每分钟清理一次过期缓存 33 | } 34 | 35 | /** 36 | * 获取缓存管理器实例 37 | * @returns 缓存管理器实例 38 | */ 39 | public static getInstance(): CacheManager { 40 | if (!CacheManager.instance) { 41 | CacheManager.instance = new CacheManager(); 42 | } 43 | return CacheManager.instance; 44 | } 45 | 46 | /** 47 | * 设置缓存 48 | * @param key 缓存键 49 | * @param data 缓存数据 50 | * @param ttl 缓存生存时间(秒),默认使用配置中的TTL 51 | * @returns 是否成功设置缓存 52 | */ 53 | public set(key: string, data: T, ttl?: number): boolean { 54 | if (!this.config.cache.enabled) { 55 | return false; 56 | } 57 | 58 | // 如果缓存已达到最大容量,清理最早的条目 59 | if (this.cache.size >= this.config.cache.maxSize) { 60 | this.cleanOldestCache(); 61 | } 62 | 63 | const now = Date.now(); 64 | const actualTtl = ttl || this.config.cache.ttl; 65 | 66 | this.cache.set(key, { 67 | data, 68 | timestamp: now, 69 | expiresAt: now + (actualTtl * 1000) 70 | }); 71 | 72 | Logger.debug(`缓存设置: ${key} (TTL: ${actualTtl}秒)`); 73 | return true; 74 | } 75 | 76 | /** 77 | * 获取缓存 78 | * @param key 缓存键 79 | * @returns 缓存数据,如果未找到或已过期则返回null 80 | */ 81 | public get(key: string): T | null { 82 | if (!this.config.cache.enabled) { 83 | return null; 84 | } 85 | 86 | const cacheItem = this.cache.get(key); 87 | if (!cacheItem) { 88 | Logger.debug(`缓存未命中: ${key}`); 89 | return null; 90 | } 91 | 92 | // 检查是否过期 93 | if (Date.now() > cacheItem.expiresAt) { 94 | Logger.debug(`缓存已过期: ${key}`); 95 | this.cache.delete(key); 96 | return null; 97 | } 98 | 99 | Logger.debug(`缓存命中: ${key}`); 100 | return cacheItem.data as T; 101 | } 102 | 103 | /** 104 | * 删除缓存 105 | * @param key 缓存键 106 | * @returns 是否成功删除 107 | */ 108 | public delete(key: string): boolean { 109 | if (!this.config.cache.enabled) { 110 | return false; 111 | } 112 | 113 | const result = this.cache.delete(key); 114 | if (result) { 115 | Logger.debug(`缓存删除: ${key}`); 116 | } 117 | return result; 118 | } 119 | 120 | /** 121 | * 清空所有缓存 122 | */ 123 | public clear(): void { 124 | if (!this.config.cache.enabled) { 125 | return; 126 | } 127 | 128 | const size = this.cache.size; 129 | this.cache.clear(); 130 | Logger.debug(`清空全部缓存,删除了 ${size} 条记录`); 131 | } 132 | 133 | /** 134 | * 根据前缀清除缓存 135 | * @param prefix 缓存键前缀 136 | * @returns 清除的缓存数量 137 | */ 138 | public clearByPrefix(prefix: string): number { 139 | if (!this.config.cache.enabled) { 140 | return 0; 141 | } 142 | 143 | let count = 0; 144 | for (const key of this.cache.keys()) { 145 | if (key.startsWith(prefix)) { 146 | this.cache.delete(key); 147 | count++; 148 | } 149 | } 150 | 151 | if (count > 0) { 152 | Logger.debug(`按前缀清除缓存: ${prefix}, 删除了 ${count} 条记录`); 153 | } 154 | return count; 155 | } 156 | 157 | /** 158 | * 清理过期缓存 159 | * @returns 清理的缓存数量 160 | */ 161 | private cleanExpiredCache(): number { 162 | if (!this.config.cache.enabled) { 163 | return 0; 164 | } 165 | 166 | const now = Date.now(); 167 | let count = 0; 168 | 169 | for (const [key, item] of this.cache.entries()) { 170 | if (now > item.expiresAt) { 171 | this.cache.delete(key); 172 | count++; 173 | } 174 | } 175 | 176 | if (count > 0) { 177 | Logger.debug(`清理过期缓存,删除了 ${count} 条记录`); 178 | } 179 | return count; 180 | } 181 | 182 | /** 183 | * 清理最旧的缓存 184 | * @param count 要清理的条目数,默认为1 185 | */ 186 | private cleanOldestCache(count: number = 1): void { 187 | if (!this.config.cache.enabled || this.cache.size === 0) { 188 | return; 189 | } 190 | 191 | // 按时间戳排序 192 | const entries = Array.from(this.cache.entries()) 193 | .sort((a, b) => a[1].timestamp - b[1].timestamp); 194 | 195 | // 删除最早的几条记录 196 | const toDelete = Math.min(count, entries.length); 197 | for (let i = 0; i < toDelete; i++) { 198 | this.cache.delete(entries[i][0]); 199 | } 200 | 201 | Logger.debug(`清理最旧缓存,删除了 ${toDelete} 条记录`); 202 | } 203 | 204 | /** 205 | * 获取缓存统计信息 206 | * @returns 缓存统计信息对象 207 | */ 208 | public getStats(): { size: number; enabled: boolean; maxSize: number; ttl: number } { 209 | return { 210 | size: this.cache.size, 211 | enabled: this.config.cache.enabled, 212 | maxSize: this.config.cache.maxSize, 213 | ttl: this.config.cache.ttl 214 | }; 215 | } 216 | 217 | 218 | /** 219 | * 缓存Wiki到文档ID的转换结果 220 | * @param wikiToken Wiki Token 221 | * @param documentId 文档ID 222 | * @returns 是否成功设置缓存 223 | */ 224 | public cacheWikiToDocId(wikiToken: string, documentId: string): boolean { 225 | return this.set(`wiki:${wikiToken}`, documentId); 226 | } 227 | 228 | /** 229 | * 获取缓存的Wiki转换结果 230 | * @param wikiToken Wiki Token 231 | * @returns 文档ID,如果未找到或已过期则返回null 232 | */ 233 | public getWikiToDocId(wikiToken: string): string | null { 234 | return this.get(`wiki:${wikiToken}`); 235 | } 236 | 237 | } -------------------------------------------------------------------------------- /src/services/feishuAuthService.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Config } from '../utils/config.js'; 3 | import { Logger } from '../utils/logger.js'; 4 | import { TokenCacheManager } from '../utils/auth/tokenCacheManager.js'; 5 | import { AuthRequiredError } from '../utils/error.js'; 6 | 7 | export class AuthService { 8 | public config = Config.getInstance(); 9 | 10 | // 获取用户信息 11 | public async getUserInfo(access_token: string): Promise { 12 | Logger.warn('[AuthService] getUserInfo called'); 13 | try { 14 | const response = await axios.get( 15 | 'https://open.feishu.cn/open-apis/authen/v1/user_info', 16 | { headers: { Authorization: `Bearer ${access_token}` } } 17 | ); 18 | Logger.debug('[AuthService] getUserInfo response', response.data); 19 | return response.data; 20 | } catch (error) { 21 | Logger.error('[AuthService] getUserInfo error', error); 22 | throw error; 23 | } 24 | } 25 | 26 | // 通过授权码换取user_access_token 27 | public async getUserTokenByCode({ client_id, client_secret, code, redirect_uri, code_verifier }: { 28 | client_id: string; 29 | client_secret: string; 30 | code: string; 31 | redirect_uri: string; 32 | code_verifier?: string; 33 | }) { 34 | Logger.warn('[AuthService] getUserTokenByCode called', { client_id, code, redirect_uri }); 35 | const body: any = { 36 | grant_type: 'authorization_code', 37 | client_id, 38 | client_secret, 39 | code, 40 | redirect_uri 41 | }; 42 | if (code_verifier) body.code_verifier = code_verifier; 43 | Logger.debug('[AuthService] getUserTokenByCode request', body); 44 | const response = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', { 45 | method: 'POST', 46 | headers: { 'Content-Type': 'application/json' }, 47 | body: JSON.stringify(body) 48 | }); 49 | const data = await response.json(); 50 | Logger.debug('[AuthService] getUserTokenByCode response', data); 51 | return data; 52 | } 53 | 54 | /** 55 | * 刷新用户访问令牌 56 | * 从缓存中获取token信息并刷新,如果缓存中没有必要信息则使用传入的备用参数 57 | * @param clientKey 客户端缓存键 58 | * @param appId 应用ID(可选,如果tokenInfo中没有则使用此参数) 59 | * @param appSecret 应用密钥(可选,如果tokenInfo中没有则使用此参数) 60 | * @returns 刷新后的token信息 61 | * @throws 如果无法获取必要的刷新信息则抛出错误 62 | */ 63 | public async refreshUserToken(clientKey: string, appId?: string, appSecret?: string): Promise { 64 | const tokenCacheManager = TokenCacheManager.getInstance(); 65 | 66 | // 从缓存中获取token信息 67 | const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey); 68 | if (!tokenInfo) { 69 | throw new Error(`无法获取token信息: ${clientKey}`); 70 | } 71 | 72 | // 获取刷新所需的必要信息 73 | const actualRefreshToken = tokenInfo.refresh_token; 74 | const actualAppId = tokenInfo.client_id || appId; 75 | const actualAppSecret = tokenInfo.client_secret || appSecret; 76 | 77 | // 验证必要参数 78 | if (!actualRefreshToken) { 79 | throw new Error('无法获取refresh_token,无法刷新用户访问令牌'); 80 | } 81 | if (!actualAppId || !actualAppSecret) { 82 | throw new Error('无法获取client_id或client_secret,无法刷新用户访问令牌'); 83 | } 84 | 85 | const body = { 86 | grant_type: 'refresh_token', 87 | client_id: actualAppId, 88 | client_secret: actualAppSecret, 89 | refresh_token: actualRefreshToken 90 | }; 91 | 92 | Logger.debug('[AuthService] 刷新用户访问令牌请求:', { 93 | clientKey, 94 | client_id: actualAppId, 95 | has_refresh_token: !!actualRefreshToken 96 | }); 97 | 98 | const response = await axios.post('https://open.feishu.cn/open-apis/authen/v2/oauth/token', body, { 99 | headers: { 'Content-Type': 'application/json' } 100 | }); 101 | const data = response.data; 102 | 103 | if (data && data.access_token && data.expires_in) { 104 | // 计算过期时间戳 105 | data.expires_at = Math.floor(Date.now() / 1000) + data.expires_in; 106 | if (data.refresh_token_expires_in) { 107 | data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in; 108 | } 109 | 110 | // 保留client_id和client_secret(优先使用tokenInfo中的,如果没有则使用实际使用的参数) 111 | data.client_id = actualAppId; 112 | data.client_secret = actualAppSecret; 113 | 114 | // 缓存新的token信息 115 | const refreshTtl = data.refresh_token_expires_in || 3600 * 24 * 365; // 默认1年 116 | tokenCacheManager.cacheUserToken(clientKey, data, refreshTtl); 117 | Logger.info(`[AuthService] 用户访问令牌刷新并缓存成功: ${clientKey}`); 118 | 119 | return data; 120 | } else { 121 | Logger.warn('[AuthService] 刷新用户访问令牌失败:', data); 122 | throw new Error('刷新用户访问令牌失败'); 123 | } 124 | } 125 | 126 | /** 127 | * 获取用户访问令牌 128 | * 检查token状态,如果有效则返回缓存的token,如果过期则尝试刷新 129 | * @param clientKey 客户端缓存键 130 | * @param appId 应用ID(可选,如果tokenInfo中没有则使用此参数) 131 | * @param appSecret 应用密钥(可选,如果tokenInfo中没有则使用此参数) 132 | * @returns 用户访问令牌 133 | * @throws 如果无法获取有效的token则抛出AuthRequiredError 134 | */ 135 | public async getUserAccessToken(clientKey: string, appId?: string, appSecret?: string): Promise { 136 | const tokenCacheManager = TokenCacheManager.getInstance(); 137 | 138 | // 检查用户token状态 139 | const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey); 140 | Logger.debug(`[AuthService] 用户token状态:`, tokenStatus); 141 | 142 | if (tokenStatus.isValid && !tokenStatus.shouldRefresh) { 143 | // token有效且不需要刷新,直接返回 144 | const cachedToken = tokenCacheManager.getUserToken(clientKey); 145 | if (cachedToken) { 146 | Logger.debug('[AuthService] 使用缓存的用户访问令牌'); 147 | return cachedToken; 148 | } 149 | } 150 | 151 | if (tokenStatus.canRefresh && (tokenStatus.isExpired || tokenStatus.shouldRefresh)) { 152 | // 可以刷新token 153 | Logger.info('[AuthService] 尝试刷新用户访问令牌'); 154 | try { 155 | // 使用统一的刷新方法,它会自动从缓存中获取必要信息 156 | const refreshedToken = await this.refreshUserToken(clientKey, appId, appSecret); 157 | if (refreshedToken && refreshedToken.access_token) { 158 | Logger.info('[AuthService] 用户访问令牌刷新成功'); 159 | return refreshedToken.access_token; 160 | } 161 | } catch (error) { 162 | Logger.warn('[AuthService] 刷新用户访问令牌失败:', error); 163 | // 刷新失败,清除缓存,需要重新授权 164 | tokenCacheManager.removeUserToken(clientKey); 165 | } 166 | } 167 | 168 | // 没有有效的token或刷新失败,需要用户授权 169 | Logger.warn('[AuthService] 没有有效的用户token,需要用户授权'); 170 | 171 | throw new AuthRequiredError('user', '需要用户授权'); 172 | } 173 | } -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger.js'; 2 | 3 | /** 4 | * 飞书API错误接口 5 | */ 6 | export interface FeishuApiError { 7 | code?: number; 8 | msg?: string; 9 | error?: { 10 | field_violations?: Array<{ 11 | field: string; 12 | description?: string; 13 | value?: any; 14 | }>; 15 | troubleshooter?: string; 16 | }; 17 | } 18 | 19 | /** 20 | * 错误排查指南映射 21 | */ 22 | const errorGuides: Record = { 23 | // 飞书API标准错误码 24 | '1770002': '资源未找到。请检查文档ID/块ID是否正确,并确保您有权限访问该资源。', 25 | '1770001': '权限不足。请确保应用有足够的权限访问此资源。', 26 | '1770003': '内部服务错误。请稍后重试。', 27 | '1770004': '参数格式错误。请检查API请求参数是否正确。', 28 | '1770005': '请求频率限制。请减少请求频率后重试。', 29 | '1770006': '操作冲突。可能有其他用户正在编辑同一资源。', 30 | '1770007': '资源已被删除。请检查资源是否存在。', 31 | '1770008': '资源已被归档。请检查资源状态。', 32 | '1770015': '文档或文件夹已被移动。请使用新的位置访问。', 33 | 34 | // 身份验证和通用错误 35 | '99991671': '飞书应用身份验证失败。请检查App ID和App Secret是否正确,或者重新注册飞书应用。', 36 | '99991663': '权限不足。请确保:\n1. 应用已获得正确的权限范围\n2. 文档已与应用共享\n3. 您有访问该文档的权限', 37 | '99991672': '请求频率超过限制。请稍后再试或优化代码减少请求次数。', 38 | '99991661': '资源不存在。请检查文档ID/块ID是否正确,并确保资源仍然存在。', 39 | '99991648': '文档ID格式不正确。请检查ID格式,应为标准飞书文档ID、URL或Token。', 40 | 'token_invalid': '访问令牌无效。请尝试刷新访问令牌。', 41 | 'invalid_token': '访问令牌无效。请尝试刷新访问令牌。', 42 | '404': '资源未找到。请检查URL或ID是否正确。', 43 | '403': '访问被拒绝。请检查权限设置并确保您有足够的访问权限。', 44 | '401': '未授权。请检查认证凭据或尝试重新获取访问令牌。', 45 | '400': '请求参数有误。请检查提供的参数格式和值是否正确。', 46 | '500': '服务器内部错误。请稍后重试或联系飞书支持团队。' 47 | }; 48 | 49 | /** 50 | * 格式化错误消息 51 | * 对飞书API各种错误响应格式进行统一处理 52 | * 53 | * @param error 原始错误 54 | * @param context 错误上下文(可选) 55 | * @returns 格式化的错误消息 56 | */ 57 | export function formatErrorMessage(error: any, context?: string): string { 58 | try { 59 | // 预处理错误对象 60 | if (!error) { 61 | return '发生未知错误'; 62 | } 63 | 64 | // 确定错误类型 65 | let errorCode: number | string | undefined; 66 | let errorMsg = ''; 67 | let fieldViolations: any[] = []; 68 | let troubleshooter = ''; 69 | let logId = ''; 70 | 71 | // 优先处理 Axios 响应:直接读取 response.data.data.msg 或 response.data.msg 72 | if (error?.response?.data && typeof error.response.data === 'object') { 73 | const respData = error.response.data as any; 74 | const msgFromResp = respData?.data?.msg ?? respData?.msg; 75 | const codeFromResp = respData?.data?.code ?? respData?.code; 76 | const logFromResp = respData?.log_id || respData?.error?.log_id || error.response.headers?.['x-tt-logid'] || ''; 77 | 78 | if (typeof msgFromResp === 'string') { 79 | let formatted = ''; 80 | if (context) { 81 | formatted += `${context}: `; 82 | } 83 | if (codeFromResp !== undefined) { 84 | formatted += `${msgFromResp} (错误码: ${codeFromResp})`; 85 | } else { 86 | formatted += msgFromResp; 87 | } 88 | if (logFromResp) { 89 | formatted += `\n日志ID: ${logFromResp}`; 90 | } 91 | return formatted; 92 | } 93 | } 94 | 95 | // 处理飞书API标准错误格式 96 | if (error.apiError) { 97 | const apiError = error.apiError; 98 | 99 | errorCode = apiError.code; 100 | errorMsg = apiError.msg || ''; 101 | 102 | if (apiError.error) { 103 | fieldViolations = apiError.error.field_violations || []; 104 | troubleshooter = apiError.error.troubleshooter || ''; 105 | logId = apiError.error.log_id || ''; 106 | } 107 | } 108 | // 处理直接包含code和msg的格式 109 | else if (error.code !== undefined && error.msg !== undefined) { 110 | errorCode = error.code; 111 | errorMsg = error.msg; 112 | 113 | if (error.error) { 114 | fieldViolations = error.error.field_violations || []; 115 | troubleshooter = error.error.troubleshooter || ''; 116 | logId = error.error.log_id || ''; 117 | } 118 | } 119 | // 处理HTTP类错误 120 | else if (error.status) { 121 | errorCode = error.status; 122 | errorMsg = error.statusText || error.err || '请求失败'; 123 | } 124 | // 处理标准Error对象 125 | else if (error instanceof Error) { 126 | errorMsg = error.message; 127 | } 128 | // 处理字符串错误 129 | else if (typeof error === 'string') { 130 | errorMsg = error; 131 | } 132 | // 处理其他对象类型的错误 133 | else if (typeof error === 'object') { 134 | errorMsg = error.message || error.error || JSON.stringify(error); 135 | } 136 | 137 | // 构建基本错误消息 138 | let formattedMessage = ''; 139 | if (context) { 140 | formattedMessage += `${context}: `; 141 | } 142 | 143 | if (errorCode !== undefined) { 144 | formattedMessage += `${errorMsg} (错误码: ${errorCode})`; 145 | } else { 146 | formattedMessage += errorMsg; 147 | } 148 | 149 | // 添加日志ID 150 | if (logId) { 151 | formattedMessage += `\n日志ID: ${logId}`; 152 | } 153 | 154 | // 添加字段验证错误信息 155 | if (fieldViolations && fieldViolations.length > 0) { 156 | formattedMessage += '\n字段验证错误:'; 157 | fieldViolations.forEach((violation) => { 158 | let detail = `\n - ${violation.field}`; 159 | if (violation.description) { 160 | detail += `: ${violation.description}`; 161 | } 162 | if (violation.value !== undefined) { 163 | detail += `,提供的值: ${violation.value}`; 164 | } 165 | formattedMessage += detail; 166 | }); 167 | } 168 | 169 | // 添加排查建议 170 | if (troubleshooter) { 171 | formattedMessage += `\n\n排查建议:\n${troubleshooter}`; 172 | } else { 173 | // 尝试添加预定义的错误指南 174 | const errorCodeStr = String(errorCode); 175 | if (errorGuides[errorCodeStr]) { 176 | formattedMessage += `\n\n排查建议:\n${errorGuides[errorCodeStr]}`; 177 | } else { 178 | // 如果没有精确匹配,尝试通过错误消息内容模糊匹配 179 | for (const [key, guide] of Object.entries(errorGuides)) { 180 | if (errorMsg.toLowerCase().includes(key.toLowerCase())) { 181 | formattedMessage += `\n\n排查建议:\n${guide}`; 182 | break; 183 | } 184 | } 185 | } 186 | } 187 | 188 | return formattedMessage; 189 | } catch (e) { 190 | Logger.error("格式化错误消息时发生错误:", e); 191 | return typeof error === 'string' ? error : '发生未知错误'; 192 | } 193 | } 194 | 195 | /** 196 | * 包装错误为标准格式 197 | * 198 | * @param message 错误消息前缀 199 | * @param originalError 原始错误 200 | * @returns 包装后的错误对象 201 | */ 202 | export function wrapError(message: string, originalError: any): Error { 203 | const errorMessage = formatErrorMessage(originalError); 204 | return new Error(`${message}: ${errorMessage}`); 205 | } 206 | 207 | /** 208 | * 授权异常类 209 | * 用于处理需要用户授权的情况 210 | */ 211 | export class AuthRequiredError extends Error { 212 | public readonly authType: 'tenant' | 'user'; 213 | public readonly authUrl?: string; 214 | public readonly message: string; 215 | 216 | constructor(authType: 'tenant' | 'user', message: string, authUrl?: string) { 217 | super(message); 218 | this.name = 'AuthRequiredError'; 219 | this.authType = authType; 220 | this.authUrl = authUrl; 221 | this.message = message; 222 | } 223 | } -------------------------------------------------------------------------------- /src/utils/paramUtils.ts: -------------------------------------------------------------------------------- 1 | import { normalizeDocumentId, normalizeWikiToken } from './document.js'; 2 | import { Logger } from './logger.js'; 3 | import { formatErrorMessage } from './error.js'; 4 | 5 | /** 6 | * 参数验证错误 7 | */ 8 | export class ParamValidationError extends Error { 9 | public readonly param: string; 10 | 11 | constructor(param: string, message: string) { 12 | super(message); 13 | this.name = 'ParamValidationError'; 14 | this.param = param; 15 | } 16 | } 17 | 18 | /** 19 | * 通用参数配置接口 20 | */ 21 | export interface CommonParams { 22 | documentId?: string; 23 | blockId?: string; 24 | parentBlockId?: string; 25 | index?: number; 26 | startIndex?: number; 27 | [key: string]: any; 28 | } 29 | 30 | /** 31 | * 参数处理工具类 32 | * 提供参数验证、转换和处理功能 33 | */ 34 | export class ParamUtils { 35 | /** 36 | * 处理文档ID参数 37 | * 验证并规范化文档ID 38 | * 39 | * @param documentId 文档ID或URL 40 | * @returns 规范化的文档ID 41 | * @throws 如果文档ID无效则抛出错误 42 | */ 43 | public static processDocumentId(documentId: string): string { 44 | if (!documentId) { 45 | throw new ParamValidationError('documentId', '文档ID不能为空'); 46 | } 47 | 48 | try { 49 | return normalizeDocumentId(documentId); 50 | } catch (error) { 51 | throw new ParamValidationError('documentId', formatErrorMessage(error)); 52 | } 53 | } 54 | 55 | /** 56 | * 处理Wiki Token参数 57 | * 验证并规范化Wiki Token 58 | * 59 | * @param wikiUrl Wiki URL或Token 60 | * @returns 规范化的Wiki Token 61 | * @throws 如果Wiki Token无效则抛出错误 62 | */ 63 | public static processWikiToken(wikiUrl: string): string { 64 | if (!wikiUrl) { 65 | throw new ParamValidationError('wikiUrl', 'Wiki URL不能为空'); 66 | } 67 | 68 | try { 69 | return normalizeWikiToken(wikiUrl); 70 | } catch (error) { 71 | throw new ParamValidationError('wikiUrl', formatErrorMessage(error)); 72 | } 73 | } 74 | 75 | /** 76 | * 处理块ID参数 77 | * 验证块ID是否有效 78 | * 79 | * @param blockId 块ID 80 | * @returns 验证后的块ID 81 | * @throws 如果块ID无效则抛出错误 82 | */ 83 | public static processBlockId(blockId: string): string { 84 | if (!blockId) { 85 | throw new ParamValidationError('blockId', '块ID不能为空'); 86 | } 87 | 88 | if (!/^[a-zA-Z0-9_-]{5,}$/.test(blockId)) { 89 | throw new ParamValidationError('blockId', '块ID格式无效'); 90 | } 91 | 92 | return blockId; 93 | } 94 | 95 | /** 96 | * 处理父块ID参数 97 | * 验证父块ID是否有效 98 | * 99 | * @param parentBlockId 父块ID 100 | * @returns 验证后的父块ID 101 | * @throws 如果父块ID无效则抛出错误 102 | */ 103 | public static processParentBlockId(parentBlockId: string): string { 104 | if (!parentBlockId) { 105 | throw new ParamValidationError('parentBlockId', '父块ID不能为空'); 106 | } 107 | 108 | if (!/^[a-zA-Z0-9_-]{5,}$/.test(parentBlockId)) { 109 | throw new ParamValidationError('parentBlockId', '父块ID格式无效'); 110 | } 111 | 112 | return parentBlockId; 113 | } 114 | 115 | /** 116 | * 处理插入位置索引参数 117 | * 验证并规范化索引值 118 | * 119 | * @param index 插入位置索引 120 | * @returns 验证后的索引值 121 | * @throws 如果索引无效则抛出错误 122 | */ 123 | public static processIndex(index: number): number { 124 | if (index === undefined || index === null) { 125 | return 0; // 默认值 126 | } 127 | 128 | if (!Number.isInteger(index) || index < 0) { 129 | throw new ParamValidationError('index', '索引必须是非负整数'); 130 | } 131 | 132 | return index; 133 | } 134 | 135 | /** 136 | * 处理对齐方式参数 137 | * 验证并规范化对齐方式值 138 | * 139 | * @param align 对齐方式 140 | * @returns 验证后的对齐方式值 141 | */ 142 | public static processAlign(align: number): number { 143 | if (align === undefined || align === null) { 144 | return 1; // 默认左对齐 145 | } 146 | 147 | if (![1, 2, 3].includes(align)) { 148 | Logger.warn(`对齐方式值 ${align} 无效,使用默认值1(左对齐)`); 149 | return 1; 150 | } 151 | 152 | return align; 153 | } 154 | 155 | /** 156 | * 处理语言类型参数 157 | * 验证并规范化语言类型值 158 | * 159 | * @param language 语言类型 160 | * @returns 验证后的语言类型值 161 | */ 162 | public static processLanguage(language: number): number { 163 | if (language === undefined || language === null) { 164 | return 1; // 默认纯文本 165 | } 166 | 167 | if (!Number.isInteger(language) || language < 1 || language > 71) { 168 | Logger.warn(`语言类型值 ${language} 无效,使用默认值1(纯文本)`); 169 | return 1; 170 | } 171 | 172 | return language; 173 | } 174 | 175 | /** 176 | * 处理标题级别参数 177 | * 验证并规范化标题级别值 178 | * 179 | * @param level 标题级别 180 | * @returns 验证后的标题级别值 181 | */ 182 | public static processHeadingLevel(level: number): number { 183 | if (level === undefined || level === null) { 184 | return 1; // 默认一级标题 185 | } 186 | 187 | // 限制在1-9范围内 188 | return Math.max(1, Math.min(9, level)); 189 | } 190 | 191 | /** 192 | * 处理画板ID参数 193 | * 验证并规范化画板ID,支持从URL中提取 194 | * 195 | * @param whiteboardId 画板ID或URL 196 | * @returns 规范化的画板ID 197 | * @throws 如果画板ID无效则抛出错误 198 | */ 199 | public static processWhiteboardId(whiteboardId: string): string { 200 | if (!whiteboardId) { 201 | throw new ParamValidationError('whiteboardId', '画板ID不能为空'); 202 | } 203 | 204 | try { 205 | // 从URL中提取画板ID 206 | let normalizedWhiteboardId = whiteboardId; 207 | if (whiteboardId.includes('feishu.cn/board/')) { 208 | // 从URL中提取画板ID 209 | const matches = whiteboardId.match(/board\/([^\/\?]+)/); 210 | if (matches) { 211 | normalizedWhiteboardId = matches[1]; 212 | } else { 213 | throw new ParamValidationError('whiteboardId', '无法从URL中提取画板ID'); 214 | } 215 | } 216 | 217 | // 验证画板ID格式(基本格式检查) 218 | if (!/^[a-zA-Z0-9_-]{5,}$/.test(normalizedWhiteboardId)) { 219 | throw new ParamValidationError('whiteboardId', '画板ID格式无效'); 220 | } 221 | 222 | return normalizedWhiteboardId; 223 | } catch (error) { 224 | if (error instanceof ParamValidationError) { 225 | throw error; 226 | } 227 | throw new ParamValidationError('whiteboardId', formatErrorMessage(error)); 228 | } 229 | } 230 | 231 | /** 232 | * 批量处理通用参数 233 | * 验证并规范化常用参数集 234 | * 235 | * @param params 通用参数对象 236 | * @returns 处理后的参数对象 237 | */ 238 | public static processCommonParams(params: CommonParams): CommonParams { 239 | const result: CommonParams = { ...params }; 240 | 241 | // 处理文档ID 242 | if (params.documentId) { 243 | result.documentId = ParamUtils.processDocumentId(params.documentId); 244 | } 245 | 246 | // 处理块ID 247 | if (params.blockId) { 248 | result.blockId = ParamUtils.processBlockId(params.blockId); 249 | } 250 | 251 | // 处理父块ID 252 | if (params.parentBlockId) { 253 | result.parentBlockId = ParamUtils.processParentBlockId(params.parentBlockId); 254 | } 255 | 256 | // 处理索引 257 | if (params.index !== undefined) { 258 | result.index = ParamUtils.processIndex(params.index); 259 | } 260 | 261 | // 处理起始索引 262 | if (params.startIndex !== undefined) { 263 | result.startIndex = ParamUtils.processIndex(params.startIndex); 264 | } 265 | 266 | return result; 267 | } 268 | } -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 日志级别枚举 3 | */ 4 | export enum LogLevel { 5 | DEBUG = 0, 6 | INFO = 1, 7 | LOG = 2, 8 | WARN = 3, 9 | ERROR = 4, 10 | NONE = 5 11 | } 12 | 13 | // 导入文件系统模块 14 | import * as fs from 'fs'; 15 | import * as path from 'path'; 16 | 17 | /** 18 | * 日志管理器配置接口 19 | */ 20 | export interface LoggerConfig { 21 | enabled: boolean; // 日志总开关 22 | minLevel: LogLevel; 23 | showTimestamp: boolean; 24 | showLevel: boolean; 25 | timestampFormat?: string; 26 | logToFile: boolean; 27 | logFilePath: string; 28 | maxObjectDepth: number; 29 | maxObjectStringLength: number; 30 | } 31 | 32 | /** 33 | * 增强的日志管理器类 34 | * 提供可配置的日志记录功能,支持不同日志级别和格式化 35 | */ 36 | export class Logger { 37 | private static config: LoggerConfig = { 38 | enabled: true, // 默认开启日志 39 | minLevel: LogLevel.DEBUG, // 修改为DEBUG级别,确保捕获所有日志 40 | showTimestamp: true, 41 | showLevel: true, 42 | timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS', 43 | logToFile: false, 44 | logFilePath: 'log/log.txt', 45 | maxObjectDepth: 2, // 限制对象序列化深度 46 | maxObjectStringLength: 5000000 // 限制序列化后字符串长度 47 | }; 48 | 49 | /** 50 | * 配置日志管理器 51 | * @param config 日志配置项 52 | */ 53 | public static configure(config: Partial): void { 54 | this.config = { ...this.config, ...config }; 55 | 56 | // 确保日志目录存在 57 | if (this.config.logToFile && this.config.enabled) { 58 | const logDir = path.dirname(this.config.logFilePath); 59 | if (!fs.existsSync(logDir)) { 60 | fs.mkdirSync(logDir, { recursive: true }); 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * 设置日志开关 67 | * @param enabled 是否启用日志 68 | */ 69 | public static setEnabled(enabled: boolean): void { 70 | this.config.enabled = enabled; 71 | } 72 | 73 | /** 74 | * 检查日志是否可输出 75 | * @param level 日志级别 76 | * @returns 是否可输出 77 | */ 78 | private static canLog(level: LogLevel): boolean { 79 | return this.config.enabled && level >= this.config.minLevel; 80 | } 81 | 82 | /** 83 | * 格式化日志消息 84 | * @param level 日志级别 85 | * @param args 日志参数 86 | * @returns 格式化后的日志字符串数组 87 | */ 88 | private static formatLogMessage(level: LogLevel, args: any[]): any[] { 89 | const result: any[] = []; 90 | 91 | // 添加时间戳 92 | if (this.config.showTimestamp) { 93 | const now = new Date(); 94 | const timestamp = this.formatDate(now, this.config.timestampFormat || 'yyyy-MM-dd HH:mm:ss.SSS'); 95 | result.push(`[${timestamp}]`); 96 | } 97 | 98 | // 添加日志级别 99 | if (this.config.showLevel) { 100 | const levelStr = LogLevel[level].padEnd(5, ' '); 101 | result.push(`[${levelStr}]`); 102 | } 103 | 104 | // 添加原始日志内容 105 | return [...result, ...args]; 106 | } 107 | 108 | /** 109 | * 将日志写入文件 110 | * @param logParts 日志内容部分 111 | */ 112 | private static writeToFile(logParts: any[]): void { 113 | if (!this.config.enabled || !this.config.logToFile) return; 114 | 115 | try { 116 | // 将日志内容转换为字符串 117 | let logString = ''; 118 | for (const part of logParts) { 119 | if (typeof part === 'object') { 120 | try { 121 | // 简化对象序列化 122 | logString += this.safeStringify(part) + ' '; 123 | } catch (e) { 124 | logString += '[Object] '; 125 | } 126 | } else { 127 | logString += part + ' '; 128 | } 129 | } 130 | 131 | // 添加换行符 132 | logString += '\n'; 133 | 134 | // 以追加模式写入文件 135 | fs.appendFileSync(this.config.logFilePath, logString); 136 | } catch (error) { 137 | console.error('写入日志文件失败:', error); 138 | } 139 | } 140 | 141 | /** 142 | * 安全的对象序列化,限制深度和长度 143 | * @param obj 要序列化的对象 144 | * @returns 序列化后的字符串 145 | */ 146 | private static safeStringify(obj: any): string { 147 | const seen = new Set(); 148 | 149 | const stringified = JSON.stringify(obj, (key, value) => { 150 | // 处理循环引用 151 | if (typeof value === 'object' && value !== null) { 152 | if (seen.has(value)) { 153 | return '[Circular]'; 154 | } 155 | seen.add(value); 156 | } 157 | 158 | // 处理请求/响应对象 159 | if (key === 'request' || key === 'socket' || key === 'agent' || 160 | key === '_events' || key === '_eventsCount' || key === '_maxListeners' || 161 | key === 'rawHeaders' || key === 'rawTrailers') { 162 | return '[Object]'; 163 | } 164 | 165 | return value; 166 | }, 2); 167 | 168 | if (stringified && stringified.length > this.config.maxObjectStringLength) { 169 | return stringified.substring(0, this.config.maxObjectStringLength) + '... [截断]'; 170 | } 171 | 172 | return stringified; 173 | } 174 | 175 | /** 176 | * 格式化日期 177 | * @param date 日期对象 178 | * @param format 格式字符串 179 | * @returns 格式化后的日期字符串 180 | */ 181 | private static formatDate(date: Date, format: string): string { 182 | const year = date.getFullYear().toString(); 183 | const month = (date.getMonth() + 1).toString().padStart(2, '0'); 184 | const day = date.getDate().toString().padStart(2, '0'); 185 | const hours = date.getHours().toString().padStart(2, '0'); 186 | const minutes = date.getMinutes().toString().padStart(2, '0'); 187 | const seconds = date.getSeconds().toString().padStart(2, '0'); 188 | const milliseconds = date.getMilliseconds().toString().padStart(3, '0'); 189 | 190 | return format 191 | .replace('yyyy', year) 192 | .replace('MM', month) 193 | .replace('dd', day) 194 | .replace('HH', hours) 195 | .replace('mm', minutes) 196 | .replace('ss', seconds) 197 | .replace('SSS', milliseconds); 198 | } 199 | 200 | /** 201 | * 记录调试级别日志 202 | * @param args 日志参数 203 | */ 204 | public static debug(...args: any[]): void { 205 | if (this.canLog(LogLevel.DEBUG)) { 206 | const formattedMessage = this.formatLogMessage(LogLevel.DEBUG, args); 207 | console.debug(...formattedMessage); 208 | this.writeToFile(formattedMessage); 209 | } 210 | } 211 | 212 | /** 213 | * 记录信息级别日志 214 | * @param args 日志参数 215 | */ 216 | public static info(...args: any[]): void { 217 | if (this.canLog(LogLevel.INFO)) { 218 | const formattedMessage = this.formatLogMessage(LogLevel.INFO, args); 219 | console.info(...formattedMessage); 220 | this.writeToFile(formattedMessage); 221 | } 222 | } 223 | 224 | /** 225 | * 记录普通级别日志 226 | * @param args 日志参数 227 | */ 228 | public static log(...args: any[]): void { 229 | if (this.canLog(LogLevel.LOG)) { 230 | const formattedMessage = this.formatLogMessage(LogLevel.LOG, args); 231 | console.log(...formattedMessage); 232 | this.writeToFile(formattedMessage); 233 | } 234 | } 235 | 236 | /** 237 | * 记录警告级别日志 238 | * @param args 日志参数 239 | */ 240 | public static warn(...args: any[]): void { 241 | if (this.canLog(LogLevel.WARN)) { 242 | const formattedMessage = this.formatLogMessage(LogLevel.WARN, args); 243 | console.warn(...formattedMessage); 244 | this.writeToFile(formattedMessage); 245 | } 246 | } 247 | 248 | /** 249 | * 记录错误级别日志 250 | * @param args 日志参数 251 | */ 252 | public static error(...args: any[]): void { 253 | if (this.canLog(LogLevel.ERROR)) { 254 | const formattedMessage = this.formatLogMessage(LogLevel.ERROR, args); 255 | console.error(...formattedMessage); 256 | this.writeToFile(formattedMessage); 257 | } 258 | } 259 | 260 | /** 261 | * 记录请求和响应的详细信息 262 | * @param method 请求方法 263 | * @param url 请求URL 264 | * @param data 请求数据 265 | * @param response 响应数据 266 | * @param statusCode 响应状态码 267 | */ 268 | public static logApiCall(method: string, url: string, data: any, response: any, statusCode: number): void { 269 | if (this.canLog(LogLevel.DEBUG)) { 270 | this.debug('API调用详情:'); 271 | this.debug(`请求: ${method} ${url}`); 272 | 273 | // 简化请求数据记录 274 | if (data) { 275 | try { 276 | if (typeof data === 'string') { 277 | // 尝试解析JSON字符串 278 | const parsedData = JSON.parse(data); 279 | this.debug('请求数据:', parsedData); 280 | } else { 281 | this.debug('请求数据:', data); 282 | } 283 | } catch (e) { 284 | this.debug('请求数据:', data); 285 | } 286 | } else { 287 | this.debug('请求数据: None'); 288 | } 289 | 290 | this.debug(`响应状态: ${statusCode}`); 291 | 292 | // 简化响应数据记录 293 | if (response) { 294 | // 只记录关键信息 295 | const simplifiedResponse = response.data ? { data: response.data } : response; 296 | this.debug('响应数据:', simplifiedResponse); 297 | } else { 298 | this.debug('响应数据: None'); 299 | } 300 | } else if (this.canLog(LogLevel.INFO)) { 301 | this.info(`API调用: ${method} ${url} - 状态码: ${statusCode}`); 302 | } 303 | } 304 | } -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 3 | import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; 4 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' 5 | import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' 6 | import { randomUUID } from 'node:crypto' 7 | import { Logger } from './utils/logger.js'; 8 | import { SSEConnectionManager } from './manager/sseConnectionManager.js'; 9 | import { FeishuMcp } from './mcp/feishuMcp.js'; 10 | import { callback} from './services/callbackService.js'; 11 | import { UserAuthManager, UserContextManager, getBaseUrl ,TokenCacheManager, TokenRefreshManager } from './utils/auth/index.js'; 12 | 13 | export class FeishuMcpServer { 14 | private connectionManager: SSEConnectionManager; 15 | private userAuthManager: UserAuthManager; 16 | private userContextManager: UserContextManager; 17 | 18 | constructor() { 19 | this.connectionManager = new SSEConnectionManager(); 20 | this.userAuthManager = UserAuthManager.getInstance(); 21 | this.userContextManager = UserContextManager.getInstance(); 22 | 23 | // 初始化TokenCacheManager,确保在启动时从文件加载缓存 24 | TokenCacheManager.getInstance(); 25 | 26 | // 启动Token自动刷新管理器 27 | const tokenRefreshManager = TokenRefreshManager.getInstance(); 28 | tokenRefreshManager.start(); 29 | Logger.info('Token自动刷新管理器已在服务器启动时初始化'); 30 | } 31 | 32 | async connect(transport: Transport): Promise { 33 | const server = new FeishuMcp(); 34 | await server.connect(transport); 35 | 36 | Logger.info = (...args: any[]) => { 37 | server.server.sendLoggingMessage({ level: 'info', data: args }); 38 | }; 39 | Logger.error = (...args: any[]) => { 40 | server.server.sendLoggingMessage({ level: 'error', data: args }); 41 | }; 42 | 43 | Logger.info('Server connected and ready to process requests'); 44 | } 45 | 46 | async startHttpServer(port: number): Promise { 47 | const app = express(); 48 | 49 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} 50 | 51 | // Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint 52 | app.use("/mcp", express.json()); 53 | 54 | app.post('/mcp', async (req, res) => { 55 | try { 56 | Logger.log("Received StreamableHTTP request", { 57 | method: req.method, 58 | url: req.url, 59 | headers: req.headers, 60 | body: req.body, 61 | query: req.query, 62 | params: req.params 63 | }); 64 | // Check for existing session ID 65 | const sessionId = req.headers['mcp-session-id'] as string | undefined 66 | let transport: StreamableHTTPServerTransport 67 | 68 | if (sessionId && transports[sessionId]) { 69 | // Reuse existing transport 70 | Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId); 71 | transport = transports[sessionId] 72 | } else if (!sessionId && isInitializeRequest(req.body)) { 73 | // New initialization request 74 | transport = new StreamableHTTPServerTransport({ 75 | sessionIdGenerator: () => randomUUID(), 76 | onsessioninitialized: (sessionId) => { 77 | // Store the transport by session ID 78 | Logger.log(`[StreamableHTTP connection] ${sessionId}`); 79 | transports[sessionId] = transport 80 | } 81 | }) 82 | 83 | // Clean up transport and server when closed 84 | transport.onclose = () => { 85 | if (transport.sessionId) { 86 | Logger.log(`[StreamableHTTP delete] ${transports[transport.sessionId]}`); 87 | delete transports[transport.sessionId] 88 | } 89 | } 90 | 91 | // Create and connect server instance 92 | const server = new FeishuMcp(); 93 | await server.connect(transport); 94 | } else { 95 | // Invalid request 96 | res.status(400).json({ 97 | jsonrpc: '2.0', 98 | error: { 99 | code: -32000, 100 | message: 'Bad Request: No valid session ID provided', 101 | }, 102 | id: null, 103 | }) 104 | return 105 | } 106 | 107 | // Handle the request 108 | await transport.handleRequest(req, res, req.body) 109 | } catch (error) { 110 | console.error('Error handling MCP request:', error) 111 | if (!res.headersSent) { 112 | res.status(500).json({ 113 | jsonrpc: '2.0', 114 | error: { 115 | code: -32603, 116 | message: 'Internal server error', 117 | }, 118 | id: null, 119 | }) 120 | } 121 | } 122 | }) 123 | 124 | // Handle GET requests for server-to-client notifications via Streamable HTTP 125 | app.get('/mcp', async (req, res) => { 126 | try { 127 | Logger.log("Received StreamableHTTP request get" ) 128 | const sessionId = req.headers['mcp-session-id'] as string | undefined 129 | if (!sessionId || !transports[sessionId]) { 130 | res.status(400).send('Invalid or missing session ID') 131 | return 132 | } 133 | 134 | const transport = transports[sessionId] 135 | await transport.handleRequest(req, res) 136 | } catch (error) { 137 | console.error('Error handling GET request:', error) 138 | if (!res.headersSent) { 139 | res.status(500).send('Internal server error') 140 | } 141 | } 142 | }) 143 | 144 | // Handle DELETE requests for session termination 145 | app.delete('/mcp', async (req, res) => { 146 | try { 147 | const sessionId = req.headers['mcp-session-id'] as string | undefined 148 | if (!sessionId || !transports[sessionId]) { 149 | res.status(400).send('Invalid or missing session ID') 150 | return 151 | } 152 | 153 | const transport = transports[sessionId] 154 | await transport.handleRequest(req, res) 155 | 156 | // Clean up resources after session termination 157 | if (transport.sessionId) { 158 | delete transports[transport.sessionId] 159 | } 160 | } catch (error) { 161 | console.error('Error handling DELETE request:', error) 162 | if (!res.headersSent) { 163 | res.status(500).send('Internal server error') 164 | } 165 | } 166 | }) 167 | 168 | app.get('/sse', async (req: Request, res: Response) => { 169 | // 获取 userKey 参数 170 | let userKey = req.query.userKey as string | undefined; 171 | 172 | const sseTransport = new SSEServerTransport('/messages', res); 173 | const sessionId = sseTransport.sessionId; 174 | 175 | // 如果 userKey 为空,使用 sessionId 替代 176 | if (!userKey) { 177 | userKey = sessionId; 178 | } 179 | 180 | Logger.log(`[SSE Connection] New SSE connection established for sessionId ${sessionId}, userKey: ${userKey}, params:${JSON.stringify(req.params)} headers:${JSON.stringify(req.headers)} `,); 181 | 182 | // 创建用户会话映射 183 | this.userAuthManager.createSession(sessionId, userKey); 184 | Logger.log(`[UserAuth] Created session mapping: sessionId=${sessionId}, userKey=${userKey}`); 185 | 186 | this.connectionManager.addConnection(sessionId, sseTransport, req, res); 187 | try { 188 | const tempServer = new FeishuMcp(); 189 | await tempServer.connect(sseTransport); 190 | Logger.info(`[SSE Connection] Successfully connected transport for: ${sessionId}`,); 191 | } catch (error) { 192 | Logger.error(`[SSE Connection] Error connecting server to transport for ${sessionId}:`, error); 193 | this.connectionManager.removeConnection(sessionId); 194 | // 清理用户会话映射 195 | this.userAuthManager.removeSession(sessionId); 196 | if (!res.writableEnded) { 197 | res.status(500).end('Failed to connect MCP server to transport'); 198 | } 199 | return; 200 | } 201 | }); 202 | 203 | app.post('/messages', async (req: Request, res: Response) => { 204 | const sessionId = req.query.sessionId as string; 205 | 206 | // 通过 sessionId 获取 userKey 207 | const userKey = this.userAuthManager.getUserKeyBySessionId(sessionId); 208 | 209 | Logger.info(`[SSE messages] Received message with sessionId: ${sessionId}, userKey: ${userKey}, params: ${JSON.stringify(req.query)}, body: ${JSON.stringify(req.body)}`,); 210 | 211 | if (!sessionId) { 212 | res.status(400).send('Missing sessionId query parameter'); 213 | return; 214 | } 215 | 216 | const transport = this.connectionManager.getTransport(sessionId); 217 | Logger.log(`[SSE messages] Retrieved transport for sessionId ${sessionId}: ${transport ? transport.sessionId : 'Transport not found'}`,); 218 | 219 | if (!transport) { 220 | res 221 | .status(404) 222 | .send(`No active connection found for sessionId: ${sessionId}`); 223 | return; 224 | } 225 | 226 | // 获取 baseUrl 227 | const baseUrl = getBaseUrl(req); 228 | 229 | // 在用户上下文中执行 transport.handlePostMessage 230 | this.userContextManager.run( 231 | { 232 | userKey: userKey || '', 233 | baseUrl: baseUrl 234 | }, 235 | async () => { 236 | await transport.handlePostMessage(req, res); 237 | } 238 | ); 239 | }); 240 | 241 | app.get('/callback', callback); 242 | 243 | app.listen(port, '0.0.0.0', () => { 244 | Logger.info(`HTTP server listening on port ${port}`); 245 | Logger.info(`SSE endpoint available at http://localhost:${port}/sse`); 246 | Logger.info(`Message endpoint available at http://localhost:${port}/messages`); 247 | Logger.info(`StreamableHTTP endpoint available at http://localhost:${port}/mcp`); 248 | }); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 飞书 MCP 服务器 2 | 3 | 4 | [![npm version](https://img.shields.io/npm/v/feishu-mcp?color=blue&label=npm)](https://www.npmjs.com/package/feishu-mcp) 5 | [![MIT License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) 6 | 7 | 为 [Cursor](https://cursor.sh/)、[Windsurf](https://codeium.com/windsurf)、[Cline](https://cline.bot/) 和其他 AI 驱动的编码工具提供访问、编辑和结构化处理飞书文档的能力,基于 [Model Context Protocol](https://modelcontextprotocol.io/introduction) 服务器实现。 8 | 9 | 本项目让 AI 编码工具能够直接获取和理解飞书文档的结构化内容,显著提升文档处理的智能化和效率。 10 | 11 | **完整覆盖飞书文档的真实使用流程,助你高效利用文档资源:** 12 | 1. **文件夹目录获取**:快速获取和浏览飞书文档文件夹下的所有文档,便于整体管理和查找。 13 | 2. **内容获取与理解**:支持结构化、分块、富文本等多维度内容读取,AI 能精准理解文档上下文。 14 | 3. **智能创建与编辑**:可自动创建新文档、批量生成和编辑内容,满足多样化写作需求。 15 | 4. **高效检索与搜索**:内置关键字搜索,帮助你在大量文档中迅速找到目标信息。 16 | 17 | 本项目让你在飞书文档的日常使用流程中实现智能获取、编辑和搜索,提升内容处理效率和体验。 18 | 19 | ### 🎬 使用演示视频 20 | 21 | 你可以通过以下视频了解 MCP 的实际使用效果和操作流程: 22 | 23 | 24 | 飞书 MCP 使用演示 25 | 26 | 27 | 28 | 飞书 MCP 使用演示 29 | 30 | 31 | > ⭐ **Star 本项目,第一时间获取最新功能和重要更新!** 关注项目可以让你不错过任何新特性、修复和优化,助你持续高效使用。你的支持也将帮助我们更好地完善和发展项目。⭐ 32 | 33 | --- 34 | 35 | ## 🛠️ 工具功能详情 36 | 37 | | 功能类别 | 工具名称 | 描述 | 使用场景 | 状态 | 38 | |---------|--------------------------------------------------------------|-------------------|---------------|------| 39 | | **文档管理** | `create_feishu_document` | 创建新的飞书文档 | 从零开始创建文档 | ✅ 已完成 | 40 | | | `get_feishu_document_info` | 获取文档基本信息 | 验证文档存在性和权限 | ✅ 已完成 | 41 | | | `get_feishu_document_blocks` | 获取文档块结构 | 了解文档层级结构 | ✅ 已完成 | 42 | | **内容编辑** | `batch_create_feishu_blocks` | 批量创建多个块 | 高效创建连续内容 | ✅ 已完成 | 43 | | | `update_feishu_block_text` | 更新块文本内容 | 修改现有内容 | ✅ 已完成 | 44 | | | `delete_feishu_document_blocks` | 删除文档块 | 清理和重构文档内容 | ✅ 已完成 | 45 | | **文件夹管理** | `get_feishu_folder_files` | 获取文件夹文件列表 | 浏览文件夹内容 | ✅ 已完成 | 46 | | | `create_feishu_folder` | 创建新文件夹 | 组织文档结构 | ✅ 已完成 | 47 | | **搜索功能** | `search_feishu_documents` | 搜索文档 | 查找特定内容 | ✅ 已完成 | 48 | | **工具功能** | `convert_feishu_wiki_to_document_id` | Wiki链接转换 | 将Wiki链接转为文档ID | ✅ 已完成 | 49 | | | `get_feishu_image_resource` | 获取图片资源 | 下载文档中的图片 | ✅ 已完成 | 50 | | | `get_feishu_whiteboard_content` | 获取画板内容 | 获取画板中的图形元素和结构(流程图、思维导图等) | ✅ 已完成 | 51 | | **高级功能** | `create_feishu_table` | 创建和编辑表格 | 结构化数据展示 | ✅ 已完成 | 52 | | | 流程图插入 | 支持流程图和思维导图 | 流程梳理和可视化 | ✅ 已完成 | 53 | | 图片插入 | `upload_and_bind_image_to_block` | 支持插入本地和远程图片 | 修改文档内容 | ✅ 已完成 | 54 | | | 公式支持 | 支持数学公式 | 学术和技术文档 | ✅ 已完成 | 55 | 56 | ### 🎨 支持的样式功能(基本支持md所有格式) 57 | 58 | - **文本样式**:粗体、斜体、下划线、删除线、行内代码 59 | - **文本颜色**:灰色、棕色、橙色、黄色、绿色、蓝色、紫色 60 | - **对齐方式**:左对齐、居中、右对齐 61 | - **标题级别**:支持1-9级标题 62 | - **代码块**:支持多种编程语言语法高亮 63 | - **列表**:有序列表(编号)、无序列表(项目符号) 64 | - **图片**:支持本地图片和网络图片 65 | - **公式**:在文本块中插入数学公式,支持LaTeX语法 66 | - **mermaid图表**:支持流程图、时序图、思维导图、类图、饼图等等 67 | - **表格**:支持创建多行列表格,单元格可包含文本、标题、列表、代码块等多种内容类型 68 | 69 | --- 70 | 71 | ## 📈 一周计划:提升工具效率 72 | 73 | - ~~**精简工具集**:21个工具 → 13个工具,移除冗余,聚焦核心功能~~ 0.0.15 ✅ 74 | - ~~**优化描述**:7000+ tokens → 3000+ tokens,简化提示,节省请求token~~ 0.0.15 ✅ 75 | - ~~**批量增强**:新增批量更新、批量图片上传,单次操作效率提升50%~~ 0.0.15 ✅ 76 | - **流程优化**:减少多步调用,实现一键完成复杂任务 77 | - ~~**支持多种凭证类型**:包括 tenant_access_token和 user_access_token,满足不同场景下的认证需求~~ (飞书应用配置发生变更) 0.0.16 ✅。 78 | - ~~**支持cursor用户登录**:方便在cursor平台用户认证 不做了,没必要 ❌~~ 79 | - ~~**支持mermaid图表**:流程图、时序图等等,丰富文档内容~~ 0.1.11 ✅ 80 | - ~~**支持表格创建**:创建包含各种块类型的复杂表格,支持样式控制~~ 0.1.2 ✅ 81 | - ~~**支持飞书多用户user认证**:一人部署,可以多人使用~~ 0.1.3 ✅ 82 | - ~~**支持user_access_token自动刷新**:无需频繁授权,提高使用体验~~ 0.1.6 ✅ 83 | 84 | --- 85 | 86 | ## 🔧 飞书配置教程 87 | 88 | **⚠️ 重要提示:在开始使用之前,必须先完成飞书应用配置,否则无法正常使用本工具。** 89 | 90 | 关于如何创建飞书应用和获取应用凭证的说明可以在[官方教程](https://open.feishu.cn/document/home/develop-a-bot-in-5-minutes/create-an-app)找到。 91 | 92 | **详细的飞书应用配置步骤**:有关注册飞书应用、配置权限、添加文档访问权限的详细指南,请参阅 [手把手教程 FEISHU_CONFIG.md](FEISHU_CONFIG.md)。 93 | 94 | --- 95 | 96 | ## 🏃‍♂️ 快速开始 97 | 98 | ### 方式一:使用 NPM 快速运行 99 | 100 | ```bash 101 | npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret=<你的飞书应用密钥> --feishu-auth-type= 102 | ``` 103 | 104 | ### 方式二:本地运行 105 | 1. **克隆仓库** 106 | ```bash 107 | git clone https://github.com/cso1z/Feishu-MCP.git 108 | cd Feishu-MCP 109 | ``` 110 | 111 | 2. **安装依赖** 112 | ```bash 113 | pnpm install 114 | ``` 115 | 116 | 3. **配置环境变量(复制一份.env.example保存为.env文件)** 117 | 118 | 4. **编辑 .env 文件** 119 | 在项目根目录下找到并用任意文本编辑器打开 `.env` 文件,填写你的飞书应用凭证: 120 | ```env 121 | FEISHU_APP_ID=cli_xxxxx 122 | FEISHU_APP_SECRET=xxxxx 123 | PORT=3333 124 | FEISHU_AUTH_TYPE=tenant/user 125 | ``` 126 | 127 | 5. **运行服务器** 128 | ```bash 129 | pnpm run dev 130 | ``` 131 | 132 | ## 🐳 Docker 部署 133 | 134 | ### 方式一:使用 Docker Compose(推荐) 135 | 136 | 1. **克隆仓库** 137 | ```bash 138 | git clone https://github.com/cso1z/Feishu-MCP.git 139 | cd Feishu-MCP 140 | ``` 141 | 142 | 2. **配置环境变量** 143 | 复制 `.env.example` 文件并重命名为 `.env`,然后填写你的飞书应用凭证: 144 | ```env 145 | FEISHU_APP_ID=cli_xxxxx 146 | FEISHU_APP_SECRET=xxxxx 147 | PORT=3333 148 | FEISHU_AUTH_TYPE=tenant/user 149 | ``` 150 | 151 | 3. **启动服务** 152 | ```bash 153 | docker-compose up -d 154 | ``` 155 | 156 | 4. **查看日志** 157 | ```bash 158 | docker-compose logs -f 159 | ``` 160 | 161 | ### 方式二:直接使用 Docker 162 | 163 | 1. **构建镜像** 164 | ```bash 165 | docker build -t feishu-mcp . 166 | ``` 167 | 168 | 2. **运行容器** 169 | ```bash 170 | docker run -d \ 171 | --name feishu-mcp \ 172 | -p 3333:3333 \ 173 | -e FEISHU_APP_ID=your_app_id \ 174 | -e FEISHU_APP_SECRET=your_app_secret \ 175 | -e FEISHU_AUTH_TYPE=tenant \ 176 | feishu-mcp 177 | ``` 178 | 179 | 或者使用环境变量文件: 180 | ```bash 181 | docker run -d \ 182 | --name feishu-mcp \ 183 | -p 3333:3333 \ 184 | --env-file .env \ 185 | feishu-mcp 186 | ``` 187 | 188 | ### 环境变量说明 189 | 190 | Docker 部署支持以下环境变量: 191 | 192 | | 变量名 | 必需 | 描述 | 默认值 | 193 | |--------|------|------|--------| 194 | | `FEISHU_APP_ID` | ✅ | 飞书应用 ID | - | 195 | | `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥 | - | 196 | | `FEISHU_BASE_URL` | ❌ | 飞书 API 基础地址 | `https://open.feishu.cn/open-apis` | 197 | | `FEISHU_AUTH_TYPE` | ❌ | 认证凭证类型 (`tenant`/`user`) | `tenant` | 198 | | `PORT` | ❌ | 服务器端口 | `3333` | 199 | | `LOG_LEVEL` | ❌ | 日志级别 (`error`/`warn`/`info`/`debug`) | `info` | 200 | | `CACHE_ENABLED` | ❌ | 是否启用缓存 | `true` | 201 | | `CACHE_TTL` | ❌ | 缓存过期时间(秒) | `300` | 202 | 203 | ### 数据持久化 204 | 205 | Docker 部署会自动挂载以下卷以实现数据持久化: 206 | - `/app/logs` - 日志文件目录 207 | - `/app/cache` - 缓存文件目录 208 | 209 | ## ⚙️ 项目配置 210 | 211 | ### 环境变量配置 212 | 213 | | 变量名 | 必需 | 描述 | 默认值 | 214 | |--------|------|--------------------------------------------------------------------|-------| 215 | | `FEISHU_APP_ID` | ✅ | 飞书应用 ID | - | 216 | | `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥 | - | 217 | | `PORT` | ❌ | 服务器端口 | `3333` | 218 | | `FEISHU_AUTH_TYPE` | ❌ | 认证凭证类型,使用 `user`(用户级,使用时是用户的身份操作飞书文档,需OAuth授权),使用 `tenant`(应用级,默认) | `tenant` | 219 | 220 | ### 配置文件方式(适用于 Cursor、Cline 等) 221 | 222 | ``` 223 | { 224 | "mcpServers": { 225 | "feishu-mcp": { 226 | "command": "npx", 227 | "args": ["-y", "feishu-mcp", "--stdio"], 228 | "env": { 229 | "FEISHU_APP_ID": "<你的飞书应用ID>", 230 | "FEISHU_APP_SECRET": "<你的飞书应用密钥>", 231 | "FEISHU_AUTH_TYPE": "" 232 | } 233 | }, 234 | "feishu_local": { 235 | "url": "http://localhost:3333/sse?userKey=123456" 236 | } 237 | } 238 | } 239 | ``` 240 | --- 241 | 242 | ## 📝 使用贴士(重要) 243 | 244 | 1. ### **推荐指定文件夹**: 245 | 新建文档时,建议主动提供飞书文件夹 token(可为具体文件夹或根文件夹),这样可以更高效地定位和管理文档。如果不确定具体的子文件夹,可以让LLM自动在你指定的文件夹下查找最合适的子目录来新建文档。 246 | 247 | > **如何获取文件夹 token?** 248 | > 打开飞书文件夹页面,复制链接(如 `https://.../drive/folder/xxxxxxxxxxxxxxxxxxxxxx`),token 就是链接最后的那一串字符(如 `xxxxxxxxxxxxxxxxxxxxxx`,请勿泄露真实 token)。 249 | 250 | 2. ### **图片上传路径说明**: 251 | 本地运行 MCP 时,图片路径既支持本地绝对路径,也支持 http/https 网络图片;如在服务器环境,仅支持网络图片链接(由于cursor调用mcp时参数长度限制,暂不支持直接上传图片文件本体,请使用图片路径或链接方式上传)。 252 | 253 | 3. ### **公式使用说明**: 254 | 在文本块中可以混合使用普通文本和公式元素。公式使用LaTeX语法,如:`1+2=3`、`\frac{a}{b}`、`\sqrt{x}`等。支持在同一文本块中包含多个公式和普通文本。 255 | 256 | 4. ### **使用飞书user认证**: 257 | user认证与tenant认证在增加权限时是有区分的,所以**在初次由tenant切换到user时需要注意配置的权限**;为了区分不同的用户需要在配置mcp server服务的url增加query参数:userKey,**该值是用户的唯一标识 所以最好在设置时越随机越好** 258 | --- 259 | ## 🚨 故障排查 260 | 261 | ### 权限问题排查 262 | 先对照配置问题查看: [手把手教程 FEISHU_CONFIG.md](FEISHU_CONFIG.md)。 263 | 264 | #### 问题确认 265 | 1. **检查应用权限**:确保应用已获得必要的文档访问权限 266 | 2. **验证文档授权**:确认目标文档已授权给应用或应用所在的群组 267 | 3. **检查可用范围**:确保应用发布版本的可用范围包含文档所有者 268 | 269 | #### 权限验证与排查 270 | 1. 获取token:[自建应用获取 app_access_token](https://open.feishu.cn/api-explorer?apiName=app_access_token_internal&project=auth&resource=auth&version=v3) 271 | 2. 使用第1步获取的token,验证是否有权限访问该文档:[获取文档基本信息](https://open.feishu.cn/api-explorer?apiName=get&project=docx&resource=document&version=v1) 272 | 273 | 274 | ### 常见问题 275 | 276 | - **找不到应用**:检查应用是否已发布且可用范围配置正确 277 | - **权限不足**:参考[云文档常见问题](https://open.feishu.cn/document/ukTMukTMukTM/uczNzUjL3czM14yN3MTN) 278 | - **知识库访问问题**:参考[知识库常见问题](https://open.feishu.cn/document/server-docs/docs/wiki-v2/wiki-qa) 279 | 280 | --- 281 | 282 | ## 📚 开发者 Wiki 283 | 284 | 详细的开发文档和技术指南,为学习者和贡献者提供全面的指导: 285 | 286 | - **[Wiki 首页](https://github.com/cso1z/Feishu-MCP/wiki)** - 完整的文档索引和快速导航 287 | - **[架构设计](https://github.com/cso1z/Feishu-MCP/wiki/架构设计)** - 整体架构和技术栈说明 288 | - **[核心模块详解](https://github.com/cso1z/Feishu-MCP/wiki/核心模块详解)** - 各模块的实现细节和代码示例 289 | - **[认证与授权](https://github.com/cso1z/Feishu-MCP/wiki/认证与授权机制)** - Token 管理和多用户支持机制 290 | - **[开发者指南](https://github.com/cso1z/Feishu-MCP/wiki/开发者指南)** - 环境搭建、开发流程、调试技巧 291 | - **[API 参考](https://github.com/cso1z/Feishu-MCP/wiki/API-参考文档)** - 所有工具函数的详细文档 292 | - **[最佳实践](https://github.com/cso1z/Feishu-MCP/wiki/最佳实践)** - 代码规范、性能优化、安全实践 293 | - **[MCP 协议实现](https://github.com/cso1z/Feishu-MCP/wiki/MCP-协议实现)** - MCP 协议详解和传输层实现 294 | 295 | --- 296 | 297 | ## 💖 支持项目 298 | 299 | 如果这个项目帮助到了你,请考虑: 300 | 301 | - ⭐ 给项目一个 Star 302 | - 🐛 报告 Bug 和问题 303 | - 💡 提出新功能建议 304 | - 📖 改进文档 305 | - 🔀 提交 Pull Request 306 | 307 | 你的支持是我们前进的动力! 308 | 309 | 310 | ## Star History 311 | 312 | [![Star History Chart](https://api.star-history.com/svg?repos=cso1z/feishu-mcp&type=Timeline)](https://www.star-history.com/#cso1z/feishu-mcp&Timeline) 313 | -------------------------------------------------------------------------------- /src/utils/document.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 从URL或ID中提取飞书文档ID 3 | * 支持多种格式: 4 | * 1. 标准文档URL: https://xxx.feishu.cn/docs/xxx 或 https://xxx.feishu.cn/docx/xxx 5 | * 2. API URL: https://open.feishu.cn/open-apis/docx/v1/documents/xxx 6 | * 3. 直接ID: JcKbdlokYoPIe0xDzJ1cduRXnRf 7 | * 8 | * @param input 文档URL或ID 9 | * @returns 提取的文档ID或null 10 | */ 11 | export function extractDocumentId(input: string): string | null { 12 | // 移除首尾空白 13 | input = input.trim(); 14 | 15 | // 处理各种URL格式 16 | const docxMatch = input.match(/\/docx\/([a-zA-Z0-9_-]+)/i); 17 | const docsMatch = input.match(/\/docs\/([a-zA-Z0-9_-]+)/i); 18 | const apiMatch = input.match(/\/documents\/([a-zA-Z0-9_-]+)/i); 19 | const directIdMatch = input.match(/^([a-zA-Z0-9_-]{10,})$/); // 假设ID至少10个字符 20 | 21 | // 按优先级返回匹配结果 22 | const match = docxMatch || docsMatch || apiMatch || directIdMatch; 23 | return match ? match[1] : null; 24 | } 25 | 26 | /** 27 | * 从URL或Token中提取Wiki节点ID 28 | * 支持多种格式: 29 | * 1. Wiki URL: https://xxx.feishu.cn/wiki/xxx 30 | * 2. 直接Token: xxx 31 | * 32 | * @param input Wiki URL或Token 33 | * @returns 提取的Wiki Token或null 34 | */ 35 | export function extractWikiToken(input: string): string | null { 36 | // 移除首尾空白 37 | input = input.trim(); 38 | 39 | // 处理Wiki URL格式 40 | const wikiMatch = input.match(/\/wiki\/([a-zA-Z0-9_-]+)/i); 41 | const directMatch = input.match(/^([a-zA-Z0-9_-]{10,})$/); // 假设Token至少10个字符 42 | 43 | // 提取Token,如果存在查询参数,去掉它们 44 | let token = wikiMatch ? wikiMatch[1] : (directMatch ? directMatch[1] : null); 45 | if (token && token.includes('?')) { 46 | token = token.split('?')[0]; 47 | } 48 | 49 | return token; 50 | } 51 | 52 | /** 53 | * 规范化文档ID 54 | * 提取输入中的文档ID,如果提取失败则返回原输入 55 | * 56 | * @param input 文档URL或ID 57 | * @returns 规范化的文档ID 58 | * @throws 如果无法提取有效ID则抛出错误 59 | */ 60 | export function normalizeDocumentId(input: string): string { 61 | const id = extractDocumentId(input); 62 | if (!id) { 63 | throw new Error(`无法从 "${input}" 提取有效的文档ID`); 64 | } 65 | return id; 66 | } 67 | 68 | /** 69 | * 规范化Wiki Token 70 | * 提取输入中的Wiki Token,如果提取失败则返回原输入 71 | * 72 | * @param input Wiki URL或Token 73 | * @returns 规范化的Wiki Token 74 | * @throws 如果无法提取有效Token则抛出错误 75 | */ 76 | export function normalizeWikiToken(input: string): string { 77 | const token = extractWikiToken(input); 78 | if (!token) { 79 | throw new Error(`无法从 "${input}" 提取有效的Wiki Token`); 80 | } 81 | return token; 82 | } 83 | 84 | /** 85 | * 根据图片二进制数据检测MIME类型 86 | * @param buffer 图片二进制数据 87 | * @returns MIME类型字符串 88 | */ 89 | export function detectMimeType(buffer: Buffer): string { 90 | // 简单的图片格式检测,根据文件头进行判断 91 | if (buffer.length < 4) { 92 | return 'application/octet-stream'; 93 | } 94 | 95 | // JPEG格式 96 | if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { 97 | return 'image/jpeg'; 98 | } 99 | // PNG格式 100 | else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { 101 | return 'image/png'; 102 | } 103 | // GIF格式 104 | else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { 105 | return 'image/gif'; 106 | } 107 | // SVG格式 - 检查字符串前缀 108 | else if (buffer.length > 5 && buffer.toString('ascii', 0, 5).toLowerCase() === ' 12 && 114 | buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && 115 | buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { 116 | return 'image/webp'; 117 | } 118 | // 默认二进制流 119 | else { 120 | return 'application/octet-stream'; 121 | } 122 | } 123 | 124 | function formatExpire(seconds: number): string { 125 | if (!seconds || isNaN(seconds)) return ''; 126 | if (seconds < 0) return `已过期 (${seconds}s)`; 127 | const h = Math.floor(seconds / 3600); 128 | const m = Math.floor((seconds % 3600) / 60); 129 | const s = seconds % 60; 130 | let str = ''; 131 | if (h) str += h + '小时'; 132 | if (m) str += m + '分'; 133 | if (s || (!h && !m)) str += s + '秒'; 134 | return `${str} (${seconds}s)`; 135 | } 136 | 137 | export function renderFeishuAuthResultHtml(data: any): string { 138 | const isError = data && data.error; 139 | const now = Math.floor(Date.now() / 1000); 140 | let expiresIn = data && data.expires_in; 141 | let refreshExpiresIn = data && (data.refresh_token_expires_in || data.refresh_expires_in); 142 | if (expiresIn && expiresIn > 1000000000) expiresIn = expiresIn - now; 143 | if (refreshExpiresIn && refreshExpiresIn > 1000000000) refreshExpiresIn = refreshExpiresIn - now; 144 | const tokenBlock = data && !isError ? ` 145 |
146 |

Token 信息

147 |
    148 |
  • token_type: ${data.token_type || ''}
  • 149 |
  • access_token: 点击展开/收起
    ${data.access_token || ''}
  • 150 |
  • expires_in: ${formatExpire(expiresIn)}
  • 151 |
  • refresh_token: 点击展开/收起
    ${data.refresh_token || ''}
  • 152 |
  • refresh_token_expires_in: ${formatExpire(refreshExpiresIn)}
  • 153 |
  • scope:
    ${(data.scope || '').replace(/ /g, '\n')}
  • 154 |
155 |
156 | 授权成功,继续完成任务 157 | 158 |
159 |
160 | ` : ''; 161 | let userBlock = ''; 162 | const userInfo = data && data.userInfo && data.userInfo.data; 163 | if (userInfo) { 164 | userBlock = ` 165 |
166 |
167 | 168 |
169 | 173 |
174 | `; 175 | } 176 | const errorBlock = isError ? ` 177 |
178 |

授权失败

179 |
${escapeHtml(data.error || '')}
180 |
错误码: ${data.code || ''}
181 |
182 | ` : ''; 183 | return ` 184 | 185 | 186 | 飞书授权结果 187 | 188 | 189 | 222 | 251 | 252 | 253 |
254 |

飞书授权结果

255 | ${errorBlock} 256 | ${tokenBlock} 257 | ${userBlock} 258 |
259 | 点击展开/收起原始数据 260 |
${escapeHtml(JSON.stringify(data, null, 2))}
261 |
262 |
263 | 264 | 265 | `; 266 | } 267 | 268 | function escapeHtml(str: string) { 269 | return str.replace(/[&<>"]|'/g, function (c) { 270 | return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c; 271 | }); 272 | } -------------------------------------------------------------------------------- /src/mcp/tools/feishuTools.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | // import { z } from 'zod'; 3 | import { formatErrorMessage } from '../../utils/error.js'; 4 | import { FeishuApiService } from '../../services/feishuApiService.js'; 5 | import { Logger } from '../../utils/logger.js'; 6 | import { 7 | DocumentIdSchema, 8 | // BlockIdSchema, 9 | SearchKeySchema, 10 | WhiteboardIdSchema, 11 | DocumentTitleSchema, 12 | FolderTokenSchema, 13 | } from '../../types/feishuSchema.js'; 14 | 15 | /** 16 | * 注册飞书相关的MCP工具 17 | * @param server MCP服务器实例 18 | * @param feishuService 飞书API服务实例 19 | */ 20 | export function registerFeishuTools(server: McpServer, feishuService: FeishuApiService | null): void { 21 | // 添加创建飞书文档工具 22 | server.tool( 23 | 'create_feishu_document', 24 | 'Creates a new Feishu document and returns its information. Use this tool when you need to create a document from scratch with a specific title and folder location.', 25 | { 26 | title: DocumentTitleSchema, 27 | folderToken: FolderTokenSchema, 28 | }, 29 | async ({ title, folderToken }) => { 30 | try { 31 | Logger.info(`开始创建飞书文档,标题: ${title}${folderToken ? `,文件夹Token: ${folderToken}` : ',使用默认文件夹'}`); 32 | const newDoc = await feishuService?.createDocument(title, folderToken); 33 | if (!newDoc) { 34 | throw new Error('创建文档失败,未返回文档信息'); 35 | } 36 | Logger.info(`飞书文档创建成功,文档ID: ${newDoc.objToken || newDoc.document_id}`); 37 | return { 38 | content: [{ type: 'text', text: JSON.stringify(newDoc, null, 2) }], 39 | }; 40 | } catch (error) { 41 | Logger.error(`创建飞书文档失败:`, error); 42 | const errorMessage = formatErrorMessage(error); 43 | return { 44 | content: [{ type: 'text', text: `创建飞书文档失败: ${errorMessage}` }], 45 | }; 46 | } 47 | }, 48 | ); 49 | 50 | // 添加获取飞书文档信息工具 51 | server.tool( 52 | 'get_feishu_document_info', 53 | 'Retrieves basic information about a Feishu document. Use this to verify a document exists, check access permissions, or get metadata like title, type, and creation information.', 54 | { 55 | documentId: DocumentIdSchema, 56 | }, 57 | async ({ documentId }) => { 58 | try { 59 | if (!feishuService) { 60 | return { 61 | content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 62 | }; 63 | } 64 | 65 | Logger.info(`开始获取飞书文档信息,文档ID: ${documentId}`); 66 | const docInfo = await feishuService.getDocumentInfo(documentId); 67 | Logger.info(`飞书文档信息获取成功,标题: ${docInfo.title}`); 68 | 69 | return { 70 | content: [{ type: 'text', text: JSON.stringify(docInfo, null, 2) }], 71 | }; 72 | } catch (error) { 73 | Logger.error(`获取飞书文档信息失败:`, error); 74 | const errorMessage = formatErrorMessage(error, '获取飞书文档信息失败'); 75 | return { 76 | content: [{ type: 'text', text: errorMessage }], 77 | }; 78 | } 79 | }, 80 | ); 81 | 82 | // 添加获取飞书文档内容工具 83 | // server.tool( 84 | // 'get_feishu_document_content', 85 | // 'Retrieves the plain text content of a Feishu document. Ideal for content analysis, processing, or when you need to extract text without formatting. The content maintains the document structure but without styling. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.', 86 | // { 87 | // documentId: DocumentIdSchema, 88 | // lang: z.number().optional().default(0).describe('Language code (optional). Default is 0 (Chinese). Use 1 for English if available.'), 89 | // }, 90 | // async ({ documentId, lang }) => { 91 | // try { 92 | // if (!feishuService) { 93 | // return { 94 | // content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }], 95 | // }; 96 | // } 97 | // 98 | // Logger.info(`开始获取飞书文档内容,文档ID: ${documentId},语言: ${lang}`); 99 | // const content = await feishuService.getDocumentContent(documentId, lang); 100 | // Logger.info(`飞书文档内容获取成功,内容长度: ${content.length}字符`); 101 | // 102 | // return { 103 | // content: [{ type: 'text', text: content }], 104 | // }; 105 | // } catch (error) { 106 | // Logger.error(`获取飞书文档内容失败:`, error); 107 | // const errorMessage = formatErrorMessage(error); 108 | // return { 109 | // content: [{ type: 'text', text: `获取飞书文档内容失败: ${errorMessage}` }], 110 | // }; 111 | // } 112 | // }, 113 | // ); 114 | 115 | // 添加获取飞书文档块工具 116 | server.tool( 117 | 'get_feishu_document_blocks', 118 | 'Retrieves the block structure information of a Feishu document. Essential to use before inserting content to understand document structure and determine correct insertion positions. Returns a detailed hierarchy of blocks with their IDs, types, and content. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.', 119 | { 120 | documentId: DocumentIdSchema, 121 | }, 122 | async ({ documentId }) => { 123 | try { 124 | if (!feishuService) { 125 | return { 126 | content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }], 127 | }; 128 | } 129 | 130 | Logger.info(`开始获取飞书文档块,文档ID: ${documentId}`); 131 | const blocks = await feishuService.getDocumentBlocks(documentId); 132 | Logger.info(`飞书文档块获取成功,共 ${blocks.length} 个块`); 133 | 134 | // 检查是否有 block_type 为 43 的块(画板块) 135 | const whiteboardBlocks = blocks.filter((block: any) => block.block_type === 43); 136 | const hasWhiteboardBlocks = whiteboardBlocks.length > 0; 137 | 138 | // 检查是否有 block_type 为 27 的块(图片块) 139 | const imageBlocks = blocks.filter((block: any) => block.block_type === 27); 140 | const hasImageBlocks = imageBlocks.length > 0; 141 | 142 | let responseText = JSON.stringify(blocks, null, 2); 143 | 144 | if (hasWhiteboardBlocks) { 145 | responseText += '\n\n⚠️ 检测到画板块 (block_type: 43)!\n'; 146 | responseText += `发现 ${whiteboardBlocks.length} 个画板块。\n`; 147 | responseText += '💡 提示:如果您需要获取画板的具体内容(如流程图、思维导图等),可以使用 get_feishu_whiteboard_content 工具。\n'; 148 | responseText += '画板信息:\n'; 149 | whiteboardBlocks.forEach((block: any, index: number) => { 150 | responseText += ` ${index + 1}. 块ID: ${block.block_id}`; 151 | if (block.board && block.board.token) { 152 | responseText += `, 画板ID: ${block.board.token}`; 153 | } 154 | responseText += '\n'; 155 | }); 156 | responseText += '📝 注意:只有在需要分析画板内容时才调用上述工具,仅了解文档结构时无需获取。'; 157 | } 158 | 159 | if (hasImageBlocks) { 160 | responseText += '\n\n🖼️ 检测到图片块 (block_type: 27)!\n'; 161 | responseText += `发现 ${imageBlocks.length} 个图片块。\n`; 162 | responseText += '💡 提示:如果您需要查看图片的具体内容,可以使用 get_feishu_image_resource 工具下载图片。\n'; 163 | responseText += '图片信息:\n'; 164 | imageBlocks.forEach((block: any, index: number) => { 165 | responseText += ` ${index + 1}. 块ID: ${block.block_id}`; 166 | if (block.image && block.image.token) { 167 | responseText += `, 媒体ID: ${block.image.token}`; 168 | } 169 | responseText += '\n'; 170 | }); 171 | responseText += '📝 注意:只有在需要查看图片内容时才调用上述工具,仅了解文档结构时无需获取。'; 172 | } 173 | 174 | return { 175 | content: [{ type: 'text', text: responseText }], 176 | }; 177 | } catch (error) { 178 | Logger.error(`获取飞书文档块失败:`, error); 179 | const errorMessage = formatErrorMessage(error); 180 | return { 181 | content: [{ type: 'text', text: `获取飞书文档块失败: ${errorMessage}` }], 182 | }; 183 | } 184 | }, 185 | ); 186 | 187 | // 添加获取块内容工具 188 | // server.tool( 189 | // 'get_feishu_block_content', 190 | // 'Retrieves the detailed content and structure of a specific block in a Feishu document. Useful for inspecting block properties, formatting, and content, especially before making updates or for debugging purposes. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.', 191 | // { 192 | // documentId: DocumentIdSchema, 193 | // blockId: BlockIdSchema, 194 | // }, 195 | // async ({ documentId, blockId }) => { 196 | // try { 197 | // if (!feishuService) { 198 | // return { 199 | // content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 200 | // }; 201 | // } 202 | // 203 | // Logger.info(`开始获取飞书块内容,文档ID: ${documentId},块ID: ${blockId}`); 204 | // const blockContent = await feishuService.getBlockContent(documentId, blockId); 205 | // Logger.info(`飞书块内容获取成功,块类型: ${blockContent.block_type}`); 206 | // 207 | // return { 208 | // content: [{ type: 'text', text: JSON.stringify(blockContent, null, 2) }], 209 | // }; 210 | // } catch (error) { 211 | // Logger.error(`获取飞书块内容失败:`, error); 212 | // const errorMessage = formatErrorMessage(error); 213 | // return { 214 | // content: [{ type: 'text', text: `获取飞书块内容失败: ${errorMessage}` }], 215 | // }; 216 | // } 217 | // }, 218 | // ); 219 | 220 | // 添加搜索文档工具 221 | server.tool( 222 | 'search_feishu_documents', 223 | 'Searches for documents in Feishu. Supports keyword-based search and returns document information including title, type, and owner. Use this tool to find specific content or related documents in your document library.', 224 | { 225 | searchKey: SearchKeySchema, 226 | }, 227 | async ({ searchKey }) => { 228 | try { 229 | if (!feishuService) { 230 | return { 231 | content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration.' }], 232 | }; 233 | } 234 | 235 | Logger.info(`开始搜索飞书文档,关键字: ${searchKey},`); 236 | const searchResult = await feishuService.searchDocuments(searchKey); 237 | Logger.info(`文档搜索完成,找到 ${searchResult.size} 个结果`); 238 | return { 239 | content: [ 240 | { type: 'text', text: JSON.stringify(searchResult, null, 2) }, 241 | ], 242 | }; 243 | } catch (error) { 244 | Logger.error(`搜索飞书文档失败:`, error); 245 | const errorMessage = formatErrorMessage(error); 246 | return { 247 | content: [ 248 | { type: 'text', text: `搜索飞书文档失败: ${errorMessage}` }, 249 | ], 250 | }; 251 | } 252 | }, 253 | ); 254 | 255 | // 添加获取画板内容工具 256 | server.tool( 257 | 'get_feishu_whiteboard_content', 258 | 'Retrieves the content and structure of a Feishu whiteboard. Use this to analyze whiteboard content, extract information, or understand the structure of collaborative diagrams. The whiteboard ID can be obtained from the board.token field when getting document blocks with block_type: 43.', 259 | { 260 | whiteboardId: WhiteboardIdSchema, 261 | }, 262 | async ({ whiteboardId }) => { 263 | try { 264 | if (!feishuService) { 265 | return { 266 | content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }], 267 | }; 268 | } 269 | 270 | Logger.info(`开始获取飞书画板内容,画板ID: ${whiteboardId}`); 271 | const whiteboardContent = await feishuService.getWhiteboardContent(whiteboardId); 272 | const nodeCount = whiteboardContent.nodes?.length || 0; 273 | Logger.info(`飞书画板内容获取成功,节点数量: ${nodeCount}`); 274 | 275 | // 检查节点数量是否超过100 276 | if (nodeCount > 200) { 277 | Logger.info(`画板节点数量过多 (${nodeCount} > 200),返回缩略图`); 278 | 279 | try { 280 | const thumbnailBuffer = await feishuService.getWhiteboardThumbnail(whiteboardId); 281 | const thumbnailBase64 = thumbnailBuffer.toString('base64'); 282 | 283 | return { 284 | content: [ 285 | { 286 | type: 'image', 287 | data: thumbnailBase64, 288 | mimeType: 'image/png' 289 | } 290 | ], 291 | }; 292 | } catch (thumbnailError) { 293 | Logger.warn(`获取画板缩略图失败,返回基本信息: ${thumbnailError}`); 294 | } 295 | } 296 | 297 | return { 298 | content: [{ type: 'text', text: JSON.stringify(whiteboardContent, null, 2) }], 299 | }; 300 | } catch (error) { 301 | Logger.error(`获取飞书画板内容失败:`, error); 302 | const errorMessage = formatErrorMessage(error); 303 | return { 304 | content: [{ type: 'text', text: `获取飞书画板内容失败: ${errorMessage}` }], 305 | }; 306 | } 307 | }, 308 | ); 309 | } -------------------------------------------------------------------------------- /src/services/blockFactory.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../utils/logger.js'; 2 | 3 | /** 4 | * 块类型接口 5 | */ 6 | export interface FeishuBlock { 7 | block_type: number; 8 | [key: string]: any; 9 | } 10 | 11 | /** 12 | * 文本样式接口 13 | */ 14 | export interface TextElementStyle { 15 | bold?: boolean; // 是否加粗 16 | italic?: boolean; // 是否斜体 17 | underline?: boolean; // 是否下划线 18 | strikethrough?: boolean; // 是否删除线 19 | inline_code?: boolean; // 是否行内代码 20 | text_color?: number; // 文本颜色 21 | } 22 | 23 | /** 24 | * 文本内容接口 25 | */ 26 | export interface TextContent { 27 | text: string; // 文本内容 28 | style?: TextElementStyle; // 文本样式 29 | } 30 | 31 | /** 32 | * 公式内容接口 33 | */ 34 | export interface EquationContent { 35 | equation: string; // 公式内容 36 | style?: TextElementStyle; // 文本样式 37 | } 38 | 39 | /** 40 | * 文本元素类型 - 可以是普通文本或公式 41 | */ 42 | export type TextElement = TextContent | EquationContent; 43 | 44 | /** 45 | * 文本块接口 46 | */ 47 | export interface TextBlock extends FeishuBlock { 48 | block_type: 2; // 文本块类型固定为2 49 | text: { 50 | elements: Array<{ 51 | text_run: { 52 | content: string; 53 | text_element_style: TextElementStyle; 54 | } 55 | }>; 56 | style: { 57 | align: number; // 对齐方式:1左对齐,2居中,3右对齐 58 | } 59 | }; 60 | } 61 | 62 | /** 63 | * 代码块接口 64 | */ 65 | export interface CodeBlock extends FeishuBlock { 66 | block_type: 14; // 代码块类型固定为14 67 | code: { 68 | elements: Array<{ 69 | text_run: { 70 | content: string; 71 | text_element_style: TextElementStyle; 72 | } 73 | }>; 74 | style: { 75 | language: number; // 语言类型代码 76 | wrap: boolean; // 是否自动换行 77 | } 78 | }; 79 | } 80 | 81 | /** 82 | * 标题块接口 83 | */ 84 | export interface HeadingBlock extends FeishuBlock { 85 | block_type: number; // 标题块类型:3-11(对应标题级别1-9) 86 | [headingKey: string]: any; // 动态属性名,如heading1, heading2等 87 | } 88 | 89 | /** 90 | * 块类型枚举 91 | */ 92 | export enum BlockType { 93 | TEXT = 'text', 94 | CODE = 'code', 95 | HEADING = 'heading', 96 | LIST = 'list', 97 | IMAGE = 'image', 98 | MERMAID = 'mermaid' 99 | } 100 | 101 | /** 102 | * 对齐方式枚举 103 | */ 104 | export enum AlignType { 105 | LEFT = 1, 106 | CENTER = 2, 107 | RIGHT = 3 108 | } 109 | 110 | /** 111 | * 块工厂类 112 | * 提供统一接口创建不同类型的块内容 113 | */ 114 | export class BlockFactory { 115 | private static instance: BlockFactory; 116 | 117 | private constructor() {} 118 | 119 | /** 120 | * 获取块工厂实例 121 | * @returns 块工厂实例 122 | */ 123 | public static getInstance(): BlockFactory { 124 | if (!BlockFactory.instance) { 125 | BlockFactory.instance = new BlockFactory(); 126 | } 127 | return BlockFactory.instance; 128 | } 129 | 130 | /** 131 | * 获取默认的文本元素样式 132 | * @returns 默认文本元素样式 133 | */ 134 | public static getDefaultTextElementStyle(): TextElementStyle { 135 | return { 136 | bold: false, 137 | inline_code: false, 138 | italic: false, 139 | strikethrough: false, 140 | underline: false 141 | }; 142 | } 143 | 144 | /** 145 | * 应用默认文本样式 146 | * @param style 已有样式(可选) 147 | * @returns 合并后的样式 148 | */ 149 | public static applyDefaultTextStyle(style?: TextElementStyle): TextElementStyle { 150 | const defaultStyle = BlockFactory.getDefaultTextElementStyle(); 151 | return style ? { ...defaultStyle, ...style } : defaultStyle; 152 | } 153 | 154 | /** 155 | * 创建块内容 156 | * @param type 块类型 157 | * @param options 块选项 158 | * @returns 块内容对象 159 | */ 160 | public createBlock(type: BlockType, options: any): FeishuBlock { 161 | switch (type) { 162 | case BlockType.TEXT: 163 | return this.createTextBlock(options); 164 | case BlockType.CODE: 165 | return this.createCodeBlock(options); 166 | case BlockType.HEADING: 167 | return this.createHeadingBlock(options); 168 | case BlockType.LIST: 169 | return this.createListBlock(options); 170 | case BlockType.IMAGE: 171 | return this.createImageBlock(options); 172 | case BlockType.MERMAID: 173 | return this.createMermaidBlock(options); 174 | default: 175 | Logger.error(`不支持的块类型: ${type}`); 176 | throw new Error(`不支持的块类型: ${type}`); 177 | } 178 | } 179 | 180 | /** 181 | * 创建文本块内容 182 | * @param options 文本块选项 183 | * @returns 文本块内容对象 184 | */ 185 | public createTextBlock(options: { 186 | textContents: Array, 187 | align?: AlignType 188 | }): FeishuBlock { 189 | const { textContents, align = AlignType.LEFT } = options; 190 | 191 | return { 192 | block_type: 2, // 2表示文本块 193 | text: { 194 | elements: textContents.map(content => { 195 | // 检查是否是公式元素 196 | if ('equation' in content) { 197 | return { 198 | equation: { 199 | content: content.equation, 200 | text_element_style: BlockFactory.applyDefaultTextStyle(content.style) 201 | } 202 | }; 203 | } else { 204 | // 普通文本元素 205 | return { 206 | text_run: { 207 | content: content.text, 208 | text_element_style: BlockFactory.applyDefaultTextStyle(content.style) 209 | } 210 | }; 211 | } 212 | }), 213 | style: { 214 | align: align, // 1 居左,2 居中,3 居右 215 | folded: false 216 | } 217 | } 218 | }; 219 | } 220 | 221 | /** 222 | * 创建代码块内容 223 | * @param options 代码块选项 224 | * @returns 代码块内容对象 225 | */ 226 | public createCodeBlock(options: { 227 | code: string, 228 | language?: number, 229 | wrap?: boolean 230 | }): FeishuBlock { 231 | const { code, language = 0, wrap = false } = options; 232 | // 校验 language 合法性,飞书API只允许1~75 233 | const safeLanguage = language >= 1 && language <= 75 ? language : 1; 234 | 235 | return { 236 | block_type: 14, // 14表示代码块 237 | code: { 238 | elements: [ 239 | { 240 | text_run: { 241 | content: code, 242 | text_element_style: BlockFactory.getDefaultTextElementStyle() 243 | } 244 | } 245 | ], 246 | style: { 247 | language: safeLanguage, 248 | wrap: wrap 249 | } 250 | } 251 | }; 252 | } 253 | 254 | /** 255 | * 创建标题块内容 256 | * @param options 标题块选项 257 | * @returns 标题块内容对象 258 | */ 259 | public createHeadingBlock(options: { 260 | text: string, 261 | level?: number, 262 | align?: AlignType 263 | }): FeishuBlock { 264 | const { text, level = 1, align = AlignType.LEFT } = options; 265 | 266 | // 确保标题级别在有效范围内(1-9) 267 | const safeLevel = Math.max(1, Math.min(9, level)); 268 | 269 | // 根据标题级别设置block_type和对应的属性名 270 | // 飞书API中,一级标题的block_type为3,二级标题为4,以此类推 271 | const blockType = 2 + safeLevel; // 一级标题为3,二级标题为4,以此类推 272 | const headingKey = `heading${safeLevel}`; // heading1, heading2, ... 273 | 274 | // 构建块内容 275 | const blockContent: any = { 276 | block_type: blockType 277 | }; 278 | 279 | // 设置对应级别的标题属性 280 | blockContent[headingKey] = { 281 | elements: [ 282 | { 283 | text_run: { 284 | content: text, 285 | text_element_style: BlockFactory.getDefaultTextElementStyle() 286 | } 287 | } 288 | ], 289 | style: { 290 | align: align, 291 | folded: false 292 | } 293 | }; 294 | 295 | return blockContent; 296 | } 297 | 298 | /** 299 | * 创建列表块内容(有序或无序) 300 | * @param options 列表块选项 301 | * @returns 列表块内容对象 302 | */ 303 | public createListBlock(options: { 304 | text: string, 305 | isOrdered?: boolean, 306 | align?: AlignType 307 | }): FeishuBlock { 308 | const { text, isOrdered = false, align = AlignType.LEFT } = options; 309 | 310 | // 有序列表是 block_type: 13,无序列表是 block_type: 12 311 | const blockType = isOrdered ? 13 : 12; 312 | const propertyKey = isOrdered ? "ordered" : "bullet"; 313 | 314 | // 构建块内容 315 | const blockContent: any = { 316 | block_type: blockType 317 | }; 318 | 319 | // 设置列表属性 320 | blockContent[propertyKey] = { 321 | elements: [ 322 | { 323 | text_run: { 324 | content: text, 325 | text_element_style: BlockFactory.getDefaultTextElementStyle() 326 | } 327 | } 328 | ], 329 | style: { 330 | align: align, 331 | folded: false 332 | } 333 | }; 334 | 335 | return blockContent; 336 | } 337 | 338 | /** 339 | * 创建图片块内容(空图片块,需要后续设置图片资源) 340 | * @param options 图片块选项 341 | * @returns 图片块内容对象 342 | */ 343 | public createImageBlock(options: { 344 | width?: number, 345 | height?: number 346 | } = {}): FeishuBlock { 347 | const { width = 100, height = 100 } = options; 348 | 349 | return { 350 | block_type: 27, // 27表示图片块 351 | image: { 352 | width: width, 353 | height: height, 354 | token: "" // 空token,需要后续通过API设置 355 | } 356 | }; 357 | } 358 | 359 | /** 360 | * 创建Mermaid 361 | * @param options Mermaid块选项 362 | * @returns Mermaid块内容对象 363 | */ 364 | public createMermaidBlock( 365 | options: { 366 | code?: string; 367 | } = {}, 368 | ): FeishuBlock { 369 | const { code } = options; 370 | return { 371 | block_type: 40, 372 | add_ons: { 373 | component_id: '', 374 | component_type_id: 'blk_631fefbbae02400430b8f9f4', 375 | record: JSON.stringify({ 376 | data: code, 377 | }), 378 | }, 379 | }; 380 | } 381 | 382 | /** 383 | * 创建表格块 384 | * @param options 表格块选项 385 | * @returns 表格块内容对象 386 | */ 387 | public createTableBlock(options: { 388 | columnSize: number; 389 | rowSize: number; 390 | cells?: Array<{ 391 | coordinate: { row: number; column: number }; 392 | content: any; 393 | }>; 394 | }): any { 395 | const { columnSize, rowSize, cells = [] } = options; 396 | 397 | // 生成表格ID 398 | const tableId = `table_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 399 | 400 | const imageBlocks= Array<{ 401 | coordinate: { row: number; column: number }; 402 | localBlockId: string; 403 | }>() 404 | 405 | // 创建表格单元格 406 | const tableCells = []; 407 | const descendants = []; 408 | 409 | for (let row = 0; row < rowSize; row++) { 410 | for (let col = 0; col < columnSize; col++) { 411 | const cellId = `table_cell${row}_${col}`; 412 | 413 | // 查找是否有配置的单元格内容 414 | const cellConfigs = cells.filter(cell => 415 | cell.coordinate.row === row && cell.coordinate.column === col 416 | ); 417 | 418 | // 创建单元格内容 419 | const cellContentBlocks = []; 420 | const cellContentIds = []; 421 | 422 | if (cellConfigs.length > 0) { 423 | // 处理多个内容块 424 | cellConfigs.forEach((cellConfig, index) => { 425 | const cellContentId = `${cellId}_child_${index}`; 426 | const cellContentBlock = { 427 | block_id: cellContentId, 428 | ...cellConfig.content, 429 | children: [] 430 | }; 431 | cellContentBlocks.push(cellContentBlock); 432 | cellContentIds.push(cellContentId); 433 | Logger.info(`处理块:${JSON.stringify(cellConfig)} ${index}`) 434 | if (cellConfig.content.block_type === 27) { 435 | //把图片块保存起来,用于后续获取该图片块的token 436 | imageBlocks.push({ 437 | coordinate: cellConfig.coordinate, 438 | localBlockId: cellContentId, 439 | }); 440 | } 441 | }); 442 | } else { 443 | // 创建空的文本块 444 | const cellContentId = `${cellId}_child`; 445 | const cellContentBlock = { 446 | block_id: cellContentId, 447 | ...this.createTextBlock({ 448 | textContents: [{ text: "" }] 449 | }), 450 | children: [] 451 | }; 452 | cellContentBlocks.push(cellContentBlock); 453 | cellContentIds.push(cellContentId); 454 | } 455 | 456 | // 创建表格单元格块 457 | const tableCell = { 458 | block_id: cellId, 459 | block_type: 32, // 表格单元格类型 460 | table_cell: {}, 461 | children: cellContentIds 462 | }; 463 | 464 | tableCells.push(cellId); 465 | descendants.push(tableCell); 466 | descendants.push(...cellContentBlocks); 467 | } 468 | } 469 | 470 | // 创建表格主体 471 | const tableBlock = { 472 | block_id: tableId, 473 | block_type: 31, // 表格块类型 474 | table: { 475 | property: { 476 | row_size: rowSize, 477 | column_size: columnSize 478 | } 479 | }, 480 | children: tableCells 481 | }; 482 | 483 | descendants.unshift(tableBlock); 484 | 485 | // 过滤并记录 block_type 为 27 的元素 486 | Logger.info(`发现 ${imageBlocks.length} 个图片块 (block_type: 27): ${JSON.stringify(imageBlocks)}`); 487 | 488 | return { 489 | children_id: [tableId], 490 | descendants: descendants, 491 | imageBlocks:imageBlocks 492 | }; 493 | } 494 | } -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { config as loadDotEnv } from 'dotenv'; 2 | import { hideBin } from 'yargs/helpers'; 3 | import yargs from 'yargs'; 4 | import { Logger, LogLevel } from './logger.js'; 5 | 6 | /** 7 | * 配置来源枚举 8 | */ 9 | export enum ConfigSource { 10 | DEFAULT = 'default', 11 | ENV = 'env', 12 | CLI = 'cli', 13 | FILE = 'file' 14 | } 15 | 16 | /** 17 | * 服务器配置接口 18 | */ 19 | export interface ServerConfig { 20 | port: number; 21 | } 22 | 23 | /** 24 | * 飞书配置接口 25 | */ 26 | export interface FeishuConfig { 27 | appId: string; 28 | appSecret: string; 29 | baseUrl: string; 30 | authType: 'tenant' | 'user'; 31 | tokenEndpoint: string; 32 | } 33 | 34 | /** 35 | * 日志配置接口 36 | */ 37 | export interface LogConfig { 38 | level: LogLevel; 39 | showTimestamp: boolean; 40 | showLevel: boolean; 41 | timestampFormat: string; 42 | } 43 | 44 | /** 45 | * 缓存配置接口 46 | */ 47 | export interface CacheConfig { 48 | enabled: boolean; 49 | ttl: number; // 单位:秒 50 | maxSize: number; // 最大缓存条目数 51 | } 52 | 53 | /** 54 | * 应用配置管理类 55 | * 统一管理所有配置,支持环境变量、命令行参数和默认值 56 | */ 57 | export class Config { 58 | private static instance: Config; 59 | 60 | public readonly server: ServerConfig; 61 | public readonly feishu: FeishuConfig; 62 | public readonly log: LogConfig; 63 | public readonly cache: CacheConfig; 64 | 65 | public readonly configSources: { 66 | [key: string]: ConfigSource; 67 | }; 68 | 69 | /** 70 | * 私有构造函数,用于单例模式 71 | */ 72 | private constructor() { 73 | // 确保在任何配置读取前加载.env文件 74 | loadDotEnv(); 75 | 76 | // 解析命令行参数 77 | const argv = this.parseCommandLineArgs(); 78 | 79 | // 初始化配置来源记录 80 | this.configSources = {}; 81 | 82 | // 配置服务器 83 | this.server = this.initServerConfig(argv); 84 | 85 | // 配置飞书 86 | this.feishu = this.initFeishuConfig(argv); 87 | 88 | // 配置日志 89 | this.log = this.initLogConfig(argv); 90 | 91 | // 配置缓存 92 | this.cache = this.initCacheConfig(argv); 93 | } 94 | 95 | /** 96 | * 获取配置单例 97 | * @returns 配置实例 98 | */ 99 | public static getInstance(): Config { 100 | if (!Config.instance) { 101 | Config.instance = new Config(); 102 | } 103 | return Config.instance; 104 | } 105 | 106 | /** 107 | * 解析命令行参数 108 | * @returns 解析后的参数对象 109 | */ 110 | private parseCommandLineArgs(): any { 111 | return yargs(hideBin(process.argv)) 112 | .options({ 113 | port: { 114 | type: 'number', 115 | description: '服务器监听端口' 116 | }, 117 | 'log-level': { 118 | type: 'string', 119 | description: '日志级别 (debug, info, log, warn, error, none)' 120 | }, 121 | 'feishu-app-id': { 122 | type: 'string', 123 | description: '飞书应用ID' 124 | }, 125 | 'feishu-app-secret': { 126 | type: 'string', 127 | description: '飞书应用密钥' 128 | }, 129 | 'feishu-base-url': { 130 | type: 'string', 131 | description: '飞书API基础URL' 132 | }, 133 | 'cache-enabled': { 134 | type: 'boolean', 135 | description: '是否启用缓存' 136 | }, 137 | 'cache-ttl': { 138 | type: 'number', 139 | description: '缓存生存时间(秒)' 140 | }, 141 | 'feishu-auth-type': { 142 | type: 'string', 143 | description: '飞书认证类型 (tenant 或 user)' 144 | }, 145 | 'feishu-token-endpoint': { 146 | type: 'string', 147 | description: '获取token的接口地址,默认 http://localhost:3333/getToken' 148 | } 149 | }) 150 | .help() 151 | .parseSync(); 152 | } 153 | 154 | /** 155 | * 初始化服务器配置 156 | * @param argv 命令行参数 157 | * @returns 服务器配置 158 | */ 159 | private initServerConfig(argv: any): ServerConfig { 160 | const serverConfig: ServerConfig = { 161 | port: 3333, 162 | }; 163 | 164 | // 处理PORT 165 | if (argv.port) { 166 | serverConfig.port = argv.port; 167 | this.configSources['server.port'] = ConfigSource.CLI; 168 | } else if (process.env.PORT) { 169 | serverConfig.port = parseInt(process.env.PORT, 10); 170 | this.configSources['server.port'] = ConfigSource.ENV; 171 | } else { 172 | this.configSources['server.port'] = ConfigSource.DEFAULT; 173 | } 174 | return serverConfig; 175 | } 176 | 177 | /** 178 | * 初始化飞书配置 179 | * @param argv 命令行参数 180 | * @returns 飞书配置 181 | */ 182 | private initFeishuConfig(argv: any): FeishuConfig { 183 | // 先初始化serverConfig以获取端口 184 | const serverConfig = this.server || this.initServerConfig(argv); 185 | const feishuConfig: FeishuConfig = { 186 | appId: '', 187 | appSecret: '', 188 | baseUrl: 'https://open.feishu.cn/open-apis', 189 | authType: 'tenant', // 默认 190 | tokenEndpoint: `http://127.0.0.1:${serverConfig.port}/getToken`, // 默认动态端口 191 | }; 192 | 193 | // 处理App ID 194 | if (argv['feishu-app-id']) { 195 | feishuConfig.appId = argv['feishu-app-id']; 196 | this.configSources['feishu.appId'] = ConfigSource.CLI; 197 | } else if (process.env.FEISHU_APP_ID) { 198 | feishuConfig.appId = process.env.FEISHU_APP_ID; 199 | this.configSources['feishu.appId'] = ConfigSource.ENV; 200 | } 201 | 202 | // 处理App Secret 203 | if (argv['feishu-app-secret']) { 204 | feishuConfig.appSecret = argv['feishu-app-secret']; 205 | this.configSources['feishu.appSecret'] = ConfigSource.CLI; 206 | } else if (process.env.FEISHU_APP_SECRET) { 207 | feishuConfig.appSecret = process.env.FEISHU_APP_SECRET; 208 | this.configSources['feishu.appSecret'] = ConfigSource.ENV; 209 | } 210 | 211 | // 处理Base URL 212 | if (argv['feishu-base-url']) { 213 | feishuConfig.baseUrl = argv['feishu-base-url']; 214 | this.configSources['feishu.baseUrl'] = ConfigSource.CLI; 215 | } else if (process.env.FEISHU_BASE_URL) { 216 | feishuConfig.baseUrl = process.env.FEISHU_BASE_URL; 217 | this.configSources['feishu.baseUrl'] = ConfigSource.ENV; 218 | } else { 219 | this.configSources['feishu.baseUrl'] = ConfigSource.DEFAULT; 220 | } 221 | 222 | // 处理authType 223 | if (argv['feishu-auth-type']) { 224 | feishuConfig.authType = argv['feishu-auth-type'] === 'user' ? 'user' : 'tenant'; 225 | this.configSources['feishu.authType'] = ConfigSource.CLI; 226 | } else if (process.env.FEISHU_AUTH_TYPE) { 227 | feishuConfig.authType = process.env.FEISHU_AUTH_TYPE === 'user' ? 'user' : 'tenant'; 228 | this.configSources['feishu.authType'] = ConfigSource.ENV; 229 | } else { 230 | this.configSources['feishu.authType'] = ConfigSource.DEFAULT; 231 | } 232 | 233 | // 处理tokenEndpoint 234 | if (argv['feishu-token-endpoint']) { 235 | feishuConfig.tokenEndpoint = argv['feishu-token-endpoint']; 236 | this.configSources['feishu.tokenEndpoint'] = ConfigSource.CLI; 237 | } else if (process.env.FEISHU_TOKEN_ENDPOINT) { 238 | feishuConfig.tokenEndpoint = process.env.FEISHU_TOKEN_ENDPOINT; 239 | this.configSources['feishu.tokenEndpoint'] = ConfigSource.ENV; 240 | } else { 241 | this.configSources['feishu.tokenEndpoint'] = ConfigSource.DEFAULT; 242 | } 243 | 244 | return feishuConfig; 245 | } 246 | 247 | /** 248 | * 初始化日志配置 249 | * @param argv 命令行参数 250 | * @returns 日志配置 251 | */ 252 | private initLogConfig(argv: any): LogConfig { 253 | const logConfig: LogConfig = { 254 | level: LogLevel.INFO, 255 | showTimestamp: true, 256 | showLevel: true, 257 | timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS' 258 | }; 259 | 260 | // 处理日志级别 261 | if (argv['log-level']) { 262 | logConfig.level = this.getLogLevelFromString(argv['log-level']); 263 | this.configSources['log.level'] = ConfigSource.CLI; 264 | } else if (process.env.LOG_LEVEL) { 265 | logConfig.level = this.getLogLevelFromString(process.env.LOG_LEVEL); 266 | this.configSources['log.level'] = ConfigSource.ENV; 267 | } else { 268 | this.configSources['log.level'] = ConfigSource.DEFAULT; 269 | } 270 | 271 | // 处理时间戳显示 272 | if (process.env.LOG_SHOW_TIMESTAMP) { 273 | logConfig.showTimestamp = process.env.LOG_SHOW_TIMESTAMP.toLowerCase() === 'true'; 274 | this.configSources['log.showTimestamp'] = ConfigSource.ENV; 275 | } else { 276 | this.configSources['log.showTimestamp'] = ConfigSource.DEFAULT; 277 | } 278 | 279 | // 处理级别显示 280 | if (process.env.LOG_SHOW_LEVEL) { 281 | logConfig.showLevel = process.env.LOG_SHOW_LEVEL.toLowerCase() === 'true'; 282 | this.configSources['log.showLevel'] = ConfigSource.ENV; 283 | } else { 284 | this.configSources['log.showLevel'] = ConfigSource.DEFAULT; 285 | } 286 | 287 | // 处理时间戳格式 288 | if (process.env.LOG_TIMESTAMP_FORMAT) { 289 | logConfig.timestampFormat = process.env.LOG_TIMESTAMP_FORMAT; 290 | this.configSources['log.timestampFormat'] = ConfigSource.ENV; 291 | } else { 292 | this.configSources['log.timestampFormat'] = ConfigSource.DEFAULT; 293 | } 294 | 295 | return logConfig; 296 | } 297 | 298 | /** 299 | * 初始化缓存配置 300 | * @param argv 命令行参数 301 | * @returns 缓存配置 302 | */ 303 | private initCacheConfig(argv: any): CacheConfig { 304 | const cacheConfig: CacheConfig = { 305 | enabled: true, 306 | ttl: 300, // 5分钟,单位:秒 307 | maxSize: 100 308 | }; 309 | 310 | // 处理缓存启用 311 | if (argv['cache-enabled'] !== undefined) { 312 | cacheConfig.enabled = argv['cache-enabled']; 313 | this.configSources['cache.enabled'] = ConfigSource.CLI; 314 | } else if (process.env.CACHE_ENABLED) { 315 | cacheConfig.enabled = process.env.CACHE_ENABLED.toLowerCase() === 'true'; 316 | this.configSources['cache.enabled'] = ConfigSource.ENV; 317 | } else { 318 | this.configSources['cache.enabled'] = ConfigSource.DEFAULT; 319 | } 320 | 321 | // 处理TTL 322 | if (argv['cache-ttl']) { 323 | cacheConfig.ttl = argv['cache-ttl']; 324 | this.configSources['cache.ttl'] = ConfigSource.CLI; 325 | } else if (process.env.CACHE_TTL) { 326 | cacheConfig.ttl = parseInt(process.env.CACHE_TTL, 10); 327 | this.configSources['cache.ttl'] = ConfigSource.ENV; 328 | } else { 329 | this.configSources['cache.ttl'] = ConfigSource.DEFAULT; 330 | } 331 | 332 | // 处理最大缓存大小 333 | if (process.env.CACHE_MAX_SIZE) { 334 | cacheConfig.maxSize = parseInt(process.env.CACHE_MAX_SIZE, 10); 335 | this.configSources['cache.maxSize'] = ConfigSource.ENV; 336 | } else { 337 | this.configSources['cache.maxSize'] = ConfigSource.DEFAULT; 338 | } 339 | 340 | return cacheConfig; 341 | } 342 | 343 | /** 344 | * 从字符串获取日志级别 345 | * @param levelStr 日志级别字符串 346 | * @returns 日志级别枚举值 347 | */ 348 | private getLogLevelFromString(levelStr: string): LogLevel { 349 | switch (levelStr.toLowerCase()) { 350 | case 'debug': return LogLevel.DEBUG; 351 | case 'info': return LogLevel.INFO; 352 | case 'log': return LogLevel.LOG; 353 | case 'warn': return LogLevel.WARN; 354 | case 'error': return LogLevel.ERROR; 355 | case 'none': return LogLevel.NONE; 356 | default: return LogLevel.INFO; 357 | } 358 | } 359 | 360 | /** 361 | * 打印当前配置信息 362 | * @param isStdioMode 是否在stdio模式下 363 | */ 364 | public printConfig(isStdioMode: boolean = false): void { 365 | if (isStdioMode) return; 366 | 367 | Logger.info('当前配置:'); 368 | 369 | Logger.info('服务器配置:'); 370 | Logger.info(`- 端口: ${this.server.port} (来源: ${this.configSources['server.port']})`); 371 | 372 | Logger.info('飞书配置:'); 373 | if (this.feishu.appId) { 374 | Logger.info(`- App ID: ${this.maskApiKey(this.feishu.appId)} (来源: ${this.configSources['feishu.appId']})`); 375 | } 376 | if (this.feishu.appSecret) { 377 | Logger.info(`- App Secret: ${this.maskApiKey(this.feishu.appSecret)} (来源: ${this.configSources['feishu.appSecret']})`); 378 | } 379 | Logger.info(`- API URL: ${this.feishu.baseUrl} (来源: ${this.configSources['feishu.baseUrl']})`); 380 | Logger.info(`- 认证类型: ${this.feishu.authType} (来源: ${this.configSources['feishu.authType']})`); 381 | 382 | Logger.info('日志配置:'); 383 | Logger.info(`- 日志级别: ${LogLevel[this.log.level]} (来源: ${this.configSources['log.level']})`); 384 | Logger.info(`- 显示时间戳: ${this.log.showTimestamp} (来源: ${this.configSources['log.showTimestamp']})`); 385 | Logger.info(`- 显示日志级别: ${this.log.showLevel} (来源: ${this.configSources['log.showLevel']})`); 386 | 387 | Logger.info('缓存配置:'); 388 | Logger.info(`- 启用缓存: ${this.cache.enabled} (来源: ${this.configSources['cache.enabled']})`); 389 | Logger.info(`- 缓存TTL: ${this.cache.ttl}秒 (来源: ${this.configSources['cache.ttl']})`); 390 | Logger.info(`- 最大缓存条目: ${this.cache.maxSize} (来源: ${this.configSources['cache.maxSize']})`); 391 | } 392 | 393 | /** 394 | * 掩盖API密钥 395 | * @param key API密钥 396 | * @returns 掩盖后的密钥字符串 397 | */ 398 | private maskApiKey(key: string): string { 399 | if (!key || key.length <= 4) return '****'; 400 | return `${key.substring(0, 2)}****${key.substring(key.length - 2)}`; 401 | } 402 | 403 | /** 404 | * 验证配置是否完整有效 405 | * @returns 是否验证成功 406 | */ 407 | public validate(): boolean { 408 | // 验证服务器配置 409 | if (!this.server.port || this.server.port <= 0) { 410 | Logger.error('无效的服务器端口配置'); 411 | return false; 412 | } 413 | 414 | // 验证飞书配置 415 | if (!this.feishu.appId) { 416 | Logger.error('缺少飞书应用ID,请通过环境变量FEISHU_APP_ID或命令行参数--feishu-app-id提供'); 417 | return false; 418 | } 419 | 420 | if (!this.feishu.appSecret) { 421 | Logger.error('缺少飞书应用Secret,请通过环境变量FEISHU_APP_SECRET或命令行参数--feishu-app-secret提供'); 422 | return false; 423 | } 424 | 425 | return true; 426 | } 427 | } -------------------------------------------------------------------------------- /src/services/baseService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosRequestConfig } from 'axios'; 2 | import FormData from 'form-data'; 3 | import { Logger } from '../utils/logger.js'; 4 | import { formatErrorMessage, AuthRequiredError } from '../utils/error.js'; 5 | import { Config } from '../utils/config.js'; 6 | import { TokenCacheManager, UserContextManager,AuthUtils } from '../utils/auth/index.js'; 7 | 8 | /** 9 | * API请求错误接口 10 | */ 11 | export interface ApiError { 12 | status: number; 13 | err: string; 14 | apiError?: any; 15 | logId?: string; 16 | } 17 | 18 | /** 19 | * API响应接口 20 | */ 21 | export interface ApiResponse { 22 | code: number; 23 | msg: string; 24 | data: T; 25 | log_id?: string; 26 | } 27 | 28 | /** 29 | * API服务基类 30 | * 提供通用的HTTP请求处理和认证功能 31 | */ 32 | export abstract class BaseApiService { 33 | 34 | /** 35 | * 获取API基础URL 36 | * @returns API基础URL 37 | */ 38 | protected abstract getBaseUrl(): string; 39 | 40 | /** 41 | * 获取访问令牌 42 | * @param userKey 用户标识(可选) 43 | * @returns 访问令牌 44 | */ 45 | protected abstract getAccessToken(userKey?: string): Promise; 46 | 47 | /** 48 | * 处理API错误 49 | * @param error 错误对象 50 | * @param message 错误上下文消息 51 | * @throws 标准化的API错误 52 | */ 53 | protected handleApiError(error: any, message: string): never { 54 | Logger.error(`${message}:`, error); 55 | 56 | // 如果已经是格式化的API错误,直接重新抛出 57 | if (error && typeof error === 'object' && 'status' in error && 'err' in error) { 58 | throw error; 59 | } 60 | 61 | // 处理Axios错误 62 | if (error instanceof AxiosError && error.response) { 63 | const responseData = error.response.data; 64 | const apiError: ApiError = { 65 | status: error.response.status, 66 | err: formatErrorMessage(error, message), 67 | apiError: responseData, 68 | logId: responseData?.log_id 69 | }; 70 | throw apiError; 71 | } 72 | 73 | // 处理其他类型的错误 74 | const errorMessage = error instanceof Error 75 | ? error.message 76 | : (typeof error === 'string' ? error : '未知错误'); 77 | 78 | throw { 79 | status: 500, 80 | err: formatErrorMessage(error, message), 81 | apiError: { 82 | code: -1, 83 | msg: errorMessage, 84 | error 85 | } 86 | } as ApiError; 87 | } 88 | 89 | /** 90 | * 执行API请求 91 | * @param endpoint 请求端点 92 | * @param method 请求方法 93 | * @param data 请求数据 94 | * @param needsAuth 是否需要认证 95 | * @param additionalHeaders 附加请求头 96 | * @param responseType 响应类型 97 | * @param retry 是否允许重试,默认为false 98 | * @returns 响应数据 99 | */ 100 | protected async request( 101 | endpoint: string, 102 | method: string = 'GET', 103 | data?: any, 104 | needsAuth: boolean = true, 105 | additionalHeaders?: Record, 106 | responseType?: 'json' | 'arraybuffer' | 'blob' | 'document' | 'text' | 'stream', 107 | retry: boolean = false 108 | ): Promise { 109 | // 获取用户上下文 110 | const userContextManager = UserContextManager.getInstance(); 111 | const userKey = userContextManager.getUserKey(); 112 | const baseUrl = userContextManager.getBaseUrl(); 113 | const clientKey = AuthUtils.generateClientKey(userKey); 114 | 115 | Logger.debug(`[BaseService] Request context - userKey: ${userKey}, baseUrl: ${baseUrl}`); 116 | 117 | try { 118 | // 构建请求URL 119 | const url = `${this.getBaseUrl()}${endpoint}`; 120 | 121 | // 准备请求头 122 | const headers: Record = { 123 | ...additionalHeaders 124 | }; 125 | 126 | // 如果数据是FormData,合并FormData的headers 127 | // 否则设置为application/json 128 | if (data instanceof FormData) { 129 | Object.assign(headers, data.getHeaders()); 130 | } else { 131 | headers['Content-Type'] = 'application/json'; 132 | } 133 | 134 | // 添加认证令牌 135 | if (needsAuth) { 136 | const accessToken = await this.getAccessToken(userKey); 137 | headers['Authorization'] = `Bearer ${accessToken}`; 138 | } 139 | 140 | // 记录请求信息 141 | Logger.debug('准备发送请求:'); 142 | Logger.debug(`请求URL: ${url}`); 143 | Logger.debug(`请求方法: ${method}`); 144 | if (data) { 145 | Logger.debug(`请求数据:`, data); 146 | } 147 | 148 | // 构建请求配置 149 | const config: AxiosRequestConfig = { 150 | method, 151 | url, 152 | headers, 153 | data: method !== 'GET' ? data : undefined, 154 | params: method === 'GET' ? data : undefined, 155 | responseType: responseType || 'json' 156 | }; 157 | 158 | // 发送请求 159 | const response = await axios>(config); 160 | 161 | // 记录响应信息 162 | Logger.debug('收到响应:'); 163 | Logger.debug(`响应状态码: ${response.status}`); 164 | Logger.debug(`响应头:`, response.headers); 165 | Logger.debug(`响应数据:`, response.data); 166 | 167 | // 对于非JSON响应,直接返回数据 168 | if (responseType && responseType !== 'json') { 169 | return response.data as T; 170 | } 171 | 172 | // 检查API错误(仅对JSON响应) 173 | if (response.data && typeof response.data.code === 'number' && response.data.code !== 0) { 174 | Logger.error(`API返回错误码: ${response.data.code}, 错误消息: ${response.data.msg}`); 175 | throw { 176 | status: response.status, 177 | err: response.data.msg || 'API返回错误码', 178 | apiError: response.data, 179 | logId: response.data.log_id 180 | } as ApiError; 181 | } 182 | 183 | // 返回数据 184 | return response.data.data; 185 | } catch (error) { 186 | const config = Config.getInstance().feishu; 187 | 188 | // 处理授权异常 189 | if (error instanceof AuthRequiredError) { 190 | return this.handleAuthFailure(config.authType==="tenant", clientKey, baseUrl, userKey); 191 | } 192 | 193 | const tokenError = new Set([ 194 | 4001, // Invalid token, please refresh 195 | 20006, // 过期 User Access Token 196 | 20013, // get tenant access token fail 197 | 99991663, // Invalid access token for authorization (often tenant token) 198 | 99991668, // Invalid access token for authorization (user token) 199 | 99991677, // user token expire 200 | 99991669, // invalid user refresh token 201 | 99991664, // invalid app token 202 | 99991665 // invalid tenant code 203 | ]); 204 | // 处理认证相关错误(401, 403等 或 明确的 token 错误码) 205 | if (error instanceof AxiosError && error.response && tokenError.has(Number(error.response.data?.code))) { 206 | Logger.warn(`认证失败 (${error.response.status}): ${endpoint} ${JSON.stringify(error.response.data)}`); 207 | 208 | // 获取配置和token缓存管理器 209 | const tokenCacheManager = TokenCacheManager.getInstance(); 210 | 211 | // 如果已经重试过,直接处理认证失败 212 | if (retry) { 213 | return this.handleAuthFailure(config.authType==="tenant", clientKey, baseUrl, userKey); 214 | } 215 | 216 | // 根据认证类型处理token过期 217 | if (config.authType === 'tenant') { 218 | return this.handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType); 219 | } else { 220 | return this.handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType,baseUrl, userKey); 221 | } 222 | } 223 | // 处理其他错误 224 | this.handleApiError(error, `API请求失败 (${endpoint})`); 225 | } 226 | } 227 | 228 | /** 229 | * GET请求 230 | * @param endpoint 请求端点 231 | * @param params 请求参数 232 | * @param needsAuth 是否需要认证 233 | * @returns 响应数据 234 | */ 235 | protected async get(endpoint: string, params?: any, needsAuth: boolean = true): Promise { 236 | return this.request(endpoint, 'GET', params, needsAuth); 237 | } 238 | 239 | /** 240 | * POST请求 241 | * @param endpoint 请求端点 242 | * @param data 请求数据 243 | * @param needsAuth 是否需要认证 244 | * @returns 响应数据 245 | */ 246 | protected async post(endpoint: string, data?: any, needsAuth: boolean = true): Promise { 247 | return this.request(endpoint, 'POST', data, needsAuth); 248 | } 249 | 250 | /** 251 | * PUT请求 252 | * @param endpoint 请求端点 253 | * @param data 请求数据 254 | * @param needsAuth 是否需要认证 255 | * @returns 响应数据 256 | */ 257 | protected async put(endpoint: string, data?: any, needsAuth: boolean = true): Promise { 258 | return this.request(endpoint, 'PUT', data, needsAuth); 259 | } 260 | 261 | /** 262 | * PATCH请求 263 | * @param endpoint 请求端点 264 | * @param data 请求数据 265 | * @param needsAuth 是否需要认证 266 | * @returns 响应数据 267 | */ 268 | protected async patch(endpoint: string, data?: any, needsAuth: boolean = true): Promise { 269 | return this.request(endpoint, 'PATCH', data, needsAuth); 270 | } 271 | 272 | /** 273 | * DELETE请求 274 | * @param endpoint 请求端点 275 | * @param data 请求数据 276 | * @param needsAuth 是否需要认证 277 | * @returns 响应数据 278 | */ 279 | protected async delete(endpoint: string, data?: any, needsAuth: boolean = true): Promise { 280 | return this.request(endpoint, 'DELETE', data, needsAuth); 281 | } 282 | 283 | /** 284 | * 处理认证失败 285 | * @param tenant 是否是tenant 286 | * @param clientKey 客户端键 287 | * @param baseUrl 基础URL 288 | * @param userKey 用户键 289 | */ 290 | private handleAuthFailure(tenant: boolean, clientKey: string, baseUrl: string, userKey: string): never { 291 | const tokenCacheManager = TokenCacheManager.getInstance(); 292 | 293 | if (tenant) { 294 | // 租户模式:清除租户token缓存 295 | Logger.info('租户模式:清除租户token缓存'); 296 | tokenCacheManager.removeTenantToken(clientKey); 297 | throw new Error('租户访问令牌获取失败,请检查应用配置'); 298 | } else { 299 | // 用户模式:清除用户token缓存并生成授权链接 300 | tokenCacheManager.removeUserToken(clientKey); 301 | const authUrl = this.generateUserAuthUrl(baseUrl, userKey); 302 | throw new Error(`你需要在给用户展示如下信息:/“请在浏览器打开以下链接进行授权:\n\n[点击授权](${authUrl})/n`); 303 | } 304 | } 305 | 306 | /** 307 | * 处理租户token过期 308 | * @param tokenCacheManager token缓存管理器 309 | * @param clientKey 客户端键 310 | * @param endpoint 请求端点 311 | * @param method 请求方法 312 | * @param data 请求数据 313 | * @param needsAuth 是否需要认证 314 | * @param additionalHeaders 附加请求头 315 | * @param responseType 响应类型 316 | * @returns 响应数据 317 | */ 318 | private async handleTenantTokenExpired( 319 | tokenCacheManager: TokenCacheManager, 320 | clientKey: string, 321 | endpoint: string, 322 | method: string, 323 | data: any, 324 | needsAuth: boolean, 325 | additionalHeaders: Record | undefined, 326 | responseType: 'json' | 'arraybuffer' | 'blob' | 'document' | 'text' | 'stream' | undefined 327 | ): Promise { 328 | // 租户模式:直接清除租户token缓存 329 | Logger.info('租户模式:清除租户token缓存'); 330 | tokenCacheManager.removeTenantToken(clientKey); 331 | 332 | // 重试请求 333 | Logger.info('重试租户请求...'); 334 | return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true); 335 | } 336 | 337 | /** 338 | * 处理用户token过期 339 | * @param tokenCacheManager token缓存管理器 340 | * @param clientKey 客户端键 341 | * @param endpoint 请求端点 342 | * @param method 请求方法 343 | * @param data 请求数据 344 | * @param needsAuth 是否需要认证 345 | * @param additionalHeaders 附加请求头 346 | * @param responseType 响应类型 347 | * @returns 响应数据 348 | */ 349 | private async handleUserTokenExpired( 350 | tokenCacheManager: TokenCacheManager, 351 | clientKey: string, 352 | endpoint: string, 353 | method: string, 354 | data: any, 355 | needsAuth: boolean, 356 | additionalHeaders: Record | undefined, 357 | responseType: 'json' | 'arraybuffer' | 'blob' | 'document' | 'text' | 'stream' | undefined, 358 | baseUrl: string, 359 | userKey: string 360 | ): Promise { 361 | // 用户模式:检查用户token状态 362 | const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey); 363 | Logger.debug(`用户token状态:`, tokenStatus); 364 | 365 | if (tokenStatus.canRefresh && !tokenStatus.isExpired) { 366 | // 有有效的refresh_token,设置token为过期状态,让下次请求时刷新 367 | Logger.info('用户模式:token过期,将在下次请求时刷新'); 368 | const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey); 369 | if (tokenInfo) { 370 | // 设置access_token为过期,但保留refresh_token 371 | tokenInfo.expires_at = Math.floor(Date.now() / 1000) - 1; 372 | tokenCacheManager.cacheUserToken(clientKey, tokenInfo); 373 | } 374 | 375 | // 重试请求 376 | Logger.info('重试用户请求...'); 377 | return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true); 378 | } else { 379 | // refresh_token已过期或不存在,直接清除缓存 380 | Logger.warn('用户模式:refresh_token已过期,清除用户token缓存'); 381 | tokenCacheManager.removeUserToken(clientKey); 382 | return this.handleAuthFailure(true,clientKey,baseUrl,userKey); 383 | } 384 | } 385 | 386 | /** 387 | * 生成用户授权URL 388 | * @param baseUrl 基础URL 389 | * @param userKey 用户键 390 | * @returns 授权URL 391 | */ 392 | private generateUserAuthUrl(baseUrl: string, userKey: string): string { 393 | const { appId, appSecret } = Config.getInstance().feishu; 394 | const clientKey = AuthUtils.generateClientKey(userKey); 395 | const redirect_uri = `${baseUrl}/callback`; 396 | 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'); 397 | const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri); 398 | 399 | return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`; 400 | } 401 | } -------------------------------------------------------------------------------- /src/utils/auth/tokenCacheManager.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import { Logger } from '../logger.js'; 4 | 5 | /** 6 | * 用户Token信息接口 7 | */ 8 | export interface UserTokenInfo { 9 | token_type: string; 10 | access_token: string; 11 | refresh_token: string; 12 | scope: string; 13 | code: number; 14 | expires_at: number; 15 | refresh_token_expires_at: number; 16 | generated_token: string; 17 | client_id: string; // 应用ID,用于刷新token 18 | client_secret: string; // 应用密钥,用于刷新token 19 | } 20 | 21 | /** 22 | * 租户Token信息接口 23 | */ 24 | export interface TenantTokenInfo { 25 | app_access_token: string; 26 | expires_at: number; 27 | } 28 | 29 | /** 30 | * 缓存项接口 31 | */ 32 | interface CacheItem { 33 | data: T; 34 | timestamp: number; 35 | expiresAt: number; 36 | } 37 | 38 | /** 39 | * Token状态接口 40 | */ 41 | export interface TokenStatus { 42 | isValid: boolean; 43 | isExpired: boolean; 44 | canRefresh: boolean; 45 | shouldRefresh: boolean; // 是否应该提前刷新 46 | } 47 | 48 | /** 49 | * Token缓存管理器 50 | * 专门处理用户token和租户token的缓存管理 51 | */ 52 | export class TokenCacheManager { 53 | private static instance: TokenCacheManager; 54 | private cache: Map>; 55 | private userTokenCacheFile: string; 56 | private tenantTokenCacheFile: string; 57 | 58 | /** 59 | * 私有构造函数,用于单例模式 60 | */ 61 | private constructor() { 62 | this.cache = new Map(); 63 | this.userTokenCacheFile = path.resolve(process.cwd(), 'user_token_cache.json'); 64 | this.tenantTokenCacheFile = path.resolve(process.cwd(), 'tenant_token_cache.json'); 65 | 66 | this.loadTokenCaches(); 67 | this.startCacheCleanupTimer(); 68 | } 69 | 70 | /** 71 | * 获取TokenCacheManager实例 72 | */ 73 | public static getInstance(): TokenCacheManager { 74 | if (!TokenCacheManager.instance) { 75 | TokenCacheManager.instance = new TokenCacheManager(); 76 | } 77 | return TokenCacheManager.instance; 78 | } 79 | 80 | /** 81 | * 系统启动时从本地文件缓存中读取token记录 82 | */ 83 | private loadTokenCaches(): void { 84 | this.loadUserTokenCache(); 85 | this.loadTenantTokenCache(); 86 | } 87 | 88 | /** 89 | * 加载用户token缓存 90 | */ 91 | private loadUserTokenCache(): void { 92 | if (fs.existsSync(this.userTokenCacheFile)) { 93 | try { 94 | const raw = fs.readFileSync(this.userTokenCacheFile, 'utf-8'); 95 | const cacheData = JSON.parse(raw); 96 | 97 | let loadedCount = 0; 98 | for (const key in cacheData) { 99 | if (key.startsWith('user_access_token:')) { 100 | this.cache.set(key, cacheData[key]); 101 | loadedCount++; 102 | } 103 | } 104 | 105 | Logger.info(`已加载用户token缓存,共 ${loadedCount} 条记录`); 106 | } catch (error) { 107 | Logger.warn('加载用户token缓存失败:', error); 108 | } 109 | } else { 110 | Logger.info('用户token缓存文件不存在,将创建新的缓存'); 111 | } 112 | } 113 | 114 | /** 115 | * 加载租户token缓存 116 | */ 117 | private loadTenantTokenCache(): void { 118 | if (fs.existsSync(this.tenantTokenCacheFile)) { 119 | try { 120 | const raw = fs.readFileSync(this.tenantTokenCacheFile, 'utf-8'); 121 | const cacheData = JSON.parse(raw); 122 | 123 | let loadedCount = 0; 124 | for (const key in cacheData) { 125 | if (key.startsWith('tenant_access_token:')) { 126 | this.cache.set(key, cacheData[key]); 127 | loadedCount++; 128 | } 129 | } 130 | 131 | Logger.info(`已加载租户token缓存,共 ${loadedCount} 条记录`); 132 | } catch (error) { 133 | Logger.warn('加载租户token缓存失败:', error); 134 | } 135 | } 136 | } 137 | 138 | /** 139 | * 根据key获取完整的用户token信息 140 | * @param key 缓存键 141 | * @returns 完整的用户token信息对象,如果未找到或refresh_token过期则返回null 142 | */ 143 | public getUserTokenInfo(key: string): UserTokenInfo | null { 144 | const cacheKey = `user_access_token:${key}`; 145 | const cacheItem = this.cache.get(cacheKey); 146 | 147 | if (!cacheItem) { 148 | Logger.debug(`用户token信息未找到: ${key}`); 149 | return null; 150 | } 151 | 152 | const tokenInfo = cacheItem.data as UserTokenInfo; 153 | const now = Math.floor(Date.now() / 1000); 154 | 155 | // 检查refresh_token是否过期(如果有的话) 156 | if (tokenInfo.refresh_token && tokenInfo.refresh_token_expires_at) { 157 | if (tokenInfo.refresh_token_expires_at < now) { 158 | Logger.debug(`用户token的refresh_token已过期,从缓存中删除: ${key}`); 159 | this.cache.delete(cacheKey); 160 | this.saveUserTokenCache(); 161 | return null; 162 | } 163 | } else { 164 | // 如果没有refresh_token信息,检查缓存本身是否过期 165 | if (Date.now() > cacheItem.expiresAt) { 166 | Logger.debug(`用户token缓存已过期: ${key}`); 167 | this.cache.delete(cacheKey); 168 | this.saveUserTokenCache(); 169 | return null; 170 | } 171 | } 172 | 173 | Logger.debug(`获取用户token信息成功: ${key}`); 174 | return tokenInfo; 175 | } 176 | 177 | /** 178 | * 根据key获取用户的access_token值 179 | * @param key 缓存键 180 | * @returns access_token字符串,如果未找到或已过期则返回null 181 | */ 182 | public getUserToken(key: string): string | null { 183 | const tokenInfo = this.getUserTokenInfo(key); 184 | return tokenInfo ? tokenInfo.access_token : null; 185 | } 186 | 187 | /** 188 | * 根据key获取租户token信息 189 | * @param key 缓存键 190 | * @returns 租户token信息,如果未找到或已过期则返回null 191 | */ 192 | public getTenantTokenInfo(key: string): TenantTokenInfo | null { 193 | const cacheKey = `tenant_access_token:${key}`; 194 | const cacheItem = this.cache.get(cacheKey); 195 | 196 | if (!cacheItem) { 197 | Logger.debug(`租户token信息未找到: ${key}`); 198 | return null; 199 | } 200 | 201 | // 检查是否过期 202 | if (Date.now() > cacheItem.expiresAt) { 203 | Logger.debug(`租户token信息已过期: ${key}`); 204 | this.cache.delete(cacheKey); 205 | this.saveTenantTokenCache(); 206 | return null; 207 | } 208 | 209 | Logger.debug(`获取租户token信息成功: ${key}`); 210 | return cacheItem.data as TenantTokenInfo; 211 | } 212 | 213 | 214 | /** 215 | * 删除租户token 216 | * @param key 缓存键 217 | * @returns 是否成功删除 218 | */ 219 | public removeTenantToken(key: string): boolean { 220 | const cacheKey = `tenant_access_token:${key}`; 221 | const result = this.cache.delete(cacheKey); 222 | 223 | if (result) { 224 | this.saveUserTokenCache(); 225 | Logger.debug(`租户token删除成功: ${key}`); 226 | } 227 | 228 | return result; 229 | } 230 | 231 | /** 232 | * 根据key获取租户的access_token值 233 | * @param key 缓存键 234 | * @returns app_access_token字符串,如果未找到或已过期则返回null 235 | */ 236 | public getTenantToken(key: string): string | null { 237 | const tokenInfo = this.getTenantTokenInfo(key); 238 | return tokenInfo ? tokenInfo.app_access_token : null; 239 | } 240 | 241 | /** 242 | * 缓存用户token信息 243 | * @param key 缓存键 244 | * @param tokenInfo 用户token信息 245 | * @param customTtl 自定义TTL(秒),如果不提供则使用refresh_token的过期时间 246 | * @returns 是否成功缓存 247 | */ 248 | public cacheUserToken(key: string, tokenInfo: UserTokenInfo, customTtl?: number): boolean { 249 | try { 250 | const now = Date.now(); 251 | const cacheKey = `user_access_token:${key}`; 252 | 253 | // 计算过期时间 - 优先使用refresh_token的过期时间,确保可以刷新 254 | let expiresAt: number; 255 | if (customTtl) { 256 | expiresAt = now + (customTtl * 1000); 257 | } else if (tokenInfo.refresh_token_expires_at) { 258 | // 使用refresh_token的过期时间,确保在refresh_token有效期内缓存不会被清除 259 | expiresAt = tokenInfo.refresh_token_expires_at * 1000; // 转换为毫秒 260 | Logger.debug(`使用refresh_token过期时间作为缓存过期时间: ${new Date(expiresAt).toISOString()}`); 261 | } else if (tokenInfo.expires_at) { 262 | // 如果没有refresh_token_expires_at信息,降级使用access_token的过期时间 263 | expiresAt = tokenInfo.expires_at * 1000; 264 | Logger.warn(`没有refresh_token过期时间戳,使用access_token过期时间: ${new Date(expiresAt).toISOString()}`); 265 | } else { 266 | // 最后的降级方案:如果没有任何过期时间信息,设置默认的2小时过期 267 | expiresAt = now + (2 * 60 * 60 * 1000); // 2小时 268 | Logger.warn(`没有过期时间信息,使用默认2小时作为缓存过期时间`); 269 | } 270 | 271 | const cacheItem: CacheItem = { 272 | data: tokenInfo, 273 | timestamp: now, 274 | expiresAt: expiresAt 275 | }; 276 | 277 | this.cache.set(cacheKey, cacheItem); 278 | this.saveUserTokenCache(); 279 | 280 | Logger.debug(`用户token缓存成功: ${key}, 缓存过期时间: ${new Date(expiresAt).toISOString()}`); 281 | return true; 282 | } catch (error) { 283 | Logger.error(`缓存用户token失败: ${key}`, error); 284 | return false; 285 | } 286 | } 287 | 288 | /** 289 | * 缓存租户token信息 290 | * @param key 缓存键 291 | * @param tokenInfo 租户token信息 292 | * @param customTtl 自定义TTL(秒),如果不提供则使用token本身的过期时间 293 | * @returns 是否成功缓存 294 | */ 295 | public cacheTenantToken(key: string, tokenInfo: TenantTokenInfo, customTtl?: number): boolean { 296 | try { 297 | const now = Date.now(); 298 | const cacheKey = `tenant_access_token:${key}`; 299 | 300 | // 计算过期时间 301 | let expiresAt: number; 302 | if (customTtl) { 303 | expiresAt = now + (customTtl * 1000); 304 | } else if (tokenInfo.expires_at) { 305 | expiresAt = tokenInfo.expires_at * 1000; // 转换为毫秒 306 | } else { 307 | // 如果没有过期时间信息,设置默认的2小时过期 308 | expiresAt = now + (2 * 60 * 60 * 1000); 309 | Logger.warn(`租户token没有过期时间信息,使用默认2小时`); 310 | } 311 | 312 | const cacheItem: CacheItem = { 313 | data: tokenInfo, 314 | timestamp: now, 315 | expiresAt: expiresAt 316 | }; 317 | 318 | this.cache.set(cacheKey, cacheItem); 319 | this.saveTenantTokenCache(); 320 | 321 | Logger.debug(`租户token缓存成功: ${key}, 过期时间: ${new Date(expiresAt).toISOString()}`); 322 | return true; 323 | } catch (error) { 324 | Logger.error(`缓存租户token失败: ${key}`, error); 325 | return false; 326 | } 327 | } 328 | 329 | /** 330 | * 检查用户token状态 331 | * @param key 缓存键 332 | * @returns token状态信息 333 | */ 334 | public checkUserTokenStatus(key: string): TokenStatus { 335 | const tokenInfo = this.getUserTokenInfo(key); 336 | 337 | if (!tokenInfo) { 338 | return { 339 | isValid: false, 340 | isExpired: true, 341 | canRefresh: false, 342 | shouldRefresh: false 343 | }; 344 | } 345 | 346 | const now = Math.floor(Date.now() / 1000); 347 | const isExpired = tokenInfo.expires_at ? tokenInfo.expires_at < now : false; 348 | const timeToExpiry = tokenInfo.expires_at ? Math.max(0, tokenInfo.expires_at - now) : 0; 349 | 350 | // 判断是否可以刷新 351 | const canRefresh = !!( 352 | tokenInfo.refresh_token && 353 | tokenInfo.refresh_token_expires_at && 354 | tokenInfo.refresh_token_expires_at > now 355 | ); 356 | 357 | // 判断是否应该提前刷新(提前5分钟) 358 | const shouldRefresh = timeToExpiry > 0 && timeToExpiry < 300 && canRefresh; 359 | 360 | return { 361 | isValid: !isExpired, 362 | isExpired, 363 | canRefresh, 364 | shouldRefresh 365 | }; 366 | } 367 | 368 | /** 369 | * 删除用户token 370 | * @param key 缓存键 371 | * @returns 是否成功删除 372 | */ 373 | public removeUserToken(key: string): boolean { 374 | const cacheKey = `user_access_token:${key}`; 375 | const result = this.cache.delete(cacheKey); 376 | 377 | if (result) { 378 | this.saveUserTokenCache(); 379 | Logger.debug(`用户token删除成功: ${key}`); 380 | } 381 | 382 | return result; 383 | } 384 | 385 | /** 386 | * 保存用户token缓存到文件 387 | */ 388 | private saveUserTokenCache(): void { 389 | const cacheData: Record = {}; 390 | 391 | for (const [key, value] of this.cache.entries()) { 392 | if (key.startsWith('user_access_token:')) { 393 | cacheData[key] = value; 394 | } 395 | } 396 | 397 | try { 398 | fs.writeFileSync(this.userTokenCacheFile, JSON.stringify(cacheData, null, 2), 'utf-8'); 399 | Logger.debug('用户token缓存已保存到文件'); 400 | } catch (error) { 401 | Logger.warn('保存用户token缓存失败:', error); 402 | } 403 | } 404 | 405 | /** 406 | * 保存租户token缓存到文件 407 | */ 408 | private saveTenantTokenCache(): void { 409 | const cacheData: Record = {}; 410 | 411 | for (const [key, value] of this.cache.entries()) { 412 | if (key.startsWith('tenant_access_token:')) { 413 | cacheData[key] = value; 414 | } 415 | } 416 | 417 | try { 418 | fs.writeFileSync(this.tenantTokenCacheFile, JSON.stringify(cacheData, null, 2), 'utf-8'); 419 | Logger.debug('租户token缓存已保存到文件'); 420 | } catch (error) { 421 | Logger.warn('保存租户token缓存失败:', error); 422 | } 423 | } 424 | 425 | /** 426 | * 清理过期缓存 427 | * 对于用户token,只有在refresh_token过期时才清理 428 | * 对于租户token,按缓存过期时间清理 429 | * @returns 清理的数量 430 | */ 431 | public cleanExpiredTokens(): number { 432 | const now = Date.now(); 433 | const nowSeconds = Math.floor(now / 1000); 434 | let cleanedCount = 0; 435 | const keysToDelete: string[] = []; 436 | 437 | for (const [key, cacheItem] of this.cache.entries()) { 438 | let shouldDelete = false; 439 | 440 | if (key.startsWith('user_access_token:')) { 441 | // 用户token:检查refresh_token是否过期 442 | const tokenInfo = cacheItem.data as UserTokenInfo; 443 | if (tokenInfo.refresh_token && tokenInfo.refresh_token_expires_at) { 444 | // 有refresh_token,只有refresh_token过期才删除 445 | shouldDelete = tokenInfo.refresh_token_expires_at < nowSeconds; 446 | if (shouldDelete) { 447 | Logger.debug(`清理用户token - refresh_token已过期: ${key}`); 448 | } 449 | } else { 450 | // 没有refresh_token,按缓存过期时间删除 451 | shouldDelete = cacheItem.expiresAt <= now; 452 | if (shouldDelete) { 453 | Logger.debug(`清理用户token - 无refresh_token且缓存过期: ${key}`); 454 | } 455 | } 456 | } else { 457 | // 租户token或其他类型:按缓存过期时间删除 458 | shouldDelete = cacheItem.expiresAt <= now; 459 | if (shouldDelete) { 460 | Logger.debug(`清理过期缓存: ${key}`); 461 | } 462 | } 463 | 464 | if (shouldDelete) { 465 | keysToDelete.push(key); 466 | } 467 | } 468 | 469 | // 批量删除 470 | keysToDelete.forEach(key => { 471 | this.cache.delete(key); 472 | cleanedCount++; 473 | }); 474 | 475 | if (cleanedCount > 0) { 476 | // 分别保存用户和租户缓存 477 | this.saveUserTokenCache(); 478 | this.saveTenantTokenCache(); 479 | Logger.info(`清理过期token,删除了 ${cleanedCount} 条记录`); 480 | } 481 | 482 | return cleanedCount; 483 | } 484 | 485 | /** 486 | * 启动缓存清理定时器 487 | */ 488 | private startCacheCleanupTimer(): void { 489 | // 每5分钟清理一次过期缓存 490 | setInterval(() => { 491 | this.cleanExpiredTokens(); 492 | }, 5 * 60 * 1000); 493 | 494 | Logger.info('Token缓存清理定时器已启动,每5分钟执行一次'); 495 | } 496 | 497 | /** 498 | * 获取所有用户token的key列表(不包含前缀) 499 | * @returns 用户token的key数组 500 | */ 501 | public getAllUserTokenKeys(): string[] { 502 | const keys: string[] = []; 503 | 504 | for (const [key] of this.cache.entries()) { 505 | if (key.startsWith('user_access_token:')) { 506 | // 提取clientKey(去掉前缀) 507 | const clientKey = key.substring('user_access_token:'.length); 508 | keys.push(clientKey); 509 | } 510 | } 511 | 512 | Logger.debug(`获取到 ${keys.length} 个用户token keys`); 513 | return keys; 514 | } 515 | } 516 | -------------------------------------------------------------------------------- /doc/MCP服务实现分享.md: -------------------------------------------------------------------------------- 1 | # 飞书 MCP 服务实现分享 2 | 3 | ## 1. MCP 协议简介 4 | 5 | MCP(Model Context Protocol)是一种用于AI模型与外部系统交互的协议,它允许AI模型(如Cursor、Windsurf、Cline等)能够访问和操作外部系统的数据,从而提供更智能、更精准的服务。 6 | 7 | ### 1.1 MCP 的核心理念 8 | 9 | - **上下文扩展**:允许AI模型获取更多的上下文信息,超越用户输入的限制 10 | - **工具调用**:使AI模型能够通过定义好的接口调用外部系统的功能 11 | - **双向通信**:在AI模型和外部系统之间建立双向通信通道 12 | 13 | ### 1.2 MCP 的优势 14 | 15 | - 提高AI模型的理解准确性 16 | - 减少用户手动复制粘贴的需求 17 | - 简化复杂任务的处理流程 18 | - 保护敏感信息,只提供必要的数据 19 | 20 | ## 2. 飞书 MCP 服务器架构 21 | 22 | ### 2.1 整体架构 23 | 24 | 飞书 MCP 服务器是基于 MCP 协议实现的一个中间层服务,它连接了AI编码工具(如Cursor)和飞书文档系统,使AI工具能够直接访问和操作飞书文档。 25 | 26 | ``` 27 | +----------------+ +------------------+ +----------------+ 28 | | | | | | | 29 | | AI编码工具 | <===> | 飞书 MCP 服务器 | <===> | 飞书API | 30 | | (Cursor等) | | | | | 31 | +----------------+ +------------------+ +----------------+ 32 | ``` 33 | 34 | ### 2.2 核心组件 35 | 36 | - **McpServer**:基于@modelcontextprotocol/sdk实现的MCP服务器核心 37 | - **FeishuService**:负责与飞书API交互的服务层 38 | - **Transport层**:支持多种通信方式(HTTP/SSE和标准输入输出) 39 | 40 | ## 3. 技术实现细节 41 | 42 | ### 3.1 服务器初始化流程 43 | 44 | 1. 加载环境配置(.env文件和命令行参数) 45 | 2. 初始化配置管理器(Config) 46 | 3. 初始化飞书服务(FeishuApiService) 47 | 4. 创建MCP服务器实例(FeishuMcpServer) 48 | 5. 注册工具函数(Tools) 49 | 6. 根据运行模式选择通信方式(HTTP或标准输入输出) 50 | 51 | ```typescript 52 | // 服务器启动流程示例 53 | export async function startServer(): Promise { 54 | try { 55 | // 检查是否为标准输入输出模式 56 | const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio"); 57 | 58 | // 初始化日志 59 | Logger.initialize(); 60 | Logger.info('飞书MCP服务器启动中...'); 61 | 62 | // 创建MCP服务器实例 63 | const server = new FeishuMcpServer(); 64 | 65 | // 根据模式选择通信方式 66 | if (isStdioMode) { 67 | Logger.info('使用标准输入输出模式'); 68 | const transport = new StdioServerTransport(); 69 | await server.connect(transport); 70 | } else { 71 | // 获取配置 72 | const config = Config.getInstance(); 73 | const port = config.server.port; 74 | 75 | Logger.info(`使用HTTP模式,端口: ${port}`); 76 | await server.startHttpServer(port); 77 | } 78 | } catch (error) { 79 | Logger.error('服务器启动失败:', error); 80 | process.exit(1); 81 | } 82 | } 83 | ``` 84 | 85 | 特别注意的是,FeishuApiService采用了单例模式实现,确保整个应用中只有一个实例: 86 | 87 | ```typescript 88 | export class FeishuApiService extends BaseApiService { 89 | private static instance: FeishuApiService; 90 | 91 | // 私有构造函数,防止外部直接创建实例 92 | private constructor() { 93 | super(); 94 | this.cacheManager = CacheManager.getInstance(); 95 | this.blockFactory = BlockFactory.getInstance(); 96 | this.config = Config.getInstance(); 97 | } 98 | 99 | // 获取实例的静态方法 100 | public static getInstance(): FeishuApiService { 101 | if (!FeishuApiService.instance) { 102 | FeishuApiService.instance = new FeishuApiService(); 103 | } 104 | return FeishuApiService.instance; 105 | } 106 | 107 | // 其他方法... 108 | } 109 | ``` 110 | 111 | 此单例模式确保了整个应用生命周期内只存在一个飞书服务实例,有效管理资源和共享状态。 112 | 113 | ### 3.2 飞书API交互 114 | 115 | 飞书MCP服务器通过FeishuService与飞书API进行交互,主要功能包括: 116 | 117 | 1. **认证管理**:获取和刷新飞书访问令牌 118 | 2. **文档操作**:创建、读取和修改飞书文档 119 | 3. **文档块操作**:获取和创建文档块(文本块、代码块等) 120 | 121 | ```typescript 122 | // 飞书服务示例代码 123 | export class FeishuService { 124 | private readonly appId: string; 125 | private readonly appSecret: string; 126 | private readonly baseUrl = "https://open.feishu.cn/open-apis"; 127 | private accessToken: string | null = null; 128 | private tokenExpireTime: number | null = null; 129 | 130 | constructor(appId: string, appSecret: string) { 131 | this.appId = appId; 132 | this.appSecret = appSecret; 133 | } 134 | 135 | // 获取访问令牌 136 | private async getAccessToken(): Promise { 137 | // 检查令牌是否过期 138 | if (this.accessToken && !this.isTokenExpired()) { 139 | return this.accessToken; 140 | } 141 | 142 | // 获取新的访问令牌 143 | const response = await axios.post(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, { 144 | app_id: this.appId, 145 | app_secret: this.appSecret, 146 | }); 147 | 148 | this.accessToken = response.data.tenant_access_token; 149 | this.tokenExpireTime = Date.now() + response.data.expire * 1000; 150 | return this.accessToken; 151 | } 152 | 153 | // 其他API方法... 154 | } 155 | ``` 156 | 157 | ### 3.3 MCP工具实现 158 | 159 | 飞书MCP服务器通过注册一系列工具函数,使AI模型能够执行特定的操作: 160 | 161 | 1. **创建文档**:`create_feishu_document` 162 | 2. **获取文档信息**:`get_feishu_document_info` 163 | 3. **获取文档内容**:`get_feishu_document_content` 164 | 4. **获取文档块**:`get_feishu_document_blocks` 165 | 5. **获取块内容**:`get_feishu_block_content` 166 | 6. **创建文本块**:`create_feishu_text_block` 167 | 7. **创建代码块**:`create_feishu_code_block` 168 | 8. **创建标题块**:`create_feishu_heading_block` 169 | 9. **创建列表块**:`create_feishu_list_block` 170 | 10. **Wiki链接转换**:`convert_feishu_wiki_to_document_id` 171 | 172 | ```typescript 173 | // 工具注册示例 174 | private registerTools(): void { 175 | // 添加创建飞书文档工具 176 | this.server.tool( 177 | "create_feishu_document", 178 | "Creates a new Feishu document and returns its information.", 179 | { 180 | title: z.string().describe("Document title (required). This will be displayed in the Feishu document list and document header."), 181 | folderToken: z.string().describe("Folder token (required). Specifies where to create the document.") 182 | }, 183 | async ({ title, folderToken }) => { 184 | try { 185 | const newDoc = await this.feishuService?.createDocument(title, folderToken); 186 | if (!newDoc) { 187 | throw new Error('创建文档失败,未返回文档信息'); 188 | } 189 | return { 190 | content: [{ type: "text", text: JSON.stringify(newDoc, null, 2) }], 191 | }; 192 | } catch (error) { 193 | const errorMessage = formatErrorMessage(error); 194 | return { 195 | content: [{ type: "text", text: `创建飞书文档失败: ${errorMessage}` }], 196 | }; 197 | } 198 | } 199 | ); 200 | 201 | // 其他工具注册... 202 | } 203 | ``` 204 | 205 | 每个工具函数都经过精心设计,提供了详细的描述和参数验证,确保AI模型能够正确理解和使用这些功能。同时,使用Zod库进行参数验证,提高了API的稳定性和安全性。 206 | 207 | ### 3.4 通信协议实现 208 | 209 | 飞书MCP服务器支持两种通信方式: 210 | 211 | 1. **HTTP/SSE模式**:通过HTTP服务器和Server-Sent Events实现与AI工具的通信 212 | 2. **标准输入输出模式**:通过进程的标准输入和输出流实现通信,适用于CLI环境 213 | 214 | ```typescript 215 | // HTTP服务器启动方法 216 | async startHttpServer(port: number): Promise { 217 | const app = express(); 218 | app.use(express.json()); 219 | 220 | // 简单的健康检查端点 221 | app.get("/", (_req: Request, res: Response) => { 222 | res.send("Feishu MCP Server is running"); 223 | }); 224 | 225 | // MCP通信端点 226 | app.get("/mcp", (req: IncomingMessage, res: ServerResponse) => { 227 | Logger.info('收到新的MCP连接请求'); 228 | this.sseTransport = new SSEServerTransport(); 229 | this.sseTransport.handleRequest(req, res); 230 | this.server.connect(this.sseTransport); 231 | }); 232 | 233 | // 启动HTTP服务器 234 | app.listen(port, () => { 235 | Logger.info(`飞书MCP服务器已启动,监听端口 ${port}`); 236 | }); 237 | } 238 | 239 | // 标准输入输出连接方法 240 | async connect(transport: Transport): Promise { 241 | try { 242 | Logger.info('正在连接传输层...'); 243 | await this.server.connect(transport); 244 | Logger.info('传输层连接成功'); 245 | } catch (error) { 246 | Logger.error('传输层连接失败:', error); 247 | throw error; 248 | } 249 | } 250 | ``` 251 | 252 | #### 3.4.1 SSE(Server-Sent Events)通信 253 | 254 | 服务器使用Server-Sent Events技术实现与客户端的实时通信,这是一种基于HTTP的单向通信技术,适合服务器向客户端推送消息的场景。主要特点包括: 255 | 256 | - **长连接**:建立一次HTTP连接后保持打开状态 257 | - **单向通信**:服务器向客户端推送数据,客户端通过其他HTTP请求发送响应 258 | - **自动重连**:客户端断开连接后会自动尝试重新连接 259 | - **标准化格式**:遵循EventSource API规范,易于客户端处理 260 | 261 | #### 3.4.2 标准输入输出通信 262 | 263 | 标准输入输出模式主要用于命令行环境,特别是在集成到其他应用时更为便捷。它的主要特点包括: 264 | 265 | - **无需网络端口**:不占用网络端口,避免端口冲突 266 | - **进程间通信**:通过进程标准输入输出流进行数据交换 267 | - **简单集成**:容易嵌入到其他命令行工具或脚本中 268 | - **低开销**:通信开销小,适合嵌入式环境 269 | 270 | 通过支持这两种通信方式,飞书MCP服务器能够适应不同的使用场景,既可以作为独立服务部署,也可以作为命令行工具或嵌入式组件使用。 271 | 272 | ## 4. 工作流程 273 | 274 | ### 4.1 用户使用流程 275 | 276 | 1. 在Cursor的Agent模式下打开编辑器 277 | 2. 粘贴飞书文档的链接 278 | 3. 要求Cursor基于飞书文档执行操作(分析内容、创建代码等) 279 | 4. Cursor通过MCP服务器从飞书获取文档内容 280 | 5. Cursor使用获取的内容辅助编写代码 281 | 282 | ### 4.2 数据流转过程 283 | 284 | ``` 285 | +----------------+ +------------------+ +----------------+ 286 | | | 1.请求文档内容 | | 2.API认证请求 | | 287 | | Cursor | ---------------> | 飞书 MCP 服务器 | ---------------> | 飞书API | 288 | | | | | | | 289 | | | 4.返回处理后的 | | 3.返回原始数据 | | 290 | | | <--------------- | | <--------------- | | 291 | +----------------+ 文档内容 +------------------+ +----------------+ 292 | ``` 293 | 294 | ## 5. 优化与特性 295 | 296 | ### 5.1 数据优化 297 | 298 | 飞书MCP服务器在处理飞书API返回的数据时,会进行以下优化: 299 | 300 | - **数据简化**:移除不必要的元数据,减少传输给AI模型的数据量 301 | - **格式转换**:将复杂的API响应转换为更易于AI模型理解的格式 302 | - **内容提取**:从文档块中提取关键内容,忽略样式等次要信息 303 | 304 | ### 5.2 特色功能 305 | 306 | - **Markdown语法支持**:自动将Markdown语法转换为飞书文档的样式属性 307 | - **文档块管理**:精细化控制文档结构,支持在特定位置插入内容 308 | - **多种认证方式**:支持通过环境变量和命令行参数配置认证信息 309 | 310 | ### 5.3 缓存管理系统 311 | 312 | 为了提高性能和减少API请求次数,飞书MCP服务器实现了一套完善的缓存管理系统: 313 | 314 | #### 5.3.1 令牌缓存 315 | 316 | ```typescript 317 | // 缓存令牌 318 | public cacheToken(token: string, expireSeconds: number): void { 319 | this.tokenCache.token = token; 320 | this.tokenCache.expireTime = Date.now() + (expireSeconds * 1000); 321 | 322 | Logger.debug(`令牌已缓存,过期时间: ${new Date(this.tokenCache.expireTime).toISOString()}`); 323 | } 324 | 325 | // 获取缓存的令牌 326 | public getToken(): string | null { 327 | if (!this.tokenCache.token || !this.tokenCache.expireTime) { 328 | return null; 329 | } 330 | 331 | // 检查令牌是否过期(提前30秒认为过期) 332 | const now = Date.now(); 333 | const safeExpireTime = this.tokenCache.expireTime - (30 * 1000); 334 | 335 | if (now >= safeExpireTime) { 336 | Logger.debug('缓存的令牌已过期或即将过期'); 337 | return null; 338 | } 339 | 340 | return this.tokenCache.token; 341 | } 342 | ``` 343 | 344 | #### 5.3.2 缓存策略 345 | 346 | 飞书MCP服务器采用了多层次的缓存策略: 347 | 348 | 1. **内存缓存**:将访问令牌等频繁使用的数据存储在内存中 349 | 2. **过期控制**:自动管理缓存项的过期时间,避免使用过期数据 350 | 3. **提前刷新**:在令牌即将过期前提前刷新,减少请求失败的可能性 351 | 4. **缓存失效**:提供机制清除无效的缓存数据 352 | 353 | #### 5.3.3 缓存优势 354 | 355 | - **提高响应速度**:减少重复的API请求,特别是获取访问令牌的请求 356 | - **减轻服务器负担**:减少对飞书API服务器的请求次数 357 | - **增强稳定性**:当飞书API暂时不可用时,仍可使用缓存数据提供服务 358 | - **避免速率限制**:控制API请求频率,避免触发飞书API的速率限制 359 | 360 | 通过缓存管理系统,飞书MCP服务器在保持数据新鲜度的同时,显著提高了性能和可靠性。 361 | 362 | ## 6. 部署与配置 363 | 364 | ### 6.1 环境要求 365 | 366 | - Node.js v20.17.0或更高版本 367 | - 飞书开放平台应用(需要获取AppID和AppSecret) 368 | 369 | ### 6.2 配置方式 370 | 371 | 1. **环境变量配置**:通过.env文件设置 372 | ``` 373 | PORT=3000 374 | FEISHU_APP_ID=your_app_id 375 | FEISHU_APP_SECRET=your_app_secret 376 | ``` 377 | 378 | 2. **命令行参数配置**: 379 | ```bash 380 | feishu-mcp --port 3000 --feishu-app-id your_app_id --feishu-app-secret your_app_secret 381 | ``` 382 | 383 | ### 6.3 启动方式 384 | 385 | 1. **HTTP模式**: 386 | ```bash 387 | npm start 388 | # 或 389 | npm run start:http 390 | ``` 391 | 392 | 2. **CLI模式**: 393 | ```bash 394 | npm run start:cli 395 | ``` 396 | 397 | ## 7. 应用场景 398 | 399 | ### 7.1 开发场景 400 | 401 | - **需求分析**:AI工具可以直接读取飞书文档中的需求说明,进行分析并生成代码 402 | - **文档生成**:根据代码自动生成技术文档并保存到飞书 403 | - **代码审查**:将代码审查意见直接写入飞书文档 404 | 405 | #### 7.1.1 需求分析实例 406 | 407 | 以下是一个开发者使用Cursor编码工具与飞书MCP服务器进行需求分析的实际对话示例: 408 | 409 | ``` 410 | 用户: 请帮我阅读这个飞书文档,并生成代码实现其中描述的功能:https://xxx.feishu.cn/docx/xxxx 411 | 412 | Cursor: 我会帮你查看这个飞书文档并生成代码。让我先查看文档内容。 413 | 414 | [Cursor 通过MCP服务器获取飞书文档内容] 415 | 416 | 文档内容已获取。这是一个用户管理API的需求文档,它要求实现以下功能: 417 | 1. 用户注册 418 | 2. 用户登录 419 | 3. 密码重置 420 | 4. 用户资料更新 421 | 422 | 以下是基于需求的代码实现: 423 | 424 | // 用户模型 425 | interface User { 426 | id: string; 427 | username: string; 428 | email: string; 429 | password: string; 430 | createdAt: Date; 431 | updatedAt: Date; 432 | } 433 | 434 | // 用户控制器 435 | class UserController { 436 | // 用户注册 437 | async register(req, res) { 438 | // 实现代码... 439 | } 440 | 441 | // 用户登录 442 | async login(req, res) { 443 | // 实现代码... 444 | } 445 | 446 | // 密码重置 447 | async resetPassword(req, res) { 448 | // 实现代码... 449 | } 450 | 451 | // 更新用户资料 452 | async updateProfile(req, res) { 453 | // 实现代码... 454 | } 455 | } 456 | ``` 457 | 458 | ### 7.2 协作场景 459 | 460 | - **团队协作**:多人共同编辑的飞书文档可以被AI工具实时访问 461 | - **知识库集成**:将团队知识库与AI编码工具连接,提高开发效率 462 | 463 | #### 7.2.1 团队协作示例 464 | 465 | 团队在飞书文档中协作编辑API规范,然后使用Cursor通过MCP服务器访问这些规范并生成代码: 466 | 467 | ``` 468 | 用户: 请根据我们团队的飞书API规范文档,为新的订单管理模块生成API接口代码:https://xxx.feishu.cn/docx/yyyy 469 | 470 | Cursor: 我会根据您团队的API规范文档生成订单管理模块的接口代码。让我先获取文档内容。 471 | 472 | [Cursor 通过MCP服务器获取飞书文档内容] 473 | 474 | 我已获取您团队的API规范文档。根据文档,新的订单管理模块需要遵循RESTful API设计原则,并包含以下接口: 475 | 476 | 1. 创建订单 477 | 2. 获取订单列表 478 | 3. 获取订单详情 479 | 4. 更新订单状态 480 | 5. 取消订单 481 | 482 | 以下是符合您团队API规范的代码实现: 483 | 484 | ```typescript 485 | // 订单控制器 486 | import { Router } from 'express'; 487 | import { OrderService } from '../services/OrderService'; 488 | import { authenticate } from '../middlewares/auth'; 489 | import { validateOrderInput } from '../validators/order'; 490 | 491 | const router = Router(); 492 | const orderService = new OrderService(); 493 | 494 | // 创建订单 495 | router.post('/', authenticate, validateOrderInput, async (req, res) => { 496 | try { 497 | const order = await orderService.createOrder(req.body, req.user.id); 498 | return res.status(201).json({ 499 | success: true, 500 | data: order 501 | }); 502 | } catch (error) { 503 | return res.status(500).json({ 504 | success: false, 505 | message: error.message 506 | }); 507 | } 508 | }); 509 | 510 | // 其他API路由... 511 | 512 | export default router; 513 | ``` 514 | 515 | ### 7.3 实时交互流程 516 | 517 | 在实际使用中,飞书MCP服务器、AI工具和用户之间的交互流程如下: 518 | 519 | 1. **服务启动**:用户启动飞书MCP服务器(HTTP或CLI模式) 520 | 2. **AI工具连接**:AI工具(如Cursor)通过MCP协议连接到服务器 521 | 3. **用户输入**:用户在AI工具中提供飞书文档链接或直接要求访问飞书文档 522 | 4. **文档获取**:AI工具通过MCP服务器从飞书获取文档内容 523 | 5. **内容处理**:AI模型分析文档内容并执行用户要求的任务 524 | 6. **结果返回**:AI工具向用户展示处理结果 525 | 7. **后续交互**:用户可以进一步要求AI工具基于文档内容执行其他操作 526 | 527 | 这种无缝的交互过程极大地提高了开发效率,使AI工具能够直接访问团队的知识库和文档,减少了信息传递的障碍。 528 | 529 | ## 8. 未来展望 530 | 531 | - **支持更多飞书文档类型**:表格、思维导图等 532 | - **双向实时同步**:AI工具的输出可以实时同步到飞书文档 533 | - **多平台集成**:支持更多AI编码工具和文档平台 534 | - **高级权限管理**:细粒度的访问控制和安全策略 535 | 536 | ## 9. 项目目录结构 537 | 538 | ### 9.1 主要目录结构 539 | 540 | ``` 541 | feishu-mcp/ 542 | ├── src/ # 源代码 543 | │ ├── index.ts # 应用程序入口点 544 | │ ├── cli.ts # CLI模式入口 545 | │ ├── server.ts # 主服务器实现 546 | │ ├── services/ # 服务层实现 547 | │ │ ├── baseService.ts # 基础服务抽象类 548 | │ │ ├── feishuApiService.ts # 飞书API服务实现 549 | │ │ └── blockFactory.ts # 文档块工厂类 550 | │ ├── types/ # 类型定义 551 | │ │ └── feishuSchema.ts # 飞书API相关Schema定义 552 | │ └── utils/ # 工具类 553 | │ ├── cache.ts # 缓存管理 554 | │ ├── config.ts # 配置管理 555 | │ ├── document.ts # 文档处理工具 556 | │ ├── error.ts # 错误处理 557 | │ ├── logger.ts # 日志工具 558 | │ └── paramUtils.ts # 参数处理工具 559 | ├── dist/ # 编译后的代码 560 | ├── doc/ # 文档 561 | ├── .env.example # 环境变量示例 562 | ├── .env # 环境变量配置 563 | ├── package.json # 项目依赖配置 564 | └── tsconfig.json # TypeScript配置 565 | ``` 566 | 567 | ### 9.2 核心模块说明 568 | 569 | #### 9.2.1 服务器模块 (server.ts) 570 | 571 | 服务器模块是整个应用的核心,它实现了MCP协议的服务器端,负责处理来自AI工具的请求,并注册各种工具函数供AI模型调用。主要功能包括: 572 | 573 | - MCP服务器初始化 574 | - 工具函数注册 575 | - 通信传输层管理(HTTP/SSE或标准输入输出) 576 | - 请求路由和处理 577 | 578 | #### 9.2.2 飞书API服务 (services/feishuApiService.ts) 579 | 580 | 飞书API服务是与飞书平台交互的核心组件,负责处理所有与飞书API相关的操作。主要功能包括: 581 | 582 | - 访问令牌获取和管理 583 | - 文档创建和读取 584 | - 文档块查询和操作 585 | - API错误处理和重试 586 | 587 | #### 9.2.3 文档块工厂 (services/blockFactory.ts) 588 | 589 | 文档块工厂提供了创建各种飞书文档块的统一接口,支持创建不同类型的块内容。主要功能包括: 590 | 591 | - 文本块创建 592 | - 代码块创建 593 | - 标题块创建 594 | - 列表块创建 595 | - 混合块批量创建 596 | 597 | #### 9.2.4 配置管理 (utils/config.ts) 598 | 599 | 配置管理模块负责加载和管理应用的配置信息,支持从环境变量和命令行参数中读取配置。主要功能包括: 600 | 601 | - 环境变量加载 602 | - 命令行参数解析 603 | - 配置校验和默认值处理 604 | 605 | #### 9.2.5 缓存管理 (utils/cache.ts) 606 | 607 | 缓存管理模块提供了内存缓存功能,用于存储访问令牌等需要重复使用的数据。主要功能包括: 608 | 609 | - 令牌缓存 610 | - 缓存过期管理 611 | - 缓存清理 612 | 613 | ## 10. 总结 614 | 615 | 飞书MCP服务器通过实现Model Context Protocol,成功地将飞书文档系统与AI编码工具连接起来,使AI工具能够直接访问和操作飞书文档。这种集成极大地提高了开发效率,减少了上下文切换,使AI工具能够更准确地理解和处理文档内容。 616 | 617 | 通过模块化的设计和灵活的配置选项,飞书MCP服务器可以适应各种使用场景,为开发者提供更智能、更高效的编码体验。 -------------------------------------------------------------------------------- /src/types/feishuSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // 文档ID或URL参数定义 4 | export const DocumentIdSchema = z.string().describe( 5 | 'Document ID or URL (required). Supports the following formats:\n' + 6 | '1. Standard document URL: https://xxx.feishu.cn/docs/xxx or https://xxx.feishu.cn/docx/xxx\n' + 7 | '2. Direct document ID: e.g., JcKbdlokYoPIe0xDzJ1cduRXnRf\n' + 8 | 'Note: Wiki links require conversion with convert_feishu_wiki_to_document_id first.' 9 | ); 10 | 11 | // 父块ID参数定义 12 | export const ParentBlockIdSchema = z.string().describe( 13 | 'Parent block ID (required). Target block ID where content will be added, without any URL prefix. ' + 14 | 'For page-level (root level) insertion, extract and use only the document ID portion (not the full URL) as parentBlockId. ' + 15 | 'Obtain existing block IDs using the get_feishu_document_blocks tool.' 16 | ); 17 | 18 | // 块ID参数定义 19 | export const BlockIdSchema = z.string().describe( 20 | 'Block ID (required). The ID of the specific block to get content from. You can obtain block IDs using the get_feishu_document_blocks tool.' 21 | ); 22 | 23 | // 插入位置索引参数定义 24 | export const IndexSchema = z.number().describe( 25 | 'Insertion position index (required). This index is relative to the children array of the specified parentBlockId block (not the whole document).\n' + 26 | 'If parentBlockId is the document root (i.e., the document ID), index refers to the position among the document content blocks (excluding the title block itself).\n' + 27 | '0 means to insert as the first content block after the title.\n' + 28 | 'If children is empty or missing, use 0 to insert the first content block.\n' + 29 | 'For nested blocks, index is relative to the parent block\'s children.\n' + 30 | '**index must satisfy 0 ≤ index ≤ parentBlock.children.length, otherwise the API will return an error.**\n'+ 31 | 'Note: The title block itself is not part of the children array and cannot be operated on with index.' + 32 | 'Specifies where the block should be inserted. Use 0 to insert at the beginning. ' + 33 | 'Use get_feishu_document_blocks tool to understand document structure if unsure. ' + 34 | 'For consecutive insertions, calculate next index as previous index + 1.' 35 | ); 36 | 37 | // 起始插入位置索引参数定义 38 | export const StartIndexSchema = z.number().describe( 39 | 'Starting insertion position index (required). This index is relative to the children array of the specified parentBlockId block.\n' + 40 | 'For the document root, this means the content blocks after the title. For other blocks, it means the sub-blocks under that block.\n' + 41 | 'The index does not include the title block itself.' + 42 | 'Specifies where the first block should be inserted or deleted. Use 0 to insert at the beginning. ' + 43 | 'Use get_feishu_document_blocks tool to understand document structure if unsure.' 44 | ); 45 | 46 | // 结束位置索引参数定义 47 | export const EndIndexSchema = z.number().describe( 48 | 'Ending position index (required). This index is relative to the children array of the specified parentBlockId block.\n' + 49 | 'For the document root, this means the content blocks after the title. For other blocks, it means the sub-blocks under that block.\n' + 50 | 'The index does not include the title block itself.' + 51 | 'Specifies the end of the range for deletion (exclusive). ' + 52 | 'For example, to delete blocks 2, 3, and 4, use startIndex=2, endIndex=5. ' + 53 | 'To delete a single block at position 2, use startIndex=2, endIndex=3.' 54 | ); 55 | 56 | // 文本对齐方式参数定义 57 | export const AlignSchema = z.number().optional().default(1).describe( 58 | 'Text alignment: 1 for left (default), 2 for center, 3 for right.' 59 | ); 60 | 61 | // 文本对齐方式参数定义(带验证) 62 | export const AlignSchemaWithValidation = z.number().optional().default(1).refine( 63 | val => val === 1 || val === 2 || val === 3, 64 | { message: "Alignment must be one of: 1 (left), 2 (center), or 3 (right)" } 65 | ).describe( 66 | 'Text alignment (optional): 1 for left (default), 2 for center, 3 for right. Only these three values are allowed.' 67 | ); 68 | 69 | // 文本样式属性定义 70 | export const TextStylePropertiesSchema = { 71 | bold: z.boolean().optional().describe('Whether to make text bold. Default is false, equivalent to **text** in Markdown.'), 72 | italic: z.boolean().optional().describe('Whether to make text italic. Default is false, equivalent to *text* in Markdown.'), 73 | underline: z.boolean().optional().describe('Whether to add underline. Default is false.'), 74 | strikethrough: z.boolean().optional().describe('Whether to add strikethrough. Default is false, equivalent to ~~text~~ in Markdown.'), 75 | inline_code: z.boolean().optional().describe('Whether to format as inline code. Default is false, equivalent to `code` in Markdown.'), 76 | text_color: z.number().optional().refine(val => !val || (val >= 0 && val <= 7), { 77 | message: "Text color must be between 0 and 7 inclusive" 78 | }).describe('Text color value. Default is 0 (black). Available values are only: 1 (gray), 2 (brown), 3 (orange), 4 (yellow), 5 (green), 6 (blue), 7 (purple). Values outside this range will cause an error.'), 79 | background_color: z.number().optional().refine(val => !val || (val >= 1 && val <= 7), { 80 | message: "Background color must be between 1 and 7 inclusive" 81 | }).describe('Background color value. Available values are only: 1 (gray), 2 (brown), 3 (orange), 4 (yellow), 5 (green), 6 (blue), 7 (purple). Values outside this range will cause an error.') 82 | }; 83 | 84 | // 文本样式对象定义 85 | export const TextStyleSchema = z.object(TextStylePropertiesSchema).optional().describe( 86 | 'Text style settings. Explicitly set style properties instead of relying on Markdown syntax conversion.' 87 | ); 88 | 89 | // 文本内容单元定义 - 支持普通文本和公式元素 90 | export const TextElementSchema = z.union([ 91 | z.object({ 92 | text: z.string().describe('Text content. Provide plain text without markdown syntax; use style object for formatting.'), 93 | style: TextStyleSchema 94 | }).describe('Regular text element with optional styling.'), 95 | z.object({ 96 | equation: z.string().describe('Mathematical equation content. The formula or expression to display. Format: LaTeX.'), 97 | style: TextStyleSchema 98 | }).describe('Mathematical equation element with optional styling.') 99 | ]); 100 | 101 | // 文本内容数组定义 102 | export const TextElementsArraySchema = z.array(TextElementSchema).describe( 103 | 'Array of text content objects. A block can contain multiple text segments with different styles. Example: [{text:"Hello",style:{bold:true}},{text:" World",style:{italic:true}}]' 104 | ); 105 | 106 | // 代码块语言参数定义 107 | export const CodeLanguageSchema = z.number().optional().default(1).describe( 108 | "Programming language code (optional). Common language codes:\n" + 109 | "1: PlainText; 2: ABAP; 3: Ada; 4: Apache; 5: Apex; 6: Assembly; 7: Bash; 8: CSharp; 9: C++; 10: C; " + 110 | "11: COBOL; 12: CSS; 13: CoffeeScript; 14: D; 15: Dart; 16: Delphi; 17: Django; 18: Dockerfile; 19: Erlang; 20: Fortran; " + 111 | "22: Go; 23: Groovy; 24: HTML; 25: HTMLBars; 26: HTTP; 27: Haskell; 28: JSON; 29: Java; 30: JavaScript; " + 112 | "31: Julia; 32: Kotlin; 33: LateX; 34: Lisp; 36: Lua; 37: MATLAB; 38: Makefile; 39: Markdown; 40: Nginx; " + 113 | "41: Objective-C; 43: PHP; 44: Perl; 46: PowerShell; 47: Prolog; 48: ProtoBuf; 49: Python; 50: R; " + 114 | "52: Ruby; 53: Rust; 54: SAS; 55: SCSS; 56: SQL; 57: Scala; 58: Scheme; 60: Shell; 61: Swift; 62: Thrift; " + 115 | "63: TypeScript; 64: VBScript; 65: Visual Basic; 66: XML; 67: YAML; 68: CMake; 69: Diff; 70: Gherkin; 71: GraphQL. " + 116 | "Default is 1 (PlainText)." 117 | ); 118 | 119 | // 代码块自动换行参数定义 120 | export const CodeWrapSchema = z.boolean().optional().default(false).describe( 121 | 'Whether to enable automatic line wrapping. Default is false.' 122 | ); 123 | 124 | // 文本样式段落定义 - 用于批量创建块工具 125 | export const TextStyleBlockSchema = z.object({ 126 | textStyles: z.array(TextElementSchema).describe('Array of text content objects with styles. A block can contain multiple text segments with different styles, including both regular text and equations. Example: [{text:"Hello",style:{bold:true}},{equation:"1+2=3",style:{}}]'), 127 | align: z.number().optional().default(1).describe('Text alignment: 1 for left (default), 2 for center, 3 for right.'), 128 | }); 129 | 130 | // 代码块内容定义 - 用于批量创建块工具 131 | export const CodeBlockSchema = z.object({ 132 | code: z.string().describe('Code content. The complete code text to display.'), 133 | language: CodeLanguageSchema, 134 | wrap: CodeWrapSchema, 135 | }); 136 | 137 | // 标题块内容定义 - 用于批量创建块工具 138 | export const HeadingBlockSchema = z.object({ 139 | level: z.number().min(1).max(9).describe('Heading level from 1 to 9, where 1 is the largest (h1) and 9 is the smallest (h9).'), 140 | content: z.string().describe('Heading text content. The actual text of the heading.'), 141 | align: AlignSchemaWithValidation, 142 | }); 143 | 144 | // 列表块内容定义 - 用于批量创建块工具 145 | export const ListBlockSchema = z.object({ 146 | content: z.string().describe('List item content. The actual text of the list item.'), 147 | isOrdered: z.boolean().optional().default(false).describe('Whether this is an ordered (numbered) list item. Default is false (bullet point/unordered).'), 148 | align: AlignSchemaWithValidation, 149 | }); 150 | 151 | // 块类型枚举 - 用于批量创建块工具 152 | export const BlockTypeEnum = z.string().describe( 153 | "Block type (required). Supports: 'text', 'code', 'heading', 'list', 'image','mermaid',as well as 'heading1' through 'heading9'. " + 154 | "For headings, we recommend using 'heading' with level property, but 'heading1'-'heading9' are also supported. " + 155 | "For images, use 'image' to create empty image blocks that can be filled later. " + 156 | "For text blocks, you can include both regular text and equation elements in the same block." 157 | ); 158 | 159 | // 图片宽度参数定义 160 | export const ImageWidthSchema = z.number().optional().describe( 161 | 'Image width in pixels (optional). If not provided, the original image width will be used.' 162 | ); 163 | 164 | // 图片高度参数定义 165 | export const ImageHeightSchema = z.number().optional().describe( 166 | 'Image height in pixels (optional). If not provided, the original image height will be used.' 167 | ); 168 | 169 | // 图片块内容定义 - 用于批量创建块工具 170 | export const ImageBlockSchema = z.object({ 171 | width: ImageWidthSchema, 172 | height: ImageHeightSchema 173 | }); 174 | 175 | // Mermaid代码参数定义 176 | export const MermaidCodeSchema = z.string().describe( 177 | 'Mermaid code (required). The complete Mermaid chart code, e.g. \'graph TD; A-->B;\'. ' + 178 | 'IMPORTANT: When node text contains special characters like parentheses (), brackets [], or arrows -->, ' + 179 | 'wrap the entire text in double quotes to prevent parsing errors. ' + 180 | 'Example: A["finish()/返回键"] instead of A[finish()/返回键].' 181 | ); 182 | 183 | export const MermaidBlockSchema = z.object({ 184 | code: MermaidCodeSchema, 185 | }); 186 | 187 | // 块配置定义 - 用于批量创建块工具 188 | export const BlockConfigSchema = z.object({ 189 | blockType: BlockTypeEnum, 190 | options: z.union([ 191 | z.object({ text: TextStyleBlockSchema }).describe("Text block options. Used when blockType is 'text'."), 192 | z.object({ code: CodeBlockSchema }).describe("Code block options. Used when blockType is 'code'."), 193 | z.object({ heading: HeadingBlockSchema }).describe("Heading block options. Used with both 'heading' and 'headingN' formats."), 194 | z.object({ list: ListBlockSchema }).describe("List block options. Used when blockType is 'list'."), 195 | z.object({ image: ImageBlockSchema }).describe("Image block options. Used when blockType is 'image'. Creates empty image blocks."), 196 | z.object({ mermaid: MermaidBlockSchema}).describe("Mermaid block options. Used when blockType is 'mermaid'."), 197 | z.record(z.any()).describe("Fallback for any other block options") 198 | ]).describe('Options for the specific block type. Provide the corresponding options object based on blockType.'), 199 | }); 200 | 201 | // 表格列数参数定义 202 | export const TableColumnSizeSchema = z.number().min(1).describe( 203 | 'Table column size (required). The number of columns in the table. Must be at least 1.' 204 | ); 205 | 206 | // 表格行数参数定义 207 | export const TableRowSizeSchema = z.number().min(1).describe( 208 | 'Table row size (required). The number of rows in the table. Must be at least 1.' 209 | ); 210 | 211 | // 表格单元格坐标参数定义 212 | export const TableCellCoordinateSchema = z.object({ 213 | row: z.number().min(0).describe('Row coordinate (0-based). The row position of the cell in the table.'), 214 | column: z.number().min(0).describe('Column coordinate (0-based). The column position of the cell in the table.') 215 | }); 216 | 217 | 218 | // 表格单元格内容配置定义 219 | export const TableCellContentSchema = z.object({ 220 | coordinate: TableCellCoordinateSchema, 221 | content: BlockConfigSchema 222 | }); 223 | 224 | // 表格创建参数定义 - 专门用于创建表格块工具 225 | export const TableCreateSchema = z.object({ 226 | columnSize: TableColumnSizeSchema, 227 | rowSize: TableRowSizeSchema, 228 | cells: z.array(TableCellContentSchema).optional().describe( 229 | 'Array of cell configurations (optional). Each cell specifies its position (row, column) and content block configuration. ' + 230 | 'If not provided, empty text blocks will be created for all cells. ' + 231 | 'IMPORTANT: Multiple cells can have the same coordinates (row, column) - when this happens, ' + 232 | 'the content blocks will be added sequentially to the same cell, allowing you to create rich content ' + 233 | 'with multiple blocks (text, code, images, etc.) within a single cell. ' + 234 | 'Example: [{coordinate:{row:0,column:0}, content:{blockType:"text", options:{text:{textStyles:[{text:"Header"}]}}}, ' + 235 | '{coordinate:{row:0,column:0}, content:{blockType:"code", options:{code:{code:"console.log(\'hello\')", language:30}}}}] ' + 236 | 'will add both a text block and a code block to cell (0,0).' 237 | ) 238 | }); 239 | 240 | // 媒体ID参数定义 241 | export const MediaIdSchema = z.string().describe( 242 | 'Media ID (required). The unique identifier for a media resource (image, file, etc.) in Feishu. ' + 243 | 'Usually obtained from image blocks or file references in documents. ' + 244 | 'Format is typically like "boxcnrHpsg1QDqXAAAyachabcef".' 245 | ); 246 | 247 | // 额外参数定义 - 用于媒体资源下载 248 | export const MediaExtraSchema = z.string().optional().describe( 249 | 'Extra parameters for media download (optional). ' + 250 | 'These parameters are passed directly to the Feishu API and can modify how the media is returned.' 251 | ); 252 | 253 | // 文件夹Token参数定义 254 | export const FolderTokenSchema = z.string().describe( 255 | 'Folder token (required). The unique identifier for a folder in Feishu. ' + 256 | 'Format is an alphanumeric string like "FWK2fMleClICfodlHHWc4Mygnhb".' 257 | ); 258 | 259 | // 文件夹名称参数定义 260 | export const FolderNameSchema = z.string().describe( 261 | 'Folder name (required). The name for the new folder to be created.' 262 | ); 263 | 264 | // 搜索关键字参数定义 265 | export const SearchKeySchema = z.string().describe( 266 | 'Search keyword (required). The keyword to search for in documents.' 267 | ); 268 | 269 | // 图片路径或URL参数定义 270 | export const ImagePathOrUrlSchema = z.string().describe( 271 | 'Image path or URL (required). Supports the following formats:\n' + 272 | '1. Local file absolute path: e.g., "C:\\path\\to\\image.jpg"\n' + 273 | '2. HTTP/HTTPS URL: e.g., "https://example.com/image.png"\n' + 274 | 'The tool will automatically detect the format and handle accordingly.' 275 | ); 276 | 277 | // 图片文件名参数定义 278 | export const ImageFileNameSchema = z.string().optional().describe( 279 | 'Image file name (optional). If not provided, a default name will be generated based on the source. ' + 280 | 'Should include the file extension, e.g., "image.png" or "photo.jpg".' 281 | ); 282 | 283 | 284 | // 批量图片上传绑定参数定义 285 | export const ImagesArraySchema = z.array(z.object({ 286 | blockId: BlockIdSchema, 287 | imagePathOrUrl: ImagePathOrUrlSchema, 288 | fileName: ImageFileNameSchema.optional(), 289 | })).describe( 290 | 'Array of image binding objects (required). Each object must include: blockId (target image block ID), imagePathOrUrl (local path or URL of the image), and optionally fileName (image file name, e.g., "image.png").' 291 | ); 292 | 293 | // 画板ID参数定义 294 | export const WhiteboardIdSchema = z.string().describe( 295 | 'Whiteboard ID (required). This is the token value from the board.token field when getting document blocks.\n' + 296 | 'When you find a block with block_type: 43, the whiteboard ID is located in board.token field.\n' + 297 | 'Example: "EPJKwvY5ghe3pVbKj9RcT2msnBX"' 298 | ); 299 | 300 | // 文档标题参数定义 301 | export const DocumentTitleSchema = z.string().describe('Document title (required). This will be displayed in the Feishu document list and document header.'); 302 | --------------------------------------------------------------------------------