openclaw-lark-multi-agent 1.0.20 → 1.0.22
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/dist/feishu-bot.d.ts +19 -0
- package/dist/feishu-bot.js +363 -53
- package/dist/live-status.d.ts +56 -0
- package/dist/live-status.js +184 -0
- package/dist/message-store.d.ts +6 -0
- package/dist/message-store.js +38 -0
- package/dist/openclaw-client.d.ts +15 -0
- package/dist/openclaw-client.js +31 -8
- package/package.json +1 -1
package/dist/feishu-bot.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as lark from "@larksuiteoapi/node-sdk";
|
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { persistBotModel } from "./config.js";
|
|
4
4
|
import { getI18n, normalizeLocale } from "./i18n.js";
|
|
5
|
+
import { LiveStatusController } from "./live-status.js";
|
|
5
6
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
6
7
|
import { basename, extname, join, resolve } from "path";
|
|
7
8
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
@@ -14,6 +15,15 @@ const MAX_MERGED_TRIGGER_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT
|
|
|
14
15
|
const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_BYTES || 128 * 1024);
|
|
15
16
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
16
17
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
18
|
+
const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
|
|
19
|
+
class UnhealthySessionError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
constructor(status) {
|
|
22
|
+
super(`unhealthy session: ${status}`);
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.name = "UnhealthySessionError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
17
27
|
/**
|
|
18
28
|
* Manages a single Feishu bot instance.
|
|
19
29
|
*
|
|
@@ -261,6 +271,10 @@ export class FeishuBot {
|
|
|
261
271
|
*/
|
|
262
272
|
async drainOnStartup() {
|
|
263
273
|
try {
|
|
274
|
+
const restoredDeliveries = this.store.resetStaleDeliveries(this.config.name, 5 * 60_000, 5);
|
|
275
|
+
if (restoredDeliveries.restored > 0 || restoredDeliveries.failed > 0) {
|
|
276
|
+
console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries.restored}, failed=${restoredDeliveries.failed}`);
|
|
277
|
+
}
|
|
264
278
|
const chats = this.store.getAllChatInfo();
|
|
265
279
|
const drainTasks = [];
|
|
266
280
|
for (const chat of chats) {
|
|
@@ -273,6 +287,12 @@ export class FeishuBot {
|
|
|
273
287
|
// Drain only messages that were explicitly marked as reply triggers.
|
|
274
288
|
// Context-only messages should not start an OpenClaw run after restart.
|
|
275
289
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
|
|
290
|
+
const pendingDeliveries = this.store.getPendingDeliveries(chat.chatId, this.config.name, 1);
|
|
291
|
+
if (pendingDeliveries.length > 0) {
|
|
292
|
+
drainTasks.push(this.dispatchPendingDeliveries(chat.chatId).catch((err) => {
|
|
293
|
+
console.warn(`[${this.config.name}] Startup delivery drain failed for ${chat.chatId.slice(-8)}:`, err.message);
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
276
296
|
if (pendingTriggerIds.size > 0) {
|
|
277
297
|
console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
|
|
278
298
|
// Do not let one slow/stuck chat block startup drain for all other chats.
|
|
@@ -350,8 +370,9 @@ export class FeishuBot {
|
|
|
350
370
|
}
|
|
351
371
|
// --- Extract text content based on message type ---
|
|
352
372
|
let cleanText = "";
|
|
373
|
+
let rawText = "";
|
|
353
374
|
if (messageType === "text") {
|
|
354
|
-
|
|
375
|
+
rawText = content.text || "";
|
|
355
376
|
cleanText = this.cleanMentions(rawText);
|
|
356
377
|
// A mention-only text message is still a valid routing trigger. Feishu may
|
|
357
378
|
// expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
|
|
@@ -427,7 +448,7 @@ export class FeishuBot {
|
|
|
427
448
|
cleanText = commandText;
|
|
428
449
|
}
|
|
429
450
|
const preliminaryCommandName = cleanText.trim().split(/\s+/)[0]?.toLowerCase() || "";
|
|
430
|
-
const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
|
|
451
|
+
const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
|
|
431
452
|
const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
|
|
432
453
|
// --- Record to local store (ALL messages, before command/response checks) ---
|
|
433
454
|
const senderName = isBot
|
|
@@ -467,9 +488,12 @@ export class FeishuBot {
|
|
|
467
488
|
const isModelCommand = commandName === "/model";
|
|
468
489
|
const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
|
|
469
490
|
if (chatType !== "p2p") {
|
|
491
|
+
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
492
|
+
const singleBotGroup = groupBotCount === 1;
|
|
470
493
|
if (isModelCommand) {
|
|
471
494
|
// Model switching is per-bot. @all is rejected by one coordinator;
|
|
472
|
-
// otherwise group /model must explicitly target this bot.
|
|
495
|
+
// otherwise group /model must explicitly target this bot. A single-bot
|
|
496
|
+
// group treats the sole bot as the implicit target.
|
|
473
497
|
if (routing.isAllMention) {
|
|
474
498
|
if (!this.isDiscussionCoordinator())
|
|
475
499
|
return;
|
|
@@ -478,12 +502,58 @@ export class FeishuBot {
|
|
|
478
502
|
if (!routing.isCurrentBotMentioned)
|
|
479
503
|
return;
|
|
480
504
|
}
|
|
505
|
+
else if (!singleBotGroup) {
|
|
506
|
+
if (!this.isDiscussionCoordinator())
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
else if (isChairmanCommand) {
|
|
511
|
+
// /chairman @Bot is owned by the mentioned bot itself. This avoids
|
|
512
|
+
// requiring a coordinator to parse another bot's mention metadata,
|
|
513
|
+
// which can vary across Feishu clients/bridges. Untargeted/status,
|
|
514
|
+
// @all, and clear/off remain coordinator-owned group commands.
|
|
515
|
+
//
|
|
516
|
+
// Target resolution uses both Feishu mention metadata AND a text
|
|
517
|
+
// fallback (parsing "@Bot" / "Bot" from the raw message), so a
|
|
518
|
+
// /chairman that arrives without mention metadata still routes to
|
|
519
|
+
// the intended bot instead of silently failing.
|
|
520
|
+
const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
|
|
521
|
+
const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
|
|
522
|
+
const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
|
|
523
|
+
const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
|
|
524
|
+
// A pure status query: no clear keyword, no mention metadata, and no
|
|
525
|
+
// textual argument at all. Anything else is a set/route attempt.
|
|
526
|
+
const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
|
|
527
|
+
if (isClearArg || isStatusArg) {
|
|
528
|
+
// Group-level status/clear: one coordinator handles it.
|
|
529
|
+
if (!this.isDiscussionCoordinator())
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
else if (targets.length >= 1) {
|
|
533
|
+
// Targets resolved: only the (single) targeted bot acts. If
|
|
534
|
+
// multiple distinct targets resolved, the coordinator handles
|
|
535
|
+
// the "only one chairman" error once.
|
|
536
|
+
const uniqueTargets = Array.from(new Set(targets));
|
|
537
|
+
const targetedSelf = uniqueTargets.includes(this.config.name);
|
|
538
|
+
if (uniqueTargets.length > 1) {
|
|
539
|
+
if (!this.isDiscussionCoordinator())
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
else if (!targetedSelf) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
481
546
|
else {
|
|
482
|
-
|
|
547
|
+
// A set attempt whose target could not be resolved at all (missing
|
|
548
|
+
// metadata AND no readable name in text): coordinator handles the
|
|
549
|
+
// fallback so the user still gets an actionable reply.
|
|
550
|
+
if (!this.isDiscussionCoordinator())
|
|
551
|
+
return;
|
|
483
552
|
}
|
|
484
553
|
}
|
|
485
|
-
else if (
|
|
486
|
-
//
|
|
554
|
+
else if (isLocaleCommand && !routing.hasTargetedMention) {
|
|
555
|
+
// Untargeted or @all locale is a group-level setting; one coordinator handles it.
|
|
556
|
+
// If the user explicitly @s a bot, the targeted bot should execute and answer.
|
|
487
557
|
if (!this.isDiscussionCoordinator())
|
|
488
558
|
return;
|
|
489
559
|
}
|
|
@@ -497,8 +567,9 @@ export class FeishuBot {
|
|
|
497
567
|
if (!routing.isCurrentBotMentioned)
|
|
498
568
|
return;
|
|
499
569
|
}
|
|
500
|
-
else if (!routing.isAllMention) {
|
|
501
|
-
// Other bridge commands in a group need an explicit target or @all.
|
|
570
|
+
else if (!routing.isAllMention && !singleBotGroup) {
|
|
571
|
+
// Other bridge commands in a multi-bot group need an explicit target or @all.
|
|
572
|
+
// A single-bot group treats the sole bot as the implicit target.
|
|
502
573
|
return;
|
|
503
574
|
}
|
|
504
575
|
}
|
|
@@ -521,12 +592,13 @@ export class FeishuBot {
|
|
|
521
592
|
`🔄 /reset — 重置当前 bot 的 OpenClaw session`,
|
|
522
593
|
`⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
|
|
523
594
|
`🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
|
|
524
|
-
|
|
595
|
+
`📡 /livestatus [on|off] — 开关非 verbose 下的单条覆盖式运行状态消息(默认开启;/livestatus off 可关闭)`,
|
|
596
|
+
`🔓 /free [on|off] — 开关当前 bot 的 free 模式(仅非 Discuss 模式下影响普通消息主动回复)`,
|
|
525
597
|
`🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
|
|
526
598
|
`🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
|
|
527
599
|
`🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
|
|
528
|
-
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot
|
|
529
|
-
`👑 /chairman @Bot|off —
|
|
600
|
+
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论(所有非 mute bot + Chairman 参与,忽略 free)`,
|
|
601
|
+
`👑 /chairman @Bot|off — 设置/清除本群唯一 Chairman(状态见 /status)`,
|
|
530
602
|
`🌐 /locale zh|en — 设置/查看当前群语言`,
|
|
531
603
|
`❓ /help — 显示此帮助信息`,
|
|
532
604
|
``,
|
|
@@ -583,6 +655,14 @@ export class FeishuBot {
|
|
|
583
655
|
return;
|
|
584
656
|
}
|
|
585
657
|
if (commandName === "/model") {
|
|
658
|
+
if (chatType !== "p2p" && !routing.isAllMention && !routing.hasTargetedMention) {
|
|
659
|
+
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
660
|
+
if (groupBotCount > 1) {
|
|
661
|
+
await this.replyMessage(messageId, "❌ /model 是单 bot 设置,请明确 @ 一个 bot,例如:@Claude /model provider/model-id");
|
|
662
|
+
markCommandSynced();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
586
666
|
await this.handleModelCommand(chatId, messageId, cleanText.trim());
|
|
587
667
|
markCommandSynced();
|
|
588
668
|
return;
|
|
@@ -601,6 +681,18 @@ export class FeishuBot {
|
|
|
601
681
|
markCommandSynced();
|
|
602
682
|
return;
|
|
603
683
|
}
|
|
684
|
+
if (commandName === "/livestatus") {
|
|
685
|
+
const current = this.store.getBotLiveStatus(this.config.name, chatId);
|
|
686
|
+
const parts = cleanText.trim().split(/\s+/);
|
|
687
|
+
const arg = (parts[1] || "toggle").toLowerCase();
|
|
688
|
+
const next = arg === "on" ? true : arg === "off" ? false : !current;
|
|
689
|
+
this.store.setBotLiveStatus(this.config.name, chatId, next);
|
|
690
|
+
await this.replyMessage(messageId, next
|
|
691
|
+
? `📡 ${this.config.name} Live Status 已开启\n非 verbose 模式下会用一条可覆盖消息显示运行状态,并在最终回复时覆盖成答案。`
|
|
692
|
+
: `📡 ${this.config.name} Live Status 已关闭\n非 verbose 模式下只发送最终回复。`);
|
|
693
|
+
markCommandSynced();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
604
696
|
if (commandName === "/free") {
|
|
605
697
|
if (chatType === "p2p") {
|
|
606
698
|
await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
|
|
@@ -665,7 +757,7 @@ export class FeishuBot {
|
|
|
665
757
|
return;
|
|
666
758
|
}
|
|
667
759
|
if (commandName === "/chairman") {
|
|
668
|
-
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim());
|
|
760
|
+
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
|
|
669
761
|
markCommandSynced();
|
|
670
762
|
return;
|
|
671
763
|
}
|
|
@@ -687,7 +779,8 @@ export class FeishuBot {
|
|
|
687
779
|
const chairman = this.getChairmanParticipant(chatId);
|
|
688
780
|
const participants = this.getDiscussionParticipants(chatId);
|
|
689
781
|
if (participants.length > 0 || chairman) {
|
|
690
|
-
discussionManager.
|
|
782
|
+
const preempted = discussionManager.status(chatId);
|
|
783
|
+
const started = discussionManager.startIfAbsent({
|
|
691
784
|
chatId,
|
|
692
785
|
rootMessageId: messageId,
|
|
693
786
|
topic: cleanText,
|
|
@@ -705,19 +798,26 @@ export class FeishuBot {
|
|
|
705
798
|
}
|
|
706
799
|
},
|
|
707
800
|
});
|
|
801
|
+
if (started && preempted) {
|
|
802
|
+
await this.sendMessage(chatId, this.isEn(chatId)
|
|
803
|
+
? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
|
|
804
|
+
: `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
|
|
805
|
+
}
|
|
708
806
|
}
|
|
709
807
|
else if (this.isDiscussionCoordinator()) {
|
|
710
808
|
await this.sendMessage(chatId, this.isEn(chatId)
|
|
711
|
-
? "💬 Discuss is on, but there
|
|
712
|
-
: "💬 Discuss
|
|
809
|
+
? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
|
|
810
|
+
: "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
|
|
713
811
|
}
|
|
714
812
|
if (insertedId > 0)
|
|
715
813
|
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
716
814
|
return;
|
|
717
815
|
}
|
|
718
816
|
}
|
|
719
|
-
// --- Mute mode: do not forward
|
|
720
|
-
|
|
817
|
+
// --- Mute mode: do not forward ordinary muted bots to OpenClaw. Chairman
|
|
818
|
+
// intentionally outranks mute, so a muted Chairman can still answer/facilitate
|
|
819
|
+
// in Chairman paths.
|
|
820
|
+
if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute" && this.store.getChairmanBot(chatId) !== this.config.name) {
|
|
721
821
|
if (routing.isCurrentBotMentioned) {
|
|
722
822
|
await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
|
|
723
823
|
if (insertedId > 0) {
|
|
@@ -735,6 +835,10 @@ export class FeishuBot {
|
|
|
735
835
|
}
|
|
736
836
|
// Track this message for reaction status updates
|
|
737
837
|
const pending = this.pendingAckMessages.get(chatId) || [];
|
|
838
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
839
|
+
if (unhealthy) {
|
|
840
|
+
await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
|
|
841
|
+
}
|
|
738
842
|
// Anti-loop
|
|
739
843
|
const streak = this.store.getBotStreak(chatId, this.config.name);
|
|
740
844
|
if (streak >= MAX_BOT_STREAK) {
|
|
@@ -771,6 +875,78 @@ export class FeishuBot {
|
|
|
771
875
|
console.error(`[${this.config.name}] Error:`, err);
|
|
772
876
|
}
|
|
773
877
|
}
|
|
878
|
+
shouldUseLiveStatus(chatId, isNativeCommandTrigger = false) {
|
|
879
|
+
if (isNativeCommandTrigger)
|
|
880
|
+
return false;
|
|
881
|
+
if (this.store.getBotVerbose(this.config.name, chatId))
|
|
882
|
+
return false;
|
|
883
|
+
if (!this.store.getBotLiveStatus(this.config.name, chatId))
|
|
884
|
+
return false;
|
|
885
|
+
return process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS !== "0";
|
|
886
|
+
}
|
|
887
|
+
async getUnhealthySessionStatus(chatId) {
|
|
888
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
889
|
+
try {
|
|
890
|
+
const resp = await this.openclawClient.getSessionInfo(sessionKey);
|
|
891
|
+
const status = String(resp?.session?.status || "").toLowerCase();
|
|
892
|
+
if (!status)
|
|
893
|
+
return null;
|
|
894
|
+
if (["killed", "kill", "dead", "crashed", "crash", "failed", "failure", "error", "errored", "invalid"].some((bad) => status.includes(bad))) {
|
|
895
|
+
return status;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
// Absence/transient describe failures should not block a new run; chat.send
|
|
900
|
+
// will create/report the session as usual.
|
|
901
|
+
}
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
async warnUnhealthySessionAndContinue(chatId, messageId, status) {
|
|
905
|
+
await this.replyMessage(messageId, this.isEn(chatId)
|
|
906
|
+
? `⚠️ ${this.config.name} session is currently unhealthy (${status}). I will still try this message. If it keeps failing, send /reset and retry.`
|
|
907
|
+
: `⚠️ ${this.config.name} 的 session 当前状态异常(${status})。我会继续尝试处理这条消息;如果连续失败,再发送 /reset 后重试。`);
|
|
908
|
+
console.warn(`[${this.config.name}] unhealthy session ${this.getSessionKey(chatId)} status=${status}; warning user but allowing retry for ${chatId.slice(-8)}`);
|
|
909
|
+
}
|
|
910
|
+
async stopForUnhealthySession(chatId, messageId, status) {
|
|
911
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
912
|
+
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
913
|
+
this.busyChats.set(chatId, 0);
|
|
914
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
915
|
+
const remainingAcks = [];
|
|
916
|
+
for (const ack of acks) {
|
|
917
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
918
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
919
|
+
}
|
|
920
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
921
|
+
await this.addReaction(messageId, "DONE").catch(() => { });
|
|
922
|
+
await this.replyMessage(messageId, this.isEn(chatId)
|
|
923
|
+
? `⚠️ ${this.config.name} session became unhealthy while waiting (${status}). I stopped this attempt. You can send another message to try again; if it keeps failing, send /reset.`
|
|
924
|
+
: `⚠️ ${this.config.name} 的 session 在等待过程中变成异常状态(${status})。我已停止这次等待。你可以直接再发一条继续尝试;如果连续失败,再发送 /reset。`);
|
|
925
|
+
console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status}; stopped current attempt for ${chatId.slice(-8)}`);
|
|
926
|
+
}
|
|
927
|
+
async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
|
|
928
|
+
let done = false;
|
|
929
|
+
const monitoredWork = work.finally(() => { done = true; });
|
|
930
|
+
const monitor = (async () => {
|
|
931
|
+
while (!done) {
|
|
932
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_POLL_MS));
|
|
933
|
+
if (done)
|
|
934
|
+
break;
|
|
935
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
936
|
+
if (unhealthy) {
|
|
937
|
+
await this.stopForUnhealthySession(chatId, messageId, unhealthy);
|
|
938
|
+
throw new UnhealthySessionError(unhealthy);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return undefined;
|
|
942
|
+
})();
|
|
943
|
+
try {
|
|
944
|
+
return await Promise.race([monitoredWork, monitor]);
|
|
945
|
+
}
|
|
946
|
+
finally {
|
|
947
|
+
done = true;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
774
950
|
/**
|
|
775
951
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
776
952
|
* Loops until no more unsynced human messages remain.
|
|
@@ -798,6 +974,11 @@ export class FeishuBot {
|
|
|
798
974
|
if (pendingHumanTriggers.length === 0) {
|
|
799
975
|
break;
|
|
800
976
|
}
|
|
977
|
+
// Do not preflight-block a retry just because the previous session status
|
|
978
|
+
// is still marked unhealthy. handleMessage already warns the user; the
|
|
979
|
+
// current chat.send attempt should be allowed so users can choose to try
|
|
980
|
+
// again without being forced through /reset. The in-flight health monitor
|
|
981
|
+
// below still stops a run that becomes unhealthy while waiting.
|
|
801
982
|
this.busyChats.set(chatId, Date.now());
|
|
802
983
|
// Pick the batch to process this loop. Consecutive plain human messages are
|
|
803
984
|
// merged into a single run; native escaped commands (//x) are processed on
|
|
@@ -850,6 +1031,17 @@ export class FeishuBot {
|
|
|
850
1031
|
console.warn(`[${this.config.name}] Dropped ${droppedMergedTriggers.length} older pending trigger(s) for ${chatId.slice(-8)} due to merge limits (kept=${mergedTriggers.length}, bytes=${bytes}): ${summaries}`);
|
|
851
1032
|
}
|
|
852
1033
|
}
|
|
1034
|
+
const deliveredTriggerIds = mergedTriggers
|
|
1035
|
+
.map((m) => m.id || 0)
|
|
1036
|
+
.filter((id) => id > 0 && this.store.hasDeliveredReply(this.config.name, chatId, id));
|
|
1037
|
+
if (deliveredTriggerIds.length > 0) {
|
|
1038
|
+
console.warn(`[${this.config.name}] Clearing ${deliveredTriggerIds.length} stale delivered pending trigger(s) for ${chatId.slice(-8)}: ${deliveredTriggerIds.join(",")}`);
|
|
1039
|
+
for (const id of deliveredTriggerIds)
|
|
1040
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
1041
|
+
mergedTriggers = mergedTriggers.filter((m) => !m.id || !deliveredTriggerIds.includes(m.id));
|
|
1042
|
+
if (mergedTriggers.length === 0)
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
853
1045
|
const isNativeCommandTrigger = firstIsNative;
|
|
854
1046
|
const lastHuman = mergedTriggers[mergedTriggers.length - 1];
|
|
855
1047
|
const triggerId = lastHuman.id || 0;
|
|
@@ -915,6 +1107,7 @@ export class FeishuBot {
|
|
|
915
1107
|
}
|
|
916
1108
|
}
|
|
917
1109
|
let runAcceptedByOpenClaw = false;
|
|
1110
|
+
let liveStatus;
|
|
918
1111
|
try {
|
|
919
1112
|
const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
|
|
920
1113
|
let reply;
|
|
@@ -933,8 +1126,22 @@ export class FeishuBot {
|
|
|
933
1126
|
currentMarkedSubmitted = true;
|
|
934
1127
|
}
|
|
935
1128
|
};
|
|
1129
|
+
liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
|
|
1130
|
+
? new LiveStatusController({
|
|
1131
|
+
create: (text) => this.sendOrdered(chatId, () => this.replyTextMessage(lastHuman.messageId || "", text)),
|
|
1132
|
+
edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
|
|
1133
|
+
warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
|
|
1134
|
+
}, {
|
|
1135
|
+
botName: this.config.name,
|
|
1136
|
+
locale: this.isEn(chatId) ? "en" : "zh",
|
|
1137
|
+
disableHint: this.isEn(chatId)
|
|
1138
|
+
? `Tip: send /livestatus off to hide this status message`
|
|
1139
|
+
: `提示:调用 /livestatus off 关闭该状态提示`,
|
|
1140
|
+
})
|
|
1141
|
+
: undefined;
|
|
1142
|
+
liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
|
|
936
1143
|
try {
|
|
937
|
-
reply = await this.openclawClient.chatSendWithContext({
|
|
1144
|
+
reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
|
|
938
1145
|
sessionKey,
|
|
939
1146
|
unsyncedMessages: contextMsgs,
|
|
940
1147
|
currentMessage: mergedContent,
|
|
@@ -958,7 +1165,8 @@ export class FeishuBot {
|
|
|
958
1165
|
// INSERT OR IGNORE in message_sync.
|
|
959
1166
|
markSubmittedBatch(`submitted:${runId}`);
|
|
960
1167
|
},
|
|
961
|
-
|
|
1168
|
+
onProgress: (event) => liveStatus?.progress(event),
|
|
1169
|
+
}));
|
|
962
1170
|
}
|
|
963
1171
|
finally {
|
|
964
1172
|
// OpenClaw may emit the final assistant session.message just after
|
|
@@ -980,6 +1188,7 @@ export class FeishuBot {
|
|
|
980
1188
|
// submitted far enough that auto-replaying risks duplicate delivery.
|
|
981
1189
|
// The user can resend a new message if they want to retry.
|
|
982
1190
|
markSubmittedBatch("empty-returned");
|
|
1191
|
+
liveStatus?.dispose();
|
|
983
1192
|
console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
|
|
984
1193
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
985
1194
|
const remainingAcks = [];
|
|
@@ -1053,7 +1262,12 @@ export class FeishuBot {
|
|
|
1053
1262
|
}
|
|
1054
1263
|
else {
|
|
1055
1264
|
try {
|
|
1265
|
+
// Final answer always goes through the normal interactive-card
|
|
1266
|
+
// delivery path so Markdown renders correctly. The live status is a
|
|
1267
|
+
// SEPARATE message: once the final reply is enqueued, mark the
|
|
1268
|
+
// status message done.
|
|
1056
1269
|
await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
|
|
1270
|
+
await liveStatus?.complete();
|
|
1057
1271
|
// Mark every merged trigger as delivered so retries/restarts do
|
|
1058
1272
|
// not re-process any of them.
|
|
1059
1273
|
for (const id of mergedTriggerIds)
|
|
@@ -1063,13 +1277,18 @@ export class FeishuBot {
|
|
|
1063
1277
|
// enqueueAndDispatchDelivery already sent a user-visible delivery
|
|
1064
1278
|
// failure. Do not fall through to the generic provider-error path;
|
|
1065
1279
|
// that creates a second misleading "bot did not complete" message.
|
|
1280
|
+
await liveStatus?.fail().catch(() => { });
|
|
1066
1281
|
console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
|
|
1067
1282
|
}
|
|
1068
1283
|
}
|
|
1069
1284
|
}
|
|
1070
1285
|
if (isRuntimeFailure && lastHuman.messageId) {
|
|
1286
|
+
liveStatus?.dispose();
|
|
1071
1287
|
this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
|
|
1072
1288
|
}
|
|
1289
|
+
else if (!shouldReply && !hasAttachments) {
|
|
1290
|
+
liveStatus?.dispose();
|
|
1291
|
+
}
|
|
1073
1292
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
1074
1293
|
// Replace ack reactions with DONE only for trigger messages actually
|
|
1075
1294
|
// processed in this run. Queued messages that arrived mid-run keep their
|
|
@@ -1088,15 +1307,29 @@ export class FeishuBot {
|
|
|
1088
1307
|
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
1089
1308
|
}
|
|
1090
1309
|
catch (err) {
|
|
1310
|
+
if (err instanceof UnhealthySessionError) {
|
|
1311
|
+
// stopForUnhealthySession already warned the user; mark the live
|
|
1312
|
+
// status as interrupted. The user can send another message to retry
|
|
1313
|
+
// without being forced through /reset.
|
|
1314
|
+
await liveStatus?.fail().catch(() => { });
|
|
1315
|
+
console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
|
|
1316
|
+
break;
|
|
1317
|
+
}
|
|
1091
1318
|
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
1092
1319
|
const errorText = this.formatUserVisibleError(err);
|
|
1093
1320
|
if (lastHuman.messageId) {
|
|
1321
|
+
// Error message goes through the normal delivery path; the live status
|
|
1322
|
+
// is a separate message that we mark interrupted afterwards.
|
|
1094
1323
|
await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
|
|
1095
1324
|
.then(() => {
|
|
1096
1325
|
if (triggerId)
|
|
1097
1326
|
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
1098
1327
|
})
|
|
1099
1328
|
.catch(() => { });
|
|
1329
|
+
await liveStatus?.fail().catch(() => { });
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
await liveStatus?.fail().catch(() => { });
|
|
1100
1333
|
}
|
|
1101
1334
|
if (triggerId) {
|
|
1102
1335
|
// At-most-once: if anything after send attempt failed, this batch has
|
|
@@ -1113,7 +1346,7 @@ export class FeishuBot {
|
|
|
1113
1346
|
for (const ack of pendingAcks) {
|
|
1114
1347
|
if (completedTriggerIdSet.has(ack.rowId)) {
|
|
1115
1348
|
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
1116
|
-
await this.addReaction(ack.messageId, "
|
|
1349
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
1117
1350
|
}
|
|
1118
1351
|
else {
|
|
1119
1352
|
remainingAcks.push(ack);
|
|
@@ -1185,7 +1418,7 @@ export class FeishuBot {
|
|
|
1185
1418
|
return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
|
|
1186
1419
|
}
|
|
1187
1420
|
isAllMention(rawText, mentions = []) {
|
|
1188
|
-
if (rawText && (
|
|
1421
|
+
if (rawText && /(^|\s)@(?:_all|all|所有人)(?=\s|$)/i.test(rawText))
|
|
1189
1422
|
return true;
|
|
1190
1423
|
return mentions.some((m) => this.isAllMentionItem(m));
|
|
1191
1424
|
}
|
|
@@ -1227,7 +1460,7 @@ export class FeishuBot {
|
|
|
1227
1460
|
return null;
|
|
1228
1461
|
}
|
|
1229
1462
|
escapeRegExp(value) {
|
|
1230
|
-
return value.replace(/[.*+?^${}()|[\]
|
|
1463
|
+
return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
1231
1464
|
}
|
|
1232
1465
|
resolveBotName(sender) {
|
|
1233
1466
|
const openId = sender?.sender_id?.open_id;
|
|
@@ -1308,7 +1541,7 @@ export class FeishuBot {
|
|
|
1308
1541
|
const t = text.trim();
|
|
1309
1542
|
if (!t)
|
|
1310
1543
|
return false;
|
|
1311
|
-
return /^\/(help|status|compact|reset|stop|verbose|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
|
|
1544
|
+
return /^\/(help|status|compact|reset|stop|verbose|livestatus|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
|
|
1312
1545
|
|| this.isBridgeControlReply(t);
|
|
1313
1546
|
}
|
|
1314
1547
|
isRuntimeFailureText(text) {
|
|
@@ -1390,12 +1623,6 @@ export class FeishuBot {
|
|
|
1390
1623
|
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
|
|
1391
1624
|
return;
|
|
1392
1625
|
}
|
|
1393
|
-
else if (sourceType === "assistant_visible") {
|
|
1394
|
-
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["assistant_visible"]))
|
|
1395
|
-
return;
|
|
1396
|
-
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 30, ["assistant_visible"]))
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
1626
|
const deliveryId = this.store.enqueueDelivery({
|
|
1400
1627
|
sessionKey: this.deliverySessionKey(chatId),
|
|
1401
1628
|
chatId,
|
|
@@ -1473,6 +1700,49 @@ export class FeishuBot {
|
|
|
1473
1700
|
.filter((name) => Boolean(name));
|
|
1474
1701
|
return Array.from(new Set(names));
|
|
1475
1702
|
}
|
|
1703
|
+
mentionedBotNamesFromChairmanText(...texts) {
|
|
1704
|
+
const normalizedTexts = texts
|
|
1705
|
+
.filter(Boolean)
|
|
1706
|
+
.map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
|
|
1707
|
+
.filter(Boolean)
|
|
1708
|
+
.map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
|
|
1709
|
+
if (normalizedTexts.length === 0)
|
|
1710
|
+
return [];
|
|
1711
|
+
const names = [];
|
|
1712
|
+
const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
|
|
1713
|
+
for (const raw of normalizedTexts) {
|
|
1714
|
+
for (const bot of candidates) {
|
|
1715
|
+
const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
|
|
1716
|
+
const displayNames = [
|
|
1717
|
+
botName,
|
|
1718
|
+
`@${botName}`,
|
|
1719
|
+
`万万(${botName})`,
|
|
1720
|
+
`@万万(${botName})`,
|
|
1721
|
+
`万万(${botName})`,
|
|
1722
|
+
`@万万(${botName})`,
|
|
1723
|
+
];
|
|
1724
|
+
if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
|
|
1725
|
+
names.push(bot.config.name);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return Array.from(new Set(names));
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Resolve the target bot name(s) for a /chairman command using every signal
|
|
1733
|
+
* available, in priority order:
|
|
1734
|
+
* 1. Feishu mention metadata (app_id / open_id / name).
|
|
1735
|
+
* 2. Text fallback: "@Bot" or "Bot" written in the message body.
|
|
1736
|
+
*
|
|
1737
|
+
* This keeps /chairman working even when a Feishu client or bridge omits
|
|
1738
|
+
* mention metadata. Returns a de-duplicated, order-preserving list.
|
|
1739
|
+
*/
|
|
1740
|
+
resolveChairmanTargets(mentions, text, rawText = "") {
|
|
1741
|
+
const fromMeta = this.mentionedBotNames(mentions);
|
|
1742
|
+
if (fromMeta.length > 0)
|
|
1743
|
+
return fromMeta;
|
|
1744
|
+
return this.mentionedBotNamesFromChairmanText(text, rawText);
|
|
1745
|
+
}
|
|
1476
1746
|
isDiscussionCoordinator() {
|
|
1477
1747
|
const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
|
|
1478
1748
|
if (bots.length === 0)
|
|
@@ -1482,7 +1752,7 @@ export class FeishuBot {
|
|
|
1482
1752
|
getDiscussionParticipants(chatId) {
|
|
1483
1753
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1484
1754
|
return Array.from(FeishuBot.allBots.values())
|
|
1485
|
-
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId)
|
|
1755
|
+
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute")
|
|
1486
1756
|
.map((bot) => this.asDiscussionParticipant(bot, chatId));
|
|
1487
1757
|
}
|
|
1488
1758
|
getChairmanParticipant(chatId) {
|
|
@@ -1549,27 +1819,51 @@ export class FeishuBot {
|
|
|
1549
1819
|
}
|
|
1550
1820
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
1551
1821
|
}
|
|
1822
|
+
async replyTextMessage(messageId, text) {
|
|
1823
|
+
const res = await this.client.im.message.reply({
|
|
1824
|
+
path: { message_id: messageId },
|
|
1825
|
+
data: {
|
|
1826
|
+
content: JSON.stringify({ text }),
|
|
1827
|
+
msg_type: "text",
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1830
|
+
return res?.data?.message_id || res?.message_id;
|
|
1831
|
+
}
|
|
1832
|
+
async editTextMessage(messageId, text) {
|
|
1833
|
+
await this.client.im.v1.message.update({
|
|
1834
|
+
path: { message_id: messageId },
|
|
1835
|
+
data: {
|
|
1836
|
+
content: JSON.stringify({ text }),
|
|
1837
|
+
msg_type: "text",
|
|
1838
|
+
},
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
async deleteMessageById(messageId) {
|
|
1842
|
+
await this.client.im.v1.message.delete({ path: { message_id: messageId } });
|
|
1843
|
+
}
|
|
1552
1844
|
async replyMessage(messageId, text) {
|
|
1553
1845
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
1554
1846
|
const card = this.buildMarkdownCard(text);
|
|
1555
1847
|
try {
|
|
1556
|
-
await this.client.im.message.reply({
|
|
1848
|
+
const res = await this.client.im.message.reply({
|
|
1557
1849
|
path: { message_id: messageId },
|
|
1558
1850
|
data: {
|
|
1559
1851
|
content: JSON.stringify(card),
|
|
1560
1852
|
msg_type: "interactive",
|
|
1561
1853
|
},
|
|
1562
1854
|
});
|
|
1855
|
+
return res?.data?.message_id || res?.message_id;
|
|
1563
1856
|
}
|
|
1564
1857
|
catch {
|
|
1565
1858
|
// Fallback to plain text if card fails
|
|
1566
|
-
await this.client.im.message.reply({
|
|
1859
|
+
const res = await this.client.im.message.reply({
|
|
1567
1860
|
path: { message_id: messageId },
|
|
1568
1861
|
data: {
|
|
1569
1862
|
content: JSON.stringify({ text }),
|
|
1570
1863
|
msg_type: "text",
|
|
1571
1864
|
},
|
|
1572
1865
|
});
|
|
1866
|
+
return res?.data?.message_id || res?.message_id;
|
|
1573
1867
|
}
|
|
1574
1868
|
}
|
|
1575
1869
|
extractBridgeAttachments(reply) {
|
|
@@ -1750,7 +2044,7 @@ export class FeishuBot {
|
|
|
1750
2044
|
async sendMessage(chatId, text) {
|
|
1751
2045
|
const card = this.buildMarkdownCard(text);
|
|
1752
2046
|
try {
|
|
1753
|
-
await this.client.im.message.create({
|
|
2047
|
+
const res = await this.client.im.message.create({
|
|
1754
2048
|
params: { receive_id_type: "chat_id" },
|
|
1755
2049
|
data: {
|
|
1756
2050
|
receive_id: chatId,
|
|
@@ -1758,12 +2052,13 @@ export class FeishuBot {
|
|
|
1758
2052
|
msg_type: "interactive",
|
|
1759
2053
|
},
|
|
1760
2054
|
});
|
|
2055
|
+
return res?.data?.message_id || res?.message_id;
|
|
1761
2056
|
}
|
|
1762
2057
|
catch (err) {
|
|
1763
2058
|
console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
|
|
1764
2059
|
// Fallback to plain text
|
|
1765
2060
|
try {
|
|
1766
|
-
await this.client.im.message.create({
|
|
2061
|
+
const res = await this.client.im.message.create({
|
|
1767
2062
|
params: { receive_id_type: "chat_id" },
|
|
1768
2063
|
data: {
|
|
1769
2064
|
receive_id: chatId,
|
|
@@ -1771,6 +2066,7 @@ export class FeishuBot {
|
|
|
1771
2066
|
msg_type: "text",
|
|
1772
2067
|
},
|
|
1773
2068
|
});
|
|
2069
|
+
return res?.data?.message_id || res?.message_id;
|
|
1774
2070
|
}
|
|
1775
2071
|
catch (fallbackErr) {
|
|
1776
2072
|
console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
|
|
@@ -1785,7 +2081,7 @@ export class FeishuBot {
|
|
|
1785
2081
|
sendOrdered(chatId, fn) {
|
|
1786
2082
|
const prev = this.sendQueue.get(chatId) || Promise.resolve();
|
|
1787
2083
|
const next = prev.then(fn, fn); // run even if previous failed
|
|
1788
|
-
this.sendQueue.set(chatId, next);
|
|
2084
|
+
this.sendQueue.set(chatId, next.then(() => undefined, () => undefined));
|
|
1789
2085
|
return next;
|
|
1790
2086
|
}
|
|
1791
2087
|
/**
|
|
@@ -1860,8 +2156,8 @@ export class FeishuBot {
|
|
|
1860
2156
|
}
|
|
1861
2157
|
this.store.setDiscussMode(chatId, true);
|
|
1862
2158
|
await this.replyMessage(messageId, this.isEn(chatId)
|
|
1863
|
-
? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all
|
|
1864
|
-
: `💬 Discuss 已开启\nChairman:${chairman}\n
|
|
2159
|
+
? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all non-muted bots + Chairman; free mode is ignored in Discuss\nRounds: ${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`
|
|
2160
|
+
: `💬 Discuss 已开启\nChairman:${chairman}\n参与者:当前群所有非 mute bot + Chairman(Discuss 模式忽略 free 开关)\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`);
|
|
1865
2161
|
return;
|
|
1866
2162
|
}
|
|
1867
2163
|
if (action === "off") {
|
|
@@ -1879,7 +2175,7 @@ export class FeishuBot {
|
|
|
1879
2175
|
}
|
|
1880
2176
|
if (action === "rounds") {
|
|
1881
2177
|
const n = Number.parseInt(parts[2] || "", 10);
|
|
1882
|
-
if (!Number.
|
|
2178
|
+
if (!Number.isInteger(n) || n < 1 || n > 10) {
|
|
1883
2179
|
await this.replyMessage(messageId, "❌ Usage: /discuss rounds <1-10>");
|
|
1884
2180
|
return;
|
|
1885
2181
|
}
|
|
@@ -1896,49 +2192,61 @@ export class FeishuBot {
|
|
|
1896
2192
|
`💬 Discuss: ${info?.discuss ? "on" : "off"}`,
|
|
1897
2193
|
`Rounds: ${info?.discussMaxRounds || 10}`,
|
|
1898
2194
|
`Chairman: ${info?.chairmanBot || "not set"}`,
|
|
1899
|
-
`Participants: ${participants.length ? participants.join(", ") : "(
|
|
2195
|
+
`Participants: ${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(none available)"}`,
|
|
1900
2196
|
active ? `Active: round ${active.currentRound}/${active.maxRounds}, topic=${active.topic.slice(0, 80)}` : "Active: none",
|
|
1901
2197
|
].join("\n") : [
|
|
1902
2198
|
`💬 Discuss: ${info?.discuss ? "on" : "off"}`,
|
|
1903
2199
|
`轮数:${info?.discussMaxRounds || 10}`,
|
|
1904
2200
|
`Chairman:${info?.chairmanBot || "未设置"}`,
|
|
1905
|
-
`参与者:${participants.length ? participants.join(", ") : "
|
|
2201
|
+
`参与者:${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(无可参与者)"}`,
|
|
1906
2202
|
active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
|
|
1907
2203
|
].join("\n"));
|
|
1908
2204
|
}
|
|
1909
|
-
async handleChairmanCommand(chatId, chatType, messageId, mentions, text) {
|
|
2205
|
+
async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
|
|
1910
2206
|
if (chatType === "p2p") {
|
|
1911
2207
|
await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
|
|
1912
2208
|
return;
|
|
1913
2209
|
}
|
|
1914
2210
|
const parts = text.split(/\s+/).filter(Boolean);
|
|
1915
|
-
const action = (parts[1] || "
|
|
2211
|
+
const action = (parts[1] || "").toLowerCase();
|
|
1916
2212
|
if (["off", "clear", "none"].includes(action)) {
|
|
1917
2213
|
const previous = this.store.getChairmanBot(chatId);
|
|
1918
2214
|
this.store.clearChairmanBot(chatId);
|
|
1919
2215
|
await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
|
|
1920
2216
|
return;
|
|
1921
2217
|
}
|
|
1922
|
-
const
|
|
1923
|
-
if (
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
2218
|
+
const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
|
|
2219
|
+
if (resolvedBotNames.length === 0) {
|
|
2220
|
+
// /chairman is intentionally not a status query. Status lives in /status;
|
|
2221
|
+
// this command is only for switching or clearing the Chairman. Keeping the
|
|
2222
|
+
// two meanings separate prevents failed mention parsing from being masked
|
|
2223
|
+
// as a harmless "current status" response.
|
|
2224
|
+
const groupBots = Array.from(FeishuBot.allBots.values())
|
|
2225
|
+
.filter((bot) => bot.store === this.store)
|
|
2226
|
+
.map((bot) => bot.config.name);
|
|
2227
|
+
const uniqueGroupBots = Array.from(new Set(groupBots));
|
|
2228
|
+
const choices = uniqueGroupBots.length > 0
|
|
2229
|
+
? uniqueGroupBots.map((name) => `/chairman ${name}`).join("\n")
|
|
2230
|
+
: "/chairman @某个Bot";
|
|
2231
|
+
await this.replyMessage(messageId, `❌ /chairman 只用于设置/切换 Chairman,不再用于状态查询。\n` +
|
|
2232
|
+
`查看当前 Chairman 请用 /status。\n` +
|
|
2233
|
+
`切换请 @ 一个 bot,或直接发(可复制):\n${choices}\n` +
|
|
2234
|
+
`清除请用:/chairman off`);
|
|
1928
2235
|
return;
|
|
1929
2236
|
}
|
|
1930
|
-
if (
|
|
2237
|
+
if (resolvedBotNames.length > 1) {
|
|
1931
2238
|
await this.replyMessage(messageId, "❌ 一个群只能设置一个 Chairman。请只 @ 一个 bot。");
|
|
1932
2239
|
return;
|
|
1933
2240
|
}
|
|
1934
|
-
const next =
|
|
2241
|
+
const next = resolvedBotNames[0];
|
|
1935
2242
|
const previous = this.store.getChairmanBot(chatId);
|
|
1936
2243
|
this.store.setChairmanBot(chatId, next);
|
|
1937
2244
|
const mode = this.store.getBotMode(next, chatId);
|
|
1938
2245
|
const lines = [previous && previous !== next ? `✅ Chairman 已从 ${previous} 切换为 ${next}` : `✅ Chairman 已设置为 ${next}`];
|
|
1939
2246
|
lines.push("作用:");
|
|
1940
|
-
lines.push("-
|
|
1941
|
-
lines.push("- Discuss
|
|
2247
|
+
lines.push("- 非 Discuss 模式下:free bot 会主动回答普通消息;没有 free bot 时由 Chairman 兜底");
|
|
2248
|
+
lines.push("- Discuss 模式下:所有非 mute bot + Chairman 参与讨论,free 开关会被忽略");
|
|
2249
|
+
lines.push("- Chairman 优先级高于 mute,会参与兜底回答、主持、调停并做最终总结");
|
|
1942
2250
|
if (mode === "mute")
|
|
1943
2251
|
lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
|
|
1944
2252
|
await this.replyMessage(messageId, lines.join("\n"));
|
|
@@ -2004,6 +2312,7 @@ export class FeishuBot {
|
|
|
2004
2312
|
const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
|
|
2005
2313
|
const status = session?.status || "unknown";
|
|
2006
2314
|
const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
|
|
2315
|
+
const liveStatus = this.store.getBotLiveStatus(this.config.name, chatId) ? "📡 开启" : "📴 关闭";
|
|
2007
2316
|
const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
|
|
2008
2317
|
const chairman = chatType === "p2p" ? "" : this.store.getChairmanBot(chatId);
|
|
2009
2318
|
const chairmanStatus = chatType === "p2p"
|
|
@@ -2025,6 +2334,7 @@ export class FeishuBot {
|
|
|
2025
2334
|
`🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
|
|
2026
2335
|
`📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
|
|
2027
2336
|
`🔧 Verbose: ${verboseStatus}`,
|
|
2337
|
+
`📡 Live Status: ${liveStatus}`,
|
|
2028
2338
|
`🎛️ Mode: ${mode}`,
|
|
2029
2339
|
`👑 Chairman: ${chairmanStatus}`,
|
|
2030
2340
|
`🌐 Locale: ${localeStatus}`,
|
|
@@ -2113,7 +2423,7 @@ export class FeishuBot {
|
|
|
2113
2423
|
chatName: "私聊",
|
|
2114
2424
|
members: "",
|
|
2115
2425
|
memberNames: "",
|
|
2116
|
-
ownerBot: this.config.name,
|
|
2426
|
+
ownerBot: existing?.ownerBot || this.config.name,
|
|
2117
2427
|
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
2118
2428
|
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
2119
2429
|
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|