opencode-feishu-plugin 0.2.5 → 0.2.7

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
@@ -360,6 +360,7 @@ When the agent calls the `question` tool (or any form interaction), OpenCode cre
360
360
 
361
361
  - **Two equivalent ways to answer**: tap an option button, or just **send text in the topic** (no need to tap "✍️ reply directly" first). Text is matched intelligently — option label/value are matched to their value, booleans accept 是/否 & yes/no & 1/0, numbers are parsed, multiselect splits on commas, anything else counts as a **manual answer**;
362
362
  - multi-field forms can be answered with a mix of taps and a text reply; they submit automatically once every field is filled;
363
+ - **Option-only questions** (options present, custom input not allowed): you may reply with the **option number/letter** (e.g. `1`, `B`) or the option label. Any **other** text is treated as "you meant something else": the plugin **skips that form** and passes your message to the AI as a **normal message** instead of a bogus answer.
363
364
  - **the card is recalled once answered/cancelled**; if it is past Feishu's recall window, it degrades to a "submitted/cancelled" result card instead.
364
365
 
365
366
  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".
@@ -401,9 +402,21 @@ Without this relay, any clarifying question would stall the Feishu session forev
401
402
  | `topicStatusInTitle` | boolean | `false` | Add a status emoji prefix to the root card title (e.g. `🟡 session name`). Off by default: the topic name shows in the sidebar, and flipping it would be noisy |
402
403
  | `topicStatusThrottleMs` | number | `1000` | Min root-card status refresh interval (clamped 500–10000); patched only when the kind changes |
403
404
  | `cardMaxTables` | number | `4` | Max markdown tables kept per card (clamped 1–5); tables beyond it are degraded **cumulatively per card** into fenced code blocks (no content lost) to avoid Feishu 400 `code=230099` |
405
+ | `runnerCardMaxTools` | number | `12` | Max tool blocks kept on the run card (1–50); older ones collapse into "…omitted N tool calls" |
406
+ | `runnerCardTextMax` | number | `2048` | Per-text-block character cap on the run card (512–8192) |
407
+ | `finalAnswerMinChars` | number | `600` | Final answers at least this long are sent **as their own card/file** (`0` disables splitting) |
408
+ | `finalAnswerFileMinBytes` | number | `20480` | Final answers at least this many bytes are delivered as a `.md` file (8192–102400) |
404
409
  | `keepalive` | boolean | `true` | **Location keep-alive**: periodically emits activity so OpenCode does not evict the idle Location after 60 minutes (which unloads the plugin and closes the Feishu long connection) |
405
410
  | `keepaliveIntervalMs` | number | `1200000` | Keep-alive interval (default 20 min, clamped 5–45); must stay well below OpenCode's hardcoded 60-minute TTL |
406
411
 
412
+ ### Long answers (run-card slimming + separate final answer)
413
+
414
+ A long turn (dozens of tool calls) can push a single run card to its limits (28KB / 200 elements), triggering degradation and **dropping the oldest blocks** — the card looks "full" and earlier content disappears. Default strategy:
415
+
416
+ 1. **The run card is progress-only**: keep the last `runnerCardMaxTools` (default 12) tool blocks, collapsing older ones into "…omitted N tool calls"; each text block is capped at `runnerCardTextMax` (default 2KB).
417
+ 2. **The final answer is sent separately**: when a turn ends and the trailing text is at least `finalAnswerMinChars` (default 600 chars), it is sent as its own "✅ 完整回答" card; the run card keeps only a short notice.
418
+ 3. **Very long answers become files**: at least `finalAnswerFileMinBytes` (default 20KB) → delivered as a `.md` file (preview/download), never truncated.
419
+
407
420
  ### Location keep-alive (on by default)
408
421
 
409
422
  OpenCode **evicts idle Locations**, which unloads plugins and closes the Feishu long connection:
package/README.md CHANGED
@@ -215,6 +215,7 @@ agent 调 `question` 等 form 类交互时,插件把它转成飞书卡片:
215
215
 
