openclaw-lark-multi-agent 1.0.20 → 1.0.21
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 +14 -0
- package/dist/feishu-bot.js +248 -31
- package/dist/message-store.d.ts +4 -0
- package/dist/message-store.js +14 -0
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -73,6 +73,9 @@ 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 getUnhealthySessionStatus;
|
|
77
|
+
private recoverFromUnhealthySession;
|
|
78
|
+
private withSessionHealthMonitor;
|
|
76
79
|
/**
|
|
77
80
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
78
81
|
* Loops until no more unsynced human messages remain.
|
|
@@ -106,6 +109,17 @@ export declare class FeishuBot {
|
|
|
106
109
|
private dispatchPendingDeliveries;
|
|
107
110
|
private hasFreeModeBot;
|
|
108
111
|
private mentionedBotNames;
|
|
112
|
+
private mentionedBotNamesFromChairmanText;
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the target bot name(s) for a /chairman command using every signal
|
|
115
|
+
* available, in priority order:
|
|
116
|
+
* 1. Feishu mention metadata (app_id / open_id / name).
|
|
117
|
+
* 2. Text fallback: "@Bot" or "Bot" written in the message body.
|
|
118
|
+
*
|
|
119
|
+
* This keeps /chairman working even when a Feishu client or bridge omits
|
|
120
|
+
* mention metadata. Returns a de-duplicated, order-preserving list.
|
|
121
|
+
*/
|
|
122
|
+
private resolveChairmanTargets;
|
|
109
123
|
private isDiscussionCoordinator;
|
|
110
124
|
private getDiscussionParticipants;
|
|
111
125
|
private getChairmanParticipant;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -14,6 +14,15 @@ const MAX_MERGED_TRIGGER_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT
|
|
|
14
14
|
const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_BYTES || 128 * 1024);
|
|
15
15
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
16
16
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
17
|
+
const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
|
|
18
|
+
class UnhealthySessionError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
constructor(status) {
|
|
21
|
+
super(`unhealthy session: ${status}`);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.name = "UnhealthySessionError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
17
26
|
/**
|
|
18
27
|
* Manages a single Feishu bot instance.
|
|
19
28
|
*
|
|
@@ -261,6 +270,10 @@ export class FeishuBot {
|
|
|
261
270
|
*/
|
|
262
271
|
async drainOnStartup() {
|
|
263
272
|
try {
|
|
273
|
+
const restoredDeliveries = this.store.resetStaleDeliveries(this.config.name, 5 * 60_000, 5);
|
|
274
|
+
if (restoredDeliveries.restored > 0 || restoredDeliveries.failed > 0) {
|
|
275
|
+
console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries.restored}, failed=${restoredDeliveries.failed}`);
|
|
276
|
+
}
|
|
264
277
|
const chats = this.store.getAllChatInfo();
|
|
265
278
|
const drainTasks = [];
|
|
266
279
|
for (const chat of chats) {
|
|
@@ -273,6 +286,12 @@ export class FeishuBot {
|
|
|
273
286
|
// Drain only messages that were explicitly marked as reply triggers.
|
|
274
287
|
// Context-only messages should not start an OpenClaw run after restart.
|
|
275
288
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
|
|
289
|
+
const pendingDeliveries = this.store.getPendingDeliveries(chat.chatId, this.config.name, 1);
|
|
290
|
+
if (pendingDeliveries.length > 0) {
|
|
291
|
+
drainTasks.push(this.dispatchPendingDeliveries(chat.chatId).catch((err) => {
|
|
292
|
+
console.warn(`[${this.config.name}] Startup delivery drain failed for ${chat.chatId.slice(-8)}:`, err.message);
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
276
295
|
if (pendingTriggerIds.size > 0) {
|
|
277
296
|
console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
|
|
278
297
|
// Do not let one slow/stuck chat block startup drain for all other chats.
|
|
@@ -350,8 +369,9 @@ export class FeishuBot {
|
|
|
350
369
|
}
|
|
351
370
|
// --- Extract text content based on message type ---
|
|
352
371
|
let cleanText = "";
|
|
372
|
+
let rawText = "";
|
|
353
373
|
if (messageType === "text") {
|
|
354
|
-
|
|
374
|
+
rawText = content.text || "";
|
|
355
375
|
cleanText = this.cleanMentions(rawText);
|
|
356
376
|
// A mention-only text message is still a valid routing trigger. Feishu may
|
|
357
377
|
// expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
|
|
@@ -467,9 +487,12 @@ export class FeishuBot {
|
|
|
467
487
|
const isModelCommand = commandName === "/model";
|
|
468
488
|
const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
|
|
469
489
|
if (chatType !== "p2p") {
|
|
490
|
+
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
491
|
+
const singleBotGroup = groupBotCount === 1;
|
|
470
492
|
if (isModelCommand) {
|
|
471
493
|
// Model switching is per-bot. @all is rejected by one coordinator;
|
|
472
|
-
// otherwise group /model must explicitly target this bot.
|
|
494
|
+
// otherwise group /model must explicitly target this bot. A single-bot
|
|
495
|
+
// group treats the sole bot as the implicit target.
|
|
473
496
|
if (routing.isAllMention) {
|
|
474
497
|
if (!this.isDiscussionCoordinator())
|
|
475
498
|
return;
|
|
@@ -478,12 +501,58 @@ export class FeishuBot {
|
|
|
478
501
|
if (!routing.isCurrentBotMentioned)
|
|
479
502
|
return;
|
|
480
503
|
}
|
|
504
|
+
else if (!singleBotGroup) {
|
|
505
|
+
if (!this.isDiscussionCoordinator())
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else if (isChairmanCommand) {
|
|
510
|
+
// /chairman @Bot is owned by the mentioned bot itself. This avoids
|
|
511
|
+
// requiring a coordinator to parse another bot's mention metadata,
|
|
512
|
+
// which can vary across Feishu clients/bridges. Untargeted/status,
|
|
513
|
+
// @all, and clear/off remain coordinator-owned group commands.
|
|
514
|
+
//
|
|
515
|
+
// Target resolution uses both Feishu mention metadata AND a text
|
|
516
|
+
// fallback (parsing "@Bot" / "Bot" from the raw message), so a
|
|
517
|
+
// /chairman that arrives without mention metadata still routes to
|
|
518
|
+
// the intended bot instead of silently failing.
|
|
519
|
+
const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
|
|
520
|
+
const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
|
|
521
|
+
const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
|
|
522
|
+
const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
|
|
523
|
+
// A pure status query: no clear keyword, no mention metadata, and no
|
|
524
|
+
// textual argument at all. Anything else is a set/route attempt.
|
|
525
|
+
const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
|
|
526
|
+
if (isClearArg || isStatusArg) {
|
|
527
|
+
// Group-level status/clear: one coordinator handles it.
|
|
528
|
+
if (!this.isDiscussionCoordinator())
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
else if (targets.length >= 1) {
|
|
532
|
+
// Targets resolved: only the (single) targeted bot acts. If
|
|
533
|
+
// multiple distinct targets resolved, the coordinator handles
|
|
534
|
+
// the "only one chairman" error once.
|
|
535
|
+
const uniqueTargets = Array.from(new Set(targets));
|
|
536
|
+
const targetedSelf = uniqueTargets.includes(this.config.name);
|
|
537
|
+
if (uniqueTargets.length > 1) {
|
|
538
|
+
if (!this.isDiscussionCoordinator())
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
else if (!targetedSelf) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
481
545
|
else {
|
|
482
|
-
|
|
546
|
+
// A set attempt whose target could not be resolved at all (missing
|
|
547
|
+
// metadata AND no readable name in text): coordinator handles the
|
|
548
|
+
// fallback so the user still gets an actionable reply.
|
|
549
|
+
if (!this.isDiscussionCoordinator())
|
|
550
|
+
return;
|
|
483
551
|
}
|
|
484
552
|
}
|
|
485
|
-
else if (
|
|
486
|
-
//
|
|
553
|
+
else if (isLocaleCommand && !routing.hasTargetedMention) {
|
|
554
|
+
// Untargeted or @all locale is a group-level setting; one coordinator handles it.
|
|
555
|
+
// If the user explicitly @s a bot, the targeted bot should execute and answer.
|
|
487
556
|
if (!this.isDiscussionCoordinator())
|
|
488
557
|
return;
|
|
489
558
|
}
|
|
@@ -497,8 +566,9 @@ export class FeishuBot {
|
|
|
497
566
|
if (!routing.isCurrentBotMentioned)
|
|
498
567
|
return;
|
|
499
568
|
}
|
|
500
|
-
else if (!routing.isAllMention) {
|
|
501
|
-
// Other bridge commands in a group need an explicit target or @all.
|
|
569
|
+
else if (!routing.isAllMention && !singleBotGroup) {
|
|
570
|
+
// Other bridge commands in a multi-bot group need an explicit target or @all.
|
|
571
|
+
// A single-bot group treats the sole bot as the implicit target.
|
|
502
572
|
return;
|
|
503
573
|
}
|
|
504
574
|
}
|
|
@@ -583,6 +653,14 @@ export class FeishuBot {
|
|
|
583
653
|
return;
|
|
584
654
|
}
|
|
585
655
|
if (commandName === "/model") {
|
|
656
|
+
if (chatType !== "p2p" && !routing.isAllMention && !routing.hasTargetedMention) {
|
|
657
|
+
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
658
|
+
if (groupBotCount > 1) {
|
|
659
|
+
await this.replyMessage(messageId, "❌ /model 是单 bot 设置,请明确 @ 一个 bot,例如:@Claude /model provider/model-id");
|
|
660
|
+
markCommandSynced();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
586
664
|
await this.handleModelCommand(chatId, messageId, cleanText.trim());
|
|
587
665
|
markCommandSynced();
|
|
588
666
|
return;
|
|
@@ -665,7 +743,7 @@ export class FeishuBot {
|
|
|
665
743
|
return;
|
|
666
744
|
}
|
|
667
745
|
if (commandName === "/chairman") {
|
|
668
|
-
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim());
|
|
746
|
+
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
|
|
669
747
|
markCommandSynced();
|
|
670
748
|
return;
|
|
671
749
|
}
|
|
@@ -687,7 +765,8 @@ export class FeishuBot {
|
|
|
687
765
|
const chairman = this.getChairmanParticipant(chatId);
|
|
688
766
|
const participants = this.getDiscussionParticipants(chatId);
|
|
689
767
|
if (participants.length > 0 || chairman) {
|
|
690
|
-
discussionManager.
|
|
768
|
+
const preempted = discussionManager.status(chatId);
|
|
769
|
+
const started = discussionManager.startIfAbsent({
|
|
691
770
|
chatId,
|
|
692
771
|
rootMessageId: messageId,
|
|
693
772
|
topic: cleanText,
|
|
@@ -705,6 +784,11 @@ export class FeishuBot {
|
|
|
705
784
|
}
|
|
706
785
|
},
|
|
707
786
|
});
|
|
787
|
+
if (started && preempted) {
|
|
788
|
+
await this.sendMessage(chatId, this.isEn(chatId)
|
|
789
|
+
? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
|
|
790
|
+
: `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
|
|
791
|
+
}
|
|
708
792
|
}
|
|
709
793
|
else if (this.isDiscussionCoordinator()) {
|
|
710
794
|
await this.sendMessage(chatId, this.isEn(chatId)
|
|
@@ -735,6 +819,11 @@ export class FeishuBot {
|
|
|
735
819
|
}
|
|
736
820
|
// Track this message for reaction status updates
|
|
737
821
|
const pending = this.pendingAckMessages.get(chatId) || [];
|
|
822
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
823
|
+
if (unhealthy) {
|
|
824
|
+
await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
738
827
|
// Anti-loop
|
|
739
828
|
const streak = this.store.getBotStreak(chatId, this.config.name);
|
|
740
829
|
if (streak >= MAX_BOT_STREAK) {
|
|
@@ -771,6 +860,65 @@ export class FeishuBot {
|
|
|
771
860
|
console.error(`[${this.config.name}] Error:`, err);
|
|
772
861
|
}
|
|
773
862
|
}
|
|
863
|
+
async getUnhealthySessionStatus(chatId) {
|
|
864
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
865
|
+
try {
|
|
866
|
+
const resp = await this.openclawClient.getSessionInfo(sessionKey);
|
|
867
|
+
const status = String(resp?.session?.status || "").toLowerCase();
|
|
868
|
+
if (!status)
|
|
869
|
+
return null;
|
|
870
|
+
if (["killed", "kill", "dead", "crashed", "crash", "failed", "failure", "error", "errored", "invalid"].some((bad) => status.includes(bad))) {
|
|
871
|
+
return status;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
// Absence/transient describe failures should not block a new run; chat.send
|
|
876
|
+
// will create/report the session as usual.
|
|
877
|
+
}
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
async recoverFromUnhealthySession(chatId, messageId, insertedId, status) {
|
|
881
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
882
|
+
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
883
|
+
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
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
888
|
+
for (const ack of acks) {
|
|
889
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
890
|
+
await this.addReaction(ack.messageId, "FAIL").catch(() => { });
|
|
891
|
+
}
|
|
892
|
+
this.pendingAckMessages.set(chatId, []);
|
|
893
|
+
await this.addReaction(messageId, "FAIL").catch(() => { });
|
|
894
|
+
await this.replyMessage(messageId, this.isEn(chatId)
|
|
895
|
+
? `⚠️ ${this.config.name} session is unhealthy (${status}). Cleared ${cleared} pending message(s) and stopped waiting. Please send /reset and retry.`
|
|
896
|
+
: `⚠️ ${this.config.name} 的 session 状态异常(${status})。已停止等待、清掉 ${cleared} 条待处理消息并关闭等待反应。请先发送 /reset 后重试。`);
|
|
897
|
+
console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status}; cleared ${cleared} pending trigger(s) for ${chatId.slice(-8)}`);
|
|
898
|
+
}
|
|
899
|
+
async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
|
|
900
|
+
let done = false;
|
|
901
|
+
const monitoredWork = work.finally(() => { done = true; });
|
|
902
|
+
const monitor = (async () => {
|
|
903
|
+
while (!done) {
|
|
904
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_POLL_MS));
|
|
905
|
+
if (done)
|
|
906
|
+
break;
|
|
907
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
908
|
+
if (unhealthy) {
|
|
909
|
+
await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
|
|
910
|
+
throw new UnhealthySessionError(unhealthy);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return undefined;
|
|
914
|
+
})();
|
|
915
|
+
try {
|
|
916
|
+
return await Promise.race([monitoredWork, monitor]);
|
|
917
|
+
}
|
|
918
|
+
finally {
|
|
919
|
+
done = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
774
922
|
/**
|
|
775
923
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
776
924
|
* Loops until no more unsynced human messages remain.
|
|
@@ -798,6 +946,12 @@ export class FeishuBot {
|
|
|
798
946
|
if (pendingHumanTriggers.length === 0) {
|
|
799
947
|
break;
|
|
800
948
|
}
|
|
949
|
+
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
950
|
+
if (unhealthy) {
|
|
951
|
+
const latest = pendingHumanTriggers[pendingHumanTriggers.length - 1];
|
|
952
|
+
await this.recoverFromUnhealthySession(chatId, latest.messageId, latest.id || 0, unhealthy);
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
801
955
|
this.busyChats.set(chatId, Date.now());
|
|
802
956
|
// Pick the batch to process this loop. Consecutive plain human messages are
|
|
803
957
|
// merged into a single run; native escaped commands (//x) are processed on
|
|
@@ -850,6 +1004,17 @@ export class FeishuBot {
|
|
|
850
1004
|
console.warn(`[${this.config.name}] Dropped ${droppedMergedTriggers.length} older pending trigger(s) for ${chatId.slice(-8)} due to merge limits (kept=${mergedTriggers.length}, bytes=${bytes}): ${summaries}`);
|
|
851
1005
|
}
|
|
852
1006
|
}
|
|
1007
|
+
const deliveredTriggerIds = mergedTriggers
|
|
1008
|
+
.map((m) => m.id || 0)
|
|
1009
|
+
.filter((id) => id > 0 && this.store.hasDeliveredReply(this.config.name, chatId, id));
|
|
1010
|
+
if (deliveredTriggerIds.length > 0) {
|
|
1011
|
+
console.warn(`[${this.config.name}] Clearing ${deliveredTriggerIds.length} stale delivered pending trigger(s) for ${chatId.slice(-8)}: ${deliveredTriggerIds.join(",")}`);
|
|
1012
|
+
for (const id of deliveredTriggerIds)
|
|
1013
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
1014
|
+
mergedTriggers = mergedTriggers.filter((m) => !m.id || !deliveredTriggerIds.includes(m.id));
|
|
1015
|
+
if (mergedTriggers.length === 0)
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
853
1018
|
const isNativeCommandTrigger = firstIsNative;
|
|
854
1019
|
const lastHuman = mergedTriggers[mergedTriggers.length - 1];
|
|
855
1020
|
const triggerId = lastHuman.id || 0;
|
|
@@ -934,7 +1099,7 @@ export class FeishuBot {
|
|
|
934
1099
|
}
|
|
935
1100
|
};
|
|
936
1101
|
try {
|
|
937
|
-
reply = await this.openclawClient.chatSendWithContext({
|
|
1102
|
+
reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
|
|
938
1103
|
sessionKey,
|
|
939
1104
|
unsyncedMessages: contextMsgs,
|
|
940
1105
|
currentMessage: mergedContent,
|
|
@@ -958,7 +1123,7 @@ export class FeishuBot {
|
|
|
958
1123
|
// INSERT OR IGNORE in message_sync.
|
|
959
1124
|
markSubmittedBatch(`submitted:${runId}`);
|
|
960
1125
|
},
|
|
961
|
-
});
|
|
1126
|
+
}));
|
|
962
1127
|
}
|
|
963
1128
|
finally {
|
|
964
1129
|
// OpenClaw may emit the final assistant session.message just after
|
|
@@ -1088,6 +1253,10 @@ export class FeishuBot {
|
|
|
1088
1253
|
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
1089
1254
|
}
|
|
1090
1255
|
catch (err) {
|
|
1256
|
+
if (err instanceof UnhealthySessionError) {
|
|
1257
|
+
console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1091
1260
|
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
1092
1261
|
const errorText = this.formatUserVisibleError(err);
|
|
1093
1262
|
if (lastHuman.messageId) {
|
|
@@ -1185,7 +1354,7 @@ export class FeishuBot {
|
|
|
1185
1354
|
return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
|
|
1186
1355
|
}
|
|
1187
1356
|
isAllMention(rawText, mentions = []) {
|
|
1188
|
-
if (rawText && (
|
|
1357
|
+
if (rawText && /(^|\s)@(?:_all|all|所有人)(?=\s|$)/i.test(rawText))
|
|
1189
1358
|
return true;
|
|
1190
1359
|
return mentions.some((m) => this.isAllMentionItem(m));
|
|
1191
1360
|
}
|
|
@@ -1227,7 +1396,7 @@ export class FeishuBot {
|
|
|
1227
1396
|
return null;
|
|
1228
1397
|
}
|
|
1229
1398
|
escapeRegExp(value) {
|
|
1230
|
-
return value.replace(/[.*+?^${}()|[\]
|
|
1399
|
+
return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
1231
1400
|
}
|
|
1232
1401
|
resolveBotName(sender) {
|
|
1233
1402
|
const openId = sender?.sender_id?.open_id;
|
|
@@ -1390,12 +1559,6 @@ export class FeishuBot {
|
|
|
1390
1559
|
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
|
|
1391
1560
|
return;
|
|
1392
1561
|
}
|
|
1393
|
-
else if (sourceType === "assistant_visible") {
|
|
1394
|
-
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["assistant_visible"]))
|
|
1395
|
-
return;
|
|
1396
|
-
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 30, ["assistant_visible"]))
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
1562
|
const deliveryId = this.store.enqueueDelivery({
|
|
1400
1563
|
sessionKey: this.deliverySessionKey(chatId),
|
|
1401
1564
|
chatId,
|
|
@@ -1473,6 +1636,49 @@ export class FeishuBot {
|
|
|
1473
1636
|
.filter((name) => Boolean(name));
|
|
1474
1637
|
return Array.from(new Set(names));
|
|
1475
1638
|
}
|
|
1639
|
+
mentionedBotNamesFromChairmanText(...texts) {
|
|
1640
|
+
const normalizedTexts = texts
|
|
1641
|
+
.filter(Boolean)
|
|
1642
|
+
.map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
|
|
1643
|
+
.filter(Boolean)
|
|
1644
|
+
.map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
|
|
1645
|
+
if (normalizedTexts.length === 0)
|
|
1646
|
+
return [];
|
|
1647
|
+
const names = [];
|
|
1648
|
+
const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
|
|
1649
|
+
for (const raw of normalizedTexts) {
|
|
1650
|
+
for (const bot of candidates) {
|
|
1651
|
+
const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
|
|
1652
|
+
const displayNames = [
|
|
1653
|
+
botName,
|
|
1654
|
+
`@${botName}`,
|
|
1655
|
+
`万万(${botName})`,
|
|
1656
|
+
`@万万(${botName})`,
|
|
1657
|
+
`万万(${botName})`,
|
|
1658
|
+
`@万万(${botName})`,
|
|
1659
|
+
];
|
|
1660
|
+
if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
|
|
1661
|
+
names.push(bot.config.name);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return Array.from(new Set(names));
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Resolve the target bot name(s) for a /chairman command using every signal
|
|
1669
|
+
* available, in priority order:
|
|
1670
|
+
* 1. Feishu mention metadata (app_id / open_id / name).
|
|
1671
|
+
* 2. Text fallback: "@Bot" or "Bot" written in the message body.
|
|
1672
|
+
*
|
|
1673
|
+
* This keeps /chairman working even when a Feishu client or bridge omits
|
|
1674
|
+
* mention metadata. Returns a de-duplicated, order-preserving list.
|
|
1675
|
+
*/
|
|
1676
|
+
resolveChairmanTargets(mentions, text, rawText = "") {
|
|
1677
|
+
const fromMeta = this.mentionedBotNames(mentions);
|
|
1678
|
+
if (fromMeta.length > 0)
|
|
1679
|
+
return fromMeta;
|
|
1680
|
+
return this.mentionedBotNamesFromChairmanText(text, rawText);
|
|
1681
|
+
}
|
|
1476
1682
|
isDiscussionCoordinator() {
|
|
1477
1683
|
const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
|
|
1478
1684
|
if (bots.length === 0)
|
|
@@ -1879,7 +2085,7 @@ export class FeishuBot {
|
|
|
1879
2085
|
}
|
|
1880
2086
|
if (action === "rounds") {
|
|
1881
2087
|
const n = Number.parseInt(parts[2] || "", 10);
|
|
1882
|
-
if (!Number.
|
|
2088
|
+
if (!Number.isInteger(n) || n < 1 || n > 10) {
|
|
1883
2089
|
await this.replyMessage(messageId, "❌ Usage: /discuss rounds <1-10>");
|
|
1884
2090
|
return;
|
|
1885
2091
|
}
|
|
@@ -1906,32 +2112,43 @@ export class FeishuBot {
|
|
|
1906
2112
|
active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
|
|
1907
2113
|
].join("\n"));
|
|
1908
2114
|
}
|
|
1909
|
-
async handleChairmanCommand(chatId, chatType, messageId, mentions, text) {
|
|
2115
|
+
async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
|
|
1910
2116
|
if (chatType === "p2p") {
|
|
1911
2117
|
await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
|
|
1912
2118
|
return;
|
|
1913
2119
|
}
|
|
1914
2120
|
const parts = text.split(/\s+/).filter(Boolean);
|
|
1915
|
-
const action = (parts[1] || "
|
|
2121
|
+
const action = (parts[1] || "").toLowerCase();
|
|
1916
2122
|
if (["off", "clear", "none"].includes(action)) {
|
|
1917
2123
|
const previous = this.store.getChairmanBot(chatId);
|
|
1918
2124
|
this.store.clearChairmanBot(chatId);
|
|
1919
2125
|
await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
|
|
1920
2126
|
return;
|
|
1921
2127
|
}
|
|
1922
|
-
const
|
|
1923
|
-
if (
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
2128
|
+
const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
|
|
2129
|
+
if (resolvedBotNames.length === 0) {
|
|
2130
|
+
// /chairman is intentionally not a status query. Status lives in /status;
|
|
2131
|
+
// this command is only for switching or clearing the Chairman. Keeping the
|
|
2132
|
+
// two meanings separate prevents failed mention parsing from being masked
|
|
2133
|
+
// as a harmless "current status" response.
|
|
2134
|
+
const groupBots = Array.from(FeishuBot.allBots.values())
|
|
2135
|
+
.filter((bot) => bot.store === this.store)
|
|
2136
|
+
.map((bot) => bot.config.name);
|
|
2137
|
+
const uniqueGroupBots = Array.from(new Set(groupBots));
|
|
2138
|
+
const choices = uniqueGroupBots.length > 0
|
|
2139
|
+
? uniqueGroupBots.map((name) => `/chairman ${name}`).join("\n")
|
|
2140
|
+
: "/chairman @某个Bot";
|
|
2141
|
+
await this.replyMessage(messageId, `❌ /chairman 只用于设置/切换 Chairman,不再用于状态查询。\n` +
|
|
2142
|
+
`查看当前 Chairman 请用 /status。\n` +
|
|
2143
|
+
`切换请 @ 一个 bot,或直接发(可复制):\n${choices}\n` +
|
|
2144
|
+
`清除请用:/chairman off`);
|
|
1928
2145
|
return;
|
|
1929
2146
|
}
|
|
1930
|
-
if (
|
|
2147
|
+
if (resolvedBotNames.length > 1) {
|
|
1931
2148
|
await this.replyMessage(messageId, "❌ 一个群只能设置一个 Chairman。请只 @ 一个 bot。");
|
|
1932
2149
|
return;
|
|
1933
2150
|
}
|
|
1934
|
-
const next =
|
|
2151
|
+
const next = resolvedBotNames[0];
|
|
1935
2152
|
const previous = this.store.getChairmanBot(chatId);
|
|
1936
2153
|
this.store.setChairmanBot(chatId, next);
|
|
1937
2154
|
const mode = this.store.getBotMode(next, chatId);
|
|
@@ -2113,7 +2330,7 @@ export class FeishuBot {
|
|
|
2113
2330
|
chatName: "私聊",
|
|
2114
2331
|
members: "",
|
|
2115
2332
|
memberNames: "",
|
|
2116
|
-
ownerBot: this.config.name,
|
|
2333
|
+
ownerBot: existing?.ownerBot || this.config.name,
|
|
2117
2334
|
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
2118
2335
|
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
2119
2336
|
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|
package/dist/message-store.d.ts
CHANGED
|
@@ -72,6 +72,10 @@ export declare class MessageStore {
|
|
|
72
72
|
getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
|
|
73
73
|
hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
|
|
74
74
|
hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
|
|
75
|
+
resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
|
|
76
|
+
restored: number;
|
|
77
|
+
failed: number;
|
|
78
|
+
};
|
|
75
79
|
claimDelivery(id: number): boolean;
|
|
76
80
|
markDeliveryDelivered(id: number): void;
|
|
77
81
|
markDeliveryFailed(id: number): void;
|
package/dist/message-store.js
CHANGED
|
@@ -436,6 +436,20 @@ export class MessageStore {
|
|
|
436
436
|
}
|
|
437
437
|
return false;
|
|
438
438
|
}
|
|
439
|
+
resetStaleDeliveries(botName, staleMs, maxAttempts) {
|
|
440
|
+
const cutoff = Date.now() - staleMs;
|
|
441
|
+
const fail = this.db.prepare(`
|
|
442
|
+
UPDATE delivery_outbox
|
|
443
|
+
SET status = 'failed', updated_at = ?
|
|
444
|
+
WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts >= ?
|
|
445
|
+
`).run(Date.now(), botName, cutoff, maxAttempts);
|
|
446
|
+
const restore = this.db.prepare(`
|
|
447
|
+
UPDATE delivery_outbox
|
|
448
|
+
SET status = 'pending', updated_at = ?
|
|
449
|
+
WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts < ?
|
|
450
|
+
`).run(Date.now(), botName, cutoff, maxAttempts);
|
|
451
|
+
return { restored: restore.changes, failed: fail.changes };
|
|
452
|
+
}
|
|
439
453
|
claimDelivery(id) {
|
|
440
454
|
const result = this.db.prepare(`
|
|
441
455
|
UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
|