feat(robot): add guided hotspot binding flow

This commit is contained in:
2026-08-16 14:27:43 +08:00
parent 54443232dd
commit b7a1590ca1
7 changed files with 855 additions and 18 deletions

View File

@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { shell } from 'electron';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
import { WORKS_SQUARE_CONFIG } from '../works-config';
@@ -16,6 +17,8 @@ const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/;
const CATALOG_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/;
const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MAX_RETRY_AFTER_SECONDS = 2;
const PROVISIONING_PORTAL_URL = 'http://192.168.4.1/';
const GUIDED_HOTSPOT_BINDING_ENABLED = process.env.NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING === '1';
type TokenGetter = typeof getValidWorksSquareAccessToken;
@@ -25,6 +28,8 @@ export type AiHardwareRouteDependencies = {
apiBaseUrl?: string;
randomUuid?: () => string;
timeoutMs?: number;
guidedHotspotBinding?: boolean;
openExternal?: (url: string) => Promise<void>;
};
class SafeRouteError extends Error {
@@ -386,7 +391,18 @@ function safeUpstreamError(payload: unknown, status: number): { code: string; er
? detail.error_code
: null;
if (!code) return safeErrorForStatus(status);
return { code, error: SAFE_UPSTREAM_ERRORS[code], retryable: detail?.retryable === true };
return {
code,
error: SAFE_UPSTREAM_ERRORS[code],
retryable: code === 'ai_hardware_activation_code_invalid' ? false : detail?.retryable === true,
};
}
function hasRequestBody(req: IncomingMessage): boolean {
const contentLength = req.headers['content-length'];
const normalizedLength = Array.isArray(contentLength) ? contentLength[0] : contentLength;
return (normalizedLength !== undefined && normalizedLength !== '0')
|| req.headers['transfer-encoding'] !== undefined;
}
function sendFailure(
@@ -424,6 +440,8 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
const createUuid = dependencies.randomUuid ?? randomUUID;
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const guidedHotspotBinding = dependencies.guidedHotspotBinding ?? GUIDED_HOTSPOT_BINDING_ENABLED;
const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url));
return async function handleAiHardwareRoutes(
req: IncomingMessage,
@@ -439,14 +457,49 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
let operationId: string | undefined;
try {
const method = req.method ?? 'GET';
if (method === 'GET' && url.pathname === `${LOCAL_ROOT}/provisioning-capabilities`) {
if (url.search || hasRequestBody(req)) {
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
}
sendJson(res, 200, {
success: true,
data: { guided_hotspot_binding: guidedHotspotBinding },
});
return true;
}
if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-portal/open`) {
if (url.search) {
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
}
const input = await readBoundedJson(req);
ensureExactKeys(input, new Set());
if (!guidedHotspotBinding) {
throw new SafeRouteError(
403,
'AI_HARDWARE_PROVISIONING_DISABLED',
'AI hardware guided provisioning is not enabled',
);
}
try {
await openExternal(PROVISIONING_PORTAL_URL);
} catch {
throw new SafeRouteError(
502,
'AI_HARDWARE_PORTAL_OPEN_FAILED',
'AI hardware provisioning portal could not be opened',
);
}
sendJson(res, 200, { success: true, data: { opened: true } });
return true;
}
let upstreamPath: string;
let body: Record<string, unknown> | undefined;
let project: (value: unknown) => Record<string, unknown> | null;
let requireEtag = false;
let ifMatch: number | undefined;
let expectedStatus: number;
const method = req.method ?? 'GET';
if (method === 'GET' && url.pathname === LOCAL_ROOT) {
upstreamPath = UPSTREAM_ROOT;
project = projectOverview;