216
216
  - **两种作答方式等价**:直接点选项按钮,或**直接在话题里发文字**(无需先点「✍️ 直接回复答案」)。文本会智能匹配——命中选项 label/value 用选项值,`boolean` 认「是/否、yes/no、1/0」,`number`/`integer` 转数值,多选按顿号/逗号拆分,其余视为**手动输入**。
217
217
  - 多字段表单可以混合作答:点几个按钮 + 补一条文字,填满即自动提交。
218
+ - **纯选项题**(有选项且不允许自填):可以直接**回复序号/字母**(如 `1`、`B`)或选项原文;若你发的是**其它内容**,插件会视为「你想说别的」——**自动跳过该表单**并把这条消息当**普通消息**交给 AI 处理,不再被误当成答案。
218
219
  - **作答 / 取消后卡片会被撤回**(不再残留待填卡);若超出飞书撤回时限,降级为「已提交 / 已取消」结果卡。
219
220
  - **没有这层转发,agent 一反问飞书会话就会永久卡住**——这也是会话卡死的常见原因。
220
221
 
@@ -262,6 +263,10 @@ agent 调 `question` 等 form 类交互时,插件把它转成飞书卡片:
262
263
  | `topicStatusInTitle` | boolean | `false` | 是否在根卡标题加状态 emoji 前缀 |
263
264
  | `topicStatusThrottleMs` | number | `1000` | 根卡状态刷新最小间隔(500–10000) |
264
265
  | `cardMaxTables` | number | `4` | 单卡最多保留的 markdown 表格数(1–5);超出按整卡累计降级为围栏代码块,避免飞书 400 `code=230099` |
266
+ | `runnerCardMaxTools` | number | `12` | 运行卡最多保留的工具块数(1–50);更早的合并为「已省略前 N 次工具调用」 |
267
+ | `runnerCardTextMax` | number | `2048` | 运行卡单个文本块字符上限(512–8192) |
268
+ | `finalAnswerMinChars` | number | `600` | 最终回答 ≥ 该长度即**单独成卡/成文件**(0 = 关闭拆分) |
269
+ | `finalAnswerFileMinBytes` | number | `20480` | 最终回答 ≥ 该字节数转为 `.md` 文件发送(8192–102400) |
265
270
  | `keepalive` | boolean | `true` | **位置保活**:周期性向 opencode 发一次活动,阻止 60 分钟空闲回收 location(会关掉飞书长连接、机器人失联) |
266
271
  | `keepaliveIntervalMs` | number | `1200000` | 保活间隔(默认 20 分钟,夹取 5–45);必须显著小于 opencode 硬编码的 60 分钟 TTL |
267
272
 
@@ -283,6 +288,16 @@ permission.evaluate (插件 hook) permission.asked (事件流)
283
288
  - **三重单人边界**:可用范围「仅本人」+ 不申请群权限 + 代码层 open_id 白名单。
284
289
  - **`always` 语义**:仅当请求带 `save[]` 时才持久化,否则等价于「允许一次」。
285
290
 
291
+ ### 长回答处理(运行卡瘦身 + 最终答案独立)
292
+
293
+ 长任务(几十次工具调用)会把单张运行卡撑到上限(28KB / 200 元素),触发降级与**丢弃最旧块**——用户会看到"卡被撑满、前面内容消失"。默认策略:
294
+
295
+ 1. **运行卡只做进度**:最多保留最近 `runnerCardMaxTools`(默认 12)个工具块,更早的合并为「…已省略前 N 次工具调用」;单个文本块上限 `runnerCardTextMax`(默认 2KB)。
296
+ 2. **最终答案独立发送**:一轮结束时末尾文本 ≥ `finalAnswerMinChars`(默认 600 字符)→ 单独发一张「✅ 完整回答」卡(不与被工具噪声塞满的运行卡抢空间);运行卡内只留「完整回答已单独发送」提示。
297
+ 3. **超长转文件**:最终回答 ≥ `finalAnswerFileMinBytes`(默认 20KB)→ 作为 `.md` 文件发送(可预览/下载),内容不截断、不丢失。
298
+
299
+ > 想要"完整轨迹保留、不省略工具调用":把 `runnerCardMaxTools` 调到足够大并接受卡片消息变多(或提高 `finalAnswerMinChars` 降低拆分频率)。
300
+
286
301
  ### 位置保活(防空闲失联,默认开启)
