openclaw-lark-multi-agent 0.1.3 → 0.1.4
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.
- package/README.md +23 -0
- package/README.zh-CN.md +23 -0
- package/dist/feishu-bot.d.ts +4 -0
- package/dist/feishu-bot.js +121 -57
- package/dist/message-store.d.ts +2 -0
- package/dist/message-store.js +25 -1
- package/dist/openclaw-client.js +46 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -232,6 +232,29 @@ OpenClaw-level slash commands can be sent by escaping with a double slash:
|
|
|
232
232
|
|
|
233
233
|
The bridge converts `//status` to `/status` and forwards it to OpenClaw instead of handling it locally.
|
|
234
234
|
|
|
235
|
+
How to test the double-slash behavior:
|
|
236
|
+
|
|
237
|
+
1. In a private chat with one bot, send `//status`.
|
|
238
|
+
- Expected: the message is forwarded to OpenClaw as `/status`.
|
|
239
|
+
- It should **not** be handled by the bridge-level `/status` command.
|
|
240
|
+
2. In a group chat, mention a specific bot or use `@all` with a double slash:
|
|
241
|
+
|
|
242
|
+
```text
|
|
243
|
+
@GPT //status
|
|
244
|
+
@all //reset
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Expected: the bridge strips the leading mention for routing, converts `//...` to `/...`, and forwards the command to OpenClaw.
|
|
248
|
+
|
|
249
|
+
To test bridge-level commands instead, use a single slash:
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
/reset
|
|
253
|
+
@all /reset
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Expected: the bridge handles the command locally and does not forward it to OpenClaw.
|
|
257
|
+
|
|
235
258
|
## Message routing rules
|
|
236
259
|
|
|
237
260
|
### Private chats
|
package/README.zh-CN.md
CHANGED
|
@@ -232,6 +232,29 @@ openclaw-lark-multi-agent install-windows-service
|
|
|
232
232
|
|
|
233
233
|
桥接层会把 `//status` 转成 `/status` 并转发给 OpenClaw,而不是自己处理。
|
|
234
234
|
|
|
235
|
+
如何测试 double slash 行为:
|
|
236
|
+
|
|
237
|
+
1. 在某个 bot 私聊里发送 `//status`。
|
|
238
|
+
- 预期:消息会被转成 `/status` 发给 OpenClaw。
|
|
239
|
+
- 它**不会**被桥接层当成本地 `/status` 命令处理。
|
|
240
|
+
2. 在群聊里,先 @ 某个 bot,或者 @所有人,再加双斜杠命令:
|
|
241
|
+
|
|
242
|
+
```text
|
|
243
|
+
@GPT //status
|
|
244
|
+
@所有人 //reset
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
预期:桥接层只用前面的 @ 来做路由,然后把 `//...` 转成 `/...` 发给 OpenClaw。
|
|
248
|
+
|
|
249
|
+
如果要测试桥接层自己的命令,用单斜杠:
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
/reset
|
|
253
|
+
@所有人 /reset
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
预期:桥接层本地处理这个命令,不会转发给 OpenClaw。
|
|
257
|
+
|
|
235
258
|
## 消息路由规则
|
|
236
259
|
|
|
237
260
|
### 私聊
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -64,11 +64,15 @@ export declare class FeishuBot {
|
|
|
64
64
|
*/
|
|
65
65
|
private processQueue;
|
|
66
66
|
private processQueueInner;
|
|
67
|
+
private shouldHandleBridgeCommand;
|
|
67
68
|
private shouldRespond;
|
|
68
69
|
private isMentioned;
|
|
70
|
+
private isAllMention;
|
|
71
|
+
private mentionedBotName;
|
|
69
72
|
private resolveBotName;
|
|
70
73
|
private resolveHumanName;
|
|
71
74
|
private cleanMentions;
|
|
75
|
+
private stripLeadingCommandMentions;
|
|
72
76
|
private replyMessage;
|
|
73
77
|
/**
|
|
74
78
|
* Send a proactive message to a chat (not a reply).
|
package/dist/feishu-bot.js
CHANGED
|
@@ -302,11 +302,18 @@ export class FeishuBot {
|
|
|
302
302
|
}
|
|
303
303
|
if (!cleanText.trim())
|
|
304
304
|
return;
|
|
305
|
+
// Commands may be prefixed by @all / @bot in group chats. Strip those
|
|
306
|
+
// leading routing mentions before deciding whether this is a bridge command
|
|
307
|
+
// or an escaped OpenClaw command.
|
|
308
|
+
const trimmedCleanText = cleanText.trim();
|
|
309
|
+
const commandText = this.stripLeadingCommandMentions(trimmedCleanText);
|
|
305
310
|
// Escape hatch: //command means send /command through to OpenClaw,
|
|
306
311
|
// while /command remains a bridge-level openclaw-lark-multi-agent command.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
312
|
+
if (commandText.startsWith("//")) {
|
|
313
|
+
cleanText = "/" + commandText.slice(2).trimStart();
|
|
314
|
+
}
|
|
315
|
+
else if (commandText.startsWith("/")) {
|
|
316
|
+
cleanText = commandText;
|
|
310
317
|
}
|
|
311
318
|
// --- Record to local store (ALL messages, before command/response checks) ---
|
|
312
319
|
const senderName = isBot
|
|
@@ -327,11 +334,13 @@ export class FeishuBot {
|
|
|
327
334
|
// --- Commands: in p2p always respond; in group, check shouldRespond first ---
|
|
328
335
|
// Single slash commands are handled by the bridge. Double slash commands were
|
|
329
336
|
// already unescaped above and should pass through to OpenClaw instead.
|
|
330
|
-
const isBridgeCommand = !
|
|
337
|
+
const isBridgeCommand = !commandText.startsWith("//");
|
|
331
338
|
const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free)/.test(cleanText.trim());
|
|
332
339
|
if (isCommand) {
|
|
333
|
-
// In group chats, commands
|
|
334
|
-
|
|
340
|
+
// In group chats, bridge commands must be explicitly routed to this bot
|
|
341
|
+
// or @all. Do not let Free Discussion make a bot execute commands meant
|
|
342
|
+
// for another bot.
|
|
343
|
+
if (chatType !== "p2p" && !this.shouldHandleBridgeCommand(chatType, message, isBot, message.content))
|
|
335
344
|
return;
|
|
336
345
|
const markCommandSynced = () => {
|
|
337
346
|
if (insertedId > 0) {
|
|
@@ -343,12 +352,33 @@ export class FeishuBot {
|
|
|
343
352
|
const helpText = [
|
|
344
353
|
`📚 ${this.config.name} Bot 命令列表`,
|
|
345
354
|
`━━━━━━━━━━━━━━━━━━`,
|
|
355
|
+
`桥接层命令(单斜杠,由 openclaw-lark-multi-agent 本地处理)`,
|
|
346
356
|
`📊 /status — 查看当前模型、Token 用量、Session 状态`,
|
|
347
|
-
`🧹 /compact —
|
|
348
|
-
`🔄 /reset —
|
|
349
|
-
`🔊 /verbose —
|
|
350
|
-
`🔓 /free
|
|
357
|
+
`🧹 /compact — 压缩当前 bot 的 OpenClaw session`,
|
|
358
|
+
`🔄 /reset — 重置当前 bot 的 OpenClaw session`,
|
|
359
|
+
`🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
|
|
360
|
+
`🔓 /free [on|off|status] — 开关当前 bot 在当前群聊的 Free Discussion`,
|
|
351
361
|
`❓ /help — 显示此帮助信息`,
|
|
362
|
+
``,
|
|
363
|
+
`OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
|
|
364
|
+
`🆕 //new — 新建/切换会话`,
|
|
365
|
+
`🔄 //reset — 让 OpenClaw 自己执行 reset`,
|
|
366
|
+
`🧹 //compact [instructions] — 让 OpenClaw 压缩上下文`,
|
|
367
|
+
`⏹️ //stopOptions — 查看/停止当前运行选项`,
|
|
368
|
+
`🧠 //think <level> — 设置思考等级`,
|
|
369
|
+
`🤖 //model <id> — 切换/查看模型`,
|
|
370
|
+
`⚡ //fast status|on|off — OpenAI fast mode`,
|
|
371
|
+
`🔊 //verbose on|off|full — OpenClaw verbose`,
|
|
372
|
+
`🧵 //trace on|off|rawStatus — OpenClaw trace`,
|
|
373
|
+
`📊 //status — OpenClaw 原生状态`,
|
|
374
|
+
`📋 //tasks — 查看任务`,
|
|
375
|
+
`👤 //whoami — 查看当前身份/会话`,
|
|
376
|
+
`🧩 //contextSkills — 查看当前上下文技能`,
|
|
377
|
+
`🛠️ //skill <name> [input] — 调用技能`,
|
|
378
|
+
`🧰 //tools — 查看可用工具`,
|
|
379
|
+
`📖 //commands — 查看完整 OpenClaw 命令`,
|
|
380
|
+
``,
|
|
381
|
+
`示例:群里发 @${this.config.name} //status,可把 /status 直接交给 OpenClaw。`,
|
|
352
382
|
].join("\n");
|
|
353
383
|
await this.replyMessage(messageId, helpText);
|
|
354
384
|
markCommandSynced();
|
|
@@ -390,14 +420,23 @@ export class FeishuBot {
|
|
|
390
420
|
markCommandSynced();
|
|
391
421
|
return;
|
|
392
422
|
}
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
this.store.
|
|
396
|
-
|
|
397
|
-
|
|
423
|
+
const parts = cleanText.trim().split(/\s+/);
|
|
424
|
+
const arg = (parts[1] || "").toLowerCase();
|
|
425
|
+
const isOn = this.store.getBotFreeDiscussion(this.config.name, chatId);
|
|
426
|
+
const next = arg === "on" || arg === "true" || arg === "1"
|
|
427
|
+
? true
|
|
428
|
+
: arg === "off" || arg === "false" || arg === "0"
|
|
429
|
+
? false
|
|
430
|
+
: arg === "status"
|
|
431
|
+
? isOn
|
|
432
|
+
: !isOn;
|
|
433
|
+
if (arg !== "status")
|
|
434
|
+
this.store.setBotFreeDiscussion(this.config.name, chatId, next);
|
|
435
|
+
if (next) {
|
|
436
|
+
await this.replyMessage(messageId, `🔓 ${this.config.name} Free Discussion 已开启\n只影响当前 Bot 在当前群聊的自由发言(连续 Bot 回复超过 ${MAX_BOT_STREAK} 轮将暂停,等待人类发言)`);
|
|
398
437
|
}
|
|
399
438
|
else {
|
|
400
|
-
await this.replyMessage(messageId,
|
|
439
|
+
await this.replyMessage(messageId, `🔒 ${this.config.name} Free Discussion 已关闭\n只影响当前 Bot;群聊中需要 @ 指定 Bot 才会回复`);
|
|
401
440
|
}
|
|
402
441
|
markCommandSynced();
|
|
403
442
|
return;
|
|
@@ -576,6 +615,16 @@ export class FeishuBot {
|
|
|
576
615
|
await new Promise((r) => setTimeout(r, 200));
|
|
577
616
|
}
|
|
578
617
|
}
|
|
618
|
+
shouldHandleBridgeCommand(chatType, message, isBot, rawText) {
|
|
619
|
+
if (chatType === "p2p")
|
|
620
|
+
return !isBot;
|
|
621
|
+
if (isBot)
|
|
622
|
+
return false;
|
|
623
|
+
const mentions = message.mentions || [];
|
|
624
|
+
if (this.isAllMention(rawText, mentions))
|
|
625
|
+
return true;
|
|
626
|
+
return this.isMentioned(mentions);
|
|
627
|
+
}
|
|
579
628
|
shouldRespond(chatType, message, isBot, chatId, rawText) {
|
|
580
629
|
if (chatType === "p2p")
|
|
581
630
|
return !isBot;
|
|
@@ -585,52 +634,48 @@ export class FeishuBot {
|
|
|
585
634
|
return this.isMentioned(mentions);
|
|
586
635
|
}
|
|
587
636
|
// @all in text: all bots respond
|
|
588
|
-
if (
|
|
637
|
+
if (this.isAllMention(rawText, mentions))
|
|
589
638
|
return true;
|
|
590
639
|
// Check if this bot is explicitly mentioned
|
|
591
640
|
if (this.isMentioned(mentions))
|
|
592
641
|
return true;
|
|
593
642
|
// Check if any other bot is mentioned (not us) — don't respond
|
|
594
|
-
const anyBotMentioned = mentions.some((m) =>
|
|
595
|
-
for (const bot of FeishuBot.allBots.values()) {
|
|
596
|
-
if (m.id?.app_id === bot.config.appId)
|
|
597
|
-
return true;
|
|
598
|
-
if (bot.botOpenId && m.id?.open_id === bot.botOpenId)
|
|
599
|
-
return true;
|
|
600
|
-
}
|
|
601
|
-
return false;
|
|
602
|
-
});
|
|
643
|
+
const anyBotMentioned = mentions.some((m) => this.mentionedBotName(m) !== null);
|
|
603
644
|
if (anyBotMentioned && !this.isMentioned(mentions))
|
|
604
645
|
return false;
|
|
605
646
|
// No bot mentioned: check free discussion mode
|
|
606
647
|
if (chatId) {
|
|
607
|
-
|
|
608
|
-
if (chatInfo?.freeDiscussion)
|
|
648
|
+
if (this.store.getBotFreeDiscussion(this.config.name, chatId))
|
|
609
649
|
return true;
|
|
610
650
|
}
|
|
611
651
|
// Default: don't respond without @
|
|
612
652
|
return false;
|
|
613
653
|
}
|
|
614
654
|
isMentioned(mentions) {
|
|
615
|
-
return mentions.some((m) =>
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
655
|
+
return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
|
|
656
|
+
}
|
|
657
|
+
isAllMention(rawText, mentions = []) {
|
|
658
|
+
if (rawText && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
|
|
659
|
+
return true;
|
|
660
|
+
return mentions.some((m) => m.key === "all" || m.key === "@_all" || m.id?.user_id === "all" || m.id?.open_id === "all" || m.name === "所有人");
|
|
661
|
+
}
|
|
662
|
+
mentionedBotName(mention) {
|
|
663
|
+
if (mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人")
|
|
664
|
+
return null;
|
|
665
|
+
const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
|
|
666
|
+
for (const bot of candidates) {
|
|
667
|
+
if (mention.id?.app_id === bot.config.appId)
|
|
668
|
+
return bot.config.name;
|
|
669
|
+
if (bot.botOpenId && mention.id?.open_id === bot.botOpenId)
|
|
670
|
+
return bot.config.name;
|
|
671
|
+
if (typeof mention.name === "string") {
|
|
672
|
+
const n = mention.name.toLowerCase();
|
|
673
|
+
const botName = bot.config.name.toLowerCase();
|
|
674
|
+
if (n === botName || n.includes(`(${botName})`) || n.includes(`(${botName})`))
|
|
675
|
+
return bot.config.name;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return null;
|
|
634
679
|
}
|
|
635
680
|
resolveBotName(sender) {
|
|
636
681
|
const openId = sender?.sender_id?.open_id;
|
|
@@ -647,6 +692,18 @@ export class FeishuBot {
|
|
|
647
692
|
cleanMentions(text) {
|
|
648
693
|
return text.replace(/@_user_\d+/g, "").trim();
|
|
649
694
|
}
|
|
695
|
+
stripLeadingCommandMentions(text) {
|
|
696
|
+
let s = text.trim();
|
|
697
|
+
let prev = "";
|
|
698
|
+
while (s !== prev) {
|
|
699
|
+
prev = s;
|
|
700
|
+
s = s
|
|
701
|
+
.replace(/^(@_all|@all|@所有人|所有人)\s*/i, "")
|
|
702
|
+
.replace(/^@\S+\s*/u, "")
|
|
703
|
+
.trimStart();
|
|
704
|
+
}
|
|
705
|
+
return s;
|
|
706
|
+
}
|
|
650
707
|
async replyMessage(messageId, text) {
|
|
651
708
|
// Use interactive card for markdown rendering
|
|
652
709
|
const card = {
|
|
@@ -699,16 +756,23 @@ export class FeishuBot {
|
|
|
699
756
|
},
|
|
700
757
|
});
|
|
701
758
|
}
|
|
702
|
-
catch {
|
|
759
|
+
catch (err) {
|
|
760
|
+
console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
|
|
703
761
|
// Fallback to plain text
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
762
|
+
try {
|
|
763
|
+
await this.client.im.message.create({
|
|
764
|
+
params: { receive_id_type: "chat_id" },
|
|
765
|
+
data: {
|
|
766
|
+
receive_id: chatId,
|
|
767
|
+
content: JSON.stringify({ text }),
|
|
768
|
+
msg_type: "text",
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
catch (fallbackErr) {
|
|
773
|
+
console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
|
|
774
|
+
throw fallbackErr;
|
|
775
|
+
}
|
|
712
776
|
}
|
|
713
777
|
}
|
|
714
778
|
/**
|
|
@@ -785,7 +849,7 @@ export class FeishuBot {
|
|
|
785
849
|
const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
|
|
786
850
|
const status = session?.status || "unknown";
|
|
787
851
|
const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
|
|
788
|
-
const freeStatus =
|
|
852
|
+
const freeStatus = this.store.getBotFreeDiscussion(this.config.name, chatId) ? "🔓 开启" : "🔒 关闭";
|
|
789
853
|
const statusText = [
|
|
790
854
|
`📊 ${this.config.name} Bot Status`,
|
|
791
855
|
`━━━━━━━━━━━━━━━━━━`,
|
package/dist/message-store.d.ts
CHANGED
|
@@ -58,6 +58,8 @@ export declare class MessageStore {
|
|
|
58
58
|
setVerbose(chatId: string, verbose: boolean): void;
|
|
59
59
|
setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
|
|
60
60
|
getBotVerbose(botName: string, chatId: string): boolean;
|
|
61
|
+
setBotFreeDiscussion(botName: string, chatId: string, on: boolean): void;
|
|
62
|
+
getBotFreeDiscussion(botName: string, chatId: string): boolean;
|
|
61
63
|
getChatInfo(chatId: string): ChatInfo | null;
|
|
62
64
|
getAllChatInfo(): ChatInfo[];
|
|
63
65
|
/**
|
package/dist/message-store.js
CHANGED
|
@@ -70,11 +70,12 @@ export class MessageStore {
|
|
|
70
70
|
);
|
|
71
71
|
|
|
72
72
|
-- Per-bot, per-chat settings. A group can contain multiple bots, so settings
|
|
73
|
-
-- like verbose must not be shared globally at chat level.
|
|
73
|
+
-- like verbose/free discussion must not be shared globally at chat level.
|
|
74
74
|
CREATE TABLE IF NOT EXISTS bot_chat_settings (
|
|
75
75
|
bot_name TEXT NOT NULL,
|
|
76
76
|
chat_id TEXT NOT NULL,
|
|
77
77
|
verbose INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
free_discussion INTEGER NOT NULL DEFAULT 0,
|
|
78
79
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
79
80
|
PRIMARY KEY (bot_name, chat_id)
|
|
80
81
|
);
|
|
@@ -100,6 +101,13 @@ export class MessageStore {
|
|
|
100
101
|
catch {
|
|
101
102
|
// Column already exists
|
|
102
103
|
}
|
|
104
|
+
// Migration: make free discussion per-bot per-chat.
|
|
105
|
+
try {
|
|
106
|
+
this.db.exec(`ALTER TABLE bot_chat_settings ADD COLUMN free_discussion INTEGER NOT NULL DEFAULT 0`);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Column already exists
|
|
110
|
+
}
|
|
103
111
|
}
|
|
104
112
|
/**
|
|
105
113
|
* Insert a message. Returns the auto-increment id, or -1 if duplicate.
|
|
@@ -270,6 +278,22 @@ export class MessageStore {
|
|
|
270
278
|
`).get(botName, chatId);
|
|
271
279
|
return !!row?.verbose;
|
|
272
280
|
}
|
|
281
|
+
setBotFreeDiscussion(botName, chatId, on) {
|
|
282
|
+
this.db.prepare(`
|
|
283
|
+
INSERT INTO bot_chat_settings (bot_name, chat_id, free_discussion, updated_at)
|
|
284
|
+
VALUES (?, ?, ?, ?)
|
|
285
|
+
ON CONFLICT (bot_name, chat_id) DO UPDATE SET
|
|
286
|
+
free_discussion = excluded.free_discussion,
|
|
287
|
+
updated_at = excluded.updated_at
|
|
288
|
+
`).run(botName, chatId, on ? 1 : 0, Date.now());
|
|
289
|
+
}
|
|
290
|
+
getBotFreeDiscussion(botName, chatId) {
|
|
291
|
+
const row = this.db.prepare(`
|
|
292
|
+
SELECT free_discussion FROM bot_chat_settings
|
|
293
|
+
WHERE bot_name = ? AND chat_id = ?
|
|
294
|
+
`).get(botName, chatId);
|
|
295
|
+
return !!row?.free_discussion;
|
|
296
|
+
}
|
|
273
297
|
getChatInfo(chatId) {
|
|
274
298
|
const row = this.db.prepare(`SELECT * FROM chat_info WHERE chat_id = ?`).get(chatId);
|
|
275
299
|
if (!row)
|
package/dist/openclaw-client.js
CHANGED
|
@@ -139,15 +139,31 @@ export class OpenClawClient {
|
|
|
139
139
|
const msg = frame.payload.message || frame.payload;
|
|
140
140
|
const role = msg.role;
|
|
141
141
|
const content = msg.content;
|
|
142
|
-
// Proactive assistant text messages (suppress during active chatSend)
|
|
143
|
-
|
|
142
|
+
// Proactive assistant text messages (suppress during active chatSend).
|
|
143
|
+
// Cron/session-targeted runs often emit structured content arrays rather
|
|
144
|
+
// than a plain string. Extract only visible text parts and ignore thinking
|
|
145
|
+
// / tool blocks so the bridge can deliver final cron results via the bot.
|
|
146
|
+
let proactiveText = "";
|
|
147
|
+
if (role === "assistant") {
|
|
148
|
+
if (typeof content === "string") {
|
|
149
|
+
proactiveText = content;
|
|
150
|
+
}
|
|
151
|
+
else if (Array.isArray(content)) {
|
|
152
|
+
proactiveText = content
|
|
153
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
154
|
+
.map((part) => part.text)
|
|
155
|
+
.join("\n")
|
|
156
|
+
.trim();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (proactiveText) {
|
|
144
160
|
if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
|
|
145
161
|
console.log(`[OpenClaw] Suppressing proactive msg for ${shortKey} (active chatSend)`);
|
|
146
162
|
}
|
|
147
163
|
else {
|
|
148
164
|
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
149
165
|
if (cb)
|
|
150
|
-
cb(
|
|
166
|
+
cb(proactiveText);
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
169
|
// Tool calls in assistant messages — skip, using agent item events instead
|
|
@@ -232,6 +248,7 @@ export class OpenClawClient {
|
|
|
232
248
|
let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey}` : "";
|
|
233
249
|
let chatFinalTimer = null;
|
|
234
250
|
let lifecycleEndTimer = null;
|
|
251
|
+
let replayInvalidTimer = null;
|
|
235
252
|
const collectStartedAt = Date.now();
|
|
236
253
|
let lifecycleStartedLogged = false;
|
|
237
254
|
const timer = setTimeout(() => {
|
|
@@ -240,6 +257,8 @@ export class OpenClawClient {
|
|
|
240
257
|
clearTimeout(chatFinalTimer);
|
|
241
258
|
if (lifecycleEndTimer)
|
|
242
259
|
clearTimeout(lifecycleEndTimer);
|
|
260
|
+
if (replayInvalidTimer)
|
|
261
|
+
clearTimeout(replayInvalidTimer);
|
|
243
262
|
console.warn(`[OpenClaw] collectReply timeout for runId=${runId} sessionKey=${sessionKey}`);
|
|
244
263
|
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
245
264
|
console.warn(`[OpenClaw] abort after collectReply timeout failed:`, err.message);
|
|
@@ -253,6 +272,8 @@ export class OpenClawClient {
|
|
|
253
272
|
clearTimeout(chatFinalTimer);
|
|
254
273
|
if (lifecycleEndTimer)
|
|
255
274
|
clearTimeout(lifecycleEndTimer);
|
|
275
|
+
if (replayInvalidTimer)
|
|
276
|
+
clearTimeout(replayInvalidTimer);
|
|
256
277
|
resolve(finalText);
|
|
257
278
|
};
|
|
258
279
|
const poller = setInterval(() => {
|
|
@@ -282,6 +303,12 @@ export class OpenClawClient {
|
|
|
282
303
|
continue;
|
|
283
304
|
}
|
|
284
305
|
bucket.splice(i, 1);
|
|
306
|
+
// If more events arrive after a replay-invalid lifecycle end, that lifecycle
|
|
307
|
+
// was not terminal for the user-visible run. Keep waiting for the real final.
|
|
308
|
+
if (replayInvalidTimer) {
|
|
309
|
+
clearTimeout(replayInvalidTimer);
|
|
310
|
+
replayInvalidTimer = null;
|
|
311
|
+
}
|
|
285
312
|
if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
|
|
286
313
|
lifecycleStartedLogged = true;
|
|
287
314
|
console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
|
|
@@ -310,20 +337,33 @@ export class OpenClawClient {
|
|
|
310
337
|
const finalText = chatFinalText || text;
|
|
311
338
|
const finishFromLifecycle = () => {
|
|
312
339
|
const latestFinalText = chatFinalText || text;
|
|
340
|
+
if (!chatFinalText && latestFinalText.trim() === "N") {
|
|
341
|
+
// Some providers stream the first character of NO_REPLY ("N") but
|
|
342
|
+
// never deliver a final chat message in time. Never surface a lone
|
|
343
|
+
// "N" to the user; treat it as a suppressed reply.
|
|
344
|
+
finish("NO_REPLY");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
313
347
|
if (!latestFinalText && ev.data?.livenessState !== "working") {
|
|
314
348
|
const state = ev.data?.livenessState || "unknown";
|
|
315
349
|
const reason = ev.data?.stopReason || "";
|
|
316
350
|
const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
|
|
317
|
-
|
|
351
|
+
const failureText = `⚠️ Agent 未正常完成\n状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}\n请重试,或用 /reset 重置会话`;
|
|
352
|
+
if (ev.data?.replayInvalid) {
|
|
353
|
+
console.warn(`[OpenClaw] replayInvalid lifecycle observed for runId=${evRunId || runId}; waiting for subsequent events before surfacing failure`);
|
|
354
|
+
replayInvalidTimer = setTimeout(() => finish(failureText), 120000);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
finish(failureText);
|
|
318
358
|
}
|
|
319
359
|
else {
|
|
320
360
|
finish(latestFinalText);
|
|
321
361
|
}
|
|
322
362
|
};
|
|
323
363
|
// If lifecycle end beats chat final, a short delta like "N" can be a truncated
|
|
324
|
-
// final reply. Wait
|
|
364
|
+
// final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
|
|
325
365
|
if (!chatFinalText && text.length <= 1) {
|
|
326
|
-
lifecycleEndTimer = setTimeout(finishFromLifecycle,
|
|
366
|
+
lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
|
|
327
367
|
}
|
|
328
368
|
else {
|
|
329
369
|
finishFromLifecycle();
|