openclaw-lark-multi-agent 1.4.1 → 1.4.3

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.
@@ -19,6 +19,8 @@ const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
19
19
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
20
20
  const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
21
21
  const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
22
+ const DELIVERY_MAX_ATTEMPTS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_DELIVERY_MAX_ATTEMPTS || 5);
23
+ const DELIVERY_RETRY_BASE_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_DELIVERY_RETRY_BASE_MS || 1_000);
22
24
  // Auto-retry: when a reply looks truncated/incomplete, ask the same session
23
25
  // whether it finished. If it did not, let it continue; loop until it confirms
24
26
  // completion or the retry budget is spent. Off by setting AUTO_RETRY=0.
@@ -88,6 +90,8 @@ export class FeishuBot {
88
90
  sendQueue = new Map();
89
91
  /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
90
92
  delayedFailureTimers = new Map();
93
+ /** Per-chat durable outbox retry timer. */
94
+ deliveryRetryTimers = new Map();
91
95
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
92
96
  lastRealDeliveryAt = new Map();
93
97
  /** Per-chat: whether we've already sent the high-context alert this cycle. */
@@ -229,14 +233,18 @@ export class FeishuBot {
229
233
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
230
234
  this.cancelDelayedFailure(chatId);
231
235
  const activeTarget = this.activeDeliveryTargets.get(chatId);
236
+ let finalStatusMeta;
232
237
  try {
233
- await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, undefined);
234
238
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
239
+ finalStatusMeta = await activeTarget?.liveStatus?.prepareTerminalDelivery(false);
240
+ }
241
+ await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, finalStatusMeta);
242
+ if (!finalStatusMeta && sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
235
243
  await activeTarget?.liveStatus?.complete().catch(() => { });
236
244
  }
237
245
  }
238
246
  catch (err) {
239
- if (sourceType !== "verbose_transcript")
247
+ if (!finalStatusMeta && sourceType !== "verbose_transcript")
240
248
  await activeTarget?.liveStatus?.fail().catch(() => { });
241
249
  throw err;
242
250
  }
