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 +31 -7
- package/README.md +31 -7
- package/dist/index.js +199 -20
- package/package.json +1 -1
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
|
|
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
|
|
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. **
|
|
40
|
-
|
|
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
|
 
|
|
106
113
|
|
|
114
|
+
### Bot menu (quick buttons above the input box)
|
|
115
|
+
|
|
116
|
+

|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
 
|
|
106
113
|
|
|
114
|
+
### 机器人菜单(输入框快捷按钮)
|
|
115
|
+
|
|
116
|
+

|
|
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` | 看门狗阈值(
|
|
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
|
|
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),
|
|
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
|
|
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
|
|
145912
|
-
|
|
145913
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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",
|