openclaw-lark-multi-agent 1.4.11 → 1.4.12
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 +14 -6
- package/README.zh-CN.md +16 -8
- package/dist/discussion-manager.js +7 -0
- package/dist/feishu-bot.d.ts +0 -11
- package/dist/feishu-bot.js +105 -119
- package/dist/message-store.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -306,14 +306,22 @@ By default, a bot responds when:
|
|
|
306
306
|
|
|
307
307
|
- it is directly mentioned;
|
|
308
308
|
- `@all` / `@_all` appears in the message;
|
|
309
|
-
- this bot's Free mode is enabled
|
|
309
|
+
- a Chairman is set for this group, and this bot's Free mode is enabled for a plain human message with no mentions;
|
|
310
|
+
- a Chairman is set, no bot is Free, and this bot is the Chairman (even if muted).
|
|
310
311
|
|
|
311
312
|
Free mode is intentionally per-bot and conservative:
|
|
312
313
|
|
|
313
|
-
- Free mode lets a bot reply to ordinary human messages
|
|
314
|
+
- Free mode lets a bot reply to ordinary unmentioned human messages **only while this group has a Chairman**.
|
|
314
315
|
- If a human message mentions another bot, only that bot may respond. Other Free-mode bots stay silent.
|
|
315
316
|
- If a human message mentions a regular person, Free-mode bots stay silent.
|
|
316
|
-
- `@all` remains a broadcast trigger and may activate every eligible bot.
|
|
317
|
+
- `@all` remains a broadcast trigger and may activate every eligible bot. If the message also targets somebody else but not this bot, target exclusivity wins.
|
|
318
|
+
- These rules apply equally to one-bot and multi-bot groups, including local commands such as `/help` and `/free`: no coordinator or single-bot fallback may answer a mention addressed elsewhere.
|
|
319
|
+
- Mention identity requires a matching configured `app_id` or an already probed bot `open_id`. Known conflicting app/open IDs veto a match; neither wins by order. An unprobed open ID cannot establish identity (a matching app ID still can). Display names, including exact names and parenthesized suffixes, never establish identity. Message metadata never updates probed identities.
|
|
320
|
+
- Structured rich-post `at` nodes can supply an already known open ID when mention metadata is absent; they cannot override conflicting metadata. Missing/unresolved mentions or standalone `@name`/platform placeholders fail closed. Embedded `@` in email addresses or ordinary text is not a command or identity signal.
|
|
321
|
+
|
|
322
|
+
**Clearing Chairman is not mute.** `/chairman off` (also `clear` / `none`) atomically clears the canonical group Chairman and persists Discuss off, stops not-yet-started discussion turns, and blocks **new unmentioned group model triggers**, including Free bots and stale Discuss-on state after reopening the database. It does not abort an executing agent, change an existing queue task's identity, or suppress its eventual result. Explicit verified self/multi-target mentions, standalone `@all`, and private chats retain their normal behavior. Free/mute settings are not reset.
|
|
323
|
+
|
|
324
|
+
Local management commands are evaluated before the ordinary-chat gate. Use `/chairman <exact configured bot name>` without mentions, or a verified `/chairman @Bot`, to restore the group; Free/Chairman fallback then works again. `/discuss on` still requires a Chairman and must be enabled again after off. Bare group help/settings retain their existing single-coordinator routing; bare `/chairman` shows usage, not status (use `/status`).
|
|
317
325
|
|
|
318
326
|
Mention-only routing is supported. If a user first sends content and then sends only a bot mention, for example:
|
|
319
327
|
|
|
@@ -344,7 +352,7 @@ Persistent constraints (such as "do not call Feishu send tools directly" and the
|
|
|
344
352
|
|
|
345
353
|
`/discuss` is an explicit group-level multi-agent discussion scheduler. It is separate from Free mode:
|
|
346
354
|
|
|
347
|
-
- `/free` controls whether a single bot may answer plain human messages.
|
|
355
|
+
- `/free` controls whether a single bot may answer plain human messages in a group with a Chairman.
|
|
348
356
|
- `/discuss on` requires a chairman to be set first (`/chairman @Bot`). It lets one coordinator take over plain human messages and run all non-muted bots plus the chairman in barrier-style rounds. Free mode is ignored inside discussion: every bot that is not muted participates regardless of its Free setting.
|
|
349
357
|
- Targeted mentions still fall through to normal routing, so `@GPT hello` works even while discuss mode is enabled.
|
|
350
358
|
- Each participant receives the same round prompt and does not see other participants' replies from the current round until the next round.
|
|
@@ -373,11 +381,11 @@ Each group can have exactly one chairman, set with `/chairman @Bot`. Setting a n
|
|
|
373
381
|
|
|
374
382
|
```text
|
|
375
383
|
/chairman @Bot set the chairman
|
|
376
|
-
/chairman show
|
|
384
|
+
/chairman show usage (use /status for current chairman)
|
|
377
385
|
/chairman off clear the chairman
|
|
378
386
|
```
|
|
379
387
|
|
|
380
|
-
`/chairman` is a group-level command
|
|
388
|
+
`/chairman` is a group-level command: the verified target handles targeted setup, and the coordinator handles untargeted management. Multiple-target errors belong to the first verified target, never an unmentioned coordinator.
|
|
381
389
|
|
|
382
390
|
### `/locale`
|
|
383
391
|
|
package/README.zh-CN.md
CHANGED
|
@@ -236,7 +236,7 @@ openclaw-lark-multi-agent install-windows-service
|
|
|
236
236
|
- `/mute` — 开关当前 bot 在当前群聊里的 mute 模式
|
|
237
237
|
- `/mode` — 查看当前 bot 在当前聊天里的模式
|
|
238
238
|
- `/discuss on|off|status|stop|rounds N` — 控制群级多 bot 讨论模式(需先设置 Chairman;默认 10 轮)
|
|
239
|
-
- `/chairman [@Bot
|
|
239
|
+
- `/chairman [@Bot|配置名|off]` — 设置/切换/清除本群唯一 Chairman
|
|
240
240
|
- `/locale [zh|en]` — 设置/查看当前群语言
|
|
241
241
|
|
|
242
242
|
如果你想把 slash command 直接发给 OpenClaw,可以用双斜杠转义:
|
|
@@ -284,14 +284,22 @@ openclaw-lark-multi-agent install-windows-service
|
|
|
284
284
|
|
|
285
285
|
- 被直接 @;
|
|
286
286
|
- 消息里出现 `@all` / `@_all`;
|
|
287
|
-
-
|
|
287
|
+
- 本群已设置 Chairman、当前 bot 开启了 Free 模式,且这是一条无 @ 的普通人类消息;
|
|
288
|
+
- 本群已设置 Chairman、没有 Free bot,由 Chairman 兜底(即使 Chairman 是 mute)。
|
|
288
289
|
|
|
289
290
|
Free 模式是 per-bot 且保守的:
|
|
290
291
|
|
|
291
|
-
- Free
|
|
292
|
+
- Free 模式**仅在本群已设置 Chairman 时**允许 bot 回复无 @ 的普通人类消息。
|
|
292
293
|
- 如果人类消息 @ 了另一个 bot,只有被 @ 的 bot 可以回复,其他 Free 模式 bot 保持静默。
|
|
293
294
|
- 如果人类消息 @ 了普通人,Free 模式 bot 保持静默。
|
|
294
|
-
- `@all` 仍然是广播触发,可激活所有符合条件的 bot
|
|
295
|
+
- `@all` 仍然是广播触发,可激活所有符合条件的 bot;但同时定向 @ 其他人/bot 且没有 @ 当前 bot 时,定向排他优先。
|
|
296
|
+
- 单 bot 和多 bot 群、Free/Chairman/Discuss、`/help`、`/free` 等本地命令统一遵循定向排他规则;coordinator 或单 bot 不能兜底抢答。
|
|
297
|
+
- 点名身份仅由匹配的配置 `app_id` 或启动探测已确认的 bot `open_id` 确定。已知 app/open ID 冲突时拒绝匹配,不按顺序让其中一个优先;尚未探测的 open ID 不能独立确认身份,但匹配的 app ID 仍可确认。名字、同名人/外部机器人、括号后缀不构成身份依据,消息元数据不会覆盖已探测身份。
|
|
298
|
+
- 富文本结构化 `at` 节点只在缺少 mention 元数据时可按已知 open ID 确认目标,不能覆盖冲突元数据。缺元数据的 `@name` / 平台占位符及无法确认的目标保持安静;邮箱或普通文字内嵌的 `@` 不当作指令或身份信号。
|
|
299
|
+
|
|
300
|
+
**Chairman off 不是 mute,但禁止无 @ 的普通新消息触发模型。** `/chairman off`(也支持 `clear` / `none`)原子持久化清空群 Chairman 并关闭 Discuss,停止尚未开始的讨论轮次;即使 bot 原来 Free 或数据库遗留 Discuss on,无 Chairman 时普通新消息也不会触发。不会 abort 已执行 agent、改变旧队列任务身份或阻止其最终结果回传;明确 @ 自己/多目标含自己、单独 `@all`、私聊仍按原规则,Free/mute 设置不清除。
|
|
301
|
+
|
|
302
|
+
本地管理命令先于普通聊天门禁处理。可无 @ 发送 `/chairman 精确配置名`,或通过可靠点名 `/chairman @Bot` 恢复;之后 Free/Chairman 兜底恢复原行为。Discuss 需重新 `/discuss on` 且必须已有 Chairman。无目标管理命令保留原 coordinator 规则;裸 `/chairman` 返回用法,查看状态用 `/status`。
|
|
295
303
|
|
|
296
304
|
支持“纯 @ 触发”。例如用户先发:
|
|
297
305
|
|
|
@@ -323,8 +331,8 @@ catch-up 上下文是 bot 随当前消息一起拿到的群里未看到的发言
|
|
|
323
331
|
|
|
324
332
|
`/discuss` 是显式的群级多智能体讨论调度器,和 Free 模式分工不同:
|
|
325
333
|
|
|
326
|
-
- `/free` 控制单个 bot
|
|
327
|
-
- `/discuss on` 需先设置 Chairman(`/chairman @Bot`)。它让一个 coordinator 接管普通人类消息,并按 barrier-style round
|
|
334
|
+
- `/free` 控制单个 bot 是否可以在已设 Chairman 的群响应普通人类消息。
|
|
335
|
+
- `/discuss on` 需先设置 Chairman(`/chairman @Bot`)。它让一个 coordinator 接管普通人类消息,并按 barrier-style round 调度所有非 mute bot 加上 Chairman,忽略 Free 开关。
|
|
328
336
|
- 定向 @ 仍然走普通路由,所以 discuss 开启时 `@GPT hello` 仍会只触发 GPT。
|
|
329
337
|
- 每个参与 bot 在同一轮拿到相同 prompt,本轮内看不到其他 bot 的回复,下一轮才会看到上一轮结果。
|
|
330
338
|
- Chairman 每轮最后发言:先给出自己的观点,质疑薄弱点,调停分歧,并决定继续还是总结。
|
|
@@ -352,11 +360,11 @@ catch-up 上下文是 bot 随当前消息一起拿到的群里未看到的发言
|
|
|
352
360
|
|
|
353
361
|
```text
|
|
354
362
|
/chairman @Bot 设置 Chairman
|
|
355
|
-
/chairman
|
|
363
|
+
/chairman 显示设置用法;查看状态请用 /status
|
|
356
364
|
/chairman off 清除 Chairman
|
|
357
365
|
```
|
|
358
366
|
|
|
359
|
-
`/chairman`
|
|
367
|
+
`/chairman` 是群级命令:定向设置由目标 bot 处理,无目标管理由 coordinator 处理;多目标错误由第一个可靠目标回复,未被点名的 coordinator 不抢答。
|
|
360
368
|
|
|
361
369
|
### `/locale` 语言
|
|
362
370
|
|
|
@@ -124,10 +124,17 @@ export class DiscussionManager {
|
|
|
124
124
|
parts.push(`${errorNames.join("、")} ${t.error}`);
|
|
125
125
|
await sendSystemMessage(t.discussionRoundNotice(current.currentRound, current.maxRounds, parts.join(current.locale === "zh" ? ";" : "; "))).catch(() => { });
|
|
126
126
|
}
|
|
127
|
+
// stop/preemption may occur while a system notice is being delivered.
|
|
128
|
+
if (this.sessions.get(session.chatId)?.id !== sessionId || current.status !== "running")
|
|
129
|
+
return;
|
|
127
130
|
const mustFinish = allNoReply || current.currentRound >= current.maxRounds;
|
|
128
131
|
if (chairman) {
|
|
129
132
|
const chairmanPrompt = this.buildChairmanPrompt(current, replies, mustFinish);
|
|
130
133
|
const chairResult = await chairman.runDiscussionTurn(current.chatId, chairmanPrompt, { round: current.currentRound, maxRounds: current.maxRounds });
|
|
134
|
+
// An old in-flight Chairman may finish after stop or replacement. Its
|
|
135
|
+
// delivery belongs to that turn, but it must not mutate the new session.
|
|
136
|
+
if (this.sessions.get(session.chatId)?.id !== sessionId || current.status !== "running")
|
|
137
|
+
return;
|
|
131
138
|
const chairText = (chairResult.text || "").trim();
|
|
132
139
|
replies[chairman.name] = chairText;
|
|
133
140
|
const latest = current.completedRounds[current.completedRounds.length - 1];
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -128,7 +128,6 @@ export declare class FeishuBot {
|
|
|
128
128
|
private isAllMention;
|
|
129
129
|
private isAllMentionItem;
|
|
130
130
|
private mentionedBotName;
|
|
131
|
-
private escapeRegExp;
|
|
132
131
|
private resolveBotName;
|
|
133
132
|
private resolveHumanName;
|
|
134
133
|
private cleanMentions;
|
|
@@ -206,16 +205,6 @@ export declare class FeishuBot {
|
|
|
206
205
|
private dispatchPendingDeliveries;
|
|
207
206
|
private hasFreeModeBot;
|
|
208
207
|
private mentionedBotNames;
|
|
209
|
-
private mentionedBotNamesFromChairmanText;
|
|
210
|
-
/**
|
|
211
|
-
* Resolve the target bot name(s) for a /chairman command using every signal
|
|
212
|
-
* available, in priority order:
|
|
213
|
-
* 1. Feishu mention metadata (app_id / open_id / name).
|
|
214
|
-
* 2. Text fallback: "@Bot" or "Bot" written in the message body.
|
|
215
|
-
*
|
|
216
|
-
* This keeps /chairman working even when a Feishu client or bridge omits
|
|
217
|
-
* mention metadata. Returns a de-duplicated, order-preserving list.
|
|
218
|
-
*/
|
|
219
208
|
private resolveChairmanTargets;
|
|
220
209
|
private isDiscussionCoordinator;
|
|
221
210
|
private static markBotSeenInChat;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -414,15 +414,8 @@ export class FeishuBot {
|
|
|
414
414
|
const messageType = message.message_type;
|
|
415
415
|
const messageId = message.message_id;
|
|
416
416
|
const isBot = sender?.sender_type === "app";
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
for (const m of message.mentions) {
|
|
420
|
-
const bot = FeishuBot.allBots.get(m.id?.app_id || "");
|
|
421
|
-
if (bot && m.id?.open_id) {
|
|
422
|
-
bot.botOpenId = m.id.open_id;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
417
|
+
// Identity comes from the startup probe, never from message mentions:
|
|
418
|
+
// a conflicting app_id/open_id pair must not overwrite a known identity.
|
|
426
419
|
if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post" && messageType !== "share_doc")
|
|
427
420
|
return;
|
|
428
421
|
// --- Dedup: atomically claim this message for this bot before any await.
|
|
@@ -512,7 +505,7 @@ export class FeishuBot {
|
|
|
512
505
|
cleanText = await this.hydrateFeishuDocsInMessage(cleanText, content, messageId);
|
|
513
506
|
if (!cleanText.trim())
|
|
514
507
|
return;
|
|
515
|
-
const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "");
|
|
508
|
+
const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "", content);
|
|
516
509
|
// Commands may be prefixed by @all / @bot in group chats. Strip those
|
|
517
510
|
// leading routing mentions before deciding whether this is a bridge command
|
|
518
511
|
// or an escaped OpenClaw command.
|
|
@@ -551,6 +544,10 @@ export class FeishuBot {
|
|
|
551
544
|
});
|
|
552
545
|
if (insertedId < 0)
|
|
553
546
|
insertedId = this.store.getMessageId(messageId) || -1;
|
|
547
|
+
// Target exclusivity precedes ALL group command/coordinator, broadcast,
|
|
548
|
+
// single-bot, mute, Free and Discuss paths. Unknown targets are not self.
|
|
549
|
+
if (chatType !== "p2p" && routing.hasTargetedMention && !routing.isCurrentBotMentioned)
|
|
550
|
+
return;
|
|
554
551
|
// --- Commands: in p2p always respond; in group, check shouldRespond first ---
|
|
555
552
|
// Single slash commands are handled by the bridge. Double slash commands were
|
|
556
553
|
// already unescaped above and should pass through to OpenClaw instead.
|
|
@@ -589,35 +586,28 @@ export class FeishuBot {
|
|
|
589
586
|
}
|
|
590
587
|
}
|
|
591
588
|
else if (isChairmanCommand) {
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
// which can vary across Feishu clients/bridges. Untargeted/status,
|
|
595
|
-
// @all, and clear/off remain coordinator-owned group commands.
|
|
596
|
-
//
|
|
597
|
-
// Target resolution uses both Feishu mention metadata AND a text
|
|
598
|
-
// fallback (parsing "@Bot" / "Bot" from the raw message), so a
|
|
599
|
-
// /chairman that arrives without mention metadata still routes to
|
|
600
|
-
// the intended bot instead of silently failing.
|
|
589
|
+
// Verified mentions belong to the targeted bot. Bare configured
|
|
590
|
+
// names remain local management arguments, not mention identity.
|
|
601
591
|
const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
|
|
602
592
|
const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
|
|
603
593
|
const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
|
|
604
|
-
const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
|
|
594
|
+
const targets = routing.hasTargetedMention ? routing.targetedBotNames : this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
|
|
605
595
|
// A pure status query: no clear keyword, no mention metadata, and no
|
|
606
596
|
// textual argument at all. Anything else is a set/route attempt.
|
|
607
597
|
const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
|
|
608
598
|
if (isClearArg || isStatusArg) {
|
|
609
|
-
//
|
|
610
|
-
if (!this.isDiscussionCoordinator())
|
|
599
|
+
// One eligible owner: first verified target, or bare coordinator.
|
|
600
|
+
if (routing.hasTargetedMention ? routing.targetedBotNames[0] !== this.config.name : !this.isDiscussionCoordinator())
|
|
611
601
|
return;
|
|
612
602
|
}
|
|
613
603
|
else if (targets.length >= 1) {
|
|
614
604
|
// Targets resolved: only the (single) targeted bot acts. If
|
|
615
|
-
// multiple distinct targets resolved, the
|
|
616
|
-
// the "only one chairman" error once.
|
|
605
|
+
// multiple distinct targets resolved, the first target handles
|
|
606
|
+
// the "only one chairman" error once (never an unmentioned bot).
|
|
617
607
|
const uniqueTargets = Array.from(new Set(targets));
|
|
618
608
|
const targetedSelf = uniqueTargets.includes(this.config.name);
|
|
619
609
|
if (uniqueTargets.length > 1) {
|
|
620
|
-
if (
|
|
610
|
+
if (uniqueTargets[0] !== this.config.name)
|
|
621
611
|
return;
|
|
622
612
|
}
|
|
623
613
|
else if (!targetedSelf) {
|
|
@@ -800,6 +790,7 @@ export class FeishuBot {
|
|
|
800
790
|
if (next === "free") {
|
|
801
791
|
await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式
|
|
802
792
|
不需要 @ 也可以回复普通人类消息;如果消息明确 @ 了其他 bot 或普通人,我不会抢答。
|
|
793
|
+
需先设置本群 Chairman,Free 才会回答无 @ 的普通消息。
|
|
803
794
|
如需多轮自动讨论,请使用群级命令 /discuss on。`);
|
|
804
795
|
}
|
|
805
796
|
else {
|
|
@@ -845,7 +836,7 @@ export class FeishuBot {
|
|
|
845
836
|
return;
|
|
846
837
|
}
|
|
847
838
|
if (commandName === "/chairman") {
|
|
848
|
-
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
|
|
839
|
+
await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText, routing.hasTargetedMention ? routing.targetedBotNames : undefined);
|
|
849
840
|
markCommandSynced();
|
|
850
841
|
return;
|
|
851
842
|
}
|
|
@@ -855,11 +846,16 @@ export class FeishuBot {
|
|
|
855
846
|
return;
|
|
856
847
|
}
|
|
857
848
|
}
|
|
849
|
+
// Local management commands above can restore Chairman. Only NEW
|
|
850
|
+
// unmentioned human triggers are gated; existing queue/delivery owners stay.
|
|
851
|
+
if (chatType !== "p2p" && !isBot && !routing.hasTargetedMention && !routing.isAllMention
|
|
852
|
+
&& !this.store.getChairmanBot(chatId))
|
|
853
|
+
return;
|
|
858
854
|
// --- Discuss mode: group-level multi-bot round scheduler. It takes over
|
|
859
855
|
// plain human messages so normal Free mode does not duplicate Round 1.
|
|
860
856
|
// Targeted mentions must fall through to normal routing so @GPT still
|
|
861
857
|
// works while discuss mode is enabled.
|
|
862
|
-
if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
|
|
858
|
+
if (chatType !== "p2p" && !isBot && this.store.getChairmanBot(chatId) && this.store.getChatInfo(chatId)?.discuss) {
|
|
863
859
|
// Discuss mode owns ordinary and @all human messages. Explicit @bot/@human
|
|
864
860
|
// falls through to normal targeted routing.
|
|
865
861
|
if (!routing.hasTargetedMention) {
|
|
@@ -1702,10 +1698,8 @@ export class FeishuBot {
|
|
|
1702
1698
|
return !isBot;
|
|
1703
1699
|
if (isBot)
|
|
1704
1700
|
return false;
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
return true;
|
|
1708
|
-
return this.isMentioned(mentions);
|
|
1701
|
+
const routing = this.getRoutingIntent(chatType, message, rawText);
|
|
1702
|
+
return routing.hasTargetedMention ? routing.isCurrentBotMentioned : routing.isAllMention;
|
|
1709
1703
|
}
|
|
1710
1704
|
shouldRespond(chatType, _message, isBot, chatId, routing) {
|
|
1711
1705
|
if (chatType === "p2p")
|
|
@@ -1713,17 +1707,16 @@ export class FeishuBot {
|
|
|
1713
1707
|
// Bot messages: only respond if this bot is explicitly mentioned.
|
|
1714
1708
|
if (isBot)
|
|
1715
1709
|
return routing.isCurrentBotMentioned;
|
|
1716
|
-
// @all is an explicit broadcast to all non-muted bots.
|
|
1717
|
-
if (routing.isAllMention)
|
|
1718
|
-
return true;
|
|
1719
1710
|
// Explicit targeted mentions are exclusive. If this bot is not one of the
|
|
1720
1711
|
// mentioned bots, free/chairman must not steal the turn.
|
|
1721
1712
|
if (routing.hasTargetedMention)
|
|
1722
1713
|
return routing.isCurrentBotMentioned;
|
|
1723
|
-
//
|
|
1724
|
-
if (routing.
|
|
1725
|
-
return
|
|
1714
|
+
// @all alone is an explicit broadcast, not an unmentioned message.
|
|
1715
|
+
if (routing.isAllMention)
|
|
1716
|
+
return true;
|
|
1726
1717
|
if (chatId) {
|
|
1718
|
+
if (!this.store.getChairmanBot(chatId))
|
|
1719
|
+
return false;
|
|
1727
1720
|
if (this.store.getBotMode(this.config.name, chatId) === "free")
|
|
1728
1721
|
return true;
|
|
1729
1722
|
// Chairman fallback: if nobody is in free mode, the unique chairman
|
|
@@ -1734,12 +1727,46 @@ export class FeishuBot {
|
|
|
1734
1727
|
}
|
|
1735
1728
|
return false;
|
|
1736
1729
|
}
|
|
1737
|
-
getRoutingIntent(chatType, message, rawText) {
|
|
1738
|
-
const
|
|
1730
|
+
getRoutingIntent(chatType, message, rawText = "", content) {
|
|
1731
|
+
const metadata = message.mentions || [];
|
|
1732
|
+
const mentions = [...metadata];
|
|
1733
|
+
const mentionTexts = [rawText];
|
|
1734
|
+
if (message.message_type === "post") {
|
|
1735
|
+
// Only structured post nodes carry mention semantics; do not interpret
|
|
1736
|
+
// arbitrary JSON strings as identity or broaden attachment handling.
|
|
1737
|
+
const posts = [content, ...Object.values(content || {})];
|
|
1738
|
+
for (const post of posts) {
|
|
1739
|
+
if (!Array.isArray(post?.content))
|
|
1740
|
+
continue;
|
|
1741
|
+
if (typeof post.title === "string")
|
|
1742
|
+
mentionTexts.push(post.title);
|
|
1743
|
+
for (const paragraph of post.content) {
|
|
1744
|
+
if (!Array.isArray(paragraph))
|
|
1745
|
+
continue;
|
|
1746
|
+
for (const node of paragraph) {
|
|
1747
|
+
if (node?.tag === "text" && typeof node.text === "string")
|
|
1748
|
+
mentionTexts.push(node.text);
|
|
1749
|
+
if (node?.tag !== "at")
|
|
1750
|
+
continue;
|
|
1751
|
+
// Rich-post user_id is matched only to an already probed open_id.
|
|
1752
|
+
mentions.push({ id: { open_id: node.user_id }, name: node.user_name });
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1739
1757
|
const isAllMention = chatType !== "p2p" && this.isAllMention(rawText, mentions);
|
|
1740
|
-
|
|
1758
|
+
// Structured nodes fill missing metadata, not override conflicting metadata.
|
|
1759
|
+
const targetedBotNames = this.mentionedBotNames(metadata.length ? metadata : mentions);
|
|
1760
|
+
// Missing metadata must not turn a platform placeholder or standalone @name
|
|
1761
|
+
// into an unmentioned Free/Chairman trigger. This grants NO identity. Emails
|
|
1762
|
+
// and embedded @ characters remain ordinary text, not commands or mentions.
|
|
1763
|
+
const unresolvedTextMention = mentionTexts.some(text => {
|
|
1764
|
+
const withoutAllPlaceholders = text.replace(/@_user_\d+/g, key => metadata.some(m => m.key === key && this.isAllMentionItem(m)) ? " " : key);
|
|
1765
|
+
return /@_user_\d+/.test(withoutAllPlaceholders)
|
|
1766
|
+
|| /(^|\s)@(?!(?:_all|all|所有人)(?=\s|$))[^\s@]+/iu.test(withoutAllPlaceholders);
|
|
1767
|
+
});
|
|
1741
1768
|
const hasHumanMention = mentions.some((m) => !this.isAllMentionItem(m) && !this.mentionedBotName(m));
|
|
1742
|
-
const hasTargetedMention = targetedBotNames.length > 0 || hasHumanMention;
|
|
1769
|
+
const hasTargetedMention = targetedBotNames.length > 0 || hasHumanMention || unresolvedTextMention;
|
|
1743
1770
|
return {
|
|
1744
1771
|
isAllMention,
|
|
1745
1772
|
targetedBotNames,
|
|
@@ -1757,44 +1784,32 @@ export class FeishuBot {
|
|
|
1757
1784
|
return mentions.some((m) => this.isAllMentionItem(m));
|
|
1758
1785
|
}
|
|
1759
1786
|
isAllMentionItem(mention) {
|
|
1760
|
-
|
|
1787
|
+
const id = mention.id || {};
|
|
1788
|
+
// A display name cannot override a concrete non-broadcast identity.
|
|
1789
|
+
if (id.app_id || (id.open_id && id.open_id !== "all") || (id.user_id && id.user_id !== "all"))
|
|
1790
|
+
return false;
|
|
1791
|
+
return mention.key === "all" || mention.key === "@_all" || id.user_id === "all" || id.open_id === "all";
|
|
1761
1792
|
}
|
|
1762
1793
|
mentionedBotName(mention) {
|
|
1763
1794
|
if (this.isAllMentionItem(mention))
|
|
1764
1795
|
return null;
|
|
1765
|
-
const
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
`万万(${botName})`,
|
|
1783
|
-
];
|
|
1784
|
-
if (exactNames.includes(raw))
|
|
1785
|
-
return bot.config.name;
|
|
1786
|
-
// Generic display-name fallback: many deployments name bots like
|
|
1787
|
-
// "光子 (Claude)" or "万万(GPT)". Match only when the parenthesized
|
|
1788
|
-
// suffix is exactly the configured bot name, avoiding loose substring
|
|
1789
|
-
// matches that caused shared-prefix bots to steal each other's mentions.
|
|
1790
|
-
if (new RegExp(`^[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw))
|
|
1791
|
-
return bot.config.name;
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
return null;
|
|
1795
|
-
}
|
|
1796
|
-
escapeRegExp(value) {
|
|
1797
|
-
return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
1796
|
+
const id = mention.id || {};
|
|
1797
|
+
const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
|
|
1798
|
+
const matches = candidates.filter((bot) => {
|
|
1799
|
+
// Known conflicting app/open credentials veto, regardless of order. An
|
|
1800
|
+
// unprobed open_id cannot establish identity, but a matching app_id can.
|
|
1801
|
+
if (id.app_id && id.app_id !== bot.config.appId)
|
|
1802
|
+
return false;
|
|
1803
|
+
if (id.open_id && bot.botOpenId && id.open_id !== bot.botOpenId)
|
|
1804
|
+
return false;
|
|
1805
|
+
if (id.open_id && candidates.some((other) => other !== bot && other.botOpenId === id.open_id))
|
|
1806
|
+
return false;
|
|
1807
|
+
return (id.app_id && id.app_id === bot.config.appId)
|
|
1808
|
+
|| (id.open_id && bot.botOpenId && id.open_id === bot.botOpenId);
|
|
1809
|
+
});
|
|
1810
|
+
// Names (including exact/parenthesized suffixes) and unverified user/union
|
|
1811
|
+
// IDs never establish bot identity. Ambiguous matches fail closed.
|
|
1812
|
+
return matches.length === 1 ? matches[0].config.name : null;
|
|
1798
1813
|
}
|
|
1799
1814
|
resolveBotName(sender) {
|
|
1800
1815
|
const openId = sender?.sender_id?.open_id;
|
|
@@ -2531,48 +2546,17 @@ export class FeishuBot {
|
|
|
2531
2546
|
.filter((name) => Boolean(name));
|
|
2532
2547
|
return Array.from(new Set(names));
|
|
2533
2548
|
}
|
|
2534
|
-
mentionedBotNamesFromChairmanText(...texts) {
|
|
2535
|
-
const normalizedTexts = texts
|
|
2536
|
-
.filter(Boolean)
|
|
2537
|
-
.map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
|
|
2538
|
-
.filter(Boolean)
|
|
2539
|
-
.map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
|
|
2540
|
-
if (normalizedTexts.length === 0)
|
|
2541
|
-
return [];
|
|
2542
|
-
const names = [];
|
|
2543
|
-
const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
|
|
2544
|
-
for (const raw of normalizedTexts) {
|
|
2545
|
-
for (const bot of candidates) {
|
|
2546
|
-
const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
|
|
2547
|
-
const displayNames = [
|
|
2548
|
-
botName,
|
|
2549
|
-
`@${botName}`,
|
|
2550
|
-
`万万(${botName})`,
|
|
2551
|
-
`@万万(${botName})`,
|
|
2552
|
-
`万万(${botName})`,
|
|
2553
|
-
`@万万(${botName})`,
|
|
2554
|
-
];
|
|
2555
|
-
if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
|
|
2556
|
-
names.push(bot.config.name);
|
|
2557
|
-
}
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2560
|
-
return Array.from(new Set(names));
|
|
2561
|
-
}
|
|
2562
|
-
/**
|
|
2563
|
-
* Resolve the target bot name(s) for a /chairman command using every signal
|
|
2564
|
-
* available, in priority order:
|
|
2565
|
-
* 1. Feishu mention metadata (app_id / open_id / name).
|
|
2566
|
-
* 2. Text fallback: "@Bot" or "Bot" written in the message body.
|
|
2567
|
-
*
|
|
2568
|
-
* This keeps /chairman working even when a Feishu client or bridge omits
|
|
2569
|
-
* mention metadata. Returns a de-duplicated, order-preserving list.
|
|
2570
|
-
*/
|
|
2571
2549
|
resolveChairmanTargets(mentions, text, rawText = "") {
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2550
|
+
if (mentions.some((m) => !this.isAllMentionItem(m)))
|
|
2551
|
+
return this.mentionedBotNames(mentions);
|
|
2552
|
+
// Only a bare, exact configured name is a local management argument. Never
|
|
2553
|
+
// reinterpret an unresolved @name/placeholder as authority to target a bot.
|
|
2554
|
+
const argument = text.replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
|
|
2555
|
+
if (/(^|\s)@(?!(?:_all|all|所有人)(?=\s|$))[^\s@]+/iu.test(rawText) || argument.includes("@"))
|
|
2556
|
+
return [];
|
|
2557
|
+
return [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)]
|
|
2558
|
+
.filter((bot) => bot.config.name.toLowerCase() === argument)
|
|
2559
|
+
.map((bot) => bot.config.name);
|
|
2576
2560
|
}
|
|
2577
2561
|
isDiscussionCoordinator() {
|
|
2578
2562
|
const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
|
|
@@ -3480,7 +3464,7 @@ export class FeishuBot {
|
|
|
3480
3464
|
active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
|
|
3481
3465
|
].join("\n"));
|
|
3482
3466
|
}
|
|
3483
|
-
async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
|
|
3467
|
+
async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "", routedTargets) {
|
|
3484
3468
|
if (chatType === "p2p") {
|
|
3485
3469
|
await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
|
|
3486
3470
|
return;
|
|
@@ -3490,10 +3474,12 @@ export class FeishuBot {
|
|
|
3490
3474
|
if (["off", "clear", "none"].includes(action)) {
|
|
3491
3475
|
const previous = this.store.getChairmanBot(chatId);
|
|
3492
3476
|
this.store.clearChairmanBot(chatId);
|
|
3493
|
-
|
|
3477
|
+
discussionManager.stop(chatId); // Stop future turns; never abort in-flight agents.
|
|
3478
|
+
await this.replyMessage(messageId, (previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman")
|
|
3479
|
+
+ "\nDiscuss 已关闭;无 @ 的普通新消息不再触发回复(含 Free)。这不是 mute,明确 @ 仍按原规则处理;已执行任务不被中止。");
|
|
3494
3480
|
return;
|
|
3495
3481
|
}
|
|
3496
|
-
const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
|
|
3482
|
+
const resolvedBotNames = routedTargets ?? this.resolveChairmanTargets(mentions, text, rawText);
|
|
3497
3483
|
if (resolvedBotNames.length === 0) {
|
|
3498
3484
|
// /chairman is intentionally not a status query. Status lives in /status;
|
|
3499
3485
|
// this command is only for switching or clearing the Chairman. Keeping the
|
package/dist/message-store.js
CHANGED
|
@@ -891,7 +891,12 @@ export class MessageStore {
|
|
|
891
891
|
`).run(chatId, botName, Date.now());
|
|
892
892
|
}
|
|
893
893
|
clearChairmanBot(chatId) {
|
|
894
|
-
|
|
894
|
+
// Persist both fields atomically so reopening cannot revive Discuss after off.
|
|
895
|
+
this.db.prepare(`
|
|
896
|
+
INSERT INTO chat_info (chat_id, chat_type, chat_name, chairman_bot, discuss, updated_at)
|
|
897
|
+
VALUES (?, 'group', '', '', 0, ?)
|
|
898
|
+
ON CONFLICT (chat_id) DO UPDATE SET chairman_bot = '', discuss = 0, updated_at = excluded.updated_at
|
|
899
|
+
`).run(chatId, Date.now());
|
|
895
900
|
}
|
|
896
901
|
getChairmanBot(chatId) {
|
|
897
902
|
const row = this.db.prepare(`SELECT chairman_bot FROM chat_info WHERE chat_id = ?`).get(chatId);
|