botmux 2.9.1 → 2.9.3
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 +645 -314
- 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 +35 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +114 -11
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +88 -6
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +398 -62
- 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 +2248 -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, replyMessage } from './client.js';
|
|
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,6 +84,117 @@ export async function probeBotOpenId(larkAppId) {
|
|
|
82
84
|
throw new Error('No open_id in bot info response');
|
|
83
85
|
}
|
|
84
86
|
}
|
|
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}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function checkRequiredScopes(larkAppId) {
|
|
120
|
+
const bot = getBot(larkAppId);
|
|
121
|
+
try {
|
|
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');
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
logger.debug(`[${larkAppId}] scope check errored: ${err?.message ?? err}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
85
198
|
// ─── Group chat stats cache ───────────────────────────────────────────────
|
|
86
199
|
//
|
|
87
200
|
// chat.get returns both user_count (real users only) and bot_count (bots).
|
|
@@ -101,7 +214,9 @@ export async function getGroupStats(larkAppId, chatId) {
|
|
|
101
214
|
return info;
|
|
102
215
|
}
|
|
103
216
|
catch (err) {
|
|
104
|
-
|
|
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}`);
|
|
105
220
|
if (cached)
|
|
106
221
|
return { userCount: cached.userCount, botCount: cached.botCount };
|
|
107
222
|
// Fallback: assume multi-person, multi-bot → require @mention to be safe.
|
|
@@ -133,6 +248,17 @@ export function readBotOpenIdCrossRef(dataDir, larkAppId) {
|
|
|
133
248
|
catch { /* ignore */ }
|
|
134
249
|
return map;
|
|
135
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
|
+
}
|
|
136
262
|
/** Update the per-bot cross-reference from @mention data in an event.
|
|
137
263
|
* mentionsList comes from Lark event message.mentions array. */
|
|
138
264
|
export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
|
|
@@ -192,7 +318,10 @@ export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
|
|
|
192
318
|
export function isBotMentioned(larkAppId, message, _senderOpenId) {
|
|
193
319
|
const botOpenId = getBot(larkAppId).botOpenId;
|
|
194
320
|
if (!botOpenId) {
|
|
195
|
-
|
|
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`);
|
|
196
325
|
return false;
|
|
197
326
|
}
|
|
198
327
|
// 1. Check message.mentions array (populated for user-sent text messages)
|
|
@@ -221,27 +350,30 @@ export function isBotMentioned(larkAppId, message, _senderOpenId) {
|
|
|
221
350
|
}
|
|
222
351
|
// ─── Permission gates ────────────────────────────────────────────────────
|
|
223
352
|
//
|
|
224
|
-
// Two
|
|
353
|
+
// Two gates:
|
|
225
354
|
// canTalk — may address the bot in this chat (prompts, thread replies)
|
|
226
355
|
// canOperate — may trigger state-changing actions (card buttons, daemon
|
|
227
356
|
// slash commands like /cd /restart /close /oncall)
|
|
228
357
|
//
|
|
229
|
-
// Non-oncall chats: both fall back to the bot's allowedUsers.
|
|
230
|
-
// chats: talking is open to everyone
|
|
231
|
-
//
|
|
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.
|
|
232
366
|
export function canTalk(larkAppId, chatId, senderOpenId) {
|
|
233
|
-
|
|
234
|
-
|
|
367
|
+
if (chatId && isChatOncallBoundForAnyBot(chatId))
|
|
368
|
+
return true;
|
|
369
|
+
if (isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId))
|
|
235
370
|
return true;
|
|
236
371
|
const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
|
|
237
372
|
if (allowedUsers.length === 0)
|
|
238
373
|
return true;
|
|
239
374
|
return !!senderOpenId && allowedUsers.includes(senderOpenId);
|
|
240
375
|
}
|
|
241
|
-
export function canOperate(larkAppId,
|
|
242
|
-
const oncall = chatId ? findOncallChat(larkAppId, chatId) : undefined;
|
|
243
|
-
if (oncall)
|
|
244
|
-
return !!senderOpenId && oncall.owners.includes(senderOpenId);
|
|
376
|
+
export function canOperate(larkAppId, _chatId, senderOpenId) {
|
|
245
377
|
const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
|
|
246
378
|
if (allowedUsers.length === 0)
|
|
247
379
|
return true;
|
|
@@ -273,6 +405,123 @@ export async function checkGroupMessageAccess(larkAppId, message, chatId, sender
|
|
|
273
405
|
}
|
|
274
406
|
return 'ignore';
|
|
275
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
|
+
}
|
|
276
525
|
/**
|
|
277
526
|
* Create and start the Lark WSClient with event dispatching.
|
|
278
527
|
* Returns the WSClient instance for lifecycle management.
|
|
@@ -303,15 +552,26 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
|
|
|
303
552
|
if (message.mentions?.length > 0) {
|
|
304
553
|
updateBotOpenIdCrossRef(config.session.dataDir, larkAppId, message.mentions);
|
|
305
554
|
}
|
|
306
|
-
|
|
307
|
-
|
|
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) {
|
|
308
571
|
const senderOpenId = sender.sender_id?.open_id;
|
|
309
|
-
const rootId = message.root_id;
|
|
310
|
-
if (!rootId)
|
|
311
|
-
return; // ignore bot messages outside threads
|
|
312
572
|
const isSelfMessage = senderOpenId === getBot(larkAppId).botOpenId;
|
|
573
|
+
// Self messages: only echoed `/close` commands matter.
|
|
313
574
|
if (isSelfMessage) {
|
|
314
|
-
// Own messages: only process /close commands
|
|
315
575
|
try {
|
|
316
576
|
const body = JSON.parse(message.content ?? '{}');
|
|
317
577
|
if (body.text?.trim() !== '/close')
|
|
@@ -320,69 +580,142 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
|
|
|
320
580
|
catch {
|
|
321
581
|
return;
|
|
322
582
|
}
|
|
323
|
-
|
|
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}`));
|
|
324
586
|
return;
|
|
325
587
|
}
|
|
326
|
-
//
|
|
327
|
-
if (isBotMentioned(larkAppId, message, undefined))
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
}
|
|
330
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}`));
|
|
331
606
|
return;
|
|
332
607
|
}
|
|
333
|
-
const rootId = message.root_id;
|
|
334
|
-
const chatId = message.chat_id;
|
|
335
|
-
const chatType = message.chat_type; // 'group' or 'p2p'
|
|
336
|
-
const messageId = message.message_id;
|
|
337
608
|
const senderOpenId = sender?.sender_id?.open_id;
|
|
338
609
|
const isAllowed = canTalk(larkAppId, chatId, senderOpenId);
|
|
339
610
|
logger.debug('Received message:', message);
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
646
|
}
|
|
353
647
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
681
|
}
|
|
364
|
-
|
|
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) {
|
|
365
696
|
const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId);
|
|
366
697
|
if (access === 'not_allowed') {
|
|
367
|
-
|
|
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}`);
|
|
368
703
|
return;
|
|
369
704
|
}
|
|
370
705
|
if (access === 'ignore') {
|
|
371
|
-
logger.debug(`Ignoring group
|
|
706
|
+
logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
|
|
372
707
|
return;
|
|
373
708
|
}
|
|
374
709
|
}
|
|
375
710
|
}
|
|
376
711
|
else if (!isAllowed) {
|
|
377
|
-
|
|
378
|
-
logger.debug(`Ignoring message from non-allowed user: ${senderOpenId}`);
|
|
712
|
+
logger.debug(`Ignoring p2p message from non-allowed user: ${senderOpenId}`);
|
|
379
713
|
return;
|
|
380
714
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
: 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);
|
|
386
719
|
promise.catch(err => logger.error(`Error handling message event: ${err}`));
|
|
387
720
|
}
|
|
388
721
|
catch (err) {
|
|
@@ -394,7 +727,10 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
|
|
|
394
727
|
const wsClient = new Lark.WSClient({
|
|
395
728
|
appId: larkAppId,
|
|
396
729
|
appSecret: larkAppSecret,
|
|
397
|
-
|
|
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,
|
|
398
734
|
});
|
|
399
735
|
wsClient.start({ eventDispatcher });
|
|
400
736
|
logger.info('Daemon WSClient started');
|