@@ -318,9 +326,9 @@ export class FeishuBot {
318
326
  */
319
327
  async drainOnStartup() {
320
328
  try {
321
- const restoredDeliveries = this.store.resetStaleDeliveries(this.config.name, 5 * 60_000, 5);
322
- if (restoredDeliveries.restored > 0 || restoredDeliveries.failed > 0) {
323
- console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries.restored}, failed=${restoredDeliveries.failed}`);
329
+ const restoredDeliveries = this.store.resetDeliveringOnStartup(this.config.name);
330
+ if (restoredDeliveries > 0) {
331
+ console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries}`);
324
332
  }
325
333
  const chats = this.store.getAllChatInfo();
326
334
  const drainTasks = [];
@@ -1342,7 +1350,18 @@ export class FeishuBot {
1342
1350
  // submitted far enough that auto-replaying risks duplicate delivery.
1343
1351
  // The user can resend a new message if they want to retry.
1344
1352
  markSubmittedBatch("empty-returned");
1345
- await liveStatus?.noReply().catch(() => { });
1353
+ // The proactive session.message path can already have delivered the
1354
+ // real answer on this trigger's shared live-status card while the
1355
+ // chat-final collector returns an empty string. In that race, a late
1356
+ // noReply() patch would overwrite the answer with the idle summary.
1357
+ const existingAnswer = this.store.getDeliveryByKey(this.config.name, chatId, `trigger:${triggerId}`);
1358
+ const answerAlreadyOwned = this.deliveryOwnsVisiblePayload(existingAnswer);
1359
+ if (answerAlreadyOwned) {
1360
+ console.log(`[${this.config.name}] Empty-final cleanup skipped; trigger ${triggerId} already has an answer delivery`);
1361
+ }
1362
+ else {
1363
+ await liveStatus?.noReply().catch(() => { });
1364
+ }
1346
1365
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
1347
1366
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1348
1367
  const remainingAcks = [];
@@ -1416,13 +1435,21 @@ export class FeishuBot {
1416
1435
  await liveStatus?.complete().catch(() => { });
1417
1436
  }
1418
1437
  else {
1438
+ let finalStatusMeta;
1419
1439
  try {
1420
- // Final answer always goes through the normal interactive-card
1421
- // delivery path so Markdown renders correctly. The live status is a
1422
- // SEPARATE message: once the final reply is enqueued, mark the
1423
- // status message done.
1424
- await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
1425
- await liveStatus?.complete();
1440
+ // If a live-status card exists, freeze it and persist enough
1441
+ // metadata in the outbox to turn THIS SAME card into the final
1442
+ // answer. Fast replies / disabled status / create failures have no
1443
+ // card and transparently use the existing new-message path.
1444
+ // Text answers become the card body. Attachment-only results still
1445
+ // freeze the card so the outbox can durably collapse it to a done
1446
+ // summary (including disabled-but-existing cards).
1447
+ if ((shouldReply && visibleReply.trim()) || hasAttachments) {
1448
+ finalStatusMeta = await liveStatus?.prepareTerminalDelivery(false);
1449
+ }
1450
+ await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, finalStatusMeta);
1451
+ if (!finalStatusMeta)
1452
+ await liveStatus?.complete();
1426
1453
  // Mark every merged trigger as delivered so retries/restarts do
1427
1454
  // not re-process any of them.
1428
1455
  for (const id of mergedTriggerIds)
@@ -1434,7 +1461,11 @@ export class FeishuBot {
1434
1461
  // enqueueAndDispatchDelivery already sent a user-visible delivery
1435
1462
  // failure. Do not fall through to the generic provider-error path;
1436
1463
  // that creates a second misleading "bot did not complete" message.
1437
- await liveStatus?.fail().catch(() => { });
1464
+ // If the card was not frozen for merged final delivery, retain the
1465
+ // normal interrupted status. A prepared card has already been
1466
+ // patched/fallen back by the durable dispatcher.
1467
+ if (!finalStatusMeta)
1468
+ await liveStatus?.fail().catch(() => { });
1438
1469
  console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
1439
1470
  }
1440
1471
  }
@@ -1444,10 +1475,44 @@ export class FeishuBot {
1444
1475
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1445
1476
  }
1446
1477
  else if (!shouldReply && !hasAttachments) {
1447
- // Explicit NO_REPLY (or empty visible text): the model finished without
1448
- // producing a user-visible reply. Mark the status card done with a
1449
- // "no content" summary instead of leaving it stuck on "正在执行".
1450
- await liveStatus?.noReply().catch(() => { });
1478
+ // A proactive session.message may already own this trigger and carry
1479
+ // the real answer while collectReply returns empty/NO_REPLY. Never
1480
+ // overwrite that same card with a 💤 summary.
1481
+ const existingAnswer = this.store.getDeliveryByKey(this.config.name, chatId, `trigger:${triggerId}`);
1482
+ const answerAlreadyOwned = this.deliveryOwnsVisiblePayload(existingAnswer);
1483
+ if (answerAlreadyOwned) {
1484
+ console.log(`[${this.config.name}] NO_REPLY cleanup skipped; trigger ${triggerId} already has an answer delivery`);
1485
+ }
1486
+ else {
1487
+ // Explicit NO_REPLY/empty final also uses the durable cleanup outbox
1488
+ // when a card exists, so a previously-disabled card gets one terminal
1489
+ // patch attempt instead of remaining stuck on "正在执行".
1490
+ const noReplyMeta = await liveStatus?.prepareTerminalDelivery(true);
1491
+ if (noReplyMeta) {
1492
+ const cleanupId = this.store.enqueueDelivery({
1493
+ sessionKey: this.deliverySessionKey(chatId),
1494
+ chatId,
1495
+ botName: this.config.name,
1496
+ sourceType: "no_reply_status_cleanup",
1497
+ sourceId: `no-reply:${triggerId}:${noReplyMeta.messageId}`,
1498
+ deliveryKey: `trigger:${triggerId}:no-reply-status`,
1499
+ contentHash: "",
1500
+ content: "",
1501
+ attachmentsJson: "[]",
1502
+ replyToMessageId: lastHuman.messageId || "",
1503
+ deliveryMode: "patch_live_status",
1504
+ targetMessageId: noReplyMeta.messageId,
1505
+ deliveryMetaJson: JSON.stringify(noReplyMeta),
1506
+ textDelivered: true,
1507
+ cleanupPending: true,
1508
+ });
1509
+ if (cleanupId)
1510
+ await this.dispatchPendingDeliveries(chatId, lastHuman.messageId);
1511
+ }
1512
+ else {
1513
+ await liveStatus?.noReply().catch(() => { });
1514
+ }
1515
+ }
1451
1516
  }
1452
1517
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1453
1518
  // Replace ack reactions with DONE only for trigger messages actually
@@ -1658,6 +1723,60 @@ export class FeishuBot {
1658
1723
  },
1659
1724
  };
1660
1725
  }
