feat(robot): add guided hotspot binding flow
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user