@itpay/cli 2.1.17 → 2.1.18

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.
@@ -5,11 +5,40 @@ import { buildAgentChatHandoff } from "../render/markdown.js";
5
5
  import { platformKeyForHost } from "../render/plan.js";
6
6
  import { renderTerminalQR } from "../render/qr.js";
7
7
  import { localizeCardURL, normalizeCardLocale } from "../render/locale.js";
8
+ import { openInSystemBrowser } from "../render/browser.js";
9
+ import { hostCapabilities, normalizeViewer } from "../state/client_context.js";
8
10
  import { DEFAULT_BASE_URL } from "../state/config.js";
9
11
  import { buildCheckoutQRPlan } from "./buy.js";
10
12
  import { buildCheckoutHandoff, shouldPrepareLocalCheckoutImage } from "./checkout_handoff.js";
11
- import { writeCommandEnvelope } from "./guidance.js";
13
+ import { CommandContractError, writeCommandEnvelope } from "./guidance.js";
14
+ import { resolvePresentation, resolveRelay } from "./presentation.js";
12
15
  export async function runCheckoutPresentation(backend, options) {
16
+ const present = options.present;
17
+ if (present !== undefined && !["auto", "browser", "image", "link", "none"].includes(present)) {
18
+ throw new CommandContractError("present_invalid", `--present must be one of auto|browser|image|link|none`, "使用支持的展示方式;未知参数已被拒绝。", []);
19
+ }
20
+ const relayRequested = Boolean(options.relayOption || options.confirmRelay || options.requestKey || options.relayStatus);
21
+ if (relayRequested && options.present && options.present !== "none") {
22
+ throw new CommandContractError("present_relay_conflict", "--present and --relay-* options are mutually exclusive", "展示和消息转发二选一;不要在同一次调用中同时开页面和发消息。", []);
23
+ }
24
+ if (options.noOpen && options.present === "browser") {
25
+ throw new CommandContractError("present_conflict", "--present browser conflicts with --no-open", "--no-open 与浏览器展示冲突;改用 --present link 或 image。", []);
26
+ }
27
+ if (options.relayStatus) {
28
+ // Read-only relay status. The backend exposes no relay routes yet, so the
29
+ // honest answer is unavailable — never a resend, never a guessed state.
30
+ writeCommandEnvelope({
31
+ status: "relay_unavailable",
32
+ result: { relay_id: options.relayStatus },
33
+ instruction: "当前 Backend 未提供消息转发状态查询;不要重发。读取原订单/Checkout 状态核对。",
34
+ next: { command: `itpay checkout --id ${options.checkoutID} --token ${options.displayToken} --json`, reason: "读取原 Checkout 当前状态" },
35
+ recovery: [],
36
+ }, {
37
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
38
+ ...(options.output ? { output: options.output } : {}),
39
+ });
40
+ return;
41
+ }
13
42
  const locale = normalizeCardLocale(options.locale);
14
43
  const presentation = await backend.getCheckoutPresentation(options.checkoutID, options.displayToken, locale === "en" ? locale : undefined);
15
44
  const host = options.host ?? "terminal";
@@ -56,12 +85,78 @@ export async function runCheckoutPresentation(backend, options) {
56
85
  if (!options.jsonOutput && platformKeyForHost(host) === "terminal") {
57
86
  plainResult.push("qr:", await renderTerminalQR(checkoutURL, "terminal"));
58
87
  }
88
+ // W5: deterministic presentation plan — business state first, then declared
89
+ // capabilities. `present` is the explicit display request; without it (or
90
+ // under --json alone) nothing opens.
91
+ // An explicit --viewer (including "unknown") wins over the host default.
92
+ const viewer = options.viewer ? normalizeViewer(options.viewer) : (host === "terminal" ? "desktop" : "unknown");
93
+ const locality = ["terminal", "codex", "claude-code"].includes(host) ? "same_device" : "unknown";
94
+ const decision = resolvePresentation({
95
+ business: {
96
+ commerce_policy: "allowed",
97
+ payment_state: "unpaid",
98
+ quote_valid: presentation.rail_quote?.expires_at
99
+ ? Date.parse(presentation.rail_quote.expires_at) > Date.now()
100
+ : true,
101
+ amount_minor: presentation.checkout.amount_minor,
102
+ },
103
+ viewer,
104
+ executor_locality: locality,
105
+ capabilities: hostCapabilities(host),
106
+ ...(explicitChoiceFor(options.present) ? { explicit_choice: explicitChoiceFor(options.present) } : {}),
107
+ });
108
+ const presentationResult = {
109
+ recommended: decision.recommended,
110
+ alternatives: decision.alternatives,
111
+ ...(decision.blocker ? { blocker: decision.blocker } : {}),
112
+ };
113
+ let dispatch = { status: "not_attempted" };
114
+ // Explicit --present browser may open even under --json (the flag IS the
115
+ // display request); --present auto under --json stays a plan and never opens.
116
+ const browserRequested = options.present === "browser"
117
+ || (options.present === "auto" && !options.jsonOutput && !options.noOpen && decision.recommended === "open_system_browser");
118
+ if (browserRequested && !options.noOpen && process.env.ITPAY_NO_BROWSER !== "1") {
119
+ const target = decision.recommended === "open_embedded_browser" ? decision.recommended : "open_system_browser";
120
+ dispatch = await openInSystemBrowser(checkoutURL, options.baseURL);
121
+ presentationResult.dispatched = { route: target, observation: dispatch.status };
122
+ // Dispatch proves the request was accepted — nothing about visibility.
123
+ if (dispatch.status === "dispatch_accepted") {
124
+ envelope.instruction += " 已发起在本机浏览器打开官方确认页;只有用户确认看到页面才视为已显示,没看到时改发链接。";
125
+ }
126
+ else {
127
+ envelope.instruction += " 浏览器打开未成功;按 handoff 链接展示,不要重复尝试同一方式。";
128
+ }
129
+ }
130
+ if (relayRequested) {
131
+ presentationResult.relay = resolveRelay({
132
+ ...(options.relayOption ? { relayOption: options.relayOption } : {}),
133
+ ...(options.confirmRelay ? { confirmRelay: options.confirmRelay } : {}),
134
+ ...(options.requestKey ? { requestKey: options.requestKey } : {}),
135
+ backendCapability: false,
136
+ });
137
+ envelope.instruction += " 消息转发当前不可用;不要声称已发送,改用现有官方入口展示。";
138
+ }
139
+ envelope.result = { ...envelope.result, presentation: presentationResult };
140
+ envelope.communication = {
141
+ schema_version: "itpay.communication.v1",
142
+ tell: decision.communication.tell,
143
+ wait_for: decision.communication.wait_for,
144
+ must_convey: decision.communication.must_convey,
145
+ };
59
146
  writeCommandEnvelope(envelope, {
60
147
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
61
148
  ...(options.output ? { output: options.output } : {}),
62
149
  plainResult,
63
150
  });
64
151
  }
152
+ function explicitChoiceFor(present) {
153
+ switch (present) {
154
+ case "browser": return "open_system_browser";
155
+ case "link": return "show_link";
156
+ case "image": return "show_official_image";
157
+ default: return undefined;
158
+ }
159
+ }
65
160
  function pendingCheckoutEnvelope(presentation, checkoutURL, plan, nextCommand, agentType, target) {
66
161
  const platform = platformKeyForHost(plan.host);
67
162
  const amount = formatMoney(presentation.checkout.amount_minor, presentation.checkout.currency);
@@ -104,13 +199,25 @@ function pendingCheckoutEnvelope(presentation, checkoutURL, plan, nextCommand, a
104
199
  })),
