opencode-feishu-plugin 0.2.10 → 0.2.12

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.en.md CHANGED
@@ -22,22 +22,29 @@ Connect [OpenCode](https://opencode.ai) to Feishu/Lark: **one Feishu topic = one
22
22
  | 📊 **Real-time visible** | "Thinking" receipt → live tool-call cards → streaming text updates; footer shows the current model |
23
23
  | ⏹ **Controllable** | Every reply card has a "force stop" button; a watchdog auto-interrupts stuck sessions; native queue with `/steer` `/now` to cut in |
24
24
  | 📎 **Images / files** | Images and files sent in Feishu are downloaded and attached to the session, so vision/file-capable models can see and read them |
25
+ | 🖱 **Main-window menu** | Two fixed quick buttons above the chat input — "New session" and "Session list" (bot custom menu) — one tap to fire them |
25
26
  | 🚫 **No ports** | Full long-connection; no inbound port needed on the server |
26
27
 
27
28
  ## 1. Feishu app setup (~3 minutes)
28
29
 
29
30
  1. Open the [Feishu Open Platform](https://open.feishu.cn/app) → **Create an enterprise self-built app**.
30
31
  2. **Add app capability → Bot**.
31
- 3. **Permission management** — enable these two minimal scopes:
32
+ 3. **Permission management → API permissions**: enable these two **required** scopes first:
32
33
  - `im:message.p2p_msg:readonly` — read messages users send to the bot in p2p chat
33
34
  - `im:message:send_as_bot` — send messages as the app (also used to update cards)
34
35
 
35
- To **receive images / files** (downloaded and attached to sessions), add one more:
36
- - `im:message:readonly` — fetch message resources (required to download images / files)
36
+ **To receive images / files, add one more** (skip if you don't need it):
37
+ - `im:message:readonly` — fetch message resources (**required** to download images / files)
38
+
39
+ > ⚠️ Without `im:message:readonly`: images/files are **not downloaded** — the message still reaches the AI, but with placeholder text ("…download failed…"). After enabling it, **re-create and publish an app version** for it to take effect.
37
40
  4. **Events & Callbacks → Event configuration**: subscription method **"Use long connection to receive events"** (do **not** pick Webhook), add event `im.message.receive_v1`.
38
41
  5. **Events & Callbacks → Callback configuration**: same long-connection method, add callback `card.action.trigger` (zero permission requirement).
39
- 6. **Version management & release**: availability = **only yourself**, create a version and **publish**. ⚠️ Without publishing the app stays in "development" state and the long connection cannot connect — the bot will never respond.
40
- 7. Note down the **App ID** (`cli_…`) and **App Secret**.
42
+ 6. **Bot menu (optional, recommended)**: **App capabilities → Bot → Bot custom menu** — enable the menu, pick the **floating menu** style, and add two items (any name/icon, **action = push event**):
43
+ - "➕ New session" → `event_key` = `new`
44
+ - "📋 Session list" → `event_key` = `sessions`
45
+ Then add the event `application.bot.menu_v6` under **Event configuration** (zero permission requirement). Result: two quick buttons above the chat input that act like sending `/new` / `/sessions`.
46
+ 7. **Version management & release**: availability = **only yourself**, create a version and **publish**. ⚠️ Without publishing the app stays in "development" state and the long connection cannot connect — the bot will never respond.
47
+ 8. Note down the **App ID** (`cli_…`) and **App Secret**.
41
48
 
42
49
  > **Why no group permission?** This plugin is a "single-user remote control". Without group permission the bot **physically cannot receive group messages** — the single-user boundary is guaranteed by the platform scope layer, not only by code.
43
50
 
@@ -104,6 +111,22 @@ The main chat **only manages**; plain text never enters any session.
104
111
 
105
112
  ![Create-session form card: directory, model, permission](image/new.png) ![Session list card: paginate, enter/reopen, create](image/sessions.png)
106
113
 
114
+ ### Bot menu (quick buttons above the input box)
115
+
116
+ ![Configure the bot custom menu in the developer console (floating menu): the phone preview shows "new" / "sessions" pinned above the input box; the right panel is the item config (action = push event)](image/btns.png)
117
+
118
+ After configuring the menu (setup step 6), two quick buttons stay pinned **above the chat input** in the bot window:
119
+
120
+ | Button | Equivalent command | Effect |
121
+ |---|---|---|
122
+ | `new` | `/new` | Opens the create-session form card |
123
+ | `sessions` | `/sessions` | Opens the session list card |
124
+
125
+ - Menu `event_key`s must be **`new` / `sessions`** (the literal `/new`, `/sessions` also accepted);
126
+ - Clicks arrive over the long connection (`application.bot.menu_v6`, zero permission requirement); the plugin synthesizes an **equivalent command message** — allowlist, dedup and command matrix behave exactly like typed commands; unknown keys are ignored silently;
127
+ - After changing the menu, **re-publish the app version** (per the console: takes effect within ~5 minutes after publishing);
128
+ - Message the bot at least once before using the menu (the plugin remembers the p2p chat from messages; any normal usage satisfies this).
129
+
107
130
  ### Inside a topic (work)
108
131
 
109
132
  A topic = a session; sending plain text is giving the AI a command.
@@ -150,7 +173,7 @@ When the agent asks via `question` or other form tools, the form becomes a Feish
150
173
  | `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | Log level |
151
174
  | `logFile` | string \| boolean | — | `true` = write `<configDir>/plugins/feishu.log`; recommended in server mode |
152
175
  | `approvalTtlMs` | number | `600000` | Approval token / card validity |
153
- | `staleExecutionMs` | number | `300000` | Watchdog threshold (1–60 min) |
176
+ | `staleExecutionMs` | number | `300000` | Watchdog threshold (0–60 min; **0 = disabled**; sessions waiting on a form / pending approval are never killed) |
154
177
  | `gatewayLocation` | string | — | Only start the gateway at this location (or its subdirectories); empty = any location |
155
178
 
156
179
  Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepalive*`, `gatewayMatchGraceMs`) → [docs/advanced.en.md](./docs/advanced.en.md#full-configuration).
@@ -160,11 +183,12 @@ Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepa
160
183
  | Symptom | Fix |
161
184
  |---|---|
162
185
  | No response to messages | ① Is the app **published** and does availability include you? ② Is the subscription **long connection** (not Webhook)? ③ Is `im:message.p2p_msg:readonly` enabled? |
186
+ | Images / files not received (placeholder / "download failed") | Enable `im:message:readonly` (Permission management → API permissions, search "fetch message resources") → **re-create and publish an app version**; check the `feishu.log` "附件下载失败" entry for the exact reason |
163
187
  | `feishu.json` changes don't apply | Check the path, then `opencode reload` |
164
188
  | Plugin not loaded at all | npm: confirm the package name is in the `plugins` array; directory: confirm `plugins/<name>/index.js` exists |
165
189
  | No approval cards | That session wasn't started from Feishu (no mapping); by design the plugin doesn't take it over |
166
190
  | Button click says invalid credential | Token expired (10 min default) or clicker not in allowlist |
167
- | Session seems stuck, messages only queue | Watchdog auto-interrupts after 5 min; or tap "⏹ force stop" / send `/stop` |
191
+ | Session seems stuck, messages only queue | Watchdog auto-interrupts after 5 min (never while a form / approval is pending; `staleExecutionMs: 0` disables it); or tap "⏹ force stop" / send `/stop` |
168
192
  | Bot goes silent after ~1h idle | opencode recycles idle locations; built-in keepalive restores automatically, see [docs/advanced.en.md](./docs/advanced.en.md#location-keep-alive) |
169
193
  | Can't see plugin logs | stderr is discarded in server mode; set `logFile: true` |
170
194
  | Multiple long connections / duplicate replies | Set `gatewayLocation` to a common working directory, see [docs/advanced.en.md](./docs/advanced.en.md#multiple-instances-and-gateway-election) |
package/README.md CHANGED
@@ -22,22 +22,29 @@
22
22
  | 📊 **实时可见** | 「思考中」回执 → 工具调用实时上卡 → 文本流式更新,页脚显示当前模型 |
23
23
  | ⏹ **可控可停** | 每张回复卡带「强制停止」;看门狗自动中断卡死会话;忙时原生排队,`/steer` `/now` 可插队 |
24
24
  | 📎 **图片 / 文件** | 飞书里的图片 / 文件自动下载并挂进会话,支持视觉 / 文件的模型直接看图、读文件 |
25
+ | 🖱 **主窗口菜单** | 机器人输入框上固定「新建会话」「会话列表」快捷按钮(机器人自定义菜单),一点即用 |
25
26
  | 🚫 **无端口** | 全程长连接,服务器无需开放任何入站端口 |
26
27
 
27
28
  ## 一、飞书后台配置(约 3 分钟)
28
29
 
29
30
  1. 打开 [飞书开放平台](https://open.feishu.cn/app) → **创建企业自建应用**。
30
31
  2. **添加应用能力 → 机器人**。
31
- 3. **权限管理**,开通这两个最小 scope:
32
+ 3. **权限管理 → API 权限**:先开这两个**必开** scope:
32
33
  - `im:message.p2p_msg:readonly` —— 读取用户发给机器人的单聊消息
33
34
  - `im:message:send_as_bot` —— 以应用身份发消息(也用于更新卡片)
34
35
 
35
- 如需**接收图片 / 文件**(下载后挂进会话),再加开一个:
36
- - `im:message:readonly` —— 获取消息中的资源文件(图片 / 文件下载的必要条件)
36
+ **接收图片 / 文件需要再加开一个**(不需要该功能可跳过):
37
+ - `im:message:readonly` —— 获取消息中的资源文件(图片 / 文件下载的**必要条件**)
38
+
39
+ > ⚠️ 未开通 `im:message:readonly` 时:图片/文件**不会被下载**,消息照常送达 AI,但只带占位文本("…下载失败:…")。开通后要**重新创建版本并发布**才生效。
37
40
  4. **事件与回调 → 事件配置**:订阅方式选**「使用长连接接收事件」**(不要选 Webhook),添加事件 `im.message.receive_v1`。
38
41
  5. **事件与回调 → 回调配置**:订阅方式同样选**长连接**,添加回调 `card.action.trigger`(零权限要求)。
39
- 6. **版本管理与发布**:可用范围 = **仅本人**,创建版本并**发布**。⚠️ 不发布就是开发态,长连接连不上,机器人不会有任何反应。
40
- 7. 记下 **App ID**(`cli_…`)与 **App Secret**。
42
+ 6. **机器人菜单(可选,推荐)**:**应用能力 → 机器人 → 机器人自定义菜单** 开启菜单,展示形式选**悬浮菜单**,添加两个菜单项(名称/图标随意,**响应动作=推送事件**):
43
+ - 「➕ 新建会话」→ 事件 Key = `new`
44
+ - 「📋 会话列表」→ 事件 Key = `sessions`
45
+ 再到 **事件配置** 添加事件 `application.bot.menu_v6`(零权限要求)。效果:机器人输入框上多两个快捷按钮,点一下等于发 `/new` / `/sessions`。
46
+ 7. **版本管理与发布**:可用范围 = **仅本人**,创建版本并**发布**。⚠️ 不发布就是开发态,长连接连不上,机器人不会有任何反应。
47
+ 8. 记下 **App ID**(`cli_…`)与 **App Secret**。
41
48
 
42
49
  > **为什么不申请群权限?** 本插件是"一个人的遥控台"。不申请群权限,机器人**物理上收不到群消息**,单人边界由平台 scope 层保证,而不是只靠代码判断。
43
50
 
@@ -104,6 +111,22 @@ tail -f ~/.config/opencode/plugins/feishu.log # 应看到「飞书长连接已
104
111
 
105
112
  ![建会话表单卡:目录、模型、权限一次填完](image/new.png) ![会话列表卡片:翻页、进入/再开、新建](image/sessions.png)
106
113
 
114
+ ### 机器人菜单(输入框快捷按钮)
115
+
116
+ ![开发者后台配置机器人自定义菜单(悬浮菜单):手机端预览里输入框上方固定 new / sessions 两个按钮,右侧为菜单项配置(响应动作=推送事件)](image/btns.png)
117
+
118
+ 按「一、飞书后台配置」第 6 步配好菜单后,机器人聊天窗口的**输入框上方**会常驻两个快捷按钮:
119
+
120
+ | 按钮 | 等价命令 | 效果 |
121
+ |---|---|---|
122
+ | `new` | `/new` | 弹出建会话表单卡 |
123
+ | `sessions` | `/sessions` | 弹出会话列表卡 |
124
+
125
+ - 菜单项的事件 Key 需配置为 **`new` / `sessions`**(也兼容直接把 `/new`、`/sessions` 填成 Key);
126
+ - 点击事件走长连接(`application.bot.menu_v6`,零权限要求),插件把它合成一条**等价命令消息**处理——白名单、去重、命令矩阵与手输完全一致;未知 Key 静默忽略;
127
+ - 菜单配置修改后需**重新发布版本**(官方说明:发版成功后约 5 分钟内生效);
128
+ - 首次使用菜单前,先给机器人发过至少一条消息(插件据消息记住单聊会话;正常使用必然满足)。
129
+
107
130
  ### 话题内(干活)
108
131
 
109
132
  一个话题 = 一个会话,发普通文本就是给 AI 下指令。
@@ -150,7 +173,7 @@ agent 反问时表单会变成飞书卡片,**点按钮或在话题里直接发
150
173
  | `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | 日志级别 |
151
174
  | `logFile` | string \| boolean | — | `true` = 写 `<configDir>/plugins/feishu.log`,服务模式建议开启 |
152
175
  | `approvalTtlMs` | number | `600000` | 审批 token / 卡片有效期 |
153
- | `staleExecutionMs` | number | `300000` | 看门狗阈值(1–60 分钟) |
176
+ | `staleExecutionMs` | number | `300000` | 看门狗阈值(0–60 分钟;**0 = 关闭看门狗**;待答表单 / 未决审批期间不判卡死) |
154
177
  | `gatewayLocation` | string | — | 只在该 location(及其子目录)启动网关;留空 = 任意 location 生效 |
155
178
 
156
179
  完整配置(含 `cardMaxTables`、`topicStatus*`、`resumeSummary*`、`keepalive*`、`gatewayMatchGraceMs` 等进阶项)见 [docs/advanced.md](./docs/advanced.md#完整配置项)。
@@ -160,11 +183,12 @@ agent 反问时表单会变成飞书卡片,**点按钮或在话题里直接发
160
183
  | 现象 | 处理 |
161
184
  |---|---|
162
185
  | 发消息没反应 | ① 应用是否**已发布**、可用范围是否勾了你;② 订阅是否选了**长连接**(不是 Webhook);③ 是否开通 `im:message.p2p_msg:readonly` |
186
+ | 图片 / 文件收不到(只显示占位 / “下载失败”) | 开通 `im:message:readonly`(权限管理 → API 权限,搜“获取消息中的资源文件”)→ **重新创建版本并发布**;具体失败原因看 `feishu.log` 的「附件下载失败」 |
163
187
  | 改了 `feishu.json` 不生效 | 确认路径,然后 `opencode reload` |
164
188
  | 插件完全没被加载 | npm 方式确认包名在 `plugins` 数组;目录方式确认 `plugins/<名>/index.js` 存在 |
165
189
  | 审批卡收不到 | 该会话不是从飞书发起的(无映射),插件按设计不接管 |
166
190
  | 点按钮提示凭证无效 | token 过期(默认 10 分钟)或点击者不在白名单 |
167
- | 会话像卡死、只排队 | 看门狗默认 5 分钟后自动中断;也可点「⏹ 强制停止」或发 `/stop` |
191
+ | 会话像卡死、只排队 | 看门狗默认 5 分钟后自动中断(**待答表单 / 未决审批期间不中断**;`staleExecutionMs: 0` 可关闭);也可点「⏹ 强制停止」或发 `/stop` |
168
192
  | 空闲约 1 小时后失联 | opencode 会回收空闲 location;内置保活默认开启会自动恢复,见 [docs/advanced.md](./docs/advanced.md#位置保活) |
169
193
  | 看不到插件日志 | 服务模式下 stderr 被丢弃,设 `logFile: true` |
170
194
  | 多个长连接 / 重复回复 | 设置 `gatewayLocation` 为常用工作目录,见 [docs/advanced.md](./docs/advanced.md#多实例与网关选举) |
package/dist/index.js CHANGED
@@ -144008,7 +144008,7 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144008
144008
  const topicGuidance = asBoolean(merged.topicGuidance, true);
144009
144009
  const throttle2 = clamp4(asNumber(merged.streamThrottleMs, 400), 400, 6e4);
144010
144010
  const approvalTtlMs = clamp4(asNumber(merged.approvalTtlMs, 10 * 60 * 1e3), 3e4, 24 * 60 * 60 * 1e3);
144011
- const staleExecutionMs = clamp4(asNumber(merged.staleExecutionMs, 5 * 60 * 1e3), 6e4, 60 * 60 * 1e3);
144011
+ const staleExecutionMs = clamp4(asNumber(merged.staleExecutionMs, 5 * 60 * 1e3), 0, 60 * 60 * 1e3);
144012
144012
  const maxResourcesShown = clamp4(asNumber(merged.maxResourcesShown, 8), 1, 50);
144013
144013
  const allowedRootsRaw = asStringArray(merged.allowedRoots);
144014
144014
  const allowedRootsCandidates = (allowedRootsRaw.length > 0 ? allowedRootsRaw : [...DEFAULT_ALLOWED_ROOTS]).filter(isAbsolute);
@@ -144507,6 +144507,18 @@ var TtlMap = class {
144507
144507
  }
144508
144508
  this.data.delete(key);
144509
144509
  }
144510
+ /** 遍历未过期条目(惰性清理已过期项;用于按会话反查 pending 状态)。 */
144511
+ entries() {
144512
+ const out = [];
144513
+ for (const [key, hit] of this.data) {
144514
+ if (hit.expiresAt <= this.now()) {
144515
+ this.delete(key);
144516
+ continue;
144517
+ }
144518
+ out.push([key, hit.value]);
144519
+ }
144520
+ return out;
144521
+ }
144510
144522
  clear() {
144511
144523
  for (const timer of this.timers.values()) clearTimeout(timer);
144512
144524
  this.timers.clear();
@@ -144767,6 +144779,17 @@ function parseIncomingMessage(data) {
144767
144779
  ...str(message.parent_id) ? { parentId: str(message.parent_id) } : {}
144768
144780
  };
144769
144781
  }
144782
+ function parseBotMenuEvent(data) {
144783
+ if (!isRecord2(data)) return void 0;
144784
+ const header = isRecord2(data.header) ? data.header : {};
144785
+ const event = isRecord2(data.event) ? data.event : data;
144786
+ const operator = isRecord2(event.operator) ? event.operator : {};
144787
+ const operatorId = isRecord2(operator.operator_id) ? operator.operator_id : {};
144788
+ const operatorOpenId = str(operatorId.open_id);
144789
+ const eventKey = str(event.event_key);
144790
+ if (!operatorOpenId || !eventKey) return void 0;
144791
+ return { eventId: str(header.event_id) || str(data.event_id), eventKey, operatorOpenId };
144792
+ }
144770
144793
  function parseCardAction(data) {
144771
144794
  if (!isRecord2(data)) return void 0;
144772
144795
  const context3 = isRecord2(data.context) ? data.context : {};
@@ -144826,7 +144849,17 @@ function describeCardActionEvent(data) {
144826
144849
  // src/feishu/gateway.ts
144827
144850
  function startGateway(options2) {
144828
144851
  const { log: log2 } = options2;
144829
- const dispatcher = new EventDispatcher({}).register({
144852
+ const larkLogger = (tag2) => ({
144853
+ error: (...msg) => log2.error(tag2, { msg: safeArgs(msg) }),
144854
+ warn: (...msg) => log2.warn(tag2, { msg: safeArgs(msg) }),
144855
+ info: (...msg) => log2.debug(tag2, { msg: safeArgs(msg) }),
144856
+ debug: (...msg) => log2.debug(tag2, { msg: safeArgs(msg) }),
144857
+ trace: (...msg) => log2.debug(tag2, { msg: safeArgs(msg) })
144858
+ });
144859
+ const dispatcher = new EventDispatcher({
144860
+ loggerLevel: options2.logLevel === "debug" ? LoggerLevel.debug : LoggerLevel.info,
144861
+ logger: larkLogger("lark.dispatcher")
144862
+ }).register({
144830
144863
  "im.message.receive_v1": (data) => {
144831
144864
  try {
144832
144865
  const message = parseIncomingMessage(data);
@@ -144858,6 +144891,27 @@ function startGateway(options2) {
144858
144891
  log2.error("\u5361\u7247\u56DE\u8C03\u5904\u7406\u5F02\u5E38", { error: errorMessage(err2) });
144859
144892
  return { toast: { type: "error", content: "\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5" } };
144860
144893
  }
144894
+ },
144895
+ "application.bot.menu_v6": (data) => {
144896
+ try {
144897
+ const click = parseBotMenuEvent(data);
144898
+ if (!click) {
144899
+ log2.debug("\u83DC\u5355\u4E8B\u4EF6\u89E3\u6790\u5931\u8D25", {
144900
+ shape: typeof data === "object" && data !== null ? Object.keys(data).slice(0, 20) : typeof data
144901
+ });
144902
+ return;
144903
+ }
144904
+ log2.debug("\u6536\u5230\u673A\u5668\u4EBA\u83DC\u5355\u70B9\u51FB", {
144905
+ eventKey: click.eventKey,
144906
+ operator: maskId(click.operatorOpenId)
144907
+ });
144908
+ if (!options2.onBotMenu) return;
144909
+ void Promise.resolve(options2.onBotMenu(click)).catch((err2) => {
144910
+ log2.error("\u83DC\u5355\u70B9\u51FB\u5904\u7406\u5931\u8D25", { eventKey: click.eventKey, error: errorMessage(err2) });
144911
+ });
144912
+ } catch (err2) {
144913
+ log2.error("\u83DC\u5355 handler \u5F02\u5E38", { error: errorMessage(err2) });
144914
+ }
144861
144915
  }
144862
144916
  });
144863
144917
  const loggerLevel = options2.logLevel === "debug" ? LoggerLevel.debug : LoggerLevel.warn;
@@ -144866,13 +144920,7 @@ function startGateway(options2) {
144866
144920
  appSecret: options2.appSecret,
144867
144921
  domain: options2.domain === "lark" ? Domain.Lark : Domain.Feishu,
144868
144922
  loggerLevel,
144869
- logger: {
144870
- error: (...msg) => log2.error("lark.ws", { msg: safeArgs(msg) }),
144871
- warn: (...msg) => log2.warn("lark.ws", { msg: safeArgs(msg) }),
144872
- info: (...msg) => log2.debug("lark.ws", { msg: safeArgs(msg) }),
144873
- debug: (...msg) => log2.debug("lark.ws", { msg: safeArgs(msg) }),
144874
- trace: (...msg) => log2.debug("lark.ws", { msg: safeArgs(msg) })
144875
- }
144923
+ logger: larkLogger("lark.ws")
144876
144924
  });
144877
144925
  void wsClient.start({ eventDispatcher: dispatcher }).catch((err2) => {
144878
144926
  log2.error("\u98DE\u4E66\u957F\u8FDE\u63A5\u542F\u52A8\u5931\u8D25", { error: errorMessage(err2) });
@@ -145904,14 +145952,17 @@ var ExecutionTracker = class {
145904
145952
  /**
145905
145953
  * 返回「正在跑但超过 maxIdleMs 无任何活动」的 sessionID,并将其从运行态移除。
145906
145954
  * 用于插件层看门狗:事件丢失或交互工具(question/permission)永久挂起时兜底放开排队。
145955
+ *
145956
+ * `shouldSkip` 返回 true 的会话视为**合法等待**(如待答表单 / 未决审批):
145957
+ * 既不返回、也不移出运行态(避免在等待期被误杀,也避免丢追踪导致后续排队判定失真)。
145907
145958
  */
145908
- stale(maxIdleMs, now3 = Date.now()) {
145959
+ stale(maxIdleMs, now3 = Date.now(), shouldSkip) {
145909
145960
  const out = [];
145910
145961
  for (const [sessionID, at] of this.running) {
145911
- if (now3 - at >= maxIdleMs) {
145912
- this.running.delete(sessionID);
145913
- out.push(sessionID);
145914
- }
145962
+ if (now3 - at < maxIdleMs) continue;
145963
+ if (shouldSkip?.(sessionID)) continue;
145964
+ this.running.delete(sessionID);
145965
+ out.push(sessionID);
145915
145966
  }
145916
145967
  return out;
145917
145968
  }
@@ -145919,6 +145970,41 @@ var ExecutionTracker = class {
145919
145970
  this.running.clear();
145920
145971
  }
145921
145972
  };
145973
+ var SessionParentLinks = class {
145974
+ constructor(maxEntries = 2e3) {
145975
+ this.maxEntries = maxEntries;
145976
+ }
145977
+ maxEntries;
145978
+ parents = /* @__PURE__ */ new Map();
145979
+ /** 登记/清理某会话的父链接(无 parentID = 顶级会话,清掉旧链接)。 */
145980
+ remember(sessionID, parentID) {
145981
+ if (!sessionID) return;
145982
+ if (!parentID) {
145983
+ this.parents.delete(sessionID);
145984
+ return;
145985
+ }
145986
+ this.parents.delete(sessionID);
145987
+ this.parents.set(sessionID, parentID);
145988
+ while (this.parents.size > this.maxEntries) {
145989
+ const oldest = this.parents.keys().next().value;
145990
+ if (oldest === void 0) break;
145991
+ this.parents.delete(oldest);
145992
+ }
145993
+ }
145994
+ parentOf(sessionID) {
145995
+ return this.parents.get(sessionID);
145996
+ }
145997
+ /** 沿父链逐级回调(含自身;防环 + 深度上限)。 */
145998
+ walk(sessionID, visit) {
145999
+ let current = sessionID;
146000
+ const seen = /* @__PURE__ */ new Set();
146001
+ for (let depth = 0; current && depth < 16 && !seen.has(current); depth += 1) {
146002
+ seen.add(current);
146003
+ visit(current);
146004
+ current = this.parents.get(current);
146005
+ }
146006
+ }
146007
+ };
145922
146008
 
145923
146009
  // src/utils/throttle.ts
145924
146010
  function createThrottler(options2) {
@@ -146699,12 +146785,13 @@ function createRunController(deps) {
146699
146785
  hasActive(sessionID) {
146700
146786
  return sessions.get(sessionID)?.active !== void 0;
146701
146787
  },
146702
- staleQueued(maxIdleMs, now3 = Date.now()) {
146788
+ staleQueued(maxIdleMs, now3 = Date.now(), shouldSkip) {
146703
146789
  const out = [];
146704
146790
  for (const [sessionID, runs] of sessions) {
146705
146791
  if (runs.active) continue;
146706
146792
  const first = runs.queued[0];
146707
146793
  if (!first || now3 - first.queuedAt < maxIdleMs) continue;
146794
+ if (shouldSkip?.(sessionID)) continue;
146708
146795
  if (notifiedQueued.has(sessionID)) continue;
146709
146796
  notifiedQueued.add(sessionID);
146710
146797
  out.push(sessionID);
@@ -147215,6 +147302,13 @@ var FormRelay = class {
147215
147302
  this.forms.delete(id2);
147216
147303
  void this.finish(pending, "cancelled");
147217
147304
  }
147305
+ /** 是否有尚未提交/取消的待答表单(看门狗判活:等表单属合法等待,不应判 stale)。 */
147306
+ hasPendingFor(sessionID) {
147307
+ for (const [, pending] of this.forms.entries()) {
147308
+ if (pending.sessionID === sessionID && !pending.settled) return true;
147309
+ }
147310
+ return false;
147311
+ }
147218
147312
  dispose() {
147219
147313
  this.disposed = true;
147220
147314
  this.forms.clear();
@@ -148978,6 +149072,13 @@ var ApprovalManager = class {
148978
149072
  parsed.decision === "reject" ? "\u5DF2\u62D2\u7EDD" : "\u5DF2\u5141\u8BB8"
148979
149073
  );
148980
149074
  }
149075
+ /** 该会话是否有未决审批(看门狗判活:等审批属合法等待,不应判 stale)。 */
149076
+ hasPendingFor(sessionID) {
149077
+ for (const [, card] of this.cards.entries()) {
149078
+ if (!card.resolved && card.input.sessionID === sessionID) return true;
149079
+ }
149080
+ return false;
149081
+ }
148981
149082
  dispose() {
148982
149083
  this.seenRequests.clear();
148983
149084
  this.cards.clear();
@@ -150731,6 +150832,14 @@ async function routeEvent(event, deps) {
150731
150832
  const touched = event.data?.sessionID;
150732
150833
  if (typeof touched === "string") deps.touch(touched);
150733
150834
  switch (event.type) {
150835
+ case "session.created": {
150836
+ const data = event.data;
150837
+ const childId = typeof data.sessionID === "string" && data.sessionID ? data.sessionID : typeof data.id === "string" && data.id ? data.id : "";
150838
+ if (!childId) break;
150839
+ const parent = typeof data.parentID === "string" && data.parentID ? data.parentID : typeof data.parentId === "string" && data.parentId ? data.parentId : void 0;
150840
+ deps.onSessionCreated?.(childId, parent);
150841
+ break;
150842
+ }
150734
150843
  case "permission.asked":
150735
150844
  void deps.onPermissionAsked(event.data).catch((err2) => deps.log.warn("\u5904\u7406 permission.asked \u5931\u8D25", { error: errorMessage(err2) }));
150736
150845
  break;
@@ -151520,6 +151629,7 @@ async function start(ctx, config, log2, logSink) {
151520
151629
  await owner.load().catch((err2) => log2.warn("owner \u8BFB\u53D6\u5931\u8D25", { error: errorMessage(err2) }));
151521
151630
  const dedup = new MessageDedup(storage, log2);
151522
151631
  const executions = new ExecutionTracker();
151632
+ const sessionLinks = new SessionParentLinks();
151523
151633
  const runs = createRunController({
151524
151634
  sender,
151525
151635
  log: log2,
@@ -151893,6 +152003,7 @@ async function start(ctx, config, log2, logSink) {
151893
152003
  log2.debug("\u5FFD\u7565\u975E\u767D\u540D\u5355\u7528\u6237", { sender: maskId(message.senderOpenId) });
151894
152004
  return;
151895
152005
  }
152006
+ void rememberMenuChat(message.senderOpenId, message.chatId);
151896
152007
  if (!message.text) return;
151897
152008
  if (!await dedup.claim(message.messageId)) {
151898
152009
  log2.debug("\u5FFD\u7565\u91CD\u590D\u6D88\u606F", { messageId: message.messageId });
@@ -151955,6 +152066,66 @@ async function start(ctx, config, log2, logSink) {
151955
152066
  log2.info("\u8BDD\u9898\u65B0\u5EFA opencode \u4F1A\u8BDD", { sessionID: created.id, threadId: message.threadId, chatId: message.chatId });
151956
152067
  await runInSession(message, created.id, message.messageId);
151957
152068
  }
152069
+ const MENU_COMMANDS = {
152070
+ new: { cmd: "/new", label: "\u65B0\u5EFA\u4F1A\u8BDD" },
152071
+ sessions: { cmd: "/sessions", label: "\u4F1A\u8BDD\u5217\u8868" },
152072
+ // 容忍把命令原文直接当 event_key 配置。
152073
+ "/new": { cmd: "/new", label: "\u65B0\u5EFA\u4F1A\u8BDD" },
152074
+ "/sessions": { cmd: "/sessions", label: "\u4F1A\u8BDD\u5217\u8868" }
152075
+ };
152076
+ const menuChatCache = /* @__PURE__ */ new Map();
152077
+ const menuChatKey = (openId) => `feishu:v2:menu-chat:${openId}`;
152078
+ async function rememberMenuChat(openId, chatId) {
152079
+ if (!openId || !chatId || menuChatCache.get(openId) === chatId) return;
152080
+ menuChatCache.set(openId, chatId);
152081
+ try {
152082
+ await ctx.storage.set(menuChatKey(openId), chatId);
152083
+ } catch (err2) {
152084
+ log2.debug("\u83DC\u5355 chatId \u6301\u4E45\u5316\u5931\u8D25", { error: errorMessage(err2) });
152085
+ }
152086
+ }
152087
+ async function resolveMenuChat(openId) {
152088
+ const cached3 = menuChatCache.get(openId);
152089
+ if (cached3) return cached3;
152090
+ try {
152091
+ const stored = await ctx.storage.get(menuChatKey(openId));
152092
+ if (typeof stored === "string" && stored) {
152093
+ menuChatCache.set(openId, stored);
152094
+ return stored;
152095
+ }
152096
+ } catch {
152097
+ }
152098
+ return void 0;
152099
+ }
152100
+ async function handleBotMenu(click) {
152101
+ if (!await owner.admit(click.operatorOpenId)) {
152102
+ log2.debug("\u5FFD\u7565\u975E\u767D\u540D\u5355\u7528\u6237\u7684\u83DC\u5355\u70B9\u51FB", { operator: maskId(click.operatorOpenId) });
152103
+ return;
152104
+ }
152105
+ const entry = MENU_COMMANDS[click.eventKey.trim()];
152106
+ if (!entry) {
152107
+ log2.debug("\u672A\u77E5\u83DC\u5355 event_key\uFF0C\u5FFD\u7565", { eventKey: click.eventKey });
152108
+ return;
152109
+ }
152110
+ const chatId = await resolveMenuChat(click.operatorOpenId);
152111
+ if (!chatId) {
152112
+ log2.warn("\u83DC\u5355\u70B9\u51FB\u7F3A\u5C11\u5355\u804A\u4E0A\u4E0B\u6587\uFF1A\u8BF7\u5148\u7ED9\u673A\u5668\u4EBA\u53D1\u4E00\u6761\u6D88\u606F\u540E\u518D\u4F7F\u7528\u83DC\u5355", {
152113
+ eventKey: click.eventKey
152114
+ });
152115
+ return;
152116
+ }
152117
+ const synthetic = {
152118
+ eventId: click.eventId,
152119
+ messageId: `menu_${click.eventId || Date.now()}`,
152120
+ chatId,
152121
+ chatType: "p2p",
152122
+ messageType: "text",
152123
+ text: entry.cmd,
152124
+ senderOpenId: click.operatorOpenId
152125
+ };
152126
+ log2.info("\u673A\u5668\u4EBA\u83DC\u5355\u70B9\u51FB \u2192 \u6267\u884C\u547D\u4EE4", { eventKey: click.eventKey, cmd: entry.cmd, label: entry.label });
152127
+ await handleMessage(synthetic);
152128
+ }
151958
152129
  async function runInActiveSession(message) {
151959
152130
  let active = await sessionMap.getActive(message.chatId);
151960
152131
  if (!active) {
@@ -152023,6 +152194,7 @@ ${downloadedAttachmentPrompt(message.attachment, outcome)}`;
152023
152194
  log: log2,
152024
152195
  logLevel: config.logLevel,
152025
152196
  onMessage: (message) => handleMessage(message),
152197
+ onBotMenu: (click) => handleBotMenu(click),
152026
152198
  onCardAction: (action) => routeCardAction(action, {
152027
152199
  log: log2,
152028
152200
  handleForm: (a) => formRelay.handleCardAction(a),
@@ -152063,9 +152235,13 @@ ${downloadedAttachmentPrompt(message.attachment, outcome)}`;
152063
152235
  })();
152064
152236
  const eventDeps = {
152065
152237
  log: log2,
152066
- touch: (sessionID) => executions.touch(sessionID),
152238
+ touch: (sessionID) => sessionLinks.walk(sessionID, (id2) => executions.touch(id2)),
152067
152239
  markStarted: (sessionID) => executions.markStarted(sessionID),
152068
152240
  markEnded: (sessionID) => executions.markEnded(sessionID),
152241
+ onSessionCreated: (sessionID, parentID) => {
152242
+ sessionLinks.remember(sessionID, parentID);
152243
+ log2.debug("session.created \u7236\u5B50\u767B\u8BB0", { sessionID, parentID: parentID ?? null });
152244
+ },
152069
152245
  applyRun: (sessionID, event) => runs.apply(sessionID, event),
152070
152246
  onPermissionAsked: (data) => approvals.onAsked(data),
152071
152247
  onPermissionReplied: (data) => approvals.onReplied(data),
@@ -152088,13 +152264,16 @@ ${downloadedAttachmentPrompt(message.attachment, outcome)}`;
152088
152264
  }
152089
152265
  await sender.sendText(link2.chatId, text);
152090
152266
  }
152091
- const stopWatchdog = startWatchdog({
152267
+ const hasPendingInteraction = (sessionID) => approvals.hasPendingFor(sessionID) || formRelay.hasPendingFor(sessionID);
152268
+ const stopWatchdog = config.staleExecutionMs > 0 ? startWatchdog({
152092
152269
  log: log2,
152093
152270
  staleExecutionMs: config.staleExecutionMs,
152094
- staleExecutions: () => executions.stale(config.staleExecutionMs),
152095
- staleQueued: () => runs.staleQueued(config.staleExecutionMs),
152271
+ staleExecutions: () => executions.stale(config.staleExecutionMs, Date.now(), hasPendingInteraction),
152272
+ staleQueued: () => runs.staleQueued(config.staleExecutionMs, Date.now(), hasPendingInteraction),
152096
152273
  recover: (sessionID, reason) => recovery.recover(sessionID, reason)
152097
- });
152274
+ }) : () => {
152275
+ log2.info("\u770B\u95E8\u72D7\u5DF2\u5173\u95ED\uFF08staleExecutionMs=0\uFF09");
152276
+ };
152098
152277
  async function notifyStuck(sessionID, reason, ok) {
152099
152278
  const link2 = await sessionMap.resolveBySession(sessionID);
152100
152279
  if (!link2) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Feishu/Lark plugin for OpenCode V2 — single-user topics as sessions, in-card permission approvals, streaming cards. Minimal permissions, no public endpoint.",
5
5
  "type": "module",
6
6
  "main": "index.js",