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.
@@ -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
- await this.openclawClient.subscribeSession(sessionKey, async (text) => {
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
- console.log(`[${this.config.name}] Proactive message for ${chatId.slice(-8)}`);
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, "assistant_visible", this.deliverySourceId("proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined);
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 ? "native_command" : "normal",
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 isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode|discuss|chairman|locale)/.test(cleanText.trim());
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 = cleanText.trim().startsWith("/discuss");
431
- const isChairmanCommand = cleanText.trim().startsWith("/chairman");
432
- const isLocaleCommand = cleanText.trim().startsWith("/locale");
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 (isChairmanCommand || isLocaleCommand) {
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 (cleanText.trim().startsWith("/help")) {
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 (cleanText.trim().startsWith("/status")) {
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 (cleanText.trim().startsWith("/compact")) {
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 (cleanText.trim().startsWith("/reset")) {
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 (cleanText.trim().startsWith("/verbose")) {
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
- this.store.setBotVerbose(this.config.name, chatId, !isOn);
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 (cleanText.trim().startsWith("/free")) {
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 (cleanText.trim().startsWith("/mute")) {
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 (cleanText.trim().startsWith("/mode")) {
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 (cleanText.trim().startsWith("/discuss")) {
657
+ if (commandName === "/discuss") {
593
658
  await this.handleDiscussCommand(chatId, chatType, messageId, cleanText.trim());
594
659
  markCommandSynced();
595
660
  return;
596
661
  }
597
- if (cleanText.trim().startsWith("/chairman")) {
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 (cleanText.trim().startsWith("/locale")) {
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
- const catchupMessages = this.store.getUnsyncedMessagesForBot(this.config.name, chatId, triggerId);
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 !== "native_command");
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. Do not mark sync, clear pending triggers, or mark DONE.
833
- // Leave the trigger pending for a later retry/new message.
834
- console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; keeping pending for retry`);
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 only the snapshot processed in this run as synced. Messages that
838
- // arrive while the agent is busy may already be in pendingAckMessages,
839
- // but they are not part of allUnsynced/humanUnsynced for this run and
840
- // must remain pending for the next loop.
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
- this.store.markMessagesSynced(this.config.name, chatId, processedMessages.map((m) => m.id || 0), syncBatchId);
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 (mergedTriggerIdSet.has(ack.rowId)) {
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
- // The run failed after OpenClaw/provider rejected it. Notify the user
935
- // and clear only this failed trigger so later messages can continue.
936
- this.store.clearPendingTrigger(this.config.name, chatId, triggerId);
937
- this.store.markSynced(this.config.name, chatId, triggerId);
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 === triggerId) {
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 (!deliveryKey) {
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
  }