openclaw-lark-multi-agent 1.0.17 → 1.0.19
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 +58 -7
- package/README.zh-CN.md +60 -7
- package/dist/config.d.ts +6 -0
- package/dist/config.js +18 -1
- package/dist/feishu-bot.d.ts +15 -1
- package/dist/feishu-bot.js +317 -47
- package/dist/index.js +1 -1
- package/dist/message-store.d.ts +6 -3
- package/dist/message-store.js +34 -6
- package/dist/openclaw-client.d.ts +27 -2
- package/dist/openclaw-client.js +189 -24
- package/package.json +1 -1
package/dist/feishu-bot.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as lark from "@larksuiteoapi/node-sdk";
|
|
2
2
|
import { createRequire } from "module";
|
|
3
|
+
import { persistBotModel } from "./config.js";
|
|
3
4
|
import { getI18n, normalizeLocale } from "./i18n.js";
|
|
4
5
|
import { existsSync, readFileSync, statSync } from "fs";
|
|
5
6
|
import { basename, extname, resolve } from "path";
|
|
@@ -9,6 +10,8 @@ import { discussionManager } from "./discussion-manager.js";
|
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const LMA_VERSION = require("../package.json").version;
|
|
11
12
|
const MAX_BOT_STREAK = 10;
|
|
13
|
+
const MAX_MERGED_TRIGGER_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_MESSAGES || 100);
|
|
14
|
+
const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_BYTES || 128 * 1024);
|
|
12
15
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
13
16
|
/**
|
|
14
17
|
* Manages a single Feishu bot instance.
|
|
@@ -34,6 +37,7 @@ export class FeishuBot {
|
|
|
34
37
|
pendingAckMessages = new Map();
|
|
35
38
|
/** Per-chat pending tool message sends (to await before final reply) */
|
|
36
39
|
pendingToolSends = new Map();
|
|
40
|
+
recentVerboseToolMessages = new Map();
|
|
37
41
|
/** Per-chat processQueue lock to avoid duplicate concurrent chat.send runs */
|
|
38
42
|
queueRuns = new Map();
|
|
39
43
|
/** Per-chat serial send queue to guarantee message order */
|
|
@@ -46,13 +50,15 @@ export class FeishuBot {
|
|
|
46
50
|
activeDeliveryTargets = new Map();
|
|
47
51
|
adminOpenId;
|
|
48
52
|
locale;
|
|
53
|
+
configPath;
|
|
49
54
|
static allBots = new Map();
|
|
50
|
-
constructor(config, openclawClient, store, adminOpenId) {
|
|
55
|
+
constructor(config, openclawClient, store, adminOpenId, configPath) {
|
|
51
56
|
this.config = config;
|
|
52
57
|
this.openclawClient = openclawClient;
|
|
53
58
|
this.store = store;
|
|
54
59
|
this.adminOpenId = adminOpenId || null;
|
|
55
60
|
this.locale = normalizeLocale(config.locale);
|
|
61
|
+
this.configPath = configPath;
|
|
56
62
|
// Session keys are now per-chat: lma-<botname>-<chatId>
|
|
57
63
|
this.client = new lark.Client({
|
|
58
64
|
appId: config.appId,
|
|
@@ -166,14 +172,16 @@ export class FeishuBot {
|
|
|
166
172
|
}
|
|
167
173
|
this.initializedSessions.add(sessionKey);
|
|
168
174
|
// Subscribe to session events (proactive messages + tool calls)
|
|
169
|
-
|
|
175
|
+
this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
|
|
176
|
+
await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
|
|
170
177
|
try {
|
|
171
|
-
|
|
178
|
+
const sourceType = meta?.sourceType === "verbose_transcript" ? "verbose_transcript" : "assistant_visible";
|
|
179
|
+
console.log(`[${this.config.name}] ${sourceType === "verbose_transcript" ? "Verbose transcript" : "Proactive message"} for ${chatId.slice(-8)}`);
|
|
172
180
|
const parsed = this.extractBridgeAttachments(text);
|
|
173
|
-
if (parsed.text.trim() || parsed.attachments.length > 0)
|
|
181
|
+
if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
|
|
174
182
|
this.cancelDelayedFailure(chatId);
|
|
175
183
|
const activeTarget = this.activeDeliveryTargets.get(chatId);
|
|
176
|
-
await this.enqueueAndDispatchDelivery(chatId,
|
|
184
|
+
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);
|
|
177
185
|
}
|
|
178
186
|
catch (err) {
|
|
179
187
|
console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
|
|
@@ -189,6 +197,16 @@ export class FeishuBot {
|
|
|
189
197
|
const inputPreview = toolInput.length > 200 ? toolInput.substring(0, 200) + "..." : toolInput;
|
|
190
198
|
const outputPreview = toolOutput.length > 300 ? toolOutput.substring(0, 300) + "..." : toolOutput;
|
|
191
199
|
const msg = `🔧 Tool: ${toolName}\n📥 ${inputPreview}${toolOutput ? `\n📤 ${outputPreview}` : ""}`;
|
|
200
|
+
const dedupeKey = `${chatId}:${msg}`;
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
const lastSentAt = this.recentVerboseToolMessages.get(dedupeKey) || 0;
|
|
203
|
+
if (now - lastSentAt < 5_000)
|
|
204
|
+
return;
|
|
205
|
+
this.recentVerboseToolMessages.set(dedupeKey, now);
|
|
206
|
+
for (const [key, ts] of this.recentVerboseToolMessages) {
|
|
207
|
+
if (now - ts > 60_000)
|
|
208
|
+
this.recentVerboseToolMessages.delete(key);
|
|
209
|
+
}
|
|
192
210
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] Sending tool msg to Feishu...`);
|
|
193
211
|
await this.sendMessage(chatId, msg);
|
|
194
212
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] Tool msg sent OK`);
|
|
@@ -403,6 +421,9 @@ export class FeishuBot {
|
|
|
403
421
|
else if (commandText.startsWith("/")) {
|
|
404
422
|
cleanText = commandText;
|
|
405
423
|
}
|
|
424
|
+
const preliminaryCommandName = cleanText.trim().split(/\s+/)[0]?.toLowerCase() || "";
|
|
425
|
+
const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
|
|
426
|
+
const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
|
|
406
427
|
// --- Record to local store (ALL messages, before command/response checks) ---
|
|
407
428
|
const senderName = isBot
|
|
408
429
|
? this.resolveBotName(sender) || "Bot"
|
|
@@ -414,7 +435,13 @@ export class FeishuBot {
|
|
|
414
435
|
senderName,
|
|
415
436
|
content: cleanText,
|
|
416
437
|
timestamp: Date.now(),
|
|
417
|
-
triggerKind: isNativeOpenClawCommand
|
|
438
|
+
triggerKind: isNativeOpenClawCommand
|
|
439
|
+
? "native_command"
|
|
440
|
+
: isPreliminaryBridgeCommand
|
|
441
|
+
? "bridge_command"
|
|
442
|
+
: isBot && this.isBridgeControlReply(cleanText)
|
|
443
|
+
? "bridge_control_reply"
|
|
444
|
+
: "normal",
|
|
418
445
|
});
|
|
419
446
|
if (insertedId < 0)
|
|
420
447
|
insertedId = this.store.getMessageId(messageId) || -1;
|
|
@@ -422,16 +449,35 @@ export class FeishuBot {
|
|
|
422
449
|
// Single slash commands are handled by the bridge. Double slash commands were
|
|
423
450
|
// already unescaped above and should pass through to OpenClaw instead.
|
|
424
451
|
const isBridgeCommand = !commandText.startsWith("//");
|
|
425
|
-
const
|
|
452
|
+
const commandName = preliminaryCommandName;
|
|
453
|
+
const bridgeCommands = preliminaryBridgeCommands;
|
|
454
|
+
const isCommand = isBridgeCommand && bridgeCommands.has(commandName);
|
|
426
455
|
if (isCommand) {
|
|
427
456
|
// In group chats, most bridge commands must be explicitly routed to this
|
|
428
457
|
// bot or @all. /discuss is a group-level command, so an unmentioned
|
|
429
458
|
// /discuss command is handled by one coordinator bot to avoid N replies.
|
|
430
|
-
const isDiscussCommand =
|
|
431
|
-
const isChairmanCommand =
|
|
432
|
-
const isLocaleCommand =
|
|
459
|
+
const isDiscussCommand = commandName === "/discuss";
|
|
460
|
+
const isChairmanCommand = commandName === "/chairman";
|
|
461
|
+
const isLocaleCommand = commandName === "/locale";
|
|
462
|
+
const isModelCommand = commandName === "/model";
|
|
463
|
+
const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
|
|
433
464
|
if (chatType !== "p2p") {
|
|
434
|
-
if (
|
|
465
|
+
if (isModelCommand) {
|
|
466
|
+
// Model switching is per-bot. @all is rejected by one coordinator;
|
|
467
|
+
// otherwise group /model must explicitly target this bot.
|
|
468
|
+
if (routing.isAllMention) {
|
|
469
|
+
if (!this.isDiscussionCoordinator())
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
else if (routing.hasTargetedMention) {
|
|
473
|
+
if (!routing.isCurrentBotMentioned)
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else if (isChairmanCommand || isLocaleCommand) {
|
|
435
481
|
// Group-level settings; one coordinator handles them.
|
|
436
482
|
if (!this.isDiscussionCoordinator())
|
|
437
483
|
return;
|
|
@@ -460,7 +506,7 @@ export class FeishuBot {
|
|
|
460
506
|
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
461
507
|
}
|
|
462
508
|
};
|
|
463
|
-
if (
|
|
509
|
+
if (commandName === "/help") {
|
|
464
510
|
const helpText = [
|
|
465
511
|
`📚 ${this.config.name} Bot 命令列表`,
|
|
466
512
|
`━━━━━━━━━━━━━━━━━━`,
|
|
@@ -468,10 +514,12 @@ export class FeishuBot {
|
|
|
468
514
|
`📊 /status — 查看当前模型、Token 用量、Session 状态`,
|
|
469
515
|
`🧹 /compact — 压缩当前 bot 的 OpenClaw session`,
|
|
470
516
|
`🔄 /reset — 重置当前 bot 的 OpenClaw session`,
|
|
517
|
+
`⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
|
|
471
518
|
`🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
|
|
472
519
|
`🔓 /free [on|off] — 开关当前 bot 的 free 模式(不 @ 也可回复)`,
|
|
473
520
|
`🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
|
|
474
521
|
`🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
|
|
522
|
+
`🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
|
|
475
523
|
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
|
|
476
524
|
`👑 /chairman @Bot|off — 设置/查看/清除本群唯一 Chairman`,
|
|
477
525
|
`🌐 /locale zh|en — 设置/查看当前群语言`,
|
|
@@ -501,37 +549,54 @@ export class FeishuBot {
|
|
|
501
549
|
markCommandSynced();
|
|
502
550
|
return;
|
|
503
551
|
}
|
|
504
|
-
if (
|
|
552
|
+
if (commandName === "/status") {
|
|
505
553
|
await this.ensureSession(chatId);
|
|
506
554
|
await this.handleStatusCommand(chatId, chatType, messageId);
|
|
507
555
|
markCommandSynced();
|
|
508
556
|
return;
|
|
509
557
|
}
|
|
510
|
-
if (
|
|
558
|
+
if (commandName === "/compact") {
|
|
511
559
|
await this.ensureSession(chatId);
|
|
512
560
|
await this.handleCompactCommand(chatId, messageId);
|
|
513
561
|
markCommandSynced();
|
|
514
562
|
return;
|
|
515
563
|
}
|
|
516
|
-
if (
|
|
564
|
+
if (commandName === "/reset") {
|
|
517
565
|
await this.ensureSession(chatId);
|
|
518
566
|
await this.handleResetCommand(chatId, messageId);
|
|
519
567
|
markCommandSynced();
|
|
520
568
|
return;
|
|
521
569
|
}
|
|
522
|
-
if (
|
|
570
|
+
if (commandName === "/stop") {
|
|
571
|
+
await this.handleStopCommand(chatId, messageId);
|
|
572
|
+
markCommandSynced();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (rejectModelAll) {
|
|
576
|
+
await this.replyMessage(messageId, "❌ /model 是单 bot 设置,不能 @所有人。请明确 @ 一个 bot,例如:@Claude /model provider/model-id");
|
|
577
|
+
markCommandSynced();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (commandName === "/model") {
|
|
581
|
+
await this.handleModelCommand(chatId, messageId, cleanText.trim());
|
|
582
|
+
markCommandSynced();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (commandName === "/verbose") {
|
|
523
586
|
const isOn = this.store.getBotVerbose(this.config.name, chatId);
|
|
524
|
-
|
|
587
|
+
const nextVerbose = !isOn;
|
|
588
|
+
this.store.setBotVerbose(this.config.name, chatId, nextVerbose);
|
|
589
|
+
this.openclawClient.setVerboseTranscriptDelivery(this.getSessionKey(chatId), nextVerbose);
|
|
525
590
|
if (isOn) {
|
|
526
|
-
await this.replyMessage(messageId, `🔇 ${this.config.name} Verbose 已关闭\n只影响当前 Bot 在当前会话的 Tool call
|
|
591
|
+
await this.replyMessage(messageId, `🔇 ${this.config.name} Verbose 已关闭\n只影响当前 Bot 在当前会话的 Tool call 和中间文本显示`);
|
|
527
592
|
}
|
|
528
593
|
else {
|
|
529
|
-
await this.replyMessage(messageId, `🔊 ${this.config.name} Verbose 已开启\n只影响当前 Bot 在当前会话的 Tool call
|
|
594
|
+
await this.replyMessage(messageId, `🔊 ${this.config.name} Verbose 已开启\n只影响当前 Bot 在当前会话的 Tool call 和中间文本显示`);
|
|
530
595
|
}
|
|
531
596
|
markCommandSynced();
|
|
532
597
|
return;
|
|
533
598
|
}
|
|
534
|
-
if (
|
|
599
|
+
if (commandName === "/free") {
|
|
535
600
|
if (chatType === "p2p") {
|
|
536
601
|
await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
|
|
537
602
|
markCommandSynced();
|
|
@@ -559,7 +624,7 @@ export class FeishuBot {
|
|
|
559
624
|
markCommandSynced();
|
|
560
625
|
return;
|
|
561
626
|
}
|
|
562
|
-
if (
|
|
627
|
+
if (commandName === "/mute") {
|
|
563
628
|
if (chatType === "p2p") {
|
|
564
629
|
await this.replyMessage(messageId, "❌ Mute 模式只在群聊中可用");
|
|
565
630
|
markCommandSynced();
|
|
@@ -577,7 +642,7 @@ export class FeishuBot {
|
|
|
577
642
|
markCommandSynced();
|
|
578
643
|
return;
|
|
579
644
|
}
|
|
580
|
-
if (
|
|
645
|
+
if (commandName === "/mode") {
|
|
581
646
|
if (chatType === "p2p") {
|
|
582
647
|
await this.replyMessage(messageId, `🎛️ ${this.config.name} 当前模式:normal(私聊总是响应)`);
|
|
583
648
|
}
|
|
@@ -589,17 +654,17 @@ export class FeishuBot {
|
|
|
589
654
|
markCommandSynced();
|
|
590
655
|
return;
|
|
591
656
|
}
|
|
592
|
-
if (
|
|
657
|
+
if (commandName === "/discuss") {
|
|
593
658
|
await this.handleDiscussCommand(chatId, chatType, messageId, cleanText.trim());
|
|
594
659
|
markCommandSynced();
|
|
595
660
|
return;
|
|
596
661
|
}
|
|
597
|
-
if (
|
|
662
|
+
if (commandName === "/chairman") {
|
|
598
663
|
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim());
|
|
599
664
|
markCommandSynced();
|
|
600
665
|
return;
|
|
601
666
|
}
|
|
602
|
-
if (
|
|
667
|
+
if (commandName === "/locale") {
|
|
603
668
|
await this.handleLocaleCommand(chatId, chatType, messageId, cleanText.trim());
|
|
604
669
|
markCommandSynced();
|
|
605
670
|
return;
|
|
@@ -749,12 +814,45 @@ export class FeishuBot {
|
|
|
749
814
|
mergedTriggers.push(m);
|
|
750
815
|
}
|
|
751
816
|
}
|
|
817
|
+
// Bound the merged current message payload. When a bot is stuck, many
|
|
818
|
+
// human triggers can accumulate; sending all of them can exceed OpenClaw's
|
|
819
|
+
// single-message/payload limit. Keep the most recent messages and drop the
|
|
820
|
+
// older ones from this batch (marking them synced/cleared below so they do
|
|
821
|
+
// not clog the queue forever).
|
|
822
|
+
const originalMergedTriggers = mergedTriggers;
|
|
823
|
+
const droppedMergedTriggers = [];
|
|
824
|
+
if (!firstIsNative) {
|
|
825
|
+
const selected = [];
|
|
826
|
+
let bytes = 0;
|
|
827
|
+
for (let i = originalMergedTriggers.length - 1; i >= 0; i--) {
|
|
828
|
+
const m = originalMergedTriggers[i];
|
|
829
|
+
const addBytes = Buffer.byteLength((selected.length ? "\n" : "") + m.content, "utf8");
|
|
830
|
+
if (selected.length > 0 && (selected.length >= MAX_MERGED_TRIGGER_MESSAGES || bytes + addBytes > MAX_MERGED_TRIGGER_BYTES)) {
|
|
831
|
+
droppedMergedTriggers.unshift(m);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
// Always keep at least the newest trigger, even if it is individually
|
|
835
|
+
// too large; OpenClaw will return a real payload error instead of us
|
|
836
|
+
// silently dropping the current user request.
|
|
837
|
+
selected.unshift(m);
|
|
838
|
+
bytes += addBytes;
|
|
839
|
+
}
|
|
840
|
+
mergedTriggers = selected;
|
|
841
|
+
if (droppedMergedTriggers.length > 0) {
|
|
842
|
+
const summaries = droppedMergedTriggers
|
|
843
|
+
.map((m) => `#${m.id || "?"}:${m.content.replace(/\s+/g, " ").trim().slice(0, 120)}`)
|
|
844
|
+
.join(" | ");
|
|
845
|
+
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
|
+
}
|
|
847
|
+
}
|
|
752
848
|
const isNativeCommandTrigger = firstIsNative;
|
|
753
849
|
const lastHuman = mergedTriggers[mergedTriggers.length - 1];
|
|
754
850
|
const triggerId = lastHuman.id || 0;
|
|
755
851
|
const mergedContent = mergedTriggers.map((m) => m.content).join("\n");
|
|
756
852
|
const mergedTriggerIds = mergedTriggers.map((m) => m.id || 0).filter(Boolean);
|
|
853
|
+
const droppedTriggerIds = droppedMergedTriggers.map((m) => m.id || 0).filter(Boolean);
|
|
757
854
|
const mergedTriggerIdSet = new Set(mergedTriggerIds);
|
|
855
|
+
const completedTriggerIdSet = new Set([...droppedTriggerIds, ...mergedTriggerIds]);
|
|
758
856
|
// Catch-up is only injected in group chats. p2p must never get it.
|
|
759
857
|
// Use "not p2p" rather than "=== group": chatInfo is always cached before
|
|
760
858
|
// a message reaches the queue, but if chat_type were ever missing we'd
|
|
@@ -763,10 +861,16 @@ export class FeishuBot {
|
|
|
763
861
|
const isGroup = chatType !== "p2p";
|
|
764
862
|
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
765
863
|
console.warn(`[${this.config.name}] Duplicate trigger skipped for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
766
|
-
for (const id of mergedTriggerIds)
|
|
864
|
+
for (const id of [...droppedTriggerIds, ...mergedTriggerIds])
|
|
767
865
|
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
768
866
|
continue;
|
|
769
867
|
}
|
|
868
|
+
if (droppedTriggerIds.length > 0) {
|
|
869
|
+
const dropBatchId = `${this.config.name}:${chatId}:dropped:${Date.now()}`;
|
|
870
|
+
this.store.markMessagesSynced(this.config.name, chatId, droppedTriggerIds, dropBatchId);
|
|
871
|
+
for (const id of droppedTriggerIds)
|
|
872
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
873
|
+
}
|
|
770
874
|
// Catch-up context: messages this bot has not seen yet in this GROUP chat.
|
|
771
875
|
// Includes both human and other-bot messages (so mention-only replies can
|
|
772
876
|
// see the human message they refer to). Excludes:
|
|
@@ -777,16 +881,25 @@ export class FeishuBot {
|
|
|
777
881
|
// p2p never injects catch-up; native command runs never inject catch-up.
|
|
778
882
|
let contextMsgs = [];
|
|
779
883
|
if (isGroup && !isNativeCommandTrigger) {
|
|
780
|
-
|
|
884
|
+
// Use a processing-time snapshot upper bound rather than the current
|
|
885
|
+
// trigger id. In multi-bot groups, another bot's visible reply may be
|
|
886
|
+
// stored locally after the human trigger row even though it is already
|
|
887
|
+
// relevant context for this run. The snapshot captures all messages the
|
|
888
|
+
// bridge knows about at dispatch time, while the filters below still
|
|
889
|
+
// exclude current/pending triggers and this bot's own messages.
|
|
890
|
+
const contextUpperBoundId = Math.max(triggerId, this.store.getLatestMessageId(chatId));
|
|
891
|
+
const catchupMessages = this.store.getUnsyncedMessagesForBot(this.config.name, chatId, contextUpperBoundId);
|
|
781
892
|
contextMsgs = catchupMessages.filter((m) => m.senderName !== this.config.name
|
|
893
|
+
&& !(m.senderType === "human" && m.id && m.id > triggerId)
|
|
782
894
|
&& !(m.id && mergedTriggerIdSet.has(m.id))
|
|
783
895
|
&& !(m.id && pendingTriggerIds.has(m.id))
|
|
784
|
-
&& m.triggerKind
|
|
896
|
+
&& (m.triggerKind === "normal" || !m.triggerKind)
|
|
897
|
+
&& !this.isLegacyBridgeControlMessage(m.content));
|
|
785
898
|
}
|
|
786
899
|
const processedMessages = [...contextMsgs, ...mergedTriggers].filter((m) => m.id);
|
|
787
900
|
const queueStartedAt = Date.now();
|
|
788
901
|
const sessionKey = await this.ensureSession(chatId);
|
|
789
|
-
console.log(`[${this.config.name}] Sending ${mergedTriggers.length} trigger(s) as 1 run to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length})`);
|
|
902
|
+
console.log(`[${this.config.name}] Sending ${mergedTriggers.length} trigger(s) as 1 run to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length}, dropped=${droppedTriggerIds.length})`);
|
|
790
903
|
// Update reactions: queued messages → sent (GET/了解)
|
|
791
904
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
792
905
|
for (const ack of pendingAcks) {
|
|
@@ -796,9 +909,25 @@ export class FeishuBot {
|
|
|
796
909
|
ack.emoji = "Get";
|
|
797
910
|
}
|
|
798
911
|
}
|
|
912
|
+
let runAcceptedByOpenClaw = false;
|
|
799
913
|
try {
|
|
800
914
|
const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
|
|
801
915
|
let reply;
|
|
916
|
+
let contextMarkedSubmitted = false;
|
|
917
|
+
let currentMarkedSubmitted = false;
|
|
918
|
+
const markSubmittedBatch = (suffix) => {
|
|
919
|
+
const contextIds = contextMsgs.map((m) => m.id || 0).filter(Boolean);
|
|
920
|
+
if (!contextMarkedSubmitted && contextIds.length > 0) {
|
|
921
|
+
this.store.markMessagesSynced(this.config.name, chatId, contextIds, `${this.config.name}:${chatId}:${triggerId}:context-${suffix}`);
|
|
922
|
+
contextMarkedSubmitted = true;
|
|
923
|
+
}
|
|
924
|
+
if (!currentMarkedSubmitted && mergedTriggerIds.length > 0) {
|
|
925
|
+
this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, `${this.config.name}:${chatId}:${triggerId}:current-${suffix}`);
|
|
926
|
+
for (const id of mergedTriggerIds)
|
|
927
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
928
|
+
currentMarkedSubmitted = true;
|
|
929
|
+
}
|
|
930
|
+
};
|
|
802
931
|
try {
|
|
803
932
|
reply = await this.openclawClient.chatSendWithContext({
|
|
804
933
|
sessionKey,
|
|
@@ -811,6 +940,19 @@ export class FeishuBot {
|
|
|
811
940
|
timeoutMs: 1_800_000,
|
|
812
941
|
includeContext: !isNativeCommandTrigger,
|
|
813
942
|
includeBridgeAttachmentHint: !isNativeCommandTrigger,
|
|
943
|
+
onSendAttempt: () => {
|
|
944
|
+
// At-most-once: as soon as we begin issuing chat.send, do not let
|
|
945
|
+
// this batch auto-replay. If the RPC response is lost after
|
|
946
|
+
// OpenClaw accepts it, retrying would duplicate the same message.
|
|
947
|
+
markSubmittedBatch("send-attempt");
|
|
948
|
+
},
|
|
949
|
+
onSubmitted: (runId) => {
|
|
950
|
+
runAcceptedByOpenClaw = true;
|
|
951
|
+
// Refine state for logs/idempotent sync rows when OpenClaw returns
|
|
952
|
+
// a runId. markSubmittedBatch is idempotent due to local flags and
|
|
953
|
+
// INSERT OR IGNORE in message_sync.
|
|
954
|
+
markSubmittedBatch(`submitted:${runId}`);
|
|
955
|
+
},
|
|
814
956
|
});
|
|
815
957
|
}
|
|
816
958
|
finally {
|
|
@@ -829,18 +971,39 @@ export class FeishuBot {
|
|
|
829
971
|
if (trulyEmptyReply) {
|
|
830
972
|
// Empty final text is not the same as an explicit NO_REPLY. It often
|
|
831
973
|
// means the upstream session/run was interrupted, raced, or collected
|
|
832
|
-
// incorrectly.
|
|
833
|
-
//
|
|
834
|
-
|
|
974
|
+
// incorrectly. But if chatSendWithContext returned, the content was
|
|
975
|
+
// submitted far enough that auto-replaying risks duplicate delivery.
|
|
976
|
+
// The user can resend a new message if they want to retry.
|
|
977
|
+
markSubmittedBatch("empty-returned");
|
|
978
|
+
console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
|
|
979
|
+
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
980
|
+
const remainingAcks = [];
|
|
981
|
+
for (const ack of pendingAcks) {
|
|
982
|
+
if (completedTriggerIdSet.has(ack.rowId)) {
|
|
983
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
984
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
remainingAcks.push(ack);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
835
991
|
break;
|
|
836
992
|
}
|
|
837
|
-
// Mark
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
993
|
+
// Mark current triggers only after the run produced a non-empty result.
|
|
994
|
+
// Catch-up context is marked as soon as chat.send is accepted (above),
|
|
995
|
+
// because it has already been delivered even if the run later times out
|
|
996
|
+
// or is stopped. Messages that arrive while the agent is busy must remain
|
|
997
|
+
// pending for the next loop.
|
|
841
998
|
const maxId = Math.max(...processedMessages.map((m) => m.id || 0));
|
|
842
999
|
const syncBatchId = `${this.config.name}:${chatId}:${triggerId}:${Date.now()}`;
|
|
843
|
-
|
|
1000
|
+
if (!currentMarkedSubmitted)
|
|
1001
|
+
this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, syncBatchId);
|
|
1002
|
+
if (!contextMarkedSubmitted && contextMsgs.length > 0) {
|
|
1003
|
+
// Defensive fallback for mocked clients or older implementations that
|
|
1004
|
+
// do not call onSubmitted: avoid duplicate context on successful runs.
|
|
1005
|
+
this.store.markMessagesSynced(this.config.name, chatId, contextMsgs.map((m) => m.id || 0), `${syncBatchId}:context-success`);
|
|
1006
|
+
}
|
|
844
1007
|
this.store.markSynced(this.config.name, chatId, maxId);
|
|
845
1008
|
for (const id of mergedTriggerIds)
|
|
846
1009
|
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
@@ -909,7 +1072,7 @@ export class FeishuBot {
|
|
|
909
1072
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
910
1073
|
const remainingAcks = [];
|
|
911
1074
|
for (const ack of pendingAcks) {
|
|
912
|
-
if (
|
|
1075
|
+
if (completedTriggerIdSet.has(ack.rowId)) {
|
|
913
1076
|
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
914
1077
|
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
915
1078
|
}
|
|
@@ -931,15 +1094,19 @@ export class FeishuBot {
|
|
|
931
1094
|
.catch(() => { });
|
|
932
1095
|
}
|
|
933
1096
|
if (triggerId) {
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
1097
|
+
// At-most-once: if anything after send attempt failed, this batch has
|
|
1098
|
+
// already been marked/cleared by onSendAttempt. If failure happened
|
|
1099
|
+
// before the callback (e.g. before acquiring the slot), clear only this
|
|
1100
|
+
// processing batch to avoid startup/drain loops; the user can resend.
|
|
1101
|
+
for (const id of mergedTriggerIds)
|
|
1102
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
1103
|
+
this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, `${this.config.name}:${chatId}:${triggerId}:${runAcceptedByOpenClaw ? "failed-submitted" : "failed-attempt"}`);
|
|
1104
|
+
this.store.markSynced(this.config.name, chatId, Math.max(...mergedTriggerIds));
|
|
938
1105
|
}
|
|
939
1106
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
940
1107
|
const remainingAcks = [];
|
|
941
1108
|
for (const ack of pendingAcks) {
|
|
942
|
-
if (ack.rowId
|
|
1109
|
+
if (completedTriggerIdSet.has(ack.rowId)) {
|
|
943
1110
|
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
944
1111
|
await this.addReaction(ack.messageId, "FAIL").catch(() => { });
|
|
945
1112
|
}
|
|
@@ -1111,6 +1278,34 @@ export class FeishuBot {
|
|
|
1111
1278
|
}
|
|
1112
1279
|
return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
|
|
1113
1280
|
}
|
|
1281
|
+
isBridgeControlReply(text) {
|
|
1282
|
+
const t = text.trim();
|
|
1283
|
+
if (!t)
|
|
1284
|
+
return false;
|
|
1285
|
+
return /^✅ Session reset\.?$/i.test(t)
|
|
1286
|
+
|| /^✅ Session 已重置/m.test(t)
|
|
1287
|
+
|| /^✅ Session 已压缩/m.test(t)
|
|
1288
|
+
|| /^❌ (重置|压缩|stop|模型切换|无法持久化|用法|Free 模式|Mute 模式|一个群只能设置一个 Chairman)/m.test(t)
|
|
1289
|
+
|| /^⏹️ .*已停止/m.test(t)
|
|
1290
|
+
|| /^📊 /m.test(t)
|
|
1291
|
+
|| /^🤖 .*当前模型:/m.test(t)
|
|
1292
|
+
|| /^Models \(/m.test(t)
|
|
1293
|
+
|| /^(Switch|More|All): \/models?/m.test(t)
|
|
1294
|
+
|| /^🎛️ .*当前模式:/m.test(t)
|
|
1295
|
+
|| /^🌐 (当前语言|Current locale|Locale set|语言已设置)/m.test(t)
|
|
1296
|
+
|| /^👑 /m.test(t)
|
|
1297
|
+
|| /^💬 Discuss /m.test(t);
|
|
1298
|
+
}
|
|
1299
|
+
// Backward-compatibility for rows stored before trigger_kind distinguished
|
|
1300
|
+
// bridge_command / bridge_control_reply. Keep this intentionally narrow to
|
|
1301
|
+
// avoid hiding ordinary AI replies that happen to start with ✅/❌/🤖.
|
|
1302
|
+
isLegacyBridgeControlMessage(text) {
|
|
1303
|
+
const t = text.trim();
|
|
1304
|
+
if (!t)
|
|
1305
|
+
return false;
|
|
1306
|
+
return /^\/(help|status|compact|reset|stop|verbose|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
|
|
1307
|
+
|| this.isBridgeControlReply(t);
|
|
1308
|
+
}
|
|
1114
1309
|
isRuntimeFailureText(text) {
|
|
1115
1310
|
return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
|
|
1116
1311
|
}
|
|
@@ -1178,10 +1373,22 @@ export class FeishuBot {
|
|
|
1178
1373
|
const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
|
|
1179
1374
|
const contentHash = this.stableHash(normalizedPayload);
|
|
1180
1375
|
const finalDeliveryKey = deliveryKey || sourceId;
|
|
1181
|
-
if (
|
|
1182
|
-
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000))
|
|
1376
|
+
if (sourceType === "verbose_transcript") {
|
|
1377
|
+
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
|
|
1183
1378
|
return;
|
|
1184
|
-
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8))
|
|
1379
|
+
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
else if (!deliveryKey) {
|
|
1383
|
+
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
|
|
1384
|
+
return;
|
|
1385
|
+
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
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"]))
|
|
1185
1392
|
return;
|
|
1186
1393
|
}
|
|
1187
1394
|
const deliveryId = this.store.enqueueDelivery({
|
|
@@ -1213,7 +1420,7 @@ export class FeishuBot {
|
|
|
1213
1420
|
const attachments = JSON.parse(item.attachmentsJson || "[]");
|
|
1214
1421
|
if (item.content.trim()) {
|
|
1215
1422
|
const replyTarget = item.replyToMessageId || replyToMessageId;
|
|
1216
|
-
const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
|
|
1423
|
+
const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "verbose_transcript" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
|
|
1217
1424
|
if (shouldReplyToSource) {
|
|
1218
1425
|
try {
|
|
1219
1426
|
await this.replyMessage(replyTarget, item.content);
|
|
@@ -1731,6 +1938,39 @@ export class FeishuBot {
|
|
|
1731
1938
|
lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
|
|
1732
1939
|
await this.replyMessage(messageId, lines.join("\n"));
|
|
1733
1940
|
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Handle /model command: show or switch this bot's bound model.
|
|
1943
|
+
*/
|
|
1944
|
+
async handleModelCommand(chatId, messageId, text) {
|
|
1945
|
+
const parts = text.split(/\s+/).filter(Boolean);
|
|
1946
|
+
const nextModel = parts[1];
|
|
1947
|
+
if (!nextModel) {
|
|
1948
|
+
await this.replyMessage(messageId, `🤖 ${this.config.name} 当前模型:${this.config.model}\n用法:/model <provider/model-id>`);
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (nextModel === this.config.model) {
|
|
1952
|
+
await this.replyMessage(messageId, `🤖 ${this.config.name} 已经在使用:${this.config.model}`);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (!this.configPath) {
|
|
1956
|
+
await this.replyMessage(messageId, "❌ 无法持久化模型设置:运行时没有 configPath");
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const previous = this.config.model;
|
|
1960
|
+
const sessionKey = await this.ensureSession(chatId);
|
|
1961
|
+
try {
|
|
1962
|
+
// Validate/apply to the current session first. If OpenClaw rejects the
|
|
1963
|
+
// model, do not persist a bad config.
|
|
1964
|
+
await this.openclawClient.patchSession({ key: sessionKey, model: nextModel });
|
|
1965
|
+
persistBotModel(this.configPath, this.config.name, nextModel);
|
|
1966
|
+
this.config.model = nextModel;
|
|
1967
|
+
await this.replyMessage(messageId, `✅ ${this.config.name} 模型已切换并持久化\n之前:${previous}\n现在:${nextModel}`);
|
|
1968
|
+
}
|
|
1969
|
+
catch (err) {
|
|
1970
|
+
await this.openclawClient.patchSession({ key: sessionKey, model: previous }).catch(() => { });
|
|
1971
|
+
await this.replyMessage(messageId, `❌ 模型切换失败,配置未修改\n目标:${nextModel}\n原因:${err.message}`);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1734
1974
|
/**
|
|
1735
1975
|
* Handle /status command: show current session info.
|
|
1736
1976
|
*/
|
|
@@ -1820,6 +2060,36 @@ export class FeishuBot {
|
|
|
1820
2060
|
await this.replyMessage(messageId, `❌ 重置失败: ${err.message}`);
|
|
1821
2061
|
}
|
|
1822
2062
|
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Force-stop a stuck run for this bot in this chat. Aborts all active OpenClaw
|
|
2065
|
+
* runs for the session, clears the busy lock and every pending trigger, and
|
|
2066
|
+
* resets stuck reactions so new messages are processed normally again.
|
|
2067
|
+
*/
|
|
2068
|
+
async handleStopCommand(chatId, messageId) {
|
|
2069
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
2070
|
+
try {
|
|
2071
|
+
// Abort all active runs for this session (chat.abort with no runId).
|
|
2072
|
+
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
2073
|
+
// Force-unlock the busy gate so queued messages can be processed.
|
|
2074
|
+
this.busyChats.set(chatId, 0);
|
|
2075
|
+
// Drop every pending trigger for this bot/chat so the stuck run is not retried.
|
|
2076
|
+
const cleared = this.store.clearAllPendingTriggers(this.config.name, chatId);
|
|
2077
|
+
// Clear stuck Typing/Get reactions on queued messages.
|
|
2078
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
2079
|
+
for (const ack of acks) {
|
|
2080
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
2081
|
+
}
|
|
2082
|
+
this.pendingAckMessages.set(chatId, []);
|
|
2083
|
+
const locale = this.chatLocale(chatId);
|
|
2084
|
+
await this.replyMessage(messageId, locale === "en"
|
|
2085
|
+
? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now.`
|
|
2086
|
+
: `⏹️ 已停止。已中止当前 run、解锁队列,并清掉 ${cleared} 条待处理消息。现在可以重新发消息了。`);
|
|
2087
|
+
console.log(`[${this.config.name}] /stop force-cleared ${chatId.slice(-8)}: aborted run, cleared ${cleared} pending`);
|
|
2088
|
+
}
|
|
2089
|
+
catch (err) {
|
|
2090
|
+
await this.replyMessage(messageId, `❌ stop 失败: ${err.message}`);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
1823
2093
|
/**
|
|
1824
2094
|
* Fetch chat info (name, type, members) via Feishu API and cache in SQLite.
|
|
1825
2095
|
* Called once per chat on first message.
|
package/dist/index.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function startApp(configPath) {
|
|
|
42
42
|
// Two-phase startup: register all bots first, then start WS connections
|
|
43
43
|
const bots = [];
|
|
44
44
|
for (const botConfig of config.bots) {
|
|
45
|
-
const bot = new FeishuBot(botConfig, openclawClient, store, config.adminOpenId);
|
|
45
|
+
const bot = new FeishuBot(botConfig, openclawClient, store, config.adminOpenId, resolvedConfigPath);
|
|
46
46
|
bots.push(bot);
|
|
47
47
|
bot.register(); // Phase 1: register to allBots map
|
|
48
48
|
}
|