287
302
 
288
303
  opencode 会**回收空闲的 location**,这会连带卸载插件、关闭飞书长连接:
package/dist/index.js CHANGED
@@ -144025,6 +144025,10 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144025
144025
  const topicStatusThrottleMs = clamp4(asNumber(merged.topicStatusThrottleMs, 1e3), 500, 1e4);
144026
144026
  const cardMaxTables = clamp4(asNumber(merged.cardMaxTables, 4), 1, 5);
144027
144027
  const keepalive = asBoolean(merged.keepalive, true);
144028
+ const runnerCardMaxTools = clamp4(asNumber(merged.runnerCardMaxTools, 12), 1, 50);
144029
+ const runnerCardTextMax = clamp4(asNumber(merged.runnerCardTextMax, 2048), 512, 8192);
144030
+ const finalAnswerMinChars = clamp4(asNumber(merged.finalAnswerMinChars, 600), 0, 2e4);
144031
+ const finalAnswerFileMinBytes = clamp4(asNumber(merged.finalAnswerFileMinBytes, 20 * 1024), 8192, 102400);
144028
144032
  const keepaliveIntervalMs = clamp4(
144029
144033
  asNumber(merged.keepaliveIntervalMs, 20 * 60 * 1e3),
144030
144034
  5 * 60 * 1e3,
@@ -144081,7 +144085,11 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144081
144085
  topicStatusThrottleMs,
144082
144086
  cardMaxTables,
144083
144087
  keepalive,
144084
- keepaliveIntervalMs
144088
+ keepaliveIntervalMs,
144089
+ runnerCardMaxTools,
144090
+ runnerCardTextMax,
144091
+ finalAnswerMinChars,
144092
+ finalAnswerFileMinBytes
144085
144093
  };
144086
144094
  }
144087
144095
  function loadConfigFile(env, deps, warnings) {
@@ -145173,6 +145181,29 @@ function createFeishuSender(client, log2, options2 = {}) {
145173
145181
  return void 0;
145174
145182
  }
145175
145183
  },
145184
+ async sendFile(chatId, fileName, content, replyToMessageId) {
145185
+ try {
145186
+ const uploaded = await client.im.file.create({
145187
+ data: { file_type: "stream", file_name: fileName, file: content }
145188
+ });
145189
+ const fileKey = uploaded?.file_key ?? "";
145190
+ if (!fileKey) return { ok: false, error: "missing file_key" };
145191
+ const payload = { msg_type: "file", content: JSON.stringify({ file_key: fileKey }) };
145192
+ const res = replyToMessageId ? await client.im.message.reply({ path: { message_id: replyToMessageId }, data: payload }) : await client.im.message.create({
145193
+ params: { receive_id_type: "chat_id" },
145194
+ data: { receive_id: chatId, ...payload }
145195
+ });
145196
+ if (res?.code && res.code !== 0) {
145197
+ log2.warn("\u53D1\u9001\u6587\u4EF6\u5931\u8D25", { chatId, fileName, code: res.code, msg: res.msg });
145198
+ return { ok: false, error: `code=${res.code} msg=${res.msg ?? ""}` };
145199
+ }
145200
+ const messageId = res?.data?.message_id ?? "";
145201
+ return messageId ? { ok: true, messageId } : { ok: true };
145202
+ } catch (err2) {
145203
+ log2.warn("\u53D1\u9001\u6587\u4EF6\u5F02\u5E38", { chatId, fileName, error: describeLarkError(err2) });
145204
+ return { ok: false, error: describeLarkError(err2) };
145205
+ }
145206
+ },
145176
145207
  async deleteMessage(messageId) {
145177
145208
  if (!messageId) return { ok: false, error: "missing messageId" };
145178
145209
  try {
@@ -145885,6 +145916,8 @@ function reduce6(state, event) {
145885
145916
  status: "error",
145886
145917
  output: event.output
145887
145918
  }));
