openclaw-lark-multi-agent 0.1.9 → 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 +8 -1
- package/dist/feishu-bot.js +254 -28
- package/dist/message-store.d.ts +11 -2
- package/dist/message-store.js +56 -10
- 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
|
/**
|
|
@@ -68,12 +69,17 @@ export declare class FeishuBot {
|
|
|
68
69
|
private shouldRespond;
|
|
69
70
|
private isMentioned;
|
|
70
71
|
private isAllMention;
|
|
72
|
+
private isAllMentionItem;
|
|
71
73
|
private mentionedBotName;
|
|
72
74
|
private resolveBotName;
|
|
73
75
|
private resolveHumanName;
|
|
74
76
|
private cleanMentions;
|
|
75
77
|
private stripLeadingCommandMentions;
|
|
76
78
|
private buildMarkdownCard;
|
|
79
|
+
private formatUserVisibleError;
|
|
80
|
+
private isDiscussionCoordinator;
|
|
81
|
+
private getDiscussionParticipants;
|
|
82
|
+
private runDiscussionTurn;
|
|
77
83
|
private replyMessage;
|
|
78
84
|
private extractBridgeAttachments;
|
|
79
85
|
private validateBridgeAttachmentPath;
|
|
@@ -99,6 +105,7 @@ export declare class FeishuBot {
|
|
|
99
105
|
* Finds the bot's own reaction of that type and deletes it.
|
|
100
106
|
*/
|
|
101
107
|
private removeReaction;
|
|
108
|
+
private handleDiscussCommand;
|
|
102
109
|
/**
|
|
103
110
|
* Handle /status command: show current session info.
|
|
104
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)
|
|
@@ -264,6 +272,12 @@ export class FeishuBot {
|
|
|
264
272
|
if (messageType === "text") {
|
|
265
273
|
const rawText = content.text || "";
|
|
266
274
|
cleanText = this.cleanMentions(rawText);
|
|
275
|
+
// A mention-only text message is still a valid routing trigger. Feishu may
|
|
276
|
+
// expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
|
|
277
|
+
// so decide emptiness after stripping leading routing mentions.
|
|
278
|
+
if ((this.isMentioned(message.mentions || []) || this.isAllMention(rawText, message.mentions || [])) && !this.stripLeadingCommandMentions(cleanText).trim()) {
|
|
279
|
+
cleanText = "请回复上面最近一条用户消息。";
|
|
280
|
+
}
|
|
267
281
|
}
|
|
268
282
|
else if (messageType === "image") {
|
|
269
283
|
// Download image and pass local path
|
|
@@ -345,17 +359,23 @@ export class FeishuBot {
|
|
|
345
359
|
// Single slash commands are handled by the bridge. Double slash commands were
|
|
346
360
|
// already unescaped above and should pass through to OpenClaw instead.
|
|
347
361
|
const isBridgeCommand = !commandText.startsWith("//");
|
|
348
|
-
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());
|
|
349
363
|
if (isCommand) {
|
|
350
|
-
// In group chats, bridge commands must be explicitly routed to this
|
|
351
|
-
// or @all.
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|
|
355
372
|
const markCommandSynced = () => {
|
|
356
373
|
if (insertedId > 0) {
|
|
357
374
|
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
358
|
-
|
|
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);
|
|
359
379
|
}
|
|
360
380
|
};
|
|
361
381
|
if (cleanText.trim().startsWith("/help")) {
|
|
@@ -370,6 +390,7 @@ export class FeishuBot {
|
|
|
370
390
|
`🔓 /free — 切换当前 bot 的 free 模式(不 @ 也可回复)`,
|
|
371
391
|
`🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
|
|
372
392
|
`🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
|
|
393
|
+
`💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
|
|
373
394
|
`❓ /help — 显示此帮助信息`,
|
|
374
395
|
``,
|
|
375
396
|
`OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
|
|
@@ -474,6 +495,33 @@ export class FeishuBot {
|
|
|
474
495
|
markCommandSynced();
|
|
475
496
|
return;
|
|
476
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
|
+
}
|
|
477
525
|
}
|
|
478
526
|
// --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
|
|
479
527
|
if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
|
|
@@ -481,7 +529,7 @@ export class FeishuBot {
|
|
|
481
529
|
await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
|
|
482
530
|
if (insertedId > 0) {
|
|
483
531
|
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
484
|
-
this.store.
|
|
532
|
+
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
485
533
|
}
|
|
486
534
|
}
|
|
487
535
|
return;
|
|
@@ -495,7 +543,7 @@ export class FeishuBot {
|
|
|
495
543
|
// Track this message for reaction status updates
|
|
496
544
|
const pending = this.pendingAckMessages.get(chatId) || [];
|
|
497
545
|
// Anti-loop
|
|
498
|
-
const streak = this.store.getBotStreak(chatId);
|
|
546
|
+
const streak = this.store.getBotStreak(chatId, this.config.name);
|
|
499
547
|
if (streak >= MAX_BOT_STREAK) {
|
|
500
548
|
console.log(`[${this.config.name}] Anti-loop: ${streak} consecutive bot msgs`);
|
|
501
549
|
return;
|
|
@@ -507,7 +555,7 @@ export class FeishuBot {
|
|
|
507
555
|
if (isBusy) {
|
|
508
556
|
// Queued: show waiting reaction
|
|
509
557
|
await this.addReaction(messageId, "Typing").catch(() => { });
|
|
510
|
-
pending.push({ messageId, emoji: "Typing" });
|
|
558
|
+
pending.push({ messageId, emoji: "Typing", rowId: insertedId });
|
|
511
559
|
this.pendingAckMessages.set(chatId, pending);
|
|
512
560
|
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, queuing: "${cleanText.substring(0, 50)}..."`);
|
|
513
561
|
return; // Message is in DB, will be picked up when agent finishes
|
|
@@ -522,7 +570,7 @@ export class FeishuBot {
|
|
|
522
570
|
// --- Not busy, send now (with any accumulated messages) ---
|
|
523
571
|
// Acknowledge receipt: sent to OpenClaw (GET/了解)
|
|
524
572
|
await this.addReaction(messageId, "Get").catch(() => { });
|
|
525
|
-
pending.push({ messageId, emoji: "Get" });
|
|
573
|
+
pending.push({ messageId, emoji: "Get", rowId: insertedId });
|
|
526
574
|
this.pendingAckMessages.set(chatId, pending);
|
|
527
575
|
await this.processQueue(chatId);
|
|
528
576
|
}
|
|
@@ -590,15 +638,29 @@ export class FeishuBot {
|
|
|
590
638
|
timeoutMs: 600000,
|
|
591
639
|
});
|
|
592
640
|
console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
|
|
593
|
-
// Mark everything up to now as synced
|
|
594
|
-
const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
|
|
595
|
-
this.store.markSynced(this.config.name, chatId, maxId);
|
|
596
|
-
this.store.clearPendingTriggers(this.config.name, chatId, maxId);
|
|
597
641
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
598
642
|
const visibleReply = parsedReply.text;
|
|
599
643
|
const trimmedReply = visibleReply.trim();
|
|
600
|
-
const shouldReply = trimmedReply.length > 0 && trimmedReply.toUpperCase() !== "NO_REPLY";
|
|
601
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;
|
|
602
664
|
// Record bot reply only if it is user-visible/context-worthy.
|
|
603
665
|
if (shouldReply || hasAttachments) {
|
|
604
666
|
const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
|
|
@@ -612,8 +674,15 @@ export class FeishuBot {
|
|
|
612
674
|
content: storedContent,
|
|
613
675
|
timestamp: Date.now(),
|
|
614
676
|
});
|
|
615
|
-
if (replyId > 0)
|
|
616
|
-
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
|
+
}
|
|
617
686
|
}
|
|
618
687
|
// Wait for all pending tool event messages to be delivered first
|
|
619
688
|
const toolSends = this.pendingToolSends.get(chatId) || [];
|
|
@@ -650,16 +719,55 @@ export class FeishuBot {
|
|
|
650
719
|
}
|
|
651
720
|
}
|
|
652
721
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
653
|
-
// 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.
|
|
654
725
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
726
|
+
const remainingAcks = [];
|
|
655
727
|
for (const ack of pendingAcks) {
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
}
|
|
658
735
|
}
|
|
659
|
-
this.pendingAckMessages.set(chatId,
|
|
736
|
+
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
660
737
|
}
|
|
661
738
|
catch (err) {
|
|
662
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);
|
|
663
771
|
break;
|
|
664
772
|
}
|
|
665
773
|
finally {
|
|
@@ -694,9 +802,11 @@ export class FeishuBot {
|
|
|
694
802
|
// Check if this bot is explicitly mentioned
|
|
695
803
|
if (this.isMentioned(mentions))
|
|
696
804
|
return true;
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
805
|
+
// Targeted mentions are exclusive. If a human mentions another person or
|
|
806
|
+
// another bot, free-mode bots must not steal that message. Free mode only
|
|
807
|
+
// applies to plain human messages with no targeted mentions.
|
|
808
|
+
const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
|
|
809
|
+
if (hasTargetedMention)
|
|
700
810
|
return false;
|
|
701
811
|
// No bot mentioned: check current per-bot mode
|
|
702
812
|
if (chatId) {
|
|
@@ -712,10 +822,13 @@ export class FeishuBot {
|
|
|
712
822
|
isAllMention(rawText, mentions = []) {
|
|
713
823
|
if (rawText && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
|
|
714
824
|
return true;
|
|
715
|
-
return mentions.some((m) =>
|
|
825
|
+
return mentions.some((m) => this.isAllMentionItem(m));
|
|
826
|
+
}
|
|
827
|
+
isAllMentionItem(mention) {
|
|
828
|
+
return mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人";
|
|
716
829
|
}
|
|
717
830
|
mentionedBotName(mention) {
|
|
718
|
-
if (
|
|
831
|
+
if (this.isAllMentionItem(mention))
|
|
719
832
|
return null;
|
|
720
833
|
const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
|
|
721
834
|
for (const bot of candidates) {
|
|
@@ -768,6 +881,72 @@ export class FeishuBot {
|
|
|
768
881
|
},
|
|
769
882
|
};
|
|
770
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
|
+
}
|
|
771
950
|
async replyMessage(messageId, text) {
|
|
772
951
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
773
952
|
const card = this.buildMarkdownCard(text);
|
|
@@ -1002,6 +1181,49 @@ ${doc.url}`);
|
|
|
1002
1181
|
// ignore
|
|
1003
1182
|
}
|
|
1004
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
|
+
}
|
|
1005
1227
|
/**
|
|
1006
1228
|
* Handle /status command: show current session info.
|
|
1007
1229
|
*/
|
|
@@ -1102,6 +1324,8 @@ ${doc.url}`);
|
|
|
1102
1324
|
ownerBot: this.config.name,
|
|
1103
1325
|
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
1104
1326
|
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
1327
|
+
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|
|
1328
|
+
discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
|
|
1105
1329
|
updatedAt: Date.now(),
|
|
1106
1330
|
});
|
|
1107
1331
|
return;
|
|
@@ -1139,6 +1363,8 @@ ${doc.url}`);
|
|
|
1139
1363
|
ownerBot: "", // group chats are shared, no owner
|
|
1140
1364
|
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
1141
1365
|
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
1366
|
+
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|
|
1367
|
+
discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
|
|
1142
1368
|
updatedAt: Date.now(),
|
|
1143
1369
|
});
|
|
1144
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
|
/**
|
|
@@ -51,12 +54,18 @@ export declare class MessageStore {
|
|
|
51
54
|
*/
|
|
52
55
|
getRecent(chatId: string, maxCount?: number): ChatMessage[];
|
|
53
56
|
/**
|
|
54
|
-
* Count consecutive bot
|
|
57
|
+
* Count consecutive messages from one bot at the tail of a chat.
|
|
58
|
+
*
|
|
59
|
+
* Other bots do not consume this bot's anti-loop budget. Human messages reset
|
|
60
|
+
* the streak. This lets multiple bots free-discuss without a global bot-streak
|
|
61
|
+
* guard shutting everyone down after N total bot messages.
|
|
55
62
|
*/
|
|
56
|
-
getBotStreak(chatId: string): number;
|
|
63
|
+
getBotStreak(chatId: string, botName: string): number;
|
|
57
64
|
upsertChatInfo(info: ChatInfo): void;
|
|
58
65
|
setFreeDiscussion(chatId: string, on: boolean): void;
|
|
59
66
|
setVerbose(chatId: string, verbose: boolean): void;
|
|
67
|
+
setDiscussMode(chatId: string, on: boolean): void;
|
|
68
|
+
setDiscussMaxRounds(chatId: string, rounds: number): void;
|
|
60
69
|
setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
|
|
61
70
|
getBotVerbose(botName: string, chatId: string): boolean;
|
|
62
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
|
|
@@ -233,29 +254,33 @@ export class MessageStore {
|
|
|
233
254
|
}));
|
|
234
255
|
}
|
|
235
256
|
/**
|
|
236
|
-
* Count consecutive bot
|
|
257
|
+
* Count consecutive messages from one bot at the tail of a chat.
|
|
258
|
+
*
|
|
259
|
+
* Other bots do not consume this bot's anti-loop budget. Human messages reset
|
|
260
|
+
* the streak. This lets multiple bots free-discuss without a global bot-streak
|
|
261
|
+
* guard shutting everyone down after N total bot messages.
|
|
237
262
|
*/
|
|
238
|
-
getBotStreak(chatId) {
|
|
263
|
+
getBotStreak(chatId, botName) {
|
|
239
264
|
const rows = this.db.prepare(`
|
|
240
|
-
SELECT sender_type FROM messages
|
|
265
|
+
SELECT sender_type, sender_name FROM messages
|
|
241
266
|
WHERE chat_id = ?
|
|
242
267
|
ORDER BY timestamp DESC
|
|
243
|
-
LIMIT
|
|
268
|
+
LIMIT 50
|
|
244
269
|
`).all(chatId);
|
|
245
270
|
let count = 0;
|
|
246
271
|
for (const r of rows) {
|
|
247
|
-
if (r.sender_type === "
|
|
248
|
-
count++;
|
|
249
|
-
else
|
|
272
|
+
if (r.sender_type === "human")
|
|
250
273
|
break;
|
|
274
|
+
if (r.sender_type === "bot" && r.sender_name === botName)
|
|
275
|
+
count++;
|
|
251
276
|
}
|
|
252
277
|
return count;
|
|
253
278
|
}
|
|
254
279
|
// --- Chat info ---
|
|
255
280
|
upsertChatInfo(info) {
|
|
256
281
|
this.db.prepare(`
|
|
257
|
-
INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, updated_at)
|
|
258
|
-
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
259
284
|
ON CONFLICT (chat_id) DO UPDATE SET
|
|
260
285
|
chat_type = excluded.chat_type,
|
|
261
286
|
chat_name = excluded.chat_name,
|
|
@@ -264,8 +289,10 @@ export class MessageStore {
|
|
|
264
289
|
verbose = excluded.verbose,
|
|
265
290
|
free_discussion = excluded.free_discussion,
|
|
266
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,
|
|
267
294
|
updated_at = excluded.updated_at
|
|
268
|
-
`).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);
|
|
269
296
|
}
|
|
270
297
|
setFreeDiscussion(chatId, on) {
|
|
271
298
|
this.db.prepare(`UPDATE chat_info SET free_discussion = ? WHERE chat_id = ?`).run(on ? 1 : 0, chatId);
|
|
@@ -275,6 +302,21 @@ export class MessageStore {
|
|
|
275
302
|
UPDATE chat_info SET verbose = ? WHERE chat_id = ?
|
|
276
303
|
`).run(verbose ? 1 : 0, chatId);
|
|
277
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
|
+
}
|
|
278
320
|
setBotVerbose(botName, chatId, verbose) {
|
|
279
321
|
this.db.prepare(`
|
|
280
322
|
INSERT INTO bot_chat_settings (bot_name, chat_id, verbose, updated_at)
|
|
@@ -330,6 +372,8 @@ export class MessageStore {
|
|
|
330
372
|
ownerBot: row.owner_bot || '',
|
|
331
373
|
freeDiscussion: !!row.free_discussion,
|
|
332
374
|
verbose: !!row.verbose,
|
|
375
|
+
discuss: !!row.discuss,
|
|
376
|
+
discussMaxRounds: row.discuss_max_rounds || 3,
|
|
333
377
|
updatedAt: row.updated_at,
|
|
334
378
|
};
|
|
335
379
|
}
|
|
@@ -344,6 +388,8 @@ export class MessageStore {
|
|
|
344
388
|
ownerBot: r.owner_bot || '',
|
|
345
389
|
freeDiscussion: !!r.free_discussion,
|
|
346
390
|
verbose: !!r.verbose,
|
|
391
|
+
discuss: !!r.discuss,
|
|
392
|
+
discussMaxRounds: r.discuss_max_rounds || 3,
|
|
347
393
|
updatedAt: r.updated_at,
|
|
348
394
|
}));
|
|
349
395
|
}
|