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 +13 -0
- package/README.md +15 -0
- package/dist/index.js +231 -9
- 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".
|
|
@@ -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
|
-
|
|
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())
|
|
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"
|
|
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
|
|
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)
|
|
146747
|
-
|
|
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.
|
|
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",
|