openclaw-lark-multi-agent 1.4.2 → 1.4.4

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,13 @@ 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
+ /** Ephemeral model footer overrides used by final-message wrappers. Keeping
94
+ * replyMessage/sendMessage two-argument APIs avoids contaminating ordinary
95
+ * command/error/card sends with model attribution. */
96
+ replyModelFooters = new Map();
97
+ sendModelFooters = new Map();
98
+ /** Per-chat durable outbox retry timer. */
99
+ deliveryRetryTimers = new Map();
91
100
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
92
101
  lastRealDeliveryAt = new Map();
93
102
  /** Per-chat: whether we've already sent the high-context alert this cycle. */
@@ -230,7 +239,7 @@ export class FeishuBot {
230
239
  this.cancelDelayedFailure(chatId);
231
240
  const activeTarget = this.activeDeliveryTargets.get(chatId);
232
241
  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);
242
+ 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, sourceType === "verbose_transcript" ? undefined : this.config.model);
234
243
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
235
244
  await activeTarget?.liveStatus?.complete().catch(() => { });
236
245
  }
@@ -318,9 +327,9 @@ export class FeishuBot {
318
327
  */
319
328
  async drainOnStartup() {
320
329
  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}`);
330
+ const restoredDeliveries = this.store.resetDeliveringOnStartup(this.config.name);
331
+ if (restoredDeliveries > 0) {
332
+ console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries}`);
324
333
  }
325
334
  const chats = this.store.getAllChatInfo();
326
335
  const drainTasks = [];
@@ -1342,7 +1351,18 @@ export class FeishuBot {
1342
1351
  // submitted far enough that auto-replaying risks duplicate delivery.
1343
1352
  // The user can resend a new message if they want to retry.
1344
1353
  markSubmittedBatch("empty-returned");
1345
- await liveStatus?.noReply().catch(() => { });
1354
+ // The proactive session.message path can already have delivered the
1355
+ // real answer on this trigger's shared live-status card while the
1356
+ // chat-final collector returns an empty string. In that race, a late
1357
+ // noReply() patch would overwrite the answer with the idle summary.
1358
+ const existingAnswer = this.store.getDeliveryByKey(this.config.name, chatId, `trigger:${triggerId}`);
1359
+ const answerAlreadyOwned = this.deliveryOwnsVisiblePayload(existingAnswer);
1360
+ if (answerAlreadyOwned) {
1361
+ console.log(`[${this.config.name}] Empty-final cleanup skipped; trigger ${triggerId} already has an answer delivery`);
1362
+ }
1363
+ else {
1364
+ await liveStatus?.noReply().catch(() => { });
1365
+ }
1346
1366
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
1347
1367
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1348
1368
  const remainingAcks = [];
@@ -1417,11 +1437,10 @@ export class FeishuBot {
1417
1437
  }
1418
1438
  else {
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}`);
1440
+ // Keep live status and final answer as separate messages. The final
1441
+ // answer is created at completion time so it remains at the bottom
1442
+ // even when the user inserted more messages during the run.
1443
+ 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}`, this.config.model);
1425
1444
  await liveStatus?.complete();
1426
1445
  // Mark every merged trigger as delivered so retries/restarts do
1427
1446
  // not re-process any of them.
@@ -1444,10 +1463,44 @@ export class FeishuBot {
1444
1463
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1445
1464
  }
1446
1465
  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(() => { });