1726
+ deliveryOwnsVisiblePayload(item) {
1727
+ if (!item || item.status === "failed")
1728
+ return false;
1729
+ if (item.content.trim())
1730
+ return true;
1731
+ try {
1732
+ const attachments = JSON.parse(item.attachmentsJson || "[]");
1733
+ return Array.isArray(attachments) && attachments.length > 0;
1734
+ }
1735
+ catch {
1736
+ return false;
1737
+ }
1738
+ }
1739
+ completionSummary(meta, emoji = "✅") {
1740
+ const en = meta.locale === "en";
1741
+ const core = en
1742
+ ? `${emoji} ${meta.toolCalls} tool call${meta.toolCalls === 1 ? "" : "s"} · ⏱ ${meta.elapsed}`
1743
+ : `${emoji} 累计${meta.toolCalls} 次工具调用 · ⏱ 耗时${meta.elapsed}`;
1744
+ return meta.model ? `${core} · 🧠 ${this.escapeCardText(meta.model)}` : core;
1745
+ }
1746
+ /** Final answer card used to replace the running live-status card in place. */
1747
+ buildFinalAnswerCard(text, meta) {
1748
+ const elements = buildFeishuCardElements(text);
1749
+ elements.push({ tag: "hr" });
1750
+ elements.push({
1751
+ tag: "markdown",
1752
+ content: `<font color='grey'>${this.completionSummary(meta)}</font>`,
1753
+ });
1754
+ return {
1755
+ schema: "2.0",
1756
+ config: { update_multi: true, width_mode: "fill" },
1757
+ body: { elements },
1758
+ };
1759
+ }
1760
+ async patchFinalAnswerCard(messageId, text, meta, _chatId) {
1761
+ await this.client.im.message.patch({
1762
+ path: { message_id: messageId },
1763
+ data: { content: JSON.stringify(this.buildFinalAnswerCard(text, meta)) },
1764
+ });
1765
+ }
1766
+ /** Best-effort cleanup when a final-card patch was too large/failed and the
1767
+ * answer had to be delivered as a separate message. */
1768
+ async patchLiveStatusDoneSummary(messageId, meta, chatId) {
1769
+ const view = {
1770
+ title: meta.locale === "en" ? `✅ ${this.config.name} done` : `✅ ${this.config.name} 已完成`,
1771
+ lines: [],
1772
+ elapsed: meta.elapsed,
1773
+ model: meta.model,
1774
+ toolCalls: meta.toolCalls,
1775
+ noReply: Boolean(meta.noReply),
1776
+ state: "done",
1777
+ };
1778
+ await this.patchLiveStatusCard(messageId, view, chatId);
1779
+ }
1661
1780
  formatUserVisibleError(err) {
1662
1781
  const raw = err instanceof Error ? err.message : String(err);
1663
1782
  let reason = raw.replace(/\s+/g, " ").trim();
@@ -1711,16 +1830,14 @@ export class FeishuBot {
1711
1830
  * Heuristic: does this reply look truncated / cut off mid-task? Used as the
1712
1831
  * SOLE auto-retry trigger — if true, we ask the session a one-line “done?” probe.
1713
1832
  *
1714
- * Signals (any one is enough):
1715
- * 1. Unbalanced code fence — almost certainly cut off mid code block.
1716
- * 2. Trailing clause-joining punctuation (comma/colon/semicolon/、) — a
1717
- * sentence was left hanging.
1718
- * 3. A dangling connector / lead-in (“然后”, “接下来”, “let me”, “and”…).
1719
- * 4. Ends without ANY sentence-ending punctuation / closer (no terminal
1720
- * punctuation, closing bracket/quote, code fence, or wrap-up emoji).
1721
- * Per Stephen: a reply that ends with no punctuation at all is also
1722
- * treated as likely unfinished. A false positive only costs one cheap,
1723
- * invisible confirmation probe; the session itself is the real arbiter.
1833
+ * Signals are intentionally HIGH PRECISION. A reply is suspicious only when:
1834
+ * 1. Its Markdown/code structure is visibly unclosed; or
1835
+ * 2. It ends on punctuation/words that grammatically demand continuation.
1836
+ *
1837
+ * Merely lacking a final punctuation mark is NOT enough. Short answers,
1838
+ * headings, model lists, paths, hashes, commands, English labels, and normal
1839
+ * chat prose routinely end without punctuation; treating all of them as
1840
+ * truncated caused obvious false-positive confirmation probes.
1724
1841
  */
1725
1842
  looksTruncated(text) {
1726
1843
  const t = (text || "").trim();
@@ -1730,21 +1847,19 @@ export class FeishuBot {
1730
1847
  const fences = (t.match(/```/g) || []).length;
1731
1848
  if (fences % 2 === 1)
1732
1849
  return true;
1733
- // (2a) Trailing clause-joining punctuation: a sentence was left hanging.
1734
- if (/[,,::、;;]\s*$/u.test(t))
1850
+ // (2a) A terminal comma/semicolon/、 strongly suggests an unfinished clause.
1851
+ // A colon is only suspicious when it is a real prose lead-in near the end;
1852
+ // URLs, times, drive letters, key:value and headings commonly end in colons.
1853
+ if (/[,,、;;]\s*$/u.test(t))
1735
1854
  return true;
1736
- // (2b) Ends on an explicit continuation cue / lead-in.
1737
- if (/(接下来|然后|首先|现在我|让我|下一步|我先|我来|我接着|接着|稍等|let me|now i|next|first|\b(?:and|then|so|to|the|a)\b)\s*$/iu.test(t))
1855
+ if (!/\b(?:https?|ftp):\/\/\S*$/iu.test(t)
1856
+ && /[::]\s*$/u.test(t)
1857
+ && /(?:如下|包括|例如|原因|步骤|结论|结果|注意|namely|including|such as|because|steps?|results?|notes?)\s*[::]\s*$/iu.test(t))
1738
1858
  return true;
1739
- // (2c) Ends without ANY sentence-ending punctuation / closer. A finished
1740
- // reply normally ends with terminal punctuation, a closing bracket/
1741
- // quote, a code fence, or a wrap-up emoji; if none of those are present
1742
- // the reply was most likely cut off mid-sentence. (Stephen: “结尾没有
1743
- // 任何标点符号也算疑似未结束”.)
1744
- const endOk = /[。!?.!?…)\]」』】)"'`”’~~—✨✅👍🙏🎉🙌]\s*$/u.test(t)
1745
- || /```\s*$/.test(t)
1746
- || /[}\]]\s*$/.test(t);
1747
- if (!endOk)
1859
+ // (2b) Explicit continuation cues only. Keep this narrow: generic sentence
1860
+ // starters like “我先/我来/first/the/a” are valid standalone captions and
1861
+ // were the largest source of false positives.
1862
+ if (/(接下来|然后|下一步|我接着|接着|稍等|let me|next)\s*$/iu.test(t))
1748
1863
  return true;
1749
1864
  return false;
1750
1865
  }
@@ -1979,24 +2094,58 @@ export class FeishuBot {
1979
2094
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
1980
2095
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
1981
2096
  }
1982
- async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
2097
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, liveStatusFinal) {
1983
2098
  if (!text.trim() && attachments.length === 0)
1984
2099
  return;
1985
2100
  const attachmentsJson = JSON.stringify(attachments);
1986
2101
  const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
1987
2102
  const contentHash = this.stableHash(normalizedPayload);
1988
2103
  const finalDeliveryKey = deliveryKey || sourceId;
2104
+ const enqueueDurableStatusCleanup = async (meta, suffix) => {
2105
+ const cleanupId = this.store.enqueueDelivery({
2106
+ sessionKey: this.deliverySessionKey(chatId),
2107
+ chatId,
2108
+ botName: this.config.name,
2109
+ sourceType: `${sourceType}_status_cleanup`,
2110
+ sourceId: `${sourceId}:status-cleanup:${suffix}:${meta.messageId}`,
2111
+ deliveryKey: `${finalDeliveryKey}:status-cleanup:${suffix}:${meta.messageId}`,
2112
+ contentHash: "",
2113
+ content: "",
2114
+ attachmentsJson: "[]",
2115
+ replyToMessageId: replyToMessageId || "",
2116
+ deliveryMode: "patch_live_status",
2117
+ targetMessageId: meta.messageId,
2118
+ deliveryMetaJson: JSON.stringify(meta),
2119
+ textDelivered: true,
2120
+ cleanupPending: true,
2121
+ });
2122
+ if (cleanupId)
2123
+ await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2124
+ };
2125
+ const finishDedupedStatus = async () => {
2126
+ if (!liveStatusFinal)
2127
+ return;
2128
+ await enqueueDurableStatusCleanup(liveStatusFinal, "content-dedupe");
2129
+ };
1989
2130
  if (sourceType === "verbose_transcript") {
1990
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
2131
+ if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"])) {
2132
+ await finishDedupedStatus();
1991
2133
  return;
1992
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
2134
+ }
2135
+ if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"])) {
2136
+ await finishDedupedStatus();
1993
2137
  return;
2138
+ }
1994
2139
  }
1995
2140
  else if (!deliveryKey) {
1996
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
2141
+ if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType])) {
2142
+ await finishDedupedStatus();
1997
2143
  return;
1998
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
2144
+ }
2145
+ if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType])) {
2146
+ await finishDedupedStatus();
1999
2147
  return;
2148
+ }
2000
2149
  }
2001
2150
  const deliveryId = this.store.enqueueDelivery({
2002
2151
  sessionKey: this.deliverySessionKey(chatId),
@@ -2009,62 +2158,279 @@ export class FeishuBot {
2009
2158
  content: text,
2010
2159
  attachmentsJson,
2011
2160
  replyToMessageId: replyToMessageId || "",
2161
+ deliveryMode: liveStatusFinal ? "patch_live_status" : "send",
2162
+ targetMessageId: liveStatusFinal?.messageId || "",
2163
+ deliveryMetaJson: liveStatusFinal ? JSON.stringify(liveStatusFinal) : "{}",
2012
2164
  });
2013
- if (deliveryId === null)
2165
+ if (deliveryId === null) {
2166
+ // Another path already owns this stable logical delivery (commonly the
2167
+ // proactive callback racing the chat-final path). Never show the full
2168
+ // answer again on a second card. The original outbox row remains the sole
2169
+ // owner of text delivery; a different newly-frozen card is only collapsed.
2170
+ const existing = this.store.getDeliveryByKey(this.config.name, chatId, finalDeliveryKey);
2171
+ // A failed owner must not permanently poison a logical trigger. Re-enqueue
2172
+ // the payload under a deterministic recovery key so a later authoritative
2173
+ // delivery gets a fresh retry budget.
2174
+ if (existing?.status === "failed" && !existing.textDelivered && text.trim()) {
2175
+ if (existing.content.trim() === text.trim()) {
2176
+ this.store.requeueFailedDelivery(existing.id);
2177
+ }
2178
+ else {
2179
+ const recoveryKey = `${finalDeliveryKey}:recovery:${contentHash}`;
2180
+ this.store.enqueueDelivery({
2181
+ sessionKey: this.deliverySessionKey(chatId), chatId, botName: this.config.name,
2182
+ sourceType: `${sourceType}_recovery`, sourceId: `${sourceId}:recovery`,
2183
+ deliveryKey: recoveryKey, contentHash, content: text, attachmentsJson: "[]",
2184
+ replyToMessageId: replyToMessageId || "",
2185
+ deliveryMode: liveStatusFinal ? "patch_live_status" : "send",
2186
+ targetMessageId: liveStatusFinal?.messageId || "",
2187
+ deliveryMetaJson: liveStatusFinal ? JSON.stringify(liveStatusFinal) : "{}",
2188
+ });
2189
+ }
2190
+ }
2191
+ // The chat-final result is authoritative over an earlier proactive event.
2192
+ // If proactive claimed the trigger key with different text, persist a
2193
+ // deterministic correction row that patches the same card after it.
2194
+ if (sourceId.startsWith("visible:") && existing?.sourceId.startsWith("proactive:")
2195
+ && text.trim() && existing.content.trim() !== text.trim()) {
2196
+ const correctionKey = `${finalDeliveryKey}:authoritative:${contentHash}`;
2197
+ this.store.enqueueDelivery({
2198
+ sessionKey: this.deliverySessionKey(chatId), chatId, botName: this.config.name,
2199
+ sourceType: "assistant_visible_authoritative", sourceId: `${sourceId}:authoritative`,
2200
+ deliveryKey: correctionKey, contentHash, content: text, attachmentsJson: "[]",
2201
+ replyToMessageId: replyToMessageId || "",
2202
+ deliveryMode: liveStatusFinal || existing.targetMessageId ? "patch_live_status" : "send",
2203
+ targetMessageId: liveStatusFinal?.messageId || existing.targetMessageId,
2204
+ deliveryMetaJson: liveStatusFinal?.messageId
2205
+ ? JSON.stringify(liveStatusFinal)
2206
+ : existing.deliveryMetaJson,
2207
+ });
2208
+ }
2209
+ // A competing final may add attachments that the first owner did not see.
2210
+ // Persist only the missing attachments in their own stable attachment row
2211
+ // instead of silently dropping them or duplicating the final text.
2212
+ const existingAttachments = (() => {
2213
+ try {
2214
+ return JSON.parse(existing?.attachmentsJson || "[]");
2215
+ }
2216
+ catch {
2217
+ return [];
2218
+ }
2219
+ })();
2220
+ const supplementalRows = this.store.listDeliveriesByKeyPrefix(this.config.name, chatId, `${finalDeliveryKey}:attachments:`);
2221
+ const deliveredSupplementalKeys = new Set();
2222
+ const incomingAttachmentKeys = new Set(attachments.map((a) => JSON.stringify(a)));
2223
+ for (const row of supplementalRows) {
2224
+ try {
2225
+ const rowAttachments = JSON.parse(row.attachmentsJson || "[]");
2226
+ const rowKeys = rowAttachments.map((a) => JSON.stringify(a));
2227
+ if (row.status === "failed") {
2228
+ // Revive a failed supplemental row when the same attachment is
2229
+ // explicitly presented again; its deterministic key would otherwise
2230
+ // make INSERT OR IGNORE suppress every future retry.
2231
+ if (rowKeys.some((key) => incomingAttachmentKeys.has(key))) {
2232
+ this.store.requeueFailedDelivery(row.id);
2233
+ for (const key of rowKeys)
2234
+ deliveredSupplementalKeys.add(key);
2235
+ }
2236
+ continue;
2237
+ }
2238
+ for (const key of rowKeys)
2239
+ deliveredSupplementalKeys.add(key);
2240
+ }
2241
+ catch { /* malformed legacy row: ignore and keep processing */ }
2242
+ }
2243
+ const existingKeys = new Set([
2244
+ ...existingAttachments.map((a) => JSON.stringify(a)),
2245
+ ...deliveredSupplementalKeys,
2246
+ ]);
2247
+ const missingAttachments = attachments.filter((a) => !existingKeys.has(JSON.stringify(a)));
2248
+ if (missingAttachments.length > 0) {
2249
+ const missingJson = JSON.stringify(missingAttachments);
2250
+ this.store.enqueueDelivery({
2251
+ sessionKey: this.deliverySessionKey(chatId),
2252
+ chatId,
2253
+ botName: this.config.name,
2254
+ sourceType: `${sourceType}_attachments`,
2255
+ sourceId: `${sourceId}:attachments:${this.stableHash(missingJson)}`,
2256
+ deliveryKey: `${finalDeliveryKey}:attachments:${this.stableHash(missingJson)}`,
2257
+ contentHash: this.stableHash(`|${missingJson}`),
2258
+ content: "",
2259
+ attachmentsJson: missingJson,
2260
+ replyToMessageId: replyToMessageId || "",
2261
+ deliveryMode: "send",
2262
+ targetMessageId: "",
2263
+ deliveryMetaJson: "{}",
2264
+ });
2265
+ }
2266
+ // Dispatch recovery/correction/supplement rows together in creation order.
2267
+ await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2268
+ // A correction/recovery row can intentionally patch the newly-frozen card.
2269
+ // Never enqueue a compact cleanup for that same card afterwards: it would
2270
+ // overwrite the authoritative answer that was just patched into place.
2271
+ const competingRows = this.store.listDeliveriesByKeyPrefix(this.config.name, chatId, `${finalDeliveryKey}:`);
2272
+ const finalCardOwnsVisiblePayload = Boolean(liveStatusFinal) && [existing, ...competingRows]
2273
+ .some((row) => row?.targetMessageId === liveStatusFinal.messageId && this.deliveryOwnsVisiblePayload(row));
2274
+ if (liveStatusFinal && existing?.targetMessageId !== liveStatusFinal.messageId && !finalCardOwnsVisiblePayload) {
2275
+ const cleanupJson = JSON.stringify(liveStatusFinal);
2276
+ const cleanupId = this.store.enqueueDelivery({
2277
+ sessionKey: this.deliverySessionKey(chatId),
2278
+ chatId,
2279
+ botName: this.config.name,
2280
+ sourceType: `${sourceType}_status_cleanup`,
2281
+ sourceId: `${sourceId}:status-cleanup:${liveStatusFinal.messageId}`,
2282
+ deliveryKey: `${finalDeliveryKey}:status-cleanup:${liveStatusFinal.messageId}`,
2283
+ contentHash: "",
2284
+ content: "",
2285
+ attachmentsJson: "[]",
2286
+ replyToMessageId: replyToMessageId || "",
2287
+ deliveryMode: "patch_live_status",
2288
+ targetMessageId: liveStatusFinal.messageId,
2289
+ deliveryMetaJson: cleanupJson,
2290
+ textDelivered: true,
2291
+ cleanupPending: true,
2292
+ });
2293
+ if (cleanupId)
2294
+ await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2295
+ }
2014
2296
  return;
2297
+ }
2015
2298
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2016
2299
  if (sourceType === "assistant_visible" && (text.trim() || attachments.length > 0) && text.trim().toUpperCase() !== "NO_REPLY" && !this.isRuntimeFailureText(text.trim())) {
2017
2300
  this.lastRealDeliveryAt.set(chatId, Date.now());
2018
2301
  }
2019
2302
  }
2303
+ scheduleDeliveryRetry(chatId, attempt) {
2304
+ if (this.deliveryRetryTimers.has(chatId))
2305
+ return;
2306
+ const delay = Math.min(30_000, DELIVERY_RETRY_BASE_MS * Math.max(1, 2 ** Math.max(0, attempt - 1)));
2307
+ const timer = setTimeout(() => {
2308
+ this.deliveryRetryTimers.delete(chatId);
2309
+ void this.dispatchPendingDeliveries(chatId).catch((err) => {
2310
+ console.warn(`[${this.config.name}] scheduled delivery retry failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
2311
+ });
2312
+ }, delay);
2313
+ timer.unref?.();
2314
+ this.deliveryRetryTimers.set(chatId, timer);
2315
+ }
2020
2316
  async dispatchPendingDeliveries(chatId, replyToMessageId) {
2021
- const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
2022
- for (const item of pending) {
2023
- await this.sendOrdered(chatId, async () => {
2024
- try {
2317
+ let retryScheduled = false;
2318
+ while (true) {
2319
+ const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
2320
+ if (pending.length === 0)
2321
+ break;
2322
+ for (const item of pending) {
2323
+ await this.sendOrdered(chatId, async () => {
2025
2324
  if (!item.id || !this.store.claimDelivery(item.id))
2026
2325
  return;
2027
- const attachments = JSON.parse(item.attachmentsJson || "[]");
2028
- if (item.content.trim()) {
2029
- const replyTarget = item.replyToMessageId || replyToMessageId;
2030
- const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "verbose_transcript" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
2031
- if (shouldReplyToSource) {
2032
- try {
2033
- await this.replyMessage(replyTarget, item.content);
2326
+ let failureStage = "text";
2327
+ try {
2328
+ const attachments = JSON.parse(item.attachmentsJson || "[]");
2329
+ // Text first. A patch error is ambiguous: Feishu may have applied the
2330
+ // patch but timed out returning the response. Do NOT immediately create
2331
+ // a second visible answer. Retry the same idempotent patch; only after
2332
+ // the retry budget is exhausted do we fall back to a new message.
2333
+ if (item.content.trim() && !item.textDelivered) {
2334
+ const replyTarget = item.replyToMessageId || replyToMessageId;
2335
+ const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "verbose_transcript" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
2336
+ if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
2337
+ try {
2338
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2339
+ await this.patchFinalAnswerCard(item.targetMessageId, item.content, meta, chatId);
2340
+ this.store.markDeliveryTextDelivered(item.id);
2341
+ }
2342
+ catch (patchErr) {
2343
+ if (item.attempts + 1 < DELIVERY_MAX_ATTEMPTS)
2344
+ throw patchErr;
2345
+ console.warn(`[${this.config.name}] final-card patch exhausted; falling back to new message:`, this.errorSummary(patchErr));
2346
+ if (shouldReplyToSource) {
2347
+ try {
2348
+ await this.replyMessage(replyTarget, item.content);
2349
+ }
2350
+ catch {
2351
+ await this.sendMessage(chatId, item.content);
2352
+ }
2353
+ }
2354
+ else {
2355
+ await this.sendMessage(chatId, item.content);
2356
+ }
2357
+ this.store.markDeliveryTextDeliveredWithCleanup(item.id);
2358
+ }
2034
2359
  }
2035
- catch (err) {
2036
- console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
2037
- await this.sendMessage(chatId, item.content);
2360
+ else {
2361
+ if (shouldReplyToSource) {
2362
+ try {
2363
+ await this.replyMessage(replyTarget, item.content);
2364
+ }
2365
+ catch {
2366
+ await this.sendMessage(chatId, item.content);
2367
+ }
2368
+ }
2369
+ else {
2370
+ await this.sendMessage(chatId, item.content);
2371
+ }
2372
+ this.store.markDeliveryTextDelivered(item.id);
2038
2373
  }
2039
2374
  }
2040
- else {
2041
- await this.sendMessage(chatId, item.content);
2375
+ // Attachments are independent from status-card cleanup. An unpatchable
2376
+ // or deleted status card must never block otherwise-sendable files.
2377
+ failureStage = "attachment";
2378
+ const cursor = Math.max(0, item.attachmentCursor || 0);
2379
+ for (let i = cursor; i < attachments.length; i++) {
2380
+ await this.sendBridgeAttachment(chatId, attachments[i]);
2381
+ this.store.markDeliveryAttachmentCursor(item.id, i + 1);
2382
+ }
2383
+ // Cleanup last, after all payload is visible. Cleanup-only rows and
2384
+ // attachment-only finals enter here as a separately retryable stage.
2385
+ const refreshedBeforeCleanup = this.store.getDeliveryByKey(item.botName, item.chatId, item.deliveryKey);
2386
+ const needsCleanup = Boolean(refreshedBeforeCleanup?.cleanupPending)
2387
+ || (!item.content.trim() && item.deliveryMode === "patch_live_status" && Boolean(item.targetMessageId));
2388
+ if (needsCleanup && item.targetMessageId) {
2389
+ failureStage = "cleanup";
2390
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2391
+ await this.patchLiveStatusDoneSummary(item.targetMessageId, meta, chatId);
2392
+ this.store.markDeliveryCleanupPending(item.id, false);
2042
2393
  }
2043
- }
2044
- for (const attachment of attachments)
2045
- await this.sendBridgeAttachment(chatId, attachment);
2046
- if (item.id)
2047
2394
  this.store.markDeliveryDelivered(item.id);
2048
- }
2049
- catch (err) {
2050
- if (this.isOutOfChatError(err))
2051
- this.markCurrentBotUnavailable(chatId, err);
2052
- if (item.id)
2053
- this.store.markDeliveryFailed(item.id);
2054
- const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
2055
- const replyTarget = item.replyToMessageId || replyToMessageId;
2056
- try {
2057
- if (replyTarget)
2058
- await this.replyMessage(replyTarget, errorText);
2059
- else
2060
- await this.sendMessage(chatId, errorText);
2061
2395
  }
2062
- catch (notifyErr) {
2063
- console.warn(`[${this.config.name}] Failed to notify attachment delivery error:`, this.errorSummary(notifyErr));
2396
+ catch (err) {
2397
+ if (this.isOutOfChatError(err))
2398
+ this.markCurrentBotUnavailable(chatId, err);
2399
+ const willRetry = this.store.retryDelivery(item.id, DELIVERY_MAX_ATTEMPTS);
2400
+ if (willRetry) {
2401
+ retryScheduled = true;
2402
+ console.warn(`[${this.config.name}] delivery ${item.id} ${failureStage} failed (attempt ${item.attempts + 1}/${DELIVERY_MAX_ATTEMPTS}); retry scheduled:`, this.errorSummary(err));
2403
+ this.scheduleDeliveryRetry(chatId, item.attempts + 1);
2404
+ return;
2405
+ }
2406
+ // Cleanup is cosmetic once text/attachments are visible. Exhausting
2407
+ // cleanup retries must not turn a successful answer into a scary
2408
+ // user-facing failure.
2409
+ if (failureStage === "cleanup") {
2410
+ this.store.markDeliveryDelivered(item.id);
2411
+ console.warn(`[${this.config.name}] status-card cleanup abandoned after ${DELIVERY_MAX_ATTEMPTS} attempts:`, this.errorSummary(err));
2412
+ return;
2413
+ }
2414
+ this.store.markDeliveryFailed(item.id);
2415
+ const label = failureStage === "attachment" ? "附件发送失败" : "最终回复发送失败";
2416
+ const errorText = `⚠️ ${label}(已重试 ${DELIVERY_MAX_ATTEMPTS} 次):${this.errorSummary(err)}`;
2417
+ const replyTarget = item.replyToMessageId || replyToMessageId;
2418
+ try {
2419
+ if (replyTarget)
2420
+ await this.replyMessage(replyTarget, errorText);
2421
+ else
2422
+ await this.sendMessage(chatId, errorText);
2423
+ }
2424
+ catch (notifyErr) {
2425
+ console.warn(`[${this.config.name}] Failed to notify terminal delivery error:`, this.errorSummary(notifyErr));
2426
+ }
2064
2427
  }
2065
- throw err;
2066
- }
2067
- });
2428
+ });
2429
+ }
2430
+ // Drain subsequent pages immediately after successful batches. If any row
2431
+ // was returned to pending, honor its backoff timer instead of hot-looping.
2432
+ if (pending.length < 50 || retryScheduled)
2433
+ break;
2068
2434
  }
2069
2435
  }
2070
2436
  hasFreeModeBot(chatId) {
@@ -2223,6 +2589,7 @@ export class FeishuBot {
2223
2589
  const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
2224
2590
  displayReply = `${displayReply}\n\n${roundMarker}`;
2225
2591
  }
2592
+ let finalStatusMeta;
2226
2593
  if (isVisible || parsedReply.attachments.length > 0) {
2227
2594
  const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
2228
2595
  .filter(Boolean)
@@ -2236,10 +2603,14 @@ export class FeishuBot {
2236
2603
  timestamp: Date.now(),
2237
2604
  });
2238
2605
  try {
2239
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
2606
+ if (isVisible || parsedReply.attachments.length > 0) {
2607
+ finalStatusMeta = await liveStatus?.prepareTerminalDelivery(false);
2608
+ }
2609
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, finalStatusMeta);
2240
2610
  }
2241
2611
  catch (err) {
2242
- await liveStatus?.fail().catch(() => { });
2612
+ if (!finalStatusMeta)
2613
+ await liveStatus?.fail().catch(() => { });
2243
2614
  throw err;
2244
2615
  }
2245
2616
  }
@@ -2248,7 +2619,7 @@ export class FeishuBot {
2248
2619
  if (!isVisible && parsedReply.attachments.length === 0) {
2249
2620
  await liveStatus?.noReply().catch(() => { });
2250
2621
  }
2251
- else {
2622
+ else if (!finalStatusMeta) {
2252
2623
  await liveStatus?.complete().catch(() => { });
2253
2624
  }
2254
2625
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
@@ -2260,9 +2631,12 @@ export class FeishuBot {
2260
2631
  // the finished card does not take up much screen space.
2261
2632
  if (view.state === "done") {
2262
2633
  const statusEmoji = view.noReply ? "💤" : "✅";
2263
- const summary = en
2264
- ? `${statusEmoji} ${view.toolCalls} tool call${view.toolCalls === 1 ? "" : "s"} · ⏱ ${view.elapsed}`
2265
- : `${statusEmoji} 累计${view.toolCalls} 次工具调用 · ⏱ 耗时${view.elapsed}`;
2634
+ const summary = this.completionSummary({
2635
+ toolCalls: view.toolCalls,
2636
+ elapsed: view.elapsed,
2637
+ model: view.model,
2638
+ locale: en ? "en" : "zh",
2639
+ }, statusEmoji);
2266
2640
  return {
2267
2641
  schema: "2.0",
2268
2642
  config: { update_multi: true, width_mode: "fill" },
@@ -2751,13 +3125,12 @@ export class FeishuBot {
2751
3125
  catch (err) {
2752
3126
  console.warn(`[${this.config.name}] Feishu doc conversion failed, falling back to file attachment:`, this.errorSummary(err));
2753
3127
  const caption = attachment.caption?.trim();
2754
- await this.sendMessage(chatId, `${caption ? `${caption}\n` : ""}飞书文档创建失败,已改为 Markdown 文件附件发送。`);
2755
3128
  await this.sendBridgeFileAttachment(chatId, filePath);
3129
+ await this.sendMessage(chatId, `${caption ? `${caption}\n` : ""}飞书文档创建失败,已改为 Markdown 文件附件发送。`);
2756
3130
  return;
2757
3131
  }
2758
3132
  }
2759
- if (attachment.caption?.trim())
2760
- await this.sendMessage(chatId, attachment.caption.trim());
3133
+ const caption = attachment.caption?.trim();
2761
3134
  if (type === "image") {
2762
3135
  if (statSync(filePath).size > 10 * 1024 * 1024)
2763
3136
  throw new Error(`Image too large (>10MB): ${filePath}`);
@@ -2775,9 +3148,13 @@ export class FeishuBot {
2775
3148
  msg_type: "image",
2776
3149
  },
2777
3150
  });
3151
+ if (caption)
3152
+ await this.sendMessage(chatId, caption);
2778
3153
  return;
2779
3154
  }
2780
3155
  await this.sendBridgeFileAttachment(chatId, filePath);
3156
+ if (caption)
3157
+ await this.sendMessage(chatId, caption);
2781
3158
  }
2782
3159
  async sendBridgeFileAttachment(chatId, filePath) {
2783
3160
  const uploaded = await this.client.im.file.create({