openclaw-lark-multi-agent 1.0.22 → 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 +23 -0
- package/dist/feishu-bot.js +166 -14
- package/dist/message-store.d.ts +6 -0
- package/dist/message-store.js +57 -0
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export declare class FeishuBot {
|
|
|
40
40
|
private locale;
|
|
41
41
|
private configPath?;
|
|
42
42
|
private static allBots;
|
|
43
|
+
/** Chats where this bot app has actually received an event in this process. */
|
|
44
|
+
private static seenBotChats;
|
|
43
45
|
constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string, configPath?: string);
|
|
44
46
|
private handleMessageRecalled;
|
|
45
47
|
register(): void;
|
|
@@ -75,6 +77,20 @@ export declare class FeishuBot {
|
|
|
75
77
|
private handleMessage;
|
|
76
78
|
private shouldUseLiveStatus;
|
|
77
79
|
private getUnhealthySessionStatus;
|
|
80
|
+
/**
|
|
81
|
+
* Distinguish SESSION death from RUN failure. `sessions.describe` reports the
|
|
82
|
+
* status of the most recent run, not whether the session is usable. A run can
|
|
83
|
+
* end as `aborted`/`error`/`failed`/`timeout` (e.g. an LLM idle timeout) while
|
|
84
|
+
* the session itself stays perfectly usable for the next message — confirmed in
|
|
85
|
+
* the wild: LMA reported "killed" but `/status` immediately showed running.
|
|
86
|
+
*
|
|
87
|
+
* Only treat the session as dead for statuses that mean the session itself is
|
|
88
|
+
* gone and a new run cannot start without /reset. Use word-boundary matching so
|
|
89
|
+
* a run-level "error" substring never trips this. Run-level failures are NOT
|
|
90
|
+
* surfaced here: the work promise will either return the (possibly empty) reply
|
|
91
|
+
* or throw, and that is handled by the normal delivery/error path.
|
|
92
|
+
*/
|
|
93
|
+
private isSessionDeadStatus;
|
|
78
94
|
private warnUnhealthySessionAndContinue;
|
|
79
95
|
private stopForUnhealthySession;
|
|
80
96
|
private withSessionHealthMonitor;
|
|
@@ -107,6 +123,7 @@ export declare class FeishuBot {
|
|
|
107
123
|
private deliverySessionKey;
|
|
108
124
|
private stableHash;
|
|
109
125
|
private deliverySourceId;
|
|
126
|
+
private sendSystemDelivery;
|
|
110
127
|
private enqueueAndDispatchDelivery;
|
|
111
128
|
private dispatchPendingDeliveries;
|
|
112
129
|
private hasFreeModeBot;
|
|
@@ -123,10 +140,14 @@ export declare class FeishuBot {
|
|
|
123
140
|
*/
|
|
124
141
|
private resolveChairmanTargets;
|
|
125
142
|
private isDiscussionCoordinator;
|
|
143
|
+
private static markBotSeenInChat;
|
|
144
|
+
private markCurrentBotSeenInChat;
|
|
145
|
+
private isBotAvailableInChat;
|
|
126
146
|
private getDiscussionParticipants;
|
|
127
147
|
private getChairmanParticipant;
|
|
128
148
|
private asDiscussionParticipant;
|
|
129
149
|
private runDiscussionTurn;
|
|
150
|
+
private sendTextMessage;
|
|
130
151
|
private replyTextMessage;
|
|
131
152
|
private editTextMessage;
|
|
132
153
|
private deleteMessageById;
|
|
@@ -137,6 +158,8 @@ export declare class FeishuBot {
|
|
|
137
158
|
private inferFeishuFileType;
|
|
138
159
|
private isImagePath;
|
|
139
160
|
private errorSummary;
|
|
161
|
+
private isOutOfChatError;
|
|
162
|
+
private markCurrentBotUnavailable;
|
|
140
163
|
private stripReadOnlyDocxFields;
|
|
141
164
|
private createFeishuDocFromMarkdown;
|
|
142
165
|
private sendBridgeAttachment;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -16,6 +16,7 @@ const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MA
|
|
|
16
16
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
17
17
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
18
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);
|
|
19
20
|
class UnhealthySessionError extends Error {
|
|
20
21
|
status;
|
|
21
22
|
constructor(status) {
|
|
@@ -63,6 +64,8 @@ export class FeishuBot {
|
|
|
63
64
|
locale;
|
|
64
65
|
configPath;
|
|
65
66
|
static allBots = new Map();
|
|
67
|
+
/** Chats where this bot app has actually received an event in this process. */
|
|
68
|
+
static seenBotChats = new Map();
|
|
66
69
|
constructor(config, openclawClient, store, adminOpenId, configPath) {
|
|
67
70
|
this.config = config;
|
|
68
71
|
this.openclawClient = openclawClient;
|
|
@@ -192,7 +195,17 @@ export class FeishuBot {
|
|
|
192
195
|
if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
|
|
193
196
|
this.cancelDelayedFailure(chatId);
|
|
194
197
|
const activeTarget = this.activeDeliveryTargets.get(chatId);
|
|
195
|
-
|
|
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
|
+
}
|
|
196
209
|
}
|
|
197
210
|
catch (err) {
|
|
198
211
|
console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
|
|
@@ -330,6 +343,7 @@ export class FeishuBot {
|
|
|
330
343
|
const message = event.message;
|
|
331
344
|
const sender = event.sender;
|
|
332
345
|
const chatId = message.chat_id;
|
|
346
|
+
this.markCurrentBotSeenInChat(chatId);
|
|
333
347
|
const chatType = message.chat_type;
|
|
334
348
|
const messageType = message.message_type;
|
|
335
349
|
const messageId = message.message_id;
|
|
@@ -486,6 +500,7 @@ export class FeishuBot {
|
|
|
486
500
|
const isChairmanCommand = commandName === "/chairman";
|
|
487
501
|
const isLocaleCommand = commandName === "/locale";
|
|
488
502
|
const isModelCommand = commandName === "/model";
|
|
503
|
+
const isHelpCommand = commandName === "/help";
|
|
489
504
|
const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
|
|
490
505
|
if (chatType !== "p2p") {
|
|
491
506
|
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
@@ -557,6 +572,13 @@ export class FeishuBot {
|
|
|
557
572
|
if (!this.isDiscussionCoordinator())
|
|
558
573
|
return;
|
|
559
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
|
+
}
|
|
560
582
|
else if (isDiscussCommand && !routing.hasTargetedMention) {
|
|
561
583
|
// Untargeted or @all /discuss is group-level; one coordinator handles it.
|
|
562
584
|
if (!this.isDiscussionCoordinator())
|
|
@@ -787,25 +809,25 @@ export class FeishuBot {
|
|
|
787
809
|
maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
|
|
788
810
|
participants,
|
|
789
811
|
chairman,
|
|
790
|
-
sendSystemMessage: async (text) => { await this.
|
|
812
|
+
sendSystemMessage: async (text) => { await this.sendSystemDelivery(chatId, text); },
|
|
791
813
|
locale: discussionLocale,
|
|
792
814
|
onComplete: async (event) => {
|
|
793
815
|
if (event.reason === "chairman_final") {
|
|
794
816
|
this.store.setDiscussMode(event.chatId, false);
|
|
795
|
-
await this.
|
|
817
|
+
await this.sendSystemDelivery(event.chatId, this.isEn(event.chatId)
|
|
796
818
|
? `💬 Discuss ended: Chairman ${event.chairmanName || ""} completed the final summary. Discuss mode has been turned off automatically.`
|
|
797
819
|
: `💬 Discuss 已结束:Chairman ${event.chairmanName || ""} 已完成总结,已自动关闭 Discuss 模式。`);
|
|
798
820
|
}
|
|
799
821
|
},
|
|
800
822
|
});
|
|
801
823
|
if (started && preempted) {
|
|
802
|
-
await this.
|
|
824
|
+
await this.sendSystemDelivery(chatId, this.isEn(chatId)
|
|
803
825
|
? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
|
|
804
826
|
: `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
|
|
805
827
|
}
|
|
806
828
|
}
|
|
807
829
|
else if (this.isDiscussionCoordinator()) {
|
|
808
|
-
await this.
|
|
830
|
+
await this.sendSystemDelivery(chatId, this.isEn(chatId)
|
|
809
831
|
? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
|
|
810
832
|
: "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
|
|
811
833
|
}
|
|
@@ -891,9 +913,8 @@ export class FeishuBot {
|
|
|
891
913
|
const status = String(resp?.session?.status || "").toLowerCase();
|
|
892
914
|
if (!status)
|
|
893
915
|
return null;
|
|
894
|
-
if (
|
|
916
|
+
if (this.isSessionDeadStatus(status))
|
|
895
917
|
return status;
|
|
896
|
-
}
|
|
897
918
|
}
|
|
898
919
|
catch {
|
|
899
920
|
// Absence/transient describe failures should not block a new run; chat.send
|
|
@@ -901,6 +922,26 @@ export class FeishuBot {
|
|
|
901
922
|
}
|
|
902
923
|
return null;
|
|
903
924
|
}
|
|
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
|
+
}
|
|
904
945
|
async warnUnhealthySessionAndContinue(chatId, messageId, status) {
|
|
905
946
|
await this.replyMessage(messageId, this.isEn(chatId)
|
|
906
947
|
? `⚠️ ${this.config.name} session is currently unhealthy (${status}). I will still try this message. If it keeps failing, send /reset and retry.`
|
|
@@ -934,8 +975,18 @@ export class FeishuBot {
|
|
|
934
975
|
break;
|
|
935
976
|
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
936
977
|
if (unhealthy) {
|
|
937
|
-
|
|
938
|
-
|
|
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
|
+
}
|
|
939
990
|
}
|
|
940
991
|
}
|
|
941
992
|
return undefined;
|
|
@@ -1140,6 +1191,9 @@ export class FeishuBot {
|
|
|
1140
1191
|
})
|
|
1141
1192
|
: undefined;
|
|
1142
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;
|
|
1143
1197
|
try {
|
|
1144
1198
|
reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
|
|
1145
1199
|
sessionKey,
|
|
@@ -1259,6 +1313,7 @@ export class FeishuBot {
|
|
|
1259
1313
|
if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
|
|
1260
1314
|
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
1261
1315
|
console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
1316
|
+
await liveStatus?.complete().catch(() => { });
|
|
1262
1317
|
}
|
|
1263
1318
|
else {
|
|
1264
1319
|
try {
|
|
@@ -1283,7 +1338,7 @@ export class FeishuBot {
|
|
|
1283
1338
|
}
|
|
1284
1339
|
}
|
|
1285
1340
|
if (isRuntimeFailure && lastHuman.messageId) {
|
|
1286
|
-
liveStatus?.
|
|
1341
|
+
await liveStatus?.fail().catch(() => { });
|
|
1287
1342
|
this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
|
|
1288
1343
|
}
|
|
1289
1344
|
else if (!shouldReply && !hasAttachments) {
|
|
@@ -1604,6 +1659,10 @@ export class FeishuBot {
|
|
|
1604
1659
|
deliverySourceId(kind, text) {
|
|
1605
1660
|
return `${kind}:${this.stableHash(text)}`;
|
|
1606
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
|
+
}
|
|
1607
1666
|
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
|
|
1608
1667
|
if (!text.trim() && attachments.length === 0)
|
|
1609
1668
|
return;
|
|
@@ -1672,6 +1731,8 @@ export class FeishuBot {
|
|
|
1672
1731
|
this.store.markDeliveryDelivered(item.id);
|
|
1673
1732
|
}
|
|
1674
1733
|
catch (err) {
|
|
1734
|
+
if (this.isOutOfChatError(err))
|
|
1735
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
1675
1736
|
if (item.id)
|
|
1676
1737
|
this.store.markDeliveryFailed(item.id);
|
|
1677
1738
|
const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
|
|
@@ -1749,17 +1810,42 @@ export class FeishuBot {
|
|
|
1749
1810
|
return true;
|
|
1750
1811
|
return bots[0] === this;
|
|
1751
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
|
+
}
|
|
1752
1838
|
getDiscussionParticipants(chatId) {
|
|
1753
1839
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1754
1840
|
return Array.from(FeishuBot.allBots.values())
|
|
1755
|
-
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute")
|
|
1841
|
+
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute" && this.isBotAvailableInChat(bot, chatId))
|
|
1756
1842
|
.map((bot) => this.asDiscussionParticipant(bot, chatId));
|
|
1757
1843
|
}
|
|
1758
1844
|
getChairmanParticipant(chatId) {
|
|
1759
1845
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1760
1846
|
if (!chairman)
|
|
1761
1847
|
return undefined;
|
|
1762
|
-
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));
|
|
1763
1849
|
return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
|
|
1764
1850
|
}
|
|
1765
1851
|
asDiscussionParticipant(bot, chatId) {
|
|
@@ -1771,6 +1857,21 @@ export class FeishuBot {
|
|
|
1771
1857
|
async runDiscussionTurn(chatId, prompt, meta) {
|
|
1772
1858
|
const sessionKey = await this.ensureSession(chatId);
|
|
1773
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} 轮讨论` : "讨论中");
|
|
1774
1875
|
let reply;
|
|
1775
1876
|
try {
|
|
1776
1877
|
reply = await this.openclawClient.chatSendWithContext({
|
|
@@ -1781,8 +1882,13 @@ export class FeishuBot {
|
|
|
1781
1882
|
deliver: false,
|
|
1782
1883
|
timeoutMs: 1_800_000,
|
|
1783
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)); },
|
|
1784
1886
|
});
|
|
1785
1887
|
}
|
|
1888
|
+
catch (err) {
|
|
1889
|
+
await liveStatus?.fail().catch(() => { });
|
|
1890
|
+
throw err;
|
|
1891
|
+
}
|
|
1786
1892
|
finally {
|
|
1787
1893
|
// OpenClaw can emit the final assistant session.message shortly after
|
|
1788
1894
|
// chatSend/collectReply returns. Keep discussion proactive muted briefly;
|
|
@@ -1815,10 +1921,36 @@ export class FeishuBot {
|
|
|
1815
1921
|
content: storedContent,
|
|
1816
1922
|
timestamp: Date.now(),
|
|
1817
1923
|
});
|
|
1818
|
-
|
|
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
|
+
}
|
|
1819
1931
|
}
|
|
1932
|
+
await liveStatus?.complete().catch(() => { });
|
|
1820
1933
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
1821
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
|
+
}
|
|
1822
1954
|
async replyTextMessage(messageId, text) {
|
|
1823
1955
|
const res = await this.client.im.message.reply({
|
|
1824
1956
|
path: { message_id: messageId },
|
|
@@ -1868,7 +2000,11 @@ export class FeishuBot {
|
|
|
1868
2000
|
}
|
|
1869
2001
|
extractBridgeAttachments(reply) {
|
|
1870
2002
|
const attachments = [];
|
|
1871
|
-
|
|
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;
|
|
1872
2008
|
let text = reply.replace(markerPattern, (_match, jsonText) => {
|
|
1873
2009
|
try {
|
|
1874
2010
|
const parsed = JSON.parse(String(jsonText).trim());
|
|
@@ -1938,6 +2074,16 @@ export class FeishuBot {
|
|
|
1938
2074
|
const msg = data?.msg || data?.message || err?.message || String(err);
|
|
1939
2075
|
return `${code}${msg}`.slice(0, 800);
|
|
1940
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
|
+
}
|
|
1941
2087
|
stripReadOnlyDocxFields(value) {
|
|
1942
2088
|
if (Array.isArray(value))
|
|
1943
2089
|
return value.map((item) => this.stripReadOnlyDocxFields(item));
|
|
@@ -2052,10 +2198,13 @@ export class FeishuBot {
|
|
|
2052
2198
|
msg_type: "interactive",
|
|
2053
2199
|
},
|
|
2054
2200
|
});
|
|
2201
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2055
2202
|
return res?.data?.message_id || res?.message_id;
|
|
2056
2203
|
}
|
|
2057
2204
|
catch (err) {
|
|
2058
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);
|
|
2059
2208
|
// Fallback to plain text
|
|
2060
2209
|
try {
|
|
2061
2210
|
const res = await this.client.im.message.create({
|
|
@@ -2066,10 +2215,13 @@ export class FeishuBot {
|
|
|
2066
2215
|
msg_type: "text",
|
|
2067
2216
|
},
|
|
2068
2217
|
});
|
|
2218
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2069
2219
|
return res?.data?.message_id || res?.message_id;
|
|
2070
2220
|
}
|
|
2071
2221
|
catch (fallbackErr) {
|
|
2072
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);
|
|
2073
2225
|
throw fallbackErr;
|
|
2074
2226
|
}
|
|
2075
2227
|
}
|
package/dist/message-store.d.ts
CHANGED
|
@@ -70,6 +70,12 @@ export declare class MessageStore {
|
|
|
70
70
|
enqueueDelivery(item: Omit<DeliveryOutboxItem, "id" | "status" | "attempts" | "createdAt" | "updatedAt">): number | null;
|
|
71
71
|
getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
|
|
72
72
|
getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
|
|
73
|
+
markBotSeenInChat(botName: string, chatId: string): void;
|
|
74
|
+
hasBotSeenInChat(botName: string, chatId: string): boolean;
|
|
75
|
+
markBotUnavailableInChat(botName: string, chatId: string, reason?: string): void;
|
|
76
|
+
clearBotUnavailableInChat(botName: string, chatId: string): void;
|
|
77
|
+
isBotUnavailableInChat(botName: string, chatId: string): boolean;
|
|
78
|
+
hasAnyDeliveredToChat(botName: string, chatId: string): boolean;
|
|
73
79
|
hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
|
|
74
80
|
hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
|
|
75
81
|
resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
|
package/dist/message-store.js
CHANGED
|
@@ -111,6 +111,27 @@ export class MessageStore {
|
|
|
111
111
|
PRIMARY KEY (bot_name, chat_id)
|
|
112
112
|
);
|
|
113
113
|
|
|
114
|
+
-- Durable positive membership signal. Feishu does not reliably expose bot
|
|
115
|
+
-- members via chat_members, so receiving an event in a chat is the strongest
|
|
116
|
+
-- proof that this bot app is actually installed in that group.
|
|
117
|
+
CREATE TABLE IF NOT EXISTS bot_chat_seen (
|
|
118
|
+
bot_name TEXT NOT NULL,
|
|
119
|
+
chat_id TEXT NOT NULL,
|
|
120
|
+
updated_at INTEGER NOT NULL,
|
|
121
|
+
PRIMARY KEY (bot_name, chat_id)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
-- Durable negative membership cache. Feishu message create returns 230002
|
|
125
|
+
-- when a bot app is not in a chat; cache that so discussion scheduling and
|
|
126
|
+
-- later proactive deliveries do not keep generating ghost participants.
|
|
127
|
+
CREATE TABLE IF NOT EXISTS bot_chat_unavailable (
|
|
128
|
+
bot_name TEXT NOT NULL,
|
|
129
|
+
chat_id TEXT NOT NULL,
|
|
130
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
131
|
+
updated_at INTEGER NOT NULL,
|
|
132
|
+
PRIMARY KEY (bot_name, chat_id)
|
|
133
|
+
);
|
|
134
|
+
|
|
114
135
|
-- Durable delivery ledger for user-visible assistant outputs. All final
|
|
115
136
|
-- text/attachment outputs should be inserted here first and dispatched
|
|
116
137
|
-- once, regardless of whether they came from chat final, proactive
|
|
@@ -409,6 +430,42 @@ export class MessageStore {
|
|
|
409
430
|
`).all(maxCount);
|
|
410
431
|
return rows.map((r) => this.mapDelivery(r));
|
|
411
432
|
}
|
|
433
|
+
markBotSeenInChat(botName, chatId) {
|
|
434
|
+
this.db.prepare(`
|
|
435
|
+
INSERT INTO bot_chat_seen(bot_name, chat_id, updated_at)
|
|
436
|
+
VALUES (?, ?, ?)
|
|
437
|
+
ON CONFLICT(bot_name, chat_id) DO UPDATE SET updated_at = excluded.updated_at
|
|
438
|
+
`).run(botName, chatId, Date.now());
|
|
439
|
+
// If the bot is receiving events from this chat again, it is no longer out
|
|
440
|
+
// of the chat. This recovers automatically when a bot is re-added to a group.
|
|
441
|
+
this.clearBotUnavailableInChat(botName, chatId);
|
|
442
|
+
}
|
|
443
|
+
hasBotSeenInChat(botName, chatId) {
|
|
444
|
+
const row = this.db.prepare(`SELECT 1 FROM bot_chat_seen WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
|
|
445
|
+
return !!row;
|
|
446
|
+
}
|
|
447
|
+
markBotUnavailableInChat(botName, chatId, reason = '') {
|
|
448
|
+
this.db.prepare(`
|
|
449
|
+
INSERT INTO bot_chat_unavailable(bot_name, chat_id, reason, updated_at)
|
|
450
|
+
VALUES (?, ?, ?, ?)
|
|
451
|
+
ON CONFLICT(bot_name, chat_id) DO UPDATE SET reason = excluded.reason, updated_at = excluded.updated_at
|
|
452
|
+
`).run(botName, chatId, reason, Date.now());
|
|
453
|
+
}
|
|
454
|
+
clearBotUnavailableInChat(botName, chatId) {
|
|
455
|
+
this.db.prepare(`DELETE FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ?`).run(botName, chatId);
|
|
456
|
+
}
|
|
457
|
+
isBotUnavailableInChat(botName, chatId) {
|
|
458
|
+
const row = this.db.prepare(`SELECT 1 FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
|
|
459
|
+
return !!row;
|
|
460
|
+
}
|
|
461
|
+
hasAnyDeliveredToChat(botName, chatId) {
|
|
462
|
+
const row = this.db.prepare(`
|
|
463
|
+
SELECT 1 FROM delivery_outbox
|
|
464
|
+
WHERE bot_name = ? AND chat_id = ? AND status = 'delivered'
|
|
465
|
+
LIMIT 1
|
|
466
|
+
`).get(botName, chatId);
|
|
467
|
+
return !!row;
|
|
468
|
+
}
|
|
412
469
|
hasRecentSimilarDelivery(botName, chatId, contentHash, windowMs, sourceTypes) {
|
|
413
470
|
if (!contentHash)
|
|
414
471
|
return false;
|