1466
+ // A proactive session.message may already own this trigger and carry
1467
+ // the real answer while collectReply returns empty/NO_REPLY. Never
1468
+ // overwrite that same card with a 💤 summary.
1469
+ const existingAnswer = this.store.getDeliveryByKey(this.config.name, chatId, `trigger:${triggerId}`);
1470
+ const answerAlreadyOwned = this.deliveryOwnsVisiblePayload(existingAnswer);
1471
+ if (answerAlreadyOwned) {
1472
+ console.log(`[${this.config.name}] NO_REPLY cleanup skipped; trigger ${triggerId} already has an answer delivery`);
1473
+ }
1474
+ else {
1475
+ // Explicit NO_REPLY/empty final also uses the durable cleanup outbox
1476
+ // when a card exists, so a previously-disabled card gets one terminal
1477
+ // patch attempt instead of remaining stuck on "正在执行".
1478
+ const noReplyMeta = await liveStatus?.prepareTerminalDelivery(true);
1479
+ if (noReplyMeta) {
1480
+ const cleanupId = this.store.enqueueDelivery({
1481
+ sessionKey: this.deliverySessionKey(chatId),
1482
+ chatId,
1483
+ botName: this.config.name,
1484
+ sourceType: "no_reply_status_cleanup",
1485
+ sourceId: `no-reply:${triggerId}:${noReplyMeta.messageId}`,
1486
+ deliveryKey: `trigger:${triggerId}:no-reply-status`,
1487
+ contentHash: "",
1488
+ content: "",
1489
+ attachmentsJson: "[]",
1490
+ replyToMessageId: lastHuman.messageId || "",
1491
+ deliveryMode: "patch_live_status",
1492
+ targetMessageId: noReplyMeta.messageId,
1493
+ deliveryMetaJson: JSON.stringify(noReplyMeta),
1494
+ textDelivered: true,
1495
+ cleanupPending: true,
1496
+ });
1497
+ if (cleanupId)
1498
+ await this.dispatchPendingDeliveries(chatId, lastHuman.messageId);
1499
+ }
1500
+ else {
1501
+ await liveStatus?.noReply().catch(() => { });
1502
+ }
1503
+ }
1451
1504
  }
1452
1505
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1453
1506
  // Replace ack reactions with DONE only for trigger messages actually
@@ -1649,14 +1702,53 @@ export class FeishuBot {
1649
1702
  }
1650
1703
  return s;
1651
1704
  }
1652
- buildMarkdownCard(text) {
1705
+ buildMarkdownCard(text, model) {
1706
+ const elements = buildFeishuCardElements(text);
1707
+ if (model?.trim()) {
1708
+ elements.push({ tag: "hr" });
1709
+ elements.push({
1710
+ tag: "markdown",
1711
+ content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}</font>`,
1712
+ });
1713
+ }
1653
1714
  return {
1654
1715
  schema: "2.0",
1655
1716
  config: { wide_screen_mode: true },
1656
- body: {
1657
- elements: buildFeishuCardElements(text),
1658
- },
1717
+ body: { elements },
1718
+ };
1719
+ }
1720
+ deliveryOwnsVisiblePayload(item) {
1721
+ if (!item || item.status === "failed")
1722
+ return false;
1723
+ if (item.content.trim())
1724
+ return true;
1725
+ try {
1726
+ const attachments = JSON.parse(item.attachmentsJson || "[]");
1727
+ return Array.isArray(attachments) && attachments.length > 0;
1728
+ }
1729
+ catch {
1730
+ return false;
1731
+ }
1732
+ }
1733
+ completionSummary(meta, emoji = "✅") {
1734
+ const en = meta.locale === "en";
1735
+ return en
1736
+ ? `${emoji} ${meta.toolCalls} tool call${meta.toolCalls === 1 ? "" : "s"} · ⏱ ${meta.elapsed}`
1737
+ : `${emoji} 累计${meta.toolCalls} 次工具调用 · ⏱ 耗时${meta.elapsed}`;
1738
+ }
1739
+ /** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
1740
+ * outbox entries that once targeted the live-status card. */
1741
+ async patchLiveStatusDoneSummary(messageId, meta, chatId) {
1742
+ const view = {
1743
+ title: meta.locale === "en" ? `✅ ${this.config.name} done` : `✅ ${this.config.name} 已完成`,
1744
+ lines: [],
1745
+ elapsed: meta.elapsed,
1746
+ model: meta.model,
1747
+ toolCalls: meta.toolCalls,
1748
+ noReply: Boolean(meta.noReply),
1749
+ state: "done",
1659
1750
  };
1751
+ await this.patchLiveStatusCard(messageId, view, chatId);
1660
1752
  }
1661
1753
  formatUserVisibleError(err) {
1662
1754
  const raw = err instanceof Error ? err.message : String(err);
@@ -1711,16 +1803,14 @@ export class FeishuBot {
1711
1803
  * Heuristic: does this reply look truncated / cut off mid-task? Used as the
1712
1804
  * SOLE auto-retry trigger — if true, we ask the session a one-line “done?” probe.
1713
1805
  *
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.
1806
+ * Signals are intentionally HIGH PRECISION. A reply is suspicious only when:
1807
+ * 1. Its Markdown/code structure is visibly unclosed; or
1808
+ * 2. It ends on punctuation/words that grammatically demand continuation.
1809
+ *
1810
+ * Merely lacking a final punctuation mark is NOT enough. Short answers,
1811
+ * headings, model lists, paths, hashes, commands, English labels, and normal
1812
+ * chat prose routinely end without punctuation; treating all of them as
1813
+ * truncated caused obvious false-positive confirmation probes.
1724
1814
  */
1725
1815
  looksTruncated(text) {
1726
1816
  const t = (text || "").trim();
@@ -1730,21 +1820,19 @@ export class FeishuBot {
1730
1820
  const fences = (t.match(/```/g) || []).length;
1731
1821
  if (fences % 2 === 1)
1732
1822
  return true;
1733
- // (2a) Trailing clause-joining punctuation: a sentence was left hanging.
1734
- if (/[,,::、;;]\s*$/u.test(t))
1823
+ // (2a) A terminal comma/semicolon/、 strongly suggests an unfinished clause.
1824
+ // A colon is only suspicious when it is a real prose lead-in near the end;
1825
+ // URLs, times, drive letters, key:value and headings commonly end in colons.
1826
+ if (/[,,、;;]\s*$/u.test(t))
1735
1827
  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))
1828
+ if (!/\b(?:https?|ftp):\/\/\S*$/iu.test(t)
1829
+ && /[::]\s*$/u.test(t)
1830
+ && /(?:如下|包括|例如|原因|步骤|结论|结果|注意|namely|including|such as|because|steps?|results?|notes?)\s*[::]\s*$/iu.test(t))
1738
1831
  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)
1832
+ // (2b) Explicit continuation cues only. Keep this narrow: generic sentence
1833
+ // starters like “我先/我来/first/the/a” are valid standalone captions and
1834
+ // were the largest source of false positives.
1835
+ if (/(接下来|然后|下一步|我接着|接着|稍等|let me|next)\s*$/iu.test(t))
1748
1836
  return true;
1749
1837
  return false;
1750
1838
  }
@@ -1979,13 +2067,16 @@ export class FeishuBot {
1979
2067
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
1980
2068
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
1981
2069
  }
1982
- async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
2070
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel) {
1983
2071
  if (!text.trim() && attachments.length === 0)
1984
2072
  return;
1985
2073
  const attachmentsJson = JSON.stringify(attachments);
1986
2074
  const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
1987
2075
  const contentHash = this.stableHash(normalizedPayload);
1988
2076
  const finalDeliveryKey = deliveryKey || sourceId;
2077
+ const deliveryMetaJson = finalReplyModel?.trim()
2078
+ ? JSON.stringify({ model: finalReplyModel.trim() })
2079
+ : "{}";
1989
2080
  if (sourceType === "verbose_transcript") {
1990
2081
  if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
1991
2082
  return;
@@ -2009,62 +2100,239 @@ export class FeishuBot {
2009
2100
  content: text,
2010
2101
  attachmentsJson,
2011
2102
  replyToMessageId: replyToMessageId || "",
2103
+ deliveryMode: "send",
2104
+ targetMessageId: "",
2105
+ deliveryMetaJson,
2012
2106
  });
2013
- if (deliveryId === null)
2107
+ if (deliveryId === null) {
2108
+ // Another path already owns this stable logical delivery (commonly the
2109
+ // proactive callback racing the chat-final path). Never show the full
2110
+ // answer again on a second card. The original outbox row remains the sole
2111
+ // owner of text delivery; a different newly-frozen card is only collapsed.
2112
+ const existing = this.store.getDeliveryByKey(this.config.name, chatId, finalDeliveryKey);
2113
+ // A failed owner must not permanently poison a logical trigger. Re-enqueue
2114
+ // the payload under a deterministic recovery key so a later authoritative
2115
+ // delivery gets a fresh retry budget.
2116
+ if (existing?.status === "failed" && !existing.textDelivered && text.trim()) {
2117
+ if (existing.content.trim() === text.trim()) {
2118
+ this.store.requeueFailedDelivery(existing.id);
2119
+ }
2120
+ else {
2121
+ const recoveryKey = `${finalDeliveryKey}:recovery:${contentHash}`;
2122
+ this.store.enqueueDelivery({
2123
+ sessionKey: this.deliverySessionKey(chatId), chatId, botName: this.config.name,
2124
+ sourceType: `${sourceType}_recovery`, sourceId: `${sourceId}:recovery`,
2125
+ deliveryKey: recoveryKey, contentHash, content: text, attachmentsJson: "[]",
2126
+ replyToMessageId: replyToMessageId || "",
2127
+ deliveryMode: "send",
2128
+ targetMessageId: "",
2129
+ deliveryMetaJson,
2130
+ });
2131
+ }
2132
+ }
2133
+ // The chat-final result is authoritative over an earlier proactive event.
2134
+ // If proactive claimed the trigger key with different text, persist a
2135
+ // deterministic correction row that patches the same card after it.
2136
+ if (sourceId.startsWith("visible:") && existing?.sourceId.startsWith("proactive:")
2137
+ && text.trim() && existing.content.trim() !== text.trim()) {
2138
+ const correctionKey = `${finalDeliveryKey}:authoritative:${contentHash}`;
2139
+ this.store.enqueueDelivery({
2140
+ sessionKey: this.deliverySessionKey(chatId), chatId, botName: this.config.name,
2141
+ sourceType: "assistant_visible_authoritative", sourceId: `${sourceId}:authoritative`,
2142
+ deliveryKey: correctionKey, contentHash, content: text, attachmentsJson: "[]",
2143
+ replyToMessageId: replyToMessageId || "",
2144
+ deliveryMode: "send",
2145
+ targetMessageId: "",
2146
+ deliveryMetaJson,
2147
+ });
2148
+ }
2149
+ // A competing final may add attachments that the first owner did not see.
2150
+ // Persist only the missing attachments in their own stable attachment row
2151
+ // instead of silently dropping them or duplicating the final text.
2152
+ const existingAttachments = (() => {
2153
+ try {
2154
+ return JSON.parse(existing?.attachmentsJson || "[]");
2155
+ }
2156
+ catch {
2157
+ return [];
2158
+ }
2159
+ })();
2160
+ const supplementalRows = this.store.listDeliveriesByKeyPrefix(this.config.name, chatId, `${finalDeliveryKey}:attachments:`);
2161
+ const deliveredSupplementalKeys = new Set();
2162
+ const incomingAttachmentKeys = new Set(attachments.map((a) => JSON.stringify(a)));
2163
+ for (const row of supplementalRows) {
2164
+ try {
2165
+ const rowAttachments = JSON.parse(row.attachmentsJson || "[]");
2166
+ const rowKeys = rowAttachments.map((a) => JSON.stringify(a));
2167
+ if (row.status === "failed") {
2168
+ // Revive a failed supplemental row when the same attachment is
2169
+ // explicitly presented again; its deterministic key would otherwise
2170
+ // make INSERT OR IGNORE suppress every future retry.
2171
+ if (rowKeys.some((key) => incomingAttachmentKeys.has(key))) {
2172
+ this.store.requeueFailedDelivery(row.id);
2173
+ for (const key of rowKeys)
2174
+ deliveredSupplementalKeys.add(key);
2175
+ }
2176
+ continue;
2177
+ }
2178
+ for (const key of rowKeys)
2179
+ deliveredSupplementalKeys.add(key);
2180
+ }
2181
+ catch { /* malformed legacy row: ignore and keep processing */ }
2182
+ }
2183
+ const existingKeys = new Set([
2184
+ ...existingAttachments.map((a) => JSON.stringify(a)),
2185
+ ...deliveredSupplementalKeys,
2186
+ ]);
2187
+ const missingAttachments = attachments.filter((a) => !existingKeys.has(JSON.stringify(a)));
2188
+ if (missingAttachments.length > 0) {
2189
+ const missingJson = JSON.stringify(missingAttachments);
2190
+ this.store.enqueueDelivery({
2191
+ sessionKey: this.deliverySessionKey(chatId),
2192
+ chatId,
2193
+ botName: this.config.name,
2194
+ sourceType: `${sourceType}_attachments`,
2195
+ sourceId: `${sourceId}:attachments:${this.stableHash(missingJson)}`,
2196
+ deliveryKey: `${finalDeliveryKey}:attachments:${this.stableHash(missingJson)}`,
2197
+ contentHash: this.stableHash(`|${missingJson}`),
2198
+ content: "",
2199
+ attachmentsJson: missingJson,
2200
+ replyToMessageId: replyToMessageId || "",
2201
+ deliveryMode: "send",
2202
+ targetMessageId: "",
2203
+ deliveryMetaJson: "{}",
2204
+ });
2205
+ }
2206
+ // Dispatch recovery/correction/supplement rows together in creation order.
2207
+ await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2014
2208
  return;
2209
+ }
2015
2210
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2016
2211
  if (sourceType === "assistant_visible" && (text.trim() || attachments.length > 0) && text.trim().toUpperCase() !== "NO_REPLY" && !this.isRuntimeFailureText(text.trim())) {
2017
2212
  this.lastRealDeliveryAt.set(chatId, Date.now());
2018
2213
  }
2019
2214
  }
