openclaw-lark-multi-agent 1.0.21 → 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 +6 -1
- package/dist/feishu-bot.js +134 -41
- package/dist/live-status.d.ts +56 -0
- package/dist/live-status.js +184 -0
- package/dist/message-store.d.ts +2 -0
- package/dist/message-store.js +24 -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.d.ts
CHANGED
|
@@ -73,8 +73,10 @@ export declare class FeishuBot {
|
|
|
73
73
|
static getByName(name: string): FeishuBot | undefined;
|
|
74
74
|
static findByOpenId(openId: string): FeishuBot | undefined;
|
|
75
75
|
private handleMessage;
|
|
76
|
+
private shouldUseLiveStatus;
|
|
76
77
|
private getUnhealthySessionStatus;
|
|
77
|
-
private
|
|
78
|
+
private warnUnhealthySessionAndContinue;
|
|
79
|
+
private stopForUnhealthySession;
|
|
78
80
|
private withSessionHealthMonitor;
|
|
79
81
|
/**
|
|
80
82
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
@@ -125,6 +127,9 @@ export declare class FeishuBot {
|
|
|
125
127
|
private getChairmanParticipant;
|
|
126
128
|
private asDiscussionParticipant;
|
|
127
129
|
private runDiscussionTurn;
|
|
130
|
+
private replyTextMessage;
|
|
131
|
+
private editTextMessage;
|
|
132
|
+
private deleteMessageById;
|
|
128
133
|
private replyMessage;
|
|
129
134
|
private extractBridgeAttachments;
|
|
130
135
|
private pushBridgeAttachment;
|
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";
|
|
@@ -447,7 +448,7 @@ export class FeishuBot {
|
|
|
447
448
|
cleanText = commandText;
|
|
448
449
|
}
|
|
449
450
|
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"]);
|
|
451
|
+
const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
|
|
451
452
|
const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
|
|
452
453
|
// --- Record to local store (ALL messages, before command/response checks) ---
|
|
453
454
|
const senderName = isBot
|
|
@@ -591,12 +592,13 @@ export class FeishuBot {
|
|
|
591
592
|
`🔄 /reset — 重置当前 bot 的 OpenClaw session`,
|
|
592
593
|
`⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
|
|
593
594
|
`🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
|
|
594
|
-
|
|
595
|
+
`📡 /livestatus [on|off] — 开关非 verbose 下的单条覆盖式运行状态消息(默认开启;/livestatus off 可关闭)`,
|
|
596
|
+
`🔓 /free [on|off] — 开关当前 bot 的 free 模式(仅非 Discuss 模式下影响普通消息主动回复)`,
|
|
595
597
|
`🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
|
|
596
598
|
`🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
|
|
597
599
|
`🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
|
|
598
|
-
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot
|
|
599
|
-
`👑 /chairman @Bot|off —
|
|
600
|
+
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论(所有非 mute bot + Chairman 参与,忽略 free)`,
|
|
601
|
+
`👑 /chairman @Bot|off — 设置/清除本群唯一 Chairman(状态见 /status)`,
|
|
600
602
|
`🌐 /locale zh|en — 设置/查看当前群语言`,
|
|
601
603
|
`❓ /help — 显示此帮助信息`,
|
|
602
604
|
``,
|
|
@@ -679,6 +681,18 @@ export class FeishuBot {
|
|
|
679
681
|
markCommandSynced();
|
|
680
682
|
return;
|
|
681
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
|
+
}
|
|
682
696
|
if (commandName === "/free") {
|
|
683
697
|
if (chatType === "p2p") {
|
|
684
698
|
await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
|
|
@@ -792,16 +806,18 @@ export class FeishuBot {
|
|
|
792
806
|
}
|
|
793
807
|
else if (this.isDiscussionCoordinator()) {
|
|
794
808
|
await this.sendMessage(chatId, this.isEn(chatId)
|
|
795
|
-
? "💬 Discuss is on, but there
|
|
796
|
-
: "💬 Discuss
|
|
809
|
+
? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
|
|
810
|
+
: "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
|
|
797
811
|
}
|
|
798
812
|
if (insertedId > 0)
|
|
799
813
|
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
800
814
|
return;
|
|
801
815
|
}
|
|
802
816
|
}
|
|
803
|
-
// --- Mute mode: do not forward
|
|
804
|
-
|
|
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) {
|
|
805
821
|
if (routing.isCurrentBotMentioned) {
|
|
806
822
|
await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
|
|
807
823
|
if (insertedId > 0) {
|
|
@@ -821,8 +837,7 @@ export class FeishuBot {
|
|
|
821
837
|
const pending = this.pendingAckMessages.get(chatId) || [];
|
|
822
838
|
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
823
839
|
if (unhealthy) {
|
|
824
|
-
await this.
|
|
825
|
-
return;
|
|
840
|
+
await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
|
|
826
841
|
}
|
|
827
842
|
// Anti-loop
|
|
828
843
|
const streak = this.store.getBotStreak(chatId, this.config.name);
|
|
@@ -860,6 +875,15 @@ export class FeishuBot {
|
|
|
860
875
|
console.error(`[${this.config.name}] Error:`, err);
|
|
861
876
|
}
|
|
862
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
|
+
}
|
|
863
887
|
async getUnhealthySessionStatus(chatId) {
|
|
864
888
|
const sessionKey = this.getSessionKey(chatId);
|
|
865
889
|
try {
|
|
@@ -877,24 +901,28 @@ export class FeishuBot {
|
|
|
877
901
|
}
|
|
878
902
|
return null;
|
|
879
903
|
}
|
|
880
|
-
async
|
|
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) {
|
|
881
911
|
const sessionKey = this.getSessionKey(chatId);
|
|
882
912
|
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
883
913
|
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
914
|
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
915
|
+
const remainingAcks = [];
|
|
888
916
|
for (const ack of acks) {
|
|
889
917
|
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
890
|
-
await this.addReaction(ack.messageId, "
|
|
918
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
891
919
|
}
|
|
892
|
-
this.pendingAckMessages.set(chatId,
|
|
893
|
-
await this.addReaction(messageId, "
|
|
920
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
921
|
+
await this.addReaction(messageId, "DONE").catch(() => { });
|
|
894
922
|
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};
|
|
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)}`);
|
|
898
926
|
}
|
|
899
927
|
async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
|
|
900
928
|
let done = false;
|
|
@@ -906,7 +934,7 @@ export class FeishuBot {
|
|
|
906
934
|
break;
|
|
907
935
|
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
908
936
|
if (unhealthy) {
|
|
909
|
-
await this.
|
|
937
|
+
await this.stopForUnhealthySession(chatId, messageId, unhealthy);
|
|
910
938
|
throw new UnhealthySessionError(unhealthy);
|
|
911
939
|
}
|
|
912
940
|
}
|
|
@@ -946,12 +974,11 @@ export class FeishuBot {
|
|
|
946
974
|
if (pendingHumanTriggers.length === 0) {
|
|
947
975
|
break;
|
|
948
976
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
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.
|
|
955
982
|
this.busyChats.set(chatId, Date.now());
|
|
956
983
|
// Pick the batch to process this loop. Consecutive plain human messages are
|
|
957
984
|
// merged into a single run; native escaped commands (//x) are processed on
|
|
@@ -1080,6 +1107,7 @@ export class FeishuBot {
|
|
|
1080
1107
|
}
|
|
1081
1108
|
}
|
|
1082
1109
|
let runAcceptedByOpenClaw = false;
|
|
1110
|
+
let liveStatus;
|
|
1083
1111
|
try {
|
|
1084
1112
|
const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
|
|
1085
1113
|
let reply;
|
|
@@ -1098,6 +1126,20 @@ export class FeishuBot {
|
|
|
1098
1126
|
currentMarkedSubmitted = true;
|
|
1099
1127
|
}
|
|
1100
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 回复");
|
|
1101
1143
|
try {
|
|
1102
1144
|
reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
|
|
1103
1145
|
sessionKey,
|
|
@@ -1123,6 +1165,7 @@ export class FeishuBot {
|
|
|
1123
1165
|
// INSERT OR IGNORE in message_sync.
|
|
1124
1166
|
markSubmittedBatch(`submitted:${runId}`);
|
|
1125
1167
|
},
|
|
1168
|
+
onProgress: (event) => liveStatus?.progress(event),
|
|
1126
1169
|
}));
|
|
1127
1170
|
}
|
|
1128
1171
|
finally {
|
|
@@ -1145,6 +1188,7 @@ export class FeishuBot {
|
|
|
1145
1188
|
// submitted far enough that auto-replaying risks duplicate delivery.
|
|
1146
1189
|
// The user can resend a new message if they want to retry.
|
|
1147
1190
|
markSubmittedBatch("empty-returned");
|
|
1191
|
+
liveStatus?.dispose();
|
|
1148
1192
|
console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
|
|
1149
1193
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
1150
1194
|
const remainingAcks = [];
|
|
@@ -1218,7 +1262,12 @@ export class FeishuBot {
|
|
|
1218
1262
|
}
|
|
1219
1263
|
else {
|
|
1220
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.
|
|
1221
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();
|
|
1222
1271
|
// Mark every merged trigger as delivered so retries/restarts do
|
|
1223
1272
|
// not re-process any of them.
|
|
1224
1273
|
for (const id of mergedTriggerIds)
|
|
@@ -1228,13 +1277,18 @@ export class FeishuBot {
|
|
|
1228
1277
|
// enqueueAndDispatchDelivery already sent a user-visible delivery
|
|
1229
1278
|
// failure. Do not fall through to the generic provider-error path;
|
|
1230
1279
|
// that creates a second misleading "bot did not complete" message.
|
|
1280
|
+
await liveStatus?.fail().catch(() => { });
|
|
1231
1281
|
console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
|
|
1232
1282
|
}
|
|
1233
1283
|
}
|
|
1234
1284
|
}
|
|
1235
1285
|
if (isRuntimeFailure && lastHuman.messageId) {
|
|
1286
|
+
liveStatus?.dispose();
|
|
1236
1287
|
this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
|
|
1237
1288
|
}
|
|
1289
|
+
else if (!shouldReply && !hasAttachments) {
|
|
1290
|
+
liveStatus?.dispose();
|
|
1291
|
+
}
|
|
1238
1292
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
1239
1293
|
// Replace ack reactions with DONE only for trigger messages actually
|
|
1240
1294
|
// processed in this run. Queued messages that arrived mid-run keep their
|
|
@@ -1254,18 +1308,28 @@ export class FeishuBot {
|
|
|
1254
1308
|
}
|
|
1255
1309
|
catch (err) {
|
|
1256
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(() => { });
|
|
1257
1315
|
console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
|
|
1258
1316
|
break;
|
|
1259
1317
|
}
|
|
1260
1318
|
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
1261
1319
|
const errorText = this.formatUserVisibleError(err);
|
|
1262
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.
|
|
1263
1323
|
await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
|
|
1264
1324
|
.then(() => {
|
|
1265
1325
|
if (triggerId)
|
|
1266
1326
|
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
1267
1327
|
})
|
|
1268
1328
|
.catch(() => { });
|
|
1329
|
+
await liveStatus?.fail().catch(() => { });
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
await liveStatus?.fail().catch(() => { });
|
|
1269
1333
|
}
|
|
1270
1334
|
if (triggerId) {
|
|
1271
1335
|
// At-most-once: if anything after send attempt failed, this batch has
|
|
@@ -1282,7 +1346,7 @@ export class FeishuBot {
|
|
|
1282
1346
|
for (const ack of pendingAcks) {
|
|
1283
1347
|
if (completedTriggerIdSet.has(ack.rowId)) {
|
|
1284
1348
|
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
1285
|
-
await this.addReaction(ack.messageId, "
|
|
1349
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
1286
1350
|
}
|
|
1287
1351
|
else {
|
|
1288
1352
|
remainingAcks.push(ack);
|
|
@@ -1477,7 +1541,7 @@ export class FeishuBot {
|
|
|
1477
1541
|
const t = text.trim();
|
|
1478
1542
|
if (!t)
|
|
1479
1543
|
return false;
|
|
1480
|
-
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)
|
|
1481
1545
|
|| this.isBridgeControlReply(t);
|
|
1482
1546
|
}
|
|
1483
1547
|
isRuntimeFailureText(text) {
|
|
@@ -1688,7 +1752,7 @@ export class FeishuBot {
|
|
|
1688
1752
|
getDiscussionParticipants(chatId) {
|
|
1689
1753
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1690
1754
|
return Array.from(FeishuBot.allBots.values())
|
|
1691
|
-
.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")
|
|
1692
1756
|
.map((bot) => this.asDiscussionParticipant(bot, chatId));
|
|
1693
1757
|
}
|
|
1694
1758
|
getChairmanParticipant(chatId) {
|
|
@@ -1755,27 +1819,51 @@ export class FeishuBot {
|
|
|
1755
1819
|
}
|
|
1756
1820
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
1757
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
|
+
}
|
|
1758
1844
|
async replyMessage(messageId, text) {
|
|
1759
1845
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
1760
1846
|
const card = this.buildMarkdownCard(text);
|
|
1761
1847
|
try {
|
|
1762
|
-
await this.client.im.message.reply({
|
|
1848
|
+
const res = await this.client.im.message.reply({
|
|
1763
1849
|
path: { message_id: messageId },
|
|
1764
1850
|
data: {
|
|
1765
1851
|
content: JSON.stringify(card),
|
|
1766
1852
|
msg_type: "interactive",
|
|
1767
1853
|
},
|
|
1768
1854
|
});
|
|
1855
|
+
return res?.data?.message_id || res?.message_id;
|
|
1769
1856
|
}
|
|
1770
1857
|
catch {
|
|
1771
1858
|
// Fallback to plain text if card fails
|
|
1772
|
-
await this.client.im.message.reply({
|
|
1859
|
+
const res = await this.client.im.message.reply({
|
|
1773
1860
|
path: { message_id: messageId },
|
|
1774
1861
|
data: {
|
|
1775
1862
|
content: JSON.stringify({ text }),
|
|
1776
1863
|
msg_type: "text",
|
|
1777
1864
|
},
|
|
1778
1865
|
});
|
|
1866
|
+
return res?.data?.message_id || res?.message_id;
|
|
1779
1867
|
}
|
|
1780
1868
|
}
|
|
1781
1869
|
extractBridgeAttachments(reply) {
|
|
@@ -1956,7 +2044,7 @@ export class FeishuBot {
|
|
|
1956
2044
|
async sendMessage(chatId, text) {
|
|
1957
2045
|
const card = this.buildMarkdownCard(text);
|
|
1958
2046
|
try {
|
|
1959
|
-
await this.client.im.message.create({
|
|
2047
|
+
const res = await this.client.im.message.create({
|
|
1960
2048
|
params: { receive_id_type: "chat_id" },
|
|
1961
2049
|
data: {
|
|
1962
2050
|
receive_id: chatId,
|
|
@@ -1964,12 +2052,13 @@ export class FeishuBot {
|
|
|
1964
2052
|
msg_type: "interactive",
|
|
1965
2053
|
},
|
|
1966
2054
|
});
|
|
2055
|
+
return res?.data?.message_id || res?.message_id;
|
|
1967
2056
|
}
|
|
1968
2057
|
catch (err) {
|
|
1969
2058
|
console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
|
|
1970
2059
|
// Fallback to plain text
|
|
1971
2060
|
try {
|
|
1972
|
-
await this.client.im.message.create({
|
|
2061
|
+
const res = await this.client.im.message.create({
|
|
1973
2062
|
params: { receive_id_type: "chat_id" },
|
|
1974
2063
|
data: {
|
|
1975
2064
|
receive_id: chatId,
|
|
@@ -1977,6 +2066,7 @@ export class FeishuBot {
|
|
|
1977
2066
|
msg_type: "text",
|
|
1978
2067
|
},
|
|
1979
2068
|
});
|
|
2069
|
+
return res?.data?.message_id || res?.message_id;
|
|
1980
2070
|
}
|
|
1981
2071
|
catch (fallbackErr) {
|
|
1982
2072
|
console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
|
|
@@ -1991,7 +2081,7 @@ export class FeishuBot {
|
|
|
1991
2081
|
sendOrdered(chatId, fn) {
|
|
1992
2082
|
const prev = this.sendQueue.get(chatId) || Promise.resolve();
|
|
1993
2083
|
const next = prev.then(fn, fn); // run even if previous failed
|
|
1994
|
-
this.sendQueue.set(chatId, next);
|
|
2084
|
+
this.sendQueue.set(chatId, next.then(() => undefined, () => undefined));
|
|
1995
2085
|
return next;
|
|
1996
2086
|
}
|
|
1997
2087
|
/**
|
|
@@ -2066,8 +2156,8 @@ export class FeishuBot {
|
|
|
2066
2156
|
}
|
|
2067
2157
|
this.store.setDiscussMode(chatId, true);
|
|
2068
2158
|
await this.replyMessage(messageId, this.isEn(chatId)
|
|
2069
|
-
? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all
|
|
2070
|
-
: `💬 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}`);
|
|
2071
2161
|
return;
|
|
2072
2162
|
}
|
|
2073
2163
|
if (action === "off") {
|
|
@@ -2102,13 +2192,13 @@ export class FeishuBot {
|
|
|
2102
2192
|
`💬 Discuss: ${info?.discuss ? "on" : "off"}`,
|
|
2103
2193
|
`Rounds: ${info?.discussMaxRounds || 10}`,
|
|
2104
2194
|
`Chairman: ${info?.chairmanBot || "not set"}`,
|
|
2105
|
-
`Participants: ${participants.length ? participants.join(", ") : "(
|
|
2195
|
+
`Participants: ${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(none available)"}`,
|
|
2106
2196
|
active ? `Active: round ${active.currentRound}/${active.maxRounds}, topic=${active.topic.slice(0, 80)}` : "Active: none",
|
|
2107
2197
|
].join("\n") : [
|
|
2108
2198
|
`💬 Discuss: ${info?.discuss ? "on" : "off"}`,
|
|
2109
2199
|
`轮数:${info?.discussMaxRounds || 10}`,
|
|
2110
2200
|
`Chairman:${info?.chairmanBot || "未设置"}`,
|
|
2111
|
-
`参与者:${participants.length ? participants.join(", ") : "
|
|
2201
|
+
`参与者:${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(无可参与者)"}`,
|
|
2112
2202
|
active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
|
|
2113
2203
|
].join("\n"));
|
|
2114
2204
|
}
|
|
@@ -2154,8 +2244,9 @@ export class FeishuBot {
|
|
|
2154
2244
|
const mode = this.store.getBotMode(next, chatId);
|
|
2155
2245
|
const lines = [previous && previous !== next ? `✅ Chairman 已从 ${previous} 切换为 ${next}` : `✅ Chairman 已设置为 ${next}`];
|
|
2156
2246
|
lines.push("作用:");
|
|
2157
|
-
lines.push("-
|
|
2158
|
-
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,会参与兜底回答、主持、调停并做最终总结");
|
|
2159
2250
|
if (mode === "mute")
|
|
2160
2251
|
lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
|
|
2161
2252
|
await this.replyMessage(messageId, lines.join("\n"));
|
|
@@ -2221,6 +2312,7 @@ export class FeishuBot {
|
|
|
2221
2312
|
const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
|
|
2222
2313
|
const status = session?.status || "unknown";
|
|
2223
2314
|
const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
|
|
2315
|
+
const liveStatus = this.store.getBotLiveStatus(this.config.name, chatId) ? "📡 开启" : "📴 关闭";
|
|
2224
2316
|
const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
|
|
2225
2317
|
const chairman = chatType === "p2p" ? "" : this.store.getChairmanBot(chatId);
|
|
2226
2318
|
const chairmanStatus = chatType === "p2p"
|
|
@@ -2242,6 +2334,7 @@ export class FeishuBot {
|
|
|
2242
2334
|
`🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
|
|
2243
2335
|
`📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
|
|
2244
2336
|
`🔧 Verbose: ${verboseStatus}`,
|
|
2337
|
+
`📡 Live Status: ${liveStatus}`,
|
|
2245
2338
|
`🎛️ Mode: ${mode}`,
|
|
2246
2339
|
`👑 Chairman: ${chairmanStatus}`,
|
|
2247
2340
|
`🌐 Locale: ${localeStatus}`,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ProgressEvent } from "./openclaw-client";
|
|
2
|
+
export type LiveStatusCallbacks = {
|
|
3
|
+
create: (text: string) => Promise<string | undefined>;
|
|
4
|
+
edit: (messageId: string, text: string) => Promise<void>;
|
|
5
|
+
warn?: (message: string, err?: unknown) => void;
|
|
6
|
+
};
|
|
7
|
+
export type LiveStatusOptions = {
|
|
8
|
+
botName: string;
|
|
9
|
+
locale?: "zh" | "en";
|
|
10
|
+
delayMs?: number;
|
|
11
|
+
maxChars?: number;
|
|
12
|
+
maxEdits?: number;
|
|
13
|
+
disableHint?: string;
|
|
14
|
+
};
|
|
15
|
+
export declare class LiveStatusController {
|
|
16
|
+
private readonly callbacks;
|
|
17
|
+
private readonly opts;
|
|
18
|
+
private messageId?;
|
|
19
|
+
private lastSentText;
|
|
20
|
+
private detail;
|
|
21
|
+
/** Number of edits already issued against the live message (excludes create). */
|
|
22
|
+
private editCount;
|
|
23
|
+
private createTimer?;
|
|
24
|
+
private createPromise?;
|
|
25
|
+
private finalized;
|
|
26
|
+
private disabled;
|
|
27
|
+
/** True after the running-state budget is spent; future progress is ignored. */
|
|
28
|
+
private runningLimitReached;
|
|
29
|
+
constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
|
|
30
|
+
get id(): string | undefined;
|
|
31
|
+
start(initialDetail?: string): void;
|
|
32
|
+
progress(event: ProgressEvent | string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Finish the live status when the real (final) reply is delivered separately.
|
|
35
|
+
* The final answer is sent by the normal interactive-card path, so the status
|
|
36
|
+
* message must NOT be overwritten with the answer (text type does not render
|
|
37
|
+
* Markdown). Feishu renders message deletion as a visible "recalled a message"
|
|
38
|
+
* tombstone, so keep the status message and mark it done instead.
|
|
39
|
+
*/
|
|
40
|
+
complete(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Finish the live status when the run failed and the error is delivered by the
|
|
43
|
+
* normal path. Use a neutral/negative marker so it does not contradict the
|
|
44
|
+
* separately delivered error message.
|
|
45
|
+
*/
|
|
46
|
+
fail(): Promise<void>;
|
|
47
|
+
dispose(): void;
|
|
48
|
+
private stopTimers;
|
|
49
|
+
private ensureCreatedNow;
|
|
50
|
+
private ensureCreated;
|
|
51
|
+
private editRunningStatus;
|
|
52
|
+
private safeEdit;
|
|
53
|
+
private formatToolStart;
|
|
54
|
+
private formatStatus;
|
|
55
|
+
private formatLimitNotice;
|
|
56
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
|
|
2
|
+
const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
|
|
3
|
+
// Feishu caps a single message at 20 edits (code=230072 once exhausted). Keep the
|
|
4
|
+
// final edit for the completion marker; running-state updates may consume at most
|
|
5
|
+
// maxEdits - 1, with the last running edit reserved for an explicit limit notice.
|
|
6
|
+
const DEFAULT_MAX_EDITS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_EDITS || 20);
|
|
7
|
+
export class LiveStatusController {
|
|
8
|
+
callbacks;
|
|
9
|
+
opts;
|
|
10
|
+
messageId;
|
|
11
|
+
lastSentText = "";
|
|
12
|
+
detail = "";
|
|
13
|
+
/** Number of edits already issued against the live message (excludes create). */
|
|
14
|
+
editCount = 0;
|
|
15
|
+
createTimer;
|
|
16
|
+
createPromise;
|
|
17
|
+
finalized = false;
|
|
18
|
+
disabled = false;
|
|
19
|
+
/** True after the running-state budget is spent; future progress is ignored. */
|
|
20
|
+
runningLimitReached = false;
|
|
21
|
+
constructor(callbacks, opts) {
|
|
22
|
+
this.callbacks = callbacks;
|
|
23
|
+
this.opts = opts;
|
|
24
|
+
}
|
|
25
|
+
get id() { return this.messageId; }
|
|
26
|
+
start(initialDetail) {
|
|
27
|
+
if (this.disabled || this.finalized || this.createTimer || this.messageId)
|
|
28
|
+
return;
|
|
29
|
+
this.detail = initialDetail || "";
|
|
30
|
+
this.createTimer = setTimeout(() => {
|
|
31
|
+
this.createTimer = undefined;
|
|
32
|
+
this.createPromise = this.ensureCreated();
|
|
33
|
+
}, this.opts.delayMs ?? DEFAULT_DELAY_MS);
|
|
34
|
+
}
|
|
35
|
+
async progress(event) {
|
|
36
|
+
if (this.disabled || this.finalized || this.runningLimitReached)
|
|
37
|
+
return;
|
|
38
|
+
// Only tool-call start is meaningful enough to spend one of Feishu's limited
|
|
39
|
+
// edit slots. Ignore verbose assistant notes, lifecycle ticks, tool end, and
|
|
40
|
+
// tool error; the final/error answer is delivered by the normal message path.
|
|
41
|
+
if (typeof event !== "string") {
|
|
42
|
+
if (event.kind !== "tool" || event.phase !== "start")
|
|
43
|
+
return;
|
|
44
|
+
this.detail = this.formatToolStart(event);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// String progress is kept for tests/backwards compatibility, but in normal
|
|
48
|
+
// runtime OpenClaw progress is structured and only tool start passes above.
|
|
49
|
+
this.detail = event.trim();
|
|
50
|
+
}
|
|
51
|
+
if (!this.detail)
|
|
52
|
+
return;
|
|
53
|
+
await this.ensureCreatedNow();
|
|
54
|
+
await this.editRunningStatus();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Finish the live status when the real (final) reply is delivered separately.
|
|
58
|
+
* The final answer is sent by the normal interactive-card path, so the status
|
|
59
|
+
* message must NOT be overwritten with the answer (text type does not render
|
|
60
|
+
* Markdown). Feishu renders message deletion as a visible "recalled a message"
|
|
61
|
+
* tombstone, so keep the status message and mark it done instead.
|
|
62
|
+
*/
|
|
63
|
+
async complete() {
|
|
64
|
+
this.finalized = true;
|
|
65
|
+
this.stopTimers();
|
|
66
|
+
if (this.createPromise)
|
|
67
|
+
await this.createPromise.catch(() => { });
|
|
68
|
+
if (!this.messageId || this.disabled)
|
|
69
|
+
return;
|
|
70
|
+
const doneText = this.opts.locale === "en"
|
|
71
|
+
? `\u2705 ${this.opts.botName} done`
|
|
72
|
+
: `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
|
|
73
|
+
await this.safeEdit(doneText, true);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Finish the live status when the run failed and the error is delivered by the
|
|
77
|
+
* normal path. Use a neutral/negative marker so it does not contradict the
|
|
78
|
+
* separately delivered error message.
|
|
79
|
+
*/
|
|
80
|
+
async fail() {
|
|
81
|
+
this.finalized = true;
|
|
82
|
+
this.stopTimers();
|
|
83
|
+
if (this.createPromise)
|
|
84
|
+
await this.createPromise.catch(() => { });
|
|
85
|
+
if (!this.messageId || this.disabled)
|
|
86
|
+
return;
|
|
87
|
+
const failedText = this.opts.locale === "en"
|
|
88
|
+
? `\u26A0\uFE0F ${this.opts.botName} stopped`
|
|
89
|
+
: `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
|
|
90
|
+
await this.safeEdit(failedText, true);
|
|
91
|
+
}
|
|
92
|
+
dispose() {
|
|
93
|
+
this.finalized = true;
|
|
94
|
+
this.stopTimers();
|
|
95
|
+
}
|
|
96
|
+
stopTimers() {
|
|
97
|
+
if (this.createTimer) {
|
|
98
|
+
clearTimeout(this.createTimer);
|
|
99
|
+
this.createTimer = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async ensureCreatedNow() {
|
|
103
|
+
if (this.createTimer) {
|
|
104
|
+
clearTimeout(this.createTimer);
|
|
105
|
+
this.createTimer = undefined;
|
|
106
|
+
this.createPromise = this.ensureCreated();
|
|
107
|
+
}
|
|
108
|
+
if (this.createPromise)
|
|
109
|
+
await this.createPromise.catch(() => { });
|
|
110
|
+
}
|
|
111
|
+
async ensureCreated() {
|
|
112
|
+
if (this.disabled || this.finalized || this.messageId)
|
|
113
|
+
return;
|
|
114
|
+
const text = this.formatStatus(true);
|
|
115
|
+
try {
|
|
116
|
+
const id = await this.callbacks.create(text);
|
|
117
|
+
if (!id) {
|
|
118
|
+
this.disabled = true;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.messageId = id;
|
|
122
|
+
this.lastSentText = text;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
this.disabled = true;
|
|
126
|
+
this.callbacks.warn?.("live status create failed", err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async editRunningStatus() {
|
|
130
|
+
if (!this.messageId || this.disabled || this.finalized || this.runningLimitReached)
|
|
131
|
+
return;
|
|
132
|
+
const maxEdits = this.opts.maxEdits ?? DEFAULT_MAX_EDITS;
|
|
133
|
+
const runningBudget = Math.max(0, maxEdits - 1); // reserve final edit for complete()
|
|
134
|
+
if (this.editCount >= runningBudget)
|
|
135
|
+
return;
|
|
136
|
+
const isLimitNotice = this.editCount === runningBudget - 1;
|
|
137
|
+
const text = isLimitNotice ? this.formatLimitNotice() : this.formatStatus();
|
|
138
|
+
const ok = await this.safeEdit(text);
|
|
139
|
+
if (ok && isLimitNotice)
|
|
140
|
+
this.runningLimitReached = true;
|
|
141
|
+
}
|
|
142
|
+
async safeEdit(text, force = false) {
|
|
143
|
+
if (!this.messageId || this.disabled)
|
|
144
|
+
return false;
|
|
145
|
+
if (!force && this.finalized)
|
|
146
|
+
return false;
|
|
147
|
+
if (!force && text === this.lastSentText)
|
|
148
|
+
return true;
|
|
149
|
+
try {
|
|
150
|
+
await this.callbacks.edit(this.messageId, text);
|
|
151
|
+
this.lastSentText = text;
|
|
152
|
+
this.editCount++;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
this.disabled = true;
|
|
157
|
+
this.stopTimers();
|
|
158
|
+
this.callbacks.warn?.("live status edit failed", err);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
formatToolStart(event) {
|
|
163
|
+
const raw = event.text || event.name || "";
|
|
164
|
+
const prefix = `${event.name} start`;
|
|
165
|
+
let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
|
|
166
|
+
if (detail.startsWith(":"))
|
|
167
|
+
detail = detail.slice(1).trim();
|
|
168
|
+
return detail ? `${event.name}: ${detail}` : event.name;
|
|
169
|
+
}
|
|
170
|
+
formatStatus(includeDisableHint = false) {
|
|
171
|
+
const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
|
|
172
|
+
const clean = (this.detail || "").replace(/\s+/g, " ").trim();
|
|
173
|
+
const clipped = clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 1))}\u2026` : clean;
|
|
174
|
+
const base = !clipped
|
|
175
|
+
? (this.opts.locale === "en" ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`)
|
|
176
|
+
: (this.opts.locale === "en" ? `${this.opts.botName} is working: ${clipped}` : `${this.opts.botName} \u6b63\u5728\u6267\u884c\uff1a${clipped}`);
|
|
177
|
+
return includeDisableHint && this.opts.disableHint ? `${base}\n${this.opts.disableHint}` : base;
|
|
178
|
+
}
|
|
179
|
+
formatLimitNotice() {
|
|
180
|
+
return this.opts.locale === "en"
|
|
181
|
+
? `${this.opts.botName} reached the update limit and is still running`
|
|
182
|
+
: `${this.opts.botName} \u5df2\u8fbe\u66f4\u65b0\u9650\u5236\uff0c\u6b63\u5728\u6301\u7eed\u6267\u884c\u4e2d`;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/message-store.d.ts
CHANGED
|
@@ -119,6 +119,8 @@ export declare class MessageStore {
|
|
|
119
119
|
getChairmanBot(chatId: string): string;
|
|
120
120
|
setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
|
|
121
121
|
getBotVerbose(botName: string, chatId: string): boolean;
|
|
122
|
+
setBotLiveStatus(botName: string, chatId: string, on: boolean): void;
|
|
123
|
+
getBotLiveStatus(botName: string, chatId: string): boolean;
|
|
122
124
|
setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
|
|
123
125
|
getBotMode(botName: string, chatId: string): BotChatMode;
|
|
124
126
|
setBotFreeDiscussion(botName: string, chatId: string, on: boolean): void;
|
package/dist/message-store.js
CHANGED
|
@@ -104,6 +104,7 @@ export class MessageStore {
|
|
|
104
104
|
bot_name TEXT NOT NULL,
|
|
105
105
|
chat_id TEXT NOT NULL,
|
|
106
106
|
verbose INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
live_status INTEGER NOT NULL DEFAULT 1,
|
|
107
108
|
free_discussion INTEGER NOT NULL DEFAULT 0,
|
|
108
109
|
mode TEXT NOT NULL DEFAULT 'normal',
|
|
109
110
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
@@ -135,6 +136,13 @@ export class MessageStore {
|
|
|
135
136
|
);
|
|
136
137
|
CREATE INDEX IF NOT EXISTS idx_delivery_outbox_pending ON delivery_outbox(status, created_at);
|
|
137
138
|
`);
|
|
139
|
+
// Migration: add per-bot live status toggle if missing
|
|
140
|
+
try {
|
|
141
|
+
this.db.exec(`ALTER TABLE bot_chat_settings ADD COLUMN live_status INTEGER NOT NULL DEFAULT 1`);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Column already exists
|
|
145
|
+
}
|
|
138
146
|
// Migration: add delivery_outbox delivery key columns if missing
|
|
139
147
|
try {
|
|
140
148
|
this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_key TEXT NOT NULL DEFAULT ''`);
|
|
@@ -694,6 +702,22 @@ export class MessageStore {
|
|
|
694
702
|
`).get(botName, chatId);
|
|
695
703
|
return !!row?.verbose;
|
|
696
704
|
}
|
|
705
|
+
setBotLiveStatus(botName, chatId, on) {
|
|
706
|
+
this.db.prepare(`
|
|
707
|
+
INSERT INTO bot_chat_settings (bot_name, chat_id, live_status, updated_at)
|
|
708
|
+
VALUES (?, ?, ?, ?)
|
|
709
|
+
ON CONFLICT (bot_name, chat_id) DO UPDATE SET
|
|
710
|
+
live_status = excluded.live_status,
|
|
711
|
+
updated_at = excluded.updated_at
|
|
712
|
+
`).run(botName, chatId, on ? 1 : 0, Date.now());
|
|
713
|
+
}
|
|
714
|
+
getBotLiveStatus(botName, chatId) {
|
|
715
|
+
const row = this.db.prepare(`
|
|
716
|
+
SELECT live_status FROM bot_chat_settings
|
|
717
|
+
WHERE bot_name = ? AND chat_id = ?
|
|
718
|
+
`).get(botName, chatId);
|
|
719
|
+
return row ? row.live_status !== 0 : true;
|
|
720
|
+
}
|
|
697
721
|
setBotMode(botName, chatId, mode) {
|
|
698
722
|
const freeDiscussion = mode === "free" ? 1 : 0;
|
|
699
723
|
this.db.prepare(`
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { OpenClawConfig } from "./config.js";
|
|
2
2
|
import { ChatMessage } from "./message-store.js";
|
|
3
|
+
export type ProgressEvent = {
|
|
4
|
+
kind: "tool";
|
|
5
|
+
phase: "start" | "end" | "error";
|
|
6
|
+
name: string;
|
|
7
|
+
text: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "assistant_note";
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "lifecycle";
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
3
15
|
export type ChatAttachment = {
|
|
4
16
|
type?: string;
|
|
5
17
|
mimeType?: string;
|
|
@@ -26,6 +38,7 @@ export declare class OpenClawClient {
|
|
|
26
38
|
private toolEventCallbacks;
|
|
27
39
|
private lastToolNamesByItemId;
|
|
28
40
|
private sessionMessageCallbacks;
|
|
41
|
+
private progressCallbacks;
|
|
29
42
|
/** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
|
|
30
43
|
private verboseTranscriptSessions;
|
|
31
44
|
private verboseAssistantTimers;
|
|
@@ -138,6 +151,7 @@ export declare class OpenClawClient {
|
|
|
138
151
|
onSendAttempt?: () => void | Promise<void>;
|
|
139
152
|
/** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
|
|
140
153
|
onSubmitted?: (runId: string) => void | Promise<void>;
|
|
154
|
+
onProgress?: (event: ProgressEvent) => void | Promise<void>;
|
|
141
155
|
}): Promise<string>;
|
|
142
156
|
private shouldInjectBridgeAttachmentHint;
|
|
143
157
|
private bridgeAttachmentHint;
|
|
@@ -170,6 +184,7 @@ export declare class OpenClawClient {
|
|
|
170
184
|
onSendAttempt?: () => void | Promise<void>;
|
|
171
185
|
/** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
|
|
172
186
|
onSubmitted?: (runId: string) => void | Promise<void>;
|
|
187
|
+
onProgress?: (event: ProgressEvent) => void | Promise<void>;
|
|
173
188
|
}): Promise<string>;
|
|
174
189
|
private formatContextLines;
|
|
175
190
|
private writeContextSyncFile;
|
package/dist/openclaw-client.js
CHANGED
|
@@ -29,6 +29,7 @@ export class OpenClawClient {
|
|
|
29
29
|
toolEventCallbacks = new Map();
|
|
30
30
|
lastToolNamesByItemId = new Map();
|
|
31
31
|
sessionMessageCallbacks = new Map();
|
|
32
|
+
progressCallbacks = new Map();
|
|
32
33
|
/** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
|
|
33
34
|
verboseTranscriptSessions = new Set();
|
|
34
35
|
verboseAssistantTimers = new Map();
|
|
@@ -239,11 +240,17 @@ export class OpenClawClient {
|
|
|
239
240
|
this.flushVerboseAssistantState(rawKey);
|
|
240
241
|
this.clearVerboseAssistantState(rawKey);
|
|
241
242
|
}
|
|
242
|
-
if (
|
|
243
|
+
if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
|
|
243
244
|
const phase = data.phase || "event";
|
|
244
245
|
const meta = data.meta || data.input || data.args || "";
|
|
245
246
|
const output = data.output || data.result || data.error || "";
|
|
246
|
-
|
|
247
|
+
if (toolCb)
|
|
248
|
+
toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
|
|
249
|
+
const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
|
|
250
|
+
if (progressCb) {
|
|
251
|
+
const detail = phase === "start" ? String(meta || "") : String(output || meta || "");
|
|
252
|
+
void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
|
|
253
|
+
}
|
|
247
254
|
}
|
|
248
255
|
}
|
|
249
256
|
if (frame.event === "agent" && frame.payload?.stream === "command_output") {
|
|
@@ -348,11 +355,12 @@ export class OpenClawClient {
|
|
|
348
355
|
}
|
|
349
356
|
}
|
|
350
357
|
flushVerboseAssistantState(rawKey) {
|
|
351
|
-
if (!this.isVerboseTranscriptEnabled(rawKey))
|
|
352
|
-
return false;
|
|
353
358
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
354
359
|
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
355
|
-
|
|
360
|
+
const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
|
|
361
|
+
if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
|
|
362
|
+
return false;
|
|
363
|
+
if (!cb && !progressCb)
|
|
356
364
|
return false;
|
|
357
365
|
const key = rawKey;
|
|
358
366
|
const latest = this.verboseAssistantLatest.get(key)?.trim() || "";
|
|
@@ -368,16 +376,22 @@ export class OpenClawClient {
|
|
|
368
376
|
if (!toSend)
|
|
369
377
|
return false;
|
|
370
378
|
this.verboseAssistantSent.set(key, latest);
|
|
371
|
-
|
|
379
|
+
if (this.isVerboseTranscriptEnabled(rawKey) && cb)
|
|
380
|
+
cb(toSend, { sourceType: "verbose_transcript" });
|
|
381
|
+
if (progressCb)
|
|
382
|
+
void progressCb({ kind: "assistant_note", text: toSend });
|
|
372
383
|
return true;
|
|
373
384
|
}
|
|
374
385
|
handleVerboseAssistantStream(payload) {
|
|
375
386
|
const rawKey = payload?.sessionKey || "";
|
|
376
|
-
if (!rawKey
|
|
387
|
+
if (!rawKey)
|
|
377
388
|
return;
|
|
378
389
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
379
390
|
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
380
|
-
|
|
391
|
+
const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
|
|
392
|
+
if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
|
|
393
|
+
return;
|
|
394
|
+
if (!cb && !progressCb)
|
|
381
395
|
return;
|
|
382
396
|
const data = payload.data || {};
|
|
383
397
|
const fullText = typeof data.text === "string" ? data.text : typeof data.deltaText === "string" && data.replace ? data.deltaText : "";
|
|
@@ -1090,6 +1104,11 @@ export class OpenClawClient {
|
|
|
1090
1104
|
}
|
|
1091
1105
|
console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
|
|
1092
1106
|
await params.onSubmitted?.(result.runId);
|
|
1107
|
+
if (params.onProgress) {
|
|
1108
|
+
this.progressCallbacks.set(sk, params.onProgress);
|
|
1109
|
+
this.progressCallbacks.set(fullSessionKey, params.onProgress);
|
|
1110
|
+
void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
|
|
1111
|
+
}
|
|
1093
1112
|
return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
|
|
1094
1113
|
}
|
|
1095
1114
|
finally {
|
|
@@ -1097,6 +1116,8 @@ export class OpenClawClient {
|
|
|
1097
1116
|
// collectReply returns. Keep a short grace window so normal chat replies
|
|
1098
1117
|
// are not delivered twice via the proactive-message path. Cron/LMA runs
|
|
1099
1118
|
// are unaffected because they do not go through chatSend.
|
|
1119
|
+
this.progressCallbacks.delete(sk);
|
|
1120
|
+
this.progressCallbacks.delete(fullSessionKey);
|
|
1100
1121
|
this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
|
|
1101
1122
|
this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
|
|
1102
1123
|
}
|
|
@@ -1149,6 +1170,7 @@ export class OpenClawClient {
|
|
|
1149
1170
|
emptyFinalAsNoReply: params.emptyFinalAsNoReply,
|
|
1150
1171
|
onSendAttempt: params.onSendAttempt,
|
|
1151
1172
|
onSubmitted: params.onSubmitted,
|
|
1173
|
+
onProgress: params.onProgress,
|
|
1152
1174
|
});
|
|
1153
1175
|
}
|
|
1154
1176
|
// Build context block + actual message in one chat.send, or write the
|
|
@@ -1184,6 +1206,7 @@ export class OpenClawClient {
|
|
|
1184
1206
|
emptyFinalAsNoReply: params.emptyFinalAsNoReply,
|
|
1185
1207
|
onSendAttempt: params.onSendAttempt,
|
|
1186
1208
|
onSubmitted: params.onSubmitted,
|
|
1209
|
+
onProgress: params.onProgress,
|
|
1187
1210
|
});
|
|
1188
1211
|
}
|
|
1189
1212
|
formatContextLines(messages) {
|