Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* botmux setup — interactive first-time configuration
* botmux setup --no-open-platform-auto — skip Feishu Open Platform automation
* botmux setup list|add|configure|edit|remove — scripted (non-TUI) bot management, see `botmux setup help`
* botmux clone <bot> [--name <name>] — create a new app, then copy an existing bot's configuration
* botmux start — start daemon and auto plugin services
* botmux stop [--with-plugin] — stop daemon (optionally stop auto plugin services)
* botmux restart [--include-pm2] [--with-plugin] — restart daemon, then ensure auto plugin services;
Expand Down Expand Up @@ -53,6 +54,8 @@ import {
applyBotConfigEdits,
assertUniqueBotProcessNames,
botProcessName,
cloneBotConfig,
cloneOwnerEntries,
normalizeBotConfig,
parseBotConfigsJson,
parseBotSelection,
Expand All @@ -65,7 +68,7 @@ import {
} from './setup/bot-config-editor.js';
import { resolveCliSelection, selectionKeyForBot } from './setup/cli-selection.js';
import { checkCliAvailability, hasAgentLaunchConfigChanged } from './setup/cli-availability.js';
import { resolveSetupAppName } from './setup/app-name.js';
import { resolveCloneAppName, resolveSetupAppName } from './setup/app-name.js';
import {
blocksSetupBotStart,
classifySetupOpenPlatformOutcome,
Expand Down Expand Up @@ -1915,7 +1918,10 @@ function botJsonView(bot: Record<string, any>, index: number): Record<string, an
* 的老姿势在问题序列变化时会静默错位)。校验口径与 TUI 一致:目录存在性、
* owner 必填、凭证变更时的 tenant_access_token 校验,任一失败不写盘。
*/
async function cmdSetupScripted(argv: string[]): Promise<void> {
async function cmdSetupScripted(
argv: string[],
cloneSource?: Record<string, any>,
): Promise<void> {
const wantsJson = argv.includes('--json');
let cmd: SetupCommand;
try {
Expand Down Expand Up @@ -2184,6 +2190,9 @@ async function cmdSetupScripted(argv: string[]): Promise<void> {
failSetupScripted(cmd.json, err?.message ?? String(err));
return;
}
if (cloneSource) {
bot = cloneBotConfig(cloneSource, bot);
}

if (existing.some(b => b?.larkAppId === bot.larkAppId)) {
failSetupScripted(cmd.json, `AppID ${bot.larkAppId} 已存在,修改请用 botmux setup edit ${bot.larkAppId}。`);
Expand Down Expand Up @@ -2486,6 +2495,51 @@ async function cmdSetupScripted(argv: string[]): Promise<void> {
}
}

async function cmdClone(argv: string[]): Promise<void> {
const [sourceSelector, nameFlag, requestedName] = argv;
if (
!sourceSelector
|| (argv.length !== 1 && (argv.length !== 3 || nameFlag !== '--name' || !requestedName?.trim()))
) {
console.error('用法: botmux clone <进程名|配置名|AppID> [--name <新名称>]');
process.exitCode = 1;
return;
}
const bots = loadBotsJson();
const sourceIndex = parseBotSelection(sourceSelector, bots);
if (sourceIndex === undefined) {
console.error(`找不到机器人 "${sourceSelector}"。`);
process.exitCode = 1;
return;
}
const source = bots[sourceIndex];
const owners = cloneOwnerEntries(
source,
process.env.BOTMUX_LARK_APP_ID,
process.env.BOTMUX_OWNER_OPEN_ID ?? process.env.__OWNER_OPEN_ID,
);
if (!hasOwnerEntry(owners)) {
console.error('源机器人没有可跨应用复用的 owner(邮箱、手机号、on_ union_id,或当前会话已认证的 owner)。');
process.exitCode = 1;
return;
}
const addArgs = ['add', '--create-app', '--allowed-users', owners.join(',')];
if (botBrand(source) === 'lark') {
if (requestedName) {
console.error('Lark SDK 创建路径暂不支持自定义应用名称。');
process.exitCode = 1;
return;
}
addArgs.push('--brand', 'lark');
} else {
const sourceName = typeof source.displayName === 'string' && source.displayName.trim()
? source.displayName.trim()
: botProcessName(source, sourceIndex);
addArgs.push('--app-name', resolveCloneAppName(requestedName, sourceName));
}
await cmdSetupScripted(addArgs, source);
}

// ─── Commands ────────────────────────────────────────────────────────────────

async function cmdSetup(): Promise<void> {
Expand Down Expand Up @@ -7280,6 +7334,8 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
命令:
setup 交互式配置(首次使用 / 添加机器人)
默认使用 botmux 内置 Feishu Web QR 登录尝试自动导入权限/redirect/发布版本;可加 --no-open-platform-auto 跳过
clone <机器人名> [--name <新名称>]
创建新应用并复制该机器人的行为配置;留空名称自动使用 源名称-copy-时间戳
start 启动 daemon,并启动 mode=auto 的插件 service
stop 停止 daemon(默认不停止插件 service;--with-plugin 显式停止 mode=auto 的插件 service)
restart 重启 daemon(默认不停止插件 service,core 启动后确保 mode=auto 正在运行;--with-plugin 显式先停再启动 auto service)
Expand Down Expand Up @@ -14145,6 +14201,7 @@ switch (command) {
else await cmdSetup();
break;
}
case 'clone': await cmdClone(process.argv.slice(3)); break;
case 'start': await cmdStart(); break;
case 'serve': await cmdServe(process.argv.slice(3)); break;
case 'start-bot': await cmdStartBot(process.argv.slice(3)); break;
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4780,6 +4780,7 @@ const server = createServer(async (req, res) => {
if (req.method === 'POST' && url.pathname === '/api/bot-onboarding/start') {
let parsed: {
appName?: unknown;
cloneSourceAppId?: unknown;
registrationMode?: unknown;
sessionMode?: unknown;
expectedIdentity?: unknown;
Expand Down Expand Up @@ -4871,6 +4872,9 @@ const server = createServer(async (req, res) => {
}
const job = botOnboarding.start({
appName,
...(typeof parsed.cloneSourceAppId === 'string' && parsed.cloneSourceAppId.trim()
? { cloneSourceAppId: parsed.cloneSourceAppId.trim() }
: {}),
registrationMode,
...(registrationMode === 'web' ? { sessionMode, expectedIdentity } : {}),
cliId,
Expand Down
35 changes: 28 additions & 7 deletions src/dashboard/bot-onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
import { readBotsJsonOrEmpty, writeBotsJsonAtomic } from '../setup/bots-store.js';
import { atomicWriteFileSync } from '../utils/atomic-write.js';
import { logger } from '../utils/logger.js';
import { normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry } from '../setup/bot-config-editor.js';
import { cloneBotConfig, normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry } from '../setup/bot-config-editor.js';
import {
detectUnusableOwnerEntries,
resolveScannerAllowedUser,
Expand All @@ -19,7 +19,7 @@ import {
type CriticalScopeReadbackResult,
type RemainingStep,
} from '../setup/verify-permissions.js';
import { resolveSetupAppName } from '../setup/app-name.js';
import { resolveCloneAppName, resolveSetupAppName } from '../setup/app-name.js';
import {
automateOpenPlatformSetup,
BOT_BASELINE_APP_EVENTS,
Expand Down Expand Up @@ -141,8 +141,9 @@ export interface BotOnboardingSnapshot {

/** 调用方 (dashboard) 已校验过的表单输入: CLI / 工作目录 / model. */
export interface BotOnboardingInput {
/** 飞书应用名称;留空时按待追加的 bots.json 行号生成 botmux-N。 */
/** 飞书应用名称;普通创建留空生成 botmux-N,克隆留空生成 源名称-copy-时间戳。 */
appName?: string;
cloneSourceAppId?: string;
/** 默认 Feishu 单码主路径;compat 是用户明确确认过的 SDK 兼容模式。 */
registrationMode?: 'web' | 'compat';
/**
Expand Down Expand Up @@ -1591,9 +1592,24 @@ export class BotOnboardingManager {
}

private async run(id: string, input: BotOnboardingInput = {}): Promise<void> {
const configuredBots = readBotsJsonOrEmpty(this.opts.botsJsonPath);
const cloneSource = input.cloneSourceAppId
? configuredBots.find((bot: any) => bot?.larkAppId === input.cloneSourceAppId)
: undefined;
if (input.cloneSourceAppId && !cloneSource) {
this.patch(id, { status: 'failed', error: 'clone_source_not_found', message: '源机器人不存在' });
return;
}
// Freeze the resolved name before any asynchronous work. Later bot list
// changes must not make the name drift midway through onboarding.
const appName = resolveSetupAppName(input.appName, readBotsJsonOrEmpty(this.opts.botsJsonPath).length);
const appName = cloneSource
? resolveCloneAppName(
input.appName,
[cloneSource.displayName, cloneSource.name, input.cloneSourceAppId]
.find(value => typeof value === 'string' && value.trim()),
this.now(),
)
: resolveSetupAppName(input.appName, configuredBots.length);
this.patch(id, {
registrationMode: input.registrationMode ?? 'web',
...(input.requireCriticalScopesBeforeActivation
Expand Down Expand Up @@ -1673,9 +1689,9 @@ export class BotOnboardingManager {

// CLI / 工作目录 / model 来自前端表单 (dashboard 已用 resolveCliId +
// invalidWorkingDirs 校验过). 留空回退到 setup 同款默认: claude-code / '~'.
const cliId: CliId = input.cliId ?? 'claude-code';
const workingDir = input.workingDir?.trim() || '~';
const bot: Record<string, any> = {
let cliId: CliId = input.cliId ?? 'claude-code';
let workingDir = input.workingDir?.trim() || '~';
let bot: Record<string, any> = {
larkAppId: result.appId,
larkAppSecret: result.appSecret,
cliId,
Expand All @@ -1690,6 +1706,11 @@ export class BotOnboardingManager {
if (result.brand === 'lark') {
bot.brand = 'lark';
}
if (cloneSource) {
bot = cloneBotConfig(cloneSource, bot);
cliId = bot.cliId ?? 'claude-code';
workingDir = bot.defaultWorkingDir ?? bot.workingDir ?? '~';
}
// 注意:此处 **不** 立刻把 bot 写进 bots.json。空 allowedUsers 的 bot 一旦落盘,
// 就是一个「可被 botmux start/restart 读取、运行时按无白名单全开放」的 fail-open
// 隐患(哪怕没出 restart hint, 关弹窗 / 重启 / pm2 重启都会以开放模式起)。
Expand Down
17 changes: 17 additions & 0 deletions src/dashboard/web/bot-defaults-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,23 @@ export function BotDefaultsPage() {
</div>
<div className="page-heading-actions">
<RefreshIconButton id="bd-refresh" label={tr('botDefaults.refresh')} busy={refreshing} disabled={refreshing} onClick={() => void reload()} />
{ui.authed && bots.length > 0 ? (
<DropdownMenu<string>
className="clone-bot-menu"
ariaLabel={tr('botOnboarding.clone')}
disabled={onboardingBusy}
label={tr('botOnboarding.clone')}
value=""
options={bots.map(bot => ({
value: bot.larkAppId,
label: `${bot.botName ?? bot.larkAppId} · ${displayCliId(bot, cliIdOf(bot.larkAppId))}`,
}))}
onChange={sourceAppId => {
setOnboardingBusy(true);
void openBotOnboarding(sourceAppId).finally(() => setOnboardingBusy(false));
}}
/>
) : null}
{ui.authed ? (
<CreateActionButton
className="page-primary-action add-bot-btn"
Expand Down
6 changes: 5 additions & 1 deletion src/dashboard/web/bot-onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { confirm } from './confirm-modal.js';
import { t } from './ui.js';

export const OPEN_BOT_ONBOARDING_EVENT = 'botmux:open-bot-onboarding';
let cloneSourceAppId: string | undefined;

type OnboardingStatus =
| 'starting'
Expand Down Expand Up @@ -245,7 +246,8 @@ function normalizeFormForOptions(form: OnboardingFormState, cliState: CliOptions
}, cliId, cliState);
}

export async function openBotOnboarding(): Promise<void> {
export async function openBotOnboarding(sourceAppId?: string): Promise<void> {
cloneSourceAppId = sourceAppId;
window.dispatchEvent(new Event(OPEN_BOT_ONBOARDING_EVENT));
}

Expand Down Expand Up @@ -636,6 +638,7 @@ export function BotOnboardingDialog(props: { open: boolean; onClose(): void }):

const close = useCallback(() => {
stopPolling();
cloneSourceAppId = undefined;
props.onClose();
}, [props, stopPolling]);

Expand Down Expand Up @@ -735,6 +738,7 @@ export function BotOnboardingDialog(props: { open: boolean; onClose(): void }):
workingDir: form.workingDir.trim(),
dirMode: form.dirMode,
model: form.model.trim() || undefined,
cloneSourceAppId,
}),
});
const body = await res.json();
Expand Down
2 changes: 2 additions & 0 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const zh = {
'skin.cyber': '2077',
'skin.fallout': 'Fallout',
'botOnboarding.add': '添加机器人',
'botOnboarding.clone': '克隆机器人',
'botOnboarding.title': '添加机器人',
'botOnboarding.intro': '设置它如何工作。首次扫码登录后,后续可复用当前账号免扫码添加。',
'botOnboarding.sessionChecking': '正在检查飞书登录状态…',
Expand Down Expand Up @@ -2554,6 +2555,7 @@ const en: Record<keyof typeof zh, string> = {
'skin.cyber': '2077',
'skin.fallout': 'Fallout',
'botOnboarding.add': 'Add Bot',
'botOnboarding.clone': 'Clone Bot',
'botOnboarding.title': 'Add Bot',
'botOnboarding.intro': 'Choose how it works. Scan once to sign in, then reuse that account for future additions without scanning.',
'botOnboarding.sessionChecking': 'Checking your Feishu sign-in…',
Expand Down
13 changes: 13 additions & 0 deletions src/dashboard/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -22914,6 +22914,19 @@ dialog select[multiple]:hover:not(:disabled) {
}

/* ─── Bot 配置:平台化表单控件 ───────────────────────────────────────────── */
.bot-defaults-page .clone-bot-menu[open] {
z-index: 1000;
}

.bot-defaults-page .clone-bot-menu > .sect-sort-pop {
right: 0;
left: auto;
width: max-content;
min-width: 220px;
max-width: min(340px, calc(100vw - 48px));
transform: none;
}

.bot-defaults-page .bd-detail,
.bot-defaults-page .bd-card.bd-profile {
background: transparent;
Expand Down
11 changes: 11 additions & 0 deletions src/setup/app-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,14 @@ export function resolveSetupAppName(requestedName: string | undefined, nextBotIn
const requested = requestedName?.trim();
return requested || `botmux-${nextBotIndex}`;
}

export function resolveCloneAppName(
requestedName: string | undefined,
sourceName: string | undefined,
now = Date.now(),
): string {
const requested = requestedName?.trim();
if (requested) return requested;
const suffix = `-copy-${now}`;
return `${(sourceName?.trim() || 'botmux').slice(0, 64 - suffix.length)}${suffix}`;
}
56 changes: 56 additions & 0 deletions src/setup/bot-config-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,62 @@ export function parseBotSelection(
return byProcessName >= 0 ? byProcessName : undefined;
}

/**
* 把源 Bot 的行为配置覆盖到刚创建的目标 Bot,同时保留目标应用自己的身份。
* Dashboard 与 CLI clone 共用这里,避免两条入口各维护一份排除字段。
*/
export function cloneBotConfig(
source: Record<string, any>,
target: Record<string, any>,
): Record<string, any> {
const cloned: Record<string, any> = { ...target, ...source };

for (const key of [
'apiOnly',
'name',
'displayName',
'messageListeners',
'oncallChats',
'allowedChatGroups',
'chatGrants',
'globalGrants',
'quotaState',
'grantExpiryState',
'sessionGroup',
'chatReplyModes',
'noCardChats',
'activationPending',
'activationDeactivating',
'activationStarting',
'activationCommitted',
]) {
delete cloned[key];
}

for (const key of ['larkAppId', 'larkAppSecret', 'brand', 'allowedUsers', 'ownerOpenId']) {
if (Object.prototype.hasOwnProperty.call(target, key) && target[key] !== undefined) {
cloned[key] = target[key];
} else {
delete cloned[key];
}
}

return cloned;
}

/** 只允许 daemon 已认证的 source open_id 进入共享的跨应用 owner 归一化。 */
export function cloneOwnerEntries(
source: Record<string, any>,
sourceAppId?: string,
sourceOwnerOpenId?: string,
): string[] {
const managedOwner = sourceAppId === source.larkAppId ? sourceOwnerOpenId : undefined;
if (!Array.isArray(source.allowedUsers)) return [];
return source.allowedUsers.filter((entry: unknown): entry is string => (
typeof entry === 'string' && (!entry.startsWith('ou_') || entry === managedOwner)
));
}

export function removeBotConfig<T extends { larkAppId?: string; name?: unknown }>(
bots: T[],
selection: string,
Expand Down
Loading