openclaw-lark-multi-agent 1.0.19 → 1.0.21
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/dist/feishu-bot.d.ts +22 -0
- package/dist/feishu-bot.js +408 -35
- package/dist/message-store.d.ts +4 -0
- package/dist/message-store.js +14 -0
- package/dist/openclaw-client.js +33 -2
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -73,6 +73,9 @@ export declare class FeishuBot {
|
|
|
73
73
|
static getByName(name: string): FeishuBot | undefined;
|
|
74
74
|
static findByOpenId(openId: string): FeishuBot | undefined;
|
|
75
75
|
private handleMessage;
|
|
76
|
+
private getUnhealthySessionStatus;
|
|
77
|
+
private recoverFromUnhealthySession;
|
|
78
|
+
private withSessionHealthMonitor;
|
|
76
79
|
/**
|
|
77
80
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
78
81
|
* Loops until no more unsynced human messages remain.
|
|
@@ -106,6 +109,17 @@ export declare class FeishuBot {
|
|
|
106
109
|
private dispatchPendingDeliveries;
|
|
107
110
|
private hasFreeModeBot;
|
|
108
111
|
private mentionedBotNames;
|
|
112
|
+
private mentionedBotNamesFromChairmanText;
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the target bot name(s) for a /chairman command using every signal
|
|
115
|
+
* available, in priority order:
|
|
116
|
+
* 1. Feishu mention metadata (app_id / open_id / name).
|
|
117
|
+
* 2. Text fallback: "@Bot" or "Bot" written in the message body.
|
|
118
|
+
*
|
|
119
|
+
* This keeps /chairman working even when a Feishu client or bridge omits
|
|
120
|
+
* mention metadata. Returns a de-duplicated, order-preserving list.
|
|
121
|
+
*/
|
|
122
|
+
private resolveChairmanTargets;
|
|
109
123
|
private isDiscussionCoordinator;
|
|
110
124
|
private getDiscussionParticipants;
|
|
111
125
|
private getChairmanParticipant;
|
|
@@ -176,6 +190,14 @@ export declare class FeishuBot {
|
|
|
176
190
|
* Send a model-drift notification to the affected chat.
|
|
177
191
|
*/
|
|
178
192
|
private hydrateInlineImageKeys;
|
|
193
|
+
private hydrateFeishuDocsInMessage;
|
|
194
|
+
private extractFeishuDocRefs;
|
|
195
|
+
private extractFeishuDocRefsFromText;
|
|
196
|
+
private hydrateFeishuDoc;
|
|
197
|
+
private resolveWikiDocRef;
|
|
198
|
+
private cleanupFeishuMarkdown;
|
|
199
|
+
private fetchFeishuDocMarkdown;
|
|
200
|
+
private safeFileName;
|
|
179
201
|
/**
|
|
180
202
|
* Download a resource (image/file/audio) from a Feishu message.
|
|
181
203
|
* Returns the local file path.
|
package/dist/feishu-bot.js
CHANGED
|
@@ -2,9 +2,9 @@ import * as lark from "@larksuiteoapi/node-sdk";
|
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { persistBotModel } from "./config.js";
|
|
4
4
|
import { getI18n, normalizeLocale } from "./i18n.js";
|
|
5
|
-
import { existsSync, readFileSync, statSync } from "fs";
|
|
6
|
-
import { basename, extname, resolve } from "path";
|
|
7
|
-
import { getBridgeAttachmentsDir } from "./paths.js";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
6
|
+
import { basename, extname, join, resolve } from "path";
|
|
7
|
+
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
8
8
|
import { buildFeishuCardElements } from "./markdown.js";
|
|
9
9
|
import { discussionManager } from "./discussion-manager.js";
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
@@ -13,6 +13,16 @@ const MAX_BOT_STREAK = 10;
|
|
|
13
13
|
const MAX_MERGED_TRIGGER_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_MESSAGES || 100);
|
|
14
14
|
const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_BYTES || 128 * 1024);
|
|
15
15
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
16
|
+
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
17
|
+
const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
|
|
18
|
+
class UnhealthySessionError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
constructor(status) {
|
|
21
|
+
super(`unhealthy session: ${status}`);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.name = "UnhealthySessionError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
16
26
|
/**
|
|
17
27
|
* Manages a single Feishu bot instance.
|
|
18
28
|
*
|
|
@@ -260,6 +270,10 @@ export class FeishuBot {
|
|
|
260
270
|
*/
|
|
261
271
|
async drainOnStartup() {
|
|
262
272
|
try {
|
|
273
|
+
const restoredDeliveries = this.store.resetStaleDeliveries(this.config.name, 5 * 60_000, 5);
|
|
274
|
+
if (restoredDeliveries.restored > 0 || restoredDeliveries.failed > 0) {
|
|
275
|
+
console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries.restored}, failed=${restoredDeliveries.failed}`);
|
|
276
|
+
}
|
|
263
277
|
const chats = this.store.getAllChatInfo();
|
|
264
278
|
const drainTasks = [];
|
|
265
279
|
for (const chat of chats) {
|
|
@@ -272,6 +286,12 @@ export class FeishuBot {
|
|
|
272
286
|
// Drain only messages that were explicitly marked as reply triggers.
|
|
273
287
|
// Context-only messages should not start an OpenClaw run after restart.
|
|
274
288
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
|
|
289
|
+
const pendingDeliveries = this.store.getPendingDeliveries(chat.chatId, this.config.name, 1);
|
|
290
|
+
if (pendingDeliveries.length > 0) {
|
|
291
|
+
drainTasks.push(this.dispatchPendingDeliveries(chat.chatId).catch((err) => {
|
|
292
|
+
console.warn(`[${this.config.name}] Startup delivery drain failed for ${chat.chatId.slice(-8)}:`, err.message);
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
275
295
|
if (pendingTriggerIds.size > 0) {
|
|
276
296
|
console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
|
|
277
297
|
// Do not let one slow/stuck chat block startup drain for all other chats.
|
|
@@ -322,7 +342,7 @@ export class FeishuBot {
|
|
|
322
342
|
}
|
|
323
343
|
}
|
|
324
344
|
}
|
|
325
|
-
if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post")
|
|
345
|
+
if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post" && messageType !== "share_doc")
|
|
326
346
|
return;
|
|
327
347
|
// --- Dedup: atomically claim this message for this bot before any await.
|
|
328
348
|
// Feishu/WebSocket can deliver the same event more than once; a separate
|
|
@@ -349,8 +369,9 @@ export class FeishuBot {
|
|
|
349
369
|
}
|
|
350
370
|
// --- Extract text content based on message type ---
|
|
351
371
|
let cleanText = "";
|
|
372
|
+
let rawText = "";
|
|
352
373
|
if (messageType === "text") {
|
|
353
|
-
|
|
374
|
+
rawText = content.text || "";
|
|
354
375
|
cleanText = this.cleanMentions(rawText);
|
|
355
376
|
// A mention-only text message is still a valid routing trigger. Feishu may
|
|
356
377
|
// expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
|
|
@@ -401,9 +422,13 @@ export class FeishuBot {
|
|
|
401
422
|
// Rich text post - extract all text content
|
|
402
423
|
cleanText = await this.hydrateInlineImageKeys(this.extractPostText(content), messageId);
|
|
403
424
|
}
|
|
425
|
+
else if (messageType === "share_doc") {
|
|
426
|
+
cleanText = content.title || content.name || content.text || content.url || "[Feishu document]";
|
|
427
|
+
}
|
|
404
428
|
else if (messageType === "sticker") {
|
|
405
429
|
cleanText = `[Sticker: ${content.file_key || "unknown"}]`;
|
|
406
430
|
}
|
|
431
|
+
cleanText = await this.hydrateFeishuDocsInMessage(cleanText, content, messageId);
|
|
407
432
|
if (!cleanText.trim())
|
|
408
433
|
return;
|
|
409
434
|
const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "");
|
|
@@ -462,9 +487,12 @@ export class FeishuBot {
|
|
|
462
487
|
const isModelCommand = commandName === "/model";
|
|
463
488
|
const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
|
|
464
489
|
if (chatType !== "p2p") {
|
|
490
|
+
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
491
|
+
const singleBotGroup = groupBotCount === 1;
|
|
465
492
|
if (isModelCommand) {
|
|
466
493
|
// Model switching is per-bot. @all is rejected by one coordinator;
|
|
467
|
-
// otherwise group /model must explicitly target this bot.
|
|
494
|
+
// otherwise group /model must explicitly target this bot. A single-bot
|
|
495
|
+
// group treats the sole bot as the implicit target.
|
|
468
496
|
if (routing.isAllMention) {
|
|
469
497
|
if (!this.isDiscussionCoordinator())
|
|
470
498
|
return;
|
|
@@ -473,12 +501,58 @@ export class FeishuBot {
|
|
|
473
501
|
if (!routing.isCurrentBotMentioned)
|
|
474
502
|
return;
|
|
475
503
|
}
|
|
504
|
+
else if (!singleBotGroup) {
|
|
505
|
+
if (!this.isDiscussionCoordinator())
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else if (isChairmanCommand) {
|
|
510
|
+
// /chairman @Bot is owned by the mentioned bot itself. This avoids
|
|
511
|
+
// requiring a coordinator to parse another bot's mention metadata,
|
|
512
|
+
// which can vary across Feishu clients/bridges. Untargeted/status,
|
|
513
|
+
// @all, and clear/off remain coordinator-owned group commands.
|
|
514
|
+
//
|
|
515
|
+
// Target resolution uses both Feishu mention metadata AND a text
|
|
516
|
+
// fallback (parsing "@Bot" / "Bot" from the raw message), so a
|
|
517
|
+
// /chairman that arrives without mention metadata still routes to
|
|
518
|
+
// the intended bot instead of silently failing.
|
|
519
|
+
const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
|
|
520
|
+
const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
|
|
521
|
+
const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
|
|
522
|
+
const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
|
|
523
|
+
// A pure status query: no clear keyword, no mention metadata, and no
|
|
524
|
+
// textual argument at all. Anything else is a set/route attempt.
|
|
525
|
+
const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
|
|
526
|
+
if (isClearArg || isStatusArg) {
|
|
527
|
+
// Group-level status/clear: one coordinator handles it.
|
|
528
|
+
if (!this.isDiscussionCoordinator())
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
else if (targets.length >= 1) {
|
|
532
|
+
// Targets resolved: only the (single) targeted bot acts. If
|
|
533
|
+
// multiple distinct targets resolved, the coordinator handles
|
|
534
|
+
// the "only one chairman" error once.
|
|
535
|
+
const uniqueTargets = Array.from(new Set(targets));
|
|
536
|
+
const targetedSelf = uniqueTargets.includes(this.config.name);
|
|
537
|
+
if (uniqueTargets.length > 1) {
|
|
538
|
+
if (!this.isDiscussionCoordinator())
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
else if (!targetedSelf) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
476
545
|
else {
|
|
477
|
-
|
|
546
|
+
// A set attempt whose target could not be resolved at all (missing
|
|
547
|
+
// metadata AND no readable name in text): coordinator handles the
|
|
548
|
+
// fallback so the user still gets an actionable reply.
|
|
549
|
+
if (!this.isDiscussionCoordinator())
|
|
550
|
+
return;
|
|
478
551
|
}
|
|
479
552
|
}
|
|
480
|
-
else if (
|
|
481
|
-
//
|
|
553
|
+
else if (isLocaleCommand && !routing.hasTargetedMention) {
|
|
554
|
+
// Untargeted or @all locale is a group-level setting; one coordinator handles it.
|
|
555
|
+
// If the user explicitly @s a bot, the targeted bot should execute and answer.
|
|
482
556
|
if (!this.isDiscussionCoordinator())
|
|
483
557
|
return;
|
|
484
558
|
}
|
|
@@ -492,8 +566,9 @@ export class FeishuBot {
|
|
|
492
566
|
if (!routing.isCurrentBotMentioned)
|
|
493
567
|
return;
|
|
494
568
|
}
|
|
495
|
-
else if (!routing.isAllMention) {
|
|
496
|
-
// Other bridge commands in a group need an explicit target or @all.
|
|
569
|
+
else if (!routing.isAllMention && !singleBotGroup) {
|
|
570
|
+
// Other bridge commands in a multi-bot group need an explicit target or @all.
|
|
571
|
+
// A single-bot group treats the sole bot as the implicit target.
|
|
497
572
|
return;
|
|
498
573
|
}
|
|
499
574
|
}
|
|
@@ -578,6 +653,14 @@ export class FeishuBot {
|
|
|
578
653
|
return;
|
|
579
654
|
}
|
|
580
655
|
if (commandName === "/model") {
|
|
656
|
+
if (chatType !== "p2p" && !routing.isAllMention && !routing.hasTargetedMention) {
|
|
657
|
+
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
658
|
+
if (groupBotCount > 1) {
|
|
659
|
+
await this.replyMessage(messageId, "❌ /model 是单 bot 设置,请明确 @ 一个 bot,例如:@Claude /model provider/model-id");
|
|
660
|
+
markCommandSynced();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
581
664
|
await this.handleModelCommand(chatId, messageId, cleanText.trim());
|
|
582
665
|
markCommandSynced();
|
|
583
666
|
return;
|
|
@@ -660,7 +743,7 @@ export class FeishuBot {
|
|
|
660
743
|
return;
|
|
661
744
|
}
|
|
662
745
|
if (commandName === "/chairman") {
|
|
663
|
-
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim());
|
|
746
|
+
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
|
|
664
747
|
markCommandSynced();
|
|
665
748
|
return;
|
|
666
749
|
}
|
|
@@ -682,7 +765,8 @@ export class FeishuBot {
|
|
|
682
765
|
const chairman = this.getChairmanParticipant(chatId);
|
|
683
766
|
const participants = this.getDiscussionParticipants(chatId);
|
|
684
767
|
if (participants.length > 0 || chairman) {
|
|
685
|
-
discussionManager.
|
|
768
|
+
const preempted = discussionManager.status(chatId);
|
|
769
|
+
const started = discussionManager.startIfAbsent({
|
|
686
770
|
chatId,
|
|
687
771
|
rootMessageId: messageId,
|
|
688
772
|
topic: cleanText,
|
|
@@ -700,6 +784,11 @@ export class FeishuBot {
|
|
|
700
784
|
}
|
|
701
785
|
},
|
|
702
786
|
});
|
|
787
|
+
if (started && preempted) {
|
|
788
|
+
await this.sendMessage(chatId, this.isEn(chatId)
|
|
789
|
+
? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
|
|
790
|
+
: `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
|
|
791
|
+
}
|
|
703
792
|
}
|
|
704
793
|
else if (this.isDiscussionCoordinator()) {
|
|
705
794
|
await this.sendMessage(chatId, this.isEn(chatId)
|
|
@@ -730,6 +819,11 @@ export class FeishuBot {
|
|
|
730
819
|
}
|
|
731
820
|
// Track this message for reaction status updates
|
|
732
821
|
const pending = this.pendingAckMessages.get(chatId) || [];
|
|
822
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
823
|
+
if (unhealthy) {
|
|
824
|
+
await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
733
827
|
// Anti-loop
|
|
734
828
|
const streak = this.store.getBotStreak(chatId, this.config.name);
|
|
735
829
|
if (streak >= MAX_BOT_STREAK) {
|
|
@@ -766,6 +860,65 @@ export class FeishuBot {
|
|
|
766
860
|
console.error(`[${this.config.name}] Error:`, err);
|
|
767
861
|
}
|
|
768
862
|
}
|
|
863
|
+
async getUnhealthySessionStatus(chatId) {
|
|
864
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
865
|
+
try {
|
|
866
|
+
const resp = await this.openclawClient.getSessionInfo(sessionKey);
|
|
867
|
+
const status = String(resp?.session?.status || "").toLowerCase();
|
|
868
|
+
if (!status)
|
|
869
|
+
return null;
|
|
870
|
+
if (["killed", "kill", "dead", "crashed", "crash", "failed", "failure", "error", "errored", "invalid"].some((bad) => status.includes(bad))) {
|
|
871
|
+
return status;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
// Absence/transient describe failures should not block a new run; chat.send
|
|
876
|
+
// will create/report the session as usual.
|
|
877
|
+
}
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
async recoverFromUnhealthySession(chatId, messageId, insertedId, status) {
|
|
881
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
882
|
+
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
883
|
+
this.busyChats.set(chatId, 0);
|
|
884
|
+
const cleared = this.store.clearAllPendingTriggers(this.config.name, chatId);
|
|
885
|
+
if (insertedId > 0)
|
|
886
|
+
this.store.markMessagesSynced(this.config.name, chatId, [insertedId], `${this.config.name}:${chatId}:${insertedId}:unhealthy-session`);
|
|
887
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
888
|
+
for (const ack of acks) {
|
|
889
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
890
|
+
await this.addReaction(ack.messageId, "FAIL").catch(() => { });
|
|
891
|
+
}
|
|
892
|
+
this.pendingAckMessages.set(chatId, []);
|
|
893
|
+
await this.addReaction(messageId, "FAIL").catch(() => { });
|
|
894
|
+
await this.replyMessage(messageId, this.isEn(chatId)
|
|
895
|
+
? `⚠️ ${this.config.name} session is unhealthy (${status}). Cleared ${cleared} pending message(s) and stopped waiting. Please send /reset and retry.`
|
|
896
|
+
: `⚠️ ${this.config.name} 的 session 状态异常(${status})。已停止等待、清掉 ${cleared} 条待处理消息并关闭等待反应。请先发送 /reset 后重试。`);
|
|
897
|
+
console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status}; cleared ${cleared} pending trigger(s) for ${chatId.slice(-8)}`);
|
|
898
|
+
}
|
|
899
|
+
async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
|
|
900
|
+
let done = false;
|
|
901
|
+
const monitoredWork = work.finally(() => { done = true; });
|
|
902
|
+
const monitor = (async () => {
|
|
903
|
+
while (!done) {
|
|
904
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_POLL_MS));
|
|
905
|
+
if (done)
|
|
906
|
+
break;
|
|
907
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
908
|
+
if (unhealthy) {
|
|
909
|
+
await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
|
|
910
|
+
throw new UnhealthySessionError(unhealthy);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return undefined;
|
|
914
|
+
})();
|
|
915
|
+
try {
|
|
916
|
+
return await Promise.race([monitoredWork, monitor]);
|
|
917
|
+
}
|
|
918
|
+
finally {
|
|
919
|
+
done = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
769
922
|
/**
|
|
770
923
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
771
924
|
* Loops until no more unsynced human messages remain.
|
|
@@ -793,6 +946,12 @@ export class FeishuBot {
|
|
|
793
946
|
if (pendingHumanTriggers.length === 0) {
|
|
794
947
|
break;
|
|
795
948
|
}
|
|
949
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
950
|
+
if (unhealthy) {
|
|
951
|
+
const latest = pendingHumanTriggers[pendingHumanTriggers.length - 1];
|
|
952
|
+
await this.recoverFromUnhealthySession(chatId, latest.messageId, latest.id || 0, unhealthy);
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
796
955
|
this.busyChats.set(chatId, Date.now());
|
|
797
956
|
// Pick the batch to process this loop. Consecutive plain human messages are
|
|
798
957
|
// merged into a single run; native escaped commands (//x) are processed on
|
|
@@ -845,6 +1004,17 @@ export class FeishuBot {
|
|
|
845
1004
|
console.warn(`[${this.config.name}] Dropped ${droppedMergedTriggers.length} older pending trigger(s) for ${chatId.slice(-8)} due to merge limits (kept=${mergedTriggers.length}, bytes=${bytes}): ${summaries}`);
|
|
846
1005
|
}
|
|
847
1006
|
}
|
|
1007
|
+
const deliveredTriggerIds = mergedTriggers
|
|
1008
|
+
.map((m) => m.id || 0)
|
|
1009
|
+
.filter((id) => id > 0 && this.store.hasDeliveredReply(this.config.name, chatId, id));
|
|
1010
|
+
if (deliveredTriggerIds.length > 0) {
|
|
1011
|
+
console.warn(`[${this.config.name}] Clearing ${deliveredTriggerIds.length} stale delivered pending trigger(s) for ${chatId.slice(-8)}: ${deliveredTriggerIds.join(",")}`);
|
|
1012
|
+
for (const id of deliveredTriggerIds)
|
|
1013
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
1014
|
+
mergedTriggers = mergedTriggers.filter((m) => !m.id || !deliveredTriggerIds.includes(m.id));
|
|
1015
|
+
if (mergedTriggers.length === 0)
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
848
1018
|
const isNativeCommandTrigger = firstIsNative;
|
|
849
1019
|
const lastHuman = mergedTriggers[mergedTriggers.length - 1];
|
|
850
1020
|
const triggerId = lastHuman.id || 0;
|
|
@@ -929,7 +1099,7 @@ export class FeishuBot {
|
|
|
929
1099
|
}
|
|
930
1100
|
};
|
|
931
1101
|
try {
|
|
932
|
-
reply = await this.openclawClient.chatSendWithContext({
|
|
1102
|
+
reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
|
|
933
1103
|
sessionKey,
|
|
934
1104
|
unsyncedMessages: contextMsgs,
|
|
935
1105
|
currentMessage: mergedContent,
|
|
@@ -953,7 +1123,7 @@ export class FeishuBot {
|
|
|
953
1123
|
// INSERT OR IGNORE in message_sync.
|
|
954
1124
|
markSubmittedBatch(`submitted:${runId}`);
|
|
955
1125
|
},
|
|
956
|
-
});
|
|
1126
|
+
}));
|
|
957
1127
|
}
|
|
958
1128
|
finally {
|
|
959
1129
|
// OpenClaw may emit the final assistant session.message just after
|
|
@@ -1083,6 +1253,10 @@ export class FeishuBot {
|
|
|
1083
1253
|
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
1084
1254
|
}
|
|
1085
1255
|
catch (err) {
|
|
1256
|
+
if (err instanceof UnhealthySessionError) {
|
|
1257
|
+
console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1086
1260
|
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
1087
1261
|
const errorText = this.formatUserVisibleError(err);
|
|
1088
1262
|
if (lastHuman.messageId) {
|
|
@@ -1180,7 +1354,7 @@ export class FeishuBot {
|
|
|
1180
1354
|
return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
|
|
1181
1355
|
}
|
|
1182
1356
|
isAllMention(rawText, mentions = []) {
|
|
1183
|
-
if (rawText && (
|
|
1357
|
+
if (rawText && /(^|\s)@(?:_all|all|所有人)(?=\s|$)/i.test(rawText))
|
|
1184
1358
|
return true;
|
|
1185
1359
|
return mentions.some((m) => this.isAllMentionItem(m));
|
|
1186
1360
|
}
|
|
@@ -1222,7 +1396,7 @@ export class FeishuBot {
|
|
|
1222
1396
|
return null;
|
|
1223
1397
|
}
|
|
1224
1398
|
escapeRegExp(value) {
|
|
1225
|
-
return value.replace(/[.*+?^${}()|[\]
|
|
1399
|
+
return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
1226
1400
|
}
|
|
1227
1401
|
resolveBotName(sender) {
|
|
1228
1402
|
const openId = sender?.sender_id?.open_id;
|
|
@@ -1385,12 +1559,6 @@ export class FeishuBot {
|
|
|
1385
1559
|
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
|
|
1386
1560
|
return;
|
|
1387
1561
|
}
|
|
1388
|
-
else if (sourceType === "assistant_visible") {
|
|
1389
|
-
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["assistant_visible"]))
|
|
1390
|
-
return;
|
|
1391
|
-
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 30, ["assistant_visible"]))
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
1562
|
const deliveryId = this.store.enqueueDelivery({
|
|
1395
1563
|
sessionKey: this.deliverySessionKey(chatId),
|
|
1396
1564
|
chatId,
|
|
@@ -1468,6 +1636,49 @@ export class FeishuBot {
|
|
|
1468
1636
|
.filter((name) => Boolean(name));
|
|
1469
1637
|
return Array.from(new Set(names));
|
|
1470
1638
|
}
|
|
1639
|
+
mentionedBotNamesFromChairmanText(...texts) {
|
|
1640
|
+
const normalizedTexts = texts
|
|
1641
|
+
.filter(Boolean)
|
|
1642
|
+
.map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
|
|
1643
|
+
.filter(Boolean)
|
|
1644
|
+
.map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
|
|
1645
|
+
if (normalizedTexts.length === 0)
|
|
1646
|
+
return [];
|
|
1647
|
+
const names = [];
|
|
1648
|
+
const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
|
|
1649
|
+
for (const raw of normalizedTexts) {
|
|
1650
|
+
for (const bot of candidates) {
|
|
1651
|
+
const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
|
|
1652
|
+
const displayNames = [
|
|
1653
|
+
botName,
|
|
1654
|
+
`@${botName}`,
|
|
1655
|
+
`万万(${botName})`,
|
|
1656
|
+
`@万万(${botName})`,
|
|
1657
|
+
`万万(${botName})`,
|
|
1658
|
+
`@万万(${botName})`,
|
|
1659
|
+
];
|
|
1660
|
+
if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
|
|
1661
|
+
names.push(bot.config.name);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return Array.from(new Set(names));
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Resolve the target bot name(s) for a /chairman command using every signal
|
|
1669
|
+
* available, in priority order:
|
|
1670
|
+
* 1. Feishu mention metadata (app_id / open_id / name).
|
|
1671
|
+
* 2. Text fallback: "@Bot" or "Bot" written in the message body.
|
|
1672
|
+
*
|
|
1673
|
+
* This keeps /chairman working even when a Feishu client or bridge omits
|
|
1674
|
+
* mention metadata. Returns a de-duplicated, order-preserving list.
|
|
1675
|
+
*/
|
|
1676
|
+
resolveChairmanTargets(mentions, text, rawText = "") {
|
|
1677
|
+
const fromMeta = this.mentionedBotNames(mentions);
|
|
1678
|
+
if (fromMeta.length > 0)
|
|
1679
|
+
return fromMeta;
|
|
1680
|
+
return this.mentionedBotNamesFromChairmanText(text, rawText);
|
|
1681
|
+
}
|
|
1471
1682
|
isDiscussionCoordinator() {
|
|
1472
1683
|
const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
|
|
1473
1684
|
if (bots.length === 0)
|
|
@@ -1874,7 +2085,7 @@ export class FeishuBot {
|
|
|
1874
2085
|
}
|
|
1875
2086
|
if (action === "rounds") {
|
|
1876
2087
|
const n = Number.parseInt(parts[2] || "", 10);
|
|
1877
|
-
if (!Number.
|
|
2088
|
+
if (!Number.isInteger(n) || n < 1 || n > 10) {
|
|
1878
2089
|
await this.replyMessage(messageId, "❌ Usage: /discuss rounds <1-10>");
|
|
1879
2090
|
return;
|
|
1880
2091
|
}
|
|
@@ -1901,32 +2112,43 @@ export class FeishuBot {
|
|
|
1901
2112
|
active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
|
|
1902
2113
|
].join("\n"));
|
|
1903
2114
|
}
|
|
1904
|
-
async handleChairmanCommand(chatId, chatType, messageId, mentions, text) {
|
|
2115
|
+
async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
|
|
1905
2116
|
if (chatType === "p2p") {
|
|
1906
2117
|
await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
|
|
1907
2118
|
return;
|
|
1908
2119
|
}
|
|
1909
2120
|
const parts = text.split(/\s+/).filter(Boolean);
|
|
1910
|
-
const action = (parts[1] || "
|
|
2121
|
+
const action = (parts[1] || "").toLowerCase();
|
|
1911
2122
|
if (["off", "clear", "none"].includes(action)) {
|
|
1912
2123
|
const previous = this.store.getChairmanBot(chatId);
|
|
1913
2124
|
this.store.clearChairmanBot(chatId);
|
|
1914
2125
|
await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
|
|
1915
2126
|
return;
|
|
1916
2127
|
}
|
|
1917
|
-
const
|
|
1918
|
-
if (
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2128
|
+
const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
|
|
2129
|
+
if (resolvedBotNames.length === 0) {
|
|
2130
|
+
// /chairman is intentionally not a status query. Status lives in /status;
|
|
2131
|
+
// this command is only for switching or clearing the Chairman. Keeping the
|
|
2132
|
+
// two meanings separate prevents failed mention parsing from being masked
|
|
2133
|
+
// as a harmless "current status" response.
|
|
2134
|
+
const groupBots = Array.from(FeishuBot.allBots.values())
|
|
2135
|
+
.filter((bot) => bot.store === this.store)
|
|
2136
|
+
.map((bot) => bot.config.name);
|
|
2137
|
+
const uniqueGroupBots = Array.from(new Set(groupBots));
|
|
2138
|
+
const choices = uniqueGroupBots.length > 0
|
|
2139
|
+
? uniqueGroupBots.map((name) => `/chairman ${name}`).join("\n")
|
|
2140
|
+
: "/chairman @某个Bot";
|
|
2141
|
+
await this.replyMessage(messageId, `❌ /chairman 只用于设置/切换 Chairman,不再用于状态查询。\n` +
|
|
2142
|
+
`查看当前 Chairman 请用 /status。\n` +
|
|
2143
|
+
`切换请 @ 一个 bot,或直接发(可复制):\n${choices}\n` +
|
|
2144
|
+
`清除请用:/chairman off`);
|
|
1923
2145
|
return;
|
|
1924
2146
|
}
|
|
1925
|
-
if (
|
|
2147
|
+
if (resolvedBotNames.length > 1) {
|
|
1926
2148
|
await this.replyMessage(messageId, "❌ 一个群只能设置一个 Chairman。请只 @ 一个 bot。");
|
|
1927
2149
|
return;
|
|
1928
2150
|
}
|
|
1929
|
-
const next =
|
|
2151
|
+
const next = resolvedBotNames[0];
|
|
1930
2152
|
const previous = this.store.getChairmanBot(chatId);
|
|
1931
2153
|
this.store.setChairmanBot(chatId, next);
|
|
1932
2154
|
const mode = this.store.getBotMode(next, chatId);
|
|
@@ -2108,7 +2330,7 @@ export class FeishuBot {
|
|
|
2108
2330
|
chatName: "私聊",
|
|
2109
2331
|
members: "",
|
|
2110
2332
|
memberNames: "",
|
|
2111
|
-
ownerBot: this.config.name,
|
|
2333
|
+
ownerBot: existing?.ownerBot || this.config.name,
|
|
2112
2334
|
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
2113
2335
|
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
2114
2336
|
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|
|
@@ -2181,6 +2403,157 @@ export class FeishuBot {
|
|
|
2181
2403
|
out = out.replace(r.from, r.to);
|
|
2182
2404
|
return out;
|
|
2183
2405
|
}
|
|
2406
|
+
async hydrateFeishuDocsInMessage(text, content, messageId) {
|
|
2407
|
+
const refs = this.extractFeishuDocRefs(text, content);
|
|
2408
|
+
if (refs.length === 0)
|
|
2409
|
+
return text;
|
|
2410
|
+
const hydrated = [];
|
|
2411
|
+
for (const ref of refs.slice(0, 5)) {
|
|
2412
|
+
try {
|
|
2413
|
+
const doc = await this.hydrateFeishuDoc(ref, messageId);
|
|
2414
|
+
hydrated.push(`[FeishuDoc: ${doc.title || doc.token} -> ${doc.markdownPath}]`);
|
|
2415
|
+
}
|
|
2416
|
+
catch (err) {
|
|
2417
|
+
hydrated.push(`[FeishuDoc: ${ref.title || ref.token} - hydration failed: ${this.errorSummary(err)}]`);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
return `${text.trim()}\n\n[飞书文档已由 LMA 用机器人权限读取并转换为 Markdown 附件,OpenClaw 可直接读取附件内容。]\n${hydrated.join("\n")}`.trim();
|
|
2421
|
+
}
|
|
2422
|
+
extractFeishuDocRefs(text, content) {
|
|
2423
|
+
const refs = [];
|
|
2424
|
+
const add = (ref) => {
|
|
2425
|
+
if (!ref.token || refs.some((r) => r.type === ref.type && r.token === ref.token))
|
|
2426
|
+
return;
|
|
2427
|
+
refs.push(ref);
|
|
2428
|
+
};
|
|
2429
|
+
const scanValue = (value, title) => {
|
|
2430
|
+
if (!value)
|
|
2431
|
+
return;
|
|
2432
|
+
if (typeof value === "string") {
|
|
2433
|
+
for (const ref of this.extractFeishuDocRefsFromText(value, title))
|
|
2434
|
+
add(ref);
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
if (Array.isArray(value)) {
|
|
2438
|
+
for (const item of value)
|
|
2439
|
+
scanValue(item, title);
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
if (typeof value !== "object")
|
|
2443
|
+
return;
|
|
2444
|
+
const nextTitle = value.title || value.name || value.file_name || title;
|
|
2445
|
+
const explicitType = String(value.obj_type || value.doc_type || value.type || "").toLowerCase();
|
|
2446
|
+
const docxToken = value.docx_token || value.document_id || (explicitType === "docx" ? value.token : undefined);
|
|
2447
|
+
const docToken = value.doc_token || (explicitType === "doc" ? value.token : undefined);
|
|
2448
|
+
const wikiToken = value.wiki_token || value.node_token || (explicitType === "wiki" ? value.token : undefined);
|
|
2449
|
+
if (docxToken)
|
|
2450
|
+
add({ type: "docx", token: String(docxToken), title: nextTitle, url: value.url || value.href });
|
|
2451
|
+
if (docToken)
|
|
2452
|
+
add({ type: "doc", token: String(docToken), title: nextTitle, url: value.url || value.href });
|
|
2453
|
+
if (wikiToken)
|
|
2454
|
+
add({ type: "wiki", token: String(wikiToken), title: nextTitle, url: value.url || value.href });
|
|
2455
|
+
for (const v of Object.values(value))
|
|
2456
|
+
scanValue(v, nextTitle);
|
|
2457
|
+
};
|
|
2458
|
+
scanValue(content);
|
|
2459
|
+
for (const ref of this.extractFeishuDocRefsFromText(text))
|
|
2460
|
+
add(ref);
|
|
2461
|
+
return refs;
|
|
2462
|
+
}
|
|
2463
|
+
extractFeishuDocRefsFromText(text, title) {
|
|
2464
|
+
const refs = [];
|
|
2465
|
+
const pattern = /https?:\/\/[^\s)\]>"']+\/(docx|docs|wiki)\/([A-Za-z0-9_-]+)/g;
|
|
2466
|
+
for (const match of text.matchAll(pattern)) {
|
|
2467
|
+
const kind = match[1] === "docs" ? "doc" : match[1];
|
|
2468
|
+
refs.push({ type: kind, token: match[2], title, url: match[0] });
|
|
2469
|
+
}
|
|
2470
|
+
return refs;
|
|
2471
|
+
}
|
|
2472
|
+
async hydrateFeishuDoc(ref, messageId) {
|
|
2473
|
+
let resolved = ref;
|
|
2474
|
+
if (ref.type === "wiki")
|
|
2475
|
+
resolved = await this.resolveWikiDocRef(ref);
|
|
2476
|
+
const markdown = this.cleanupFeishuMarkdown(await this.fetchFeishuDocMarkdown(resolved));
|
|
2477
|
+
const title = resolved.title || ref.title || `${resolved.type}-${resolved.token}`;
|
|
2478
|
+
mkdirSync(FEISHU_DOCS_DIR, { recursive: true });
|
|
2479
|
+
const fileName = `${messageId}-${this.safeFileName(title)}-${resolved.token.slice(0, 8)}.md`;
|
|
2480
|
+
const markdownPath = join(FEISHU_DOCS_DIR, fileName);
|
|
2481
|
+
const body = [
|
|
2482
|
+
`# ${title}`,
|
|
2483
|
+
``,
|
|
2484
|
+
`- Source type: ${ref.type}${resolved.type !== ref.type ? ` -> ${resolved.type}` : ""}`,
|
|
2485
|
+
`- Token: ${ref.token}`,
|
|
2486
|
+
resolved.url || ref.url ? `- URL: ${resolved.url || ref.url}` : "",
|
|
2487
|
+
`- Hydrated at: ${new Date().toISOString()}`,
|
|
2488
|
+
``,
|
|
2489
|
+
markdown.trim(),
|
|
2490
|
+
``,
|
|
2491
|
+
].filter(Boolean).join("\n");
|
|
2492
|
+
writeFileSync(markdownPath, body, "utf8");
|
|
2493
|
+
return { ...resolved, title, markdownPath };
|
|
2494
|
+
}
|
|
2495
|
+
async resolveWikiDocRef(ref) {
|
|
2496
|
+
const resp = await this.client.wiki.v2.space.getNode({ params: { token: ref.token } });
|
|
2497
|
+
const node = resp?.data?.node;
|
|
2498
|
+
if (!node?.obj_token || !node?.obj_type)
|
|
2499
|
+
throw new Error(`Wiki node not readable: ${resp?.msg || "missing obj_token"}`);
|
|
2500
|
+
const objType = String(node.obj_type).toLowerCase();
|
|
2501
|
+
if (objType !== "docx" && objType !== "doc")
|
|
2502
|
+
throw new Error(`Unsupported wiki object type: ${objType}`);
|
|
2503
|
+
return { type: objType, token: node.obj_token, title: node.title || ref.title, url: ref.url };
|
|
2504
|
+
}
|
|
2505
|
+
cleanupFeishuMarkdown(markdown) {
|
|
2506
|
+
return markdown
|
|
2507
|
+
.replace(/\\&\\#/g, "&#")
|
|
2508
|
+
.replace(/\\&#/g, "&#")
|
|
2509
|
+
.replace(/\\&#/g, "&#")
|
|
2510
|
+
.replace(/&#/g, "&#")
|
|
2511
|
+
.replace(/&#(\d+);/g, (_, code) => {
|
|
2512
|
+
const n = Number(code);
|
|
2513
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : _;
|
|
2514
|
+
})
|
|
2515
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => {
|
|
2516
|
+
const n = Number.parseInt(code, 16);
|
|
2517
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : _;
|
|
2518
|
+
})
|
|
2519
|
+
.replace(/"/g, '"')
|
|
2520
|
+
.replace(/'/g, "'")
|
|
2521
|
+
.replace(/</g, "<")
|
|
2522
|
+
.replace(/>/g, ">")
|
|
2523
|
+
.replace(/&/g, "&")
|
|
2524
|
+
.replace(/\\([+\-=(){}[\].!,,。;:!?、])/g, "$1")
|
|
2525
|
+
.replace(/\\\*/g, "*")
|
|
2526
|
+
.replace(/\\_/g, "_")
|
|
2527
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
2528
|
+
.trim();
|
|
2529
|
+
}
|
|
2530
|
+
async fetchFeishuDocMarkdown(ref) {
|
|
2531
|
+
let contentErr;
|
|
2532
|
+
const docs = this.client.docs;
|
|
2533
|
+
if ((ref.type === "docx" || ref.type === "doc") && docs?.v1?.content?.get) {
|
|
2534
|
+
try {
|
|
2535
|
+
const resp = await docs.v1.content.get({ params: { doc_token: ref.token, doc_type: ref.type, content_type: "markdown", lang: "zh" } });
|
|
2536
|
+
const content = resp?.data?.content;
|
|
2537
|
+
if (content)
|
|
2538
|
+
return content;
|
|
2539
|
+
contentErr = new Error(`docs content empty: ${resp?.msg || "unknown"}`);
|
|
2540
|
+
}
|
|
2541
|
+
catch (err) {
|
|
2542
|
+
contentErr = err;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
if (ref.type === "docx") {
|
|
2546
|
+
const resp = await this.client.docx.document.rawContent({ path: { document_id: ref.token } });
|
|
2547
|
+
const content = resp?.data?.content;
|
|
2548
|
+
if (content)
|
|
2549
|
+
return content;
|
|
2550
|
+
throw new Error(`docx raw content empty: ${resp?.msg || this.errorSummary(contentErr)}`);
|
|
2551
|
+
}
|
|
2552
|
+
throw new Error(`Legacy doc markdown hydration failed: ${this.errorSummary(contentErr)}`);
|
|
2553
|
+
}
|
|
2554
|
+
safeFileName(name) {
|
|
2555
|
+
return name.replace(/[\\/:*?\"<>|\s]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "feishu-doc";
|
|
2556
|
+
}
|
|
2184
2557
|
/**
|
|
2185
2558
|
* Download a resource (image/file/audio) from a Feishu message.
|
|
2186
2559
|
* Returns the local file path.
|
package/dist/message-store.d.ts
CHANGED
|
@@ -72,6 +72,10 @@ export declare class MessageStore {
|
|
|
72
72
|
getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
|
|
73
73
|
hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
|
|
74
74
|
hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
|
|
75
|
+
resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
|
|
76
|
+
restored: number;
|
|
77
|
+
failed: number;
|
|
78
|
+
};
|
|
75
79
|
claimDelivery(id: number): boolean;
|
|
76
80
|
markDeliveryDelivered(id: number): void;
|
|
77
81
|
markDeliveryFailed(id: number): void;
|
package/dist/message-store.js
CHANGED
|
@@ -436,6 +436,20 @@ export class MessageStore {
|
|
|
436
436
|
}
|
|
437
437
|
return false;
|
|
438
438
|
}
|
|
439
|
+
resetStaleDeliveries(botName, staleMs, maxAttempts) {
|
|
440
|
+
const cutoff = Date.now() - staleMs;
|
|
441
|
+
const fail = this.db.prepare(`
|
|
442
|
+
UPDATE delivery_outbox
|
|
443
|
+
SET status = 'failed', updated_at = ?
|
|
444
|
+
WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts >= ?
|
|
445
|
+
`).run(Date.now(), botName, cutoff, maxAttempts);
|
|
446
|
+
const restore = this.db.prepare(`
|
|
447
|
+
UPDATE delivery_outbox
|
|
448
|
+
SET status = 'pending', updated_at = ?
|
|
449
|
+
WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts < ?
|
|
450
|
+
`).run(Date.now(), botName, cutoff, maxAttempts);
|
|
451
|
+
return { restored: restore.changes, failed: fail.changes };
|
|
452
|
+
}
|
|
439
453
|
claimDelivery(id) {
|
|
440
454
|
const result = this.db.prepare(`
|
|
441
455
|
UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
|
package/dist/openclaw-client.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
-
import { basename, extname, join } from "path";
|
|
3
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
4
|
+
import { basename, extname, join, resolve, sep } from "path";
|
|
5
5
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
6
6
|
export const GATEWAY_PROTOCOL_MIN = 3;
|
|
7
7
|
export const GATEWAY_PROTOCOL_MAX = 4;
|
|
8
8
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
9
9
|
const CONTEXT_SYNC_DIR = join(getDataDir(), "context-sync");
|
|
10
|
+
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
11
|
+
const MAX_FEISHU_DOC_ATTACHMENT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_FEISHU_DOC_ATTACHMENT_BYTES || 1024 * 1024);
|
|
10
12
|
const MAX_INLINE_CONTEXT_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_MESSAGES || 20);
|
|
11
13
|
const MAX_INLINE_CONTEXT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_BYTES || 128 * 1024);
|
|
12
14
|
/**
|
|
@@ -1224,6 +1226,7 @@ export class OpenClawClient {
|
|
|
1224
1226
|
const attachments = [];
|
|
1225
1227
|
const seen = new Set();
|
|
1226
1228
|
const imagePattern = /\[Image: ([^\]\n]+)\]/g;
|
|
1229
|
+
const docPattern = /\[FeishuDoc: [^\]\n]*? -> ([^\]\n]+\.md)\]/g;
|
|
1227
1230
|
for (const content of contents) {
|
|
1228
1231
|
for (const match of content.matchAll(imagePattern)) {
|
|
1229
1232
|
const imagePath = match[1]?.trim();
|
|
@@ -1244,6 +1247,34 @@ export class OpenClawClient {
|
|
|
1244
1247
|
console.warn(`[OpenClaw] failed to attach image ${imagePath}:`, err.message);
|
|
1245
1248
|
}
|
|
1246
1249
|
}
|
|
1250
|
+
for (const match of content.matchAll(docPattern)) {
|
|
1251
|
+
const docPath = match[1]?.trim();
|
|
1252
|
+
if (!docPath || seen.has(docPath))
|
|
1253
|
+
continue;
|
|
1254
|
+
seen.add(docPath);
|
|
1255
|
+
try {
|
|
1256
|
+
const resolvedDocPath = resolve(docPath);
|
|
1257
|
+
const allowedRoot = resolve(FEISHU_DOCS_DIR);
|
|
1258
|
+
if (resolvedDocPath !== allowedRoot && !resolvedDocPath.startsWith(`${allowedRoot}${sep}`)) {
|
|
1259
|
+
throw new Error(`Feishu doc markdown path outside hydration dir: ${docPath}`);
|
|
1260
|
+
}
|
|
1261
|
+
const size = statSync(resolvedDocPath).size;
|
|
1262
|
+
let docContent = readFileSync(resolvedDocPath, "utf8");
|
|
1263
|
+
if (size > MAX_FEISHU_DOC_ATTACHMENT_BYTES) {
|
|
1264
|
+
const truncated = Buffer.from(docContent, "utf8").subarray(0, MAX_FEISHU_DOC_ATTACHMENT_BYTES).toString("utf8");
|
|
1265
|
+
docContent = `${truncated}\n\n---\n[文档过大,LMA 已截断到 ${MAX_FEISHU_DOC_ATTACHMENT_BYTES} bytes;如需完整内容,请让 LMA 扩大 OPENCLAW_LARK_MULTI_AGENT_MAX_FEISHU_DOC_ATTACHMENT_BYTES 或改用本地路径读取。]`;
|
|
1266
|
+
}
|
|
1267
|
+
attachments.push({
|
|
1268
|
+
type: "file",
|
|
1269
|
+
mimeType: "text/markdown",
|
|
1270
|
+
fileName: basename(resolvedDocPath),
|
|
1271
|
+
content: Buffer.from(docContent, "utf8").toString("base64"),
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
catch (err) {
|
|
1275
|
+
console.warn(`[OpenClaw] failed to attach Feishu doc markdown ${docPath}:`, err.message);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1247
1278
|
}
|
|
1248
1279
|
return attachments;
|
|
1249
1280
|
}
|