105
200
  } } : {}),
106
201
  ...(railPassengersPending ? { rail_passengers_confirmed: false } : {}),
202
+ ...(presentation.payment_deadline_at ? { payment_deadline_at: presentation.payment_deadline_at } : {}),
203
+ ...(typeof presentation.payment_remaining_seconds === "number"
204
+ ? { payment_remaining_seconds: presentation.payment_remaining_seconds }
205
+ : {}),
206
+ ...(presentation.server_now ? { server_now: presentation.server_now } : {}),
207
+ ...(presentation.verified_sms_contact ? { verified_sms_contact: presentation.verified_sms_contact } : {}),
208
+ ...(presentation.account_phone_option?.masked_recipient
209
+ ? { account_phone_option: { masked_recipient: presentation.account_phone_option.masked_recipient } }
210
+ : {}),
107
211
  },
108
212
  handoff: presentationHandoff.handoff,
109
213
  instruction: railPassengersPending
110
214
  ? `${presentationHandoff.instruction} 请用户在受保护网页填写乘车人并确认报价;姓名、证件和手机号只在网页填写,不要贴到对话中。座位偏好仅为购票请求、购票时才提交给供应商且不保证满足;无法逐人提交的偏好将自动分配座位,以实际出票为准。`
111
215
  : presentationHandoff.instruction,
