botmux 2.9.0 → 2.9.2
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.en.md +140 -76
- package/README.md +134 -75
- package/dist/adapters/backend/pty-backend.d.ts +6 -0
- package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
- package/dist/adapters/backend/pty-backend.js +10 -0
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
- package/dist/adapters/backend/session-backend-selector.js +26 -0
- package/dist/adapters/backend/session-backend-selector.js.map +1 -0
- package/dist/adapters/backend/tmux-backend.d.ts +80 -3
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +301 -49
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +100 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
- package/dist/adapters/backend/tmux-pipe-backend.js +473 -0
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
- package/dist/adapters/cli/aiden.d.ts.map +1 -1
- package/dist/adapters/cli/aiden.js +5 -0
- package/dist/adapters/cli/aiden.js.map +1 -1
- package/dist/adapters/cli/claude-code.d.ts +40 -1
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +470 -49
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/coco.d.ts.map +1 -1
- package/dist/adapters/cli/coco.js +191 -9
- package/dist/adapters/cli/coco.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +94 -17
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/shared-hints.d.ts +2 -2
- package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
- package/dist/adapters/cli/shared-hints.js +7 -5
- package/dist/adapters/cli/shared-hints.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +38 -1
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/autostart.d.ts +14 -0
- package/dist/autostart.d.ts.map +1 -0
- package/dist/autostart.js +357 -0
- package/dist/autostart.js.map +1 -0
- package/dist/bot-registry.d.ts +29 -3
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +91 -12
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/arg-utils.d.ts +11 -0
- package/dist/cli/arg-utils.d.ts.map +1 -0
- package/dist/cli/arg-utils.js +25 -0
- package/dist/cli/arg-utils.js.map +1 -0
- package/dist/cli/create-group-resolver.d.ts +32 -0
- package/dist/cli/create-group-resolver.d.ts.map +1 -0
- package/dist/cli/create-group-resolver.js +70 -0
- package/dist/cli/create-group-resolver.js.map +1 -0
- package/dist/cli/quoted-render.d.ts +30 -0
- package/dist/cli/quoted-render.d.ts.map +1 -0
- package/dist/cli/quoted-render.js +29 -0
- package/dist/cli/quoted-render.js.map +1 -0
- package/dist/cli.js +916 -272
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -8
- package/dist/config.js.map +1 -1
- package/dist/core/command-handler.d.ts +43 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +167 -64
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-events.d.ts +57 -0
- package/dist/core/dashboard-events.d.ts.map +1 -0
- package/dist/core/dashboard-events.js +23 -0
- package/dist/core/dashboard-events.js.map +1 -0
- package/dist/core/dashboard-ipc-server.d.ts +43 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -0
- package/dist/core/dashboard-ipc-server.js +481 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -0
- package/dist/core/dashboard-locate.d.ts +20 -0
- package/dist/core/dashboard-locate.d.ts.map +1 -0
- package/dist/core/dashboard-locate.js +26 -0
- package/dist/core/dashboard-locate.js.map +1 -0
- package/dist/core/dashboard-rows.d.ts +31 -0
- package/dist/core/dashboard-rows.d.ts.map +1 -0
- package/dist/core/dashboard-rows.js +65 -0
- package/dist/core/dashboard-rows.js.map +1 -0
- package/dist/core/inherit-peer.d.ts +14 -0
- package/dist/core/inherit-peer.d.ts.map +1 -0
- package/dist/core/inherit-peer.js +32 -0
- package/dist/core/inherit-peer.js.map +1 -0
- package/dist/core/scheduler.d.ts +24 -0
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +93 -2
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/session-activity.d.ts +3 -0
- package/dist/core/session-activity.d.ts.map +1 -0
- package/dist/core/session-activity.js +20 -0
- package/dist/core/session-activity.js.map +1 -0
- package/dist/core/session-discovery.d.ts +39 -0
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +114 -21
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts +72 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +396 -106
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/types.d.ts +27 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +14 -3
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +72 -3
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +459 -38
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +601 -309
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +41 -0
- package/dist/dashboard/aggregator.d.ts.map +1 -0
- package/dist/dashboard/aggregator.js +125 -0
- package/dist/dashboard/aggregator.js.map +1 -0
- package/dist/dashboard/auth.d.ts +23 -0
- package/dist/dashboard/auth.d.ts.map +1 -0
- package/dist/dashboard/auth.js +66 -0
- package/dist/dashboard/auth.js.map +1 -0
- package/dist/dashboard/operator-selector.d.ts +20 -0
- package/dist/dashboard/operator-selector.d.ts.map +1 -0
- package/dist/dashboard/operator-selector.js +39 -0
- package/dist/dashboard/operator-selector.js.map +1 -0
- package/dist/dashboard/registry.d.ts +35 -0
- package/dist/dashboard/registry.d.ts.map +1 -0
- package/dist/dashboard/registry.js +74 -0
- package/dist/dashboard/registry.js.map +1 -0
- package/dist/dashboard/web/app.d.ts +2 -0
- package/dist/dashboard/web/app.d.ts.map +1 -0
- package/dist/dashboard/web/app.js +45 -0
- package/dist/dashboard/web/app.js.map +1 -0
- package/dist/dashboard/web/bot-defaults.d.ts +2 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
- package/dist/dashboard/web/bot-defaults.js +201 -0
- package/dist/dashboard/web/bot-defaults.js.map +1 -0
- package/dist/dashboard/web/groups.d.ts +16 -0
- package/dist/dashboard/web/groups.d.ts.map +1 -0
- package/dist/dashboard/web/groups.js +584 -0
- package/dist/dashboard/web/groups.js.map +1 -0
- package/dist/dashboard/web/schedules.d.ts +2 -0
- package/dist/dashboard/web/schedules.d.ts.map +1 -0
- package/dist/dashboard/web/schedules.js +105 -0
- package/dist/dashboard/web/schedules.js.map +1 -0
- package/dist/dashboard/web/sessions.d.ts +2 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -0
- package/dist/dashboard/web/sessions.js +374 -0
- package/dist/dashboard/web/sessions.js.map +1 -0
- package/dist/dashboard/web/store.d.ts +23 -0
- package/dist/dashboard/web/store.d.ts.map +1 -0
- package/dist/dashboard/web/store.js +82 -0
- package/dist/dashboard/web/store.js.map +1 -0
- package/dist/dashboard-web/app.js +263 -0
- package/dist/dashboard-web/index.html +23 -0
- package/dist/dashboard-web/style.css +93 -0
- package/dist/dashboard.d.ts +2 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +639 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/im/lark/card-builder.d.ts +18 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +70 -9
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +123 -109
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +36 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +119 -13
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +92 -8
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +410 -89
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/forwarded-renderer.d.ts +23 -0
- package/dist/im/lark/forwarded-renderer.d.ts.map +1 -0
- package/dist/im/lark/forwarded-renderer.js +105 -0
- package/dist/im/lark/forwarded-renderer.js.map +1 -0
- package/dist/im/lark/md-card.d.ts +73 -0
- package/dist/im/lark/md-card.d.ts.map +1 -0
- package/dist/im/lark/md-card.js +332 -0
- package/dist/im/lark/md-card.js.map +1 -0
- package/dist/im/lark/merge-forward.d.ts +32 -0
- package/dist/im/lark/merge-forward.d.ts.map +1 -0
- package/dist/im/lark/merge-forward.js +110 -0
- package/dist/im/lark/merge-forward.js.map +1 -0
- package/dist/im/lark/message-parser.d.ts +9 -3
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +48 -13
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/quote-hint.d.ts +18 -0
- package/dist/im/lark/quote-hint.d.ts.map +1 -0
- package/dist/im/lark/quote-hint.js +23 -0
- package/dist/im/lark/quote-hint.js.map +1 -0
- package/dist/services/bridge-fallback-gate.d.ts +42 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
- package/dist/services/bridge-fallback-gate.js +12 -0
- package/dist/services/bridge-fallback-gate.js.map +1 -0
- package/dist/services/bridge-rotation-policy.d.ts +139 -0
- package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
- package/dist/services/bridge-rotation-policy.js +125 -0
- package/dist/services/bridge-rotation-policy.js.map +1 -0
- package/dist/services/bridge-turn-queue.d.ts +154 -0
- package/dist/services/bridge-turn-queue.d.ts.map +1 -0
- package/dist/services/bridge-turn-queue.js +316 -0
- package/dist/services/bridge-turn-queue.js.map +1 -0
- package/dist/services/chat-first-seen-store.d.ts +27 -0
- package/dist/services/chat-first-seen-store.d.ts.map +1 -0
- package/dist/services/chat-first-seen-store.js +114 -0
- package/dist/services/chat-first-seen-store.js.map +1 -0
- package/dist/services/claude-transcript.d.ts +268 -0
- package/dist/services/claude-transcript.d.ts.map +1 -0
- package/dist/services/claude-transcript.js +798 -0
- package/dist/services/claude-transcript.js.map +1 -0
- package/dist/services/coco-transcript.d.ts +35 -0
- package/dist/services/coco-transcript.d.ts.map +1 -0
- package/dist/services/coco-transcript.js +192 -0
- package/dist/services/coco-transcript.js.map +1 -0
- package/dist/services/codex-bridge-queue.d.ts +56 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -0
- package/dist/services/codex-bridge-queue.js +150 -0
- package/dist/services/codex-bridge-queue.js.map +1 -0
- package/dist/services/codex-transcript.d.ts +84 -0
- package/dist/services/codex-transcript.d.ts.map +1 -0
- package/dist/services/codex-transcript.js +298 -0
- package/dist/services/codex-transcript.js.map +1 -0
- package/dist/services/group-creator.d.ts +23 -0
- package/dist/services/group-creator.d.ts.map +1 -0
- package/dist/services/group-creator.js +75 -0
- package/dist/services/group-creator.js.map +1 -0
- package/dist/services/groups-store.d.ts +98 -0
- package/dist/services/groups-store.d.ts.map +1 -0
- package/dist/services/groups-store.js +213 -0
- package/dist/services/groups-store.js.map +1 -0
- package/dist/services/oncall-store.d.ts +80 -8
- package/dist/services/oncall-store.d.ts.map +1 -1
- package/dist/services/oncall-store.js +265 -55
- package/dist/services/oncall-store.js.map +1 -1
- package/dist/services/project-scanner.d.ts +1 -2
- package/dist/services/project-scanner.d.ts.map +1 -1
- package/dist/services/project-scanner.js +118 -68
- package/dist/services/project-scanner.js.map +1 -1
- package/dist/services/schedule-store.d.ts +5 -0
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +77 -1
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/services/session-store.d.ts +22 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +62 -4
- package/dist/services/session-store.js.map +1 -1
- package/dist/setup/bots-store.d.ts +3 -0
- package/dist/setup/bots-store.d.ts.map +1 -0
- package/dist/setup/bots-store.js +24 -0
- package/dist/setup/bots-store.js.map +1 -0
- package/dist/setup/detect-platform.d.ts +14 -0
- package/dist/setup/detect-platform.d.ts.map +1 -0
- package/dist/setup/detect-platform.js +139 -0
- package/dist/setup/detect-platform.js.map +1 -0
- package/dist/setup/ensure-fonts.d.ts +13 -0
- package/dist/setup/ensure-fonts.d.ts.map +1 -0
- package/dist/setup/ensure-fonts.js +225 -0
- package/dist/setup/ensure-fonts.js.map +1 -0
- package/dist/setup/ensure-tmux.d.ts +60 -0
- package/dist/setup/ensure-tmux.d.ts.map +1 -0
- package/dist/setup/ensure-tmux.js +236 -0
- package/dist/setup/ensure-tmux.js.map +1 -0
- package/dist/setup/index.d.ts +9 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +46 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/setup/lark-scopes.json +301 -0
- package/dist/setup/register-app.d.ts +52 -0
- package/dist/setup/register-app.d.ts.map +1 -0
- package/dist/setup/register-app.js +91 -0
- package/dist/setup/register-app.js.map +1 -0
- package/dist/setup/verify-permissions.d.ts +115 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -0
- package/dist/setup/verify-permissions.js +207 -0
- package/dist/setup/verify-permissions.js.map +1 -0
- package/dist/skills/definitions.d.ts +4 -0
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +133 -19
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/installer.d.ts +3 -1
- package/dist/skills/installer.d.ts.map +1 -1
- package/dist/skills/installer.js +18 -3
- package/dist/skills/installer.js.map +1 -1
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +6 -0
- package/dist/utils/bot-routing.d.ts.map +1 -0
- package/dist/utils/bot-routing.js +50 -0
- package/dist/utils/bot-routing.js.map +1 -0
- package/dist/utils/file-lock.d.ts +2 -0
- package/dist/utils/file-lock.d.ts.map +1 -0
- package/dist/utils/file-lock.js +114 -0
- package/dist/utils/file-lock.js.map +1 -0
- package/dist/utils/font-installer.js +1 -1
- package/dist/utils/font-installer.js.map +1 -1
- package/dist/utils/idle-detector.d.ts +6 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +25 -4
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +60 -8
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/render-dimensions.d.ts +48 -0
- package/dist/utils/render-dimensions.d.ts.map +1 -0
- package/dist/utils/render-dimensions.js +55 -0
- package/dist/utils/render-dimensions.js.map +1 -0
- package/dist/utils/screen-analyzer.d.ts.map +1 -1
- package/dist/utils/screen-analyzer.js +24 -0
- package/dist/utils/screen-analyzer.js.map +1 -1
- package/dist/utils/screenshot-renderer.d.ts.map +1 -1
- package/dist/utils/screenshot-renderer.js +67 -23
- package/dist/utils/screenshot-renderer.js.map +1 -1
- package/dist/utils/terminal-renderer.d.ts +16 -0
- package/dist/utils/terminal-renderer.d.ts.map +1 -1
- package/dist/utils/terminal-renderer.js +40 -23
- package/dist/utils/terminal-renderer.js.map +1 -1
- package/dist/utils/transient-snapshot.d.ts +28 -0
- package/dist/utils/transient-snapshot.d.ts.map +1 -0
- package/dist/utils/transient-snapshot.js +96 -0
- package/dist/utils/transient-snapshot.js.map +1 -0
- package/dist/worker.js +2220 -83
- package/dist/worker.js.map +1 -1
- package/package.json +12 -5
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
import * as Lark from '@larksuiteoapi/node-sdk';
|
|
7
7
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import { getBot, getAllBots,
|
|
9
|
+
import { getBot, getAllBots, isChatOncallBoundForAnyBot } from '../../bot-registry.js';
|
|
10
10
|
import { config } from '../../config.js';
|
|
11
|
-
import { getChatInfo,
|
|
11
|
+
import { getChatInfo, getChatMode, replyMessage, sendUserMessage } from './client.js';
|
|
12
12
|
import { logger } from '../../utils/logger.js';
|
|
13
|
+
import { parseForceTopicInvocation } from '../../core/command-handler.js';
|
|
14
|
+
import { stripLeadingMentions } from './message-parser.js';
|
|
13
15
|
// ─── Bot identity ─────────────────────────────────────────────────────────
|
|
14
16
|
/** Set the bot's open_id. Callers should also call writeBotInfoFile() to persist. */
|
|
15
17
|
export function setBotOpenId(larkAppId, id) {
|
|
16
18
|
getBot(larkAppId).botOpenId = id;
|
|
17
19
|
}
|
|
18
|
-
/** Persist bot registry info to disk for
|
|
20
|
+
/** Persist bot registry info to disk for agent-facing CLI subcommands to read.
|
|
19
21
|
* Merges current process's bot(s) into the existing file so that
|
|
20
22
|
* multiple daemon processes (one per bot) don't overwrite each other. */
|
|
21
23
|
export function writeBotInfoFile(dataDir) {
|
|
@@ -82,40 +84,143 @@ export async function probeBotOpenId(larkAppId) {
|
|
|
82
84
|
throw new Error('No open_id in bot info response');
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
|
-
// ───
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
// ─── Required-scope check ───────────────────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// Bot-to-bot @mention 投递依赖 "获取群组中其他机器人和用户@当前机器人的消息"
|
|
90
|
+
// 权限(scope: im:message.group_at_msg.include_bot:readonly)。该权限关闭
|
|
91
|
+
// 后飞书不会把跨 bot 的事件推到 WSClient,botmux 的 handleThreadReply 收
|
|
92
|
+
// 不到,看上去就是"另一个 bot @ 我没反应"——而 botmux 已经把本地 signal-file
|
|
93
|
+
// 转发删了,不再有兜底。启动时主动校验一下,缺了就向 allowedUsers[0] 私信
|
|
94
|
+
// 提示。
|
|
95
|
+
//
|
|
96
|
+
// 校验通过飞书 "Get application info" API(应用身份):
|
|
97
|
+
// GET /open-apis/application/v6/applications/{app_id}?lang=zh_cn
|
|
98
|
+
// 返回的 data.app.scopes 是个 {scope, description, ...} 数组,遍历找
|
|
99
|
+
// scope 字段是否包含目标 key。
|
|
100
|
+
//
|
|
101
|
+
// 鸡生蛋约束:调这个 API 自身需要 admin:app.info:readonly 或
|
|
102
|
+
// application:application:self_manage 中任一权限。后者免审批,是
|
|
103
|
+
// 推荐路径——拿不到 app info 时(飞书返回 99991672)我们就主动私信
|
|
104
|
+
// admin 提示开通 self_manage,下次重启就能自检。
|
|
105
|
+
const REQUIRED_BOT_AT_SCOPE = 'im:message.group_at_msg.include_bot:readonly';
|
|
106
|
+
const SELF_MANAGE_SCOPE = 'application:application:self_manage';
|
|
107
|
+
function getAdminOpenId(bot) {
|
|
108
|
+
return bot.resolvedAllowedUsers.find(u => u.startsWith('ou_'));
|
|
109
|
+
}
|
|
110
|
+
async function dmAdmin(larkAppId, adminOpenId, content, contextTag) {
|
|
111
|
+
try {
|
|
112
|
+
await sendUserMessage(larkAppId, adminOpenId, content, 'text');
|
|
113
|
+
logger.info(`[${larkAppId}] notified admin ${adminOpenId.substring(0, 12)} about ${contextTag}`);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.warn(`[${larkAppId}] failed to DM admin about ${contextTag}: ${err?.message ?? err}`);
|
|
93
117
|
}
|
|
118
|
+
}
|
|
119
|
+
export async function checkRequiredScopes(larkAppId) {
|
|
120
|
+
const bot = getBot(larkAppId);
|
|
94
121
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
122
|
+
const tokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ app_id: bot.config.larkAppId, app_secret: bot.config.larkAppSecret }),
|
|
126
|
+
});
|
|
127
|
+
const tokenData = await tokenRes.json();
|
|
128
|
+
if (tokenData.code !== 0) {
|
|
129
|
+
logger.debug(`[${larkAppId}] scope check skipped: tenant_access_token failed (${tokenData.msg})`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const infoRes = await fetch(`https://open.feishu.cn/open-apis/application/v6/applications/${bot.config.larkAppId}?lang=zh_cn`, { headers: { Authorization: `Bearer ${tokenData.tenant_access_token}` } });
|
|
133
|
+
const infoData = await infoRes.json();
|
|
134
|
+
// 99991672 = 应用身份缺权限。最常见就是 admin:app.info:readonly /
|
|
135
|
+
// application:application:self_manage 都没拿到,导致根本查不到自己的
|
|
136
|
+
// scope 列表。这种"鸡生蛋"情况单独提示:让 admin 开通免审批的
|
|
137
|
+
// self_manage 后下次重启就能自检了。
|
|
138
|
+
if (infoData.code === 99991672) {
|
|
139
|
+
const selfManageAuthUrl = `https://open.feishu.cn/app/${bot.config.larkAppId}/auth?q=${encodeURIComponent(SELF_MANAGE_SCOPE)}&op_from=openapi&token_type=tenant`;
|
|
140
|
+
const targetAuthUrl = `https://open.feishu.cn/app/${bot.config.larkAppId}/auth?q=${encodeURIComponent(REQUIRED_BOT_AT_SCOPE)}&op_from=openapi&token_type=tenant`;
|
|
141
|
+
logger.warn(`[${larkAppId}] scope 自检 API 被拒(99991672):应用缺少 ${SELF_MANAGE_SCOPE}(免审批)。` +
|
|
142
|
+
`开通后下次 daemon 重启即可自动核验跨 bot @ 必需权限 ${REQUIRED_BOT_AT_SCOPE}。申请链接:${selfManageAuthUrl}`);
|
|
143
|
+
const adminOpenId = getAdminOpenId(bot);
|
|
144
|
+
if (!adminOpenId) {
|
|
145
|
+
logger.warn(`[${larkAppId}] 没有 resolved 的 admin open_id,self_manage 提示仅出现在 daemon 日志`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const dm = `⚠️ botmux 想自动核验机器人 "${bot.botName ?? larkAppId}" 是否开通了跨 bot @ 必需权限,但发现应用自身缺少一个**免审批**的辅助权限,因此查不到 scope 列表。\n\n` +
|
|
149
|
+
`**操作步骤(点链接 → 申请开通 → 重启 daemon)**:\n` +
|
|
150
|
+
`1. 开通 ${SELF_MANAGE_SCOPE}(免审批,自动通过):\n ${selfManageAuthUrl}\n\n` +
|
|
151
|
+
`2. 顺便确认/开通真正的目标权限 ${REQUIRED_BOT_AT_SCOPE}("获取群组中其他机器人和用户@当前机器人的消息",免审批,自动通过):\n ${targetAuthUrl}\n\n` +
|
|
152
|
+
`3. \`botmux restart\`,启动后 botmux 会自动复核,结果会再次发到这里。\n\n` +
|
|
153
|
+
`**为什么需要**:botmux 多机器人协作(A 机器人 @ B 机器人)依赖目标权限把跨 bot 事件推送过来;不开通则跨 bot @ 完全失效。`;
|
|
154
|
+
await dmAdmin(larkAppId, adminOpenId, dm, 'self_manage scope (auto-approved) missing');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (infoData.code !== 0) {
|
|
158
|
+
logger.debug(`[${larkAppId}] scope check skipped: app info failed (code=${infoData.code} msg=${infoData.msg ?? ''})`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Lark 文档示例把 scopes 放在 data.app.scopes;为防响应结构变化,
|
|
162
|
+
// 同时兜底 data.scopes / data.application.scopes,取到的第一个非空数组为准。
|
|
163
|
+
const scopesRaw = infoData.data?.app?.scopes
|
|
164
|
+
?? infoData.data?.application?.scopes
|
|
165
|
+
?? infoData.data?.scopes
|
|
166
|
+
?? [];
|
|
167
|
+
if (!Array.isArray(scopesRaw) || scopesRaw.length === 0) {
|
|
168
|
+
logger.debug(`[${larkAppId}] scope check inconclusive: scopes array empty or shape unexpected — skipping`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const grantedScopes = scopesRaw.map(s => typeof s === 'string' ? s : s?.scope).filter(Boolean);
|
|
172
|
+
if (grantedScopes.includes(REQUIRED_BOT_AT_SCOPE)) {
|
|
173
|
+
logger.info(`[${larkAppId}] required scope present: ${REQUIRED_BOT_AT_SCOPE}`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Missing — log + DM
|
|
177
|
+
logger.error(`[${larkAppId}] 缺少必需权限 ${REQUIRED_BOT_AT_SCOPE}("获取群组中其他机器人和用户@当前机器人的消息")。` +
|
|
178
|
+
`跨 bot @ 消息无法到达本 bot,多 bot 协作会失效。请到飞书开放平台 → 应用 → 权限管理里申请该权限,开通后 \`botmux restart\`。`);
|
|
179
|
+
const adminOpenId = getAdminOpenId(bot);
|
|
180
|
+
if (!adminOpenId) {
|
|
181
|
+
logger.warn(`[${larkAppId}] no resolved admin open_id in allowedUsers; missing-scope warning visible only in daemon log`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const authUrl = `https://open.feishu.cn/app/${bot.config.larkAppId}/auth?q=${encodeURIComponent(REQUIRED_BOT_AT_SCOPE)}&op_from=openapi&token_type=tenant`;
|
|
185
|
+
const dm = `⚠️ botmux 启动检查发现机器人 "${bot.botName ?? larkAppId}" 缺少必需权限\n\n` +
|
|
186
|
+
`权限名:获取群组中其他机器人和用户@当前机器人的消息\n` +
|
|
187
|
+
`scope: ${REQUIRED_BOT_AT_SCOPE}\n\n` +
|
|
188
|
+
`没开通的话,跨机器人 @ 收不到事件,botmux 多机器人协作的整套场景都失效。\n\n` +
|
|
189
|
+
`**操作步骤**:\n` +
|
|
190
|
+
`1. 点链接申请权限(免审批,自动通过):${authUrl}\n` +
|
|
191
|
+
`2. \`botmux restart\``;
|
|
192
|
+
await dmAdmin(larkAppId, adminOpenId, dm, 'required scope missing');
|
|
98
193
|
}
|
|
99
194
|
catch (err) {
|
|
100
|
-
logger.debug(`
|
|
101
|
-
return cached?.count ?? 999; // fallback: assume multi-person
|
|
195
|
+
logger.debug(`[${larkAppId}] scope check errored: ${err?.message ?? err}`);
|
|
102
196
|
}
|
|
103
197
|
}
|
|
104
|
-
|
|
105
|
-
|
|
198
|
+
// ─── Group chat stats cache ───────────────────────────────────────────────
|
|
199
|
+
//
|
|
200
|
+
// chat.get returns both user_count (real users only) and bot_count (bots).
|
|
201
|
+
// One API call, one cache — used to gate auto-replies in multi-bot/multi-user
|
|
202
|
+
// groups (oncall chats often have 3rd-party oncall/form/AI-search bots).
|
|
203
|
+
export const CHAT_CACHE_TTL = 5 * 60_000; // 5 minutes
|
|
204
|
+
const chatStatsCache = new Map();
|
|
205
|
+
export async function getGroupStats(larkAppId, chatId) {
|
|
106
206
|
const cacheKey = `${larkAppId}:${chatId}`;
|
|
107
|
-
const cached =
|
|
207
|
+
const cached = chatStatsCache.get(cacheKey);
|
|
108
208
|
if (cached && Date.now() - cached.fetchedAt < CHAT_CACHE_TTL) {
|
|
109
|
-
return cached.
|
|
209
|
+
return { userCount: cached.userCount, botCount: cached.botCount };
|
|
110
210
|
}
|
|
111
211
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
return
|
|
212
|
+
const info = await getChatInfo(larkAppId, chatId);
|
|
213
|
+
chatStatsCache.set(cacheKey, { userCount: info.userCount, botCount: info.botCount, fetchedAt: Date.now() });
|
|
214
|
+
return info;
|
|
115
215
|
}
|
|
116
216
|
catch (err) {
|
|
117
|
-
|
|
118
|
-
|
|
217
|
+
// Soft failure — the fallback below assumes worst case (multi-user,
|
|
218
|
+
// multi-bot → require @mention). No user-visible regression, so debug.
|
|
219
|
+
logger.debug(`Failed to get chat stats for ${chatId}, using safe fallback: ${err}`);
|
|
220
|
+
if (cached)
|
|
221
|
+
return { userCount: cached.userCount, botCount: cached.botCount };
|
|
222
|
+
// Fallback: assume multi-person, multi-bot → require @mention to be safe.
|
|
223
|
+
return { userCount: 999, botCount: 999 };
|
|
119
224
|
}
|
|
120
225
|
}
|
|
121
226
|
// ─── Cross-bot open_id mapping ──────────────────────────────────────────
|
|
@@ -143,6 +248,17 @@ export function readBotOpenIdCrossRef(dataDir, larkAppId) {
|
|
|
143
248
|
catch { /* ignore */ }
|
|
144
249
|
return map;
|
|
145
250
|
}
|
|
251
|
+
/** Is `senderOpenId` a registered botmux peer (from larkAppId's cross-ref)?
|
|
252
|
+
* Used to gate chat-scope foreign-bot @mention spawning to vetted peers. */
|
|
253
|
+
export function isKnownPeerBot(dataDir, larkAppId, senderOpenId) {
|
|
254
|
+
if (!senderOpenId)
|
|
255
|
+
return false;
|
|
256
|
+
for (const openId of readBotOpenIdCrossRef(dataDir, larkAppId).values()) {
|
|
257
|
+
if (openId === senderOpenId)
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
146
262
|
/** Update the per-bot cross-reference from @mention data in an event.
|
|
147
263
|
* mentionsList comes from Lark event message.mentions array. */
|
|
148
264
|
export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
|
|
@@ -202,7 +318,10 @@ export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
|
|
|
202
318
|
export function isBotMentioned(larkAppId, message, _senderOpenId) {
|
|
203
319
|
const botOpenId = getBot(larkAppId).botOpenId;
|
|
204
320
|
if (!botOpenId) {
|
|
205
|
-
|
|
321
|
+
// Startup race: events can arrive before probeBotOpenId() resolves the
|
|
322
|
+
// per-bot open_id. Subsequent events succeed once the probe completes,
|
|
323
|
+
// so this is not a real warning — drop to debug to keep error.log clean.
|
|
324
|
+
logger.debug(`[${larkAppId}] Bot open_id not yet known, skipping @mention check`);
|
|
206
325
|
return false;
|
|
207
326
|
}
|
|
208
327
|
// 1. Check message.mentions array (populated for user-sent text messages)
|
|
@@ -231,27 +350,30 @@ export function isBotMentioned(larkAppId, message, _senderOpenId) {
|
|
|
231
350
|
}
|
|
232
351
|
// ─── Permission gates ────────────────────────────────────────────────────
|
|
233
352
|
//
|
|
234
|
-
// Two
|
|
353
|
+
// Two gates:
|
|
235
354
|
// canTalk — may address the bot in this chat (prompts, thread replies)
|
|
236
355
|
// canOperate — may trigger state-changing actions (card buttons, daemon
|
|
237
356
|
// slash commands like /cd /restart /close /oncall)
|
|
238
357
|
//
|
|
239
|
-
// Non-oncall chats: both fall back to the bot's allowedUsers.
|
|
240
|
-
// chats: talking is open to everyone
|
|
241
|
-
//
|
|
358
|
+
// Non-oncall chats: both fall back to the bot's allowedUsers.
|
|
359
|
+
// Oncall-bound chats: talking is open to everyone in the group; operating
|
|
360
|
+
// still requires allowedUsers (single source of truth — no per-chat owners).
|
|
361
|
+
//
|
|
362
|
+
// Oncall is a chat-level concept: `isChatOncallBoundForAnyBot` returns true
|
|
363
|
+
// when ANY bot (this one or a sibling in another daemon) has the chat bound,
|
|
364
|
+
// so an unbound sibling doesn't fall back to allowedUsers and reply
|
|
365
|
+
// "⚠️ 无操作权限" when @-mentioned in a shared oncall workspace.
|
|
242
366
|
export function canTalk(larkAppId, chatId, senderOpenId) {
|
|
243
|
-
|
|
244
|
-
|
|
367
|
+
if (chatId && isChatOncallBoundForAnyBot(chatId))
|
|
368
|
+
return true;
|
|
369
|
+
if (isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId))
|
|
245
370
|
return true;
|
|
246
371
|
const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
|
|
247
372
|
if (allowedUsers.length === 0)
|
|
248
373
|
return true;
|
|
249
374
|
return !!senderOpenId && allowedUsers.includes(senderOpenId);
|
|
250
375
|
}
|
|
251
|
-
export function canOperate(larkAppId,
|
|
252
|
-
const oncall = chatId ? findOncallChat(larkAppId, chatId) : undefined;
|
|
253
|
-
if (oncall)
|
|
254
|
-
return !!senderOpenId && oncall.owners.includes(senderOpenId);
|
|
376
|
+
export function canOperate(larkAppId, _chatId, senderOpenId) {
|
|
255
377
|
const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
|
|
256
378
|
if (allowedUsers.length === 0)
|
|
257
379
|
return true;
|
|
@@ -274,13 +396,8 @@ export async function checkGroupMessageAccess(larkAppId, message, chatId, sender
|
|
|
274
396
|
// No @mention — only allow if sender is the sole human in the group
|
|
275
397
|
// AND this is the only bot in the chat. With multiple bots, require @mention
|
|
276
398
|
// to disambiguate.
|
|
277
|
-
// Note: each daemon registers only 1 bot, so getAllBots().length is always 1.
|
|
278
|
-
// Use getGroupBotCount (API query) to get the real count of bots in the chat.
|
|
279
399
|
if (isAllowed) {
|
|
280
|
-
const
|
|
281
|
-
getGroupUserCount(larkAppId, chatId),
|
|
282
|
-
getGroupBotCount(larkAppId, chatId),
|
|
283
|
-
]);
|
|
400
|
+
const { userCount, botCount } = await getGroupStats(larkAppId, chatId);
|
|
284
401
|
logger.debug(`Group user count: ${userCount}, bot count: ${botCount}`);
|
|
285
402
|
if (userCount <= 1 && botCount <= 1) {
|
|
286
403
|
return 'allowed';
|
|
@@ -288,6 +405,123 @@ export async function checkGroupMessageAccess(larkAppId, message, chatId, sender
|
|
|
288
405
|
}
|
|
289
406
|
return 'ignore';
|
|
290
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Best-effort plain-text extraction from a Lark message for routing-level
|
|
410
|
+
* decisions (currently: `/t` / `/topic` detection). Handles the two common
|
|
411
|
+
* shapes — `text` (`{"text": "..."}`) and `post` (zh_cn/en_us nested
|
|
412
|
+
* paragraphs of `text` / `at` nodes). Other types (image, file, sticker,
|
|
413
|
+
* interactive, …) return null so the caller falls through to the default
|
|
414
|
+
* routing path.
|
|
415
|
+
*
|
|
416
|
+
* Kept deliberately tiny rather than reusing parseEventMessage: the dispatcher
|
|
417
|
+
* runs on every inbound event and we only need a quick text peek before the
|
|
418
|
+
* permission gates / scope override; full parseEventMessage still runs once
|
|
419
|
+
* inside the chosen handler.
|
|
420
|
+
*/
|
|
421
|
+
export function extractMessageTextForRouting(message) {
|
|
422
|
+
if (!message?.content)
|
|
423
|
+
return null;
|
|
424
|
+
try {
|
|
425
|
+
const obj = JSON.parse(message.content);
|
|
426
|
+
// text shape: {"text":"..."}. Lark stuffs placeholder keys like "@_user_1"
|
|
427
|
+
// into obj.text; the human name only lives in message.mentions[].name. We
|
|
428
|
+
// must resolve keys → @${name} so stripLeadingMentions can strip them
|
|
429
|
+
// before parseForceTopicInvocation sees the content. Mirrors the
|
|
430
|
+
// resolveMentions logic in parseEventMessage.
|
|
431
|
+
if (typeof obj?.text === 'string') {
|
|
432
|
+
let text = obj.text;
|
|
433
|
+
const mentions = message?.mentions;
|
|
434
|
+
if (Array.isArray(mentions)) {
|
|
435
|
+
for (const m of mentions) {
|
|
436
|
+
if (m?.key && m?.name) {
|
|
437
|
+
text = text.split(m.key).join(`@${m.name}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return text;
|
|
442
|
+
}
|
|
443
|
+
// post shape: {"zh_cn":{"content":[[{tag:"text",text:"..."},{tag:"at",...}]]}}
|
|
444
|
+
// Post messages keep @mentions as separate `at` nodes (not embedded in
|
|
445
|
+
// text), so the joined text-node content is already clean of placeholders.
|
|
446
|
+
const inner = obj?.zh_cn ?? obj?.en_us ?? obj;
|
|
447
|
+
if (Array.isArray(inner?.content)) {
|
|
448
|
+
const parts = [];
|
|
449
|
+
for (const para of inner.content) {
|
|
450
|
+
if (!Array.isArray(para))
|
|
451
|
+
continue;
|
|
452
|
+
for (const node of para) {
|
|
453
|
+
if (node?.tag === 'text' && typeof node.text === 'string') {
|
|
454
|
+
parts.push(node.text);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return parts.length > 0 ? parts.join('') : null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch { /* malformed content — skip */ }
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* If the inbound message starts with `/t` / `/topic` AND the routing
|
|
466
|
+
* currently lands on chat-scope, override to thread-scope anchored at
|
|
467
|
+
* the inbound message_id. This makes "force topic mode" work even when
|
|
468
|
+
* the bot already owns a chat-scope session in the chat — the dispatcher
|
|
469
|
+
* routes to handleNewTopic at a fresh anchor instead of falling into
|
|
470
|
+
* handleThreadReply on the chat-scope owner.
|
|
471
|
+
*
|
|
472
|
+
* Already-thread messages (real Lark 话题, p2p, 话题群) are left alone:
|
|
473
|
+
* the prefix is still stripped downstream by handleNewTopic.
|
|
474
|
+
*/
|
|
475
|
+
export function maybeApplyForceTopicOverride(routing, message, messageId) {
|
|
476
|
+
if (routing.scope !== 'chat')
|
|
477
|
+
return false;
|
|
478
|
+
const rawText = extractMessageTextForRouting(message);
|
|
479
|
+
if (!rawText)
|
|
480
|
+
return false;
|
|
481
|
+
const stripped = stripLeadingMentions(rawText.trim(), message?.mentions ?? []);
|
|
482
|
+
if (!parseForceTopicInvocation(stripped))
|
|
483
|
+
return false;
|
|
484
|
+
routing.scope = 'thread';
|
|
485
|
+
routing.anchor = messageId;
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
/** Compute the scope + anchor for an inbound message:
|
|
489
|
+
* - root_id + thread_id → thread-scope, anchor = root_id (real Lark 话题)
|
|
490
|
+
* - 话题群 + no real thread → thread-scope, anchor = message_id (thread seed)
|
|
491
|
+
* - p2p + no real thread → thread-scope, anchor = message_id (each DM
|
|
492
|
+
* top-level message starts a fresh topic; a
|
|
493
|
+
* reply inside an existing thread carries
|
|
494
|
+
* root_id+thread_id and threads into its session)
|
|
495
|
+
* - 普通群 + no real thread → chat-scope, anchor = chat_id (entire group
|
|
496
|
+
* is one session)
|
|
497
|
+
*
|
|
498
|
+
* Why we gate on thread_id (not root_id alone): Lark 客户端的引用气泡 / 快速
|
|
499
|
+
* 回复 UI 有时会给"用户视角的顶层消息"塞 root_id 但**不会**塞 thread_id。
|
|
500
|
+
* 飞书官方文档:root_id/parent_id "仅在回复消息场景会有返回值";thread_id
|
|
501
|
+
* "不返回说明该消息非话题消息"。所以 thread_id 才是"是否真的处于话题里"的
|
|
502
|
+
* 权威信号。只看 root_id 会把 quote-bubble 错认为话题回复,把用户从 chat-scope
|
|
503
|
+
* 会话里拽走、又起一个孤立的 thread session。
|
|
504
|
+
* Exported for unit tests. */
|
|
505
|
+
export async function decideRouting(larkAppId, message) {
|
|
506
|
+
const rootId = message.root_id;
|
|
507
|
+
const threadId = message.thread_id;
|
|
508
|
+
if (rootId && threadId)
|
|
509
|
+
return { scope: 'thread', anchor: rootId };
|
|
510
|
+
const chatType = message.chat_type ?? 'group';
|
|
511
|
+
const messageId = message.message_id;
|
|
512
|
+
const chatId = message.chat_id;
|
|
513
|
+
// 私聊:每条 top-level DM 都视为新话题 — 跟话题群同款,匹配 Lark DM 的话题
|
|
514
|
+
// 化默认行为,避免无限把 1:1 对话塞进同一个 CLI 进程里。
|
|
515
|
+
if (chatType === 'p2p') {
|
|
516
|
+
return { scope: 'thread', anchor: messageId };
|
|
517
|
+
}
|
|
518
|
+
// Group chat — fetch chat_mode (cached) to disambiguate 话题群 from 普通群.
|
|
519
|
+
const mode = await getChatMode(larkAppId, chatId);
|
|
520
|
+
if (mode === 'topic') {
|
|
521
|
+
return { scope: 'thread', anchor: messageId };
|
|
522
|
+
}
|
|
523
|
+
return { scope: 'chat', anchor: chatId };
|
|
524
|
+
}
|
|
291
525
|
/**
|
|
292
526
|
* Create and start the Lark WSClient with event dispatching.
|
|
293
527
|
* Returns the WSClient instance for lifecycle management.
|
|
@@ -318,15 +552,26 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
|
|
|
318
552
|
if (message.mentions?.length > 0) {
|
|
319
553
|
updateBotOpenIdCrossRef(config.session.dataDir, larkAppId, message.mentions);
|
|
320
554
|
}
|
|
321
|
-
|
|
322
|
-
|
|
555
|
+
const chatId = message.chat_id;
|
|
556
|
+
const chatType = (message.chat_type === 'p2p' ? 'p2p' : 'group');
|
|
557
|
+
const messageId = message.message_id;
|
|
558
|
+
// Bot-originated messages — bots historically only post inside threads
|
|
559
|
+
// (their own thread replies). With chat-scope sessions a bot can also
|
|
560
|
+
// post top-level (its first reply in a chat-scope group), so we still
|
|
561
|
+
// route them through `decideRouting` rather than gating on root_id.
|
|
562
|
+
//
|
|
563
|
+
// 飞书在跨 bot 卡片消息场景实测会把发送方标成 sender_type='bot'(不是
|
|
564
|
+
// 文档里写的 'app'),所以这里两个值都接受,否则那条路径会落到下面的
|
|
565
|
+
// user-message 通用分支,绕开 /close self-message 特判、foreign-bot
|
|
566
|
+
// chat-scope gate(isKnownPeerBot)和"Bot-to-bot @mention detected"
|
|
567
|
+
// 日志。
|
|
568
|
+
const senderType = sender?.sender_type;
|
|
569
|
+
const isBotSenderType = senderType === 'app' || senderType === 'bot';
|
|
570
|
+
if (isBotSenderType) {
|
|
323
571
|
const senderOpenId = sender.sender_id?.open_id;
|
|
324
|
-
const rootId = message.root_id;
|
|
325
|
-
if (!rootId)
|
|
326
|
-
return; // ignore bot messages outside threads
|
|
327
572
|
const isSelfMessage = senderOpenId === getBot(larkAppId).botOpenId;
|
|
573
|
+
// Self messages: only echoed `/close` commands matter.
|
|
328
574
|
if (isSelfMessage) {
|
|
329
|
-
// Own messages: only process /close commands
|
|
330
575
|
try {
|
|
331
576
|
const body = JSON.parse(message.content ?? '{}');
|
|
332
577
|
if (body.text?.trim() !== '/close')
|
|
@@ -335,69 +580,142 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
|
|
|
335
580
|
catch {
|
|
336
581
|
return;
|
|
337
582
|
}
|
|
338
|
-
|
|
583
|
+
const ctx = await decideRouting(larkAppId, message);
|
|
584
|
+
handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId })
|
|
585
|
+
.catch(err => logger.error(`Error handling message event: ${err}`));
|
|
339
586
|
return;
|
|
340
587
|
}
|
|
341
|
-
//
|
|
342
|
-
if (isBotMentioned(larkAppId, message, undefined))
|
|
343
|
-
|
|
344
|
-
|
|
588
|
+
// Foreign bot: only route on @mention of us.
|
|
589
|
+
if (!isBotMentioned(larkAppId, message, undefined))
|
|
590
|
+
return;
|
|
591
|
+
const ctx = await decideRouting(larkAppId, message);
|
|
592
|
+
// Chat-scope foreign-bot @mention without an existing session: gate to
|
|
593
|
+
// vetted botmux peers (registered in our bot-openids cross-ref). This
|
|
594
|
+
// keeps random Lark bots from silently spawning chat-scope sessions
|
|
595
|
+
// in 普通群/p2p, while letting Bot A → Bot B handoffs in 普通群 work
|
|
596
|
+
// (handleThreadReply auto-create + chat-scope inheritance below).
|
|
597
|
+
if (ctx.scope === 'chat') {
|
|
598
|
+
const ownsSession = handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? false;
|
|
599
|
+
if (!ownsSession && !isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId)) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
345
602
|
}
|
|
603
|
+
logger.info(`Bot-to-bot @mention detected (scope=${ctx.scope}): routing to handleThreadReply`);
|
|
604
|
+
handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId })
|
|
605
|
+
.catch(err => logger.error(`Error handling bot @mention: ${err}`));
|
|
346
606
|
return;
|
|
347
607
|
}
|
|
348
|
-
const rootId = message.root_id;
|
|
349
|
-
const chatId = message.chat_id;
|
|
350
|
-
const chatType = message.chat_type; // 'group' or 'p2p'
|
|
351
|
-
const messageId = message.message_id;
|
|
352
608
|
const senderOpenId = sender?.sender_id?.open_id;
|
|
353
609
|
const isAllowed = canTalk(larkAppId, chatId, senderOpenId);
|
|
354
610
|
logger.debug('Received message:', message);
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
611
|
+
// Diagnostic: record the Lark quote-bubble UI quirk where root_id
|
|
612
|
+
// appears without thread_id. decideRouting now treats this as
|
|
613
|
+
// "no thread" (chat-scope / topic / new-topic depending on context),
|
|
614
|
+
// which is the authoritative behavior. Logging it here so we can spot
|
|
615
|
+
// any future surprise in the wild.
|
|
616
|
+
if (message.root_id && !message.thread_id) {
|
|
617
|
+
logger.info(`[routing] root_id w/o thread_id (Lark UI quirk, treating as top-level): ` +
|
|
618
|
+
`msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)} ` +
|
|
619
|
+
`type=${chatType} root=${String(message.root_id).substring(0, 12)} ` +
|
|
620
|
+
`parent=${String(message.parent_id ?? '').substring(0, 12)}`);
|
|
621
|
+
}
|
|
622
|
+
const routing = await decideRouting(larkAppId, message);
|
|
623
|
+
// 话题群 → 普通群 (reverse conversion). Symmetric to the forward check
|
|
624
|
+
// below: when decideRouting lands on thread-scope purely because the
|
|
625
|
+
// *cached* chat_mode said 'topic' (no real thread_id on the message
|
|
626
|
+
// either — i.e. this would seed a brand-new thread), our 5-min cache
|
|
627
|
+
// may be stale from before a flip-back to 普通群. Re-verify with
|
|
628
|
+
// forceRefresh; if Lark now reports 'group', flatten to chat-scope so
|
|
629
|
+
// the bot doesn't keep wrapping every top-level reply in a fresh
|
|
630
|
+
// Lark topic via reply_in_thread.
|
|
631
|
+
//
|
|
632
|
+
// Skip when there's a real thread_id (authoritative thread signal,
|
|
633
|
+
// can't be cache-stale) or when chatType is p2p (DMs always thread).
|
|
634
|
+
// Runs BEFORE /t override so a `@bot /t …` in a now-flat 普通群 still
|
|
635
|
+
// gets the explicit topic seed it asked for.
|
|
636
|
+
if (routing.scope === 'thread' &&
|
|
637
|
+
routing.anchor === messageId &&
|
|
638
|
+
!message.thread_id &&
|
|
639
|
+
chatType === 'group') {
|
|
640
|
+
const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
|
|
641
|
+
if (freshMode === 'group') {
|
|
642
|
+
logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'topic' → 'group'; ` +
|
|
643
|
+
`rerouting msg=${messageId.substring(0, 12)} as chat-scope`);
|
|
644
|
+
routing.scope = 'chat';
|
|
645
|
+
routing.anchor = chatId;
|
|
367
646
|
}
|
|
368
647
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
648
|
+
// /t / /topic in 普通群: flip routing to thread-scope so the bot's
|
|
649
|
+
// first reply seeds a fresh Lark thread, even if a chat-scope session
|
|
650
|
+
// is currently active in this chat.
|
|
651
|
+
if (maybeApplyForceTopicOverride(routing, message, messageId)) {
|
|
652
|
+
logger.info(`[/t] Force-topic override: msg=${messageId.substring(0, 12)} → thread-scope, anchor=msg`);
|
|
653
|
+
}
|
|
654
|
+
let ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
|
|
655
|
+
// 普通群 → 话题群 conversion detection. Lark group admins can flip
|
|
656
|
+
// chat_mode at any time; our 30/5-min cache lags. If routing landed on
|
|
657
|
+
// chat-scope AND we own a session at this chat, the chat-scope session
|
|
658
|
+
// may be stale from before a conversion. Re-fetch chat_mode with
|
|
659
|
+
// forceRefresh to confirm. If it's now 'topic', the session is dead:
|
|
660
|
+
// sendMessage(chatId) at dispatch time would wrap each reply in a new
|
|
661
|
+
// Lark topic (the user-reported bug). Evict the stale session, then
|
|
662
|
+
// route this message as if it were a brand-new thread seed so
|
|
663
|
+
// handleNewTopic spawns a thread-scope session anchored at messageId.
|
|
664
|
+
// Gate on ownsSession to avoid an API roundtrip on every fresh inbound.
|
|
665
|
+
if (routing.scope === 'chat' && ownsSession) {
|
|
666
|
+
const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
|
|
667
|
+
if (freshMode === 'topic') {
|
|
668
|
+
logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'group' → 'topic'; ` +
|
|
669
|
+
`evicting stale chat-scope session and rerouting msg=${messageId.substring(0, 12)} as thread seed`);
|
|
670
|
+
try {
|
|
671
|
+
handlers.onChatModeConverted?.(chatId, larkAppId);
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
logger.warn(`onChatModeConverted handler threw: ${err}`);
|
|
675
|
+
}
|
|
676
|
+
routing.scope = 'thread';
|
|
677
|
+
routing.anchor = messageId;
|
|
678
|
+
// ownsSession was true on the stale chatId anchor; the new anchor
|
|
679
|
+
// (messageId) is brand-new, so no current session owns it.
|
|
680
|
+
ownsSession = false;
|
|
378
681
|
}
|
|
379
|
-
|
|
682
|
+
}
|
|
683
|
+
// Permission gating — same shape as before, just keyed on
|
|
684
|
+
// `ownsSession` (anchor-aware) instead of "rootId presence":
|
|
685
|
+
//
|
|
686
|
+
// ownsSession + 1v1 group → relax (no @mention required)
|
|
687
|
+
// ownsSession + multi → require @mention
|
|
688
|
+
// !ownsSession (group) → require @mention + allowlist
|
|
689
|
+
// p2p → allowlist only
|
|
690
|
+
if (chatType === 'group') {
|
|
691
|
+
let stats = null;
|
|
692
|
+
if (ownsSession)
|
|
693
|
+
stats = await getGroupStats(larkAppId, chatId);
|
|
694
|
+
const relax = ownsSession && isAllowed && !!stats && stats.userCount <= 1 && stats.botCount <= 1;
|
|
695
|
+
if (!relax) {
|
|
380
696
|
const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId);
|
|
381
697
|
if (access === 'not_allowed') {
|
|
382
|
-
|
|
698
|
+
if (!ownsSession) {
|
|
699
|
+
replyMessage(larkAppId, messageId, JSON.stringify({ text: '⚠️ 无操作权限' }))
|
|
700
|
+
.catch(err => logger.debug(`Failed to send permission denied: ${err}`));
|
|
701
|
+
}
|
|
702
|
+
logger.debug(`Ignoring group message from non-allowed user: ${senderOpenId}`);
|
|
383
703
|
return;
|
|
384
704
|
}
|
|
385
705
|
if (access === 'ignore') {
|
|
386
|
-
logger.debug(`Ignoring group
|
|
706
|
+
logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
|
|
387
707
|
return;
|
|
388
708
|
}
|
|
389
709
|
}
|
|
390
710
|
}
|
|
391
711
|
else if (!isAllowed) {
|
|
392
|
-
|
|
393
|
-
logger.debug(`Ignoring message from non-allowed user: ${senderOpenId}`);
|
|
712
|
+
logger.debug(`Ignoring p2p message from non-allowed user: ${senderOpenId}`);
|
|
394
713
|
return;
|
|
395
714
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
: handlers.handleThreadReply(data, rootId, larkAppId);
|
|
715
|
+
const ctx = { chatId, messageId, chatType, larkAppId, ...routing };
|
|
716
|
+
const promise = ownsSession
|
|
717
|
+
? handlers.handleThreadReply(data, ctx)
|
|
718
|
+
: handlers.handleNewTopic(data, ctx);
|
|
401
719
|
promise.catch(err => logger.error(`Error handling message event: ${err}`));
|
|
402
720
|
}
|
|
403
721
|
catch (err) {
|
|
@@ -409,7 +727,10 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
|
|
|
409
727
|
const wsClient = new Lark.WSClient({
|
|
410
728
|
appId: larkAppId,
|
|
411
729
|
appSecret: larkAppSecret,
|
|
412
|
-
|
|
730
|
+
// Default to warn — the SDK is chatty at info ("client ready", reconnect
|
|
731
|
+
// heartbeats, etc.) and floods pm2 error.log when stderr is the only sink.
|
|
732
|
+
// DEBUG=1 widens the level back to info for troubleshooting.
|
|
733
|
+
loggerLevel: process.env.DEBUG ? Lark.LoggerLevel.info : Lark.LoggerLevel.warn,
|
|
413
734
|
});
|
|
414
735
|
wsClient.start({ eventDispatcher });
|
|
415
736
|
logger.info('Daemon WSClient started');
|