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
package/dist/daemon.js
CHANGED
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
7
|
const __dirname = dirname(__filename);
|
|
8
8
|
import { config } from './config.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { statSync } from 'node:fs';
|
|
10
|
+
import { getChatMode, replyMessage, resolveAllowedUsers, sendMessage } from './im/lark/client.js';
|
|
11
|
+
import { loadBotConfigs, registerBot, getBot, getAllBots, findOncallChatForAnyBot, isChatOncallBoundForAnyBot } from './bot-registry.js';
|
|
11
12
|
import * as sessionStore from './services/session-store.js';
|
|
13
|
+
import * as chatFirstSeenStore from './services/chat-first-seen-store.js';
|
|
14
|
+
import { autoBindOncallFromDefault } from './services/oncall-store.js';
|
|
15
|
+
import * as scheduleStore from './services/schedule-store.js';
|
|
12
16
|
import * as messageQueue from './services/message-queue.js';
|
|
13
|
-
import { parseEventMessage,
|
|
17
|
+
import { parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } from './im/lark/message-parser.js';
|
|
18
|
+
import { expandMergeForward } from './im/lark/merge-forward.js';
|
|
19
|
+
import { buildQuoteHint } from './im/lark/quote-hint.js';
|
|
14
20
|
import { logger } from './utils/logger.js';
|
|
15
21
|
import { ensureCjkFontsInstalled } from './utils/font-installer.js';
|
|
16
|
-
import { sessionKey } from './core/types.js';
|
|
22
|
+
import { sessionKey, sessionAnchorId } from './core/types.js';
|
|
17
23
|
import * as scheduler from './core/scheduler.js';
|
|
18
24
|
import { scanMultipleProjects } from './services/project-scanner.js';
|
|
19
25
|
import { buildRepoSelectCard, buildStreamingCard, getCliDisplayName } from './im/lark/card-builder.js';
|
|
20
26
|
import { createCliAdapterSync } from './adapters/cli/registry.js';
|
|
21
|
-
import { initWorkerPool, forkWorker, killWorker, scheduleCardPatch, setCurrentCliVersion, CARD_POSTING_SENTINEL, } from './core/worker-pool.js';
|
|
27
|
+
import { initWorkerPool, setActiveSessionsRegistry, forkWorker, killWorker, scheduleCardPatch, setCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, } from './core/worker-pool.js';
|
|
28
|
+
import { setBotName, setLarkAppId, startIpcServer } from './core/dashboard-ipc-server.js';
|
|
22
29
|
import { saveFrozenCards } from './services/frozen-card-store.js';
|
|
23
|
-
import { DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, handleCommand } from './core/command-handler.js';
|
|
30
|
+
import { DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, handleCommand, parseSlashCommandInvocation, parseForceTopicInvocation } from './core/command-handler.js';
|
|
31
|
+
import { findInheritablePeer } from './core/inherit-peer.js';
|
|
24
32
|
import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
|
|
25
|
-
import { getSessionWorkingDir, getProjectScanDirs, downloadResources, formatAttachmentsHint, buildNewTopicPrompt, buildFollowUpContent, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, } from './core/session-manager.js';
|
|
33
|
+
import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicPrompt, buildFollowUpContent, buildBridgeInputContent, buildReforkPrompt, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, } from './core/session-manager.js';
|
|
26
34
|
import { handleCardAction } from './im/lark/card-handler.js';
|
|
27
|
-
import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate } from './im/lark/event-dispatcher.js';
|
|
35
|
+
import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate, isKnownPeerBot, checkRequiredScopes } from './im/lark/event-dispatcher.js';
|
|
36
|
+
import { markSessionActivity } from './core/session-activity.js';
|
|
28
37
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
29
38
|
const activeSessions = new Map();
|
|
30
39
|
// Cache last /repo scan results per chat for /repo <number> fallback
|
|
@@ -32,18 +41,31 @@ const lastRepoScan = new Map();
|
|
|
32
41
|
const cliVersionCache = new Map();
|
|
33
42
|
const VERSION_CHECK_INTERVAL = 60_000; // cache 1 min
|
|
34
43
|
/**
|
|
35
|
-
* Reply
|
|
36
|
-
*
|
|
37
|
-
*
|
|
44
|
+
* Reply into a session — scope-aware.
|
|
45
|
+
*
|
|
46
|
+
* `anchor` is whatever the caller has at hand:
|
|
47
|
+
* - thread-scope sessions → rootMessageId
|
|
48
|
+
* - chat-scope sessions → chatId
|
|
49
|
+
*
|
|
50
|
+
* Behaviour:
|
|
51
|
+
* - thread-scope (or no matching DS, the legacy default) → reply with
|
|
52
|
+
* reply_in_thread=true to the anchor message_id
|
|
53
|
+
* - chat-scope → send a plain
|
|
54
|
+
* message to ds.chatId (no reply, no thread). Cards / button values
|
|
55
|
+
* embed the chatId so handleCardAction can route back into the same
|
|
56
|
+
* session.
|
|
57
|
+
*
|
|
58
|
+
* Lark message ids start with `om_` and chat ids with `oc_`, so the two
|
|
59
|
+
* address spaces never collide; the lookup just tries both.
|
|
38
60
|
*/
|
|
39
|
-
async function sessionReply(
|
|
61
|
+
async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
|
|
40
62
|
let ds;
|
|
41
63
|
if (larkAppId) {
|
|
42
|
-
ds = activeSessions.get(sessionKey(
|
|
64
|
+
ds = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
43
65
|
}
|
|
44
66
|
else {
|
|
45
67
|
for (const s of activeSessions.values()) {
|
|
46
|
-
if (s
|
|
68
|
+
if (sessionAnchorId(s) === anchor) {
|
|
47
69
|
ds = s;
|
|
48
70
|
break;
|
|
49
71
|
}
|
|
@@ -52,7 +74,33 @@ async function sessionReply(rootId, content, msgType = 'text', larkAppId) {
|
|
|
52
74
|
const appId = larkAppId ?? ds?.larkAppId ?? getAllBots()[0]?.config.larkAppId;
|
|
53
75
|
if (!appId)
|
|
54
76
|
throw new Error('No bot configured');
|
|
55
|
-
|
|
77
|
+
// Chat-scope: post a plain message to the chat. No reply_in_thread → keeps
|
|
78
|
+
// the conversation flat in 普通群. The card layer carries chatId in its button
|
|
79
|
+
// values, so handleCardAction routes back via sessionKey(chatId).
|
|
80
|
+
//
|
|
81
|
+
// If a 普通群 is converted to a 话题群 while this chat-scope session is alive,
|
|
82
|
+
// a top-level sendMessage would create a brand-new topic for every reply.
|
|
83
|
+
// Force-refresh chat_mode at dispatch time and fall back to the session's
|
|
84
|
+
// original triggering message as the thread anchor.
|
|
85
|
+
//
|
|
86
|
+
// Detect chat-scope from either ds.scope or anchor's `oc_` prefix. The
|
|
87
|
+
// prefix fallback covers the close-button race: card-handler deletes ds
|
|
88
|
+
// from activeSessions BEFORE sending the close-confirmation reply, so by
|
|
89
|
+
// the time we run, ds is gone — but the anchor (chatId, oc_xxx) is enough
|
|
90
|
+
// to know we should sendMessage, not reply_in_thread to a non-message-id.
|
|
91
|
+
if (ds?.scope === 'chat' || anchor.startsWith('oc_')) {
|
|
92
|
+
const chatId = ds?.chatId ?? anchor;
|
|
93
|
+
if (ds?.scope === 'chat' && ds.session.rootMessageId) {
|
|
94
|
+
const mode = await getChatMode(appId, chatId, { forceRefresh: true });
|
|
95
|
+
if (mode === 'topic') {
|
|
96
|
+
logger.warn(`[routing] Chat-scope session ${ds.session.sessionId.substring(0, 8)} is now topic-mode; replying in original thread ${ds.session.rootMessageId.substring(0, 12)}`);
|
|
97
|
+
return replyMessage(appId, ds.session.rootMessageId, content, msgType, true);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return sendMessage(appId, chatId, content, msgType);
|
|
101
|
+
}
|
|
102
|
+
// Thread-scope (or unknown / legacy): reply in thread.
|
|
103
|
+
return replyMessage(appId, anchor, content, msgType, true);
|
|
56
104
|
}
|
|
57
105
|
// ─── PID file ────────────────────────────────────────────────────────────────
|
|
58
106
|
function getPidFile() {
|
|
@@ -107,6 +155,26 @@ function removePidFile() {
|
|
|
107
155
|
logger.info('PID file removed');
|
|
108
156
|
}
|
|
109
157
|
}
|
|
158
|
+
// ─── Daemon descriptor (dashboard registry) ─────────────────────────────────
|
|
159
|
+
// Each per-bot daemon publishes a self-descriptor JSON at
|
|
160
|
+
// ~/.botmux/data/dashboard-daemons/<larkAppId>.json so the dashboard sibling
|
|
161
|
+
// process can discover all running daemons. The file is touched every 30s as
|
|
162
|
+
// a heartbeat (mtime drives offline detection) and removed on graceful exit.
|
|
163
|
+
const DAEMON_REGISTRY_DIR = join(homedir(), '.botmux', 'data', 'dashboard-daemons');
|
|
164
|
+
function writeDaemonDescriptor(d) {
|
|
165
|
+
mkdirSync(DAEMON_REGISTRY_DIR, { recursive: true });
|
|
166
|
+
const fp = join(DAEMON_REGISTRY_DIR, `${d.larkAppId}.json`);
|
|
167
|
+
writeFileSync(fp, JSON.stringify(d), { mode: 0o600 });
|
|
168
|
+
}
|
|
169
|
+
function removeDaemonDescriptor(larkAppId) {
|
|
170
|
+
const fp = join(DAEMON_REGISTRY_DIR, `${larkAppId}.json`);
|
|
171
|
+
if (existsSync(fp)) {
|
|
172
|
+
try {
|
|
173
|
+
unlinkSync(fp);
|
|
174
|
+
}
|
|
175
|
+
catch { /* ignore */ }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
110
178
|
// ─── Version tracking ────────────────────────────────────────────────────────
|
|
111
179
|
function refreshCliVersion(cliId, cliPathOverride) {
|
|
112
180
|
const now = Date.now();
|
|
@@ -150,6 +218,39 @@ function getActiveCount() {
|
|
|
150
218
|
}
|
|
151
219
|
return count;
|
|
152
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Freeze the previous turn's streaming card at "idle" and mark a new turn so the
|
|
223
|
+
* next screen_update from the worker POSTs a fresh streaming card instead of
|
|
224
|
+
* PATCH-ing the previous one. Shared by the normal-message path and the
|
|
225
|
+
* passthrough slash-command path (/model, /clear, /compact, etc.) — without
|
|
226
|
+
* this, passthrough commands silently PATCH the previous card and the user
|
|
227
|
+
* sees no visible response.
|
|
228
|
+
*/
|
|
229
|
+
function beginNewTurn(ds, title) {
|
|
230
|
+
if (ds.streamCardId && ds.workerPort) {
|
|
231
|
+
const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
|
|
232
|
+
const dsBotCfg = getBot(ds.larkAppId).config;
|
|
233
|
+
const prevTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(dsBotCfg.cliId);
|
|
234
|
+
const prevMode = ds.displayMode ?? 'hidden';
|
|
235
|
+
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, prevTitle, ds.lastScreenContent ?? '', 'idle', dsBotCfg.cliId, prevMode, ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false);
|
|
236
|
+
scheduleCardPatch(ds, frozenCard);
|
|
237
|
+
if (ds.streamCardNonce && ds.streamCardId !== CARD_POSTING_SENTINEL) {
|
|
238
|
+
if (!ds.frozenCards)
|
|
239
|
+
ds.frozenCards = new Map();
|
|
240
|
+
ds.frozenCards.set(ds.streamCardNonce, {
|
|
241
|
+
messageId: ds.streamCardId,
|
|
242
|
+
content: ds.lastScreenContent ?? '',
|
|
243
|
+
title: prevTitle,
|
|
244
|
+
displayMode: prevMode,
|
|
245
|
+
imageKey: ds.currentImageKey,
|
|
246
|
+
});
|
|
247
|
+
saveFrozenCards(ds.session.sessionId, ds.frozenCards);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
ds.streamCardPending = true;
|
|
251
|
+
ds.currentTurnTitle = title.substring(0, 50);
|
|
252
|
+
persistStreamCardState(ds);
|
|
253
|
+
}
|
|
153
254
|
// Dependencies passed to command-handler
|
|
154
255
|
const commandDeps = {
|
|
155
256
|
activeSessions,
|
|
@@ -163,71 +264,66 @@ const cardDeps = {
|
|
|
163
264
|
sessionReply,
|
|
164
265
|
lastRepoScan,
|
|
165
266
|
};
|
|
166
|
-
// ───
|
|
267
|
+
// ─── Event handling ──────────────────────────────────────────────────────────
|
|
167
268
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
269
|
+
* Default-oncall is a uniform forward-only policy: whenever the toggle is
|
|
270
|
+
* on, ANY chat the bot is currently in — old or newly added, doesn't matter —
|
|
271
|
+
* gets auto-bound to the configured workingDir on its next observed topic,
|
|
272
|
+
* unless it's already bound (`findOncallChatForAnyBot` upstream) or the user
|
|
273
|
+
* has opted out via tombstone.
|
|
274
|
+
*
|
|
275
|
+
* Returns the binding entry on success, undefined when any precondition
|
|
276
|
+
* fails or the lock-internal authoritative check (in `autoBindOncallFromDefault`)
|
|
277
|
+
* sees a concurrent tombstone / existing binding.
|
|
170
278
|
*/
|
|
171
|
-
async function
|
|
172
|
-
|
|
173
|
-
|
|
279
|
+
async function maybeAutoBindDefaultOncall(larkAppId, chatId, chatType) {
|
|
280
|
+
if (chatType !== 'group')
|
|
281
|
+
return undefined; // oncall is group-only by design
|
|
282
|
+
const bot = getBot(larkAppId);
|
|
283
|
+
const def = bot.config.defaultOncall;
|
|
284
|
+
if (!def?.enabled || !def.workingDir)
|
|
285
|
+
return undefined;
|
|
286
|
+
// Fast-path tombstone check against the in-memory snapshot — avoids taking
|
|
287
|
+
// the lock when we already know we'd skip. The AUTHORITATIVE re-check lives
|
|
288
|
+
// inside autoBindOncallFromDefault under the file lock, so a race with a
|
|
289
|
+
// concurrent unbind (which writes the tombstone) is still safe.
|
|
290
|
+
const autobound = bot.config.defaultOncallAutoboundChats ?? [];
|
|
291
|
+
if (autobound.includes(chatId))
|
|
292
|
+
return undefined;
|
|
293
|
+
// Validate workingDir at fire time too — directory might have been
|
|
294
|
+
// deleted/moved since the dashboard save validated it. Skipping (vs.
|
|
295
|
+
// crashing) lets the user fix the path without losing other bot config.
|
|
296
|
+
const resolved = expandHome(def.workingDir);
|
|
297
|
+
let isDir = false;
|
|
174
298
|
try {
|
|
175
|
-
|
|
176
|
-
// merge_forward message_id, so explicitly disable it here. Interactive
|
|
177
|
-
// sub-messages come back in the simplified "Format A" shape which our
|
|
178
|
-
// card extractor already handles.
|
|
179
|
-
const detail = await getMessageDetail(larkAppId, messageId, { userCardContent: false });
|
|
180
|
-
const subMessages = (detail?.items ?? []).filter((m) => m.upper_message_id === messageId);
|
|
181
|
-
if (subMessages.length === 0)
|
|
182
|
-
return { extraResources };
|
|
183
|
-
const parts = ['[转发消息]'];
|
|
184
|
-
for (const msg of subMessages) {
|
|
185
|
-
const senderLabel = msg.sender?.sender_type === 'app' ? '机器人' : (msg.sender?.id ?? '未知');
|
|
186
|
-
parts.push(`--- ${senderLabel} ---`);
|
|
187
|
-
// Interactive sub-messages may still carry the simplified "upgrade your
|
|
188
|
-
// client" fallback; unwrap user_dsl before any extraction so both the
|
|
189
|
-
// resource pass and text pass see the real v2 body.
|
|
190
|
-
// Interactive sub-messages arrive via REST as a simplified fallback.
|
|
191
|
-
// Lark's im.message.get never returns user_dsl (even for the bot's own
|
|
192
|
-
// messages, even via direct id lookup), so we can only unwrap when a
|
|
193
|
-
// user_dsl somehow got through. For third-party cards whose simplified
|
|
194
|
-
// form is the "请升级至最新版本客户端" fallback, the real body is
|
|
195
|
-
// unrecoverable from REST.
|
|
196
|
-
if (msg.msg_type === 'interactive') {
|
|
197
|
-
const unwrapped = unwrapUserDslContent(msg.body?.content ?? '');
|
|
198
|
-
if (unwrapped !== null) {
|
|
199
|
-
msg.body = { ...(msg.body ?? {}), content: unwrapped };
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
// Resources first so the numberer assigns [图片 N] in attachment order;
|
|
203
|
-
// text extraction below reuses those numbers. Do NOT override messageId —
|
|
204
|
-
// Lark requires the parent merge_forward's message_id to download
|
|
205
|
-
// resources (error 234003 if sub-message ID is used).
|
|
206
|
-
const subResources = extractResources(msg.msg_type ?? 'text', msg.body?.content ?? '', numberer);
|
|
207
|
-
extraResources.push(...subResources);
|
|
208
|
-
// Recursively expand nested merge_forward
|
|
209
|
-
if (msg.msg_type === 'merge_forward' && depth < MAX_DEPTH) {
|
|
210
|
-
const nested = { content: '[合并转发消息]', msgType: 'merge_forward', messageId: msg.message_id, rootId: '', senderId: msg.sender?.id ?? '', senderType: msg.sender?.sender_type ?? '', createTime: msg.create_time ?? '', mentions: [] };
|
|
211
|
-
const { extraResources: nestedResources } = await expandMergeForward(larkAppId, msg.message_id, nested, depth + 1, numberer);
|
|
212
|
-
parts.push(nested.content);
|
|
213
|
-
extraResources.push(...nestedResources);
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
const sub = parseApiMessage(msg, numberer);
|
|
217
|
-
parts.push(sub.content);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
parsed.content = parts.join('\n');
|
|
221
|
-
parsed.msgType = 'merge_forward_expanded';
|
|
299
|
+
isDir = statSync(resolved).isDirectory();
|
|
222
300
|
}
|
|
223
|
-
catch
|
|
224
|
-
|
|
225
|
-
|
|
301
|
+
catch { /* not a dir */ }
|
|
302
|
+
if (!isDir) {
|
|
303
|
+
logger.warn(`[${larkAppId}] defaultOncall workingDir invalid (${resolved}); ` +
|
|
304
|
+
`skipping auto-bind for chat=${chatId}`);
|
|
305
|
+
return undefined;
|
|
226
306
|
}
|
|
227
|
-
|
|
307
|
+
const r = await autoBindOncallFromDefault(larkAppId, chatId, def.workingDir);
|
|
308
|
+
if (!r.ok) {
|
|
309
|
+
logger.warn(`[${larkAppId}] defaultOncall auto-bind failed: chat=${chatId} reason=${r.reason}`);
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
if (r.skipped) {
|
|
313
|
+
// Lock-internal authoritative check disagreed with our fast-path —
|
|
314
|
+
// tombstone or binding raced in. Fine, just don't surface a binding.
|
|
315
|
+
logger.info(`[${larkAppId}] defaultOncall auto-bind skipped chat=${chatId} reason=${r.skipped}`);
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
logger.info(`[${larkAppId}] defaultOncall auto-bound chat=${chatId} → ${def.workingDir}`);
|
|
319
|
+
return r.entry;
|
|
228
320
|
}
|
|
229
|
-
|
|
230
|
-
|
|
321
|
+
async function handleNewTopic(data, ctx) {
|
|
322
|
+
const { chatId, messageId, chatType, larkAppId } = ctx;
|
|
323
|
+
// scope/anchor are mutable here: `/t` / `/topic` may flip a 普通群 chat-scope
|
|
324
|
+
// routing into thread-scope so the bot's first reply seeds a Lark thread.
|
|
325
|
+
let scope = ctx.scope;
|
|
326
|
+
let anchor = ctx.anchor;
|
|
231
327
|
await resolveNonsupportMessage(data, larkAppId);
|
|
232
328
|
const { parsed, resources } = parseEventMessage(data);
|
|
233
329
|
// Expand merge_forward: fetch sub-messages and collect their resources
|
|
@@ -235,32 +331,62 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
235
331
|
const { extraResources } = await expandMergeForward(larkAppId, messageId, parsed);
|
|
236
332
|
resources.push(...extraResources);
|
|
237
333
|
}
|
|
238
|
-
|
|
334
|
+
let content = parsed.content.trim();
|
|
239
335
|
// Strip leading @<bot> mentions so "@bot /oncall bind" is recognized as a command.
|
|
240
|
-
|
|
336
|
+
let cmdContent = stripLeadingMentions(content, parsed.mentions);
|
|
337
|
+
// `/t` / `/topic` — force the bot to reply in a thread, even in 普通群.
|
|
338
|
+
// In 普通群 the inbound message is chat-scope by default; override to
|
|
339
|
+
// thread-scope anchored at the user's message_id so sessionReply() uses
|
|
340
|
+
// reply_in_thread=true and seeds a fresh Lark thread. In 话题群 / p2p
|
|
341
|
+
// (already thread-scope) it's just a prefix strip — no routing change.
|
|
342
|
+
// Empty prompt is allowed: the user can fill it in while the repo card is
|
|
343
|
+
// pending (pendingFollowUps in handleThreadReply picks up subsequent text).
|
|
344
|
+
const forceTopic = parseForceTopicInvocation(cmdContent);
|
|
345
|
+
if (forceTopic) {
|
|
346
|
+
if (scope === 'chat') {
|
|
347
|
+
scope = 'thread';
|
|
348
|
+
anchor = messageId;
|
|
349
|
+
}
|
|
350
|
+
content = forceTopic.prompt;
|
|
351
|
+
parsed.content = forceTopic.prompt;
|
|
352
|
+
cmdContent = forceTopic.prompt;
|
|
353
|
+
logger.info(`[/t] Force-topic invocation: prompt="${forceTopic.prompt.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)})`);
|
|
354
|
+
}
|
|
241
355
|
const senderOpenId = data.sender?.sender_id?.open_id;
|
|
242
356
|
const botCfg = getBot(larkAppId).config;
|
|
243
|
-
logger.info(`New
|
|
357
|
+
logger.info(`New session: "${content.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)}, resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId})`);
|
|
244
358
|
// Intercept daemon commands in new topics (no session needed for some commands)
|
|
245
|
-
|
|
246
|
-
|
|
359
|
+
const invocation = parseSlashCommandInvocation(cmdContent);
|
|
360
|
+
if (invocation) {
|
|
361
|
+
const { cmd, content: commandContent } = invocation;
|
|
247
362
|
if (PASSTHROUGH_COMMANDS.has(cmd)) {
|
|
248
|
-
await sessionReply(
|
|
363
|
+
await sessionReply(anchor, `${cmd} 需要在已有会话内使用(先发一条普通消息启动 CLI)。`, 'text', larkAppId);
|
|
249
364
|
return;
|
|
250
365
|
}
|
|
251
366
|
if (DAEMON_COMMANDS.has(cmd)) {
|
|
252
|
-
// Oncall groups:
|
|
253
|
-
// itself
|
|
254
|
-
|
|
255
|
-
|
|
367
|
+
// Oncall groups: anyone can chat with the bot, but daemon commands
|
|
368
|
+
// (including /oncall itself) require allowedUsers. Treat the chat as
|
|
369
|
+
// oncall when ANY bot has it bound — sibling bots in multi-bot
|
|
370
|
+
// deployments inherit the same gate so /cd /restart /close don't slip
|
|
371
|
+
// past allowedUsers just because this bot wasn't the one that bound.
|
|
372
|
+
if (isChatOncallBoundForAnyBot(chatId) && !canOperate(larkAppId, chatId, senderOpenId)) {
|
|
373
|
+
await sessionReply(anchor, `⚠️ ${cmd} 仅 allowedUsers 可执行。`, 'text', larkAppId);
|
|
256
374
|
return;
|
|
257
375
|
}
|
|
258
|
-
|
|
376
|
+
// Same rootMessageId reasoning as below in the main spawn path:
|
|
377
|
+
// thread-scope MUST anchor on the thread root or sessionAnchorId() will
|
|
378
|
+
// disagree with activeSessions's key and downstream card buttons silently
|
|
379
|
+
// break. Chat-scope keeps the inbound messageId as audit only.
|
|
380
|
+
const cmdRootIdForStore = scope === 'thread' ? anchor : messageId;
|
|
381
|
+
const session = sessionStore.createSession(chatId, cmdRootIdForStore, cmdContent.substring(0, 50), chatType);
|
|
382
|
+
const now = Date.now();
|
|
259
383
|
session.larkAppId = larkAppId;
|
|
260
384
|
session.ownerOpenId = senderOpenId;
|
|
261
385
|
session.lastCallerOpenId = senderOpenId;
|
|
386
|
+
session.lastMessageAt = new Date(now).toISOString();
|
|
387
|
+
session.scope = scope;
|
|
262
388
|
sessionStore.updateSession(session);
|
|
263
|
-
activeSessions.set(sessionKey(
|
|
389
|
+
activeSessions.set(sessionKey(anchor, larkAppId), {
|
|
264
390
|
session,
|
|
265
391
|
worker: null,
|
|
266
392
|
workerPort: null,
|
|
@@ -268,14 +394,15 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
268
394
|
larkAppId,
|
|
269
395
|
chatId,
|
|
270
396
|
chatType,
|
|
271
|
-
|
|
397
|
+
scope,
|
|
398
|
+
spawnedAt: Date.parse(session.createdAt) || now,
|
|
272
399
|
cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
|
|
273
|
-
lastMessageAt:
|
|
400
|
+
lastMessageAt: now,
|
|
274
401
|
hasHistory: false,
|
|
275
402
|
ownerOpenId: senderOpenId,
|
|
276
403
|
});
|
|
277
404
|
// Pass mention-stripped content so /command argument parsing works.
|
|
278
|
-
await handleCommand(cmd,
|
|
405
|
+
await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
|
|
279
406
|
return;
|
|
280
407
|
}
|
|
281
408
|
}
|
|
@@ -285,18 +412,55 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
285
412
|
parsed.attachments = attachments;
|
|
286
413
|
}
|
|
287
414
|
if (needLogin) {
|
|
288
|
-
sessionReply(
|
|
415
|
+
sessionReply(anchor, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', larkAppId);
|
|
289
416
|
}
|
|
417
|
+
// First-turn quote-reply: when the user @s the bot via Lark's "quote" UI as
|
|
418
|
+
// the very first interaction (no active session yet), the same hint that
|
|
419
|
+
// handleThreadReply prepends needs to ride along here too. Without it, the
|
|
420
|
+
// bot never learns about the quoted message_id and `botmux quoted` is dead
|
|
421
|
+
// weight on first turns. `content` (post force-topic-strip) is what the
|
|
422
|
+
// worker will see; promptContent wraps it for prompt-building paths but
|
|
423
|
+
// leaves `content` untouched for title / log substring uses.
|
|
424
|
+
const promptContent = buildQuoteHint(parsed, scope, anchor) + content;
|
|
290
425
|
refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);
|
|
291
|
-
// Create session in pending-repo state — don't spawn CLI yet
|
|
292
|
-
|
|
426
|
+
// Create session in pending-repo state — don't spawn CLI yet.
|
|
427
|
+
// For thread-scope, rootMessageId == anchor (the thread root). Critical
|
|
428
|
+
// because sessionAnchorId() uses rootMessageId for thread-scope, and the
|
|
429
|
+
// session card's button payload (value.root_id) flows from there back into
|
|
430
|
+
// activeSessions.get(sessionKey(rootId, larkAppId)) — if rootMessageId is
|
|
431
|
+
// the inbound message_id instead of the thread root, every restart/close/
|
|
432
|
+
// disconnect click silently no-ops.
|
|
433
|
+
// For chat-scope, rootMessageId stores the seed message_id (audit only);
|
|
434
|
+
// routing keys off chatId via sessionAnchorId(), so any value works.
|
|
435
|
+
const rootIdForStore = scope === 'thread' ? anchor : messageId;
|
|
436
|
+
const session = sessionStore.createSession(chatId, rootIdForStore, parsed.content.substring(0, 50), chatType);
|
|
437
|
+
const now = Date.now();
|
|
293
438
|
session.larkAppId = larkAppId;
|
|
294
439
|
session.ownerOpenId = senderOpenId;
|
|
440
|
+
session.lastMessageAt = new Date(now).toISOString();
|
|
441
|
+
session.scope = scope;
|
|
295
442
|
sessionStore.updateSession(session);
|
|
296
|
-
messageQueue.ensureQueue(
|
|
297
|
-
messageQueue.appendMessage(
|
|
298
|
-
// Oncall group: pin working dir from binding,
|
|
299
|
-
|
|
443
|
+
messageQueue.ensureQueue(anchor);
|
|
444
|
+
messageQueue.appendMessage(anchor, parsed);
|
|
445
|
+
// Oncall group: pin working dir from the chat-level binding, even if a
|
|
446
|
+
// sibling bot (running in another daemon) is the one that persisted it.
|
|
447
|
+
// Layered lookup:
|
|
448
|
+
// 1) any existing binding (this bot or sibling)
|
|
449
|
+
// 2) this bot's defaultOncall — auto-binds the chat if it's brand new
|
|
450
|
+
// and the flag is on. Once auto-bound, the chat appears in oncallChats
|
|
451
|
+
// so the next handleNewTopic sees it via (1).
|
|
452
|
+
let oncallEntry = findOncallChatForAnyBot(chatId);
|
|
453
|
+
if (!oncallEntry) {
|
|
454
|
+
oncallEntry = await maybeAutoBindDefaultOncall(larkAppId, chatId, chatType);
|
|
455
|
+
}
|
|
456
|
+
// Cross-bot / chat-scope inheritance: reuse a sibling session's workingDir
|
|
457
|
+
// and skip the repo card. Same block lives in handleThreadReply's auto-create
|
|
458
|
+
// branch — both handlers land unowned messages after the 4fec43c routing
|
|
459
|
+
// change. Helper is shared.
|
|
460
|
+
const inheritedFrom = !oncallEntry
|
|
461
|
+
? findInheritablePeer({ scope, anchor, chatId, chatType, selfAppId: larkAppId })
|
|
462
|
+
: null;
|
|
463
|
+
const pinnedWorkingDir = oncallEntry?.workingDir ?? inheritedFrom?.workingDir;
|
|
300
464
|
const ds = {
|
|
301
465
|
session,
|
|
302
466
|
worker: null,
|
|
@@ -305,29 +469,33 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
305
469
|
larkAppId,
|
|
306
470
|
chatId,
|
|
307
471
|
chatType,
|
|
308
|
-
|
|
472
|
+
scope,
|
|
473
|
+
spawnedAt: Date.parse(session.createdAt) || now,
|
|
309
474
|
cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
|
|
310
|
-
lastMessageAt:
|
|
475
|
+
lastMessageAt: now,
|
|
311
476
|
hasHistory: false,
|
|
312
|
-
pendingRepo: !
|
|
313
|
-
pendingPrompt:
|
|
477
|
+
pendingRepo: !pinnedWorkingDir,
|
|
478
|
+
pendingPrompt: promptContent,
|
|
314
479
|
pendingAttachments: attachments.length > 0 ? attachments : undefined,
|
|
315
480
|
pendingMentions: parsed.mentions,
|
|
316
481
|
ownerOpenId: senderOpenId,
|
|
317
482
|
currentTurnTitle: content.substring(0, 50),
|
|
318
|
-
workingDir:
|
|
483
|
+
workingDir: pinnedWorkingDir,
|
|
319
484
|
};
|
|
320
|
-
if (
|
|
321
|
-
ds.session.workingDir =
|
|
485
|
+
if (pinnedWorkingDir) {
|
|
486
|
+
ds.session.workingDir = pinnedWorkingDir;
|
|
322
487
|
sessionStore.updateSession(ds.session);
|
|
323
488
|
}
|
|
324
|
-
activeSessions.set(sessionKey(
|
|
325
|
-
//
|
|
326
|
-
if (
|
|
489
|
+
activeSessions.set(sessionKey(anchor, larkAppId), ds);
|
|
490
|
+
// Pinned (oncall binding or inherited from sibling bot): spawn CLI immediately.
|
|
491
|
+
if (pinnedWorkingDir) {
|
|
327
492
|
const selfBot = getBot(larkAppId);
|
|
328
|
-
const prompt = buildNewTopicPrompt(
|
|
493
|
+
const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
|
|
329
494
|
forkWorker(ds, prompt);
|
|
330
|
-
|
|
495
|
+
const reason = oncallEntry
|
|
496
|
+
? `oncall-bound chat ${chatId}`
|
|
497
|
+
: `inherited from sibling session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`;
|
|
498
|
+
logger.info(`[${tag(ds)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
|
|
331
499
|
return;
|
|
332
500
|
}
|
|
333
501
|
// Show repo selection card
|
|
@@ -339,20 +507,56 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
339
507
|
if (projects.length > 0) {
|
|
340
508
|
lastRepoScan.set(chatId, projects);
|
|
341
509
|
const currentCwd = getSessionWorkingDir(ds);
|
|
342
|
-
const cardJson = buildRepoSelectCard(projects, currentCwd,
|
|
343
|
-
ds.repoCardMessageId = await sessionReply(
|
|
510
|
+
const cardJson = buildRepoSelectCard(projects, currentCwd, anchor);
|
|
511
|
+
ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
344
512
|
logger.info(`[${tag(ds)}] Waiting for repo selection (${projects.length} projects)`);
|
|
345
513
|
}
|
|
346
514
|
else {
|
|
347
515
|
// No projects found — skip repo selection, spawn directly
|
|
348
516
|
ds.pendingRepo = false;
|
|
349
517
|
const selfBot = getBot(larkAppId);
|
|
350
|
-
const prompt = buildNewTopicPrompt(
|
|
518
|
+
const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
|
|
351
519
|
forkWorker(ds, prompt);
|
|
352
520
|
logger.info(`Session ${session.sessionId} ready (no projects to select), total active: ${getActiveCount()}`);
|
|
353
521
|
}
|
|
354
522
|
}
|
|
355
|
-
|
|
523
|
+
/** Reverse-lookup a foreign bot's display name for a sender open_id observed on
|
|
524
|
+
* this app's WS events. Priority:
|
|
525
|
+
* 1) bot-openids-${larkAppId}.json — per-app cross-ref populated by
|
|
526
|
+
* updateBotOpenIdCrossRef when @mentions go through us. Open_id is
|
|
527
|
+
* per-app scoped, so this is the authoritative map for this larkAppId.
|
|
528
|
+
* 2) bots-info.json — fallback for bots not yet in our cross-ref but
|
|
529
|
+
* registered as botmux peers (matches by their self-reported open_id;
|
|
530
|
+
* only works when the peer's app id space coincides with ours).
|
|
531
|
+
* Returns "Bot" if neither lookup hits — keeps the prefix readable rather
|
|
532
|
+
* than blocking the message.
|
|
533
|
+
*/
|
|
534
|
+
function lookupForeignBotName(senderOpenId, larkAppId) {
|
|
535
|
+
try {
|
|
536
|
+
const fp = join(config.session.dataDir, `bot-openids-${larkAppId}.json`);
|
|
537
|
+
if (existsSync(fp)) {
|
|
538
|
+
const data = JSON.parse(readFileSync(fp, 'utf-8'));
|
|
539
|
+
for (const [name, openId] of Object.entries(data)) {
|
|
540
|
+
if (openId === senderOpenId)
|
|
541
|
+
return name;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
catch { /* fall through */ }
|
|
546
|
+
try {
|
|
547
|
+
const infoPath = join(config.session.dataDir, 'bots-info.json');
|
|
548
|
+
if (existsSync(infoPath)) {
|
|
549
|
+
const entries = JSON.parse(readFileSync(infoPath, 'utf-8'));
|
|
550
|
+
const hit = entries.find(e => e.botOpenId === senderOpenId);
|
|
551
|
+
if (hit)
|
|
552
|
+
return hit.botName ?? getCliDisplayName(hit.cliId);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch { /* */ }
|
|
556
|
+
return 'Bot';
|
|
557
|
+
}
|
|
558
|
+
async function handleThreadReply(data, ctx) {
|
|
559
|
+
const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId } = ctx;
|
|
356
560
|
await resolveNonsupportMessage(data, larkAppId);
|
|
357
561
|
const { parsed, resources } = parseEventMessage(data);
|
|
358
562
|
// Expand merge_forward: fetch sub-messages and collect their resources
|
|
@@ -360,6 +564,34 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
360
564
|
const { extraResources } = await expandMergeForward(larkAppId, parsed.messageId, parsed);
|
|
361
565
|
resources.push(...extraResources);
|
|
362
566
|
}
|
|
567
|
+
// Foreign bot @mention prefix: when sender is another botmux bot,把内容包成
|
|
568
|
+
// [来自 X 的 @mention]\n<原文> 喂给 worker,让 CLI 知道这是另一个 bot 发的——
|
|
569
|
+
// 不是用户直接发的——后续不需要按"对话用户"的方式处理。signal-file 路径
|
|
570
|
+
// 删掉之前由 processBotMentionSignal 拼,现在统一在这里拼。仅影响发给
|
|
571
|
+
// worker 的 prompt 内容,title / 命令解析 / 日志还是用原 parsed.content。
|
|
572
|
+
//
|
|
573
|
+
// 检测策略走双轨:
|
|
574
|
+
// 1) `sender.sender_type === 'app' | 'bot'` —— 飞书事件标注为机器人发送。
|
|
575
|
+
// 'app' 是文档里的常规值;'bot' 是实测中跨 bot @ 卡片消息到接收方时
|
|
576
|
+
// 飞书实际给的值(与 'app' 等价对待,少依赖一次 cross-ref 学习)。
|
|
577
|
+
// 2) sender 的 open_id 在我们本 app 的 cross-ref(bot-openids-<appId>.json)
|
|
578
|
+
// 里能匹配到一个 botmux 同伴名字 —— 兜底覆盖 sender_type 又变其他取值
|
|
579
|
+
// 或者全无的边角情况,前提是之前已通过 @mention 学习链路记录过对方。
|
|
580
|
+
const senderOpenIdForPrefix = parsed.senderId || data?.sender?.sender_id?.open_id;
|
|
581
|
+
const selfBotOpenId = getBot(larkAppId).botOpenId;
|
|
582
|
+
const isBotSenderType = parsed.senderType === 'app' || parsed.senderType === 'bot';
|
|
583
|
+
const isForeignBot = !!senderOpenIdForPrefix &&
|
|
584
|
+
senderOpenIdForPrefix !== selfBotOpenId &&
|
|
585
|
+
(isBotSenderType ||
|
|
586
|
+
isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenIdForPrefix));
|
|
587
|
+
const botSenderPrefix = isForeignBot
|
|
588
|
+
? `[来自 ${lookupForeignBotName(senderOpenIdForPrefix, larkAppId)} 的 @mention]\n`
|
|
589
|
+
: '';
|
|
590
|
+
const promptContent = buildQuoteHint(parsed, scope, anchor) + botSenderPrefix + parsed.content;
|
|
591
|
+
if (isForeignBot) {
|
|
592
|
+
logger.info(`[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` +
|
|
593
|
+
`senderType=${parsed.senderType} via=${isBotSenderType ? 'sender_type' : 'cross-ref'}`);
|
|
594
|
+
}
|
|
363
595
|
const content = parsed.content.trim();
|
|
364
596
|
// Strip leading @<bot> mentions so "@bot /restart" is recognized as a command.
|
|
365
597
|
const cmdContent = stripLeadingMentions(content, parsed.mentions);
|
|
@@ -367,49 +599,64 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
367
599
|
if (isCallbackUrl(content)) {
|
|
368
600
|
const result = await handleCallbackUrl(content);
|
|
369
601
|
if (result) {
|
|
370
|
-
|
|
602
|
+
// Route through sessionReply so chat-scope (普通群) lands as a plain
|
|
603
|
+
// chat message instead of a forced new thread.
|
|
604
|
+
sessionReply(anchor, result, 'text', larkAppId)
|
|
371
605
|
.catch(err => logger.error(`Failed to reply login result: ${err}`));
|
|
372
606
|
return;
|
|
373
607
|
}
|
|
374
608
|
}
|
|
375
609
|
// Intercept daemon commands
|
|
376
|
-
|
|
377
|
-
|
|
610
|
+
const invocation = parseSlashCommandInvocation(cmdContent);
|
|
611
|
+
if (invocation) {
|
|
612
|
+
const { cmd, content: commandContent } = invocation;
|
|
378
613
|
if (PASSTHROUGH_COMMANDS.has(cmd)) {
|
|
379
|
-
const ds = activeSessions.get(sessionKey(
|
|
614
|
+
const ds = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
380
615
|
if (ds?.worker && !ds.worker.killed) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
616
|
+
// Mark a new turn so the CLI's response to /model, /clear, /compact, etc.
|
|
617
|
+
// shows up as a fresh streaming card instead of silently PATCH-ing the
|
|
618
|
+
// previous turn's card.
|
|
619
|
+
beginNewTurn(ds, commandContent);
|
|
620
|
+
ds.worker.send({ type: 'raw_input', content: commandContent });
|
|
621
|
+
markSessionActivity(ds);
|
|
622
|
+
logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`);
|
|
384
623
|
}
|
|
385
624
|
else {
|
|
386
|
-
sessionReply(
|
|
625
|
+
sessionReply(anchor, `${cmd} 需要活跃的 CLI 进程,当前话题无运行中的会话。`, 'text', larkAppId);
|
|
387
626
|
}
|
|
388
627
|
return;
|
|
389
628
|
}
|
|
390
629
|
if (DAEMON_COMMANDS.has(cmd)) {
|
|
391
|
-
// Oncall
|
|
392
|
-
const existingDs = activeSessions.get(sessionKey(
|
|
393
|
-
const threadChatId = existingDs?.chatId ?? data?.message?.chat_id;
|
|
630
|
+
// Oncall allowedUsers gate for thread-reply daemon commands
|
|
631
|
+
const existingDs = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
632
|
+
const threadChatId = existingDs?.chatId ?? ctxChatId ?? data?.message?.chat_id;
|
|
394
633
|
const threadSenderOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
|
|
395
|
-
if (
|
|
396
|
-
sessionReply(
|
|
634
|
+
if (threadChatId && isChatOncallBoundForAnyBot(threadChatId) && !canOperate(larkAppId, threadChatId, threadSenderOpenId)) {
|
|
635
|
+
sessionReply(anchor, `⚠️ ${cmd} 仅 allowedUsers 可执行。`, 'text', larkAppId);
|
|
397
636
|
return;
|
|
398
637
|
}
|
|
399
638
|
// Pass mention-stripped content so /command argument parsing works.
|
|
400
|
-
handleCommand(cmd,
|
|
639
|
+
handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
|
|
401
640
|
return;
|
|
402
641
|
}
|
|
403
642
|
}
|
|
404
|
-
logger.info(`
|
|
405
|
-
let ds = activeSessions.get(sessionKey(
|
|
406
|
-
// If another bot already owns this
|
|
643
|
+
logger.info(`Reply in ${scope}-scope session ${anchor.substring(0, 12)}: ${content.substring(0, 100)} (resources: ${resources.length})`);
|
|
644
|
+
let ds = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
645
|
+
// If another bot already owns this anchor, ignore unmentioned replies here as a
|
|
407
646
|
// second line of defense. Explicit @mentions are still allowed to spin up/take over.
|
|
647
|
+
// For chat-scope: another bot's session in the same chat is keyed by its own chatId.
|
|
648
|
+
// For thread-scope: same rootMessageId may have peer sessions across bots.
|
|
408
649
|
if (!ds) {
|
|
409
650
|
const mentionedThisBot = isBotMentioned(larkAppId, data?.message ?? {}, data?.sender?.sender_id?.open_id);
|
|
410
|
-
const hasOtherBot = [...activeSessions.values()].some(s =>
|
|
651
|
+
const hasOtherBot = [...activeSessions.values()].some(s => {
|
|
652
|
+
if (s.larkAppId === larkAppId)
|
|
653
|
+
return false;
|
|
654
|
+
if (s.scope === 'chat')
|
|
655
|
+
return s.chatId === ctxChatId && scope === 'chat';
|
|
656
|
+
return s.session.rootMessageId === anchor;
|
|
657
|
+
});
|
|
411
658
|
if (hasOtherBot && !mentionedThisBot) {
|
|
412
|
-
logger.info(`[${larkAppId}] Ignoring
|
|
659
|
+
logger.info(`[${larkAppId}] Ignoring ${scope}-scope ${anchor}; another bot already owns it`);
|
|
413
660
|
return;
|
|
414
661
|
}
|
|
415
662
|
}
|
|
@@ -420,13 +667,13 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
420
667
|
parsed.attachments = attachments;
|
|
421
668
|
}
|
|
422
669
|
if (needLogin) {
|
|
423
|
-
sessionReply(
|
|
670
|
+
sessionReply(anchor, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', effectiveAppId);
|
|
424
671
|
}
|
|
425
672
|
// Update last message time + last caller (used by `botmux send` to address
|
|
426
673
|
// reply cards to whoever triggered this turn — matters in oncall groups
|
|
427
674
|
// where the caller is often not the session owner).
|
|
428
675
|
if (ds) {
|
|
429
|
-
ds
|
|
676
|
+
markSessionActivity(ds);
|
|
430
677
|
const callerOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
|
|
431
678
|
if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
|
|
432
679
|
ds.session.lastCallerOpenId = callerOpenId;
|
|
@@ -437,8 +684,8 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
437
684
|
if (ds?.pendingRepo) {
|
|
438
685
|
// Enrich content with attachment hints and mention metadata (same as normal send)
|
|
439
686
|
let enriched = attachments.length > 0
|
|
440
|
-
? `${
|
|
441
|
-
:
|
|
687
|
+
? `${promptContent}${formatAttachmentsHint(attachments)}`
|
|
688
|
+
: promptContent;
|
|
442
689
|
if (parsed.mentions && parsed.mentions.length > 0) {
|
|
443
690
|
const mentionLines = parsed.mentions.map(m => {
|
|
444
691
|
const idPart = m.openId ? ` → open_id: ${m.openId}` : '';
|
|
@@ -449,63 +696,95 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
449
696
|
if (!ds.pendingFollowUps)
|
|
450
697
|
ds.pendingFollowUps = [];
|
|
451
698
|
ds.pendingFollowUps.push(enriched);
|
|
452
|
-
await sessionReply(
|
|
699
|
+
await sessionReply(anchor, '请先在上方卡片中选择仓库,您的消息已暂存,选择后会自动发送。', 'text', larkAppId);
|
|
453
700
|
return;
|
|
454
701
|
}
|
|
455
|
-
// Route to file queue
|
|
456
|
-
messageQueue.ensureQueue(
|
|
457
|
-
messageQueue.appendMessage(
|
|
702
|
+
// Route to file queue (keyed by anchor: rootMessageId for thread, chatId for chat)
|
|
703
|
+
messageQueue.ensureQueue(anchor);
|
|
704
|
+
messageQueue.appendMessage(anchor, parsed);
|
|
458
705
|
if (!ds) {
|
|
459
|
-
// No active session
|
|
460
|
-
|
|
461
|
-
|
|
706
|
+
// No active session at this anchor — auto-create. This branch is mostly a
|
|
707
|
+
// safety net; the dispatcher routes here only when isSessionOwner() returns
|
|
708
|
+
// true, but races (between check and execution, or session-closed events)
|
|
709
|
+
// can land us here.
|
|
710
|
+
if (activeSessions.has(sessionKey(anchor, larkAppId))) {
|
|
711
|
+
logger.info(`[${larkAppId}] Session already exists for ${scope}-scope ${anchor}, skipping auto-create`);
|
|
462
712
|
return;
|
|
463
713
|
}
|
|
464
|
-
const
|
|
465
|
-
const
|
|
714
|
+
const autoCreateChatId = ctxChatId ?? data?.message?.chat_id ?? '';
|
|
715
|
+
const autoCreateChatType = ctxChatType ?? (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
|
|
466
716
|
const botCfg = getBot(larkAppId).config;
|
|
467
|
-
logger.info(`No active session for
|
|
717
|
+
logger.info(`No active session for ${scope}-scope ${anchor}, auto-creating new session...`);
|
|
468
718
|
refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);
|
|
469
719
|
const senderOId = data.sender?.sender_id?.open_id;
|
|
470
|
-
|
|
720
|
+
// For thread-scope: rootMessageId = anchor (real thread root).
|
|
721
|
+
// For chat-scope: rootMessageId = the message_id that triggered this auto-create
|
|
722
|
+
// (used as audit trail; routing key is chatId).
|
|
723
|
+
const rootIdForStore = scope === 'thread' ? anchor : parsed.messageId;
|
|
724
|
+
const session = sessionStore.createSession(autoCreateChatId, rootIdForStore, parsed.content.substring(0, 50), autoCreateChatType);
|
|
725
|
+
const now = Date.now();
|
|
471
726
|
session.larkAppId = larkAppId;
|
|
472
727
|
session.ownerOpenId = senderOId;
|
|
473
728
|
session.lastCallerOpenId = senderOId;
|
|
729
|
+
session.lastMessageAt = new Date(now).toISOString();
|
|
730
|
+
session.scope = scope;
|
|
474
731
|
sessionStore.updateSession(session);
|
|
475
|
-
// Oncall group: pin working dir from binding,
|
|
476
|
-
// (
|
|
477
|
-
|
|
732
|
+
// Oncall group: pin working dir from the chat-level binding, even if a
|
|
733
|
+
// sibling bot (running in another daemon) is the one that persisted it.
|
|
734
|
+
// Defaults auto-bind path mirrors handleNewTopic — keep both call sites
|
|
735
|
+
// in sync (this is the auto-create branch that fires when routing lands
|
|
736
|
+
// here without an active session, e.g. chat-scope first-reply paths).
|
|
737
|
+
let oncallEntry = findOncallChatForAnyBot(autoCreateChatId);
|
|
738
|
+
if (!oncallEntry) {
|
|
739
|
+
oncallEntry = await maybeAutoBindDefaultOncall(larkAppId, autoCreateChatId, autoCreateChatType);
|
|
740
|
+
}
|
|
741
|
+
// Cross-bot / chat-scope inheritance — see findInheritablePeer comments.
|
|
742
|
+
const inheritedFrom = !oncallEntry
|
|
743
|
+
? findInheritablePeer({
|
|
744
|
+
scope,
|
|
745
|
+
anchor,
|
|
746
|
+
chatId: autoCreateChatId,
|
|
747
|
+
chatType: autoCreateChatType,
|
|
748
|
+
selfAppId: larkAppId,
|
|
749
|
+
})
|
|
750
|
+
: null;
|
|
751
|
+
const pinnedWorkingDir = oncallEntry?.workingDir ?? inheritedFrom?.workingDir;
|
|
478
752
|
const newDs = {
|
|
479
753
|
session,
|
|
480
754
|
worker: null,
|
|
481
755
|
workerPort: null,
|
|
482
756
|
workerToken: null,
|
|
483
757
|
larkAppId,
|
|
484
|
-
chatId,
|
|
485
|
-
chatType,
|
|
486
|
-
|
|
758
|
+
chatId: autoCreateChatId,
|
|
759
|
+
chatType: autoCreateChatType,
|
|
760
|
+
scope,
|
|
761
|
+
spawnedAt: Date.parse(session.createdAt) || now,
|
|
487
762
|
cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
|
|
488
|
-
lastMessageAt:
|
|
763
|
+
lastMessageAt: now,
|
|
489
764
|
hasHistory: false,
|
|
490
|
-
pendingRepo: !
|
|
491
|
-
pendingPrompt:
|
|
765
|
+
pendingRepo: !pinnedWorkingDir,
|
|
766
|
+
pendingPrompt: promptContent,
|
|
492
767
|
pendingAttachments: attachments.length > 0 ? attachments : undefined,
|
|
493
768
|
pendingMentions: parsed.mentions,
|
|
494
769
|
ownerOpenId: senderOId,
|
|
495
770
|
currentTurnTitle: parsed.content.substring(0, 50),
|
|
496
|
-
workingDir:
|
|
771
|
+
workingDir: pinnedWorkingDir,
|
|
497
772
|
};
|
|
498
|
-
if (
|
|
499
|
-
newDs.session.workingDir =
|
|
773
|
+
if (pinnedWorkingDir) {
|
|
774
|
+
newDs.session.workingDir = pinnedWorkingDir;
|
|
500
775
|
sessionStore.updateSession(newDs.session);
|
|
501
776
|
}
|
|
502
|
-
activeSessions.set(sessionKey(
|
|
503
|
-
//
|
|
504
|
-
|
|
777
|
+
activeSessions.set(sessionKey(anchor, larkAppId), newDs);
|
|
778
|
+
// Pinned (oncall binding or inherited from peer bot in same thread):
|
|
779
|
+
// spawn CLI immediately, skip repo selection.
|
|
780
|
+
if (pinnedWorkingDir) {
|
|
505
781
|
const selfBot = getBot(larkAppId);
|
|
506
|
-
const prompt = buildNewTopicPrompt(
|
|
782
|
+
const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
|
|
507
783
|
forkWorker(newDs, prompt);
|
|
508
|
-
|
|
784
|
+
const reason = oncallEntry
|
|
785
|
+
? `oncall-bound chat ${autoCreateChatId}`
|
|
786
|
+
: `inherited from peer session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`;
|
|
787
|
+
logger.info(`[${tag(newDs)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
|
|
509
788
|
return;
|
|
510
789
|
}
|
|
511
790
|
// Show repo selection card (same as handleNewTopic)
|
|
@@ -515,17 +794,17 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
515
794
|
projects = scanMultipleProjects(scanDirs2);
|
|
516
795
|
}
|
|
517
796
|
if (projects.length > 0) {
|
|
518
|
-
lastRepoScan.set(
|
|
797
|
+
lastRepoScan.set(autoCreateChatId, projects);
|
|
519
798
|
const currentCwd = getSessionWorkingDir(newDs);
|
|
520
|
-
const cardJson = buildRepoSelectCard(projects, currentCwd,
|
|
521
|
-
newDs.repoCardMessageId = await sessionReply(
|
|
799
|
+
const cardJson = buildRepoSelectCard(projects, currentCwd, anchor);
|
|
800
|
+
newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
522
801
|
logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
|
|
523
802
|
}
|
|
524
803
|
else {
|
|
525
804
|
// No projects found — skip repo selection, spawn directly
|
|
526
805
|
newDs.pendingRepo = false;
|
|
527
806
|
const selfBot = getBot(larkAppId);
|
|
528
|
-
const prompt = buildNewTopicPrompt(
|
|
807
|
+
const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
|
|
529
808
|
forkWorker(newDs, prompt);
|
|
530
809
|
}
|
|
531
810
|
return;
|
|
@@ -533,41 +812,30 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
533
812
|
// Send message to worker via IPC
|
|
534
813
|
if (ds.worker && !ds.worker.killed) {
|
|
535
814
|
const dsBotCfgForMsg = getBot(ds.larkAppId).config;
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
title: prevTitle,
|
|
561
|
-
displayMode: prevMode,
|
|
562
|
-
imageKey: ds.currentImageKey,
|
|
563
|
-
});
|
|
564
|
-
saveFrozenCards(ds.session.sessionId, ds.frozenCards);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
// Mark new turn — next screen_update will create a fresh streaming card
|
|
568
|
-
ds.streamCardPending = true;
|
|
569
|
-
ds.currentTurnTitle = parsed.content.substring(0, 50);
|
|
570
|
-
persistStreamCardState(ds);
|
|
815
|
+
// Adopt mode: the adopted CLI is the user's external process and was
|
|
816
|
+
// never injected with botmux's skill / system prompt. Sending it the
|
|
817
|
+
// `<user_message>` / `<botmux_reminder>` / `<session_id>` wrappers
|
|
818
|
+
// surfaces those tags verbatim in its UI (the user reported Codex
|
|
819
|
+
// showing raw XML on every Lark message). Use the bridge raw-input
|
|
820
|
+
// builder for ALL adopt sessions regardless of cliId — transcript
|
|
821
|
+
// harvest (Claude bridge or Codex bridge) handles the reply path
|
|
822
|
+
// out-of-band.
|
|
823
|
+
const isBridge = !!ds.adoptedFrom;
|
|
824
|
+
const selfBot = getBot(ds.larkAppId);
|
|
825
|
+
const msgContent = isBridge
|
|
826
|
+
? buildBridgeInputContent(promptContent, {
|
|
827
|
+
attachments,
|
|
828
|
+
mentions: parsed.mentions,
|
|
829
|
+
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
|
|
830
|
+
})
|
|
831
|
+
: buildFollowUpContent(promptContent, ds.session.sessionId, {
|
|
832
|
+
attachments,
|
|
833
|
+
mentions: parsed.mentions,
|
|
834
|
+
isAdoptMode: false,
|
|
835
|
+
cliId: dsBotCfgForMsg.cliId,
|
|
836
|
+
cliPathOverride: dsBotCfgForMsg.cliPathOverride,
|
|
837
|
+
});
|
|
838
|
+
beginNewTurn(ds, parsed.content);
|
|
571
839
|
ds.worker.send({ type: 'message', content: msgContent });
|
|
572
840
|
}
|
|
573
841
|
else {
|
|
@@ -576,104 +844,33 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
576
844
|
// card instead of PATCHing the previous turn's card in place.
|
|
577
845
|
logger.info(`[${tag(ds)}] Worker not running, re-forking...`);
|
|
578
846
|
ds.currentTurnTitle = parsed.content.substring(0, 50);
|
|
847
|
+
// The cosmetic freeze step (above) is gated on a live worker. With no
|
|
848
|
+
// worker we just park the current card in frozenCards — the upcoming
|
|
849
|
+
// new POST will recall it. Parking instead of deleting preserves the
|
|
850
|
+
// "old card stays until a new one is live" invariant: if fork /
|
|
851
|
+
// worker_ready / POST fails, the user still sees the previous card.
|
|
852
|
+
parkStreamCard(ds);
|
|
579
853
|
ds.streamCardId = undefined;
|
|
580
854
|
ds.streamCardNonce = undefined;
|
|
581
855
|
persistStreamCardState(ds);
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
const infoPath = join(config.session.dataDir, 'bots-info.json');
|
|
601
|
-
if (existsSync(infoPath)) {
|
|
602
|
-
const entries = JSON.parse(readFileSync(infoPath, 'utf-8'));
|
|
603
|
-
const sender = entries.find(e => e.larkAppId === signal.senderAppId);
|
|
604
|
-
if (sender)
|
|
605
|
-
senderName = sender.botName ?? getCliDisplayName(sender.cliId);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
catch { /* ignore */ }
|
|
609
|
-
const enrichedParts = [`[来自 ${senderName} 的 @mention]\n${signal.content}`];
|
|
610
|
-
if (!ds.adoptedFrom) {
|
|
611
|
-
const mentionBotCfg = getBot(ds.larkAppId).config;
|
|
612
|
-
const mentionAdapter = createCliAdapterSync(mentionBotCfg.cliId, mentionBotCfg.cliPathOverride);
|
|
613
|
-
if (!mentionAdapter.injectsSessionContext) {
|
|
614
|
-
enrichedParts.push(`Session ID: ${ds.session.sessionId}`);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
const enrichedContent = enrichedParts.join('\n\n');
|
|
618
|
-
ds.lastMessageAt = Date.now();
|
|
619
|
-
ds.streamCardPending = true;
|
|
620
|
-
ds.currentTurnTitle = signal.content.substring(0, 50);
|
|
621
|
-
persistStreamCardState(ds);
|
|
622
|
-
ds.worker.send({ type: 'message', content: enrichedContent });
|
|
623
|
-
logger.info(`[bot-mention] Routed message from ${signal.senderAppId} to ${targetAppId} in thread ${signal.rootMessageId}`);
|
|
624
|
-
}
|
|
625
|
-
else {
|
|
626
|
-
logger.debug(`[bot-mention] Target bot ${targetAppId} has no active worker for thread ${signal.rootMessageId}`);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
function isSignalForMe(signal) {
|
|
630
|
-
return getAllBots().some(b => b.botOpenId === signal.targetBotOpenId);
|
|
631
|
-
}
|
|
632
|
-
function startBotMentionWatcher() {
|
|
633
|
-
const signalDir = join(config.session.dataDir, 'bot-mentions');
|
|
634
|
-
if (!existsSync(signalDir))
|
|
635
|
-
mkdirSync(signalDir, { recursive: true });
|
|
636
|
-
// Process any existing signal files (from before daemon started)
|
|
637
|
-
try {
|
|
638
|
-
for (const file of readdirSync(signalDir)) {
|
|
639
|
-
if (!file.endsWith('.json'))
|
|
640
|
-
continue;
|
|
641
|
-
const filePath = join(signalDir, file);
|
|
642
|
-
try {
|
|
643
|
-
const signal = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
644
|
-
if (!isSignalForMe(signal))
|
|
645
|
-
continue; // not for this daemon, leave for target
|
|
646
|
-
unlinkSync(filePath);
|
|
647
|
-
processBotMentionSignal(signal);
|
|
648
|
-
}
|
|
649
|
-
catch (err) {
|
|
650
|
-
logger.debug(`[bot-mention] Failed to process signal ${file}: ${err}`);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
856
|
+
// Wrap the user message in the same `<user_message>` / `<session_id>` /
|
|
857
|
+
// `<botmux_reminder>` envelope as live-worker turns. Without this, the
|
|
858
|
+
// initial prompt that worker queues for the freshly-spawned CLI is the
|
|
859
|
+
// raw user text — the CLI sees no botmux routing context and stops calling
|
|
860
|
+
// `botmux send`, posting answers to its own terminal instead. Hits resume
|
|
861
|
+
// (after /close) and daemon-restart paths; both go through this branch
|
|
862
|
+
// because worker=null at that point.
|
|
863
|
+
const dsBotCfgForFork = getBot(ds.larkAppId).config;
|
|
864
|
+
const selfBot = getBot(ds.larkAppId);
|
|
865
|
+
const wrappedPrompt = buildReforkPrompt(ds, promptContent, {
|
|
866
|
+
attachments,
|
|
867
|
+
mentions: parsed.mentions,
|
|
868
|
+
cliId: dsBotCfgForFork.cliId,
|
|
869
|
+
cliPathOverride: dsBotCfgForFork.cliPathOverride,
|
|
870
|
+
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
|
|
871
|
+
});
|
|
872
|
+
forkWorker(ds, wrappedPrompt, ds.hasHistory);
|
|
653
873
|
}
|
|
654
|
-
catch { /* ignore */ }
|
|
655
|
-
// Watch for new signal files
|
|
656
|
-
watch(signalDir, (event, filename) => {
|
|
657
|
-
if (event !== 'rename' || !filename?.endsWith('.json'))
|
|
658
|
-
return;
|
|
659
|
-
const filePath = join(signalDir, filename);
|
|
660
|
-
// Small delay to ensure the file is fully written
|
|
661
|
-
setTimeout(() => {
|
|
662
|
-
try {
|
|
663
|
-
if (!existsSync(filePath))
|
|
664
|
-
return; // already processed or deleted
|
|
665
|
-
const signal = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
666
|
-
if (!isSignalForMe(signal))
|
|
667
|
-
return; // not for this daemon, leave for target
|
|
668
|
-
unlinkSync(filePath);
|
|
669
|
-
processBotMentionSignal(signal);
|
|
670
|
-
}
|
|
671
|
-
catch (err) {
|
|
672
|
-
logger.debug(`[bot-mention] Failed to process signal ${filename}: ${err}`);
|
|
673
|
-
}
|
|
674
|
-
}, 50);
|
|
675
|
-
});
|
|
676
|
-
logger.info(`[bot-mention] Watching for signals in ${signalDir}`);
|
|
677
874
|
}
|
|
678
875
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
679
876
|
export async function startDaemon(botIndex) {
|
|
@@ -689,19 +886,70 @@ export async function startDaemon(botIndex) {
|
|
|
689
886
|
const cfg = botConfigs[idx];
|
|
690
887
|
registerBot(cfg);
|
|
691
888
|
sessionStore.init(cfg.larkAppId);
|
|
889
|
+
chatFirstSeenStore.init(cfg.larkAppId);
|
|
890
|
+
// Watch schedules.json for external writes (e.g. `botmux schedule add`
|
|
891
|
+
// running in a separate node process) so dashboard event bus stays in sync.
|
|
892
|
+
scheduleStore.startExternalWriteWatcher();
|
|
692
893
|
logger.info(`Bot ${idx}/${botConfigs.length}: ${cfg.larkAppId} (cli: ${cfg.cliId})`);
|
|
693
894
|
writePidFile();
|
|
895
|
+
// Publish self-descriptor for the dashboard registry. The dashboard sibling
|
|
896
|
+
// process discovers running daemons by scanning ~/.botmux/data/dashboard-daemons/
|
|
897
|
+
// and watching for mtime updates (heartbeat) / file removal (shutdown).
|
|
898
|
+
const ipcPort = config.dashboard.ipcBasePort + idx;
|
|
899
|
+
const desc = {
|
|
900
|
+
larkAppId: cfg.larkAppId,
|
|
901
|
+
botName: cfg.larkAppId,
|
|
902
|
+
botIndex: idx,
|
|
903
|
+
ipcPort,
|
|
904
|
+
pid: process.pid,
|
|
905
|
+
startedAt: Date.now(),
|
|
906
|
+
lastHeartbeat: Date.now(),
|
|
907
|
+
// Strip email-form entries — the dashboard only needs resolved open_ids,
|
|
908
|
+
// and the email→open_id resolution below will rewrite this field.
|
|
909
|
+
resolvedAllowedUsers: getBot(cfg.larkAppId).resolvedAllowedUsers.filter(u => !u.includes('@')),
|
|
910
|
+
};
|
|
694
911
|
// Initialise worker pool with daemon callbacks
|
|
695
912
|
initWorkerPool({
|
|
696
913
|
sessionReply,
|
|
697
914
|
getSessionWorkingDir,
|
|
698
915
|
getActiveCount,
|
|
699
916
|
closeSession(ds) {
|
|
700
|
-
|
|
701
|
-
|
|
917
|
+
// Route through the dashboard-aware helper so session.exited / session.update
|
|
918
|
+
// events fire for withdrawn-message / crash / adopt-exit teardown paths too,
|
|
919
|
+
// matching the dashboard-driven close.
|
|
920
|
+
void closeSessionHelper(ds.session.sessionId).catch(() => { });
|
|
702
921
|
logger.info(`[${ds.session.sessionId.substring(0, 8)}] Session auto-closed (message withdrawn)`);
|
|
703
922
|
},
|
|
704
923
|
});
|
|
924
|
+
// Expose the activeSessions Map (owned by daemon) to worker-pool readers,
|
|
925
|
+
// so dashboard IPC and other consumers can list/lookup live sessions.
|
|
926
|
+
setActiveSessionsRegistry(activeSessions);
|
|
927
|
+
// Seed dashboard IPC botName with the bot's config id; the friendly name from
|
|
928
|
+
// /bot/v3/info is wired into the registry descriptor (below) but the IPC server
|
|
929
|
+
// also needs its own copy for SessionRow.botName.
|
|
930
|
+
setBotName(cfg.larkAppId);
|
|
931
|
+
setLarkAppId(cfg.larkAppId);
|
|
932
|
+
// Bind dashboard IPC HTTP server BEFORE publishing the registry descriptor.
|
|
933
|
+
// Otherwise the dashboard process can race-fetch the IPC port from the
|
|
934
|
+
// descriptor and hit ECONNREFUSED before we're listening — that left every
|
|
935
|
+
// newly-started daemon's hydrate failing on dashboard startup. Binds to
|
|
936
|
+
// 127.0.0.1 only since the dashboard sibling runs on the same host.
|
|
937
|
+
const ipcHandle = await startIpcServer({ port: ipcPort, host: '127.0.0.1' });
|
|
938
|
+
logger.info(`[dashboard-ipc] listening on 127.0.0.1:${ipcHandle.port} (bot ${idx})`);
|
|
939
|
+
// Now that the IPC port is actually listening, publish the descriptor so
|
|
940
|
+
// the dashboard can discover us and successfully fetch /api/sessions etc.
|
|
941
|
+
desc.lastHeartbeat = Date.now();
|
|
942
|
+
writeDaemonDescriptor(desc);
|
|
943
|
+
const descriptorHeartbeat = setInterval(() => {
|
|
944
|
+
desc.lastHeartbeat = Date.now();
|
|
945
|
+
try {
|
|
946
|
+
writeDaemonDescriptor(desc);
|
|
947
|
+
}
|
|
948
|
+
catch { /* best effort */ }
|
|
949
|
+
}, 30_000);
|
|
950
|
+
// Don't keep the event loop alive on this interval alone.
|
|
951
|
+
if (typeof descriptorHeartbeat.unref === 'function')
|
|
952
|
+
descriptorHeartbeat.unref();
|
|
705
953
|
// Per-bot initialization
|
|
706
954
|
for (const bot of getAllBots()) {
|
|
707
955
|
const cfg = bot.config;
|
|
@@ -719,20 +967,58 @@ export async function startDaemon(botIndex) {
|
|
|
719
967
|
logger.warn(`[${cfg.larkAppId}] Failed to resolve allowedUsers: ${err.message}`);
|
|
720
968
|
}
|
|
721
969
|
}
|
|
970
|
+
// Republish the descriptor with the post-resolution open_ids so the
|
|
971
|
+
// dashboard's create-group flow can pick this bot as creator using the
|
|
972
|
+
// operator's scope-correct open_id. Best-effort; the periodic heartbeat
|
|
973
|
+
// will eventually catch up too.
|
|
974
|
+
desc.resolvedAllowedUsers = bot.resolvedAllowedUsers.filter(u => !u.includes('@'));
|
|
975
|
+
try {
|
|
976
|
+
writeDaemonDescriptor(desc);
|
|
977
|
+
}
|
|
978
|
+
catch { /* best effort */ }
|
|
722
979
|
}
|
|
723
|
-
// Probe bot open_id and persist to bots-info.json
|
|
980
|
+
// Probe bot open_id and persist to bots-info.json. When the friendly
|
|
981
|
+
// botName comes back from /bot/v3/info, refresh the dashboard descriptor
|
|
982
|
+
// so the registry shows "Claude" / "Codex" instead of the raw app id.
|
|
724
983
|
probeBotOpenId(cfg.larkAppId).then(() => {
|
|
725
984
|
writeBotInfoFile(config.session.dataDir);
|
|
985
|
+
const probedName = bot.botName;
|
|
986
|
+
if (probedName && probedName !== desc.botName) {
|
|
987
|
+
desc.botName = probedName;
|
|
988
|
+
try {
|
|
989
|
+
writeDaemonDescriptor(desc);
|
|
990
|
+
}
|
|
991
|
+
catch { /* best effort */ }
|
|
992
|
+
}
|
|
726
993
|
}).catch(err => {
|
|
727
|
-
|
|
994
|
+
// Probe runs in background and is retried by the periodic heartbeat;
|
|
995
|
+
// a single failure here is not actionable. Surface as debug only.
|
|
996
|
+
logger.debug(`[${cfg.larkAppId}] Bot open_id probe failed (will retry): ${err.message}`);
|
|
997
|
+
});
|
|
998
|
+
// Required-scope check: 启动后 best-effort 校验
|
|
999
|
+
// im:message.group_at_msg.include_bot:readonly。缺失会 logger.error +
|
|
1000
|
+
// 私信 allowedUsers[0]。校验异步,跑失败不影响 daemon。
|
|
1001
|
+
checkRequiredScopes(cfg.larkAppId).catch(err => {
|
|
1002
|
+
logger.debug(`[${cfg.larkAppId}] required-scope check failed: ${err?.message ?? err}`);
|
|
728
1003
|
});
|
|
729
1004
|
// Start event dispatcher for this bot
|
|
730
1005
|
startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, {
|
|
731
1006
|
handleCardAction: (data, appId) => handleCardAction(data, cardDeps, appId),
|
|
732
|
-
handleNewTopic: (data,
|
|
733
|
-
handleThreadReply: (data,
|
|
734
|
-
isSessionOwner: (
|
|
735
|
-
|
|
1007
|
+
handleNewTopic: (data, ctx) => handleNewTopic(data, ctx),
|
|
1008
|
+
handleThreadReply: (data, ctx) => handleThreadReply(data, ctx),
|
|
1009
|
+
isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)),
|
|
1010
|
+
// Chat was converted 普通群 → 话题群 while we held a chat-scope session.
|
|
1011
|
+
// Evict it from the routing map so subsequent inbound messages can land
|
|
1012
|
+
// on a fresh thread-scope session (dispatcher already rerouted this turn
|
|
1013
|
+
// to handleNewTopic). The worker is left running on purpose: the user may
|
|
1014
|
+
// still have its web terminal open, and `/close` is the canonical cleanup
|
|
1015
|
+
// path. Scheduler tasks tied to this session keep their `scope='chat'`
|
|
1016
|
+
// semantics — that's an edge case worth following up on, not blocking
|
|
1017
|
+
// the main fix.
|
|
1018
|
+
onChatModeConverted: (chatId, appId) => {
|
|
1019
|
+
const key = sessionKey(chatId, appId);
|
|
1020
|
+
const evicted = activeSessions.delete(key);
|
|
1021
|
+
logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=${evicted}; worker (if any) keeps running until /close`);
|
|
736
1022
|
},
|
|
737
1023
|
});
|
|
738
1024
|
}
|
|
@@ -745,14 +1031,13 @@ export async function startDaemon(botIndex) {
|
|
|
745
1031
|
scheduler.setExecuteCallback((task) => executeScheduledTask(task, activeSessions, refreshCliVersion));
|
|
746
1032
|
scheduler.setOwnerFilter(cfg.larkAppId, idx === 0);
|
|
747
1033
|
scheduler.startScheduler();
|
|
748
|
-
// Watch for bot-to-bot mention signals from MCP send_to_thread tool.
|
|
749
|
-
// Lark WSClient does not deliver events for bot-sent messages, so the MCP
|
|
750
|
-
// tool writes signal files that the daemon picks up and routes internally.
|
|
751
|
-
startBotMentionWatcher();
|
|
752
1034
|
// Graceful shutdown
|
|
753
1035
|
const shutdown = () => {
|
|
754
1036
|
logger.info(`Daemon shutting down... (active: ${getActiveCount()})`);
|
|
755
1037
|
scheduler.stopScheduler();
|
|
1038
|
+
clearInterval(descriptorHeartbeat);
|
|
1039
|
+
removeDaemonDescriptor(cfg.larkAppId);
|
|
1040
|
+
ipcHandle.close().catch(() => { });
|
|
756
1041
|
for (const [, ds] of activeSessions) {
|
|
757
1042
|
if (ds.worker && !ds.worker.killed) {
|
|
758
1043
|
logger.info(`Shutting down worker for session ${ds.session.sessionId}`);
|
|
@@ -780,6 +1065,13 @@ export async function startDaemon(botIndex) {
|
|
|
780
1065
|
};
|
|
781
1066
|
process.on('SIGTERM', shutdown);
|
|
782
1067
|
process.on('SIGINT', shutdown);
|
|
1068
|
+
// Best-effort cleanup on plain `exit` (e.g. uncaught fatal). No worker
|
|
1069
|
+
// shutdown here since the process is already on its way out — just remove
|
|
1070
|
+
// the descriptor so the dashboard doesn't see a phantom daemon.
|
|
1071
|
+
process.on('exit', () => {
|
|
1072
|
+
clearInterval(descriptorHeartbeat);
|
|
1073
|
+
removeDaemonDescriptor(cfg.larkAppId);
|
|
1074
|
+
});
|
|
783
1075
|
logger.info('Daemon is running. Press Ctrl+C to stop.');
|
|
784
1076
|
}
|
|
785
1077
|
//# sourceMappingURL=daemon.js.map
|