@danceiny/gotry 0.2.0-rc.30 → 0.2.0-rc.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +119 -136
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/bin/gotry-inner.js +2 -1
- package/client/client.js +228 -0
- package/dist/capabilities/doctor.js +14 -23
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +8 -1
- package/dist/capabilities/session/gui-flight.js +737 -0
- package/dist/capabilities/session/gui-hotel.js +413 -0
- package/dist/capabilities/session-search.js +98 -11
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +37 -10
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +93 -5
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +119 -0
- package/dist/scripts/gotry-web-client-tests.js +47 -0
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +3 -1
- package/dist/scripts/session-tests.js +177 -6
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +219 -0
- package/dist/src/index.js +45 -17
- package/extension/manifest.json +2 -2
- package/package.json +8 -2
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +9 -2
- package/ts/capabilities/session/gui-flight.ts +792 -0
- package/ts/capabilities/session/gui-hotel.ts +451 -0
- package/ts/capabilities/session-search.ts +115 -15
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +189 -0
- package/ts/src/index.ts +46 -18
|
@@ -134,9 +134,9 @@ export function verifySecureMode(dir, file) {
|
|
|
134
134
|
/**
|
|
135
135
|
* 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
|
|
136
136
|
* 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
|
|
137
|
-
* 0700
|
|
137
|
+
* 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
|
|
138
138
|
*/
|
|
139
|
-
function
|
|
139
|
+
function preparePrivateDir(dir, file) {
|
|
140
140
|
if (process.platform === 'win32')
|
|
141
141
|
return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' };
|
|
142
142
|
try {
|
|
@@ -144,19 +144,19 @@ function prepareFlyaiConfigDir(dir, file) {
|
|
|
144
144
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
145
145
|
const entry = lstatSync(dir);
|
|
146
146
|
if (entry.isSymbolicLink())
|
|
147
|
-
return { ok: false, error: '
|
|
147
|
+
return { ok: false, error: '配置目录是符号链接,拒绝写入' };
|
|
148
148
|
if (!entry.isDirectory())
|
|
149
|
-
return { ok: false, error: '
|
|
149
|
+
return { ok: false, error: '配置路径不是目录,拒绝写入' };
|
|
150
150
|
if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
|
|
151
|
-
return { ok: false, error: '
|
|
151
|
+
return { ok: false, error: '配置目录不属于当前用户,拒绝写入' };
|
|
152
152
|
}
|
|
153
153
|
if ((entry.mode & 0o777) !== 0o700)
|
|
154
154
|
chmodSync(dir, 0o700);
|
|
155
155
|
const after = lstatSync(dir);
|
|
156
156
|
if (after.isSymbolicLink() || !after.isDirectory())
|
|
157
|
-
return { ok: false, error: '
|
|
157
|
+
return { ok: false, error: '配置目录状态发生变化,拒绝写入' };
|
|
158
158
|
if (typeof process.getuid === 'function' && after.uid !== process.getuid())
|
|
159
|
-
return { ok: false, error: '
|
|
159
|
+
return { ok: false, error: '配置目录所有者发生变化,拒绝写入' };
|
|
160
160
|
return verifySecureMode(dir, file);
|
|
161
161
|
}
|
|
162
162
|
catch (e) {
|
|
@@ -225,7 +225,7 @@ export function saveFlyaiKey(key, opts = {}) {
|
|
|
225
225
|
const read = readFlyaiConfig(configPath);
|
|
226
226
|
if (!read.ok)
|
|
227
227
|
return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` };
|
|
228
|
-
const configDir =
|
|
228
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath);
|
|
229
229
|
if (!configDir.ok)
|
|
230
230
|
return { ok: false, error: configDir.error };
|
|
231
231
|
const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text });
|
|
@@ -267,7 +267,7 @@ export function clearFlyaiKey(opts = {}) {
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
else {
|
|
270
|
-
const configDir =
|
|
270
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath);
|
|
271
271
|
if (!configDir.ok)
|
|
272
272
|
return { ok: false, source: 'none', error: configDir.error };
|
|
273
273
|
const write = atomicWriteJson(configPath, next);
|
|
@@ -303,7 +303,9 @@ export function readFlyaiVerification(homeDir) {
|
|
|
303
303
|
}
|
|
304
304
|
/** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
|
|
305
305
|
export function writeFlyaiVerification(entry, homeDir) {
|
|
306
|
-
|
|
306
|
+
const path = flyaiVerificationPath(homeDir);
|
|
307
|
+
const prepared = preparePrivateDir(dirname(path), path);
|
|
308
|
+
return prepared.ok && atomicWriteJson(path, entry).ok;
|
|
307
309
|
}
|
|
308
310
|
export function removeFlyaiVerification(homeDir) {
|
|
309
311
|
try {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs';
|
|
32
32
|
import { tmpdir } from 'node:os';
|
|
33
33
|
import { join } from 'node:path';
|
|
34
|
-
import { resolveFlyaiEndpoint, resolveFlyaiKey } from './flyai-config.js';
|
|
34
|
+
import { normalizeEndpoint, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex } from './flyai-config.js';
|
|
35
35
|
import { spawnBounded } from './spawn-bounded.js';
|
|
36
36
|
function nonEmptyText(value) {
|
|
37
37
|
if (typeof value !== 'string')
|
|
@@ -132,7 +132,7 @@ export function parseFlyaiItemList(stdout) {
|
|
|
132
132
|
: 'no complete FlyAI itemList JSON object');
|
|
133
133
|
}
|
|
134
134
|
/** 解析 stdout 中首个完整 JSON 对象(不限 itemList;ai-search 用) */
|
|
135
|
-
export function parseFlyaiJsonEnvelope(stdout) {
|
|
135
|
+
export function parseFlyaiJsonEnvelope(stdout, accepts = () => true) {
|
|
136
136
|
let start = -1;
|
|
137
137
|
let depth = 0;
|
|
138
138
|
let inString = false;
|
|
@@ -166,12 +166,13 @@ export function parseFlyaiJsonEnvelope(stdout) {
|
|
|
166
166
|
if (depth === 0) {
|
|
167
167
|
const candidate = stdout.slice(start, index + 1);
|
|
168
168
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
start = -1;
|
|
173
|
-
depth = 0;
|
|
169
|
+
const parsed = JSON.parse(candidate);
|
|
170
|
+
if (accepts(parsed))
|
|
171
|
+
return parsed;
|
|
174
172
|
}
|
|
173
|
+
catch { /* prefix logs may contain balanced non-JSON braces */ }
|
|
174
|
+
start = -1;
|
|
175
|
+
depth = 0;
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
}
|
|
@@ -463,6 +464,14 @@ const KIND_LABEL = {
|
|
|
463
464
|
flight: 'flight', train: 'train', hotel: 'hotel', poi: 'poi',
|
|
464
465
|
keyword: 'keyword', ai: 'ai', 'marriott-hotel': 'marriott hotel', 'marriott-package': 'marriott package',
|
|
465
466
|
};
|
|
467
|
+
/** 每种检索 × 生效 endpoint/key 隔离;Map 中只留下不可逆指纹。 */
|
|
468
|
+
export function flyaiBreakerScope(kind) {
|
|
469
|
+
const key = resolveFlyaiKey();
|
|
470
|
+
const endpoint = resolveFlyaiEndpoint().url;
|
|
471
|
+
const identity = sha256Hex(JSON.stringify([normalizeEndpoint(endpoint) ?? endpoint, key.key ?? null]));
|
|
472
|
+
const safeKind = typeof kind === 'string' && Object.hasOwn(KIND_LABEL, kind) ? kind : 'invalid-kind';
|
|
473
|
+
return `${safeKind}:${identity}`;
|
|
474
|
+
}
|
|
466
475
|
/**
|
|
467
476
|
* FlyAI 官方只读检索(8 命令)— 任何失败走降级;不抛错。
|
|
468
477
|
*/
|
|
@@ -472,10 +481,10 @@ export async function flyaiSearch(q) {
|
|
|
472
481
|
const envContext = await runtimeEnvNote();
|
|
473
482
|
const envNote = envContext.note;
|
|
474
483
|
const sensitive = envContext.sensitive;
|
|
475
|
-
const safe = (result) => safeFlyaiResult(result, sensitive);
|
|
484
|
+
const safe = (result) => safeFlyaiResult({ upstreamHealth: 'unknown', retryable: false, ...result }, sensitive);
|
|
476
485
|
const built = buildCliArgs(q);
|
|
477
486
|
if (built.error)
|
|
478
|
-
return { ...badArg(q.kind, built.error, ts), ...envNote };
|
|
487
|
+
return safe({ ...badArg(q.kind, built.error, ts), ...envNote });
|
|
479
488
|
const prefix = q.cliPrefixArgs ?? ['-y', '@fly-ai/flyai-cli@1.0.16'];
|
|
480
489
|
const r = await sh(q.cliBin ?? 'npx', [...prefix, ...built.args], {
|
|
481
490
|
timeoutMs: q.timeoutMs ?? 30_000,
|
|
@@ -483,7 +492,6 @@ export async function flyaiSearch(q) {
|
|
|
483
492
|
signal: q.signal,
|
|
484
493
|
});
|
|
485
494
|
const latencyMs = Date.now() - started;
|
|
486
|
-
const combined = `${r.stderr}\n${r.stdout}`;
|
|
487
495
|
if (r.cancelled) {
|
|
488
496
|
return safe({
|
|
489
497
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'cancelled',
|
|
@@ -507,60 +515,65 @@ export async function flyaiSearch(q) {
|
|
|
507
515
|
if (r.localTermination) {
|
|
508
516
|
return safe(localTerminationResult(q.kind, r.localTermination, r, latencyMs, ts, envNote));
|
|
509
517
|
}
|
|
510
|
-
if (r.error || r.code !== 0 ||
|
|
518
|
+
if (r.error || r.code !== 0 || explicitUpstreamFailure(r.stderr)) {
|
|
511
519
|
return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote));
|
|
512
520
|
}
|
|
521
|
+
// Validate the response envelope before accepting inventory for any kind.
|
|
522
|
+
// A business status is not an HTTP status; successful data is never scanned
|
|
523
|
+
// for fault words (for example an AI answer quoting an HTTP error).
|
|
524
|
+
let envelope;
|
|
525
|
+
try {
|
|
526
|
+
envelope = parseFlyaiJsonEnvelope(r.stdout, candidate => ['data', 'status', 'error'].some(key => Object.hasOwn(candidate, key))
|
|
527
|
+
|| explicitUpstreamFailure(nonEmptyText(candidate.message) ?? ''));
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
if (explicitUpstreamFailure(r.stdout))
|
|
531
|
+
return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote));
|
|
532
|
+
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
|
|
533
|
+
return safe({
|
|
534
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
535
|
+
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
536
|
+
error: `failed to parse flyai output (${e.message}): ${raw}`,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const systemMessage = nonEmptyText(envelope.systemMessage);
|
|
540
|
+
const envelopeFailure = `${nonEmptyText(envelope.message) ?? ''}\n${envelope.error == null ? '' : JSON.stringify(envelope.error)}`;
|
|
541
|
+
if (explicitUpstreamFailure(envelopeFailure)) {
|
|
542
|
+
return safe(classifyUpstreamFailure(q.kind, { ...r, stdout: envelopeFailure }, latencyMs, ts, envNote));
|
|
543
|
+
}
|
|
544
|
+
if ((envelope.status !== undefined && envelope.status !== 0) || envelope.error != null) {
|
|
545
|
+
return safe({
|
|
546
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
547
|
+
evidence: `[实时API:flyai@error@${ts}] upstream response status=${String(envelope.status ?? 'unknown')}`,
|
|
548
|
+
error: nonEmptyText(envelope.message) ?? 'FlyAI upstream response rejected the query',
|
|
549
|
+
...(systemMessage ? { systemMessage } : {}),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
513
552
|
// ai-search:data 是自由形状,不走 itemList。
|
|
514
553
|
if (q.kind === 'ai') {
|
|
515
|
-
|
|
516
|
-
try {
|
|
517
|
-
envelope = parseFlyaiJsonEnvelope(r.stdout);
|
|
518
|
-
}
|
|
519
|
-
catch (e) {
|
|
520
|
-
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
|
|
521
|
-
return safe({
|
|
522
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
523
|
-
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
524
|
-
error: `failed to parse flyai ai output (${e.message}): ${raw}`,
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
const systemMessage = nonEmptyText(envelope.systemMessage);
|
|
528
|
-
if (envelope.status !== undefined && envelope.status !== 0) {
|
|
554
|
+
if (!Object.hasOwn(envelope, 'data')) {
|
|
529
555
|
return safe({
|
|
530
556
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
531
|
-
evidence: `[实时API:flyai@error@${ts}] ai
|
|
532
|
-
error:
|
|
557
|
+
evidence: `[实时API:flyai@error@${ts}] malformed ai response: missing data`,
|
|
558
|
+
error: 'FlyAI ai response malformed: missing data member',
|
|
533
559
|
...(systemMessage ? { systemMessage } : {}),
|
|
534
560
|
});
|
|
535
561
|
}
|
|
536
562
|
const aiData = envelope.data;
|
|
537
|
-
const empty = aiData ===
|
|
563
|
+
const empty = aiData === null || aiData === ''
|
|
538
564
|
|| (Array.isArray(aiData) && aiData.length === 0);
|
|
539
565
|
return safe(JSON.parse(JSON.stringify({
|
|
540
|
-
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai',
|
|
566
|
+
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy',
|
|
541
567
|
verdict: empty ? 'miss' : 'hit',
|
|
542
568
|
...(empty ? {} : { aiData }),
|
|
543
569
|
...(systemMessage ? { systemMessage } : {}),
|
|
544
570
|
evidence: `[实时API:flyai@${ts}] ai-search ${empty ? 'empty' : 'data returned'}`,
|
|
545
571
|
})));
|
|
546
572
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
items = parseFlyaiItemList(r.stdout);
|
|
550
|
-
}
|
|
551
|
-
catch (e) {
|
|
573
|
+
const data = envelope.data;
|
|
574
|
+
if (!Array.isArray(data?.itemList)) {
|
|
552
575
|
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
|
|
553
|
-
|
|
554
|
-
if (/Sentinel/i.test(raw)) {
|
|
555
|
-
return safe({
|
|
556
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
557
|
-
retryable: false,
|
|
558
|
-
evidence: `[实时API:flyai@error@${ts}] sentinel: ${raw}`,
|
|
559
|
-
error: `FlyAI Sentinel 限流:${raw}`,
|
|
560
|
-
setup: '平台 Sentinel 限流(恢复窗口未公开)——稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
const reason = e.message;
|
|
576
|
+
const reason = 'no complete FlyAI itemList JSON object';
|
|
564
577
|
return safe({
|
|
565
578
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
566
579
|
retryable: false,
|
|
@@ -568,7 +581,15 @@ export async function flyaiSearch(q) {
|
|
|
568
581
|
error: `failed to parse flyai output (${reason}): ${raw}`,
|
|
569
582
|
});
|
|
570
583
|
}
|
|
571
|
-
return safe(parseItemListResult(q.kind,
|
|
584
|
+
return safe(parseItemListResult(q.kind, data.itemList, latencyMs, ts, envNote, r.stdout));
|
|
585
|
+
}
|
|
586
|
+
// Explicit protocol/transport evidence only. Bare business codes and generic
|
|
587
|
+
// words such as "network" or "timeout" do not diagnose an upstream outage.
|
|
588
|
+
const HTTP_FAILURE = /\bHTTP(?:\/[\d.]+)?\s*[:=]?\s*([45]\d\d)\b/i;
|
|
589
|
+
const TRANSPORT_FAILURE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN)\b|socket hang up|fetch failed|network (?:error|failure|unreachable)|(?:request|connection) (?:timed out|timeout|reset|refused)/i;
|
|
590
|
+
function explicitUpstreamFailure(text) {
|
|
591
|
+
return HTTP_FAILURE.test(text) || TRANSPORT_FAILURE.test(text)
|
|
592
|
+
|| /Invalid API key|Trial limit reached|SentinelBlockException/i.test(text);
|
|
572
593
|
}
|
|
573
594
|
/** 当前进程 key 来源/endpoint 覆盖(仅展示,与 spawn 实际生效同源) */
|
|
574
595
|
async function runtimeEnvNote() {
|
|
@@ -587,38 +608,46 @@ async function runtimeEnvNote() {
|
|
|
587
608
|
/** 非 0 退出 / HTTP 4xx-5xx 文本 → 故障闭集分类 */
|
|
588
609
|
function classifyUpstreamFailure(kind, r, latencyMs, ts, envNote) {
|
|
589
610
|
const upstream = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim();
|
|
590
|
-
const tag = (verdict, evidence, extra) => ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, evidence, ...extra });
|
|
591
|
-
|
|
611
|
+
const tag = (verdict, evidence, extra) => ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, upstreamHealth: 'unknown', evidence, ...extra });
|
|
612
|
+
const httpStatus = Number(HTTP_FAILURE.exec(upstream)?.[1]);
|
|
613
|
+
if (/Invalid API key/i.test(upstream) || httpStatus === 401) {
|
|
592
614
|
return tag('auth-error', `[实时API:flyai@error@${ts}] 401 invalid api key`, {
|
|
593
615
|
retryable: false,
|
|
594
616
|
error: `FlyAI 鉴权失败(401):${upstream.slice(0, 160)}`,
|
|
595
617
|
setup: '当前 key 无效或已吊销——在本机终端运行 `gotry setup flyai` 重新设置并验证(模型无法替你输入 key);`gotry setup flyai --clear` 可清除错误 key,清除后仍可匿名试用。',
|
|
596
618
|
});
|
|
597
619
|
}
|
|
598
|
-
if (
|
|
620
|
+
if (httpStatus === 403) {
|
|
599
621
|
return tag('forbidden', `[实时API:flyai@error@${ts}] 403 forbidden`, {
|
|
600
622
|
retryable: false,
|
|
601
623
|
error: `FlyAI 拒绝访问(403):${upstream.slice(0, 160)}`,
|
|
602
624
|
setup: '上游 403:核对该 key 的权限范围与控制台状态;如 endpoint 被 DEBUG_FLYAI_MCP_URL 覆盖,请确认指向。',
|
|
603
625
|
});
|
|
604
626
|
}
|
|
605
|
-
if (/Trial limit reached
|
|
627
|
+
if (/Trial limit reached/i.test(upstream)) {
|
|
606
628
|
return tag('needs-setup', `[实时API:flyai@error@${ts}] trial quota exhausted`, {
|
|
607
629
|
retryable: false,
|
|
608
630
|
error: `FlyAI 匿名试用额度已用尽:${upstream.slice(0, 160)}`,
|
|
609
631
|
setup: '打开 https://flyai.open.fliggy.com/console,登录后复制 API Key,本机运行 `gotry setup flyai` 配置并验证;本会话请勿盲重试 gotry_flyai_search,机/火/酒改走 gotry_session_search。',
|
|
610
632
|
});
|
|
611
633
|
}
|
|
612
|
-
if (/
|
|
634
|
+
if (/SentinelBlockException/i.test(upstream)) {
|
|
635
|
+
return tag('error', `[实时API:flyai@error@${ts}] sentinel`, {
|
|
636
|
+
retryable: false, upstreamHealth: 'unhealthy',
|
|
637
|
+
error: `FlyAI Sentinel 限流:${upstream.slice(0, 160)}`,
|
|
638
|
+
setup: '平台 Sentinel 限流(恢复窗口未公开);稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (httpStatus === 429) {
|
|
613
642
|
return tag('rate-limited', `[实时API:flyai@error@${ts}] 429 rate limited`, {
|
|
614
|
-
retryable: true,
|
|
643
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
615
644
|
error: `FlyAI 普通限流(非试用达限):${upstream.slice(0, 160)}`,
|
|
616
645
|
setup: '上游普通限流:稍候再查,勿高频连发;仍 429 请稍后或换 gotry_session_search。',
|
|
617
646
|
});
|
|
618
647
|
}
|
|
619
|
-
if (
|
|
648
|
+
if (TRANSPORT_FAILURE.test(upstream) || httpStatus >= 500) {
|
|
620
649
|
return tag('error', `[实时API:flyai@error@${ts}] transient upstream`, {
|
|
621
|
-
retryable: true,
|
|
650
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
622
651
|
error: `FlyAI 瞬时网络/上游错误:${upstream.slice(0, 160)}`,
|
|
623
652
|
setup: '网络抖动或上游 5xx:稍后重试;持续失败请检查本机网络或改走 gotry_session_search。',
|
|
624
653
|
});
|
|
@@ -676,7 +705,7 @@ function parseItemListResult(kind, items, latencyMs, ts, envNote, rawStdout) {
|
|
|
676
705
|
const count = parsed.options.length;
|
|
677
706
|
const verdict = count > 0 ? 'hit' : 'miss';
|
|
678
707
|
const evidence = `[实时API:flyai@${ts}] ${count}/${items.length} ${KIND_LABEL[kind]} options`;
|
|
679
|
-
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai', verdict, evidence, ...(systemMessage ? { systemMessage } : {}) };
|
|
708
|
+
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy', verdict, evidence, ...(systemMessage ? { systemMessage } : {}) };
|
|
680
709
|
switch (kind) {
|
|
681
710
|
case 'flight':
|
|
682
711
|
case 'train':
|
|
@@ -781,7 +810,9 @@ function parseHotelItems(items) {
|
|
|
781
810
|
}
|
|
782
811
|
const it = raw;
|
|
783
812
|
const name = nonEmptyText(it.name);
|
|
784
|
-
|
|
813
|
+
// Unrated homestays may return whitespace (live Harbin response).
|
|
814
|
+
// Only this optional descriptive field permits blank text; types stay strict.
|
|
815
|
+
const star = typeof it.star === 'string' && !it.star.trim() ? undefined : stringField(it, 'star');
|
|
785
816
|
const rawPrice = stringField(it, 'price');
|
|
786
817
|
const rate = it.rate === undefined ? undefined : it.rate === null ? null : nonEmptyText(it.rate) ?? 'bad';
|
|
787
818
|
const address = stringField(it, 'address');
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Official hbcli credential-file contract; no credentials enter tool results. */
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
import { atomicWriteJson, sha256Hex } from './flyai-config.js';
|
|
6
|
+
import { hbcliBinCandidates } from './hbcli.js';
|
|
7
|
+
import { spawnBounded } from './spawn-bounded.js';
|
|
8
|
+
export const HBCLI_REGISTRATION_URL = 'https://hotelbyte.com/zh/guides/sandbox-verification';
|
|
9
|
+
const ENVIRONMENT = 'uat';
|
|
10
|
+
const ENTRY = `openapi:${ENVIRONMENT}`;
|
|
11
|
+
export function hbcliCredentialRoot(options) {
|
|
12
|
+
return options.env?.STAICLI_HOME?.trim() || join(options.homeDir ?? homedir(), '.staicli');
|
|
13
|
+
}
|
|
14
|
+
export function hbcliCredentialPath(options) { return join(hbcliCredentialRoot(options), 'credentials.json'); }
|
|
15
|
+
export function hbcliVerificationPath(homeDir) { return join(homeDir, '.gotry', 'hbcli-verification.json'); }
|
|
16
|
+
function safeEntry(path, directory) {
|
|
17
|
+
try {
|
|
18
|
+
const entry = lstatSync(path);
|
|
19
|
+
return !entry.isSymbolicLink() && (directory ? entry.isDirectory() : entry.isFile())
|
|
20
|
+
&& (typeof process.getuid !== 'function' || entry.uid === process.getuid());
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return e.code === 'ENOENT';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function readHbcliCredentials(options) {
|
|
27
|
+
const root = hbcliCredentialRoot(options), file = hbcliCredentialPath(options);
|
|
28
|
+
try {
|
|
29
|
+
if (!isAbsolute(root) || !safeEntry(root, true) || !safeEntry(file, false))
|
|
30
|
+
return { ok: false };
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return { ok: true, data: {} };
|
|
33
|
+
const bytes = readFileSync(file, 'utf8'), data = JSON.parse(bytes);
|
|
34
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data, bytes } : { ok: false };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { ok: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function fingerprint(data) {
|
|
41
|
+
const entry = data[ENTRY];
|
|
42
|
+
return typeof entry?.appKey === 'string' && entry.appKey.trim() && typeof entry.appSecret === 'string' && entry.appSecret.trim()
|
|
43
|
+
? sha256Hex(JSON.stringify([ENVIRONMENT, entry.appKey, entry.appSecret])) : undefined;
|
|
44
|
+
}
|
|
45
|
+
function readReceipt(home) {
|
|
46
|
+
try {
|
|
47
|
+
const path = hbcliVerificationPath(home);
|
|
48
|
+
if (!safeEntry(join(home, '.gotry'), true) || !safeEntry(path, false))
|
|
49
|
+
return undefined;
|
|
50
|
+
const v = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
return v.schema === 'gotry.hbcli-verification.v1' && typeof v.fingerprint === 'string'
|
|
52
|
+
&& ['hit', 'miss', 'no-suppliers'].includes(v.verdict) && Number.isFinite(Date.parse(v.at)) ? v : undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function hbcliSetupStatus(options) {
|
|
59
|
+
const read = readHbcliCredentials(options), hash = read.ok ? fingerprint(read.data) : undefined;
|
|
60
|
+
const receipt = readReceipt(options.homeDir ?? homedir());
|
|
61
|
+
const envToken = Boolean(options.env?.HOTELBYTE_TOKEN?.trim());
|
|
62
|
+
const matches = !envToken && hash && receipt?.fingerprint === hash;
|
|
63
|
+
return { ok: true, configured: envToken || Boolean(hash), writable: read.ok && !envToken && process.platform !== 'win32',
|
|
64
|
+
source: envToken ? 'env' : hash ? 'config' : 'none', environment: ENVIRONMENT,
|
|
65
|
+
verified: Boolean(matches && receipt?.verdict !== 'no-suppliers'),
|
|
66
|
+
...(matches && receipt ? { verdict: receipt.verdict, checkedAt: receipt.at } : {}), registrationUrl: HBCLI_REGISTRATION_URL };
|
|
67
|
+
}
|
|
68
|
+
function prepareDirectory(dir) {
|
|
69
|
+
try {
|
|
70
|
+
if (process.platform === 'win32' || !isAbsolute(dir) || !safeEntry(dir, true))
|
|
71
|
+
return false;
|
|
72
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
if (!safeEntry(dir, true))
|
|
74
|
+
return false;
|
|
75
|
+
chmodSync(dir, 0o700);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function writeHbcliCredentials(data, options) {
|
|
83
|
+
const path = hbcliCredentialPath(options);
|
|
84
|
+
return readHbcliCredentials(options).ok && prepareDirectory(hbcliCredentialRoot(options)) && atomicWriteJson(path, data).ok;
|
|
85
|
+
}
|
|
86
|
+
export function writeHbcliVerification(data, verdict, options) {
|
|
87
|
+
const hash = fingerprint(data), home = options.homeDir ?? homedir();
|
|
88
|
+
if (!hash || !prepareDirectory(join(home, '.gotry')))
|
|
89
|
+
return false;
|
|
90
|
+
const entry = { schema: 'gotry.hbcli-verification.v1', fingerprint: hash, verdict, at: new Date().toISOString() };
|
|
91
|
+
return safeEntry(hbcliVerificationPath(home), false) && atomicWriteJson(hbcliVerificationPath(home), entry).ok;
|
|
92
|
+
}
|
|
93
|
+
export function removeHbcliVerification(homeDir) {
|
|
94
|
+
if (safeEntry(join(homeDir, '.gotry'), true) && safeEntry(hbcliVerificationPath(homeDir), false)) {
|
|
95
|
+
try {
|
|
96
|
+
rmSync(hbcliVerificationPath(homeDir), { force: true });
|
|
97
|
+
}
|
|
98
|
+
catch { /* best effort */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function withHbcliCredentials(data, appKey, appSecret) {
|
|
102
|
+
return { ...data, [ENTRY]: { appKey, appSecret } };
|
|
103
|
+
}
|
|
104
|
+
export function withoutHbcliCredentials(data) {
|
|
105
|
+
const next = { ...data };
|
|
106
|
+
delete next[ENTRY];
|
|
107
|
+
return next;
|
|
108
|
+
}
|
|
109
|
+
/** Verify through the existing CLI bridge, with a private disposable credential root.
|
|
110
|
+
* An upstream no-suppliers verdict is retained separately from a completed query.
|
|
111
|
+
*/
|
|
112
|
+
export async function verifyHbcliCandidate(appKey, appSecret, options, signal) {
|
|
113
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-hbcli-verify-'));
|
|
114
|
+
try {
|
|
115
|
+
if (!atomicWriteJson(join(root, 'credentials.json'), withHbcliCredentials({}, appKey, appSecret)).ok) {
|
|
116
|
+
return { verdict: 'error', error: '无法创建隔离的验证环境。' };
|
|
117
|
+
}
|
|
118
|
+
const checkIn = new Date();
|
|
119
|
+
checkIn.setUTCDate(checkIn.getUTCDate() + 1);
|
|
120
|
+
const checkOut = new Date(checkIn);
|
|
121
|
+
checkOut.setUTCDate(checkOut.getUTCDate() + 1);
|
|
122
|
+
const args = ['--json', '--env', ENVIRONMENT, 'search', 'hotel-list', '--destination-name', '上海',
|
|
123
|
+
'--check-in', checkIn.toISOString().slice(0, 10), '--check-out', checkOut.toISOString().slice(0, 10),
|
|
124
|
+
'--room-occupancies', '[{"adultCount":2,"childrenAges":[]}]', '--page-size', '1'];
|
|
125
|
+
const env = { ...(options.env ?? process.env), STAICLI_HOME: root, HOTELBYTE_ENV: ENVIRONMENT };
|
|
126
|
+
delete env.HOTELBYTE_TOKEN;
|
|
127
|
+
let result;
|
|
128
|
+
for (const bin of hbcliBinCandidates(options.hbcliBin ?? options.env?.GOTRY_HBCLI_BIN ?? 'hbcli', options.homeDir)) {
|
|
129
|
+
result = await spawnBounded(bin, args, { env, signal, timeoutMs: 20_000 });
|
|
130
|
+
if (!/ENOENT/.test(result.error ?? '') || signal.aborted)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (signal.aborted || result?.aborted)
|
|
134
|
+
return { verdict: 'cancelled', error: '验证已取消,旧配置保留。' };
|
|
135
|
+
if (!result || result.error || result.timedOut || result.drainTimedOut || result.groupReaped === false || result.code !== 0) {
|
|
136
|
+
return { verdict: 'error', error: 'HotelByte 验证未完成,请检查 hbcli 安装、网络和凭证;旧配置保留。' };
|
|
137
|
+
}
|
|
138
|
+
let value;
|
|
139
|
+
try {
|
|
140
|
+
value = JSON.parse(result.stdout);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { verdict: 'error', error: 'HotelByte 回包无法识别,旧配置保留。' };
|
|
144
|
+
}
|
|
145
|
+
if (value?.code === 300010002 || value?.code === '300010002')
|
|
146
|
+
return { verdict: 'no-suppliers' };
|
|
147
|
+
if (!value || (value.code !== undefined && value.code !== 0 && value.code !== '0') || !Array.isArray(value.list)
|
|
148
|
+
|| value.list.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
|
|
149
|
+
return { verdict: 'error', error: 'HotelByte 未接受凭证或返回了无效的查询结果;旧配置保留。' };
|
|
150
|
+
}
|
|
151
|
+
return { verdict: value.list.length ? 'hit' : 'miss' };
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
rmSync(root, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -65,6 +65,24 @@ async function attemptHbcli(bin, args, opts) {
|
|
|
65
65
|
}
|
|
66
66
|
catch { /* 非 JSON 输出,留给调用方处理 */ }
|
|
67
67
|
}
|
|
68
|
+
// CLI exit status describes the process, not the API verdict. hbcli can exit
|
|
69
|
+
// zero with an API error envelope (for example an unconfigured supplier pool).
|
|
70
|
+
const code = result?.code;
|
|
71
|
+
const invalid = result === null;
|
|
72
|
+
const upstreamFailure = code !== undefined && code !== 0 && code !== '0';
|
|
73
|
+
if (invalid || upstreamFailure) {
|
|
74
|
+
const msg = result?.msg
|
|
75
|
+
?? result?.message;
|
|
76
|
+
const detail = code === 300010002 || code === '300010002'
|
|
77
|
+
? '当前账号没有可用供应商;库存查询未完成'
|
|
78
|
+
: typeof msg === 'string' ? msg.slice(0, 200) : 'API request failed';
|
|
79
|
+
return {
|
|
80
|
+
via: 'hbcli-error', exitCode: 0, result: null,
|
|
81
|
+
evidence: `[实时API:hbcli@error@${ts}]`,
|
|
82
|
+
stdout: r.stdout.slice(0, 2000), latencyMs,
|
|
83
|
+
error: invalid ? 'failed to parse hbcli output as JSON' : `hbcli upstream code ${String(code)}: ${detail}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
68
86
|
return {
|
|
69
87
|
via: 'hbcli-realtime', exitCode: 0, result,
|
|
70
88
|
evidence: `[实时API:hbcli@${ts}]`,
|
|
@@ -138,7 +156,7 @@ export async function searchHotels(query, opts = {}) {
|
|
|
138
156
|
hbArgs.push('--check-out', query.checkOut);
|
|
139
157
|
if (query.adults)
|
|
140
158
|
hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]));
|
|
141
|
-
|
|
159
|
+
let live = await callHbcliJson(hbArgs, opts);
|
|
142
160
|
// 取消立即结束:不读静态包、不产出命中/无结果/估算,也不计上游故障
|
|
143
161
|
if (opts.signal?.aborted || /abort/i.test(live.error ?? '')) {
|
|
144
162
|
const ts = new Date().toISOString();
|
|
@@ -149,6 +167,15 @@ export async function searchHotels(query, opts = {}) {
|
|
|
149
167
|
latencyMs: live.latencyMs, error: `aborted by host signal${cleanupError}`, summary: `酒店「${query.destination}」检索已取消,未产生事实`,
|
|
150
168
|
};
|
|
151
169
|
}
|
|
170
|
+
const list = live.result?.list;
|
|
171
|
+
if (live.via === 'hbcli-realtime' && (!Array.isArray(list)
|
|
172
|
+
|| list.some(item => item === null || typeof item !== 'object' || Array.isArray(item)))) {
|
|
173
|
+
live = {
|
|
174
|
+
...live, via: 'hbcli-error', result: null,
|
|
175
|
+
evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
|
|
176
|
+
error: 'invalid hotel-list response: expected list array of hotels; inventory not observed',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
152
179
|
if (live.via === 'hbcli-realtime') {
|
|
153
180
|
// summary 即模型面(render 只透 summary):必须自带紧凑数据行 + 证据链,
|
|
154
181
|
// 否则模型只看到「实时返回」占位一句话(issue #195 实锤)。
|
|
@@ -247,7 +274,7 @@ export async function checkAvail(query, opts = {}) {
|
|
|
247
274
|
* ~/.staicli/credentials.json)——hbcli 侧 customer 档是最低优先级凭据(无
|
|
248
275
|
* portal/API-key 凭据时才生效),登录不劫持既有凭据。本层不透出 token 值
|
|
249
276
|
* (CLI 只回 token_saved,凭据留在 CLI 仓内,不进 gotry 会话/日志)。
|
|
250
|
-
* auth 命令族要求 hbcli 带 register 支持(
|
|
277
|
+
* auth 命令族要求 hbcli 带 register 支持(官方原生 0.0.4 起;0.0.3 报 unknown
|
|
251
278
|
* command,经 hbcli-error 面优雅失败)。
|
|
252
279
|
*/
|
|
253
280
|
export async function customerSendCode(email, opts = {}) {
|
|
@@ -108,7 +108,7 @@ export async function withRetry(fn, policy, onBackoff) {
|
|
|
108
108
|
export class CircuitBreaker {
|
|
109
109
|
o;
|
|
110
110
|
consecutiveFailures = 0;
|
|
111
|
-
openedAt =
|
|
111
|
+
openedAt = null;
|
|
112
112
|
generation = 0;
|
|
113
113
|
nextTokenId = 0;
|
|
114
114
|
probeToken = null;
|
|
@@ -122,9 +122,9 @@ export class CircuitBreaker {
|
|
|
122
122
|
return (this.o.now ?? Date.now)();
|
|
123
123
|
}
|
|
124
124
|
state() {
|
|
125
|
-
if (this.openedAt
|
|
125
|
+
if (this.openedAt !== null && this.now() - this.openedAt >= this.o.openMs)
|
|
126
126
|
return 'half-open';
|
|
127
|
-
if (this.openedAt
|
|
127
|
+
if (this.openedAt !== null)
|
|
128
128
|
return 'open';
|
|
129
129
|
return 'closed';
|
|
130
130
|
}
|
|
@@ -134,7 +134,7 @@ export class CircuitBreaker {
|
|
|
134
134
|
if (s === 'closed')
|
|
135
135
|
return { allowed: true, state: s, token: this.makeToken(false) };
|
|
136
136
|
if (s === 'open')
|
|
137
|
-
return { allowed: false, state: s };
|
|
137
|
+
return { allowed: false, state: s, retryAfterMs: Math.max(0, Math.min(this.o.openMs, this.o.openMs - (this.now() - this.openedAt))) };
|
|
138
138
|
// half-open:单探测语义——已有探测在途拒其余并发
|
|
139
139
|
if (this.probeToken)
|
|
140
140
|
return { allowed: false, state: s };
|
|
@@ -158,7 +158,7 @@ export class CircuitBreaker {
|
|
|
158
158
|
if (!this.owns(token))
|
|
159
159
|
return;
|
|
160
160
|
this.consecutiveFailures = 0;
|
|
161
|
-
this.openedAt =
|
|
161
|
+
this.openedAt = null;
|
|
162
162
|
if (token?.probe === true || (!token && this.probeToken !== null)) {
|
|
163
163
|
this.probeToken = null;
|
|
164
164
|
this.generation += 1;
|
|
@@ -171,7 +171,7 @@ export class CircuitBreaker {
|
|
|
171
171
|
if (token?.probe === true || (!token && this.probeToken !== null))
|
|
172
172
|
this.probeToken = null;
|
|
173
173
|
this.consecutiveFailures += 1;
|
|
174
|
-
if (this.openedAt
|
|
174
|
+
if (this.openedAt !== null || this.consecutiveFailures >= this.threshold()) {
|
|
175
175
|
this.openedAt = this.now();
|
|
176
176
|
this.generation += 1;
|
|
177
177
|
}
|