Files
NianToB/electron/yinian/http-control-plane.ts

839 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
YinianAuthSession,
YinianConfigSnapshot,
YinianHotel,
YinianImageCaptcha,
YinianNotificationChannel,
YinianSkillEntitlement,
YinianLoginWithPasswordInput,
YinianLoginWithSmsInput,
YinianLocalSkill,
YinianServerStatus,
YinianSessionState,
YinianSkillSyncResult,
YinianUser,
} from '../../shared/yinian';
import { randomUUID } from 'node:crypto';
import type { YinianControlPlane } from './control-plane';
import { getYinianStorage, type YinianStorage } from './storage';
interface HttpYinianControlPlaneOptions {
apiBaseUrl: string;
storage?: YinianStorage;
}
type JsonObject = Record<string, unknown>;
const DEFAULT_ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000;
const SKILL_CATEGORIES: YinianSkillEntitlement['category'][] = [
'ota-monitoring',
'reporting',
'guest-comm',
'ops-automation',
];
const SKILL_TRIGGERS: Array<YinianSkillEntitlement['triggers'][number]> = [
'manual',
'scheduled',
'webhook',
'reply',
];
const DEFAULT_OAUTH_CLIENT_ID = 'customPC';
const DEFAULT_OAUTH_CLIENT_SECRET = 'customPC';
const DEFAULT_OAUTH_SCOPE = 'server';
const DEFAULT_CONFIG_SYNC_PATH = '/config/sync';
const DEFAULT_SKILLS_MANIFEST_PATH = '/skills/manifest';
export class HttpYinianControlPlane implements YinianControlPlane {
private readonly apiBaseUrl: string;
private readonly storage: YinianStorage;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private currentHotelId: string | null = null;
private localSkills: YinianLocalSkill[] = [];
constructor(options: HttpYinianControlPlaneOptions) {
this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, '');
this.storage = options.storage ?? getYinianStorage();
}
async getServerStatus(): Promise<YinianServerStatus> {
const checkedAt = Date.now();
try {
const health = await this.request<JsonObject>('/health', { auth: false });
return {
mode: 'http',
apiBaseUrl: this.apiBaseUrl,
reachable: true,
checkedAt,
serverTime: readNumber(health, 'serverTime', 'server_time'),
version: readString(health, 'version', 'appVersion', 'app_version'),
message: readString(health, 'message', 'status') ?? '服务端连接正常',
};
} catch (error) {
return {
mode: 'http',
apiBaseUrl: this.apiBaseUrl,
reachable: false,
checkedAt,
message: error instanceof Error ? error.message : String(error),
};
}
}
async createImageCaptcha(randomStr = createRandomString()): Promise<YinianImageCaptcha> {
const path = `/auth/code/image?randomStr=${encodeURIComponent(randomStr)}`;
const requestUrl = `${this.apiBaseUrl}${path}`;
const response = await fetch(requestUrl, {
method: 'GET',
headers: {
'X-YINIAN-App-Version': '0.1.0',
},
}).catch((error: unknown) => {
throw new Error(describeNetworkError(error, requestUrl));
});
if (!response.ok) {
const data = await response.json().catch(() => ({})) as JsonObject;
const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`;
throw new Error(message);
}
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => ({})) as JsonObject;
return normalizeImageCaptcha(data, randomStr);
}
const imageBytes = Buffer.from(await response.arrayBuffer());
return {
randomStr,
imageBase64: imageBytes.toString('base64'),
mimeType: contentType.split(';')[0] || 'image/png',
raw: {
contentType,
size: imageBytes.byteLength,
},
};
}
async restoreSession(): Promise<YinianSessionState> {
const persisted = await this.storage.getSession();
if (!persisted || persisted.mode !== 'http') {
return { authenticated: false };
}
this.refreshToken = persisted.refreshToken ?? null;
this.currentHotelId = persisted.currentHotelId;
if (this.refreshToken) {
try {
return await this.refreshSession();
} catch {
await this.storage.clearSession();
this.refreshToken = null;
this.currentHotelId = null;
return { authenticated: false };
}
}
// Access tokens intentionally remain memory-only. Until refresh-token
// exchange is implemented, restore returns the persisted tenant snapshot so
// the shell can recover UI context without exposing provider/model keys.
return {
authenticated: true,
user: persisted.user,
hotels: persisted.hotels,
currentHotelId: persisted.currentHotelId,
accessTokenExpiresAt: persisted.accessTokenExpiresAt,
};
}
async getSessionState(): Promise<YinianSessionState> {
if (!this.accessToken) return this.restoreSession();
const persisted = await this.storage.getSession();
if (persisted?.mode === 'http') {
return {
authenticated: true,
user: persisted.user,
hotels: persisted.hotels,
currentHotelId: persisted.currentHotelId,
accessTokenExpiresAt: persisted.accessTokenExpiresAt,
};
}
return { authenticated: false };
}
async loginWithSms(input: YinianLoginWithSmsInput): Promise<YinianAuthSession> {
void input;
throw new Error('当前组织空间端仅支持账号密码登录');
}
async loginWithPassword(input: YinianLoginWithPasswordInput): Promise<YinianAuthSession> {
const response = await this.requestOAuthToken({
grant_type: 'password',
username: input.account,
account: input.account,
password: input.password,
code: input.captchaCode,
captchaCode: input.captchaCode,
captcha_code: input.captchaCode,
randomStr: input.randomStr,
random_str: input.randomStr,
});
return this.applyLoginResponse(response, input.account);
}
async logout(): Promise<YinianSessionState> {
const hotelId = this.currentHotelId;
this.accessToken = null;
this.refreshToken = null;
this.currentHotelId = null;
if (hotelId) {
await this.storage.clearConfig(hotelId);
await this.storage.clearSkillRegistry(hotelId);
}
await this.storage.clearSession();
this.localSkills = [];
return { authenticated: false };
}
async switchHotel(hotelId: string): Promise<YinianAuthSession> {
const session = await this.getSessionState();
if (!session.authenticated) throw new Error('请先登录');
const hotel = session.hotels.find((item) => item.id === hotelId);
if (!hotel) throw new Error('工作空间不存在或未开通');
this.currentHotelId = hotelId;
const nextSession = { ...session, currentHotelId: hotelId };
await this.persistSession(nextSession);
const registry = await this.storage.getSkillRegistry(hotelId);
this.localSkills = registry && 'skills' in registry ? registry.skills : [];
return nextSession;
}
async getConfigSnapshot(): Promise<YinianConfigSnapshot> {
const session = await this.getSessionState();
if (!session.authenticated) throw new Error('请先登录');
const hotelId = session.currentHotelId;
const configPath = buildScopedEndpoint(
process.env.YINIAN_CONFIG_SYNC_PATH?.trim() || DEFAULT_CONFIG_SYNC_PATH,
hotelId,
);
let response: JsonObject;
try {
response = await this.request<JsonObject>(configPath);
} catch (error) {
if (isMissingServerResourceError(error)) {
const snapshot = createDefaultConfigSnapshot(session);
await this.storage.setConfig(snapshot);
return snapshot;
}
throw error;
}
const hotel = normalizeHotel(response.hotel) ?? session.hotels.find((item) => item.id === hotelId) ?? session.hotels[0];
const snapshot = {
serverTime: readNumber(response, 'serverTime', 'server_time') ?? Date.now(),
user: session.user,
hotel,
hotels: session.hotels,
entitlements: normalizeEntitlements(readManifestItems(response, 'entitlements')),
notificationChannels: normalizeNotificationChannels(readArray(response, 'notificationChannels', 'notification_channels')),
featureFlags: normalizeBooleanRecord(readObject(response, 'featureFlags', 'feature_flags')),
uiPolicy: normalizeUiPolicy(readObject(response, 'uiPolicy', 'ui_policy')),
};
await this.storage.setConfig(snapshot);
return snapshot;
}
async syncSkills(): Promise<YinianSkillSyncResult> {
const session = await this.getSessionState();
if (!session.authenticated) throw new Error('请先登录');
const manifestPath = buildScopedEndpoint(
process.env.YINIAN_SKILLS_MANIFEST_PATH?.trim() || DEFAULT_SKILLS_MANIFEST_PATH,
session.currentHotelId,
);
const now = Date.now();
let response: JsonObject;
try {
response = await this.request<JsonObject>(manifestPath);
} catch (error) {
if (isMissingServerResourceError(error)) {
this.localSkills = [];
await this.storage.setSkillRegistry({
hotelId: session.currentHotelId,
updatedAt: now,
skills: [],
});
return {
hotelId: session.currentHotelId,
syncedAt: now,
skills: [],
};
}
throw error;
}
const skills = readManifestItems(response).filter(isObject).map((item) => normalizeLocalSkill(item, now));
const previousRegistry = await this.storage.getSkillRegistry(session.currentHotelId);
const previousSkills = previousRegistry && 'skills' in previousRegistry ? previousRegistry.skills : [];
this.localSkills = skills.map((skill) => {
const previous = previousSkills.find((item) => item.skillId === skill.skillId);
return {
...skill,
installedAt: previous?.installedAt ?? skill.installedAt,
status: !skill.enabled ? 'disabled' : previous?.version === skill.version ? 'skipped' : previous ? 'updated' : skill.status,
};
});
await this.storage.setSkillRegistry({
hotelId: session.currentHotelId,
updatedAt: now,
skills: this.localSkills,
});
return {
hotelId: session.currentHotelId,
syncedAt: now,
skills: this.localSkills,
};
}
async listLocalSkills(): Promise<YinianLocalSkill[]> {
const session = await this.getSessionState();
if (session.authenticated) {
const registry = await this.storage.getSkillRegistry(session.currentHotelId);
if (registry && 'skills' in registry) {
this.localSkills = registry.skills;
}
}
return this.localSkills;
}
async getSkillRegistry(hotelId?: string) {
return this.storage.getSkillRegistry(hotelId);
}
private async applyLoginResponse(response: JsonObject, accountHint?: string): Promise<YinianAuthSession> {
this.accessToken = readString(response, 'accessToken', 'access_token');
this.refreshToken = readString(response, 'refreshToken', 'refresh_token');
if (!this.accessToken) throw new Error('服务端未返回 access token');
const session = this.normalizeSessionFromPayload(response, accountHint) ?? await this.getSessionState();
if (!session.authenticated) throw new Error('登录成功但未获取到工作空间信息');
this.currentHotelId = session.currentHotelId;
await this.persistSession(session);
return session;
}
private async refreshSession(): Promise<YinianSessionState> {
if (!this.refreshToken) return { authenticated: false };
const response = await this.requestOAuthToken({
grant_type: 'refresh_token',
refreshToken: this.refreshToken,
refresh_token: this.refreshToken,
});
return this.applyLoginResponse(response);
}
private async persistSession(session: YinianAuthSession): Promise<void> {
await this.storage.setSession({
mode: 'http',
user: session.user,
hotels: session.hotels,
currentHotelId: session.currentHotelId,
accessTokenExpiresAt: session.accessTokenExpiresAt,
refreshToken: this.refreshToken ?? undefined,
updatedAt: Date.now(),
});
}
private async requestOAuthToken(fields: Record<string, string | undefined>): Promise<JsonObject> {
const clientId = process.env.YINIAN_AUTH_CLIENT_ID?.trim() || DEFAULT_OAUTH_CLIENT_ID;
const headers = createOAuthHeaders(clientId);
const clientBodyFields = headers.Authorization
? {}
: { clientId, client_id: clientId };
return this.request<JsonObject>('/auth/oauth2/token', {
method: 'POST',
auth: false,
form: {
scope: process.env.YINIAN_AUTH_SCOPE?.trim() || DEFAULT_OAUTH_SCOPE,
...clientBodyFields,
...fields,
},
headers,
});
}
private normalizeSessionFromPayload(payload: JsonObject, accountHint?: string): YinianAuthSession | null {
const tokenPayload = this.accessToken ? parseJwtPayload(this.accessToken) : undefined;
const sessionPayload = {
...(tokenPayload ?? {}),
...payload,
...(readObject(payload, 'session') ?? {}),
};
const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo')
?? extractUserPayload(sessionPayload, accountHint);
const user = normalizeUser(userValue);
const hotelsValue = readArray(sessionPayload, 'hotels', 'workspaces', 'businesses', 'tenants');
const hotels = normalizeHotels(hotelsValue);
if (hotels.length === 0) {
hotels.push(createDefaultWorkspace(sessionPayload, userValue, user));
}
const currentHotelId = readString(sessionPayload, 'currentHotelId', 'current_hotel_id', 'currentWorkspaceId', 'current_workspace_id', 'workspaceId', 'workspace_id')
?? this.currentHotelId
?? hotels[0]?.id;
if (!currentHotelId) return null;
return {
authenticated: true,
user,
hotels,
currentHotelId,
accessTokenExpiresAt: readAccessTokenExpiresAt(sessionPayload),
};
}
private async request<T>(
path: string,
options: { method?: string; body?: JsonObject; form?: Record<string, string | string[] | undefined>; auth?: boolean; headers?: Record<string, string> } = {},
): Promise<T> {
const headers: Record<string, string> = {
'X-YINIAN-App-Version': '0.1.0',
...(options.headers ?? {}),
};
const body = options.form
? createFormBody(options.form)
: options.body
? JSON.stringify(options.body)
: undefined;
if (options.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
if (options.form && !headers['Content-Type']) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (options.auth !== false && this.accessToken) {
headers.Authorization = `Bearer ${this.accessToken}`;
}
const requestUrl = `${this.apiBaseUrl}${path}`;
const response = await fetch(requestUrl, {
method: options.method ?? 'GET',
headers,
body,
}).catch((error: unknown) => {
throw new Error(describeNetworkError(error, requestUrl));
});
const data = await response.json().catch(() => ({})) as JsonObject;
if (!response.ok) {
const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`;
throw new Error(message);
}
const payload = unwrapApiPayload(data);
return payload as T;
}
}
function createDevicePayload(): JsonObject {
return {
device_id: 'dev_local_electron',
platform: process.platform,
app_version: '0.1.0',
machine_name: '智念助手',
};
}
function describeNetworkError(error: unknown, requestUrl: string): string {
const hostname = safeHostname(requestUrl);
const code = readNestedErrorString(error, 'code');
const syscall = readNestedErrorString(error, 'syscall');
const detail = code ? `${code}${syscall ? `/${syscall}` : ''}` : '';
if (code === 'ENOTFOUND') {
return `无法连接服务端:域名 ${hostname} 解析失败${detail}。请检查服务端地址、网络或 VPN。`;
}
if (code === 'ECONNREFUSED') {
return `无法连接服务端:${hostname} 拒绝连接${detail}。请确认服务端已启动并允许当前网络访问。`;
}
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') {
return `无法连接服务端:连接 ${hostname} 超时${detail}。请检查网络、代理或 VPN。`;
}
if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || code === 'DEPTH_ZERO_SELF_SIGNED_CERT') {
return `无法连接服务端:${hostname} 的 HTTPS 证书校验失败${detail}。请检查证书配置。`;
}
return `无法连接服务端:请求 ${hostname} 失败${detail}。请检查网络、代理、VPN 或服务端地址。`;
}
function safeHostname(requestUrl: string): string {
try {
return new URL(requestUrl).hostname;
} catch {
return requestUrl;
}
}
function readNestedErrorString(error: unknown, key: string): string | undefined {
const visited = new Set<unknown>();
let current: unknown = error;
while (isObject(current) && !visited.has(current)) {
visited.add(current);
const value = current[key];
if (typeof value === 'string' && value.trim()) return value;
current = current.cause;
}
return undefined;
}
function createOAuthHeaders(clientId: string): Record<string, string> {
const explicitBasic = process.env.YINIAN_AUTH_BASIC?.trim();
if (explicitBasic) {
return { Authorization: explicitBasic.startsWith('Basic ') ? explicitBasic : `Basic ${explicitBasic}` };
}
const clientSecret = process.env.YINIAN_AUTH_CLIENT_SECRET?.trim() || DEFAULT_OAUTH_CLIENT_SECRET;
if (!clientSecret) return {};
return {
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
};
}
function createFormBody(fields: Record<string, string | string[] | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(fields)) {
if (value == null || value === '') continue;
if (Array.isArray(value)) {
value.forEach((item) => params.append(key, item));
} else {
params.append(key, value);
}
}
return params.toString();
}
function createRandomString(): string {
return randomUUID();
}
function parseJwtPayload(token: string): JsonObject | undefined {
const [, rawPayload] = token.split('.');
if (!rawPayload) return undefined;
try {
const normalized = rawPayload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as unknown;
return isObject(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
function buildScopedEndpoint(endpoint: string, workspaceId: string): string {
const [rawPath, rawQuery = ''] = endpoint.split('?');
const path = rawPath
.replaceAll('{workspaceId}', encodeURIComponent(workspaceId))
.replaceAll('{workspace_id}', encodeURIComponent(workspaceId))
.replaceAll('{hotelId}', encodeURIComponent(workspaceId))
.replaceAll('{hotel_id}', encodeURIComponent(workspaceId))
.replaceAll('{tenantId}', encodeURIComponent(workspaceId))
.replaceAll('{tenant_id}', encodeURIComponent(workspaceId));
const params = new URLSearchParams(rawQuery);
const hasExplicitScope = endpoint.includes('{workspaceId}')
|| endpoint.includes('{workspace_id}')
|| endpoint.includes('{hotelId}')
|| endpoint.includes('{hotel_id}')
|| endpoint.includes('{tenantId}')
|| endpoint.includes('{tenant_id}')
|| params.has('workspaceId')
|| params.has('workspace_id')
|| params.has('hotelId')
|| params.has('hotel_id')
|| params.has('tenantId')
|| params.has('tenant_id');
if (!hasExplicitScope) {
params.set('hotel_id', workspaceId);
}
const query = params.toString();
return query ? `${path}?${query}` : path;
}
function normalizeImageCaptcha(value: JsonObject, fallbackRandomStr: string): YinianImageCaptcha {
const payload = unwrapApiPayload(value);
const image = readString(payload, 'image', 'img', 'captcha', 'captchaImage', 'captcha_image');
const imageBase64 = readString(payload, 'imageBase64', 'image_base64', 'base64');
return {
randomStr: readString(payload, 'randomStr', 'random_str', 'key') ?? fallbackRandomStr,
image,
imageBase64,
mimeType: readString(payload, 'mimeType', 'mime_type') ?? (imageBase64 ? 'image/png' : undefined),
raw: payload,
};
}
function normalizeUser(value: unknown): YinianUser {
const object = isObject(value) ? value : {};
return {
id: readString(object, 'id', 'userId', 'user_id', 'userIdStr', 'user_id_str', 'sub', 'user_name') ?? 'user_unknown',
name: readString(object, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name', 'account') ?? '未命名用户',
phone: readString(object, 'phone', 'mobile', 'phoneNumber', 'phone_number'),
email: readString(object, 'email'),
avatar: readString(object, 'avatar'),
};
}
function extractUserPayload(sessionPayload: JsonObject, accountHint?: string): JsonObject {
const account = accountHint?.trim()
|| readString(sessionPayload, 'username', 'userName', 'user_name', 'account', 'phone', 'mobile')
|| '当前用户';
return {
id: readString(sessionPayload, 'userId', 'user_id', 'id', 'sub', 'user_name') ?? account,
name: readString(sessionPayload, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name') ?? account,
phone: readString(sessionPayload, 'phone', 'mobile', 'phoneNumber', 'phone_number'),
email: readString(sessionPayload, 'email'),
avatar: readString(sessionPayload, 'avatar'),
tenantId: readString(sessionPayload, 'tenantId', 'tenant_id', 'deptId', 'dept_id', 'orgId', 'org_id'),
};
}
function createDefaultWorkspace(sessionPayload: JsonObject, userPayload: JsonObject, user: YinianUser): YinianHotel {
const tenantId = readString(userPayload, 'tenantId', 'tenant_id')
?? readString(sessionPayload, 'tenantId', 'tenant_id')
?? readString(sessionPayload, 'deptId', 'dept_id', 'orgId', 'org_id')
?? 'default';
return {
id: `service_${tenantId}`,
name: readString(sessionPayload, 'tenantName', 'tenant_name', 'workspaceName', 'workspace_name', 'deptName', 'dept_name', 'orgName', 'org_name')
?? `${user.name}的组织空间`,
};
}
function createDefaultConfigSnapshot(session: YinianAuthSession): YinianConfigSnapshot {
return {
serverTime: Date.now(),
user: session.user,
hotel: session.hotels.find((item) => item.id === session.currentHotelId) ?? session.hotels[0],
hotels: session.hotels,
entitlements: [],
notificationChannels: [],
featureFlags: {},
uiPolicy: {
defaultPage: 'today',
showAdvancedSettings: false,
},
};
}
function isMissingServerResourceError(error: unknown): boolean {
return error instanceof Error && /No static resource|404|not found/i.test(error.message);
}
function normalizeHotels(value: unknown): YinianHotel[] {
if (!Array.isArray(value)) return [];
return value.map(normalizeHotel).filter((hotel): hotel is YinianHotel => Boolean(hotel));
}
function normalizeHotel(value: unknown): YinianHotel | null {
if (!isObject(value)) return null;
const hotel: YinianHotel = {
id: readString(value, 'id') ?? readString(value, 'hotelId', 'hotel_id', 'workspaceId', 'workspace_id', 'tenantId', 'tenant_id') ?? 'hotel_unknown',
name: readString(value, 'name') ?? '未命名工作空间',
brand: readString(value, 'brand'),
};
return hotel;
}
function normalizeLocalSkill(value: JsonObject, now: number): YinianLocalSkill {
const skillId = readString(
value,
'skillId',
'skill_id',
'appId',
'app_id',
'applicationId',
'application_id',
'code',
'skillCode',
'skill_code',
'id',
) ?? 'unknown-skill';
const version = readString(value, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0';
const status = readString(value, 'status', 'state');
const enabled = value.enabled !== false
&& value.kill_switch !== true
&& status !== 'disabled'
&& status !== 'disable'
&& status !== 'off';
return {
skillId,
name: readString(value, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId,
version,
enabled,
installedAt: now,
lastSyncedAt: now,
status: enabled ? 'installed' : 'disabled',
source: 'nianxx',
bundleSha256: readString(value, 'bundleSha256', 'bundle_sha256', 'sha256', 'checksum', 'digest', 'bundleHash', 'bundle_hash'),
};
}
function normalizeEntitlements(value: unknown[] | undefined): YinianSkillEntitlement[] {
if (!value) return [];
return value.filter(isObject).map((item) => {
const skillId = readString(item, 'skillId', 'skill_id', 'appId', 'app_id', 'applicationId', 'application_id', 'code', 'id') ?? 'unknown-skill';
const status = readString(item, 'status', 'state');
return {
skillId,
name: readString(item, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId,
version: readString(item, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0',
enabled: item.enabled !== false && status !== 'disabled' && status !== 'disable' && status !== 'off',
category: normalizeSkillCategory(readString(item, 'category')),
triggers: normalizeSkillTriggers(readArray(item, 'triggers')),
lastRunAt: readNumber(item, 'lastRunAt', 'last_run_at'),
};
});
}
function normalizeNotificationChannels(value: unknown[] | undefined): YinianNotificationChannel[] {
if (!value) return [];
return value.filter(isObject).map((item) => ({
id: readString(item, 'id') ?? 'channel_unknown',
kind: readString(item, 'kind') ?? 'unknown',
label: readString(item, 'label') ?? '未命名通知通道',
recipient: readString(item, 'recipient') ?? '',
enabled: item.enabled !== false,
source: readString(item, 'source') === 'kernel' ? 'kernel' : 'nianxx',
}));
}
function normalizeUiPolicy(value: JsonObject | undefined): YinianConfigSnapshot['uiPolicy'] {
const defaultPage = readString(value ?? {}, 'defaultPage', 'default_page');
return {
defaultPage: defaultPage === 'chat' ? 'chat' : 'today',
showAdvancedSettings: readBoolean(value ?? {}, 'showAdvancedSettings', 'show_advanced_settings') ?? false,
};
}
function normalizeSkillCategory(value: string | undefined): YinianSkillEntitlement['category'] {
return SKILL_CATEGORIES.includes(value as YinianSkillEntitlement['category'])
? value as YinianSkillEntitlement['category']
: 'ops-automation';
}
function normalizeSkillTriggers(value: unknown[] | undefined): YinianSkillEntitlement['triggers'] {
if (!value) return [];
return value.filter((item): item is YinianSkillEntitlement['triggers'][number] => (
typeof item === 'string'
&& SKILL_TRIGGERS.includes(item as YinianSkillEntitlement['triggers'][number])
));
}
function readAccessTokenExpiresAt(object: JsonObject): number {
return readNumber(object, 'accessTokenExpiresAt', 'access_token_expires_at', 'expiresAt', 'expires_at')
?? readExpiresIn(object)
?? (Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS);
}
function readExpiresIn(object: JsonObject): number | undefined {
const expiresIn = readNumber(object, 'expiresIn', 'expires_in');
if (!expiresIn) return undefined;
return Date.now() + expiresIn * 1000;
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(object: JsonObject, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = object[key];
if (typeof value === 'string') return value;
}
return undefined;
}
function readNumber(object: JsonObject, ...keys: string[]): number | undefined {
for (const key of keys) {
const value = object[key];
if (typeof value === 'number') return value;
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value);
}
return undefined;
}
function readBoolean(object: JsonObject, ...keys: string[]): boolean | undefined {
for (const key of keys) {
const value = object[key];
if (typeof value === 'boolean') return value;
}
return undefined;
}
function readObject(object: JsonObject, ...keys: string[]): JsonObject | undefined {
for (const key of keys) {
const value = object[key];
if (isObject(value)) return value;
}
return undefined;
}
function unwrapApiPayload(data: JsonObject): JsonObject {
const dataObject = readObject(data, 'data');
if (dataObject) {
return {
...data,
...dataObject,
};
}
return data;
}
function readManifestItems(object: JsonObject, preferredKey?: string): unknown[] {
if (preferredKey) {
const preferred = readArray(object, preferredKey);
if (preferred) return preferred;
}
const direct = readArray(object, 'skills', 'apps', 'applications', 'records', 'list', 'items', 'rows');
if (direct) return direct;
const data = object.data;
if (Array.isArray(data)) return data;
if (isObject(data)) {
return readManifestItems(data, preferredKey);
}
return [];
}
function readErrorMessage(data: JsonObject): string | undefined {
const errorObject = readObject(data, 'error');
return readString(errorObject ?? {}, 'message', 'msg', 'detail')
?? readString(data, 'message', 'msg', 'error_description')
?? (typeof data.error === 'string' ? data.error : undefined);
}
function readArray(object: JsonObject, ...keys: string[]): unknown[] | undefined {
for (const key of keys) {
const value = object[key];
if (Array.isArray(value)) return value;
}
return undefined;
}
function normalizeBooleanRecord(value: JsonObject | undefined): Record<string, boolean> {
if (!value) return {};
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'),
);
}