opencode-feishu-plugin 0.2.4 → 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 +2 -0
- package/README.md +3 -1
- package/dist/index.js +147 -5
- package/package.json +1 -1
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".
|
|
@@ -389,6 +390,7 @@ Without this relay, any clarifying question would stall the Feishu session forev
|
|
|
389
390
|
| `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | Log level (secrets are never logged, only their presence) |
|
|
390
391
|
| `logFile` | string \| boolean | — | `true` writes `<configDir>/plugins/feishu.log`. **Plugin stderr is discarded in service mode — enable this when debugging** |
|
|
391
392
|
| `gatewayLocation` | string | — | Only start the gateway in this location **or any of its subdirectories**. `~` is expanded and relative paths / trailing slashes are normalized. **Set this to your usual working directory** to avoid multiple long connections; leave empty to run in every location |
|
|
393
|
+
| `gatewayMatchGraceMs` | number | `3000` | Grace window for **exact-match priority**: a subdirectory candidate waits this long and yields if a location equal to `gatewayLocation` shows up (`0` = no wait, subdirectory takes over immediately) |
|
|
392
394
|
| `approvalTtlMs` | number | `600000` | Approval token / card TTL |
|
|
393
395
|
| `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 |
|
|
394
396
|
| `maxResourcesShown` | number | `8` | Max resource lines shown on an approval card |
|
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
|
|
|
@@ -249,7 +250,8 @@ agent 调 `question` 等 form 类交互时,插件把它转成飞书卡片:
|
|
|
249
250
|
| `recentDirsLimit` / `recentModelsLimit` | number | `5` | 表单「最近使用」条数(1–20) |
|
|
250
251
|
| `logLevel` | `debug`\|`info`\|`warn`\|`error` | `info` | 日志级别 |
|
|
251
252
|
| `logFile` | string \| boolean | — | `true` = 写 `<configDir>/plugins/feishu.log`;服务模式建议开启 |
|
|
252
|
-
| `gatewayLocation` | string | — | 只在该 location
|
|
253
|
+
| `gatewayLocation` | string | — | 只在该 location(或其**子目录**兜底)启动网关;`~` 自动展开、相对路径/尾斜杠会归一化。留空 = 任意 location 生效 |
|
|
254
|
+
| `gatewayMatchGraceMs` | number | `3000` | **精确匹配优先**的宽限窗口:子目录候选先等这么久,出现 `here === gatewayLocation` 就让位(0 = 不等待,子目录立即兜底) |
|
|
253
255
|
| `approvalTtlMs` | number | `600000` | 审批 token / 卡片有效期 |
|
|
254
256
|
| `staleExecutionMs` | number | `300000` | 看门狗阈值(夹取 1–60 分钟) |
|
|
255
257
|
| `maxResourcesShown` | number | `8` | 审批卡最多展示的资源行数 |
|
package/dist/index.js
CHANGED
|
@@ -144033,6 +144033,7 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
|
|
|
144033
144033
|
const domain = merged.domain === "lark" ? "lark" : "feishu";
|
|
144034
144034
|
const logFile = resolveLogFile(merged.logFile, env, deps);
|
|
144035
144035
|
const gatewayLocation = normalizeGatewayLocation(asString(merged.gatewayLocation));
|
|
144036
|
+
const gatewayMatchGraceMs = clamp4(asNumber(merged.gatewayMatchGraceMs, 3e3), 0, 1e4);
|
|
144036
144037
|
const signSecretRaw = expandEnv(asString(merged.signSecret), env);
|
|
144037
144038
|
const signSecret = signSecretRaw && signSecretRaw.length > 0 ? signSecretRaw : deriveSignSecret(appSecret);
|
|
144038
144039
|
let enabled = true;
|
|
@@ -144062,6 +144063,7 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
|
|
|
144062
144063
|
logLevel,
|
|
144063
144064
|
logFile,
|
|
144064
144065
|
gatewayLocation,
|
|
144066
|
+
gatewayMatchGraceMs,
|
|
144065
144067
|
approvalTtlMs,
|
|
144066
144068
|
staleExecutionMs,
|
|
144067
144069
|
signSecret,
|
|
@@ -144276,6 +144278,45 @@ function releaseProcessGuard() {
|
|
|
144276
144278
|
const g = globalThis;
|
|
144277
144279
|
const slot = g[GUARD_SLOT];
|
|
144278
144280
|
if (slot) slot.active = false;
|
|
144281
|
+
resetExactGateway();
|
|
144282
|
+
}
|
|
144283
|
+
var GATEWAY_EXACT_SLOT = /* @__PURE__ */ Symbol.for("opencode-feishu-v2/gateway-exact");
|
|
144284
|
+
function electionSlot() {
|
|
144285
|
+
const g = globalThis;
|
|
144286
|
+
let slot = g[GATEWAY_EXACT_SLOT];
|
|
144287
|
+
if (!slot) {
|
|
144288
|
+
slot = { exact: false, resolvers: [] };
|
|
144289
|
+
g[GATEWAY_EXACT_SLOT] = slot;
|
|
144290
|
+
}
|
|
144291
|
+
return slot;
|
|
144292
|
+
}
|
|
144293
|
+
function markExactGateway() {
|
|
144294
|
+
const slot = electionSlot();
|
|
144295
|
+
slot.exact = true;
|
|
144296
|
+
if (slot.timer) {
|
|
144297
|
+
clearTimeout(slot.timer);
|
|
144298
|
+
slot.timer = void 0;
|
|
144299
|
+
}
|
|
144300
|
+
for (const resolve4 of slot.resolvers.splice(0)) resolve4(true);
|
|
144301
|
+
}
|
|
144302
|
+
function resetExactGateway() {
|
|
144303
|
+
const g = globalThis;
|
|
144304
|
+
const slot = g[GATEWAY_EXACT_SLOT];
|
|
144305
|
+
if (slot) slot.exact = false;
|
|
144306
|
+
}
|
|
144307
|
+
function waitForExactGateway(graceMs) {
|
|
144308
|
+
const slot = electionSlot();
|
|
144309
|
+
if (slot.exact) return Promise.resolve(true);
|
|
144310
|
+
if (!(graceMs > 0)) return Promise.resolve(false);
|
|
144311
|
+
return new Promise((resolve4) => {
|
|
144312
|
+
slot.resolvers.push(resolve4);
|
|
144313
|
+
if (!slot.timer) {
|
|
144314
|
+
slot.timer = setTimeout(() => {
|
|
144315
|
+
slot.timer = void 0;
|
|
144316
|
+
for (const r of slot.resolvers.splice(0)) r(false);
|
|
144317
|
+
}, graceMs);
|
|
144318
|
+
}
|
|
144319
|
+
});
|
|
144279
144320
|
}
|
|
144280
144321
|
var GATEWAY_SEEN_SLOT = /* @__PURE__ */ Symbol.for("opencode-feishu-v2/gateway-seen");
|
|
144281
144322
|
function trackGatewayLocationSeen(input) {
|
|
@@ -146696,14 +146737,24 @@ function buildFormCard(form, answers, opts = {}) {
|
|
|
146696
146737
|
elements.push(cardButton("\u270D\uFE0F \u76F4\u63A5\u56DE\u590D\u7B54\u6848", "default", { f: form.id, k: field.key, free: true }));
|
|
146697
146738
|
}
|
|
146698
146739
|
}
|
|
146699
|
-
const
|
|
146740
|
+
const missingKeys = missingFields(form, answers);
|
|
146741
|
+
const missing2 = missingKeys.map((k) => {
|
|
146700
146742
|
const f = form.fields.find((x) => x.key === k);
|
|
146701
146743
|
return f ? fieldTitle(f) : k;
|
|
146702
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
|
+
});
|
|
146703
146749
|
const hints = [];
|
|
146704
146750
|
if (opts.notice) hints.push(opts.notice);
|
|
146705
|
-
if (missing2.length > 0 && visible.length > 1)
|
|
146706
|
-
|
|
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
|
+
}
|
|
146707
146758
|
if (hints.length > 0) elements.push({ tag: "markdown", content: truncateCardContent(hints.join("\n\n")) });
|
|
146708
146759
|
const header = headerFor(form);
|
|
146709
146760
|
return {
|
|
@@ -146848,6 +146899,17 @@ var FormRelay = class {
|
|
|
146848
146899
|
return false;
|
|
146849
146900
|
}
|
|
146850
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
|
+
}
|
|
146851
146913
|
pending.answers[fieldKey] = coerceAnswer(field, text);
|
|
146852
146914
|
pending.awaitingField = void 0;
|
|
146853
146915
|
this.awaiting.delete(sessionID);
|
|
@@ -146920,6 +146982,24 @@ var FormRelay = class {
|
|
|
146920
146982
|
this.forms.delete(pending.form.id);
|
|
146921
146983
|
await this.finish(pending, "answered");
|
|
146922
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
|
+
}
|
|
146923
147003
|
/**
|
|
146924
147004
|
* 表单收敛:**优先撤回卡片**(作答/取消后不再残留待填卡),撤回失败(超时限/无权限)
|
|
146925
147005
|
* 才降级 patch 成结果卡,避免卡片永久停在待填态。
|
|
@@ -146970,9 +147050,28 @@ function coerceAnswer(field, text) {
|
|
|
146970
147050
|
return matchOption(field, trimmed) ?? trimmed;
|
|
146971
147051
|
}
|
|
146972
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
|
+
}
|
|
146973
147060
|
function matchOption(field, text) {
|
|
146974
147061
|
if (!field.options?.length) return void 0;
|
|
146975
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
|
+
}
|
|
146976
147075
|
const lower = trimmed.toLowerCase();
|
|
146977
147076
|
const hit = field.options.find(
|
|
146978
147077
|
(o) => o.value === trimmed || o.label.trim() === trimmed || o.label.trim().toLowerCase() === lower
|
|
@@ -147019,6 +147118,27 @@ function authHeaders(service3) {
|
|
|
147019
147118
|
const token = Buffer.from(`opencode:${service3.password}`, "utf8").toString("base64");
|
|
147020
147119
|
return { authorization: `Basic ${token}` };
|
|
147021
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
|
+
}
|
|
147022
147142
|
async function replyFormOverHttp(input, deps) {
|
|
147023
147143
|
const discover = deps.discover ?? (() => discoverLocalService());
|
|
147024
147144
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
@@ -150996,7 +151116,9 @@ var index_default = plugin_exports.define({
|
|
|
150996
151116
|
}
|
|
150997
151117
|
}
|
|
150998
151118
|
if (config.gatewayLocation) {
|
|
150999
|
-
const
|
|
151119
|
+
const exact = Boolean(here) && here === config.gatewayLocation;
|
|
151120
|
+
const sub = Boolean(here) && !exact && isUnder(here, config.gatewayLocation);
|
|
151121
|
+
const matched = exact || sub;
|
|
151000
151122
|
const keepSink = matched || startedWatchdog;
|
|
151001
151123
|
try {
|
|
151002
151124
|
trackGatewayLocationSeen({
|
|
@@ -151015,6 +151137,24 @@ var index_default = plugin_exports.define({
|
|
|
151015
151137
|
return async () => {
|
|
151016
151138
|
};
|
|
151017
151139
|
}
|
|
151140
|
+
if (exact) {
|
|
151141
|
+
markExactGateway();
|
|
151142
|
+
} else {
|
|
151143
|
+
const exactSeen = await waitForExactGateway(config.gatewayMatchGraceMs);
|
|
151144
|
+
if (exactSeen) {
|
|
151145
|
+
log2.debug("\u7CBE\u786E\u5339\u914D location \u5DF2\u5C31\u7EEA\uFF0C\u5B50\u76EE\u5F55\u5019\u9009\u8BA9\u4F4D", {
|
|
151146
|
+
here,
|
|
151147
|
+
expected: config.gatewayLocation
|
|
151148
|
+
});
|
|
151149
|
+
if (!keepSink) logSink?.close();
|
|
151150
|
+
return async () => {
|
|
151151
|
+
};
|
|
151152
|
+
}
|
|
151153
|
+
log2.info("\u7CBE\u786E\u5339\u914D location \u672A\u51FA\u73B0\uFF0C\u5B50\u76EE\u5F55\u5019\u9009\u63A5\u7BA1\u7F51\u5173", {
|
|
151154
|
+
here,
|
|
151155
|
+
expected: config.gatewayLocation
|
|
151156
|
+
});
|
|
151157
|
+
}
|
|
151018
151158
|
}
|
|
151019
151159
|
if (!acquireProcessGuard()) {
|
|
151020
151160
|
log2.debug("\u68C0\u6D4B\u5230\u540C\u8FDB\u7A0B\u91CD\u590D setup\uFF0C\u8DF3\u8FC7\u542F\u52A8\uFF08\u4EC5\u9996\u4E2A\u5B9E\u4F8B\u751F\u6548\uFF09");
|
|
@@ -151099,7 +151239,9 @@ async function start(ctx, config, log2, logSink) {
|
|
|
151099
151239
|
log: log2,
|
|
151100
151240
|
getLink: (sessionID) => sessionMap.resolveBySession(sessionID),
|
|
151101
151241
|
isAllowed: (openId) => owner.isAllowed(openId),
|
|
151102
|
-
reply: (input) => replyForm(ctx, input, log2)
|
|
151242
|
+
reply: (input) => replyForm(ctx, input, log2),
|
|
151243
|
+
// 选项题收到非选项文本 → 取消表单(解除阻塞),让该消息按普通 prompt 处理。
|
|
151244
|
+
cancel: (input) => cancelFormOverHttp(input, { log: log2 })
|
|
151103
151245
|
});
|
|
151104
151246
|
const wizard = new WizardStore(storage, log2);
|
|
151105
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.
|
|
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",
|