opencode-feishu-plugin 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.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".
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
 
package/dist/index.js CHANGED
@@ -146737,14 +146737,24 @@ function buildFormCard(form, answers, opts = {}) {
146737
146737
  elements.push(cardButton("\u270D\uFE0F \u76F4\u63A5\u56DE\u590D\u7B54\u6848", "default", { f: form.id, k: field.key, free: true }));
146738
146738
  }
146739
146739
  }
146740
- const missing2 = missingFields(form, answers).map((k) => {
146740
+ const missingKeys = missingFields(form, answers);
146741
+ const missing2 = missingKeys.map((k) => {
146741
146742
  const f = form.fields.find((x) => x.key === k);
146742
146743
  return f ? fieldTitle(f) : k;
146743
146744
  });
146745
+ const optionOnly = missingKeys.length > 0 && missingKeys.every((k) => {
146746
+ const f = form.fields.find((x) => x.key === k);
146747
+ return (f?.options?.length ?? 0) > 0 && f?.custom !== true;
146748
+ });
146744
146749
  const hints = [];
146745
146750
  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");
146751
+ if (missing2.length > 0 && visible.length > 1) {
146752
+ 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"}`);
146753
+ } else if (missing2.length > 0) {
146754
+ hints.push(
146755
+ 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"
146756
+ );
146757
+ }
146748
146758
  if (hints.length > 0) elements.push({ tag: "markdown", content: truncateCardContent(hints.join("\n\n")) });
146749
146759
  const header = headerFor(form);
146750
146760
  return {
@@ -146889,6 +146899,17 @@ var FormRelay = class {
146889
146899
  return false;
146890
146900
  }
146891
146901
  const field = pending.form.fields.find((f) => f.key === fieldKey);
146902
+ if (!fieldAcceptsText(field, text)) {
146903
+ 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", {
146904
+ sessionID,
146905
+ formID: pending.form.id,
146906
+ fieldKey
146907
+ });
146908
+ this.awaiting.delete(sessionID);
146909
+ this.forms.delete(pending.form.id);
146910
+ void this.cancelPending(pending);
146911
+ return false;
146912
+ }
146892
146913
  pending.answers[fieldKey] = coerceAnswer(field, text);
146893
146914
  pending.awaitingField = void 0;
146894
146915
  this.awaiting.delete(sessionID);
@@ -146961,6 +146982,24 @@ var FormRelay = class {
146961
146982
  this.forms.delete(pending.form.id);
146962
146983
  await this.finish(pending, "answered");
146963
146984
  }
146985
+ /** 取消 pending form(best-effort),随后撤回卡片。 */
146986
+ async cancelPending(pending) {
146987
+ if (this.deps.cancel) {
146988
+ try {
146989
+ await this.deps.cancel({
146990
+ sessionID: pending.sessionID,
146991
+ formID: pending.form.id,
146992
+ ...pending.directory ? { directory: pending.directory } : {}
146993
+ });
146994
+ } catch (err2) {
146995
+ this.deps.log.warn("form.cancel \u5931\u8D25\uFF08\u8BE5\u6D88\u606F\u53EF\u80FD\u4ECD\u6392\u961F\uFF09", {
146996
+ formID: pending.form.id,
146997
+ error: errorMessage(err2)
146998
+ });
146999
+ }
147000
+ }
147001
+ await this.finish(pending, "cancelled");
147002
+ }
146964
147003
  /**
146965
147004
  * 表单收敛:**优先撤回卡片**(作答/取消后不再残留待填卡),撤回失败(超时限/无权限)
146966
147005
  * 才降级 patch 成结果卡,避免卡片永久停在待填态。
@@ -147011,9 +147050,28 @@ function coerceAnswer(field, text) {
147011
147050
  return matchOption(field, trimmed) ?? trimmed;
147012
147051
  }
147013
147052
  }
147053
+ function fieldAcceptsText(field, text) {
147054
+ if (!field) return true;
147055
+ const hasOptions = (field.options?.length ?? 0) > 0;
147056
+ if (!hasOptions) return true;
147057
+ if (field.custom === true) return true;
147058
+ return matchOption(field, text) !== void 0;
147059
+ }
147014
147060
  function matchOption(field, text) {
147015
147061
  if (!field.options?.length) return void 0;
147016
147062
  const trimmed = text.trim();
147063
+ const byNumber = /^(\d{1,2})[.、))]?$/.exec(trimmed);
147064
+ if (byNumber) {
147065
+ const index2 = Number(byNumber[1]) - 1;
147066
+ const option3 = field.options[index2];
147067
+ if (option3) return option3.value;
147068
+ }
147069
+ const byLetter = /^([A-Za-z])[.、))]?$/.exec(trimmed);
147070
+ if (byLetter) {
147071
+ const index2 = byLetter[1].toUpperCase().charCodeAt(0) - 65;
147072
+ const option3 = field.options[index2];
147073
+ if (option3) return option3.value;
147074
+ }
147017
147075
  const lower = trimmed.toLowerCase();
147018
147076
  const hit = field.options.find(
147019
147077
  (o) => o.value === trimmed || o.label.trim() === trimmed || o.label.trim().toLowerCase() === lower
@@ -147060,6 +147118,27 @@ function authHeaders(service3) {
147060
147118
  const token = Buffer.from(`opencode:${service3.password}`, "utf8").toString("base64");
147061
147119
  return { authorization: `Basic ${token}` };
147062
147120
  }
147121
+ async function cancelFormOverHttp(input, deps) {
147122
+ const discover = deps.discover ?? (() => discoverLocalService());
147123
+ const doFetch = deps.fetchImpl ?? fetch;
147124
+ const service3 = await discover();
147125
+ if (!service3) throw new Error("\u672A\u53D1\u73B0\u672C\u673A opencode \u670D\u52A1\u6CE8\u518C\uFF08service.json \u7F3A\u5931\u6216\u635F\u574F\uFF09");
147126
+ const path2 = `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`;
147127
+ const headers = { ...authHeaders(service3) };
147128
+ if (input.directory) headers["x-opencode-directory"] = encodeURIComponent(input.directory);
147129
+ const response2 = await doFetch(`${service3.url}${path2}`, {
147130
+ method: "DELETE",
147131
+ headers,
147132
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 8e3)
147133
+ });
147134
+ if (!response2.ok) {
147135
+ const detail = await response2.text().catch(() => "");
147136
+ deps.log.debug("\u8868\u5355 HTTP \u53D6\u6D88\u5931\u8D25", { status: response2.status, formID: input.formID });
147137
+ throw new Error(
147138
+ `\u8868\u5355\u53D6\u6D88\u5931\u8D25 HTTP ${response2.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`
147139
+ );
147140
+ }
147141
+ }
147063
147142
  async function replyFormOverHttp(input, deps) {
147064
147143
  const discover = deps.discover ?? (() => discoverLocalService());
147065
147144
  const doFetch = deps.fetchImpl ?? fetch;
@@ -151160,7 +151239,9 @@ async function start(ctx, config, log2, logSink) {
151160
151239
  log: log2,
151161
151240
  getLink: (sessionID) => sessionMap.resolveBySession(sessionID),
151162
151241
  isAllowed: (openId) => owner.isAllowed(openId),
151163
- reply: (input) => replyForm(ctx, input, log2)
151242
+ reply: (input) => replyForm(ctx, input, log2),
151243
+ // 选项题收到非选项文本 → 取消表单(解除阻塞),让该消息按普通 prompt 处理。
151244
+ cancel: (input) => cancelFormOverHttp(input, { log: log2 })
151164
151245
  });
151165
151246
  const wizard = new WizardStore(storage, log2);
151166
151247
  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.6",
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",