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
package/dist/cli.js
CHANGED
|
@@ -14,14 +14,26 @@
|
|
|
14
14
|
* botmux list --plain — plain table output (for piping / scripts)
|
|
15
15
|
* botmux delete <id> — close a session by ID prefix
|
|
16
16
|
* botmux delete all — close all active sessions
|
|
17
|
+
* botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd)
|
|
17
18
|
*/
|
|
18
19
|
import { execSync, spawnSync, spawn } from 'node:child_process';
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync } from 'node:fs';
|
|
20
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync } from 'node:fs';
|
|
20
21
|
import { join, dirname } from 'node:path';
|
|
21
22
|
import { homedir } from 'node:os';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
23
24
|
import { createInterface } from 'node:readline';
|
|
24
25
|
import { createRequire } from 'node:module';
|
|
26
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
27
|
+
import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
|
|
28
|
+
import { tmuxEnv } from './setup/ensure-tmux.js';
|
|
29
|
+
import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
|
|
30
|
+
import { logger } from './utils/logger.js';
|
|
31
|
+
import { firstPositional } from './cli/arg-utils.js';
|
|
32
|
+
// CLI subcommands (send/thread/bots/list/etc) print JSON to stdout for
|
|
33
|
+
// callers to parse. Transitive logger.info calls from shared modules would
|
|
34
|
+
// corrupt that stream, so the CLI process runs silent by default. DEBUG=1
|
|
35
|
+
// re-enables logging end-to-end for CLI troubleshooting.
|
|
36
|
+
logger.setSilent(true);
|
|
25
37
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
38
|
const __dirname = dirname(__filename);
|
|
27
39
|
const require = createRequire(import.meta.url);
|
|
@@ -106,6 +118,21 @@ function ecosystemConfig() {
|
|
|
106
118
|
out_file: join(LOG_DIR, `daemon-${i}-out.log`),
|
|
107
119
|
env: { SESSION_DATA_DIR: DATA_DIR, BOTMUX_BOT_INDEX: String(i) },
|
|
108
120
|
}));
|
|
121
|
+
apps.push({
|
|
122
|
+
name: 'botmux-dashboard',
|
|
123
|
+
script: join(PKG_ROOT, 'dist', 'dashboard.js'),
|
|
124
|
+
cwd: PKG_ROOT,
|
|
125
|
+
autorestart: true,
|
|
126
|
+
max_restarts: 10,
|
|
127
|
+
restart_delay: 3000,
|
|
128
|
+
error_file: join(LOG_DIR, 'dashboard-error.log'),
|
|
129
|
+
out_file: join(LOG_DIR, 'dashboard-out.log'),
|
|
130
|
+
merge_logs: true,
|
|
131
|
+
env: {
|
|
132
|
+
BOTMUX_DASHBOARD_HOST: process.env.BOTMUX_DASHBOARD_HOST ?? '0.0.0.0',
|
|
133
|
+
BOTMUX_DASHBOARD_PORT: process.env.BOTMUX_DASHBOARD_PORT ?? '7891',
|
|
134
|
+
},
|
|
135
|
+
});
|
|
109
136
|
const cfg = { apps };
|
|
110
137
|
const tmpFile = join(CONFIG_DIR, 'ecosystem.config.json');
|
|
111
138
|
writeFileSync(tmpFile, JSON.stringify(cfg, null, 2));
|
|
@@ -118,32 +145,211 @@ function ask(rl, question) {
|
|
|
118
145
|
return new Promise(resolve => rl.question(question, resolve));
|
|
119
146
|
}
|
|
120
147
|
// ─── Setup helpers ──────────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
// Thin wrapper around setup/bots-store.writeBotsJsonAtomic so call-sites keep
|
|
149
|
+
// the same name without passing BOTS_JSON_FILE explicitly each time.
|
|
150
|
+
function writeBotsJsonAtomic(bots) {
|
|
151
|
+
writeBotsAtomic(BOTS_JSON_FILE, bots);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 从 bot 配置里取 brand. 旧的 bots.json (1.0 之前) 没这个字段, default 到 feishu
|
|
155
|
+
* 保留向后兼容. cmdStart 凭证校验 + printRemainingSteps 深链都靠它选 host.
|
|
156
|
+
*/
|
|
157
|
+
function botBrand(b) {
|
|
158
|
+
return b?.brand === 'lark' ? 'lark' : 'feishu';
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 把 botmux 推荐的完整 scope JSON (从 src/setup/lark-scopes.json) 写到
|
|
162
|
+
* 用户配置目录, 同时给出跨平台一键复制命令. JSON 长 (293 项, 297 行),
|
|
163
|
+
* terminal 直接打印用户也复制不了, 写文件 + pbcopy/xclip 才是顺手的姿势.
|
|
164
|
+
*
|
|
165
|
+
* Returns: 写出的 JSON 文件绝对路径.
|
|
166
|
+
*/
|
|
167
|
+
function writeScopesJsonToConfigDir() {
|
|
168
|
+
// build script 会把 src/setup/lark-scopes.json copy 到 dist/setup/.
|
|
169
|
+
// dist 模式下 __dirname 是 dist/, 找 ./setup/lark-scopes.json; dev (tsx)
|
|
170
|
+
// 模式找 src/setup/lark-scopes.json 在源码同目录也成立.
|
|
171
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
172
|
+
const srcCandidates = [
|
|
173
|
+
join(here, 'setup', 'lark-scopes.json'),
|
|
174
|
+
join(here, '..', 'src', 'setup', 'lark-scopes.json'),
|
|
175
|
+
];
|
|
176
|
+
let scopesPath = srcCandidates[0];
|
|
177
|
+
for (const p of srcCandidates) {
|
|
178
|
+
if (existsSync(p)) {
|
|
179
|
+
scopesPath = p;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const destPath = join(CONFIG_DIR, 'lark-scopes.json');
|
|
184
|
+
copyFileSync(scopesPath, destPath);
|
|
185
|
+
return destPath;
|
|
186
|
+
}
|
|
187
|
+
function printCopyHint(filePath) {
|
|
188
|
+
// 环境感知: SSH/headless 没有 X server, xclip 一定报 "Can't open display".
|
|
189
|
+
// 这种场景下"剪贴板"在用户本地 (运行 SSH 客户端的那台机器), 远程机上能做的:
|
|
190
|
+
// - 直接 cat, 让用户在本地 terminal 鼠标选中 (SSH 选中即写本地剪贴板)
|
|
191
|
+
// - OSC 52: terminal app 代写本地剪贴板, iTerm2 / kitty / WezTerm /
|
|
192
|
+
// Alacritty / tmux 1.5+ 都支持, gnome-terminal / Terminal.app 不支持
|
|
193
|
+
// 检测 DISPLAY (X11) 或 WAYLAND_DISPLAY 都没有, 或 SSH_* 环境变量存在
|
|
194
|
+
// → 当作 SSH 场景, 不推荐 xclip / pbcopy.
|
|
195
|
+
const isSsh = !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
|
|
196
|
+
const hasLocalGui = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) && !isSsh;
|
|
197
|
+
const isMacLocal = process.platform === 'darwin' && !isSsh;
|
|
198
|
+
console.log(' 把 JSON 内容拷到本地剪贴板, 然后到飞书"批量导入/导出权限"页粘贴:');
|
|
199
|
+
if (isMacLocal) {
|
|
200
|
+
console.log(` macOS 本地: cat ${filePath} | pbcopy`);
|
|
201
|
+
}
|
|
202
|
+
else if (hasLocalGui) {
|
|
203
|
+
console.log(` Linux 本地 (X 服务器): cat ${filePath} | xclip -selection clipboard`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// SSH / headless: 鼠标选中是最稳的, OSC 52 作为高级选项
|
|
207
|
+
console.log(` SSH 终端鼠标选中复制: cat ${filePath}`);
|
|
208
|
+
console.log(' (终端把选中的字符直接写到你本地剪贴板, 不依赖远端剪贴板工具)');
|
|
209
|
+
console.log(` 或 OSC 52 (兼容 iTerm2 / kitty / WezTerm / Alacritty / tmux 1.5+):`);
|
|
210
|
+
console.log(` base64 -w0 < ${filePath} | awk 'BEGIN{printf "\\033]52;c;"}{printf "%s",$0}END{printf "\\a"}'`);
|
|
211
|
+
}
|
|
212
|
+
console.log('');
|
|
132
213
|
}
|
|
214
|
+
function printRemainingSteps(appId, brand) {
|
|
215
|
+
// PersonalAgent 应用扫码建出来时已默认订阅 im.message.receive_v1 +
|
|
216
|
+
// card.action.trigger, 并开通 bot 能力, 主线只剩两步: 申请权限 + 重定向
|
|
217
|
+
// URL (按需). README "Step 8 收不到消息时" 段提供 fallback 自查链接.
|
|
218
|
+
const host = brand === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
|
|
219
|
+
const home = `https://${host}/app/${appId}`;
|
|
220
|
+
let scopesJsonPath = '';
|
|
221
|
+
try {
|
|
222
|
+
scopesJsonPath = writeScopesJsonToConfigDir();
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
// 不应阻止 setup 完成, 只 WARN
|
|
226
|
+
console.log(`\n⚠️ 写权限 JSON 失败 (${err.message}), 请手动从仓库源码 src/setup/lark-scopes.json 拷.`);
|
|
227
|
+
}
|
|
228
|
+
console.log('\n剩余两步在开放平台完成:\n');
|
|
229
|
+
console.log(' 1. 申请权限 (一次性导入完整 JSON 提交审批)');
|
|
230
|
+
console.log(` 申请链接: ${home}/auth → 进入「权限管理」→「批量导入/导出权限」→ 粘贴 → 提交`);
|
|
231
|
+
if (scopesJsonPath) {
|
|
232
|
+
console.log(` 权限 JSON: ${scopesJsonPath}`);
|
|
233
|
+
printCopyHint(scopesJsonPath);
|
|
234
|
+
}
|
|
235
|
+
console.log('');
|
|
236
|
+
console.log(' 2. 添加重定向 URL (用于 botmux 内 `/login` 拿用户 UAT 获取卡片消息)');
|
|
237
|
+
console.log(` 申请链接: ${home}/safe → 进入「安全设置」→「重定向 URL」`);
|
|
238
|
+
console.log(' 填入: http://127.0.0.1:9768/callback');
|
|
239
|
+
console.log(' 不需要 `/login` 拿卡片消息的话, 这一步可以跳过.\n');
|
|
240
|
+
console.log(' 完成后 `botmux start` (或 `botmux restart`),启动检查不会卡住,');
|
|
241
|
+
console.log(' 缺权限只 WARN,去开放平台补齐后 daemon 自动恢复。\n');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* 让用户选"扫码建应用"还是"手动粘 AppID/Secret".
|
|
245
|
+
*
|
|
246
|
+
* 默认走扫码: 调 SDK `registerApp` → 拿 client_id/client_secret. 失败 (用户拒绝/
|
|
247
|
+
* 超时/网络/取消) 一律降级到手动, 不阻塞流程.
|
|
248
|
+
*
|
|
249
|
+
* Codex review 边界:
|
|
250
|
+
* - secret 不进 argv / 日志 / 错误链 (registerApp 内部 safeMsg 已做; 手动模式下
|
|
251
|
+
* AppSecret 通过 rl.question 异步读取, 不会出现在 process.argv)
|
|
252
|
+
* - 任何失败都返回结构化对象, 不抛 (调用方根据 ok=false 回退)
|
|
253
|
+
*/
|
|
254
|
+
async function obtainCredentials(rl) {
|
|
255
|
+
console.log('── 飞书应用建立 ──\n');
|
|
256
|
+
console.log('1) 扫码建应用(推荐,一步拿到 AppID/Secret,需要飞书 App 扫码)');
|
|
257
|
+
console.log('2) 手动粘 AppID/Secret(已经在开放平台创建好应用了)\n');
|
|
258
|
+
const choice = (await ask(rl, '选择 [1]: ')).trim();
|
|
259
|
+
if (choice !== '2') {
|
|
260
|
+
// 动态导入避免冷启动加载 SDK
|
|
261
|
+
const { tryRegisterApp } = await import('./setup/register-app.js');
|
|
262
|
+
const result = await tryRegisterApp();
|
|
263
|
+
if (result.ok) {
|
|
264
|
+
// Lark 国际版需要 daemon 链路全程走 larksuite.com 域 (Client domain /
|
|
265
|
+
// WSClient / event-dispatcher 的 fetch URL / scope 深链 host). 当前
|
|
266
|
+
// botmux runtime 这几处都硬编码 feishu.cn, 所以即使扫码成功了也无法
|
|
267
|
+
// 真正跑起来. 干净做法是 setup 阶段就拒绝, 让用户用 feishu 租户. 单
|
|
268
|
+
// 独 PR 完整接入 lark 后再去掉这个分支.
|
|
269
|
+
if (result.brand === 'lark') {
|
|
270
|
+
console.log(`\n❌ 检测到 Lark 国际版 (larksuite.com) 租户。`);
|
|
271
|
+
console.log(` botmux 当前 daemon 运行链路仅支持飞书 (feishu.cn) 租户,`);
|
|
272
|
+
console.log(` Lark 国际版完整接入会在单独 PR 跟进 (BotConfig / Client domain /`);
|
|
273
|
+
console.log(` WSClient / event-dispatcher 等需要一并支持).`);
|
|
274
|
+
console.log(` 请用飞书 (feishu.cn) 租户重试 setup。\n`);
|
|
275
|
+
return { ok: false, reason: 'lark_unsupported' };
|
|
276
|
+
}
|
|
277
|
+
console.log(`\n✅ 应用创建成功`);
|
|
278
|
+
console.log(` App ID: ${result.appId}`);
|
|
279
|
+
console.log(` 租户类型: ${result.brand}`);
|
|
280
|
+
if (result.userOpenId) {
|
|
281
|
+
console.log(` 扫码人 open_id: ${result.userOpenId}(将默认作为 allowedUsers)`);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
appId: result.appId,
|
|
286
|
+
appSecret: result.appSecret,
|
|
287
|
+
brand: result.brand,
|
|
288
|
+
userOpenId: result.userOpenId,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
console.log(`\n⚠️ 扫码失败 (${result.error}): ${result.message}`);
|
|
292
|
+
if (result.error === 'aborted') {
|
|
293
|
+
// 用户主动取消整个 setup, 不再问手动 fallback
|
|
294
|
+
return { ok: false, reason: 'cancelled' };
|
|
295
|
+
}
|
|
296
|
+
console.log(' 降级到手动输入 AppID/Secret。\n');
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
console.log('\n请在浏览器打开 https://open.feishu.cn/app 创建应用,然后回来粘 ID/Secret。\n');
|
|
300
|
+
}
|
|
301
|
+
// 手动 fallback. 不再提问租户类型 — 当前 daemon runtime 只支持 feishu,
|
|
302
|
+
// 让用户选 lark 是误导. 等 lark 完整接入再加回来.
|
|
303
|
+
const appId = (await ask(rl, 'AppID (cli_xxx): ')).trim();
|
|
304
|
+
const appSecret = (await ask(rl, 'AppSecret: ')).trim();
|
|
305
|
+
if (!appId || !appSecret) {
|
|
306
|
+
console.log('\n❌ AppID/AppSecret 不能为空,setup 中止。');
|
|
307
|
+
return { ok: false, reason: 'cancelled' };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, appId, appSecret, brand: 'feishu' };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 收集一个机器人完整配置 (凭证 + CLI/工作目录/allowedUsers).
|
|
313
|
+
*
|
|
314
|
+
* 顺序: 拿凭证 → tenant_access_token 验证 → 通过才返回 bot 对象. 验证失败
|
|
315
|
+
* 直接返回 null, 调用方负责"不写 bots.json". Codex review 边界 #2.
|
|
316
|
+
*/
|
|
133
317
|
async function promptBotConfig(rl) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
318
|
+
const creds = await obtainCredentials(rl);
|
|
319
|
+
if (!creds.ok)
|
|
320
|
+
return null;
|
|
321
|
+
// 凭证立刻验证. 通不过不写 bots.json.
|
|
322
|
+
console.log('\n校验凭证(取 tenant_access_token)…');
|
|
323
|
+
const { validateCredentials } = await import('./setup/verify-permissions.js');
|
|
324
|
+
const v = await validateCredentials(creds.appId, creds.appSecret, creds.brand);
|
|
325
|
+
if (!v.ok) {
|
|
326
|
+
console.log(`\n❌ 凭证校验失败 (${v.error}): ${v.message}`);
|
|
327
|
+
console.log(' 不写 bots.json。请重新运行 botmux setup。');
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
console.log('✅ 凭证有效(tenant_access_token 已成功获取)\n');
|
|
331
|
+
console.log('支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) gemini 6) opencode');
|
|
137
332
|
const cliChoice = await ask(rl, 'CLI 适配器 [1]: ');
|
|
138
333
|
const cliIdMap = { '1': 'claude-code', '2': 'aiden', '3': 'coco', '4': 'codex', '5': 'gemini', '6': 'opencode' };
|
|
139
334
|
const cliId = cliIdMap[cliChoice] ?? (cliChoice || 'claude-code');
|
|
140
335
|
const workingDir = await ask(rl, '默认工作目录 [~]: ');
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
336
|
+
// 不再持久化 brand 字段: setup 阶段 brand=lark 直接被 obtainCredentials 中止,
|
|
337
|
+
// 落盘的永远是 'feishu', 写进配置是死字段. 等 lark 完整接入再加回来, 那时
|
|
338
|
+
// 同步打开 daemon 链路的 brand 透传. botBrand() helper 读不到字段会 default
|
|
339
|
+
// 到 'feishu', 兼容旧版同时支持将来扩展.
|
|
340
|
+
const bot = {
|
|
341
|
+
larkAppId: creds.appId,
|
|
342
|
+
larkAppSecret: creds.appSecret,
|
|
343
|
+
cliId,
|
|
344
|
+
// 总是写 workingDir, 留空用 '~'. 用户手动编辑 bots.json 时一眼能看到字段
|
|
345
|
+
// 在哪儿, 不用去 README 查字段名.
|
|
346
|
+
workingDir: workingDir.trim() || '~',
|
|
347
|
+
};
|
|
348
|
+
// 不再问 allowedUsers — 扫码场景默认填扫码人自己 (registerApp 返回里有 open_id);
|
|
349
|
+
// 想改成多人共用 / 不限制, 用户后续手动编辑 ~/.botmux/bots.json 的 allowedUsers
|
|
350
|
+
// 字段即可. 手动 fallback 场景没 open_id, 字段直接不写 (== 不限制).
|
|
351
|
+
if (creds.userOpenId)
|
|
352
|
+
bot.allowedUsers = [creds.userOpenId];
|
|
147
353
|
return bot;
|
|
148
354
|
}
|
|
149
355
|
/** Parse .env file to extract bot config for migration to bots.json */
|
|
@@ -177,16 +383,25 @@ function parseDotEnvToBotConfig() {
|
|
|
177
383
|
bot.projectScanDir = vars.PROJECT_SCAN_DIR;
|
|
178
384
|
return bot;
|
|
179
385
|
}
|
|
180
|
-
/**
|
|
386
|
+
/**
|
|
387
|
+
* 收集一个机器人配置并写盘 (单机器人 fresh install / 重新配置).
|
|
388
|
+
*
|
|
389
|
+
* 失败路径 (扫码取消 / 凭证校验不通过): 不创建任何配置文件, 不动旧 .env.
|
|
390
|
+
* Codex review 边界 #2: 中途失败一律不留半截 JSON.
|
|
391
|
+
*/
|
|
181
392
|
async function writeSingleBotConfig() {
|
|
182
|
-
console.log('── 飞书应用配置 ──\n');
|
|
183
|
-
printLarkPermissions();
|
|
184
393
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
185
394
|
const bot = await promptBotConfig(rl);
|
|
186
395
|
rl.close();
|
|
187
|
-
|
|
396
|
+
if (!bot)
|
|
397
|
+
return false;
|
|
398
|
+
writeBotsJsonAtomic([bot]);
|
|
188
399
|
console.log(`\n✅ 配置已写入: ${BOTS_JSON_FILE}`);
|
|
189
|
-
|
|
400
|
+
printRemainingSteps(bot.larkAppId, botBrand(bot));
|
|
401
|
+
console.log(`下一步:`);
|
|
402
|
+
console.log(` 1. botmux start 启动 daemon`);
|
|
403
|
+
console.log(` 2. botmux autostart enable 注册开机自启(推荐:${process.platform === 'darwin' ? 'mac launchd' : process.platform === 'linux' ? 'linux user systemd' : '当前平台暂不支持'},无需 sudo)`);
|
|
404
|
+
return true;
|
|
190
405
|
}
|
|
191
406
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
192
407
|
async function cmdSetup() {
|
|
@@ -207,26 +422,36 @@ async function cmdSetup() {
|
|
|
207
422
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
208
423
|
const action = await ask(rl, '操作: 1) 添加新机器人 2) 重新配置 (1/2) [1]: ');
|
|
209
424
|
if (action === '2') {
|
|
210
|
-
renameSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
|
|
211
|
-
console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak\n`);
|
|
212
425
|
console.log('\n── 重新配置 ──\n');
|
|
213
|
-
printLarkPermissions();
|
|
214
426
|
const newBot = await promptBotConfig(rl);
|
|
215
427
|
rl.close();
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
428
|
+
if (!newBot) {
|
|
429
|
+
console.log('\n⚠️ setup 中止,旧配置保留不动。');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// Codex review #1: 先 copyFileSync 备份, 再原子写新文件. 之前先 rename
|
|
433
|
+
// 旧文件再 write, 一旦 write 失败 (磁盘/权限/进程被 kill) 用户就丢了
|
|
434
|
+
// bots.json. copy 之后写失败旧文件原地不动, .bak 是无害的同名副本.
|
|
435
|
+
copyFileSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
|
|
436
|
+
console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak`);
|
|
437
|
+
writeBotsJsonAtomic([newBot]);
|
|
438
|
+
console.log(`✅ 配置已写入: ${BOTS_JSON_FILE}`);
|
|
439
|
+
printRemainingSteps(newBot.larkAppId, botBrand(newBot));
|
|
440
|
+
console.log(`下一步: botmux restart\n`);
|
|
219
441
|
return;
|
|
220
442
|
}
|
|
221
443
|
console.log('\n── 添加新机器人 ──\n');
|
|
222
|
-
printLarkPermissions();
|
|
223
444
|
const newBot = await promptBotConfig(rl);
|
|
224
445
|
rl.close();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
446
|
+
if (!newBot) {
|
|
447
|
+
console.log('\n⚠️ setup 中止,bots.json 不动。');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
writeBotsJsonAtomic([...bots, newBot]);
|
|
451
|
+
console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length + 1} 个`);
|
|
228
452
|
console.log(` 配置文件: ${BOTS_JSON_FILE}`);
|
|
229
|
-
|
|
453
|
+
printRemainingSteps(newBot.larkAppId, botBrand(newBot));
|
|
454
|
+
console.log(`下一步: botmux restart\n`);
|
|
230
455
|
}
|
|
231
456
|
else if (hasEnv) {
|
|
232
457
|
// --- Single-bot mode (.env exists) ---
|
|
@@ -235,9 +460,11 @@ async function cmdSetup() {
|
|
|
235
460
|
const action = await ask(rl, '操作: 1) 添加新机器人 2) 覆盖当前配置 (1/2): ');
|
|
236
461
|
if (action === '2') {
|
|
237
462
|
rl.close();
|
|
238
|
-
await writeSingleBotConfig();
|
|
239
|
-
|
|
240
|
-
|
|
463
|
+
const ok = await writeSingleBotConfig();
|
|
464
|
+
if (ok) {
|
|
465
|
+
renameSync(ENV_FILE, ENV_FILE + '.bak');
|
|
466
|
+
console.log(` 旧 .env 已备份: ${ENV_FILE}.bak`);
|
|
467
|
+
}
|
|
241
468
|
return;
|
|
242
469
|
}
|
|
243
470
|
// Migrate .env → bots.json
|
|
@@ -250,16 +477,20 @@ async function cmdSetup() {
|
|
|
250
477
|
}
|
|
251
478
|
console.log(`\n当前机器人: ${existingBot.larkAppId} (${existingBot.cliId ?? 'claude-code'})`);
|
|
252
479
|
console.log('\n── 添加新机器人 ──\n');
|
|
253
|
-
printLarkPermissions();
|
|
254
480
|
const newBot = await promptBotConfig(rl);
|
|
255
481
|
rl.close();
|
|
256
|
-
|
|
257
|
-
|
|
482
|
+
if (!newBot) {
|
|
483
|
+
console.log('\n⚠️ setup 中止,.env 和 bots.json 都不动。');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// 写新文件成功后才备份 .env. 失败不动两边.
|
|
487
|
+
writeBotsJsonAtomic([existingBot, newBot]);
|
|
258
488
|
renameSync(ENV_FILE, ENV_FILE + '.bak');
|
|
259
489
|
console.log(`\n✅ 已迁移到多机器人配置`);
|
|
260
490
|
console.log(` 配置文件: ${BOTS_JSON_FILE}`);
|
|
261
491
|
console.log(` 旧配置已备份: ${ENV_FILE}.bak`);
|
|
262
|
-
|
|
492
|
+
printRemainingSteps(newBot.larkAppId, botBrand(newBot));
|
|
493
|
+
console.log(`下一步: botmux restart\n`);
|
|
263
494
|
}
|
|
264
495
|
else {
|
|
265
496
|
// --- Fresh install ---
|
|
@@ -342,7 +573,7 @@ function preflightNodeSanity() {
|
|
|
342
573
|
}
|
|
343
574
|
}
|
|
344
575
|
}
|
|
345
|
-
function cmdStart() {
|
|
576
|
+
async function cmdStart() {
|
|
346
577
|
if (!hasConfig()) {
|
|
347
578
|
console.error('❌ 未找到配置文件');
|
|
348
579
|
console.error(' 请先运行: botmux setup');
|
|
@@ -350,6 +581,41 @@ function cmdStart() {
|
|
|
350
581
|
}
|
|
351
582
|
ensureConfigDir();
|
|
352
583
|
preflightNodeSanity();
|
|
584
|
+
await ensureSystemDependencies();
|
|
585
|
+
// 启动前快速校验每个 bot 的凭证. Codex review 边界 #5: 凭证无效是
|
|
586
|
+
// 唯一应该阻塞 start 的情况; scope/event 缺失在 daemon 起来后用 WARN
|
|
587
|
+
// + 私信处理 (event-dispatcher.checkRequiredScopes).
|
|
588
|
+
//
|
|
589
|
+
// 失败时打印明确的 appId 前缀和错误码, 不打印 secret, 不 spawn pm2 进程.
|
|
590
|
+
const botsForCheck = loadBotsJson();
|
|
591
|
+
if (botsForCheck.length > 0) {
|
|
592
|
+
const { validateCredentials } = await import('./setup/verify-permissions.js');
|
|
593
|
+
const invalid = [];
|
|
594
|
+
for (const b of botsForCheck) {
|
|
595
|
+
if (!b.larkAppId || !b.larkAppSecret) {
|
|
596
|
+
invalid.push({ appId: b.larkAppId || '(空 appId)', reason: 'larkAppId/larkAppSecret 缺失' });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const v = await validateCredentials(b.larkAppId, b.larkAppSecret, botBrand(b));
|
|
600
|
+
if (!v.ok) {
|
|
601
|
+
if (v.error === 'invalid_credentials') {
|
|
602
|
+
invalid.push({ appId: b.larkAppId, reason: v.message });
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
// network / unknown — 不应该拦下启动, 走 WARN
|
|
606
|
+
console.warn(`⚠️ [${b.larkAppId}] 启动前凭证验证未成功(${v.error}): ${v.message}`);
|
|
607
|
+
console.warn(` daemon 仍会启动;启动后 dispatcher 会自行重试。`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (invalid.length > 0) {
|
|
612
|
+
console.error('\n❌ 以下机器人凭证无效,botmux start 中止:\n');
|
|
613
|
+
for (const e of invalid)
|
|
614
|
+
console.error(` - ${e.appId}: ${e.reason}`);
|
|
615
|
+
console.error('\n 修复方式: 运行 `botmux setup` 选 "重新配置" 重新走扫码/手动流程。');
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
353
619
|
cleanupLegacyPm2();
|
|
354
620
|
const cfg = ecosystemConfig();
|
|
355
621
|
runPm2(['start', cfg]);
|
|
@@ -358,6 +624,33 @@ function cmdStart() {
|
|
|
358
624
|
console.log(`\n✅ daemon 已启动${count > 1 ? ` (${count} 个机器人, 每个独立进程)` : ''}`);
|
|
359
625
|
console.log(` 日志: botmux logs`);
|
|
360
626
|
console.log(` 状态: botmux status`);
|
|
627
|
+
// If the user previously enabled autostart, sync the unit file in case
|
|
628
|
+
// node/cli.js paths changed since (nvm switch, npm upgrade, etc.).
|
|
629
|
+
if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
|
|
630
|
+
console.log(` autostart unit 已同步到当前 Node/cli.js 路径`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Wipe stale dashboard-daemon descriptors (mtime older than 5 minutes).
|
|
635
|
+
* Live daemons refresh their descriptor every 30s via heartbeat; anything
|
|
636
|
+
* older is from a daemon that exited without cleaning up. Called as part of
|
|
637
|
+
* the pm2 zombie-cleanup flow so the dashboard registry stays consistent.
|
|
638
|
+
*/
|
|
639
|
+
function cleanupStaleDaemonDescriptors() {
|
|
640
|
+
const regDir = join(DATA_DIR, 'dashboard-daemons');
|
|
641
|
+
if (!existsSync(regDir))
|
|
642
|
+
return;
|
|
643
|
+
for (const f of readdirSync(regDir)) {
|
|
644
|
+
if (!f.endsWith('.json'))
|
|
645
|
+
continue;
|
|
646
|
+
const fp = join(regDir, f);
|
|
647
|
+
try {
|
|
648
|
+
const stat = statSync(fp);
|
|
649
|
+
if (Date.now() - stat.mtimeMs > 5 * 60_000)
|
|
650
|
+
unlinkSync(fp);
|
|
651
|
+
}
|
|
652
|
+
catch { /* ignore */ }
|
|
653
|
+
}
|
|
361
654
|
}
|
|
362
655
|
/** Delete all pm2 processes matching botmux / botmux-* under the given PM2_HOME. */
|
|
363
656
|
function deleteAllBotmuxProcesses(home = PM2_HOME) {
|
|
@@ -439,10 +732,12 @@ function cmdStop() {
|
|
|
439
732
|
}
|
|
440
733
|
}
|
|
441
734
|
catch { /* */ }
|
|
735
|
+
// Wipe abandoned dashboard-daemon descriptors left behind by stopped daemons.
|
|
736
|
+
cleanupStaleDaemonDescriptors();
|
|
442
737
|
if (!stopped)
|
|
443
738
|
console.log('daemon 未在运行。');
|
|
444
739
|
}
|
|
445
|
-
function cmdRestart() {
|
|
740
|
+
async function cmdRestart() {
|
|
446
741
|
if (!hasConfig()) {
|
|
447
742
|
console.error('❌ 未找到配置文件');
|
|
448
743
|
console.error(' 请先运行: botmux setup');
|
|
@@ -450,11 +745,31 @@ function cmdRestart() {
|
|
|
450
745
|
}
|
|
451
746
|
ensureConfigDir();
|
|
452
747
|
preflightNodeSanity();
|
|
748
|
+
await ensureSystemDependencies();
|
|
453
749
|
cleanupLegacyPm2();
|
|
454
750
|
// Delete all botmux processes (handles both old single-process and new multi-process)
|
|
455
751
|
deleteAllBotmuxProcesses();
|
|
752
|
+
// Wipe abandoned dashboard-daemon descriptors left behind by killed daemons.
|
|
753
|
+
cleanupStaleDaemonDescriptors();
|
|
456
754
|
const cfg = ecosystemConfig();
|
|
457
755
|
runPm2(['start', cfg]);
|
|
756
|
+
if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
|
|
757
|
+
console.log(`autostart unit 已同步到当前 Node/cli.js 路径`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/** Wraps `ensureDependencies()`. Neither tmux nor fonts are load-bearing —
|
|
761
|
+
* ensureDependencies surfaces failures as warnings and the daemon continues
|
|
762
|
+
* on PTY backend. Only an unexpected exception (programmer error) propagates. */
|
|
763
|
+
async function ensureSystemDependencies() {
|
|
764
|
+
const { ensureDependencies } = await import('./setup/index.js');
|
|
765
|
+
try {
|
|
766
|
+
await ensureDependencies();
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
console.error('');
|
|
770
|
+
console.error(`依赖检测内部错误: ${err?.message ?? String(err)}`);
|
|
771
|
+
// Don't exit — let daemon start try anyway; worst case PTY backend works.
|
|
772
|
+
}
|
|
458
773
|
}
|
|
459
774
|
/**
|
|
460
775
|
* If a legacy ~/.pm2 daemon with botmux processes still exists alongside our
|
|
@@ -538,6 +853,44 @@ function cmdUpgrade() {
|
|
|
538
853
|
process.exit(1);
|
|
539
854
|
}
|
|
540
855
|
}
|
|
856
|
+
/**
|
|
857
|
+
* Print a fresh dashboard URL by HMAC-authing to the dashboard process's
|
|
858
|
+
* loopback rotation endpoint. Each call invalidates the previously-issued
|
|
859
|
+
* token, so sharing a URL is the same as sharing a one-shot session.
|
|
860
|
+
*/
|
|
861
|
+
async function cmdDashboard() {
|
|
862
|
+
const SECRET_PATH = join(CONFIG_DIR, '.dashboard-secret');
|
|
863
|
+
if (!existsSync(SECRET_PATH)) {
|
|
864
|
+
console.error('Dashboard not initialised. Run `botmux restart` first.');
|
|
865
|
+
process.exit(1);
|
|
866
|
+
}
|
|
867
|
+
const secret = readFileSync(SECRET_PATH, 'utf8').trim();
|
|
868
|
+
const ts = Math.floor(Date.now() / 1000).toString();
|
|
869
|
+
const nonce = randomBytes(8).toString('hex');
|
|
870
|
+
const sig = createHmac('sha256', secret).update(`${ts}:${nonce}`).digest('base64url');
|
|
871
|
+
const port = process.env.BOTMUX_DASHBOARD_PORT ?? '7891';
|
|
872
|
+
let res;
|
|
873
|
+
try {
|
|
874
|
+
res = await fetch(`http://127.0.0.1:${port}/__cli/rotate`, {
|
|
875
|
+
method: 'POST',
|
|
876
|
+
headers: {
|
|
877
|
+
'X-Botmux-Cli-Ts': ts,
|
|
878
|
+
'X-Botmux-Cli-Nonce': nonce,
|
|
879
|
+
'X-Botmux-Cli-Auth': sig,
|
|
880
|
+
},
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
catch {
|
|
884
|
+
console.error(`dashboard process not reachable on 127.0.0.1:${port} — \`botmux restart\` will start it`);
|
|
885
|
+
process.exit(1);
|
|
886
|
+
}
|
|
887
|
+
if (!res.ok) {
|
|
888
|
+
console.error('Rotation failed:', res.status, await res.text());
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
891
|
+
const body = await res.json();
|
|
892
|
+
console.log(body.url);
|
|
893
|
+
}
|
|
541
894
|
/**
|
|
542
895
|
* Resolve the session data directory.
|
|
543
896
|
* Priority: SESSION_DATA_DIR env > daemon breadcrumb (~/.botmux/.data-dir) > default (~/.botmux/data)
|
|
@@ -812,7 +1165,7 @@ function printSessionTable(active) {
|
|
|
812
1165
|
/** Check if a tmux session exists. */
|
|
813
1166
|
function tmuxSessionExists(name) {
|
|
814
1167
|
try {
|
|
815
|
-
execSync(`tmux has-session -t ${name} 2>/dev/null`, { stdio: 'ignore' });
|
|
1168
|
+
execSync(`tmux has-session -t ${name} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
|
|
816
1169
|
return true;
|
|
817
1170
|
}
|
|
818
1171
|
catch {
|
|
@@ -956,7 +1309,7 @@ function interactiveSessionPicker(active) {
|
|
|
956
1309
|
// Kill tmux session
|
|
957
1310
|
if (r.hasTmux) {
|
|
958
1311
|
try {
|
|
959
|
-
execSync(`tmux kill-session -t '${r.tmuxName}' 2>/dev/null`, { stdio: 'ignore' });
|
|
1312
|
+
execSync(`tmux kill-session -t '${r.tmuxName}' 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
|
|
960
1313
|
}
|
|
961
1314
|
catch { /* */ }
|
|
962
1315
|
}
|
|
@@ -1027,6 +1380,7 @@ function interactiveSessionPicker(active) {
|
|
|
1027
1380
|
cleanup();
|
|
1028
1381
|
spawnSync('tmux', ['attach-session', '-t', selected.tmuxName], {
|
|
1029
1382
|
stdio: 'inherit',
|
|
1383
|
+
env: tmuxEnv(),
|
|
1030
1384
|
});
|
|
1031
1385
|
resolve();
|
|
1032
1386
|
return;
|
|
@@ -1124,7 +1478,7 @@ function cmdDelete() {
|
|
|
1124
1478
|
// Kill associated tmux session if it exists
|
|
1125
1479
|
const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`;
|
|
1126
1480
|
try {
|
|
1127
|
-
execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { stdio: 'ignore' });
|
|
1481
|
+
execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
|
|
1128
1482
|
console.log(` killed tmux ${tmuxName}`);
|
|
1129
1483
|
}
|
|
1130
1484
|
catch { /* no tmux session */ }
|
|
@@ -1136,6 +1490,140 @@ function cmdDelete() {
|
|
|
1136
1490
|
}
|
|
1137
1491
|
console.log(`\n已关闭 ${toDelete.length} 个会话`);
|
|
1138
1492
|
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Discover online daemons. Mirrors the staleness rule used by
|
|
1495
|
+
* dashboard/registry.ts (90s heartbeat) so we don't try to talk to a daemon
|
|
1496
|
+
* that's been dead but left a stale descriptor behind. Uses resolveDataDir()
|
|
1497
|
+
* so SESSION_DATA_DIR / breadcrumb-overridden deployments find the right
|
|
1498
|
+
* descriptor directory.
|
|
1499
|
+
*/
|
|
1500
|
+
function listOnlineDaemons() {
|
|
1501
|
+
const regDir = join(resolveDataDir(), 'dashboard-daemons');
|
|
1502
|
+
if (!existsSync(regDir))
|
|
1503
|
+
return [];
|
|
1504
|
+
const STALE_MS = 90_000;
|
|
1505
|
+
const now = Date.now();
|
|
1506
|
+
const all = [];
|
|
1507
|
+
let names = [];
|
|
1508
|
+
try {
|
|
1509
|
+
names = readdirSync(regDir);
|
|
1510
|
+
}
|
|
1511
|
+
catch {
|
|
1512
|
+
return [];
|
|
1513
|
+
}
|
|
1514
|
+
for (const f of names) {
|
|
1515
|
+
if (!f.endsWith('.json'))
|
|
1516
|
+
continue;
|
|
1517
|
+
try {
|
|
1518
|
+
const d = JSON.parse(readFileSync(join(regDir, f), 'utf-8'));
|
|
1519
|
+
if (typeof d?.ipcPort !== 'number' || typeof d?.larkAppId !== 'string')
|
|
1520
|
+
continue;
|
|
1521
|
+
if (now - (d.lastHeartbeat ?? 0) > STALE_MS)
|
|
1522
|
+
continue;
|
|
1523
|
+
all.push({ ipcPort: d.ipcPort, larkAppId: d.larkAppId, lastHeartbeat: d.lastHeartbeat });
|
|
1524
|
+
}
|
|
1525
|
+
catch { /* skip malformed */ }
|
|
1526
|
+
}
|
|
1527
|
+
return all;
|
|
1528
|
+
}
|
|
1529
|
+
function findDaemon(larkAppId) {
|
|
1530
|
+
const all = listOnlineDaemons();
|
|
1531
|
+
if (larkAppId)
|
|
1532
|
+
return all.find(d => d.larkAppId === larkAppId) ?? null;
|
|
1533
|
+
return all[0] ?? null;
|
|
1534
|
+
}
|
|
1535
|
+
async function cmdResume() {
|
|
1536
|
+
const target = process.argv[3];
|
|
1537
|
+
if (!target) {
|
|
1538
|
+
console.error('用法: botmux resume <session-id|prefix>');
|
|
1539
|
+
console.error(' 通过 botmux list 查看活跃会话;resume 仅适用于 status=closed 的会话');
|
|
1540
|
+
process.exit(1);
|
|
1541
|
+
}
|
|
1542
|
+
const sessions = loadSessions();
|
|
1543
|
+
const closed = [...sessions.values()].filter(s => s.status === 'closed');
|
|
1544
|
+
if (closed.length === 0) {
|
|
1545
|
+
console.error('没有已关闭的会话可恢复。');
|
|
1546
|
+
process.exit(1);
|
|
1547
|
+
}
|
|
1548
|
+
const matches = closed.filter(s => s.sessionId.startsWith(target));
|
|
1549
|
+
if (matches.length === 0) {
|
|
1550
|
+
console.error(`❌ 未找到匹配 "${target}" 的已关闭会话`);
|
|
1551
|
+
process.exit(1);
|
|
1552
|
+
}
|
|
1553
|
+
if (matches.length > 1) {
|
|
1554
|
+
console.error(`❌ "${target}" 匹配了 ${matches.length} 个会话,请提供更长的 ID 前缀:`);
|
|
1555
|
+
for (const s of matches) {
|
|
1556
|
+
console.error(` ${s.sessionId.substring(0, 12)} ${s.title}`);
|
|
1557
|
+
}
|
|
1558
|
+
process.exit(1);
|
|
1559
|
+
}
|
|
1560
|
+
const session = matches[0];
|
|
1561
|
+
// Legacy sessions persisted before per-bot files lack larkAppId. Rather
|
|
1562
|
+
// than silently routing to "the first online daemon" — which can land on
|
|
1563
|
+
// the wrong bot in multi-bot setups and corrupt state — refuse and tell
|
|
1564
|
+
// the user what's missing. Single-bot setups still work (we resolve to
|
|
1565
|
+
// that lone daemon below).
|
|
1566
|
+
if (!session.larkAppId) {
|
|
1567
|
+
const online = listOnlineDaemons();
|
|
1568
|
+
if (online.length > 1) {
|
|
1569
|
+
console.error(`❌ 会话 ${session.sessionId.substring(0, 12)} 缺少 larkAppId,多 bot 部署下无法判定归属。`);
|
|
1570
|
+
console.error(' 解决办法:手动给该 session 补 larkAppId 后重试,或使用对应 bot 的话题里 ▶️ 恢复会话 按钮。');
|
|
1571
|
+
console.error(` 在线 daemon (${online.length}): ${online.map(d => d.larkAppId).join(', ')}`);
|
|
1572
|
+
process.exit(1);
|
|
1573
|
+
}
|
|
1574
|
+
if (online.length === 0) {
|
|
1575
|
+
console.error('❌ 没有在线 daemon。请先:botmux start');
|
|
1576
|
+
process.exit(1);
|
|
1577
|
+
}
|
|
1578
|
+
// Single online daemon — safe to use
|
|
1579
|
+
}
|
|
1580
|
+
const daemon = findDaemon(session.larkAppId);
|
|
1581
|
+
if (!daemon) {
|
|
1582
|
+
const hint = session.larkAppId
|
|
1583
|
+
? `未找到 daemon (larkAppId=${session.larkAppId})`
|
|
1584
|
+
: '未找到任何在线 daemon';
|
|
1585
|
+
console.error(`❌ ${hint}。请确认 daemon 正在运行:botmux status`);
|
|
1586
|
+
process.exit(1);
|
|
1587
|
+
}
|
|
1588
|
+
let res;
|
|
1589
|
+
try {
|
|
1590
|
+
res = await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/sessions/${encodeURIComponent(session.sessionId)}/resume`, { method: 'POST' });
|
|
1591
|
+
}
|
|
1592
|
+
catch (err) {
|
|
1593
|
+
console.error(`❌ 无法连接到 daemon (port=${daemon.ipcPort}): ${err?.message ?? err}`);
|
|
1594
|
+
process.exit(1);
|
|
1595
|
+
}
|
|
1596
|
+
let body = {};
|
|
1597
|
+
try {
|
|
1598
|
+
body = await res.json();
|
|
1599
|
+
}
|
|
1600
|
+
catch { /* */ }
|
|
1601
|
+
if (res.ok && body?.ok) {
|
|
1602
|
+
console.log(`✅ 会话已恢复: ${session.sessionId.substring(0, 12)} ${session.title}`);
|
|
1603
|
+
if (body.workingDir)
|
|
1604
|
+
console.log(` 工作目录: ${body.workingDir}`);
|
|
1605
|
+
console.log(' 下一条消息会以 --resume 拉起 CLI;已在原话题留通知。');
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const errCode = body?.error ?? `HTTP ${res.status}`;
|
|
1609
|
+
if (errCode === 'anchor_occupied') {
|
|
1610
|
+
const occ = body?.activeSessionId ? ` (占用者: ${body.activeSessionId.substring(0, 12)})` : '';
|
|
1611
|
+
console.error(`❌ 当前话题已有新的活跃会话${occ},无法 resume 旧会话。`);
|
|
1612
|
+
}
|
|
1613
|
+
else if (errCode === 'not_closed') {
|
|
1614
|
+
console.error('❌ 会话当前不是 closed 状态,无需 resume。');
|
|
1615
|
+
}
|
|
1616
|
+
else if (errCode === 'not_found') {
|
|
1617
|
+
console.error('❌ daemon 中找不到该会话(可能已被清理)。');
|
|
1618
|
+
}
|
|
1619
|
+
else if (errCode === 'adopt_unsupported') {
|
|
1620
|
+
console.error('❌ adopt 接管会话不支持 resume。');
|
|
1621
|
+
}
|
|
1622
|
+
else {
|
|
1623
|
+
console.error(`❌ 恢复失败: ${errCode}`);
|
|
1624
|
+
}
|
|
1625
|
+
process.exit(1);
|
|
1626
|
+
}
|
|
1139
1627
|
function showHelp() {
|
|
1140
1628
|
console.log(`
|
|
1141
1629
|
botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
|
|
@@ -1148,11 +1636,17 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
|
|
|
1148
1636
|
logs 查看 daemon 日志(--lines N, --bot <index>)
|
|
1149
1637
|
status 查看 daemon 状态
|
|
1150
1638
|
upgrade 升级到最新版本
|
|
1639
|
+
dashboard 打印新的 Web Dashboard 一次性登录 URL(旧 token 同时失效)
|
|
1151
1640
|
list 列出活跃会话(交互式选择并连接 tmux)
|
|
1152
1641
|
--plain 纯文本表格输出(管道/脚本场景)
|
|
1153
1642
|
delete <id> 关闭指定会话(支持 ID 前缀匹配)
|
|
1154
1643
|
delete all 关闭所有活跃会话
|
|
1155
1644
|
delete stopped 清理所有进程已退出的僵尸会话
|
|
1645
|
+
resume <id> 恢复一个已关闭的会话(支持 ID 前缀匹配)— 会话标记回 active,
|
|
1646
|
+
下条消息会以 --resume 重新拉起 CLI 进程
|
|
1647
|
+
autostart enable 注册开机自启(macOS launchd / Linux user systemd,无需 sudo)
|
|
1648
|
+
autostart disable 注销开机自启
|
|
1649
|
+
autostart status 查看自启状态
|
|
1156
1650
|
|
|
1157
1651
|
定时任务(可在 CLI 会话内自动推断 chat):
|
|
1158
1652
|
schedule list 列出所有任务
|
|
@@ -1167,8 +1661,15 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
|
|
|
1167
1661
|
--files <path> 附件(可重复)
|
|
1168
1662
|
--mention <open_id:name> @提及(可重复)
|
|
1169
1663
|
--card | --text 强制卡片 / 纯文本(默认按 md 语法自动判断)
|
|
1664
|
+
--top-level 发顶层消息(不回复进当前话题)
|
|
1665
|
+
--chat-id <oc_xxx> 指定目标群(默认当前话题所在群)
|
|
1170
1666
|
bots list 列出当前群聊中的机器人(含 open_id)
|
|
1171
|
-
|
|
1667
|
+
history [--limit N] 拉取当前会话的消息历史 (JSON),话题群 → 话题内,普通群 → 整群
|
|
1668
|
+
quoted <message_id> 拉取被引用的单条消息 (JSON),message_id 取自 daemon 注入的引用提示行
|
|
1669
|
+
|
|
1670
|
+
新建飞书群:
|
|
1671
|
+
create-group --bot <name> [--bot ...] [--name "群名"]
|
|
1672
|
+
用指定 bot 起新群;详见 \`botmux create-group --help\`
|
|
1172
1673
|
|
|
1173
1674
|
配置目录: ~/.botmux/
|
|
1174
1675
|
文档: https://github.com/deepcoldy/botmux
|
|
@@ -1246,13 +1747,18 @@ function argFlag(args, flag) {
|
|
|
1246
1747
|
return args.includes(flag);
|
|
1247
1748
|
}
|
|
1248
1749
|
/** Extract positional args, skipping --flag and the value that follows it
|
|
1249
|
-
* (for --flag <value> style). --flag=value style is self-contained.
|
|
1250
|
-
|
|
1750
|
+
* (for --flag <value> style). --flag=value style is self-contained.
|
|
1751
|
+
* `booleanFlags` lists flags that take no value — without this hint the
|
|
1752
|
+
* parser swallows the *next* arg as the flag's value, which silently eats
|
|
1753
|
+
* positional content (or, worse, a following --flag's value). */
|
|
1754
|
+
function positionals(args, booleanFlags = []) {
|
|
1251
1755
|
const out = [];
|
|
1252
1756
|
for (let i = 0; i < args.length; i++) {
|
|
1253
1757
|
const a = args[i];
|
|
1254
1758
|
if (a.startsWith('--')) {
|
|
1255
|
-
|
|
1759
|
+
const flagName = a.includes('=') ? a.slice(0, a.indexOf('=')) : a;
|
|
1760
|
+
const isBoolean = booleanFlags.includes(flagName);
|
|
1761
|
+
if (!a.includes('=') && !isBoolean && i + 1 < args.length)
|
|
1256
1762
|
i++; // skip value
|
|
1257
1763
|
continue;
|
|
1258
1764
|
}
|
|
@@ -1331,6 +1837,9 @@ async function cmdSchedule(sub, rest) {
|
|
|
1331
1837
|
chatId,
|
|
1332
1838
|
rootMessageId,
|
|
1333
1839
|
larkAppId,
|
|
1840
|
+
creatorChatId: cur?.chatId,
|
|
1841
|
+
creatorRootMessageId: cur?.rootMessageId,
|
|
1842
|
+
creatorLarkAppId: cur?.larkAppId,
|
|
1334
1843
|
chatType: cur?.chatType === 'p2p' ? 'p2p' : 'topic_group',
|
|
1335
1844
|
deliver,
|
|
1336
1845
|
});
|
|
@@ -1395,13 +1904,15 @@ async function cmdSchedule(sub, rest) {
|
|
|
1395
1904
|
process.exit(1);
|
|
1396
1905
|
}
|
|
1397
1906
|
}
|
|
1398
|
-
|
|
1907
|
+
/** Resolve a CLI subcommand's larkAppId by walking the session marker. Common
|
|
1908
|
+
* prelude for `history` / `quoted` / similar commands that need to talk to
|
|
1909
|
+
* Lark on behalf of the session that spawned them. Exits with stderr on
|
|
1910
|
+
* failure so callers can stay focused on the happy path. */
|
|
1911
|
+
async function resolveSessionAppId(sessionIdArg) {
|
|
1399
1912
|
process.env.SESSION_DATA_DIR ??= resolveDataDir();
|
|
1400
|
-
const limit = parseInt(argValue(rest, '--limit') ?? '50', 10);
|
|
1401
|
-
const sessionIdArg = argValue(rest, '--session-id');
|
|
1402
1913
|
const sid = sessionIdArg ?? findAncestorSessionId();
|
|
1403
1914
|
if (!sid) {
|
|
1404
|
-
console.error('无法推断 session-id。请在 Lark
|
|
1915
|
+
console.error('无法推断 session-id。请在 Lark 话题/群里的 CLI 会话中运行,或传 --session-id <id>。');
|
|
1405
1916
|
process.exit(1);
|
|
1406
1917
|
}
|
|
1407
1918
|
const sessions = loadSessions();
|
|
@@ -1421,15 +1932,75 @@ async function cmdThreadMessages(rest) {
|
|
|
1421
1932
|
registerBot(cfg);
|
|
1422
1933
|
}
|
|
1423
1934
|
catch { /* ignore */ }
|
|
1424
|
-
|
|
1935
|
+
return { sid, larkAppId: s.larkAppId, session: s };
|
|
1936
|
+
}
|
|
1937
|
+
async function cmdHistory(rest) {
|
|
1938
|
+
const limit = parseInt(argValue(rest, '--limit') ?? '50', 10);
|
|
1939
|
+
const sessionIdArg = argValue(rest, '--session-id');
|
|
1940
|
+
const { sid, larkAppId: appId, session: s } = await resolveSessionAppId(sessionIdArg);
|
|
1941
|
+
const { listThreadMessages, listChatMessages } = await import('./im/lark/client.js');
|
|
1425
1942
|
const { parseApiMessage } = await import('./im/lark/message-parser.js');
|
|
1943
|
+
const { expandMergeForward } = await import('./im/lark/merge-forward.js');
|
|
1426
1944
|
try {
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1945
|
+
// Chat-scope sessions (普通群整群一会话) have no thread to walk — list the
|
|
1946
|
+
// chat container directly and let the caller cap with --limit. Thread-scope
|
|
1947
|
+
// sessions walk the thread container by root_id.
|
|
1948
|
+
const isChatScope = s.scope === 'chat';
|
|
1949
|
+
const raw = isChatScope
|
|
1950
|
+
? await listChatMessages(appId, s.chatId, limit)
|
|
1951
|
+
: await listThreadMessages(appId, s.chatId, s.rootMessageId, limit);
|
|
1952
|
+
// Expand merge_forward to <forwarded_messages> XML, mirroring the live event
|
|
1953
|
+
// path in daemon.ts. Each merge_forward gets its own numberer (we don't
|
|
1954
|
+
// download resources here — only [图片 N] placeholders matter).
|
|
1955
|
+
const messages = await Promise.all(raw.map(async (m) => {
|
|
1956
|
+
const parsed = parseApiMessage(m);
|
|
1957
|
+
if (parsed.msgType === 'merge_forward') {
|
|
1958
|
+
await expandMergeForward(appId, parsed.messageId, parsed);
|
|
1959
|
+
}
|
|
1960
|
+
return parsed;
|
|
1961
|
+
}));
|
|
1962
|
+
console.log(JSON.stringify({
|
|
1963
|
+
sessionId: sid,
|
|
1964
|
+
chatId: s.chatId,
|
|
1965
|
+
scope: isChatScope ? 'chat' : 'thread',
|
|
1966
|
+
...(isChatScope ? {} : { rootMessageId: s.rootMessageId }),
|
|
1967
|
+
messages,
|
|
1968
|
+
total: messages.length,
|
|
1969
|
+
}, null, 2));
|
|
1430
1970
|
}
|
|
1431
1971
|
catch (err) {
|
|
1432
|
-
console.error(
|
|
1972
|
+
console.error(`获取消息失败: ${err.message}`);
|
|
1973
|
+
process.exit(1);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
async function cmdQuoted(rest) {
|
|
1977
|
+
const sessionIdArg = argValue(rest, '--session-id');
|
|
1978
|
+
// Positional message_id is required. The id comes verbatim from the
|
|
1979
|
+
// `[用户引用了消息 用 botmux quoted om_xxx 查看]` prompt prefix the daemon
|
|
1980
|
+
// injects when the user used the Lark quote-reply UI. Skip --session-id and
|
|
1981
|
+
// its value so `botmux quoted --session-id <uuid> om_xxx` doesn't pick up
|
|
1982
|
+
// the uuid as the message id.
|
|
1983
|
+
const messageId = firstPositional(rest, ['--session-id']);
|
|
1984
|
+
if (!messageId) {
|
|
1985
|
+
console.error('用法: botmux quoted <message_id> [--session-id <id>]');
|
|
1986
|
+
process.exit(1);
|
|
1987
|
+
}
|
|
1988
|
+
const { larkAppId: appId } = await resolveSessionAppId(sessionIdArg);
|
|
1989
|
+
const { getMessageDetail } = await import('./im/lark/client.js');
|
|
1990
|
+
const { expandMergeForward } = await import('./im/lark/merge-forward.js');
|
|
1991
|
+
const { renderQuotedMessage } = await import('./cli/quoted-render.js');
|
|
1992
|
+
try {
|
|
1993
|
+
const detail = await getMessageDetail(appId, messageId);
|
|
1994
|
+
const msg = detail?.items?.[0];
|
|
1995
|
+
if (!msg) {
|
|
1996
|
+
console.error(`未找到消息 ${messageId}`);
|
|
1997
|
+
process.exit(1);
|
|
1998
|
+
}
|
|
1999
|
+
const rendered = await renderQuotedMessage(appId, msg, expandMergeForward);
|
|
2000
|
+
console.log(JSON.stringify(rendered, null, 2));
|
|
2001
|
+
}
|
|
2002
|
+
catch (err) {
|
|
2003
|
+
console.error(`获取被引用消息失败: ${err.message}`);
|
|
1433
2004
|
process.exit(1);
|
|
1434
2005
|
}
|
|
1435
2006
|
}
|
|
@@ -1464,139 +2035,23 @@ function argValues(args, ...flags) {
|
|
|
1464
2035
|
}
|
|
1465
2036
|
return out;
|
|
1466
2037
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
}
|
|
1471
|
-
/** Parse a contiguous pipe-table block into a Feishu card v2 `table` element. */
|
|
1472
|
-
function parseTableBlock(block) {
|
|
1473
|
-
const lines = block.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
1474
|
-
if (lines.length < 2)
|
|
1475
|
-
return null;
|
|
1476
|
-
const rows = lines.map(l => l.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()));
|
|
1477
|
-
const sepIdx = rows.findIndex(r => r.length > 0 && r.every(c => /^:?-{2,}:?$/.test(c)));
|
|
1478
|
-
const header = rows[0];
|
|
1479
|
-
const body = sepIdx === 1 ? rows.slice(2) : rows.slice(1);
|
|
1480
|
-
if (header.length === 0)
|
|
1481
|
-
return null;
|
|
1482
|
-
const columns = header.map((h, i) => ({
|
|
1483
|
-
name: `c${i}`,
|
|
1484
|
-
display_name: h || ' ',
|
|
1485
|
-
data_type: 'lark_md',
|
|
1486
|
-
width: 'auto',
|
|
1487
|
-
}));
|
|
1488
|
-
const tableRows = body.map(r => {
|
|
1489
|
-
const o = {};
|
|
1490
|
-
for (let i = 0; i < header.length; i++)
|
|
1491
|
-
o[`c${i}`] = r[i] ?? '';
|
|
1492
|
-
return o;
|
|
1493
|
-
});
|
|
1494
|
-
return {
|
|
1495
|
-
tag: 'table',
|
|
1496
|
-
page_size: Math.min(10, Math.max(1, tableRows.length || 1)),
|
|
1497
|
-
row_height: 'low',
|
|
1498
|
-
header_style: {
|
|
1499
|
-
text_align: 'left',
|
|
1500
|
-
text_size: 'normal',
|
|
1501
|
-
background_style: 'grey',
|
|
1502
|
-
text_color: 'default',
|
|
1503
|
-
bold: true,
|
|
1504
|
-
lines: 1,
|
|
1505
|
-
},
|
|
1506
|
-
columns,
|
|
1507
|
-
rows: tableRows,
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
/**
|
|
1511
|
-
* Split markdown into card v2 body elements:
|
|
1512
|
-
* 1. Fenced code blocks are preserved verbatim (shielded from heading/table
|
|
1513
|
-
* transforms so `#` and `|` inside code don't get mis-parsed).
|
|
1514
|
-
* 2. Pipe-table blocks in prose become native `table` elements.
|
|
1515
|
-
* 3. Everything else becomes a `markdown` element with ATX headings promoted
|
|
1516
|
-
* to bold (Feishu's markdown element doesn't render `#`).
|
|
1517
|
-
* Consecutive markdown fragments are merged so the card keeps reasonable
|
|
1518
|
-
* element counts.
|
|
1519
|
-
*/
|
|
1520
|
-
function buildCardBodyElements(md) {
|
|
1521
|
-
const elements = [];
|
|
1522
|
-
let buffer = '';
|
|
1523
|
-
const flushBuffer = () => {
|
|
1524
|
-
const t = buffer.replace(/^\s+|\s+$/g, '');
|
|
1525
|
-
if (t)
|
|
1526
|
-
elements.push({ tag: 'markdown', content: transformHeadings(t) });
|
|
1527
|
-
buffer = '';
|
|
1528
|
-
};
|
|
1529
|
-
// Segment by fenced code blocks (``` ... ```)
|
|
1530
|
-
const fenceRe = /^```[^\n]*\n[\s\S]*?^```[ \t]*$/gm;
|
|
1531
|
-
const segments = [];
|
|
1532
|
-
let fCursor = 0;
|
|
1533
|
-
let fm;
|
|
1534
|
-
while ((fm = fenceRe.exec(md)) !== null) {
|
|
1535
|
-
if (fm.index > fCursor)
|
|
1536
|
-
segments.push({ type: 'prose', text: md.slice(fCursor, fm.index) });
|
|
1537
|
-
segments.push({ type: 'code', text: fm[0] });
|
|
1538
|
-
fCursor = fm.index + fm[0].length;
|
|
1539
|
-
}
|
|
1540
|
-
if (fCursor < md.length)
|
|
1541
|
-
segments.push({ type: 'prose', text: md.slice(fCursor) });
|
|
1542
|
-
for (const seg of segments) {
|
|
1543
|
-
if (seg.type === 'code') {
|
|
1544
|
-
buffer += (buffer && !buffer.endsWith('\n') ? '\n' : '') + seg.text + '\n';
|
|
1545
|
-
continue;
|
|
1546
|
-
}
|
|
1547
|
-
const tableRe = /(?:^[ \t]*\|.+\|[ \t]*\r?\n?){2,}/gm;
|
|
1548
|
-
let tCursor = 0;
|
|
1549
|
-
let tm;
|
|
1550
|
-
while ((tm = tableRe.exec(seg.text)) !== null) {
|
|
1551
|
-
buffer += seg.text.slice(tCursor, tm.index);
|
|
1552
|
-
flushBuffer();
|
|
1553
|
-
const table = parseTableBlock(tm[0]);
|
|
1554
|
-
if (table)
|
|
1555
|
-
elements.push(table);
|
|
1556
|
-
else
|
|
1557
|
-
buffer += tm[0];
|
|
1558
|
-
tCursor = tm.index + tm[0].length;
|
|
1559
|
-
}
|
|
1560
|
-
buffer += seg.text.slice(tCursor);
|
|
1561
|
-
}
|
|
1562
|
-
flushBuffer();
|
|
1563
|
-
return elements;
|
|
1564
|
-
}
|
|
1565
|
-
/**
|
|
1566
|
-
* Heuristic: does `text` contain markdown syntax that renders badly as plain
|
|
1567
|
-
* text in Feishu (code fences, headings, lists, bold, inline code, links,
|
|
1568
|
-
* tables, blockquotes, hr)? If so, `cmdSend` switches to an interactive card
|
|
1569
|
-
* so Feishu can render it properly.
|
|
1570
|
-
*/
|
|
1571
|
-
function hasMarkdown(text) {
|
|
1572
|
-
if (!text)
|
|
1573
|
-
return false;
|
|
1574
|
-
return (/```/.test(text) ||
|
|
1575
|
-
/^#{1,6}\s/m.test(text) ||
|
|
1576
|
-
/^\s{0,3}[-*+]\s+\S/m.test(text) ||
|
|
1577
|
-
/^\s{0,3}\d+\.\s+\S/m.test(text) ||
|
|
1578
|
-
/\*\*[^*\n]+\*\*/.test(text) ||
|
|
1579
|
-
/(^|[^`])`[^`\n]+`([^`]|$)/.test(text) ||
|
|
1580
|
-
/\[[^\]\n]+\]\([^)\n]+\)/.test(text) ||
|
|
1581
|
-
/^\s*\|.+\|\s*$/m.test(text) ||
|
|
1582
|
-
/^>\s/m.test(text) ||
|
|
1583
|
-
/^(?:---|\*\*\*|___)\s*$/m.test(text));
|
|
1584
|
-
}
|
|
2038
|
+
// Card v2 body builder helpers — extracted to im/lark/md-card.ts so the
|
|
2039
|
+
// daemon's bridge fallback path can produce identical cards. cmdSend
|
|
2040
|
+
// keeps using `buildCardBodyElements` and `hasMarkdown` from there.
|
|
2041
|
+
import { buildCardBodyElements, hasMarkdown } from './im/lark/md-card.js';
|
|
1585
2042
|
/**
|
|
1586
2043
|
* Decide who the reply card should @ in its footer.
|
|
1587
2044
|
*
|
|
1588
|
-
* Non-oncall chats: `发送给: @<owner
|
|
1589
|
-
* Oncall chats: `发送给: @<last caller>` (falls back to owner if unknown)
|
|
1590
|
-
*
|
|
1591
|
-
* notified. Caller is deduped out of cc to avoid double-@.
|
|
2045
|
+
* Non-oncall chats: `发送给: @<owner>`.
|
|
2046
|
+
* Oncall chats: `发送给: @<last caller>` (falls back to owner if unknown) —
|
|
2047
|
+
* permission is governed by allowedUsers, so there's no per-chat list to cc.
|
|
1592
2048
|
*/
|
|
1593
2049
|
function buildFooterAddressing(s, oncall) {
|
|
1594
2050
|
const owner = s.ownerOpenId;
|
|
1595
2051
|
const caller = s.lastCallerOpenId ?? owner;
|
|
1596
2052
|
if (!oncall)
|
|
1597
2053
|
return { sendTo: owner, cc: [] };
|
|
1598
|
-
|
|
1599
|
-
return { sendTo: caller, cc };
|
|
2054
|
+
return { sendTo: caller, cc: [] };
|
|
1600
2055
|
}
|
|
1601
2056
|
async function cmdSend(rest) {
|
|
1602
2057
|
process.env.SESSION_DATA_DIR ??= resolveDataDir();
|
|
@@ -1607,6 +2062,12 @@ async function cmdSend(rest) {
|
|
|
1607
2062
|
const contentFile = argValue(rest, '--content-file');
|
|
1608
2063
|
const forceCard = rest.includes('--card');
|
|
1609
2064
|
const forceText = rest.includes('--text');
|
|
2065
|
+
// Publish-mode flags: post a fresh top-level message in a chat instead of
|
|
2066
|
+
// replying into the bound thread. Lets a session "publish" to a different
|
|
2067
|
+
// chat (e.g. a public release-notes group) while keeping its own thread
|
|
2068
|
+
// for streaming-card / progress UI.
|
|
2069
|
+
const sendTopLevel = rest.includes('--top-level');
|
|
2070
|
+
const overrideChatId = argValue(rest, '--chat-id');
|
|
1610
2071
|
const sid = sessionIdArg ?? findAncestorSessionId();
|
|
1611
2072
|
if (!sid) {
|
|
1612
2073
|
console.error('无法推断 session-id。请在 Lark 话题内的 CLI 会话中运行,或传 --session-id <id>。');
|
|
@@ -1632,7 +2093,7 @@ async function cmdSend(rest) {
|
|
|
1632
2093
|
content = readFileSync(contentFile, 'utf-8');
|
|
1633
2094
|
}
|
|
1634
2095
|
else {
|
|
1635
|
-
const pos = positionals(rest);
|
|
2096
|
+
const pos = positionals(rest, ['--card', '--text', '--top-level']);
|
|
1636
2097
|
if (pos.length > 0) {
|
|
1637
2098
|
content = pos.join(' ');
|
|
1638
2099
|
}
|
|
@@ -1666,15 +2127,31 @@ async function cmdSend(rest) {
|
|
|
1666
2127
|
}
|
|
1667
2128
|
}
|
|
1668
2129
|
// Register bots so Lark client works
|
|
1669
|
-
const { registerBot, loadBotConfigs,
|
|
2130
|
+
const { registerBot, loadBotConfigs, findOncallChatForAnyBot } = await import('./bot-registry.js');
|
|
1670
2131
|
try {
|
|
1671
2132
|
for (const cfg of loadBotConfigs())
|
|
1672
2133
|
registerBot(cfg);
|
|
1673
2134
|
}
|
|
1674
2135
|
catch { /* */ }
|
|
1675
|
-
const { replyMessage, uploadImage, uploadFile } = await import('./im/lark/client.js');
|
|
2136
|
+
const { sendMessage, replyMessage, uploadImage, uploadFile } = await import('./im/lark/client.js');
|
|
1676
2137
|
const appId = s.larkAppId;
|
|
1677
|
-
|
|
2138
|
+
// Effective target chat for top-level mode (defaults to session's chat)
|
|
2139
|
+
const targetChatId = overrideChatId ?? s.chatId;
|
|
2140
|
+
// Chat-scope sessions (普通群整群一会话) post to chatId without
|
|
2141
|
+
// reply_in_thread, otherwise Lark would force every reply into a fresh
|
|
2142
|
+
// topic — defeating the whole point of chat-scope routing.
|
|
2143
|
+
const isChatScope = s.scope === 'chat';
|
|
2144
|
+
// Oncall addressing only meaningful for replies inside the session's own
|
|
2145
|
+
// chat — skip when publishing top-level or to a different chat. Treat
|
|
2146
|
+
// oncall as chat-level: in multi-daemon setups this session's bot may not
|
|
2147
|
+
// be the one that persisted the binding, but users still expect footer
|
|
2148
|
+
// addressing to go to the last caller in the shared oncall workspace.
|
|
2149
|
+
const oncallEntry = !sendTopLevel && !overrideChatId && s.chatId
|
|
2150
|
+
? findOncallChatForAnyBot(s.chatId) : undefined;
|
|
2151
|
+
// Dispatch helper: top-level / chat-scope send vs reply-in-thread, single decision point
|
|
2152
|
+
const dispatch = (content, msgType) => (sendTopLevel || isChatScope)
|
|
2153
|
+
? sendMessage(appId, targetChatId, content, msgType)
|
|
2154
|
+
: replyMessage(appId, s.rootMessageId, content, msgType, true);
|
|
1678
2155
|
try {
|
|
1679
2156
|
// Upload images in parallel
|
|
1680
2157
|
const imageKeys = [];
|
|
@@ -1702,6 +2179,13 @@ async function cmdSend(rest) {
|
|
|
1702
2179
|
// app's cross-ref file for per-app-scoped open_ids. Without this, a plain
|
|
1703
2180
|
// "@Claude" in text only triggers IPC routing but Lark UI shows it as
|
|
1704
2181
|
// plain text — confusing the user who thinks the @ didn't fire.
|
|
2182
|
+
//
|
|
2183
|
+
// bot-to-bot @mention 两条触发入口(显式 --mention / 正文 `@BotName`)都
|
|
2184
|
+
// 落到下方的 mentions 数组,单 source of truth:让 Lark 在消息里渲染
|
|
2185
|
+
// 真正的 @at 元素。对方 bot 的 daemon 通过 WSClient 原生事件接到(依赖
|
|
2186
|
+
// "获取群组中其他机器人和用户@当前机器人的消息"权限),不再走任何本地
|
|
2187
|
+
// 转发——botmux 历史上为绕过 Lark 不投递跨 bot 事件搞过 signal-file,
|
|
2188
|
+
// 那套已经在该权限上线后整体下线。
|
|
1705
2189
|
try {
|
|
1706
2190
|
const dataDir = resolveDataDir();
|
|
1707
2191
|
const botInfoPath = join(dataDir, 'bots-info.json');
|
|
@@ -1711,18 +2195,34 @@ async function cmdSend(rest) {
|
|
|
1711
2195
|
? JSON.parse(readFileSync(crossRefPath, 'utf-8'))
|
|
1712
2196
|
: {};
|
|
1713
2197
|
const alreadyMentioned = new Set(mentions.map(m => m.open_id));
|
|
1714
|
-
|
|
2198
|
+
// Sort by name length desc so longer names ("Claude分身") win over their
|
|
2199
|
+
// prefix ("Claude") when both could match — break-on-first-hit otherwise
|
|
2200
|
+
// routes "@Claude分身" to Claude.
|
|
2201
|
+
const sortedEntries = [...botEntries].sort((a, b) => (b.botName?.length ?? 0) - (a.botName?.length ?? 0));
|
|
2202
|
+
for (const entry of sortedEntries) {
|
|
1715
2203
|
if (!entry.botName || entry.larkAppId === appId)
|
|
1716
2204
|
continue;
|
|
1717
2205
|
const names = [entry.botName, entry.cliId].filter(Boolean);
|
|
1718
2206
|
for (const name of names) {
|
|
1719
|
-
const
|
|
2207
|
+
const escName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2208
|
+
// Boundary: lookbehind blocks only ASCII word chars (so `user@Claude`
|
|
2209
|
+
// is rejected but `看看@CoCo` is accepted — CJK prefix is normal in
|
|
2210
|
+
// Chinese text). Lookahead blocks any Unicode letter/digit so
|
|
2211
|
+
// `@Claude2` doesn't match name "Claude" and `@Claude分身好的` doesn't
|
|
2212
|
+
// either-half-match.
|
|
2213
|
+
const re = new RegExp(`(?<![A-Za-z0-9_])@${escName}(?![\\p{L}\\p{N}_])`, 'iu');
|
|
1720
2214
|
if (!re.test(text))
|
|
1721
2215
|
continue;
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
|
|
1725
|
-
|
|
2216
|
+
// Lark open_id is per-app scoped. Use sender-scoped id from cross-ref
|
|
2217
|
+
// only — falling back to entry.botOpenId would feed Lark a wrong-scope
|
|
2218
|
+
// id (target's self-scoped) and the API would reject it. Skip + warn
|
|
2219
|
+
// so the missing cross-ref is observable instead of silently dropped.
|
|
2220
|
+
const senderScopedId = crossRef[entry.botName];
|
|
2221
|
+
if (!senderScopedId) {
|
|
2222
|
+
console.error(`[botmux send] no cross-ref entry for "${entry.botName}" in app ${appId}, skipping auto-mention (cross-ref populates after the sender app first sees the target bot)`);
|
|
2223
|
+
break;
|
|
2224
|
+
}
|
|
2225
|
+
if (alreadyMentioned.has(senderScopedId))
|
|
1726
2226
|
break;
|
|
1727
2227
|
mentions.push({ open_id: senderScopedId, name: entry.botName });
|
|
1728
2228
|
alreadyMentioned.add(senderScopedId);
|
|
@@ -1742,6 +2242,13 @@ async function cmdSend(rest) {
|
|
|
1742
2242
|
const mentionPattern = namedMentions.length > 0
|
|
1743
2243
|
? new RegExp(`@(${namedMentions.map(m => m.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'gi')
|
|
1744
2244
|
: null;
|
|
2245
|
+
// Capture sentAtMs BEFORE dispatch — the worker's bridge fallback gates
|
|
2246
|
+
// on `sentAtMs ∈ [turn.markTimeMs, nextTurn.markTimeMs)`. If we recorded
|
|
2247
|
+
// it after dispatch (which can take seconds), a slow Lark RTT could push
|
|
2248
|
+
// this send's timestamp past the next turn's mark and falsely suppress
|
|
2249
|
+
// that turn's fallback emit. Pre-dispatch timestamp captures the moment
|
|
2250
|
+
// we committed to sending — that's the boundary the gate cares about.
|
|
2251
|
+
const sentAtMs = Date.now();
|
|
1745
2252
|
let messageId;
|
|
1746
2253
|
if (useCard) {
|
|
1747
2254
|
// Inline @mention → <at id=open_id></at>; explicit --mention args that
|
|
@@ -1788,10 +2295,13 @@ async function cmdSend(rest) {
|
|
|
1788
2295
|
// Footer: de-emphasized markdown (v2 dropped the `note` tag). Use small
|
|
1789
2296
|
// text size + grey font tag so it reads like a footnote below the hr.
|
|
1790
2297
|
// Oncall groups: `发送给` targets whoever triggered this turn (may not
|
|
1791
|
-
// be the session owner)
|
|
1792
|
-
// stay informed. Non-oncall: keep owner-only behaviour.
|
|
2298
|
+
// be the session owner). Non-oncall: keep owner-only behaviour.
|
|
1793
2299
|
const footerParts = ['[botmux](https://github.com/deepcoldy/botmux)'];
|
|
1794
|
-
|
|
2300
|
+
// Top-level publish has no specific recipient — drop "发送给/cc" addressing
|
|
2301
|
+
// so the message doesn't @ the session owner who isn't even in the target chat.
|
|
2302
|
+
const addressing = sendTopLevel
|
|
2303
|
+
? { sendTo: undefined, cc: [] }
|
|
2304
|
+
: buildFooterAddressing(s, oncallEntry);
|
|
1795
2305
|
if (addressing.sendTo)
|
|
1796
2306
|
footerParts.push(`发送给:<at id=${addressing.sendTo}></at>`);
|
|
1797
2307
|
if (addressing.cc.length > 0) {
|
|
@@ -1808,7 +2318,7 @@ async function cmdSend(rest) {
|
|
|
1808
2318
|
config: { update_multi: true },
|
|
1809
2319
|
body: { direction: 'vertical', elements },
|
|
1810
2320
|
});
|
|
1811
|
-
messageId = await
|
|
2321
|
+
messageId = await dispatch(cardJson, 'interactive');
|
|
1812
2322
|
}
|
|
1813
2323
|
else {
|
|
1814
2324
|
// Plain-text path: build post content, paragraph per line.
|
|
@@ -1847,9 +2357,11 @@ async function cmdSend(rest) {
|
|
|
1847
2357
|
}
|
|
1848
2358
|
}
|
|
1849
2359
|
// Footer: mirror the card layout — a blank paragraph separates the body
|
|
1850
|
-
// from the addressing line(s). `发送给: @<caller>` always
|
|
1851
|
-
//
|
|
1852
|
-
const addressing =
|
|
2360
|
+
// from the addressing line(s). `发送给: @<caller>` always. Top-level
|
|
2361
|
+
// publish has no specific recipient — skip addressing entirely.
|
|
2362
|
+
const addressing = sendTopLevel
|
|
2363
|
+
? { sendTo: undefined, cc: [] }
|
|
2364
|
+
: buildFooterAddressing(s, oncallEntry);
|
|
1853
2365
|
if (addressing.sendTo || addressing.cc.length > 0) {
|
|
1854
2366
|
if (postContent.length > 0)
|
|
1855
2367
|
postContent.push([{ tag: 'text', text: '' }]);
|
|
@@ -1861,77 +2373,37 @@ async function cmdSend(rest) {
|
|
|
1861
2373
|
}
|
|
1862
2374
|
}
|
|
1863
2375
|
const postJson = JSON.stringify({ zh_cn: { title: '', content: postContent } });
|
|
1864
|
-
messageId = await
|
|
2376
|
+
messageId = await dispatch(postJson, 'post');
|
|
2377
|
+
}
|
|
2378
|
+
// Bridge fallback marker — append-only jsonl per session. The worker
|
|
2379
|
+
// gates its non-adopt transcript-driven fallback on whether any send
|
|
2380
|
+
// happened within the current Lark turn's window. Only when this send
|
|
2381
|
+
// landed in the session's own thread (not --top-level, not --chat-id
|
|
2382
|
+
// override) does it cancel that turn's fallback.
|
|
2383
|
+
if (!sendTopLevel && !overrideChatId) {
|
|
2384
|
+
try {
|
|
2385
|
+
const markerDir = join(resolveDataDir(), 'turn-sends');
|
|
2386
|
+
if (!existsSync(markerDir))
|
|
2387
|
+
mkdirSync(markerDir, { recursive: true });
|
|
2388
|
+
// sentAtMs was captured pre-dispatch (see above). messageId is the
|
|
2389
|
+
// confirmed Lark message id from the now-successful dispatch.
|
|
2390
|
+
const line = JSON.stringify({ sentAtMs, messageId }) + '\n';
|
|
2391
|
+
appendFileSync(join(markerDir, `${sid}.jsonl`), line);
|
|
2392
|
+
}
|
|
2393
|
+
catch { /* best-effort: marker miss only causes a redundant fallback message */ }
|
|
1865
2394
|
}
|
|
1866
2395
|
// Send file attachments as separate messages
|
|
1867
2396
|
const fileIds = [];
|
|
1868
2397
|
for (const fp of files) {
|
|
1869
2398
|
const fileKey = await uploadFile(appId, fp);
|
|
1870
|
-
const fid = await
|
|
2399
|
+
const fid = await dispatch(JSON.stringify({ file_key: fileKey }), 'file');
|
|
1871
2400
|
fileIds.push(fid);
|
|
1872
2401
|
}
|
|
1873
|
-
// Bot-to-bot
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
if (existsSync(botInfoPath))
|
|
1879
|
-
botEntries = JSON.parse(readFileSync(botInfoPath, 'utf-8'));
|
|
1880
|
-
}
|
|
1881
|
-
catch { /* */ }
|
|
1882
|
-
const openIdToAppId = new Map();
|
|
1883
|
-
for (const e of botEntries)
|
|
1884
|
-
if (e.botOpenId)
|
|
1885
|
-
openIdToAppId.set(e.botOpenId, e.larkAppId);
|
|
1886
|
-
try {
|
|
1887
|
-
for (const file of readdirSync(dataDir)) {
|
|
1888
|
-
if (!file.startsWith('bot-openids-') || !file.endsWith('.json'))
|
|
1889
|
-
continue;
|
|
1890
|
-
try {
|
|
1891
|
-
const crossRef = JSON.parse(readFileSync(join(dataDir, file), 'utf-8'));
|
|
1892
|
-
for (const [botName, crossOpenId] of Object.entries(crossRef)) {
|
|
1893
|
-
const entry = botEntries.find(e => e.botName?.toLowerCase() === botName.toLowerCase());
|
|
1894
|
-
if (entry)
|
|
1895
|
-
openIdToAppId.set(crossOpenId, entry.larkAppId);
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
catch { /* */ }
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
catch { /* */ }
|
|
1902
|
-
const targetAppIds = new Set();
|
|
1903
|
-
for (const m of mentions) {
|
|
1904
|
-
const ta = openIdToAppId.get(m.open_id);
|
|
1905
|
-
if (ta && ta !== appId)
|
|
1906
|
-
targetAppIds.add(ta);
|
|
1907
|
-
}
|
|
1908
|
-
if (text && botEntries.length > 0) {
|
|
1909
|
-
for (const entry of botEntries) {
|
|
1910
|
-
if (!entry.botOpenId || entry.larkAppId === appId)
|
|
1911
|
-
continue;
|
|
1912
|
-
const names = [entry.botName, entry.cliId].filter(Boolean);
|
|
1913
|
-
for (const name of names) {
|
|
1914
|
-
if (new RegExp(`@${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text)) {
|
|
1915
|
-
targetAppIds.add(entry.larkAppId);
|
|
1916
|
-
break;
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
if (targetAppIds.size > 0) {
|
|
1922
|
-
const signalDir = join(dataDir, 'bot-mentions');
|
|
1923
|
-
if (!existsSync(signalDir))
|
|
1924
|
-
mkdirSync(signalDir, { recursive: true });
|
|
1925
|
-
for (const targetApp of targetAppIds) {
|
|
1926
|
-
const te = botEntries.find(e => e.larkAppId === targetApp);
|
|
1927
|
-
const signal = {
|
|
1928
|
-
rootMessageId: s.rootMessageId, chatId: s.chatId, chatType: s.chatType,
|
|
1929
|
-
senderAppId: appId, targetBotOpenId: te?.botOpenId ?? targetApp,
|
|
1930
|
-
content: text, messageId, timestamp: Date.now(),
|
|
1931
|
-
};
|
|
1932
|
-
writeFileSync(join(signalDir, `${Date.now()}-${(te?.botOpenId ?? targetApp).slice(-8)}.json`), JSON.stringify(signal));
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
2402
|
+
// Bot-to-bot 转发依赖飞书"获取群组中其他机器人和用户@当前机器人的消息"权限:
|
|
2403
|
+
// 目标 bot 的 daemon 现在能从 WSClient 原生收到 sender_type='app' 的事件,
|
|
2404
|
+
// 不需要 botmux 自己再写本地 signal 文件做转发。outgoing 消息里 @BotName /
|
|
2405
|
+
// --mention 的 open_id 解析(在上方 mentions 数组里完成)仍然必要,它让
|
|
2406
|
+
// Lark 在消息里渲染真正的 @at 元素,从而触发对方 bot 的 WS 事件投递。
|
|
1935
2407
|
console.log(JSON.stringify({ success: true, messageId, sessionId: sid }));
|
|
1936
2408
|
}
|
|
1937
2409
|
catch (err) {
|
|
@@ -1939,6 +2411,147 @@ async function cmdSend(rest) {
|
|
|
1939
2411
|
process.exit(1);
|
|
1940
2412
|
}
|
|
1941
2413
|
}
|
|
2414
|
+
// ─── Create-group subcommand ─────────────────────────────────────────────────
|
|
2415
|
+
async function cmdCreateGroup(rest) {
|
|
2416
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
2417
|
+
console.log(`
|
|
2418
|
+
botmux create-group — 用一组机器人新建飞书群
|
|
2419
|
+
|
|
2420
|
+
用法:
|
|
2421
|
+
botmux create-group --bot <name|larkAppId> [--bot ...] [--name "群名"]
|
|
2422
|
+
|
|
2423
|
+
参数:
|
|
2424
|
+
--bot <ref> 至少一个,可多次。ref 推荐用 bot 显示名(同 botmux send 的 @<name>)或完整 larkAppId;
|
|
2425
|
+
cliId(如 claude-code)仅作 fallback —— 多个 bot 常共用同一个 cliId,重名命中只能取
|
|
2426
|
+
bots.json 中第一个。重名 → 取 bots.json 中第一个匹配,stderr 打 warning。
|
|
2427
|
+
重复 ref → 自动去重保留首次顺序。
|
|
2428
|
+
--name <群名> 可选;不传则用飞书默认无名群。
|
|
2429
|
+
|
|
2430
|
+
行为:
|
|
2431
|
+
- 第一个解析到的 bot 作为 creator(决定建群身份 + 初始群主 + open_id app scope)。
|
|
2432
|
+
- 邀请用户 / 转让群主 / @通知 对象都从 creator 的 resolvedAllowedUsers 取首个 open_id(email 自动转换;
|
|
2433
|
+
转不出来或为空则跳过对应步骤,stderr warning)。
|
|
2434
|
+
- 不依赖 botmux 会话,任何环境都能跑。
|
|
2435
|
+
|
|
2436
|
+
输出协议(skill 友好):
|
|
2437
|
+
- 成功(即使 transfer/notify 部分失败):stdout 单行 chatId,exit 0;stderr 打人类提示 + applink。
|
|
2438
|
+
- 失败(缺 --bot / 解析失败 / chat.create 抛错):stdout 空,exit 非零;stderr 打错误。
|
|
2439
|
+
`);
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
process.env.SESSION_DATA_DIR ??= resolveDataDir();
|
|
2443
|
+
const botRefs = argValues(rest, '--bot');
|
|
2444
|
+
const name = argValue(rest, '--name');
|
|
2445
|
+
if (botRefs.length === 0) {
|
|
2446
|
+
console.error('用法: botmux create-group --bot <name|larkAppId> [--bot ...] [--name "群名"]');
|
|
2447
|
+
console.error('至少传一个 --bot。');
|
|
2448
|
+
process.exit(1);
|
|
2449
|
+
}
|
|
2450
|
+
// Load bot configs (bots.json order) and bots-info.json (for botName)
|
|
2451
|
+
const { registerBot, loadBotConfigs } = await import('./bot-registry.js');
|
|
2452
|
+
let botConfigs;
|
|
2453
|
+
try {
|
|
2454
|
+
botConfigs = loadBotConfigs().map(c => ({ larkAppId: c.larkAppId, cliId: c.cliId }));
|
|
2455
|
+
}
|
|
2456
|
+
catch (err) {
|
|
2457
|
+
console.error(`加载 bots.json 失败: ${err?.message ?? err}`);
|
|
2458
|
+
process.exit(1);
|
|
2459
|
+
}
|
|
2460
|
+
const dataDir = resolveDataDir();
|
|
2461
|
+
const botInfoPath = join(dataDir, 'bots-info.json');
|
|
2462
|
+
let botInfoEntries = [];
|
|
2463
|
+
try {
|
|
2464
|
+
if (existsSync(botInfoPath))
|
|
2465
|
+
botInfoEntries = JSON.parse(readFileSync(botInfoPath, 'utf-8'));
|
|
2466
|
+
}
|
|
2467
|
+
catch { /* */ }
|
|
2468
|
+
const { resolveBotRefs } = await import('./cli/create-group-resolver.js');
|
|
2469
|
+
const resolved = resolveBotRefs(botRefs, botConfigs, botInfoEntries.map(b => ({ larkAppId: b.larkAppId, botName: b.botName })));
|
|
2470
|
+
for (const w of resolved.ambiguousWarnings)
|
|
2471
|
+
console.error(`⚠️ ${w}`);
|
|
2472
|
+
if (resolved.invalid.length > 0) {
|
|
2473
|
+
console.error(`无法解析的 --bot 引用: ${resolved.invalid.join(', ')}`);
|
|
2474
|
+
console.error('可用 bot:');
|
|
2475
|
+
for (const cfg of botConfigs) {
|
|
2476
|
+
const info = botInfoEntries.find(b => b.larkAppId === cfg.larkAppId);
|
|
2477
|
+
console.error(` - ${info?.botName ?? '(unnamed)'} cliId=${cfg.cliId} ${cfg.larkAppId}`);
|
|
2478
|
+
}
|
|
2479
|
+
process.exit(1);
|
|
2480
|
+
}
|
|
2481
|
+
if (resolved.larkAppIds.length === 0) {
|
|
2482
|
+
console.error('未解析到任何 bot,请检查 --bot 引用。');
|
|
2483
|
+
process.exit(1);
|
|
2484
|
+
}
|
|
2485
|
+
const creatorLarkAppId = resolved.larkAppIds[0];
|
|
2486
|
+
// Register bots so getBotClient works inside service
|
|
2487
|
+
const fullConfigs = loadBotConfigs();
|
|
2488
|
+
const needed = new Set(resolved.larkAppIds);
|
|
2489
|
+
try {
|
|
2490
|
+
for (const cfg of fullConfigs)
|
|
2491
|
+
if (needed.has(cfg.larkAppId))
|
|
2492
|
+
registerBot(cfg);
|
|
2493
|
+
}
|
|
2494
|
+
catch (err) {
|
|
2495
|
+
console.error(`注册 bot 失败: ${err?.message ?? err}`);
|
|
2496
|
+
process.exit(1);
|
|
2497
|
+
}
|
|
2498
|
+
// Derive user_open_id from creator's allowedUsers (creator app scope only).
|
|
2499
|
+
// resolveAllowedUsers converts emails → open_ids via creator's Lark client.
|
|
2500
|
+
const creatorCfg = fullConfigs.find(c => c.larkAppId === creatorLarkAppId);
|
|
2501
|
+
const allowedRaw = creatorCfg?.allowedUsers ?? [];
|
|
2502
|
+
const { resolveAllowedUsers } = await import('./im/lark/client.js');
|
|
2503
|
+
let creatorAllowedOpenIds = [];
|
|
2504
|
+
try {
|
|
2505
|
+
creatorAllowedOpenIds = await resolveAllowedUsers(creatorLarkAppId, allowedRaw);
|
|
2506
|
+
}
|
|
2507
|
+
catch (err) {
|
|
2508
|
+
console.error(`⚠️ 解析 creator allowedUsers 失败: ${err?.message ?? err}(继续创建空群)`);
|
|
2509
|
+
}
|
|
2510
|
+
const targetOpenId = creatorAllowedOpenIds[0];
|
|
2511
|
+
if (!targetOpenId) {
|
|
2512
|
+
console.error('⚠️ creator bot 的 allowedUsers 没有可用 open_id — 将创建仅含 bot 的群(跳过邀请/转让/@通知)。');
|
|
2513
|
+
}
|
|
2514
|
+
const { createGroupWithBots } = await import('./services/group-creator.js');
|
|
2515
|
+
let result;
|
|
2516
|
+
try {
|
|
2517
|
+
result = await createGroupWithBots({
|
|
2518
|
+
creatorLarkAppId,
|
|
2519
|
+
larkAppIds: resolved.larkAppIds,
|
|
2520
|
+
name: name?.trim() || undefined,
|
|
2521
|
+
userOpenIds: targetOpenId ? [targetOpenId] : [],
|
|
2522
|
+
transferOwnerTo: targetOpenId,
|
|
2523
|
+
notifyOwnerOpenId: targetOpenId,
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
catch (err) {
|
|
2527
|
+
console.error(`建群失败: ${err?.message ?? err}`);
|
|
2528
|
+
process.exit(1);
|
|
2529
|
+
}
|
|
2530
|
+
// Always stdout chatId on createChat success — even if transfer/notify
|
|
2531
|
+
// partially failed, the chat exists and retrying would create duplicates.
|
|
2532
|
+
process.stdout.write(`${result.chatId}\n`);
|
|
2533
|
+
// Human-readable summary + warnings → stderr.
|
|
2534
|
+
const link = `https://applink.feishu.cn/client/chat/open?openChatId=${encodeURIComponent(result.chatId)}`;
|
|
2535
|
+
console.error(`✅ 群已创建:${link}`);
|
|
2536
|
+
if (result.invalidBotIds.length > 0) {
|
|
2537
|
+
console.error(`⚠️ 飞书拒绝邀请的 bot: ${result.invalidBotIds.join(', ')}`);
|
|
2538
|
+
}
|
|
2539
|
+
if (result.invalidUserIds.length > 0) {
|
|
2540
|
+
console.error(`⚠️ 飞书拒绝邀请的 user: ${result.invalidUserIds.join(', ')}`);
|
|
2541
|
+
}
|
|
2542
|
+
if (result.transferError) {
|
|
2543
|
+
console.error(`⚠️ 群主转让失败 (${result.transferError}) — 当前群主仍为 creator bot`);
|
|
2544
|
+
}
|
|
2545
|
+
else if (result.ownerTransferredTo) {
|
|
2546
|
+
console.error(`✅ 群主已转让给 ${result.ownerTransferredTo}`);
|
|
2547
|
+
}
|
|
2548
|
+
if (result.notifyError) {
|
|
2549
|
+
console.error(`⚠️ @通知发送失败: ${result.notifyError}`);
|
|
2550
|
+
}
|
|
2551
|
+
else if (result.notifyMessageId) {
|
|
2552
|
+
console.error(`✅ @通知已发送 (msg ${result.notifyMessageId})`);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
1942
2555
|
// ─── Bots subcommand ─────────────────────────────────────────────────────────
|
|
1943
2556
|
async function cmdBots(sub, rest) {
|
|
1944
2557
|
process.env.SESSION_DATA_DIR ??= resolveDataDir();
|
|
@@ -2019,13 +2632,13 @@ switch (command) {
|
|
|
2019
2632
|
await cmdSetup();
|
|
2020
2633
|
break;
|
|
2021
2634
|
case 'start':
|
|
2022
|
-
cmdStart();
|
|
2635
|
+
await cmdStart();
|
|
2023
2636
|
break;
|
|
2024
2637
|
case 'stop':
|
|
2025
2638
|
cmdStop();
|
|
2026
2639
|
break;
|
|
2027
2640
|
case 'restart':
|
|
2028
|
-
cmdRestart();
|
|
2641
|
+
await cmdRestart();
|
|
2029
2642
|
break;
|
|
2030
2643
|
case 'logs':
|
|
2031
2644
|
cmdLogs();
|
|
@@ -2036,6 +2649,9 @@ switch (command) {
|
|
|
2036
2649
|
case 'upgrade':
|
|
2037
2650
|
cmdUpgrade();
|
|
2038
2651
|
break;
|
|
2652
|
+
case 'dashboard':
|
|
2653
|
+
await cmdDashboard();
|
|
2654
|
+
break;
|
|
2039
2655
|
case 'list':
|
|
2040
2656
|
case 'ls':
|
|
2041
2657
|
await cmdList();
|
|
@@ -2045,21 +2661,49 @@ switch (command) {
|
|
|
2045
2661
|
case 'rm':
|
|
2046
2662
|
cmdDelete();
|
|
2047
2663
|
break;
|
|
2664
|
+
case 'resume':
|
|
2665
|
+
await cmdResume();
|
|
2666
|
+
break;
|
|
2048
2667
|
case 'schedule':
|
|
2049
2668
|
await cmdSchedule(process.argv[3] ?? '', process.argv.slice(4));
|
|
2050
2669
|
break;
|
|
2051
2670
|
case 'send':
|
|
2052
2671
|
await cmdSend(process.argv.slice(3));
|
|
2053
2672
|
break;
|
|
2673
|
+
case 'create-group':
|
|
2674
|
+
await cmdCreateGroup(process.argv.slice(3));
|
|
2675
|
+
break;
|
|
2054
2676
|
case 'bots':
|
|
2055
2677
|
await cmdBots(process.argv[3] ?? 'list', process.argv.slice(4));
|
|
2056
2678
|
break;
|
|
2679
|
+
case 'history':
|
|
2680
|
+
await cmdHistory(process.argv.slice(3));
|
|
2681
|
+
break;
|
|
2682
|
+
case 'quoted':
|
|
2683
|
+
await cmdQuoted(process.argv.slice(3));
|
|
2684
|
+
break;
|
|
2057
2685
|
case 'thread': {
|
|
2686
|
+
// Removed in favor of `botmux history` (普通群也兼容). Friendly stderr so
|
|
2687
|
+
// pre-rename scripts/skills surface the rename instead of "unknown command".
|
|
2058
2688
|
const sub = process.argv[3] ?? '';
|
|
2059
|
-
|
|
2060
|
-
|
|
2689
|
+
console.error(sub === 'messages' || sub === 'msgs'
|
|
2690
|
+
? `\`botmux thread ${sub}\` 已重命名为 \`botmux history\` (跑普通群和话题群都用它)。`
|
|
2691
|
+
: `\`botmux thread\` 已下线,请用 \`botmux history\``);
|
|
2692
|
+
process.exit(1);
|
|
2693
|
+
break;
|
|
2694
|
+
}
|
|
2695
|
+
case 'autostart': {
|
|
2696
|
+
ensureConfigDir();
|
|
2697
|
+
const sub = process.argv[3] ?? 'status';
|
|
2698
|
+
const opts = { pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR };
|
|
2699
|
+
if (sub === 'enable' || sub === 'install')
|
|
2700
|
+
enableAutostart(opts);
|
|
2701
|
+
else if (sub === 'disable' || sub === 'uninstall')
|
|
2702
|
+
disableAutostart(opts);
|
|
2703
|
+
else if (sub === 'status')
|
|
2704
|
+
autostartStatus(opts);
|
|
2061
2705
|
else {
|
|
2062
|
-
console.error(`用法: botmux
|
|
2706
|
+
console.error(`用法: botmux autostart <enable|disable|status>`);
|
|
2063
2707
|
process.exit(1);
|
|
2064
2708
|
}
|
|
2065
2709
|
break;
|