145919
+ case "final.separated":
145920
+ return { ...state, finalSeparated: true };
145888
145921
  default:
145889
145922
  return state;
145890
145923
  }
@@ -146061,6 +146094,14 @@ function closeCodeFences(text) {
146061
146094
  if (fences && fences.length % 2 !== 0) return `${text}${CODE_FENCE}`;
146062
146095
  return text;
146063
146096
  }
146097
+ function buildFinalAnswerCard(text) {
146098
+ return {
146099
+ schema: "2.0",
146100
+ config: { update_multi: true },
146101
+ header: { title: { tag: "plain_text", content: "\u2705 \u5B8C\u6574\u56DE\u7B54" }, template: "green" },
146102
+ body: { elements: [{ tag: "markdown", content: truncateCardContent(text, 24 * 1024) }] }
146103
+ };
146104
+ }
146064
146105
 
146065
146106
  // src/feishu/run-renderer.ts
146066
146107
  var COLLAPSE_TOOL_THRESHOLD = 3;
@@ -146068,11 +146109,30 @@ var HEADER_SUMMARY_MAX = 80;
146068
146109
  var TOOL_INPUT_MAX = 600;
146069
146110
  var OUTPUT_FIRST_LINE_MAX = 200;
146070
146111
  var TEXT_ELEMENT_MAX = 8 * 1024;
