openclaw-lark-multi-agent 0.1.14 → 1.0.0
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/README.md +54 -1
- package/README.zh-CN.md +52 -1
- package/dist/discussion-manager.js +17 -0
- package/dist/feishu-bot.d.ts +1 -0
- package/dist/feishu-bot.js +64 -18
- package/dist/message-store.d.ts +3 -0
- package/dist/message-store.js +48 -3
- package/dist/openclaw-client.d.ts +8 -1
- package/dist/openclaw-client.js +90 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,10 +32,13 @@ All of them connect to the same OpenClaw Gateway while keeping sessions, queues,
|
|
|
32
32
|
- Local SQLite message store for context, trigger tracking, and duplicate prevention
|
|
33
33
|
- `pending_triggers` queue so restart recovery does not replay every context message
|
|
34
34
|
- `delivered_replies` table so one trigger message gets at most one delivered reply per bot
|
|
35
|
+
- Durable delivery outbox for assistant-visible output, with stable delivery keys, atomic claim-before-send dispatch, and short-window duplicate suppression
|
|
36
|
+
- Message recall handling: recalled queued/pending user messages are removed before they reach OpenClaw and excluded from future context
|
|
35
37
|
- Feishu image download and OpenClaw multimodal attachment forwarding
|
|
36
38
|
- Bridge attachment marker protocol for generated files/images/documents
|
|
37
39
|
- Feishu CardKit v2 Markdown rendering, including native table elements for pipe tables
|
|
38
40
|
- Bridge-level slash commands and escaped OpenClaw slash commands
|
|
41
|
+
- `/discuss` mode for barrier-style multi-bot group discussion, including per-round markers and no-reply status notices
|
|
39
42
|
- Linux systemd installer with separate runtime and state directories
|
|
40
43
|
|
|
41
44
|
## Architecture
|
|
@@ -228,6 +231,7 @@ Bridge-level commands use a single slash and are handled by this project:
|
|
|
228
231
|
- `/free` — toggle this bot's Free mode in the current group chat
|
|
229
232
|
- `/mute` — toggle this bot's mute mode in the current group chat
|
|
230
233
|
- `/mode` — show this bot's current mode in the current chat
|
|
234
|
+
- `/discuss on|off|status|stop|rounds N` — control group-level multi-bot discussion mode
|
|
231
235
|
|
|
232
236
|
OpenClaw-level slash commands can be sent by escaping with a double slash:
|
|
233
237
|
|
|
@@ -294,7 +298,54 @@ the mention-only message is treated as a trigger and is combined with the previo
|
|
|
294
298
|
|
|
295
299
|
Bot messages do not trigger other bots unless they mention them. The anti-loop guard is counted per bot per chat: other bots' replies do not consume the current bot's streak budget, and a human message resets the streak.
|
|
296
300
|
|
|
297
|
-
|
|
301
|
+
### `/discuss` mode
|
|
302
|
+
|
|
303
|
+
`/discuss` is an explicit group-level multi-agent discussion scheduler. It is separate from Free mode:
|
|
304
|
+
|
|
305
|
+
- `/free` controls whether a single bot may answer plain human messages.
|
|
306
|
+
- `/discuss on` lets one coordinator take over plain human messages and run all Free-mode bots in barrier-style rounds.
|
|
307
|
+
- Targeted mentions still fall through to normal routing, so `@GPT hello` works even while discuss mode is enabled.
|
|
308
|
+
- Each participant receives the same round prompt and does not see other participants' replies from the current round until the next round.
|
|
309
|
+
- Each visible discussion reply is annotated with a round marker such as `—— 第 2/3 轮 · Claude`.
|
|
310
|
+
- If some participants return `NO_REPLY` or an empty reply, the coordinator sends a lightweight status notice such as `💬 第 3/3 轮:Qwen、Gemini 无新增回复`.
|
|
311
|
+
- When the configured maximum round count is reached, the coordinator sends a completion notice.
|
|
312
|
+
|
|
313
|
+
Commands:
|
|
314
|
+
|
|
315
|
+
```text
|
|
316
|
+
/discuss on
|
|
317
|
+
/discuss off
|
|
318
|
+
/discuss status
|
|
319
|
+
/discuss stop
|
|
320
|
+
/discuss rounds 3
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Delivery outbox and duplicate prevention
|
|
324
|
+
|
|
325
|
+
All user-visible assistant outputs go through the local `delivery_outbox` before being sent to Feishu. This includes normal chat final replies, proactive `session.message` replies, delayed runtime-error notices, provider-error notices, discussion replies, and attachment marker deliveries.
|
|
326
|
+
|
|
327
|
+
The outbox provides several protections:
|
|
328
|
+
|
|
329
|
+
- stable trigger-based delivery keys such as `trigger:<message_row_id>`;
|
|
330
|
+
- `UNIQUE(bot_name, chat_id, delivery_key)` to prevent duplicate deliveries for the same logical output;
|
|
331
|
+
- `pending -> delivering -> delivered/failed` claim-before-send dispatch to avoid concurrent resend races;
|
|
332
|
+
- short-window content-hash dedupe for proactive-only outputs;
|
|
333
|
+
- short-window containment dedupe for cases where `chat final` contains an intermediate note plus final answer while proactive contains only the final answer;
|
|
334
|
+
- attachment-aware dedupe so file/image/document deliveries are not accidentally collapsed with text-only replies.
|
|
335
|
+
|
|
336
|
+
This keeps normal replies, subagent/proactive completions, discussion messages, delayed runtime failures, provider errors, and generated attachments on one consistent delivery path.
|
|
337
|
+
|
|
338
|
+
## Message recall
|
|
339
|
+
|
|
340
|
+
The bridge subscribes to Feishu `im.message.recalled_v1` events. When a user recalls a message that is still pending or queued:
|
|
341
|
+
|
|
342
|
+
1. the original message remains in the local `messages` ledger for audit;
|
|
343
|
+
2. the message is recorded in `recalled_messages`;
|
|
344
|
+
3. matching `pending_triggers` for each bot are removed;
|
|
345
|
+
4. pending local reaction acknowledgements are removed;
|
|
346
|
+
5. future context sync excludes the recalled message.
|
|
347
|
+
|
|
348
|
+
Version 1 behavior intentionally does **not** abort an OpenClaw run that has already started processing the recalled message, and it does not recall bot replies that were already sent. The first goal is to make recall reliable for queued/not-yet-processed messages.
|
|
298
349
|
|
|
299
350
|
|
|
300
351
|
## Markdown, tables, and attachments
|
|
@@ -316,6 +367,8 @@ SQLite state lives in the configured data directory. Important tables:
|
|
|
316
367
|
- `sync_state` — per-bot/per-chat sync cursor
|
|
317
368
|
- `pending_triggers` — messages that should actively trigger a bot run
|
|
318
369
|
- `delivered_replies` — delivered response markers for idempotency
|
|
370
|
+
- `delivery_outbox` — durable user-visible delivery ledger with claim/dedupe state
|
|
371
|
+
- `recalled_messages` — recalled user messages excluded from pending work and future context
|
|
319
372
|
- `processed_events` — Feishu event de-duplication
|
|
320
373
|
- `bot_chat_settings` — per-bot/per-chat settings such as verbose mode
|
|
321
374
|
|
package/README.zh-CN.md
CHANGED
|
@@ -32,10 +32,13 @@ Lark/飞书里每个机器人都有自己的 App 身份,但 OpenClaw 通常在
|
|
|
32
32
|
- 本地 SQLite 消息存储,用于上下文、触发队列和重复投递防护
|
|
33
33
|
- `pending_triggers` 队列,避免重启后把所有历史上下文都重新发给 OpenClaw
|
|
34
34
|
- `delivered_replies` 表,保证同一个触发消息每个 bot 最多投递一次回复
|
|
35
|
+
- 持久化 delivery outbox:所有用户可见输出先入库,再通过稳定 key、原子 claim 和短窗口去重后投递
|
|
36
|
+
- 消息撤回处理:已撤回且仍在排队/待处理的用户消息会从 pending 队列移除,并从后续上下文中排除
|
|
35
37
|
- 支持飞书图片下载,并以 OpenClaw multimodal attachment 形式转发
|
|
36
38
|
- Bridge attachment marker 协议,用于发送生成的文件、图片和文档
|
|
37
39
|
- Feishu CardKit v2 Markdown 渲染,并把 pipe table 转成原生 table 组件
|
|
38
40
|
- 桥接层 slash command + 转义后的 OpenClaw slash command
|
|
41
|
+
- `/discuss` 多 bot 结构化讨论模式,支持轮次标注和无新增回复提示
|
|
39
42
|
- Linux systemd 安装脚本,运行产物和状态目录分离
|
|
40
43
|
|
|
41
44
|
## 架构
|
|
@@ -228,6 +231,7 @@ openclaw-lark-multi-agent install-windows-service
|
|
|
228
231
|
- `/free` — 开关当前 bot 在当前群聊里的 Free 模式
|
|
229
232
|
- `/mute` — 开关当前 bot 在当前群聊里的 mute 模式
|
|
230
233
|
- `/mode` — 查看当前 bot 在当前聊天里的模式
|
|
234
|
+
- `/discuss on|off|status|stop|rounds N` — 控制群级多 bot 讨论模式
|
|
231
235
|
|
|
232
236
|
如果你想把 slash command 直接发给 OpenClaw,可以用双斜杠转义:
|
|
233
237
|
|
|
@@ -294,8 +298,55 @@ Free 模式是 per-bot 且保守的:
|
|
|
294
298
|
|
|
295
299
|
bot 发出的消息默认不会触发其他 bot,除非明确 @。anti-loop 防护按 bot + chat 单独计算:其他 bot 的发言不会消耗当前 bot 的额度,人类发言会重置计数。
|
|
296
300
|
|
|
297
|
-
`/free` 不是完整的多 Agent 讨论调度器。未来独立 `/discuss` 模式的设计草案在 [`docs/ideas/discussion-mode.md`](docs/ideas/discussion-mode.md)。
|
|
298
301
|
|
|
302
|
+
## `/discuss` 讨论模式
|
|
303
|
+
|
|
304
|
+
`/discuss` 是显式的群级多智能体讨论调度器,和 Free 模式分工不同:
|
|
305
|
+
|
|
306
|
+
- `/free` 控制单个 bot 是否可以响应普通人类消息。
|
|
307
|
+
- `/discuss on` 让一个 coordinator 接管普通人类消息,并按 barrier-style round 调度所有 Free 模式 bot。
|
|
308
|
+
- 定向 @ 仍然走普通路由,所以 discuss 开启时 `@GPT hello` 仍会只触发 GPT。
|
|
309
|
+
- 每个参与 bot 在同一轮拿到相同 prompt,本轮内看不到其他 bot 的回复,下一轮才会看到上一轮结果。
|
|
310
|
+
- 每条可见讨论回复会自动追加轮次标注,例如 `—— 第 2/3 轮 · Claude`。
|
|
311
|
+
- 如果某些 bot 返回 `NO_REPLY` 或空回复,coordinator 会发送轻量提示,例如 `💬 第 3/3 轮:Qwen、Gemini 无新增回复`。
|
|
312
|
+
- 达到配置轮数后,coordinator 会发送讨论完成提示。
|
|
313
|
+
|
|
314
|
+
命令:
|
|
315
|
+
|
|
316
|
+
```text
|
|
317
|
+
/discuss on
|
|
318
|
+
/discuss off
|
|
319
|
+
/discuss status
|
|
320
|
+
/discuss stop
|
|
321
|
+
/discuss rounds 3
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Delivery outbox 与重复投递防护
|
|
325
|
+
|
|
326
|
+
所有用户可见 assistant 输出都会先进入本地 `delivery_outbox`,再统一投递到飞书。覆盖普通 chat final、proactive `session.message`、延迟 runtime error、provider error、discussion 回复和附件 marker。
|
|
327
|
+
|
|
328
|
+
outbox 提供:
|
|
329
|
+
|
|
330
|
+
- 稳定 trigger key,例如 `trigger:<message_row_id>`;
|
|
331
|
+
- `UNIQUE(bot_name, chat_id, delivery_key)` 防止同一逻辑输出重复投递;
|
|
332
|
+
- `pending -> delivering -> delivered/failed` 原子 claim,避免并发重复发送;
|
|
333
|
+
- proactive-only 输出的短窗口 content hash 去重;
|
|
334
|
+
- chat final 包含“中间说明 + 最终答案”、proactive 只包含“最终答案”时的短窗口包含关系去重;
|
|
335
|
+
- 附件感知去重,避免把带文件/图片/文档的输出和纯文本误合并。
|
|
336
|
+
|
|
337
|
+
这样普通回复、subagent/proactive 回传、讨论消息、延迟错误、provider 错误和生成附件都走同一条可靠投递链路。
|
|
338
|
+
|
|
339
|
+
## 消息撤回
|
|
340
|
+
|
|
341
|
+
桥接层订阅飞书 `im.message.recalled_v1` 事件。用户撤回一条仍在 pending/排队中的消息时:
|
|
342
|
+
|
|
343
|
+
1. 原始消息仍保留在 `messages` 表,便于审计;
|
|
344
|
+
2. 撤回记录写入 `recalled_messages`;
|
|
345
|
+
3. 删除各 bot 对应的 `pending_triggers`;
|
|
346
|
+
4. 移除本地 pending reaction ack;
|
|
347
|
+
5. 后续同步上下文时排除这条撤回消息。
|
|
348
|
+
|
|
349
|
+
v1 行为边界:如果消息已经进入 OpenClaw 正在处理,暂不 abort;如果 bot 回复已经发出,也不自动撤回 bot 回复。第一版目标是可靠取消“尚未处理/排队中”的撤回消息。
|
|
299
350
|
|
|
300
351
|
## Markdown、表格和附件
|
|
301
352
|
|
|
@@ -92,10 +92,27 @@ export class DiscussionManager {
|
|
|
92
92
|
replies[value.botName] = value.error ? `[ERROR] ${value.error}` : value.text.trim();
|
|
93
93
|
}
|
|
94
94
|
current.completedRounds.push({ round: current.currentRound, replies });
|
|
95
|
+
const noReplyNames = participants
|
|
96
|
+
.map((participant) => participant.name)
|
|
97
|
+
.filter((name) => {
|
|
98
|
+
const text = (replies[name] || "").trim();
|
|
99
|
+
return !text || text.toUpperCase() === "NO_REPLY";
|
|
100
|
+
});
|
|
101
|
+
const errorNames = participants
|
|
102
|
+
.map((participant) => participant.name)
|
|
103
|
+
.filter((name) => (replies[name] || "").trim().startsWith("[ERROR]"));
|
|
95
104
|
const allNoReply = participants.every((participant) => {
|
|
96
105
|
const text = (replies[participant.name] || "").trim();
|
|
97
106
|
return !text || text.toUpperCase() === "NO_REPLY" || text.startsWith("[ERROR]");
|
|
98
107
|
});
|
|
108
|
+
if (sendSystemMessage && !allNoReply && (noReplyNames.length > 0 || errorNames.length > 0)) {
|
|
109
|
+
const parts = [];
|
|
110
|
+
if (noReplyNames.length > 0)
|
|
111
|
+
parts.push(`${noReplyNames.join("、")} 无新增回复`);
|
|
112
|
+
if (errorNames.length > 0)
|
|
113
|
+
parts.push(`${errorNames.join("、")} 出错`);
|
|
114
|
+
await sendSystemMessage(`💬 第 ${current.currentRound}/${current.maxRounds} 轮:${parts.join(";")}`).catch(() => { });
|
|
115
|
+
}
|
|
99
116
|
if (allNoReply) {
|
|
100
117
|
current.status = "completed";
|
|
101
118
|
this.sessions.delete(current.chatId);
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export declare class FeishuBot {
|
|
|
34
34
|
private adminOpenId;
|
|
35
35
|
private static allBots;
|
|
36
36
|
constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string);
|
|
37
|
+
private handleMessageRecalled;
|
|
37
38
|
register(): void;
|
|
38
39
|
/**
|
|
39
40
|
* Get the session key for a specific chat.
|
package/dist/feishu-bot.js
CHANGED
|
@@ -56,8 +56,33 @@ export class FeishuBot {
|
|
|
56
56
|
});
|
|
57
57
|
this.eventDispatcher = new lark.EventDispatcher({}).register({
|
|
58
58
|
"im.message.receive_v1": this.handleMessage.bind(this),
|
|
59
|
+
"im.message.recalled_v1": this.handleMessageRecalled.bind(this),
|
|
59
60
|
});
|
|
60
61
|
}
|
|
62
|
+
async handleMessageRecalled(data) {
|
|
63
|
+
console.log(`[${this.config.name}] Message recalled event:`, JSON.stringify(data));
|
|
64
|
+
const messageId = data?.message_id;
|
|
65
|
+
const chatId = data?.chat_id;
|
|
66
|
+
if (!messageId || !chatId)
|
|
67
|
+
return;
|
|
68
|
+
const rowId = this.store.getMessageId(messageId);
|
|
69
|
+
this.store.markMessageRecalled(messageId, chatId, Number(data?.recall_time) || Date.now(), data?.recall_type || '');
|
|
70
|
+
if (!rowId)
|
|
71
|
+
return;
|
|
72
|
+
this.store.clearPendingTrigger(this.config.name, chatId, rowId);
|
|
73
|
+
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
74
|
+
const remainingAcks = [];
|
|
75
|
+
for (const ack of pendingAcks) {
|
|
76
|
+
if (ack.rowId === rowId) {
|
|
77
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
remainingAcks.push(ack);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
84
|
+
console.log(`[${this.config.name}] Recalled message ${messageId} row=${rowId}; pending trigger canceled if present`);
|
|
85
|
+
}
|
|
61
86
|
register() {
|
|
62
87
|
FeishuBot.allBots.set(this.config.appId, this);
|
|
63
88
|
}
|
|
@@ -503,6 +528,8 @@ export class FeishuBot {
|
|
|
503
528
|
}
|
|
504
529
|
// --- Discuss mode: group-level multi-bot round scheduler. It takes over
|
|
505
530
|
// plain human messages so normal Free mode does not duplicate Round 1.
|
|
531
|
+
// Targeted mentions must fall through to normal routing so @GPT still
|
|
532
|
+
// works while discuss mode is enabled.
|
|
506
533
|
if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
|
|
507
534
|
const mentions = message.mentions || [];
|
|
508
535
|
const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
|
|
@@ -924,11 +951,16 @@ export class FeishuBot {
|
|
|
924
951
|
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
|
|
925
952
|
if (!text.trim() && attachments.length === 0)
|
|
926
953
|
return;
|
|
927
|
-
const
|
|
954
|
+
const attachmentsJson = JSON.stringify(attachments);
|
|
955
|
+
const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
|
|
928
956
|
const contentHash = this.stableHash(normalizedPayload);
|
|
929
957
|
const finalDeliveryKey = deliveryKey || sourceId;
|
|
930
|
-
if (!deliveryKey
|
|
931
|
-
|
|
958
|
+
if (!deliveryKey) {
|
|
959
|
+
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000))
|
|
960
|
+
return;
|
|
961
|
+
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8))
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
932
964
|
const deliveryId = this.store.enqueueDelivery({
|
|
933
965
|
sessionKey: this.deliverySessionKey(chatId),
|
|
934
966
|
chatId,
|
|
@@ -938,7 +970,7 @@ export class FeishuBot {
|
|
|
938
970
|
deliveryKey: finalDeliveryKey,
|
|
939
971
|
contentHash,
|
|
940
972
|
content: text,
|
|
941
|
-
attachmentsJson
|
|
973
|
+
attachmentsJson,
|
|
942
974
|
replyToMessageId: replyToMessageId || "",
|
|
943
975
|
});
|
|
944
976
|
if (deliveryId === null)
|
|
@@ -998,22 +1030,36 @@ export class FeishuBot {
|
|
|
998
1030
|
}
|
|
999
1031
|
async runDiscussionTurn(chatId, prompt, meta) {
|
|
1000
1032
|
const sessionKey = await this.ensureSession(chatId);
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1033
|
+
const releaseProactiveMute = this.openclawClient.muteProactiveDelivery(sessionKey);
|
|
1034
|
+
let reply;
|
|
1035
|
+
try {
|
|
1036
|
+
reply = await this.openclawClient.chatSendWithContext({
|
|
1037
|
+
sessionKey,
|
|
1038
|
+
unsyncedMessages: [],
|
|
1039
|
+
currentMessage: prompt,
|
|
1040
|
+
currentSenderName: "Discussion Scheduler",
|
|
1041
|
+
deliver: false,
|
|
1042
|
+
timeoutMs: 1_800_000,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
finally {
|
|
1046
|
+
// OpenClaw can emit the final assistant session.message shortly after
|
|
1047
|
+
// chatSend/collectReply returns. Keep discussion proactive muted briefly;
|
|
1048
|
+
// the discussion coordinator already owns user-visible delivery.
|
|
1049
|
+
releaseProactiveMute(120_000);
|
|
1050
|
+
}
|
|
1009
1051
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
1010
|
-
|
|
1011
|
-
const
|
|
1052
|
+
const rawVisibleReply = parsedReply.text.trim();
|
|
1053
|
+
const discussionMarkerPattern = /\n*—— 第 \d+\/\d+ 轮 · .+$/;
|
|
1054
|
+
const cleanVisibleReply = rawVisibleReply.replace(discussionMarkerPattern, "").trim();
|
|
1055
|
+
let displayReply = cleanVisibleReply;
|
|
1056
|
+
const isVisible = cleanVisibleReply.length > 0 && cleanVisibleReply.toUpperCase() !== "NO_REPLY";
|
|
1012
1057
|
if (isVisible && meta) {
|
|
1013
|
-
|
|
1058
|
+
const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
|
|
1059
|
+
displayReply = `${displayReply}\n\n${roundMarker}`;
|
|
1014
1060
|
}
|
|
1015
1061
|
if (isVisible || parsedReply.attachments.length > 0) {
|
|
1016
|
-
const storedContent = [
|
|
1062
|
+
const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
|
|
1017
1063
|
.filter(Boolean)
|
|
1018
1064
|
.join("\n");
|
|
1019
1065
|
this.store.insert({
|
|
@@ -1024,9 +1070,9 @@ export class FeishuBot {
|
|
|
1024
1070
|
content: storedContent,
|
|
1025
1071
|
timestamp: Date.now(),
|
|
1026
1072
|
});
|
|
1027
|
-
await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ?
|
|
1073
|
+
await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
|
|
1028
1074
|
}
|
|
1029
|
-
return { botName: this.config.name, text:
|
|
1075
|
+
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
1030
1076
|
}
|
|
1031
1077
|
async replyMessage(messageId, text) {
|
|
1032
1078
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
package/dist/message-store.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export declare class MessageStore {
|
|
|
51
51
|
*/
|
|
52
52
|
insert(msg: ChatMessage): number;
|
|
53
53
|
getMessageId(messageId: string): number | null;
|
|
54
|
+
markMessageRecalled(messageId: string, chatId: string, recalledAt?: number, recallType?: string): void;
|
|
55
|
+
isMessageRecalled(messageId: string): boolean;
|
|
54
56
|
markPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
|
|
55
57
|
getPendingTriggerIds(botName: string, chatId: string): Set<number>;
|
|
56
58
|
clearPendingTriggers(botName: string, chatId: string, upToId: number): void;
|
|
@@ -61,6 +63,7 @@ export declare class MessageStore {
|
|
|
61
63
|
getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
|
|
62
64
|
getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
|
|
63
65
|
hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number): boolean;
|
|
66
|
+
hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number): boolean;
|
|
64
67
|
claimDelivery(id: number): boolean;
|
|
65
68
|
markDeliveryDelivered(id: number): void;
|
|
66
69
|
markDeliveryFailed(id: number): void;
|
package/dist/message-store.js
CHANGED
|
@@ -51,6 +51,16 @@ export class MessageStore {
|
|
|
51
51
|
PRIMARY KEY (bot_name, message_id)
|
|
52
52
|
);
|
|
53
53
|
|
|
54
|
+
-- Tracks recalled human messages. Recalled messages are kept in the raw
|
|
55
|
+
-- message ledger for audit, but excluded from future OpenClaw context and
|
|
56
|
+
-- pending trigger processing.
|
|
57
|
+
CREATE TABLE IF NOT EXISTS recalled_messages (
|
|
58
|
+
message_id TEXT PRIMARY KEY,
|
|
59
|
+
chat_id TEXT NOT NULL,
|
|
60
|
+
recalled_at INTEGER NOT NULL,
|
|
61
|
+
recall_type TEXT NOT NULL DEFAULT ''
|
|
62
|
+
);
|
|
63
|
+
|
|
54
64
|
-- Tracks messages that should actively trigger a bot reply.
|
|
55
65
|
-- Other unsynced messages remain local context and are sent only when a trigger arrives.
|
|
56
66
|
CREATE TABLE IF NOT EXISTS pending_triggers (
|
|
@@ -225,6 +235,17 @@ export class MessageStore {
|
|
|
225
235
|
const row = this.db.prepare(`SELECT id FROM messages WHERE message_id = ?`).get(messageId);
|
|
226
236
|
return row?.id || null;
|
|
227
237
|
}
|
|
238
|
+
markMessageRecalled(messageId, chatId, recalledAt = Date.now(), recallType = '') {
|
|
239
|
+
this.db.prepare(`
|
|
240
|
+
INSERT INTO recalled_messages (message_id, chat_id, recalled_at, recall_type)
|
|
241
|
+
VALUES (?, ?, ?, ?)
|
|
242
|
+
ON CONFLICT(message_id) DO UPDATE SET chat_id = excluded.chat_id, recalled_at = excluded.recalled_at, recall_type = excluded.recall_type
|
|
243
|
+
`).run(messageId, chatId, recalledAt, recallType || '');
|
|
244
|
+
}
|
|
245
|
+
isMessageRecalled(messageId) {
|
|
246
|
+
const row = this.db.prepare(`SELECT 1 FROM recalled_messages WHERE message_id = ?`).get(messageId);
|
|
247
|
+
return !!row;
|
|
248
|
+
}
|
|
228
249
|
markPendingTrigger(botName, chatId, messageRowId) {
|
|
229
250
|
this.db.prepare(`
|
|
230
251
|
INSERT OR IGNORE INTO pending_triggers (bot_name, chat_id, message_row_id)
|
|
@@ -304,6 +325,29 @@ export class MessageStore {
|
|
|
304
325
|
`).get(botName, chatId, contentHash, Date.now() - windowMs);
|
|
305
326
|
return !!row;
|
|
306
327
|
}
|
|
328
|
+
hasRecentOverlappingDelivery(botName, chatId, content, attachmentsJson, windowMs, minShortLength = 8) {
|
|
329
|
+
const normalized = content.trim();
|
|
330
|
+
if (normalized.length < minShortLength)
|
|
331
|
+
return false;
|
|
332
|
+
const rows = this.db.prepare(`
|
|
333
|
+
SELECT content, attachments_json FROM delivery_outbox
|
|
334
|
+
WHERE bot_name = ? AND chat_id = ? AND created_at >= ? AND status IN ('pending', 'delivering', 'delivered')
|
|
335
|
+
ORDER BY created_at DESC
|
|
336
|
+
LIMIT 20
|
|
337
|
+
`).all(botName, chatId, Date.now() - windowMs);
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
if ((row.attachments_json || '[]') !== attachmentsJson)
|
|
340
|
+
continue;
|
|
341
|
+
const existing = String(row.content || '').trim();
|
|
342
|
+
if (existing.length < minShortLength)
|
|
343
|
+
continue;
|
|
344
|
+
const shorter = existing.length <= normalized.length ? existing : normalized;
|
|
345
|
+
const longer = existing.length <= normalized.length ? normalized : existing;
|
|
346
|
+
if (shorter.length >= minShortLength && longer.includes(shorter))
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
307
351
|
claimDelivery(id) {
|
|
308
352
|
const result = this.db.prepare(`
|
|
309
353
|
UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
|
|
@@ -350,9 +394,10 @@ export class MessageStore {
|
|
|
350
394
|
`).get(botName, chatId);
|
|
351
395
|
const lastId = row?.last_synced_msg_id || 0;
|
|
352
396
|
const rows = this.db.prepare(`
|
|
353
|
-
SELECT
|
|
354
|
-
|
|
355
|
-
|
|
397
|
+
SELECT messages.* FROM messages
|
|
398
|
+
LEFT JOIN recalled_messages ON recalled_messages.message_id = messages.message_id
|
|
399
|
+
WHERE messages.chat_id = ? AND messages.id > ? AND recalled_messages.message_id IS NULL
|
|
400
|
+
ORDER BY messages.timestamp ASC
|
|
356
401
|
LIMIT ?
|
|
357
402
|
`).all(chatId, lastId, maxCount);
|
|
358
403
|
return rows.map((r) => ({
|
|
@@ -25,12 +25,16 @@ export declare class OpenClawClient {
|
|
|
25
25
|
private sessionMessageCallbacks;
|
|
26
26
|
/** Session keys that should be re-subscribed on reconnect */
|
|
27
27
|
private subscribedKeys;
|
|
28
|
-
/** Session keys with active/recent chatSend —
|
|
28
|
+
/** Session keys with active/recent chatSend — proactive is forwarded and deduped by outbox. */
|
|
29
29
|
private suppressedSessions;
|
|
30
30
|
private suppressedSessionTimers;
|
|
31
|
+
/** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
|
|
32
|
+
private mutedProactiveSessions;
|
|
33
|
+
private mutedProactiveSessionCounts;
|
|
31
34
|
constructor(config: OpenClawConfig);
|
|
32
35
|
connect(): Promise<void>;
|
|
33
36
|
private _doConnect;
|
|
37
|
+
private handleProactiveSessionMessage;
|
|
34
38
|
private scheduleReconnect;
|
|
35
39
|
private rpc;
|
|
36
40
|
/**
|
|
@@ -71,6 +75,9 @@ export declare class OpenClawClient {
|
|
|
71
75
|
abortChat(sessionKey: string, runId: string): Promise<any>;
|
|
72
76
|
private suppressSessionKeys;
|
|
73
77
|
private releaseSuppressedSessionKeysAfter;
|
|
78
|
+
private addMutedProactiveKey;
|
|
79
|
+
private releaseMutedProactiveKey;
|
|
80
|
+
muteProactiveDelivery(sessionKey: string): (delayMs?: number) => void;
|
|
74
81
|
chatSend(params: {
|
|
75
82
|
sessionKey: string;
|
|
76
83
|
message: string;
|
package/dist/openclaw-client.js
CHANGED
|
@@ -23,9 +23,12 @@ export class OpenClawClient {
|
|
|
23
23
|
sessionMessageCallbacks = new Map();
|
|
24
24
|
/** Session keys that should be re-subscribed on reconnect */
|
|
25
25
|
subscribedKeys = new Set();
|
|
26
|
-
/** Session keys with active/recent chatSend —
|
|
26
|
+
/** Session keys with active/recent chatSend — proactive is forwarded and deduped by outbox. */
|
|
27
27
|
suppressedSessions = new Set();
|
|
28
28
|
suppressedSessionTimers = new Map();
|
|
29
|
+
/** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
|
|
30
|
+
mutedProactiveSessions = new Set();
|
|
31
|
+
mutedProactiveSessionCounts = new Map();
|
|
29
32
|
constructor(config) {
|
|
30
33
|
this.config = config;
|
|
31
34
|
}
|
|
@@ -140,45 +143,7 @@ export class OpenClawClient {
|
|
|
140
143
|
// Try both raw key and without agent:main: prefix
|
|
141
144
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
142
145
|
const msg = frame.payload.message || frame.payload;
|
|
143
|
-
|
|
144
|
-
const content = msg.content;
|
|
145
|
-
// Proactive assistant text messages (suppress during active chatSend).
|
|
146
|
-
// Cron/session-targeted runs often emit structured content arrays rather
|
|
147
|
-
// than a plain string. Extract only visible text parts and ignore thinking
|
|
148
|
-
// / tool blocks so the bridge can deliver final cron results via the bot.
|
|
149
|
-
let proactiveText = "";
|
|
150
|
-
if (role === "assistant") {
|
|
151
|
-
if (typeof content === "string") {
|
|
152
|
-
proactiveText = content;
|
|
153
|
-
}
|
|
154
|
-
else if (Array.isArray(content)) {
|
|
155
|
-
const hasToolBlock = content.some((part) => {
|
|
156
|
-
const type = String(part?.type || "").toLowerCase();
|
|
157
|
-
return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
|
|
158
|
-
});
|
|
159
|
-
// Do not deliver mixed text+toolCall assistant messages through
|
|
160
|
-
// the proactive final-text path; those are usually intermediate
|
|
161
|
-
// reasoning/status during a tool loop. Tool calls are still
|
|
162
|
-
// delivered via the verbose channel from agent item events when
|
|
163
|
-
// /verbose is enabled. Cron final messages arrive as text-only
|
|
164
|
-
// (optionally with thinking).
|
|
165
|
-
if (!hasToolBlock) {
|
|
166
|
-
proactiveText = content
|
|
167
|
-
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
168
|
-
.map((part) => part.text)
|
|
169
|
-
.join("\n")
|
|
170
|
-
.trim();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
if (proactiveText) {
|
|
175
|
-
if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
|
|
176
|
-
console.log(`[OpenClaw] Forwarding proactive msg for ${shortKey} during active chatSend; delivery outbox will dedupe`);
|
|
177
|
-
}
|
|
178
|
-
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
179
|
-
if (cb)
|
|
180
|
-
cb(proactiveText);
|
|
181
|
-
}
|
|
146
|
+
this.handleProactiveSessionMessage(rawKey, msg);
|
|
182
147
|
// Tool calls in assistant messages — skip, using agent item events instead
|
|
183
148
|
// (session.message toolCall events are batched, not real-time)
|
|
184
149
|
}
|
|
@@ -210,6 +175,53 @@ export class OpenClawClient {
|
|
|
210
175
|
});
|
|
211
176
|
});
|
|
212
177
|
}
|
|
178
|
+
handleProactiveSessionMessage(rawKey, msg) {
|
|
179
|
+
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
180
|
+
const role = msg.role;
|
|
181
|
+
const content = msg.content;
|
|
182
|
+
// Proactive assistant text messages. Cron/session-targeted runs often emit
|
|
183
|
+
// structured content arrays rather than a plain string. Extract only visible
|
|
184
|
+
// text parts and ignore thinking/tool blocks so the bridge can deliver final
|
|
185
|
+
// cron results via the bot.
|
|
186
|
+
let proactiveText = "";
|
|
187
|
+
if (role === "assistant") {
|
|
188
|
+
if (typeof content === "string") {
|
|
189
|
+
proactiveText = content;
|
|
190
|
+
}
|
|
191
|
+
else if (Array.isArray(content)) {
|
|
192
|
+
const hasToolBlock = content.some((part) => {
|
|
193
|
+
const type = String(part?.type || "").toLowerCase();
|
|
194
|
+
return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
|
|
195
|
+
});
|
|
196
|
+
// Do not deliver mixed text+toolCall assistant messages through
|
|
197
|
+
// the proactive final-text path; those are usually intermediate
|
|
198
|
+
// reasoning/status during a tool loop. Tool calls are still
|
|
199
|
+
// delivered via the verbose channel from agent item events when
|
|
200
|
+
// /verbose is enabled. Cron final messages arrive as text-only
|
|
201
|
+
// (optionally with thinking).
|
|
202
|
+
if (!hasToolBlock) {
|
|
203
|
+
proactiveText = content
|
|
204
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
205
|
+
.map((part) => part.text)
|
|
206
|
+
.join("\n")
|
|
207
|
+
.trim();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!proactiveText)
|
|
212
|
+
return false;
|
|
213
|
+
if (this.mutedProactiveSessions.has(rawKey) || this.mutedProactiveSessions.has(shortKey)) {
|
|
214
|
+
console.log(`[OpenClaw] Dropping proactive msg for ${shortKey}; delivery is owned by the caller`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
|
|
218
|
+
console.log(`[OpenClaw] Forwarding proactive msg for ${shortKey} during active chatSend; delivery outbox will dedupe`);
|
|
219
|
+
}
|
|
220
|
+
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
221
|
+
if (cb)
|
|
222
|
+
cb(proactiveText);
|
|
223
|
+
return Boolean(cb);
|
|
224
|
+
}
|
|
213
225
|
scheduleReconnect() {
|
|
214
226
|
const delay = this.reconnectDelay;
|
|
215
227
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
@@ -492,6 +504,42 @@ export class OpenClawClient {
|
|
|
492
504
|
this.suppressedSessionTimers.set(key, timer);
|
|
493
505
|
}
|
|
494
506
|
}
|
|
507
|
+
addMutedProactiveKey(key) {
|
|
508
|
+
const count = this.mutedProactiveSessionCounts.get(key) || 0;
|
|
509
|
+
this.mutedProactiveSessionCounts.set(key, count + 1);
|
|
510
|
+
this.mutedProactiveSessions.add(key);
|
|
511
|
+
}
|
|
512
|
+
releaseMutedProactiveKey(key) {
|
|
513
|
+
const count = this.mutedProactiveSessionCounts.get(key) || 0;
|
|
514
|
+
if (count <= 1) {
|
|
515
|
+
this.mutedProactiveSessionCounts.delete(key);
|
|
516
|
+
this.mutedProactiveSessions.delete(key);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
this.mutedProactiveSessionCounts.set(key, count - 1);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
muteProactiveDelivery(sessionKey) {
|
|
523
|
+
const shortKey = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
|
|
524
|
+
const fullKey = `agent:main:${shortKey}`;
|
|
525
|
+
const keys = [shortKey, fullKey];
|
|
526
|
+
for (const key of keys)
|
|
527
|
+
this.addMutedProactiveKey(key);
|
|
528
|
+
let released = false;
|
|
529
|
+
return (delayMs = 0) => {
|
|
530
|
+
if (released)
|
|
531
|
+
return;
|
|
532
|
+
released = true;
|
|
533
|
+
const release = () => {
|
|
534
|
+
for (const key of keys)
|
|
535
|
+
this.releaseMutedProactiveKey(key);
|
|
536
|
+
};
|
|
537
|
+
if (delayMs > 0)
|
|
538
|
+
setTimeout(release, delayMs);
|
|
539
|
+
else
|
|
540
|
+
release();
|
|
541
|
+
};
|
|
542
|
+
}
|
|
495
543
|
async chatSend(params) {
|
|
496
544
|
const sk = params.sessionKey;
|
|
497
545
|
const fullSessionKey = `agent:main:${sk}`;
|
|
@@ -619,6 +667,8 @@ export class OpenClawClient {
|
|
|
619
667
|
clearTimeout(timer);
|
|
620
668
|
this.suppressedSessionTimers.clear();
|
|
621
669
|
this.suppressedSessions.clear();
|
|
670
|
+
this.mutedProactiveSessions.clear();
|
|
671
|
+
this.mutedProactiveSessionCounts.clear();
|
|
622
672
|
if (this.ws) {
|
|
623
673
|
this.ws.close();
|
|
624
674
|
this.ws = null;
|