112
216
  next: { command: nextCommand, reason: "稍后只查询同一 Checkout" },
113
- recovery: [],
217
+ recovery: [{
218
+ command: nextCommand,
219
+ reason: "付款时限由服务端统一确定,刷新不会延长;到期未支付自动取消后须重新核价下单,不要重发旧付款动作",
220
+ }],
114
221
  };
115
222
  }
116
223
  function terminalCheckoutEnvelope(presentation) {
@@ -60,6 +60,12 @@ export function writeCommandEnvelope(value, options = {}) {
60
60
  out(`instruction: ${qualified.instruction}\n`);
61
61
  if (qualified.next)
62
62
  out(`next: ${qualified.next.command}\n`);
63
+ if ("interaction" in qualified && qualified.interaction) {
64
+ out(`interaction: ${JSON.stringify(qualified.interaction)}\n`);
65
+ }
66
+ if ("communication" in qualified && qualified.communication) {
67
+ out(`communication: ${JSON.stringify(qualified.communication)}\n`);
68
+ }
63
69
  if (qualified.recovery.length > 0) {
64
70
  out("recovery:\n");
65
71
  for (const action of qualified.recovery) {
@@ -71,6 +77,10 @@ export function writeCommandEnvelope(value, options = {}) {
71
77
  function qualifyEnvelope(value, agentType) {
72
78
  return {
73
79
  ...value,
80
+ result: qualifyCommandsDeep(value.result, agentType),
81
+ ...("handoff" in value && value.handoff ? { handoff: qualifyCommandsDeep(value.handoff, agentType) } : {}),
82
+ ...("interaction" in value && value.interaction ? { interaction: qualifyCommandsDeep(value.interaction, agentType) } : {}),
83
+ ...("communication" in value && value.communication ? { communication: qualifyCommandsDeep(value.communication, agentType) } : {}),
74
84
  next: value.next ? { ...value.next, command: qualifyBackendCommand(qualifyItPayCommand(value.next.command, agentType)) } : null,
75
85
  recovery: value.recovery.map((action) => ({
76
86
  ...action,
@@ -78,6 +88,23 @@ function qualifyEnvelope(value, agentType) {
78
88
  })),
79
89
  };
80
90
  }
91
+ // Every `itpay ` command string anywhere in the envelope — result fields,
92
+ // interaction recipes/templates, available_actions — keeps the same Agent
93
+ // Type / Backend qualification as top-level next/recovery. Instruction prose
94
+ // never starts with "itpay " so prose stays untouched.
95
+ function qualifyCommandsDeep(value, agentType) {
96
+ if (typeof value === "string") {
97
+ return value.startsWith("itpay ")
98
+ ? qualifyBackendCommand(qualifyItPayCommand(value, agentType))
99
+ : value;
100
+ }
101
+ if (Array.isArray(value))
102
+ return value.map((item) => qualifyCommandsDeep(item, agentType));
103
+ if (value && typeof value === "object") {
104
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, qualifyCommandsDeep(item, agentType)]));
105
+ }
106
+ return value;
107
+ }
81
108
  export function errorRecoveryActions(error) {
82
109
  if (!(error instanceof HttpError))
83
110
  return [];
@@ -39,7 +39,10 @@ function orderEnvelope(order, delivery, lockedRefund) {
39
39
  instruction = "先告诉用户这笔订单已经取消,没有可继续的付款或交付;不要创建替代订单,除非用户另行提出新的购买。";
40
40
  }
41
41
  else if (order.status === "pending_payment") {
42
- instruction = "先告诉用户订单正在等待付款,超时未支付会被自动取消;不要创建替代订单,也不要重复发起支付授权。";
42
+ const remaining = typeof order.payment_remaining_seconds === "number" && order.payment_remaining_seconds <= 0;
43
+ instruction = remaining
44
+ ? "先告诉用户这笔订单的付款时限已到,可能已被取消;如刚完成付款请说明系统正在核对到账,不要重复支付;否则引导用户重新核价下单,刷新不会延长付款期限。"
45
+ : `先告诉用户订单正在等待付款${order.payment_deadline_at ? `,付款截止时间为 ${order.payment_deadline_at}` : ""},超时未支付会被自动取消;不要创建替代订单,也不要重复发起支付授权。`;
43
46
  next = { command: `itpay order ${order.order_id} --json`, reason: "刷新订单支付状态" };
44
47
  }
45
48
  else if (!["delivered", "refunded", "failed", "cancelled"].includes(order.status)) {
@@ -53,6 +56,10 @@ function orderEnvelope(order, delivery, lockedRefund) {
53
56
  ...(order.order_code ? { order_code: order.order_code } : {}),
54
57
  amount: formatMoney(order.amount_minor, order.currency),
55
58
  ...(order.payment_deadline_at ? { payment_deadline_at: order.payment_deadline_at } : {}),
59
+ ...(typeof order.payment_remaining_seconds === "number"
60
+ ? { payment_remaining_seconds: order.payment_remaining_seconds }
61
+ : {}),
62
+ ...(order.server_now ? { server_now: order.server_now } : {}),
56
63
  ...(delivery ? { delivery_mode: delivery.delivery_mode } : {}),
57
64
  access_locked: Boolean(lockedRefund),
58
65
  ...(delivery?.service_execution_id ? { service_execution_id: delivery.service_execution_id } : {}),
@@ -0,0 +1,153 @@
1
+ // Deterministic PresentationResolver (spec 03 §5): business state first, then
2
+ // declared viewer/executor capabilities, then the user's explicit choice and
3
+ // failure history. Output is one recommended route plus at most two feasible
4
+ // alternatives — never a parallel burst of open + send.
5
+ //
6
+ // Routes are abstract kinds; the caller maps them to concrete handoff fields
7
+ // (url, card image, qr). "unknown" capability answers are never treated as
8
+ // "yes", and a route that already failed is not retried without a new reason.
9
+ const yes = (value) => value === "yes";
10
+ export function resolvePresentation(input) {
11
+ const blocker = businessBlocker(input.business);
12
+ if (blocker) {
13
+ return {
14
+ blocker,
15
+ recommended: "none",
16
+ alternatives: [],
17
+ routes: [],
18
+ communication: communicationForBlocker(blocker),
19
+ };
20
+ }
21
+ const caps = input.capabilities;
22
+ const local = input.executor_locality === "same_device";
23
+ const routes = [];
24
+ if (input.viewer === "desktop") {
25
+ if (yes(caps.user_visible_browser))
26
+ routes.push("open_embedded_browser");
27
+ if (local && yes(caps.system_browser))
28
+ routes.push("open_system_browser");
29
+ if (yes(caps.clickable_https))
30
+ routes.push("show_link");
31
+ if (yes(caps.image_visible))
32
+ routes.push("show_official_image");
33
+ if (yes(caps.user_visible_terminal) && yes(caps.other_device_scan))
34
+ routes.push("show_terminal_qr");
35
+ }
36
+ else if (input.viewer === "mobile") {
37
+ if (yes(caps.native_url_button))
38
+ routes.push("show_mobile_button");
39
+ if (yes(caps.clickable_https))
40
+ routes.push("show_mobile_link");
41
+ if (yes(caps.user_visible_browser))
42
+ routes.push("open_user_browser");
43
+ if (local && yes(caps.system_browser))
44
+ routes.push("open_system_browser");
45
+ // QR never targets the same phone the human is holding.
46
+ }
47
+ else {
48
+ if (yes(caps.clickable_https))
49
+ routes.push("show_link");
50
+ if (routes.length === 0 && yes(caps.image_visible))
51
+ routes.push("ask_viewer_device");
52
+ }
53
+ // Relay is a consented fallback, not a presentation route — it only appears
54
+ // when an issued, verified contact option exists.
55
+ if (input.verified_phone_option === true)
56
+ routes.push("request_sms_consent");
57
+ if (input.verified_email_option === true)
58
+ routes.push("request_email_consent");
59
+ routes.push("show_copyable_entry");
60
+ const failed = new Set(input.failed_routes ?? []);
61
+ const feasible = routes.filter((route) => !failed.has(route));
62
+ const chosen = input.explicit_choice;
63
+ if (chosen && feasible.includes(chosen)) {
64
+ feasible.splice(feasible.indexOf(chosen), 1);
65
+ feasible.unshift(chosen);
66
+ }
67
+ const ranked = feasible.length > 0 ? feasible : ["show_copyable_entry"];
68
+ const [recommended, ...alternatives] = ranked;
69
+ return {
70
+ recommended: recommended,
71
+ alternatives: alternatives.slice(0, 2),
72
+ routes: ranked.slice(0, 3),
73
+ communication: {
74
+ tell: tellForRoute(recommended),
75
+ wait_for: "人完成官方页面上的确认/付款;dispatch 成功不等于已付款",
76
+ must_convey: [
77
+ "真实金额与订单归属以 owner 返回为准",
78
+ "已发起打开不代表已显示或已付款",
79
+ ...(input.business.amount_minor === undefined ? ["金额尚未确认,不称锁价或最终价"] : []),
80
+ ],
81
+ },
82
+ };
83
+ }
84
+ function businessBlocker(business) {
85
+ if (business.commerce_policy === "blocked")
86
+ return "policy_blocked";
87
+ if (business.commerce_policy === "review")
88
+ return "policy_review_required";
89
+ if (business.payment_state !== "unpaid")
90
+ return "read_same_order";
91
+ if (business.quote_valid !== true)
92
+ return "recover_expired_quote";
93
+ if (typeof business.amount_minor !== "number" || business.amount_minor <= 0)
94
+ return "read_same_order";
95
+ return undefined;
96
+ }
97
+ function communicationForBlocker(blocker) {
98
+ switch (blocker) {
99
+ case "policy_blocked":
100
+ return { tell: "该服务在当前宿主/准入策略下不可用,不绕外链或消息逃避。", wait_for: "用户更换获准渠道", must_convey: ["技术与准入分开说明"] };
101
+ case "policy_review_required":
102
+ return { tell: "当前准入策略要求复核,不发起展示动作。", wait_for: "准入复核结果", must_convey: ["不是业务失败"] };
103
+ case "read_same_order":
104
+ return { tell: "该订单不处于待付款状态;先读取原订单,不展示付款入口、不新建支付动作。", wait_for: "原订单状态", must_convey: ["用户声称已付仍须核对原单"] };
105
+ case "recover_expired_quote":
106
+ return { tell: "报价已失效或过期;读取原执行由 owner 重新核价,不向人收取旧金额。", wait_for: "新的 owner 报价", must_convey: ["旧二维码/入口不再有效"] };
107
+ }
108
+ }
109
+ function tellForRoute(route) {
110
+ switch (route) {
111
+ case "open_embedded_browser":
112
+ case "open_system_browser":
113
+ case "open_user_browser":
114
+ return "已发起打开官方页面;若用户未看到,换一种方式,仍是同一订单。";
115
+ case "show_link":
116
+ case "show_mobile_link":
117
+ return "把官方链接作为可点击入口交给用户。";
118
+ case "show_mobile_button":
119
+ return "展示原生 URL 按钮直达官方页。";
120
+ case "show_official_image":
121
+ return "展示官方图片入口(另一台设备用)。";
122
+ case "show_terminal_qr":
123
+ return "展示二维码供另一台设备扫码。";
124
+ case "request_sms_consent":
125
+ return "建议把订单入口发到已验证的手机尾号;征得明确同意后发送一条。";
126
+ case "request_email_consent":
127
+ return "建议把订单入口发到已验证的邮箱;征得明确同意后发送一封。";
128
+ case "ask_viewer_device":
129
+ return "无法判断用户在看哪种设备;最多澄清一次设备类型。";
130
+ case "show_copyable_entry":
131
+ return "给出可复制的官方入口并说明如何打开。";
132
+ default:
133
+ return "当前无可行展示方式;只说明状态,等待用户给出可用渠道。";
134
+ }
135
+ }
136
+ export function resolveRelay(input) {
137
+ if (!input.backendCapability) {
138
+ return {
139
+ kind: "unavailable",
140
+ reason: "当前 Backend 未提供消息转发(relay)能力;改用官方链接或二维码呈现,不得伪造发送结果。",
141
+ };
142
+ }
143
+ if (!input.relayOption) {
144
+ return { kind: "invalid", reason: "--relay-option 必须是服务端已签发的脱敏联系人引用。" };
145
+ }
146
+ if (!input.requestKey || !input.requestKey.trim()) {
147
+ return { kind: "invalid", reason: "--request-key 是必填的稳定幂等键。" };
148
+ }
149
+ if (!input.confirmRelay) {
150
+ return { kind: "needs_consent" };
151
+ }
152
+ return { kind: "unavailable", reason: "relay 后端未启用。" };
153
+ }