diff --git a/.env.example b/.env.example index de8a3c0..c4509ba 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ FEISHU_APP_SECRET=xxxxxxxxxxxxxx FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxx FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxx +# 飞书事件接收模式:http (HTTP 回调) 或 websocket (WebSocket 长连接) +# HTTP 回调:需要公网 IP/域名,配置简单 +# WebSocket:不需要公网 IP,内网可用,需要飞书 SDK +FEISHU_MODE=http + # 七牛云配置(可选,也可通过卡片配置) QINIU_ACCESS_KEY=YOUR_ACCESS_KEY QINIU_SECRET_KEY=YOUR_SECRET_KEY diff --git a/README.md b/README.md index 1c02196..672373e 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,29 @@ ### 3. 配置事件订阅 +**方式一:HTTP 回调(默认)** + 1. 进入"事件订阅"页面 -2. 开启"启用事件订阅" -3. 填写请求地址:`https://your-domain.com/feishu/event` +2. 选择 **"HTTP 回调"** 方式 +3. 开启"启用事件订阅" +4. 填写请求地址:`https://your-domain.com/feishu/event` +5. 配置订阅事件: + - `im.message.receive_v1` - 接收消息 +6. 保存后复制 Verification Token 和 Encrypt Key + +**方式二:WebSocket 长连接(内网推荐)** + +1. 进入"事件订阅"页面 +2. 选择 **"WebSocket 长连接"** 方式 +3. 开启"启用事件订阅" 4. 配置订阅事件: - `im.message.receive_v1` - 接收消息 - - `im.message.group_at_msg.receive_v1` - 群组 @ 消息(可选) 5. 保存后复制 Verification Token 和 Encrypt Key +6. 在 `.env` 中设置 `FEISHU_MODE=websocket` + +> **💡 提示:** WebSocket 模式不需要公网 IP,适合内网部署。 +> +> 详细配置请查看 [`WEBSOCKET.md`](./WEBSOCKET.md) ### 4. 配置机器人 @@ -168,6 +184,11 @@ FEISHU_APP_SECRET=xxxxxxxxxxxxxx FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxx FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxx +# 飞书事件接收模式:http (HTTP 回调) 或 websocket (WebSocket 长连接) +# - http: 需要公网 IP/域名,配置简单 +# - websocket: 不需要公网 IP,内网可用 +FEISHU_MODE=http + # 七牛云配置(可选,也可通过卡片配置) QINIU_ACCESS_KEY=xxxxxxxxxxxxxx QINIU_SECRET_KEY=xxxxxxxxxxxxxx @@ -241,7 +262,8 @@ qiniu-feishu-bot/ ├── README.md # 项目说明 ├── DEPLOY.md # 详细部署指南(Linux/macOS/Windows) ├── WINDOWS.md # Windows 专用指南 -└── NGINX.md # Nginx 反向代理部署指南 +├── NGINX.md # Nginx 反向代理部署指南 +└── WEBSOCKET.md # WebSocket 长连接模式配置指南 ``` --- diff --git a/WEBSOCKET.md b/WEBSOCKET.md new file mode 100644 index 0000000..a92b29d --- /dev/null +++ b/WEBSOCKET.md @@ -0,0 +1,213 @@ +# WebSocket 长连接模式配置指南 + +## 📡 两种模式对比 + +| 特性 | HTTP 回调 | WebSocket 长连接 | +|------|----------|-----------------| +| 公网 IP | ✅ 需要 | ❌ 不需要 | +| 域名 | ✅ 需要 | ❌ 不需要 | +| HTTPS | ✅ 推荐 | ❌ 不需要 | +| 内网部署 | ❌ 困难 | ✅ 简单 | +| 实时性 | 好 | 更好 | +| 配置复杂度 | 简单 | 中等 | + +--- + +## 🚀 配置 WebSocket 模式 + +### 1️⃣ 飞书开放平台配置 + +1. 访问 https://open.feishu.cn/ +2. 进入你的应用管理页面 +3. 点击"事件订阅" +4. **选择"WebSocket 长连接"方式** +5. 开启"启用事件订阅" +6. 添加订阅事件: + - `im.message.receive_v1` - 接收消息 +7. 保存并复制: + - Verification Token + - Encrypt Key + +### 2️⃣ 修改配置文件 + +编辑 `.env` 文件: + +```env +# 飞书配置 +FEISHU_APP_ID=cli_xxxxxxxxxx +FEISHU_APP_SECRET=xxxxxxxxxxxxxx +FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxx +FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxx + +# 设置为 WebSocket 模式 +FEISHU_MODE=websocket + +# 七牛云配置 +QINIU_ACCESS_KEY=xxxxxxxxxxxxxx +QINIU_SECRET_KEY=xxxxxxxxxxxxxx +QINIU_BUCKET=your-bucket-name +QINIU_REGION=z0 +QINIU_DOMAIN=https://your-cdn.com + +# 服务配置 +PORT=3030 +NODE_ENV=production +``` + +### 3️⃣ 安装依赖 + +```bash +cd /path/to/qiniu-feishu-bot +npm install +``` + +### 4️⃣ 启动服务 + +```bash +# 使用 PM2 +pm2 restart qiniu-bot + +# 或直接启动 +npm start +``` + +### 5️⃣ 查看日志 + +```bash +# PM2 日志 +pm2 logs qiniu-bot + +# 应该看到: +# 🚀 七牛云上传机器人启动 (WebSocket 长连接模式) +# 📡 WebSocket 已启动 +# ✅ WebSocket 连接成功 +``` + +--- + +## 🔧 故障排查 + +### WebSocket 连接失败 + +**检查配置:** +```bash +# 验证 .env 配置 +cat .env | grep FEISHU + +# 确认 FEISHU_MODE=websocket +``` + +**查看日志:** +```bash +pm2 logs qiniu-bot --lines 100 +``` + +**常见错误:** + +1. **验证失败** + - 检查 Verification Token 是否正确 + - 检查 Encrypt Key 是否正确 + +2. **连接被拒绝** + - 检查防火墙是否允许出站连接 + - 确认服务器能访问外网 + +3. **认证失败** + - 检查 App ID 和 App Secret + +--- + +## 📊 监控 WebSocket 状态 + +### 健康检查 + +```bash +curl http://localhost:3030/health +``` + +返回: +```json +{ + "status": "ok", + "timestamp": "2026-03-05T08:00:00.000Z", + "mode": "websocket", + "port": 3030 +} +``` + +### 连接状态 + +查看 PM2 日志中的连接状态: + +```bash +pm2 logs qiniu-bot | grep -E "(WebSocket|连接|open|close)" +``` + +--- + +## 🔄 切换模式 + +### 从 HTTP 切换到 WebSocket + +```bash +# 1. 修改 .env +nano .env +# 设置 FEISHU_MODE=websocket + +# 2. 重启服务 +pm2 restart qiniu-bot + +# 3. 验证 +pm2 logs qiniu-bot +``` + +### 从 WebSocket 切换到 HTTP + +```bash +# 1. 修改 .env +nano .env +# 设置 FEISHU_MODE=http + +# 2. 重启服务 +pm2 restart qiniu-bot + +# 3. 在飞书开放平台改回 HTTP 回调方式 +``` + +--- + +## 💡 最佳实践 + +### WebSocket 模式适用场景 + +- ✅ 内网服务器(无公网 IP) +- ✅ 开发测试环境 +- ✅ 不想配置域名和 HTTPS +- ✅ 需要更好的实时性 + +### HTTP 回调模式适用场景 + +- ✅ 云服务器(有公网 IP) +- ✅ 生产环境 +- ✅ 已有域名和 HTTPS +- ✅ 需要更可控的连接管理 + +--- + +## 📝 注意事项 + +1. **WebSocket 长连接会保持在线状态** + - 确保服务器网络稳定 + - 断线会自动重连(5 秒间隔) + +2. **两种方式不能同时使用** + - 通过 `FEISHU_MODE` 配置选择 + - 飞书开放平台也要对应配置 + +3. **健康检查始终可用** + - 无论哪种模式,`/health` 端点都工作 + - 可用于监控服务状态 + +--- + +**🍙 祝你使用愉快!** diff --git a/package-lock.json b/package-lock.json index 5f3ee80..0c2466d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@larksuiteoapi/node-sdk": "^1.5.0", "axios": "^1.6.0", "dotenv": "^16.3.1", "express": "^4.18.2" @@ -17,6 +18,94 @@ "node": ">=18.0.0" } }, + "node_modules/@larksuiteoapi/node-sdk": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.59.0.tgz", + "integrity": "sha512-sBpkruTvZDOxnVtoTbepWKRX0j1Y1ZElQYu0x7+v088sI9pcpbVp6ZzCGn62dhrKPatzNyCJyzYCPXPYQWccrA==", + "license": "MIT", + "dependencies": { + "axios": "~1.13.3", + "lodash.identity": "^3.0.0", + "lodash.merge": "^4.6.2", + "lodash.pickby": "^4.6.0", + "protobufjs": "^7.2.6", + "qs": "^6.14.2", + "ws": "^8.19.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -563,6 +652,30 @@ "node": ">= 0.10" } }, + "node_modules/lodash.identity": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz", + "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -686,6 +799,30 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -924,6 +1061,12 @@ "node": ">= 0.6" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -950,6 +1093,27 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 7efe711..32460fa 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "author": "饭团 🍙", "license": "MIT", "dependencies": { + "@larksuiteoapi/node-sdk": "^1.5.0", "axios": "^1.6.0", "dotenv": "^16.3.1", "express": "^4.18.2" diff --git a/src/index.js b/src/index.js index f7c7012..faf13d8 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ * 2. 支持交互式卡片上传 * 3. 支持命令触发上传 * 4. 配置管理 + * 5. 支持 HTTP 回调和 WebSocket 长连接两种模式 */ require('dotenv').config(); @@ -21,8 +22,14 @@ const { QiniuUploader } = require('./qiniu-uploader'); const { UploadCard } = require('./cards/upload-card'); const { ConfigCard } = require('./cards/config-card'); +// 飞书 SDK(WebSocket 模式) +const { Api, eventSubscription } = require('@larksuiteoapi/node-sdk'); + const app = express(); -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 3030; + +// 运行模式:'http' 或 'websocket' +const MODE = (process.env.FEISHU_MODE || 'http').toLowerCase(); // 中间件 app.use(express.json()); @@ -299,13 +306,89 @@ app.post('/feishu/event', handleFeishuEvent); // 健康检查 app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + mode: MODE, + port: PORT + }); }); // ============ 启动服务 ============ -app.listen(PORT, () => { - log(`🚀 七牛云上传机器人启动`); - log(`📍 端口:${PORT}`); - log(`🔗 事件地址:https://your-domain.com/feishu/event`); -}); +function startHTTPMode() { + app.listen(PORT, () => { + log(`🚀 七牛云上传机器人启动 (HTTP 回调模式)`); + log(`📍 端口:${PORT}`); + log(`🔗 事件地址:http://your-domain.com:${PORT}/feishu/event`); + log(`💡 提示:在飞书开放平台配置事件订阅地址为上述地址`); + }); +} + +function startWebSocketMode() { + log(`🚀 七牛云上传机器人启动 (WebSocket 长连接模式)`); + log(`💡 提示:在飞书开放平台选择 "WebSocket 长连接" 方式`); + + // 创建飞书客户端 + const client = new Api({ + appId: process.env.FEISHU_APP_ID, + appSecret: process.env.FEISHU_APP_SECRET, + }); + + // 创建 WebSocket 长连接 + const ws = eventSubscription({ + appId: process.env.FEISHU_APP_ID, + appSecret: process.env.FEISHU_APP_SECRET, + encryptKey: process.env.FEISHU_ENCRYPT_KEY, + verificationToken: process.env.FEISHU_VERIFICATION_TOKEN, + logLevel: 'info', + }); + + // 监听消息事件 + ws.on('im.message.receive_v1', async (data) => { + log('收到消息事件'); + await handleMessage(data); + }); + + // 监听连接状态 + ws.on('open', () => { + log('✅ WebSocket 连接成功'); + }); + + ws.on('close', () => { + log('❌ WebSocket 连接关闭,5 秒后重连...'); + setTimeout(() => { + try { + ws.start(); + } catch (e) { + log('重连失败:', e.message); + } + }, 5000); + }); + + ws.on('error', (error) => { + log('❌ WebSocket 错误:', error.message); + }); + + // 启动 WebSocket 连接 + try { + ws.start(); + log('📡 WebSocket 已启动'); + } catch (error) { + log('❌ WebSocket 启动失败:', error.message); + log('💡 请检查飞书配置是否正确'); + } + + // HTTP 服务器仍然运行(用于健康检查) + app.listen(PORT, () => { + log(`📍 健康检查端口:${PORT}`); + log(`🔗 健康检查地址:http://localhost:${PORT}/health`); + }); +} + +// 根据配置启动对应模式 +if (MODE === 'websocket') { + startWebSocketMode(); +} else { + startHTTPMode(); +}