opencode-feishu 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +585 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 NeverMore93
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# OpenCode 飞书插件
|
|
2
|
+
|
|
3
|
+
独立运行的飞书机器人服务,通过飞书 WebSocket 长连接与本地 [OpenCode](https://github.com/anomalyco/opencode) Server 对接,在飞书内与 AI 对话,并支持流式回复与实时占位更新。本插件仅作为**消息中继**:所有消息(包括以 `/` 开头的内容)原样转发给 OpenCode,不解析命令、不选择模型或 Agent。无需公网服务器即可完成从零到可用的对接。
|
|
4
|
+
|
|
5
|
+
**主要能力:**
|
|
6
|
+
|
|
7
|
+
- **飞书 WebSocket 长连接**:使用飞书「事件与回调」的「长连接」模式接收消息,无需配置 Webhook 地址。
|
|
8
|
+
- **OpenCode 对话**:将飞书消息转为 OpenCode 会话的 prompt,通过轮询 + SSE 事件流获取回复并回写到飞书。
|
|
9
|
+
- **群聊静默监听**:始终将群消息转发给 OpenCode 积累上下文,仅在 bot 被直接 @提及时才回复,避免刷屏。
|
|
10
|
+
- **入群上下文摄入**:bot 被拉入群聊时,自动读取历史消息作为 OpenCode 对话上下文。
|
|
11
|
+
- **SSE 事件流**:实时更新「正在思考…」占位消息,断线自动重连(5 秒间隔)。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 架构概览
|
|
16
|
+
|
|
17
|
+
```mermaid
|
|
18
|
+
flowchart LR
|
|
19
|
+
FeishuApp["Feishu App (WebSocket)"] --> Gateway["gateway.ts"]
|
|
20
|
+
Gateway --> Dedup["dedup.ts"]
|
|
21
|
+
Gateway --> GroupFilter["group-filter.ts"]
|
|
22
|
+
Gateway -->|"bot.added"| History["history.ts"]
|
|
23
|
+
Gateway --> Chat["chat.ts"]
|
|
24
|
+
Chat --> SessionMgr["session/manager.ts"]
|
|
25
|
+
Chat --> OcClient["opencode/client.ts"]
|
|
26
|
+
OcClient --> OpenCodeAPI["OpenCode Server API"]
|
|
27
|
+
History --> OcClient
|
|
28
|
+
Chat --> Sender["sender.ts"]
|
|
29
|
+
SSE["events.ts (SSE)"] --> OcClient
|
|
30
|
+
SSE --> Sender
|
|
31
|
+
Sender --> FeishuApp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **对话路径**:用户发送任意消息(含以 `/` 开头)→ `chat.ts` 获取/创建 OpenCode 会话、原样发送 prompt,轮询消息列表并在稳定后回写;同时 `events.ts` 订阅 OpenCode SSE,实时更新飞书占位消息。
|
|
35
|
+
- **静默监听**:群聊中未被 @提及的消息通过 `noReply: true` 发送给 OpenCode,仅记录上下文但不触发 AI 回复。
|
|
36
|
+
- **入群摄入**:`im.chat.member.bot.added_v1` 事件触发后,`history.ts` 拉取群聊历史消息并以 `noReply: true` 注入 OpenCode。
|
|
37
|
+
- **事件流**:`events.ts` 与 OpenCode 保持 SSE 连接,收到 `message.part.updated` 等事件时更新对应会话的飞书占位消息,断线后 5 秒重连。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 前置条件
|
|
42
|
+
|
|
43
|
+
- **Node.js** >= 20(或 Bun >= 1.0)
|
|
44
|
+
- **OpenCode Server** 已在本机或可访问的机器上运行,默认地址为 `http://localhost:4096`(参见 [OpenCode 文档](https://github.com/anomalyco/opencode))
|
|
45
|
+
- **飞书自建应用**:在 [飞书开放平台](https://open.feishu.cn/app) 创建应用并获取 App ID、App Secret
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 飞书开放平台配置(逐步)
|
|
50
|
+
|
|
51
|
+
### 4.1 创建/打开应用
|
|
52
|
+
|
|
53
|
+
在浏览器打开 [飞书开放平台 - 应用列表](https://open.feishu.cn/app),创建或打开你的自建应用,记下 **App ID** 和 **App Secret**(在「凭证与基础信息」中)。
|
|
54
|
+
|
|
55
|
+
### 4.2 添加机器人能力
|
|
56
|
+
|
|
57
|
+
进入该应用 → 左侧「添加应用能力」→ 添加「机器人」。若已添加则跳过。
|
|
58
|
+
|
|
59
|
+
### 4.3 配置事件订阅
|
|
60
|
+
|
|
61
|
+
- 进入「事件与回调」页面。
|
|
62
|
+
- **不需要**填写「请求地址」:本服务使用 **WebSocket 长连接** 接收消息,而非 Webhook。
|
|
63
|
+
- 在「事件订阅」中添加以下事件:
|
|
64
|
+
|
|
65
|
+
| 事件 | 说明 |
|
|
66
|
+
|------|------|
|
|
67
|
+
| `im.message.receive_v1` | 接收单聊与群聊消息 |
|
|
68
|
+
| `im.chat.member.bot.added_v1` | 机器人进群(触发历史上下文摄入) |
|
|
69
|
+
|
|
70
|
+
- 保存。
|
|
71
|
+
|
|
72
|
+
### 4.4 订阅方式:长连接(必选)
|
|
73
|
+
|
|
74
|
+
在「事件与回调」中,将 **订阅方式** 设置为「使用 长连接 接收事件/回调」。若使用 Webhook 模式,本服务无法收到消息。
|
|
75
|
+
|
|
76
|
+
### 4.5 配置权限
|
|
77
|
+
|
|
78
|
+
进入「权限管理」,搜索并开通以下权限:
|
|
79
|
+
|
|
80
|
+
| 权限 | 说明 |
|
|
81
|
+
|------|------|
|
|
82
|
+
| `im:message` | 获取与发送单聊、群组消息 |
|
|
83
|
+
| `im:message:send_as_bot` | 以应用身份发消息 |
|
|
84
|
+
| `im:chat` | 获取群组信息(群聊场景需要) |
|
|
85
|
+
| `im:message:readonly`(群聊消息读取) | 获取群组中所有消息(入群历史摄入需要) |
|
|
86
|
+
|
|
87
|
+
保存后若有权限变更,需在「版本管理与发布」中重新发布。
|
|
88
|
+
|
|
89
|
+
### 4.6 发布应用
|
|
90
|
+
|
|
91
|
+
进入「版本管理与发布」→ 创建版本 → 提交审核 → 发布。自建应用在企业内通常可即时通过。
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 配置说明
|
|
96
|
+
|
|
97
|
+
### 5.1 环境变量
|
|
98
|
+
|
|
99
|
+
复制项目根目录下的 `.env.example` 为 `.env`,按需填写。所有项均可通过环境变量覆盖配置文件中的同名字段。
|
|
100
|
+
|
|
101
|
+
**必填:**
|
|
102
|
+
|
|
103
|
+
| 变量 | 说明 |
|
|
104
|
+
|------|------|
|
|
105
|
+
| `FEISHU_APP_ID` | 飞书应用 App ID |
|
|
106
|
+
| `FEISHU_APP_SECRET` | 飞书应用 App Secret |
|
|
107
|
+
|
|
108
|
+
**OpenCode:**
|
|
109
|
+
|
|
110
|
+
| 变量 | 类型 | 默认值 | 说明 |
|
|
111
|
+
|------|------|--------|------|
|
|
112
|
+
| `OPENCODE_TIMEOUT` | number | 120000 | 单次对话超时(毫秒) |
|
|
113
|
+
|
|
114
|
+
服务固定连接 `http://localhost:4096`,不可配置。
|
|
115
|
+
|
|
116
|
+
**机器人行为:**
|
|
117
|
+
|
|
118
|
+
| 变量 | 类型 | 默认值 | 说明 |
|
|
119
|
+
|------|------|--------|------|
|
|
120
|
+
| `BOT_THINKING_DELAY` | number | 2500 | 发送「正在思考…」前的延迟(毫秒) |
|
|
121
|
+
| `BOT_ENABLE_STREAMING` | boolean | true | 是否启用流式更新占位消息 |
|
|
122
|
+
| `BOT_STREAM_INTERVAL` | number | 1000 | 流式更新间隔(毫秒) |
|
|
123
|
+
|
|
124
|
+
### 5.2 配置文件
|
|
125
|
+
|
|
126
|
+
除环境变量外,可从以下路径读取 JSON 配置(先加载的为底,后加载的覆盖):
|
|
127
|
+
|
|
128
|
+
- `~/.config/opencode/feishu-bot.json`(或由 `XDG_CONFIG_HOME` 指定目录下的 `opencode/feishu-bot.json`)
|
|
129
|
+
- 项目目录下的 `.opencode/feishu-bot.json`
|
|
130
|
+
|
|
131
|
+
结构与 `Config` 一致,例如:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"feishu": {
|
|
136
|
+
"appId": "cli_xxxx",
|
|
137
|
+
"appSecret": "xxxx"
|
|
138
|
+
},
|
|
139
|
+
"opencode": {
|
|
140
|
+
"timeout": 120000
|
|
141
|
+
},
|
|
142
|
+
"bot": {
|
|
143
|
+
"thinkingDelay": 2500,
|
|
144
|
+
"enableStreaming": true,
|
|
145
|
+
"streamInterval": 1000
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
环境变量会覆盖上述文件中同名字段。
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 安装与运行
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm install
|
|
158
|
+
npm run build
|
|
159
|
+
npm start
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
开发时可在项目根目录执行:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
npm run dev
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
另开终端运行 `node dist/index.js`。启动成功后,日志中应依次出现:
|
|
169
|
+
|
|
170
|
+
| 日志内容 | 含义 |
|
|
171
|
+
|----------|------|
|
|
172
|
+
| `配置加载成功` | 环境变量/配置文件已加载 |
|
|
173
|
+
| `Bot open_id 获取成功` | 成功获取 bot 身份信息(用于 @提及检测) |
|
|
174
|
+
| `OpenCode 连接状态 healthy: true` | OpenCode 服务可达 |
|
|
175
|
+
| `OpenCode 事件流连接中…` | 正在连接 OpenCode SSE |
|
|
176
|
+
| `Feishu WebSocket gateway started` | 飞书 WebSocket 网关已启动 |
|
|
177
|
+
| `服务就绪:飞书网关已连接` | 可正常收发消息 |
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 本地打包与安装
|
|
182
|
+
|
|
183
|
+
### 打包为 tarball
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npm run build
|
|
187
|
+
npm pack
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
生成 `opencode-feishu-0.1.0.tgz`,包含 `dist/` 和 `README.md`。
|
|
191
|
+
|
|
192
|
+
### 全局安装
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# 从 tarball 安装
|
|
196
|
+
npm install -g ./opencode-feishu-0.1.0.tgz
|
|
197
|
+
|
|
198
|
+
# 或开发模式(链接本地目录)
|
|
199
|
+
npm link
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
安装后可在任意目录运行 `opencode-feishu` 命令启动服务。
|
|
203
|
+
|
|
204
|
+
### 作为依赖安装到其他项目
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npm install /path/to/opencode-feishu-0.1.0.tgz
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 对话流程说明
|
|
213
|
+
|
|
214
|
+
- **会话获取/创建**:按飞书会话键(单聊 `feishu-p2p-<userId>`,群聊 `feishu-group-<chatId>`)先查内存缓存,再按 OpenCode 会话标题前缀匹配恢复,若无则新建会话。
|
|
215
|
+
- **「正在思考…」**:发送用户消息后,延迟 `BOT_THINKING_DELAY`(默认 2500ms)再在飞书发送「正在思考…」占位消息;回复就绪后更新该条消息为最终内容,再删除占位。
|
|
216
|
+
- **轮询**:每 1.5 秒拉取一次该 OpenCode 会话的消息列表,取最后一条 assistant 文本;若连续 2 次相同则视为稳定,结束轮询。
|
|
217
|
+
- **SSE 流式更新**:同时订阅 OpenCode 的 SSE 事件流;收到 `message.part.updated` 时实时更新飞书占位内容。断线后 5 秒自动重连。
|
|
218
|
+
- **超时**:若在 `OPENCODE_TIMEOUT`(默认 120 秒)内未得到稳定回复,将返回「响应超时」并结束等待。
|
|
219
|
+
- **纯中继**:所有消息(包括以 `/` 开头的文本)均原样转发给 OpenCode,由 OpenCode 决定模型、Agent 与行为。
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 群聊行为
|
|
224
|
+
|
|
225
|
+
Bot 在群聊中始终以**静默监听模式**运行:
|
|
226
|
+
|
|
227
|
+
### 静默监听
|
|
228
|
+
|
|
229
|
+
- 群聊中的**所有文本消息**都会转发给 OpenCode 作为对话上下文(使用 `noReply: true`,不触发 AI 回复、不消耗 AI tokens)。
|
|
230
|
+
- 仅在 bot 被**直接 @提及**时,才触发正常的 AI 对话并在飞书群内回复。
|
|
231
|
+
- 未被 @提及的消息在飞书侧完全无感——不会产生任何回复或可见的 bot 行为。
|
|
232
|
+
|
|
233
|
+
### 入群上下文摄入
|
|
234
|
+
|
|
235
|
+
- 当 bot 被**首次拉入群聊**时(触发 `im.chat.member.bot.added_v1` 事件),自动拉取该群最近 50 条历史消息。
|
|
236
|
+
- 历史消息格式化后以 `noReply: true` 发送给 OpenCode,作为对话的背景上下文。
|
|
237
|
+
- 后续当有人 @bot 提问时,AI 已拥有群聊的历史背景,可以给出更精准的回答。
|
|
238
|
+
|
|
239
|
+
### 行为矩阵
|
|
240
|
+
|
|
241
|
+
| 场景 | 发送到 OpenCode | noReply | 飞书回复 |
|
|
242
|
+
|------|:---:|:---:|:---:|
|
|
243
|
+
| 单聊(私聊) | 是 | 否 | 是 |
|
|
244
|
+
| 群聊 + bot 被 @提及 | 是 | 否 | 是 |
|
|
245
|
+
| 群聊 + bot 未被 @提及 | 是 | **是** | **否** |
|
|
246
|
+
| bot 首次入群 | 历史消息 | **是** | **否** |
|
|
247
|
+
|
|
248
|
+
### 群聊发送者身份
|
|
249
|
+
|
|
250
|
+
发往 OpenCode 的群聊消息会带上发送者身份,便于 AI 区分是谁在说话:每条消息文本前会加上 `[open_id]` 前缀(飞书用户的 `open_id`),例如:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
[ou_xxxxxxxxxxxx]: 帮我看一下这个 bug
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
单聊不添加此前缀;历史摄入中的消息同样按 `[open_id]` 或 `[Bot]` 标识。
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## 会话管理
|
|
261
|
+
|
|
262
|
+
- **会话键**:单聊为 `feishu-p2p-<发送者 userId>`,群聊为 `feishu-group-<群 chatId>`。
|
|
263
|
+
- **OpenCode 会话标题**:新建会话标题格式为 `Feishu-feishu-<chatType>-<id>-<时间戳>`,用于重启后按标题前缀恢复。
|
|
264
|
+
- **缓存**:内存中会话键到 OpenCode 会话 ID 的映射保留 24 小时;超时可通过 `cleanupExpired()` 清理(当前实现中由会话管理器内部使用)。
|
|
265
|
+
- **恢复**:进程重启后无内存缓存,会通过「列出 OpenCode 会话 + 按标题前缀匹配」恢复对应飞书会话的 OpenCode 会话,找不到则新建。
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 部署建议
|
|
270
|
+
|
|
271
|
+
- **进程常驻**:使用 systemd、pm2 或 Docker 等运行 `node dist/index.js`,并确保环境变量或配置文件路径正确。
|
|
272
|
+
- **日志**:标准输出为 JSON 行日志,可按需重定向或接入日志系统。
|
|
273
|
+
- **健康检查**:可直接请求 OpenCode 的 v2 健康检查接口(若需自建探活)。
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 常见问题与排查
|
|
278
|
+
|
|
279
|
+
| 现象 | 可能原因 | 处理 |
|
|
280
|
+
|------|----------|------|
|
|
281
|
+
| 启动报错「Missing Feishu config」 | 未设置 `FEISHU_APP_ID` 或 `FEISHU_APP_SECRET` | 在 `.env` 或配置文件中填写飞书凭证 |
|
|
282
|
+
| 日志中「OpenCode 连接状态 healthy: false」 | OpenCode 未启动或地址不可达 | 确认 OpenCode 已在本机 `http://localhost:4096` 运行 |
|
|
283
|
+
| 日志中「Bot open_id 为空」或「fallback 模式」 | bot info API 调用失败 | 检查飞书 App ID/Secret 是否正确;fallback 模式下任何 @提及都会触发回复 |
|
|
284
|
+
| 群聊中不回复 | 未 @提及 bot 或未使用长连接 | 在群中 @bot 后发送消息;在飞书开放平台将订阅方式改为「长连接」 |
|
|
285
|
+
| 入群后未摄入历史 | 未订阅 `im.chat.member.bot.added_v1` 事件或缺少群消息读取权限 | 在飞书开放平台添加事件订阅并开通 `im:message:readonly` 权限 |
|
|
286
|
+
| 回复显示「响应超时」 | 等待时间超过 `OPENCODE_TIMEOUT` 或 OpenCode 响应过慢 | 适当增大 `OPENCODE_TIMEOUT` 或检查 OpenCode 与模型状态 |
|
|
287
|
+
| 同一条消息被处理多次 | 飞书 WebSocket 重复投递 | 服务内对同一 `messageId` 在 10 分钟内去重,一般无需处理;若仍异常可检查 `dedup` 逻辑 |
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 项目结构
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
opencode-feishu/
|
|
295
|
+
├── src/
|
|
296
|
+
│ ├── index.ts # 入口:加载配置、获取 bot info、启动网关与事件流、挂接对话
|
|
297
|
+
│ ├── config.ts # 多源配置加载(文件 + 环境变量)
|
|
298
|
+
│ ├── types.ts # 配置与消息上下文等类型定义
|
|
299
|
+
│ ├── feishu/
|
|
300
|
+
│ │ ├── gateway.ts # 飞书 WebSocket 网关、消息回调与 bot 入群事件
|
|
301
|
+
│ │ ├── sender.ts # 飞书消息发送、更新、删除
|
|
302
|
+
│ │ ├── dedup.ts # 消息去重(10 分钟窗口)
|
|
303
|
+
│ │ ├── group-filter.ts # 群聊 @提及检测(仅在 bot 被直接 @时回复)
|
|
304
|
+
│ │ └── history.ts # 入群历史上下文摄入
|
|
305
|
+
│ ├── handler/
|
|
306
|
+
│ │ └── chat.ts # 对话:静默监听 / 占位、prompt、轮询、回写
|
|
307
|
+
│ ├── opencode/
|
|
308
|
+
│ │ ├── client.ts # OpenCode SDK 封装(会话、消息、健康、SSE、noReply)
|
|
309
|
+
│ │ └── events.ts # OpenCode SSE 订阅与占位实时更新
|
|
310
|
+
│ └── session/
|
|
311
|
+
│ └── manager.ts # 飞书会话键与 OpenCode 会话的映射与恢复
|
|
312
|
+
├── .env.example
|
|
313
|
+
├── package.json
|
|
314
|
+
├── tsup.config.ts
|
|
315
|
+
└── README.md
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## 许可证
|
|
321
|
+
|
|
322
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import * as Lark from '@larksuiteoapi/node-sdk';
|
|
5
|
+
import { ProxyAgent } from 'proxy-agent';
|
|
6
|
+
|
|
7
|
+
// src/index.ts
|
|
8
|
+
|
|
9
|
+
// src/feishu/dedup.ts
|
|
10
|
+
var SEEN_TTL_MS = 10 * 60 * 1e3;
|
|
11
|
+
var seen = /* @__PURE__ */ new Map();
|
|
12
|
+
function isDuplicate(messageId) {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [k, ts] of seen) {
|
|
15
|
+
if (now - ts > SEEN_TTL_MS) seen.delete(k);
|
|
16
|
+
}
|
|
17
|
+
if (!messageId) return false;
|
|
18
|
+
if (seen.has(messageId)) return true;
|
|
19
|
+
seen.set(messageId, now);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/feishu/group-filter.ts
|
|
24
|
+
function isBotMentioned(mentions, botOpenId) {
|
|
25
|
+
if (!botOpenId) return mentions.length > 0;
|
|
26
|
+
return mentions.some((m) => m.id?.open_id === botOpenId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/feishu/gateway.ts
|
|
30
|
+
function startFeishuGateway(options) {
|
|
31
|
+
const { config, botOpenId = "", onMessage, onBotAdded, log } = options;
|
|
32
|
+
const { appId, appSecret } = config;
|
|
33
|
+
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY || "";
|
|
34
|
+
const wsAgent = new ProxyAgent();
|
|
35
|
+
if (proxyUrl) {
|
|
36
|
+
log("info", "WS proxy enabled", { proxy: proxyUrl });
|
|
37
|
+
}
|
|
38
|
+
const sdkConfig = {
|
|
39
|
+
appId,
|
|
40
|
+
appSecret,
|
|
41
|
+
domain: Lark.Domain.Feishu,
|
|
42
|
+
appType: Lark.AppType.SelfBuild
|
|
43
|
+
};
|
|
44
|
+
const client = new Lark.Client(sdkConfig);
|
|
45
|
+
const dispatcher = new Lark.EventDispatcher({}).register({
|
|
46
|
+
"im.message.receive_v1": async (data) => {
|
|
47
|
+
try {
|
|
48
|
+
log("info", "\u6536\u5230\u98DE\u4E66\u4E8B\u4EF6", {
|
|
49
|
+
keys: Object.keys(data || {})
|
|
50
|
+
});
|
|
51
|
+
const message = data.message;
|
|
52
|
+
if (!message) return;
|
|
53
|
+
const chatId = message.chat_id;
|
|
54
|
+
if (!chatId) return;
|
|
55
|
+
const messageId = message.message_id;
|
|
56
|
+
if (isDuplicate(messageId)) return;
|
|
57
|
+
const messageType = message.message_type ?? "text";
|
|
58
|
+
log("info", "\u98DE\u4E66\u6D88\u606F\u5143\u4FE1\u606F", {
|
|
59
|
+
chatId,
|
|
60
|
+
messageId: messageId ?? "",
|
|
61
|
+
messageType,
|
|
62
|
+
hasContent: !!message.content
|
|
63
|
+
});
|
|
64
|
+
if (messageType !== "text" || !message.content) return;
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(message.content);
|
|
68
|
+
text = (parsed.text ?? "").trim();
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
text = text.replace(/@_user_\d+\s*/g, "").trim();
|
|
73
|
+
if (!text) return;
|
|
74
|
+
const chatType = message.chat_type === "group" ? "group" : "p2p";
|
|
75
|
+
let shouldReply = true;
|
|
76
|
+
if (chatType === "group") {
|
|
77
|
+
const mentions = Array.isArray(message.mentions) ? message.mentions : [];
|
|
78
|
+
shouldReply = isBotMentioned(
|
|
79
|
+
mentions,
|
|
80
|
+
botOpenId
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const sender = data.sender;
|
|
84
|
+
const senderId = sender?.sender_id?.open_id ?? "";
|
|
85
|
+
const rootId = message.root_id;
|
|
86
|
+
const ctx = {
|
|
87
|
+
chatId: String(chatId),
|
|
88
|
+
messageId: messageId ?? "",
|
|
89
|
+
messageType,
|
|
90
|
+
content: text,
|
|
91
|
+
chatType,
|
|
92
|
+
senderId,
|
|
93
|
+
rootId,
|
|
94
|
+
shouldReply
|
|
95
|
+
};
|
|
96
|
+
log("info", "\u6536\u5230\u98DE\u4E66\u6D88\u606F", {
|
|
97
|
+
chatId: String(chatId),
|
|
98
|
+
messageId: messageId ?? "",
|
|
99
|
+
chatType,
|
|
100
|
+
shouldReply,
|
|
101
|
+
textPreview: text.slice(0, 80)
|
|
102
|
+
});
|
|
103
|
+
await onMessage(ctx);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log("error", "\u6D88\u606F\u5904\u7406\u9519\u8BEF", {
|
|
106
|
+
error: err instanceof Error ? err.message : String(err)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"im.chat.member.bot.added_v1": async (data) => {
|
|
111
|
+
try {
|
|
112
|
+
const chatId = data.chat_id;
|
|
113
|
+
if (chatId && onBotAdded) {
|
|
114
|
+
log("info", "Bot \u88AB\u6DFB\u52A0\u5230\u7FA4\u804A", { chatId });
|
|
115
|
+
await onBotAdded(chatId);
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log("error", "Bot\u5165\u7FA4\u5904\u7406\u9519\u8BEF", {
|
|
119
|
+
error: err instanceof Error ? err.message : String(err)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const wsClient = new Lark.WSClient({
|
|
125
|
+
...sdkConfig,
|
|
126
|
+
agent: wsAgent,
|
|
127
|
+
loggerLevel: Lark.LoggerLevel.info,
|
|
128
|
+
logger: {
|
|
129
|
+
error: (...msg) => log("error", "[lark.ws]", { msg }),
|
|
130
|
+
warn: (...msg) => log("warn", "[lark.ws]", { msg }),
|
|
131
|
+
info: (...msg) => log("info", "[lark.ws]", { msg }),
|
|
132
|
+
debug: (...msg) => log("info", "[lark.ws]", { msg }),
|
|
133
|
+
trace: (...msg) => log("info", "[lark.ws]", { msg })
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
wsClient.start({ eventDispatcher: dispatcher });
|
|
137
|
+
log("info", "\u98DE\u4E66 WebSocket \u7F51\u5173\u5DF2\u542F\u52A8", { appIdPrefix: appId.slice(0, 8) + "..." });
|
|
138
|
+
const stop = () => {
|
|
139
|
+
log("info", "\u98DE\u4E66 WebSocket \u7F51\u5173\u505C\u6B62\u4E2D");
|
|
140
|
+
wsClient.close();
|
|
141
|
+
log("info", "\u98DE\u4E66 WebSocket \u7F51\u5173\u5DF2\u505C\u6B62");
|
|
142
|
+
};
|
|
143
|
+
return { client, stop };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/feishu/sender.ts
|
|
147
|
+
async function sendTextMessage(client, chatId, text, _replyToId) {
|
|
148
|
+
if (!chatId?.trim()) {
|
|
149
|
+
return { ok: false, error: "No chat_id provided" };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const res = await client.im.message.create({
|
|
153
|
+
params: { receive_id_type: "chat_id" },
|
|
154
|
+
data: {
|
|
155
|
+
receive_id: chatId.trim(),
|
|
156
|
+
msg_type: "text",
|
|
157
|
+
content: JSON.stringify({ text })
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return { ok: true, messageId: res?.data?.message_id ?? "" };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function updateMessage(client, messageId, text) {
|
|
166
|
+
try {
|
|
167
|
+
await client.im.message.update({
|
|
168
|
+
path: { message_id: messageId },
|
|
169
|
+
data: {
|
|
170
|
+
msg_type: "text",
|
|
171
|
+
content: JSON.stringify({ text })
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
return { ok: true, messageId };
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/handler/event.ts
|
|
181
|
+
var pendingBySession = /* @__PURE__ */ new Map();
|
|
182
|
+
function registerPending(sessionId, payload) {
|
|
183
|
+
pendingBySession.set(sessionId, { ...payload, textBuffer: "" });
|
|
184
|
+
}
|
|
185
|
+
function unregisterPending(sessionId) {
|
|
186
|
+
pendingBySession.delete(sessionId);
|
|
187
|
+
}
|
|
188
|
+
async function handleEvent(event, log) {
|
|
189
|
+
switch (event.type) {
|
|
190
|
+
case "message.part.updated": {
|
|
191
|
+
const part = event.properties.part;
|
|
192
|
+
if (!part) break;
|
|
193
|
+
const sessionId = part.sessionID;
|
|
194
|
+
if (!sessionId) break;
|
|
195
|
+
const payload = pendingBySession.get(sessionId);
|
|
196
|
+
if (!payload) break;
|
|
197
|
+
const delta = event.properties.delta;
|
|
198
|
+
if (delta) {
|
|
199
|
+
payload.textBuffer += delta;
|
|
200
|
+
} else {
|
|
201
|
+
const fullText = extractPartText(part);
|
|
202
|
+
if (fullText) {
|
|
203
|
+
payload.textBuffer = fullText;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (payload.textBuffer) {
|
|
207
|
+
const res = await updateMessage(payload.feishuClient, payload.placeholderId, payload.textBuffer.trim());
|
|
208
|
+
if (!res.ok) ;
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "session.error": {
|
|
213
|
+
const props = event.properties;
|
|
214
|
+
const sessionId = props.sessionID;
|
|
215
|
+
if (!sessionId) break;
|
|
216
|
+
const payload = pendingBySession.get(sessionId);
|
|
217
|
+
if (!payload) break;
|
|
218
|
+
const errMsg = props.error?.message ?? String(props.error);
|
|
219
|
+
const updateRes = await updateMessage(payload.feishuClient, payload.placeholderId, `\u274C \u4F1A\u8BDD\u9519\u8BEF: ${errMsg}`);
|
|
220
|
+
if (!updateRes.ok) {
|
|
221
|
+
const sendRes = await sendTextMessage(payload.feishuClient, payload.chatId, `\u274C \u4F1A\u8BDD\u9519\u8BEF: ${errMsg}`);
|
|
222
|
+
if (!sendRes.ok) {
|
|
223
|
+
log("error", "\u53D1\u9001\u9519\u8BEF\u6D88\u606F\u5931\u8D25", { sessionId, error: String(errMsg) });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function extractPartText(part) {
|
|
231
|
+
if (part.type === "text") return part.text ?? "";
|
|
232
|
+
if (part.type === "reasoning" && part.text) return `\u{1F914} \u601D\u8003: ${part.text}
|
|
233
|
+
|
|
234
|
+
`;
|
|
235
|
+
return "";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/session.ts
|
|
239
|
+
var SESSION_KEY_PREFIX = "feishu";
|
|
240
|
+
var TITLE_PREFIX = "Feishu";
|
|
241
|
+
function buildSessionKey(chatType, id) {
|
|
242
|
+
return `${SESSION_KEY_PREFIX}-${chatType}-${id}`;
|
|
243
|
+
}
|
|
244
|
+
async function getOrCreateSession(client, sessionKey, directory) {
|
|
245
|
+
const titlePrefix = `${TITLE_PREFIX}-${sessionKey}-`;
|
|
246
|
+
const query = directory ? { directory } : void 0;
|
|
247
|
+
const { data: sessions } = await client.session.list({ query });
|
|
248
|
+
if (Array.isArray(sessions)) {
|
|
249
|
+
const candidates = sessions.filter(
|
|
250
|
+
(s) => s.title && s.title.startsWith(titlePrefix)
|
|
251
|
+
);
|
|
252
|
+
if (candidates.length > 0) {
|
|
253
|
+
candidates.sort((a, b) => {
|
|
254
|
+
const tsA = parseInt(a.title?.split("-").pop() ?? "0", 10);
|
|
255
|
+
const tsB = parseInt(b.title?.split("-").pop() ?? "0", 10);
|
|
256
|
+
if (tsA && tsB) return tsB - tsA;
|
|
257
|
+
const ca = a.time?.created ?? 0;
|
|
258
|
+
const cb = b.time?.created ?? 0;
|
|
259
|
+
return cb - ca;
|
|
260
|
+
});
|
|
261
|
+
const best = candidates[0];
|
|
262
|
+
if (best?.id) return { id: best.id, title: best.title };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const title = `${titlePrefix}${Date.now()}`;
|
|
266
|
+
const createResp = await client.session.create({ query, body: { title } });
|
|
267
|
+
if (!createResp?.data?.id) {
|
|
268
|
+
const err = createResp?.error;
|
|
269
|
+
throw new Error(
|
|
270
|
+
`\u521B\u5EFA OpenCode \u4F1A\u8BDD\u5931\u8D25: ${err ? JSON.stringify(err) : "unknown"}`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return { id: createResp.data.id, title: createResp.data.title };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/handler/chat.ts
|
|
277
|
+
var POLL_INTERVAL_MS = 1500;
|
|
278
|
+
var STABLE_POLLS = 2;
|
|
279
|
+
async function handleChat(ctx, deps) {
|
|
280
|
+
const { content, chatId, chatType, senderId, shouldReply } = ctx;
|
|
281
|
+
if (!content.trim()) return;
|
|
282
|
+
const { config, client, feishuClient, log, directory } = deps;
|
|
283
|
+
const query = directory ? { directory } : void 0;
|
|
284
|
+
const sessionKey = buildSessionKey(chatType, chatType === "p2p" ? senderId : chatId);
|
|
285
|
+
const session = await getOrCreateSession(client, sessionKey, directory);
|
|
286
|
+
let promptContent = content;
|
|
287
|
+
if (chatType === "group" && senderId) {
|
|
288
|
+
promptContent = `[${senderId}]: ${content}`;
|
|
289
|
+
}
|
|
290
|
+
if (!shouldReply) {
|
|
291
|
+
try {
|
|
292
|
+
await client.session.prompt({
|
|
293
|
+
path: { id: session.id },
|
|
294
|
+
query,
|
|
295
|
+
body: {
|
|
296
|
+
parts: [{ type: "text", text: promptContent }],
|
|
297
|
+
noReply: true
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
} catch (err) {
|
|
301
|
+
log("warn", "\u9759\u9ED8\u8F6C\u53D1\u5931\u8D25", {
|
|
302
|
+
error: err instanceof Error ? err.message : String(err)
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const timeout = config.timeout;
|
|
308
|
+
const thinkingDelay = config.thinkingDelay;
|
|
309
|
+
let placeholderId = "";
|
|
310
|
+
let done = false;
|
|
311
|
+
const timer = thinkingDelay > 0 ? setTimeout(async () => {
|
|
312
|
+
if (done) return;
|
|
313
|
+
const res = await sendTextMessage(feishuClient, chatId, "\u6B63\u5728\u601D\u8003\u2026");
|
|
314
|
+
if (done) return;
|
|
315
|
+
if (res.ok && res.messageId) {
|
|
316
|
+
placeholderId = res.messageId;
|
|
317
|
+
registerPending(session.id, { chatId, placeholderId, feishuClient });
|
|
318
|
+
}
|
|
319
|
+
}, thinkingDelay) : null;
|
|
320
|
+
try {
|
|
321
|
+
await client.session.prompt({
|
|
322
|
+
path: { id: session.id },
|
|
323
|
+
query,
|
|
324
|
+
body: {
|
|
325
|
+
parts: [{ type: "text", text: promptContent }]
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
const start = Date.now();
|
|
329
|
+
let lastText = "";
|
|
330
|
+
let sameCount = 0;
|
|
331
|
+
while (Date.now() - start < timeout) {
|
|
332
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
333
|
+
const { data: messages } = await client.session.messages({ path: { id: session.id }, query });
|
|
334
|
+
const text = extractLastAssistantText(messages ?? []);
|
|
335
|
+
if (text && text !== lastText) {
|
|
336
|
+
lastText = text;
|
|
337
|
+
sameCount = 0;
|
|
338
|
+
} else if (text && text.length > 0) {
|
|
339
|
+
sameCount++;
|
|
340
|
+
if (sameCount >= STABLE_POLLS) break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const { data: finalMessages } = await client.session.messages({ path: { id: session.id }, query });
|
|
344
|
+
const finalText = extractLastAssistantText(finalMessages ?? []) || lastText || (Date.now() - start >= timeout ? "\u26A0\uFE0F \u54CD\u5E94\u8D85\u65F6" : "[\u65E0\u56DE\u590D]");
|
|
345
|
+
if (placeholderId) {
|
|
346
|
+
const res = await updateMessage(feishuClient, placeholderId, finalText);
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
await sendTextMessage(feishuClient, chatId, finalText);
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
await sendTextMessage(feishuClient, chatId, finalText);
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
log("error", "\u5BF9\u8BDD\u5904\u7406\u5931\u8D25", {
|
|
355
|
+
error: err instanceof Error ? err.message : String(err)
|
|
356
|
+
});
|
|
357
|
+
const msg = "\u274C " + (err instanceof Error ? err.message : String(err));
|
|
358
|
+
if (placeholderId) {
|
|
359
|
+
const res = await updateMessage(feishuClient, placeholderId, msg);
|
|
360
|
+
if (!res.ok) {
|
|
361
|
+
await sendTextMessage(feishuClient, chatId, msg);
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
await sendTextMessage(feishuClient, chatId, msg);
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
done = true;
|
|
368
|
+
if (timer) clearTimeout(timer);
|
|
369
|
+
unregisterPending(session.id);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function extractLastAssistantText(messages) {
|
|
373
|
+
const assistant = messages.filter((m) => m.info?.role === "assistant").pop();
|
|
374
|
+
const parts = assistant?.parts ?? [];
|
|
375
|
+
return parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("\n").trim();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/feishu/history.ts
|
|
379
|
+
var DEFAULT_MAX_MESSAGES = 50;
|
|
380
|
+
var DEFAULT_PAGE_SIZE = 50;
|
|
381
|
+
async function ingestGroupHistory(feishuClient, opencodeClient, chatId, options) {
|
|
382
|
+
const { maxMessages = DEFAULT_MAX_MESSAGES, log } = options;
|
|
383
|
+
log("info", "\u5F00\u59CB\u6444\u5165\u7FA4\u804A\u5386\u53F2\u4E0A\u4E0B\u6587", { chatId, maxMessages });
|
|
384
|
+
const messages = await fetchRecentMessages(feishuClient, chatId, maxMessages, log);
|
|
385
|
+
if (!messages.length) {
|
|
386
|
+
log("info", "\u7FA4\u804A\u65E0\u5386\u53F2\u6D88\u606F\uFF0C\u8DF3\u8FC7\u6444\u5165", { chatId });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const sessionKey = buildSessionKey("group", chatId);
|
|
390
|
+
const session = await getOrCreateSession(opencodeClient, sessionKey);
|
|
391
|
+
const contextText = formatHistoryAsContext(messages);
|
|
392
|
+
await opencodeClient.session.prompt({
|
|
393
|
+
path: { id: session.id },
|
|
394
|
+
body: {
|
|
395
|
+
parts: [{ type: "text", text: contextText }],
|
|
396
|
+
noReply: true
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
log("info", "\u7FA4\u804A\u5386\u53F2\u4E0A\u4E0B\u6587\u6444\u5165\u5B8C\u6210", { chatId, messageCount: messages.length, sessionId: session.id });
|
|
400
|
+
}
|
|
401
|
+
async function fetchRecentMessages(client, chatId, maxMessages, log) {
|
|
402
|
+
const result = [];
|
|
403
|
+
let pageToken;
|
|
404
|
+
try {
|
|
405
|
+
while (result.length < maxMessages) {
|
|
406
|
+
const res = await client.im.message.list({
|
|
407
|
+
params: {
|
|
408
|
+
container_id_type: "chat",
|
|
409
|
+
container_id: chatId,
|
|
410
|
+
sort_type: "ByCreateTimeDesc",
|
|
411
|
+
page_size: Math.min(DEFAULT_PAGE_SIZE, maxMessages - result.length),
|
|
412
|
+
...pageToken ? { page_token: pageToken } : {}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
const items = res?.data?.items;
|
|
416
|
+
if (!items || items.length === 0) break;
|
|
417
|
+
for (const item of items) {
|
|
418
|
+
if (item.deleted) continue;
|
|
419
|
+
if (item.msg_type !== "text" || !item.body?.content) continue;
|
|
420
|
+
let text;
|
|
421
|
+
try {
|
|
422
|
+
const parsed = JSON.parse(item.body.content);
|
|
423
|
+
text = (parsed.text ?? "").trim();
|
|
424
|
+
} catch {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (!text) continue;
|
|
428
|
+
result.push({
|
|
429
|
+
senderType: item.sender?.sender_type ?? "unknown",
|
|
430
|
+
senderId: item.sender?.id ?? "",
|
|
431
|
+
content: text,
|
|
432
|
+
createTime: item.create_time ?? ""
|
|
433
|
+
});
|
|
434
|
+
if (result.length >= maxMessages) break;
|
|
435
|
+
}
|
|
436
|
+
if (!res?.data?.has_more) break;
|
|
437
|
+
pageToken = res.data.page_token ?? void 0;
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
log("warn", "\u62C9\u53D6\u7FA4\u804A\u5386\u53F2\u6D88\u606F\u5931\u8D25", {
|
|
441
|
+
chatId,
|
|
442
|
+
error: err instanceof Error ? err.message : String(err)
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
result.reverse();
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
function formatHistoryAsContext(messages) {
|
|
449
|
+
const header = [
|
|
450
|
+
"[\u7FA4\u804A\u5386\u53F2\u4E0A\u4E0B\u6587 - \u4EE5\u4E0B\u662F bot \u52A0\u5165\u524D\u7684\u7FA4\u804A\u8BB0\u5F55\uFF0C\u4EC5\u4F5C\u4E3A\u80CC\u666F\u4FE1\u606F\uFF0C\u65E0\u9700\u56DE\u590D]",
|
|
451
|
+
`\u6D88\u606F\u6570\u91CF: ${messages.length}`,
|
|
452
|
+
"---"
|
|
453
|
+
].join("\n");
|
|
454
|
+
const body = messages.map((m) => {
|
|
455
|
+
const time = m.createTime ? new Date(Number(m.createTime)).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }) : "unknown";
|
|
456
|
+
const senderLabel = m.senderType === "app" ? "[Bot]" : `[${m.senderId}]`;
|
|
457
|
+
return `[${time}] ${senderLabel}: ${m.content}`;
|
|
458
|
+
}).join("\n");
|
|
459
|
+
return `${header}
|
|
460
|
+
${body}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/index.ts
|
|
464
|
+
var SERVICE_NAME = "opencode-feishu";
|
|
465
|
+
var DEFAULT_CONFIG = {
|
|
466
|
+
timeout: 12e4,
|
|
467
|
+
thinkingDelay: 2500
|
|
468
|
+
};
|
|
469
|
+
var FeishuPlugin = async (ctx) => {
|
|
470
|
+
const { client } = ctx;
|
|
471
|
+
let gateway = null;
|
|
472
|
+
let resolvedConfig = null;
|
|
473
|
+
const log = (level, message, extra) => {
|
|
474
|
+
client.app.log({
|
|
475
|
+
body: {
|
|
476
|
+
service: SERVICE_NAME,
|
|
477
|
+
level,
|
|
478
|
+
message,
|
|
479
|
+
extra
|
|
480
|
+
}
|
|
481
|
+
}).catch(() => {
|
|
482
|
+
const payload = JSON.stringify({ service: SERVICE_NAME, level, message, ...extra, time: (/* @__PURE__ */ new Date()).toISOString() });
|
|
483
|
+
if (level === "error") console.error(payload);
|
|
484
|
+
else console.log(payload);
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
const configPath = join(homedir(), ".config", "opencode", "plugins", "feishu.json");
|
|
488
|
+
if (!existsSync(configPath)) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`\u7F3A\u5C11\u98DE\u4E66\u914D\u7F6E\u6587\u4EF6\uFF1A\u8BF7\u521B\u5EFA ${configPath}\uFF0C\u5185\u5BB9\u4E3A {"appId":"cli_xxx","appSecret":"xxx"}`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
let feishuRaw;
|
|
494
|
+
try {
|
|
495
|
+
feishuRaw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
496
|
+
} catch (parseErr) {
|
|
497
|
+
throw new Error(`\u98DE\u4E66\u914D\u7F6E\u6587\u4EF6\u683C\u5F0F\u9519\u8BEF\uFF1A${configPath} \u5FC5\u987B\u662F\u5408\u6CD5\u7684 JSON (${parseErr})`);
|
|
498
|
+
}
|
|
499
|
+
if (!feishuRaw.appId || !feishuRaw.appSecret) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
`\u98DE\u4E66\u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1A${configPath} \u4E2D\u5FC5\u987B\u5305\u542B appId \u548C appSecret`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
resolvedConfig = {
|
|
505
|
+
appId: feishuRaw.appId,
|
|
506
|
+
appSecret: feishuRaw.appSecret,
|
|
507
|
+
timeout: feishuRaw.timeout ?? DEFAULT_CONFIG.timeout,
|
|
508
|
+
thinkingDelay: feishuRaw.thinkingDelay ?? DEFAULT_CONFIG.thinkingDelay
|
|
509
|
+
};
|
|
510
|
+
const botOpenId = await fetchBotOpenId(resolvedConfig.appId, resolvedConfig.appSecret, log);
|
|
511
|
+
gateway = startFeishuGateway({
|
|
512
|
+
config: resolvedConfig,
|
|
513
|
+
botOpenId,
|
|
514
|
+
onMessage: async (msgCtx) => {
|
|
515
|
+
if (!msgCtx.content.trim() || !gateway || !resolvedConfig) return;
|
|
516
|
+
await handleChat(msgCtx, {
|
|
517
|
+
config: resolvedConfig,
|
|
518
|
+
client,
|
|
519
|
+
feishuClient: gateway.client,
|
|
520
|
+
log,
|
|
521
|
+
directory: ctx.directory
|
|
522
|
+
});
|
|
523
|
+
},
|
|
524
|
+
onBotAdded: (chatId) => {
|
|
525
|
+
if (!gateway) return;
|
|
526
|
+
ingestGroupHistory(gateway.client, client, chatId, {
|
|
527
|
+
maxMessages: 50,
|
|
528
|
+
log
|
|
529
|
+
}).catch((err) => {
|
|
530
|
+
log("error", "\u7FA4\u804A\u5386\u53F2\u6444\u5165\u5931\u8D25", {
|
|
531
|
+
chatId,
|
|
532
|
+
error: err instanceof Error ? err.message : String(err)
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
log
|
|
537
|
+
});
|
|
538
|
+
log("info", "\u98DE\u4E66\u63D2\u4EF6\u5DF2\u521D\u59CB\u5316", {
|
|
539
|
+
appId: resolvedConfig.appId.slice(0, 8) + "...",
|
|
540
|
+
botOpenId: botOpenId || "(fallback mode)"
|
|
541
|
+
});
|
|
542
|
+
const hooks = {
|
|
543
|
+
event: async ({ event }) => {
|
|
544
|
+
if (!gateway) return;
|
|
545
|
+
await handleEvent(event, log);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
return hooks;
|
|
549
|
+
};
|
|
550
|
+
async function fetchBotOpenId(appId, appSecret, log) {
|
|
551
|
+
try {
|
|
552
|
+
const tokenRes = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
553
|
+
method: "POST",
|
|
554
|
+
headers: { "Content-Type": "application/json" },
|
|
555
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret })
|
|
556
|
+
});
|
|
557
|
+
const tokenData = await tokenRes.json();
|
|
558
|
+
const token = tokenData?.tenant_access_token;
|
|
559
|
+
if (!token) {
|
|
560
|
+
log("warn", "\u83B7\u53D6 tenant_access_token \u5931\u8D25\uFF0C\u7FA4\u804A @\u63D0\u53CA\u68C0\u6D4B\u5C06\u4F7F\u7528 fallback \u6A21\u5F0F");
|
|
561
|
+
return "";
|
|
562
|
+
}
|
|
563
|
+
const botRes = await fetch("https://open.feishu.cn/open-apis/bot/v3/info", {
|
|
564
|
+
method: "GET",
|
|
565
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
566
|
+
});
|
|
567
|
+
const botData = await botRes.json();
|
|
568
|
+
const openId = botData?.bot?.open_id;
|
|
569
|
+
if (openId) {
|
|
570
|
+
log("info", "Bot open_id \u83B7\u53D6\u6210\u529F", { openId });
|
|
571
|
+
return openId;
|
|
572
|
+
}
|
|
573
|
+
log("warn", "Bot open_id \u4E3A\u7A7A\uFF0C\u7FA4\u804A @\u63D0\u53CA\u68C0\u6D4B\u5C06\u4F7F\u7528 fallback \u6A21\u5F0F");
|
|
574
|
+
return "";
|
|
575
|
+
} catch (err) {
|
|
576
|
+
log("warn", "\u83B7\u53D6 bot open_id \u5931\u8D25\uFF0C\u7FA4\u804A @\u63D0\u53CA\u68C0\u6D4B\u5C06\u4F7F\u7528 fallback \u6A21\u5F0F", {
|
|
577
|
+
error: err instanceof Error ? err.message : String(err)
|
|
578
|
+
});
|
|
579
|
+
return "";
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export { FeishuPlugin };
|
|
584
|
+
//# sourceMappingURL=index.js.map
|
|
585
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/feishu/dedup.ts","../src/feishu/group-filter.ts","../src/feishu/gateway.ts","../src/feishu/sender.ts","../src/handler/event.ts","../src/session.ts","../src/handler/chat.ts","../src/feishu/history.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * 消息去重 — 飞书 WebSocket 可能重复投递同一事件\n */\nconst SEEN_TTL_MS = 10 * 60 * 1000; // 10 分钟\n\nconst seen = new Map<string, number>();\n\n/** 清理过期条目并判断是否重复 */\nexport function isDuplicate(messageId: string | undefined | null): boolean {\n const now = Date.now();\n\n for (const [k, ts] of seen) {\n if (now - ts > SEEN_TTL_MS) seen.delete(k);\n }\n\n if (!messageId) return false;\n if (seen.has(messageId)) return true;\n seen.set(messageId, now);\n return false;\n}\n\n/** 清空去重状态(测试用) */\nexport function clearDedup(): void {\n seen.clear();\n}\n","/**\n * 群聊过滤:仅在 bot 被直接 @提及时回复\n */\n\n/**\n * 检查 bot 是否被直接 @提及\n * @param mentions 飞书事件中的 @ 提及列表\n * @param botOpenId bot 自身的 open_id(启动时获取)\n * @returns 当 bot 被 @提及时返回 true\n */\nexport function isBotMentioned(\n mentions: Array<{ id?: { open_id?: string }; [key: string]: unknown }>,\n botOpenId: string\n): boolean {\n // fallback: 若启动时未能获取 bot open_id,只要有任何 @提及就回复\n if (!botOpenId) return mentions.length > 0;\n return mentions.some((m) => m.id?.open_id === botOpenId);\n}\n","/**\n * 飞书 WebSocket 长连接:接收消息并回调\n */\nimport * as Lark from \"@larksuiteoapi/node-sdk\"\nimport { ProxyAgent } from \"proxy-agent\"\nimport type { FeishuMessageContext, ResolvedConfig, LogFn } from \"../types.js\"\nimport { isDuplicate } from \"./dedup.js\"\nimport { isBotMentioned } from \"./group-filter.js\"\n\nexport interface FeishuGatewayOptions {\n config: ResolvedConfig\n /** bot 自身的 open_id(启动时通过 bot info API 获取),用于群聊 @提及检测 */\n botOpenId?: string\n onMessage: (ctx: FeishuMessageContext) => void | Promise<void>\n /** bot 被拉入群聊时触发(用于摄入历史上下文) */\n onBotAdded?: (chatId: string) => void | Promise<void>\n log: LogFn\n}\n\nexport interface FeishuGatewayResult {\n client: InstanceType<typeof Lark.Client>\n stop: () => void\n}\n\n/**\n * 启动飞书 WebSocket 网关,返回 Client(供 sender 使用)和 stop 函数\n */\nexport function startFeishuGateway(options: FeishuGatewayOptions): FeishuGatewayResult {\n const { config, botOpenId = \"\", onMessage, onBotAdded, log } = options\n const { appId, appSecret } = config\n const proxyUrl =\n process.env.HTTPS_PROXY ||\n process.env.HTTP_PROXY ||\n process.env.ALL_PROXY ||\n \"\"\n\n const wsAgent = new ProxyAgent()\n if (proxyUrl) {\n log(\"info\", \"WS proxy enabled\", { proxy: proxyUrl })\n }\n\n const sdkConfig = {\n appId,\n appSecret,\n domain: Lark.Domain.Feishu,\n appType: Lark.AppType.SelfBuild,\n }\n\n const client = new Lark.Client(sdkConfig)\n\n const dispatcher = new Lark.EventDispatcher({}).register({\n \"im.message.receive_v1\": async (data: Record<string, unknown>) => {\n try {\n log(\"info\", \"收到飞书事件\", {\n keys: Object.keys(data || {}),\n })\n const message = (data as { message?: Record<string, unknown> }).message\n if (!message) return\n\n const chatId = message.chat_id as string | undefined\n if (!chatId) return\n\n const messageId = message.message_id as string | undefined\n if (isDuplicate(messageId)) return\n\n const messageType = (message.message_type as string) ?? \"text\"\n log(\"info\", \"飞书消息元信息\", {\n chatId,\n messageId: messageId ?? \"\",\n messageType,\n hasContent: !!message.content,\n })\n if (messageType !== \"text\" || !message.content) return\n\n let text: string\n try {\n const parsed = JSON.parse(message.content as string) as { text?: string }\n text = (parsed.text ?? \"\").trim()\n } catch {\n return\n }\n text = text.replace(/@_user_\\d+\\s*/g, \"\").trim()\n if (!text) return\n\n const chatType = (message.chat_type as string) === \"group\" ? \"group\" : \"p2p\"\n\n // 群聊:仅在被 @ 时回复(静默监听)\n let shouldReply = true\n if (chatType === \"group\") {\n const mentions = Array.isArray(message.mentions) ? message.mentions : []\n shouldReply = isBotMentioned(\n mentions as Array<{ id?: { open_id?: string } }>,\n botOpenId,\n )\n }\n\n const sender = (data as { sender?: { sender_id?: { open_id?: string } } }).sender\n const senderId = sender?.sender_id?.open_id ?? \"\"\n const rootId = message.root_id as string | undefined\n\n const ctx: FeishuMessageContext = {\n chatId: String(chatId),\n messageId: messageId ?? \"\",\n messageType,\n content: text,\n chatType,\n senderId,\n rootId,\n shouldReply,\n }\n\n log(\"info\", \"收到飞书消息\", {\n chatId: String(chatId),\n messageId: messageId ?? \"\",\n chatType,\n shouldReply,\n textPreview: text.slice(0, 80),\n })\n\n await onMessage(ctx)\n } catch (err) {\n log(\"error\", \"消息处理错误\", {\n error: err instanceof Error ? err.message : String(err),\n })\n }\n },\n \"im.chat.member.bot.added_v1\": async (data: Record<string, unknown>) => {\n try {\n const chatId = data.chat_id as string | undefined\n if (chatId && onBotAdded) {\n log(\"info\", \"Bot 被添加到群聊\", { chatId })\n await onBotAdded(chatId)\n }\n } catch (err) {\n log(\"error\", \"Bot入群处理错误\", {\n error: err instanceof Error ? err.message : String(err),\n })\n }\n },\n })\n\n const wsClient = new Lark.WSClient({\n ...sdkConfig,\n agent: wsAgent,\n loggerLevel: Lark.LoggerLevel.info,\n logger: {\n error: (...msg: unknown[]) => log(\"error\", \"[lark.ws]\", { msg }),\n warn: (...msg: unknown[]) => log(\"warn\", \"[lark.ws]\", { msg }),\n info: (...msg: unknown[]) => log(\"info\", \"[lark.ws]\", { msg }),\n debug: (...msg: unknown[]) => log(\"info\", \"[lark.ws]\", { msg }),\n trace: (...msg: unknown[]) => log(\"info\", \"[lark.ws]\", { msg }),\n },\n })\n\n wsClient.start({ eventDispatcher: dispatcher })\n log(\"info\", \"飞书 WebSocket 网关已启动\", { appIdPrefix: appId.slice(0, 8) + \"...\" })\n\n const stop = () => {\n log(\"info\", \"飞书 WebSocket 网关停止中\")\n wsClient.close()\n log(\"info\", \"飞书 WebSocket 网关已停止\")\n }\n\n return { client, stop }\n}\n","/**\n * 飞书消息发送:文本、更新、删除\n */\nimport type * as Lark from \"@larksuiteoapi/node-sdk\";\n\nexport interface FeishuSendResult {\n ok: boolean;\n messageId?: string;\n error?: string;\n}\n\n/**\n * 发送文本消息到飞书会话\n */\nexport async function sendTextMessage(\n client: InstanceType<typeof Lark.Client>,\n chatId: string,\n text: string,\n _replyToId?: string\n): Promise<FeishuSendResult> {\n if (!chatId?.trim()) {\n return { ok: false, error: \"No chat_id provided\" };\n }\n try {\n const res = await client.im.message.create({\n params: { receive_id_type: \"chat_id\" },\n data: {\n receive_id: chatId.trim(),\n msg_type: \"text\",\n content: JSON.stringify({ text }),\n },\n });\n return { ok: true, messageId: res?.data?.message_id ?? \"\" };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n/**\n * 更新已有消息(如替换「正在思考…」占位)\n */\nexport async function updateMessage(\n client: InstanceType<typeof Lark.Client>,\n messageId: string,\n text: string\n): Promise<FeishuSendResult> {\n try {\n await client.im.message.update({\n path: { message_id: messageId },\n data: {\n msg_type: \"text\",\n content: JSON.stringify({ text }),\n },\n });\n return { ok: true, messageId };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n/**\n * 删除消息(如移除占位消息)\n */\nexport async function deleteMessage(\n client: InstanceType<typeof Lark.Client>,\n messageId: string\n): Promise<void> {\n try {\n await client.im.message.delete({ path: { message_id: messageId } });\n } catch {\n // 尽力清理,忽略失败\n }\n}\n","/**\n * OpenCode 事件处理:通过插件 event 钩子接收事件,更新飞书占位消息\n */\nimport type { Event } from \"@opencode-ai/sdk\"\nimport type { LogFn } from \"../types.js\"\nimport * as sender from \"../feishu/sender.js\"\nimport type * as Lark from \"@larksuiteoapi/node-sdk\"\n\nexport interface PendingReplyPayload {\n chatId: string\n placeholderId: string\n feishuClient: InstanceType<typeof Lark.Client>\n textBuffer: string\n}\n\nconst pendingBySession = new Map<string, PendingReplyPayload>()\n\nexport function registerPending(\n sessionId: string,\n payload: Omit<PendingReplyPayload, \"textBuffer\">,\n): void {\n pendingBySession.set(sessionId, { ...payload, textBuffer: \"\" })\n}\n\nexport function unregisterPending(sessionId: string): void {\n pendingBySession.delete(sessionId)\n}\n\n/**\n * 处理 OpenCode 事件(由插件 event 钩子调用)\n */\nexport async function handleEvent(\n event: Event,\n log: LogFn,\n): Promise<void> {\n switch (event.type) {\n case \"message.part.updated\": {\n const part = event.properties.part\n if (!part) break\n\n const sessionId = part.sessionID\n if (!sessionId) break\n\n const payload = pendingBySession.get(sessionId)\n if (!payload) break\n\n // delta 是增量文本,part.text 是全量文本\n const delta = (event.properties as { delta?: string }).delta\n if (delta) {\n payload.textBuffer += delta\n } else {\n // 无 delta 时用全量文本替换(而非追加,避免文本重复)\n const fullText = extractPartText(part)\n if (fullText) {\n payload.textBuffer = fullText\n }\n }\n\n if (payload.textBuffer) {\n const res = await sender.updateMessage(payload.feishuClient, payload.placeholderId, payload.textBuffer.trim())\n if (!res.ok) {\n // best-effort: 更新失败不阻塞\n }\n }\n break\n }\n case \"session.error\": {\n const props = event.properties as Record<string, unknown>\n const sessionId = props.sessionID as string | undefined\n if (!sessionId) break\n\n const payload = pendingBySession.get(sessionId)\n if (!payload) break\n\n const errMsg = (props.error as Record<string, unknown>)?.message ?? String(props.error)\n const updateRes = await sender.updateMessage(payload.feishuClient, payload.placeholderId, `❌ 会话错误: ${errMsg}`)\n if (!updateRes.ok) {\n const sendRes = await sender.sendTextMessage(payload.feishuClient, payload.chatId, `❌ 会话错误: ${errMsg}`)\n if (!sendRes.ok) {\n log(\"error\", \"发送错误消息失败\", { sessionId, error: String(errMsg) })\n }\n }\n break\n }\n default:\n break\n }\n}\n\nfunction extractPartText(part: { type?: string; text?: string; [key: string]: unknown }): string {\n if (part.type === \"text\") return part.text ?? \"\"\n if (part.type === \"reasoning\" && part.text) return `🤔 思考: ${part.text}\\n\\n`\n return \"\"\n}\n","/**\n * 共享会话管理:查找或创建 OpenCode 会话\n */\nimport type { OpencodeClient } from \"@opencode-ai/sdk\"\n\nconst SESSION_KEY_PREFIX = \"feishu\"\nconst TITLE_PREFIX = \"Feishu\"\n\n/**\n * 构建会话键\n */\nexport function buildSessionKey(chatType: \"p2p\" | \"group\", id: string): string {\n return `${SESSION_KEY_PREFIX}-${chatType}-${id}`\n}\n\n/**\n * 查找或创建 OpenCode 会话(按标题前缀匹配)\n */\nexport async function getOrCreateSession(\n client: OpencodeClient,\n sessionKey: string,\n directory?: string,\n): Promise<{ id: string; title?: string }> {\n const titlePrefix = `${TITLE_PREFIX}-${sessionKey}-`\n\n const query = directory ? { directory } : undefined\n const { data: sessions } = await client.session.list({ query })\n if (Array.isArray(sessions)) {\n const candidates = sessions.filter(\n (s) => s.title && s.title.startsWith(titlePrefix),\n )\n if (candidates.length > 0) {\n candidates.sort((a, b) => {\n const tsA = parseInt(a.title?.split(\"-\").pop() ?? \"0\", 10)\n const tsB = parseInt(b.title?.split(\"-\").pop() ?? \"0\", 10)\n if (tsA && tsB) return tsB - tsA\n const ca = a.time?.created ?? 0\n const cb = b.time?.created ?? 0\n return cb - ca\n })\n const best = candidates[0]\n if (best?.id) return { id: best.id, title: best.title }\n }\n }\n\n const title = `${titlePrefix}${Date.now()}`\n const createResp = await client.session.create({ query, body: { title } })\n if (!createResp?.data?.id) {\n const err = (createResp as unknown as { error?: unknown })?.error\n throw new Error(\n `创建 OpenCode 会话失败: ${err ? JSON.stringify(err) : \"unknown\"}`,\n )\n }\n return { id: createResp.data.id, title: createResp.data.title }\n}\n","/**\n * 对话处理:会话管理、占位消息、prompt 发送、轮询等待、最终回复\n */\nimport type { FeishuMessageContext, ResolvedConfig, LogFn } from \"../types.js\"\nimport type { OpencodeClient } from \"@opencode-ai/sdk\"\nimport * as sender from \"../feishu/sender.js\"\nimport { registerPending, unregisterPending } from \"./event.js\"\nimport { buildSessionKey, getOrCreateSession } from \"../session.js\"\nimport type * as Lark from \"@larksuiteoapi/node-sdk\"\n\nconst POLL_INTERVAL_MS = 1500\nconst STABLE_POLLS = 2\n\nexport interface ChatDeps {\n config: ResolvedConfig\n client: OpencodeClient\n feishuClient: InstanceType<typeof Lark.Client>\n log: LogFn\n directory: string\n}\n\nexport async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps): Promise<void> {\n const { content, chatId, chatType, senderId, shouldReply } = ctx\n if (!content.trim()) return\n\n const { config, client, feishuClient, log, directory } = deps\n const query = directory ? { directory } : undefined\n\n const sessionKey = buildSessionKey(chatType, chatType === \"p2p\" ? senderId : chatId)\n const session = await getOrCreateSession(client, sessionKey, directory)\n\n // 群聊消息添加发送者身份前缀\n let promptContent = content\n if (chatType === \"group\" && senderId) {\n promptContent = `[${senderId}]: ${content}`\n }\n\n // 静默监听模式:消息发给 OpenCode 作为上下文,不触发 AI 回复\n if (!shouldReply) {\n try {\n await client.session.prompt({\n path: { id: session.id },\n query,\n body: {\n parts: [{ type: \"text\", text: promptContent }],\n noReply: true,\n },\n })\n } catch (err) {\n log(\"warn\", \"静默转发失败\", {\n error: err instanceof Error ? err.message : String(err),\n })\n }\n return\n }\n\n const timeout = config.timeout\n const thinkingDelay = config.thinkingDelay\n\n let placeholderId = \"\"\n let done = false\n const timer =\n thinkingDelay > 0\n ? setTimeout(async () => {\n if (done) return\n const res = await sender.sendTextMessage(feishuClient, chatId, \"正在思考…\")\n if (done) return // 重新检查,防止发送期间主流程已结束\n if (res.ok && res.messageId) {\n placeholderId = res.messageId\n registerPending(session.id, { chatId, placeholderId, feishuClient })\n }\n }, thinkingDelay)\n : null\n\n try {\n await client.session.prompt({\n path: { id: session.id },\n query,\n body: {\n parts: [{ type: \"text\", text: promptContent }],\n },\n })\n\n const start = Date.now()\n let lastText = \"\"\n let sameCount = 0\n\n while (Date.now() - start < timeout) {\n await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))\n const { data: messages } = await client.session.messages({ path: { id: session.id }, query })\n const text = extractLastAssistantText(messages ?? [])\n\n if (text && text !== lastText) {\n lastText = text\n sameCount = 0\n } else if (text && text.length > 0) {\n sameCount++\n if (sameCount >= STABLE_POLLS) break\n }\n }\n\n const { data: finalMessages } = await client.session.messages({ path: { id: session.id }, query })\n const finalText =\n extractLastAssistantText(finalMessages ?? []) ||\n lastText ||\n (Date.now() - start >= timeout ? \"⚠️ 响应超时\" : \"[无回复]\")\n\n if (placeholderId) {\n const res = await sender.updateMessage(feishuClient, placeholderId, finalText)\n if (!res.ok) {\n await sender.sendTextMessage(feishuClient, chatId, finalText)\n }\n } else {\n await sender.sendTextMessage(feishuClient, chatId, finalText)\n }\n } catch (err) {\n log(\"error\", \"对话处理失败\", {\n error: err instanceof Error ? err.message : String(err),\n })\n const msg = \"❌ \" + (err instanceof Error ? err.message : String(err))\n if (placeholderId) {\n const res = await sender.updateMessage(feishuClient, placeholderId, msg)\n if (!res.ok) {\n await sender.sendTextMessage(feishuClient, chatId, msg)\n }\n } else {\n await sender.sendTextMessage(feishuClient, chatId, msg)\n }\n } finally {\n done = true\n if (timer) clearTimeout(timer)\n unregisterPending(session.id)\n }\n}\n\nfunction extractLastAssistantText(\n messages: Array<{\n info: { role?: string; [key: string]: unknown }\n parts: Array<{ type?: string; text?: string; [key: string]: unknown }>\n }>,\n): string {\n const assistant = messages.filter((m) => m.info?.role === \"assistant\").pop()\n const parts = assistant?.parts ?? []\n return parts\n .filter((p) => p.type === \"text\")\n .map((p) => p.text ?? \"\")\n .join(\"\\n\")\n .trim()\n}\n","/**\n * 群聊历史上下文摄入:bot 被拉入群聊时,读取历史消息并发送给 OpenCode 作为上下文\n */\nimport type * as Lark from \"@larksuiteoapi/node-sdk\"\nimport type { OpencodeClient } from \"@opencode-ai/sdk\"\nimport type { LogFn } from \"../types.js\"\nimport { buildSessionKey, getOrCreateSession } from \"../session.js\"\n\ninterface HistoryMessage {\n senderType: string\n senderId: string\n content: string\n createTime: string\n}\n\nconst DEFAULT_MAX_MESSAGES = 50\nconst DEFAULT_PAGE_SIZE = 50\n\n/**\n * 拉取群聊历史消息并注入 OpenCode 会话作为背景上下文\n */\nexport async function ingestGroupHistory(\n feishuClient: InstanceType<typeof Lark.Client>,\n opencodeClient: OpencodeClient,\n chatId: string,\n options: {\n maxMessages?: number\n log: LogFn\n },\n): Promise<void> {\n const { maxMessages = DEFAULT_MAX_MESSAGES, log } = options\n\n log(\"info\", \"开始摄入群聊历史上下文\", { chatId, maxMessages })\n\n // 1. 拉取历史消息\n const messages = await fetchRecentMessages(feishuClient, chatId, maxMessages, log)\n if (!messages.length) {\n log(\"info\", \"群聊无历史消息,跳过摄入\", { chatId })\n return\n }\n\n // 2. 获取/创建 OpenCode 会话\n const sessionKey = buildSessionKey(\"group\", chatId)\n const session = await getOrCreateSession(opencodeClient, sessionKey)\n\n // 3. 格式化为上下文文本\n const contextText = formatHistoryAsContext(messages)\n\n // 4. 发送到 OpenCode(noReply: true,仅记录上下文,不触发 AI 回复)\n await opencodeClient.session.prompt({\n path: { id: session.id },\n body: {\n parts: [{ type: \"text\", text: contextText }],\n noReply: true,\n },\n })\n\n log(\"info\", \"群聊历史上下文摄入完成\", { chatId, messageCount: messages.length, sessionId: session.id })\n}\n\n/**\n * 通过飞书 API 拉取群聊最近的文本消息\n */\nasync function fetchRecentMessages(\n client: InstanceType<typeof Lark.Client>,\n chatId: string,\n maxMessages: number,\n log: LogFn,\n): Promise<HistoryMessage[]> {\n const result: HistoryMessage[] = []\n let pageToken: string | undefined\n\n try {\n while (result.length < maxMessages) {\n const res = await client.im.message.list({\n params: {\n container_id_type: \"chat\",\n container_id: chatId,\n sort_type: \"ByCreateTimeDesc\",\n page_size: Math.min(DEFAULT_PAGE_SIZE, maxMessages - result.length),\n ...(pageToken ? { page_token: pageToken } : {}),\n },\n })\n\n const items = res?.data?.items\n if (!items || items.length === 0) break\n\n for (const item of items) {\n if (item.deleted) continue\n if (item.msg_type !== \"text\" || !item.body?.content) continue\n\n let text: string\n try {\n const parsed = JSON.parse(item.body.content) as { text?: string }\n text = (parsed.text ?? \"\").trim()\n } catch {\n continue\n }\n if (!text) continue\n\n result.push({\n senderType: item.sender?.sender_type ?? \"unknown\",\n senderId: item.sender?.id ?? \"\",\n content: text,\n createTime: item.create_time ?? \"\",\n })\n\n if (result.length >= maxMessages) break\n }\n\n if (!res?.data?.has_more) break\n pageToken = res.data.page_token ?? undefined\n }\n } catch (err) {\n log(\"warn\", \"拉取群聊历史消息失败\", {\n chatId,\n error: err instanceof Error ? err.message : String(err),\n })\n }\n\n // API 返回倒序(最新在前),翻转为正序(最早在前)\n result.reverse()\n return result\n}\n\n/**\n * 将历史消息格式化为上下文文本\n */\nfunction formatHistoryAsContext(messages: HistoryMessage[]): string {\n const header = [\n \"[群聊历史上下文 - 以下是 bot 加入前的群聊记录,仅作为背景信息,无需回复]\",\n `消息数量: ${messages.length}`,\n \"---\",\n ].join(\"\\n\")\n\n const body = messages\n .map((m) => {\n const time = m.createTime\n ? new Date(Number(m.createTime)).toLocaleString(\"zh-CN\", { timeZone: \"Asia/Shanghai\" })\n : \"unknown\"\n const senderLabel = m.senderType === \"app\" ? \"[Bot]\" : `[${m.senderId}]`\n return `[${time}] ${senderLabel}: ${m.content}`\n })\n .join(\"\\n\")\n\n return `${header}\\n${body}`\n}\n","/**\n * OpenCode 飞书插件:通过飞书 WebSocket 长连接接入 OpenCode AI 对话\n */\nimport { readFileSync, existsSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { homedir } from \"node:os\"\nimport type { Plugin, Hooks } from \"@opencode-ai/plugin\"\nimport type { FeishuPluginConfig, ResolvedConfig, LogFn } from \"./types.js\"\nimport { startFeishuGateway, type FeishuGatewayResult } from \"./feishu/gateway.js\"\nimport { handleChat } from \"./handler/chat.js\"\nimport { handleEvent } from \"./handler/event.js\"\nimport { ingestGroupHistory } from \"./feishu/history.js\"\n\nconst SERVICE_NAME = \"opencode-feishu\"\n\nconst DEFAULT_CONFIG: Omit<ResolvedConfig, \"appId\" | \"appSecret\"> = {\n timeout: 120_000,\n thinkingDelay: 2_500,\n}\n\nexport const FeishuPlugin: Plugin = async (ctx) => {\n const { client } = ctx\n let gateway: FeishuGatewayResult | null = null\n let resolvedConfig: ResolvedConfig | null = null\n\n const log: LogFn = (level, message, extra) => {\n client.app.log({\n body: {\n service: SERVICE_NAME,\n level,\n message,\n extra,\n },\n }).catch(() => {\n // fallback: 如果 OpenCode 日志系统不可用,使用 console\n const payload = JSON.stringify({ service: SERVICE_NAME, level, message, ...extra, time: new Date().toISOString() })\n if (level === \"error\") console.error(payload)\n else console.log(payload)\n })\n }\n\n // ── 初始化:直接在插件函数体中执行(不依赖任何 hook) ──\n\n // 从 ~/.config/opencode/plugins/feishu.json 读取配置\n const configPath = join(homedir(), \".config\", \"opencode\", \"plugins\", \"feishu.json\")\n\n if (!existsSync(configPath)) {\n throw new Error(\n `缺少飞书配置文件:请创建 ${configPath},内容为 {\"appId\":\"cli_xxx\",\"appSecret\":\"xxx\"}`,\n )\n }\n\n let feishuRaw: FeishuPluginConfig\n try {\n feishuRaw = JSON.parse(readFileSync(configPath, \"utf-8\")) as FeishuPluginConfig\n } catch (parseErr) {\n throw new Error(`飞书配置文件格式错误:${configPath} 必须是合法的 JSON (${parseErr})`)\n }\n\n if (!feishuRaw.appId || !feishuRaw.appSecret) {\n throw new Error(\n `飞书配置不完整:${configPath} 中必须包含 appId 和 appSecret`,\n )\n }\n\n resolvedConfig = {\n appId: feishuRaw.appId,\n appSecret: feishuRaw.appSecret,\n timeout: feishuRaw.timeout ?? DEFAULT_CONFIG.timeout,\n thinkingDelay: feishuRaw.thinkingDelay ?? DEFAULT_CONFIG.thinkingDelay,\n }\n\n // 获取 bot open_id(用于群聊 @提及检测)\n const botOpenId = await fetchBotOpenId(resolvedConfig.appId, resolvedConfig.appSecret, log)\n\n // 启动飞书 WebSocket 网关\n gateway = startFeishuGateway({\n config: resolvedConfig,\n botOpenId,\n onMessage: async (msgCtx) => {\n if (!msgCtx.content.trim() || !gateway || !resolvedConfig) return\n await handleChat(msgCtx, {\n config: resolvedConfig,\n client,\n feishuClient: gateway.client,\n log,\n directory: ctx.directory,\n })\n },\n onBotAdded: (chatId) => {\n if (!gateway) return\n ingestGroupHistory(gateway.client, client, chatId, {\n maxMessages: 50,\n log,\n }).catch((err) => {\n log(\"error\", \"群聊历史摄入失败\", {\n chatId,\n error: err instanceof Error ? err.message : String(err),\n })\n })\n },\n log,\n })\n\n log(\"info\", \"飞书插件已初始化\", {\n appId: resolvedConfig.appId.slice(0, 8) + \"...\",\n botOpenId: botOpenId || \"(fallback mode)\",\n })\n\n // ── 返回 hooks ──\n const hooks: Hooks = {\n event: async ({ event }) => {\n if (!gateway) return\n await handleEvent(event, log)\n },\n }\n return hooks\n}\n\n/**\n * 获取 bot 自身的 open_id(用于群聊 @提及检测)\n */\nasync function fetchBotOpenId(appId: string, appSecret: string, log: LogFn): Promise<string> {\n try {\n const tokenRes = await fetch(\"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ app_id: appId, app_secret: appSecret }),\n })\n const tokenData = await tokenRes.json() as { tenant_access_token?: string }\n const token = tokenData?.tenant_access_token\n if (!token) {\n log(\"warn\", \"获取 tenant_access_token 失败,群聊 @提及检测将使用 fallback 模式\")\n return \"\"\n }\n\n const botRes = await fetch(\"https://open.feishu.cn/open-apis/bot/v3/info\", {\n method: \"GET\",\n headers: { Authorization: `Bearer ${token}` },\n })\n const botData = await botRes.json() as { bot?: { open_id?: string } }\n const openId = botData?.bot?.open_id\n if (openId) {\n log(\"info\", \"Bot open_id 获取成功\", { openId })\n return openId\n }\n log(\"warn\", \"Bot open_id 为空,群聊 @提及检测将使用 fallback 模式\")\n return \"\"\n } catch (err) {\n log(\"warn\", \"获取 bot open_id 失败,群聊 @提及检测将使用 fallback 模式\", {\n error: err instanceof Error ? err.message : String(err),\n })\n return \"\"\n }\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-feishu",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenCode 飞书插件 — 通过飞书 WebSocket 长连接接入 OpenCode AI 对话",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"dev": "tsup --watch",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"author": "NeverMore93",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/NeverMore93/opencode-feishu"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/NeverMore93/opencode-feishu#readme",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@larksuiteoapi/node-sdk": "^1.56.1",
|
|
33
|
+
"proxy-agent": "^6.5.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@opencode-ai/plugin": ">=1.1.0",
|
|
37
|
+
"@opencode-ai/sdk": ">=1.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@opencode-ai/plugin": "^1.1.53",
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"tsup": "^8.0.0",
|
|
43
|
+
"typescript": "^5.5.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20.0.0"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"opencode",
|
|
50
|
+
"opencode-plugin",
|
|
51
|
+
"feishu",
|
|
52
|
+
"lark",
|
|
53
|
+
"飞书",
|
|
54
|
+
"飞书机器人",
|
|
55
|
+
"ai",
|
|
56
|
+
"chatbot"
|
|
57
|
+
],
|
|
58
|
+
"license": "MIT"
|
|
59
|
+
}
|