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 CHANGED
@@ -1,73 +1,14 @@
1
- # OpenCode 飞书插件
1
+ # opencode-feishu
2
2
 
3
- **opencode-feishu** 是 [OpenCode](https://github.com/anomalyco/opencode) 的官方飞书插件(不是独立服务),通过飞书 WebSocket 长连接将飞书消息接入 OpenCode AI 对话。插件作为**消息中继**:所有消息(包括以 `/` 开头的命令)原样转发给 OpenCode,不解析命令、不选择模型或 Agent。
3
+ [![npm](https://img.shields.io/npm/v/opencode-feishu)](https://www.npmjs.com/package/opencode-feishu)
4
4
 
5
- npm 包地址:[https://www.npmjs.com/package/opencode-feishu](https://www.npmjs.com/package/opencode-feishu)
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
- ```bash
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
- 配置文件中必须包含 `appId` 和 `appSecret`,其余字段均有默认值(见[配置说明](#配置说明))。
96
-
97
- ---
30
+ 也支持通过环境变量注入敏感值(适合容器部署):
98
31
 
99
- ## 飞书开放平台配置
100
-
101
- ### 创建或打开应用
102
-
103
- 在浏览器打开 [飞书开放平台 - 应用列表](https://open.feishu.cn/app),创建或打开你的自建应用,记下 **App ID** 和 **App Secret**(在「凭证与基础信息」页面)。
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
- ```mermaid
146
- flowchart LR
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
- 配置文件路径:`~/.config/opencode/plugins/feishu.json`
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` | 发送「正在思考…」前的延迟(毫秒),设为 0 可禁用占位消息 |
181
- | `proxy` | string | 否 | | HTTP/HTTPS 代理地址(如 `http://127.0.0.1:7890`) |
182
-
183
- 完整配置示例:
184
-
185
- ```json
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
- | 群聊 + bot 未被 @提及 | 是 | **是** | **否** |
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 install
284
-
285
- # 一次性构建(生成 dist/)
286
- npm run build
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 SEEN_TTL_MS = 10 * 60 * 1e3;
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 > SEEN_TTL_MS) seen.delete(k);
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, POLL_INTERVAL_MS));
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 >= STABLE_POLLS) break;
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 = DEFAULT_MAX_MESSAGES, log } = options;
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 = JSON.parse(readFileSync(configPath, "utf-8"));
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: 50,
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",