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.
- package/README.md +7 -6
- package/README.zh-CN.md +8 -2
- package/config.example.json +1 -1
- package/dist/cli.js +1 -1
- package/dist/feishu-bot.d.ts +23 -10
- package/dist/feishu-bot.js +396 -99
- package/dist/live-status.d.ts +17 -0
- package/dist/live-status.js +27 -0
- package/dist/message-store.d.ts +30 -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,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.
|
|
322
|
-
if (restoredDeliveries
|
|
323
|
-
console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries
|
|
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
|
-
|
|
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
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
|
|
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
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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)
|
|
1734
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
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
|
-
// (
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
|
|
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
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
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
|
-
|
|
2028
|
-
|
|
2029
|
-
const
|
|
2030
|
-
|
|
2031
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
-
|
|
2041
|
-
|
|
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 (
|
|
2063
|
-
|
|
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
|
-
|
|
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 =
|
|
2264
|
-
|
|
2265
|
-
:
|
|
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
|
-
//
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
});
|