openclaw-lark-multi-agent 1.0.21 → 1.0.23
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 +29 -1
- package/dist/feishu-bot.js +297 -52
- package/dist/live-status.d.ts +56 -0
- package/dist/live-status.js +184 -0
- package/dist/message-store.d.ts +8 -0
- package/dist/message-store.js +81 -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";
|
|
@@ -15,6 +16,7 @@ const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MA
|
|
|
15
16
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
16
17
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
17
18
|
const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
|
|
19
|
+
const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
|
|
18
20
|
class UnhealthySessionError extends Error {
|
|
19
21
|
status;
|
|
20
22
|
constructor(status) {
|
|
@@ -62,6 +64,8 @@ export class FeishuBot {
|
|
|
62
64
|
locale;
|
|
63
65
|
configPath;
|
|
64
66
|
static allBots = new Map();
|
|
67
|
+
/** Chats where this bot app has actually received an event in this process. */
|
|
68
|
+
static seenBotChats = new Map();
|
|
65
69
|
constructor(config, openclawClient, store, adminOpenId, configPath) {
|
|
66
70
|
this.config = config;
|
|
67
71
|
this.openclawClient = openclawClient;
|
|
@@ -191,7 +195,17 @@ export class FeishuBot {
|
|
|
191
195
|
if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
|
|
192
196
|
this.cancelDelayedFailure(chatId);
|
|
193
197
|
const activeTarget = this.activeDeliveryTargets.get(chatId);
|
|
194
|
-
|
|
198
|
+
try {
|
|
199
|
+
await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, undefined);
|
|
200
|
+
if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
|
|
201
|
+
await activeTarget?.liveStatus?.complete().catch(() => { });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (sourceType !== "verbose_transcript")
|
|
206
|
+
await activeTarget?.liveStatus?.fail().catch(() => { });
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
195
209
|
}
|
|
196
210
|
catch (err) {
|
|
197
211
|
console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
|
|
@@ -329,6 +343,7 @@ export class FeishuBot {
|
|
|
329
343
|
const message = event.message;
|
|
330
344
|
const sender = event.sender;
|
|
331
345
|
const chatId = message.chat_id;
|
|
346
|
+
this.markCurrentBotSeenInChat(chatId);
|
|
332
347
|
const chatType = message.chat_type;
|
|
333
348
|
const messageType = message.message_type;
|
|
334
349
|
const messageId = message.message_id;
|
|
@@ -447,7 +462,7 @@ export class FeishuBot {
|
|
|
447
462
|
cleanText = commandText;
|
|
448
463
|
}
|
|
449
464
|
const preliminaryCommandName = cleanText.trim().split(/\s+/)[0]?.toLowerCase() || "";
|
|
450
|
-
const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
|
|
465
|
+
const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
|
|
451
466
|
const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
|
|
452
467
|
// --- Record to local store (ALL messages, before command/response checks) ---
|
|
453
468
|
const senderName = isBot
|
|
@@ -485,6 +500,7 @@ export class FeishuBot {
|
|
|
485
500
|
const isChairmanCommand = commandName === "/chairman";
|
|
486
501
|
const isLocaleCommand = commandName === "/locale";
|
|
487
502
|
const isModelCommand = commandName === "/model";
|
|
503
|
+
const isHelpCommand = commandName === "/help";
|
|
488
504
|
const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
|
|
489
505
|
if (chatType !== "p2p") {
|
|
490
506
|
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
@@ -556,6 +572,13 @@ export class FeishuBot {
|
|
|
556
572
|
if (!this.isDiscussionCoordinator())
|
|
557
573
|
return;
|
|
558
574
|
}
|
|
575
|
+
else if (isHelpCommand && !routing.hasTargetedMention) {
|
|
576
|
+
// Bare or @all /help in a multi-bot group should never be silent or
|
|
577
|
+
// spammy. Let the coordinator answer once instead of requiring users
|
|
578
|
+
// to know they must @ a bot before they can discover the command list.
|
|
579
|
+
if (!this.isDiscussionCoordinator())
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
559
582
|
else if (isDiscussCommand && !routing.hasTargetedMention) {
|
|
560
583
|
// Untargeted or @all /discuss is group-level; one coordinator handles it.
|
|
561
584
|
if (!this.isDiscussionCoordinator())
|
|
@@ -591,12 +614,13 @@ export class FeishuBot {
|
|
|
591
614
|
`🔄 /reset — 重置当前 bot 的 OpenClaw session`,
|
|
592
615
|
`⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
|
|
593
616
|
`🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
|
|
594
|
-
|
|
617
|
+
`📡 /livestatus [on|off] — 开关非 verbose 下的单条覆盖式运行状态消息(默认开启;/livestatus off 可关闭)`,
|
|
618
|
+
`🔓 /free [on|off] — 开关当前 bot 的 free 模式(仅非 Discuss 模式下影响普通消息主动回复)`,
|
|
595
619
|
`🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
|
|
596
620
|
`🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
|
|
597
621
|
`🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
|
|
598
|
-
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot
|
|
599
|
-
`👑 /chairman @Bot|off —
|
|
622
|
+
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论(所有非 mute bot + Chairman 参与,忽略 free)`,
|
|
623
|
+
`👑 /chairman @Bot|off — 设置/清除本群唯一 Chairman(状态见 /status)`,
|
|
600
624
|
`🌐 /locale zh|en — 设置/查看当前群语言`,
|
|
601
625
|
`❓ /help — 显示此帮助信息`,
|
|
602
626
|
``,
|
|
@@ -679,6 +703,18 @@ export class FeishuBot {
|
|
|
679
703
|
markCommandSynced();
|
|
680
704
|
return;
|
|
681
705
|
}
|
|
706
|
+
if (commandName === "/livestatus") {
|
|
707
|
+
const current = this.store.getBotLiveStatus(this.config.name, chatId);
|
|
708
|
+
const parts = cleanText.trim().split(/\s+/);
|
|
709
|
+
const arg = (parts[1] || "toggle").toLowerCase();
|
|
710
|
+
const next = arg === "on" ? true : arg === "off" ? false : !current;
|
|
711
|
+
this.store.setBotLiveStatus(this.config.name, chatId, next);
|
|
712
|
+
await this.replyMessage(messageId, next
|
|
713
|
+
? `📡 ${this.config.name} Live Status 已开启\n非 verbose 模式下会用一条可覆盖消息显示运行状态,并在最终回复时覆盖成答案。`
|
|
714
|
+
: `📡 ${this.config.name} Live Status 已关闭\n非 verbose 模式下只发送最终回复。`);
|
|
715
|
+
markCommandSynced();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
682
718
|
if (commandName === "/free") {
|
|
683
719
|
if (chatType === "p2p") {
|
|
684
720
|
await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
|
|
@@ -773,35 +809,37 @@ export class FeishuBot {
|
|
|
773
809
|
maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
|
|
774
810
|
participants,
|
|
775
811
|
chairman,
|
|
776
|
-
sendSystemMessage: async (text) => { await this.
|
|
812
|
+
sendSystemMessage: async (text) => { await this.sendSystemDelivery(chatId, text); },
|
|
777
813
|
locale: discussionLocale,
|
|
778
814
|
onComplete: async (event) => {
|
|
779
815
|
if (event.reason === "chairman_final") {
|
|
780
816
|
this.store.setDiscussMode(event.chatId, false);
|
|
781
|
-
await this.
|
|
817
|
+
await this.sendSystemDelivery(event.chatId, this.isEn(event.chatId)
|
|
782
818
|
? `💬 Discuss ended: Chairman ${event.chairmanName || ""} completed the final summary. Discuss mode has been turned off automatically.`
|
|
783
819
|
: `💬 Discuss 已结束:Chairman ${event.chairmanName || ""} 已完成总结,已自动关闭 Discuss 模式。`);
|
|
784
820
|
}
|
|
785
821
|
},
|
|
786
822
|
});
|
|
787
823
|
if (started && preempted) {
|
|
788
|
-
await this.
|
|
824
|
+
await this.sendSystemDelivery(chatId, this.isEn(chatId)
|
|
789
825
|
? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
|
|
790
826
|
: `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
|
|
791
827
|
}
|
|
792
828
|
}
|
|
793
829
|
else if (this.isDiscussionCoordinator()) {
|
|
794
|
-
await this.
|
|
795
|
-
? "💬 Discuss is on, but there
|
|
796
|
-
: "💬 Discuss
|
|
830
|
+
await this.sendSystemDelivery(chatId, this.isEn(chatId)
|
|
831
|
+
? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
|
|
832
|
+
: "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
|
|
797
833
|
}
|
|
798
834
|
if (insertedId > 0)
|
|
799
835
|
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
800
836
|
return;
|
|
801
837
|
}
|
|
802
838
|
}
|
|
803
|
-
// --- Mute mode: do not forward
|
|
804
|
-
|
|
839
|
+
// --- Mute mode: do not forward ordinary muted bots to OpenClaw. Chairman
|
|
840
|
+
// intentionally outranks mute, so a muted Chairman can still answer/facilitate
|
|
841
|
+
// in Chairman paths.
|
|
842
|
+
if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute" && this.store.getChairmanBot(chatId) !== this.config.name) {
|
|
805
843
|
if (routing.isCurrentBotMentioned) {
|
|
806
844
|
await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
|
|
807
845
|
if (insertedId > 0) {
|
|
@@ -821,8 +859,7 @@ export class FeishuBot {
|
|
|
821
859
|
const pending = this.pendingAckMessages.get(chatId) || [];
|
|
822
860
|
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
823
861
|
if (unhealthy) {
|
|
824
|
-
await this.
|
|
825
|
-
return;
|
|
862
|
+
await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
|
|
826
863
|
}
|
|
827
864
|
// Anti-loop
|
|
828
865
|
const streak = this.store.getBotStreak(chatId, this.config.name);
|
|
@@ -860,6 +897,15 @@ export class FeishuBot {
|
|
|
860
897
|
console.error(`[${this.config.name}] Error:`, err);
|
|
861
898
|
}
|
|
862
899
|
}
|
|
900
|
+
shouldUseLiveStatus(chatId, isNativeCommandTrigger = false) {
|
|
901
|
+
if (isNativeCommandTrigger)
|
|
902
|
+
return false;
|
|
903
|
+
if (this.store.getBotVerbose(this.config.name, chatId))
|
|
904
|
+
return false;
|
|
905
|
+
if (!this.store.getBotLiveStatus(this.config.name, chatId))
|
|
906
|
+
return false;
|
|
907
|
+
return process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS !== "0";
|
|
908
|
+
}
|
|
863
909
|
async getUnhealthySessionStatus(chatId) {
|
|
864
910
|
const sessionKey = this.getSessionKey(chatId);
|
|
865
911
|
try {
|
|
@@ -867,9 +913,8 @@ export class FeishuBot {
|
|
|
867
913
|
const status = String(resp?.session?.status || "").toLowerCase();
|
|
868
914
|
if (!status)
|
|
869
915
|
return null;
|
|
870
|
-
if (
|
|
916
|
+
if (this.isSessionDeadStatus(status))
|
|
871
917
|
return status;
|
|
872
|
-
}
|
|
873
918
|
}
|
|
874
919
|
catch {
|
|
875
920
|
// Absence/transient describe failures should not block a new run; chat.send
|
|
@@ -877,24 +922,48 @@ export class FeishuBot {
|
|
|
877
922
|
}
|
|
878
923
|
return null;
|
|
879
924
|
}
|
|
880
|
-
|
|
925
|
+
/**
|
|
926
|
+
* Distinguish SESSION death from RUN failure. `sessions.describe` reports the
|
|
927
|
+
* status of the most recent run, not whether the session is usable. A run can
|
|
928
|
+
* end as `aborted`/`error`/`failed`/`timeout` (e.g. an LLM idle timeout) while
|
|
929
|
+
* the session itself stays perfectly usable for the next message — confirmed in
|
|
930
|
+
* the wild: LMA reported "killed" but `/status` immediately showed running.
|
|
931
|
+
*
|
|
932
|
+
* Only treat the session as dead for statuses that mean the session itself is
|
|
933
|
+
* gone and a new run cannot start without /reset. Use word-boundary matching so
|
|
934
|
+
* a run-level "error" substring never trips this. Run-level failures are NOT
|
|
935
|
+
* surfaced here: the work promise will either return the (possibly empty) reply
|
|
936
|
+
* or throw, and that is handled by the normal delivery/error path.
|
|
937
|
+
*/
|
|
938
|
+
isSessionDeadStatus(status) {
|
|
939
|
+
const s = status.toLowerCase();
|
|
940
|
+
// Session-level death tokens only. Notably excludes aborted/error/errored/
|
|
941
|
+
// failed/failure/timeout, which are run-level outcomes on a live session.
|
|
942
|
+
const deadTokens = ["killed", "dead", "crashed", "destroyed", "terminated", "gone"];
|
|
943
|
+
return deadTokens.some((tok) => new RegExp(`(^|[^a-z])${tok}([^a-z]|$)`).test(s));
|
|
944
|
+
}
|
|
945
|
+
async warnUnhealthySessionAndContinue(chatId, messageId, status) {
|
|
946
|
+
await this.replyMessage(messageId, this.isEn(chatId)
|
|
947
|
+
? `⚠️ ${this.config.name} session is currently unhealthy (${status}). I will still try this message. If it keeps failing, send /reset and retry.`
|
|
948
|
+
: `⚠️ ${this.config.name} 的 session 当前状态异常(${status})。我会继续尝试处理这条消息;如果连续失败,再发送 /reset 后重试。`);
|
|
949
|
+
console.warn(`[${this.config.name}] unhealthy session ${this.getSessionKey(chatId)} status=${status}; warning user but allowing retry for ${chatId.slice(-8)}`);
|
|
950
|
+
}
|
|
951
|
+
async stopForUnhealthySession(chatId, messageId, status) {
|
|
881
952
|
const sessionKey = this.getSessionKey(chatId);
|
|
882
953
|
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
883
954
|
this.busyChats.set(chatId, 0);
|
|
884
|
-
const cleared = this.store.clearAllPendingTriggers(this.config.name, chatId);
|
|
885
|
-
if (insertedId > 0)
|
|
886
|
-
this.store.markMessagesSynced(this.config.name, chatId, [insertedId], `${this.config.name}:${chatId}:${insertedId}:unhealthy-session`);
|
|
887
955
|
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
956
|
+
const remainingAcks = [];
|
|
888
957
|
for (const ack of acks) {
|
|
889
958
|
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
890
|
-
await this.addReaction(ack.messageId, "
|
|
959
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
891
960
|
}
|
|
892
|
-
this.pendingAckMessages.set(chatId,
|
|
893
|
-
await this.addReaction(messageId, "
|
|
961
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
962
|
+
await this.addReaction(messageId, "DONE").catch(() => { });
|
|
894
963
|
await this.replyMessage(messageId, this.isEn(chatId)
|
|
895
|
-
? `⚠️ ${this.config.name} session
|
|
896
|
-
: `⚠️ ${this.config.name} 的 session
|
|
897
|
-
console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status};
|
|
964
|
+
? `⚠️ ${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.`
|
|
965
|
+
: `⚠️ ${this.config.name} 的 session 在等待过程中变成异常状态(${status})。我已停止这次等待。你可以直接再发一条继续尝试;如果连续失败,再发送 /reset。`);
|
|
966
|
+
console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status}; stopped current attempt for ${chatId.slice(-8)}`);
|
|
898
967
|
}
|
|
899
968
|
async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
|
|
900
969
|
let done = false;
|
|
@@ -906,8 +975,18 @@ export class FeishuBot {
|
|
|
906
975
|
break;
|
|
907
976
|
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
908
977
|
if (unhealthy) {
|
|
909
|
-
|
|
910
|
-
|
|
978
|
+
// Session status can briefly report killed/aborted while the final
|
|
979
|
+
// response is still racing back to the bridge. Confirm once before
|
|
980
|
+
// surfacing a user-visible failure, otherwise users can see both a
|
|
981
|
+
// successful reply and a misleading "session became unhealthy" notice.
|
|
982
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_CONFIRM_MS));
|
|
983
|
+
if (done)
|
|
984
|
+
break;
|
|
985
|
+
const confirmedUnhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
986
|
+
if (confirmedUnhealthy) {
|
|
987
|
+
await this.stopForUnhealthySession(chatId, messageId, confirmedUnhealthy);
|
|
988
|
+
throw new UnhealthySessionError(confirmedUnhealthy);
|
|
989
|
+
}
|
|
911
990
|
}
|
|
912
991
|
}
|
|
913
992
|
return undefined;
|
|
@@ -946,12 +1025,11 @@ export class FeishuBot {
|
|
|
946
1025
|
if (pendingHumanTriggers.length === 0) {
|
|
947
1026
|
break;
|
|
948
1027
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
1028
|
+
// Do not preflight-block a retry just because the previous session status
|
|
1029
|
+
// is still marked unhealthy. handleMessage already warns the user; the
|
|
1030
|
+
// current chat.send attempt should be allowed so users can choose to try
|
|
1031
|
+
// again without being forced through /reset. The in-flight health monitor
|
|
1032
|
+
// below still stops a run that becomes unhealthy while waiting.
|
|
955
1033
|
this.busyChats.set(chatId, Date.now());
|
|
956
1034
|
// Pick the batch to process this loop. Consecutive plain human messages are
|
|
957
1035
|
// merged into a single run; native escaped commands (//x) are processed on
|
|
@@ -1080,6 +1158,7 @@ export class FeishuBot {
|
|
|
1080
1158
|
}
|
|
1081
1159
|
}
|
|
1082
1160
|
let runAcceptedByOpenClaw = false;
|
|
1161
|
+
let liveStatus;
|
|
1083
1162
|
try {
|
|
1084
1163
|
const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
|
|
1085
1164
|
let reply;
|
|
@@ -1098,6 +1177,23 @@ export class FeishuBot {
|
|
|
1098
1177
|
currentMarkedSubmitted = true;
|
|
1099
1178
|
}
|
|
1100
1179
|
};
|
|
1180
|
+
liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
|
|
1181
|
+
? new LiveStatusController({
|
|
1182
|
+
create: (text) => this.sendOrdered(chatId, () => this.replyTextMessage(lastHuman.messageId || "", text)),
|
|
1183
|
+
edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
|
|
1184
|
+
warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
|
|
1185
|
+
}, {
|
|
1186
|
+
botName: this.config.name,
|
|
1187
|
+
locale: this.isEn(chatId) ? "en" : "zh",
|
|
1188
|
+
disableHint: this.isEn(chatId)
|
|
1189
|
+
? `Tip: send /livestatus off to hide this status message`
|
|
1190
|
+
: `提示:调用 /livestatus off 关闭该状态提示`,
|
|
1191
|
+
})
|
|
1192
|
+
: undefined;
|
|
1193
|
+
liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
|
|
1194
|
+
const activeTargetForStatus = this.activeDeliveryTargets.get(chatId);
|
|
1195
|
+
if (activeTargetForStatus?.triggerId === triggerId && liveStatus)
|
|
1196
|
+
activeTargetForStatus.liveStatus = liveStatus;
|
|
1101
1197
|
try {
|
|
1102
1198
|
reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
|
|
1103
1199
|
sessionKey,
|
|
@@ -1123,6 +1219,7 @@ export class FeishuBot {
|
|
|
1123
1219
|
// INSERT OR IGNORE in message_sync.
|
|
1124
1220
|
markSubmittedBatch(`submitted:${runId}`);
|
|
1125
1221
|
},
|
|
1222
|
+
onProgress: (event) => liveStatus?.progress(event),
|
|
1126
1223
|
}));
|
|
1127
1224
|
}
|
|
1128
1225
|
finally {
|
|
@@ -1145,6 +1242,7 @@ export class FeishuBot {
|
|
|
1145
1242
|
// submitted far enough that auto-replaying risks duplicate delivery.
|
|
1146
1243
|
// The user can resend a new message if they want to retry.
|
|
1147
1244
|
markSubmittedBatch("empty-returned");
|
|
1245
|
+
liveStatus?.dispose();
|
|
1148
1246
|
console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
|
|
1149
1247
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
1150
1248
|
const remainingAcks = [];
|
|
@@ -1215,10 +1313,16 @@ export class FeishuBot {
|
|
|
1215
1313
|
if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
|
|
1216
1314
|
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
1217
1315
|
console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
1316
|
+
await liveStatus?.complete().catch(() => { });
|
|
1218
1317
|
}
|
|
1219
1318
|
else {
|
|
1220
1319
|
try {
|
|
1320
|
+
// Final answer always goes through the normal interactive-card
|
|
1321
|
+
// delivery path so Markdown renders correctly. The live status is a
|
|
1322
|
+
// SEPARATE message: once the final reply is enqueued, mark the
|
|
1323
|
+
// status message done.
|
|
1221
1324
|
await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
|
|
1325
|
+
await liveStatus?.complete();
|
|
1222
1326
|
// Mark every merged trigger as delivered so retries/restarts do
|
|
1223
1327
|
// not re-process any of them.
|
|
1224
1328
|
for (const id of mergedTriggerIds)
|
|
@@ -1228,13 +1332,18 @@ export class FeishuBot {
|
|
|
1228
1332
|
// enqueueAndDispatchDelivery already sent a user-visible delivery
|
|
1229
1333
|
// failure. Do not fall through to the generic provider-error path;
|
|
1230
1334
|
// that creates a second misleading "bot did not complete" message.
|
|
1335
|
+
await liveStatus?.fail().catch(() => { });
|
|
1231
1336
|
console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
|
|
1232
1337
|
}
|
|
1233
1338
|
}
|
|
1234
1339
|
}
|
|
1235
1340
|
if (isRuntimeFailure && lastHuman.messageId) {
|
|
1341
|
+
await liveStatus?.fail().catch(() => { });
|
|
1236
1342
|
this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
|
|
1237
1343
|
}
|
|
1344
|
+
else if (!shouldReply && !hasAttachments) {
|
|
1345
|
+
liveStatus?.dispose();
|
|
1346
|
+
}
|
|
1238
1347
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
1239
1348
|
// Replace ack reactions with DONE only for trigger messages actually
|
|
1240
1349
|
// processed in this run. Queued messages that arrived mid-run keep their
|
|
@@ -1254,18 +1363,28 @@ export class FeishuBot {
|
|
|
1254
1363
|
}
|
|
1255
1364
|
catch (err) {
|
|
1256
1365
|
if (err instanceof UnhealthySessionError) {
|
|
1366
|
+
// stopForUnhealthySession already warned the user; mark the live
|
|
1367
|
+
// status as interrupted. The user can send another message to retry
|
|
1368
|
+
// without being forced through /reset.
|
|
1369
|
+
await liveStatus?.fail().catch(() => { });
|
|
1257
1370
|
console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
|
|
1258
1371
|
break;
|
|
1259
1372
|
}
|
|
1260
1373
|
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
1261
1374
|
const errorText = this.formatUserVisibleError(err);
|
|
1262
1375
|
if (lastHuman.messageId) {
|
|
1376
|
+
// Error message goes through the normal delivery path; the live status
|
|
1377
|
+
// is a separate message that we mark interrupted afterwards.
|
|
1263
1378
|
await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
|
|
1264
1379
|
.then(() => {
|
|
1265
1380
|
if (triggerId)
|
|
1266
1381
|
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
1267
1382
|
})
|
|
1268
1383
|
.catch(() => { });
|
|
1384
|
+
await liveStatus?.fail().catch(() => { });
|
|
1385
|
+
}
|
|
1386
|
+
else {
|
|
1387
|
+
await liveStatus?.fail().catch(() => { });
|
|
1269
1388
|
}
|
|
1270
1389
|
if (triggerId) {
|
|
1271
1390
|
// At-most-once: if anything after send attempt failed, this batch has
|
|
@@ -1282,7 +1401,7 @@ export class FeishuBot {
|
|
|
1282
1401
|
for (const ack of pendingAcks) {
|
|
1283
1402
|
if (completedTriggerIdSet.has(ack.rowId)) {
|
|
1284
1403
|
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
1285
|
-
await this.addReaction(ack.messageId, "
|
|
1404
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
1286
1405
|
}
|
|
1287
1406
|
else {
|
|
1288
1407
|
remainingAcks.push(ack);
|
|
@@ -1477,7 +1596,7 @@ export class FeishuBot {
|
|
|
1477
1596
|
const t = text.trim();
|
|
1478
1597
|
if (!t)
|
|
1479
1598
|
return false;
|
|
1480
|
-
return /^\/(help|status|compact|reset|stop|verbose|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
|
|
1599
|
+
return /^\/(help|status|compact|reset|stop|verbose|livestatus|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
|
|
1481
1600
|
|| this.isBridgeControlReply(t);
|
|
1482
1601
|
}
|
|
1483
1602
|
isRuntimeFailureText(text) {
|
|
@@ -1540,6 +1659,10 @@ export class FeishuBot {
|
|
|
1540
1659
|
deliverySourceId(kind, text) {
|
|
1541
1660
|
return `${kind}:${this.stableHash(text)}`;
|
|
1542
1661
|
}
|
|
1662
|
+
async sendSystemDelivery(chatId, text) {
|
|
1663
|
+
const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
1664
|
+
await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
|
|
1665
|
+
}
|
|
1543
1666
|
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
|
|
1544
1667
|
if (!text.trim() && attachments.length === 0)
|
|
1545
1668
|
return;
|
|
@@ -1608,6 +1731,8 @@ export class FeishuBot {
|
|
|
1608
1731
|
this.store.markDeliveryDelivered(item.id);
|
|
1609
1732
|
}
|
|
1610
1733
|
catch (err) {
|
|
1734
|
+
if (this.isOutOfChatError(err))
|
|
1735
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
1611
1736
|
if (item.id)
|
|
1612
1737
|
this.store.markDeliveryFailed(item.id);
|
|
1613
1738
|
const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
|
|
@@ -1685,17 +1810,42 @@ export class FeishuBot {
|
|
|
1685
1810
|
return true;
|
|
1686
1811
|
return bots[0] === this;
|
|
1687
1812
|
}
|
|
1813
|
+
static markBotSeenInChat(botName, chatId) {
|
|
1814
|
+
let set = FeishuBot.seenBotChats.get(botName);
|
|
1815
|
+
if (!set) {
|
|
1816
|
+
set = new Set();
|
|
1817
|
+
FeishuBot.seenBotChats.set(botName, set);
|
|
1818
|
+
}
|
|
1819
|
+
set.add(chatId);
|
|
1820
|
+
}
|
|
1821
|
+
markCurrentBotSeenInChat(chatId) {
|
|
1822
|
+
FeishuBot.markBotSeenInChat(this.config.name, chatId);
|
|
1823
|
+
this.store.markBotSeenInChat(this.config.name, chatId);
|
|
1824
|
+
}
|
|
1825
|
+
isBotAvailableInChat(bot, chatId) {
|
|
1826
|
+
// A bot can be globally configured but not actually installed in this Feishu
|
|
1827
|
+
// group. Discuss participants must be restricted to bots that either have
|
|
1828
|
+
// received an event in this chat during this process, or have previously
|
|
1829
|
+
// delivered a message successfully in this chat. Otherwise LMA may generate
|
|
1830
|
+
// "ghost" participants and Feishu will reject delivery with 230002
|
|
1831
|
+
// (Bot/User can NOT be out of the chat).
|
|
1832
|
+
if (this.store.isBotUnavailableInChat(bot.config.name, chatId))
|
|
1833
|
+
return false;
|
|
1834
|
+
return FeishuBot.seenBotChats.get(bot.config.name)?.has(chatId)
|
|
1835
|
+
|| this.store.hasBotSeenInChat(bot.config.name, chatId)
|
|
1836
|
+
|| this.store.hasAnyDeliveredToChat(bot.config.name, chatId);
|
|
1837
|
+
}
|
|
1688
1838
|
getDiscussionParticipants(chatId) {
|
|
1689
1839
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1690
1840
|
return Array.from(FeishuBot.allBots.values())
|
|
1691
|
-
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId)
|
|
1841
|
+
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute" && this.isBotAvailableInChat(bot, chatId))
|
|
1692
1842
|
.map((bot) => this.asDiscussionParticipant(bot, chatId));
|
|
1693
1843
|
}
|
|
1694
1844
|
getChairmanParticipant(chatId) {
|
|
1695
1845
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1696
1846
|
if (!chairman)
|
|
1697
1847
|
return undefined;
|
|
1698
|
-
const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman);
|
|
1848
|
+
const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman && this.isBotAvailableInChat(candidate, chatId));
|
|
1699
1849
|
return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
|
|
1700
1850
|
}
|
|
1701
1851
|
asDiscussionParticipant(bot, chatId) {
|
|
@@ -1707,6 +1857,21 @@ export class FeishuBot {
|
|
|
1707
1857
|
async runDiscussionTurn(chatId, prompt, meta) {
|
|
1708
1858
|
const sessionKey = await this.ensureSession(chatId);
|
|
1709
1859
|
const releaseProactiveMute = this.openclawClient.muteProactiveDelivery(sessionKey);
|
|
1860
|
+
const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
|
|
1861
|
+
const liveStatus = liveStatusEnabled
|
|
1862
|
+
? new LiveStatusController({
|
|
1863
|
+
create: (text) => this.sendOrdered(chatId, () => this.sendTextMessage(chatId, text)),
|
|
1864
|
+
edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
|
|
1865
|
+
warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
|
|
1866
|
+
}, {
|
|
1867
|
+
botName: this.config.name,
|
|
1868
|
+
locale: this.isEn(chatId) ? "en" : "zh",
|
|
1869
|
+
disableHint: this.isEn(chatId)
|
|
1870
|
+
? `Tip: send /livestatus off to hide this status message`
|
|
1871
|
+
: `提示:调用 /livestatus off 关闭该状态提示`,
|
|
1872
|
+
})
|
|
1873
|
+
: undefined;
|
|
1874
|
+
liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
|
|
1710
1875
|
let reply;
|
|
1711
1876
|
try {
|
|
1712
1877
|
reply = await this.openclawClient.chatSendWithContext({
|
|
@@ -1717,8 +1882,13 @@ export class FeishuBot {
|
|
|
1717
1882
|
deliver: false,
|
|
1718
1883
|
timeoutMs: 1_800_000,
|
|
1719
1884
|
emptyFinalAsNoReply: true,
|
|
1885
|
+
onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
|
|
1720
1886
|
});
|
|
1721
1887
|
}
|
|
1888
|
+
catch (err) {
|
|
1889
|
+
await liveStatus?.fail().catch(() => { });
|
|
1890
|
+
throw err;
|
|
1891
|
+
}
|
|
1722
1892
|
finally {
|
|
1723
1893
|
// OpenClaw can emit the final assistant session.message shortly after
|
|
1724
1894
|
// chatSend/collectReply returns. Keep discussion proactive muted briefly;
|
|
@@ -1751,36 +1921,90 @@ export class FeishuBot {
|
|
|
1751
1921
|
content: storedContent,
|
|
1752
1922
|
timestamp: Date.now(),
|
|
1753
1923
|
});
|
|
1754
|
-
|
|
1924
|
+
try {
|
|
1925
|
+
await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
|
|
1926
|
+
}
|
|
1927
|
+
catch (err) {
|
|
1928
|
+
await liveStatus?.fail().catch(() => { });
|
|
1929
|
+
throw err;
|
|
1930
|
+
}
|
|
1755
1931
|
}
|
|
1932
|
+
await liveStatus?.complete().catch(() => { });
|
|
1756
1933
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
1757
1934
|
}
|
|
1935
|
+
async sendTextMessage(chatId, text) {
|
|
1936
|
+
try {
|
|
1937
|
+
const res = await this.client.im.message.create({
|
|
1938
|
+
params: { receive_id_type: "chat_id" },
|
|
1939
|
+
data: {
|
|
1940
|
+
receive_id: chatId,
|
|
1941
|
+
content: JSON.stringify({ text }),
|
|
1942
|
+
msg_type: "text",
|
|
1943
|
+
},
|
|
1944
|
+
});
|
|
1945
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
1946
|
+
return res?.data?.message_id || res?.message_id;
|
|
1947
|
+
}
|
|
1948
|
+
catch (err) {
|
|
1949
|
+
if (this.isOutOfChatError(err))
|
|
1950
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
1951
|
+
throw err;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
async replyTextMessage(messageId, text) {
|
|
1955
|
+
const res = await this.client.im.message.reply({
|
|
1956
|
+
path: { message_id: messageId },
|
|
1957
|
+
data: {
|
|
1958
|
+
content: JSON.stringify({ text }),
|
|
1959
|
+
msg_type: "text",
|
|
1960
|
+
},
|
|
1961
|
+
});
|
|
1962
|
+
return res?.data?.message_id || res?.message_id;
|
|
1963
|
+
}
|
|
1964
|
+
async editTextMessage(messageId, text) {
|
|
1965
|
+
await this.client.im.v1.message.update({
|
|
1966
|
+
path: { message_id: messageId },
|
|
1967
|
+
data: {
|
|
1968
|
+
content: JSON.stringify({ text }),
|
|
1969
|
+
msg_type: "text",
|
|
1970
|
+
},
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
async deleteMessageById(messageId) {
|
|
1974
|
+
await this.client.im.v1.message.delete({ path: { message_id: messageId } });
|
|
1975
|
+
}
|
|
1758
1976
|
async replyMessage(messageId, text) {
|
|
1759
1977
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
1760
1978
|
const card = this.buildMarkdownCard(text);
|
|
1761
1979
|
try {
|
|
1762
|
-
await this.client.im.message.reply({
|
|
1980
|
+
const res = await this.client.im.message.reply({
|
|
1763
1981
|
path: { message_id: messageId },
|
|
1764
1982
|
data: {
|
|
1765
1983
|
content: JSON.stringify(card),
|
|
1766
1984
|
msg_type: "interactive",
|
|
1767
1985
|
},
|
|
1768
1986
|
});
|
|
1987
|
+
return res?.data?.message_id || res?.message_id;
|
|
1769
1988
|
}
|
|
1770
1989
|
catch {
|
|
1771
1990
|
// Fallback to plain text if card fails
|
|
1772
|
-
await this.client.im.message.reply({
|
|
1991
|
+
const res = await this.client.im.message.reply({
|
|
1773
1992
|
path: { message_id: messageId },
|
|
1774
1993
|
data: {
|
|
1775
1994
|
content: JSON.stringify({ text }),
|
|
1776
1995
|
msg_type: "text",
|
|
1777
1996
|
},
|
|
1778
1997
|
});
|
|
1998
|
+
return res?.data?.message_id || res?.message_id;
|
|
1779
1999
|
}
|
|
1780
2000
|
}
|
|
1781
2001
|
extractBridgeAttachments(reply) {
|
|
1782
2002
|
const attachments = [];
|
|
1783
|
-
|
|
2003
|
+
// Some models occasionally corrupt the closing tag from
|
|
2004
|
+
// </LMA_BRIDGE_ATTACHMENTS> to </parameter> while preserving the exact JSON
|
|
2005
|
+
// payload. Treat that as a recoverable marker shape; otherwise the whole
|
|
2006
|
+
// marker leaks as user-visible text and attachments are not sent.
|
|
2007
|
+
const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
|
|
1784
2008
|
let text = reply.replace(markerPattern, (_match, jsonText) => {
|
|
1785
2009
|
try {
|
|
1786
2010
|
const parsed = JSON.parse(String(jsonText).trim());
|
|
@@ -1850,6 +2074,16 @@ export class FeishuBot {
|
|
|
1850
2074
|
const msg = data?.msg || data?.message || err?.message || String(err);
|
|
1851
2075
|
return `${code}${msg}`.slice(0, 800);
|
|
1852
2076
|
}
|
|
2077
|
+
isOutOfChatError(err) {
|
|
2078
|
+
const data = err?.response?.data || err?.data;
|
|
2079
|
+
const code = data?.code;
|
|
2080
|
+
const msg = data?.msg || data?.message || err?.message || String(err);
|
|
2081
|
+
return Number(code) === 230002 || /Bot\/User can NOT be out of the chat/i.test(String(msg));
|
|
2082
|
+
}
|
|
2083
|
+
markCurrentBotUnavailable(chatId, err) {
|
|
2084
|
+
this.store.markBotUnavailableInChat(this.config.name, chatId, this.errorSummary(err));
|
|
2085
|
+
console.warn(`[${this.config.name}] Marked unavailable in chat ${chatId.slice(-8)} after Feishu out-of-chat error`);
|
|
2086
|
+
}
|
|
1853
2087
|
stripReadOnlyDocxFields(value) {
|
|
1854
2088
|
if (Array.isArray(value))
|
|
1855
2089
|
return value.map((item) => this.stripReadOnlyDocxFields(item));
|
|
@@ -1956,7 +2190,7 @@ export class FeishuBot {
|
|
|
1956
2190
|
async sendMessage(chatId, text) {
|
|
1957
2191
|
const card = this.buildMarkdownCard(text);
|
|
1958
2192
|
try {
|
|
1959
|
-
await this.client.im.message.create({
|
|
2193
|
+
const res = await this.client.im.message.create({
|
|
1960
2194
|
params: { receive_id_type: "chat_id" },
|
|
1961
2195
|
data: {
|
|
1962
2196
|
receive_id: chatId,
|
|
@@ -1964,12 +2198,16 @@ export class FeishuBot {
|
|
|
1964
2198
|
msg_type: "interactive",
|
|
1965
2199
|
},
|
|
1966
2200
|
});
|
|
2201
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2202
|
+
return res?.data?.message_id || res?.message_id;
|
|
1967
2203
|
}
|
|
1968
2204
|
catch (err) {
|
|
1969
2205
|
console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
|
|
2206
|
+
if (this.isOutOfChatError(err))
|
|
2207
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
1970
2208
|
// Fallback to plain text
|
|
1971
2209
|
try {
|
|
1972
|
-
await this.client.im.message.create({
|
|
2210
|
+
const res = await this.client.im.message.create({
|
|
1973
2211
|
params: { receive_id_type: "chat_id" },
|
|
1974
2212
|
data: {
|
|
1975
2213
|
receive_id: chatId,
|
|
@@ -1977,9 +2215,13 @@ export class FeishuBot {
|
|
|
1977
2215
|
msg_type: "text",
|
|
1978
2216
|
},
|
|
1979
2217
|
});
|
|
2218
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2219
|
+
return res?.data?.message_id || res?.message_id;
|
|
1980
2220
|
}
|
|
1981
2221
|
catch (fallbackErr) {
|
|
1982
2222
|
console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
|
|
2223
|
+
if (this.isOutOfChatError(fallbackErr))
|
|
2224
|
+
this.markCurrentBotUnavailable(chatId, fallbackErr);
|
|
1983
2225
|
throw fallbackErr;
|
|
1984
2226
|
}
|
|
1985
2227
|
}
|
|
@@ -1991,7 +2233,7 @@ export class FeishuBot {
|
|
|
1991
2233
|
sendOrdered(chatId, fn) {
|
|
1992
2234
|
const prev = this.sendQueue.get(chatId) || Promise.resolve();
|
|
1993
2235
|
const next = prev.then(fn, fn); // run even if previous failed
|
|
1994
|
-
this.sendQueue.set(chatId, next);
|
|
2236
|
+
this.sendQueue.set(chatId, next.then(() => undefined, () => undefined));
|
|
1995
2237
|
return next;
|
|
1996
2238
|
}
|
|
1997
2239
|
/**
|
|
@@ -2066,8 +2308,8 @@ export class FeishuBot {
|
|
|
2066
2308
|
}
|
|
2067
2309
|
this.store.setDiscussMode(chatId, true);
|
|
2068
2310
|
await this.replyMessage(messageId, this.isEn(chatId)
|
|
2069
|
-
? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all
|
|
2070
|
-
: `💬 Discuss 已开启\nChairman:${chairman}\n
|
|
2311
|
+
? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all non-muted bots + Chairman; free mode is ignored in Discuss\nRounds: ${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`
|
|
2312
|
+
: `💬 Discuss 已开启\nChairman:${chairman}\n参与者:当前群所有非 mute bot + Chairman(Discuss 模式忽略 free 开关)\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`);
|
|
2071
2313
|
return;
|
|
2072
2314
|
}
|
|
2073
2315
|
if (action === "off") {
|
|
@@ -2102,13 +2344,13 @@ export class FeishuBot {
|
|
|
2102
2344
|
`💬 Discuss: ${info?.discuss ? "on" : "off"}`,
|
|
2103
2345
|
`Rounds: ${info?.discussMaxRounds || 10}`,
|
|
2104
2346
|
`Chairman: ${info?.chairmanBot || "not set"}`,
|
|
2105
|
-
`Participants: ${participants.length ? participants.join(", ") : "(
|
|
2347
|
+
`Participants: ${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(none available)"}`,
|
|
2106
2348
|
active ? `Active: round ${active.currentRound}/${active.maxRounds}, topic=${active.topic.slice(0, 80)}` : "Active: none",
|
|
2107
2349
|
].join("\n") : [
|
|
2108
2350
|
`💬 Discuss: ${info?.discuss ? "on" : "off"}`,
|
|
2109
2351
|
`轮数:${info?.discussMaxRounds || 10}`,
|
|
2110
2352
|
`Chairman:${info?.chairmanBot || "未设置"}`,
|
|
2111
|
-
`参与者:${participants.length ? participants.join(", ") : "
|
|
2353
|
+
`参与者:${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(无可参与者)"}`,
|
|
2112
2354
|
active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
|
|
2113
2355
|
].join("\n"));
|
|
2114
2356
|
}
|
|
@@ -2154,8 +2396,9 @@ export class FeishuBot {
|
|
|
2154
2396
|
const mode = this.store.getBotMode(next, chatId);
|
|
2155
2397
|
const lines = [previous && previous !== next ? `✅ Chairman 已从 ${previous} 切换为 ${next}` : `✅ Chairman 已设置为 ${next}`];
|
|
2156
2398
|
lines.push("作用:");
|
|
2157
|
-
lines.push("-
|
|
2158
|
-
lines.push("- Discuss
|
|
2399
|
+
lines.push("- 非 Discuss 模式下:free bot 会主动回答普通消息;没有 free bot 时由 Chairman 兜底");
|
|
2400
|
+
lines.push("- Discuss 模式下:所有非 mute bot + Chairman 参与讨论,free 开关会被忽略");
|
|
2401
|
+
lines.push("- Chairman 优先级高于 mute,会参与兜底回答、主持、调停并做最终总结");
|
|
2159
2402
|
if (mode === "mute")
|
|
2160
2403
|
lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
|
|
2161
2404
|
await this.replyMessage(messageId, lines.join("\n"));
|
|
@@ -2221,6 +2464,7 @@ export class FeishuBot {
|
|
|
2221
2464
|
const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
|
|
2222
2465
|
const status = session?.status || "unknown";
|
|
2223
2466
|
const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
|
|
2467
|
+
const liveStatus = this.store.getBotLiveStatus(this.config.name, chatId) ? "📡 开启" : "📴 关闭";
|
|
2224
2468
|
const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
|
|
2225
2469
|
const chairman = chatType === "p2p" ? "" : this.store.getChairmanBot(chatId);
|
|
2226
2470
|
const chairmanStatus = chatType === "p2p"
|
|
@@ -2242,6 +2486,7 @@ export class FeishuBot {
|
|
|
2242
2486
|
`🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
|
|
2243
2487
|
`📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
|
|
2244
2488
|
`🔧 Verbose: ${verboseStatus}`,
|
|
2489
|
+
`📡 Live Status: ${liveStatus}`,
|
|
2245
2490
|
`🎛️ Mode: ${mode}`,
|
|
2246
2491
|
`👑 Chairman: ${chairmanStatus}`,
|
|
2247
2492
|
`🌐 Locale: ${localeStatus}`,
|