openclaw-lark-multi-agent 0.1.13 → 0.1.15

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.
@@ -20,7 +20,10 @@ type DiscussionSession = {
20
20
  };
21
21
  export type DiscussionParticipant = {
22
22
  name: string;
23
- runDiscussionTurn(chatId: string, prompt: string): Promise<ReplyResult>;
23
+ runDiscussionTurn(chatId: string, prompt: string, meta?: {
24
+ round: number;
25
+ maxRounds: number;
26
+ }): Promise<ReplyResult>;
24
27
  };
25
28
  export declare class DiscussionManager {
26
29
  private sessions;
@@ -70,7 +70,7 @@ export class DiscussionManager {
70
70
  const prompt = this.buildPrompt(session);
71
71
  const results = await Promise.allSettled(participants.map(async (participant) => {
72
72
  try {
73
- return await participant.runDiscussionTurn(session.chatId, prompt);
73
+ return await participant.runDiscussionTurn(session.chatId, prompt, { round: session.currentRound, maxRounds: session.maxRounds });
74
74
  }
75
75
  catch (err) {
76
76
  return {
@@ -92,10 +92,27 @@ export class DiscussionManager {
92
92
  replies[value.botName] = value.error ? `[ERROR] ${value.error}` : value.text.trim();
93
93
  }
94
94
  current.completedRounds.push({ round: current.currentRound, replies });
95
+ const noReplyNames = participants
96
+ .map((participant) => participant.name)
97
+ .filter((name) => {
98
+ const text = (replies[name] || "").trim();
99
+ return !text || text.toUpperCase() === "NO_REPLY";
100
+ });
101
+ const errorNames = participants
102
+ .map((participant) => participant.name)
103
+ .filter((name) => (replies[name] || "").trim().startsWith("[ERROR]"));
95
104
  const allNoReply = participants.every((participant) => {
96
105
  const text = (replies[participant.name] || "").trim();
97
106
  return !text || text.toUpperCase() === "NO_REPLY" || text.startsWith("[ERROR]");
98
107
  });
108
+ if (sendSystemMessage && !allNoReply && (noReplyNames.length > 0 || errorNames.length > 0)) {
109
+ const parts = [];
110
+ if (noReplyNames.length > 0)
111
+ parts.push(`${noReplyNames.join("、")} 无新增回复`);
112
+ if (errorNames.length > 0)
113
+ parts.push(`${errorNames.join("、")} 出错`);
114
+ await sendSystemMessage(`💬 第 ${current.currentRound}/${current.maxRounds} 轮:${parts.join(";")}`).catch(() => { });
115
+ }
99
116
  if (allNoReply) {
100
117
  current.status = "completed";
101
118
  this.sessions.delete(current.chatId);
@@ -29,6 +29,8 @@ export declare class FeishuBot {
29
29
  private queueRuns;
30
30
  /** Per-chat serial send queue to guarantee message order */
31
31
  private sendQueue;
32
+ /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
33
+ private delayedFailureTimers;
32
34
  private adminOpenId;
33
35
  private static allBots;
34
36
  constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string);
@@ -77,6 +79,14 @@ export declare class FeishuBot {
77
79
  private stripLeadingCommandMentions;
78
80
  private buildMarkdownCard;
79
81
  private formatUserVisibleError;
82
+ private isRuntimeFailureText;
83
+ private cancelDelayedFailure;
84
+ private scheduleDelayedFailure;
85
+ private deliverySessionKey;
86
+ private stableHash;
87
+ private deliverySourceId;
88
+ private enqueueAndDispatchDelivery;
89
+ private dispatchPendingDeliveries;
80
90
  private isDiscussionCoordinator;
81
91
  private getDiscussionParticipants;
82
92
  private runDiscussionTurn;
@@ -34,6 +34,8 @@ export class FeishuBot {
34
34
  queueRuns = new Map();
35
35
  /** Per-chat serial send queue to guarantee message order */
36
36
  sendQueue = new Map();
37
+ /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
38
+ delayedFailureTimers = new Map();
37
39
  adminOpenId;
38
40
  static allBots = new Map();
39
41
  constructor(config, openclawClient, store, adminOpenId) {
@@ -114,11 +116,9 @@ export class FeishuBot {
114
116
  try {
115
117
  console.log(`[${this.config.name}] Proactive message for ${chatId.slice(-8)}`);
116
118
  const parsed = this.extractBridgeAttachments(text);
117
- if (parsed.text.trim())
118
- await this.sendMessage(chatId, parsed.text);
119
- for (const attachment of parsed.attachments) {
120
- await this.sendBridgeAttachment(chatId, attachment);
121
- }
119
+ if (parsed.text.trim() || parsed.attachments.length > 0)
120
+ this.cancelDelayedFailure(chatId);
121
+ 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);
122
122
  }
123
123
  catch (err) {
124
124
  console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
@@ -503,6 +503,8 @@ export class FeishuBot {
503
503
  }
504
504
  // --- Discuss mode: group-level multi-bot round scheduler. It takes over
505
505
  // plain human messages so normal Free mode does not duplicate Round 1.
506
+ // Targeted mentions must fall through to normal routing so @GPT still
507
+ // works while discuss mode is enabled.
506
508
  if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
507
509
  const mentions = message.mentions || [];
508
510
  const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
@@ -661,6 +663,7 @@ export class FeishuBot {
661
663
  this.store.markSynced(this.config.name, chatId, maxId);
662
664
  this.store.clearPendingTriggers(this.config.name, chatId, maxId);
663
665
  const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
666
+ const isRuntimeFailure = shouldReply && this.isRuntimeFailureText(trimmedReply);
664
667
  // Record bot reply only if it is user-visible/context-worthy.
665
668
  if (shouldReply || hasAttachments) {
666
669
  const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
@@ -692,32 +695,19 @@ export class FeishuBot {
692
695
  }
693
696
  // Reply to the last human message on Feishu (ordered after tool msgs)
694
697
  // Skip empty replies and explicit NO_REPLY responses
695
- if ((shouldReply || hasAttachments) && lastHuman.messageId) {
698
+ if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
696
699
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
697
700
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
698
701
  }
699
702
  else {
700
- await this.sendOrdered(chatId, async () => {
701
- if (shouldReply) {
702
- try {
703
- await this.replyMessage(lastHuman.messageId, visibleReply);
704
- }
705
- catch (err) {
706
- // Reply can fail for historical messages sent before this bot joined the chat
707
- // (Feishu 230002: Bot/User can NOT be out of the chat). Fall back to a normal
708
- // chat message so queue processing still completes.
709
- console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
710
- await this.sendMessage(chatId, visibleReply);
711
- }
712
- }
713
- for (const attachment of parsedReply.attachments) {
714
- await this.sendBridgeAttachment(chatId, attachment);
715
- }
716
- if (triggerId)
717
- this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
718
- });
703
+ await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
704
+ if (triggerId)
705
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
719
706
  }
720
707
  }
708
+ if (isRuntimeFailure && lastHuman.messageId) {
709
+ this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
710
+ }
721
711
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
722
712
  // Replace ack reactions with DONE only for trigger messages actually
723
713
  // processed in this run. Queued messages that arrived mid-run keep their
@@ -739,16 +729,12 @@ export class FeishuBot {
739
729
  console.error(`[${this.config.name}] processQueue error:`, err);
740
730
  const errorText = this.formatUserVisibleError(err);
741
731
  if (lastHuman.messageId) {
742
- await this.sendOrdered(chatId, async () => {
743
- try {
744
- await this.replyMessage(lastHuman.messageId, errorText);
745
- }
746
- catch {
747
- await this.sendMessage(chatId, errorText);
748
- }
732
+ await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
733
+ .then(() => {
749
734
  if (triggerId)
750
735
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
751
- }).catch(() => { });
736
+ })
737
+ .catch(() => { });
752
738
  }
753
739
  if (triggerId) {
754
740
  // The run failed after OpenClaw/provider rejected it. Notify the user
@@ -899,6 +885,110 @@ export class FeishuBot {
899
885
  }
900
886
  return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
901
887
  }
888
+ isRuntimeFailureText(text) {
889
+ return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
890
+ }
891
+ cancelDelayedFailure(chatId) {
892
+ const timer = this.delayedFailureTimers.get(chatId);
893
+ if (timer)
894
+ clearTimeout(timer);
895
+ this.delayedFailureTimers.delete(chatId);
896
+ }
897
+ scheduleDelayedFailure(chatId, replyToMessageId, text, triggerId) {
898
+ this.cancelDelayedFailure(chatId);
899
+ const timer = setTimeout(() => {
900
+ this.delayedFailureTimers.delete(chatId);
901
+ void this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`)
902
+ .then(() => {
903
+ if (triggerId)
904
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, replyToMessageId);
905
+ })
906
+ .catch((err) => {
907
+ console.warn(`[${this.config.name}] delayed failure delivery failed:`, err.message);
908
+ });
909
+ }, 60_000);
910
+ this.delayedFailureTimers.set(chatId, timer);
911
+ }
912
+ deliverySessionKey(chatId) {
913
+ return this.getSessionKey(chatId);
914
+ }
915
+ stableHash(text) {
916
+ let hash = 2166136261;
917
+ for (let i = 0; i < text.length; i++) {
918
+ hash ^= text.charCodeAt(i);
919
+ hash = Math.imul(hash, 16777619);
920
+ }
921
+ return (hash >>> 0).toString(36);
922
+ }
923
+ deliverySourceId(kind, text) {
924
+ return `${kind}:${this.stableHash(text)}`;
925
+ }
926
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
927
+ if (!text.trim() && attachments.length === 0)
928
+ return;
929
+ const attachmentsJson = JSON.stringify(attachments);
930
+ const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
931
+ const contentHash = this.stableHash(normalizedPayload);
932
+ const finalDeliveryKey = deliveryKey || sourceId;
933
+ if (!deliveryKey) {
934
+ if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000))
935
+ return;
936
+ if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8))
937
+ return;
938
+ }
939
+ const deliveryId = this.store.enqueueDelivery({
940
+ sessionKey: this.deliverySessionKey(chatId),
941
+ chatId,
942
+ botName: this.config.name,
943
+ sourceType,
944
+ sourceId,
945
+ deliveryKey: finalDeliveryKey,
946
+ contentHash,
947
+ content: text,
948
+ attachmentsJson,
949
+ replyToMessageId: replyToMessageId || "",
950
+ });
951
+ if (deliveryId === null)
952
+ return;
953
+ await this.dispatchPendingDeliveries(chatId, replyToMessageId);
954
+ }
955
+ async dispatchPendingDeliveries(chatId, replyToMessageId) {
956
+ const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
957
+ for (const item of pending) {
958
+ await this.sendOrdered(chatId, async () => {
959
+ try {
960
+ if (!item.id || !this.store.claimDelivery(item.id))
961
+ return;
962
+ const attachments = JSON.parse(item.attachmentsJson || "[]");
963
+ if (item.content.trim()) {
964
+ const replyTarget = item.replyToMessageId || replyToMessageId;
965
+ const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
966
+ if (shouldReplyToSource) {
967
+ try {
968
+ await this.replyMessage(replyTarget, item.content);
969
+ }
970
+ catch (err) {
971
+ console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
972
+ await this.sendMessage(chatId, item.content);
973
+ }
974
+ }
975
+ else {
976
+ await this.sendMessage(chatId, item.content);
977
+ }
978
+ }
979
+ for (const attachment of attachments)
980
+ await this.sendBridgeAttachment(chatId, attachment);
981
+ if (item.id)
982
+ this.store.markDeliveryDelivered(item.id);
983
+ }
984
+ catch (err) {
985
+ if (item.id)
986
+ this.store.markDeliveryFailed(item.id);
987
+ throw err;
988
+ }
989
+ });
990
+ }
991
+ }
902
992
  isDiscussionCoordinator() {
903
993
  const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
904
994
  if (bots.length === 0)
@@ -910,24 +1000,41 @@ export class FeishuBot {
910
1000
  .filter((bot) => bot.store === this.store && bot.store.getBotMode(bot.config.name, chatId) === "free")
911
1001
  .map((bot) => ({
912
1002
  name: bot.config.name,
913
- runDiscussionTurn: async (_chatId, prompt) => bot.runDiscussionTurn(chatId, prompt),
1003
+ runDiscussionTurn: async (_chatId, prompt, meta) => bot.runDiscussionTurn(chatId, prompt, meta),
914
1004
  }));
915
1005
  }
916
- async runDiscussionTurn(chatId, prompt) {
1006
+ async runDiscussionTurn(chatId, prompt, meta) {
917
1007
  const sessionKey = await this.ensureSession(chatId);
918
- const reply = await this.openclawClient.chatSendWithContext({
919
- sessionKey,
920
- unsyncedMessages: [],
921
- currentMessage: prompt,
922
- currentSenderName: "Discussion Scheduler",
923
- deliver: false,
924
- timeoutMs: 1_800_000,
925
- });
1008
+ const releaseProactiveMute = this.openclawClient.muteProactiveDelivery(sessionKey);
1009
+ let reply;
1010
+ try {
1011
+ reply = await this.openclawClient.chatSendWithContext({
1012
+ sessionKey,
1013
+ unsyncedMessages: [],
1014
+ currentMessage: prompt,
1015
+ currentSenderName: "Discussion Scheduler",
1016
+ deliver: false,
1017
+ timeoutMs: 1_800_000,
1018
+ });
1019
+ }
1020
+ finally {
1021
+ // OpenClaw can emit the final assistant session.message shortly after
1022
+ // chatSend/collectReply returns. Keep discussion proactive muted briefly;
1023
+ // the discussion coordinator already owns user-visible delivery.
1024
+ releaseProactiveMute(120_000);
1025
+ }
926
1026
  const parsedReply = this.extractBridgeAttachments(reply);
927
- const visibleReply = parsedReply.text.trim();
928
- const isVisible = visibleReply.length > 0 && visibleReply.toUpperCase() !== "NO_REPLY";
1027
+ const rawVisibleReply = parsedReply.text.trim();
1028
+ const discussionMarkerPattern = /\n*—— \d+\/\d+ · .+$/;
1029
+ const cleanVisibleReply = rawVisibleReply.replace(discussionMarkerPattern, "").trim();
1030
+ let displayReply = cleanVisibleReply;
1031
+ const isVisible = cleanVisibleReply.length > 0 && cleanVisibleReply.toUpperCase() !== "NO_REPLY";
1032
+ if (isVisible && meta) {
1033
+ const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
1034
+ displayReply = `${displayReply}\n\n${roundMarker}`;
1035
+ }
929
1036
  if (isVisible || parsedReply.attachments.length > 0) {
930
- const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
1037
+ const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
931
1038
  .filter(Boolean)
932
1039
  .join("\n");
933
1040
  this.store.insert({
@@ -938,14 +1045,9 @@ export class FeishuBot {
938
1045
  content: storedContent,
939
1046
  timestamp: Date.now(),
940
1047
  });
941
- await this.sendOrdered(chatId, async () => {
942
- if (isVisible)
943
- await this.sendMessage(chatId, visibleReply);
944
- for (const attachment of parsedReply.attachments)
945
- await this.sendBridgeAttachment(chatId, attachment);
946
- });
1048
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
947
1049
  }
948
- return { botName: this.config.name, text: visibleReply, visible: isVisible };
1050
+ return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
949
1051
  }
950
1052
  async replyMessage(messageId, text) {
951
1053
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
@@ -25,6 +25,23 @@ export interface ChatMessage {
25
25
  content: string;
26
26
  timestamp: number;
27
27
  }
28
+ export interface DeliveryOutboxItem {
29
+ id?: number;
30
+ sessionKey: string;
31
+ chatId: string;
32
+ botName: string;
33
+ sourceType: string;
34
+ sourceId: string;
35
+ deliveryKey: string;
36
+ contentHash: string;
37
+ content: string;
38
+ attachmentsJson: string;
39
+ replyToMessageId: string;
40
+ status: "pending" | "delivering" | "delivered" | "failed";
41
+ attempts: number;
42
+ createdAt: number;
43
+ updatedAt: number;
44
+ }
28
45
  export declare class MessageStore {
29
46
  private db;
30
47
  constructor(dbPath?: string);
@@ -40,6 +57,15 @@ export declare class MessageStore {
40
57
  clearPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
41
58
  hasDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number): boolean;
42
59
  markDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number, replyMessageId?: string): void;
60
+ enqueueDelivery(item: Omit<DeliveryOutboxItem, "id" | "status" | "attempts" | "createdAt" | "updatedAt">): number | null;
61
+ getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
62
+ getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
63
+ hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number): boolean;
64
+ hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number): boolean;
65
+ claimDelivery(id: number): boolean;
66
+ markDeliveryDelivered(id: number): void;
67
+ markDeliveryFailed(id: number): void;
68
+ private mapDelivery;
43
69
  /**
44
70
  * Get messages that haven't been synced to a bot's session yet.
45
71
  * Returns messages ordered by timestamp ascending.
@@ -82,7 +82,74 @@ export class MessageStore {
82
82
  updated_at INTEGER NOT NULL DEFAULT 0,
83
83
  PRIMARY KEY (bot_name, chat_id)
84
84
  );
85
+
86
+ -- Durable delivery ledger for user-visible assistant outputs. All final
87
+ -- text/attachment outputs should be inserted here first and dispatched
88
+ -- once, regardless of whether they came from chat final, proactive
89
+ -- session.message, subagent announce, or delayed error handling.
90
+ CREATE TABLE IF NOT EXISTS delivery_outbox (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ session_key TEXT NOT NULL,
93
+ chat_id TEXT NOT NULL,
94
+ bot_name TEXT NOT NULL,
95
+ source_type TEXT NOT NULL,
96
+ source_id TEXT NOT NULL,
97
+ delivery_key TEXT NOT NULL DEFAULT '',
98
+ content_hash TEXT NOT NULL DEFAULT '',
99
+ content TEXT NOT NULL DEFAULT '',
100
+ attachments_json TEXT NOT NULL DEFAULT '[]',
101
+ reply_to_message_id TEXT NOT NULL DEFAULT '',
102
+ status TEXT NOT NULL DEFAULT 'pending',
103
+ attempts INTEGER NOT NULL DEFAULT 0,
104
+ created_at INTEGER NOT NULL,
105
+ updated_at INTEGER NOT NULL,
106
+ UNIQUE(bot_name, chat_id, delivery_key),
107
+ UNIQUE(session_key, source_type, source_id)
108
+ );
109
+ CREATE INDEX IF NOT EXISTS idx_delivery_outbox_pending ON delivery_outbox(status, created_at);
110
+ `);
111
+ // Migration: add delivery_outbox delivery key columns if missing
112
+ try {
113
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_key TEXT NOT NULL DEFAULT ''`);
114
+ }
115
+ catch {
116
+ // Column already exists
117
+ }
118
+ try {
119
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`);
120
+ }
121
+ catch {
122
+ // Column already exists
123
+ }
124
+ try {
125
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN reply_to_message_id TEXT NOT NULL DEFAULT ''`);
126
+ }
127
+ catch {
128
+ // Column already exists
129
+ }
130
+ // Backfill stable keys for rows created by earlier outbox experiments before
131
+ // creating indexes that reference the new columns.
132
+ this.db.exec(`
133
+ UPDATE delivery_outbox
134
+ SET delivery_key = source_id
135
+ WHERE delivery_key IS NULL OR delivery_key = '';
85
136
  `);
137
+ try {
138
+ this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_outbox_key ON delivery_outbox(bot_name, chat_id, delivery_key)`);
139
+ }
140
+ catch {
141
+ // Existing duplicate experimental rows can prevent index creation in dev DB.
142
+ // Fresh DBs still get the table-level UNIQUE constraint; dirty DBs still use
143
+ // source unique + short-window content hash until cleaned.
144
+ }
145
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_delivery_outbox_content ON delivery_outbox(bot_name, chat_id, content_hash, created_at)`);
146
+ // Migration: add delivery_outbox reply target if missing
147
+ try {
148
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN reply_to_message_id TEXT NOT NULL DEFAULT ''`);
149
+ }
150
+ catch {
151
+ // Column already exists
152
+ }
86
153
  // Migration: add verbose column if missing
87
154
  try {
88
155
  this.db.exec(`ALTER TABLE chat_info ADD COLUMN verbose INTEGER NOT NULL DEFAULT 0`);
@@ -197,6 +264,104 @@ export class MessageStore {
197
264
  VALUES (?, ?, ?, ?, ?)
198
265
  `).run(botName, chatId, triggerMessageRowId, Date.now(), replyMessageId);
199
266
  }
267
+ enqueueDelivery(item) {
268
+ const now = Date.now();
269
+ const result = this.db.prepare(`
270
+ INSERT OR IGNORE INTO delivery_outbox
271
+ (session_key, chat_id, bot_name, source_type, source_id, delivery_key, content_hash, content, attachments_json, reply_to_message_id, status, attempts, created_at, updated_at)
272
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
273
+ `).run(item.sessionKey, item.chatId, item.botName, item.sourceType, item.sourceId, item.deliveryKey, item.contentHash, item.content, item.attachmentsJson, item.replyToMessageId || '', now, now);
274
+ return result.changes ? Number(result.lastInsertRowid) : null;
275
+ }
276
+ getDeliveryBySource(sessionKey, sourceType, sourceId) {
277
+ const row = this.db.prepare(`
278
+ SELECT * FROM delivery_outbox
279
+ WHERE session_key = ? AND source_type = ? AND source_id = ?
280
+ `).get(sessionKey, sourceType, sourceId);
281
+ return row ? this.mapDelivery(row) : null;
282
+ }
283
+ getPendingDeliveries(chatId, botName, maxCount = 20) {
284
+ if (chatId && botName) {
285
+ const rows = this.db.prepare(`
286
+ SELECT * FROM delivery_outbox
287
+ WHERE status = 'pending' AND chat_id = ? AND bot_name = ?
288
+ ORDER BY created_at ASC LIMIT ?
289
+ `).all(chatId, botName, maxCount);
290
+ return rows.map((r) => this.mapDelivery(r));
291
+ }
292
+ const rows = this.db.prepare(`
293
+ SELECT * FROM delivery_outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?
294
+ `).all(maxCount);
295
+ return rows.map((r) => this.mapDelivery(r));
296
+ }
297
+ hasRecentSimilarDelivery(botName, chatId, contentHash, windowMs) {
298
+ if (!contentHash)
299
+ return false;
300
+ const row = this.db.prepare(`
301
+ SELECT 1 FROM delivery_outbox
302
+ WHERE bot_name = ? AND chat_id = ? AND content_hash = ? AND created_at >= ? AND status IN ('pending', 'delivering', 'delivered')
303
+ LIMIT 1
304
+ `).get(botName, chatId, contentHash, Date.now() - windowMs);
305
+ return !!row;
306
+ }
307
+ hasRecentOverlappingDelivery(botName, chatId, content, attachmentsJson, windowMs, minShortLength = 8) {
308
+ const normalized = content.trim();
309
+ if (normalized.length < minShortLength)
310
+ return false;
311
+ const rows = this.db.prepare(`
312
+ SELECT content, attachments_json FROM delivery_outbox
313
+ WHERE bot_name = ? AND chat_id = ? AND created_at >= ? AND status IN ('pending', 'delivering', 'delivered')
314
+ ORDER BY created_at DESC
315
+ LIMIT 20
316
+ `).all(botName, chatId, Date.now() - windowMs);
317
+ for (const row of rows) {
318
+ if ((row.attachments_json || '[]') !== attachmentsJson)
319
+ continue;
320
+ const existing = String(row.content || '').trim();
321
+ if (existing.length < minShortLength)
322
+ continue;
323
+ const shorter = existing.length <= normalized.length ? existing : normalized;
324
+ const longer = existing.length <= normalized.length ? normalized : existing;
325
+ if (shorter.length >= minShortLength && longer.includes(shorter))
326
+ return true;
327
+ }
328
+ return false;
329
+ }
330
+ claimDelivery(id) {
331
+ const result = this.db.prepare(`
332
+ UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
333
+ `).run(Date.now(), id);
334
+ return result.changes === 1;
335
+ }
336
+ markDeliveryDelivered(id) {
337
+ this.db.prepare(`
338
+ UPDATE delivery_outbox SET status = 'delivered', updated_at = ? WHERE id = ?
339
+ `).run(Date.now(), id);
340
+ }
341
+ markDeliveryFailed(id) {
342
+ this.db.prepare(`
343
+ UPDATE delivery_outbox SET status = 'failed', updated_at = ? WHERE id = ?
344
+ `).run(Date.now(), id);
345
+ }
346
+ mapDelivery(row) {
347
+ return {
348
+ id: row.id,
349
+ sessionKey: row.session_key,
350
+ chatId: row.chat_id,
351
+ botName: row.bot_name,
352
+ sourceType: row.source_type,
353
+ sourceId: row.source_id,
354
+ deliveryKey: row.delivery_key || row.source_id,
355
+ contentHash: row.content_hash || '',
356
+ content: row.content || '',
357
+ attachmentsJson: row.attachments_json || '[]',
358
+ replyToMessageId: row.reply_to_message_id || '',
359
+ status: row.status,
360
+ attempts: row.attempts || 0,
361
+ createdAt: row.created_at,
362
+ updatedAt: row.updated_at,
363
+ };
364
+ }
200
365
  /**
201
366
  * Get messages that haven't been synced to a bot's session yet.
202
367
  * Returns messages ordered by timestamp ascending.
@@ -25,12 +25,16 @@ export declare class OpenClawClient {
25
25
  private sessionMessageCallbacks;
26
26
  /** Session keys that should be re-subscribed on reconnect */
27
27
  private subscribedKeys;
28
- /** Session keys with active/recent chatSend — suppress proactive message delivery */
28
+ /** Session keys with active/recent chatSend — proactive is forwarded and deduped by outbox. */
29
29
  private suppressedSessions;
30
30
  private suppressedSessionTimers;
31
+ /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
32
+ private mutedProactiveSessions;
33
+ private mutedProactiveSessionCounts;
31
34
  constructor(config: OpenClawConfig);
32
35
  connect(): Promise<void>;
33
36
  private _doConnect;
37
+ private handleProactiveSessionMessage;
34
38
  private scheduleReconnect;
35
39
  private rpc;
36
40
  /**
@@ -71,6 +75,9 @@ export declare class OpenClawClient {
71
75
  abortChat(sessionKey: string, runId: string): Promise<any>;
72
76
  private suppressSessionKeys;
73
77
  private releaseSuppressedSessionKeysAfter;
78
+ private addMutedProactiveKey;
79
+ private releaseMutedProactiveKey;
80
+ muteProactiveDelivery(sessionKey: string): (delayMs?: number) => void;
74
81
  chatSend(params: {
75
82
  sessionKey: string;
76
83
  message: string;
@@ -23,9 +23,12 @@ export class OpenClawClient {
23
23
  sessionMessageCallbacks = new Map();
24
24
  /** Session keys that should be re-subscribed on reconnect */
25
25
  subscribedKeys = new Set();
26
- /** Session keys with active/recent chatSend — suppress proactive message delivery */
26
+ /** Session keys with active/recent chatSend — proactive is forwarded and deduped by outbox. */
27
27
  suppressedSessions = new Set();
28
28
  suppressedSessionTimers = new Map();
29
+ /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
30
+ mutedProactiveSessions = new Set();
31
+ mutedProactiveSessionCounts = new Map();
29
32
  constructor(config) {
30
33
  this.config = config;
31
34
  }
@@ -140,47 +143,7 @@ export class OpenClawClient {
140
143
  // Try both raw key and without agent:main: prefix
141
144
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
142
145
  const msg = frame.payload.message || frame.payload;
143
- const role = msg.role;
144
- const content = msg.content;
145
- // Proactive assistant text messages (suppress during active chatSend).
146
- // Cron/session-targeted runs often emit structured content arrays rather
147
- // than a plain string. Extract only visible text parts and ignore thinking
148
- // / tool blocks so the bridge can deliver final cron results via the bot.
149
- let proactiveText = "";
150
- if (role === "assistant") {
151
- if (typeof content === "string") {
152
- proactiveText = content;
153
- }
154
- else if (Array.isArray(content)) {
155
- const hasToolBlock = content.some((part) => {
156
- const type = String(part?.type || "").toLowerCase();
157
- return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
158
- });
159
- // Do not deliver mixed text+toolCall assistant messages through
160
- // the proactive final-text path; those are usually intermediate
161
- // reasoning/status during a tool loop. Tool calls are still
162
- // delivered via the verbose channel from agent item events when
163
- // /verbose is enabled. Cron final messages arrive as text-only
164
- // (optionally with thinking).
165
- if (!hasToolBlock) {
166
- proactiveText = content
167
- .filter((part) => part?.type === "text" && typeof part.text === "string")
168
- .map((part) => part.text)
169
- .join("\n")
170
- .trim();
171
- }
172
- }
173
- }
174
- if (proactiveText) {
175
- if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
176
- console.log(`[OpenClaw] Suppressing proactive msg for ${shortKey} (active chatSend)`);
177
- }
178
- else {
179
- const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
180
- if (cb)
181
- cb(proactiveText);
182
- }
183
- }
146
+ this.handleProactiveSessionMessage(rawKey, msg);
184
147
  // Tool calls in assistant messages — skip, using agent item events instead
185
148
  // (session.message toolCall events are batched, not real-time)
186
149
  }
@@ -212,6 +175,53 @@ export class OpenClawClient {
212
175
  });
213
176
  });
214
177
  }
178
+ handleProactiveSessionMessage(rawKey, msg) {
179
+ const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
180
+ const role = msg.role;
181
+ const content = msg.content;
182
+ // Proactive assistant text messages. Cron/session-targeted runs often emit
183
+ // structured content arrays rather than a plain string. Extract only visible
184
+ // text parts and ignore thinking/tool blocks so the bridge can deliver final
185
+ // cron results via the bot.
186
+ let proactiveText = "";
187
+ if (role === "assistant") {
188
+ if (typeof content === "string") {
189
+ proactiveText = content;
190
+ }
191
+ else if (Array.isArray(content)) {
192
+ const hasToolBlock = content.some((part) => {
193
+ const type = String(part?.type || "").toLowerCase();
194
+ return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
195
+ });
196
+ // Do not deliver mixed text+toolCall assistant messages through
197
+ // the proactive final-text path; those are usually intermediate
198
+ // reasoning/status during a tool loop. Tool calls are still
199
+ // delivered via the verbose channel from agent item events when
200
+ // /verbose is enabled. Cron final messages arrive as text-only
201
+ // (optionally with thinking).
202
+ if (!hasToolBlock) {
203
+ proactiveText = content
204
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
205
+ .map((part) => part.text)
206
+ .join("\n")
207
+ .trim();
208
+ }
209
+ }
210
+ }
211
+ if (!proactiveText)
212
+ return false;
213
+ if (this.mutedProactiveSessions.has(rawKey) || this.mutedProactiveSessions.has(shortKey)) {
214
+ console.log(`[OpenClaw] Dropping proactive msg for ${shortKey}; delivery is owned by the caller`);
215
+ return false;
216
+ }
217
+ if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
218
+ console.log(`[OpenClaw] Forwarding proactive msg for ${shortKey} during active chatSend; delivery outbox will dedupe`);
219
+ }
220
+ const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
221
+ if (cb)
222
+ cb(proactiveText);
223
+ return Boolean(cb);
224
+ }
215
225
  scheduleReconnect() {
216
226
  const delay = this.reconnectDelay;
217
227
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
@@ -494,6 +504,42 @@ export class OpenClawClient {
494
504
  this.suppressedSessionTimers.set(key, timer);
495
505
  }
496
506
  }
507
+ addMutedProactiveKey(key) {
508
+ const count = this.mutedProactiveSessionCounts.get(key) || 0;
509
+ this.mutedProactiveSessionCounts.set(key, count + 1);
510
+ this.mutedProactiveSessions.add(key);
511
+ }
512
+ releaseMutedProactiveKey(key) {
513
+ const count = this.mutedProactiveSessionCounts.get(key) || 0;
514
+ if (count <= 1) {
515
+ this.mutedProactiveSessionCounts.delete(key);
516
+ this.mutedProactiveSessions.delete(key);
517
+ }
518
+ else {
519
+ this.mutedProactiveSessionCounts.set(key, count - 1);
520
+ }
521
+ }
522
+ muteProactiveDelivery(sessionKey) {
523
+ const shortKey = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
524
+ const fullKey = `agent:main:${shortKey}`;
525
+ const keys = [shortKey, fullKey];
526
+ for (const key of keys)
527
+ this.addMutedProactiveKey(key);
528
+ let released = false;
529
+ return (delayMs = 0) => {
530
+ if (released)
531
+ return;
532
+ released = true;
533
+ const release = () => {
534
+ for (const key of keys)
535
+ this.releaseMutedProactiveKey(key);
536
+ };
537
+ if (delayMs > 0)
538
+ setTimeout(release, delayMs);
539
+ else
540
+ release();
541
+ };
542
+ }
497
543
  async chatSend(params) {
498
544
  const sk = params.sessionKey;
499
545
  const fullSessionKey = `agent:main:${sk}`;
@@ -621,6 +667,8 @@ export class OpenClawClient {
621
667
  clearTimeout(timer);
622
668
  this.suppressedSessionTimers.clear();
623
669
  this.suppressedSessions.clear();
670
+ this.mutedProactiveSessions.clear();
671
+ this.mutedProactiveSessionCounts.clear();
624
672
  if (this.ws) {
625
673
  this.ws.close();
626
674
  this.ws = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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": {