diff --git a/bun.lock b/bun.lock index d9f368720d..1c83ebc0c8 100644 --- a/bun.lock +++ b/bun.lock @@ -346,6 +346,7 @@ "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", + "@larksuiteoapi/node-sdk": "1.60.0", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", @@ -604,6 +605,7 @@ "trustedDependencies": [ "electron", "esbuild", + "protobufjs", "web-tree-sitter", "tree-sitter-bash", ], @@ -1363,6 +1365,8 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.60.0", "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", { "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-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g=="], + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "https://registry.npmmirror.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], "@lezer/common": ["@lezer/common@1.5.1", "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], @@ -1703,6 +1707,26 @@ "@protobuf-ts/runtime-rpc": ["@protobuf-ts/runtime-rpc@2.11.1", "https://registry.npmmirror.com/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1" } }, "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.4.tgz", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/colors": ["@radix-ui/colors@1.0.1", "https://registry.npmmirror.com/@radix-ui/colors/-/colors-1.0.1.tgz", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="], @@ -3545,6 +3569,8 @@ "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + "lodash.identity": ["lodash.identity@3.0.0", "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="], + "lodash.includes": ["lodash.includes@4.3.0", "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isarguments": ["lodash.isarguments@3.1.0", "https://registry.npmmirror.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], @@ -3561,8 +3587,12 @@ "lodash.isstring": ["lodash.isstring@4.0.1", "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.once": ["lodash.once@4.1.1", "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "lodash.pickby": ["lodash.pickby@4.6.0", "https://registry.npmmirror.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="], + "log-symbols": ["log-symbols@4.1.0", "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], "loglevelnext": ["loglevelnext@6.0.0", "https://registry.npmmirror.com/loglevelnext/-/loglevelnext-6.0.0.tgz", {}, "sha512-FDl1AI2sJGjHHG3XKJd6sG3/6ncgiGCQ0YkW46nxe7SfqQq6hujd9CvFXIXtkGBUN83KPZ2KSOJK8q5P0bSSRQ=="], @@ -4093,6 +4123,8 @@ "proto-list": ["proto-list@1.2.4", "https://registry.npmmirror.com/proto-list/-/proto-list-1.2.4.tgz", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], + "protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "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", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "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", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -5247,6 +5279,8 @@ "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-9.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + "@larksuiteoapi/node-sdk/ws": ["ws@8.19.0", "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@mdx-js/mdx/source-map": ["source-map@0.7.6", "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], diff --git a/docs/feishu-architecture.md b/docs/feishu-architecture.md new file mode 100644 index 0000000000..704331781d --- /dev/null +++ b/docs/feishu-architecture.md @@ -0,0 +1,317 @@ +# 飞书连接架构文档 + +## 概述 + +Aether 通过飞书官方 SDK 的 **WebSocket 长连接模式**,在本地与飞书服务器建立实时通信。用户在飞书中给机器人发消息,Aether 本地接收并调用 AI 处理后,通过飞书 API 回复。 + +核心特点: +- **无需公网地址**:本地主动连接飞书服务器,不需要 webhook 或云端部署 +- **内置实现**:TypeScript 原生集成,非子进程方案 +- **体验一致**:与微信连接的使用流程完全对齐 + +## 整体架构 + +``` +飞书用户 + │ + ▼ +飞书服务器 + │ WebSocket 推送 + ▼ +┌─────────────────────────────────────────────────┐ +│ Aether 本地进程 │ +│ │ +│ ┌──────────────┐ Bus 事件 ┌────────────┐ │ +│ │ FeishuManager│ ──────────────▶│ SSE 事件流 │ │ +│ │ (manager.ts)│ │(routes/ │ │ +│ │ │◀──── HTTP ────│ feishu.ts) │ │ +│ └──────┬───────┘ └──────┬──────┘ │ +│ │ │ │ +│ │ Instance.bind() │ SSE │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌────────────┐ │ +│ │ Session API │ │ 前端 UI │ │ +│ │ Session.create│ │(dialog- │ │ +│ │ Prompt.prompt│ │ feishu.tsx) │ │ +│ └──────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────┘ + │ + │ larkClient.im.message.reply() + ▼ +飞书用户看到回复 +``` + +### 图中名词解释 + +如果你是第一次看这张图,下面逐个解释每个名词: + +**飞书服务器** — 飞书的云端服务,负责在用户之间传递消息,类似微信的后台。 + +**WebSocket 推送** — 一种网络连接方式。普通 HTTP 像「发短信」,你问一次服务器答一次。WebSocket 像「打电话」,连接建立后一直保持通着,服务器有新消息随时推过来,不需要你反复去问。这就是为什么 Aether 不需要公网地址——是你的电脑主动打电话给飞书服务器,而不是反过来。 + +**Aether 本地进程** — 运行在你电脑上的 Aether 程序,就是你双击打开的那个应用。整个大框就是它。 + +**FeishuManager (manager.ts)** — Aether 里专门负责和飞书打交道的模块。它做三件事:收飞书消息、交给 AI 处理、把回复发回飞书。同时它还记住「飞书的哪个聊天 = Aether 的哪个 AI 会话」。 + +**Bus 事件** — 程序内部的广播机制。像对讲机:FeishuManager 喊一句「我已连上飞书了」,所有在听的模块都能收到。这里主要用来把连接状态(连接中/已连接/出错)传给前端。 + +**SSE 事件流 (routes/feishu.ts)** — Server-Sent Events,一种服务器向浏览器单向推送数据的技术。Aether 后端通过 SSE 持续向前端推送状态更新。前端收到后就能实时刷新界面,显示「正在连接...」或「已连接」。 + +**HTTP** — 前端和后端之间最基本的请求/响应通信。你在界面上点击「连接飞书」按钮 → 前端发一个 HTTP 请求到后端 → 后端执行连接操作并返回结果。图中 `routes/feishu.ts` 定义了后端能响应哪些请求(开始连接、断开、查状态等)。 + +**Instance.bind()** — 这是一个技术细节。飞书的消息回调运行在「另一个执行空间」里,访问不到 Aether 的项目信息(比如当前工作目录)。`Instance.bind()` 的作用是在连接飞书时把项目信息「打包」起来,等消息回调触发时再「解包」恢复,这样回调里就能正常创建 AI 会话了。 + +**Session API** — Aether 的 AI 会话接口。`Session.create` = 新建一个 AI 对话;`Prompt.prompt` = 把用户说的话发给 AI 模型,等 AI 想好了返回回答。 + +**前端 UI (dialog-feishu.tsx)** — 你在 Aether 界面里看到的「飞书连接」弹窗。用来输入 App ID/Secret、显示连接状态、提供断开/重连按钮。 + +**larkClient.im.message.reply()** — 飞书 SDK 提供的「回复消息」方法。AI 生成回答后,通过这个方法把文字发回飞书,用户就能在飞书聊天里看到回复了。 + +### 一句话总结 + +飞书用户发消息 → 飞书服务器通过 WebSocket 推给你电脑上的 Aether → FeishuManager 收到后交给 AI 处理 → AI 回复后通过飞书 SDK 发回去 → 飞书用户看到回复。与此同时,连接状态通过 Bus → SSE 实时推给前端界面显示。 + +## 文件结构 + +``` +packages/opencode/src/ + feishu/ + manager.ts # 核心:连接管理、消息处理、会话映射 + +packages/opencode/src/server/routes/ + feishu.ts # HTTP API 路由(start/stop/status/events) + +packages/app/src/ + context/feishu.ts # 前端全局状态信号 + components/ + dialog-feishu.tsx # 连接对话框 UI + prompt-input.tsx # 工具栏飞书按钮(状态指示) + +packages/ui/src/components/ + icon.tsx # feishu 图标定义 +``` + +## 核心模块详解 + +### 1. FeishuManager (`packages/opencode/src/feishu/manager.ts`) + +单例模式的连接管理器,负责全部飞书交互逻辑。 + +#### 状态机 + +``` +idle ──▶ starting ──▶ connected + ▲ │ │ + │ ▼ │ + └───── error ◀───────────┘ +``` + +| 状态 | 含义 | +|------|------| +| `idle` | 未连接,等待用户操作 | +| `starting` | 正在建立 WebSocket 连接 | +| `connected` | 连接成功,正常接收消息 | +| `error` | 连接失败或运行时错误 | + +#### 关键方法 + +| 方法 | 职责 | +|------|------| +| `start(config?)` | 入口。加载或接收配置,触发连接 | +| `_doStart(config)` | 实际连接逻辑:创建 SDK 客户端、注册事件、启动 WebSocket | +| `handleMessage(data)` | 接收飞书消息 → 过滤 @mention → 映射会话 → 调用 AI → 回复 | +| `handleCommand(text)` | 处理 `/new`、`/help` 等斜杠命令 | +| `replyText(messageId, text)` | 通过飞书 REST API(原生 fetch)回复消息 | +| `stop()` | 断开 WebSocket,清理客户端 | +| `clearSession()` | 删除本地配置和会话映射文件 | + +#### AsyncLocalStorage 上下文绑定 + +这是架构中最关键的技术细节。 + +`Session.create()` 和 `SessionPrompt.prompt()` 依赖 `Instance` AsyncLocalStorage 上下文(提供 `directory`、`worktree` 等项目信息)。HTTP 路由通过中间件自动注入此上下文,但飞书 SDK 的 WebSocket 事件回调运行在独立的异步上下文中,没有 Instance 信息。 + +解决方案:在 `_doStart()` 中使用 `Instance.bind()` 捕获当前上下文: + +```typescript +// _doStart 由 HTTP 请求触发,此时 Instance 上下文可用 +private async _doStart(config: FeishuConfig): Promise { + // 捕获 Instance 上下文,绑定到事件回调 + const boundHandleMessage = Instance.bind((data: any) => { + void this.handleMessage(data) // 不 await,立即返回 + }) + + const eventDispatcher = new lark.EventDispatcher({}) + eventDispatcher.register({ + "im.message.receive_v1": boundHandleMessage, // 事件触发时自动恢复上下文 + }) + // ... +} +``` + +调用链:`HTTP POST /feishu/start` → 中间件注入 Instance 上下文 → `FeishuManager.start()` → `void _doStart()` 同步启动 → `Instance.bind()` 捕获上下文 → 后续事件回调中恢复。 + +> **为什么用 `void` 而不是 `await`?** +> 飞书 SDK 的事件回调要求快速返回。如果回调里 `await handleMessage()`(等 AI 生成回复,可能要几秒到几十秒),飞书服务器会认为投递失败并重发同一条消息,导致用户收到重复回复。改为 `void` 后回调立即返回,`handleMessage` 在后台异步执行。 + +#### 会话映射 + +飞书聊天到 Aether 会话的映射规则: + +``` +映射 key = `${chatId}:${rootId}` +``` + +- `chatId`:飞书聊天 ID +- `rootId`:消息线程根 ID(`root_id` 或 `parent_id`,回退到 `message_id`) + +同一线程内的消息共享同一个 Aether 会话。使用 `/new` 命令清除当前聊天的所有映射。 + +首次发消息(无映射)时,优先复用 Aether 中最近的会话,无已有会话时才新建。 + +映射持久化到本地文件 `sessions.json`。 + +#### 数据持久化 + +| 文件 | 内容 | +|------|------| +| `config.json` | App ID 和 App Secret | +| `sessions.json` | 飞书聊天 → Aether 会话 ID 映射 | + +存储路径按平台: +- Windows: `%APPDATA%\opencode\feishu\` +- macOS: `~/Library/Application Support/opencode/feishu/` +- Linux: `~/.local/share/opencode/feishu/` + +### 2. HTTP 路由 (`packages/opencode/src/server/routes/feishu.ts`) + +通过 Hono 框架注册在 `/feishu` 路径下。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/feishu/start` | 启动连接。body 可选传 `appId`/`appSecret`,否则用已保存配置 | +| POST | `/feishu/stop` | 断开连接 | +| GET | `/feishu/status` | 返回 `{ status, appId, hasConfig, error }` | +| GET | `/feishu/events` | SSE 事件流,推送状态变更和心跳 | +| DELETE | `/feishu/session` | 清除本地配置和会话数据 | + +#### SSE 事件流 + +`/feishu/events` 端点通过 Server-Sent Events 向前端实时推送状态: + +``` +初始连接 → 推送当前 status + → 若已 connected,额外推送 feishu.connected 事件 +运行中 → 每 10 秒推送 heartbeat + → 订阅 Bus 上所有 feishu.* 事件并转发 +断开 → 清理订阅和定时器 +``` + +事件格式: +```json +data: {"type": "feishu.connected", "properties": {"appId": "cli_xxx"}} +data: {"type": "feishu.status", "properties": {"status": "starting", "message": "正在连接飞书..."}} +data: {"type": "feishu.error", "properties": {"code": "start_failed", "message": "..."}} +``` + +### 3. 前端 UI (`packages/app/src/components/dialog-feishu.tsx`) + +SolidJS 对话框组件,提供完整的连接管理界面。 + +#### UI 状态 + +| 状态 | 显示内容 | +|------|---------| +| `idle` | 飞书图标 + "连接飞书"按钮(有配置时)或"配置飞书应用"按钮 | +| `config` | App ID / App Secret 输入表单 | +| `loading` | 旋转动画 + 状态文字 | +| `connected` | 绿色勾 + 已连接信息 + "断开连接"/"切换应用"按钮 | +| `error` | 红色警告 + 错误信息 + "重试"按钮 | + +#### 事件处理时序 + +``` +用户点击"连接飞书" + │ + ├─ 1. connectSSE() 先建立 SSE 连接,避免遗漏事件 + │ + └─ 2. fetch POST /start 触发后端连接 + │ + └─ SSE 接收事件 ──▶ 更新 UI 状态 +``` + +关键设计:SSE 在 start 请求之前建立,因为 `_doStart` 是 `void` 调用(异步不等待),可能在 start 返回前就完成连接。 + +### 4. 全局状态 (`packages/app/src/context/feishu.ts`) + +```typescript +export type FeishuStatus = "idle" | "loading" | "connected" | "error" +export const [feishuStatus, setFeishuStatus] = createSignal("idle") +``` + +在 `prompt-input.tsx` 中用于工具栏按钮的状态指示颜色: +- 蓝色 = connected +- 黄色闪烁 = loading +- 红色 = error +- 灰色 = idle + +## 消息处理流程 + +``` +1. 飞书用户发送文本消息 +2. 飞书服务器通过 WebSocket 推送 im.message.receive_v1 事件 +3. 事件回调通过 Instance.bind 恢复上下文,用 void 立即返回(不阻塞 SDK) +4. handleMessage 在后台异步执行: + a. 非文本消息 → 回复"暂时只支持文本消息" + b. 过滤 @mention 占位符(群聊中的 `@_user_1 ` 前缀) + c. 斜杠命令 → handleCommand 处理 + d. 普通文本 → 继续 +5. 根据 chatId + rootId 查找已有 Aether 会话 + a. 有映射 → 复用已映射的会话 + b. 无映射 → 复用最近的会话;若无任何会话则新建 +6. SessionPrompt.prompt() 将文本发送给 AI +7. 提取 AI 回复的文本部分 +8. larkClient.im.message.reply() 回复到飞书 +9. 如果任何步骤报错 → catch 中通过 replyText 将错误信息发回飞书,用户会收到"处理消息时出错: xxx" +``` + +## 依赖 + +| 包 | 版本 | 用途 | +|----|------|------| +| `@larksuiteoapi/node-sdk` | 1.60.0 | 飞书官方 SDK:WSClient、EventDispatcher、Client | + +SDK 使用方式: +- `lark.WSClient` — WebSocket 长连接客户端 +- `lark.EventDispatcher` — 事件注册和分发 +- `lark.Client` — REST API 客户端(发送回复消息) +- `lark.LoggerLevel.debug` — 调试日志级别 + +## 与微信连接的架构对比 + +| 维度 | 微信 | 飞书 | +|------|------|------| +| 连接方式 | Python 子进程 + 客户端长连接 | TypeScript 内置 + WebSocket 长连接 | +| SDK | Python wechatferry | Node.js @larksuiteoapi/node-sdk | +| 认证 | 扫二维码 | App ID + App Secret | +| 消息桥接 | 子进程 stdout/HTTP 通信 | 进程内直接调用 Session API | +| 上下文处理 | HTTP API 自带中间件上下文 | Instance.bind() 手动绑定上下文 | +| 需要公网 | 否 | 否 | +| 首次配置 | 扫码 | 飞书开放平台创建应用 | + +## 已知限制 + +1. **仅支持文本消息**:图片、文件等消息类型暂不处理 +2. **无自动重连**:WebSocket 断开后需手动重新连接 +3. **单实例**:FeishuManager 是全局单例,不支持同时连接多个飞书应用 +4. **群聊限制**:当前设计面向私聊场景,群聊中 @机器人 需要额外的消息过滤逻辑 + +## 变更记录 + +| 日期 | 修改内容 | 原因 | +|------|---------|------| +| 2026-04-06 11:25 | 事件回调从 `await` 改为 `void`(非阻塞) | 飞书服务器在回调未及时返回时会重发消息,导致用户收到重复回复 | +| 2026-04-06 11:39 | `handleMessage` 的 catch 中增加 `replyText` 错误反馈 | 报错时飞书用户无任何提示,现在会收到错误信息 | +| 2026-04-06 16:03 | 首次发消息优先复用最近会话,而非总是新建 | 飞书每条消息都新建会话,web 端体验混乱 | +| 2026-04-06 16:15 | 每条 AI 回复顶部追加项目和会话标题(`📁 项目名\n💬 会话名\n───`) | 用户无法感知当前处于哪个项目/会话 | +| 2026-04-06 16:25 | `/new` 改为立即创建新会话(`Session.create`),不再等到下一条消息才新建 | web 端侧边栏需实时出现新会话,旧实现只清除映射延迟到下条消息才生效 | diff --git a/docs/feishu-setup-guide.md b/docs/feishu-setup-guide.md new file mode 100644 index 0000000000..798f7db533 --- /dev/null +++ b/docs/feishu-setup-guide.md @@ -0,0 +1,186 @@ +# 飞书接入 Aether 使用说明 + +## 功能概述 + +Aether 支持通过飞书进行对话,体验与微信连接一致: + +1. 打开 Aether +2. 点击"飞书连接" +3. 输入应用凭证 +4. 连接成功后,在飞书中直接与 Aether AI 对话 + +## 技术原理 + +使用飞书 SDK 的 **WebSocket 长连接模式**,本地 Aether 主动连接飞书服务器。 + +- 不需要公网地址 +- 不需要部署 webhook 服务 +- 不需要云端桥接 +- 本地解压即可使用 + +这与微信的客户端连接模式本质相同。 + +## 飞书开放平台配置 + +使用前需要在飞书开放平台创建应用。 + +### 第一步:创建应用 + +1. 打开 [飞书开放平台](https://open.feishu.cn/app) +2. 点击「创建企业自建应用」 +3. 填写应用名称(如 "Aether AI")和描述 +4. 创建完成后,在「凭证与基础信息」页面获取: + - **App ID**(格式:`cli_xxxxxxxxxxxxxxxx`) + - **App Secret** + +### 第二步:开启机器人能力 + +1. 在 [飞书开放平台](https://open.feishu.cn/app) 的应用列表中,点击刚创建的应用名称进入应用详情页 +2. 在左侧导航栏找到「添加应用能力」,点击进入 +3. 找到「机器人」,点击「添加」 + +### 第三步:配置事件订阅 + +1. 在应用详情页左侧导航栏,点击「事件与回调」→「事件配置」 +2. 添加事件:`im.message.receive_v1`(接收消息) +3. 订阅方式选择:**使用长连接接收事件**(非 webhook) + +### 第四步:配置权限 + +在应用详情页左侧导航栏,点击「权限管理」,搜索并开通以下权限: + +| 权限 | 说明 | +|------|------| +| `im:message` | 获取与发送消息 | +| `im:message:send_as_bot` | 以机器人身份发送消息 | + +### 第五步:发布应用 + +1. 在应用详情页左侧导航栏,点击「版本管理与发布」 +2. 创建版本并提交审核 +3. 管理员审核通过后即可使用 + +## 在 Aether 中连接 + +### 通过界面操作 + +1. 打开 Aether +2. 点击输入框旁边的菜单按钮 +3. 点击「飞书连接」 +4. 首次使用:输入 App ID 和 App Secret,点击「连接」 +5. 之后使用:直接点击「连接飞书」 + +### 连接状态 + +| 状态 | 图标颜色 | 说明 | +|------|----------|------| +| 未连接 | 灰色 | 等待连接 | +| 连接中 | 黄色闪烁 | 正在建立 WebSocket 连接 | +| 已连接 | 蓝色 | 正常工作中 | +| 错误 | 红色 | 连接失败,可重试 | + +## 使用方式 + +连接成功后,在飞书中直接给机器人发送消息即可。 + +### 支持的消息类型 + +- 文本消息 ✅ +- 图片/文件(后续支持) + +### 内置命令 + +| 命令 | 说明 | +|------|------| +| `/new` | 开始新对话(清除当前会话上下文) | +| `/help` | 显示帮助信息 | + +### 会话映射规则 + +飞书对话到 Aether 会话的映射: + +- 每个飞书聊天 + 消息线程 = 一个 Aether 会话 +- 同一线程内的消息共享上下文 +- 不同线程或使用 `/new` 后会创建新会话 + +## 配置文件位置 + +配置保存在本地,下次启动无需重新输入: + +| 平台 | 路径 | +|------|------| +| Windows | `%APPDATA%\opencode\feishu\config.json` | +| macOS | `~/Library/Application Support/opencode/feishu/config.json` | +| Linux | `~/.local/share/opencode/feishu/config.json` | + +## 与微信连接的对比 + +| 项目 | 微信 | 飞书 | +|------|------|------| +| 认证方式 | 扫二维码 | App ID + Secret | +| 连接方式 | 客户端长连接 | WebSocket 长连接 | +| 需要公网 | 否 | 否 | +| 首次配置 | 扫码即可 | 需先在飞书平台创建应用 | +| 后续使用 | 点击连接 | 点击连接 | +| 实现语言 | Python 子进程 | TypeScript(内置) | + +## 架构说明 + +``` +飞书用户发送消息 + ↓ +飞书服务器 + ↓ (WebSocket 推送) +Aether 本地 (FeishuManager) + ↓ +会话映射 → 创建/复用 Aether Session + ↓ +SessionPrompt.prompt() → AI 处理 + ↓ +飞书 SDK → 回复消息 + ↓ +飞书用户看到回复 +``` + +### 代码结构 + +``` +packages/opencode/src/feishu/ + manager.ts # 连接管理器(SDK 初始化、消息处理、会话映射) + +packages/opencode/src/server/routes/ + feishu.ts # HTTP API(start/stop/status/events) + +packages/app/src/ + context/feishu.ts # 前端状态 + components/dialog-feishu.tsx # 连接对话框 UI +``` + +### API 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/feishu/start` | 启动飞书连接 | +| POST | `/feishu/stop` | 断开连接 | +| GET | `/feishu/status` | 获取连接状态 | +| GET | `/feishu/events` | SSE 事件流 | +| DELETE | `/feishu/session` | 清除配置和会话数据 | + +## 常见问题 + +### 连接失败 + +1. 确认 App ID 和 App Secret 是否正确 +2. 确认应用已开启「机器人」能力 +3. 确认事件订阅使用了「长连接」模式 +4. 确认应用已发布且审核通过 + +### 收不到消息 + +1. 确认已添加 `im.message.receive_v1` 事件订阅 +2. 确认已开通 `im:message` 权限 +3. 确认在飞书中直接给机器人发消息(非群聊中 @机器人,群聊需额外配置) + +### 断线重连 + +当前版本断线后需要手动重新点击「连接飞书」。自动重连功能将在后续版本实现。 diff --git a/packages/app/src/components/dialog-feishu.tsx b/packages/app/src/components/dialog-feishu.tsx new file mode 100644 index 0000000000..8d858802e3 --- /dev/null +++ b/packages/app/src/components/dialog-feishu.tsx @@ -0,0 +1,316 @@ +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Dialog } from "@opencode-ai/ui/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Component, Show, Switch, Match, createSignal, onCleanup, onMount } from "solid-js" +import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" +import { setFeishuStatus } from "@/context/feishu" + +type FeishuStatus = "idle" | "loading" | "config" | "connected" | "error" + +interface FeishuEvent { + type: string + properties: { + status?: FeishuStatus | "starting" + message?: string + appId?: string + code?: string + } +} + +export const DialogFeishu: Component = () => { + const dialog = useDialog() + const sdk = useSDK() + const server = useServer() + const [status, setStatus] = createSignal("idle") + const [error, setError] = createSignal<{ code: string; message: string } | null>(null) + const [appId, setAppId] = createSignal("") + const [appSecret, setAppSecret] = createSignal("") + const [connectedAppId, setConnectedAppId] = createSignal(null) + const [loadingMsg, setLoadingMsg] = createSignal("正在连接飞书...") + const [hasConfig, setHasConfig] = createSignal(false) + + const authHeaders = (): HeadersInit => { + const s = server.current?.http + if (!s?.password) return {} + return { Authorization: `Basic ${btoa(`${s.username ?? "opencode"}:${s.password}`)}` } + } + + const updateStatus = (s: FeishuStatus) => { + setStatus(s) + setFeishuStatus(s === "config" ? "idle" : s) + if (s !== "loading") setLoadingMsg("正在连接飞书...") + } + + let abort: AbortController | null = null + + const fetchStatus = async () => { + try { + const response = await fetch(`${sdk.url}/feishu/status`, { headers: authHeaders() }) + const data = await response.json() + setHasConfig(data.hasConfig) + if (data.status === "connected" && data.appId) { + updateStatus("connected") + setConnectedAppId(data.appId) + } else if (data.error) { + setError(data.error) + updateStatus("error") + } else if (data.hasConfig) { + // Has saved config, show idle (ready to connect) + updateStatus("idle") + } + } catch {} + } + + const startBridge = async (withConfig = false) => { + updateStatus("loading") + setLoadingMsg("正在连接飞书...") + setError(null) + + // Connect SSE first so we don't miss any events + connectSSE() + + try { + const body: any = {} + if (withConfig) { + body.appId = appId() + body.appSecret = appSecret() + } + + const response = await fetch(`${sdk.url}/feishu/start`, { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + const data = await response.json() + + if (!data.success) { + if (data.code === "config_missing") { + updateStatus("config") + return + } + setError({ code: data.code || "start_failed", message: data.message || "连接飞书失败" }) + updateStatus("error") + return + } + + // SSE already connected, events will flow through + } catch (err) { + setError({ code: "network_error", message: String(err) }) + updateStatus("error") + } + } + + const stopBridge = async () => { + if (abort) { + abort.abort() + abort = null + } + await fetch(`${sdk.url}/feishu/stop`, { method: "POST", headers: authHeaders() }) + updateStatus("idle") + } + + const logout = async () => { + if (abort) { + abort.abort() + abort = null + } + await fetch(`${sdk.url}/feishu/stop`, { method: "POST", headers: authHeaders() }) + await fetch(`${sdk.url}/feishu/session`, { method: "DELETE", headers: authHeaders() }) + setConnectedAppId(null) + setHasConfig(false) + setAppId("") + setAppSecret("") + updateStatus("idle") + } + + const connectSSE = () => { + if (abort) { + abort.abort() + } + abort = new AbortController() + + void (async () => { + try { + const response = await fetch(`${sdk.url}/feishu/events`, { + headers: { ...authHeaders(), Accept: "text/event-stream" }, + signal: abort!.signal, + }) + if (!response.ok || !response.body) return + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buf = "" + + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + + const lines = buf.split("\n") + buf = lines.pop() ?? "" + for (const line of lines) { + if (!line.startsWith("data:")) continue + const raw = line.slice(5).trim() + if (!raw) continue + try { + const event: FeishuEvent = JSON.parse(raw) + if (event.type === "feishu.connected" && event.properties.appId) { + setConnectedAppId(event.properties.appId) + updateStatus("connected") + } else if (event.type === "feishu.error") { + setError({ + code: event.properties.code || "unknown", + message: event.properties.message || "未知错误", + }) + updateStatus("error") + } else if (event.type === "feishu.status" && event.properties.status) { + const s = event.properties.status === "starting" ? "loading" : event.properties.status + updateStatus(s) + if (event.properties.message) setLoadingMsg(event.properties.message) + } + } catch {} + } + } + } catch {} + })() + } + + onMount(() => { + fetchStatus() + }) + + onCleanup(() => { + if (abort) { + abort.abort() + abort = null + } + }) + + return ( + +
+ }> + +
+ +

连接飞书后,可在飞书中使用 Aether AI

+ updateStatus("config")}> + 配置飞书应用 + + } + > +
+ + +
+
+
+
+ + +
+ +

+ 请在飞书开放平台创建应用,获取 App ID 和 App Secret +

+
+
+ + setAppId(e.currentTarget.value)} + placeholder="cli_xxxxxxxx" + class="w-full px-3 py-2 rounded-md border border-border-base bg-surface-base text-text-base text-13-regular focus:outline-none focus:border-border-focus" + /> +
+
+ + setAppSecret(e.currentTarget.value)} + placeholder="输入 App Secret" + class="w-full px-3 py-2 rounded-md border border-border-base bg-surface-base text-text-base text-13-regular focus:outline-none focus:border-border-focus" + /> +
+
+
+ + +
+
+
+ + +
+
+

{loadingMsg()}

+
+ + + +
+
+ +
+
+

已连接飞书

+ +

App: {connectedAppId()!.slice(0, 16)}...

+
+
+
+ + +
+
+
+ + +
+
+ +
+
+

连接失败

+ +

{error()!.message}

+
+
+
+ + +
+
+
+ +
+
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index af7bd632d7..cf1d6b80d3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -63,6 +63,8 @@ import { KnowledgeButton } from "@/components/knowledge-button" import { DialogDefaultSkills } from "@/components/dialog-default-skills" import { DialogWeChat } from "@/components/dialog-wechat" import { status as wechatStatus } from "@/context/wechat" +import { DialogFeishu } from "@/components/dialog-feishu" +import { feishuStatus } from "@/context/feishu" interface PromptInputProps { class?: string @@ -1677,6 +1679,29 @@ export const PromptInput: Component = (props) => { 微信连接 + + + diff --git a/packages/app/src/context/feishu.ts b/packages/app/src/context/feishu.ts new file mode 100644 index 0000000000..17e52faace --- /dev/null +++ b/packages/app/src/context/feishu.ts @@ -0,0 +1,5 @@ +import { createSignal } from "solid-js" + +export type FeishuStatus = "idle" | "loading" | "connected" | "error" + +export const [feishuStatus, setFeishuStatus] = createSignal("idle") diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4955dcda9f..a7cf1b1bf4 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -93,6 +93,7 @@ "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", + "@larksuiteoapi/node-sdk": "1.60.0", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", diff --git a/packages/opencode/src/feishu/manager.ts b/packages/opencode/src/feishu/manager.ts new file mode 100644 index 0000000000..c4e0368d6f --- /dev/null +++ b/packages/opencode/src/feishu/manager.ts @@ -0,0 +1,379 @@ +import { mkdir, readFile, writeFile, rm } from "fs/promises" +import { join } from "path" +import { homedir } from "os" +import { existsSync } from "fs" +import z from "zod" +import * as lark from "@larksuiteoapi/node-sdk" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Instance } from "@/project/instance" +import { Session } from "@/session" +import { SessionPrompt } from "@/session/prompt" +import { SessionID } from "@/session/schema" + +const FEISHU_DATA_DIR = + process.platform === "darwin" + ? join(homedir(), "Library", "Application Support", "opencode", "feishu") + : process.platform === "win32" + ? join(process.env.APPDATA || homedir(), "opencode", "feishu") + : join(homedir(), ".local", "share", "opencode", "feishu") +const CONFIG_FILE = join(FEISHU_DATA_DIR, "config.json") +const SESSION_MAP_FILE = join(FEISHU_DATA_DIR, "sessions.json") + +export type FeishuStatus = "idle" | "starting" | "connected" | "error" + +export interface FeishuConfig { + appId: string + appSecret: string +} + +export interface FeishuSession { + connected: boolean + appId: string + createdAt: number +} + +export const FeishuEvent = { + StatusChanged: BusEvent.define( + "feishu.status", + z.object({ + status: z.enum(["idle", "starting", "connected", "error"]), + message: z.string().optional(), + }), + ), + Connected: BusEvent.define( + "feishu.connected", + z.object({ + appId: z.string(), + }), + ), + Error: BusEvent.define( + "feishu.error", + z.object({ + code: z.string(), + message: z.string(), + }), + ), +} + +// Session mapping: feishu chat key -> aether session ID +type SessionMap = Record + +class FeishuManagerImpl { + private wsClient: any = null + private larkClient: any = null + private _status: FeishuStatus = "idle" + private _session: FeishuSession | null = null + private _error: { code: string; message: string } | null = null + private sessionMap: SessionMap = {} + + get status() { + return this._status + } + + get session() { + return this._session + } + + get error() { + return this._error + } + + private set status(value: FeishuStatus) { + this._status = value + Bus.publish(FeishuEvent.StatusChanged, { status: value }) + } + + private statusMsg(value: FeishuStatus, message: string) { + this._status = value + Bus.publish(FeishuEvent.StatusChanged, { status: value, message }) + } + + async start(config?: FeishuConfig): Promise<{ + success: boolean + message?: string + code?: string + status?: string + appId?: string + }> { + if (this.wsClient || this._status === "starting" || this._status === "connected") { + return { success: false, message: "Feishu bridge is already running" } + } + + // If no config provided, try loading saved config + const cfg = config || (await this.loadConfig()) + if (!cfg?.appId || !cfg?.appSecret) { + this._error = { code: "config_missing", message: "请提供飞书应用的 App ID 和 App Secret" } + this.status = "error" + Bus.publish(FeishuEvent.Error, this._error) + return { success: false, code: "config_missing", message: "请提供飞书应用的 App ID 和 App Secret" } + } + + this.status = "starting" + this._error = null + + // Save config for future use + await this.saveConfig(cfg) + + // Load session map + this.sessionMap = await this.loadSessionMap() + + void this._doStart(cfg) + return { success: true } + } + + private async _doStart(config: FeishuConfig): Promise { + try { + this.statusMsg("starting", "正在连接飞书...") + console.log("[feishu] _doStart called") + + // Capture Instance context so event callbacks can access Session/Instance APIs + const boundHandleMessage = Instance.bind((data: any) => { + console.log("[feishu] >>> event received!") + void this.handleMessage(data) + }) + + // Create Feishu API client + this.larkClient = new lark.Client({ + appId: config.appId, + appSecret: config.appSecret, + disableTokenCache: false, + }) + + // Create event dispatcher + const eventDispatcher = new lark.EventDispatcher({}) + eventDispatcher.register({ + "im.message.receive_v1": boundHandleMessage, + }) + + // Create WebSocket client with debug logging + this.wsClient = new lark.WSClient({ + appId: config.appId, + appSecret: config.appSecret, + loggerLevel: lark.LoggerLevel.debug, + }) + + // eventDispatcher is passed to start(), not constructor + console.log("[feishu] calling wsClient.start()...") + await this.wsClient.start({ eventDispatcher }) + console.log("[feishu] wsClient.start() resolved") + + this._session = { + connected: true, + appId: config.appId, + createdAt: Date.now(), + } + this.status = "connected" + Bus.publish(FeishuEvent.Connected, { appId: config.appId }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this._error = { code: "start_failed", message } + this.status = "error" + Bus.publish(FeishuEvent.Error, this._error) + } + } + + private async handleMessage(data: any): Promise { + try { + console.log("[feishu] received event:", JSON.stringify(data).slice(0, 500)) + const message = data?.message + if (!message) { + console.log("[feishu] no message in event data, keys:", Object.keys(data || {})) + return + } + + const chatId = message.chat_id + const messageId = message.message_id + const rootId = message.root_id || message.parent_id || messageId + console.log("[feishu] message:", { chatId, messageId, type: message.message_type }) + + // Only handle text messages for now + if (message.message_type !== "text") { + await this.replyText(messageId, "暂时只支持文本消息") + return + } + + // Parse message content + let text: string + try { + const content = JSON.parse(message.content) + text = content.text + } catch { + console.log("[feishu] failed to parse message content:", message.content) + return + } + + if (!text?.trim()) return + console.log("[feishu] text:", text) + + // Handle commands + if (text.startsWith("/")) { + await this.handleCommand(text, messageId, chatId) + return + } + + // Map to Aether session + const sessionKey = `${chatId}:${rootId}` + let sessionId = this.sessionMap[sessionKey] + + if (!sessionId) { + // Reuse the most recent session if available, otherwise create one + const recent = [...Session.list({ roots: true, limit: 1 })] + if (recent.length > 0) { + sessionId = recent[0].id + console.log("[feishu] reusing existing session:", sessionId) + } else { + console.log("[feishu] creating new session...") + const session = await Session.create({ + title: `飞书对话 ${chatId.slice(-6)}`, + }) + sessionId = session.id + console.log("[feishu] session created:", sessionId) + } + this.sessionMap[sessionKey] = sessionId + await this.saveSessionMap() + } + + // Send to Aether + console.log("[feishu] sending to aether, session:", sessionId) + const msg = await SessionPrompt.prompt({ + sessionID: SessionID.make(sessionId), + parts: [{ type: "text", text }], + }) + console.log("[feishu] aether responded, parts:", msg?.parts?.length) + + // Extract text response + const responseText = this.extractResponseText(msg) + if (responseText) { + // Build context header so user knows which project/session is active + const projectName = Instance.directory.split("/").at(-1) ?? Instance.directory + const sessionInfo = [...Session.list({ roots: true, limit: 100 })].find((s) => s.id === sessionId) + const sessionTitle = sessionInfo?.title ?? sessionId.slice(0, 8) + const header = `📁 ${projectName}\n💬 ${sessionTitle}\n${"─".repeat(20)}\n` + + console.log("[feishu] replying:", responseText.slice(0, 100)) + await this.replyText(messageId, header + responseText) + } else { + console.log("[feishu] no text in response") + } + } catch (err) { + console.error("[feishu] handleMessage error:", err) + const messageId = data?.message?.message_id + if (messageId) { + const errMsg = err instanceof Error ? err.message : String(err) + await this.replyText(messageId, `处理消息时出错: ${errMsg}`).catch(() => {}) + } + } + } + + private extractResponseText(msg: any): string | null { + if (!msg?.parts) return null + const textParts = msg.parts.filter((p: any) => p.type === "text") + if (textParts.length === 0) return null + return textParts.map((p: any) => p.text).join("\n") + } + + private async handleCommand(text: string, messageId: string, chatId: string): Promise { + const cmd = text.trim().toLowerCase() + + if (cmd === "/new") { + // Clear session mapping for this chat + for (const key of Object.keys(this.sessionMap)) { + if (key.startsWith(`${chatId}:`)) { + delete this.sessionMap[key] + } + } + // Create new session immediately so it appears in the web UI right away. + // The next message will reuse it via the "most recent session" logic. + const session = await Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }) + await this.saveSessionMap() + await this.replyText(messageId, `✅ 已开启新对话\n💬 ${session.title}`) + } else if (cmd === "/help") { + await this.replyText( + messageId, + "可用命令:\n/new - 开始新对话\n/help - 显示帮助\n\n直接发送消息即可与 Aether AI 对话。", + ) + } else { + await this.replyText(messageId, `未知命令: ${cmd}\n发送 /help 查看可用命令。`) + } + } + + private async replyText(messageId: string, text: string): Promise { + if (!this.larkClient) return + try { + await this.larkClient.im.message.reply({ + path: { message_id: messageId }, + data: { + msg_type: "text", + content: JSON.stringify({ text }), + }, + }) + } catch (err) { + console.error("[feishu] reply error:", err) + } + } + + async stop(): Promise { + if (this.wsClient) { + try { + // The SDK's ws client doesn't have a formal stop method in all versions + // so we try to clean up gracefully + if (typeof this.wsClient.stop === "function") { + this.wsClient.stop() + } + } catch {} + this.wsClient = null + this.larkClient = null + } + this._session = null + this.status = "idle" + } + + async clearSession(): Promise { + try { + await rm(CONFIG_FILE, { force: true }) + await rm(SESSION_MAP_FILE, { force: true }) + this._session = null + this.sessionMap = {} + } catch {} + } + + async loadConfig(): Promise { + try { + if (existsSync(CONFIG_FILE)) { + const data = await readFile(CONFIG_FILE, "utf-8") + return JSON.parse(data) + } + } catch {} + return null + } + + private async saveConfig(config: FeishuConfig): Promise { + await mkdir(FEISHU_DATA_DIR, { recursive: true }) + await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)) + } + + private async loadSessionMap(): Promise { + try { + if (existsSync(SESSION_MAP_FILE)) { + const data = await readFile(SESSION_MAP_FILE, "utf-8") + return JSON.parse(data) + } + } catch {} + return {} + } + + private async saveSessionMap(): Promise { + await mkdir(FEISHU_DATA_DIR, { recursive: true }) + await writeFile(SESSION_MAP_FILE, JSON.stringify(this.sessionMap, null, 2)) + } + + async loadSession(): Promise { + // Check if there's a saved config (means user has configured before) + const config = await this.loadConfig() + if (config && this._session) return this._session + return null + } +} + +export const FeishuManager = new FeishuManagerImpl() diff --git a/packages/opencode/src/server/routes/feishu.ts b/packages/opencode/src/server/routes/feishu.ts new file mode 100644 index 0000000000..7954d0a6c0 --- /dev/null +++ b/packages/opencode/src/server/routes/feishu.ts @@ -0,0 +1,206 @@ +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { streamSSE } from "hono/streaming" +import z from "zod" +import { lazy } from "@/util/lazy" +import { FeishuManager, FeishuEvent } from "@/feishu/manager" +import { Bus } from "@/bus" +import { AsyncQueue } from "@/util/queue" + +export const FeishuRoutes = lazy(() => + new Hono() + .post( + "/start", + describeRoute({ + summary: "Start Feishu bridge", + description: "Start the Feishu bridge service with WebSocket connection", + operationId: "feishu.start", + responses: { + 200: { + description: "Bridge started", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + code: z.string().optional(), + message: z.string().optional(), + status: z.string().optional(), + appId: z.string().optional(), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const body = await c.req.json().catch(() => ({})) + const config = + body?.appId && body?.appSecret ? { appId: body.appId, appSecret: body.appSecret } : undefined + const result = await FeishuManager.start(config) + return c.json(result) + }, + ) + .post( + "/stop", + describeRoute({ + summary: "Stop Feishu bridge", + description: "Stop the Feishu bridge service", + operationId: "feishu.stop", + responses: { + 200: { + description: "Bridge stopped", + content: { + "application/json": { + schema: resolver(z.object({ success: z.boolean() })), + }, + }, + }, + }, + }), + async (c) => { + await FeishuManager.stop() + return c.json({ success: true }) + }, + ) + .get( + "/status", + describeRoute({ + summary: "Get Feishu status", + description: "Get the current Feishu bridge status", + operationId: "feishu.status", + responses: { + 200: { + description: "Status", + content: { + "application/json": { + schema: resolver( + z.object({ + status: z.enum(["idle", "starting", "connected", "error"]), + appId: z.string().nullable(), + hasConfig: z.boolean(), + error: z + .object({ + code: z.string(), + message: z.string(), + }) + .nullable(), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const config = await FeishuManager.loadConfig() + return c.json({ + status: FeishuManager.status, + appId: FeishuManager.session?.appId || null, + hasConfig: !!config, + error: FeishuManager.error, + }) + }, + ) + .get( + "/events", + describeRoute({ + summary: "Subscribe to Feishu events", + description: "Get real-time Feishu events via SSE", + operationId: "feishu.events", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver(z.any()), + }, + }, + }, + }, + }), + async (c) => { + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + + return streamSSE(c, async (stream) => { + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ + type: "feishu.status", + properties: { status: FeishuManager.status }, + }), + ) + + // If already connected, push connected event immediately + if (FeishuManager.status === "connected" && FeishuManager.session?.appId) { + q.push( + JSON.stringify({ + type: "feishu.connected", + properties: { appId: FeishuManager.session.appId }, + }), + ) + } + + const heartbeat = setInterval(() => { + q.push( + JSON.stringify({ + type: "feishu.heartbeat", + properties: {}, + }), + ) + }, 10_000) + + const unsub = Bus.subscribeAll((event) => { + if (event.type.startsWith("feishu.")) { + q.push(JSON.stringify(event)) + } + }) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + q.push(null) + } + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } + }) + }, + ) + .delete( + "/session", + describeRoute({ + summary: "Clear Feishu session", + description: "Clear the saved Feishu configuration and session data", + operationId: "feishu.session.clear", + responses: { + 200: { + description: "Session cleared", + content: { + "application/json": { + schema: resolver(z.object({ success: z.boolean() })), + }, + }, + }, + }, + }), + async (c) => { + await FeishuManager.clearSession() + return c.json({ success: true }) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index bde64b6eb4..8e9ef9f9da 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -70,6 +70,7 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { KnowledgeRoutes } from "./routes/knowledge" import { WeChatRoutes } from "./routes/wechat" +import { FeishuRoutes } from "./routes/feishu" import { ReadingModeRoutes } from "./routes/reading-mode" import { DatabaseRoutes } from "./routes/database" import { MDNS } from "./mdns" @@ -295,6 +296,7 @@ export namespace Server { .route("/tui", TuiRoutes()) .route("/knowledge", KnowledgeRoutes()) .route("/wechat", WeChatRoutes()) + .route("/feishu", FeishuRoutes()) .route("/reading-mode", ReadingModeRoutes()) .post( "/instance/dispose", diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 25627446d0..3a406474cd 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -104,6 +104,7 @@ const icons = { providers: ``, models: ``, wechat: ``, + feishu: ``, } export interface IconProps extends ComponentProps<"svg"> {