openclaw-lark-multi-agent 0.1.12 → 0.1.14

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 {
@@ -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);
@@ -661,6 +661,7 @@ export class FeishuBot {
661
661
  this.store.markSynced(this.config.name, chatId, maxId);
662
662
  this.store.clearPendingTriggers(this.config.name, chatId, maxId);
663
663
  const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
664
+ const isRuntimeFailure = shouldReply && this.isRuntimeFailureText(trimmedReply);
664
665
  // Record bot reply only if it is user-visible/context-worthy.
665
666
  if (shouldReply || hasAttachments) {
666
667
  const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
@@ -692,32 +693,19 @@ export class FeishuBot {
692
693
  }
693
694
  // Reply to the last human message on Feishu (ordered after tool msgs)
694
695
  // Skip empty replies and explicit NO_REPLY responses
695
- if ((shouldReply || hasAttachments) && lastHuman.messageId) {
696
+ if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
696
697
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
697
698
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
698
699
  }
699
700
  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
- });
701
+ await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
702
+ if (triggerId)
703
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
719
704
  }
720
705
  }
706
+ if (isRuntimeFailure && lastHuman.messageId) {
707
+ this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
708
+ }
721
709
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
722
710
  // Replace ack reactions with DONE only for trigger messages actually
723
711
  // processed in this run. Queued messages that arrived mid-run keep their
@@ -739,16 +727,12 @@ export class FeishuBot {
739
727
  console.error(`[${this.config.name}] processQueue error:`, err);
740
728
  const errorText = this.formatUserVisibleError(err);
741
729
  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
- }
730
+ await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
731
+ .then(() => {
749
732
  if (triggerId)
750
733
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
751
- }).catch(() => { });
734
+ })
735
+ .catch(() => { });
752
736
  }
753
737
  if (triggerId) {
754
738
  // The run failed after OpenClaw/provider rejected it. Notify the user
@@ -899,6 +883,105 @@ export class FeishuBot {
899
883
  }
900
884
  return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
901
885
  }
886
+ isRuntimeFailureText(text) {
887
+ return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
888
+ }
889
+ cancelDelayedFailure(chatId) {
890
+ const timer = this.delayedFailureTimers.get(chatId);
891
+ if (timer)
892
+ clearTimeout(timer);
893
+ this.delayedFailureTimers.delete(chatId);
894
+ }
895
+ scheduleDelayedFailure(chatId, replyToMessageId, text, triggerId) {
896
+ this.cancelDelayedFailure(chatId);
897
+ const timer = setTimeout(() => {
898
+ this.delayedFailureTimers.delete(chatId);
899
+ void this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`)
900
+ .then(() => {
901
+ if (triggerId)
902
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, replyToMessageId);
903
+ })
904
+ .catch((err) => {
905
+ console.warn(`[${this.config.name}] delayed failure delivery failed:`, err.message);
906
+ });
907
+ }, 60_000);
908
+ this.delayedFailureTimers.set(chatId, timer);
909
+ }
910
+ deliverySessionKey(chatId) {
911
+ return this.getSessionKey(chatId);
912
+ }
913
+ stableHash(text) {
914
+ let hash = 2166136261;
915
+ for (let i = 0; i < text.length; i++) {
916
+ hash ^= text.charCodeAt(i);
917
+ hash = Math.imul(hash, 16777619);
918
+ }
919
+ return (hash >>> 0).toString(36);
920
+ }
921
+ deliverySourceId(kind, text) {
922
+ return `${kind}:${this.stableHash(text)}`;
923
+ }
924
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
925
+ if (!text.trim() && attachments.length === 0)
926
+ return;
927
+ const normalizedPayload = `${text.trim()}|${JSON.stringify(attachments)}`;
928
+ const contentHash = this.stableHash(normalizedPayload);
929
+ const finalDeliveryKey = deliveryKey || sourceId;
930
+ if (!deliveryKey && this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000))
931
+ return;
932
+ const deliveryId = this.store.enqueueDelivery({
933
+ sessionKey: this.deliverySessionKey(chatId),
934
+ chatId,
935
+ botName: this.config.name,
936
+ sourceType,
937
+ sourceId,
938
+ deliveryKey: finalDeliveryKey,
939
+ contentHash,
940
+ content: text,
941
+ attachmentsJson: JSON.stringify(attachments),
942
+ replyToMessageId: replyToMessageId || "",
943
+ });
944
+ if (deliveryId === null)
945
+ return;
946
+ await this.dispatchPendingDeliveries(chatId, replyToMessageId);
947
+ }
948
+ async dispatchPendingDeliveries(chatId, replyToMessageId) {
949
+ const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
950
+ for (const item of pending) {
951
+ await this.sendOrdered(chatId, async () => {
952
+ try {
953
+ if (!item.id || !this.store.claimDelivery(item.id))
954
+ return;
955
+ const attachments = JSON.parse(item.attachmentsJson || "[]");
956
+ if (item.content.trim()) {
957
+ const replyTarget = item.replyToMessageId || replyToMessageId;
958
+ const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
959
+ if (shouldReplyToSource) {
960
+ try {
961
+ await this.replyMessage(replyTarget, item.content);
962
+ }
963
+ catch (err) {
964
+ console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
965
+ await this.sendMessage(chatId, item.content);
966
+ }
967
+ }
968
+ else {
969
+ await this.sendMessage(chatId, item.content);
970
+ }
971
+ }
972
+ for (const attachment of attachments)
973
+ await this.sendBridgeAttachment(chatId, attachment);
974
+ if (item.id)
975
+ this.store.markDeliveryDelivered(item.id);
976
+ }
977
+ catch (err) {
978
+ if (item.id)
979
+ this.store.markDeliveryFailed(item.id);
980
+ throw err;
981
+ }
982
+ });
983
+ }
984
+ }
902
985
  isDiscussionCoordinator() {
903
986
  const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
904
987
  if (bots.length === 0)
@@ -910,10 +993,10 @@ export class FeishuBot {
910
993
  .filter((bot) => bot.store === this.store && bot.store.getBotMode(bot.config.name, chatId) === "free")
911
994
  .map((bot) => ({
912
995
  name: bot.config.name,
913
- runDiscussionTurn: async (_chatId, prompt) => bot.runDiscussionTurn(chatId, prompt),
996
+ runDiscussionTurn: async (_chatId, prompt, meta) => bot.runDiscussionTurn(chatId, prompt, meta),
914
997
  }));
915
998
  }
916
- async runDiscussionTurn(chatId, prompt) {
999
+ async runDiscussionTurn(chatId, prompt, meta) {
917
1000
  const sessionKey = await this.ensureSession(chatId);
918
1001
  const reply = await this.openclawClient.chatSendWithContext({
919
1002
  sessionKey,
@@ -924,8 +1007,11 @@ export class FeishuBot {
924
1007
  timeoutMs: 1_800_000,
925
1008
  });
926
1009
  const parsedReply = this.extractBridgeAttachments(reply);
927
- const visibleReply = parsedReply.text.trim();
1010
+ let visibleReply = parsedReply.text.trim();
928
1011
  const isVisible = visibleReply.length > 0 && visibleReply.toUpperCase() !== "NO_REPLY";
1012
+ if (isVisible && meta) {
1013
+ visibleReply = `${visibleReply}\n\n—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
1014
+ }
929
1015
  if (isVisible || parsedReply.attachments.length > 0) {
930
1016
  const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
931
1017
  .filter(Boolean)
@@ -938,12 +1024,7 @@ export class FeishuBot {
938
1024
  content: storedContent,
939
1025
  timestamp: Date.now(),
940
1026
  });
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
- });
1027
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? visibleReply : "", parsedReply.attachments);
947
1028
  }
948
1029
  return { botName: this.config.name, text: visibleReply, visible: isVisible };
949
1030
  }
@@ -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,14 @@ 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
+ claimDelivery(id: number): boolean;
65
+ markDeliveryDelivered(id: number): void;
66
+ markDeliveryFailed(id: number): void;
67
+ private mapDelivery;
43
68
  /**
44
69
  * Get messages that haven't been synced to a bot's session yet.
45
70
  * 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);
85
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 = '';
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,81 @@ 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
+ claimDelivery(id) {
308
+ const result = this.db.prepare(`
309
+ UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
310
+ `).run(Date.now(), id);
311
+ return result.changes === 1;
312
+ }
313
+ markDeliveryDelivered(id) {
314
+ this.db.prepare(`
315
+ UPDATE delivery_outbox SET status = 'delivered', updated_at = ? WHERE id = ?
316
+ `).run(Date.now(), id);
317
+ }
318
+ markDeliveryFailed(id) {
319
+ this.db.prepare(`
320
+ UPDATE delivery_outbox SET status = 'failed', updated_at = ? WHERE id = ?
321
+ `).run(Date.now(), id);
322
+ }
323
+ mapDelivery(row) {
324
+ return {
325
+ id: row.id,
326
+ sessionKey: row.session_key,
327
+ chatId: row.chat_id,
328
+ botName: row.bot_name,
329
+ sourceType: row.source_type,
330
+ sourceId: row.source_id,
331
+ deliveryKey: row.delivery_key || row.source_id,
332
+ contentHash: row.content_hash || '',
333
+ content: row.content || '',
334
+ attachmentsJson: row.attachments_json || '[]',
335
+ replyToMessageId: row.reply_to_message_id || '',
336
+ status: row.status,
337
+ attempts: row.attempts || 0,
338
+ createdAt: row.created_at,
339
+ updatedAt: row.updated_at,
340
+ };
341
+ }
200
342
  /**
201
343
  * Get messages that haven't been synced to a bot's session yet.
202
344
  * Returns messages ordered by timestamp ascending.
@@ -173,13 +173,11 @@ export class OpenClawClient {
173
173
  }
174
174
  if (proactiveText) {
175
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);
176
+ console.log(`[OpenClaw] Forwarding proactive msg for ${shortKey} during active chatSend; delivery outbox will dedupe`);
182
177
  }
178
+ const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
179
+ if (cb)
180
+ cb(proactiveText);
183
181
  }
184
182
  // Tool calls in assistant messages — skip, using agent item events instead
185
183
  // (session.message toolCall events are batched, not real-time)
@@ -352,7 +350,14 @@ export class OpenClawClient {
352
350
  });
353
351
  // Prefer final chat message over accumulated deltas: some providers may
354
352
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
355
- finish(chatFinalText || text);
353
+ const latestFinalText = chatFinalText || text;
354
+ if (latestFinalText) {
355
+ finish(latestFinalText);
356
+ }
357
+ else {
358
+ console.warn(`[OpenClaw] collectReply: empty chatFinal fallback ignored; waiting for real text or idle timeout`);
359
+ chatFinalTimer = null;
360
+ }
356
361
  }, 5000);
357
362
  }
358
363
  }
@@ -369,7 +374,7 @@ export class OpenClawClient {
369
374
  finish("NO_REPLY");
370
375
  return;
371
376
  }
372
- if (!latestFinalText && ev.data?.livenessState !== "working") {
377
+ if (!latestFinalText) {
373
378
  const state = ev.data?.livenessState || "unknown";
374
379
  const reason = ev.data?.stopReason || "";
375
380
  const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
@@ -379,11 +384,14 @@ export class OpenClawClient {
379
384
  replayInvalidTimer = setTimeout(() => finish(failureText), 120000);
380
385
  return;
381
386
  }
382
- finish(failureText);
383
- }
384
- else {
385
- finish(latestFinalText);
387
+ if (ev.data?.livenessState !== "working") {
388
+ finish(failureText);
389
+ return;
390
+ }
391
+ console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
392
+ return;
386
393
  }
394
+ finish(latestFinalText);
387
395
  };
388
396
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
389
397
  // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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": {