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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/config.example.json +1 -1
- package/dist/cli.js +31 -6
- package/dist/feishu-bot.d.ts +19 -10
- package/dist/feishu-bot.js +470 -93
- package/dist/live-status.d.ts +17 -0
- package/dist/live-status.js +27 -0
- package/dist/message-store.d.ts +29 -0
- package/dist/message-store.js +162 -8
- package/dist/openclaw-client.d.ts +4 -0
- package/dist/openclaw-client.js +75 -0
- package/package.json +1 -1
package/dist/feishu-bot.js
CHANGED
|
@@ -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.
|
|
322
|
-
if (restoredDeliveries
|
|
323
|
-
console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries
|
|
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
|
-
|
|
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
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
|
|
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
|
|
1715
|
-
* 1.
|
|
1716
|
-
* 2.
|
|
1717
|
-
*
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
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)
|
|
1734
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
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
|
-
// (
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
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
|
-
|
|
2028
|
-
|
|
2029
|
-
const
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
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
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
-
|
|
2041
|
-
|
|
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 (
|
|
2063
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
2264
|
-
|
|
2265
|
-
:
|
|
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
|
-
|
|
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({
|