opencode-feishu 0.3.3 → 0.3.6
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/README.md +45 -327
- package/dist/index.js +48 -12
- package/dist/index.js.map +1 -1
- package/package.json +53 -53
package/README.md
CHANGED
|
@@ -1,73 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-feishu
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/opencode-feishu)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 主要能力
|
|
10
|
-
|
|
11
|
-
- **飞书 WebSocket 长连接**:使用飞书「事件与回调」的「长连接」模式接收消息,无需配置公网 Webhook 地址。
|
|
12
|
-
- **OpenCode 对话中继**:将飞书消息转为 OpenCode 会话的 prompt,通过轮询获取回复并回写到飞书。
|
|
13
|
-
- **群聊静默监听**:始终将群消息转发给 OpenCode 积累上下文,仅在 bot 被直接 @提及时才回复,避免刷屏。
|
|
14
|
-
- **入群上下文摄入**:bot 被拉入群聊时,自动读取最近 50 条历史消息作为 OpenCode 对话上下文。
|
|
15
|
-
- **事件流实时更新**:通过插件 `event` 钩子接收 `message.part.updated` 事件,实时更新「正在思考…」占位消息。
|
|
16
|
-
|
|
17
|
-
---
|
|
5
|
+
[OpenCode](https://opencode.ai) 飞书插件 — 通过飞书 WebSocket 长连接将飞书消息接入 OpenCode AI 对话。
|
|
18
6
|
|
|
19
7
|
## 快速开始
|
|
20
8
|
|
|
21
|
-
###
|
|
22
|
-
|
|
23
|
-
- **Node.js** >= 20.0.0
|
|
24
|
-
- **OpenCode**:已安装并能正常使用(参见 [OpenCode 文档](https://github.com/anomalyco/opencode))
|
|
25
|
-
- **飞书自建应用**:在 [飞书开放平台](https://open.feishu.cn/app) 创建应用,获取 App ID 和 App Secret
|
|
26
|
-
|
|
27
|
-
### 方式一:从 npm 安装(推荐)
|
|
28
|
-
|
|
29
|
-
在 OpenCode 插件目录下安装:
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
# OpenCode 插件目录(Linux/macOS)
|
|
33
|
-
cd ~/.config/opencode/plugins
|
|
34
|
-
|
|
35
|
-
# 创建插件目录并安装
|
|
36
|
-
mkdir -p opencode-feishu
|
|
37
|
-
cd opencode-feishu
|
|
38
|
-
npm install opencode-feishu
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
### 方式二:从源码构建
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
git clone https://github.com/your-org/opencode-feishu.git
|
|
45
|
-
cd opencode-feishu
|
|
46
|
-
npm install
|
|
47
|
-
npm run build
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### 本地开发安装(junction/symlink)
|
|
51
|
-
|
|
52
|
-
构建完成后,将项目目录链接到 OpenCode 插件目录,避免每次修改都需要重新复制文件。
|
|
53
|
-
|
|
54
|
-
**Windows(使用 junction,无需管理员权限):**
|
|
55
|
-
|
|
56
|
-
```powershell
|
|
57
|
-
$source = "D:\path\to\opencode-feishu"
|
|
58
|
-
$target = "$env:USERPROFILE\.config\opencode\plugins\opencode-feishu"
|
|
59
|
-
New-Item -ItemType Junction -Path $target -Target $source
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
**Linux/macOS(使用 symlink):**
|
|
9
|
+
### 1. 配置 OpenCode 加载插件
|
|
63
10
|
|
|
64
|
-
|
|
65
|
-
ln -s /path/to/opencode-feishu ~/.config/opencode/plugins/opencode-feishu
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### 配置 OpenCode 加载插件
|
|
69
|
-
|
|
70
|
-
在 OpenCode 配置文件(`~/.config/opencode/opencode.json`)中声明插件:
|
|
11
|
+
在 `~/.config/opencode/opencode.json` 中添加:
|
|
71
12
|
|
|
72
13
|
```json
|
|
73
14
|
{
|
|
@@ -75,15 +16,9 @@ ln -s /path/to/opencode-feishu ~/.config/opencode/plugins/opencode-feishu
|
|
|
75
16
|
}
|
|
76
17
|
```
|
|
77
18
|
|
|
78
|
-
### 创建飞书配置文件
|
|
19
|
+
### 2. 创建飞书配置文件
|
|
79
20
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
```
|
|
83
|
-
~/.config/opencode/plugins/feishu.json
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
内容示例:
|
|
21
|
+
创建 `~/.config/opencode/plugins/feishu.json`:
|
|
87
22
|
|
|
88
23
|
```json
|
|
89
24
|
{
|
|
@@ -92,287 +27,70 @@ ln -s /path/to/opencode-feishu ~/.config/opencode/plugins/opencode-feishu
|
|
|
92
27
|
}
|
|
93
28
|
```
|
|
94
29
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
---
|
|
30
|
+
也支持通过环境变量注入敏感值(适合容器部署):
|
|
98
31
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
### 添加机器人能力
|
|
106
|
-
|
|
107
|
-
进入该应用 → 左侧「添加应用能力」→ 添加「机器人」。若已添加则跳过。
|
|
108
|
-
|
|
109
|
-
### 配置事件订阅
|
|
110
|
-
|
|
111
|
-
进入「事件与回调」页面,**不需要**填写「请求地址」:本插件使用 **WebSocket 长连接**接收消息,而非 Webhook。
|
|
112
|
-
|
|
113
|
-
在「事件订阅」中添加以下事件:
|
|
114
|
-
|
|
115
|
-
| 事件 | 说明 |
|
|
116
|
-
|------|------|
|
|
117
|
-
| `im.message.receive_v1` | 接收单聊与群聊消息 |
|
|
118
|
-
| `im.chat.member.bot.added_v1` | 机器人进群(触发历史上下文摄入) |
|
|
119
|
-
|
|
120
|
-
### 订阅方式:长连接(必选)
|
|
121
|
-
|
|
122
|
-
在「事件与回调」中,将**订阅方式**设置为「使用长连接接收事件/回调」。使用 Webhook 模式将无法收到消息。
|
|
123
|
-
|
|
124
|
-
### 配置权限
|
|
125
|
-
|
|
126
|
-
进入「权限管理」,搜索并开通以下权限:
|
|
127
|
-
|
|
128
|
-
| 权限 | 说明 |
|
|
129
|
-
|------|------|
|
|
130
|
-
| `im:message` | 获取与发送单聊、群组消息 |
|
|
131
|
-
| `im:message:send_as_bot` | 以应用身份发消息 |
|
|
132
|
-
| `im:chat` | 获取群组信息(群聊场景需要) |
|
|
133
|
-
| `im:message:readonly` | 获取群组中所有消息(入群历史摄入需要) |
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"appId": "${FEISHU_APP_ID}",
|
|
35
|
+
"appSecret": "${FEISHU_APP_SECRET}"
|
|
36
|
+
}
|
|
37
|
+
```
|
|
134
38
|
|
|
135
|
-
|
|
39
|
+
`${VAR_NAME}` 占位符会在启动时从 `process.env` 读取,未设置则报错。明文值直接使用。
|
|
136
40
|
|
|
137
|
-
###
|
|
41
|
+
### 3. 配置飞书应用
|
|
138
42
|
|
|
139
|
-
|
|
43
|
+
在 [飞书开放平台](https://open.feishu.cn/app) 创建自建应用,然后:
|
|
140
44
|
|
|
141
|
-
|
|
45
|
+
1. **添加机器人能力**
|
|
46
|
+
2. **事件订阅** — 添加 `im.message.receive_v1` 和 `im.chat.member.bot.added_v1`
|
|
47
|
+
3. **订阅方式** — 选择「使用长连接接收事件/回调」(不是 Webhook)
|
|
48
|
+
4. **权限** — 开通 `im:message`、`im:message:send_as_bot`、`im:chat`、`im:message:readonly`
|
|
49
|
+
5. **发布应用**
|
|
142
50
|
|
|
143
|
-
|
|
51
|
+
### 4. 启动 OpenCode
|
|
144
52
|
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
FeishuApp["飞书客户端"] -->|"WebSocket 长连接"| Gateway["gateway.ts\n(飞书网关)"]
|
|
148
|
-
Gateway --> Dedup["dedup.ts\n(消息去重)"]
|
|
149
|
-
Gateway --> GroupFilter["group-filter.ts\n(@提及检测)"]
|
|
150
|
-
Gateway -->|"bot.added 事件"| History["history.ts\n(历史摄入)"]
|
|
151
|
-
Gateway -->|"im.message.receive_v1"| Chat["chat.ts\n(对话处理)"]
|
|
152
|
-
Chat --> Session["session.ts\n(会话管理)"]
|
|
153
|
-
Chat -->|"prompt/messages"| OpenCodeAPI["OpenCode SDK"]
|
|
154
|
-
History -->|"noReply: true"| OpenCodeAPI
|
|
155
|
-
OpenCodeAPI --> EventHook["event 钩子\n(message.part.updated)"]
|
|
156
|
-
EventHook --> EventHandler["event.ts\n(事件处理)"]
|
|
157
|
-
EventHandler -->|"实时更新占位"| Sender["sender.ts\n(消息发送)"]
|
|
158
|
-
Chat -->|"最终回复"| Sender
|
|
159
|
-
Sender --> FeishuApp
|
|
53
|
+
```bash
|
|
54
|
+
opencode
|
|
160
55
|
```
|
|
161
56
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- **对话路径**:用户消息 → `gateway.ts` 接收 → `dedup.ts` 去重 → `group-filter.ts` 判断是否回复 → `chat.ts` 获取/创建 OpenCode 会话 → 发送 prompt → 轮询消息 → `sender.ts` 回写飞书
|
|
165
|
-
- **实时更新**:`chat.ts` 发送 prompt 后创建占位消息,OpenCode 通过 `event` 钩子推送 `message.part.updated` 事件,`event.ts` 处理后实时更新飞书占位消息内容
|
|
166
|
-
- **静默监听**:群聊中未被 @提及的消息通过 `noReply: true` 发送给 OpenCode,仅记录上下文但不触发 AI 回复
|
|
167
|
-
- **入群摄入**:`im.chat.member.bot.added_v1` 事件触发后,`history.ts` 拉取群聊最近 50 条历史消息并以 `noReply: true` 注入 OpenCode
|
|
168
|
-
|
|
169
|
-
---
|
|
57
|
+
插件自动安装并连接飞书 WebSocket。
|
|
170
58
|
|
|
171
59
|
## 配置说明
|
|
172
60
|
|
|
173
|
-
|
|
61
|
+
`~/.config/opencode/plugins/feishu.json` 完整配置:
|
|
174
62
|
|
|
175
63
|
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
|
176
64
|
|------|------|:----:|--------|------|
|
|
177
65
|
| `appId` | string | 是 | — | 飞书应用 App ID |
|
|
178
66
|
| `appSecret` | string | 是 | — | 飞书应用 App Secret |
|
|
179
|
-
| `timeout` | number | 否 | `120000` |
|
|
180
|
-
| `thinkingDelay` | number | 否 | `2500` |
|
|
181
|
-
| `
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
"appId": "cli_xxxxxxxxxxxx",
|
|
188
|
-
"appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
189
|
-
"timeout": 120000,
|
|
190
|
-
"thinkingDelay": 2500
|
|
191
|
-
}
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
---
|
|
195
|
-
|
|
196
|
-
## 日志
|
|
197
|
-
|
|
198
|
-
### Debug 日志文件
|
|
199
|
-
|
|
200
|
-
插件每次初始化时会创建(并覆盖)以下文件:
|
|
201
|
-
|
|
202
|
-
```
|
|
203
|
-
~/feishu-debug.log
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
该文件记录插件初始化过程中的详细信息,包含配置加载、bot open_id 获取、WebSocket 连接诊断等,排查问题时首先查看此文件。
|
|
207
|
-
|
|
208
|
-
### OpenCode 日志系统
|
|
209
|
-
|
|
210
|
-
插件通过 `client.app.log()` 将结构化日志输出到 OpenCode 日志系统:
|
|
211
|
-
|
|
212
|
-
- **service**:`opencode-feishu`
|
|
213
|
-
- **level**:`info`、`warn`、`error`
|
|
214
|
-
- 可在 OpenCode 的日志界面中按 service 过滤查看
|
|
215
|
-
|
|
216
|
-
### Fallback 行为
|
|
217
|
-
|
|
218
|
-
若 `client.app.log()` 调用失败(如 OpenCode 版本不兼容),插件会降级到 `console.log`/`console.error` 输出,日志格式为 JSON 行。
|
|
219
|
-
|
|
220
|
-
---
|
|
221
|
-
|
|
222
|
-
## 对话流程说明
|
|
223
|
-
|
|
224
|
-
- **会话获取/创建**:按飞书会话键(单聊 `feishu-p2p-<userId>`,群聊 `feishu-group-<chatId>`)查找已有 OpenCode 会话(按标题前缀匹配),若无则新建。
|
|
225
|
-
- **「正在思考…」**:发送用户消息后,延迟 `thinkingDelay`(默认 2500ms)再在飞书发送「正在思考…」占位消息;等待期间通过事件钩子实时更新内容,回复就绪后更新为最终内容。
|
|
226
|
-
- **轮询**:每 1500ms 拉取一次该 OpenCode 会话的消息列表,取最后一条 assistant 文本;若连续 2 次内容相同则视为稳定,结束轮询。
|
|
227
|
-
- **实时更新**:插件 `event` 钩子接收 `message.part.updated` 事件,实时将累积的回复内容更新到飞书占位消息。
|
|
228
|
-
- **超时**:若在 `timeout`(默认 120 秒)内未得到稳定回复,将返回「⚠️ 响应超时」并结束等待。
|
|
229
|
-
- **纯中继**:所有消息(包括以 `/` 开头的文本)均原样转发给 OpenCode,由 OpenCode 决定模型、Agent 与行为。
|
|
230
|
-
|
|
231
|
-
---
|
|
67
|
+
| `timeout` | number | 否 | `120000` | AI 响应超时(毫秒) |
|
|
68
|
+
| `thinkingDelay` | number | 否 | `2500` | 发送"正在思考…"前的延迟(毫秒),设为 0 禁用 |
|
|
69
|
+
| `logLevel` | string | 否 | `"info"` | 日志级别:fatal/error/warn/info/debug/trace |
|
|
70
|
+
| `maxHistoryMessages` | number | 否 | `200` | 入群时拉取历史消息的最大条数 |
|
|
71
|
+
| `pollInterval` | number | 否 | `1000` | 轮询 AI 响应的间隔(毫秒) |
|
|
72
|
+
| `stablePolls` | number | 否 | `3` | 连续几次轮询内容不变视为回复完成 |
|
|
73
|
+
| `dedupTtl` | number | 否 | `600000` | 消息去重缓存过期时间(毫秒) |
|
|
232
74
|
|
|
233
75
|
## 群聊行为
|
|
234
76
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
- 群聊中的**所有文本消息**都会转发给 OpenCode 作为对话上下文(使用 `noReply: true`,不触发 AI 回复,不消耗 AI tokens)。
|
|
238
|
-
- 仅在 bot 被**直接 @提及**时,才触发正常的 AI 对话并在飞书群内回复。
|
|
239
|
-
- 未被 @提及的消息在飞书侧完全无感——不会产生任何回复或可见的 bot 行为。
|
|
240
|
-
|
|
241
|
-
### 入群上下文摄入
|
|
242
|
-
|
|
243
|
-
- 当 bot 被**首次拉入群聊**时(触发 `im.chat.member.bot.added_v1` 事件),自动拉取该群最近 50 条历史消息。
|
|
244
|
-
- 历史消息格式化后以 `noReply: true` 发送给 OpenCode,作为对话的背景上下文。
|
|
245
|
-
- 后续当有人 @bot 提问时,AI 已拥有群聊的历史背景,可以给出更精准的回答。
|
|
246
|
-
|
|
247
|
-
### 行为矩阵
|
|
248
|
-
|
|
249
|
-
| 场景 | 发送到 OpenCode | noReply | 飞书回复 |
|
|
77
|
+
| 场景 | 发送到 OpenCode | AI 回复 | 飞书回复 |
|
|
250
78
|
|------|:---:|:---:|:---:|
|
|
251
|
-
|
|
|
252
|
-
| 群聊 + bot
|
|
253
|
-
|
|
|
254
|
-
| bot
|
|
255
|
-
|
|
256
|
-
### 群聊发送者身份
|
|
257
|
-
|
|
258
|
-
发往 OpenCode 的群聊消息会带上发送者身份前缀,便于 AI 区分是谁在说话:
|
|
259
|
-
|
|
260
|
-
```
|
|
261
|
-
[ou_xxxxxxxxxxxx]: 帮我看一下这个 bug
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
单聊不添加此前缀;历史摄入中的消息同样按 `[open_id]` 或 `[Bot]` 标识。
|
|
265
|
-
|
|
266
|
-
---
|
|
267
|
-
|
|
268
|
-
## 会话管理
|
|
269
|
-
|
|
270
|
-
- **会话键**:单聊为 `feishu-p2p-<发送者 userId>`,群聊为 `feishu-group-<群 chatId>`。
|
|
271
|
-
- **OpenCode 会话标题**:格式为 `Feishu-<sessionKey>-<时间戳>`,用于进程重启后按标题前缀恢复会话。
|
|
272
|
-
- **恢复**:进程重启后,通过「列出 OpenCode 会话 + 按标题前缀匹配」恢复对应飞书会话的 OpenCode 会话,找不到则新建。
|
|
273
|
-
- **注意**:若手动修改了 OpenCode 会话标题,重启后将无法恢复该会话,会自动新建一个。
|
|
79
|
+
| 单聊 | 是 | 是 | 是 |
|
|
80
|
+
| 群聊 + @bot | 是 | 是 | 是 |
|
|
81
|
+
| 群聊未 @bot | 是 | 否(静默积累上下文) | 否 |
|
|
82
|
+
| bot 入群 | 历史消息 | 否 | 否 |
|
|
274
83
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
## 开发指南
|
|
278
|
-
|
|
279
|
-
### 构建命令
|
|
84
|
+
## 开发
|
|
280
85
|
|
|
281
86
|
```bash
|
|
282
|
-
# 安装依赖
|
|
283
|
-
npm
|
|
284
|
-
|
|
285
|
-
#
|
|
286
|
-
npm
|
|
287
|
-
|
|
288
|
-
# 开发模式(监听文件变更并自动重新构建)
|
|
289
|
-
npm run dev
|
|
290
|
-
|
|
291
|
-
# 仅做类型检查,不生成文件
|
|
292
|
-
npm run typecheck
|
|
87
|
+
npm install # 安装依赖
|
|
88
|
+
npm run build # 构建
|
|
89
|
+
npm run dev # 开发模式(监听变更)
|
|
90
|
+
npm run typecheck # 类型检查
|
|
91
|
+
npm publish # 发布到 npm
|
|
293
92
|
```
|
|
294
93
|
|
|
295
|
-
### 发布
|
|
296
|
-
|
|
297
|
-
```bash
|
|
298
|
-
# 一键版本发布(交互式选择 patch/minor/major,自动 commit + tag + push)
|
|
299
|
-
npm run release
|
|
300
|
-
|
|
301
|
-
# 手动发布(prepublishOnly 自动执行构建+类型检查)
|
|
302
|
-
npm publish
|
|
303
|
-
|
|
304
|
-
# 干运行:查看将要发布的文件(不实际发布)
|
|
305
|
-
npm publish --dry-run
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
推送 `v*` tag 后 GitHub Actions 自动发布到 npm(需在 GitHub 仓库 Settings > Secrets 中配置 `NPM_TOKEN`)。
|
|
309
|
-
|
|
310
|
-
### 开发流程
|
|
311
|
-
|
|
312
|
-
1. 修改 `src/` 下的源文件
|
|
313
|
-
2. 运行 `npm run dev` 保持后台自动构建
|
|
314
|
-
3. 重启 OpenCode 使插件重新加载
|
|
315
|
-
4. 查看 `~/feishu-debug.log` 排查初始化问题
|
|
316
|
-
5. 在 OpenCode 日志界面按 `service=opencode-feishu` 过滤查看运行日志
|
|
317
|
-
|
|
318
|
-
### 添加新的事件处理器
|
|
319
|
-
|
|
320
|
-
1. 在 `src/feishu/gateway.ts` 中注册新的飞书事件类型
|
|
321
|
-
2. 在 `src/handler/` 中添加对应处理逻辑
|
|
322
|
-
3. 在 `src/index.ts` 中连接处理器到网关回调
|
|
323
|
-
|
|
324
|
-
### 调整轮询行为
|
|
325
|
-
|
|
326
|
-
在 `src/handler/chat.ts` 中修改以下常量:
|
|
327
|
-
|
|
328
|
-
```typescript
|
|
329
|
-
const POLL_INTERVAL_MS = 1500 // 轮询间隔
|
|
330
|
-
const STABLE_POLLS = 2 // 连续多少次相同内容视为稳定
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
---
|
|
334
|
-
|
|
335
|
-
## 项目结构
|
|
336
|
-
|
|
337
|
-
```
|
|
338
|
-
opencode-feishu/
|
|
339
|
-
├── src/
|
|
340
|
-
│ ├── index.ts # 插件入口:导出 FeishuPlugin,初始化配置/网关
|
|
341
|
-
│ ├── types.ts # 类型定义(FeishuPluginConfig, ResolvedConfig, LogFn)
|
|
342
|
-
│ ├── types/ws.d.ts # WebSocket 类型声明
|
|
343
|
-
│ ├── session.ts # 会话管理(查找/创建 OpenCode 会话,标题前缀匹配)
|
|
344
|
-
│ ├── feishu/
|
|
345
|
-
│ │ ├── gateway.ts # 飞书 WebSocket 网关、消息回调与 bot 入群事件
|
|
346
|
-
│ │ ├── sender.ts # 飞书消息发送、更新、删除
|
|
347
|
-
│ │ ├── dedup.ts # 消息去重(10 分钟窗口)
|
|
348
|
-
│ │ ├── group-filter.ts # 群聊 @提及检测(仅在 bot 被直接 @时回复)
|
|
349
|
-
│ │ └── history.ts # 入群历史上下文摄入
|
|
350
|
-
│ └── handler/
|
|
351
|
-
│ ├── chat.ts # 对话处理(prompt、轮询、回复)
|
|
352
|
-
│ └── event.ts # OpenCode 事件处理(message.part.updated 实时更新)
|
|
353
|
-
├── package.json
|
|
354
|
-
├── tsup.config.ts
|
|
355
|
-
└── README.md
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
---
|
|
359
|
-
|
|
360
|
-
## 常见问题与排查
|
|
361
|
-
|
|
362
|
-
| 现象 | 可能原因 | 处理 |
|
|
363
|
-
|------|----------|------|
|
|
364
|
-
| 启动报错「缺少飞书配置文件」 | 未创建配置文件或路径不对 | 在 `~/.config/opencode/plugins/feishu.json` 创建配置文件 |
|
|
365
|
-
| 启动报错「飞书配置不完整」 | `appId` 或 `appSecret` 字段缺失 | 检查配置文件中是否包含这两个字段 |
|
|
366
|
-
| 日志中「Bot open_id 为空」或「fallback 模式」 | bot info API 调用失败 | 检查飞书 App ID/Secret 是否正确;fallback 模式下任何 @提及都会触发回复 |
|
|
367
|
-
| 群聊中不回复 | 未 @提及 bot,或未使用长连接订阅方式 | 在群中 @bot 后发送消息;确认飞书开放平台订阅方式为「长连接」 |
|
|
368
|
-
| 入群后未摄入历史 | 未订阅 `im.chat.member.bot.added_v1` 事件,或缺少群消息读取权限 | 在飞书开放平台添加事件订阅并开通 `im:message:readonly` 权限 |
|
|
369
|
-
| 回复显示「⚠️ 响应超时」 | 等待时间超过 `timeout`,或 OpenCode/模型响应过慢 | 适当增大配置文件中的 `timeout` 值,或检查 OpenCode 与模型状态 |
|
|
370
|
-
| 同一条消息被处理多次 | 飞书 WebSocket 重复投递 | 插件对同一 `messageId` 在 10 分钟内自动去重,一般无需处理;如仍异常可查看 `feishu-debug.log` |
|
|
371
|
-
| 看不到插件日志 | OpenCode 版本不支持 `client.app.log()` | 插件会降级到 console 输出,查看 OpenCode 进程的标准输出 |
|
|
372
|
-
| 插件未被 OpenCode 加载 | `opencode.json` 未声明插件,或插件目录路径错误 | 确认 `~/.config/opencode/opencode.json` 中 `"plugin"` 数组包含 `"opencode-feishu"` |
|
|
373
|
-
|
|
374
|
-
---
|
|
375
|
-
|
|
376
94
|
## 许可证
|
|
377
95
|
|
|
378
96
|
MIT
|
package/dist/index.js
CHANGED
|
@@ -98744,12 +98744,15 @@ var EExecStatus;
|
|
|
98744
98744
|
})(EExecStatus || (EExecStatus = {}));
|
|
98745
98745
|
|
|
98746
98746
|
// src/feishu/dedup.ts
|
|
98747
|
-
var
|
|
98747
|
+
var seenTtlMs = 10 * 60 * 1e3;
|
|
98748
98748
|
var seen = /* @__PURE__ */ new Map();
|
|
98749
|
+
function initDedup(ttl) {
|
|
98750
|
+
seenTtlMs = ttl;
|
|
98751
|
+
}
|
|
98749
98752
|
function isDuplicate(messageId) {
|
|
98750
98753
|
const now = Date.now();
|
|
98751
98754
|
for (const [k, ts] of seen) {
|
|
98752
|
-
if (now - ts >
|
|
98755
|
+
if (now - ts > seenTtlMs) seen.delete(k);
|
|
98753
98756
|
}
|
|
98754
98757
|
if (!messageId) return false;
|
|
98755
98758
|
if (seen.has(messageId)) return true;
|
|
@@ -99011,8 +99014,6 @@ async function getOrCreateSession(client, sessionKey, directory) {
|
|
|
99011
99014
|
}
|
|
99012
99015
|
|
|
99013
99016
|
// src/handler/chat.ts
|
|
99014
|
-
var POLL_INTERVAL_MS = 1500;
|
|
99015
|
-
var STABLE_POLLS = 2;
|
|
99016
99017
|
async function handleChat(ctx, deps) {
|
|
99017
99018
|
const { content, chatId, chatType, senderId, createTime, shouldReply } = ctx;
|
|
99018
99019
|
if (!content.trim()) return;
|
|
@@ -99046,6 +99047,8 @@ async function handleChat(ctx, deps) {
|
|
|
99046
99047
|
}
|
|
99047
99048
|
const timeout = config.timeout;
|
|
99048
99049
|
const thinkingDelay = config.thinkingDelay;
|
|
99050
|
+
const pollInterval = config.pollInterval;
|
|
99051
|
+
const stablePolls = config.stablePolls;
|
|
99049
99052
|
let placeholderId = "";
|
|
99050
99053
|
let done = false;
|
|
99051
99054
|
const timer = thinkingDelay > 0 ? setTimeout(async () => {
|
|
@@ -99071,7 +99074,7 @@ async function handleChat(ctx, deps) {
|
|
|
99071
99074
|
let lastText = "";
|
|
99072
99075
|
let sameCount = 0;
|
|
99073
99076
|
while (Date.now() - start < timeout) {
|
|
99074
|
-
await new Promise((r) => setTimeout(r,
|
|
99077
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
99075
99078
|
const { data: messages } = await client.session.messages({ path: { id: session.id }, query });
|
|
99076
99079
|
const text = extractLastAssistantText(messages ?? []);
|
|
99077
99080
|
if (text && text !== lastText) {
|
|
@@ -99085,7 +99088,7 @@ async function handleChat(ctx, deps) {
|
|
|
99085
99088
|
}
|
|
99086
99089
|
} else if (text && text.length > 0) {
|
|
99087
99090
|
sameCount++;
|
|
99088
|
-
if (sameCount >=
|
|
99091
|
+
if (sameCount >= stablePolls) break;
|
|
99089
99092
|
}
|
|
99090
99093
|
}
|
|
99091
99094
|
const { data: finalMessages } = await client.session.messages({ path: { id: session.id }, query });
|
|
@@ -99121,10 +99124,9 @@ function extractLastAssistantText(messages) {
|
|
|
99121
99124
|
}
|
|
99122
99125
|
|
|
99123
99126
|
// src/feishu/history.ts
|
|
99124
|
-
var DEFAULT_MAX_MESSAGES = 50;
|
|
99125
99127
|
var DEFAULT_PAGE_SIZE = 50;
|
|
99126
99128
|
async function ingestGroupHistory(feishuClient, opencodeClient, chatId, options) {
|
|
99127
|
-
const { maxMessages
|
|
99129
|
+
const { maxMessages, log } = options;
|
|
99128
99130
|
log("info", "\u5F00\u59CB\u6444\u5165\u7FA4\u804A\u5386\u53F2\u4E0A\u4E0B\u6587", { chatId, maxMessages });
|
|
99129
99131
|
const messages = await fetchRecentMessages(feishuClient, chatId, maxMessages, log);
|
|
99130
99132
|
if (!messages.length) {
|
|
@@ -99211,7 +99213,11 @@ var isDebug = !!process.env.FEISHU_DEBUG;
|
|
|
99211
99213
|
var DEFAULT_CONFIG = {
|
|
99212
99214
|
timeout: 12e4,
|
|
99213
99215
|
thinkingDelay: 2500,
|
|
99214
|
-
logLevel: "info"
|
|
99216
|
+
logLevel: "info",
|
|
99217
|
+
maxHistoryMessages: 200,
|
|
99218
|
+
pollInterval: 1e3,
|
|
99219
|
+
stablePolls: 3,
|
|
99220
|
+
dedupTtl: 10 * 60 * 1e3
|
|
99215
99221
|
};
|
|
99216
99222
|
var FeishuPlugin = async (ctx) => {
|
|
99217
99223
|
const { client } = ctx;
|
|
@@ -99238,7 +99244,9 @@ var FeishuPlugin = async (ctx) => {
|
|
|
99238
99244
|
}
|
|
99239
99245
|
let feishuRaw;
|
|
99240
99246
|
try {
|
|
99241
|
-
feishuRaw =
|
|
99247
|
+
feishuRaw = resolveEnvPlaceholders(
|
|
99248
|
+
JSON.parse(readFileSync(configPath, "utf-8"))
|
|
99249
|
+
);
|
|
99242
99250
|
} catch (parseErr) {
|
|
99243
99251
|
throw new Error(`\u98DE\u4E66\u914D\u7F6E\u6587\u4EF6\u683C\u5F0F\u9519\u8BEF\uFF1A${configPath} \u5FC5\u987B\u662F\u5408\u6CD5\u7684 JSON (${parseErr})`);
|
|
99244
99252
|
}
|
|
@@ -99252,8 +99260,13 @@ var FeishuPlugin = async (ctx) => {
|
|
|
99252
99260
|
appSecret: feishuRaw.appSecret,
|
|
99253
99261
|
timeout: feishuRaw.timeout ?? DEFAULT_CONFIG.timeout,
|
|
99254
99262
|
thinkingDelay: feishuRaw.thinkingDelay ?? DEFAULT_CONFIG.thinkingDelay,
|
|
99255
|
-
logLevel: feishuRaw.logLevel ?? DEFAULT_CONFIG.logLevel
|
|
99263
|
+
logLevel: feishuRaw.logLevel ?? DEFAULT_CONFIG.logLevel,
|
|
99264
|
+
maxHistoryMessages: feishuRaw.maxHistoryMessages ?? DEFAULT_CONFIG.maxHistoryMessages,
|
|
99265
|
+
pollInterval: feishuRaw.pollInterval ?? DEFAULT_CONFIG.pollInterval,
|
|
99266
|
+
stablePolls: feishuRaw.stablePolls ?? DEFAULT_CONFIG.stablePolls,
|
|
99267
|
+
dedupTtl: feishuRaw.dedupTtl ?? DEFAULT_CONFIG.dedupTtl
|
|
99256
99268
|
};
|
|
99269
|
+
initDedup(resolvedConfig.dedupTtl);
|
|
99257
99270
|
const botOpenId = await fetchBotOpenId(resolvedConfig.appId, resolvedConfig.appSecret, log);
|
|
99258
99271
|
gateway = startFeishuGateway({
|
|
99259
99272
|
config: resolvedConfig,
|
|
@@ -99271,7 +99284,7 @@ var FeishuPlugin = async (ctx) => {
|
|
|
99271
99284
|
onBotAdded: (chatId) => {
|
|
99272
99285
|
if (!gateway) return;
|
|
99273
99286
|
ingestGroupHistory(gateway.client, client, chatId, {
|
|
99274
|
-
maxMessages:
|
|
99287
|
+
maxMessages: resolvedConfig.maxHistoryMessages,
|
|
99275
99288
|
log
|
|
99276
99289
|
}).catch((err) => {
|
|
99277
99290
|
log("error", "\u7FA4\u804A\u5386\u53F2\u6444\u5165\u5931\u8D25", {
|
|
@@ -99294,6 +99307,29 @@ var FeishuPlugin = async (ctx) => {
|
|
|
99294
99307
|
};
|
|
99295
99308
|
return hooks;
|
|
99296
99309
|
};
|
|
99310
|
+
function resolveEnvPlaceholders(obj) {
|
|
99311
|
+
if (typeof obj === "string") {
|
|
99312
|
+
if (!obj.includes("${")) return obj;
|
|
99313
|
+
return obj.replace(/\$\{(\w+)\}/g, (_match, name) => {
|
|
99314
|
+
const val = process.env[name];
|
|
99315
|
+
if (val === void 0) {
|
|
99316
|
+
throw new Error(`\u73AF\u5883\u53D8\u91CF ${name} \u672A\u8BBE\u7F6E\uFF08\u914D\u7F6E\u503C\u5F15\u7528\u4E86 \${${name}}\uFF09`);
|
|
99317
|
+
}
|
|
99318
|
+
return val;
|
|
99319
|
+
});
|
|
99320
|
+
}
|
|
99321
|
+
if (Array.isArray(obj)) {
|
|
99322
|
+
return obj.map(resolveEnvPlaceholders);
|
|
99323
|
+
}
|
|
99324
|
+
if (obj !== null && typeof obj === "object") {
|
|
99325
|
+
const result = {};
|
|
99326
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
99327
|
+
result[key] = resolveEnvPlaceholders(value);
|
|
99328
|
+
}
|
|
99329
|
+
return result;
|
|
99330
|
+
}
|
|
99331
|
+
return obj;
|
|
99332
|
+
}
|
|
99297
99333
|
async function fetchBotOpenId(appId, appSecret, log) {
|
|
99298
99334
|
const tokenRes = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
99299
99335
|
method: "POST",
|