opencode-feishu-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 moyuanhua
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,430 @@
1
+ # opencode-feishu-plugin
2
+
3
+ **English** | [简体中文](./README.md)
4
+
5
+ Bring [OpenCode](https://opencode.ai) into Feishu/Lark: **one Feishu topic = one OpenCode session**. Manage multiple sessions from chat, drive the agent from inside topics, and **approve permission requests with a button on a Feishu card**.
6
+
7
+ > Built **only on the OpenCode V2 plugin API** (`Plugin.define({ id, setup(ctx) })`) — no V1 packages.
8
+ > **Pure long connection** (WebSocket) for events and card callbacks: **no listening port, no public URL required**.
9
+
10
+ ---
11
+
12
+ ## Highlights
13
+
14
+ | | |
15
+ |---|---|
16
+ | 🔐 **Minimal permissions** | Only 2 scopes (read p2p messages + send as bot). **No group scopes at all** — the bot physically cannot receive group messages |
17
+ | 💬 **Topics as sessions** | Each Feishu topic maps to one OpenCode session. The main chat is a management console; work happens inside topics |
18
+ | 🚀 **One-tap entry** | `/new` opens the setup form directly; on submit the bot creates a topic under your message automatically |
19
+ | 📝 **One-shot form** | `/new` and `/form` are **fully equivalent**: fill directory + model + permissions once and submit. The directory can be typed or picked from a dropdown of the allowed root's first-level subdirectories. **Zero new permissions** |
20
+ | 🗂 **Directory tolerance** | Empty directory = the allowed root; a non-existent one is created automatically (still constrained by the `allowedRoots` allowlist) |
21
+ | ✅ **In-card approvals** | Permission requests become Feishu cards (allow once / always / reject) with signed, replay-proof buttons |
22
+ | 🪜 **Permission presets** | Read-only / Editable / Ask-on-risky / Trust — pick once per session instead of approving every call |
23
+ | 📊 **Live visibility** | Instant ack card, live tool calls (auto-collapsed when ≥3), streaming text, current model in the footer |
24
+ | 🧵 **Native queueing** | Busy session → messages queue via OpenCode's native `delivery:"queue"` |
25
+ | ⏹ **One-tap force stop** | **Every AI reply card carries a "force stop" button** — one tap interrupts (whitelist + HMAC signed). The watchdog auto-interrupts stuck sessions instead of queueing forever |
26
+ | 🚫 **No ports** | Everything over a long connection; nothing to expose |
27
+
28
+ ---
29
+
30
+ ## 1. Feishu app setup (~3 minutes)
31
+
32
+ 1. Go to the [Feishu Open Platform](https://open.feishu.cn/app) → **Create a custom app**.
33
+ 2. **Add capability → Bot**.
34
+ 3. **Permissions** — enable only these two:
35
+ - `im:message.p2p_msg:readonly` — read direct messages sent to the bot
36
+ - `im:message:send_as_bot` — send messages *as the app* (also used to update cards)
37
+ 4. **Events & Callbacks → Event subscription**: choose **"Receive events via long connection"** (do **not** pick Webhook), add event `im.message.receive_v1`.
38
+ 5. **Events & Callbacks → Callback subscription**: also choose **long connection**, add callback `card.action.trigger` (**zero permission required**).
39
+ 6. **Version management & release**: set **availability = only yourself**, create a version and publish it.
40
+ 7. Note the **App ID** (`cli_…`) and **App Secret**.
41
+
42
+ > **Why no group scopes?** This plugin is a *personal console*. With no group scopes the bot **physically cannot**
43
+ > receive group messages, so the single-user boundary is enforced by the platform, not just by code.
44
+
45
+ ---
46
+
47
+ ## 2. Installation
48
+
49
+ ### 2.1 Install the plugin (V2: loaded from npm, recommended)
50
+
51
+ OpenCode V2 declares packages to load in the `plugins` array of its config; on startup it installs them with Bun
52
+ (cached under `~/.cache/opencode/node_modules/`). Two equivalent ways:
53
+
54
+ ```bash
55
+ # Option A — CLI (recommended)
56
+ opencode plugin add opencode-feishu-plugin
57
+ ```
58
+
59
+ ```jsonc
60
+ // Option B — write ~/.config/opencode/opencode.jsonc yourself
61
+ {
62
+ "$schema": "https://opencode.ai/config.json",
63
+ "plugins": ["opencode-feishu-plugin"]
64
+ }
65
+ ```
66
+
67
+ The plugin entrypoint is the **self-contained** `dist/index.js` (Feishu SDK included), referenced by
68
+ `package.json#exports`. You do **not** run `npm install` by hand and need no extra `node_modules`.
69
+
70
+ **Local development (without npm):** clone, `npm install && npm run build`, then point `plugins` at the local directory:
71
+
72
+ ```jsonc
73
+ { "plugins": ["./path/to/opencode-feishu-plugin"] }
74
+ ```
75
+
76
+ ### 2.2 Alternative: global plugin directory (offline / fixed path)
77
+
78
+ You can also drop the build output into `<configDir>/plugins/<any-name>/` (`configDir` = `OPENCODE_CONFIG_DIR` or
79
+ `~/.config/opencode`). OpenCode auto-discovers `index.js` there (this package ships such a root entry that re-exports `dist/`):
80
+
81
+ ```bash
82
+ cd /path/to/opencode-feishu-plugin && npm install && npm run build
83
+ mkdir -p ~/.config/opencode/plugins/feishu
84
+ cp -r dist index.js package.json ~/.config/opencode/plugins/feishu/
85
+ ```
86
+
87
+ > Whichever loading path you use, **restart the service after upgrading** so the module is re-imported:
88
+ > ```bash
89
+ > opencode service restart
90
+ > ```
91
+
92
+ ### 2.3 Configure
93
+
94
+ `<configDir>/plugins/feishu.json` (`configDir` = `OPENCODE_CONFIG_DIR` or `~/.config/opencode`):
95
+
96
+ ```bash
97
+ install -m 600 /dev/null ~/.config/opencode/plugins/feishu.json
98
+ cat > ~/.config/opencode/plugins/feishu.json <<'JSON'
99
+ {
100
+ "appId": "{env:FEISHU_APP_ID}",
101
+ "appSecret": "{env:FEISHU_APP_SECRET}"
102
+ }
103
+ JSON
104
+ chmod 600 ~/.config/opencode/plugins/feishu.json
105
+ ```
106
+
107
+ Put the credentials into the **OpenCode service process** environment (not your interactive shell):
108
+
109
+ ```bash
110
+ opencode service set env FEISHU_APP_ID cli_xxxxxxxx
111
+ opencode service set env FEISHU_APP_SECRET xxxxxxxx
112
+ ```
113
+
114
+ Plaintext values inside `feishu.json` work too (keep it `chmod 600`). **Precedence**: `options` > `feishu.json` > environment.
115
+
116
+ ### 2.4 Activate & verify
117
+
118
+ ```bash
119
+ opencode reload
120
+ ```
121
+
122
+ Send the bot a direct message. **The first sender is bound as the owner**; everyone else is silently ignored.
123
+
124
+ ---
125
+
126
+ ## 3. Usage
127
+
128
+ ### Main chat (console)
129
+
130
+ The main chat is management-only; plain text never enters a session.
131
+
132
+ | Command | Purpose |
133
+ |---|---|
134
+ | `/new [title]` | **Open the setup form directly**; submit to create the session and auto-open a topic (equivalent to `/form`; the title becomes the session title) |
135
+ | `/form [title]` | Same as `/new` — an equivalent entry point |
136
+ | `/sessions` (`/ls`) | **All** sessions card: each row shows title / short id / relative time / `💬 topic-bound` / `📍 directory`, with a "▶️ Enter topic" button; 8 per page (`sessionPageSize`, 5–20) |
137
+ | `/use <n\|id-prefix>` | Switch current session (legacy, kept for compatibility) |
138
+ | `/resume [n]` | **Resume a past session**: enter the most-recently-updated (or the N-th) session by opening a topic |
139
+ | `/current` | Show current session |
140
+ | `/stop` | Interrupt the running task in the current session (every run card also has a "⏹ force stop" button) |
141
+ | `/steer <text>` | Send a message that **cuts in immediately** (steers into the running step instead of queuing) |
142
+ | `/now` | Promote this session's already-queued, not-yet-delivered messages to run immediately |
143
+ | `/dir <path>` | **Pre-fill** the form's working directory (empty = allowed root; a missing path is auto-created) |
144
+ | `/model [query]` | **Pre-fill** the form's model (also switches the current session's model inside a topic) |
145
+ | `/perm [preset]` | **Pre-fill** the form's permission preset (also changes the current session inside a topic) |
146
+ | `/cancel` | Discard an un-submitted form |
147
+ | `/help` | Command list |
148
+
149
+ ### Inside a topic (work)
150
+
151
+ One topic = one session. **Plain text inside a topic is a prompt to the agent**; replies stay in the same topic.
152
+
153
+ | Command | Purpose |
154
+ |---|---|
155
+ | `/model` | Switch the model for this session |
156
+ | `/perm` | Change the permission preset for this session |
157
+ | `/cd <path>` | Move this session's working directory (empty = allowed root; a missing path is auto-created) |
158
+ | `/steer <text>` | Steer a message into the running step immediately |
159
+ | `/now` | Promote this session's queued messages to run immediately |
160
+ | `/current` `/stop` `/help` | Same as main chat, scoped to this topic's session |
161
+
162
+ ### What `/model` really does
163
+
164
+ A `/model` switch only affects **subsequent** model calls; it does **not** rewrite history:
165
+
166
+ - opencode's `switchModel` means "switch the model used by subsequent provider turns" and appends a `model-switched` marker to the session. Earlier assistant messages keep the model they **actually ran on** at the time.
167
+ - So seeing "`Session.Info.model` is already the new model, but an earlier batch of messages is still the old model" is **expected**, not a failed switch.
168
+ - To be safe, the plugin **reads back** `ctx.session.get` after switching: it shows "✅ model switched" only when the read-back matches; a mismatch is reported as "⚠️ model may not have taken effect"; a failed read-back degrades to the requested value with a note. **The run-card footer and `/current` also display the read-back truth**.
169
+ - A failed switch (no permission / session not found) reports the error instead of pretending success.
170
+
171
+ ### Topic soft guidance (topics never hard-block off-topic messages)
172
+
173
+ When you create a session from Feishu via `/new <title>` or the form, the title becomes the topic's "theme". The plugin does **not** block off-topic messages; it only injects a short system note so the model can **briefly remind** the user to open a new session with `/new` when they clearly drift away — without refusing to answer or lecturing:
174
+
175
+ - Injected only for **Feishu-originated sessions**; local TUI sessions are **never** touched (no pollution of your own sessions).
176
+ - Skipped when the session title is unavailable; injection failures only `log.warn` and never affect execution.
177
+ - Disable it entirely with `topicGuidance: false`.
178
+
179
+ ### Creating a session (`/new` and `/form` are fully equivalent)
180
+
181
+ ```
182
+ /new fix the login bug (or /form fix the login bug)
183
+ ↓
184
+ 📝 setup form card
185
+ directory: type it, or pick from the dropdown (first-level subdirectories of the allowed root); empty = allowed root, auto-created if missing
186
+ model: dropdown (defaults to the current/most recent)
187
+ permissions: pick one of four presets
188
+ ↓ tap "Create"
189
+ The form message itself becomes the topic root: the bot replies to it with
190
+ `reply_in_thread` to post the "session ready" card inside the topic
191
+ ↓
192
+ The form card is rewritten in place into a success card titled
193
+ `✅ Created · <session title>` (this title becomes the topic name)
194
+ ↓
195
+ Jump into the topic and just send a message
196
+ ```
197
+
198
+ - `/new` and `/form` share **one entry point** and post the setup form directly; the old directory → model → permissions → confirm step cards are **gone**.
199
+ - `/dir` `/model` `/perm` still work, but only as **form pre-fill** (no longer required steps): each replies with a new pre-filled form card.
200
+ - Submission consumes the wizard state first (prevents double-click duplicates); an invalid directory **never creates a session** and returns the form with an error while keeping your input.
201
+ - Send `/cancel` to discard an un-submitted form.
202
+
203
+ ### Directory tolerance rules
204
+
205
+ | Input | Behaviour |
206
+ |---|---|
207
+ | Empty | Uses the **allowed root** `allowedRoots[0]` (the user's home by default); not an error |
208
+ | Non-existent absolute path | Auto-created with `mkdir -p`, but **must still be under `allowedRoots`** |
209
+ | Outside the roots / system dir / `/` | Rejected, nothing is created |
210
+ | Symlinks | Re-checked with `realpath` after creation; escaping `allowedRoots` or landing in a system dir → rejected |
211
+
212
+ `/cd` follows **exactly the same** rules.
213
+
214
+ **Directory precedence in the form** (dropdown and text input coexist): dropdown pick (other than "✍️ Manually enter a path") > text input > both empty falls back to `allowedRoots[0]`.
215
+ The dropdown defaults to "✍️ Manually enter a path" so typing stays authoritative and you never accidentally pick an unexpected directory; `/dir <path>` writes to the input and selects it in the dropdown if it is one of the listed options, otherwise it falls back to "Manually enter a path" (any path can still be typed).
216
+
217
+ ### One-shot form (`/form`)
218
+
219
+ - Send `/form` (or `/new` — they are equivalent) to open the form card.
220
+ - Fill in one go: **directory** (type it, or pick from a dropdown of the allowed root's first-level subdirectories; may be empty), **model** (dropdown of recent + popular, defaulting to the current/most recent model) and **permission preset** (dropdown, four presets with descriptions). Tap **Create** to submit.
221
+ - Directory dropdown options: `✍️ Manually enter a path (use the input above)` + `🏠 <root> (use this root)` + the **first-level subdirectories** of that root (hidden dirs and `node_modules` filtered out, sorted by name, at most 15; subdirectories containing `.git` are prefixed with `📦 `).
222
+ - The dropdown uses **only `allowedRoots[0]`** (the first allowed root). A scan failure (missing / no permission) silently degrades to just "manual input + root" without affecting the form or the plugin; the scan runs while rendering the form (low-frequency, not cached). `/dir` can still type any (in-scope) path.
223
+ - On submit: `session.create` → `reply_in_thread` on the **form card message** posts the ready card (the form message becomes the topic root) → bind, and you can start working in the new topic.
224
+ - With `/new <title>`, the title is stored in the wizard state and becomes the session title on submit.
225
+ - **Zero new permissions**: form submission reuses the `card.action.trigger` callback (permission requirement: None) — **no new scope, no app re-release**.
226
+ - An invalid directory **never creates a session**: the bot returns the form with an error and keeps your filled-in directory/model/permissions so you can fix and resubmit.
227
+
228
+ ### Resume a past session (`/sessions` + `/resume`)
229
+
230
+ Besides sessions created from Feishu, you can **load any past OpenCode session visible to this machine** and keep working on it.
231
+
232
+ **`/sessions` (`/ls`) — all sessions**
233
+
234
+ ```
235
+ /sessions
236
+ ↓
237
+ 🧩 OpenCode sessions (all)
238
+ 1. Fix the login bug (`ses_ab12cd34…`) · 3 hours ago · 💬 topic-bound · 📍 my-app
239
+ 2. Refactor the API (`ses_ef56gh78…`) · 2 days ago · 📍 api-server
240
+ …
241
+ [▶️ Enter topic] [▶️ New topic] [⬅️ Prev] [➡️ Next] [➕ New session]
242
+ ```
243
+
244
+ - Data source is `ctx.session.list()` (**all** OpenCode sessions, sorted by `time.updated` desc), not just the plugin's mapping table; if unavailable it falls back to the mapping list and logs a `warn`.
245
+ - Each row shows: title (truncated), short id, relative time, `💬 topic-bound` (this session already has a topic mapping), `📍 <directory tail>`.
246
+ - **Paging**: 8 per page by default (`sessionPageSize`, clamped 5–20); the bottom buttons flip pages (`{cmd:"list", page:N}`).
247
+ - **"➕ New session"** opens the setup form card (same as `/new` `/form`) instead of creating a session directly.
248
+
249
+ **"▶️ Enter topic" — wire a past session into a topic**
250
+
251
+ - Tap the button (value `{cmd:"open", s, c}`): first the session is checked for existence (`ctx.session.get`); if missing → toast "session not found" and the list card is patched into a notice.
252
+ - If it exists → `reply_in_thread` on **the list card message you tapped** posts a "✅ Entered session" card; once `thread_id` is obtained the topic ↔ session mapping (plus the topic root) is bound. **Messages you send in that topic then continue this past session** (OpenCode session context is persistent, so this is effectively a resume).
253
+ - For an already topic-bound session the button becomes "▶️ New topic" — **one session can be routed from several topics** (each topic has its own conversation context; replies land in the triggering topic).
254
+
255
+ **`/resume [n]` — skip the list**
256
+
257
+ - `/resume` runs the same "enter topic" flow for the **most recently updated** session; `/resume 3` picks the 3rd row. An out-of-range index reports the valid range.
258
+ - It uses the same ordering as `/sessions` (`time.updated` desc).
259
+
260
+ **Limitations**
261
+
262
+ - Only sessions **visible on this machine** can be resumed; deleted / foreign / invisible-to-`session.list` sessions cannot be entered.
263
+ - `/sessions` and `/resume` are main-chat commands and are **disabled inside topics** (they tell you to go back); once inside a topic just send plain text.
264
+ - With `threadRouting=false` (fallback mode), entering topics and `/resume` are unsupported.
265
+
266
+ ### Permission presets
267
+
268
+ | Preset | Meaning | Session ruleset |
269
+ |---|---|---|
270
+ | 🔒 Read-only | Look, don't touch | deny `edit` / `shell` |
271
+ | ✏️ Editable | Edits free, **commands need approval** | allow `edit`, `shell` → ask |
272
+ | ⚠️ Ask-on-risky | Edits, commands and outside-directory access all ask | risky actions ask each time |
273
+ | 🔓 Trust | Never ask | allow all |
274
+
275
+ The preset is written to a **session-scoped** ruleset and can be changed any time with `/perm`, without affecting other sessions.
276
+
277
+ ### Queue and cut-in (`/steer` `/now`)
278
+
279
+ While a session is busy, new messages use OpenCode's native queue (`delivery:"queue"`, the card footer shows "queued") and run only after the current task finishes. Two ways to cut in:
280
+
281
+ - `/steer <text>` — send this message with `delivery:"steer"` to insert it immediately (interrupts the current step, like steering in the TUI).
282
+ - `/now` — promote this session's already-queued, not-yet-delivered messages to `steer` (via OpenCode's `session.inbox.update`; content is neither lost nor re-sent).
283
+
284
+ ### Force-stop button and the watchdog
285
+
286
+ **Every AI reply card has a "⏹ force stop" button at the bottom** (ack card, streaming run card, terminal card and stuck-notice card):
287
+
288
+ - running / queued: a **red danger** "⏹ Force stop" button; tapping it interrupts the session's current execution and cancels not-yet-delivered queued messages;
289
+ - done / failed / interrupted: still rendered, but as a `default` "⏹ Stop" button; tapping only shows the toast "this task has ended" (so it never looks like you can still stop it).
290
+
291
+ The button is a **signed action** `{ cmd:"stop", sid:<sessionID>, t:<token> }`. The token reuses the approval-card HMAC mechanism and binds `sessionID + purpose + expiry + nonce`; the card **re-signs on every patch**, so long tasks never become un-stoppable due to an expired token.
292
+ Validation order: **allowlist (allowUsers/owner) → signature → sessionID binding → replay guard**; forged, cross-session and replayed clicks are rejected.
293
+
294
+ **Watchdog (5-minute threshold, configurable)**: when an execution has produced no event for longer than the threshold it is treated as stuck; the plugin **actually interrupts the server-side session** (`session.interrupt`) + **cancels queued inbox messages** + finalizes the run card + sends a notice card with a force-stop button. If a session stays queued past the same threshold without an `execution.started`, the same recovery runs and a notice is sent — no more "session stuck once, every later message queues forever".
295
+
296
+ The threshold is `staleExecutionMs` (default 5 minutes, clamped to 1–60 minutes).
297
+
298
+ ### Forms and questions (`question` tool)
299
+
300
+ When the agent calls the `question` tool (or any form interaction), OpenCode creates a pending form that blocks execution. The plugin relays it as a Feishu card:
301
+
302
+ - tap an option for single-choice fields; multi-field forms submit automatically once every field is filled;
303
+ - for free-text fields, tap "✍️ reply directly" and send the answer as a message in the **same topic**;
304
+ - the card resolves after submit/cancel.
305
+
306
+ Without this relay, any clarifying question would stall the Feishu session forever and every later message would queue behind it — a common cause of "stuck sessions".
307
+
308
+ ---
309
+
310
+ ## 4. Configuration
311
+
312
+ `<configDir>/plugins/feishu.json` (or `plugins[].options` in OpenCode). `{env:NAME}` / `${NAME}` expansion supported.
313
+
314
+ | Field | Type | Default | Description |
315
+ |---|---|---|---|
316
+ | `appId` | string | — | Feishu App ID (**required**; missing ⇒ plugin disabled, never throws) |
317
+ | `appSecret` | string | — | Feishu App Secret (**required**; never logged) |
318
+ | `domain` | `feishu`\|`lark` | `feishu` | Feishu or Lark international |
319
+ | `allowUsers` | string[] | `[]` | open_id allowlist. **Empty = app owner only** (first sender is bound and persisted) |
320
+ | `permissionGate` | `off`\|`notify`\|`gate`\|`lockdown` | `gate` | Global approval gate |
321
+ | `allowTools` | string[] | `["read","glob","grep","webfetch"]` | Auto-allow list; supports `prefix*` |
322
+ | `denyTools` | string[] | `[]` | Hard deny (takes precedence) |
323
+ | `allowedRoots` | string[] | `[homedir]` | Roots allowed as session working directories (the default directory is `allowedRoots[0]`); `/`, the filesystem root and system dirs are always rejected; an empty directory falls back to the first root and a non-existent one is auto-created. **The directory dropdown only scans the first root's first-level subdirectories** |
324
+ | `stream` | boolean | `true` | Stream replies into the card |
325
+ | `streamThrottleMs` | number | `400` | Min card update interval (floor 400ms; Feishu limit is 5 QPS) |
326
+ | `threadRouting` | boolean | `true` | Topic routing master switch; `false` restores the legacy behaviour |
327
+ | `topicGuidance` | boolean | `true` | Topic soft guidance: inject a short "use `/new` for a new topic" system note into Feishu sessions (never blocks messages); local TUI sessions are never touched |
328
+ | `recentDirsLimit` | number | `5` | Number of recent directories (1–20) |
329
+ | `recentModelsLimit` | number | `5` | Number of recent models (1–20) |
330
+ | `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | Log level (secrets are never logged, only their presence) |
331
+ | `logFile` | string \| boolean | — | `true` writes `<configDir>/plugins/feishu.log`. **Plugin stderr is discarded in service mode — enable this when debugging** |
332
+ | `gatewayLocation` | string | — | Only start the gateway in this location. OpenCode loads global plugins per location (separate VM contexts, so an in-process singleton cannot dedupe). **Set this to your usual working directory**, otherwise you get multiple long connections |
333
+ | `approvalTtlMs` | number | `600000` | Approval token / card TTL |
334
+ | `staleExecutionMs` | number | `300000` | Watchdog threshold: an execution with no event for this long is treated as stuck and auto-interrupted; a queue stuck this long without `execution.started` also triggers a notice. Clamped to 1–60 minutes |
335
+ | `maxResourcesShown` | number | `8` | Max resource lines shown on an approval card |
336
+
337
+ ---
338
+
339
+ ## 5. Security model
340
+
341
+ ```
342
+ permission.evaluate (plugin hook) permission.asked (event stream)
343
+ ────────────────────────── ──────────────────────
344
+ allow-listed tool → allow event carries {id, sessionID, action, resources, save}
345
+ deny list → deny │
346
+ otherwise (per session preset) → ask ─────────────────────┘
347
+ ▼
348
+ Feishu approval card (button value = signed token)
349
+ │ user taps
350
+ ▼
351
+ card.action.trigger over the long connection (<3s response)
352
+ │
353
+ verify: operator allow-listed → signature → bound fields → replay guard
354
+ ▼
355
+ ctx.permission.reply({sessionID, requestID, reply})
356
+ ```
357
+
358
+ - **Signed tokens**: HMAC-SHA256 binding `requestID + sessionID + operator openId + expiry + nonce`; forgery, forwarding and replay are rejected.
359
+ - **Force-stop uses the same signature scheme**: its token binds `sessionID + purpose + expiry + nonce`, the click passes the open_id allowlist before verification, and it is purpose-isolated from approval tokens (neither works for the other).
360
+ - **Only Feishu-originated sessions**: sessions without a chat↔session mapping (e.g. your local TUI) are **never downgraded to `ask`**, otherwise they would hang forever with no approval channel.
361
+ - **Three layers of single-user isolation**: platform availability (only you) + no group scopes + code-level open_id allowlist with silent ignore.
362
+ - **`always` semantics**: persisted only when the request carries `save[]`; otherwise it behaves like "once" (the card says so).
363
+
364
+ ---
365
+
366
+ ## 6. Troubleshooting
367
+
368
+ | Symptom | Fix |
369
+ |---|---|
370
+ | Bot does not respond | ① App **published** and availability includes you? ② Event/callback subscription set to **long connection** (not Webhook)? ③ `im:message.p2p_msg:readonly` granted? |
371
+ | `feishu.json` changes ignored | Confirm the path is `<configDir>/plugins/feishu.json`, then `opencode reload` |
372
+ | Plugin never loads (no logs, no error) | npm path: make sure the package name is in the config `plugins` array (`opencode plugin list` shows it). Directory path: make sure `plugins/<name>/index.js` exists (OpenCode ignores `package.json#main`) |
373
+ | Plugin code changes ignored | `opencode reload` only re-runs `setup`; it does **not** re-import the module from the same path. Upgrade with `opencode plugin update opencode-feishu-plugin`, or restart the service |
374
+ | Multiple long connections / duplicate replies | Set `gatewayLocation` to your usual working directory |
375
+ | No approval cards | The session did not originate from Feishu (no mapping); by design the plugin does not take it over |
376
+ | "Invalid credentials" on button tap | Token expired (default 10 min) or the tapper is not allow-listed |
377
+ | Card content truncated | Feishu card limit is ~30KB; the plugin truncates and marks it. Very long sessions drop the oldest blocks from the card (full content stays in the session) |
378
+ | Form submit does nothing / errors | Client too old (`select_static` needs ≥ V3.7.0), or the card is stale (form consumed/cancelled) — send `/form` or `/new` again for a fresh form |
379
+ | Form submitted but no session | A directory outside the allowlist or in a system dir returns an error card and **does not create a session**; fix it and resubmit. Empty / non-existent in-scope dirs are auto-created and never fail |
380
+ | No topic after creating a session | If auto-opening the topic fails, the form card is rewritten to "✅ Created · …" with manual-topic guidance; you can also create a topic manually from the `/sessions` card |
381
+ | No plugin logs | Plugin stderr is discarded in service mode; set `logFile: true` and read `<configDir>/plugins/feishu.log` |
382
+ | Main chat replies with a hint card | Expected: the main chat is management-only. Use `/new` and work inside a topic; set `threadRouting: false` to revert |
383
+ | Session looks stuck and messages only queue | The watchdog auto-interrupts it after `staleExecutionMs` (default 5 min) and cancels the queue, then sends a notice card; you can also tap the card's "⏹ force stop" or send `/stop` |
384
+ | Switched `/model` but older messages still show the old model | Expected: a switch only affects **subsequent** replies; history keeps each message's model. The receipt / run-card footer / `/current` all show the read-back truth |
385
+
386
+ ---
387
+
388
+ ## 7. Development
389
+
390
+ ```bash
391
+ npm install
392
+ npm run typecheck # tsc --noEmit
393
+ npm run build # tsup → dist/ (self-contained bundle)
394
+ npm test # vitest (pure logic, no live Feishu)
395
+ npm run dev # tsup --watch
396
+ ```
397
+
398
+ **Architecture**: `src/index.ts` wires everything; the Feishu interaction layer lives in `src/feishu/` (event parsing, card builders, topic routing, wizard state machine, streaming-card reducer — mostly **pure functions** for testability); `src/security/` holds token signing and the allowlist.
399
+
400
+ **Implementation notes**
401
+ - Cards are **JSON 2.0** (buttons directly in `body.elements`, callbacks via `behaviors`; the 1.0 `tag:"action"` container returns HTTP 400 on 2.0). Form cards add: `form` must sit at the root of `body.elements`, interactive `name`s must be globally unique, and at least one button must carry `form_action_type:"submit"`.
402
+ - Card updates are throttled to ≥400ms; ≥3 consecutive tool calls collapse into one summary panel (names only) to stay under the 30KB limit.
403
+ - Run-card state is maintained by a **pure reducer** (text blocks / tool blocks / footer / terminal state), keyed per `assistantMessageID`.
404
+
405
+ ---
406
+
407
+ ## 8. Relationship to other projects
408
+
409
+ This plugin targets **OpenCode V2 only** (`@opencode/plugin`, `Plugin.define`). The separately maintained `opencode-feishu` package is a **V1** plugin (`@opencode-ai/plugin`) — the two are incompatible and share no code. Pick according to your OpenCode version.
410
+
411
+ ## Known limitations
412
+
413
+ - Text-only inbound (including rich text); images/files/audio get a textual placeholder and are not downloaded.
414
+ - Only approvals for **Feishu-originated** sessions are handled. Local TUI sessions are untouched by design.
415
+ - Message dedup is `get-then-set` (not atomic): under extreme concurrency a duplicate is theoretically possible.
416
+ - Deleted topics leave stale mappings (lazily ignored).
417
+ - There is a single main path for creating sessions: the **`/new` / `/form` setup form card**; `/dir` `/model` `/perm` only pre-fill the form. The old directory/model/permissions/confirm step cards are retired from `/new` (their builders and compatibility callbacks remain, marked deprecated).
418
+ - The form is JSON 2.0 (`form` at the root of `body.elements`, globally unique interactive `name`s, a submit button with `form_action_type:"submit"`); some older clients require `select_static` ≥ V3.7.0.
419
+
420
+ - **A topic's first message may omit `thread_id`**: Feishu sometimes delivers the event without `thread_id` (it is assigned afterwards). If you send a main-chat-only command such as `/new` at that moment, it runs as a main-chat command (e.g. the form card lands in the main chat). Just continue inside the topic with a normal message.
421
+
422
+
423
+ > Publishing tip: `npm publish` triggers `prepublishOnly` (typecheck + build + test). If `node_modules` is missing it **runs `npm ci` first**, so a fresh clone can be published directly without a manual install.
424
+
425
+
426
+ > Publishing note: provenance can only be generated in CI (GitHub Actions), so `package.json` deliberately does **not** set `publishConfig.provenance`; our workflows pass `npm publish --provenance` explicitly. Publishing locally is just `npm publish --access public`.
427
+
428
+ ## License
429
+
430
+ MIT