From d95665b2a747a8dddce15f52eeabef28eb82e8bb Mon Sep 17 00:00:00 2001 From: lingxiyang <93123919+lingxiyang@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:12:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加飞书支持 --- FEISHU_SETUP.md | 140 ++++ bun.lock | 47 +- hub/package.json | 1 + hub/src/config/serverSettings.ts | 127 ++++ hub/src/config/settings.ts | 7 + hub/src/configuration.ts | 41 + hub/src/feishu/README.md | 201 +++++ hub/src/feishu/bot.ts | 823 +++++++++++++++++++++ hub/src/feishu/cardBuilder.ts | 397 ++++++++++ hub/src/feishu/index.ts | 12 + hub/src/index.ts | 34 +- hub/src/notifications/eventParsing.ts | 104 +++ hub/src/notifications/notificationHub.ts | 53 +- hub/src/notifications/notificationTypes.ts | 1 + hub/src/store/users.ts | 5 +- hub/test-feishu.sh | 70 ++ start-hub-feishu.bat | 37 + start-hub-feishu.ps1 | 44 ++ 18 files changed, 2140 insertions(+), 4 deletions(-) create mode 100644 FEISHU_SETUP.md create mode 100644 hub/src/feishu/README.md create mode 100644 hub/src/feishu/bot.ts create mode 100644 hub/src/feishu/cardBuilder.ts create mode 100644 hub/src/feishu/index.ts create mode 100644 hub/test-feishu.sh create mode 100644 start-hub-feishu.bat create mode 100644 start-hub-feishu.ps1 diff --git a/FEISHU_SETUP.md b/FEISHU_SETUP.md new file mode 100644 index 000000000..ad76b0c21 --- /dev/null +++ b/FEISHU_SETUP.md @@ -0,0 +1,140 @@ +# 飞书配置指南 + +## ⚠️ 安全提醒 + +**以下凭据为敏感信息,请妥善保管:** +- App ID: `cli_a933a4feadb81cc9` +- App Secret: `e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT` +- Verification Token: `4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0` + +**请勿:** +1. 将这些凭据提交到 Git 仓库 +2. 在公共渠道分享 +3. 硬编码在源代码中 + +## 配置方式 + +### 方式一:环境变量(推荐用于开发) + +在 `hub` 目录创建 `.env` 文件: + +```bash +cd hub +# Windows PowerShell +$env:FEISHU_APP_ID="cli_a933a4feadb81cc9" +$env:FEISHU_APP_SECRET="e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT" +$env:FEISHU_VERIFICATION_TOKEN="4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0" +$env:FEISHU_ENABLED="true" +$env:FEISHU_NOTIFICATION="true" +``` + +或在 `~/.bashrc` / `~/.zshrc` 中添加: + +```bash +export FEISHU_APP_ID="cli_a933a4feadb81cc9" +export FEISHU_APP_SECRET="e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT" +export FEISHU_VERIFICATION_TOKEN="4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0" +export FEISHU_BASE_URL="https://open.feishu.cn" # 国内版 +# export FEISHU_BASE_URL="https://open.larksuite.com" # 国际版 +export FEISHU_ENABLED="true" +export FEISHU_NOTIFICATION="true" +``` + +### 方式二:settings.json + +文件会自动创建在 `~/.hapi/settings.json`: + +```json +{ + "feishuAppId": "cli_a933a4feadb81cc9", + "feishuAppSecret": "e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT", + "feishuVerificationToken": "4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0", + "feishuBaseUrl": "https://open.feishu.cn", + "feishuEnabled": true, + "feishuNotification": true +} +``` + +**注意:`~/.hapi/` 目录已在 `.gitignore` 中,不会提交到仓库。** + +## 启动验证 + +配置完成后启动 HAPI Hub: + +```bash +cd hub +bun run start +``` + +你应该看到以下日志: + +``` +[Hub] Feishu: enabled (environment) +[Hub] Feishu notifications: enabled (environment) +... +[FeishuBot] Starting... +[FeishuWs] Token obtained, expires in 7140 seconds +[FeishuWs] Connecting to WebSocket... +[FeishuBot] WebSocket connected +``` + +## 飞书端配置 + +在飞书开放平台完成以下配置: + +### 1. 事件订阅配置 + +订阅方式:**长连接** + +订阅事件: +- ✅ im.message.receive_v1 (接收消息) +- ✅ card.action.trigger (卡片操作) +- ✅ im.bot.added_v1 (机器人进群) +- ✅ im.bot.deleted_v1 (机器人退群) + +### 2. 权限配置 + +需要申请以下权限: +- ✅ `im:chat:readonly` - 获取群组信息 +- ✅ `im:message:send` - 发送消息 +- ✅ `im:message:send_as_bot` - 以机器人身份发送消息 +- ✅ `im:message:read` - 读取消息(可选) + +### 3. 发布应用 + +完成配置后需要**发布应用**才能正常使用。 + +## 使用测试 + +1. 在飞书中搜索你的机器人 +2. 发送 `/help` 查看帮助 +3. 发送 `/bind <你的token>` 绑定账号 +4. 启动 HAPI CLI 会话,测试通知功能 + +## 故障排查 + +### WebSocket 连接失败 + +检查: +```bash +curl -I https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal +``` + +如果无法连接,检查网络代理设置。 + +### 403 Forbidden + +- 应用未发布 +- 权限未开通 +- 凭证错误 + +### 无法接收消息 + +- 检查事件订阅配置 +- 确认订阅了 `im.message.receive_v1` +- 检查 WebSocket 连接状态 + +## 参考链接 + +- [飞书开放平台 - 我的应用](https://open.feishu.cn/app/) +- [事件订阅配置文档](https://open.feishu.cn/document/server-side/event-subscription/event-subscription-configure) diff --git a/bun.lock b/bun.lock index c8f2b6fe2..1c4ad5a7f 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "version": "0.1.0", "dependencies": { "@hapi/protocol": "workspace:*", + "@larksuiteoapi/node-sdk": "^1.59.0", "@socket.io/bun-engine": "^0.1.0", "grammy": "^1.38.4", "hono": "^4.11.2", @@ -612,6 +613,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.59.0", "", { "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" } }, "sha512-sBpkruTvZDOxnVtoTbepWKRX0j1Y1ZElQYu0x7+v088sI9pcpbVp6ZzCGn62dhrKPatzNyCJyzYCPXPYQWccrA=="], + "@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="], "@livekit/protocol": ["@livekit/protocol@1.42.2", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-0jeCwoMJKcwsZICg5S6RZM4xhJoF78qMvQELjACJQn6/VB+jmiySQKOSELTXvPBVafHfEbMlqxUw2UR1jTXs2g=="], @@ -624,6 +627,26 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -882,6 +905,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.16.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-0KNIsS4/g2XW59cOXpXG16Mmcc1gU4Z/VNgvQzJL0utfOxMUTrWzDVlrt7qN13yMocrqqgKCf0JTrEdgU1221w=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.16.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-2nM/734UDmNuMyjrgMxdJBzs9nn9wvBhdlxtDtgdycGEiXefLoKKzhCRU37K7iLiW3gyHxlRShvz17dp5nu3zg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -1846,10 +1871,18 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.identity": ["lodash.identity@3.0.0", "", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "lodash.pickby": ["lodash.pickby@4.6.0", "", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="], + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -2128,6 +2161,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protobufjs": ["protobufjs@7.5.4", "", { "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" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -2138,7 +2173,7 @@ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], - "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -2724,6 +2759,10 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@larksuiteoapi/node-sdk/axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + + "@larksuiteoapi/node-sdk/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], @@ -2910,6 +2949,8 @@ "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "body-parser/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], @@ -2942,6 +2983,8 @@ "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "express/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -3070,6 +3113,8 @@ "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "@modelcontextprotocol/sdk/express/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], diff --git a/hub/package.json b/hub/package.json index aa7c78fa1..862c5e311 100644 --- a/hub/package.json +++ b/hub/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@hapi/protocol": "workspace:*", + "@larksuiteoapi/node-sdk": "^1.59.0", "@socket.io/bun-engine": "^0.1.0", "grammy": "^1.38.4", "hono": "^4.11.2", diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index 095fc2dbc..844b5a6f8 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -13,6 +13,13 @@ import { getSettingsFile, readSettings, writeSettings } from './settings' export interface ServerSettings { telegramBotToken: string | null telegramNotification: boolean + feishuAppId: string | null + feishuAppSecret: string | null + feishuEncryptKey: string | null + feishuVerificationToken: string | null + feishuEnabled: boolean + feishuNotification: boolean + feishuBaseUrl: string listenHost: string listenPort: number publicUrl: string @@ -24,6 +31,13 @@ export interface ServerSettingsResult { sources: { telegramBotToken: 'env' | 'file' | 'default' telegramNotification: 'env' | 'file' | 'default' + feishuAppId: 'env' | 'file' | 'default' + feishuAppSecret: 'env' | 'file' | 'default' + feishuEncryptKey: 'env' | 'file' | 'default' + feishuVerificationToken: 'env' | 'file' | 'default' + feishuEnabled: 'env' | 'file' | 'default' + feishuNotification: 'env' | 'file' | 'default' + feishuBaseUrl: 'env' | 'file' | 'default' listenHost: 'env' | 'file' | 'default' listenPort: 'env' | 'file' | 'default' publicUrl: 'env' | 'file' | 'default' @@ -87,11 +101,19 @@ export async function loadServerSettings(dataDir: string): Promise file > null let telegramBotToken: string | null = null if (process.env.TELEGRAM_BOT_TOKEN) { @@ -120,6 +142,104 @@ export async function loadServerSettings(dataDir: string): Promise file > null + let feishuAppId: string | null = null + if (process.env.FEISHU_APP_ID) { + feishuAppId = process.env.FEISHU_APP_ID + sources.feishuAppId = 'env' + if (settings.feishuAppId === undefined) { + settings.feishuAppId = feishuAppId + needsSave = true + } + } else if (settings.feishuAppId !== undefined) { + feishuAppId = settings.feishuAppId + sources.feishuAppId = 'file' + } + + // feishuAppSecret: env > file > null + let feishuAppSecret: string | null = null + if (process.env.FEISHU_APP_SECRET) { + feishuAppSecret = process.env.FEISHU_APP_SECRET + sources.feishuAppSecret = 'env' + if (settings.feishuAppSecret === undefined) { + settings.feishuAppSecret = feishuAppSecret + needsSave = true + } + } else if (settings.feishuAppSecret !== undefined) { + feishuAppSecret = settings.feishuAppSecret + sources.feishuAppSecret = 'file' + } + + // feishuEncryptKey: env > file > null + let feishuEncryptKey: string | null = null + if (process.env.FEISHU_ENCRYPT_KEY) { + feishuEncryptKey = process.env.FEISHU_ENCRYPT_KEY + sources.feishuEncryptKey = 'env' + if (settings.feishuEncryptKey === undefined) { + settings.feishuEncryptKey = feishuEncryptKey + needsSave = true + } + } else if (settings.feishuEncryptKey !== undefined) { + feishuEncryptKey = settings.feishuEncryptKey + sources.feishuEncryptKey = 'file' + } + + // feishuVerificationToken: env > file > null + let feishuVerificationToken: string | null = null + if (process.env.FEISHU_VERIFICATION_TOKEN) { + feishuVerificationToken = process.env.FEISHU_VERIFICATION_TOKEN + sources.feishuVerificationToken = 'env' + if (settings.feishuVerificationToken === undefined) { + settings.feishuVerificationToken = feishuVerificationToken + needsSave = true + } + } else if (settings.feishuVerificationToken !== undefined) { + feishuVerificationToken = settings.feishuVerificationToken + sources.feishuVerificationToken = 'file' + } + + // feishuEnabled: env > file > true if credentials present + let feishuEnabled = Boolean(feishuAppId && feishuAppSecret) + if (process.env.FEISHU_ENABLED !== undefined) { + feishuEnabled = process.env.FEISHU_ENABLED === 'true' + sources.feishuEnabled = 'env' + if (settings.feishuEnabled === undefined) { + settings.feishuEnabled = feishuEnabled + needsSave = true + } + } else if (settings.feishuEnabled !== undefined) { + feishuEnabled = settings.feishuEnabled + sources.feishuEnabled = 'file' + } + + // feishuNotification: env > file > true (default enabled) + let feishuNotification = true + if (process.env.FEISHU_NOTIFICATION !== undefined) { + feishuNotification = process.env.FEISHU_NOTIFICATION === 'true' + sources.feishuNotification = 'env' + if (settings.feishuNotification === undefined) { + settings.feishuNotification = feishuNotification + needsSave = true + } + } else if (settings.feishuNotification !== undefined) { + feishuNotification = settings.feishuNotification + sources.feishuNotification = 'file' + } + + // feishuBaseUrl: env > file > default (feishu.cn for CN, larksuite.com for international) + let feishuBaseUrl = 'https://open.feishu.cn' + if (process.env.FEISHU_BASE_URL) { + feishuBaseUrl = process.env.FEISHU_BASE_URL + sources.feishuBaseUrl = 'env' + if (settings.feishuBaseUrl === undefined) { + settings.feishuBaseUrl = feishuBaseUrl + needsSave = true + } + } else if (settings.feishuBaseUrl !== undefined) { + feishuBaseUrl = settings.feishuBaseUrl + sources.feishuBaseUrl = 'file' + } + // listenHost: env > file (new or old name) > default let listenHost = '127.0.0.1' if (process.env.HAPI_LISTEN_HOST) { @@ -212,6 +332,13 @@ export async function loadServerSettings(dataDir: string): Promise +``` + +The token is your `CLI_API_TOKEN` from HAPI Hub startup logs. + +### Available Commands + +| Command | Description | +|---------|-------------| +| `/bind ` | Bind Feishu account to HAPI namespace | +| `/sessions` or `/list` | List active sessions | +| `/send ` | Send message to a specific session | +| `/help` | Show help message | +| Direct message | Send to the most recent active session | + +### Receiving Notifications + +Once bound, you will receive: + +1. **Ready Notifications**: When an agent is waiting for input +2. **Permission Requests**: When agent needs approval for tools + - Click "Allow" or "Deny" buttons on the card +3. **Session Updates**: Via interactive cards + +### Example Flow + +``` +User: /bind my-token-123 +Bot: ✅ Bound to namespace: default + +User: /sessions +Bot: [Card] Active Sessions (shows list with buttons) + +User: hello agent +Bot: Message sent to session abc123... + +[Later, when agent needs permission] +Bot: [Card] Permission Request - Claude + Tool: Bash + Command: ls -la + [Allow] [Deny] + +User: (clicks Allow) +Bot: [Card] ✅ Approved +``` + +## Security + +- All communication uses TLS (wss:// and https://) +- Messages can be encrypted with `FEISHU_ENCRYPT_KEY` +- User binding requires valid CLI API token +- Session access is restricted to bound namespace + +## Troubleshooting + +### WebSocket Connection Failed + +Check: +1. `FEISHU_APP_ID` and `FEISHU_APP_SECRET` are correct +2. App is published (not in development mode) +3. Network allows outbound connection to `open.feishu.cn:443` + +### Not Receiving Notifications + +Check: +1. User is bound (`/bind` command) +2. `FEISHU_NOTIFICATION` is enabled +3. Sessions are in the same namespace as bound user + +### Message Send Failed + +Check: +1. Session is active +2. User has permission for the namespace +3. Session ID is correct (can use first 8 chars) + +## File Structure + +``` +hub/src/feishu/ +├── index.ts # Public exports +├── bot.ts # FeishuBot main class +├── wsClient.ts # WebSocket long connection client +├── apiClient.ts # Feishu API wrapper +└── cardBuilder.ts # Interactive card templates +``` + +## References + +- [Feishu Open Platform](https://open.feishu.cn/) +- [Event Subscription - WebSocket Mode](https://open.feishu.cn/document/server-side/event-subscription/event-subscription-configure) +- [Interactive Card Kit](https://open.feishu.cn/document/server-side/card-kit/interactive-card) diff --git a/hub/src/feishu/bot.ts b/hub/src/feishu/bot.ts new file mode 100644 index 000000000..96256a6e2 --- /dev/null +++ b/hub/src/feishu/bot.ts @@ -0,0 +1,823 @@ +/** + * Feishu Bot for HAPI - Using Official SDK + * + * Uses @larksuiteoapi/node-sdk for WebSocket long connection and API calls. + * + * @see https://open.feishu.cn/document/server-side-sdk/nodejs-sdk/preparation-before-development + */ + +import * as lark from '@larksuiteoapi/node-sdk' +import type { SyncEngine, Session } from '../sync/syncEngine' +import type { Store } from '../store' +import type { NotificationChannel } from '../notifications/notificationTypes' + +export interface FeishuBotConfig { + syncEngine: SyncEngine + appId: string + appSecret: string + baseUrl?: string + encryptKey?: string | null + verificationToken?: string | null + store: Store +} + +export class FeishuBot implements NotificationChannel { + private syncEngine: SyncEngine | null = null + private store: Store + private isRunning = false + private config: FeishuBotConfig + + // SDK clients + private apiClient: lark.Client | null = null + private wsClient: lark.WSClient | null = null + + // Pending permission requests for text-based interaction + private pendingPermissionRequests: Map = new Map() + + constructor(config: FeishuBotConfig) { + this.config = config + this.store = config.store + this.syncEngine = config.syncEngine + + // Initialize API client + this.apiClient = new lark.Client({ + appId: config.appId, + appSecret: config.appSecret, + domain: config.baseUrl as lark.Domain ?? lark.Domain.Feishu, + appType: lark.AppType.SelfBuild, + loggerLevel: lark.LoggerLevel.info, + }) + } + + /** + * Set/update the sync engine reference + */ + setSyncEngine(engine: SyncEngine): void { + this.syncEngine = engine + } + + /** + * Start the bot + */ + async start(): Promise { + if (this.isRunning || !this.apiClient) { + return + } + + console.log('[FeishuBot] Starting...') + this.isRunning = true + + // Create WebSocket client + this.wsClient = new lark.WSClient({ + appId: this.config.appId, + appSecret: this.config.appSecret, + domain: this.config.baseUrl as lark.Domain ?? lark.Domain.Feishu, + loggerLevel: lark.LoggerLevel.info, + }) + + // Create event dispatcher + const eventDispatcher = new lark.EventDispatcher({ + verificationToken: this.config.verificationToken ?? undefined, + encryptKey: this.config.encryptKey ?? undefined, + }) + + // Register event handlers + eventDispatcher.register({ + 'im.message.receive_v1': async (data) => { + await this.handleMessageEvent(data) + }, + 'card.action.trigger': async (data) => { + return await this.handleCardActionEvent(data) + }, + 'im.bot.added_v1': async (data) => { + console.log('[FeishuBot] Bot added to chat:', data.event?.chat_id) + }, + 'im.bot.deleted_v1': async (data) => { + console.log('[FeishuBot] Bot removed from chat:', data.event?.chat_id) + }, + }) + + // Start WebSocket connection + this.wsClient.start({ eventDispatcher }) + + console.log('[FeishuBot] WebSocket connected') + } + + /** + * Stop the bot + */ + async stop(): Promise { + if (!this.isRunning) { + return + } + + console.log('[FeishuBot] Stopping...') + this.isRunning = false + + if (this.wsClient) { + // WSClient doesn't have a stop method in the SDK, just close the connection + this.wsClient = null + } + } + + /** + * Handle im.message.receive_v1 event + */ + private async handleMessageEvent(data: lark.ImMessageReceiveV1): Promise { + console.log('[FeishuBot] Received im.message.receive_v1 event:', JSON.stringify(data, null, 2)) + + const message = data.message + if (!message) { + console.log('[FeishuBot] No message in event') + return + } + + // Only handle p2p messages (not group messages) + if (message.chat_type !== 'p2p') { + console.log(`[FeishuBot] Ignoring non-p2p message: ${message.chat_type}`) + return + } + + // Try multiple locations for sender open_id + let senderOpenId: string | undefined + + // Location 1: data.sender.sender_id.open_id (event-level sender) + const anyData = data as unknown as Record + const eventSender = anyData.sender as Record | undefined + senderOpenId = (eventSender?.sender_id as Record | undefined)?.open_id + console.log(`[FeishuBot] Location 1 (data.sender.sender_id.open_id): ${senderOpenId}`) + + // Location 2: message.sender.sender_id.open_id (SDK typed structure) + if (!senderOpenId) { + senderOpenId = message.sender?.sender_id?.open_id + console.log(`[FeishuBot] Location 2 (message.sender.sender_id.open_id): ${senderOpenId}`) + } + + // Location 3: data.open_id + if (!senderOpenId) { + senderOpenId = anyData.open_id as string | undefined + console.log(`[FeishuBot] Location 3 (data.open_id): ${senderOpenId}`) + } + + // Location 4: message.chat_id (fallback) + if (!senderOpenId) { + console.log(`[FeishuBot] Using chat_id as fallback: ${message.chat_id}`) + senderOpenId = message.chat_id + } + + if (!senderOpenId) { + console.log('[FeishuBot] No sender open_id found anywhere') + return + } + console.log(`[FeishuBot] Final senderOpenId: ${senderOpenId}`) + + // Parse message content + let text = '' + try { + if (message.content) { + console.log(`[FeishuBot] Raw message content: ${message.content}`) + const content = JSON.parse(message.content) + text = content.text || '' + console.log(`[FeishuBot] Parsed text: ${text}`) + } else { + console.log('[FeishuBot] No message content') + } + } catch (e) { + console.error('[FeishuBot] Failed to parse message content:', e) + return + } + + if (!text) { + console.log('[FeishuBot] Empty text, ignoring') + return + } + + // Parse and handle command + const command = this.parseCommand(text) + console.log(`[FeishuBot] Parsed command: ${command.command}, args: ${JSON.stringify(command.args)}`) + await this.handleCommand(senderOpenId, command, message.message_id) + } + + /** + * Handle card.action.trigger event + */ + private async handleCardActionEvent(data: lark.InteractiveCardActionEvent): Promise<{ toast?: { type: 'success' | 'error'; content: string }; card?: unknown } | void> { + console.log('[FeishuBot] Received card.action.trigger event:', JSON.stringify(data, null, 2)) + + const { action } = data + if (!action) { + console.log('[FeishuBot] No action in card event') + return { toast: { type: 'error', content: 'No action found' } } + } + + const { open_id } = action + + // Card interaction is not supported, redirect to text commands + return { + toast: { + type: 'error', + content: 'Please use text commands: /allow or /deny ' + } + } + } + + /** + * Handle permission approve/deny action + */ + private async handlePermissionAction( + openId: string, + messageId: string, + value: { action: string; sessionId?: string; requestId?: string } + ): Promise { + console.log(`[FeishuBot] handlePermissionAction: openId=${openId}, action=${value.action}, sessionId=${value.sessionId}, requestId=${value.requestId}`) + + if (!this.syncEngine || !this.apiClient) { + await this.replyToMessage(messageId, 'HAPI is not ready') + return + } + + const namespace = this.getNamespaceForOpenId(openId) + console.log(`[FeishuBot] Namespace for openId: ${namespace}`) + if (!namespace) { + await this.replyToMessage(messageId, 'Your Feishu account is not bound. Use /bind ') + return + } + + const session = value.sessionId ? this.syncEngine.getSession(value.sessionId) : null + console.log(`[FeishuBot] Session: ${session?.id}, namespace=${session?.namespace}`) + if (!session || session.namespace !== namespace) { + await this.replyToMessage(messageId, 'Session not found or access denied') + return + } + + if (!value.requestId) { + await this.replyToMessage(messageId, 'No request ID found') + return + } + + const approved = value.action === 'approve' + console.log(`[FeishuBot] Permission ${approved ? 'approved' : 'denied'} for ${value.requestId}`) + + try { + if (approved) { + await this.syncEngine.approvePermission(value.sessionId, value.requestId) + } else { + await this.syncEngine.denyPermission(value.sessionId, value.requestId) + } + await this.replyToMessage(messageId, approved ? '✅ Permission Approved' : '❌ Permission Denied') + } catch (error) { + console.error('[FeishuBot] Failed to handle permission action:', error) + await this.replyToMessage(messageId, '❌ Failed to process permission action') + } + } + + /** + * Parse command from message text + */ + private parseCommand(text: string): { command: string; args: string[] } { + const trimmed = text.trim() + const parts = trimmed.split(/\s+/) + const command = parts[0].toLowerCase().replace(/^\//, '') + const args = parts.slice(1) + return { command, args } + } + + /** + * Handle user command + */ + private async handleCommand( + openId: string, + command: { command: string; args: string[] }, + messageId: string + ): Promise { + switch (command.command) { + case 'bind': + await this.handleBind(openId, command.args, messageId) + break + case 'sessions': + case 'list': + await this.handleListSessions(openId) + break + case 'send': + await this.handleSend(openId, command.args, messageId) + break + case 'allow': + case 'approve': + await this.handleAllowDeny(openId, command.args, messageId, true) + break + case 'deny': + case 'reject': + await this.handleAllowDeny(openId, command.args, messageId, false) + break + case 'help': + case 'start': + await this.handleHelp(openId) + break + default: + // If not a command, treat as message to active session + await this.handleDirectMessage(openId, command.command + ' ' + command.args.join(' '), messageId) + } + } + + /** + * Handle /bind command + */ + private async handleBind(openId: string, args: string[], messageId: string): Promise { + console.log(`[FeishuBot] handleBind called: openId=${openId}, args=${JSON.stringify(args)}, messageId=${messageId}`) + + if (args.length === 0) { + console.log('[FeishuBot] No args provided, sending usage message') + await this.replyToMessage(messageId, 'Usage: /bind ') + return + } + + const token = args[0] + const namespace = token.includes(':') ? token.split(':')[1] : 'default' + + // Remove ALL existing feishu users for this namespace to avoid cross-app open_id issues + console.log(`[FeishuBot] Removing all existing feishu users for namespace ${namespace}`) + const existingUsers = this.store.users.getUsersByPlatformAndNamespace('feishu', namespace) + console.log(`[FeishuBot] Found ${existingUsers.length} existing users:`, existingUsers) + for (const user of existingUsers) { + console.log(`[FeishuBot] Removing old user: ${user.platformUserId}`) + this.store.users.removeUser('feishu', user.platformUserId) + } + + console.log(`[FeishuBot] Adding user: platform=feishu, openId=${openId}, namespace=${namespace}`) + const newUser = this.store.users.addUser('feishu', openId, namespace) + console.log(`[FeishuBot] New user added:`, newUser) + + // Verify + const verifyUsers = this.store.users.getUsersByPlatformAndNamespace('feishu', namespace) + console.log(`[FeishuBot] After bind, users in namespace:`, verifyUsers) + + console.log(`[FeishuBot] User added, sending reply...`) + await this.replyToMessage(messageId, `✅ Bound to namespace: ${namespace}`) + console.log(`[FeishuBot] Bind complete`) + } + + /** + * Handle /sessions command + */ + private async handleListSessions(openId: string): Promise { + const namespace = this.getNamespaceForOpenId(openId) + if (!namespace) { + await this.sendTextMessage(openId, 'Not bound. Use /bind first.') + return + } + + if (!this.syncEngine) { + await this.sendTextMessage(openId, 'HAPI is not ready') + return + } + + const sessions = this.syncEngine.getSessionsByNamespace(namespace) + const activeSessions = sessions.filter((s: Session) => s.active) + + if (activeSessions.length === 0) { + await this.sendTextMessage(openId, 'No active sessions.') + return + } + + let text = '📋 **Active Sessions**\n\n' + for (const session of activeSessions) { + const sessionName = session.metadata?.name || session.id.slice(0, 8) + const agentName = session.metadata?.flavor || 'Agent' + text += `• **${sessionName}** (${agentName})\n ID: ${session.id.slice(0, 8)}...\n\n` + } + + await this.sendTextMessage(openId, text) + } + + /** + * Handle /send command + */ + private async handleSend(openId: string, args: string[], messageId: string): Promise { + if (args.length < 2) { + await this.replyToMessage(messageId, 'Usage: /send ') + return + } + + const sessionId = args[0] + const messageText = args.slice(1).join(' ') + + await this.sendMessageToSession(openId, sessionId, messageText, messageId) + } + + /** + * Handle direct message (not a command) + */ + private async handleDirectMessage(openId: string, text: string, messageId: string): Promise { + console.log(`[FeishuBot] handleDirectMessage: openId=${openId}, text=${text.substring(0, 50)}`) + + const namespace = this.getNamespaceForOpenId(openId) + if (!namespace) { + console.log(`[FeishuBot] No namespace found for openId=${openId}`) + await this.replyToMessage(messageId, 'Not bound. Use /bind first.') + return + } + console.log(`[FeishuBot] Found namespace: ${namespace}`) + + if (!this.syncEngine) { + await this.replyToMessage(messageId, 'HAPI is not ready') + return + } + + const sessions = this.syncEngine.getSessionsByNamespace(namespace) + console.log(`[FeishuBot] Found ${sessions.length} sessions in namespace ${namespace}`) + + // Debug: list all sessions and their namespaces + const allSessions = this.syncEngine.getSessions() + console.log(`[FeishuBot] All sessions: ${allSessions.length}`) + for (const s of allSessions) { + console.log(` - ${s.id}: namespace=${s.namespace}, active=${s.active}`) + } + + const activeSessions = sessions.filter((s: Session) => s.active) + console.log(`[FeishuBot] Found ${activeSessions.length} active sessions`) + + if (activeSessions.length === 0) { + await this.replyToMessage(messageId, 'No active sessions. Start a session in HAPI first.') + return + } + + // Use the most recently updated session + const session = activeSessions.sort( + (a: Session, b: Session) => (b.updatedAt || 0) - (a.updatedAt || 0) + )[0] + + console.log(`[FeishuBot] Using session: ${session.id}`) + await this.sendMessageToSession(openId, session.id, text.trim(), messageId) + } + + /** + * Send message to a session + */ + private async sendMessageToSession( + openId: string, + sessionId: string, + text: string, + replyToMessageId: string + ): Promise { + if (!this.syncEngine) { + await this.replyToMessage(replyToMessageId, 'HAPI is not ready') + return + } + + const namespace = this.getNamespaceForOpenId(openId) + if (!namespace) { + await this.replyToMessage(replyToMessageId, 'Not bound') + return + } + + const session = this.syncEngine.getSession(sessionId) + if (!session || session.namespace !== namespace) { + await this.replyToMessage(replyToMessageId, 'Session not found or access denied') + return + } + + if (!session.active) { + await this.replyToMessage(replyToMessageId, 'Session is not active') + return + } + + try { + // Send message via syncEngine to CLI + await this.syncEngine.sendMessage(sessionId, { + text, + sentFrom: 'telegram-bot' // Use telegram-bot type for external messages + }) + console.log(`[FeishuBot] Message sent to session ${sessionId}: ${text}`) + await this.replyToMessage(replyToMessageId, `✅ Message sent to session`) + } catch (error) { + console.error(`[FeishuBot] Failed to send message to session ${sessionId}:`, error) + await this.replyToMessage(replyToMessageId, '❌ Failed to send message') + } + } + + /** + * Handle /help command + */ + private async handleHelp(openId: string): Promise { + const text = `❓ **HAPI Bot Help** + +**Available Commands:** + +• **/bind ** - Bind your Feishu account to a namespace +• **/sessions** - List all active sessions +• **/send ** - Send a message to an agent +• **/allow ** - Approve a permission request +• **/deny ** - Deny a permission request +• **/help** - Show this help message +• **Direct message** - Send to the active session (if bound)` + await this.sendTextMessage(openId, text) + } + + /** + * Handle /allow and /deny commands + */ + private async handleAllowDeny( + openId: string, + args: string[], + messageId: string, + allow: boolean + ): Promise { + if (args.length === 0) { + await this.replyToMessage(messageId, `Usage: /${allow ? 'allow' : 'deny'} `) + return + } + + const requestIdShort = args[0] + const namespace = this.getNamespaceForOpenId(openId) + + if (!namespace) { + await this.replyToMessage(messageId, 'Not bound. Use /bind first.') + return + } + + if (!this.syncEngine) { + await this.replyToMessage(messageId, 'HAPI is not ready') + return + } + + // Find the full request ID from the short form + let fullRequestId: string | null = null + let sessionId: string | null = null + + for (const [key, value] of this.pendingPermissionRequests.entries()) { + if (key.startsWith(requestIdShort) || key.slice(0, 8) === requestIdShort) { + fullRequestId = key + sessionId = value.sessionId + break + } + } + + if (!fullRequestId || !sessionId) { + await this.replyToMessage(messageId, 'Request not found or expired. Please check the request ID.') + return + } + + const session = this.syncEngine.getSession(sessionId) + if (!session || session.namespace !== namespace) { + await this.replyToMessage(messageId, 'Session not found or access denied') + return + } + + try { + if (allow) { + await this.syncEngine.approvePermission(sessionId, fullRequestId) + } else { + await this.syncEngine.denyPermission(sessionId, fullRequestId) + } + + // Remove from pending requests + this.pendingPermissionRequests.delete(fullRequestId) + + await this.replyToMessage(messageId, allow ? '✅ Permission Approved' : '❌ Permission Denied') + } catch (error) { + console.error('[FeishuBot] Failed to handle permission:', error) + await this.replyToMessage(messageId, '❌ Failed to process permission action') + } + } + + /** + * Get namespace for Feishu open_id + */ + private getNamespaceForOpenId(openId: string): string | null { + console.log(`[FeishuBot] getNamespaceForOpenId: openId=${openId}`) + const stored = this.store.users.getUser('feishu', openId) + console.log(`[FeishuBot] getUser result:`, stored) + return stored?.namespace ?? null + } + + /** + * Get bound Feishu open_ids for a namespace + */ + private getBoundOpenIds(namespace: string): string[] { + const users = this.store.users.getUsersByPlatformAndNamespace('feishu', namespace) + console.log(`[FeishuBot] getBoundOpenIds for namespace ${namespace}:`, users.map(u => ({ id: u.id, platformUserId: u.platformUserId, namespace: u.namespace }))) + return users.map((u) => u.platformUserId) + } + + /** + * Send text message using SDK + */ + private async sendTextMessage(openId: string, text: string): Promise { + if (!this.apiClient) return + + try { + await this.apiClient.im.message.create({ + params: { + receive_id_type: 'open_id', + }, + data: { + receive_id: openId, + content: JSON.stringify({ text }), + msg_type: 'text', + }, + }) + } catch (error) { + console.error('[FeishuBot] Failed to send text message:', error) + } + } + + /** + * Send card message using SDK + */ + private async sendCardMessage(openId: string, card: unknown): Promise { + if (!this.apiClient) return + + try { + await this.apiClient.im.message.create({ + params: { + receive_id_type: 'open_id', + }, + data: { + receive_id: openId, + content: JSON.stringify(card), + msg_type: 'interactive', + }, + }) + } catch (error) { + console.error('[FeishuBot] Failed to send card message:', error) + } + } + + /** + * Reply to a message using SDK + */ + private async replyToMessage(messageId: string, text: string): Promise { + if (!this.apiClient) return + + try { + await this.apiClient.im.message.reply({ + path: { + message_id: messageId, + }, + data: { + content: JSON.stringify({ text }), + msg_type: 'text', + }, + }) + } catch (error) { + console.error('[FeishuBot] Failed to reply to message:', error) + } + } + + /** + * Update card message using SDK + */ + private async updateCardMessage(messageId: string, card: unknown): Promise { + if (!this.apiClient) return + + try { + await this.apiClient.interactive.cardActions.update({ + path: { + token: messageId, + }, + data: { + card: card as Record, + }, + }) + } catch (error) { + console.error('[FeishuBot] Failed to update card message:', error) + } + } + + // + // NotificationChannel implementation + // + + /** + * Send "ready" notification + */ + async sendReady(session: Session): Promise { + if (!session.active || !this.apiClient) { + return + } + + const openIds = this.getBoundOpenIds(session.namespace) + if (openIds.length === 0) { + return + } + + const sessionName = session.metadata?.name || session.id.slice(0, 8) + const agentName = session.metadata?.flavor || 'Agent' + const text = `✅ **${agentName}** is ready\n\nSession: ${sessionName}\n\nThe agent is waiting for your next command.` + + for (const openId of openIds) { + try { + await this.sendTextMessage(openId, text) + } catch (error) { + console.error(`[FeishuBot] Failed to send ready notification to ${openId}:`, error) + } + } + } + + /** + * Send permission request notification + */ + async sendPermissionRequest(session: Session): Promise { + if (!session.active || !this.apiClient) { + return + } + + const openIds = this.getBoundOpenIds(session.namespace) + if (openIds.length === 0) { + return + } + + const requests = session.agentState?.requests + if (!requests) { + return + } + + const requestIds = Object.keys(requests) + if (requestIds.length === 0) { + return + } + + const requestId = requestIds[0] + const request = requests[requestId] + if (!request) { + return + } + + const sessionName = session.metadata?.name || session.id.slice(0, 8) + const agentName = session.metadata?.flavor || 'Agent' + const tool = request.tool || 'unknown' + const args = request.arguments || {} + + // Format tool info + let toolInfo = `**Tool:** ${tool}` + if (args && typeof args === 'object') { + const argsObj = args as Record + if (argsObj.file_path || argsObj.path) { + toolInfo += `\n**File:** ${String(argsObj.file_path || argsObj.path)}` + } + if (argsObj.command) { + toolInfo += `\n**Command:** ${String(argsObj.command).slice(0, 100)}` + } + if (argsObj.url) { + toolInfo += `\n**URL:** ${String(argsObj.url)}` + } + } + + // Store pending request for text-based interaction + this.pendingPermissionRequests.set(requestId, { + sessionId: session.id, + requestId, + tool + }) + + // Clean up old requests (keep only last 20) + const keys = Array.from(this.pendingPermissionRequests.keys()) + if (keys.length > 20) { + for (const key of keys.slice(0, keys.length - 20)) { + this.pendingPermissionRequests.delete(key) + } + } + + const text = `🔔 **Permission Request - ${agentName}**\n\nSession: ${sessionName}\n\n${toolInfo}\n\nTo approve, reply: /allow ${requestId.slice(0, 8)}\nTo deny, reply: /deny ${requestId.slice(0, 8)}` + + for (const openId of openIds) { + try { + await this.sendTextMessage(openId, text) + } catch (error) { + console.error(`[FeishuBot] Failed to send permission request to ${openId}:`, error) + } + } + } + + /** + * Send assistant message notification + */ + async sendMessage(session: Session, text: string): Promise { + console.log(`[FeishuBot] sendMessage: session=${session.id}, text=${text.substring(0, 50)}`) + + if (!session.active || !this.apiClient) { + console.log(`[FeishuBot] Session not active or apiClient not ready`) + return + } + + const openIds = this.getBoundOpenIds(session.namespace) + console.log(`[FeishuBot] Found ${openIds.length} bound openIds in namespace ${session.namespace}`) + + if (openIds.length === 0) { + return + } + + // Truncate long messages + const maxLength = 2000 + const displayText = text.length > maxLength ? text.slice(0, maxLength) + '...' : text + + for (const openId of openIds) { + try { + console.log(`[FeishuBot] Sending message to openId=${openId}`) + await this.sendTextMessage(openId, `🤖 **Claude**\n\n${displayText}`) + } catch (error) { + console.error(`[FeishuBot] Failed to send message to ${openId}:`, error) + } + } + } +} diff --git a/hub/src/feishu/cardBuilder.ts b/hub/src/feishu/cardBuilder.ts new file mode 100644 index 000000000..6d0f3ffbb --- /dev/null +++ b/hub/src/feishu/cardBuilder.ts @@ -0,0 +1,397 @@ +/** + * Feishu Card Message Builder + * + * Provides helper functions to build interactive card messages for Feishu. + * + * @see https://open.feishu.cn/document/server-side/card-kit/interactive-card + */ + +import type { Session } from '../sync/syncEngine' +import { getAgentName, getSessionName } from '../notifications/sessionInfo' + +/** + * Build a permission request card + */ +export function buildPermissionCard(session: Session, publicUrl: string): unknown { + const sessionName = getSessionName(session) + const agentName = getAgentName(session) + + const requests = session.agentState?.requests + let toolInfo = 'No pending requests' + let hasRequests = false + + if (requests) { + const requestIds = Object.keys(requests) + if (requestIds.length > 0) { + hasRequests = true + const req = requests[requestIds[0]] + if (req) { + toolInfo = formatToolInfo(req.tool, req.arguments) + } + } + } + + const card: Record = { + config: { + wide_screen_mode: true, + enable_forward: true, + }, + header: { + title: { + tag: 'plain_text', + content: `🔔 Permission Request - ${agentName}`, + }, + subtitle: { + tag: 'plain_text', + content: `Session: ${sessionName}`, + }, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: toolInfo, + }, + }, + ], + } + + if (hasRequests) { + const requestId = Object.keys(requests!)[0] + + // @ts-expect-error elements exists + card.elements.push({ + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: '✅ Allow', + }, + type: 'primary', + value: { + action: 'approve', + sessionId: session.id, + requestId: requestId, + }, + }, + { + tag: 'button', + text: { + tag: 'plain_text', + content: '❌ Deny', + }, + type: 'danger', + value: { + action: 'deny', + sessionId: session.id, + requestId: requestId, + }, + }, + ], + }) + } + + // @ts-expect-error elements exists + card.elements.push({ + tag: 'hr', + }, { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: `Session ID: ${session.id.slice(0, 8)}...`, + }, + ], + }) + + return card +} + +/** + * Build a "ready" notification card + */ +export function buildReadyCard(session: Session, publicUrl: string): unknown { + const sessionName = getSessionName(session) + const agentName = getAgentName(session) + + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: `✅ ${agentName} is Ready`, + }, + subtitle: { + tag: 'plain_text', + content: `Session: ${sessionName}`, + }, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: `The agent is waiting for your next command.`, + }, + }, + { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: 'Send Message', + }, + type: 'primary', + value: { + action: 'send_message', + sessionId: session.id, + }, + }, + { + tag: 'button', + text: { + tag: 'plain_text', + content: 'View Sessions', + }, + type: 'default', + value: { + action: 'list_sessions', + }, + }, + ], + }, + { + tag: 'hr', + }, + { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: `Session ID: ${session.id.slice(0, 8)}...`, + }, + ], + }, + ], + } +} + +/** + * Build a session list card + */ +export function buildSessionListCard(sessions: Session[]): unknown { + const elements: unknown[] = [] + + if (sessions.length === 0) { + elements.push({ + tag: 'div', + text: { + tag: 'lark_md', + content: 'No active sessions.', + }, + }) + } else { + for (const session of sessions) { + const sessionName = getSessionName(session) + const agentName = getAgentName(session) + const status = session.active ? '🟢 Active' : '🔴 Inactive' + + elements.push({ + tag: 'div', + fields: [ + { + is_short: true, + text: { + tag: 'lark_md', + content: `**${sessionName}**\n${agentName}`, + }, + }, + { + is_short: true, + text: { + tag: 'lark_md', + content: `${status}\n${session.id.slice(0, 8)}...`, + }, + }, + ], + }, { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: 'Send Message', + }, + type: 'primary', + value: { + action: 'send_message', + sessionId: session.id, + }, + }, + ], + }, { + tag: 'hr', + }) + } + } + + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: '📋 Active Sessions', + }, + }, + elements, + } +} + +/** + * Build a help card + */ +export function buildHelpCard(): unknown { + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: '❓ HAPI Bot Help', + }, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**Available Commands:**`, + }, + }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: ` +• **/bind ** - Bind your Feishu account to a namespace +• **/sessions** - List all active sessions +• **/send ** - Send a message to an agent +• **/help** - Show this help message +• **Direct message** - Send to the active session (if bound) + `.trim(), + }, + }, + { + tag: 'hr', + }, + { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: 'Use the buttons below or type commands directly.', + }, + ], + }, + { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: '📋 List Sessions', + }, + type: 'primary', + value: { + action: 'list_sessions', + }, + }, + ], + }, + ], + } +} + +/** + * Build a success/error response card + */ +export function buildResponseCard(message: string, isError = false): unknown { + return { + config: { + wide_screen_mode: true, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: isError ? `❌ ${message}` : `✅ ${message}`, + }, + }, + ], + } +} + +/** + * Format tool information for display + */ +function formatToolInfo(tool: string, args: unknown): string { + if (!args || typeof args !== 'object') { + return `**Tool:** ${tool}` + } + + const argsObj = args as Record + let result = `**Tool:** ${tool}\n` + + switch (tool) { + case 'Edit': { + const file = String(argsObj.file_path || argsObj.path || 'unknown') + result += `**File:** ${truncate(file, 100)}` + break + } + case 'Write': { + const file = String(argsObj.file_path || argsObj.path || 'unknown') + const content = String(argsObj.content || '') + result += `**File:** ${truncate(file, 100)}\n**Size:** ${content.length} chars` + break + } + case 'Read': { + const file = String(argsObj.file_path || argsObj.path || 'unknown') + result += `**File:** ${truncate(file, 100)}` + break + } + case 'Bash': { + const cmd = String(argsObj.command || '') + result += `**Command:** ${truncate(cmd, 150)}` + break + } + case 'WebFetch': { + const url = String(argsObj.url || '') + result += `**URL:** ${truncate(url, 150)}` + break + } + default: { + const argStr = JSON.stringify(args) + result += `**Args:** ${truncate(argStr, 200)}` + } + } + + return result +} + +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str + return str.slice(0, maxLength - 3) + '...' +} diff --git a/hub/src/feishu/index.ts b/hub/src/feishu/index.ts new file mode 100644 index 000000000..3953a128d --- /dev/null +++ b/hub/src/feishu/index.ts @@ -0,0 +1,12 @@ +/** + * Feishu integration for HAPI + */ + +export { FeishuBot, type FeishuBotConfig } from './bot' +export { + buildPermissionCard, + buildReadyCard, + buildSessionListCard, + buildHelpCard, + buildResponseCard, +} from './cardBuilder' diff --git a/hub/src/index.ts b/hub/src/index.ts index a53ae3308..51cc630e2 100644 --- a/hub/src/index.ts +++ b/hub/src/index.ts @@ -14,6 +14,7 @@ import { SyncEngine, type SyncEvent } from './sync/syncEngine' import { NotificationHub } from './notifications/notificationHub' import type { NotificationChannel } from './notifications/notificationTypes' import { HappyBot } from './telegram/bot' +import { FeishuBot } from './feishu/bot' import { startWebServer } from './web/server' import { getOrCreateJwtSecret } from './config/jwtSecret' import { createSocketServer } from './socket/server' @@ -99,6 +100,7 @@ function mergeCorsOrigins(base: string[], extra: string[]): string[] { let syncEngine: SyncEngine | null = null let happyBot: HappyBot | null = null +let feishuBot: FeishuBot | null = null let webServer: BunServer | null = null let sseManager: SSEManager | null = null let visibilityTracker: VisibilityTracker | null = null @@ -150,6 +152,15 @@ async function main() { console.log(`[Hub] Telegram notifications: ${config.telegramNotification ? 'enabled' : 'disabled'} (${notificationSource})`) } + if (!config.feishuEnabled) { + console.log('[Hub] Feishu: disabled (no FEISHU_APP_ID/FEISHU_APP_SECRET)') + } else { + const appIdSource = formatSource(config.sources.feishuAppId) + console.log(`[Hub] Feishu: enabled (${appIdSource})`) + const notificationSource = formatSource(config.sources.feishuNotification) + console.log(`[Hub] Feishu notifications: ${config.feishuNotification ? 'enabled' : 'disabled'} (${notificationSource})`) + } + // Display tunnel status if (relayFlag.enabled) { console.log(`[Hub] Tunnel: enabled (${relayFlag.source}), API: ${relayApiDomain}`) @@ -202,6 +213,23 @@ async function main() { } } + // Initialize Feishu bot (optional) + if (config.feishuEnabled && config.feishuAppId && config.feishuAppSecret) { + feishuBot = new FeishuBot({ + syncEngine, + appId: config.feishuAppId, + appSecret: config.feishuAppSecret, + baseUrl: config.feishuBaseUrl, + encryptKey: config.feishuEncryptKey, + verificationToken: config.feishuVerificationToken, + store + }) + // Only add to notification channels if notifications are enabled + if (config.feishuNotification) { + notificationChannels.push(feishuBot) + } + } + notificationHub = new NotificationHub(syncEngine, notificationChannels) // Start HTTP service first (before tunnel, so tunnel has something to forward to) @@ -218,10 +246,13 @@ async function main() { officialWebUrl }) - // Start the bot if configured + // Start the bots if configured if (happyBot) { await happyBot.start() } + if (feishuBot) { + await feishuBot.start() + } console.log('') console.log('[Web] Hub listening on :' + config.listenPort) @@ -295,6 +326,7 @@ async function main() { console.log('\nShutting down...') await tunnelManager?.stop() await happyBot?.stop() + await feishuBot?.stop() notificationHub?.stop() syncEngine?.stop() sseManager?.stop() diff --git a/hub/src/notifications/eventParsing.ts b/hub/src/notifications/eventParsing.ts index ac615520d..e7bc761ee 100644 --- a/hub/src/notifications/eventParsing.ts +++ b/hub/src/notifications/eventParsing.ts @@ -38,3 +38,107 @@ export function extractMessageEventType(event: SyncEvent): string | null { const eventType = data?.type return typeof eventType === 'string' ? eventType : null } + +/** + * Extract text content from assistant message + */ +export function extractAssistantMessageText(event: SyncEvent): string | null { + if (event.type !== 'message-received') { + return null + } + + const content = event.message?.content + console.log('[extractAssistantMessageText] Content:', JSON.stringify(content).substring(0, 200)) + + if (!isObject(content)) { + console.log('[extractAssistantMessageText] Content is not an object') + return null + } + + // Check if this is an assistant message + const role = content.role + console.log(`[extractAssistantMessageText] Role: ${role}`) + + if (role !== 'assistant' && role !== 'agent') { + console.log(`[extractAssistantMessageText] Not an assistant/agent message`) + return null + } + + // Extract text from content + const messageContent = content.content + if (!isObject(messageContent)) { + console.log('[extractAssistantMessageText] messageContent is not an object') + return null + } + + // Handle text content (from external sources like telegram-bot) + if (messageContent.type === 'text' && typeof messageContent.text === 'string') { + console.log('[extractAssistantMessageText] Found text content') + return messageContent.text + } + + // Handle output content (from CLI/agent) + if (messageContent.type === 'output') { + console.log('[extractAssistantMessageText] Found output content') + const outputData = messageContent.data + console.log('[extractAssistantMessageText] outputData:', JSON.stringify(outputData).substring(0, 300)) + if (isObject(outputData)) { + console.log('[extractAssistantMessageText] outputData keys:', Object.keys(outputData)) + // Try to extract text from various output formats + // Format 1: { type: 'text', text: '...' } + if (outputData.type === 'text' && typeof outputData.text === 'string') { + console.log('[extractAssistantMessageText] Found format 1') + return outputData.text + } + // Format 2: { message: { content: [...] } } + const message = outputData.message + if (isObject(message) && Array.isArray(message.content)) { + console.log('[extractAssistantMessageText] Found format 2') + const texts: string[] = [] + for (const block of message.content) { + if (isObject(block) && block.type === 'text' && typeof block.text === 'string') { + texts.push(block.text) + } + } + if (texts.length > 0) { + return texts.join('\n') + } + } + // Format 3: Direct text in data + if (typeof outputData.text === 'string') { + console.log('[extractAssistantMessageText] Found format 3') + return outputData.text + } + // Format 4: Look for content array directly in outputData + if (Array.isArray(outputData.content)) { + console.log('[extractAssistantMessageText] Found format 4') + const texts: string[] = [] + for (const block of outputData.content) { + if (isObject(block) && block.type === 'text' && typeof block.text === 'string') { + texts.push(block.text) + } + } + if (texts.length > 0) { + return texts.join('\n') + } + } + } + } + + // Handle content array (Claude format) + if (Array.isArray(messageContent.content)) { + console.log('[extractAssistantMessageText] Found content array') + const texts: string[] = [] + for (const block of messageContent.content) { + if (isObject(block) && block.type === 'text' && typeof block.text === 'string') { + texts.push(block.text) + } + } + if (texts.length > 0) { + return texts.join('\n') + } + } + + console.log('[extractAssistantMessageText] Could not extract text') + return null +} diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index b4a3d16ee..f7f2e8c28 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -1,6 +1,6 @@ import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' import type { NotificationChannel, NotificationHubOptions } from './notificationTypes' -import { extractMessageEventType } from './eventParsing' +import { extractMessageEventType, extractAssistantMessageText } from './eventParsing' export class NotificationHub { private readonly channels: NotificationChannel[] @@ -9,6 +9,8 @@ export class NotificationHub { private readonly lastKnownRequests: Map> = new Map() private readonly notificationDebounce: Map = new Map() private readonly lastReadyNotificationAt: Map = new Map() + private readonly lastMessageNotificationAt: Map = new Map() + private readonly messageCooldownMs: number = 100 // Reduced cooldown for better responsiveness private unsubscribeSyncEvents: (() => void) | null = null constructor( @@ -36,6 +38,7 @@ export class NotificationHub { this.notificationDebounce.clear() this.lastKnownRequests.clear() this.lastReadyNotificationAt.clear() + this.lastMessageNotificationAt.clear() } private handleSyncEvent(event: SyncEvent): void { @@ -55,12 +58,26 @@ export class NotificationHub { } if (event.type === 'message-received' && event.sessionId) { + console.log(`[NotificationHub] message-received event, sessionId=${event.sessionId}`) + const eventType = extractMessageEventType(event) + console.log(`[NotificationHub] Event type: ${eventType}`) + if (eventType === 'ready') { this.sendReadyNotification(event.sessionId).catch((error) => { console.error('[NotificationHub] Failed to send ready notification:', error) }) } + + // Handle assistant messages + const assistantText = extractAssistantMessageText(event) + console.log(`[NotificationHub] Assistant text: ${assistantText ? assistantText.substring(0, 100) : 'null'}`) + + if (assistantText) { + this.sendMessageNotification(event.sessionId, assistantText).catch((error) => { + console.error('[NotificationHub] Failed to send message notification:', error) + }) + } } } @@ -72,6 +89,7 @@ export class NotificationHub { } this.lastKnownRequests.delete(sessionId) this.lastReadyNotificationAt.delete(sessionId) + this.lastMessageNotificationAt.delete(sessionId) } private getNotifiableSession(sessionId: string): Session | null { @@ -165,4 +183,37 @@ export class NotificationHub { } } } + + private async sendMessageNotification(sessionId: string, text: string): Promise { + console.log(`[NotificationHub] sendMessageNotification: sessionId=${sessionId}, text=${text.substring(0, 50)}`) + + const session = this.getNotifiableSession(sessionId) + if (!session) { + console.log(`[NotificationHub] No notifiable session found`) + return + } + + // Apply cooldown per session + const now = Date.now() + const last = this.lastMessageNotificationAt.get(sessionId) ?? 0 + if (now - last < this.messageCooldownMs) { + console.log(`[NotificationHub] Message cooldown active, skipping`) + return + } + this.lastMessageNotificationAt.set(sessionId, now) + + console.log(`[NotificationHub] Calling notifyMessage for ${this.channels.length} channels`) + await this.notifyMessage(session, text) + } + + private async notifyMessage(session: Session, text: string): Promise { + for (const channel of this.channels) { + if (!channel.sendMessage) continue + try { + await channel.sendMessage(session, text) + } catch (error) { + console.error('[NotificationHub] Failed to send message notification:', error) + } + } + } } diff --git a/hub/src/notifications/notificationTypes.ts b/hub/src/notifications/notificationTypes.ts index 3e3ba2895..af000dd09 100644 --- a/hub/src/notifications/notificationTypes.ts +++ b/hub/src/notifications/notificationTypes.ts @@ -3,6 +3,7 @@ import type { Session } from '../sync/syncEngine' export type NotificationChannel = { sendReady: (session: Session) => Promise sendPermissionRequest: (session: Session) => Promise + sendMessage?: (session: Session, text: string) => Promise } export type NotificationHubOptions = { diff --git a/hub/src/store/users.ts b/hub/src/store/users.ts index 15075748c..800567f12 100644 --- a/hub/src/store/users.ts +++ b/hub/src/store/users.ts @@ -52,12 +52,15 @@ export function addUser( namespace: string ): StoredUser { const now = Date.now() + // Use INSERT OR REPLACE to update existing user db.prepare(` - INSERT OR IGNORE INTO users ( + INSERT INTO users ( platform, platform_user_id, namespace, created_at ) VALUES ( @platform, @platform_user_id, @namespace, @created_at ) + ON CONFLICT(platform, platform_user_id) DO UPDATE SET + namespace = @namespace `).run({ platform, platform_user_id: platformUserId, diff --git a/hub/test-feishu.sh b/hub/test-feishu.sh new file mode 100644 index 000000000..08ec78c15 --- /dev/null +++ b/hub/test-feishu.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Feishu Integration Test Script +# Run this to test the Feishu integration + +echo "==========================================" +echo "HAPI Feishu Integration Test" +echo "==========================================" +echo "" + +# Check if we're in the right directory +if [ ! -f "package.json" ]; then + echo "❌ Error: Please run this script from the hub directory" + exit 1 +fi + +# Set environment variables for testing +export FEISHU_APP_ID="cli_a933a4feadb81cc9" +export FEISHU_APP_SECRET="e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT" +export FEISHU_VERIFICATION_TOKEN="4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0" +export FEISHU_ENABLED="true" +export FEISHU_NOTIFICATION="true" +export FEISHU_BASE_URL="https://open.feishu.cn" + +echo "✓ Environment variables set" +echo "" +echo "Configuration:" +echo " App ID: ${FEISHU_APP_ID:0:10}..." +echo " App Secret: ${FEISHU_APP_SECRET:0:5}..." +echo " Base URL: ${FEISHU_BASE_URL}" +echo "" + +# Test network connectivity +echo "Testing network connectivity to Feishu..." +if command -v curl &> /dev/null; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://open.feishu.cn 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ]; then + echo "✓ Can reach open.feishu.cn (HTTP $HTTP_CODE)" + else + echo "⚠ Cannot reach open.feishu.cn (HTTP $HTTP_CODE)" + echo " This might be a network/proxy issue" + fi +else + echo "⚠ curl not available, skipping connectivity test" +fi +echo "" + +# Start HAPI Hub +echo "Starting HAPI Hub with Feishu integration..." +echo "Press Ctrl+C to stop" +echo "" +echo "Expected output:" +echo " [Hub] Feishu: enabled (environment)" +echo " [Hub] Feishu notifications: enabled (environment)" +echo " [FeishuBot] Starting..." +echo " [FeishuWs] Token obtained, expires in XXXX seconds" +echo " [FeishuBot] WebSocket connected" +echo "" +echo "==========================================" +echo "" + +# Run the hub +if command -v bun &> /dev/null; then + bun run start +elif command -v node &> /dev/null; then + node --loader ts-node/esm src/index.ts +else + echo "❌ Error: Neither bun nor node found" + echo "Please install bun: https://bun.sh" + exit 1 +fi diff --git a/start-hub-feishu.bat b/start-hub-feishu.bat new file mode 100644 index 000000000..375eb197b --- /dev/null +++ b/start-hub-feishu.bat @@ -0,0 +1,37 @@ +@echo off +chcp 65001 >nul +echo ========================================== +echo HAPI Hub with Feishu Integration +echo ========================================== +echo. + +REM Set Feishu credentials +set FEISHU_APP_ID=cli_a933a4feadb81cc9 +set FEISHU_APP_SECRET=e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT +set FEISHU_VERIFICATION_TOKEN=4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0 +set FEISHU_ENABLED=true +set FEISHU_NOTIFICATION=true +set FEISHU_BASE_URL=https://open.feishu.cn + +echo Configuration: +echo App ID: %FEISHU_APP_ID:~0,10%... +echo Enabled: %FEISHU_ENABLED% +echo. + +REM Add bun to PATH +set PATH=%USERPROFILE%\.bun\bin;%PATH% + +REM Check bun +call bun --version >nul 2>&1 +if errorlevel 1 ( + echo Error: Bun not found + exit /b 1 +) + +echo Starting HAPI Hub... +echo Press Ctrl+C to stop +echo. + +REM Run hub +cd /d "%~dp0\hub" +call bun run start diff --git a/start-hub-feishu.ps1 b/start-hub-feishu.ps1 new file mode 100644 index 000000000..12c6bfabf --- /dev/null +++ b/start-hub-feishu.ps1 @@ -0,0 +1,44 @@ +# HAPI Hub with Feishu Integration +# Run this script to start HAPI Hub with Feishu bot enabled + +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "HAPI Hub with Feishu Integration" -ForegroundColor Cyan +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "" + +# Set Feishu credentials +$env:FEISHU_APP_ID = "cli_a933a4feadb81cc9" +$env:FEISHU_APP_SECRET = "e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT" +$env:FEISHU_VERIFICATION_TOKEN = "4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0" +$env:FEISHU_ENABLED = "true" +$env:FEISHU_NOTIFICATION = "true" +$env:FEISHU_BASE_URL = "https://open.feishu.cn" + +Write-Host "Configuration:" -ForegroundColor Green +Write-Host " App ID: $($env:FEISHU_APP_ID.Substring(0,10))..." -ForegroundColor Gray +Write-Host " Enabled: $($env:FEISHU_ENABLED)" -ForegroundColor Gray +Write-Host "" + +# Add bun to PATH +$bunPath = "$env:USERPROFILE\.bun\bin" +$env:PATH = "$bunPath;$env:PATH" + +# Check bun +try { + $bunVersion = bun --version 2>$null + Write-Host "Bun version: $bunVersion" -ForegroundColor Green +} catch { + Write-Host "Error: Bun not found at $bunPath" -ForegroundColor Red + Write-Host "Please install Bun first: https://bun.sh" -ForegroundColor Red + exit 1 +} + +Write-Host "" +Write-Host "Starting HAPI Hub..." -ForegroundColor Cyan +Write-Host "Press Ctrl+C to stop" -ForegroundColor Yellow +Write-Host "" + +# Change to hub directory and run +$hubPath = Join-Path $PSScriptRoot "hub" +Set-Location $hubPath +bun run start From 5da8c2c2f16b7f8103532411026b33af348c50ad Mon Sep 17 00:00:00 2001 From: lingxiyang <93123919+lingxiyang@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:47:06 +0800 Subject: [PATCH 2/2] refactor(feishu): improve type safety and clean up code - Add explicit type definitions for Feishu events (ImMessageReceiveV1Data, CardActionEventData) - Fix type casting issues in bot.ts for better TypeScript compatibility - Remove debug console.log statements from eventParsing.ts - Add 'feishu' to sentFrom type in messageService.ts and syncEngine.ts - Remove obsolete Windows startup scripts (start-hub-feishu.bat/.ps1) - Update FEISHU_SETUP.md documentation Co-Authored-By: Claude Opus 4.6 --- FEISHU_SETUP.md | 24 ++--- hub/src/feishu/bot.ts | 130 ++++++++++++++++++-------- hub/src/notifications/eventParsing.ts | 15 --- hub/src/sync/messageService.ts | 2 +- hub/src/sync/syncEngine.ts | 2 +- start-hub-feishu.bat | 37 -------- start-hub-feishu.ps1 | 44 --------- 7 files changed, 102 insertions(+), 152 deletions(-) delete mode 100644 start-hub-feishu.bat delete mode 100644 start-hub-feishu.ps1 diff --git a/FEISHU_SETUP.md b/FEISHU_SETUP.md index ad76b0c21..b1c4e4d5a 100644 --- a/FEISHU_SETUP.md +++ b/FEISHU_SETUP.md @@ -1,11 +1,5 @@ # 飞书配置指南 -## ⚠️ 安全提醒 - -**以下凭据为敏感信息,请妥善保管:** -- App ID: `cli_a933a4feadb81cc9` -- App Secret: `e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT` -- Verification Token: `4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0` **请勿:** 1. 将这些凭据提交到 Git 仓库 @@ -21,9 +15,9 @@ ```bash cd hub # Windows PowerShell -$env:FEISHU_APP_ID="cli_a933a4feadb81cc9" -$env:FEISHU_APP_SECRET="e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT" -$env:FEISHU_VERIFICATION_TOKEN="4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0" +$env:FEISHU_APP_ID="cli_xx" +$env:FEISHU_APP_SECRET="xxx" +$env:FEISHU_VERIFICATION_TOKEN="xxx" $env:FEISHU_ENABLED="true" $env:FEISHU_NOTIFICATION="true" ``` @@ -31,9 +25,9 @@ $env:FEISHU_NOTIFICATION="true" 或在 `~/.bashrc` / `~/.zshrc` 中添加: ```bash -export FEISHU_APP_ID="cli_a933a4feadb81cc9" -export FEISHU_APP_SECRET="e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT" -export FEISHU_VERIFICATION_TOKEN="4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0" +export FEISHU_APP_ID="cli_xxx" +export FEISHU_APP_SECRET="xxx" +export FEISHU_VERIFICATION_TOKEN="xxx" export FEISHU_BASE_URL="https://open.feishu.cn" # 国内版 # export FEISHU_BASE_URL="https://open.larksuite.com" # 国际版 export FEISHU_ENABLED="true" @@ -46,9 +40,9 @@ export FEISHU_NOTIFICATION="true" ```json { - "feishuAppId": "cli_a933a4feadb81cc9", - "feishuAppSecret": "e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT", - "feishuVerificationToken": "4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0", + "feishuAppId": "cli_xxx", + "feishuAppSecret": "xx", + "feishuVerificationToken": "xxx", "feishuBaseUrl": "https://open.feishu.cn", "feishuEnabled": true, "feishuNotification": true diff --git a/hub/src/feishu/bot.ts b/hub/src/feishu/bot.ts index 96256a6e2..f50eba334 100644 --- a/hub/src/feishu/bot.ts +++ b/hub/src/feishu/bot.ts @@ -11,6 +11,74 @@ import type { SyncEngine, Session } from '../sync/syncEngine' import type { Store } from '../store' import type { NotificationChannel } from '../notifications/notificationTypes' +// Feishu event types +type ImMessageReceiveV1Data = { + event_id?: string + token?: string + create_time?: string + event_type?: string + tenant_key?: string + ts?: string + uuid?: string + type?: string + app_id?: string + sender: { + sender_id?: { + union_id?: string + user_id?: string + open_id?: string + } + sender_type: string + tenant_key?: string + } + message: { + message_id: string + root_id?: string + parent_id?: string + create_time: string + update_time?: string + chat_id: string + thread_id?: string + chat_type: string + message_type: string + content: string + mentions?: Array<{ + key: string + id: { + union_id?: string + user_id?: string + open_id?: string + } + name: string + tenant_key?: string + }> + user_agent?: string + sender?: { + sender_id?: { + union_id?: string + user_id?: string + open_id?: string + } + sender_type?: string + tenant_key?: string + } + } +} + +type CardActionEventData = { + open_id: string + user_id?: string + tenant_key: string + open_message_id: string + token: string + action: { + value: Record + tag: string + option?: string + timezone?: string + } +} + export interface FeishuBotConfig { syncEngine: SyncEngine appId: string @@ -40,10 +108,11 @@ export class FeishuBot implements NotificationChannel { this.syncEngine = config.syncEngine // Initialize API client + const domain = config.baseUrl ? config.baseUrl as unknown as lark.Domain : lark.Domain.Feishu this.apiClient = new lark.Client({ appId: config.appId, appSecret: config.appSecret, - domain: config.baseUrl as lark.Domain ?? lark.Domain.Feishu, + domain, appType: lark.AppType.SelfBuild, loggerLevel: lark.LoggerLevel.info, }) @@ -68,10 +137,11 @@ export class FeishuBot implements NotificationChannel { this.isRunning = true // Create WebSocket client + const wsDomain = this.config.baseUrl ? this.config.baseUrl as unknown as lark.Domain : lark.Domain.Feishu this.wsClient = new lark.WSClient({ appId: this.config.appId, appSecret: this.config.appSecret, - domain: this.config.baseUrl as lark.Domain ?? lark.Domain.Feishu, + domain: wsDomain, loggerLevel: lark.LoggerLevel.info, }) @@ -83,17 +153,19 @@ export class FeishuBot implements NotificationChannel { // Register event handlers eventDispatcher.register({ - 'im.message.receive_v1': async (data) => { - await this.handleMessageEvent(data) + 'im.message.receive_v1': async (data: unknown) => { + await this.handleMessageEvent(data as ImMessageReceiveV1Data) }, - 'card.action.trigger': async (data) => { - return await this.handleCardActionEvent(data) + 'card.action.trigger': async (data: unknown) => { + return await this.handleCardActionEvent(data as CardActionEventData) }, - 'im.bot.added_v1': async (data) => { - console.log('[FeishuBot] Bot added to chat:', data.event?.chat_id) + 'im.bot.added_v1': async (data: unknown) => { + const eventData = data as { event?: { chat_id?: string } } + console.log('[FeishuBot] Bot added to chat:', eventData.event?.chat_id) }, - 'im.bot.deleted_v1': async (data) => { - console.log('[FeishuBot] Bot removed from chat:', data.event?.chat_id) + 'im.bot.deleted_v1': async (data: unknown) => { + const eventData = data as { event?: { chat_id?: string } } + console.log('[FeishuBot] Bot removed from chat:', eventData.event?.chat_id) }, }) @@ -123,7 +195,7 @@ export class FeishuBot implements NotificationChannel { /** * Handle im.message.receive_v1 event */ - private async handleMessageEvent(data: lark.ImMessageReceiveV1): Promise { + private async handleMessageEvent(data: ImMessageReceiveV1Data): Promise { console.log('[FeishuBot] Received im.message.receive_v1 event:', JSON.stringify(data, null, 2)) const message = data.message @@ -201,17 +273,15 @@ export class FeishuBot implements NotificationChannel { /** * Handle card.action.trigger event */ - private async handleCardActionEvent(data: lark.InteractiveCardActionEvent): Promise<{ toast?: { type: 'success' | 'error'; content: string }; card?: unknown } | void> { + private async handleCardActionEvent(data: CardActionEventData): Promise<{ toast?: { type: 'success' | 'error'; content: string }; card?: unknown } | void> { console.log('[FeishuBot] Received card.action.trigger event:', JSON.stringify(data, null, 2)) - const { action } = data + const { action, open_id } = data if (!action) { console.log('[FeishuBot] No action in card event') return { toast: { type: 'error', content: 'No action found' } } } - const { open_id } = action - // Card interaction is not supported, redirect to text commands return { toast: { @@ -250,8 +320,8 @@ export class FeishuBot implements NotificationChannel { return } - if (!value.requestId) { - await this.replyToMessage(messageId, 'No request ID found') + if (!value.requestId || !value.sessionId) { + await this.replyToMessage(messageId, 'No request ID or session ID found') return } @@ -485,7 +555,7 @@ export class FeishuBot implements NotificationChannel { // Send message via syncEngine to CLI await this.syncEngine.sendMessage(sessionId, { text, - sentFrom: 'telegram-bot' // Use telegram-bot type for external messages + sentFrom: 'feishu' // Use feishu type for external messages }) console.log(`[FeishuBot] Message sent to session ${sessionId}: ${text}`) await this.replyToMessage(replyToMessageId, `✅ Message sent to session`) @@ -664,26 +734,6 @@ export class FeishuBot implements NotificationChannel { } } - /** - * Update card message using SDK - */ - private async updateCardMessage(messageId: string, card: unknown): Promise { - if (!this.apiClient) return - - try { - await this.apiClient.interactive.cardActions.update({ - path: { - token: messageId, - }, - data: { - card: card as Record, - }, - }) - } catch (error) { - console.error('[FeishuBot] Failed to update card message:', error) - } - } - // // NotificationChannel implementation // @@ -811,10 +861,12 @@ export class FeishuBot implements NotificationChannel { const maxLength = 2000 const displayText = text.length > maxLength ? text.slice(0, maxLength) + '...' : text + const agentName = session.metadata?.flavor ? session.metadata.flavor.charAt(0).toUpperCase() + session.metadata.flavor.slice(1) : 'Agent' + for (const openId of openIds) { try { console.log(`[FeishuBot] Sending message to openId=${openId}`) - await this.sendTextMessage(openId, `🤖 **Claude**\n\n${displayText}`) + await this.sendTextMessage(openId, `🤖 **${agentName}**\n\n${displayText}`) } catch (error) { console.error(`[FeishuBot] Failed to send message to ${openId}:`, error) } diff --git a/hub/src/notifications/eventParsing.ts b/hub/src/notifications/eventParsing.ts index e7bc761ee..08a43220f 100644 --- a/hub/src/notifications/eventParsing.ts +++ b/hub/src/notifications/eventParsing.ts @@ -48,52 +48,41 @@ export function extractAssistantMessageText(event: SyncEvent): string | null { } const content = event.message?.content - console.log('[extractAssistantMessageText] Content:', JSON.stringify(content).substring(0, 200)) if (!isObject(content)) { - console.log('[extractAssistantMessageText] Content is not an object') return null } // Check if this is an assistant message const role = content.role - console.log(`[extractAssistantMessageText] Role: ${role}`) if (role !== 'assistant' && role !== 'agent') { - console.log(`[extractAssistantMessageText] Not an assistant/agent message`) return null } // Extract text from content const messageContent = content.content if (!isObject(messageContent)) { - console.log('[extractAssistantMessageText] messageContent is not an object') return null } // Handle text content (from external sources like telegram-bot) if (messageContent.type === 'text' && typeof messageContent.text === 'string') { - console.log('[extractAssistantMessageText] Found text content') return messageContent.text } // Handle output content (from CLI/agent) if (messageContent.type === 'output') { - console.log('[extractAssistantMessageText] Found output content') const outputData = messageContent.data - console.log('[extractAssistantMessageText] outputData:', JSON.stringify(outputData).substring(0, 300)) if (isObject(outputData)) { - console.log('[extractAssistantMessageText] outputData keys:', Object.keys(outputData)) // Try to extract text from various output formats // Format 1: { type: 'text', text: '...' } if (outputData.type === 'text' && typeof outputData.text === 'string') { - console.log('[extractAssistantMessageText] Found format 1') return outputData.text } // Format 2: { message: { content: [...] } } const message = outputData.message if (isObject(message) && Array.isArray(message.content)) { - console.log('[extractAssistantMessageText] Found format 2') const texts: string[] = [] for (const block of message.content) { if (isObject(block) && block.type === 'text' && typeof block.text === 'string') { @@ -106,12 +95,10 @@ export function extractAssistantMessageText(event: SyncEvent): string | null { } // Format 3: Direct text in data if (typeof outputData.text === 'string') { - console.log('[extractAssistantMessageText] Found format 3') return outputData.text } // Format 4: Look for content array directly in outputData if (Array.isArray(outputData.content)) { - console.log('[extractAssistantMessageText] Found format 4') const texts: string[] = [] for (const block of outputData.content) { if (isObject(block) && block.type === 'text' && typeof block.text === 'string') { @@ -127,7 +114,6 @@ export function extractAssistantMessageText(event: SyncEvent): string | null { // Handle content array (Claude format) if (Array.isArray(messageContent.content)) { - console.log('[extractAssistantMessageText] Found content array') const texts: string[] = [] for (const block of messageContent.content) { if (isObject(block) && block.type === 'text' && typeof block.text === 'string') { @@ -139,6 +125,5 @@ export function extractAssistantMessageText(event: SyncEvent): string | null { } } - console.log('[extractAssistantMessageText] Could not extract text') return null } diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index c5bfd3163..3678d8a30 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -69,7 +69,7 @@ export class MessageService { text: string localId?: string | null attachments?: AttachmentMetadata[] - sentFrom?: 'telegram-bot' | 'webapp' + sentFrom?: 'telegram-bot' | 'webapp' | 'feishu' } ): Promise { const sentFrom = payload.sentFrom ?? 'webapp' diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6e159d6d1..904a1b53b 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -232,7 +232,7 @@ export class SyncEngine { path: string previewUrl?: string }> - sentFrom?: 'telegram-bot' | 'webapp' + sentFrom?: 'telegram-bot' | 'webapp' | 'feishu' } ): Promise { await this.messageService.sendMessage(sessionId, payload) diff --git a/start-hub-feishu.bat b/start-hub-feishu.bat deleted file mode 100644 index 375eb197b..000000000 --- a/start-hub-feishu.bat +++ /dev/null @@ -1,37 +0,0 @@ -@echo off -chcp 65001 >nul -echo ========================================== -echo HAPI Hub with Feishu Integration -echo ========================================== -echo. - -REM Set Feishu credentials -set FEISHU_APP_ID=cli_a933a4feadb81cc9 -set FEISHU_APP_SECRET=e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT -set FEISHU_VERIFICATION_TOKEN=4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0 -set FEISHU_ENABLED=true -set FEISHU_NOTIFICATION=true -set FEISHU_BASE_URL=https://open.feishu.cn - -echo Configuration: -echo App ID: %FEISHU_APP_ID:~0,10%... -echo Enabled: %FEISHU_ENABLED% -echo. - -REM Add bun to PATH -set PATH=%USERPROFILE%\.bun\bin;%PATH% - -REM Check bun -call bun --version >nul 2>&1 -if errorlevel 1 ( - echo Error: Bun not found - exit /b 1 -) - -echo Starting HAPI Hub... -echo Press Ctrl+C to stop -echo. - -REM Run hub -cd /d "%~dp0\hub" -call bun run start diff --git a/start-hub-feishu.ps1 b/start-hub-feishu.ps1 deleted file mode 100644 index 12c6bfabf..000000000 --- a/start-hub-feishu.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -# HAPI Hub with Feishu Integration -# Run this script to start HAPI Hub with Feishu bot enabled - -Write-Host "==========================================" -ForegroundColor Cyan -Write-Host "HAPI Hub with Feishu Integration" -ForegroundColor Cyan -Write-Host "==========================================" -ForegroundColor Cyan -Write-Host "" - -# Set Feishu credentials -$env:FEISHU_APP_ID = "cli_a933a4feadb81cc9" -$env:FEISHU_APP_SECRET = "e7ScIG1itQdnQPPT4KFsZfsWxrKSXhAT" -$env:FEISHU_VERIFICATION_TOKEN = "4bcHA2FSS93WLDsKrOmKDgZ3RrV26oS0" -$env:FEISHU_ENABLED = "true" -$env:FEISHU_NOTIFICATION = "true" -$env:FEISHU_BASE_URL = "https://open.feishu.cn" - -Write-Host "Configuration:" -ForegroundColor Green -Write-Host " App ID: $($env:FEISHU_APP_ID.Substring(0,10))..." -ForegroundColor Gray -Write-Host " Enabled: $($env:FEISHU_ENABLED)" -ForegroundColor Gray -Write-Host "" - -# Add bun to PATH -$bunPath = "$env:USERPROFILE\.bun\bin" -$env:PATH = "$bunPath;$env:PATH" - -# Check bun -try { - $bunVersion = bun --version 2>$null - Write-Host "Bun version: $bunVersion" -ForegroundColor Green -} catch { - Write-Host "Error: Bun not found at $bunPath" -ForegroundColor Red - Write-Host "Please install Bun first: https://bun.sh" -ForegroundColor Red - exit 1 -} - -Write-Host "" -Write-Host "Starting HAPI Hub..." -ForegroundColor Cyan -Write-Host "Press Ctrl+C to stop" -ForegroundColor Yellow -Write-Host "" - -# Change to hub directory and run -$hubPath = Join-Path $PSScriptRoot "hub" -Set-Location $hubPath -bun run start