@itpay/cli 2.1.7 → 2.1.8
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/dist/src/client/backend.js +3 -2
- package/dist/src/commands/services.js +93 -18
- package/dist/src/main.js +30 -1
- package/dist/src/state/account_auth.js +87 -5
- package/dist/src/state/config.js +2 -2
- package/docs/agent/buyer/rail-booking.json +40 -7
- package/docs/cli-reference/commands/auth.md +88 -4
- package/docs/cli-reference/commands/services/checkout.md +2 -0
- package/docs/cli-reference/commands/services/next.md +52 -0
- package/docs/cli-reference/commands/services/page.md +67 -0
- package/docs/cli-reference/commands/services/run.md +80 -0
- package/docs/cli-reference/index.md +1 -0
- package/package.json +1 -1
- package/skills/itpay/SKILL.md +19 -0
|
@@ -123,8 +123,9 @@ export class BackendClient {
|
|
|
123
123
|
advanceServiceExecution(id, input, idempotencyKey) {
|
|
124
124
|
return this.http.post(`/v1/service-executions/${encodeURIComponent(id)}/advance`, { input, idempotency_key: idempotencyKey });
|
|
125
125
|
}
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit) {
|
|
127
|
+
const qs = new URLSearchParams({ offset: String(offset), limit: String(limit) });
|
|
128
|
+
return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/result-items/${encodeURIComponent(resultItemID)}?${qs}`);
|
|
128
129
|
}
|
|
129
130
|
invokeServiceCapability(serviceExecutionID, capabilityID, input) {
|
|
130
131
|
return this.http.post(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/capabilities/${encodeURIComponent(capabilityID)}/invoke`, input, { replaySafe: Boolean(input.idempotency_key) });
|
|
@@ -196,14 +196,13 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
|
|
|
196
196
|
catch (error) {
|
|
197
197
|
if (!(error instanceof HttpError) || error.code !== "verified_phone_required")
|
|
198
198
|
throw error;
|
|
199
|
-
const link = await backend.createRailPhoneLink();
|
|
200
199
|
writeCommandEnvelope({
|
|
201
200
|
status: "human_action_required",
|
|
202
|
-
result: { service_execution_id: serviceExecutionID,
|
|
203
|
-
instruction: "
|
|
204
|
-
next:
|
|
205
|
-
recovery: [{ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(input)} --json`, reason: "
|
|
206
|
-
}, { ...options, plainResult: [
|
|
201
|
+
result: { service_execution_id: serviceExecutionID, error_code: "verified_phone_required" },
|
|
202
|
+
instruction: "需要在官方页面完成手机号验证后当前执行才能继续。运行 itpay auth login 打开官方登录页,完成手机号验证并绑定本设备后重试原命令;CLI 不接收手机号或验证码。",
|
|
203
|
+
next: { command: "itpay auth login --json", reason: "完成官方手机号验证并绑定当前设备" },
|
|
204
|
+
recovery: [{ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(input)} --json`, reason: "仅在完成手机号验证与设备绑定后重试" }],
|
|
205
|
+
}, { ...options, plainResult: ["手机号验证:itpay auth login"] });
|
|
207
206
|
return;
|
|
208
207
|
}
|
|
209
208
|
const envelope = invokedEnvelope(response, requestedCapability, readModel.capabilities, input);
|
|
@@ -770,6 +769,51 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
|
|
|
770
769
|
plainResult: servicesNextPlainResult(envelope.result),
|
|
771
770
|
});
|
|
772
771
|
}
|
|
772
|
+
export async function runServicesPage(backend, serviceExecutionID, resultItemID, options = {}) {
|
|
773
|
+
const offset = options.offset ?? 0;
|
|
774
|
+
const limit = options.limit ?? 5;
|
|
775
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
776
|
+
throw new CommandContractError("offset_invalid", "--offset must be a non-negative integer", "offset 必须是非负整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "从第一页重新读取" }]);
|
|
777
|
+
}
|
|
778
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
|
|
779
|
+
throw new CommandContractError("limit_invalid", "--limit must be an integer from 1 to 20", "limit 必须是 1 到 20 的整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${offset} --json`, reason: "用默认页大小重试" }]);
|
|
780
|
+
}
|
|
781
|
+
const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit);
|
|
782
|
+
const page = response.page;
|
|
783
|
+
const container = (page.result ?? page);
|
|
784
|
+
const catalogPage = (container.catalog_page ?? {});
|
|
785
|
+
const nextOffset = typeof catalogPage.next_offset === "number" ? catalogPage.next_offset : null;
|
|
786
|
+
const candidates = Array.isArray(container.candidates) ? container.candidates : [];
|
|
787
|
+
const envelope = {
|
|
788
|
+
status: candidates.length > 0 ? "result_page" : "result_page_end",
|
|
789
|
+
result: {
|
|
790
|
+
service_execution_id: response.service_execution_id,
|
|
791
|
+
service_capability_result_item_id: response.service_capability_result_item_id,
|
|
792
|
+
offset: catalogPage.offset ?? offset,
|
|
793
|
+
limit: catalogPage.limit ?? limit,
|
|
794
|
+
total: catalogPage.total ?? candidates.length,
|
|
795
|
+
count: catalogPage.count ?? candidates.length,
|
|
796
|
+
next_offset: nextOffset,
|
|
797
|
+
page,
|
|
798
|
+
},
|
|
799
|
+
instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。用普通语言向用户说明本页候选(车次、席别、时刻、费用口径),铁路应付与地面估算费用分开表述;不要提及 safe_payload、Execution 或内部 ID。",
|
|
800
|
+
next: nextOffset !== null
|
|
801
|
+
? { command: `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --json`, reason: "读取同版本结果的下一页" }
|
|
802
|
+
: null,
|
|
803
|
+
recovery: offset > 0
|
|
804
|
+
? [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "回到第一页" }]
|
|
805
|
+
: [],
|
|
806
|
+
};
|
|
807
|
+
writeCommandEnvelope(envelope, {
|
|
808
|
+
...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
|
|
809
|
+
...(options.output ? { output: options.output } : {}),
|
|
810
|
+
plainResult: candidates.map((candidate) => {
|
|
811
|
+
const c = candidate;
|
|
812
|
+
const title = typeof c.title === "string" ? c.title : JSON.stringify(c);
|
|
813
|
+
return `${title}`;
|
|
814
|
+
}),
|
|
815
|
+
});
|
|
816
|
+
}
|
|
773
817
|
export async function runServicesList(backend, options = {}) {
|
|
774
818
|
const limit = options.limit ?? 10;
|
|
775
819
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
@@ -1005,6 +1049,18 @@ function servicesNextEnvelope(model) {
|
|
|
1005
1049
|
recovery: [],
|
|
1006
1050
|
};
|
|
1007
1051
|
}
|
|
1052
|
+
if (state === "quota_paused") {
|
|
1053
|
+
const login = model.workflow?.error_code !== "rate_limited";
|
|
1054
|
+
const resume = { command: `itpay services run ${execution.service_id} --execution ${id} --json`, reason: "登录或限流窗口后继续同一执行" };
|
|
1055
|
+
const quota = model.quota ? { bucket: model.quota.bucket, subject_type: model.quota.subject_type, limit: model.quota.limit, remaining: model.quota.remaining } : undefined;
|
|
1056
|
+
return {
|
|
1057
|
+
status: login ? "login_required" : "rate_limited",
|
|
1058
|
+
result: { service_execution_id: id, service_id: execution.service_id, ...(quota ? { quota } : {}) },
|
|
1059
|
+
instruction: login ? "匿名免费额度已用完。使用 itpay auth login 完成官方登录并绑定当前 Agent,然后继续同一执行;不要重新发起查询,不需要付款。" : "已达到登录账号每分钟查询上限。请等到下一分钟后继续同一执行,不要连续重试。",
|
|
1060
|
+
next: login ? { command: "itpay auth login --json", reason: "登录后继续同一查询执行" } : resume,
|
|
1061
|
+
recovery: [resume],
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1008
1064
|
if (state === "human_action" && model.workflow?.human_action) {
|
|
1009
1065
|
const action = model.workflow.human_action;
|
|
1010
1066
|
const requiredFields = requiredInputFields(action.input_schema);
|
|
@@ -1143,6 +1199,31 @@ function servicesNextEnvelope(model) {
|
|
|
1143
1199
|
safe_payload: item.safe_payload,
|
|
1144
1200
|
}));
|
|
1145
1201
|
const selection = model.allowed_actions?.find((action) => action.type === "select_candidate");
|
|
1202
|
+
const railCatalog = currentItems.some((item) => {
|
|
1203
|
+
const container = (item.safe_payload?.result ?? item.safe_payload);
|
|
1204
|
+
return container?.catalog_page !== undefined || container?.cost_semantics !== undefined;
|
|
1205
|
+
});
|
|
1206
|
+
const pageRecovery = currentItems.flatMap((item) => {
|
|
1207
|
+
const container = (item.safe_payload?.result ?? item.safe_payload);
|
|
1208
|
+
const catalogPage = container?.catalog_page;
|
|
1209
|
+
const nextOffset = catalogPage?.next_offset;
|
|
1210
|
+
return typeof nextOffset === "number"
|
|
1211
|
+
? [{ command: `itpay services page ${execution.service_execution_id} ${item.service_capability_result_item_id} --offset ${nextOffset} --json`, reason: "读取已保存结果的同版本下一页,不重新查询、不消耗额度" }]
|
|
1212
|
+
: [];
|
|
1213
|
+
});
|
|
1214
|
+
const railCostGuidance = "铁路应付只含票价加服务费(quoted_total_minor);地面接驳是单独估算(estimated_ground_minor),两者相加是已知估算(estimated_door_to_door_minor),不是收款额。";
|
|
1215
|
+
const instruction = (delivery?.order_id
|
|
1216
|
+
? appendFeedbackPostmortemInstruction(items.length > 0
|
|
1217
|
+
? selection
|
|
1218
|
+
? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
|
|
1219
|
+
: "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
|
|
1220
|
+
: "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。", "delivered")
|
|
1221
|
+
: items.length > 0
|
|
1222
|
+
? selection
|
|
1223
|
+
? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
|
|
1224
|
+
: "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
|
|
1225
|
+
: "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。")
|
|
1226
|
+
+ (railCatalog && items.length > 0 ? ` ${railCostGuidance}` : "");
|
|
1146
1227
|
return {
|
|
1147
1228
|
status: items.length > 0 ? "result_ready" : "no_result",
|
|
1148
1229
|
result: {
|
|
@@ -1152,22 +1233,12 @@ function servicesNextEnvelope(model) {
|
|
|
1152
1233
|
delivery_mode: deliveryMode,
|
|
1153
1234
|
items,
|
|
1154
1235
|
},
|
|
1155
|
-
instruction
|
|
1156
|
-
? appendFeedbackPostmortemInstruction(items.length > 0
|
|
1157
|
-
? selection
|
|
1158
|
-
? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
|
|
1159
|
-
: "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
|
|
1160
|
-
: "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。", "delivered")
|
|
1161
|
-
: items.length > 0
|
|
1162
|
-
? selection
|
|
1163
|
-
? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
|
|
1164
|
-
: "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
|
|
1165
|
-
: "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。",
|
|
1236
|
+
instruction,
|
|
1166
1237
|
next: selection ? {
|
|
1167
1238
|
command: `itpay services action ${execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
|
|
1168
1239
|
reason: "仅在用户明确选择后锁定来源候选",
|
|
1169
1240
|
} : null,
|
|
1170
|
-
recovery:
|
|
1241
|
+
recovery: pageRecovery,
|
|
1171
1242
|
};
|
|
1172
1243
|
}
|
|
1173
1244
|
if (deliveryMode === "vault_artifact") {
|
|
@@ -1496,6 +1567,7 @@ export async function runServicesRun(backend, config, serviceID, input, options
|
|
|
1496
1567
|
const started = await backend.startServiceExecution({
|
|
1497
1568
|
service_id: serviceID,
|
|
1498
1569
|
client_context: { host: options.host ?? "terminal", ...(options.target ? { target: options.target } : {}) },
|
|
1570
|
+
...(input !== undefined ? { input } : {}),
|
|
1499
1571
|
});
|
|
1500
1572
|
id = started.execution.service_execution_id;
|
|
1501
1573
|
if (!started.workflow_entry) {
|
|
@@ -1513,6 +1585,9 @@ export async function runServicesRun(backend, config, serviceID, input, options
|
|
|
1513
1585
|
if (input !== undefined) {
|
|
1514
1586
|
model = await backend.advanceServiceExecution(id, input, `workflow-input:${id}`);
|
|
1515
1587
|
}
|
|
1588
|
+
else if (model.workflow?.status === "quota_paused") {
|
|
1589
|
+
model = await backend.advanceServiceExecution(id, undefined, `workflow-resume:${id}`);
|
|
1590
|
+
}
|
|
1516
1591
|
const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
|
|
1517
1592
|
const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
|
1518
1593
|
const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
|
package/dist/src/main.js
CHANGED
|
@@ -29,7 +29,7 @@ import { runInstall } from "./commands/install.js";
|
|
|
29
29
|
import { runSkillShow } from "./commands/skill.js";
|
|
30
30
|
import { runNext } from "./commands/next.js";
|
|
31
31
|
import { runVaultAccess, runVaultList, runVaultRead } from "./commands/vault.js";
|
|
32
|
-
import { collectOption, parseKeyValueList, runServicesAction, runServicesCheckout, runServicesEvents, runServicesGet, runServicesInvoke, runServicesList, runServicesNext, runServicesReadResult, runServicesQuote, runServicesStart, } from "./commands/services.js";
|
|
32
|
+
import { collectOption, parseKeyValueList, runServicesAction, runServicesCheckout, runServicesEvents, runServicesGet, runServicesInvoke, runServicesList, runServicesNext, runServicesPage, runServicesReadResult, runServicesQuote, runServicesStart, } from "./commands/services.js";
|
|
33
33
|
const program = new Command();
|
|
34
34
|
program
|
|
35
35
|
.name("itpay")
|
|
@@ -1267,6 +1267,35 @@ services
|
|
|
1267
1267
|
});
|
|
1268
1268
|
}
|
|
1269
1269
|
});
|
|
1270
|
+
services
|
|
1271
|
+
.command("page")
|
|
1272
|
+
.description("Read one page of a saved service result set (same stored version, no re-query, no quota)")
|
|
1273
|
+
.argument("<service_execution_id>")
|
|
1274
|
+
.argument("<result_item_id>")
|
|
1275
|
+
.option("--offset <offset>", "zero-based candidate offset", Number, 0)
|
|
1276
|
+
.option("--limit <limit>", "page size (1-20)", Number, 5)
|
|
1277
|
+
.option("--json", "output JSON")
|
|
1278
|
+
.action(async (serviceExecutionID, resultItemID, options) => {
|
|
1279
|
+
const config = loadConfig();
|
|
1280
|
+
const backend = newBackendClient(config);
|
|
1281
|
+
try {
|
|
1282
|
+
await runServicesPage(backend, serviceExecutionID, resultItemID, {
|
|
1283
|
+
offset: options.offset,
|
|
1284
|
+
limit: options.limit,
|
|
1285
|
+
jsonOutput: Boolean(options.json),
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
catch (error) {
|
|
1289
|
+
reportCLIError(error, {
|
|
1290
|
+
jsonOutput: Boolean(options.json),
|
|
1291
|
+
code: "service_page_failed",
|
|
1292
|
+
instruction: "分页读取的是已保存的同版本结果;不要重新发起查询或新建 execution。",
|
|
1293
|
+
recovery: [
|
|
1294
|
+
{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前结果页与合法动作" },
|
|
1295
|
+
],
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1270
1299
|
services
|
|
1271
1300
|
.command("start")
|
|
1272
1301
|
.description("Start a contract-backed service execution")
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { writeLocalPNG } from '../render/qr.js';
|
|
5
6
|
export function sellerAuthPath(baseURL, env = process.env, purpose = "seller") {
|
|
6
7
|
return resolve(env.HOME || homedir(), '.itpay-v3', `${purpose}-${createHash('sha256').update(baseURL).digest('hex').slice(0, 16)}.json`);
|
|
7
8
|
}
|
|
@@ -37,7 +38,13 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
37
38
|
if (backend) {
|
|
38
39
|
const current = await backend.agentAccountStatus();
|
|
39
40
|
if (current.status === 'authenticated')
|
|
40
|
-
return
|
|
41
|
+
return {
|
|
42
|
+
status: 'authenticated',
|
|
43
|
+
result: { bound: true, phone_verified: current.phone_verified === true },
|
|
44
|
+
instruction: '设备已绑定账号,可继续之前的查询或购买;登录不附带任何额外授权。',
|
|
45
|
+
next: { command: 'itpay services run <service_id> --execution <pending_execution_id> --json', reason: '恢复被额度暂停的原执行' },
|
|
46
|
+
recovery: [],
|
|
47
|
+
};
|
|
41
48
|
}
|
|
42
49
|
async function request(path, init = {}) {
|
|
43
50
|
const response = await fetcher(baseURL + path, { ...init, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
|
@@ -46,7 +53,28 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
46
53
|
return response;
|
|
47
54
|
}
|
|
48
55
|
if (action === 'login') {
|
|
49
|
-
|
|
56
|
+
if (backend) {
|
|
57
|
+
const open = read(baseURL, env, purpose);
|
|
58
|
+
if (open?.sessionID && open.pollToken && open.startToken) {
|
|
59
|
+
try {
|
|
60
|
+
const current = await (await request(`/v1/dashboard/auth-sessions/${encodeURIComponent(open.sessionID)}?poll_token=${encodeURIComponent(open.pollToken)}`)).json();
|
|
61
|
+
if (['created', 'waiting_provider', 'email_verification_required', 'merge_confirmation_required'].includes(current.status)) {
|
|
62
|
+
const url = open.authURL ?? '';
|
|
63
|
+
const qr = url ? await writeLocalPNG(url).catch(() => undefined) : undefined;
|
|
64
|
+
return {
|
|
65
|
+
status: 'auth_pending',
|
|
66
|
+
result: { dashboard_auth_session_id: open.sessionID, expires_at: current.expires_at, methods: ['phone', 'email', 'alipay', 'wechat'] },
|
|
67
|
+
handoff: { url, ...(qr ? { qr_local_path: qr.filePath, markdown: `` } : {}) },
|
|
68
|
+
instruction: '已有进行中的官方授权请求,沿用同一链接或二维码;不要生成新请求。用户在页面内选择手机号验证码、邮箱或钱包完成登录。',
|
|
69
|
+
next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
|
|
70
|
+
recovery: [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* fall through to a fresh request */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const response = await request('/v1/dashboard/auth-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ return_to: backend ? '/' : '/seller' }) });
|
|
50
78
|
const result = await response.json();
|
|
51
79
|
const url = new URL(result.start_url, baseURL);
|
|
52
80
|
const sameOrigin = url.origin === new URL(baseURL).origin;
|
|
@@ -60,7 +88,25 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
60
88
|
: url.searchParams.get('start_token') || fragment.get('start_token');
|
|
61
89
|
if (!startToken || !result.poll_token || !result.dashboard_auth_session_id)
|
|
62
90
|
throw new Error('Incomplete authorization response');
|
|
63
|
-
save({ baseURL, sessionID: result.dashboard_auth_session_id, pollToken: result.poll_token, startToken }, env, purpose);
|
|
91
|
+
save({ baseURL, sessionID: result.dashboard_auth_session_id, pollToken: result.poll_token, startToken, authURL: url.href }, env, purpose);
|
|
92
|
+
if (backend) {
|
|
93
|
+
const qr = await writeLocalPNG(url.href).catch(() => undefined);
|
|
94
|
+
return {
|
|
95
|
+
status: 'auth_pending',
|
|
96
|
+
result: {
|
|
97
|
+
dashboard_auth_session_id: result.dashboard_auth_session_id,
|
|
98
|
+
expires_at: result.expires_at,
|
|
99
|
+
methods: ['phone', 'email', 'alipay', 'wechat'],
|
|
100
|
+
},
|
|
101
|
+
handoff: {
|
|
102
|
+
url: url.href,
|
|
103
|
+
...(qr ? { qr_local_path: qr.filePath, markdown: `` } : {}),
|
|
104
|
+
},
|
|
105
|
+
instruction: '把官方授权页或二维码交给用户。用户在页面内选择手机号验证码、邮箱或钱包完成登录;不要替用户输入手机号或验证码,start_token 不要写入聊天记录。',
|
|
106
|
+
next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
|
|
107
|
+
recovery: [],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
64
110
|
return { status: 'authorization_required', authorization_url: url.href, instruction: `Complete ItPay login in the browser, then run ${command}.` };
|
|
65
111
|
}
|
|
66
112
|
const state = read(baseURL, env, purpose);
|
|
@@ -77,14 +123,50 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
77
123
|
return { status: 'login_required' };
|
|
78
124
|
const path = `/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}`;
|
|
79
125
|
const progress = await (await request(`${path}?poll_token=${encodeURIComponent(state.pollToken)}`)).json();
|
|
80
|
-
if (progress.status !== 'completed')
|
|
126
|
+
if (progress.status !== 'completed') {
|
|
127
|
+
if (backend) {
|
|
128
|
+
if (progress.status === 'expired') {
|
|
129
|
+
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
130
|
+
return {
|
|
131
|
+
status: 'auth_expired',
|
|
132
|
+
result: { dashboard_auth_session_id: state.sessionID },
|
|
133
|
+
instruction: '授权请求已过期。重新运行 itpay auth login 生成新请求;之前的查询输入在服务端保留,可恢复。',
|
|
134
|
+
next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
|
|
135
|
+
recovery: [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (progress.status === 'failed' || progress.status === 'cancelled' || progress.status === 'denied') {
|
|
139
|
+
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
140
|
+
return {
|
|
141
|
+
status: progress.status === 'failed' ? 'auth_denied' : `auth_${progress.status}`,
|
|
142
|
+
result: { dashboard_auth_session_id: state.sessionID },
|
|
143
|
+
instruction: '官方授权未通过或被取消。重新运行 itpay auth login 生成新请求。',
|
|
144
|
+
next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
|
|
145
|
+
recovery: [],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
status: 'auth_pending',
|
|
150
|
+
result: { dashboard_auth_session_id: state.sessionID },
|
|
151
|
+
instruction: '用户仍在官方页面完成登录;保留当前 handoff,不要生成新二维码或新请求。',
|
|
152
|
+
next: { command: 'itpay auth status --json', reason: '轮询同一授权请求', poll_after_ms: 5000 },
|
|
153
|
+
recovery: [],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
81
156
|
return { status: progress.status, instruction: 'Finish login and email verification in the browser.' };
|
|
157
|
+
}
|
|
82
158
|
if (backend) {
|
|
83
159
|
const result = await backend.bindAgentAccount({ dashboard_auth_session_id: state.sessionID, start_token: state.startToken });
|
|
84
160
|
if (result.status !== 'authenticated')
|
|
85
161
|
throw new Error('Agent binding did not complete');
|
|
86
162
|
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
87
|
-
return
|
|
163
|
+
return {
|
|
164
|
+
status: 'authenticated',
|
|
165
|
+
result: { bound: true, phone_verified: result.phone_verified === true },
|
|
166
|
+
instruction: '登录与绑定已完成,告知用户可继续之前的查询;已注册账号查询不消耗试用次数,仍受正常限流。',
|
|
167
|
+
next: { command: 'itpay services run <service_id> --execution <pending_execution_id> --json', reason: '恢复被额度暂停的原查询' },
|
|
168
|
+
recovery: [],
|
|
169
|
+
};
|
|
88
170
|
}
|
|
89
171
|
const claimed = await request(`${path}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
|
|
90
172
|
const token = /(?:^|[, ]+)itpay_buyer_session=([^;]+)/.exec(claimed.headers.get('set-cookie') ?? '')?.[1];
|
package/dist/src/state/config.js
CHANGED
|
@@ -15,8 +15,8 @@ import { OperationJournal } from "./operation_journal.js";
|
|
|
15
15
|
export const DEFAULT_BASE_URL = "https://app.itpay.ai";
|
|
16
16
|
export const DEV_BASE_URL = "https://dev.itpay.ai";
|
|
17
17
|
export const SANDBOX_BASE_URL = "https://sandbox.itpay.ai";
|
|
18
|
-
export const CLI_VERSION = "2.1.
|
|
19
|
-
export const API_CONTRACT_REVISION = "sha256:
|
|
18
|
+
export const CLI_VERSION = "2.1.8";
|
|
19
|
+
export const API_CONTRACT_REVISION = "sha256:66a95bbc5ec9d7b35747cf87206c63dd45983f26de21afab582d8dd63bf198f7";
|
|
20
20
|
const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
|
|
21
21
|
const CART_SESSION_FILENAME = "cart.json";
|
|
22
22
|
const OPERATION_JOURNAL_FILENAME = "operations.json";
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "itp.agent_doc.v1",
|
|
3
3
|
"role": "buyer",
|
|
4
|
-
"product_scope": "itpay is the single public CLI entry point, and $itpay is its user-facing Skill invocation. Under that one product entry point, the two top-level commerce actions are buy and sell: Buyer workflows are available now; Seller workflows
|
|
4
|
+
"product_scope": "itpay is the single public CLI entry point, and $itpay is its user-facing Skill invocation. Under that one product entry point, the two top-level commerce actions are buy and sell: Buyer workflows are available now; Seller workflows use the same entry point via itpay sell.",
|
|
5
5
|
"topic": "rail-booking",
|
|
6
6
|
"title": "Railway Query, Planning, and Ticket Booking",
|
|
7
|
-
"purpose": "Choose the right railway service for the buyer's situation, submit the exact input contract each service expects, and follow the protected
|
|
7
|
+
"purpose": "Choose the right railway service for the buyer's situation, submit the exact input contract each service expects, bridge a chosen train into booking via the server-issued selection, and follow the protected checkout flow without inventing field names.",
|
|
8
8
|
"when_to_use": [
|
|
9
9
|
"The buyer wants train schedules, routes, or tickets.",
|
|
10
10
|
"A railway service execution fails at the geo, search, catalog, or recommend step.",
|
|
11
11
|
"The buyer only knows a city or fuzzy location rather than a station name.",
|
|
12
|
-
"The buyer asks about seat preferences, refunds, or ticket issuance status."
|
|
12
|
+
"The buyer asks about seat preferences, refunds, or ticket issuance status.",
|
|
13
|
+
"A query returned login_required or rate_limited."
|
|
13
14
|
],
|
|
14
15
|
"service_selection": {
|
|
15
16
|
"itpay-rail-exact": "Use when the buyer knows BOTH the exact departure and arrival railway station names (e.g. 古镇 → 广州南). Direct train query, fastest path. Do NOT use it with only a city or fuzzy location.",
|
|
16
17
|
"itpay-rail-smart": "Use when the buyer has a city name, an area, an address, or wants transfer planning and recommendations. The platform resolves locations to stations itself; never silently convert a city into its namesake station.",
|
|
18
|
+
"itpay-rail-booking": "Purchase service — use ONLY after the buyer explicitly chose a train and seat from a query result. Starts quote → protected checkout → payment → asynchronous issuance on one execution. Never queried for schedules.",
|
|
17
19
|
"svc_qizhidao_company_lookup": "Not a railway service; do not mix intents."
|
|
18
20
|
},
|
|
19
21
|
"input_contract": {
|
|
@@ -43,13 +45,41 @@
|
|
|
43
45
|
"max_transfers": "Integer 0-2, allowed transfer count."
|
|
44
46
|
},
|
|
45
47
|
"example": {"origin": "中山古镇", "destination": "广州南", "travel_date": "2026-09-19", "priority": "fastest"}
|
|
48
|
+
},
|
|
49
|
+
"itpay-rail-booking": {
|
|
50
|
+
"preferred": {
|
|
51
|
+
"passengers": "Integer 1-5: number of passengers, NOT passenger identity records.",
|
|
52
|
+
"selection": {
|
|
53
|
+
"token": "The booking_offer.selection_token carried on the chosen result item.",
|
|
54
|
+
"seat_type": "Seat code when the user picked a specific class, e.g. O."
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"explicit_legs": {
|
|
58
|
+
"passengers": "Integer 1-5.",
|
|
59
|
+
"legs": "Array of 1-3 legs, each: from_station_code, to_station_code, travel_date, train_code, departure_time (HH:MM), arrival_time (HH:MM), arrival_days (int 0-7), seat_type. Only use when no booking_offer is available; never guess these values."
|
|
60
|
+
},
|
|
61
|
+
"boundary": "Identity fields (names, ID numbers, phones) never belong in this input; they are collected on the protected checkout page."
|
|
46
62
|
}
|
|
47
63
|
},
|
|
64
|
+
"selection_flow": [
|
|
65
|
+
"Exact results carry per-train candidates; smart results carry journeys. Bookable options carry booking_offer with a server-signed selection_token.",
|
|
66
|
+
"The token binds the stored result snapshot and the chosen option. Submit it with passengers (and seat_type when the user picked a class); the server derives the authoritative legs and re-quotes live. Do NOT hand-build legs JSON from the visible result.",
|
|
67
|
+
"An expired or foreign token returns a clear error; recover by running a fresh query or by explicit legs — never by guessing token contents.",
|
|
68
|
+
"Repeating the same selection restores the same booking execution instead of creating a second one."
|
|
69
|
+
],
|
|
70
|
+
"trial_and_login": {
|
|
71
|
+
"policy": "itpay-rail-exact and itpay-rail-smart each allow 2 anonymous queries per enrolled device lineage — independent counters, not a shared pool of 4.",
|
|
72
|
+
"login_required": "Trial for that service is exhausted. The query is paused BEFORE provider calls; the execution keeps its input. Offer itpay auth login; after binding, resume the SAME execution with services run --execution <id> (no input file). Do not start a new execution, do not make the user re-describe the trip, and do not burn the other service's quota to dodge the gate.",
|
|
73
|
+
"rate_limited": "Registered accounts have no cumulative search cap but normal per-minute limits apply; wait retry_after_ms and resume the same execution.",
|
|
74
|
+
"fourth_result": "The 4th result is delivered first; only the next new query requires authorization.",
|
|
75
|
+
"purchase_after_trial": "Selecting a saved option and buying it does not consume search quota and works even when trial is exhausted."
|
|
76
|
+
},
|
|
48
77
|
"flow": [
|
|
49
78
|
"services start <service> --json returns input_required plus the guidance block for rail services.",
|
|
50
79
|
"Submit input with services run <service> --execution <id> --input-json <file> --json on the SAME execution.",
|
|
51
80
|
"itpay-rail-smart may stop at a location confirmation step; show the candidate places to the buyer, then continue the same execution after the human confirms.",
|
|
52
|
-
"After
|
|
81
|
+
"After the buyer explicitly picks a train/seat, run itpay-rail-booking with the selection token (preferred) or explicit legs.",
|
|
82
|
+
"The booking execution reaches a protected checkout: passenger identity data is entered by the human on that page, never through the CLI. Account authentication may happen inside checkout — do not force a separate auth login first (Path B).",
|
|
53
83
|
"Payment success does not mean the ticket is issued. Issuance is asynchronous; keep reading the same execution with services next.",
|
|
54
84
|
"If seat details are still syncing after issuance, advise the buyer to verify the itinerary on 12306 instead of repeating the query."
|
|
55
85
|
],
|
|
@@ -58,6 +88,7 @@
|
|
|
58
88
|
"geo": "Location resolution failed: check origin/destination are real place names, or attach origin_city/coordinate hints, then start a NEW execution (a failed execution is terminal).",
|
|
59
89
|
"search": "Supplier query failed: most commonly a wrong field name (must be travel_date) or a nonexistent station name; recheck input against this doc.",
|
|
60
90
|
"catalog_or_recommend": "Location resolved but no feasible trains: change the date or stations.",
|
|
91
|
+
"login_required_is_not_failure": "login_required / rate_limited pause the execution; they are NOT failures and never justify a replacement execution.",
|
|
61
92
|
"general_rule": "Never replay a failed execution and never guess different field names; correct the input and start a new execution of the same service."
|
|
62
93
|
},
|
|
63
94
|
"seat_preferences": {
|
|
@@ -66,15 +97,17 @@
|
|
|
66
97
|
"boundary": "Never claim a preference was fulfilled before supplier confirmation; check the issued seat in the order/status output."
|
|
67
98
|
},
|
|
68
99
|
"agent_rules": [
|
|
69
|
-
"Pick the service by the buyer's input precision: exact station names → itpay-rail-exact; anything fuzzier → itpay-rail-smart.",
|
|
100
|
+
"Pick the service by the buyer's input precision: exact station names → itpay-rail-exact; anything fuzzier → itpay-rail-smart; explicit purchase intent after a selection → itpay-rail-booking.",
|
|
70
101
|
"Never invent input field names. If the guidance block or this doc does not list a field, do not send it.",
|
|
71
102
|
"Never ask the buyer for passenger names, ID numbers, or phone numbers in chat; those are collected only on the protected checkout page.",
|
|
103
|
+
"Never reconstruct provider-shaped legs JSON when a booking_offer.selection_token is available; submit the token plus passengers.",
|
|
104
|
+
"login_required and rate_limited are pauses, not failures: resume the same execution, never spawn a replacement.",
|
|
72
105
|
"Do not repeat or replay a failed execution; correct the input and start a new one.",
|
|
73
106
|
"Do not claim payment equals ticket issuance; issuance is asynchronous and confirmed only by the issued seat status."
|
|
74
107
|
],
|
|
75
108
|
"human_language": {
|
|
76
|
-
"prefer": ["车次", "出发站", "到达站", "出行日期", "座位", "余票", "出票中", "已出票"],
|
|
77
|
-
"avoid": ["workflow", "execution", "provider", "seat_source", "candidate_id"]
|
|
109
|
+
"prefer": ["车次", "出发站", "到达站", "出行日期", "座位", "余票", "出票中", "已出票", "登录后继续免费查询"],
|
|
110
|
+
"avoid": ["workflow", "execution", "provider", "seat_source", "candidate_id", "token", "quota"]
|
|
78
111
|
},
|
|
79
112
|
"search_terms": ["rail", "railway", "train", "ticket", "火车", "火车票", "高铁", "车票", "车次", "seat", "booking", "rail-booking"]
|
|
80
113
|
}
|
|
@@ -1,9 +1,93 @@
|
|
|
1
1
|
# Account login
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`itpay auth` binds the enrolled Agent Device to a real ItPay account through an official authorization handoff. It is used by Path A (trial exhausted → log in → keep querying) and any time a command returns `login_required`. Path B purchases do **not** require running `itpay auth login` first — account authentication can happen inside the protected checkout instead.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Syntax
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```bash
|
|
8
|
+
itpay auth login [--json]
|
|
9
|
+
itpay auth status [--json]
|
|
10
|
+
```
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
Both commands require an enrolled Agent Device and use the current `--agent-type` and Backend (`ITPAY_BACKEND_URL`). The Agent never receives an account bearer token; binding reuses the stable device-account ownership mechanism.
|
|
13
|
+
|
|
14
|
+
## `itpay auth login`
|
|
15
|
+
|
|
16
|
+
Creates one official authorization request bound to this Backend, Device and Agent Instance, then stops. Repeating `auth login` while a valid request is open returns the same request — it does not rotate the QR code or create parallel sessions.
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"status": "auth_pending",
|
|
21
|
+
"result": {
|
|
22
|
+
"dashboard_auth_session_id": "das_...",
|
|
23
|
+
"expires_at": "2026-09-20T12:34:56Z",
|
|
24
|
+
"methods": ["phone", "email", "alipay", "wechat"]
|
|
25
|
+
},
|
|
26
|
+
"handoff": {
|
|
27
|
+
"url": "https://dev.itpay.ai/signin?dashboard_auth_session_id=das_...&start_token=...",
|
|
28
|
+
"qr_local_path": "...",
|
|
29
|
+
"qr_image_url": "...",
|
|
30
|
+
"markdown": "..."
|
|
31
|
+
},
|
|
32
|
+
"instruction": "把官方授权页或二维码交给用户。用户在页面内选择手机号验证码、邮箱或钱包完成登录;不要替用户输入手机号或验证码。",
|
|
33
|
+
"next": {
|
|
34
|
+
"command": "itpay auth status --json",
|
|
35
|
+
"reason": "用户完成页面登录后确认绑定",
|
|
36
|
+
"poll_after_ms": 5000
|
|
37
|
+
},
|
|
38
|
+
"recovery": []
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `handoff.url` is the official sign-in page carrying this authorization request. The page offers phone-code verification, email verification, and wallet options — the user chooses; the Agent must not deep-link a single wallet and call it universal authorization.
|
|
43
|
+
- The URL's `start_token` is valid only inside the official page handoff. Never log it, echo it in chat, or send it to another host.
|
|
44
|
+
- When the user already finished login before `auth login` runs, the command returns `authenticated` directly.
|
|
45
|
+
|
|
46
|
+
## `itpay auth status`
|
|
47
|
+
|
|
48
|
+
Polls the open authorization request, then performs the device binding. Safe to run repeatedly and concurrently: only the requester Device/Instance can complete the binding.
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"status": "auth_pending",
|
|
53
|
+
"result": { "dashboard_auth_session_id": "das_...", "expires_at": "..." },
|
|
54
|
+
"instruction": "用户仍在官方页面完成登录;保留当前 handoff,不要生成新二维码。",
|
|
55
|
+
"next": { "command": "itpay auth status --json", "poll_after_ms": 5000 },
|
|
56
|
+
"recovery": []
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"status": "authenticated",
|
|
63
|
+
"result": {
|
|
64
|
+
"buyer_id": "buyer_...",
|
|
65
|
+
"bound": true,
|
|
66
|
+
"query_quota": [
|
|
67
|
+
{ "service_id": "itpay-rail-exact", "policy": "free_registered", "remaining": null },
|
|
68
|
+
{ "service_id": "itpay-rail-smart", "policy": "free_registered", "remaining": null }
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"instruction": "登录与绑定已完成,告知用户可继续之前的查询;已注册账号查询不消耗试用次数,仍受正常限流。",
|
|
72
|
+
"next": { "command": "itpay services run <service_id> --execution <pending_execution_id> --json", "reason": "恢复被额度暂停的原查询" },
|
|
73
|
+
"recovery": []
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Terminal and error states:
|
|
78
|
+
|
|
79
|
+
| `status` | Meaning | Agent action |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `authenticated` | Device bound to a real account | Resume the original intent (a quota-blocked execution, the user's request) unchanged |
|
|
82
|
+
| `auth_pending` | User has not finished the page | Keep the same handoff; poll with `poll_after_ms` |
|
|
83
|
+
| `auth_expired` | Session expired before completion | Offer to start `itpay auth login` again; prior query input is still preserved server-side |
|
|
84
|
+
| `login_required` | No open request, not bound | Run `itpay auth login` |
|
|
85
|
+
| `auth_cancelled` / `auth_denied` | User declined or closed the page | Respect the refusal; do not silently re-open; the pending query stays resumable |
|
|
86
|
+
|
|
87
|
+
## Rules
|
|
88
|
+
|
|
89
|
+
- The human's phone number and SMS code are entered only on the official page — never through chat or CLI input. A `buyer_id`, a typed phone string, or a passenger phone is **not** proof of phone verification.
|
|
90
|
+
- Login never grants Vault content, passenger history, or another Agent's authority. Other devices/platforms enroll and authorize separately.
|
|
91
|
+
- After `authenticated`, server-side identity is re-read; an anonymous execution blocked at the quota step resumes with `services run <service> --execution <id> --json` — no new execution, no re-asked itinerary.
|
|
92
|
+
- Refusing or cancelling login deletes nothing: saved results and purchase options remain usable.
|
|
93
|
+
- Seller authoring uses `itpay sell auth` separately.
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
为单个 Service Execution 快速创建或恢复 Checkout,并按 Host 向人交接付款入口。它是 `services quote -> cart add --quote -> buy --cart` 的单项快捷方式,必须复用相同的 Quote、Cart 和 Checkout Use Case。
|
|
8
8
|
|
|
9
|
+
未注册/未登录用户不要求先执行 `itpay auth login`(Path B):官方 checkout 页内完成必要的钱包认证、创建或复用账号并取得页面权限;人在页面上确认旅客、通知邮箱和最终报价后付款。支付确认沿用既有设备绑定机制,不再独立绑定。checkout 内完成账号认证不等于账号已通过手机号验证;钱包显示名、账号手机号与乘车人身份互相独立。
|
|
10
|
+
|
|
9
11
|
**上游:** `services next` 返回的 `prepare_quote` capability 和已验证输入。
|
|
10
12
|
**下游:** 人完成 Checkout,随后 `checkout` 或 `services next`。
|
|
11
13
|
|
|
@@ -197,6 +197,58 @@ itpay services next <service_execution_id> [--json]
|
|
|
197
197
|
|
|
198
198
|
`succeeded` 退款改为“交付永久关闭”,并返回 `next: null`。取消、拒绝或确定未产生资金影响的失败退款不再阻塞,但旧 grant 不会复活;用户必须重新授权。
|
|
199
199
|
|
|
200
|
+
## 额度暂停(试用/限流)
|
|
201
|
+
|
|
202
|
+
查询 Execution 因免费试用耗尽或限流被暂停时,状态为 `login_required` 或 `rate_limited`(不是 `failed`)。Execution 保留输入,可用同一 `services run <service> --execution <id> --json` 恢复——登录后或限流窗口过后从额度节点继续,不重放已完成阶段,不要新建 Execution 或重述行程。
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"status": "login_required",
|
|
207
|
+
"result": {
|
|
208
|
+
"service_execution_id": "<id>",
|
|
209
|
+
"service_id": "itpay-rail-exact",
|
|
210
|
+
"admission": "login_required",
|
|
211
|
+
"query_quota": [
|
|
212
|
+
{ "service_id": "itpay-rail-exact", "used": 2, "limit": 2, "remaining": 0 },
|
|
213
|
+
{ "service_id": "itpay-rail-smart", "used": 0, "limit": 2, "remaining": 2 }
|
|
214
|
+
]
|
|
215
|
+
},
|
|
216
|
+
"instruction": "如实告知该服务的免费试用次数与剩余;请用户登录后继续,原查询无需重新输入。",
|
|
217
|
+
"next": { "command": "itpay auth login --json", "reason": "官方登录绑定后继续免费查询" },
|
|
218
|
+
"recovery": [
|
|
219
|
+
{ "command": "itpay services run itpay-rail-exact --execution <id> --json", "when": "auth_bound" }
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`rate_limited` 只等待 `retry_after_ms` 后恢复同一执行;不需要登录、不扣试用。
|
|
225
|
+
|
|
226
|
+
## 查询结果中的购买承接
|
|
227
|
+
|
|
228
|
+
铁路查询结果项可携带服务端签发的购票承接 `booking_offer`:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"rank": 1,
|
|
233
|
+
"title": "C6908 二等座 11:48→12:20",
|
|
234
|
+
"safe_payload": { "...": "..." },
|
|
235
|
+
"booking_offer": {
|
|
236
|
+
"service_id": "itpay-rail-booking",
|
|
237
|
+
"selection_token": "sel_...",
|
|
238
|
+
"expires_at": "2026-09-20T04:00:00Z"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
用户明确选择某班次/席别并要求购买时,用该 token 开启购票执行:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
itpay services run itpay-rail-booking --input-json booking.json --json
|
|
247
|
+
# booking.json: {"passengers": 1, "selection": {"token": "<selection_token>", "seat_type": "O"}}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`booking_offer` 只是受条件约束的可选承接:查询完成本身已满足查询目标,不自动购买;过期 token 返回明确错误,恢复方式是按 `services run` 输入合同重新查询或显式提供 `legs`。
|
|
251
|
+
|
|
200
252
|
## 异常处理
|
|
201
253
|
|
|
202
254
|
execution 不存在或不属于当前设备/账号时返回错误信封,并仅建议:
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# `itpay services page`
|
|
2
|
+
|
|
3
|
+
> **Product boundary:** `itpay` is the single public CLI entry point, and `$itpay` is its user-facing Skill invocation. Under that one product entry point, the two top-level commerce actions are `buy` and `sell`: Buyer workflows are available now; Seller workflows will use the same entry point and are not implemented yet.
|
|
4
|
+
|
|
5
|
+
## 范围与意义
|
|
6
|
+
|
|
7
|
+
读取已保存 Result Item 的同版本分页。分页来自 Backend 存储的当前结果版本,不重新调用 Provider、不消耗查询额度,也不产生新的 Execution。
|
|
8
|
+
|
|
9
|
+
**上游:** `services run`、`services next` 返回的分页提示(`catalog_page.next_offset`)。
|
|
10
|
+
**下游:** 继续翻下一页,或在用户明确选择后用 `booking_offer` 承接购票。
|
|
11
|
+
|
|
12
|
+
本命令不接受 Buyer token、capability 或服务输入,不返回原始 Backend DTO 之外的额外事实;Agent 不向用户暴露 `safe_payload`、Execution 或内部 ID。
|
|
13
|
+
|
|
14
|
+
## 语法与参数
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
itpay services page <service_execution_id> <result_item_id> [--offset <offset>] [--limit <limit>] [--json]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| 参数 | 必填 | 说明 |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `service_execution_id` | 是 | 产生该结果集的 Execution ID。 |
|
|
23
|
+
| `result_item_id` | 是 | `services next` 或分页响应中的 `service_capability_result_item_id`。 |
|
|
24
|
+
| `--offset` | 否 | 从零开始的候选偏移;默认 `0`,必须是非负整数。 |
|
|
25
|
+
| `--limit` | 否 | 页大小,`1` 到 `20` 的整数;默认 `5`。 |
|
|
26
|
+
| `--json` | 否 | 输出稳定 JSON 信封;未指定时输出本页候选标题的简洁文本。 |
|
|
27
|
+
|
|
28
|
+
需要有效 Agent Device session。
|
|
29
|
+
|
|
30
|
+
## 分页信封
|
|
31
|
+
|
|
32
|
+
有候选返回 `result_page`,读尽返回 `result_page_end`:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"status": "result_page",
|
|
37
|
+
"result": {
|
|
38
|
+
"service_execution_id": "<id>",
|
|
39
|
+
"service_capability_result_item_id": "<item_id>",
|
|
40
|
+
"offset": 0,
|
|
41
|
+
"limit": 5,
|
|
42
|
+
"total": 63,
|
|
43
|
+
"count": 5,
|
|
44
|
+
"next_offset": 5,
|
|
45
|
+
"page": { "catalog_page": { "...": "..." }, "candidates": [ { "title": "<title>" } ] }
|
|
46
|
+
},
|
|
47
|
+
"instruction": "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。用普通语言向用户说明本页候选;铁路应付与地面估算费用分开表述;不要提及 safe_payload、Execution 或内部 ID。",
|
|
48
|
+
"next": { "command": "itpay services page <id> <item_id> --offset 5 --json", "reason": "读取同版本结果的下一页" },
|
|
49
|
+
"recovery": []
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`next_offset` 为 `null` 时 `next` 为 `null`;`offset > 0` 时 `recovery` 提供回到第一页的命令。页内候选可携带 `booking_offer`,承接方式与 `services next` 结果一致。
|
|
54
|
+
|
|
55
|
+
## 异常处理
|
|
56
|
+
|
|
57
|
+
`--offset` 非负整数、`--limit` 必须在 `1`–`20` 内,否则在发起请求前返回 `offset_invalid` / `limit_invalid` 信封,不消耗服务端分页。execution 或 result item 不存在、不属于当前设备/账号时返回错误信封,并仅建议:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
itpay services next <service_execution_id> --json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
分页读取的是已保存的同版本结果;不要重新发起查询或新建 execution 来"刷新"分页。
|
|
64
|
+
|
|
65
|
+
## Agent Type / Host
|
|
66
|
+
|
|
67
|
+
所有正式支持的 Local Agent Type 返回完全相同的状态、分页事实、instruction 和 next。本命令不渲染二维码,也不包含 Host handoff。
|
|
@@ -60,6 +60,86 @@ itpay services run <service_id> --execution <execution_id> --json
|
|
|
60
60
|
|
|
61
61
|
只恢复当前状态。交付、Vault 授权和退款继续使用 `services next`、`services read-result`、`vault` 与 `refund` 的既有合同。
|
|
62
62
|
|
|
63
|
+
## 免费试用额度与 `login_required`
|
|
64
|
+
|
|
65
|
+
铁路查询类服务对未登录设备提供按服务独立计数的免费试用(`itpay-rail-exact` 与 `itpay-rail-smart` 各 2 次,互不共享)。额度在查询产生供应商副作用前由服务端原子记账:地点确认、内部多阶段、轮询和已保存结果分页不重复扣次;参数校验失败且未执行查询时不扣次。
|
|
66
|
+
|
|
67
|
+
试用耗尽时,查询在供应商调用**之前**被暂停,Execution 不失败,已提交的输入服务端保留:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"status": "login_required",
|
|
72
|
+
"result": {
|
|
73
|
+
"service_execution_id": "<execution_id>",
|
|
74
|
+
"service_id": "itpay-rail-exact",
|
|
75
|
+
"admission": "login_required",
|
|
76
|
+
"query_quota": [
|
|
77
|
+
{ "service_id": "itpay-rail-exact", "used": 2, "limit": 2, "remaining": 0 },
|
|
78
|
+
{ "service_id": "itpay-rail-smart", "used": 0, "limit": 2, "remaining": 2 }
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
"instruction": "该服务的免费查询次数已用完(exact 2/2,smart 仍剩 2/2)。请用户在官方页面完成登录后继续免费查询;本次查询输入已保留,登录后恢复同一执行即可,不要新建执行或让用户重述行程。smart 额度属于另一类服务,不能拿它顶替 exact 查询。",
|
|
82
|
+
"next": {
|
|
83
|
+
"command": "itpay auth login --json",
|
|
84
|
+
"reason": "完成官方登录绑定后继续免费查询"
|
|
85
|
+
},
|
|
86
|
+
"recovery": [
|
|
87
|
+
{
|
|
88
|
+
"command": "itpay services run itpay-rail-exact --execution <execution_id> --json",
|
|
89
|
+
"when": "auth_bound",
|
|
90
|
+
"reason": "登录绑定完成后恢复同一查询执行"
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`query_quota` 如实报告两类额度各自用量,一类为零不得宣称"四次全部用完"。第四次查询的结果先正常交付,下一次新查询才需要授权。
|
|
97
|
+
|
|
98
|
+
登录绑定完成后,再次运行同一条 `services run <service> --execution <id> --json`(不带输入文件):服务端重新读取当前设备绑定身份,被暂停的 Execution 从额度节点继续,不重放已成功的供应商调用,不要求重新输入。`failed`、`recovery_required` 或已完成付款的 Execution 不能以这种方式恢复。
|
|
99
|
+
|
|
100
|
+
## `rate_limited`
|
|
101
|
+
|
|
102
|
+
已注册账号查询不设累计次数上限,但受正常每分钟限流:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"status": "rate_limited",
|
|
107
|
+
"result": {
|
|
108
|
+
"service_execution_id": "<execution_id>",
|
|
109
|
+
"service_id": "<service_id>",
|
|
110
|
+
"admission": "rate_limited",
|
|
111
|
+
"retry_after_ms": 42000
|
|
112
|
+
},
|
|
113
|
+
"instruction": "触发正常限流;等待后恢复同一执行,不要新建查询。",
|
|
114
|
+
"next": {
|
|
115
|
+
"command": "itpay services run <service_id> --execution <execution_id> --json",
|
|
116
|
+
"after_ms": 42000,
|
|
117
|
+
"reason": "限流窗口过后继续同一查询"
|
|
118
|
+
},
|
|
119
|
+
"recovery": []
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
限流不是收费、不是试用耗尽、不需要重新登录。等待中不得重复扣试用或发起新的供应商请求。
|
|
124
|
+
|
|
125
|
+
## 已选车次的购票输入(selection)
|
|
126
|
+
|
|
127
|
+
对 `itpay-rail-booking`,输入支持两种等价方式。优先使用查询结果里服务端签发的购买承接:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"passengers": 1,
|
|
132
|
+
"selection": {
|
|
133
|
+
"token": "<查询结果项中的 booking_offer.selection_token>",
|
|
134
|
+
"seat_type": "O"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`selection.token` 绑定一次具体查询结果快照与其中的班次/方案;服务端校验归属、快照版本、时效后推导权威 `legs` 并实时核价。Agent 只提交用户选定的承接、`seat_type`(必要时)与乘客人数,**绝不手工拼装 `legs`、站码、时刻或供应商形状**。显式 `legs` 输入仍然有效,字段即购票服务发布的 `input_schema`(`passengers` 为 1–5 整数人数,不是乘客身份数组)。
|
|
140
|
+
|
|
141
|
+
相同 selection 重复提交恢复同一购票执行,不会重复核价或建单。
|
|
142
|
+
|
|
63
143
|
## 终态和异常
|
|
64
144
|
|
|
65
145
|
- 成功、付款、交付、授权、退款锁等状态使用既有命令的标准信封,不额外包装 Seller 专用格式。
|
|
@@ -88,6 +88,7 @@ Commander 自动提供的 `itpay help [command]` 与 `itpay <group> help [subcom
|
|
|
88
88
|
- [`itpay services list`](commands/services/list.md)
|
|
89
89
|
- [`itpay services get`](commands/services/get.md)
|
|
90
90
|
- [`itpay services next`](commands/services/next.md)
|
|
91
|
+
- [`itpay services page`](commands/services/page.md)
|
|
91
92
|
- [`itpay services read-result`](commands/services/read-result.md)
|
|
92
93
|
- [`itpay services events`](commands/services/events.md)
|
|
93
94
|
|
package/package.json
CHANGED
package/skills/itpay/SKILL.md
CHANGED
|
@@ -31,6 +31,25 @@ purchase. If a request such as "查京东" could mean either old content or a ne
|
|
|
31
31
|
query, ask which one the human wants before calling ItPay. Do not spend quota,
|
|
32
32
|
request authorization, or start a purchase while the intent is ambiguous.
|
|
33
33
|
|
|
34
|
+
## Railway Services
|
|
35
|
+
|
|
36
|
+
Three services, three different jobs — pick by the human's input, never merge them:
|
|
37
|
+
|
|
38
|
+
| Human has | Service |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| Exact departure AND arrival station names | `itpay-rail-exact` (direct query) |
|
|
41
|
+
| City/area/address, or wants transfer planning | `itpay-rail-smart` (location resolution + planning) |
|
|
42
|
+
| Chose a train/seat and wants to buy | `itpay-rail-booking` (quote → protected checkout → issuance) |
|
|
43
|
+
|
|
44
|
+
Query results carry a server-issued `booking_offer.selection_token` on bookable
|
|
45
|
+
options; for a purchase submit that token plus passenger count — never
|
|
46
|
+
reconstruct train legs by hand. Passenger names, ID numbers and phone numbers
|
|
47
|
+
belong only on the protected checkout page, never in chat. Each query service
|
|
48
|
+
has its own free trial count (2+2, not a shared pool); `login_required` pauses
|
|
49
|
+
the query, it is not a failure — resume the same execution after `itpay auth
|
|
50
|
+
login`. For the full input contract and states, load the rail-booking topic via
|
|
51
|
+
`itpay docs search rail-booking --json`.
|
|
52
|
+
|
|
34
53
|
## Follow One Envelope
|
|
35
54
|
|
|
36
55
|
For each JSON response:
|