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.
@@ -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.
@@ -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
- const rawText = content.text || "";
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
- return;
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 (isChairmanCommand || isLocaleCommand) {
481
- // Group-level settings; one coordinator handles them.
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.startIfAbsent({
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 && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
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(/[.*+?^${}()|[\]\\]/g, "\\$&");
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.isFinite(n)) {
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] || "status").toLowerCase();
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 botNames = this.mentionedBotNames(mentions);
1918
- if (botNames.length === 0) {
1919
- const current = this.store.getChairmanBot(chatId);
1920
- await this.replyMessage(messageId, current
1921
- ? `👑 当前 Chairman:${current}\n作用:普通消息无人 free 时由 TA 回答;Discuss 模式下负责主持、调停和最终总结。`
1922
- : "👑 当前没有 Chairman\n用法:/chairman @某个Bot");
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 (botNames.length > 1) {
2147
+ if (resolvedBotNames.length > 1) {
1926
2148
  await this.replyMessage(messageId, "❌ 一个群只能设置一个 Chairman。请只 @ 一个 bot。");
1927
2149
  return;
1928
2150
  }
1929
- const next = botNames[0];
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(/\\&amp;#/g, "&#")
2510
+ .replace(/&amp;#/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(/&quot;/g, '"')
2520
+ .replace(/&apos;/g, "'")
2521
+ .replace(/&lt;/g, "<")
2522
+ .replace(/&gt;/g, ">")
2523
+ .replace(/&amp;/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.
@@ -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;
@@ -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'
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {