openclaw-lark-multi-agent 1.0.3 → 1.0.5

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.
@@ -133,10 +133,11 @@ export class DiscussionManager {
133
133
  buildPrompt(session) {
134
134
  const previous = session.completedRounds.length === 0
135
135
  ? "(暂无,当前是第一轮)"
136
- : session.completedRounds.map((round) => {
136
+ : (() => {
137
+ const round = session.completedRounds[session.completedRounds.length - 1];
137
138
  const lines = Object.entries(round.replies).map(([bot, text]) => `- ${bot}: ${text || "NO_REPLY"}`);
138
139
  return `Round ${round.round}:\n${lines.join("\n")}`;
139
- }).join("\n\n");
140
+ })();
140
141
  return [
141
142
  "这是一个多智能体结构化讨论。",
142
143
  "",
@@ -146,6 +146,7 @@ export declare class FeishuBot {
146
146
  /**
147
147
  * Send a model-drift notification to the affected chat.
148
148
  */
149
+ private hydrateInlineImageKeys;
149
150
  /**
150
151
  * Download a resource (image/file/audio) from a Feishu message.
151
152
  * Returns the local file path.
@@ -360,7 +360,7 @@ export class FeishuBot {
360
360
  }
361
361
  else if (messageType === "post") {
362
362
  // Rich text post - extract all text content
363
- cleanText = this.extractPostText(content);
363
+ cleanText = await this.hydrateInlineImageKeys(this.extractPostText(content), messageId);
364
364
  }
365
365
  else if (messageType === "sticker") {
366
366
  cleanText = `[Sticker: ${content.file_key || "unknown"}]`;
@@ -1107,6 +1107,7 @@ export class FeishuBot {
1107
1107
  currentSenderName: "Discussion Scheduler",
1108
1108
  deliver: false,
1109
1109
  timeoutMs: 1_800_000,
1110
+ emptyFinalAsNoReply: true,
1110
1111
  });
1111
1112
  }
1112
1113
  finally {
@@ -1588,6 +1589,24 @@ export class FeishuBot {
1588
1589
  /**
1589
1590
  * Send a model-drift notification to the affected chat.
1590
1591
  */
1592
+ async hydrateInlineImageKeys(text, messageId) {
1593
+ const imageKeyPattern = /\[Image: (img_[^\]\n]+)\]/g;
1594
+ const replacements = [];
1595
+ for (const match of text.matchAll(imageKeyPattern)) {
1596
+ const imageKey = match[1];
1597
+ try {
1598
+ const imgPath = await this.downloadResource(messageId, imageKey, "image");
1599
+ replacements.push({ from: match[0], to: `[Image: ${imgPath}]` });
1600
+ }
1601
+ catch (err) {
1602
+ replacements.push({ from: match[0], to: `[Image: download failed - ${err.message}]` });
1603
+ }
1604
+ }
1605
+ let out = text;
1606
+ for (const r of replacements)
1607
+ out = out.replace(r.from, r.to);
1608
+ return out;
1609
+ }
1591
1610
  /**
1592
1611
  * Download a resource (image/file/audio) from a Feishu message.
1593
1612
  * Returns the local file path.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { loadConfig } from "./config.js";
2
2
  import { OpenClawClient } from "./openclaw-client.js";
3
3
  import { MessageStore } from "./message-store.js";
4
4
  import { FeishuBot } from "./feishu-bot.js";
5
- import { mkdirSync } from "fs";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
6
6
  import { dirname, resolve } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  export async function startApp(configPath) {
@@ -19,6 +19,22 @@ export async function startApp(configPath) {
19
19
  : resolve(dirname(resolvedConfigPath), "data");
20
20
  mkdirSync(dataDir, { recursive: true });
21
21
  console.log(`Data dir: ${dataDir}`);
22
+ const lockPath = resolve(dataDir, "lma.pid");
23
+ if (existsSync(lockPath)) {
24
+ const oldPid = Number(readFileSync(lockPath, "utf8").trim());
25
+ if (Number.isFinite(oldPid) && oldPid > 0) {
26
+ try {
27
+ process.kill(oldPid, 0);
28
+ throw new Error(`Another openclaw-lark-multi-agent instance is already running (pid ${oldPid}). Stop it before starting a new one.`);
29
+ }
30
+ catch (err) {
31
+ const code = err.code;
32
+ if (code !== "ESRCH")
33
+ throw err;
34
+ }
35
+ }
36
+ }
37
+ writeFileSync(lockPath, `${process.pid}\n`);
22
38
  const store = new MessageStore(resolve(dataDir, "messages.db"));
23
39
  // Connect to OpenClaw Gateway via WebSocket
24
40
  const openclawClient = new OpenClawClient(config.openclaw);
@@ -39,6 +55,11 @@ export async function startApp(configPath) {
39
55
  console.log("\nShutting down...");
40
56
  openclawClient.disconnect();
41
57
  store.close();
58
+ try {
59
+ if (readFileSync(lockPath, "utf8").trim() === String(process.pid))
60
+ rmSync(lockPath, { force: true });
61
+ }
62
+ catch { }
42
63
  process.exit(0);
43
64
  };
44
65
  process.on("SIGINT", shutdown);
@@ -6,6 +6,8 @@ export type ChatAttachment = {
6
6
  fileName?: string;
7
7
  content: string;
8
8
  };
9
+ export declare const GATEWAY_PROTOCOL_MIN = 3;
10
+ export declare const GATEWAY_PROTOCOL_MAX = 4;
9
11
  /**
10
12
  * OpenClaw Gateway WebSocket client.
11
13
  * Full agent pipeline — tools, memory, skills, context management by OpenClaw.
@@ -25,9 +27,12 @@ export declare class OpenClawClient {
25
27
  private sessionMessageCallbacks;
26
28
  /** Session keys that should be re-subscribed on reconnect */
27
29
  private subscribedKeys;
28
- /** Session keys with active/recent chatSend proactive is forwarded and deduped by outbox. */
30
+ /** Session keys whose transcript/session.message updates are currently suppressed. */
29
31
  private suppressedSessions;
30
32
  private suppressedSessionTimers;
33
+ /** Session keys whose delivery is owned by this bridge's chatSend final path. */
34
+ private ownedDeliverySessions;
35
+ private ownedDeliverySessionTimers;
31
36
  /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
32
37
  private mutedProactiveSessions;
33
38
  private mutedProactiveSessionCounts;
@@ -73,8 +78,15 @@ export declare class OpenClawClient {
73
78
  * deliver=false prevents OpenClaw from auto-posting to channels.
74
79
  */
75
80
  abortChat(sessionKey: string, runId: string): Promise<any>;
81
+ private sessionKeyVariants;
82
+ private trackChatEventSession;
83
+ private extractTextFromChatMessage;
84
+ private emitProactiveForSession;
76
85
  private suppressSessionKeys;
77
86
  private releaseSuppressedSessionKeysAfter;
87
+ private ownDeliverySessionKeys;
88
+ private releaseOwnedDeliverySessionKeysAfter;
89
+ private isOwnedDeliverySession;
78
90
  private addMutedProactiveKey;
79
91
  private releaseMutedProactiveKey;
80
92
  muteProactiveDelivery(sessionKey: string): (delayMs?: number) => void;
@@ -84,6 +96,7 @@ export declare class OpenClawClient {
84
96
  attachments?: ChatAttachment[];
85
97
  deliver?: boolean;
86
98
  timeoutMs?: number;
99
+ emptyFinalAsNoReply?: boolean;
87
100
  }): Promise<string>;
88
101
  private shouldInjectBridgeAttachmentHint;
89
102
  private bridgeAttachmentHint;
@@ -107,6 +120,7 @@ export declare class OpenClawClient {
107
120
  currentSenderName: string;
108
121
  deliver?: boolean;
109
122
  timeoutMs?: number;
123
+ emptyFinalAsNoReply?: boolean;
110
124
  }): Promise<string>;
111
125
  private extractImageAttachments;
112
126
  disconnect(): Promise<void>;
@@ -3,6 +3,8 @@ import { randomUUID } from "crypto";
3
3
  import { readFileSync } from "fs";
4
4
  import { basename, extname } from "path";
5
5
  import { getBridgeAttachmentsDir } from "./paths.js";
6
+ export const GATEWAY_PROTOCOL_MIN = 3;
7
+ export const GATEWAY_PROTOCOL_MAX = 4;
6
8
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
7
9
  /**
8
10
  * OpenClaw Gateway WebSocket client.
@@ -23,9 +25,12 @@ export class OpenClawClient {
23
25
  sessionMessageCallbacks = new Map();
24
26
  /** Session keys that should be re-subscribed on reconnect */
25
27
  subscribedKeys = new Set();
26
- /** Session keys with active/recent chatSend proactive is forwarded and deduped by outbox. */
28
+ /** Session keys whose transcript/session.message updates are currently suppressed. */
27
29
  suppressedSessions = new Set();
28
30
  suppressedSessionTimers = new Map();
31
+ /** Session keys whose delivery is owned by this bridge's chatSend final path. */
32
+ ownedDeliverySessions = new Set();
33
+ ownedDeliverySessionTimers = new Map();
29
34
  /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
30
35
  mutedProactiveSessions = new Set();
31
36
  mutedProactiveSessionCounts = new Map();
@@ -60,8 +65,8 @@ export class OpenClawClient {
60
65
  id: "connect-1",
61
66
  method: "connect",
62
67
  params: {
63
- minProtocol: 3,
64
- maxProtocol: 3,
68
+ minProtocol: GATEWAY_PROTOCOL_MIN,
69
+ maxProtocol: GATEWAY_PROTOCOL_MAX,
65
70
  client: {
66
71
  id: "gateway-client",
67
72
  version: "1.0.0",
@@ -111,8 +116,24 @@ export class OpenClawClient {
111
116
  // Normalize chat events to look like agent events for collectReply
112
117
  if (frame.event === "chat") {
113
118
  const state = frame.payload?.state;
119
+ this.trackChatEventSession(sk, state, frame.payload);
114
120
  const msg = frame.payload?.message;
115
- if (state === "final") {
121
+ if (state === "delta") {
122
+ // v4: chat delta events carry deltaText (incremental text chunk)
123
+ const deltaText = frame.payload?.deltaText;
124
+ if (deltaText) {
125
+ this.agentEvents.get(sk).push({
126
+ ...frame.payload,
127
+ stream: "chatDelta",
128
+ data: {
129
+ deltaText,
130
+ delta: deltaText, // v3 compat
131
+ replace: frame.payload?.replace || false,
132
+ },
133
+ });
134
+ }
135
+ }
136
+ else if (state === "final") {
116
137
  // Store chat final text as fallback (only used if agent stream had no text)
117
138
  const textParts = [];
118
139
  if (msg?.content) {
@@ -215,7 +236,8 @@ export class OpenClawClient {
215
236
  return false;
216
237
  }
217
238
  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`);
239
+ console.log(`[OpenClaw] Dropping proactive transcript msg for ${shortKey}; waiting for final delivery path`);
240
+ return false;
219
241
  }
220
242
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
221
243
  if (cb)
@@ -266,9 +288,10 @@ export class OpenClawClient {
266
288
  * No aggressive timeout — waits for lifecycle end as the source of truth.
267
289
  * 30-minute safety net only for catastrophic WS disconnection.
268
290
  */
269
- collectReply(runId, timeoutMs = 1800000, targetSessionKey) {
291
+ collectReply(runId, timeoutMs = 1800000, targetSessionKey, options) {
270
292
  return new Promise((resolve, reject) => {
271
293
  let text = "";
294
+ let chatDeltaText = "";
272
295
  let chatFinalText = "";
273
296
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey}` : "";
274
297
  let chatFinalTimer = null;
@@ -292,7 +315,7 @@ export class OpenClawClient {
292
315
  this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
293
316
  console.warn(`[OpenClaw] abort after collectReply idle timeout failed:`, err.message);
294
317
  });
295
- resolve(text || chatFinalText || "(timeout: no reply received)");
318
+ resolve(text || chatDeltaText || chatFinalText || "(timeout: no reply received)");
296
319
  }, timeoutMs);
297
320
  };
298
321
  resetIdleTimer();
@@ -348,8 +371,24 @@ export class OpenClawClient {
348
371
  lifecycleStartedLogged = true;
349
372
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
350
373
  }
351
- if (ev.stream === "assistant" && ev.data?.delta) {
352
- text += ev.data.delta;
374
+ if ((ev.stream === "assistant" || ev.stream === "chatDelta") && (ev.data?.deltaText || ev.data?.delta)) {
375
+ const chunk = ev.data.deltaText || ev.data.delta;
376
+ if (ev.stream === "assistant") {
377
+ if (ev.data?.replace) {
378
+ text = chunk;
379
+ }
380
+ else {
381
+ text += chunk;
382
+ }
383
+ }
384
+ else {
385
+ if (ev.data?.replace) {
386
+ chatDeltaText = chunk;
387
+ }
388
+ else {
389
+ chatDeltaText += chunk;
390
+ }
391
+ }
353
392
  }
354
393
  if (ev.stream === "chatFinal") {
355
394
  chatFinalText = ev.data?.text || "";
@@ -362,10 +401,13 @@ export class OpenClawClient {
362
401
  });
363
402
  // Prefer final chat message over accumulated deltas: some providers may
364
403
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
365
- const latestFinalText = chatFinalText || text;
404
+ const latestFinalText = chatFinalText || text || chatDeltaText;
366
405
  if (latestFinalText) {
367
406
  finish(latestFinalText);
368
407
  }
408
+ else if (options?.emptyFinalAsNoReply) {
409
+ finish("NO_REPLY");
410
+ }
369
411
  else {
370
412
  console.warn(`[OpenClaw] collectReply: empty chatFinal fallback ignored; waiting for real text or idle timeout`);
371
413
  chatFinalTimer = null;
@@ -376,9 +418,9 @@ export class OpenClawClient {
376
418
  if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
377
419
  // Prefer final chat message over accumulated deltas: some providers may
378
420
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
379
- const finalText = chatFinalText || text;
421
+ const finalText = chatFinalText || text || chatDeltaText;
380
422
  const finishFromLifecycle = () => {
381
- const latestFinalText = chatFinalText || text;
423
+ const latestFinalText = chatFinalText || text || chatDeltaText;
382
424
  if (!chatFinalText && latestFinalText.trim() === "N") {
383
425
  // Some providers stream the first character of NO_REPLY ("N") but
384
426
  // never deliver a final chat message in time. Never surface a lone
@@ -386,6 +428,10 @@ export class OpenClawClient {
386
428
  finish("NO_REPLY");
387
429
  return;
388
430
  }
431
+ if (!latestFinalText && options?.emptyFinalAsNoReply) {
432
+ finish("NO_REPLY");
433
+ return;
434
+ }
389
435
  if (!latestFinalText) {
390
436
  const state = ev.data?.livenessState || "unknown";
391
437
  const reason = ev.data?.stopReason || "";
@@ -407,7 +453,7 @@ export class OpenClawClient {
407
453
  };
408
454
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
409
455
  // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
410
- if (!chatFinalText && text.length <= 1) {
456
+ if (!options?.emptyFinalAsNoReply && !chatFinalText && text.length <= 1) {
411
457
  lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
412
458
  }
413
459
  else {
@@ -483,6 +529,53 @@ export class OpenClawClient {
483
529
  const key = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
484
530
  return this.rpc("chat.abort", { sessionKey: key, runId }, 5000).catch(() => { });
485
531
  }
532
+ sessionKeyVariants(key) {
533
+ const shortKey = key.startsWith("agent:main:") ? key.slice("agent:main:".length) : key;
534
+ return [shortKey, `agent:main:${shortKey}`];
535
+ }
536
+ trackChatEventSession(sessionKey, state, payload) {
537
+ if (!sessionKey || sessionKey === "__default__")
538
+ return;
539
+ const keys = this.sessionKeyVariants(sessionKey);
540
+ if (this.isOwnedDeliverySession(sessionKey)) {
541
+ // This chat event belongs to a bridge-owned chat.send run. The final answer
542
+ // is delivered by collectReply/processQueue, so transcript session.message
543
+ // mirrors must stay suppressed briefly.
544
+ this.suppressSessionKeys(keys);
545
+ if (state === "final" || state === "error" || state === "aborted")
546
+ this.releaseSuppressedSessionKeysAfter(keys, 30000);
547
+ return;
548
+ }
549
+ // External WebChat/Control UI chat against an LMA session: do not forward
550
+ // streaming transcript updates, but allow the final chat message to be
551
+ // delivered through the proactive callback after it is committed.
552
+ if (state === "delta") {
553
+ this.suppressSessionKeys(keys);
554
+ }
555
+ else if (state === "final") {
556
+ const text = this.extractTextFromChatMessage(payload?.message);
557
+ if (text)
558
+ this.emitProactiveForSession(sessionKey, text);
559
+ // Keep transcript mirrors suppressed briefly; chat final already emitted
560
+ // the user-visible result for external WebChat/Control UI turns.
561
+ this.releaseSuppressedSessionKeysAfter(keys, 30000);
562
+ }
563
+ else if (state === "error" || state === "aborted") {
564
+ this.releaseSuppressedSessionKeysAfter(keys, 0);
565
+ }
566
+ }
567
+ extractTextFromChatMessage(message) {
568
+ const parts = Array.isArray(message?.content) ? message.content : [];
569
+ return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
570
+ }
571
+ emitProactiveForSession(sessionKey, text) {
572
+ const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
573
+ const cb = this.sessionMessageCallbacks.get(fullKey) || this.sessionMessageCallbacks.get(shortKey);
574
+ if (!cb)
575
+ return false;
576
+ cb(text);
577
+ return true;
578
+ }
486
579
  suppressSessionKeys(keys) {
487
580
  for (const key of keys) {
488
581
  const timer = this.suppressedSessionTimers.get(key);
@@ -504,6 +597,31 @@ export class OpenClawClient {
504
597
  this.suppressedSessionTimers.set(key, timer);
505
598
  }
506
599
  }
600
+ ownDeliverySessionKeys(keys) {
601
+ for (const key of keys) {
602
+ const timer = this.ownedDeliverySessionTimers.get(key);
603
+ if (timer)
604
+ clearTimeout(timer);
605
+ this.ownedDeliverySessionTimers.delete(key);
606
+ this.ownedDeliverySessions.add(key);
607
+ }
608
+ }
609
+ releaseOwnedDeliverySessionKeysAfter(keys, delayMs) {
610
+ for (const key of keys) {
611
+ const oldTimer = this.ownedDeliverySessionTimers.get(key);
612
+ if (oldTimer)
613
+ clearTimeout(oldTimer);
614
+ const timer = setTimeout(() => {
615
+ this.ownedDeliverySessions.delete(key);
616
+ this.ownedDeliverySessionTimers.delete(key);
617
+ }, delayMs);
618
+ this.ownedDeliverySessionTimers.set(key, timer);
619
+ }
620
+ }
621
+ isOwnedDeliverySession(sessionKey) {
622
+ const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
623
+ return this.ownedDeliverySessions.has(shortKey) || this.ownedDeliverySessions.has(fullKey);
624
+ }
507
625
  addMutedProactiveKey(key) {
508
626
  const count = this.mutedProactiveSessionCounts.get(key) || 0;
509
627
  this.mutedProactiveSessionCounts.set(key, count + 1);
@@ -545,6 +663,7 @@ export class OpenClawClient {
545
663
  const fullSessionKey = `agent:main:${sk}`;
546
664
  const suppressedKeys = [sk, fullSessionKey];
547
665
  this.suppressSessionKeys(suppressedKeys);
666
+ this.ownDeliverySessionKeys(suppressedKeys);
548
667
  try {
549
668
  // Drop stale buffered events for this session before starting a new run.
550
669
  // This prevents an old final text (e.g. previous "ok") from being consumed by
@@ -560,7 +679,7 @@ export class OpenClawClient {
560
679
  idempotencyKey: randomUUID(),
561
680
  });
562
681
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
563
- return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk);
682
+ return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply });
564
683
  }
565
684
  finally {
566
685
  // OpenClaw can emit the final assistant session.message a moment after
@@ -568,6 +687,7 @@ export class OpenClawClient {
568
687
  // are not delivered twice via the proactive-message path. Cron/LMA runs
569
688
  // are unaffected because they do not go through chatSend.
570
689
  this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
690
+ this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
571
691
  }
572
692
  }
573
693
  shouldInjectBridgeAttachmentHint(text) {
@@ -610,6 +730,7 @@ export class OpenClawClient {
610
730
  attachments,
611
731
  deliver: params.deliver,
612
732
  timeoutMs: params.timeoutMs,
733
+ emptyFinalAsNoReply: params.emptyFinalAsNoReply,
613
734
  });
614
735
  }
615
736
  // Build context block + actual message in one chat.send
@@ -632,6 +753,7 @@ export class OpenClawClient {
632
753
  attachments,
633
754
  deliver: params.deliver,
634
755
  timeoutMs: params.timeoutMs,
756
+ emptyFinalAsNoReply: params.emptyFinalAsNoReply,
635
757
  });
636
758
  }
637
759
  extractImageAttachments(contents) {
@@ -669,6 +791,10 @@ export class OpenClawClient {
669
791
  this.suppressedSessions.clear();
670
792
  this.mutedProactiveSessions.clear();
671
793
  this.mutedProactiveSessionCounts.clear();
794
+ for (const timer of this.ownedDeliverySessionTimers.values())
795
+ clearTimeout(timer);
796
+ this.ownedDeliverySessionTimers.clear();
797
+ this.ownedDeliverySessions.clear();
672
798
  if (this.ws) {
673
799
  this.ws.close();
674
800
  this.ws = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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": {