openclaw-lark-multi-agent 0.1.10 → 0.1.11
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 +41 -4
- package/README.zh-CN.md +41 -4
- package/dist/discussion-manager.d.ts +43 -0
- package/dist/discussion-manager.js +134 -0
- package/dist/feishu-bot.d.ts +7 -1
- package/dist/feishu-bot.js +237 -22
- package/dist/message-store.d.ts +5 -0
- package/dist/message-store.js +45 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,11 +25,16 @@ All of them connect to the same OpenClaw Gateway while keeping sessions, queues,
|
|
|
25
25
|
- Group chat routing:
|
|
26
26
|
- reply when directly mentioned
|
|
27
27
|
- reply to `@all` / `@_all`
|
|
28
|
-
- optional Free
|
|
28
|
+
- optional per-bot Free mode for plain human messages
|
|
29
|
+
- targeted mentions are exclusive, so Free-mode bots do not steal messages addressed to another person or bot
|
|
30
|
+
- mention-only messages can trigger a bot with the previous unsynced context
|
|
31
|
+
- Per-bot anti-loop guard, so one bot's free-discussion budget is not consumed by other bots
|
|
29
32
|
- Local SQLite message store for context, trigger tracking, and duplicate prevention
|
|
30
33
|
- `pending_triggers` queue so restart recovery does not replay every context message
|
|
31
34
|
- `delivered_replies` table so one trigger message gets at most one delivered reply per bot
|
|
32
35
|
- Feishu image download and OpenClaw multimodal attachment forwarding
|
|
36
|
+
- Bridge attachment marker protocol for generated files/images/documents
|
|
37
|
+
- Feishu CardKit v2 Markdown rendering, including native table elements for pipe tables
|
|
33
38
|
- Bridge-level slash commands and escaped OpenClaw slash commands
|
|
34
39
|
- Linux systemd installer with separate runtime and state directories
|
|
35
40
|
|
|
@@ -220,7 +225,9 @@ Bridge-level commands use a single slash and are handled by this project:
|
|
|
220
225
|
- `/compact` — compact the OpenClaw session
|
|
221
226
|
- `/reset` — reset the OpenClaw session
|
|
222
227
|
- `/verbose` — toggle tool-call messages for this bot in this chat
|
|
223
|
-
- `/free` — toggle Free
|
|
228
|
+
- `/free` — toggle this bot's Free mode in the current group chat
|
|
229
|
+
- `/mute` — toggle this bot's mute mode in the current group chat
|
|
230
|
+
- `/mode` — show this bot's current mode in the current chat
|
|
224
231
|
|
|
225
232
|
OpenClaw-level slash commands can be sent by escaping with a double slash:
|
|
226
233
|
|
|
@@ -267,9 +274,39 @@ By default, a bot responds when:
|
|
|
267
274
|
|
|
268
275
|
- it is directly mentioned;
|
|
269
276
|
- `@all` / `@_all` appears in the message;
|
|
270
|
-
- Free
|
|
277
|
+
- this bot's Free mode is enabled and the message is a plain human message with no targeted mentions.
|
|
271
278
|
|
|
272
|
-
|
|
279
|
+
Free mode is intentionally per-bot and conservative:
|
|
280
|
+
|
|
281
|
+
- Free mode lets a bot reply to ordinary human messages without being mentioned.
|
|
282
|
+
- If a human message mentions another bot, only that bot may respond. Other Free-mode bots stay silent.
|
|
283
|
+
- If a human message mentions a regular person, Free-mode bots stay silent.
|
|
284
|
+
- `@all` remains a broadcast trigger and may activate every eligible bot.
|
|
285
|
+
|
|
286
|
+
Mention-only routing is supported. If a user first sends content and then sends only a bot mention, for example:
|
|
287
|
+
|
|
288
|
+
```text
|
|
289
|
+
Please analyze the contract risk.
|
|
290
|
+
@Claude
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
the mention-only message is treated as a trigger and is combined with the previous unsynced context before being sent to the mentioned bot.
|
|
294
|
+
|
|
295
|
+
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
|
+
|
|
297
|
+
`/free` is not a full multi-agent discussion scheduler. Notes for a future explicit `/discuss` mode live in [`docs/ideas/discussion-mode.md`](docs/ideas/discussion-mode.md).
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
## Markdown, tables, and attachments
|
|
301
|
+
|
|
302
|
+
Assistant replies are sent as Feishu CardKit v2 cards. Markdown is preprocessed for Feishu rendering:
|
|
303
|
+
|
|
304
|
+
- headings are downgraded to Feishu-friendly heading levels;
|
|
305
|
+
- fenced code blocks are preserved;
|
|
306
|
+
- unsupported external Markdown image URLs are stripped unless already resolved to Feishu `img_` keys;
|
|
307
|
+
- GitHub-style pipe tables are converted into native CardKit `table` elements.
|
|
308
|
+
|
|
309
|
+
For generated files/images/documents, agents should use the bridge attachment marker protocol instead of calling Feishu messaging tools directly. The bridge strips the marker from the visible reply, validates the file path under the configured attachment directory, uploads/sends the attachment, and records it in local context. Markdown documents can be converted into Feishu cloud documents through this path.
|
|
273
310
|
|
|
274
311
|
## Data model
|
|
275
312
|
|
package/README.zh-CN.md
CHANGED
|
@@ -25,11 +25,16 @@ Lark/飞书里每个机器人都有自己的 App 身份,但 OpenClaw 通常在
|
|
|
25
25
|
- 群聊路由:
|
|
26
26
|
- 直接 @ 某个 bot 时回复
|
|
27
27
|
- `@all` / `@_all` 时回复
|
|
28
|
-
-
|
|
28
|
+
- 可选的 per-bot Free 模式,用于普通人类消息
|
|
29
|
+
- 定向 @ 具有排他性,Free 模式 bot 不会抢答发给其他人或其他 bot 的消息
|
|
30
|
+
- 纯 @ 某个 bot 的消息可以结合前文未同步上下文触发回复
|
|
31
|
+
- per-bot anti-loop 防护,其他 bot 的发言不会消耗当前 bot 的发言额度
|
|
29
32
|
- 本地 SQLite 消息存储,用于上下文、触发队列和重复投递防护
|
|
30
33
|
- `pending_triggers` 队列,避免重启后把所有历史上下文都重新发给 OpenClaw
|
|
31
34
|
- `delivered_replies` 表,保证同一个触发消息每个 bot 最多投递一次回复
|
|
32
35
|
- 支持飞书图片下载,并以 OpenClaw multimodal attachment 形式转发
|
|
36
|
+
- Bridge attachment marker 协议,用于发送生成的文件、图片和文档
|
|
37
|
+
- Feishu CardKit v2 Markdown 渲染,并把 pipe table 转成原生 table 组件
|
|
33
38
|
- 桥接层 slash command + 转义后的 OpenClaw slash command
|
|
34
39
|
- Linux systemd 安装脚本,运行产物和状态目录分离
|
|
35
40
|
|
|
@@ -220,7 +225,9 @@ openclaw-lark-multi-agent install-windows-service
|
|
|
220
225
|
- `/compact` — 压缩 OpenClaw session
|
|
221
226
|
- `/reset` — 重置 OpenClaw session
|
|
222
227
|
- `/verbose` — 开关当前 bot 在当前聊天里的 tool-call 展示
|
|
223
|
-
- `/free` —
|
|
228
|
+
- `/free` — 开关当前 bot 在当前群聊里的 Free 模式
|
|
229
|
+
- `/mute` — 开关当前 bot 在当前群聊里的 mute 模式
|
|
230
|
+
- `/mode` — 查看当前 bot 在当前聊天里的模式
|
|
224
231
|
|
|
225
232
|
如果你想把 slash command 直接发给 OpenClaw,可以用双斜杠转义:
|
|
226
233
|
|
|
@@ -267,9 +274,39 @@ openclaw-lark-multi-agent install-windows-service
|
|
|
267
274
|
|
|
268
275
|
- 被直接 @;
|
|
269
276
|
- 消息里出现 `@all` / `@_all`;
|
|
270
|
-
-
|
|
277
|
+
- 当前 bot 开启了 Free 模式,并且这是一条没有定向 @ 的普通人类消息。
|
|
271
278
|
|
|
272
|
-
|
|
279
|
+
Free 模式是 per-bot 且保守的:
|
|
280
|
+
|
|
281
|
+
- Free 模式允许 bot 在没有被 @ 时回复普通人类消息。
|
|
282
|
+
- 如果人类消息 @ 了另一个 bot,只有被 @ 的 bot 可以回复,其他 Free 模式 bot 保持静默。
|
|
283
|
+
- 如果人类消息 @ 了普通人,Free 模式 bot 保持静默。
|
|
284
|
+
- `@all` 仍然是广播触发,可激活所有符合条件的 bot。
|
|
285
|
+
|
|
286
|
+
支持“纯 @ 触发”。例如用户先发:
|
|
287
|
+
|
|
288
|
+
```text
|
|
289
|
+
帮我分析一下这个合同风险。
|
|
290
|
+
@Claude
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
第二条纯 @ 消息会被当作触发器,并和前面未同步的上下文一起发给被 @ 的 bot。
|
|
294
|
+
|
|
295
|
+
bot 发出的消息默认不会触发其他 bot,除非明确 @。anti-loop 防护按 bot + chat 单独计算:其他 bot 的发言不会消耗当前 bot 的额度,人类发言会重置计数。
|
|
296
|
+
|
|
297
|
+
`/free` 不是完整的多 Agent 讨论调度器。未来独立 `/discuss` 模式的设计草案在 [`docs/ideas/discussion-mode.md`](docs/ideas/discussion-mode.md)。
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
## Markdown、表格和附件
|
|
301
|
+
|
|
302
|
+
助手回复会以 Feishu CardKit v2 卡片发送。Markdown 会先做飞书兼容预处理:
|
|
303
|
+
|
|
304
|
+
- 标题会降级到飞书更稳定的标题层级;
|
|
305
|
+
- fenced code block 会被保护;
|
|
306
|
+
- 未解析成飞书 `img_` key 的外部 Markdown 图片会被剥离,避免卡片发送失败;
|
|
307
|
+
- GitHub 风格 pipe table 会转换成 CardKit 原生 `table` 组件。
|
|
308
|
+
|
|
309
|
+
如果 Agent 需要发送生成的文件、图片或文档,应使用 bridge attachment marker 协议,而不是直接调用飞书消息工具。桥接层会从可见回复中剥离 marker,校验文件路径位于配置的附件目录下,然后上传/发送附件,并写入本地上下文。Markdown 文档也可以通过这个路径转换成飞书云文档。
|
|
273
310
|
|
|
274
311
|
## 数据模型
|
|
275
312
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type ReplyResult = {
|
|
2
|
+
botName: string;
|
|
3
|
+
text: string;
|
|
4
|
+
visible: boolean;
|
|
5
|
+
error?: string;
|
|
6
|
+
};
|
|
7
|
+
type DiscussionSession = {
|
|
8
|
+
id: string;
|
|
9
|
+
chatId: string;
|
|
10
|
+
rootMessageId: string;
|
|
11
|
+
topic: string;
|
|
12
|
+
participants: string[];
|
|
13
|
+
currentRound: number;
|
|
14
|
+
maxRounds: number;
|
|
15
|
+
completedRounds: Array<{
|
|
16
|
+
round: number;
|
|
17
|
+
replies: Record<string, string>;
|
|
18
|
+
}>;
|
|
19
|
+
status: "running" | "stopped" | "completed";
|
|
20
|
+
};
|
|
21
|
+
export type DiscussionParticipant = {
|
|
22
|
+
name: string;
|
|
23
|
+
runDiscussionTurn(chatId: string, prompt: string): Promise<ReplyResult>;
|
|
24
|
+
};
|
|
25
|
+
export declare class DiscussionManager {
|
|
26
|
+
private sessions;
|
|
27
|
+
private seenRoots;
|
|
28
|
+
isActive(chatId: string): boolean;
|
|
29
|
+
stop(chatId: string): boolean;
|
|
30
|
+
status(chatId: string): DiscussionSession | null;
|
|
31
|
+
startIfAbsent(params: {
|
|
32
|
+
chatId: string;
|
|
33
|
+
rootMessageId: string;
|
|
34
|
+
topic: string;
|
|
35
|
+
maxRounds: number;
|
|
36
|
+
participants: DiscussionParticipant[];
|
|
37
|
+
sendSystemMessage?: (text: string) => Promise<void>;
|
|
38
|
+
}): boolean;
|
|
39
|
+
private runLoop;
|
|
40
|
+
private buildPrompt;
|
|
41
|
+
}
|
|
42
|
+
export declare const discussionManager: DiscussionManager;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
export class DiscussionManager {
|
|
3
|
+
sessions = new Map();
|
|
4
|
+
seenRoots = new Set();
|
|
5
|
+
isActive(chatId) {
|
|
6
|
+
return this.sessions.get(chatId)?.status === "running";
|
|
7
|
+
}
|
|
8
|
+
stop(chatId) {
|
|
9
|
+
const session = this.sessions.get(chatId);
|
|
10
|
+
if (!session)
|
|
11
|
+
return false;
|
|
12
|
+
session.status = "stopped";
|
|
13
|
+
this.sessions.delete(chatId);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
status(chatId) {
|
|
17
|
+
const session = this.sessions.get(chatId);
|
|
18
|
+
return session ? { ...session, completedRounds: [...session.completedRounds] } : null;
|
|
19
|
+
}
|
|
20
|
+
startIfAbsent(params) {
|
|
21
|
+
const key = `${params.chatId}:${params.rootMessageId}`;
|
|
22
|
+
if (this.seenRoots.has(key))
|
|
23
|
+
return false;
|
|
24
|
+
this.seenRoots.add(key);
|
|
25
|
+
if (this.isActive(params.chatId))
|
|
26
|
+
this.stop(params.chatId);
|
|
27
|
+
const participants = params.participants.filter((p, index, arr) => arr.findIndex((x) => x.name === p.name) === index);
|
|
28
|
+
if (participants.length === 0)
|
|
29
|
+
return false;
|
|
30
|
+
const session = {
|
|
31
|
+
id: randomUUID(),
|
|
32
|
+
chatId: params.chatId,
|
|
33
|
+
rootMessageId: params.rootMessageId,
|
|
34
|
+
topic: params.topic,
|
|
35
|
+
participants: participants.map((p) => p.name),
|
|
36
|
+
currentRound: 1,
|
|
37
|
+
maxRounds: params.maxRounds,
|
|
38
|
+
completedRounds: [],
|
|
39
|
+
status: "running",
|
|
40
|
+
};
|
|
41
|
+
this.sessions.set(params.chatId, session);
|
|
42
|
+
void this.runLoop(session.id, participants, params.sendSystemMessage).catch((err) => {
|
|
43
|
+
console.warn(`[Discussion] loop failed for ${params.chatId}:`, err instanceof Error ? err.message : String(err));
|
|
44
|
+
const current = this.sessions.get(params.chatId);
|
|
45
|
+
if (current?.id === session.id)
|
|
46
|
+
this.sessions.delete(params.chatId);
|
|
47
|
+
});
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
async runLoop(sessionId, participants, sendSystemMessage) {
|
|
51
|
+
while (true) {
|
|
52
|
+
const session = Array.from(this.sessions.values()).find((s) => s.id === sessionId);
|
|
53
|
+
if (!session || session.status !== "running")
|
|
54
|
+
return;
|
|
55
|
+
if (session.currentRound > session.maxRounds) {
|
|
56
|
+
session.status = "completed";
|
|
57
|
+
this.sessions.delete(session.chatId);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const prompt = this.buildPrompt(session);
|
|
61
|
+
const results = await Promise.allSettled(participants.map(async (participant) => {
|
|
62
|
+
try {
|
|
63
|
+
return await participant.runDiscussionTurn(session.chatId, prompt);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return {
|
|
67
|
+
botName: participant.name,
|
|
68
|
+
text: "",
|
|
69
|
+
visible: false,
|
|
70
|
+
error: err instanceof Error ? err.message : String(err),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
const current = this.sessions.get(session.chatId);
|
|
75
|
+
if (!current || current.id !== sessionId || current.status !== "running")
|
|
76
|
+
return;
|
|
77
|
+
const replies = {};
|
|
78
|
+
for (const result of results) {
|
|
79
|
+
const value = result.status === "fulfilled" ? result.value : undefined;
|
|
80
|
+
if (!value)
|
|
81
|
+
continue;
|
|
82
|
+
replies[value.botName] = value.error ? `[ERROR] ${value.error}` : value.text.trim();
|
|
83
|
+
}
|
|
84
|
+
current.completedRounds.push({ round: current.currentRound, replies });
|
|
85
|
+
const allNoReply = participants.every((participant) => {
|
|
86
|
+
const text = (replies[participant.name] || "").trim();
|
|
87
|
+
return !text || text.toUpperCase() === "NO_REPLY" || text.startsWith("[ERROR]");
|
|
88
|
+
});
|
|
89
|
+
if (allNoReply) {
|
|
90
|
+
current.status = "completed";
|
|
91
|
+
this.sessions.delete(current.chatId);
|
|
92
|
+
if (sendSystemMessage)
|
|
93
|
+
await sendSystemMessage(`💬 Discuss 已结束:第 ${current.currentRound} 轮没有新的有效补充。`).catch(() => { });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (current.currentRound >= current.maxRounds) {
|
|
97
|
+
current.status = "completed";
|
|
98
|
+
this.sessions.delete(current.chatId);
|
|
99
|
+
if (sendSystemMessage)
|
|
100
|
+
await sendSystemMessage(`💬 Discuss 已完成:已达到 ${current.maxRounds} 轮。`).catch(() => { });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
current.currentRound += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
buildPrompt(session) {
|
|
107
|
+
const previous = session.completedRounds.length === 0
|
|
108
|
+
? "(暂无,当前是第一轮)"
|
|
109
|
+
: session.completedRounds.map((round) => {
|
|
110
|
+
const lines = Object.entries(round.replies).map(([bot, text]) => `- ${bot}: ${text || "NO_REPLY"}`);
|
|
111
|
+
return `Round ${round.round}:\n${lines.join("\n")}`;
|
|
112
|
+
}).join("\n\n");
|
|
113
|
+
return [
|
|
114
|
+
"这是一个多智能体结构化讨论。",
|
|
115
|
+
"",
|
|
116
|
+
"话题:",
|
|
117
|
+
session.topic,
|
|
118
|
+
"",
|
|
119
|
+
`当前轮次:${session.currentRound}`,
|
|
120
|
+
"",
|
|
121
|
+
"已完成的轮次:",
|
|
122
|
+
previous,
|
|
123
|
+
"",
|
|
124
|
+
"本轮其他 bot 的回复你暂时看不到,请基于同一份上下文独立给出观点。",
|
|
125
|
+
"",
|
|
126
|
+
"要求:",
|
|
127
|
+
"1. 不要重复前几轮已经说过的观点。",
|
|
128
|
+
"2. 只补充新的、有价值的信息。",
|
|
129
|
+
"3. 如果没有新东西,回复 NO_REPLY。",
|
|
130
|
+
"4. 简洁作答。",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export const discussionManager = new DiscussionManager();
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export declare class FeishuBot {
|
|
|
21
21
|
private initializedSessions;
|
|
22
22
|
/** Per-chat busy lock: timestamp when became busy (0 = not busy) */
|
|
23
23
|
private busyChats;
|
|
24
|
-
/** Per-chat pending reply message IDs (to ack with DONE when
|
|
24
|
+
/** Per-chat pending reply message IDs (to ack with DONE when their trigger is processed) */
|
|
25
25
|
private pendingAckMessages;
|
|
26
26
|
/** Per-chat pending tool message sends (to await before final reply) */
|
|
27
27
|
private pendingToolSends;
|
|
@@ -56,6 +56,7 @@ export declare class FeishuBot {
|
|
|
56
56
|
*/
|
|
57
57
|
private drainOnStartup;
|
|
58
58
|
static getAllBots(): Map<string, FeishuBot>;
|
|
59
|
+
static getByName(name: string): FeishuBot | undefined;
|
|
59
60
|
static findByOpenId(openId: string): FeishuBot | undefined;
|
|
60
61
|
private handleMessage;
|
|
61
62
|
/**
|
|
@@ -75,6 +76,10 @@ export declare class FeishuBot {
|
|
|
75
76
|
private cleanMentions;
|
|
76
77
|
private stripLeadingCommandMentions;
|
|
77
78
|
private buildMarkdownCard;
|
|
79
|
+
private formatUserVisibleError;
|
|
80
|
+
private isDiscussionCoordinator;
|
|
81
|
+
private getDiscussionParticipants;
|
|
82
|
+
private runDiscussionTurn;
|
|
78
83
|
private replyMessage;
|
|
79
84
|
private extractBridgeAttachments;
|
|
80
85
|
private validateBridgeAttachmentPath;
|
|
@@ -100,6 +105,7 @@ export declare class FeishuBot {
|
|
|
100
105
|
* Finds the bot's own reaction of that type and deletes it.
|
|
101
106
|
*/
|
|
102
107
|
private removeReaction;
|
|
108
|
+
private handleDiscussCommand;
|
|
103
109
|
/**
|
|
104
110
|
* Handle /status command: show current session info.
|
|
105
111
|
*/
|
package/dist/feishu-bot.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, statSync } from "fs";
|
|
|
3
3
|
import { basename, extname, resolve } from "path";
|
|
4
4
|
import { getBridgeAttachmentsDir } from "./paths.js";
|
|
5
5
|
import { buildFeishuCardElements } from "./markdown.js";
|
|
6
|
+
import { discussionManager } from "./discussion-manager.js";
|
|
6
7
|
const MAX_BOT_STREAK = 10;
|
|
7
8
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
8
9
|
/**
|
|
@@ -25,7 +26,7 @@ export class FeishuBot {
|
|
|
25
26
|
initializedSessions = new Set();
|
|
26
27
|
/** Per-chat busy lock: timestamp when became busy (0 = not busy) */
|
|
27
28
|
busyChats = new Map();
|
|
28
|
-
/** Per-chat pending reply message IDs (to ack with DONE when
|
|
29
|
+
/** Per-chat pending reply message IDs (to ack with DONE when their trigger is processed) */
|
|
29
30
|
pendingAckMessages = new Map();
|
|
30
31
|
/** Per-chat pending tool message sends (to await before final reply) */
|
|
31
32
|
pendingToolSends = new Map();
|
|
@@ -211,6 +212,13 @@ export class FeishuBot {
|
|
|
211
212
|
static getAllBots() {
|
|
212
213
|
return FeishuBot.allBots;
|
|
213
214
|
}
|
|
215
|
+
static getByName(name) {
|
|
216
|
+
for (const bot of FeishuBot.allBots.values()) {
|
|
217
|
+
if (bot.config.name === name)
|
|
218
|
+
return bot;
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
214
222
|
static findByOpenId(openId) {
|
|
215
223
|
for (const bot of FeishuBot.allBots.values()) {
|
|
216
224
|
if (bot.botOpenId === openId)
|
|
@@ -351,17 +359,23 @@ export class FeishuBot {
|
|
|
351
359
|
// Single slash commands are handled by the bridge. Double slash commands were
|
|
352
360
|
// already unescaped above and should pass through to OpenClaw instead.
|
|
353
361
|
const isBridgeCommand = !commandText.startsWith("//");
|
|
354
|
-
const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode)/.test(cleanText.trim());
|
|
362
|
+
const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode|discuss)/.test(cleanText.trim());
|
|
355
363
|
if (isCommand) {
|
|
356
|
-
// In group chats, bridge commands must be explicitly routed to this
|
|
357
|
-
// or @all.
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
364
|
+
// In group chats, most bridge commands must be explicitly routed to this
|
|
365
|
+
// bot or @all. /discuss is a group-level command, so an unmentioned
|
|
366
|
+
// /discuss command is handled by one coordinator bot to avoid N replies.
|
|
367
|
+
const isDiscussCommand = cleanText.trim().startsWith("/discuss");
|
|
368
|
+
if (chatType !== "p2p" && !this.shouldHandleBridgeCommand(chatType, message, isBot, message.content)) {
|
|
369
|
+
if (!(isDiscussCommand && this.isDiscussionCoordinator()))
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
361
372
|
const markCommandSynced = () => {
|
|
362
373
|
if (insertedId > 0) {
|
|
363
374
|
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
364
|
-
|
|
375
|
+
// Bridge commands should only clear their own pending row. They
|
|
376
|
+
// must not clear earlier human triggers that are waiting after a
|
|
377
|
+
// failed/empty run; /status must be safe to use for debugging.
|
|
378
|
+
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
365
379
|
}
|
|
366
380
|
};
|
|
367
381
|
if (cleanText.trim().startsWith("/help")) {
|
|
@@ -376,6 +390,7 @@ export class FeishuBot {
|
|
|
376
390
|
`🔓 /free — 切换当前 bot 的 free 模式(不 @ 也可回复)`,
|
|
377
391
|
`🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
|
|
378
392
|
`🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
|
|
393
|
+
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
|
|
379
394
|
`❓ /help — 显示此帮助信息`,
|
|
380
395
|
``,
|
|
381
396
|
`OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
|
|
@@ -480,6 +495,33 @@ export class FeishuBot {
|
|
|
480
495
|
markCommandSynced();
|
|
481
496
|
return;
|
|
482
497
|
}
|
|
498
|
+
if (cleanText.trim().startsWith("/discuss")) {
|
|
499
|
+
await this.handleDiscussCommand(chatId, chatType, messageId, cleanText.trim());
|
|
500
|
+
markCommandSynced();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// --- Discuss mode: group-level multi-bot round scheduler. It takes over
|
|
505
|
+
// plain human messages so normal Free mode does not duplicate Round 1.
|
|
506
|
+
if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
|
|
507
|
+
const mentions = message.mentions || [];
|
|
508
|
+
const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
|
|
509
|
+
if (!hasTargetedMention) {
|
|
510
|
+
const participants = this.getDiscussionParticipants(chatId);
|
|
511
|
+
if (participants.length > 0) {
|
|
512
|
+
discussionManager.startIfAbsent({
|
|
513
|
+
chatId,
|
|
514
|
+
rootMessageId: messageId,
|
|
515
|
+
topic: cleanText,
|
|
516
|
+
maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
|
|
517
|
+
participants,
|
|
518
|
+
sendSystemMessage: async (text) => { await this.sendMessage(chatId, text); },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (insertedId > 0)
|
|
522
|
+
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
483
525
|
}
|
|
484
526
|
// --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
|
|
485
527
|
if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
|
|
@@ -487,7 +529,7 @@ export class FeishuBot {
|
|
|
487
529
|
await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
|
|
488
530
|
if (insertedId > 0) {
|
|
489
531
|
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
490
|
-
this.store.
|
|
532
|
+
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
491
533
|
}
|
|
492
534
|
}
|
|
493
535
|
return;
|
|
@@ -513,7 +555,7 @@ export class FeishuBot {
|
|
|
513
555
|
if (isBusy) {
|
|
514
556
|
// Queued: show waiting reaction
|
|
515
557
|
await this.addReaction(messageId, "Typing").catch(() => { });
|
|
516
|
-
pending.push({ messageId, emoji: "Typing" });
|
|
558
|
+
pending.push({ messageId, emoji: "Typing", rowId: insertedId });
|
|
517
559
|
this.pendingAckMessages.set(chatId, pending);
|
|
518
560
|
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, queuing: "${cleanText.substring(0, 50)}..."`);
|
|
519
561
|
return; // Message is in DB, will be picked up when agent finishes
|
|
@@ -528,7 +570,7 @@ export class FeishuBot {
|
|
|
528
570
|
// --- Not busy, send now (with any accumulated messages) ---
|
|
529
571
|
// Acknowledge receipt: sent to OpenClaw (GET/了解)
|
|
530
572
|
await this.addReaction(messageId, "Get").catch(() => { });
|
|
531
|
-
pending.push({ messageId, emoji: "Get" });
|
|
573
|
+
pending.push({ messageId, emoji: "Get", rowId: insertedId });
|
|
532
574
|
this.pendingAckMessages.set(chatId, pending);
|
|
533
575
|
await this.processQueue(chatId);
|
|
534
576
|
}
|
|
@@ -596,15 +638,29 @@ export class FeishuBot {
|
|
|
596
638
|
timeoutMs: 600000,
|
|
597
639
|
});
|
|
598
640
|
console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
|
|
599
|
-
// Mark everything up to now as synced
|
|
600
|
-
const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
|
|
601
|
-
this.store.markSynced(this.config.name, chatId, maxId);
|
|
602
|
-
this.store.clearPendingTriggers(this.config.name, chatId, maxId);
|
|
603
641
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
604
642
|
const visibleReply = parsedReply.text;
|
|
605
643
|
const trimmedReply = visibleReply.trim();
|
|
606
|
-
const shouldReply = trimmedReply.length > 0 && trimmedReply.toUpperCase() !== "NO_REPLY";
|
|
607
644
|
const hasAttachments = parsedReply.attachments.length > 0;
|
|
645
|
+
const explicitNoReply = trimmedReply.toUpperCase() === "NO_REPLY";
|
|
646
|
+
const trulyEmptyReply = trimmedReply.length === 0 && !hasAttachments;
|
|
647
|
+
if (trulyEmptyReply) {
|
|
648
|
+
// Empty final text is not the same as an explicit NO_REPLY. It often
|
|
649
|
+
// means the upstream session/run was interrupted, raced, or collected
|
|
650
|
+
// incorrectly. Do not mark sync, clear pending triggers, or mark DONE.
|
|
651
|
+
// Leave the trigger pending for a later retry/new message.
|
|
652
|
+
console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; keeping pending for retry`);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
// Mark only the snapshot processed in this run as synced. Messages that
|
|
656
|
+
// arrive while the agent is busy may already be in pendingAckMessages,
|
|
657
|
+
// but they are not part of allUnsynced/humanUnsynced for this run and
|
|
658
|
+
// must remain pending for the next loop.
|
|
659
|
+
const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
|
|
660
|
+
const processedTriggerIds = new Set(humanUnsynced.map((m) => m.id || 0).filter(Boolean));
|
|
661
|
+
this.store.markSynced(this.config.name, chatId, maxId);
|
|
662
|
+
this.store.clearPendingTriggers(this.config.name, chatId, maxId);
|
|
663
|
+
const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
|
|
608
664
|
// Record bot reply only if it is user-visible/context-worthy.
|
|
609
665
|
if (shouldReply || hasAttachments) {
|
|
610
666
|
const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
|
|
@@ -618,8 +674,15 @@ export class FeishuBot {
|
|
|
618
674
|
content: storedContent,
|
|
619
675
|
timestamp: Date.now(),
|
|
620
676
|
});
|
|
621
|
-
if (replyId > 0)
|
|
622
|
-
this.store.
|
|
677
|
+
if (replyId > 0) {
|
|
678
|
+
const remainingPending = this.store.getPendingTriggerIds(this.config.name, chatId);
|
|
679
|
+
const hasEarlierPending = Array.from(remainingPending).some((id) => id <= replyId);
|
|
680
|
+
// Do not advance sync past a human message that arrived while this
|
|
681
|
+
// run was busy. Otherwise the pending trigger remains in the table
|
|
682
|
+
// but getUnsyncedMessages() can no longer see it.
|
|
683
|
+
if (!hasEarlierPending)
|
|
684
|
+
this.store.markSynced(this.config.name, chatId, replyId);
|
|
685
|
+
}
|
|
623
686
|
}
|
|
624
687
|
// Wait for all pending tool event messages to be delivered first
|
|
625
688
|
const toolSends = this.pendingToolSends.get(chatId) || [];
|
|
@@ -656,16 +719,55 @@ export class FeishuBot {
|
|
|
656
719
|
}
|
|
657
720
|
}
|
|
658
721
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
659
|
-
// Replace ack reactions with DONE for
|
|
722
|
+
// Replace ack reactions with DONE only for trigger messages actually
|
|
723
|
+
// processed in this run. Queued messages that arrived mid-run keep their
|
|
724
|
+
// Typing/Get reaction and will be acknowledged by the next loop.
|
|
660
725
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
726
|
+
const remainingAcks = [];
|
|
661
727
|
for (const ack of pendingAcks) {
|
|
662
|
-
|
|
663
|
-
|
|
728
|
+
if (processedTriggerIds.has(ack.rowId)) {
|
|
729
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
730
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
remainingAcks.push(ack);
|
|
734
|
+
}
|
|
664
735
|
}
|
|
665
|
-
this.pendingAckMessages.set(chatId,
|
|
736
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
666
737
|
}
|
|
667
738
|
catch (err) {
|
|
668
739
|
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
740
|
+
const errorText = this.formatUserVisibleError(err);
|
|
741
|
+
if (lastHuman.messageId) {
|
|
742
|
+
await this.sendOrdered(chatId, async () => {
|
|
743
|
+
try {
|
|
744
|
+
await this.replyMessage(lastHuman.messageId, errorText);
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
await this.sendMessage(chatId, errorText);
|
|
748
|
+
}
|
|
749
|
+
if (triggerId)
|
|
750
|
+
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
751
|
+
}).catch(() => { });
|
|
752
|
+
}
|
|
753
|
+
if (triggerId) {
|
|
754
|
+
// The run failed after OpenClaw/provider rejected it. Notify the user
|
|
755
|
+
// and clear only this failed trigger so later messages can continue.
|
|
756
|
+
this.store.clearPendingTrigger(this.config.name, chatId, triggerId);
|
|
757
|
+
this.store.markSynced(this.config.name, chatId, triggerId);
|
|
758
|
+
}
|
|
759
|
+
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
760
|
+
const remainingAcks = [];
|
|
761
|
+
for (const ack of pendingAcks) {
|
|
762
|
+
if (ack.rowId === triggerId) {
|
|
763
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
764
|
+
await this.addReaction(ack.messageId, "FAIL").catch(() => { });
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
remainingAcks.push(ack);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
669
771
|
break;
|
|
670
772
|
}
|
|
671
773
|
finally {
|
|
@@ -779,6 +881,72 @@ export class FeishuBot {
|
|
|
779
881
|
},
|
|
780
882
|
};
|
|
781
883
|
}
|
|
884
|
+
formatUserVisibleError(err) {
|
|
885
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
886
|
+
let reason = raw.replace(/\s+/g, " ").trim();
|
|
887
|
+
if (/quota/i.test(reason) || /exceeded/i.test(reason)) {
|
|
888
|
+
const reset = reason.match(/reset at ([^.;]+)/i)?.[1];
|
|
889
|
+
reason = reset ? `模型/供应商额度已用尽,重置时间:${reset}` : "模型/供应商额度已用尽,请稍后重试或切换模型";
|
|
890
|
+
}
|
|
891
|
+
else if (/timeout/i.test(reason)) {
|
|
892
|
+
reason = "模型响应超时,请稍后重试";
|
|
893
|
+
}
|
|
894
|
+
else if (/schema|tool payload|rejected/i.test(reason)) {
|
|
895
|
+
reason = "模型供应商拒绝了请求格式或工具参数,需要调整模型/工具调用";
|
|
896
|
+
}
|
|
897
|
+
else if (reason.length > 220) {
|
|
898
|
+
reason = reason.slice(0, 220) + "...";
|
|
899
|
+
}
|
|
900
|
+
return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
|
|
901
|
+
}
|
|
902
|
+
isDiscussionCoordinator() {
|
|
903
|
+
const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
|
|
904
|
+
if (bots.length === 0)
|
|
905
|
+
return true;
|
|
906
|
+
return bots[0] === this;
|
|
907
|
+
}
|
|
908
|
+
getDiscussionParticipants(chatId) {
|
|
909
|
+
return Array.from(FeishuBot.allBots.values())
|
|
910
|
+
.filter((bot) => bot.store === this.store && bot.store.getBotMode(bot.config.name, chatId) === "free")
|
|
911
|
+
.map((bot) => ({
|
|
912
|
+
name: bot.config.name,
|
|
913
|
+
runDiscussionTurn: async (_chatId, prompt) => bot.runDiscussionTurn(chatId, prompt),
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
async runDiscussionTurn(chatId, prompt) {
|
|
917
|
+
const sessionKey = await this.ensureSession(chatId);
|
|
918
|
+
const reply = await this.openclawClient.chatSendWithContext({
|
|
919
|
+
sessionKey,
|
|
920
|
+
unsyncedMessages: [],
|
|
921
|
+
currentMessage: prompt,
|
|
922
|
+
currentSenderName: "Discussion Scheduler",
|
|
923
|
+
deliver: false,
|
|
924
|
+
timeoutMs: 600000,
|
|
925
|
+
});
|
|
926
|
+
const parsedReply = this.extractBridgeAttachments(reply);
|
|
927
|
+
const visibleReply = parsedReply.text.trim();
|
|
928
|
+
const isVisible = visibleReply.length > 0 && visibleReply.toUpperCase() !== "NO_REPLY";
|
|
929
|
+
if (isVisible || parsedReply.attachments.length > 0) {
|
|
930
|
+
const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
|
|
931
|
+
.filter(Boolean)
|
|
932
|
+
.join("\n");
|
|
933
|
+
this.store.insert({
|
|
934
|
+
chatId,
|
|
935
|
+
messageId: `self-${this.config.name}-discuss-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
936
|
+
senderType: "bot",
|
|
937
|
+
senderName: this.config.name,
|
|
938
|
+
content: storedContent,
|
|
939
|
+
timestamp: Date.now(),
|
|
940
|
+
});
|
|
941
|
+
await this.sendOrdered(chatId, async () => {
|
|
942
|
+
if (isVisible)
|
|
943
|
+
await this.sendMessage(chatId, visibleReply);
|
|
944
|
+
for (const attachment of parsedReply.attachments)
|
|
945
|
+
await this.sendBridgeAttachment(chatId, attachment);
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
return { botName: this.config.name, text: visibleReply, visible: isVisible };
|
|
949
|
+
}
|
|
782
950
|
async replyMessage(messageId, text) {
|
|
783
951
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
784
952
|
const card = this.buildMarkdownCard(text);
|
|
@@ -1013,6 +1181,49 @@ ${doc.url}`);
|
|
|
1013
1181
|
// ignore
|
|
1014
1182
|
}
|
|
1015
1183
|
}
|
|
1184
|
+
async handleDiscussCommand(chatId, chatType, messageId, text) {
|
|
1185
|
+
if (chatType === "p2p") {
|
|
1186
|
+
await this.replyMessage(messageId, "❌ Discuss 模式只在群聊中可用");
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const parts = text.split(/\s+/).filter(Boolean);
|
|
1190
|
+
const action = parts[1] || "status";
|
|
1191
|
+
if (action === "on") {
|
|
1192
|
+
this.store.setDiscussMode(chatId, true);
|
|
1193
|
+
await this.replyMessage(messageId, `💬 Discuss 已开启\n参与者:当前群所有 free 模式 bot\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 3}`);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (action === "off") {
|
|
1197
|
+
this.store.setDiscussMode(chatId, false);
|
|
1198
|
+
discussionManager.stop(chatId);
|
|
1199
|
+
await this.replyMessage(messageId, "💬 Discuss 已关闭");
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (action === "stop") {
|
|
1203
|
+
const stopped = discussionManager.stop(chatId);
|
|
1204
|
+
await this.replyMessage(messageId, stopped ? "💬 当前 discuss 已停止" : "💬 当前没有运行中的 discuss");
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (action === "rounds") {
|
|
1208
|
+
const n = Number.parseInt(parts[2] || "", 10);
|
|
1209
|
+
if (!Number.isFinite(n)) {
|
|
1210
|
+
await this.replyMessage(messageId, "❌ 用法:/discuss rounds <1-10>");
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
this.store.setDiscussMaxRounds(chatId, n);
|
|
1214
|
+
await this.replyMessage(messageId, `💬 Discuss 轮数已设置为 ${this.store.getChatInfo(chatId)?.discussMaxRounds || n}`);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const info = this.store.getChatInfo(chatId);
|
|
1218
|
+
const active = discussionManager.status(chatId);
|
|
1219
|
+
const participants = this.getDiscussionParticipants(chatId).map((p) => p.name);
|
|
1220
|
+
await this.replyMessage(messageId, [
|
|
1221
|
+
`💬 Discuss: ${info?.discuss ? "on" : "off"}`,
|
|
1222
|
+
`轮数:${info?.discussMaxRounds || 3}`,
|
|
1223
|
+
`参与者:${participants.length ? participants.join(", ") : "(无 free bot)"}`,
|
|
1224
|
+
active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
|
|
1225
|
+
].join("\n"));
|
|
1226
|
+
}
|
|
1016
1227
|
/**
|
|
1017
1228
|
* Handle /status command: show current session info.
|
|
1018
1229
|
*/
|
|
@@ -1113,6 +1324,8 @@ ${doc.url}`);
|
|
|
1113
1324
|
ownerBot: this.config.name,
|
|
1114
1325
|
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
1115
1326
|
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
1327
|
+
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|
|
1328
|
+
discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
|
|
1116
1329
|
updatedAt: Date.now(),
|
|
1117
1330
|
});
|
|
1118
1331
|
return;
|
|
@@ -1150,6 +1363,8 @@ ${doc.url}`);
|
|
|
1150
1363
|
ownerBot: "", // group chats are shared, no owner
|
|
1151
1364
|
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
1152
1365
|
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
1366
|
+
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|
|
1367
|
+
discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
|
|
1153
1368
|
updatedAt: Date.now(),
|
|
1154
1369
|
});
|
|
1155
1370
|
console.log(`[${this.config.name}] Cached chat info: ${chatName} (${chatId.slice(-8)})`);
|
package/dist/message-store.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ChatInfo {
|
|
|
12
12
|
/** Legacy chat-level free discussion flag; per-bot mode is authoritative. */
|
|
13
13
|
freeDiscussion: boolean;
|
|
14
14
|
verbose: boolean;
|
|
15
|
+
discuss: boolean;
|
|
16
|
+
discussMaxRounds: number;
|
|
15
17
|
updatedAt: number;
|
|
16
18
|
}
|
|
17
19
|
export interface ChatMessage {
|
|
@@ -35,6 +37,7 @@ export declare class MessageStore {
|
|
|
35
37
|
markPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
|
|
36
38
|
getPendingTriggerIds(botName: string, chatId: string): Set<number>;
|
|
37
39
|
clearPendingTriggers(botName: string, chatId: string, upToId: number): void;
|
|
40
|
+
clearPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
|
|
38
41
|
hasDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number): boolean;
|
|
39
42
|
markDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number, replyMessageId?: string): void;
|
|
40
43
|
/**
|
|
@@ -61,6 +64,8 @@ export declare class MessageStore {
|
|
|
61
64
|
upsertChatInfo(info: ChatInfo): void;
|
|
62
65
|
setFreeDiscussion(chatId: string, on: boolean): void;
|
|
63
66
|
setVerbose(chatId: string, verbose: boolean): void;
|
|
67
|
+
setDiscussMode(chatId: string, on: boolean): void;
|
|
68
|
+
setDiscussMaxRounds(chatId: string, rounds: number): void;
|
|
64
69
|
setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
|
|
65
70
|
getBotVerbose(botName: string, chatId: string): boolean;
|
|
66
71
|
setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
|
package/dist/message-store.js
CHANGED
|
@@ -31,6 +31,8 @@ export class MessageStore {
|
|
|
31
31
|
members TEXT NOT NULL DEFAULT '',
|
|
32
32
|
member_names TEXT NOT NULL DEFAULT '',
|
|
33
33
|
verbose INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
discuss INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
discuss_max_rounds INTEGER NOT NULL DEFAULT 3,
|
|
34
36
|
updated_at INTEGER NOT NULL DEFAULT 0
|
|
35
37
|
);
|
|
36
38
|
|
|
@@ -88,6 +90,19 @@ export class MessageStore {
|
|
|
88
90
|
catch {
|
|
89
91
|
// Column already exists
|
|
90
92
|
}
|
|
93
|
+
// Migration: add discuss columns if missing
|
|
94
|
+
try {
|
|
95
|
+
this.db.exec(`ALTER TABLE chat_info ADD COLUMN discuss INTEGER NOT NULL DEFAULT 0`);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Column already exists
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
this.db.exec(`ALTER TABLE chat_info ADD COLUMN discuss_max_rounds INTEGER NOT NULL DEFAULT 3`);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Column already exists
|
|
105
|
+
}
|
|
91
106
|
// Migration: add owner_bot column if missing
|
|
92
107
|
try {
|
|
93
108
|
this.db.exec(`ALTER TABLE chat_info ADD COLUMN owner_bot TEXT NOT NULL DEFAULT ''`);
|
|
@@ -163,6 +178,12 @@ export class MessageStore {
|
|
|
163
178
|
WHERE bot_name = ? AND chat_id = ? AND message_row_id <= ?
|
|
164
179
|
`).run(botName, chatId, upToId);
|
|
165
180
|
}
|
|
181
|
+
clearPendingTrigger(botName, chatId, messageRowId) {
|
|
182
|
+
this.db.prepare(`
|
|
183
|
+
DELETE FROM pending_triggers
|
|
184
|
+
WHERE bot_name = ? AND chat_id = ? AND message_row_id = ?
|
|
185
|
+
`).run(botName, chatId, messageRowId);
|
|
186
|
+
}
|
|
166
187
|
hasDeliveredReply(botName, chatId, triggerMessageRowId) {
|
|
167
188
|
const row = this.db.prepare(`
|
|
168
189
|
SELECT 1 FROM delivered_replies
|
|
@@ -258,8 +279,8 @@ export class MessageStore {
|
|
|
258
279
|
// --- Chat info ---
|
|
259
280
|
upsertChatInfo(info) {
|
|
260
281
|
this.db.prepare(`
|
|
261
|
-
INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, updated_at)
|
|
262
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
282
|
+
INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, discuss, discuss_max_rounds, updated_at)
|
|
283
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
263
284
|
ON CONFLICT (chat_id) DO UPDATE SET
|
|
264
285
|
chat_type = excluded.chat_type,
|
|
265
286
|
chat_name = excluded.chat_name,
|
|
@@ -268,8 +289,10 @@ export class MessageStore {
|
|
|
268
289
|
verbose = excluded.verbose,
|
|
269
290
|
free_discussion = excluded.free_discussion,
|
|
270
291
|
owner_bot = CASE WHEN excluded.owner_bot != '' THEN excluded.owner_bot ELSE chat_info.owner_bot END,
|
|
292
|
+
discuss = CASE WHEN excluded.discuss != 0 THEN excluded.discuss ELSE chat_info.discuss END,
|
|
293
|
+
discuss_max_rounds = CASE WHEN excluded.discuss_max_rounds != 3 THEN excluded.discuss_max_rounds ELSE chat_info.discuss_max_rounds END,
|
|
271
294
|
updated_at = excluded.updated_at
|
|
272
|
-
`).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.updatedAt);
|
|
295
|
+
`).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.discuss ? 1 : 0, info.discussMaxRounds || 3, info.updatedAt);
|
|
273
296
|
}
|
|
274
297
|
setFreeDiscussion(chatId, on) {
|
|
275
298
|
this.db.prepare(`UPDATE chat_info SET free_discussion = ? WHERE chat_id = ?`).run(on ? 1 : 0, chatId);
|
|
@@ -279,6 +302,21 @@ export class MessageStore {
|
|
|
279
302
|
UPDATE chat_info SET verbose = ? WHERE chat_id = ?
|
|
280
303
|
`).run(verbose ? 1 : 0, chatId);
|
|
281
304
|
}
|
|
305
|
+
setDiscussMode(chatId, on) {
|
|
306
|
+
this.db.prepare(`
|
|
307
|
+
INSERT INTO chat_info (chat_id, chat_type, chat_name, discuss, discuss_max_rounds, updated_at)
|
|
308
|
+
VALUES (?, 'group', '', ?, 3, ?)
|
|
309
|
+
ON CONFLICT (chat_id) DO UPDATE SET discuss = excluded.discuss, updated_at = excluded.updated_at
|
|
310
|
+
`).run(chatId, on ? 1 : 0, Date.now());
|
|
311
|
+
}
|
|
312
|
+
setDiscussMaxRounds(chatId, rounds) {
|
|
313
|
+
const normalized = Math.max(1, Math.min(10, Math.round(rounds)));
|
|
314
|
+
this.db.prepare(`
|
|
315
|
+
INSERT INTO chat_info (chat_id, chat_type, chat_name, discuss, discuss_max_rounds, updated_at)
|
|
316
|
+
VALUES (?, 'group', '', 0, ?, ?)
|
|
317
|
+
ON CONFLICT (chat_id) DO UPDATE SET discuss_max_rounds = excluded.discuss_max_rounds, updated_at = excluded.updated_at
|
|
318
|
+
`).run(chatId, normalized, Date.now());
|
|
319
|
+
}
|
|
282
320
|
setBotVerbose(botName, chatId, verbose) {
|
|
283
321
|
this.db.prepare(`
|
|
284
322
|
INSERT INTO bot_chat_settings (bot_name, chat_id, verbose, updated_at)
|
|
@@ -334,6 +372,8 @@ export class MessageStore {
|
|
|
334
372
|
ownerBot: row.owner_bot || '',
|
|
335
373
|
freeDiscussion: !!row.free_discussion,
|
|
336
374
|
verbose: !!row.verbose,
|
|
375
|
+
discuss: !!row.discuss,
|
|
376
|
+
discussMaxRounds: row.discuss_max_rounds || 3,
|
|
337
377
|
updatedAt: row.updated_at,
|
|
338
378
|
};
|
|
339
379
|
}
|
|
@@ -348,6 +388,8 @@ export class MessageStore {
|
|
|
348
388
|
ownerBot: r.owner_bot || '',
|
|
349
389
|
freeDiscussion: !!r.free_discussion,
|
|
350
390
|
verbose: !!r.verbose,
|
|
391
|
+
discuss: !!r.discuss,
|
|
392
|
+
discussMaxRounds: r.discuss_max_rounds || 3,
|
|
351
393
|
updatedAt: r.updated_at,
|
|
352
394
|
}));
|
|
353
395
|
}
|