diff --git a/package-lock.json b/package-lock.json index 03982137..4f8b0b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@the-next-ai/ai-gateway": "file:../../next-ai/gateway", + "@the-next-ai/bot-gateway-sdk": "^0.1.0", "electron-updater": "^6.8.9", "node-forge": "^1.4.0", "sql.js": "^1.14.1", @@ -2074,6 +2075,18 @@ "node": ">=20" } }, + "node_modules/@the-next-ai/bot-gateway-sdk": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@the-next-ai/bot-gateway-sdk/-/bot-gateway-sdk-0.1.0.tgz", + "integrity": "sha512-AedLi7oqf3ChqPllvy3h+h6plGZf/Rxbpp5TdmfAKeXjJ7ezaWVxRtsOA6EFAVXYLTtYQ+Sy0fZThW5FucKYKQ==", + "license": "MIT", + "bin": { + "bot-gateway-stdio": "bin/bot-gateway-stdio.mjs" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", diff --git a/package.json b/package.json index acc521e0..f5a3f46d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@the-next-ai/ai-gateway": "file:../../next-ai/gateway", + "@the-next-ai/bot-gateway-sdk": "^0.1.0", "electron-updater": "^6.8.9", "node-forge": "^1.4.0", "sql.js": "^1.14.1", diff --git a/src/main/bot-gateway-env.ts b/src/main/bot-gateway-env.ts new file mode 100644 index 00000000..72449b1a --- /dev/null +++ b/src/main/bot-gateway-env.ts @@ -0,0 +1,230 @@ +import os from "node:os"; +import { createRequire } from "node:module"; +import path from "node:path"; +import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig } from "../shared/app"; +import { CONFIGDIR } from "./constants"; + +const requireFromHere = createRequire(__filename); + +export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig): Record { + const bot = normalizeBotGatewayForWebSocket(resolveBotGatewayConfig(config, profile)); + if (!bot?.enabled || !bot.platform || bot.platform === "none") { + return disabledBotGatewayEnv(); + } + + const handoff = bot.handoff ?? { + enabled: false, + idleSeconds: 30, + phoneBluetoothTargets: [], + phoneWifiTargets: [], + screenLock: true, + userIdle: true + }; + const stateDir = resolveBotGatewayStateDir(bot, profile); + const env: Record = { + BOT_GATEWAY_STATE_DIR: stateDir, + CCR_BOT_GATEWAY_ACK_EVENTS: boolEnv(bot.acknowledgeEvents), + CCR_BOT_GATEWAY_ARGS_JSON: JSON.stringify(bot.args ?? []), + CCR_BOT_GATEWAY_AUTH_TYPE: bot.authType ?? "", + CCR_BOT_GATEWAY_AUTO_START_INTEGRATION: boolEnv(bot.autoStartIntegration), + CCR_BOT_GATEWAY_COMMAND: bot.command ?? "", + CCR_BOT_GATEWAY_CONFIG_JSON: JSON.stringify(bot.integrationConfig ?? {}), + CCR_BOT_GATEWAY_CREATE_INTEGRATION: boolEnv(bot.createIntegration), + CCR_BOT_GATEWAY_CREDENTIALS_JSON: JSON.stringify(bot.credentials ?? {}), + CCR_BOT_GATEWAY_CWD: bot.cwd ?? "", + CCR_BOT_GATEWAY_ENABLED: "true", + CCR_BOT_GATEWAY_FORWARD_ALL_AGENT_MESSAGES: boolEnv(bot.forwardAllAgentMessages), + CCR_BOT_GATEWAY_INTEGRATION_ID: bot.integrationId ?? "", + CCR_BOT_GATEWAY_PLATFORM: bot.platform, + CCR_BOT_GATEWAY_POLL_INTERVAL_MS: String(bot.pollIntervalMs ?? 2000), + CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: String(bot.requestTimeoutMs ?? 600000), + CCR_BOT_GATEWAY_SOURCE_DIR: bot.sourceDir ?? "", + ...botGatewaySdkEnv(), + CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: String(bot.startupTimeoutMs ?? 10000), + CCR_BOT_GATEWAY_STATE_DIR: stateDir, + CCR_BOT_GATEWAY_TENANT_ID: bot.tenantId ?? "ccr", + CCR_BOT_HANDOFF_ENABLED: boolEnv(handoff.enabled), + CCR_BOT_HANDOFF_IDLE_SECONDS: String(handoff.idleSeconds ?? 30), + CCR_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS: (handoff.phoneBluetoothTargets ?? []).join("\n"), + CCR_BOT_HANDOFF_PHONE_WIFI_TARGETS: (handoff.phoneWifiTargets ?? []).join("\n"), + CCR_BOT_HANDOFF_SCREEN_LOCK: boolEnv(handoff.screenLock), + CCR_BOT_HANDOFF_USER_IDLE: boolEnv(handoff.userIdle), + CCR_BOT_PROFILE_ID: profile.id, + CCR_BOT_PROFILE_NAME: profile.name, + + CODEXL_BOT_GATEWAY_ENABLED: "true", + CODEXL_BOT_GATEWAY_FORWARD_ALL_CODEX_MESSAGES: boolEnv(bot.forwardAllAgentMessages), + CODEXL_BOT_GATEWAY_INTEGRATION_ID: bot.integrationId ?? "", + CODEXL_BOT_GATEWAY_PLATFORM: bot.platform, + CODEXL_BOT_GATEWAY_STATE_DIR: stateDir, + CODEXL_BOT_GATEWAY_TENANT_ID: bot.tenantId ?? "ccr", + CODEXL_BOT_HANDOFF_ENABLED: boolEnv(handoff.enabled), + CODEXL_BOT_HANDOFF_IDLE_SECONDS: String(handoff.idleSeconds ?? 30), + CODEXL_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS: (handoff.phoneBluetoothTargets ?? []).join("\n"), + CODEXL_BOT_HANDOFF_PHONE_WIFI_TARGETS: (handoff.phoneWifiTargets ?? []).join("\n"), + CODEXL_BOT_HANDOFF_SCREEN_LOCK: boolEnv(handoff.screenLock), + CODEXL_BOT_HANDOFF_USER_IDLE: boolEnv(handoff.userIdle) + }; + + if (bot.conversationRef) { + env.CCR_BOT_GATEWAY_CONVERSATION_REF_JSON = JSON.stringify(bot.conversationRef); + } + + return env; +} + +function resolveBotGatewayConfig(config: AppConfig, profile: ProfileConfig): BotGatewayRuntimeConfig { + const savedBot = profile.botConfigId + ? (config.botConfigs ?? []).find((item) => item.id === profile.botConfigId) + : undefined; + return savedBot?.botGateway ?? profile.botGateway ?? config.botGateway; +} + +function botGatewaySdkEnv(): Record { + const sdkModule = resolveBotGatewaySdkModule(); + return sdkModule ? { CCR_BOT_GATEWAY_SDK_MODULE: sdkModule } : {}; +} + +function resolveBotGatewaySdkModule(): string { + try { + return path.join(path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")), "dist", "index.js"); + } catch { + return ""; + } +} + +function normalizeBotGatewayForWebSocket(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig { + const platform = normalizeBotGatewayPlatform(bot.platform); + return { + ...bot, + authType: normalizeBotGatewayAuthType(platform, bot.authType), + credentials: sanitizeBotGatewayRecord(bot.credentials), + integrationConfig: websocketBotGatewayIntegrationConfig(platform, bot.integrationConfig), + platform + }; +} + +function normalizeBotGatewayPlatform(value: string): string { + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized === "off" || normalized === "disabled") { + return "none"; + } + if (normalized === "lark") { + return "feishu"; + } + if (normalized === "dingding") { + return "dingtalk"; + } + if (["wechat", "weixin", "wx", "weixin-ilink", "weixin_ilink", "ilink"].includes(normalized)) { + return "weixin-ilink"; + } + if (["wecom", "wework", "wechat-work", "work-weixin", "enterprise-wechat"].includes(normalized)) { + return "wecom"; + } + return normalized || "none"; +} + +function normalizeBotGatewayAuthType(platform: string, value: string): string { + const normalized = value.trim().toLowerCase().replace(/-/g, "_"); + if (!platform || platform === "none") { + return ""; + } + if (!normalized || normalized === "default" || normalized === "auto" || normalized === "webhook" || normalized === "webhook_secret" || normalized === "outgoing_webhook") { + return defaultBotGatewayAuthType(platform); + } + if (normalized === "appsecret") { + return "app_secret"; + } + if (normalized === "bottoken" || normalized === "token") { + return "bot_token"; + } + if (normalized === "oauth" || normalized === "oauth_2") { + return "oauth2"; + } + if (["qr", "qr_login", "qrcode", "qr_code"].includes(normalized)) { + return "qr_login"; + } + return normalized; +} + +function defaultBotGatewayAuthType(platform: string): string { + if (platform === "weixin-ilink") { + return "qr_login"; + } + if (platform === "feishu" || platform === "dingtalk" || platform === "wecom") { + return "app_secret"; + } + if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") { + return "bot_token"; + } + return ""; +} + +function websocketBotGatewayIntegrationConfig(platform: string, value: Record): Record { + const config = sanitizeBotGatewayRecord(value); + delete config.transport; + delete config.sendMode; + const transport = botGatewayWebSocketTransport(platform); + return transport ? { ...config, transport } : config; +} + +function botGatewayWebSocketTransport(platform: string): string { + if (!platform || platform === "none") { + return ""; + } + return platform === "slack" ? "socket" : "websocket"; +} + +function sanitizeBotGatewayRecord(value: Record | undefined): Record { + const result: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return result; + } + for (const [key, rawValue] of Object.entries(value)) { + if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) { + continue; + } + result[key] = rawValue; + } + return result; +} + +function isWebhookRelatedBotGatewayKey(key: string): boolean { + const normalized = key.trim().toLowerCase().replace(/[_-]+/g, ""); + return normalized.includes("webhook") || normalized === "sendmode"; +} + +function disabledBotGatewayEnv(): Record { + return { + CCR_BOT_GATEWAY_ENABLED: "false", + CODEXL_BOT_GATEWAY_ENABLED: "false" + }; +} + +function resolveBotGatewayStateDir(bot: BotGatewayRuntimeConfig, profile: ProfileConfig): string { + const configured = (bot.stateDir ?? "").trim(); + if (configured) { + return resolveUserPath(configured); + } + const slug = sanitizePathSegment(profile.id || profile.name || profile.agent) || "default"; + return path.join(CONFIGDIR, "bot-gateway", slug); +} + +function resolveUserPath(value: string): string { + const trimmed = value.trim(); + if (trimmed === "~") { + return os.homedir(); + } + if (trimmed.startsWith("~/")) { + return path.join(os.homedir(), trimmed.slice(2)); + } + return path.resolve(trimmed || "."); +} + +function sanitizePathSegment(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function boolEnv(value: boolean): string { + return value ? "true" : "false"; +} diff --git a/src/main/bot-gateway-qr-login-service.ts b/src/main/bot-gateway-qr-login-service.ts new file mode 100644 index 00000000..6e1cfa97 --- /dev/null +++ b/src/main/bot-gateway-qr-login-service.ts @@ -0,0 +1,428 @@ +import { mkdirSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { CONFIGDIR } from "./constants"; +import type { + BotGatewayQrLoginCancelRequest, + BotGatewayQrLoginCancelResult, + BotGatewayQrLoginStartRequest, + BotGatewayQrLoginStartResult, + BotGatewayQrLoginWaitRequest, + BotGatewayQrLoginWaitResult, + BotGatewayRuntimeConfig +} from "../shared/app"; + +type BotGatewayClientWithRequest = { + close?: () => Promise | void; + health: () => Promise; + request: (method: string, params?: unknown) => Promise; +}; + +type BotGatewaySdkModule = { + createBotGatewayClient: (options?: unknown) => unknown; +}; + +type QrSession = { + botConfigId: string; + client: BotGatewayClientWithRequest; + credentials: Record; + integrationConfig: Record; + integrationId: string; + platform: string; + stateDir: string; + tenantId: string; + timeoutMs: number; +}; + +const qrSessions = new Map(); +let sdkPromise: Promise | undefined; + +export async function startBotGatewayQrLogin( + request: BotGatewayQrLoginStartRequest +): Promise { + const savedConfig = request.config; + const bot = normalizeBotGatewayForQr(savedConfig.botGateway); + if (!bot.enabled || bot.platform !== "weixin-ilink" || bot.authType !== "qr_login") { + throw new Error("微信扫码登录只支持微信平台的扫码认证方式。"); + } + + const stateDir = resolveBotGatewayStateDir(bot, savedConfig.id); + mkdirSync(stateDir, { recursive: true }); + const client = await createQrClient(bot, stateDir); + const timeoutMs = Math.max(1000, bot.requestTimeoutMs || 600000); + let registered = false; + try { + await withTimeout(client.health(), Math.max(1000, bot.startupTimeoutMs || 10000), "Bot Gateway health check timed out."); + + const integrationId = await resolveWeixinQrIntegrationId(client, bot, timeoutMs); + const rawStart = await botGatewayClientRequest(client, "auth.qr.start", { + config: bot.integrationConfig, + credentials: bot.credentials, + force: request.force !== false, + integrationId, + platform: bot.platform, + tenantId: bot.tenantId + }, timeoutMs); + const auth = unwrapGatewayResult(rawStart); + const sessionId = stringValue(auth.sessionId); + if (!sessionId) { + throw new Error("Bot Gateway QR start response missing sessionId."); + } + + const previous = qrSessions.get(sessionId); + if (previous) { + closeQrClient(previous.client); + } + qrSessions.set(sessionId, { + botConfigId: savedConfig.id, + client, + credentials: bot.credentials, + integrationConfig: bot.integrationConfig, + integrationId, + platform: bot.platform, + stateDir, + tenantId: bot.tenantId, + timeoutMs + }); + registered = true; + + return { + botConfigId: savedConfig.id, + expiresAt: stringValue(auth.expiresAt), + integrationId, + message: stringValue(auth.message), + platform: bot.platform, + qrCodeUrl: stringValue(auth.qrCodeUrl), + sessionId, + stateDir, + tenantId: bot.tenantId + }; + } catch (error) { + if (!registered) { + closeQrClient(client); + } + throw error; + } +} + +export async function waitBotGatewayQrLogin( + request: BotGatewayQrLoginWaitRequest +): Promise { + const sessionId = request.sessionId.trim(); + const session = qrSessions.get(sessionId); + if (!session) { + throw new Error("微信扫码登录会话不存在,请重新生成二维码。"); + } + + const rawWait = await botGatewayClientRequest(session.client, "auth.qr.wait", { + autoStart: true, + config: session.integrationConfig, + configOverride: { + ...session.integrationConfig, + transport: botGatewayWebSocketTransport(session.platform) + }, + credentials: session.credentials, + integrationId: session.integrationId, + platform: session.platform, + sessionId, + tenantId: session.tenantId, + timeoutMs: Math.max(1000, request.timeoutMs || 5000), + verifyCode: request.verifyCode?.trim() || undefined + }, session.timeoutMs); + const auth = unwrapGatewayResult(rawWait); + const status = stringValue(auth.status) || "pending"; + const confirmed = status === "confirmed"; + const result = { + confirmed, + integrationId: session.integrationId, + message: stringValue(auth.message), + sessionId, + stateDir: session.stateDir, + status, + tenantId: session.tenantId + }; + + if (isTerminalQrLoginStatus(status)) { + qrSessions.delete(sessionId); + closeQrClient(session.client); + } + + return result; +} + +export function cancelBotGatewayQrLogin( + request: BotGatewayQrLoginCancelRequest +): BotGatewayQrLoginCancelResult { + const sessionId = request.sessionId.trim(); + const session = qrSessions.get(sessionId); + if (session) { + qrSessions.delete(sessionId); + closeQrClient(session.client); + } + return { canceled: Boolean(session) }; +} + +async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): Promise { + const sdk = await loadBotGatewaySdk(); + const client = sdk.createBotGatewayClient({ + transport: "stdio", + env: { + ...process.env, + BOT_GATEWAY_STATE_DIR: stateDir, + CODEXL_HOME: CONFIGDIR + }, + ...(bot.command + ? { + args: bot.args, + command: resolveUserPath(bot.command), + cwd: bot.cwd ? resolveUserPath(bot.cwd) : process.cwd() + } + : {}) + }) as BotGatewayClientWithRequest; + if (!client || typeof client.request !== "function" || typeof client.health !== "function") { + throw new Error("Bot Gateway SDK client does not expose request()."); + } + return client; +} + +async function loadBotGatewaySdk(): Promise { + if (!sdkPromise) { + sdkPromise = importBotGatewaySdk(); + } + return sdkPromise; +} + +async function importBotGatewaySdk(): Promise { + const candidates = [ + process.env.CCR_BOT_GATEWAY_SDK_MODULE, + "@the-next-ai/bot-gateway-sdk" + ].filter((value): value is string => Boolean(value?.trim())); + const errors: string[] = []; + for (const candidate of candidates) { + try { + const sdk = await import(botGatewaySdkImportSpecifier(candidate)); + if (sdk && typeof sdk.createBotGatewayClient === "function") { + return sdk as BotGatewaySdkModule; + } + errors.push(`${candidate}: missing createBotGatewayClient export`); + } catch (error) { + errors.push(`${candidate}: ${formatError(error)}`); + } + } + throw new Error(`Unable to load @the-next-ai/bot-gateway-sdk. ${errors.join("; ")}`); +} + +function botGatewaySdkImportSpecifier(value: string): string { + const trimmed = value.trim(); + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) { + return trimmed; + } + if (path.isAbsolute(trimmed)) { + return pathToFileURL(trimmed).href; + } + return trimmed; +} + +async function resolveWeixinQrIntegrationId( + client: BotGatewayClientWithRequest, + bot: BotGatewayRuntimeConfig, + timeoutMs: number +): Promise { + const requested = bot.integrationId.trim(); + const raw = await botGatewayClientRequest(client, "integrations.list", {}, timeoutMs).catch(() => ({})); + const result = unwrapGatewayResult(raw); + const integrations = Array.isArray(result.integrations) ? result.integrations.filter(isRecord) : []; + const requestedIntegration = integrations.find((integration) => stringValue(integration.id) === requested); + if (requestedIntegration) { + if (stringValue(requestedIntegration.platform) === "weixin-ilink") { + return requested; + } + } else if (requested) { + return requested; + } + + const tenant = bot.tenantId.trim(); + const tenantIntegration = integrations.find((integration) => + stringValue(integration.platform) === "weixin-ilink" && + stringValue(integration.tenantId).toLowerCase() === tenant.toLowerCase() + ); + if (tenantIntegration) { + const id = stringValue(tenantIntegration.id); + if (id) return id; + } + + const platformIntegration = integrations.find((integration) => stringValue(integration.platform) === "weixin-ilink"); + if (platformIntegration) { + const id = stringValue(platformIntegration.id); + if (id) return id; + } + + return requested || safePathSegment(`weixin-ilink-${tenant || "ccr"}`); +} + +function normalizeBotGatewayForQr(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig { + const platform = normalizeBotGatewayPlatform(bot.platform); + const authType = normalizeBotGatewayAuthType(platform, bot.authType); + return { + ...bot, + authType, + credentials: sanitizeBotGatewayRecord(bot.credentials), + integrationConfig: websocketBotGatewayIntegrationConfig(platform, bot.integrationConfig), + platform, + tenantId: bot.tenantId.trim() || "ccr" + }; +} + +function websocketBotGatewayIntegrationConfig(platform: string, value: Record): Record { + const config = sanitizeBotGatewayRecord(value); + delete config.transport; + delete config.sendMode; + const transport = botGatewayWebSocketTransport(platform); + return transport ? { ...config, transport } : config; +} + +function botGatewayWebSocketTransport(platform: string): string { + if (!platform || platform === "none") { + return ""; + } + return platform === "slack" ? "socket" : "websocket"; +} + +function normalizeBotGatewayPlatform(value: string): string { + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized === "off" || normalized === "disabled") { + return "none"; + } + if (normalized === "lark") { + return "feishu"; + } + if (normalized === "dingding") { + return "dingtalk"; + } + if (normalized === "wechat" || normalized === "weixin-work" || normalized === "wework") { + return "wecom"; + } + return normalized; +} + +function normalizeBotGatewayAuthType(platform: string, value: string): string { + const normalized = value.trim().toLowerCase().replace(/[-\s]+/g, "_"); + const aliases: Record = { + qr: "qr_login", + qr_code: "qr_login", + qr_login: "qr_login", + qrcode: "qr_login", + token: "bot_token" + }; + const authType = aliases[normalized] ?? normalized; + if (platform === "weixin-ilink") { + return authType || "qr_login"; + } + return authType; +} + +function sanitizeBotGatewayRecord(value: Record | undefined): Record { + const result: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return result; + } + for (const [key, rawValue] of Object.entries(value)) { + if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) { + continue; + } + result[key] = rawValue; + } + return result; +} + +function isWebhookRelatedBotGatewayKey(key: string): boolean { + const normalized = key.trim().toLowerCase().replace(/[_-]+/g, ""); + return normalized.includes("webhook") || normalized === "sendmode"; +} + +function unwrapGatewayResult(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const result = value.result; + return isRecord(result) ? result : value; +} + +function botGatewayClientRequest( + client: BotGatewayClientWithRequest, + method: string, + params: unknown, + timeoutMs: number +): Promise { + return withTimeout(client.request(method, params), timeoutMs, `Bot Gateway request timed out: ${method}`); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + const timeout = Math.max(1000, timeoutMs || 30000); + let timer: NodeJS.Timeout | undefined; + return new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeout); + promise.then( + (value) => { + if (timer) clearTimeout(timer); + resolve(value); + }, + (error) => { + if (timer) clearTimeout(timer); + reject(error); + } + ); + }); +} + +function closeQrClient(client: BotGatewayClientWithRequest): void { + try { + const result = client.close?.(); + if (result && typeof (result as Promise).catch === "function") { + (result as Promise).catch(() => undefined); + } + } catch { + // Best-effort cleanup for a short-lived QR login helper process. + } +} + +function resolveBotGatewayStateDir(bot: BotGatewayRuntimeConfig, configId: string): string { + const configured = bot.stateDir.trim(); + if (configured) { + return resolveUserPath(configured); + } + const slug = safePathSegment(configId || bot.integrationId || bot.tenantId) || "default"; + return path.join(CONFIGDIR, "bot-gateway", slug); +} + +function resolveUserPath(value: string): string { + const trimmed = value.trim(); + if (trimmed === "~") { + return os.homedir(); + } + if (trimmed.startsWith("~/")) { + return path.join(os.homedir(), trimmed.slice(2)); + } + return trimmed; +} + +function safePathSegment(value: string): string { + return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function isTerminalQrLoginStatus(status: string): boolean { + return ["already_bound", "confirmed", "expired", "failed"].includes(status); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/cli.ts b/src/main/cli.ts index d4c68600..21eae72f 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { AppConfig, ProfileOpenSurface } from "../shared/app"; +import { botGatewayProfileEnv } from "./bot-gateway-env"; import { launchCodexAppProfile } from "./codex-app-launch"; import { buildProfileLaunchPlan, findProfileForOpen, resolveProfileOpenSurface } from "./profile-launch-core"; @@ -41,7 +42,8 @@ async function main(): Promise { const childEnv = { ...process.env, - ...plan.env + ...plan.env, + ...botGatewayProfileEnv(config, profile) }; delete childEnv.ELECTRON_RUN_AS_NODE; diff --git a/src/main/codex-app-launch.ts b/src/main/codex-app-launch.ts index f2d1bd05..bd50b31a 100644 --- a/src/main/codex-app-launch.ts +++ b/src/main/codex-app-launch.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node import os from "node:os"; import path from "node:path"; import type { AppConfig, ProfileConfig } from "../shared/app"; +import { botGatewayProfileEnv } from "./bot-gateway-env"; import { codexModelCatalogBase64 } from "./codex-model-catalog"; import { buildProfileLaunchPlan, resolveCodexConfigFile } from "./profile-launch-core"; @@ -43,6 +44,7 @@ export function launchCodexAppProfile(configDir: string, profile: ProfileConfig, const env: NodeJS.ProcessEnv = { ...process.env, ...plan.env, + ...(config ? botGatewayProfileEnv(config, profile) : {}), ...codexProfileEnv(profile), CODEX_CLI_PATH: plan.command, CODEX_ELECTRON_USER_DATA_PATH: userDataDir, diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index c0c2d1d9..261cc5bf 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -8,6 +8,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const readline = require("node:readline"); +const { pathToFileURL } = require("node:url"); const VERSION = "3.0.0"; const DEFAULT_MODEL = "claude-sonnet-4-5"; @@ -16,9 +17,14 @@ const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60 const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000); const CONFIG_DIR = path.join(os.homedir(), ".claude-code-router"); const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || path.join(CONFIG_DIR, "codex-cli-middleware.log"); +const BOT_BRIDGE = createBotGatewayBridge(); async function main() { const args = process.argv.slice(2); + if (process.env.CCR_CLAUDE_CODE_WRAPPER === "1") { + await runClaudeCodeCliWrapper(args); + return; + } if (shouldRunClaudeCodeAppServer(args)) { await runClaudeCodeAppServer(args); return; @@ -26,6 +32,34 @@ async function main() { await runCodexCliMiddleware(args.length === 0 ? defaultCodexArgs() : args); } +async function runClaudeCodeCliWrapper(args) { + const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude"); + log("claude_code_wrapper_start", { realCli, args }); + const child = childProcess.spawn(realCli, args, { + env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]), + stdio: ["inherit", "pipe", "inherit"] + }); + child.on("error", (error) => { + log("claude_code_wrapper_spawn_error", { error: formatError(error) }); + }); + let pending = ""; + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + pending += chunk.toString("utf8"); + const lines = pending.split(/\r?\n/g); + pending = lines.pop() || ""; + for (const line of lines) { + BOT_BRIDGE.handleClaudeCliLine(line); + } + }); + const code = await waitForChild(child); + if (pending.trim()) { + BOT_BRIDGE.handleClaudeCliLine(pending); + } + log("claude_code_wrapper_exit", { code }); + process.exitCode = code; +} + function defaultCodexArgs() { return normalizeProfileSurface(nonEmptyEnv("CCR_PROFILE_SURFACE") || nonEmptyEnv("CODEXL_PROFILE_SURFACE")) === "cli" ? [] @@ -70,6 +104,7 @@ async function runCodexCliMiddleware(args) { const stdoutRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity, terminal: false }); stdoutRl.on("line", (line) => { const rewritten = rewriteCodexStdoutLine(line, requestMap); + BOT_BRIDGE.handleJsonRpcLine(rewritten); if (!shouldSuppressBotBridgeLine(rewritten)) { process.stdout.write(rewritten + "\n"); } @@ -1380,6 +1415,7 @@ function writeNotification(method, params) { } function writeRaw(value) { + BOT_BRIDGE.handleJsonRpcValue(value); writeLine(process.stdout, value); } @@ -1387,6 +1423,665 @@ function writeLine(stream, value) { stream.write(JSON.stringify(value) + "\n"); } +function createBotGatewayBridge() { + const config = readBotGatewayBridgeConfig(); + if (!config.enabled) { + return { + handleClaudeCliLine() {}, + handleJsonRpcLine() {}, + handleJsonRpcValue() {} + }; + } + const bridge = new BotGatewayBridge(config); + process.once("exit", () => bridge.stop()); + return bridge; +} + +function readBotGatewayBridgeConfig() { + const enabled = boolEnv("CCR_BOT_GATEWAY_ENABLED") || boolEnv("CODEXL_BOT_GATEWAY_ENABLED"); + const platform = normalizeBotGatewayPlatform(nonEmptyEnv("CCR_BOT_GATEWAY_PLATFORM") || nonEmptyEnv("CODEXL_BOT_GATEWAY_PLATFORM") || "none"); + const handoffEnabled = boolEnv("CCR_BOT_HANDOFF_ENABLED") || boolEnv("CODEXL_BOT_HANDOFF_ENABLED"); + return { + acknowledgeEvents: boolEnv("CCR_BOT_GATEWAY_ACK_EVENTS"), + args: jsonArrayEnv("CCR_BOT_GATEWAY_ARGS_JSON"), + authType: normalizeBotGatewayAuthType(platform, nonEmptyEnv("CCR_BOT_GATEWAY_AUTH_TYPE") || ""), + autoStartIntegration: boolEnv("CCR_BOT_GATEWAY_AUTO_START_INTEGRATION"), + command: nonEmptyEnv("CCR_BOT_GATEWAY_COMMAND") || "", + conversationRef: jsonObjectEnv("CCR_BOT_GATEWAY_CONVERSATION_REF_JSON"), + createIntegration: boolEnv("CCR_BOT_GATEWAY_CREATE_INTEGRATION"), + credentials: sanitizeBotGatewayRecord(jsonObjectEnv("CCR_BOT_GATEWAY_CREDENTIALS_JSON") || {}), + cwd: nonEmptyEnv("CCR_BOT_GATEWAY_CWD") || "", + enabled: enabled && platform !== "none", + forwardAllAgentMessages: boolEnv("CCR_BOT_GATEWAY_FORWARD_ALL_AGENT_MESSAGES") || boolEnv("CODEXL_BOT_GATEWAY_FORWARD_ALL_CODEX_MESSAGES"), + handoff: { + enabled: handoffEnabled, + idleSeconds: numberEnv("CCR_BOT_HANDOFF_IDLE_SECONDS", numberEnv("CODEXL_BOT_HANDOFF_IDLE_SECONDS", 30)), + phoneBluetoothTargets: listEnv("CCR_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS") || listEnv("CODEXL_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS"), + phoneWifiTargets: listEnv("CCR_BOT_HANDOFF_PHONE_WIFI_TARGETS") || listEnv("CODEXL_BOT_HANDOFF_PHONE_WIFI_TARGETS"), + screenLock: boolEnv("CCR_BOT_HANDOFF_SCREEN_LOCK") || boolEnv("CODEXL_BOT_HANDOFF_SCREEN_LOCK"), + userIdle: boolEnv("CCR_BOT_HANDOFF_USER_IDLE") || boolEnv("CODEXL_BOT_HANDOFF_USER_IDLE") + }, + integrationConfig: websocketBotGatewayIntegrationConfig(platform, jsonObjectEnv("CCR_BOT_GATEWAY_CONFIG_JSON") || {}), + integrationId: nonEmptyEnv("CCR_BOT_GATEWAY_INTEGRATION_ID") || nonEmptyEnv("CODEXL_BOT_GATEWAY_INTEGRATION_ID") || "", + platform, + pollIntervalMs: numberEnv("CCR_BOT_GATEWAY_POLL_INTERVAL_MS", 2000), + profileId: nonEmptyEnv("CCR_BOT_PROFILE_ID") || nonEmptyEnv("CCR_CODEX_PROFILE") || nonEmptyEnv("CODEXL_CODEX_PROFILE") || "default", + profileName: nonEmptyEnv("CCR_BOT_PROFILE_NAME") || nonEmptyEnv("CODEXL_CODEX_WORKSPACE_NAME") || "CCR", + requestTimeoutMs: numberEnv("CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS", 600000), + sourceDir: nonEmptyEnv("CCR_BOT_GATEWAY_SOURCE_DIR") || "", + startupTimeoutMs: numberEnv("CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS", 10000), + stateDir: nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("BOT_GATEWAY_STATE_DIR") || "", + tenantId: nonEmptyEnv("CCR_BOT_GATEWAY_TENANT_ID") || nonEmptyEnv("CODEXL_BOT_GATEWAY_TENANT_ID") || "ccr" + }; +} + +function normalizeBotGatewayPlatform(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (!normalized || normalized === "off" || normalized === "disabled") return "none"; + if (normalized === "lark") return "feishu"; + if (normalized === "dingding") return "dingtalk"; + if (["wechat", "weixin", "wx", "weixin-ilink", "weixin_ilink", "ilink"].includes(normalized)) return "weixin-ilink"; + if (["wecom", "wework", "wechat-work", "work-weixin", "enterprise-wechat"].includes(normalized)) return "wecom"; + return normalized; +} + +function normalizeBotGatewayAuthType(platform, value) { + const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_"); + if (!platform || platform === "none") return ""; + if (!normalized || normalized === "default" || normalized === "auto" || normalized === "webhook" || normalized === "webhook_secret" || normalized === "outgoing_webhook") { + return defaultBotGatewayAuthType(platform); + } + if (normalized === "appsecret") return "app_secret"; + if (normalized === "bottoken" || normalized === "token") return "bot_token"; + if (normalized === "oauth" || normalized === "oauth_2") return "oauth2"; + if (["qr", "qr_login", "qrcode", "qr_code"].includes(normalized)) return "qr_login"; + return normalized; +} + +function defaultBotGatewayAuthType(platform) { + if (platform === "weixin-ilink") return "qr_login"; + if (platform === "feishu" || platform === "dingtalk" || platform === "wecom") return "app_secret"; + if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") return "bot_token"; + return ""; +} + +function websocketBotGatewayIntegrationConfig(platform, value) { + const config = sanitizeBotGatewayRecord(value); + delete config.transport; + delete config.sendMode; + const transport = botGatewayWebSocketTransport(platform); + return transport ? { ...config, transport } : config; +} + +function botGatewayWebSocketTransport(platform) { + if (!platform || platform === "none") return ""; + return platform === "slack" ? "socket" : "websocket"; +} + +function sanitizeBotGatewayRecord(value) { + const result = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return result; + for (const [key, rawValue] of Object.entries(value)) { + if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) continue; + result[key] = rawValue; + } + return result; +} + +function isWebhookRelatedBotGatewayKey(key) { + const normalized = key.trim().toLowerCase().replace(/[_-]+/g, ""); + return normalized.includes("webhook") || normalized === "sendmode"; +} + +class BotGatewayBridge { + constructor(config) { + this.config = config; + this.child = null; + this.client = null; + this.forwarded = new Set(); + this.latestEvent = null; + this.messageCounter = 0; + this.pollTimer = null; + this.startPromise = null; + this.claudeCliCapture = { finalText: "", resultCount: 0, text: "" }; + this.turnCaptures = new Map(); + } + + handleClaudeCliLine(line) { + if (!line || !this.config.enabled) return; + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (!message || typeof message !== "object") return; + if (message.type === "stream_event" && message.event) { + this.captureClaudeStreamEvent(message.event); + return; + } + if (message.type === "assistant" && message.message && message.message.content) { + const text = textFromContent(message.message.content); + if (text) this.claudeCliCapture.finalText = text; + return; + } + if (message.type === "result") { + const errorText = message.is_error ? stringValue(message.result) || "Claude Code returned an error" : ""; + const text = errorText + ? "Agent turn failed: " + errorText + : this.claudeCliCapture.finalText || stringValue(message.result) || this.claudeCliCapture.text; + this.completeClaudeCliCapture(text, Boolean(errorText)); + return; + } + const result = stringValue(message.result); + if (result && !message.method && !message.params) { + this.completeClaudeCliCapture(result, false); + } + } + + captureClaudeStreamEvent(event) { + if (!event || typeof event !== "object") return; + if (event.type === "content_block_delta" && event.delta && event.delta.type === "text_delta" && typeof event.delta.text === "string") { + this.claudeCliCapture.text += event.delta.text; + return; + } + if (event.type === "content_block_start" && event.content_block) { + const text = textFromContent([event.content_block]); + if (text) this.claudeCliCapture.finalText = text; + } + } + + completeClaudeCliCapture(text, isError) { + const trimmed = typeof text === "string" ? text.trim() : ""; + if (!trimmed) return; + this.claudeCliCapture.resultCount += 1; + const key = [ + isError ? "claude-cli-error" : "claude-cli", + process.pid, + this.claudeCliCapture.resultCount, + trimmed.length + ].join(":"); + this.forwardAgentText(key, trimmed, {}); + this.claudeCliCapture.finalText = ""; + this.claudeCliCapture.text = ""; + } + + handleJsonRpcLine(line) { + if (!line || !this.config.enabled) return; + let value; + try { + value = JSON.parse(line); + } catch { + return; + } + this.handleJsonRpcValue(value); + } + + handleJsonRpcValue(value) { + if (!this.config.enabled || !value || typeof value !== "object") return; + this.ensureStarted().catch((error) => this.logError("start_failed", error)); + const method = typeof value.method === "string" ? value.method : ""; + const params = value.params && typeof value.params === "object" ? value.params : {}; + if (method === "item/completed") { + this.handleCompletedItem(params); + } else if (method === "item/agentMessage/delta") { + this.handleAgentMessageDelta(params); + } else if (method === "turn/completed") { + this.handleTurnCompleted(params); + } + } + + handleCompletedItem(params) { + const item = params.item && typeof params.item === "object" ? params.item : null; + if (!isAgentMessageItem(item)) return; + const text = agentMessageItemText(item).trim(); + if (!text) return; + const capture = this.turnCapture(params); + if (capture) capture.finalText = text; + const key = ["item", params.threadId, params.turnId, item.id, text.length].map((part) => String(part || "")).join(":"); + this.forwardAgentText(key, text, params); + } + + handleAgentMessageDelta(params) { + const delta = typeof params.delta === "string" ? params.delta : typeof params.text === "string" ? params.text : ""; + if (!delta) return; + const capture = this.turnCapture(params); + if (capture) capture.text += delta; + } + + handleTurnCompleted(params) { + const turn = params.turn && typeof params.turn === "object" ? params.turn : null; + const captureKey = turnCaptureKey(params); + const errorText = turnErrorText(turn); + if (errorText) { + const key = ["turn-error", params.threadId || (turn && turn.threadId), turn && turn.id, errorText.length].map((part) => String(part || "")).join(":"); + this.forwardAgentText(key, "Agent turn failed: " + errorText, params); + if (captureKey) this.turnCaptures.delete(captureKey); + return; + } + const capture = captureKey ? this.turnCaptures.get(captureKey) : null; + const text = capture ? (capture.finalText || capture.text || "").trim() : ""; + if (text) { + const key = ["turn", params.threadId || (turn && turn.threadId), turn && turn.id, text.length].map((part) => String(part || "")).join(":"); + this.forwardAgentText(key, text, params); + } + if (captureKey) this.turnCaptures.delete(captureKey); + } + + turnCapture(params) { + const key = turnCaptureKey(params); + if (!key) return null; + let capture = this.turnCaptures.get(key); + if (!capture) { + capture = { finalText: "", text: "" }; + this.turnCaptures.set(key, capture); + } + return capture; + } + + forwardAgentText(key, text, params) { + if (this.forwarded.has(key)) return; + const decision = this.forwardDecision(); + if (!decision.shouldForward) { + log("bot_gateway_forward_skip", { key, reason: decision.reason }); + return; + } + this.forwarded.add(key); + this.ensureStarted() + .then(() => this.sendText(key, text, params, decision)) + .catch((error) => { + this.forwarded.delete(key); + this.logError("forward_failed", error); + }); + } + + forwardDecision() { + if (!this.config.forwardAllAgentMessages) { + return { shouldForward: false, reason: "forward_all_disabled" }; + } + if (!this.config.handoff.enabled) { + return { shouldForward: false, reason: "handoff_disabled" }; + } + const presence = evaluateHandoffPresence(this.config.handoff); + return { + shouldForward: presence.away, + reason: presence.away ? presence.reasons.join(", ") : presence.evidence.join(", ") + }; + } + + async sendText(key, text, params, decision) { + const conversationRef = this.resolveConversationRef(); + if (!conversationRef) { + throw new Error("No Bot Gateway conversationRef is configured and no inbound bot event context is available."); + } + this.messageCounter += 1; + const outbound = { + tenantId: this.resolveTenantId(), + integrationId: this.resolveIntegrationId(), + conversationRef, + intent: { + type: "text", + text + }, + idempotencyKey: "ccr:handoff:" + this.config.profileId + ":" + key + ":" + this.messageCounter + }; + await withTimeout(this.client.send(outbound), this.config.requestTimeoutMs, "Bot Gateway request timed out: outbound.send"); + log("bot_gateway_forward_sent", { + key, + reason: decision.reason, + textLen: text.length, + threadId: params.threadId || "", + turnId: params.turnId || "" + }); + } + + resolveTenantId() { + return eventString(this.latestEvent, "tenantId") || this.config.tenantId || "ccr"; + } + + resolveIntegrationId() { + return eventString(this.latestEvent, "integrationId") || this.config.integrationId; + } + + resolveConversationRef() { + if (this.config.conversationRef) return this.config.conversationRef; + const event = this.latestEvent; + if (!event || !event.conversation || typeof event.conversation !== "object") return null; + const id = eventString(event.conversation, "id"); + if (!id) return null; + const type = ["dm", "group", "channel", "thread"].includes(event.conversation.type) ? event.conversation.type : "dm"; + const ref = { platformConversationId: id, type }; + const threadId = event.message && typeof event.message === "object" ? eventString(event.message, "threadId") : ""; + if (threadId) ref.threadId = threadId; + return ref; + } + + async ensureStarted() { + if (this.client) return; + if (this.startPromise) return this.startPromise; + this.startPromise = this.start().finally(() => { + this.startPromise = null; + }); + return this.startPromise; + } + + async start() { + const sdk = await loadBotGatewaySdk(); + const env = Object.assign({}, process.env, { + BOT_GATEWAY_STATE_DIR: this.config.stateDir || path.join(CONFIG_DIR, "bot-gateway", safePathSegment(this.config.profileId)), + CODEXL_HOME: CONFIG_DIR + }); + const clientOptions = botGatewaySdkClientOptions(this.config, env); + this.client = sdk.createBotGatewayClient(clientOptions); + await withTimeout(this.client.health(), this.config.startupTimeoutMs, "Bot Gateway health check timed out."); + await this.ensureIntegration(); + await this.pollEvents(); + this.pollTimer = setInterval(() => { + this.pollEvents().catch((error) => this.logError("poll_failed", error)); + }, Math.max(500, this.config.pollIntervalMs)); + log("bot_gateway_started", { platform: this.config.platform, sdkTransport: clientOptions.transport, command: clientOptions.command || "sdk-bundled" }); + } + + async ensureIntegration() { + if (!this.config.integrationId) return; + if (this.config.createIntegration) { + await botGatewayClientRequest(this.client, "integrations.create", { + id: this.config.integrationId, + tenantId: this.config.tenantId, + platform: this.config.platform, + authType: this.config.authType, + credentials: this.config.credentials, + config: this.config.integrationConfig + }, this.config.requestTimeoutMs); + } + if (this.config.autoStartIntegration) { + await botGatewayClientRequest(this.client, "integrations.start", { + integrationId: this.config.integrationId + }, this.config.requestTimeoutMs).catch((error) => { + log("bot_gateway_integration_start_skip", { error: formatError(error) }); + }); + } + } + + async pollEvents() { + if (!this.client) return; + const result = await withTimeout(this.client.events(20), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.list"); + const events = Array.isArray(result && result.events) ? result.events : []; + for (const queued of events) { + const event = queued && queued.event && typeof queued.event === "object" ? queued.event : null; + if (!event || !this.matchesEvent(event)) continue; + if (event.actor && event.actor.isBot === true) continue; + this.latestEvent = event; + if (this.config.acknowledgeEvents) { + const eventId = eventString(queued, "id") || eventString(event, "id"); + if (eventId) { + await withTimeout(this.client.ackEvent(eventId), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.ack").catch((error) => { + log("bot_gateway_ack_failed", { eventId, error: formatError(error) }); + }); + } + } + } + } + + matchesEvent(event) { + if (this.config.integrationId && event.integrationId !== this.config.integrationId) return false; + if (this.config.platform && this.config.platform !== "none" && event.platform !== this.config.platform) return false; + if (this.config.tenantId && event.tenantId !== this.config.tenantId) return false; + return true; + } + + stop() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.client && typeof this.client.close === "function") { + this.client.close(); + } + this.client = null; + } + + logError(event, error) { + log("bot_gateway_" + event, { error: formatError(error) }); + } +} + +let BOT_GATEWAY_SDK_PROMISE = null; + +async function loadBotGatewaySdk() { + if (!BOT_GATEWAY_SDK_PROMISE) { + BOT_GATEWAY_SDK_PROMISE = importBotGatewaySdk(); + } + return BOT_GATEWAY_SDK_PROMISE; +} + +async function importBotGatewaySdk() { + const candidates = []; + const configured = nonEmptyEnv("CCR_BOT_GATEWAY_SDK_MODULE"); + if (configured) { + candidates.push(configured); + } + candidates.push("@the-next-ai/bot-gateway-sdk"); + const errors = []; + for (const candidate of candidates) { + try { + const sdk = await import(botGatewaySdkImportSpecifier(candidate)); + if (sdk && typeof sdk.createBotGatewayClient === "function") { + return sdk; + } + errors.push(candidate + ": missing createBotGatewayClient export"); + } catch (error) { + errors.push(candidate + ": " + formatError(error)); + } + } + throw new Error("Unable to load @the-next-ai/bot-gateway-sdk. " + errors.join("; ")); +} + +function botGatewaySdkImportSpecifier(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) return "@the-next-ai/bot-gateway-sdk"; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed; + if (path.isAbsolute(trimmed)) return pathToFileURL(trimmed).href; + return trimmed; +} + +function botGatewaySdkClientOptions(config, env) { + const command = resolveBotGatewayCommand(config); + return { + transport: "stdio", + ...(command || {}), + env + }; +} + +function resolveBotGatewayCommand(config) { + if (config.command) { + return { + command: expandHome(config.command), + args: config.args, + cwd: config.cwd || process.cwd() + }; + } + const sourceDir = expandHome(config.sourceDir || ""); + const candidates = sourceDir ? [ + path.join(sourceDir, "dist-bundle", "stdio", "stdio.js"), + path.join(sourceDir, "dist", "src", "stdio.js") + ] : []; + for (const entry of candidates) { + if (fs.existsSync(entry)) { + return { + command: process.execPath, + args: [entry], + cwd: sourceDir + }; + } + } + return undefined; +} + +function botGatewayClientRequest(client, method, params, timeoutMs) { + if (!client || typeof client.request !== "function") { + return Promise.reject(new Error("Bot Gateway SDK client does not expose request().")); + } + return withTimeout(client.request(method, params), timeoutMs, "Bot Gateway request timed out: " + method); +} + +function withTimeout(promise, timeoutMs, message) { + const timeout = Math.max(1000, timeoutMs || 30000); + let timer = null; + return new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeout); + Promise.resolve(promise).then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +function evaluateHandoffPresence(config) { + if (!config.enabled) { + return { away: false, reasons: [], evidence: ["handoff disabled"] }; + } + const reasons = []; + const evidence = []; + if (config.screenLock) { + const locked = detectScreenLocked(); + if (locked !== true) { + return { away: false, reasons, evidence: [locked === false ? "screen unlocked" : "screen lock unknown"] }; + } + reasons.push("screen locked"); + } + if (config.userIdle) { + const seconds = detectUserIdleSeconds(); + if (!Number.isFinite(seconds)) { + evidence.push("idle time unknown"); + } else if (seconds >= config.idleSeconds) { + reasons.push("idle for " + seconds + "s"); + } else { + return { away: false, reasons, evidence: ["idle for " + seconds + "s"] }; + } + } + if (config.phoneWifiTargets.length || config.phoneBluetoothTargets.length) { + evidence.push("phone target checks are configured but not available in CCR middleware"); + } + return { away: reasons.length > 0, reasons, evidence }; +} + +function detectScreenLocked() { + if (process.platform !== "darwin") return null; + const output = commandOutput("/usr/sbin/ioreg", ["-r", "-k", "CGSSessionScreenIsLocked"]) || commandOutput("/usr/sbin/ioreg", ["-n", "Root", "-d1"]); + if (!output) return null; + for (const line of output.split(/\r?\n/g)) { + if (!line.includes("CGSSessionScreenIsLocked") && !line.includes("IOConsoleLocked")) continue; + const lower = line.toLowerCase(); + if (lower.includes("yes") || lower.includes("true") || lower.includes("= 1")) return true; + if (lower.includes("no") || lower.includes("false") || lower.includes("= 0")) return false; + } + return false; +} + +function detectUserIdleSeconds() { + if (process.platform !== "darwin") return null; + const output = commandOutput("/usr/sbin/ioreg", ["-c", "IOHIDSystem"]); + if (!output) return null; + for (const line of output.split(/\r?\n/g)) { + if (!line.includes("HIDIdleTime")) continue; + const raw = String(line.split("=")[1] || "").trim(); + const digits = raw.match(/^\d+/); + if (!digits) return null; + return Math.floor(Number(digits[0]) / 1000000000); + } + return null; +} + +function commandOutput(command, args) { + try { + const result = childProcess.spawnSync(command, args, { encoding: "utf8", timeout: 2000 }); + return result.status === 0 ? result.stdout : ""; + } catch { + return ""; + } +} + +function isAgentMessageItem(item) { + if (!item || typeof item !== "object") return false; + return item.type === "agentMessage" || item.type === "agent_message" || item.type === "assistantMessage" || item.type === "assistant_message"; +} + +function agentMessageItemText(item) { + if (!item || typeof item !== "object") return ""; + if (typeof item.text === "string") return item.text; + if (typeof item.content === "string") return item.content; + if (typeof item.message === "string") return item.message; + return ""; +} + +function turnCaptureKey(params) { + const threadId = params.threadId || params.thread_id || (params.thread && params.thread.id); + const turnId = params.turnId || params.turn_id || (params.turn && params.turn.id); + if (!threadId || !turnId) return ""; + return String(threadId) + ":" + String(turnId); +} + +function turnErrorText(turn) { + if (!turn) return ""; + if (typeof turn.error === "string" && turn.error.trim()) return turn.error.trim(); + if (turn.error && typeof turn.error === "object") { + if (typeof turn.error.message === "string") return turn.error.message.trim(); + if (typeof turn.error.details === "string") return turn.error.details.trim(); + } + return ""; +} + +function eventString(value, key) { + return value && typeof value[key] === "string" ? value[key].trim() : ""; +} + +function jsonObjectEnv(name) { + const text = nonEmptyEnv(name); + if (!text) return null; + try { + const value = JSON.parse(text); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch { + return null; + } +} + +function jsonArrayEnv(name) { + const text = nonEmptyEnv(name); + if (!text) return []; + try { + const value = JSON.parse(text); + return Array.isArray(value) ? value.map((item) => String(item)) : []; + } catch { + return []; + } +} + +function listEnv(name) { + const value = process.env[name]; + if (!value) return []; + return value.split(/\r?\n|,/g).map((item) => item.trim()).filter(Boolean); +} + +function boolEnv(name) { + const value = process.env[name]; + if (!value) return false; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + +function safePathSegment(value) { + const segment = String(value || "").trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return segment || "default"; +} + function waitForChild(child) { return new Promise((resolve) => { child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1))); diff --git a/src/main/config.ts b/src/main/config.ts index 59519c2a..863aaad8 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -7,6 +7,8 @@ import type { AppConfig, ApiKeyConfig, ApiKeyLimitConfig, + BotGatewayRuntimeConfig, + BotGatewaySavedConfig, ClaudeCodeProfileConfig, CodexProfileConfig, GatewayAgentConfig, @@ -48,9 +50,15 @@ type LoadedProfileConfig = Partial> & { +type LoadedBotGatewayConfig = Partial> & { + handoff?: Partial; +}; + +type LoadedAppConfig = Partial> & { Router?: Partial; agent?: Partial; + botConfigs?: BotGatewaySavedConfig[]; + botGateway?: LoadedBotGatewayConfig; gateway?: Partial; profile?: LoadedProfileConfig; proxy?: Partial; @@ -94,6 +102,36 @@ const DEFAULT_CONFIG: AppConfig = { mcpServers: [] }, autoStart: false, + botConfigs: [], + botGateway: { + acknowledgeEvents: false, + args: [], + authType: "", + autoStartIntegration: true, + command: "", + createIntegration: false, + credentials: {}, + cwd: "", + enabled: false, + forwardAllAgentMessages: true, + handoff: { + enabled: false, + idleSeconds: 30, + phoneBluetoothTargets: [], + phoneWifiTargets: [], + screenLock: true, + userIdle: true + }, + integrationConfig: {}, + integrationId: "", + platform: "none", + pollIntervalMs: 2000, + requestTimeoutMs: 600000, + sourceDir: "/Users/jinhuilee/products/bot-gateway", + startupTimeoutMs: 10000, + stateDir: "", + tenantId: "ccr" + }, gateway: { coreHost: "127.0.0.1", corePort: 3457, @@ -177,6 +215,112 @@ const DEFAULT_CONFIG: AppConfig = { trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES }; +function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): BotGatewayRuntimeConfig { + const platform = normalizeBotGatewayPlatform(config?.platform ?? DEFAULT_CONFIG.botGateway.platform); + return { + ...DEFAULT_CONFIG.botGateway, + ...(config ?? {}), + authType: normalizeBotGatewayAuthType(platform, config?.authType ?? DEFAULT_CONFIG.botGateway.authType), + credentials: sanitizeBotGatewayRecord(config?.credentials ?? DEFAULT_CONFIG.botGateway.credentials), + handoff: { + ...DEFAULT_CONFIG.botGateway.handoff, + ...(config?.handoff ?? {}) + }, + integrationConfig: websocketBotGatewayIntegrationConfig(platform, config?.integrationConfig ?? DEFAULT_CONFIG.botGateway.integrationConfig), + platform + }; +} + +function normalizeBotGatewayPlatform(value: unknown): string { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!normalized || normalized === "off" || normalized === "disabled") { + return "none"; + } + if (normalized === "lark") { + return "feishu"; + } + if (normalized === "dingding") { + return "dingtalk"; + } + if (["wechat", "weixin", "wx", "weixin-ilink", "weixin_ilink", "ilink"].includes(normalized)) { + return "weixin-ilink"; + } + if (["wecom", "wework", "wechat-work", "work-weixin", "enterprise-wechat"].includes(normalized)) { + return "wecom"; + } + return normalized; +} + +function normalizeBotGatewayAuthType(platform: string, value: unknown): string { + const normalized = typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : ""; + if (!platform || platform === "none") { + return ""; + } + if (!normalized || normalized === "default" || normalized === "auto" || normalized === "webhook" || normalized === "webhook_secret" || normalized === "outgoing_webhook") { + return defaultBotGatewayAuthType(platform); + } + if (normalized === "appsecret") { + return "app_secret"; + } + if (normalized === "bottoken" || normalized === "token") { + return "bot_token"; + } + if (normalized === "oauth" || normalized === "oauth_2") { + return "oauth2"; + } + if (["qr", "qr_login", "qrcode", "qr_code"].includes(normalized)) { + return "qr_login"; + } + return normalized; +} + +function defaultBotGatewayAuthType(platform: string): string { + if (platform === "weixin-ilink") { + return "qr_login"; + } + if (platform === "feishu" || platform === "dingtalk" || platform === "wecom") { + return "app_secret"; + } + if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") { + return "bot_token"; + } + return ""; +} + +function websocketBotGatewayIntegrationConfig(platform: string, value: Record): Record { + const config = sanitizeBotGatewayRecord(value); + delete config.transport; + delete config.sendMode; + const transport = botGatewayWebSocketTransport(platform); + return transport ? { ...config, transport } : config; +} + +function botGatewayWebSocketTransport(platform: string): string { + if (!platform || platform === "none") { + return ""; + } + return platform === "slack" ? "socket" : "websocket"; +} + +function sanitizeBotGatewayRecord(value: Record | undefined): Record { + const result: Record = {}; + if (!isObject(value)) { + return result; + } + for (const [key, rawValue] of Object.entries(value)) { + if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) { + continue; + } + result[key] = rawValue; + } + return result; +} + +function isWebhookRelatedBotGatewayKey(key: string): boolean { + const normalized = key.trim().toLowerCase().replace(/[_-]+/g, ""); + return normalized.includes("webhook") || normalized === "sendmode"; +} + export async function loadAppConfig(): Promise { ensureConfigFile(); @@ -210,6 +354,8 @@ export async function loadAppConfig(): Promise { ...(picked.agent ?? {}), mcpServers: picked.agent?.mcpServers ?? DEFAULT_CONFIG.agent.mcpServers }, + botConfigs: picked.botConfigs ?? DEFAULT_CONFIG.botConfigs, + botGateway: completeBotGatewayConfig(picked.botGateway), gateway: { ...DEFAULT_CONFIG.gateway, ...gatewayConfig, @@ -443,6 +589,14 @@ function pickConfig(value: Partial): LoadedAppConfig { if (agent) { config.agent = agent; } + const botGateway = parseBotGateway((value as Record).botGateway ?? (value as Record).bot_gateway ?? (value as Record).bot); + if (botGateway) { + config.botGateway = botGateway; + } + const botConfigs = parseBotGatewaySavedConfigs((value as Record).botConfigs ?? (value as Record).bot_configs); + if (botConfigs) { + config.botConfigs = botConfigs; + } if (typeof value.autoStart === "boolean") { config.autoStart = value.autoStart; } @@ -977,6 +1131,14 @@ function parseStringList(value: unknown): string[] { return []; } +function parseStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) && typeof value !== "string") { + return undefined; + } + const list = parseStringList(value); + return list.length ? list : undefined; +} + function parseRouterRules(value: unknown): RouterRule[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -1054,6 +1216,199 @@ function parseAgent(value: unknown, legacyMcpServers?: unknown): Partial(); + value.forEach((item, index) => { + if (!isObject(item)) { + return; + } + const rawBot = item.botGateway ?? item.bot_gateway ?? item.bot ?? item.config; + const parsedBot = parseBotGateway(rawBot); + if (!parsedBot) { + return; + } + const botGateway = completeBotGatewayConfig(parsedBot); + if (!botGateway.enabled || !botGateway.platform || botGateway.platform === "none") { + return; + } + const fallbackId = botGateway.integrationId || `bot-${index + 1}`; + const id = readString(item.id) || readString(item.savedConfigId) || readString(item.saved_config_id) || fallbackId; + if (!id || seen.has(id)) { + return; + } + seen.add(id); + result.push({ + botGateway, + id, + name: readString(item.name) || botGateway.platform || id, + ...(readString(item.updatedAt) || readString(item.updated_at) + ? { updatedAt: readString(item.updatedAt) || readString(item.updated_at) } + : {}) + }); + }); + return result; +} + +function parseBotGatewayHandoff(value: unknown): Partial | undefined { + if (!isObject(value)) { + return undefined; + } + const handoff: Partial = {}; + if (typeof value.enabled === "boolean") { + handoff.enabled = value.enabled; + } + const idleSeconds = readNumber(value.idleSeconds ?? value.idle_seconds); + if (idleSeconds !== undefined) { + handoff.idleSeconds = clampNumber(idleSeconds, 30, 86_400); + } + if (typeof value.screenLock === "boolean") { + handoff.screenLock = value.screenLock; + } else if (typeof value.screen_lock === "boolean") { + handoff.screenLock = value.screen_lock; + } + if (typeof value.userIdle === "boolean") { + handoff.userIdle = value.userIdle; + } else if (typeof value.user_idle === "boolean") { + handoff.userIdle = value.user_idle; + } + const phoneWifiTargets = parseStringArray(value.phoneWifiTargets ?? value.phone_wifi_targets); + if (phoneWifiTargets) { + handoff.phoneWifiTargets = uniqueStrings(phoneWifiTargets).slice(0, 1); + } + const phoneBluetoothTargets = parseStringArray(value.phoneBluetoothTargets ?? value.phone_bluetooth_targets); + if (phoneBluetoothTargets) { + handoff.phoneBluetoothTargets = uniqueStrings(phoneBluetoothTargets).slice(0, 1); + } + return Object.keys(handoff).length ? handoff : undefined; +} + +function parseBotGatewayConversation(value: unknown): BotGatewayRuntimeConfig["conversationRef"] | undefined { + if (!isObject(value)) { + return undefined; + } + const platformConversationId = + readString(value.platformConversationId) || + readString(value.platform_conversation_id) || + readString(value.conversationId) || + readString(value.chatId) || + readString(value.channelId); + const gatewayConversationId = readString(value.gatewayConversationId) || readString(value.gateway_conversation_id); + if (!platformConversationId && !gatewayConversationId) { + return undefined; + } + const type = parseEnumValue(value.type, ["dm", "group", "channel", "thread"], "dm"); + const threadId = readString(value.threadId) || readString(value.thread_id); + return { + ...(gatewayConversationId ? { gatewayConversationId } : {}), + ...(platformConversationId ? { platformConversationId } : {}), + ...(threadId ? { threadId } : {}), + type + }; +} + function parseMcpServers(value: unknown): GatewayMcpServerConfig[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -1454,10 +1809,15 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { const name = readString(item.name) || (agent === "claude-code" ? "Claude Code" : "Codex"); const model = readString(item.model) ?? ""; const env = parseStringRecord(item.env) ?? {}; + const botConfigId = readString(item.botConfigId) || readString(item.bot_config_id) || readString(item.savedBotConfigId) || readString(item.saved_bot_config_id); + const parsedBotGateway = parseBotGateway(item.botGateway ?? item.bot_gateway ?? item.bot); + const botGateway = parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined; if (agent === "claude-code") { return { agent, + ...(botConfigId ? { botConfigId } : {}), + ...(botGateway ? { botGateway } : {}), enabled, env, id, @@ -1472,6 +1832,8 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { return { agent, + ...(botConfigId ? { botConfigId } : {}), + ...(botGateway ? { botGateway } : {}), cliMiddleware: true, codexCliPath: readString(item.codexCliPath) || readString(item.cliPath) || readString(item.codexPath) || "", codexHome: readString(item.codexHome) || readString(item.home) || "", @@ -1628,6 +1990,13 @@ function parseStringRecord(value: unknown): Record | undefined { return Object.keys(result).length ? result : undefined; } +function parseUnknownRecord(value: unknown): Record | undefined { + if (!isObject(value)) { + return undefined; + } + return { ...value }; +} + function parseApiKeys(value: unknown): ApiKeyConfig[] | undefined { if (!Array.isArray(value)) { return undefined; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3fdedfdc..d12aac98 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no import net from "node:net"; import path from "node:path"; import { builtInBrowserService } from "./built-in-browser"; +import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "./bot-gateway-qr-login-service"; import { syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config"; import { API_KEYS_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants"; @@ -22,7 +23,7 @@ import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "./usage-store"; import windowsManager from "./windows"; -import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; +import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; const pluginMarketplace: PluginMarketplaceEntry[] = [ { @@ -155,6 +156,15 @@ ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: Ap message: `${synced.result.message}\n${gatewayDetail}\n${apiKeyDetail}` }; }); +ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginStart, (_event, request: BotGatewayQrLoginStartRequest) => { + return startBotGatewayQrLogin(request); +}); +ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginWait, (_event, request: BotGatewayQrLoginWaitRequest) => { + return waitBotGatewayQrLogin(request); +}); +ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginCancel, (_event, request: BotGatewayQrLoginCancelRequest) => { + return cancelBotGatewayQrLogin(request); +}); ipcMain.handle(IPC_CHANNELS.appApplyProfile, async () => { const config = await loadAppConfig(); return applyProfileConfig(config); diff --git a/src/main/preload.ts b/src/main/preload.ts index 8543d1e8..dfce2991 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -7,6 +7,12 @@ import type { AppInfo, AppUpdateStatus, ApiKeyConfig, + BotGatewayQrLoginCancelRequest, + BotGatewayQrLoginCancelResult, + BotGatewayQrLoginStartRequest, + BotGatewayQrLoginStartResult, + BotGatewayQrLoginWaitRequest, + BotGatewayQrLoginWaitResult, ClaudeAppGatewayApplyResult, GatewayMcpServerConfig, GatewayMcpToolInfo, @@ -41,6 +47,7 @@ import type { contextBridge.exposeInMainWorld("ccr", { applyClaudeAppGateway: (config?: AppConfig) => ipcRenderer.invoke(IPC_CHANNELS.appApplyClaudeAppGateway, config) as Promise, applyProfile: () => ipcRenderer.invoke(IPC_CHANNELS.appApplyProfile) as Promise, + cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginCancel, request) as Promise, clearProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise, closeTray: () => ipcRenderer.invoke(IPC_CHANNELS.appCloseTray) as Promise, detectProviderIcon: (request: ProviderIconDetectionRequest) => ipcRenderer.invoke(IPC_CHANNELS.appDetectProviderIcon, request) as Promise, @@ -78,11 +85,13 @@ contextBridge.exposeInMainWorld("ccr", { setTrayDetailOpen: (open: boolean, provider?: string) => ipcRenderer.invoke(IPC_CHANNELS.appSetTrayDetailOpen, open, provider) as Promise, showMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.appShowMainWindow) as Promise, startGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStartGateway) as Promise, + startBotGatewayQrLogin: (request: BotGatewayQrLoginStartRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginStart, request) as Promise, stopGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStopGateway) as Promise, testProviderAccountConnector: (request: ProviderAccountTestRequest) => ipcRenderer.invoke(IPC_CHANNELS.appTestProviderAccountConnector, request) as Promise, updateCheck: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateCheck) as Promise, updateDownload: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateDownload) as Promise, updateInstall: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateInstall) as Promise, + waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginWait, request) as Promise, onBeforeQuit: (callback: () => void) => { const handler = () => callback(); ipcRenderer.on(IPC_CHANNELS.appBeforeQuit, handler); diff --git a/src/main/profile-launch-core.ts b/src/main/profile-launch-core.ts index 7123c858..950718e7 100644 --- a/src/main/profile-launch-core.ts +++ b/src/main/profile-launch-core.ts @@ -133,11 +133,11 @@ function buildClaudeCodeLaunchPlan( if (surface === "app") { throw new Error("Claude App opening is available from the CCR desktop app."); } - const command = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; const settingsFile = resolveClaudeCodeSettingsFile(configDir, profile); + const launcher = path.join(configDir, "bin", claudeCodeWrapperFilename(profile)); return { args: extraArgs, - command, + command: launcher, env: { CLAUDE_CONFIG_DIR: path.dirname(settingsFile), CCR_PROFILE_SURFACE: surface @@ -147,6 +147,13 @@ function buildClaudeCodeLaunchPlan( }; } +function claudeCodeWrapperFilename(profile: ProfileConfig): string { + const slug = sanitizePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; + return process.platform === "win32" + ? `ccr-claude-code-wrapper-${slug}.cmd` + : `ccr-claude-code-wrapper-${slug}`; +} + function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): string { const slug = sanitizeCodexProviderId(profile.id || profile.name || providerId) || "codex"; return process.platform === "win32" diff --git a/src/main/profile-launch-service.ts b/src/main/profile-launch-service.ts index 2701eb67..9c47430e 100644 --- a/src/main/profile-launch-service.ts +++ b/src/main/profile-launch-service.ts @@ -3,6 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n import os from "node:os"; import path from "node:path"; import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult } from "../shared/app"; +import { botGatewayProfileEnv } from "./bot-gateway-env"; import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; import { launchCodexAppProfile } from "./codex-app-launch"; @@ -44,7 +45,8 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen detached: true, env: { ...process.env, - ...plan.env + ...plan.env, + ...botGatewayProfileEnv(config, profile) }, stdio: "ignore" }); diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index 6e3112ff..875b968a 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { enforceSingleEnabledGlobalProfilePerAgent, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileConfig } from "../shared/app"; import { replacePersistedApiKeys } from "./api-key-store"; +import { botGatewayProfileEnv } from "./bot-gateway-env"; import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; import { codexModelCatalogBase64 } from "./codex-model-catalog"; import { CONFIGDIR } from "./constants"; @@ -47,7 +48,8 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token const settings = readJsonObject(settingsFile); const env = { ...Object.fromEntries(stringRecord(settings.env)), - ...profileEnv(profile) + ...profileEnv(profile), + ...botGatewayProfileEnv(config, profile) }; env.ANTHROPIC_BASE_URL = endpoint; env.ANTHROPIC_API_BASE_URL = endpoint; @@ -66,19 +68,21 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token } const helperResult = writeClaudeCodeApiKeyHelper(profile, token); + const wrapperResult = writeClaudeCodeWrapper(config, profile); const nextSettings = { ...settings, apiKeyHelper: helperResult.file, env }; const writeResult = writeFileWithBackup(settingsFile, `${JSON.stringify(nextSettings, null, 2)}\n`); + const changed = writeResult.changed || helperResult.changed || wrapperResult.changed; return { appliedAt, - backupFile: writeResult.backupFile ?? helperResult.backupFile, + backupFile: writeResult.backupFile ?? helperResult.backupFile ?? wrapperResult.backupFile, client: "claude-code", enabled: true, - message: writeResult.changed || helperResult.changed - ? "Claude Code settings are managed by CCR." + message: changed + ? `Claude Code settings are managed by CCR (wrapper ${wrapperResult.file}).` : "Claude Code settings already match CCR.", ok: true, path: settingsFile @@ -124,7 +128,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str showAllSessions: Boolean(profile.showAllSessions) }); const middlewareResult = profile.cliMiddleware - ? writeCodexCliMiddleware(profile, { + ? writeCodexCliMiddleware(config, profile, { configFormat, configFile, modelCatalogBase64: codexModelCatalogBase64(config, model), @@ -385,7 +389,83 @@ function claudeCodeApiKeyHelperCmdScript(token: string): string { ].join("\r\n"); } +function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig): { backupFile?: string; changed: boolean; file: string } { + const binDir = path.join(CONFIGDIR, "bin"); + mkdirSync(binDir, { recursive: true }); + const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename()); + const runtimeResult = writeFileWithBackup(runtimeFile, codexCliMiddlewareRuntimeScript()); + if (process.platform !== "win32") { + chmodSync(runtimeFile, 0o755); + } + const file = path.join(binDir, claudeCodeWrapperFilename(profile)); + const content = process.platform === "win32" + ? claudeCodeWrapperCmdScript(config, profile, runtimeFile) + : claudeCodeWrapperShellScript(config, profile, runtimeFile); + const writeResult = writeFileWithBackup(file, content); + if (process.platform !== "win32") { + chmodSync(file, 0o755); + } + return { + backupFile: writeResult.backupFile ?? runtimeResult.backupFile, + changed: writeResult.changed || runtimeResult.changed, + file + }; +} + +function claudeCodeWrapperFilename(profile: ProfileConfig): string { + const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent).toLowerCase() || "claude-code"; + return process.platform === "win32" + ? `ccr-claude-code-wrapper-${slug}.cmd` + : `ccr-claude-code-wrapper-${slug}`; +} + +function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { + const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; + const envExports = Object.entries({ + ...profileEnv(profile), + ...botGatewayProfileEnv(config, profile) + }) + .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") + .map(([key, value]) => `export ${key}=${shellQuote(value)}`); + return [ + "#!/bin/sh", + ...envExports, + `export CCR_CLAUDE_CODE_WRAPPER=1`, + `export CCR_REAL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, + `export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, + "NODE_BIN=${CCR_NODE_BIN:-node}", + `exec "$NODE_BIN" ${shellQuote(runtimeFile)} "$@"`, + "" + ].join("\n"); +} + +function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { + const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; + const envExports = Object.entries({ + ...profileEnv(profile), + ...botGatewayProfileEnv(config, profile) + }) + .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") + .map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`); + return [ + "@echo off", + ...envExports, + `set "CCR_CLAUDE_CODE_WRAPPER=1"`, + `set "CCR_REAL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`, + `set "CODEXL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`, + "if not defined CCR_NODE_BIN set \"CCR_NODE_BIN=node\"", + "if \"%~1\"==\"\" (", + ` "%CCR_NODE_BIN%" "${runtimeFile.replace(/"/g, '\\"')}"`, + ") else (", + ` "%CCR_NODE_BIN%" "${runtimeFile.replace(/"/g, '\\"')}" %*`, + ")", + "exit /b %ERRORLEVEL%", + "" + ].join("\r\n"); +} + function writeCodexCliMiddleware( + config: AppConfig, profile: ProfileConfig, values: { configFormat: "legacy" | "separate_profile_files"; @@ -404,8 +484,8 @@ function writeCodexCliMiddleware( } const file = path.join(binDir, codexMiddlewareFilename(profile, values.providerId)); const content = process.platform === "win32" - ? codexMiddlewareCmdScript(profile, values, runtimeFile) - : codexMiddlewareShellScript(profile, values, runtimeFile); + ? codexMiddlewareCmdScript(config, profile, values, runtimeFile) + : codexMiddlewareShellScript(config, profile, values, runtimeFile); const writeResult = writeFileWithBackup(file, content); if (process.platform !== "win32") { chmodSync(file, 0o755); @@ -428,6 +508,7 @@ function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): st } function codexMiddlewareShellScript( + config: AppConfig, profile: ProfileConfig, values: { configFormat: "legacy" | "separate_profile_files"; @@ -442,7 +523,10 @@ function codexMiddlewareShellScript( const codexHome = profile.codexHome?.trim() || path.dirname(values.configFile); const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode); const surface = normalizeProfileSurface(profile.surface); - const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `export ${key}=${shellQuote(value)}`); + const envExports = Object.entries({ + ...profileEnv(profile), + ...botGatewayProfileEnv(config, profile) + }).map(([key, value]) => `export ${key}=${shellQuote(value)}`); return [ "#!/bin/sh", ...envExports, @@ -472,6 +556,7 @@ function codexMiddlewareShellScript( } function codexMiddlewareCmdScript( + config: AppConfig, profile: ProfileConfig, values: { configFormat: "legacy" | "separate_profile_files"; @@ -488,7 +573,10 @@ function codexMiddlewareCmdScript( const surface = normalizeProfileSurface(profile.surface); const providerId = values.providerId.replace(/"/g, '\\"'); const workspaceName = (profile.name || values.providerId).replace(/"/g, '\\"'); - const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`); + const envExports = Object.entries({ + ...profileEnv(profile), + ...botGatewayProfileEnv(config, profile) + }).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`); return [ "@echo off", ...envExports, diff --git a/src/main/windows.ts b/src/main/windows.ts index f326972d..0538d72c 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -42,7 +42,8 @@ class WindowsManager { nodeIntegration: false, preload: path.join(__dirname, "preload.js"), sandbox: true, - webSecurity: true + webSecurity: true, + webviewTag: true }, width }); diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index 2f7e16ed..f0f9bb8d 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -1,7 +1,7 @@ import { AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSnapshot, AgentFilterValue, ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppUpdateStatus, - AppLanguagePreference, applyProviderProbeResult, AppToast, buildExtensionList, claudeDesignRoutingConfigFromDraft, + AppLanguagePreference, applyProviderProbeResult, AppToast, BotGatewaySavedConfig, buildExtensionList, claudeDesignRoutingConfigFromDraft, ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft, createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis, createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft, @@ -15,7 +15,7 @@ import { GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid, isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists, - navigation, NavigationId, normalizeApiKeys, normalizeConfig, normalizeLanguagePreference, normalizeOverviewWidgets, + navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeOverviewWidgets, normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayIconPreference, normalizeTrayProgressTargetTokens, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder, OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft, @@ -109,6 +109,8 @@ function App() { const [savedConfig, setSavedConfig] = useState(fallbackConfig); const [sidebarOpen, setSidebarOpen] = useState(true); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsInitialPage, setSettingsInitialPage] = useState<"appearance" | "bots" | "tray" | "update">("appearance"); + const [settingsBotAddRequestKey, setSettingsBotAddRequestKey] = useState(0); const [compactLayout, setCompactLayout] = useState(() => window.matchMedia("(max-width: 720px)").matches); const [toast, setToast] = useState(); const [languagePreference, setLanguagePreference] = useState(() => readLanguagePreference()); @@ -408,7 +410,7 @@ function App() { return buildExtensionList(draftConfig).find((extension) => extension.source === extensionDeleteTarget.source && extension.index === extensionDeleteTarget.index ); - }, [draftConfig.plugins, draftConfig.providerPlugins, draftConfig.virtualModelProfiles, extensionDeleteTarget]); + }, [draftConfig.plugins, draftConfig.providerPlugins, extensionDeleteTarget]); const extensionConfigItem = useMemo(() => { if (!extensionConfigTarget) { return undefined; @@ -443,8 +445,8 @@ function App() { const canSubmitProvider = Boolean(providerDraft.name.trim() && providerDraft.baseUrl.trim()) && providerDialogModels.length > 0; - const canSubmitProfile = isProfileDraftSubmittable(profileDraft); - const canSubmitProfileEdit = profileEditIndex !== undefined && isProfileDraftSubmittable(profileEditDraft); + const canSubmitProfile = isProfileDraftSubmittable(profileDraft) && isProfileBotSelectionValid(profileDraft, draftConfig.botConfigs); + const canSubmitProfileEdit = profileEditIndex !== undefined && isProfileDraftSubmittable(profileEditDraft) && isProfileBotSelectionValid(profileEditDraft, draftConfig.botConfigs); const canSubmitApiKey = Boolean(apiKeyDraft.name.trim()) && (apiKeyDraft.expirationPreset !== "custom" || Boolean(apiKeyDraft.expiresAt.trim())); const canSubmitApiKeyEdit = apiKeyEditDraft.expirationPreset !== "custom" || Boolean(apiKeyEditDraft.expiresAt.trim()); const canSubmitRoutingRule = @@ -1348,10 +1350,8 @@ function App() { updateConfig((config) => { if (source === "plugins") { config.plugins = (config.plugins ?? []).filter((_, itemIndex) => itemIndex !== index); - } else if (source === "providerPlugins") { - config.providerPlugins = (config.providerPlugins ?? []).filter((_, itemIndex) => itemIndex !== index); } else { - config.virtualModelProfiles = (config.virtualModelProfiles ?? []).filter((_, itemIndex) => itemIndex !== index); + config.providerPlugins = (config.providerPlugins ?? []).filter((_, itemIndex) => itemIndex !== index); } return config; }); @@ -1561,14 +1561,6 @@ function App() { config.providerPlugins = values; return config; } - - const values = [...(config.virtualModelProfiles ?? [])]; - const item = values[index]; - if (!item) { - return config; - } - values[index] = { ...item, enabled }; - config.virtualModelProfiles = values; return config; }); } @@ -1606,6 +1598,29 @@ function App() { })); } + function changeBotConfigs(botConfigs: BotGatewaySavedConfig[]) { + const normalizedBotConfigs = normalizeBotGatewaySavedConfigs(botConfigs); + const validIds = new Set(normalizedBotConfigs.map((config) => config.id)); + updateConfig((config) => ({ + ...config, + botConfigs: normalizedBotConfigs, + profile: { + ...config.profile, + profiles: config.profile.profiles.map((profile) => + profile.botConfigId && !validIds.has(profile.botConfigId) + ? removeProfileBotReference(profile) + : profile + ) + } + })); + } + + function openBotSettingsWithAddDialog() { + setSettingsInitialPage("bots"); + setSettingsBotAddRequestKey((current) => current + 1); + setSettingsOpen(true); + } + function changeOverviewWidgets(widgets: OverviewWidgetConfig[]) { updateConfig((config) => ({ ...config, @@ -1853,7 +1868,7 @@ function App() { return; } setProfileEditIndex(index); - setProfileEditDraft(createProfileDraftFromProfile(profile)); + setProfileEditDraft(createProfileDraftFromProfile(profile, draftConfig.botConfigs)); setProfileActionError(""); } @@ -1966,7 +1981,7 @@ function App() { return false; } setProfileSubmitBusy("add"); - const profile = profileConfigFromDraft(profileDraft, draftConfig.profile.profiles); + const profile = profileConfigFromDraft(profileDraft, draftConfig.profile.profiles, undefined, draftConfig.botConfigs); setProfileAgentTab(profile.agent); const next = buildConfigUpdate((config) => ({ ...config, @@ -2014,7 +2029,7 @@ function App() { setProfileActionError("Profile no longer exists."); return false; } - const nextProfile = profileConfigFromDraft(profileEditDraft, draftConfig.profile.profiles, currentProfile); + const nextProfile = profileConfigFromDraft(profileEditDraft, draftConfig.profile.profiles, currentProfile, draftConfig.botConfigs); setProfileAgentTab(nextProfile.agent); const next = buildConfigUpdate((config) => { const profiles = [...config.profile.profiles]; @@ -2110,7 +2125,10 @@ function App() { needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} onOpenServerView={() => setActiveView("server")} - onOpenSettings={() => setSettingsOpen(true)} + onOpenSettings={() => { + setSettingsInitialPage("appearance"); + setSettingsOpen(true); + }} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} proxyStatus={proxyStatus} @@ -2296,11 +2314,13 @@ function App() { onSubmit: submitPluginSettingsDraft } : undefined} profileAdd={profileAddOpen ? { + botConfigs: draftConfig.botConfigs, canSubmit: canSubmitProfile, draft: profileDraft, error: profileActionError, mode: "add", onChange: updateProfileDraft, + onCreateBot: openBotSettingsWithAddDialog, onClose: () => setProfileAddOpen(false), providers: draftConfig.Providers, submitting: profileSubmitBusy === "add", @@ -2308,11 +2328,13 @@ function App() { onSubmit: submitProfileDraft } : undefined} profileEdit={profileEditIndex !== undefined ? { + botConfigs: draftConfig.botConfigs, canSubmit: canSubmitProfileEdit, draft: profileEditDraft, error: profileActionError, mode: "edit", onChange: updateProfileEditDraft, + onCreateBot: openBotSettingsWithAddDialog, onClose: () => { setProfileEditIndex(undefined); setProfileActionError(""); @@ -2381,9 +2403,13 @@ function App() { providers: draftConfig.Providers } : undefined} settings={settingsOpen ? { + botAddRequestKey: settingsBotAddRequestKey, + botConfigs: draftConfig.botConfigs, copy, + initialPage: settingsInitialPage, isMac, languagePreference, + onChangeBotConfigs: changeBotConfigs, onCheckUpdate: checkForAppUpdate, onChangeLanguage: changeLanguagePreference, onChangeTheme: changeThemePreference, @@ -2425,6 +2451,15 @@ function App() { ); } +function removeProfileBotReference(profile: ProfileConfig): ProfileConfig { + const { botConfigId: _botConfigId, botGateway: _botGateway, ...rest } = profile; + return rest; +} + +function isProfileBotSelectionValid(draft: AddProfileDraft, botConfigs: BotGatewaySavedConfig[]): boolean { + return !draft.botEnabled || botConfigs.some((config) => config.id === draft.botConfigId.trim()); +} + function formatUnknownError(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/renderer/pages/home/components/extensions.tsx b/src/renderer/pages/home/components/extensions.tsx index 10dfff1b..56dacae5 100644 --- a/src/renderer/pages/home/components/extensions.tsx +++ b/src/renderer/pages/home/components/extensions.tsx @@ -24,7 +24,7 @@ export function ExtensionsView({ const t = useAppText(); const [query, setQuery] = useState(""); const normalizedQuery = query.trim().toLowerCase(); - const extensions = useMemo(() => buildExtensionList(config), [config.plugins, config.providerPlugins, config.virtualModelProfiles]); + const extensions = useMemo(() => buildExtensionList(config), [config.plugins, config.providerPlugins]); const visibleExtensions = useMemo( () => extensions.filter((extension) => extensionMatchesQuery(extension, normalizedQuery)), [extensions, normalizedQuery] diff --git a/src/renderer/pages/home/components/onboarding.tsx b/src/renderer/pages/home/components/onboarding.tsx index 94fe2eb1..e424a937 100644 --- a/src/renderer/pages/home/components/onboarding.tsx +++ b/src/renderer/pages/home/components/onboarding.tsx @@ -215,9 +215,11 @@ export function OnboardingView({ >
undefined} providers={config.Providers} virtualModelProfiles={config.virtualModelProfiles ?? []} /> diff --git a/src/renderer/pages/home/components/profiles.tsx b/src/renderer/pages/home/components/profiles.tsx index 55dce502..f73690a4 100644 --- a/src/renderer/pages/home/components/profiles.tsx +++ b/src/renderer/pages/home/components/profiles.tsx @@ -1,5 +1,5 @@ import { - AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, Button, + AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, Button, Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy, cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Field, GatewayProviderConfig, Input, KeyValueRowsControl, LoaderCircle, motion, @@ -684,15 +684,19 @@ function ProfileModelSelector({ } export function AddProfileForm({ + botConfigs, draft, error, onChange, + onCreateBot, providers, virtualModelProfiles = [] }: { + botConfigs: BotGatewaySavedConfig[]; draft: AddProfileDraft; error: string; onChange: (patch: Partial) => void; + onCreateBot: () => void; providers: GatewayProviderConfig[]; virtualModelProfiles?: VirtualModelProfileConfig[]; }) { @@ -768,6 +772,9 @@ export function AddProfileForm({ )} +
+ +
) => void; + onCreateBot: () => void; +}) { + const t = useAppText(); + const options = [ + { label: t("None"), value: "none" }, + ...botConfigs.map((config) => ({ label: botGatewaySavedConfigLabel(config, t), value: config.id })), + { label: t("Add new bot"), value: ADD_BOT_SELECT_VALUE } + ]; + const selectedValue = draft.botEnabled && draft.botConfigId ? draft.botConfigId : "none"; + + function updateEnabled(botEnabled: boolean) { + if (!botEnabled) { + onChange({ botConfigId: "", botConfigured: true, botEnabled: false }); + return; + } + onChange({ + botConfigId: draft.botConfigId || botConfigs[0]?.id || "", + botConfigured: true, + botEnabled: true + }); + } + + function updateBot(value: string) { + if (value === ADD_BOT_SELECT_VALUE) { + onCreateBot(); + return; + } + if (value === "none") { + onChange({ botConfigId: "", botConfigured: true, botEnabled: false }); + return; + } + onChange({ botConfigId: value, botConfigured: true, botEnabled: true }); + } + + return ( +
+
+ {t("Bot")} + +
+ {draft.botEnabled ? ( +
+ + + +
+ ) : null} +
+ ); +} + export function AddProfileDialog({ + botConfigs, canSubmit, draft, error, mode = "add", onChange, + onCreateBot, onClose, providers, submitting = false, virtualModelProfiles = [], onSubmit }: { + botConfigs: BotGatewaySavedConfig[]; canSubmit: boolean; draft: AddProfileDraft; error: string; mode?: "add" | "edit"; onChange: (patch: Partial) => void; + onCreateBot: () => void; onClose: () => void; providers: GatewayProviderConfig[]; submitting?: boolean; @@ -819,7 +892,7 @@ export function AddProfileDialog({
- +
diff --git a/src/renderer/pages/home/components/settings.tsx b/src/renderer/pages/home/components/settings.tsx index 7877db19..d3337e00 100644 --- a/src/renderer/pages/home/components/settings.tsx +++ b/src/renderer/pages/home/components/settings.tsx @@ -1,10 +1,13 @@ import { - Activity, AppConfig, AppCopy, AppLanguagePreference, AppUpdateStatus, Boxes, Button, - Check, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent, - DialogFooter, DialogHeader, DialogTitle, Field, formatSystemOption, Gauge, - DndContext, DragEndEvent, Input, KeyboardSensor, languageDisplayName, Layers3, LoaderCircle, Palette, + Activity, AppConfig, AppCopy, AppLanguagePreference, AppUpdateStatus, Boxes, BotGatewayConfigDraft, botGatewayAuthSpecsForPlatform, + botGatewayDefaultAuthType, botGatewayFieldsForAuth, botGatewayPickAuthFields, botGatewayPlatformLabel, botGatewayPlatformOptions, + botGatewaySavedConfigFromDraft, botGatewaySavedConfigLabel, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewaySavedConfig, Button, + Check, CheckCircle2, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent, + DialogFooter, DialogHeader, DialogTitle, ExternalLink, Field, formatSystemOption, Gauge, + createBotGatewayConfigDraft, DndContext, DragEndEvent, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, languageDisplayName, Layers3, LoaderCircle, + normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, Palette, PanelLeftOpen, Power, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl, - PointerSensor, rectSortingStrategy, RefreshCw, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName, + PointerSensor, QrCode, rectSortingStrategy, RefreshCw, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, trayWidgetVariantOptions, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, X @@ -13,9 +16,13 @@ import { type UpdateActionBusy = "" | "check" | "download" | "install"; export function AppSettingsDialog({ + botAddRequestKey, + botConfigs, copy, + initialPage = "appearance", isMac, languagePreference, + onChangeBotConfigs, onCheckUpdate, onChangeLanguage, onChangeTheme, @@ -35,9 +42,13 @@ export function AppSettingsDialog({ updateActionError, updateStatus }: { + botAddRequestKey?: number; + botConfigs: BotGatewaySavedConfig[]; copy: AppCopy; + initialPage?: SettingsPageId; isMac: boolean; languagePreference: AppLanguagePreference; + onChangeBotConfigs: (configs: BotGatewaySavedConfig[]) => void; onCheckUpdate: () => Promise; onChangeLanguage: (value: string) => void; onChangeTheme: (value: string) => void; @@ -60,6 +71,7 @@ export function AppSettingsDialog({ return ( { @@ -89,6 +101,16 @@ export function AppSettingsDialog({ /> ); } + if (activePage === "bots") { + return ( + + ); + } return ( void; renderPage: (activePage: SettingsPageId) => ReactNode; }) { - const [activePage, setActivePage] = useState("appearance"); + const [activePage, setActivePage] = useState(initialPage); const visiblePage = activePage === "tray" && !isMac ? "appearance" : activePage; + useEffect(() => { + setActivePage(initialPage); + }, [initialPage]); + return ( !open && onClose()}> @@ -139,6 +167,13 @@ function SettingsLayout({ label={copy.settings.appearance} onClick={() => setActivePage("appearance")} /> + setActivePage("bots")} + /> {isMac ? ( void; +}) { + const t = (value: string) => copy.text[value] ?? value; + const [editor, setEditor] = useState<{ config?: BotGatewaySavedConfig; mode: "add" | "edit" }>(); + const lastAddRequestKey = useRef(0); + + useEffect(() => { + if (addRequestKey === lastAddRequestKey.current) { + return; + } + lastAddRequestKey.current = addRequestKey; + setEditor({ mode: "add" }); + }, [addRequestKey]); + + function saveBotConfig(config: BotGatewaySavedConfig) { + const exists = botConfigs.some((item) => item.id === config.id); + onChange(exists + ? botConfigs.map((item) => item.id === config.id ? config : item) + : [...botConfigs, config]); + setEditor(undefined); + } + + function removeBotConfig(config: BotGatewaySavedConfig) { + onChange(botConfigs.filter((item) => item.id !== config.id)); + } + + return ( +
+
+
+

{copy.settings.bots}

+
{t("Manage bots used by agent profiles.")}
+
+ +
+ +
+ {botConfigs.length === 0 ? ( +
+ {t("No bots configured")} +
+ ) : null} + {botConfigs.map((config) => ( +
+
+
{config.name}
+
+ {t(botGatewayPlatformLabel(config.botGateway.platform))} + {config.botGateway.authType ? ` / ${t(authMethodLabel(config.botGateway.platform, config.botGateway.authType))}` : ""} +
+
+
+ + +
+
+ ))} +
+ + {editor ? ( + setEditor(undefined)} + onSave={saveBotConfig} + /> + ) : null} +
+ ); +} + +type BotQrDisplay = + | { kind: "empty"; src: "" } + | { kind: "frame"; src: string } + | { kind: "image"; src: string }; + +type BotQrLoginState = { + display: BotQrDisplay; + error: string; + loading: boolean; + message: string; + savedConfig?: BotGatewaySavedConfig; + start?: BotGatewayQrLoginStartResult; + status: string; + wait?: BotGatewayQrLoginWaitResult; +}; + +function BotConfigDialog({ + botConfigs, + config, + copy, + mode, + onClose, + onSave +}: { + botConfigs: BotGatewaySavedConfig[]; + config?: BotGatewaySavedConfig; + copy: AppCopy; + mode: "add" | "edit"; + onClose: () => void; + onSave: (config: BotGatewaySavedConfig) => void; +}) { + const t = (value: string) => copy.text[value] ?? value; + const [draft, setDraft] = useState(() => createBotGatewayConfigDraft(config)); + const [error, setError] = useState(""); + const [qrLogin, setQrLogin] = useState(() => emptyBotQrLoginState()); + const qrSessionRef = useRef(""); + const platform = normalizeBotGatewayPlatform(draft.botPlatform); + const authType = normalizeBotGatewayAuthType(platform, draft.botAuthType); + const authSpecs = botGatewayAuthSpecsForPlatform(platform); + const authFields = botGatewayFieldsForAuth(platform, authType); + const platformOptions = botGatewayPlatformOptions.map((option) => ({ ...option, label: t(option.label) })); + const authOptions = authSpecs.map((option) => ({ label: t(option.label), value: option.value })); + const qrLoginSupported = platform === "weixin-ilink" && authType === "qr_login"; + const qrModeKey = qrLoginSupported ? `${config?.id ?? "new"}:${platform}:${authType}` : ""; + + function update(patch: Partial) { + setDraft((current) => ({ ...current, ...patch })); + setError(""); + } + + function updatePlatform(value: string) { + const nextPlatform = normalizeBotGatewayPlatform(value); + const nextAuthType = botGatewayDefaultAuthType(nextPlatform); + update({ + botAuthFields: botGatewayPickAuthFields(draft.botAuthFields, nextPlatform, nextAuthType), + botAuthType: nextAuthType, + botPlatform: nextPlatform + }); + } + + function updateAuthType(value: string) { + const nextAuthType = normalizeBotGatewayAuthType(platform, value); + update({ + botAuthFields: botGatewayPickAuthFields(draft.botAuthFields, platform, nextAuthType), + botAuthType: nextAuthType + }); + } + + function save() { + if (!isBotGatewayConfigDraftSubmittable(draft)) { + setError(t("Bot name, platform, and required authentication fields are required.")); + return; + } + const saved = botGatewaySavedConfigFromDraft(draft, botConfigs, config ?? qrLogin.savedConfig); + if (qrLogin.start && saved.botGateway.platform === "weixin-ilink") { + saved.botGateway = { + ...saved.botGateway, + integrationId: qrLogin.start.integrationId, + stateDir: qrLogin.start.stateDir, + tenantId: qrLogin.start.tenantId + }; + } + onSave(saved); + } + + function qrSavedConfigDraft(): BotGatewaySavedConfig { + return botGatewaySavedConfigFromDraft( + draft.name.trim() ? draft : { ...draft, name: t(botGatewayPlatformLabel(platform)) }, + botConfigs, + config ?? qrLogin.savedConfig + ); + } + + async function cancelQrSession(sessionId = qrSessionRef.current) { + const normalized = sessionId.trim(); + if (!normalized) { + return; + } + qrSessionRef.current = ""; + await window.ccr?.cancelBotGatewayQrLogin?.({ sessionId: normalized }).catch(() => undefined); + } + + async function startQrLogin(force = true) { + if (!window.ccr?.startBotGatewayQrLogin) { + setQrLogin((current) => ({ + ...current, + error: t("QR login is available in the Electron app."), + loading: false + })); + return; + } + + const savedConfig = qrSavedConfigDraft(); + setQrLogin((current) => ({ + ...current, + error: "", + loading: true, + message: t("Generating QR code"), + savedConfig, + status: "starting" + })); + await cancelQrSession(); + try { + const start = await window.ccr.startBotGatewayQrLogin({ config: savedConfig, force }); + qrSessionRef.current = start.sessionId; + setQrLogin({ + display: normalizeBotQrDisplay(start.qrCodeUrl), + error: "", + loading: false, + message: start.message || t("Scan the QR code in Weixin."), + savedConfig, + start, + status: "qr_pending" + }); + } catch (error) { + setQrLogin((current) => ({ + ...current, + error: formatBotQrError(error), + loading: false, + message: "", + status: "failed" + })); + } + } + + useEffect(() => { + return () => { + const sessionId = qrSessionRef.current; + if (sessionId) { + void window.ccr?.cancelBotGatewayQrLogin?.({ sessionId }).catch(() => undefined); + } + }; + }, []); + + useEffect(() => { + if (!qrModeKey) { + void cancelQrSession(); + setQrLogin(emptyBotQrLoginState()); + return; + } + setQrLogin(emptyBotQrLoginState()); + void startQrLogin(false); + }, [qrModeKey]); + + useEffect(() => { + const sessionId = qrLogin.start?.sessionId; + if (!sessionId || !window.ccr?.waitBotGatewayQrLogin) { + return; + } + let cancelled = false; + let timer: number | undefined; + const poll = async () => { + if (cancelled) { + return; + } + try { + const wait = await window.ccr?.waitBotGatewayQrLogin?.({ sessionId, timeoutMs: 5000 }); + if (cancelled || !wait) { + return; + } + setQrLogin((current) => current.start?.sessionId === sessionId + ? { + ...current, + error: "", + message: wait.message || current.message, + status: wait.status, + wait + } + : current); + if (isTerminalBotQrLoginStatus(wait.status)) { + qrSessionRef.current = ""; + return; + } + timer = window.setTimeout(poll, 1200); + } catch (error) { + if (cancelled) { + return; + } + setQrLogin((current) => current.start?.sessionId === sessionId + ? { ...current, error: formatBotQrError(error) } + : current); + timer = window.setTimeout(poll, 2500); + } + }; + timer = window.setTimeout(poll, 800); + return () => { + cancelled = true; + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + }, [qrLogin.start?.sessionId]); + + return ( + !open && onClose()} open> + + + {mode === "add" ? t("Add bot") : t("Edit bot")} + + + +
+ + update({ name: event.target.value })} /> + + + + + {authOptions.length > 0 ? ( + + + + ) : null} + {authFields.map((field) => ( + + update({ + botAuthFields: { + ...draft.botAuthFields, + [field.key]: event.target.value + } + })} + /> + + ))} + {qrLoginSupported ? ( +
+
+
+
{t("Weixin QR login")}
+
+ {qrLogin.message || t("Scan with Weixin to connect this bot.")} +
+
+ +
+
+
+ +
+
+
+
+ {qrLogin.loading ? ( + + ) : qrLogin.status === "confirmed" ? ( + + ) : qrLogin.error || isFailedBotQrLoginStatus(qrLogin.status) ? ( + + ) : ( + + )} + {t(botQrLoginStatusLabel(qrLogin.status))} +
+ {qrLogin.error ? ( +
{qrLogin.error}
+ ) : null} +
+ {qrLogin.start?.expiresAt ? ( +
+
{t("Expires")}
+
{qrLogin.start.expiresAt}
+
+ ) : null} + {qrLogin.display.kind === "frame" ? ( + + ) : null} +
+
+
+ ) : null} +
+ {t("Forward agent messages")} + update({ botForwardAllAgentMessages: checked === true })} /> +
+
+
+ {t("Handoff")} + update({ botHandoffEnabled: checked === true })} /> +
+ {draft.botHandoffEnabled ? ( +
+ + update({ botHandoffIdleSeconds: event.target.value })} /> + + + update({ botHandoffPhoneWifiTargets: event.target.value })} /> + + + update({ botHandoffPhoneBluetoothTargets: event.target.value })} /> + +
+ ) : null} +
+
+ {error ? ( +
+ {error} +
+ ) : null} +
+ + + + +
+
+ ); +} + +function BotQrPreview({ display, label }: { display: BotQrDisplay; label: string }) { + if (display.kind === "image") { + return {label}; + } + if (display.kind === "frame") { + return ( + + ); + } + return ; +} + +function emptyBotQrLoginState(): BotQrLoginState { + return { + display: { kind: "empty", src: "" }, + error: "", + loading: false, + message: "", + status: "idle" + }; +} + +function normalizeBotQrDisplay(raw: string): BotQrDisplay { + const value = raw.trim(); + if (!value) { + return { kind: "empty", src: "" }; + } + if (value.startsWith("http://") || value.startsWith("https://")) { + return { kind: "frame", src: value }; + } + if (value.startsWith("data:")) { + return { kind: "image", src: value }; + } + if (value.startsWith(" option.value === normalized)?.label ?? normalized; +} + function UpdateSettingsPage({ actionBusy, actionError, diff --git a/src/renderer/pages/home/shared.tsx b/src/renderer/pages/home/shared.tsx index d8afde5a..1e9a49e9 100644 --- a/src/renderer/pages/home/shared.tsx +++ b/src/renderer/pages/home/shared.tsx @@ -27,12 +27,14 @@ import { Boxes, Braces, Check, + CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, Copy, Database, + ExternalLink, FolderOpen, Gauge, Globe, @@ -49,6 +51,7 @@ import { Play, Plus, Power, + QrCode, RefreshCw, Route, Search, @@ -137,6 +140,14 @@ import type { AppUpdateStatus, ApiKeyConfig, ApiKeyLimitConfig, + BotGatewayQrLoginCancelRequest, + BotGatewayQrLoginCancelResult, + BotGatewayQrLoginStartRequest, + BotGatewayQrLoginStartResult, + BotGatewayQrLoginWaitRequest, + BotGatewayQrLoginWaitResult, + BotGatewayRuntimeConfig, + BotGatewaySavedConfig, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, @@ -228,11 +239,11 @@ export { closestCenter, DndContext, DragOverlay, getFirstCollision, KeyboardSensor, MeasuringStrategy, pointerWithin, PointerSensor, rectIntersection, useSensor, useSensors, arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, CSS, AnimatePresence, LayoutGroup, motion, useReducedMotion, - Activity, ArrowDown, ArrowUp, Box, Boxes, Braces, Check, + Activity, ArrowDown, ArrowUp, Box, Boxes, Braces, Check, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, Copy, Database, FolderOpen, - Gauge, Globe, KeyRound, Layers3, LoaderCircle, MoveRight, Network, + ExternalLink, Gauge, Globe, KeyRound, Layers3, LoaderCircle, MoveRight, Network, Palette, PanelLeftClose, PanelLeftOpen, Pause, Pencil, Play, Plus, - Power, RefreshCw, Route, Search, Server, Settings, ShieldCheck, + Power, QrCode, RefreshCw, Route, Search, Server, Settings, ShieldCheck, Trash2, UserRound, X, Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, LabelList, Line, Pie, PieChart, Tooltip, XAxis, YAxis, Badge, Button, Card, CardContent, CardHeader, @@ -248,7 +259,7 @@ export { export type { HTMLAttributes, ReactPointerEvent, ReactNode, CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent, LucideIcon, AgentAnalysisFilter, AgentAnalysisSnapshot, AgentKind, AppConfig, AppInfo, AppUpdateStatus, ApiKeyConfig, - ApiKeyLimitConfig, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig, + ApiKeyLimitConfig, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginCancelResult, BotGatewayQrLoginStartRequest, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitRequest, BotGatewayQrLoginWaitResult, BotGatewayRuntimeConfig, BotGatewaySavedConfig, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig, GatewayMcpServerTransport, GatewayMcpStdioMessageMode, GatewayMcpToolInfo, GatewayStatus, OverviewMetricKind, OverviewWidgetConfig, OverviewWidgetSize, OverviewWidgetType, OverviewWidgetVariant, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProviderAccountConfig, ProviderAccountConnectorConfig, ProviderAccountHttpJsonConnectorConfig, ProviderAccountMeter, ProviderAccountStandardConnectorConfig, ProviderAccountSnapshot, ProviderAccountTestPath, ProviderAccountTestResult, ProviderDeepLinkPayload, ProviderDeepLinkRequest, @@ -266,7 +277,7 @@ export type OnboardingStepId = "provider" | "profile" | "enter"; export type AppLanguagePreference = "system" | "en" | "zh"; export type ResolvedLanguage = "en" | "zh"; export type ResolvedTheme = "light" | "dark"; -export type SettingsPageId = "appearance" | "tray" | "update"; +export type SettingsPageId = "appearance" | "bots" | "tray" | "update"; export type TrayEditableModuleId = Exclude; export type TrayComponentOptionGroup = { key: keyof TrayComponentVariants; @@ -284,6 +295,7 @@ export type AppCopy = { navigation: Record; settings: { appearance: string; + bots: string; button: string; close: string; done: string; @@ -373,6 +385,7 @@ export const appCopy: Record = { }, settings: { appearance: "Appearance", + bots: "Bots", button: "Settings", close: "Close", done: "Done", @@ -620,6 +633,7 @@ export const appCopy: Record = { }, settings: { appearance: "外观", + bots: "Bot 管理", button: "设置", close: "关闭", done: "完成", @@ -711,6 +725,8 @@ export const appCopy: Record = { "Add profile": "添加配置", "Add Provider": "添加供应商", "Add provider": "添加供应商", + "Add bot": "添加 Bot", + "Add new bot": "添加新 Bot", "Add Routing Rule": "添加路由规则", "Add routing rule": "添加路由规则", "Advanced Settings...": "高级设置...", @@ -724,7 +740,57 @@ export const appCopy: Record = { "API Keys": "API 密钥", "API key included": "已包含 API 密钥", "API key not included": "未包含 API 密钥", + "Acknowledge events": "确认事件", + "Auth type": "认证类型", + "Auth method": "认证方式", + "Auto start integration": "自动启动集成", "Base URL": "基础 URL", + "Bot": "Bot", + "Bot name, platform, and required authentication fields are required.": "Bot 名称、平台和必填认证字段不能为空。", + "Bot gateway path": "Bot 网关路径", + "Weixin iLink": "微信", + "WeCom": "企业微信", + "Feishu": "飞书", + "DingTalk": "钉钉", + "QR Login": "扫码登录", + "Weixin QR login": "微信扫码登录", + "Weixin QR code": "微信二维码", + "QR login is available in the Electron app.": "扫码登录仅在 Electron App 中可用。", + "Generate QR code": "生成二维码", + "Generating QR code": "正在生成二维码", + "Scan the QR code in Weixin.": "请使用微信扫描二维码。", + "Scan with Weixin to connect this bot.": "使用微信扫码连接这个 Bot。", + "Waiting for QR code": "等待生成二维码", + "Waiting for scan": "等待扫码", + "Scanned, confirm on phone": "已扫码,请在手机上确认", + "Verification required": "需要验证码", + "Connected": "已连接", + "QR code expired": "二维码已过期", + "Already connected": "已连接过", + "QR login failed": "扫码登录失败", + "Regenerate": "重新生成", + "Bot Token": "Bot Token", + "Account ID": "账号 ID", + "User ID": "用户 ID", + "Corp ID": "企业 ID", + "Agent ID": "Agent ID", + "Secret": "Secret", + "Signing Secret": "Signing Secret", + "App Token": "App Token", + "OAuth 2.0": "OAuth 2.0", + "OAuth Bot Token": "OAuth Bot Token", + "OAuth Access Token": "OAuth Access Token", + "Application ID": "应用 ID", + "Public Key": "公钥", + "Channel Access Token": "Channel Access Token", + "Channel Secret": "Channel Secret", + "App ID": "App ID", + "App Secret": "App Secret", + "Verification Token": "Verification Token", + "Domain": "Domain", + "App Key": "App Key", + "Robot Code": "Robot Code", + "Optional": "可选", "Auto": "自动", "Back": "返回", "Backup": "备份", @@ -734,6 +800,7 @@ export const appCopy: Record = { "Cache tokens": "缓存令牌", "Cache write": "缓存写入", "Cancel": "取消", + "Channel": "频道", "Check": "检查", "Check for updates": "检查更新", "Checking for updates": "正在检查更新", @@ -757,6 +824,7 @@ export const appCopy: Record = { "CLI only": "仅 CLI", "Concurrency": "并发", "Condition": "条件", + "Conversation type": "会话类型", "Claude Design": "Claude Design", "Claude Design model": "Claude Design 模型", "Claude Design routes": "Claude Design 路由", @@ -768,6 +836,8 @@ export const appCopy: Record = { "Configure plugin route": "配置插件路由", "Configure Routing": "配置路由", "Copy": "复制", + "Create integration": "创建集成", + "Credentials JSON": "凭据 JSON", "Continue": "继续", "Custom config path": "自定义配置路径", "Core gateway": "核心网关", @@ -792,6 +862,7 @@ export const appCopy: Record = { "Display name": "显示名称", "Double click to copy": "双击复制", "Edit": "编辑", + "Edit bot": "编辑 Bot", "Edit API Key": "编辑 API 密钥", "Edit API key": "编辑 API 密钥", "Edit Profile": "编辑配置", @@ -818,12 +889,16 @@ export const appCopy: Record = { "Fallback model": "回退模型", "Failure handling": "故障处理", "First enabled": "首个启用规则", + "Forward agent messages": "转发 Agent 消息", + "Gateway conversation ID": "网关会话 ID", "Generated config": "生成配置", "Generated path": "生成路径", + "Group": "群组", "Headers": "请求头", "Header rows require keys.": "请求头行必须填写 Key。", "Fetch usage": "获取用量", "Fetch manifest": "拉取 manifest", + "Handoff": "Handoff", "Hide advanced settings": "收起高级设置", "HTTP JSON request": "HTTP JSON 请求", "Host": "主机", @@ -835,8 +910,10 @@ export const appCopy: Record = { "Invalid JSON.": "JSON 无效。", "Image content": "图像内容", "Images": "图像", + "Idle seconds": "空闲秒数", "Input": "输入", "Input tokens": "输入令牌", + "Integration ID": "集成 ID", "Install": "安装", "Install and restart": "安装并重启", "App": "App", @@ -860,6 +937,7 @@ export const appCopy: Record = { "Long threshold": "长上下文阈值", "Max concurrency": "最大并发", "Max concurrent": "最大并发", + "Manage bots used by agent profiles.": "管理 Agent 配置中可选择的 Bot。", "Method": "方法", "Model": "模型", "Model override": "模型覆盖", @@ -880,7 +958,9 @@ export const appCopy: Record = { "No provider usage yet": "暂无供应商用量", "No provider yet": "还没有供应商", "No requests captured yet": "暂无请求记录", + "No bots configured": "尚未配置 Bot", "No route activity": "暂无路由活动", + "None": "无", "Not configured": "未配置", "Not running": "未运行", "Open": "打开", @@ -900,6 +980,9 @@ export const appCopy: Record = { "P99": "P99", "Path": "路径", "Platform": "平台", + "Platform conversation ID": "平台会话 ID", + "Phone Bluetooth target": "手机蓝牙目标", + "Phone Wi-Fi target": "手机 Wi-Fi 目标", "Plugin": "插件", "Plugin apps must be a JSON array.": "插件 App 必须是 JSON 数组。", "Plugin config JSON": "插件配置 JSON", @@ -951,6 +1034,7 @@ export const appCopy: Record = { "Request": "请求", "Request ID": "请求 ID", "Request logs database": "请求日志数据库", + "Request timeout ms": "请求超时 ms", "Requests": "请求", "Retries": "重试次数", "Retry": "继续重试", @@ -960,6 +1044,7 @@ export const appCopy: Record = { "Route Observability": "路由可观测", "Rules": "规则", "Save": "保存", + "Screen lock": "锁屏", "Search API keys": "搜索 API 密钥", "Search extensions": "搜索扩展", "Search models": "搜索模型", @@ -968,7 +1053,10 @@ export const appCopy: Record = { "Search providers or models": "搜索供应商或模型", "Search request logs": "搜索请求日志", "Search routing rules": "搜索路由规则", + "Select bot": "选择 Bot", "Server": "服务", + "Startup timeout ms": "启动超时 ms", + "State directory": "状态目录", "Account component": "账户组件", "All accounts": "所有账户", "Add widget": "添加组件", @@ -1047,8 +1135,11 @@ export const appCopy: Record = { "System proxy": "系统代理", "System default": "系统默认", "Target": "目标", + "Tenant ID": "租户 ID", "Target model": "目标模型", "Target model is required.": "目标模型不能为空。", + "Thread": "线程", + "Thread ID": "线程 ID", "Thinking": "思考", "Token Mix": "令牌构成", "Total tokens": "总令牌", @@ -1073,6 +1164,7 @@ export const appCopy: Record = { "Usage request URL must use http or https.": "用量请求 URL 必须使用 http 或 https。", "Usage database": "用量数据库", "Usage Trend": "用量趋势", + "User idle": "用户空闲", "Insert example": "插入示例", "Virtual model": "Fusion", "Virtual Models": "Fusion", @@ -1826,6 +1918,36 @@ export const fallbackConfig: AppConfig = { mcpServers: [] }, autoStart: false, + botConfigs: [], + botGateway: { + acknowledgeEvents: false, + args: [], + authType: "", + autoStartIntegration: true, + command: "", + createIntegration: false, + credentials: {}, + cwd: "", + enabled: false, + forwardAllAgentMessages: true, + handoff: { + enabled: false, + idleSeconds: 30, + phoneBluetoothTargets: [], + phoneWifiTargets: [], + screenLock: true, + userIdle: true + }, + integrationConfig: {}, + integrationId: "", + platform: "none", + pollIntervalMs: 2000, + requestTimeoutMs: 600000, + sourceDir: "/Users/jinhuilee/products/bot-gateway", + startupTimeoutMs: 10000, + stateDir: "", + tenantId: "ccr" + }, gateway: { coreHost: "127.0.0.1", corePort: 3457, @@ -2099,6 +2221,17 @@ export type AddApiKeyDraft = { export type AddProfileDraft = { agent: ProfileConfig["agent"]; + botConfigId: string; + botAuthFields: Record; + botAuthType: string; + botConfigured: boolean; + botEnabled: boolean; + botForwardAllAgentMessages: boolean; + botHandoffEnabled: boolean; + botHandoffIdleSeconds: string; + botHandoffPhoneBluetoothTargets: string; + botHandoffPhoneWifiTargets: string; + botPlatform: string; configFile: string; envRows: KeyValueDraftRow[]; model: string; @@ -2112,6 +2245,18 @@ export type AddProfileDraft = { surface: ProfileSurface; }; +export type BotGatewayConfigDraft = { + botAuthFields: Record; + botAuthType: string; + botForwardAllAgentMessages: boolean; + botHandoffEnabled: boolean; + botHandoffIdleSeconds: string; + botHandoffPhoneBluetoothTargets: string; + botHandoffPhoneWifiTargets: string; + botPlatform: string; + name: string; +}; + export type ApiKeyLimitDraftRow = { id: string; metric: ApiKeyLimitMetric; @@ -2243,7 +2388,7 @@ export type ExtensionInstallDraft = { selectedName: string; }; -export type ExtensionSource = "plugins" | "providerPlugins" | "virtualModelProfiles"; +export type ExtensionSource = "plugins" | "providerPlugins"; export type PluginRoutingConfigTarget = { index: number; @@ -2870,9 +3015,231 @@ export function profileModelMatchesQuery(providerName: string, model: string, qu return providerName.toLowerCase().includes(normalizedQuery) || model.toLowerCase().includes(normalizedQuery); } +export type BotGatewayAuthInputType = "text" | "password"; + +export type BotGatewayAuthFieldSpec = { + key: string; + label: string; + placeholder?: string; + required?: boolean; + type?: BotGatewayAuthInputType; +}; + +export type BotGatewayAuthSpec = { + fields: readonly BotGatewayAuthFieldSpec[]; + label: string; + value: string; +}; + +export type BotGatewayPlatformSpec = { + auth: readonly BotGatewayAuthSpec[]; + label: string; + value: string; +}; + +const botGatewayPlatformSpecs: readonly BotGatewayPlatformSpec[] = [ + { + value: "weixin-ilink", + label: "Weixin iLink", + auth: [ + { value: "qr_login", label: "QR Login", fields: [] }, + { + value: "bot_token", + label: "Bot Token", + fields: [ + { key: "botToken", label: "Bot Token", required: true, type: "password" }, + { key: "accountId", label: "Account ID" }, + { key: "userId", label: "User ID" } + ] + } + ] + }, + { + value: "wecom", + label: "WeCom", + auth: [ + { + value: "app_secret", + label: "App Secret", + fields: [ + { key: "corpId", label: "Corp ID", required: true }, + { key: "agentId", label: "Agent ID", required: true }, + { key: "secret", label: "Secret", required: true, type: "password" } + ] + } + ] + }, + { + value: "slack", + label: "Slack", + auth: [ + { + value: "bot_token", + label: "Bot Token", + fields: [ + { key: "botToken", label: "Bot Token", placeholder: "xoxb-...", required: true, type: "password" }, + { key: "signingSecret", label: "Signing Secret", type: "password" }, + { key: "appToken", label: "App Token", placeholder: "xapp-...", type: "password" } + ] + }, + { + value: "oauth2", + label: "OAuth 2.0", + fields: [ + { key: "botToken", label: "OAuth Bot Token", placeholder: "xoxb-...", required: true, type: "password" }, + { key: "signingSecret", label: "Signing Secret", type: "password" } + ] + } + ] + }, + { + value: "discord", + label: "Discord", + auth: [ + { + value: "bot_token", + label: "Bot Token", + fields: [ + { key: "botToken", label: "Bot Token", required: true, type: "password" }, + { key: "applicationId", label: "Application ID" }, + { key: "publicKey", label: "Public Key" } + ] + }, + { + value: "oauth2", + label: "OAuth 2.0", + fields: [ + { key: "botToken", label: "OAuth Access Token", required: true, type: "password" }, + { key: "applicationId", label: "Application ID" }, + { key: "publicKey", label: "Public Key" } + ] + } + ] + }, + { + value: "telegram", + label: "Telegram", + auth: [ + { + value: "bot_token", + label: "Bot Token", + fields: [{ key: "botToken", label: "Bot Token", required: true, type: "password" }] + } + ] + }, + { + value: "line", + label: "LINE", + auth: [ + { + value: "bot_token", + label: "Bot Token", + fields: [ + { key: "channelAccessToken", label: "Channel Access Token", required: true, type: "password" }, + { key: "channelSecret", label: "Channel Secret", type: "password" } + ] + } + ] + }, + { + value: "feishu", + label: "Feishu", + auth: [ + { + value: "app_secret", + label: "App Secret", + fields: [ + { key: "appId", label: "App ID", required: true }, + { key: "appSecret", label: "App Secret", required: true, type: "password" }, + { key: "verificationToken", label: "Verification Token", type: "password" }, + { key: "domain", label: "Domain" } + ] + } + ] + }, + { + value: "dingtalk", + label: "DingTalk", + auth: [ + { + value: "app_secret", + label: "App Secret", + fields: [ + { key: "appKey", label: "App Key", required: true }, + { key: "appSecret", label: "App Secret", required: true, type: "password" }, + { key: "robotCode", label: "Robot Code" } + ] + } + ] + } +]; + +export const botGatewayPlatformOptions = botGatewayPlatformSpecs.map(({ label, value }) => ({ label, value })); + +export function botGatewayPlatformLabel(platform: string): string { + const normalized = normalizeBotGatewayPlatform(platform); + if (normalized === "none") { + return "Bot"; + } + return botGatewayPlatformOptions.find((option) => option.value === normalized)?.label ?? normalized; +} + +export function botGatewayAuthSpecsForPlatform(platform: string): readonly BotGatewayAuthSpec[] { + const normalized = normalizeBotGatewayPlatform(platform); + if (normalized === "none") { + return []; + } + return botGatewayPlatformSpecs.find((option) => option.value === normalized)?.auth || []; +} + +export function botGatewayFieldsForAuth(platform: string, authType: string): readonly BotGatewayAuthFieldSpec[] { + const normalizedAuthType = normalizeBotGatewayAuthType(platform, authType); + return botGatewayAuthSpecsForPlatform(platform).find((option) => option.value === normalizedAuthType)?.fields || []; +} + +export function botGatewayDefaultAuthType(platform: string): string { + return botGatewayAuthSpecsForPlatform(platform)[0]?.value || ""; +} + +export function botGatewayPickAuthFields(fields: Record | undefined, platform: string, authType: string): Record { + const allowedKeys = new Set(botGatewayFieldsForAuth(platform, authType).map((field) => field.key)); + if (allowedKeys.size === 0) { + return {}; + } + const result: Record = {}; + for (const [key, rawValue] of Object.entries(fields || {})) { + const normalizedKey = key.trim(); + const value = String(rawValue ?? "").trim(); + if (normalizedKey && value && allowedKeys.has(normalizedKey) && !isWebhookRelatedBotGatewayKey(normalizedKey)) { + result[normalizedKey] = value; + } + } + return result; +} + +function createBotGatewayDraft(botGateway?: BotGatewayRuntimeConfig) { + const bot = normalizeBotGatewayRuntimeConfig(botGateway) ?? fallbackConfig.botGateway; + const platform = bot.platform || "none"; + const authType = normalizeBotGatewayAuthType(platform, bot.authType ?? ""); + return { + botConfigId: "", + botAuthFields: botGatewayPickAuthFields({ ...(bot.integrationConfig ?? {}), ...(bot.credentials ?? {}) }, platform, authType), + botAuthType: authType, + botConfigured: Boolean(botGateway), + botEnabled: Boolean(bot.enabled), + botForwardAllAgentMessages: bot.forwardAllAgentMessages !== false, + botHandoffEnabled: Boolean(bot.handoff.enabled), + botHandoffIdleSeconds: String(bot.handoff.idleSeconds ?? fallbackConfig.botGateway.handoff.idleSeconds), + botHandoffPhoneBluetoothTargets: (bot.handoff.phoneBluetoothTargets ?? []).join("\n"), + botHandoffPhoneWifiTargets: (bot.handoff.phoneWifiTargets ?? []).join("\n"), + botPlatform: bot.platform || "none" + }; +} + export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code", name?: string): AddProfileDraft { return { agent, + ...createBotGatewayDraft(), configFile: "~/.codex/config.toml", envRows: [], model: "", @@ -2887,10 +3254,16 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code" }; } -export function createProfileDraftFromProfile(profile: ProfileConfig): AddProfileDraft { +export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs: BotGatewaySavedConfig[] = []): AddProfileDraft { + const botDraft = createBotGatewayDraft(profile.botGateway); + const botConfigId = profile.botConfigId || matchingBotConfigId(profile.botGateway, botConfigs); + const selectedBot = botConfigId ? botConfigs.find((config) => config.id === botConfigId) : undefined; if (profile.agent === "claude-code") { return { ...createProfileDraft("claude-code", profile.name), + ...botDraft, + botConfigId, + botEnabled: Boolean(selectedBot || profile.botGateway?.enabled), envRows: keyValueRowsFromRecord(profile.env ?? {}), model: profile.model, scope: normalizeProfileFormScope(profile.scope), @@ -2901,6 +3274,9 @@ export function createProfileDraftFromProfile(profile: ProfileConfig): AddProfil } return { ...createProfileDraft("codex", profile.name), + ...botDraft, + botConfigId, + botEnabled: Boolean(selectedBot || profile.botGateway?.enabled), configFile: profile.configFile ?? "~/.codex/config.toml", envRows: keyValueRowsFromRecord(profile.env ?? {}), model: profile.model, @@ -2919,6 +3295,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { if (!validateProfileEnvRows(draft.envRows)) { return false; } + if (draft.botEnabled && !draft.botConfigId.trim()) { + return false; + } if (draft.agent === "claude-code") { return true; } @@ -2928,14 +3307,34 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { ); } +function matchingBotConfigId(botGateway: BotGatewayRuntimeConfig | undefined, botConfigs: BotGatewaySavedConfig[]): string { + if (!botGateway?.enabled) { + return ""; + } + const integrationId = botGateway.integrationId?.trim(); + const matched = botConfigs.find((config) => + (integrationId && config.botGateway.integrationId === integrationId) || + (config.botGateway.platform === botGateway.platform && config.botGateway.tenantId === botGateway.tenantId) + ); + return matched?.id ?? ""; +} + export function profileConfigFromDraft( draft: AddProfileDraft, existingProfiles: ProfileConfig[], - existingProfile?: ProfileConfig + existingProfile?: ProfileConfig, + botConfigs: BotGatewaySavedConfig[] = [] ): ProfileConfig { const id = existingProfile?.id ?? uniqueProfileId(existingProfiles, draft.name || draft.agent); + const selectedBot = draft.botEnabled + ? botConfigs.find((config) => config.id === draft.botConfigId.trim()) + : undefined; + const botGateway = selectedBot + ? { botConfigId: selectedBot.id, botGateway: selectedBot.botGateway } + : {}; return normalizeProfileItem({ agent: draft.agent, + ...botGateway, configFile: draft.configFile, enabled: existingProfile?.enabled ?? true, env: recordFromKeyValueRows(draft.envRows), @@ -2952,6 +3351,243 @@ export function profileConfigFromDraft( }, existingProfiles.length); } +export function createBotGatewayConfigDraft(config?: BotGatewaySavedConfig): BotGatewayConfigDraft { + const botDraft = createBotGatewayDraft(config?.botGateway); + return { + botAuthFields: botDraft.botAuthFields, + botAuthType: botDraft.botAuthType, + botForwardAllAgentMessages: botDraft.botForwardAllAgentMessages, + botHandoffEnabled: botDraft.botHandoffEnabled, + botHandoffIdleSeconds: botDraft.botHandoffIdleSeconds, + botHandoffPhoneBluetoothTargets: botDraft.botHandoffPhoneBluetoothTargets, + botHandoffPhoneWifiTargets: botDraft.botHandoffPhoneWifiTargets, + botPlatform: botDraft.botPlatform === "none" ? "weixin-ilink" : botDraft.botPlatform, + name: config?.name ?? "" + }; +} + +export function isBotGatewayConfigDraftSubmittable(draft: BotGatewayConfigDraft): boolean { + if (!draft.name.trim()) { + return false; + } + const platform = normalizeBotGatewayPlatform(draft.botPlatform); + const authType = normalizeBotGatewayAuthType(platform, draft.botAuthType); + if (!platform || platform === "none") { + return false; + } + return ( + botGatewayMissingRequiredAuthFields(draft.botAuthFields, platform, authType).length === 0 && + isNumberDraftValid(draft.botHandoffIdleSeconds, 30, 86_400) + ); +} + +export function botGatewaySavedConfigFromDraft( + draft: BotGatewayConfigDraft, + existingConfigs: BotGatewaySavedConfig[], + existingConfig?: BotGatewaySavedConfig +): BotGatewaySavedConfig { + const id = existingConfig?.id ?? uniqueBotGatewayConfigId(existingConfigs, draft.name); + const name = draft.name.trim() || botGatewayPlatformLabel(draft.botPlatform); + return normalizeBotGatewaySavedConfig({ + botGateway: botGatewayConfigFromDraft({ ...draft, botEnabled: true }, id, name, existingConfig?.botGateway), + id, + name, + updatedAt: new Date().toISOString() + }) ?? { + botGateway: fallbackConfig.botGateway, + id, + name + }; +} + +type BotGatewayConfigDraftInput = BotGatewayConfigDraft & { + botEnabled?: boolean; +}; + +function botGatewayConfigFromDraft( + draft: BotGatewayConfigDraftInput, + configId: string, + configName: string, + existingBotGateway?: BotGatewayRuntimeConfig +): BotGatewayRuntimeConfig { + const platform = normalizeBotGatewayPlatform(draft.botPlatform); + const authType = normalizeBotGatewayAuthType(platform, draft.botAuthType); + const authPayload = botGatewayAuthPayload(platform, authType, draft.botAuthFields); + const config: BotGatewayRuntimeConfig = { + ...fallbackConfig.botGateway, + acknowledgeEvents: true, + args: [], + authType, + autoStartIntegration: true, + command: "", + createIntegration: draft.botEnabled !== false && platform !== "none", + credentials: authPayload.credentials, + cwd: "", + enabled: draft.botEnabled !== false, + forwardAllAgentMessages: draft.botForwardAllAgentMessages, + handoff: { + enabled: draft.botHandoffEnabled, + idleSeconds: numberDraftValue(draft.botHandoffIdleSeconds, fallbackConfig.botGateway.handoff.idleSeconds, 30, 86_400), + phoneBluetoothTargets: splitDraftLines(draft.botHandoffPhoneBluetoothTargets).slice(0, 1), + phoneWifiTargets: splitDraftLines(draft.botHandoffPhoneWifiTargets).slice(0, 1), + screenLock: true, + userIdle: true + }, + integrationConfig: authPayload.integrationConfig, + integrationId: existingBotGateway?.integrationId?.trim() || createBotGatewayIntegrationId(configId), + platform, + pollIntervalMs: fallbackConfig.botGateway.pollIntervalMs, + requestTimeoutMs: fallbackConfig.botGateway.requestTimeoutMs, + sourceDir: "", + startupTimeoutMs: fallbackConfig.botGateway.startupTimeoutMs, + stateDir: existingBotGateway?.stateDir?.trim() || createBotGatewayStateDir(configId), + tenantId: existingBotGateway?.tenantId?.trim() || createBotGatewayTenantId(configName || configId) + }; + return config; +} + +function botGatewayMissingRequiredAuthFields(fields: Record, platform: string, authType: string): BotGatewayAuthFieldSpec[] { + return botGatewayFieldsForAuth(platform, authType).filter((field) => field.required && !fields[field.key]?.trim()); +} + +function botGatewayAuthPayload(platform: string, authType: string, fields: Record) { + const authFields = botGatewayPickAuthFields(fields, platform, authType); + const credentials: Record = {}; + const integrationConfig: Record = {}; + for (const [key, value] of Object.entries(authFields)) { + if (isBotGatewayIntegrationConfigField(platform, key)) { + integrationConfig[key] = botGatewayConfigValue(key, value); + } else { + credentials[key] = value; + } + } + return { + credentials: sanitizeBotGatewayRecord(credentials), + integrationConfig: websocketBotGatewayIntegrationConfig(platform, integrationConfig) + }; +} + +function isBotGatewayIntegrationConfigField(platform: string, key: string): boolean { + return ( + [ + "transport", + "dryRun", + "applicationId", + "publicKey", + "appId", + "appKey", + "corpId", + "agentId", + "robotCode" + ].includes(key) || + (platform === "weixin-ilink" && ["accountId", "userId", "botAgent", "routeTag"].includes(key)) || + (platform === "feishu" && ["domain", "appType", "receiveIdType", "tenantKey", "tenantAccessToken"].includes(key)) + ); +} + +function botGatewayConfigValue(key: string, value: string): unknown { + if (key === "dryRun") { + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); + } + return value; +} + +function createBotGatewayTenantId(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "ccr"; +} + +function createBotGatewayIntegrationId(profileId: string): string { + if (isUuidLike(profileId)) { + return profileId; + } + return globalThis.crypto?.randomUUID?.() ?? `bot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function createBotGatewayStateDir(configId: string): string { + const safe = configId.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "default"; + return `~/.claude-code-router/bot-gateway/${safe}`; +} + +function uniqueBotGatewayConfigId(configs: BotGatewaySavedConfig[], value: string): string { + const uuid = globalThis.crypto?.randomUUID?.(); + if (uuid && !configs.some((config) => config.id === uuid)) { + return uuid; + } + const base = createBotGatewayTenantId(value || "bot"); + const existingIds = new Set(configs.map((config) => config.id)); + if (!existingIds.has(base)) { + return base; + } + for (let index = 2; index < 1000; index += 1) { + const candidate = `${base}-${index}`; + if (!existingIds.has(candidate)) { + return candidate; + } + } + return `${base}-${Date.now()}`; +} + +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.trim()); +} + +export function normalizeBotGatewaySavedConfigs(value: unknown): BotGatewaySavedConfig[] { + if (!Array.isArray(value)) { + return []; + } + const result: BotGatewaySavedConfig[] = []; + for (const item of value) { + const normalized = normalizeBotGatewaySavedConfig(item, result.length); + if (!normalized || result.some((config) => config.id === normalized.id)) { + continue; + } + result.push(normalized); + } + return result; +} + +function normalizeBotGatewaySavedConfig(value: unknown, index = 0): BotGatewaySavedConfig | undefined { + if (!isPlainRecord(value)) { + return undefined; + } + const botGateway = normalizeBotGatewayRuntimeConfig(value.botGateway ?? value.bot_gateway ?? value.bot ?? value.config); + if (!botGateway?.enabled || !botGateway.platform || botGateway.platform === "none") { + return undefined; + } + const id = stringValue(value.id) || stringValue(value.savedConfigId) || stringValue(value.saved_config_id) || botGateway.integrationId || `bot-${index + 1}`; + const name = stringValue(value.name) || botGatewayPlatformLabel(botGateway.platform); + const updatedAt = stringValue(value.updatedAt) || stringValue(value.updated_at); + return { + botGateway, + id, + name, + ...(updatedAt ? { updatedAt } : {}) + }; +} + +export function botGatewaySavedConfigLabel(config: BotGatewaySavedConfig, translate: (value: string) => string): string { + const name = config.name.trim() || translate(botGatewayPlatformLabel(config.botGateway.platform)); + const platform = translate(botGatewayPlatformLabel(config.botGateway.platform)); + return name === platform ? name : `${name} / ${platform}`; +} + +function splitDraftLines(value: string): string[] { + return uniqueStrings(value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)); +} + +function isNumberDraftValid(value: string, min: number, max: number): boolean { + const numeric = Number(value.trim()); + return Number.isFinite(numeric) && numeric >= min && numeric <= max; +} + +function numberDraftValue(value: string, fallback: number, min: number, max: number): number { + const numeric = Number(value.trim()); + if (!Number.isFinite(numeric)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.round(numeric))); +} + export function normalizeCodexConfigFormat(_value: unknown): CodexProfileConfigFormat { return "separate_profile_files"; } @@ -2969,6 +3605,191 @@ export function normalizeProfileSurface(value: unknown): ProfileSurface { return value === "cli" || value === "app" ? value : "auto"; } +export function normalizeBotGatewayPlatform(value: unknown): string { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!normalized || normalized === "off" || normalized === "disabled") { + return "none"; + } + if (normalized === "lark") { + return "feishu"; + } + if (normalized === "dingding") { + return "dingtalk"; + } + if (["wechat", "weixin", "wx", "weixin-ilink", "weixin_ilink", "ilink"].includes(normalized)) { + return "weixin-ilink"; + } + if (["wecom", "wework", "wechat-work", "work-weixin", "enterprise-wechat"].includes(normalized)) { + return "wecom"; + } + return botGatewayPlatformOptions.some((option) => option.value === normalized) ? normalized : "none"; +} + +export function normalizeBotGatewayAuthType(platform: string, value: unknown): string { + const normalized = typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : ""; + if (!platform || platform === "none") { + return ""; + } + if (!normalized || normalized === "default" || normalized === "auto" || normalized === "webhook" || normalized === "webhook_secret" || normalized === "outgoing_webhook") { + return defaultBotGatewayAuthType(platform); + } + if (normalized === "appsecret") { + return authTypeAllowedForPlatform(platform, "app_secret"); + } + if (normalized === "bottoken" || normalized === "token") { + return authTypeAllowedForPlatform(platform, "bot_token"); + } + if (normalized === "oauth" || normalized === "oauth_2") { + return authTypeAllowedForPlatform(platform, "oauth2"); + } + if (["qr", "qr_login", "qrcode", "qr_code"].includes(normalized)) { + return authTypeAllowedForPlatform(platform, "qr_login"); + } + return authTypeAllowedForPlatform(platform, normalized); +} + +function defaultBotGatewayAuthType(platform: string): string { + return botGatewayDefaultAuthType(platform); +} + +function authTypeAllowedForPlatform(platform: string, value: string): string { + return botGatewayAuthSpecsForPlatform(platform).some((option) => option.value === value) + ? value + : defaultBotGatewayAuthType(platform); +} + +function websocketBotGatewayIntegrationConfig(platform: string, value: Record): Record { + const config = sanitizeBotGatewayRecord(value); + delete config.transport; + delete config.sendMode; + const transport = botGatewayWebSocketTransport(platform); + return transport ? { ...config, transport } : config; +} + +function botGatewayWebSocketTransport(platform: string): string { + if (!platform || platform === "none") { + return ""; + } + return platform === "slack" ? "socket" : "websocket"; +} + +function sanitizeBotGatewayRecord(value: Record | undefined): Record { + const result: Record = {}; + if (!isPlainRecord(value)) { + return result; + } + for (const [key, rawValue] of Object.entries(value)) { + if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) { + continue; + } + result[key] = rawValue; + } + return result; +} + +function isWebhookRelatedBotGatewayKey(key: string): boolean { + const normalized = key.trim().toLowerCase().replace(/[_-]+/g, ""); + return normalized.includes("webhook") || normalized === "sendmode"; +} + +export function normalizeBotGatewayRuntimeConfig(value: unknown): BotGatewayRuntimeConfig | undefined { + if (!isPlainRecord(value)) { + return undefined; + } + const record = value as Partial & Record; + const handoffRecord: Record = isPlainRecord(record.handoff) ? record.handoff : {}; + const platform = normalizeBotGatewayPlatform(record.platform); + const conversationRef = normalizeBotGatewayConversationRef(record.conversationRef ?? record.conversation_ref ?? record.conversation); + const config: BotGatewayRuntimeConfig = { + ...fallbackConfig.botGateway, + ...record, + acknowledgeEvents: typeof record.acknowledgeEvents === "boolean" ? record.acknowledgeEvents : fallbackConfig.botGateway.acknowledgeEvents, + args: Array.isArray(record.args) ? record.args.filter((item): item is string => typeof item === "string") : fallbackConfig.botGateway.args, + authType: normalizeBotGatewayAuthType(platform, typeof record.authType === "string" ? record.authType : fallbackConfig.botGateway.authType), + autoStartIntegration: typeof record.autoStartIntegration === "boolean" ? record.autoStartIntegration : fallbackConfig.botGateway.autoStartIntegration, + command: typeof record.command === "string" ? record.command : fallbackConfig.botGateway.command, + createIntegration: typeof record.createIntegration === "boolean" ? record.createIntegration : fallbackConfig.botGateway.createIntegration, + credentials: sanitizeBotGatewayRecord(isPlainRecord(record.credentials) ? record.credentials : {}), + cwd: typeof record.cwd === "string" ? record.cwd : fallbackConfig.botGateway.cwd, + enabled: typeof record.enabled === "boolean" ? record.enabled : fallbackConfig.botGateway.enabled, + forwardAllAgentMessages: typeof record.forwardAllAgentMessages === "boolean" ? record.forwardAllAgentMessages : fallbackConfig.botGateway.forwardAllAgentMessages, + handoff: { + ...fallbackConfig.botGateway.handoff, + ...handoffRecord, + enabled: typeof handoffRecord.enabled === "boolean" ? handoffRecord.enabled : fallbackConfig.botGateway.handoff.enabled, + idleSeconds: Number.isFinite(Number(handoffRecord.idleSeconds)) + ? numberDraftValue(String(handoffRecord.idleSeconds), fallbackConfig.botGateway.handoff.idleSeconds, 30, 86_400) + : fallbackConfig.botGateway.handoff.idleSeconds, + phoneBluetoothTargets: Array.isArray(handoffRecord.phoneBluetoothTargets) + ? handoffRecord.phoneBluetoothTargets.filter((item): item is string => typeof item === "string").slice(0, 1) + : fallbackConfig.botGateway.handoff.phoneBluetoothTargets, + phoneWifiTargets: Array.isArray(handoffRecord.phoneWifiTargets) + ? handoffRecord.phoneWifiTargets.filter((item): item is string => typeof item === "string").slice(0, 1) + : fallbackConfig.botGateway.handoff.phoneWifiTargets, + screenLock: typeof handoffRecord.screenLock === "boolean" ? handoffRecord.screenLock : fallbackConfig.botGateway.handoff.screenLock, + userIdle: typeof handoffRecord.userIdle === "boolean" ? handoffRecord.userIdle : fallbackConfig.botGateway.handoff.userIdle + }, + integrationConfig: websocketBotGatewayIntegrationConfig(platform, isPlainRecord(record.integrationConfig) ? record.integrationConfig : {}), + integrationId: typeof record.integrationId === "string" ? record.integrationId : fallbackConfig.botGateway.integrationId, + platform, + pollIntervalMs: Number.isFinite(Number(record.pollIntervalMs)) + ? numberDraftValue(String(record.pollIntervalMs), fallbackConfig.botGateway.pollIntervalMs, 500, 60_000) + : fallbackConfig.botGateway.pollIntervalMs, + requestTimeoutMs: Number.isFinite(Number(record.requestTimeoutMs)) + ? numberDraftValue(String(record.requestTimeoutMs), fallbackConfig.botGateway.requestTimeoutMs, 1000, 3_600_000) + : fallbackConfig.botGateway.requestTimeoutMs, + sourceDir: typeof record.sourceDir === "string" ? record.sourceDir : fallbackConfig.botGateway.sourceDir, + startupTimeoutMs: Number.isFinite(Number(record.startupTimeoutMs)) + ? numberDraftValue(String(record.startupTimeoutMs), fallbackConfig.botGateway.startupTimeoutMs, 1000, 120_000) + : fallbackConfig.botGateway.startupTimeoutMs, + stateDir: typeof record.stateDir === "string" ? record.stateDir : fallbackConfig.botGateway.stateDir, + tenantId: typeof record.tenantId === "string" ? record.tenantId : fallbackConfig.botGateway.tenantId + }; + if (conversationRef) { + config.conversationRef = conversationRef; + } else { + delete config.conversationRef; + } + return config; +} + +function normalizeBotGatewayConversationRef(value: unknown): BotGatewayRuntimeConfig["conversationRef"] { + if (!isPlainRecord(value)) { + return undefined; + } + const gatewayConversationId = typeof value.gatewayConversationId === "string" + ? value.gatewayConversationId + : typeof value.gateway_conversation_id === "string" + ? value.gateway_conversation_id + : ""; + const platformConversationId = typeof value.platformConversationId === "string" + ? value.platformConversationId + : typeof value.platform_conversation_id === "string" + ? value.platform_conversation_id + : typeof value.conversationId === "string" + ? value.conversationId + : typeof value.chatId === "string" + ? value.chatId + : typeof value.channelId === "string" + ? value.channelId + : ""; + if (!gatewayConversationId.trim() && !platformConversationId.trim()) { + return undefined; + } + const type = value.type === "group" || value.type === "channel" || value.type === "thread" ? value.type : "dm"; + const threadId = typeof value.threadId === "string" + ? value.threadId + : typeof value.thread_id === "string" + ? value.thread_id + : ""; + return { + ...(gatewayConversationId.trim() ? { gatewayConversationId: gatewayConversationId.trim() } : {}), + ...(platformConversationId.trim() ? { platformConversationId: platformConversationId.trim() } : {}), + ...(threadId.trim() ? { threadId: threadId.trim() } : {}), + type + }; +} + export function profileSummaryItems( profile: ProfileConfig, config: AppConfig, @@ -2978,6 +3799,15 @@ export function profileSummaryItems( const envSummaryItems = envCount > 0 ? [{ label: t("Environment variables"), value: String(envCount) }] : []; + const savedBot = profile.botConfigId + ? config.botConfigs.find((item) => item.id === profile.botConfigId) + : undefined; + const resolvedBotGateway = savedBot?.botGateway ?? profile.botGateway ?? config.botGateway; + const botSummaryItems = resolvedBotGateway?.enabled && resolvedBotGateway.platform !== "none" + ? [{ label: t("Bot"), value: `${t("Enabled")} (${savedBot ? botGatewaySavedConfigLabel(savedBot, t) : t(botGatewayPlatformLabel(resolvedBotGateway.platform))})` }] + : profile.botGateway + ? [{ label: t("Bot"), value: t("Disabled") }] + : []; const smallFastModel = profile.smallFastModel?.trim() || ""; const modelValue = profile.model.trim() ? profileModelDisplayValue( @@ -3006,6 +3836,7 @@ export function profileSummaryItems( ) : t("Keep Claude Code default") }, + ...botSummaryItems, ...envSummaryItems ]; } @@ -3014,6 +3845,7 @@ export function profileSummaryItems( { label: t("Model"), value: modelValue }, { label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" }, { label: t("Show all sessions"), value: profile.showAllSessions ? t("Enabled") : t("Disabled") }, + ...botSummaryItems, ...envSummaryItems ]; } @@ -3024,9 +3856,13 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro const scope = normalizeProfileScope(profile.scope); const surface = normalizeProfileSurface(profile.surface); const env = isPlainRecord(profile.env) ? stringRecordValue(profile.env) : {}; + const botGateway = normalizeBotGatewayRuntimeConfig(profile.botGateway); + const botConfigId = stringValue(profile.botConfigId); if (profile.agent === "claude-code") { return { agent: "claude-code", + ...(botConfigId ? { botConfigId } : {}), + ...(botGateway ? { botGateway } : {}), enabled: profile.enabled, env, id: profile.id || `profile-${index + 1}`, @@ -3040,6 +3876,8 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro } return { agent: "codex", + ...(botConfigId ? { botConfigId } : {}), + ...(botGateway ? { botGateway } : {}), cliMiddleware: true, codexCliPath: "", codexHome: "", @@ -3114,6 +3952,8 @@ export function normalizeUnknownProfileItem(value: Record, inde } return normalizeProfileItem({ agent, + botConfigId: typeof value.botConfigId === "string" ? value.botConfigId : typeof value.bot_config_id === "string" ? value.bot_config_id : undefined, + botGateway: normalizeBotGatewayRuntimeConfig(value.botGateway ?? value.bot_gateway ?? value.bot), cliMiddleware: typeof value.cliMiddleware === "boolean" ? value.cliMiddleware : undefined, codexCliPath: typeof value.codexCliPath === "string" ? value.codexCliPath : undefined, codexHome: typeof value.codexHome === "string" ? value.codexHome : undefined, @@ -4507,6 +5347,8 @@ export function normalizeConfig(config: AppConfig): AppConfig { ...(config.agent || {}), mcpServers: Array.isArray(config.agent?.mcpServers) ? normalizeMcpServers(config.agent.mcpServers) : fallbackConfig.agent.mcpServers }, + botConfigs: normalizeBotGatewaySavedConfigs(config.botConfigs), + botGateway: normalizeBotGatewayRuntimeConfig(config.botGateway) ?? fallbackConfig.botGateway, gateway: { ...fallbackConfig.gateway, ...(config.gateway || {}) @@ -6377,8 +7219,7 @@ export function sanitizeConfigId(value: string): string { export function buildExtensionList(config: AppConfig): ExtensionListItem[] { return [ ...(config.plugins ?? []).map((item, index) => extensionListItem("plugins", item, index)), - ...(config.providerPlugins ?? []).map((item, index) => extensionListItem("providerPlugins", item, index)), - ...(config.virtualModelProfiles ?? []).map((item, index) => extensionListItem("virtualModelProfiles", item, index)) + ...(config.providerPlugins ?? []).map((item, index) => extensionListItem("providerPlugins", item, index)) ]; } @@ -6483,32 +7324,17 @@ export function extensionListItem(source: ExtensionSource, item: unknown, index: }; } - if (source === "providerPlugins") { - const enabled = item.enabled !== false; - return { - canConfigure: false, - canToggle: true, - capability: providerPluginCapability(item), - enabled, - index, - name: stringValue(item.key) || `provider-plugin-${index + 1}`, - source, - status: enabled ? "enabled" : "disabled", - target: stringValue(item.providerName) || stringValue(item.provider) || "All providers" - }; - } - const enabled = item.enabled !== false; return { canConfigure: false, canToggle: true, - capability: virtualModelCapability(item), + capability: providerPluginCapability(item), enabled, index, - name: stringValue(item.displayName) || stringValue(item.key) || stringValue(item.id) || `fusion-${index + 1}`, + name: stringValue(item.key) || `provider-plugin-${index + 1}`, source, status: enabled ? "enabled" : "disabled", - target: virtualModelTarget(item) + target: stringValue(item.providerName) || stringValue(item.provider) || "All providers" }; } @@ -6541,7 +7367,7 @@ export function wrapperPluginCapability(item: Record): string { if (providerPlugins > 0) capabilities.push(`${providerPlugins} provider ${providerPlugins === 1 ? "plugin" : "plugins"}`); const virtualModels = isPlainRecord(coreGateway) && Array.isArray(coreGateway.virtualModelProfiles) ? coreGateway.virtualModelProfiles.length : 0; - if (virtualModels > 0) capabilities.push(`${virtualModels} virtual ${virtualModels === 1 ? "model" : "models"}`); + if (virtualModels > 0) capabilities.push(`${virtualModels} Fusion ${virtualModels === 1 ? "profile" : "profiles"}`); if (isClaudeDesignPluginConfig(item)) { const routing = readClaudeDesignRoutingConfig(item.config); @@ -6583,25 +7409,6 @@ export function providerPluginCapability(item: Record): string return capabilities.join(", "); } -export function virtualModelCapability(item: Record): string { - const tools = Array.isArray(item.tools) ? item.tools.length : 0; - const execution = isPlainRecord(item.execution) ? stringValue(item.execution.mode) : undefined; - return ["Fusion", execution || "decorate_only", `${tools} tools`].join(", "); -} - -export function virtualModelTarget(item: Record): string { - const match = isPlainRecord(item.match) ? item.match : {}; - const exactAliases = stringListValue(match.exactAliases); - const prefixes = stringListValue(match.prefixes); - const suffixes = stringListValue(match.suffixes); - const parts = [ - ...exactAliases.map((value) => `=${value}`), - ...prefixes.map((value) => `${value}*`), - ...suffixes.map((value) => `*${value}`) - ]; - return parts.length ? parts.join(", ") : "No match"; -} - export function createExtensionInstallDraft(): ExtensionInstallDraft { return { dependencies: [], diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index ec24c45e..8225ea02 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -1,5 +1,6 @@ export {}; +import type * as React from "react"; import type { AgentAnalysisFilter, AgentAnalysisSnapshot, @@ -7,6 +8,12 @@ import type { AppInfo, AppUpdateStatus, ApiKeyConfig, + BotGatewayQrLoginCancelRequest, + BotGatewayQrLoginCancelResult, + BotGatewayQrLoginStartRequest, + BotGatewayQrLoginStartResult, + BotGatewayQrLoginWaitRequest, + BotGatewayQrLoginWaitResult, ClaudeAppGatewayApplyResult, GatewayMcpServerConfig, GatewayMcpToolInfo, @@ -39,10 +46,24 @@ import type { } from "../../shared/app"; declare global { + namespace JSX { + interface IntrinsicElements { + webview: React.DetailedHTMLProps, HTMLElement> & { + allowpopups?: boolean | string; + partition?: string; + preload?: string; + src?: string; + title?: string; + webpreferences?: string; + }; + } + } + interface Window { ccr?: { applyClaudeAppGateway: (config?: AppConfig) => Promise; applyProfile: () => Promise; + cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => Promise; clearProxyNetworkCaptures: () => Promise; closeTray: () => Promise; detectProviderIcon: (request: ProviderIconDetectionRequest) => Promise; @@ -80,11 +101,13 @@ declare global { setTrayDetailOpen: (open: boolean, provider?: string) => Promise; showMainWindow: () => Promise; startGateway: () => Promise; + startBotGatewayQrLogin: (request: BotGatewayQrLoginStartRequest) => Promise; stopGateway: () => Promise; testProviderAccountConnector: (request: ProviderAccountTestRequest) => Promise; updateCheck: () => Promise; updateDownload: () => Promise; updateInstall: () => Promise; + waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => Promise; onBeforeQuit: (callback: () => void) => () => void; onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void; onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => () => void; diff --git a/src/shared/app.ts b/src/shared/app.ts index e80cea0c..b75da9d7 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -740,6 +740,8 @@ export type CodexProfileConfig = { export type ProfileConfig = { agent: ProfileClientKind; + botConfigId?: string; + botGateway?: BotGatewayRuntimeConfig; configFile?: string; cliMiddleware?: boolean; codexCliPath?: string; @@ -877,6 +879,94 @@ export type ProxyCertificateStatus = { trusted: boolean; }; +export type BotGatewayHandoffConfig = { + enabled: boolean; + idleSeconds: number; + phoneBluetoothTargets: string[]; + phoneWifiTargets: string[]; + screenLock: boolean; + userIdle: boolean; +}; + +export type BotGatewayConversationConfig = { + gatewayConversationId?: string; + platformConversationId?: string; + threadId?: string; + type: "dm" | "group" | "channel" | "thread"; +}; + +export type BotGatewayRuntimeConfig = { + acknowledgeEvents: boolean; + args: string[]; + authType: string; + autoStartIntegration: boolean; + command: string; + conversationRef?: BotGatewayConversationConfig; + createIntegration: boolean; + credentials: Record; + cwd: string; + enabled: boolean; + forwardAllAgentMessages: boolean; + handoff: BotGatewayHandoffConfig; + integrationConfig: Record; + integrationId: string; + platform: string; + pollIntervalMs: number; + requestTimeoutMs: number; + sourceDir: string; + startupTimeoutMs: number; + stateDir: string; + tenantId: string; +}; + +export type BotGatewaySavedConfig = { + botGateway: BotGatewayRuntimeConfig; + id: string; + name: string; + updatedAt?: string; +}; + +export type BotGatewayQrLoginStartRequest = { + config: BotGatewaySavedConfig; + force?: boolean; +}; + +export type BotGatewayQrLoginStartResult = { + botConfigId: string; + expiresAt: string; + integrationId: string; + message: string; + platform: string; + qrCodeUrl: string; + sessionId: string; + stateDir: string; + tenantId: string; +}; + +export type BotGatewayQrLoginWaitRequest = { + sessionId: string; + timeoutMs?: number; + verifyCode?: string; +}; + +export type BotGatewayQrLoginWaitResult = { + confirmed: boolean; + integrationId: string; + message: string; + sessionId: string; + stateDir: string; + status: string; + tenantId: string; +}; + +export type BotGatewayQrLoginCancelRequest = { + sessionId: string; +}; + +export type BotGatewayQrLoginCancelResult = { + canceled: boolean; +}; + export type AppConfig = { APIKEY: string; APIKEYS: ApiKeyConfig[]; @@ -888,6 +978,8 @@ export type AppConfig = { Router: RouterConfig; agent: GatewayAgentConfig; autoStart: boolean; + botConfigs: BotGatewaySavedConfig[]; + botGateway: BotGatewayRuntimeConfig; gateway: GatewayRuntimeConfig; preferredProvider: string; plugins: GatewayPluginConfig[]; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index a1be2813..208c73c6 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -24,6 +24,9 @@ export const IPC_CHANNELS = { appOpenProfile: "ccr:app:open-profile", appApplyClaudeAppGateway: "ccr:app:apply-claude-app-gateway", appApplyProfile: "ccr:app:apply-profile", + appBotGatewayQrLoginCancel: "ccr:app:bot-gateway-qr-login-cancel", + appBotGatewayQrLoginStart: "ccr:app:bot-gateway-qr-login-start", + appBotGatewayQrLoginWait: "ccr:app:bot-gateway-qr-login-wait", appProbeProvider: "ccr:app:probe-provider", appProviderDeepLink: "ccr:app:provider-deep-link", appGetPluginMarketplace: "ccr:app:get-plugin-marketplace",