@itpay/cli 2.1.6 → 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 +141 -23
- 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) });
|
|
@@ -57,18 +57,28 @@ const WORKFLOW_STEP_GUIDANCE = {
|
|
|
57
57
|
input: { meaning: "输入校验", hint: "对照服务声明的输入契约补齐字段后重新发起" },
|
|
58
58
|
quota: { meaning: "额度检查", hint: "免费额度或限流未通过;登录或稍后重试" },
|
|
59
59
|
geo: { meaning: "位置解析", hint: "检查 origin/destination 是否为真实地名;可附 origin_city 或坐标对象提示" },
|
|
60
|
+
geo_confirm: { meaning: "确认后位置解析", hint: "用户确认的地点仍未解析成功;重新执行并让用户从候选项中按名称+坐标选择" },
|
|
61
|
+
resolved: { meaning: "位置解析复核", hint: "位置解析未满足继续条件;检查 origin/destination 后新建执行重试" },
|
|
62
|
+
resolved_after_confirm: { meaning: "位置确认复核", hint: "确认后的位置仍未通过复核;重新执行并核对用户所选候选项" },
|
|
60
63
|
search: { meaning: "供应商车次检索", hint: "最常见是字段名错误(必须是 travel_date)或站名不存在;按 itpay docs show rail-booking 核对输入" },
|
|
61
64
|
catalog: { meaning: "可行车次计算", hint: "位置已解析但无可行车次;换日期或换站点重试" },
|
|
62
65
|
recommend: { meaning: "方案推荐", hint: "候选集无法产出推荐;放宽条件或换日期重试" },
|
|
63
66
|
delivery: { meaning: "交付", hint: "结果组装失败;稍后重试或联系运营" },
|
|
64
67
|
};
|
|
65
|
-
function failedWorkflowStep(steps) {
|
|
68
|
+
function failedWorkflowStep(steps, errorCode) {
|
|
66
69
|
if (!steps)
|
|
67
70
|
return undefined;
|
|
68
71
|
for (const [step, status] of Object.entries(steps)) {
|
|
69
72
|
if (status === "failure" && step !== "failure")
|
|
70
73
|
return step;
|
|
71
74
|
}
|
|
75
|
+
// Only a backend-recorded condition_unmet proves a false branch routed to failure.
|
|
76
|
+
if (errorCode === "condition_unmet") {
|
|
77
|
+
for (const [step, status] of Object.entries(steps)) {
|
|
78
|
+
if (status === "false" && step !== "failure")
|
|
79
|
+
return step;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
72
82
|
return undefined;
|
|
73
83
|
}
|
|
74
84
|
export async function runServicesStart(backend, serviceID, options = {}) {
|
|
@@ -186,14 +196,13 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
|
|
|
186
196
|
catch (error) {
|
|
187
197
|
if (!(error instanceof HttpError) || error.code !== "verified_phone_required")
|
|
188
198
|
throw error;
|
|
189
|
-
const link = await backend.createRailPhoneLink();
|
|
190
199
|
writeCommandEnvelope({
|
|
191
200
|
status: "human_action_required",
|
|
192
|
-
result: { service_execution_id: serviceExecutionID,
|
|
193
|
-
instruction: "
|
|
194
|
-
next:
|
|
195
|
-
recovery: [{ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(input)} --json`, reason: "
|
|
196
|
-
}, { ...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"] });
|
|
197
206
|
return;
|
|
198
207
|
}
|
|
199
208
|
const envelope = invokedEnvelope(response, requestedCapability, readModel.capabilities, input);
|
|
@@ -760,6 +769,51 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
|
|
|
760
769
|
plainResult: servicesNextPlainResult(envelope.result),
|
|
761
770
|
});
|
|
762
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
|
+
}
|
|
763
817
|
export async function runServicesList(backend, options = {}) {
|
|
764
818
|
const limit = options.limit ?? 10;
|
|
765
819
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
@@ -995,12 +1049,57 @@ function servicesNextEnvelope(model) {
|
|
|
995
1049
|
recovery: [],
|
|
996
1050
|
};
|
|
997
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
|
+
}
|
|
998
1064
|
if (state === "human_action" && model.workflow?.human_action) {
|
|
1065
|
+
const action = model.workflow.human_action;
|
|
1066
|
+
const requiredFields = requiredInputFields(action.input_schema);
|
|
1067
|
+
const rawPlaces = action.context?.places;
|
|
1068
|
+
// Only the location-confirmation action carries origin/destination places;
|
|
1069
|
+
// every other human action keeps the generic envelope.
|
|
1070
|
+
const isLocationConfirmation = !!rawPlaces && typeof rawPlaces === "object" &&
|
|
1071
|
+
("origin" in rawPlaces || "destination" in rawPlaces);
|
|
1072
|
+
if (isLocationConfirmation) {
|
|
1073
|
+
const places = rawPlaces;
|
|
1074
|
+
const sides = ["origin", "destination"].map((side) => {
|
|
1075
|
+
const place = places[side];
|
|
1076
|
+
const candidates = Array.isArray(place?.resolution_candidates) ? place.resolution_candidates : [];
|
|
1077
|
+
const name = (item) => item.poi_name ?? item.query ?? item.formatted_address;
|
|
1078
|
+
return {
|
|
1079
|
+
side,
|
|
1080
|
+
status: place?.resolution_status,
|
|
1081
|
+
...(place?.resolution_status === "resolved"
|
|
1082
|
+
? { resolved_place: { name: name(place), location: place.location } }
|
|
1083
|
+
: {}),
|
|
1084
|
+
...(candidates.length
|
|
1085
|
+
? { candidates: candidates.slice(0, 5).map((item) => ({ name: name(item), location: item.location })) }
|
|
1086
|
+
: {}),
|
|
1087
|
+
};
|
|
1088
|
+
});
|
|
1089
|
+
return {
|
|
1090
|
+
status: "confirmation_required",
|
|
1091
|
+
result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
|
|
1092
|
+
required_fields: requiredFields, sides },
|
|
1093
|
+
instruction: "向用户展示 sides 中 status=needs_confirmation 一端的候选(名称+坐标),请用户选定后按 required_fields 逐项各传一个 --input:名称取候选 name,坐标取候选 location 的 [lng,lat];已 resolved 的一端把其 resolved_place 原样填入。继续同一执行,不重新查询;这一步不是购买确认。",
|
|
1094
|
+
next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`, reason: "用户确认后继续当前查询" },
|
|
1095
|
+
recovery: [],
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
999
1098
|
return {
|
|
1000
1099
|
status: "confirmation_required",
|
|
1001
|
-
result: { service_execution_id: id, service_id: execution.service_id, human_action:
|
|
1002
|
-
instruction: "
|
|
1003
|
-
next: { command: `itpay services action ${id} --action ${
|
|
1100
|
+
result: { service_execution_id: id, service_id: execution.service_id, human_action: action },
|
|
1101
|
+
instruction: "请展示待确认的内容,请用户明确确认后,按 input_schema 填写 --input 字段。继续同一执行,不重新查询;这一步不是购买确认。",
|
|
1102
|
+
next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`, reason: "用户确认后继续当前查询" },
|
|
1004
1103
|
recovery: [],
|
|
1005
1104
|
};
|
|
1006
1105
|
}
|
|
@@ -1011,7 +1110,7 @@ function servicesNextEnvelope(model) {
|
|
|
1011
1110
|
if (state === "input_required")
|
|
1012
1111
|
command = `itpay services run ${execution.service_id} --execution ${id} --input-json <file> --json`;
|
|
1013
1112
|
const guidance = railServiceGuidance(execution.service_id);
|
|
1014
|
-
const failedStep = recovery ? failedWorkflowStep(model.workflow?.steps) : undefined;
|
|
1113
|
+
const failedStep = recovery ? failedWorkflowStep(model.workflow?.steps, model.workflow?.error_code) : undefined;
|
|
1015
1114
|
const failedStepGuidance = failedStep ? WORKFLOW_STEP_GUIDANCE[failedStep] : undefined;
|
|
1016
1115
|
const failedInvocation = recovery
|
|
1017
1116
|
? [...model.provider_invocations].reverse().find((item) => typeof item.status === "string" && item.status.startsWith("failed"))
|
|
@@ -1100,6 +1199,31 @@ function servicesNextEnvelope(model) {
|
|
|
1100
1199
|
safe_payload: item.safe_payload,
|
|
1101
1200
|
}));
|
|
1102
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}` : "");
|
|
1103
1227
|
return {
|
|
1104
1228
|
status: items.length > 0 ? "result_ready" : "no_result",
|
|
1105
1229
|
result: {
|
|
@@ -1109,22 +1233,12 @@ function servicesNextEnvelope(model) {
|
|
|
1109
1233
|
delivery_mode: deliveryMode,
|
|
1110
1234
|
items,
|
|
1111
1235
|
},
|
|
1112
|
-
instruction
|
|
1113
|
-
? appendFeedbackPostmortemInstruction(items.length > 0
|
|
1114
|
-
? selection
|
|
1115
|
-
? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
|
|
1116
|
-
: "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
|
|
1117
|
-
: "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。", "delivered")
|
|
1118
|
-
: items.length > 0
|
|
1119
|
-
? selection
|
|
1120
|
-
? "搜索已完成。用编号、名称和可公开字段向用户说明结果,然后停止。只有用户明确选择候选并要求继续时才执行 next.command;不要提及 safe_payload。"
|
|
1121
|
-
: "这一步的结果已经可用。用普通语言解释可公开字段并停止;不要提及 Arazzo、safe_payload 或内部 ID。"
|
|
1122
|
-
: "告诉用户本次查询得到 0 个结果并停止。Agent 不读取其他交付、不重放当前查询、修改输入或创建新查询。",
|
|
1236
|
+
instruction,
|
|
1123
1237
|
next: selection ? {
|
|
1124
1238
|
command: `itpay services action ${execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
|
|
1125
1239
|
reason: "仅在用户明确选择后锁定来源候选",
|
|
1126
1240
|
} : null,
|
|
1127
|
-
recovery:
|
|
1241
|
+
recovery: pageRecovery,
|
|
1128
1242
|
};
|
|
1129
1243
|
}
|
|
1130
1244
|
if (deliveryMode === "vault_artifact") {
|
|
@@ -1453,6 +1567,7 @@ export async function runServicesRun(backend, config, serviceID, input, options
|
|
|
1453
1567
|
const started = await backend.startServiceExecution({
|
|
1454
1568
|
service_id: serviceID,
|
|
1455
1569
|
client_context: { host: options.host ?? "terminal", ...(options.target ? { target: options.target } : {}) },
|
|
1570
|
+
...(input !== undefined ? { input } : {}),
|
|
1456
1571
|
});
|
|
1457
1572
|
id = started.execution.service_execution_id;
|
|
1458
1573
|
if (!started.workflow_entry) {
|
|
@@ -1470,6 +1585,9 @@ export async function runServicesRun(backend, config, serviceID, input, options
|
|
|
1470
1585
|
if (input !== undefined) {
|
|
1471
1586
|
model = await backend.advanceServiceExecution(id, input, `workflow-input:${id}`);
|
|
1472
1587
|
}
|
|
1588
|
+
else if (model.workflow?.status === "quota_paused") {
|
|
1589
|
+
model = await backend.advanceServiceExecution(id, undefined, `workflow-resume:${id}`);
|
|
1590
|
+
}
|
|
1473
1591
|
const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
|
|
1474
1592
|
const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
|
1475
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:
|