2215
+ scheduleDeliveryRetry(chatId, attempt) {
2216
+ if (this.deliveryRetryTimers.has(chatId))
2217
+ return;
2218
+ const delay = Math.min(30_000, DELIVERY_RETRY_BASE_MS * Math.max(1, 2 ** Math.max(0, attempt - 1)));
2219
+ const timer = setTimeout(() => {
2220
+ this.deliveryRetryTimers.delete(chatId);
2221
+ void this.dispatchPendingDeliveries(chatId).catch((err) => {
2222
+ console.warn(`[${this.config.name}] scheduled delivery retry failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
2223
+ });
2224
+ }, delay);
2225
+ timer.unref?.();
2226
+ this.deliveryRetryTimers.set(chatId, timer);
2227
+ }
2020
2228
  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 {
2229
+ let retryScheduled = false;
2230
+ while (true) {
2231
+ const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
2232
+ if (pending.length === 0)
2233
+ break;
2234
+ for (const item of pending) {
2235
+ await this.sendOrdered(chatId, async () => {
2025
2236
  if (!item.id || !this.store.claimDelivery(item.id))
2026
2237
  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) {
2238
+ let failureStage = "text";
2239
+ try {
2240
+ const attachments = JSON.parse(item.attachmentsJson || "[]");
2241
+ // Final text is always sent as a NEW message. Never patch it into the
2242
+ // older live-status card: user messages inserted during the run must
2243
+ // stay above the eventual final answer, not below it.
2244
+ if (item.content.trim() && !item.textDelivered) {
2245
+ const replyTarget = item.replyToMessageId || replyToMessageId;
2246
+ const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
2247
+ || item.sourceType === "verbose_transcript"
2248
+ || item.sourceType === "provider_error"
2249
+ || item.sourceType === "delayed_error");
2250
+ let finalModel;
2032
2251
  try {
2033
- await this.replyMessage(replyTarget, item.content);
2252
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2253
+ finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
2254
+ }
2255
+ catch { /* legacy/malformed metadata: send without model footer */ }
2256
+ if (shouldReplyToSource) {
2257
+ try {
2258
+ await this.replyFinalMessage(replyTarget, item.content, finalModel);
2259
+ }
2260
+ catch {
2261
+ await this.sendFinalMessage(chatId, item.content, finalModel);
2262
+ }
2263
+ }
2264
+ else {
2265
+ await this.sendFinalMessage(chatId, item.content, finalModel);
2034
2266
  }
2035
- catch (err) {
2036
- console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
2037
- await this.sendMessage(chatId, item.content);
2267
+ // Rows created by v1.4.3 may still target a live-status card. Preserve
2268
+ // restart compatibility: send their text as a new message, then
2269
+ // collapse the old status card in the cleanup stage below.
2270
+ if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
2271
+ this.store.markDeliveryTextDeliveredWithCleanup(item.id);
2272
+ }
2273
+ else {
2274
+ this.store.markDeliveryTextDelivered(item.id);
2038
2275
  }
2039
2276
  }
2040
- else {
2041
- await this.sendMessage(chatId, item.content);
2277
+ // Attachments are independent from status-card cleanup. An unpatchable
2278
+ // or deleted status card must never block otherwise-sendable files.
2279
+ failureStage = "attachment";
2280
+ const cursor = Math.max(0, item.attachmentCursor || 0);
2281
+ for (let i = cursor; i < attachments.length; i++) {
2282
+ await this.sendBridgeAttachment(chatId, attachments[i]);
2283
+ this.store.markDeliveryAttachmentCursor(item.id, i + 1);
2284
+ }
2285
+ // Cleanup last, after all payload is visible. Cleanup-only rows and
2286
+ // attachment-only finals enter here as a separately retryable stage.
2287
+ const refreshedBeforeCleanup = this.store.getDeliveryByKey(item.botName, item.chatId, item.deliveryKey);
2288
+ const needsCleanup = Boolean(refreshedBeforeCleanup?.cleanupPending)
2289
+ || (!item.content.trim() && item.deliveryMode === "patch_live_status" && Boolean(item.targetMessageId));
2290
+ if (needsCleanup && item.targetMessageId) {
2291
+ failureStage = "cleanup";
2292
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2293
+ await this.patchLiveStatusDoneSummary(item.targetMessageId, meta, chatId);
2294
+ this.store.markDeliveryCleanupPending(item.id, false);
2042
2295
  }
2043
- }
2044
- for (const attachment of attachments)
2045
- await this.sendBridgeAttachment(chatId, attachment);
2046
- if (item.id)
2047
2296
  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
2297
  }
2062
- catch (notifyErr) {
2063
- console.warn(`[${this.config.name}] Failed to notify attachment delivery error:`, this.errorSummary(notifyErr));
2298
+ catch (err) {
2299
+ if (this.isOutOfChatError(err))
2300
+ this.markCurrentBotUnavailable(chatId, err);
2301
+ const willRetry = this.store.retryDelivery(item.id, DELIVERY_MAX_ATTEMPTS);
2302
+ if (willRetry) {
2303
+ retryScheduled = true;
2304
+ console.warn(`[${this.config.name}] delivery ${item.id} ${failureStage} failed (attempt ${item.attempts + 1}/${DELIVERY_MAX_ATTEMPTS}); retry scheduled:`, this.errorSummary(err));
2305
+ this.scheduleDeliveryRetry(chatId, item.attempts + 1);
2306
+ return;
2307
+ }
2308
+ // Cleanup is cosmetic once text/attachments are visible. Exhausting
2309
+ // cleanup retries must not turn a successful answer into a scary
2310
+ // user-facing failure.
2311
+ if (failureStage === "cleanup") {
2312
+ this.store.markDeliveryDelivered(item.id);
2313
+ console.warn(`[${this.config.name}] status-card cleanup abandoned after ${DELIVERY_MAX_ATTEMPTS} attempts:`, this.errorSummary(err));
2314
+ return;
2315
+ }
2316
+ this.store.markDeliveryFailed(item.id);
2317
+ const label = failureStage === "attachment" ? "附件发送失败" : "最终回复发送失败";
2318
+ const errorText = `⚠️ ${label}(已重试 ${DELIVERY_MAX_ATTEMPTS} 次):${this.errorSummary(err)}`;
2319
+ const replyTarget = item.replyToMessageId || replyToMessageId;
2320
+ try {
2321
+ if (replyTarget)
2322
+ await this.replyMessage(replyTarget, errorText);
2323
+ else
2324
+ await this.sendMessage(chatId, errorText);
2325
+ }
2326
+ catch (notifyErr) {
2327
+ console.warn(`[${this.config.name}] Failed to notify terminal delivery error:`, this.errorSummary(notifyErr));
2328
+ }
2064
2329
  }
2065
- throw err;
2066
- }
2067
- });
2330
+ });
2331
+ }
2332
+ // Drain subsequent pages immediately after successful batches. If any row
2333
+ // was returned to pending, honor its backoff timer instead of hot-looping.
2334
+ if (pending.length < 50 || retryScheduled)
2335
+ break;
2068
2336
  }
2069
2337
  }
2070
2338
  hasFreeModeBot(chatId) {
@@ -2236,7 +2504,7 @@ export class FeishuBot {
2236
2504
  timestamp: Date.now(),
2237
2505
  });
2238
2506
  try {
2239
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
2507
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
2240
2508
  }
2241
2509
  catch (err) {
2242
2510
  await liveStatus?.fail().catch(() => { });
@@ -2260,9 +2528,11 @@ export class FeishuBot {
2260
2528
  // the finished card does not take up much screen space.
2261
2529
  if (view.state === "done") {
2262
2530
  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}`;
2531
+ const summary = this.completionSummary({
2532
+ toolCalls: view.toolCalls,
2533
+ elapsed: view.elapsed,
2534
+ locale: en ? "en" : "zh",
2535
+ }, statusEmoji);
2266
2536
  return {
2267
2537
  schema: "2.0",
2268
2538
  config: { update_multi: true, width_mode: "fill" },
@@ -2293,12 +2563,10 @@ export class FeishuBot {
2293
2563
  elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
2294
2564
  }
2295
2565
  elements.push({ tag: "hr" });
2296
- // Footer: elapsed time + model name.
2297
- const footerBits = [];
2298
- footerBits.push(en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`);
2299
- if (view.model)
2300
- footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
2301
- elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
2566
+ // Running/failed status owns timing; model attribution belongs to the final
2567
+ // reply card so the two messages have clear, non-duplicated responsibilities.
2568
+ const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2569
+ elements.push({ tag: "markdown", content: `<font color='grey'>${footer}</font>` });
2302
2570
  const template = view.state === "failed" ? "orange" : "blue";
2303
2571
  return {
2304
2572
  schema: "2.0",
@@ -2459,9 +2727,21 @@ export class FeishuBot {
2459
2727
  async deleteMessageById(messageId) {
2460
2728
  await this.client.im.v1.message.delete({ path: { message_id: messageId } });
2461
2729
  }
2730
+ async replyFinalMessage(messageId, text, model) {
2731
+ if (!model?.trim())
2732
+ return this.replyMessage(messageId, text);
2733
+ this.replyModelFooters.set(messageId, model.trim());
2734
+ try {
2735
+ return await this.replyMessage(messageId, text);
2736
+ }
2737
+ finally {
2738
+ this.replyModelFooters.delete(messageId);
2739
+ }
2740
+ }
2462
2741
  async replyMessage(messageId, text) {
2463
2742
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
2464
- const card = this.buildMarkdownCard(text);
2743
+ const model = this.replyModelFooters.get(messageId);
2744
+ const card = this.buildMarkdownCard(text, model);
2465
2745
  try {
2466
2746
  const res = await this.client.im.message.reply({
2467
2747
  path: { message_id: messageId },
@@ -2473,11 +2753,12 @@ export class FeishuBot {
2473
2753
  return res?.data?.message_id || res?.message_id;
2474
2754
  }
2475
2755
  catch {
2476
- // Fallback to plain text if card fails
2756
+ // Fallback to plain text if card fails; retain model attribution.
2757
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
2477
2758
  const res = await this.client.im.message.reply({
2478
2759
  path: { message_id: messageId },
2479
2760
  data: {
2480
- content: JSON.stringify({ text }),
2761
+ content: JSON.stringify({ text: fallbackText }),
2481
2762
  msg_type: "text",
2482
2763
  },
2483
2764
  });
@@ -2751,13 +3032,12 @@ export class FeishuBot {
2751
3032
  catch (err) {
2752
3033
  console.warn(`[${this.config.name}] Feishu doc conversion failed, falling back to file attachment:`, this.errorSummary(err));
2753
3034
  const caption = attachment.caption?.trim();
2754
- await this.sendMessage(chatId, `${caption ? `${caption}\n` : ""}飞书文档创建失败,已改为 Markdown 文件附件发送。`);
2755
3035
  await this.sendBridgeFileAttachment(chatId, filePath);
3036
+ await this.sendMessage(chatId, `${caption ? `${caption}\n` : ""}飞书文档创建失败,已改为 Markdown 文件附件发送。`);
2756
3037
  return;
2757
3038
  }
2758
3039
  }
2759
- if (attachment.caption?.trim())
2760
- await this.sendMessage(chatId, attachment.caption.trim());
3040
+ const caption = attachment.caption?.trim();
2761
3041
  if (type === "image") {
2762
3042
  if (statSync(filePath).size > 10 * 1024 * 1024)
2763
3043
  throw new Error(`Image too large (>10MB): ${filePath}`);
@@ -2775,9 +3055,13 @@ export class FeishuBot {
2775
3055
  msg_type: "image",
2776
3056
  },
2777
3057
  });
3058
+ if (caption)
3059
+ await this.sendMessage(chatId, caption);
2778
3060
  return;
2779
3061
  }
2780
3062
  await this.sendBridgeFileAttachment(chatId, filePath);
3063
+ if (caption)
3064
+ await this.sendMessage(chatId, caption);
2781
3065
  }
2782
3066
  async sendBridgeFileAttachment(chatId, filePath) {
2783
3067
  const uploaded = await this.client.im.file.create({
@@ -2802,8 +3086,20 @@ export class FeishuBot {
2802
3086
  /**
2803
3087
  * Send a proactive message to a chat (not a reply).
2804
3088
  */
3089
+ async sendFinalMessage(chatId, text, model) {
3090
+ if (!model?.trim())
3091
+ return this.sendMessage(chatId, text);
3092
+ this.sendModelFooters.set(chatId, model.trim());
3093
+ try {
3094
+ return await this.sendMessage(chatId, text);
3095
+ }
3096
+ finally {
3097
+ this.sendModelFooters.delete(chatId);
3098
+ }
3099
+ }
2805
3100
  async sendMessage(chatId, text) {
2806
- const card = this.buildMarkdownCard(text);
3101
+ const model = this.sendModelFooters.get(chatId);
3102
+ const card = this.buildMarkdownCard(text, model);
2807
3103
  try {
2808
3104
  const res = await this.client.im.message.create({
2809
3105
  params: { receive_id_type: "chat_id" },
@@ -2820,13 +3116,14 @@ export class FeishuBot {
2820
3116
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
2821
3117
  if (this.isOutOfChatError(err))
2822
3118
  this.markCurrentBotUnavailable(chatId, err);
2823
- // Fallback to plain text
3119
+ // Fallback to plain text; retain model attribution.
2824
3120
  try {
3121
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
2825
3122
  const res = await this.client.im.message.create({
2826
3123
  params: { receive_id_type: "chat_id" },
2827
3124
  data: {
2828
3125
  receive_id: chatId,
2829
- content: JSON.stringify({ text }),
3126
+ content: JSON.stringify({ text: fallbackText }),
2830
3127
  msg_type: "text",
2831
3128
  },
2832
3129
  });