146112
+ var DEFAULT_MAX_TOOLS = 12;
146113
+ var DEFAULT_TEXT_MAX = 2 * 1024;
146071
146114
  function renderRunCard(state, stopValue, options2 = {}) {
146072
146115
  const elements = [];
146073
- for (const group of groupBlocks(state.blocks)) {
146116
+ const maxTools = options2.maxTools ?? DEFAULT_MAX_TOOLS;
146117
+ const textMax = options2.textMax ?? DEFAULT_TEXT_MAX;
146118
+ const capped = capToolBlocks(state.blocks, maxTools);
146119
+ if (capped.omitted > 0) {
146120
+ elements.push(note(`\u2026\u5DF2\u7701\u7565\u524D ${capped.omitted} \u6B21\u5DE5\u5177\u8C03\u7528\uFF08\u5B8C\u6574\u8BB0\u5F55\u89C1 opencode \u4F1A\u8BDD\uFF09`));
146121
+ }
146122
+ const groups = groupBlocks(capped.blocks);
146123
+ const lastTextIndex = groups.reduce(
146124
+ (acc, g, i) => g.kind === "text" && g.content.trim() ? i : acc,
146125
+ -1
146126
+ );
146127
+ for (let i = 0; i < groups.length; i += 1) {
146128
+ const group = groups[i];
146074
146129
  if (group.kind === "text") {
146075
- if (group.content.trim()) elements.push(markdown(truncateCardContent(group.content, TEXT_ELEMENT_MAX)));
146130
+ if (!group.content.trim()) continue;
146131
+ if (state.finalSeparated && i === lastTextIndex) {
146132
+ elements.push(note("\u2705 \u5B8C\u6574\u56DE\u7B54\u5DF2\u5355\u72EC\u53D1\u9001\uFF08\u89C1\u4E0B\u65B9\u5361\u7247 / \u9644\u4EF6\uFF09"));
146133
+ continue;
146134
+ }
146135
+ elements.push(markdown(truncateCardContent(group.content, Math.min(textMax, TEXT_ELEMENT_MAX))));
146076
146136
  } else {
146077
146137
  elements.push(...renderToolGroup(group.tools, state.terminal !== "running"));
146078
146138
  }
@@ -146109,6 +146169,20 @@ function renderRunCard(state, stopValue, options2 = {}) {
146109
146169
  }
146110
146170
  return enforceSize(guarded);
146111
146171
  }
146172
+ function capToolBlocks(blocks, maxTools) {
146173
+ const total2 = blocks.reduce((n, b) => n + (b.kind === "tool" ? 1 : 0), 0);
146174
+ if (maxTools <= 0 || total2 <= maxTools) return { blocks: [...blocks], omitted: 0 };
146175
+ let toDrop = total2 - maxTools;
146176
+ const out = [];
146177
+ for (const block of blocks) {
146178
+ if (block.kind === "tool" && toDrop > 0) {
146179
+ toDrop -= 1;
146180
+ continue;
146181
+ }
146182
+ out.push(block);
146183
+ }
146184
+ return { blocks: out, omitted: total2 - maxTools };
146185
+ }
146112
146186
  function groupBlocks(blocks) {
146113
146187
  const groups = [];
146114
146188
  let toolBuf = [];
@@ -146295,6 +146369,19 @@ function byteLength(value3) {
146295
146369
  }
146296
146370
 
146297
146371
  // src/feishu/run-controller.ts
146372
+ function trailingText(blocks) {
146373
+ const parts = [];
146374
+ for (let i = blocks.length - 1; i >= 0; i -= 1) {
146375
+ const block = blocks[i];
146376
+ if (block.kind === "tool") {
146377
+ if (parts.length > 0) break;
146378
+ continue;
146379
+ }
146380
+ if (block.content.trim()) parts.unshift(block.content);
146381
+ else if (parts.length > 0) break;
146382
+ }
146383
+ return parts.join("\n\n").trim();
146384
+ }
146298
146385
  function createRunController(deps) {
146299
146386
  const sessions = /* @__PURE__ */ new Map();
146300
146387
  const throttlers = /* @__PURE__ */ new Map();
@@ -146312,6 +146399,8 @@ function createRunController(deps) {
146312
146399
  };
146313
146400
  const renderCard = (sessionID, state) => renderRunCard(state, deps.buildStopValue?.(sessionID), {
146314
146401
  maxTables: deps.cardMaxTables,
146402
+ maxTools: deps.runnerCardMaxTools,
146403
+ textMax: deps.runnerCardTextMax,
146315
146404
  onLimit: (report) => {
146316
146405
  deps.log.warn("\u8FD0\u884C\u5361\u5185\u5BB9\u8D85\u9650\uFF0C\u5DF2\u964D\u7EA7", {
146317
146406
  sessionID,
@@ -146389,7 +146478,8 @@ function createRunController(deps) {
146389
146478
  state,
146390
146479
  chain: Promise.resolve(),
146391
146480
  finalized: false,
146392
- queuedAt: Date.now()
146481
+ queuedAt: Date.now(),
146482
+ ...input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {}
146393
146483
  };
146394
146484
  if (input.delivery === "queue") {
146395
146485
  runs.queued.push(card);
@@ -146419,7 +146509,29 @@ function createRunController(deps) {
146419
146509
  }
146420
146510
  const card = runs.active;
146421
146511
  if (!card) return;
146422
- if (event.type === "execution.succeeded" || event.type === "execution.failed") {
146512
+ if (event.type === "execution.succeeded") {
146513
+ const text = trailingText(card.state.blocks);
146514
+ const long = deps.sendFinalAnswer !== void 0 && deps.finalAnswer !== void 0 && text.length >= deps.finalAnswer.minChars;
146515
+ if (long) {
146516
+ card.state = reduce6(reduce6(card.state, { type: "final.separated" }), event);
146517
+ finalize2(card);
146518
+ runs.active = void 0;
146519
+ void deps.sendFinalAnswer({
146520
+ sessionID,
146521
+ chatId: card.chatId,
146522
+ ...card.replyToMessageId ? { replyToMessageId: card.replyToMessageId } : {},
146523
+ text
146524
+ }).catch((err2) => {
146525
+ deps.log.warn("\u6700\u7EC8\u7B54\u6848\u53D1\u9001\u5931\u8D25", { sessionID, error: errorMessage(err2) });
146526
+ });
146527
+ return;
146528
+ }
146529
+ update2(card, event, true);
146530
+ finalize2(card);
146531
+ runs.active = void 0;
146532
+ return;
146533
+ }
146534
+ if (event.type === "execution.failed") {
146423
146535
  update2(card, event, true);
146424
146536
  finalize2(card);
146425
146537
  runs.active = void 0;
@@ -146737,14 +146849,24 @@ function buildFormCard(form, answers, opts = {}) {
146737
146849
  elements.push(cardButton("\u270D\uFE0F \u76F4\u63A5\u56DE\u590D\u7B54\u6848", "default", { f: form.id, k: field.key, free: true }));
146738
146850
  }
146739
146851
  }
146740
- const missing2 = missingFields(form, answers).map((k) => {
146852
+ const missingKeys = missingFields(form, answers);
146853
+ const missing2 = missingKeys.map((k) => {
146741
146854
  const f = form.fields.find((x) => x.key === k);
146742
146855
  return f ? fieldTitle(f) : k;
146743
146856
  });
146857
+ const optionOnly = missingKeys.length > 0 && missingKeys.every((k) => {
146858
+ const f = form.fields.find((x) => x.key === k);
146859
+ return (f?.options?.length ?? 0) > 0 && f?.custom !== true;
146860
+ });
146744
146861
  const hints = [];
146745
146862
  if (opts.notice) hints.push(opts.notice);
146746
- if (missing2.length > 0 && visible.length > 1) hints.push(`\u8FD8\u9700\u56DE\u7B54\uFF1A${missing2.join("\u3001")}\uFF08\u53EF\u76F4\u63A5\u53D1\u6587\u5B57\u56DE\u7B54\uFF09`);
146747
- else if (missing2.length > 0) hints.push("\u53EF\u76F4\u63A5\u53D1\u6587\u5B57\u56DE\u7B54\uFF0C\u6216\u70B9\u4E0A\u65B9\u9009\u9879\u3002");
146863
+ if (missing2.length > 0 && visible.length > 1) {
146864
+ hints.push(`\u8FD8\u9700\u56DE\u7B54\uFF1A${missing2.join("\u3001")}${optionOnly ? "\uFF08\u70B9\u9009\u9879\u6216\u56DE\u590D\u5E8F\u53F7\uFF09" : "\uFF08\u53EF\u76F4\u63A5\u53D1\u6587\u5B57\u56DE\u7B54\uFF09"}`);
146865
+ } else if (missing2.length > 0) {
146866
+ hints.push(
146867
+ optionOnly ? "\u8BF7\u70B9\u51FB\u4E0A\u65B9\u9009\u9879\uFF08\u6216\u56DE\u590D\u5E8F\u53F7\uFF0C\u5982 1\uFF09\uFF1B\u53D1\u9001\u5176\u5B83\u5185\u5BB9\u4F1A\u4F5C\u4E3A\u666E\u901A\u6D88\u606F\u5904\u7406\u3002" : "\u53EF\u76F4\u63A5\u53D1\u6587\u5B57\u56DE\u7B54\uFF0C\u6216\u70B9\u4E0A\u65B9\u9009\u9879\u3002"
146868
+ );
146869
+ }
146748
146870
  if (hints.length > 0) elements.push({ tag: "markdown", content: truncateCardContent(hints.join("\n\n")) });
146749
146871
  const header = headerFor(form);
146750
146872
  return {
@@ -146889,6 +147011,17 @@ var FormRelay = class {
146889
147011
  return false;
146890
147012
  }
146891
147013
  const field = pending.form.fields.find((f) => f.key === fieldKey);
147014
+ if (!fieldAcceptsText(field, text)) {
147015
+ this.deps.log.info("\u9009\u9879\u9898\u6536\u5230\u975E\u9009\u9879\u6587\u672C\uFF0C\u8DF3\u8FC7\u8868\u5355\u5E76\u6309\u666E\u901A\u6D88\u606F\u5904\u7406", {
147016
+ sessionID,
147017
+ formID: pending.form.id,
147018
+ fieldKey
147019
+ });
147020
+ this.awaiting.delete(sessionID);
147021
+ this.forms.delete(pending.form.id);
147022
+ void this.cancelPending(pending);
147023
+ return false;
147024
+ }
146892
147025
  pending.answers[fieldKey] = coerceAnswer(field, text);
146893
147026
  pending.awaitingField = void 0;
146894
147027
  this.awaiting.delete(sessionID);
@@ -146961,6 +147094,24 @@ var FormRelay = class {
146961
147094
  this.forms.delete(pending.form.id);
146962
147095
  await this.finish(pending, "answered");
146963
147096
  }
147097
+ /** 取消 pending form(best-effort),随后撤回卡片。 */
147098
+ async cancelPending(pending) {
147099
+ if (this.deps.cancel) {
147100
+ try {
147101
+ await this.deps.cancel({
147102
+ sessionID: pending.sessionID,
147103
+ formID: pending.form.id,
147104
+ ...pending.directory ? { directory: pending.directory } : {}
147105
+ });
147106
+ } catch (err2) {
147107
+ this.deps.log.warn("form.cancel \u5931\u8D25\uFF08\u8BE5\u6D88\u606F\u53EF\u80FD\u4ECD\u6392\u961F\uFF09", {
147108
+ formID: pending.form.id,
147109
+ error: errorMessage(err2)
147110
+ });
147111
+ }
147112
+ }
147113
+ await this.finish(pending, "cancelled");
147114
+ }
146964
147115
  /**
146965
147116
  * 表单收敛:**优先撤回卡片**(作答/取消后不再残留待填卡),撤回失败(超时限/无权限)
146966
147117
  * 才降级 patch 成结果卡,避免卡片永久停在待填态。
@@ -147011,9 +147162,28 @@ function coerceAnswer(field, text) {
147011
147162
  return matchOption(field, trimmed) ?? trimmed;
147012
147163
  }
147013
147164
  }
147165
+ function fieldAcceptsText(field, text) {
147166
+ if (!field) return true;
147167
+ const hasOptions = (field.options?.length ?? 0) > 0;
147168
+ if (!hasOptions) return true;
147169
+ if (field.custom === true) return true;
147170
+ return matchOption(field, text) !== void 0;
147171
+ }
147014
147172
  function matchOption(field, text) {
147015
147173
  if (!field.options?.length) return void 0;
147016
147174
  const trimmed = text.trim();
147175
+ const byNumber = /^(\d{1,2})[.、))]?$/.exec(trimmed);
147176
+ if (byNumber) {
147177
+ const index2 = Number(byNumber[1]) - 1;
147178
+ const option3 = field.options[index2];
147179
+ if (option3) return option3.value;
147180
+ }
147181
+ const byLetter = /^([A-Za-z])[.、))]?$/.exec(trimmed);
147182
+ if (byLetter) {
147183
+ const index2 = byLetter[1].toUpperCase().charCodeAt(0) - 65;
147184
+ const option3 = field.options[index2];
147185
+ if (option3) return option3.value;
147186
+ }
147017
147187
  const lower = trimmed.toLowerCase();
147018
147188
  const hit = field.options.find(
147019
147189
  (o) => o.value === trimmed || o.label.trim() === trimmed || o.label.trim().toLowerCase() === lower
@@ -147060,6 +147230,27 @@ function authHeaders(service3) {
147060
147230
  const token = Buffer.from(`opencode:${service3.password}`, "utf8").toString("base64");
147061
147231
  return { authorization: `Basic ${token}` };
147062
147232
  }
147233
+ async function cancelFormOverHttp(input, deps) {
147234
+ const discover = deps.discover ?? (() => discoverLocalService());
147235
+ const doFetch = deps.fetchImpl ?? fetch;
147236
+ const service3 = await discover();
147237
+ if (!service3) throw new Error("\u672A\u53D1\u73B0\u672C\u673A opencode \u670D\u52A1\u6CE8\u518C\uFF08service.json \u7F3A\u5931\u6216\u635F\u574F\uFF09");
147238
+ const path2 = `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`;
147239
+ const headers = { ...authHeaders(service3) };
147240
+ if (input.directory) headers["x-opencode-directory"] = encodeURIComponent(input.directory);
147241
+ const response2 = await doFetch(`${service3.url}${path2}`, {
147242
+ method: "DELETE",
147243
+ headers,
147244
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 8e3)
147245
+ });
147246
+ if (!response2.ok) {
147247
+ const detail = await response2.text().catch(() => "");
147248
+ deps.log.debug("\u8868\u5355 HTTP \u53D6\u6D88\u5931\u8D25", { status: response2.status, formID: input.formID });
147249
+ throw new Error(
147250
+ `\u8868\u5355\u53D6\u6D88\u5931\u8D25 HTTP ${response2.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`
147251
+ );
147252
+ }
147253
+ }
147063
147254
  async function replyFormOverHttp(input, deps) {
147064
147255
  const discover = deps.discover ?? (() => discoverLocalService());
147065
147256
  const doFetch = deps.fetchImpl ?? fetch;
@@ -151115,9 +151306,38 @@ async function start(ctx, config, log2, logSink) {
151115
151306
  enabled: config.stream,
151116
151307
  throttleMs: config.streamThrottleMs,
151117
151308
  cardMaxTables: config.cardMaxTables,
151309
+ runnerCardMaxTools: config.runnerCardMaxTools,
151310
+ runnerCardTextMax: config.runnerCardTextMax,
151311
+ finalAnswer: { minChars: config.finalAnswerMinChars },
151312
+ // 长回答单独成卡/成文件:运行卡只留「完整回答已单独发送」提示。
151313
+ sendFinalAnswer: (input) => sendFinalAnswer(input),
151118
151314
  // 每次 patch 重签强停 token(`stop` 在下方定义,闭包运行时才求值)。
151119
151315
  buildStopValue: (sessionID) => stop.buildStopValue(sessionID)
151120
151316
  });
151317
+ async function sendFinalAnswer(input) {
151318
+ const bytes = Buffer.byteLength(input.text, "utf8");
151319
+ if (bytes > config.finalAnswerFileMinBytes) {
151320
+ const fileName = `opencode-${input.sessionID.slice(-6)}.md`;
151321
+ const res2 = await sender.sendFile(
151322
+ input.chatId,
151323
+ fileName,
151324
+ Buffer.from(input.text, "utf8"),
151325
+ input.replyToMessageId
151326
+ );
151327
+ if (res2.ok) {
151328
+ log2.info("\u6700\u7EC8\u7B54\u6848\u5DF2\u8F6C\u4E3A\u6587\u4EF6\u53D1\u9001", { sessionID: input.sessionID, bytes, fileName });
151329
+ return;
151330
+ }
151331
+ log2.warn("\u6700\u7EC8\u7B54\u6848\u8F6C\u6587\u4EF6\u5931\u8D25\uFF0C\u964D\u7EA7\u4E3A\u5361\u7247", { sessionID: input.sessionID, error: res2.error ?? "unknown" });
151332
+ }
151333
+ const card = buildFinalAnswerCard(input.text);
151334
+ const res = input.replyToMessageId ? await sender.replyCard(input.replyToMessageId, card) : await sender.sendCard(input.chatId, card);
151335
+ if (!res.ok) {
151336
+ log2.warn("\u6700\u7EC8\u7B54\u6848\u5361\u7247\u53D1\u9001\u5931\u8D25", { sessionID: input.sessionID, error: res.error ?? "unknown" });
151337
+ return;
151338
+ }
151339
+ log2.info("\u6700\u7EC8\u7B54\u6848\u5DF2\u5355\u72EC\u6210\u5361", { sessionID: input.sessionID, bytes });
151340
+ }
151121
151341
  const recovery = createSessionRecovery({
151122
151342
  log: log2,
151123
151343
  resolveLink: (sessionID) => sessionMap.resolveBySession(sessionID),
@@ -151160,7 +151380,9 @@ async function start(ctx, config, log2, logSink) {
151160
151380
  log: log2,
151161
151381
  getLink: (sessionID) => sessionMap.resolveBySession(sessionID),
151162
151382
  isAllowed: (openId) => owner.isAllowed(openId),
151163
- reply: (input) => replyForm(ctx, input, log2)
151383
+ reply: (input) => replyForm(ctx, input, log2),
151384
+ // 选项题收到非选项文本 → 取消表单(解除阻塞),让该消息按普通 prompt 处理。
151385
+ cancel: (input) => cancelFormOverHttp(input, { log: log2 })
151164
151386
  });
151165
151387
  const wizard = new WizardStore(storage, log2);
151166
151388
  const recent = new RecentStore(storage, log2, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",