openclaw-lark-multi-agent 0.1.12 → 0.1.14
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/discussion-manager.d.ts +4 -1
- package/dist/discussion-manager.js +1 -1
- package/dist/feishu-bot.d.ts +10 -0
- package/dist/feishu-bot.js +123 -42
- package/dist/message-store.d.ts +25 -0
- package/dist/message-store.js +142 -0
- package/dist/openclaw-client.js +20 -12
- package/package.json +1 -1
|
@@ -20,7 +20,10 @@ type DiscussionSession = {
|
|
|
20
20
|
};
|
|
21
21
|
export type DiscussionParticipant = {
|
|
22
22
|
name: string;
|
|
23
|
-
runDiscussionTurn(chatId: string, prompt: string
|
|
23
|
+
runDiscussionTurn(chatId: string, prompt: string, meta?: {
|
|
24
|
+
round: number;
|
|
25
|
+
maxRounds: number;
|
|
26
|
+
}): Promise<ReplyResult>;
|
|
24
27
|
};
|
|
25
28
|
export declare class DiscussionManager {
|
|
26
29
|
private sessions;
|
|
@@ -70,7 +70,7 @@ export class DiscussionManager {
|
|
|
70
70
|
const prompt = this.buildPrompt(session);
|
|
71
71
|
const results = await Promise.allSettled(participants.map(async (participant) => {
|
|
72
72
|
try {
|
|
73
|
-
return await participant.runDiscussionTurn(session.chatId, prompt);
|
|
73
|
+
return await participant.runDiscussionTurn(session.chatId, prompt, { round: session.currentRound, maxRounds: session.maxRounds });
|
|
74
74
|
}
|
|
75
75
|
catch (err) {
|
|
76
76
|
return {
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare class FeishuBot {
|
|
|
29
29
|
private queueRuns;
|
|
30
30
|
/** Per-chat serial send queue to guarantee message order */
|
|
31
31
|
private sendQueue;
|
|
32
|
+
/** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
|
|
33
|
+
private delayedFailureTimers;
|
|
32
34
|
private adminOpenId;
|
|
33
35
|
private static allBots;
|
|
34
36
|
constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string);
|
|
@@ -77,6 +79,14 @@ export declare class FeishuBot {
|
|
|
77
79
|
private stripLeadingCommandMentions;
|
|
78
80
|
private buildMarkdownCard;
|
|
79
81
|
private formatUserVisibleError;
|
|
82
|
+
private isRuntimeFailureText;
|
|
83
|
+
private cancelDelayedFailure;
|
|
84
|
+
private scheduleDelayedFailure;
|
|
85
|
+
private deliverySessionKey;
|
|
86
|
+
private stableHash;
|
|
87
|
+
private deliverySourceId;
|
|
88
|
+
private enqueueAndDispatchDelivery;
|
|
89
|
+
private dispatchPendingDeliveries;
|
|
80
90
|
private isDiscussionCoordinator;
|
|
81
91
|
private getDiscussionParticipants;
|
|
82
92
|
private runDiscussionTurn;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -34,6 +34,8 @@ export class FeishuBot {
|
|
|
34
34
|
queueRuns = new Map();
|
|
35
35
|
/** Per-chat serial send queue to guarantee message order */
|
|
36
36
|
sendQueue = new Map();
|
|
37
|
+
/** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
|
|
38
|
+
delayedFailureTimers = new Map();
|
|
37
39
|
adminOpenId;
|
|
38
40
|
static allBots = new Map();
|
|
39
41
|
constructor(config, openclawClient, store, adminOpenId) {
|
|
@@ -114,11 +116,9 @@ export class FeishuBot {
|
|
|
114
116
|
try {
|
|
115
117
|
console.log(`[${this.config.name}] Proactive message for ${chatId.slice(-8)}`);
|
|
116
118
|
const parsed = this.extractBridgeAttachments(text);
|
|
117
|
-
if (parsed.text.trim())
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
await this.sendBridgeAttachment(chatId, attachment);
|
|
121
|
-
}
|
|
119
|
+
if (parsed.text.trim() || parsed.attachments.length > 0)
|
|
120
|
+
this.cancelDelayedFailure(chatId);
|
|
121
|
+
await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments);
|
|
122
122
|
}
|
|
123
123
|
catch (err) {
|
|
124
124
|
console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
|
|
@@ -661,6 +661,7 @@ export class FeishuBot {
|
|
|
661
661
|
this.store.markSynced(this.config.name, chatId, maxId);
|
|
662
662
|
this.store.clearPendingTriggers(this.config.name, chatId, maxId);
|
|
663
663
|
const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
|
|
664
|
+
const isRuntimeFailure = shouldReply && this.isRuntimeFailureText(trimmedReply);
|
|
664
665
|
// Record bot reply only if it is user-visible/context-worthy.
|
|
665
666
|
if (shouldReply || hasAttachments) {
|
|
666
667
|
const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
|
|
@@ -692,32 +693,19 @@ export class FeishuBot {
|
|
|
692
693
|
}
|
|
693
694
|
// Reply to the last human message on Feishu (ordered after tool msgs)
|
|
694
695
|
// Skip empty replies and explicit NO_REPLY responses
|
|
695
|
-
if ((shouldReply || hasAttachments) && lastHuman.messageId) {
|
|
696
|
+
if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
|
|
696
697
|
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
697
698
|
console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
698
699
|
}
|
|
699
700
|
else {
|
|
700
|
-
await this.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
await this.replyMessage(lastHuman.messageId, visibleReply);
|
|
704
|
-
}
|
|
705
|
-
catch (err) {
|
|
706
|
-
// Reply can fail for historical messages sent before this bot joined the chat
|
|
707
|
-
// (Feishu 230002: Bot/User can NOT be out of the chat). Fall back to a normal
|
|
708
|
-
// chat message so queue processing still completes.
|
|
709
|
-
console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
|
|
710
|
-
await this.sendMessage(chatId, visibleReply);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
for (const attachment of parsedReply.attachments) {
|
|
714
|
-
await this.sendBridgeAttachment(chatId, attachment);
|
|
715
|
-
}
|
|
716
|
-
if (triggerId)
|
|
717
|
-
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
718
|
-
});
|
|
701
|
+
await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
|
|
702
|
+
if (triggerId)
|
|
703
|
+
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
719
704
|
}
|
|
720
705
|
}
|
|
706
|
+
if (isRuntimeFailure && lastHuman.messageId) {
|
|
707
|
+
this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
|
|
708
|
+
}
|
|
721
709
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
722
710
|
// Replace ack reactions with DONE only for trigger messages actually
|
|
723
711
|
// processed in this run. Queued messages that arrived mid-run keep their
|
|
@@ -739,16 +727,12 @@ export class FeishuBot {
|
|
|
739
727
|
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
740
728
|
const errorText = this.formatUserVisibleError(err);
|
|
741
729
|
if (lastHuman.messageId) {
|
|
742
|
-
await this.
|
|
743
|
-
|
|
744
|
-
await this.replyMessage(lastHuman.messageId, errorText);
|
|
745
|
-
}
|
|
746
|
-
catch {
|
|
747
|
-
await this.sendMessage(chatId, errorText);
|
|
748
|
-
}
|
|
730
|
+
await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
|
|
731
|
+
.then(() => {
|
|
749
732
|
if (triggerId)
|
|
750
733
|
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
751
|
-
})
|
|
734
|
+
})
|
|
735
|
+
.catch(() => { });
|
|
752
736
|
}
|
|
753
737
|
if (triggerId) {
|
|
754
738
|
// The run failed after OpenClaw/provider rejected it. Notify the user
|
|
@@ -899,6 +883,105 @@ export class FeishuBot {
|
|
|
899
883
|
}
|
|
900
884
|
return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
|
|
901
885
|
}
|
|
886
|
+
isRuntimeFailureText(text) {
|
|
887
|
+
return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
|
|
888
|
+
}
|
|
889
|
+
cancelDelayedFailure(chatId) {
|
|
890
|
+
const timer = this.delayedFailureTimers.get(chatId);
|
|
891
|
+
if (timer)
|
|
892
|
+
clearTimeout(timer);
|
|
893
|
+
this.delayedFailureTimers.delete(chatId);
|
|
894
|
+
}
|
|
895
|
+
scheduleDelayedFailure(chatId, replyToMessageId, text, triggerId) {
|
|
896
|
+
this.cancelDelayedFailure(chatId);
|
|
897
|
+
const timer = setTimeout(() => {
|
|
898
|
+
this.delayedFailureTimers.delete(chatId);
|
|
899
|
+
void this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`)
|
|
900
|
+
.then(() => {
|
|
901
|
+
if (triggerId)
|
|
902
|
+
this.store.markDeliveredReply(this.config.name, chatId, triggerId, replyToMessageId);
|
|
903
|
+
})
|
|
904
|
+
.catch((err) => {
|
|
905
|
+
console.warn(`[${this.config.name}] delayed failure delivery failed:`, err.message);
|
|
906
|
+
});
|
|
907
|
+
}, 60_000);
|
|
908
|
+
this.delayedFailureTimers.set(chatId, timer);
|
|
909
|
+
}
|
|
910
|
+
deliverySessionKey(chatId) {
|
|
911
|
+
return this.getSessionKey(chatId);
|
|
912
|
+
}
|
|
913
|
+
stableHash(text) {
|
|
914
|
+
let hash = 2166136261;
|
|
915
|
+
for (let i = 0; i < text.length; i++) {
|
|
916
|
+
hash ^= text.charCodeAt(i);
|
|
917
|
+
hash = Math.imul(hash, 16777619);
|
|
918
|
+
}
|
|
919
|
+
return (hash >>> 0).toString(36);
|
|
920
|
+
}
|
|
921
|
+
deliverySourceId(kind, text) {
|
|
922
|
+
return `${kind}:${this.stableHash(text)}`;
|
|
923
|
+
}
|
|
924
|
+
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
|
|
925
|
+
if (!text.trim() && attachments.length === 0)
|
|
926
|
+
return;
|
|
927
|
+
const normalizedPayload = `${text.trim()}|${JSON.stringify(attachments)}`;
|
|
928
|
+
const contentHash = this.stableHash(normalizedPayload);
|
|
929
|
+
const finalDeliveryKey = deliveryKey || sourceId;
|
|
930
|
+
if (!deliveryKey && this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000))
|
|
931
|
+
return;
|
|
932
|
+
const deliveryId = this.store.enqueueDelivery({
|
|
933
|
+
sessionKey: this.deliverySessionKey(chatId),
|
|
934
|
+
chatId,
|
|
935
|
+
botName: this.config.name,
|
|
936
|
+
sourceType,
|
|
937
|
+
sourceId,
|
|
938
|
+
deliveryKey: finalDeliveryKey,
|
|
939
|
+
contentHash,
|
|
940
|
+
content: text,
|
|
941
|
+
attachmentsJson: JSON.stringify(attachments),
|
|
942
|
+
replyToMessageId: replyToMessageId || "",
|
|
943
|
+
});
|
|
944
|
+
if (deliveryId === null)
|
|
945
|
+
return;
|
|
946
|
+
await this.dispatchPendingDeliveries(chatId, replyToMessageId);
|
|
947
|
+
}
|
|
948
|
+
async dispatchPendingDeliveries(chatId, replyToMessageId) {
|
|
949
|
+
const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
|
|
950
|
+
for (const item of pending) {
|
|
951
|
+
await this.sendOrdered(chatId, async () => {
|
|
952
|
+
try {
|
|
953
|
+
if (!item.id || !this.store.claimDelivery(item.id))
|
|
954
|
+
return;
|
|
955
|
+
const attachments = JSON.parse(item.attachmentsJson || "[]");
|
|
956
|
+
if (item.content.trim()) {
|
|
957
|
+
const replyTarget = item.replyToMessageId || replyToMessageId;
|
|
958
|
+
const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
|
|
959
|
+
if (shouldReplyToSource) {
|
|
960
|
+
try {
|
|
961
|
+
await this.replyMessage(replyTarget, item.content);
|
|
962
|
+
}
|
|
963
|
+
catch (err) {
|
|
964
|
+
console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
|
|
965
|
+
await this.sendMessage(chatId, item.content);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
await this.sendMessage(chatId, item.content);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
for (const attachment of attachments)
|
|
973
|
+
await this.sendBridgeAttachment(chatId, attachment);
|
|
974
|
+
if (item.id)
|
|
975
|
+
this.store.markDeliveryDelivered(item.id);
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
if (item.id)
|
|
979
|
+
this.store.markDeliveryFailed(item.id);
|
|
980
|
+
throw err;
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
902
985
|
isDiscussionCoordinator() {
|
|
903
986
|
const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
|
|
904
987
|
if (bots.length === 0)
|
|
@@ -910,10 +993,10 @@ export class FeishuBot {
|
|
|
910
993
|
.filter((bot) => bot.store === this.store && bot.store.getBotMode(bot.config.name, chatId) === "free")
|
|
911
994
|
.map((bot) => ({
|
|
912
995
|
name: bot.config.name,
|
|
913
|
-
runDiscussionTurn: async (_chatId, prompt) => bot.runDiscussionTurn(chatId, prompt),
|
|
996
|
+
runDiscussionTurn: async (_chatId, prompt, meta) => bot.runDiscussionTurn(chatId, prompt, meta),
|
|
914
997
|
}));
|
|
915
998
|
}
|
|
916
|
-
async runDiscussionTurn(chatId, prompt) {
|
|
999
|
+
async runDiscussionTurn(chatId, prompt, meta) {
|
|
917
1000
|
const sessionKey = await this.ensureSession(chatId);
|
|
918
1001
|
const reply = await this.openclawClient.chatSendWithContext({
|
|
919
1002
|
sessionKey,
|
|
@@ -924,8 +1007,11 @@ export class FeishuBot {
|
|
|
924
1007
|
timeoutMs: 1_800_000,
|
|
925
1008
|
});
|
|
926
1009
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
927
|
-
|
|
1010
|
+
let visibleReply = parsedReply.text.trim();
|
|
928
1011
|
const isVisible = visibleReply.length > 0 && visibleReply.toUpperCase() !== "NO_REPLY";
|
|
1012
|
+
if (isVisible && meta) {
|
|
1013
|
+
visibleReply = `${visibleReply}\n\n—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
|
|
1014
|
+
}
|
|
929
1015
|
if (isVisible || parsedReply.attachments.length > 0) {
|
|
930
1016
|
const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
|
|
931
1017
|
.filter(Boolean)
|
|
@@ -938,12 +1024,7 @@ export class FeishuBot {
|
|
|
938
1024
|
content: storedContent,
|
|
939
1025
|
timestamp: Date.now(),
|
|
940
1026
|
});
|
|
941
|
-
await this.
|
|
942
|
-
if (isVisible)
|
|
943
|
-
await this.sendMessage(chatId, visibleReply);
|
|
944
|
-
for (const attachment of parsedReply.attachments)
|
|
945
|
-
await this.sendBridgeAttachment(chatId, attachment);
|
|
946
|
-
});
|
|
1027
|
+
await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? visibleReply : "", parsedReply.attachments);
|
|
947
1028
|
}
|
|
948
1029
|
return { botName: this.config.name, text: visibleReply, visible: isVisible };
|
|
949
1030
|
}
|
package/dist/message-store.d.ts
CHANGED
|
@@ -25,6 +25,23 @@ export interface ChatMessage {
|
|
|
25
25
|
content: string;
|
|
26
26
|
timestamp: number;
|
|
27
27
|
}
|
|
28
|
+
export interface DeliveryOutboxItem {
|
|
29
|
+
id?: number;
|
|
30
|
+
sessionKey: string;
|
|
31
|
+
chatId: string;
|
|
32
|
+
botName: string;
|
|
33
|
+
sourceType: string;
|
|
34
|
+
sourceId: string;
|
|
35
|
+
deliveryKey: string;
|
|
36
|
+
contentHash: string;
|
|
37
|
+
content: string;
|
|
38
|
+
attachmentsJson: string;
|
|
39
|
+
replyToMessageId: string;
|
|
40
|
+
status: "pending" | "delivering" | "delivered" | "failed";
|
|
41
|
+
attempts: number;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
updatedAt: number;
|
|
44
|
+
}
|
|
28
45
|
export declare class MessageStore {
|
|
29
46
|
private db;
|
|
30
47
|
constructor(dbPath?: string);
|
|
@@ -40,6 +57,14 @@ export declare class MessageStore {
|
|
|
40
57
|
clearPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
|
|
41
58
|
hasDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number): boolean;
|
|
42
59
|
markDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number, replyMessageId?: string): void;
|
|
60
|
+
enqueueDelivery(item: Omit<DeliveryOutboxItem, "id" | "status" | "attempts" | "createdAt" | "updatedAt">): number | null;
|
|
61
|
+
getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
|
|
62
|
+
getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
|
|
63
|
+
hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number): boolean;
|
|
64
|
+
claimDelivery(id: number): boolean;
|
|
65
|
+
markDeliveryDelivered(id: number): void;
|
|
66
|
+
markDeliveryFailed(id: number): void;
|
|
67
|
+
private mapDelivery;
|
|
43
68
|
/**
|
|
44
69
|
* Get messages that haven't been synced to a bot's session yet.
|
|
45
70
|
* Returns messages ordered by timestamp ascending.
|
package/dist/message-store.js
CHANGED
|
@@ -82,7 +82,74 @@ export class MessageStore {
|
|
|
82
82
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
83
83
|
PRIMARY KEY (bot_name, chat_id)
|
|
84
84
|
);
|
|
85
|
+
|
|
86
|
+
-- Durable delivery ledger for user-visible assistant outputs. All final
|
|
87
|
+
-- text/attachment outputs should be inserted here first and dispatched
|
|
88
|
+
-- once, regardless of whether they came from chat final, proactive
|
|
89
|
+
-- session.message, subagent announce, or delayed error handling.
|
|
90
|
+
CREATE TABLE IF NOT EXISTS delivery_outbox (
|
|
91
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
+
session_key TEXT NOT NULL,
|
|
93
|
+
chat_id TEXT NOT NULL,
|
|
94
|
+
bot_name TEXT NOT NULL,
|
|
95
|
+
source_type TEXT NOT NULL,
|
|
96
|
+
source_id TEXT NOT NULL,
|
|
97
|
+
delivery_key TEXT NOT NULL DEFAULT '',
|
|
98
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
99
|
+
content TEXT NOT NULL DEFAULT '',
|
|
100
|
+
attachments_json TEXT NOT NULL DEFAULT '[]',
|
|
101
|
+
reply_to_message_id TEXT NOT NULL DEFAULT '',
|
|
102
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
103
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
created_at INTEGER NOT NULL,
|
|
105
|
+
updated_at INTEGER NOT NULL,
|
|
106
|
+
UNIQUE(bot_name, chat_id, delivery_key),
|
|
107
|
+
UNIQUE(session_key, source_type, source_id)
|
|
108
|
+
);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_outbox_pending ON delivery_outbox(status, created_at);
|
|
85
110
|
`);
|
|
111
|
+
// Migration: add delivery_outbox delivery key columns if missing
|
|
112
|
+
try {
|
|
113
|
+
this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_key TEXT NOT NULL DEFAULT ''`);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Column already exists
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Column already exists
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN reply_to_message_id TEXT NOT NULL DEFAULT ''`);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Column already exists
|
|
129
|
+
}
|
|
130
|
+
// Backfill stable keys for rows created by earlier outbox experiments before
|
|
131
|
+
// creating indexes that reference the new columns.
|
|
132
|
+
this.db.exec(`
|
|
133
|
+
UPDATE delivery_outbox
|
|
134
|
+
SET delivery_key = source_id
|
|
135
|
+
WHERE delivery_key IS NULL OR delivery_key = '';
|
|
136
|
+
`);
|
|
137
|
+
try {
|
|
138
|
+
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_outbox_key ON delivery_outbox(bot_name, chat_id, delivery_key)`);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Existing duplicate experimental rows can prevent index creation in dev DB.
|
|
142
|
+
// Fresh DBs still get the table-level UNIQUE constraint; dirty DBs still use
|
|
143
|
+
// source unique + short-window content hash until cleaned.
|
|
144
|
+
}
|
|
145
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_delivery_outbox_content ON delivery_outbox(bot_name, chat_id, content_hash, created_at)`);
|
|
146
|
+
// Migration: add delivery_outbox reply target if missing
|
|
147
|
+
try {
|
|
148
|
+
this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN reply_to_message_id TEXT NOT NULL DEFAULT ''`);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Column already exists
|
|
152
|
+
}
|
|
86
153
|
// Migration: add verbose column if missing
|
|
87
154
|
try {
|
|
88
155
|
this.db.exec(`ALTER TABLE chat_info ADD COLUMN verbose INTEGER NOT NULL DEFAULT 0`);
|
|
@@ -197,6 +264,81 @@ export class MessageStore {
|
|
|
197
264
|
VALUES (?, ?, ?, ?, ?)
|
|
198
265
|
`).run(botName, chatId, triggerMessageRowId, Date.now(), replyMessageId);
|
|
199
266
|
}
|
|
267
|
+
enqueueDelivery(item) {
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const result = this.db.prepare(`
|
|
270
|
+
INSERT OR IGNORE INTO delivery_outbox
|
|
271
|
+
(session_key, chat_id, bot_name, source_type, source_id, delivery_key, content_hash, content, attachments_json, reply_to_message_id, status, attempts, created_at, updated_at)
|
|
272
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
|
|
273
|
+
`).run(item.sessionKey, item.chatId, item.botName, item.sourceType, item.sourceId, item.deliveryKey, item.contentHash, item.content, item.attachmentsJson, item.replyToMessageId || '', now, now);
|
|
274
|
+
return result.changes ? Number(result.lastInsertRowid) : null;
|
|
275
|
+
}
|
|
276
|
+
getDeliveryBySource(sessionKey, sourceType, sourceId) {
|
|
277
|
+
const row = this.db.prepare(`
|
|
278
|
+
SELECT * FROM delivery_outbox
|
|
279
|
+
WHERE session_key = ? AND source_type = ? AND source_id = ?
|
|
280
|
+
`).get(sessionKey, sourceType, sourceId);
|
|
281
|
+
return row ? this.mapDelivery(row) : null;
|
|
282
|
+
}
|
|
283
|
+
getPendingDeliveries(chatId, botName, maxCount = 20) {
|
|
284
|
+
if (chatId && botName) {
|
|
285
|
+
const rows = this.db.prepare(`
|
|
286
|
+
SELECT * FROM delivery_outbox
|
|
287
|
+
WHERE status = 'pending' AND chat_id = ? AND bot_name = ?
|
|
288
|
+
ORDER BY created_at ASC LIMIT ?
|
|
289
|
+
`).all(chatId, botName, maxCount);
|
|
290
|
+
return rows.map((r) => this.mapDelivery(r));
|
|
291
|
+
}
|
|
292
|
+
const rows = this.db.prepare(`
|
|
293
|
+
SELECT * FROM delivery_outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?
|
|
294
|
+
`).all(maxCount);
|
|
295
|
+
return rows.map((r) => this.mapDelivery(r));
|
|
296
|
+
}
|
|
297
|
+
hasRecentSimilarDelivery(botName, chatId, contentHash, windowMs) {
|
|
298
|
+
if (!contentHash)
|
|
299
|
+
return false;
|
|
300
|
+
const row = this.db.prepare(`
|
|
301
|
+
SELECT 1 FROM delivery_outbox
|
|
302
|
+
WHERE bot_name = ? AND chat_id = ? AND content_hash = ? AND created_at >= ? AND status IN ('pending', 'delivering', 'delivered')
|
|
303
|
+
LIMIT 1
|
|
304
|
+
`).get(botName, chatId, contentHash, Date.now() - windowMs);
|
|
305
|
+
return !!row;
|
|
306
|
+
}
|
|
307
|
+
claimDelivery(id) {
|
|
308
|
+
const result = this.db.prepare(`
|
|
309
|
+
UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
|
|
310
|
+
`).run(Date.now(), id);
|
|
311
|
+
return result.changes === 1;
|
|
312
|
+
}
|
|
313
|
+
markDeliveryDelivered(id) {
|
|
314
|
+
this.db.prepare(`
|
|
315
|
+
UPDATE delivery_outbox SET status = 'delivered', updated_at = ? WHERE id = ?
|
|
316
|
+
`).run(Date.now(), id);
|
|
317
|
+
}
|
|
318
|
+
markDeliveryFailed(id) {
|
|
319
|
+
this.db.prepare(`
|
|
320
|
+
UPDATE delivery_outbox SET status = 'failed', updated_at = ? WHERE id = ?
|
|
321
|
+
`).run(Date.now(), id);
|
|
322
|
+
}
|
|
323
|
+
mapDelivery(row) {
|
|
324
|
+
return {
|
|
325
|
+
id: row.id,
|
|
326
|
+
sessionKey: row.session_key,
|
|
327
|
+
chatId: row.chat_id,
|
|
328
|
+
botName: row.bot_name,
|
|
329
|
+
sourceType: row.source_type,
|
|
330
|
+
sourceId: row.source_id,
|
|
331
|
+
deliveryKey: row.delivery_key || row.source_id,
|
|
332
|
+
contentHash: row.content_hash || '',
|
|
333
|
+
content: row.content || '',
|
|
334
|
+
attachmentsJson: row.attachments_json || '[]',
|
|
335
|
+
replyToMessageId: row.reply_to_message_id || '',
|
|
336
|
+
status: row.status,
|
|
337
|
+
attempts: row.attempts || 0,
|
|
338
|
+
createdAt: row.created_at,
|
|
339
|
+
updatedAt: row.updated_at,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
200
342
|
/**
|
|
201
343
|
* Get messages that haven't been synced to a bot's session yet.
|
|
202
344
|
* Returns messages ordered by timestamp ascending.
|
package/dist/openclaw-client.js
CHANGED
|
@@ -173,13 +173,11 @@ export class OpenClawClient {
|
|
|
173
173
|
}
|
|
174
174
|
if (proactiveText) {
|
|
175
175
|
if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
|
|
176
|
-
console.log(`[OpenClaw]
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
180
|
-
if (cb)
|
|
181
|
-
cb(proactiveText);
|
|
176
|
+
console.log(`[OpenClaw] Forwarding proactive msg for ${shortKey} during active chatSend; delivery outbox will dedupe`);
|
|
182
177
|
}
|
|
178
|
+
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
179
|
+
if (cb)
|
|
180
|
+
cb(proactiveText);
|
|
183
181
|
}
|
|
184
182
|
// Tool calls in assistant messages — skip, using agent item events instead
|
|
185
183
|
// (session.message toolCall events are batched, not real-time)
|
|
@@ -352,7 +350,14 @@ export class OpenClawClient {
|
|
|
352
350
|
});
|
|
353
351
|
// Prefer final chat message over accumulated deltas: some providers may
|
|
354
352
|
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
355
|
-
|
|
353
|
+
const latestFinalText = chatFinalText || text;
|
|
354
|
+
if (latestFinalText) {
|
|
355
|
+
finish(latestFinalText);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
console.warn(`[OpenClaw] collectReply: empty chatFinal fallback ignored; waiting for real text or idle timeout`);
|
|
359
|
+
chatFinalTimer = null;
|
|
360
|
+
}
|
|
356
361
|
}, 5000);
|
|
357
362
|
}
|
|
358
363
|
}
|
|
@@ -369,7 +374,7 @@ export class OpenClawClient {
|
|
|
369
374
|
finish("NO_REPLY");
|
|
370
375
|
return;
|
|
371
376
|
}
|
|
372
|
-
if (!latestFinalText
|
|
377
|
+
if (!latestFinalText) {
|
|
373
378
|
const state = ev.data?.livenessState || "unknown";
|
|
374
379
|
const reason = ev.data?.stopReason || "";
|
|
375
380
|
const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
|
|
@@ -379,11 +384,14 @@ export class OpenClawClient {
|
|
|
379
384
|
replayInvalidTimer = setTimeout(() => finish(failureText), 120000);
|
|
380
385
|
return;
|
|
381
386
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
387
|
+
if (ev.data?.livenessState !== "working") {
|
|
388
|
+
finish(failureText);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
|
|
392
|
+
return;
|
|
386
393
|
}
|
|
394
|
+
finish(latestFinalText);
|
|
387
395
|
};
|
|
388
396
|
// If lifecycle end beats chat final, a short delta like "N" can be a truncated
|
|
389
397
|
// final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
|