@itpay/cli 0.2.17 → 2.0.0-rc.1

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.
Files changed (69) hide show
  1. package/README.md +148 -447
  2. package/bin/itp +1 -150
  3. package/dist/src/client/backend.js +154 -0
  4. package/dist/src/client/http.js +76 -0
  5. package/dist/src/client/types.js +4 -0
  6. package/dist/src/commands/buy.js +351 -0
  7. package/dist/src/commands/cart.js +264 -0
  8. package/dist/src/commands/catalog.js +26 -0
  9. package/dist/src/commands/checkout.js +106 -0
  10. package/dist/src/commands/docs.js +61 -0
  11. package/dist/src/commands/guidance.js +422 -0
  12. package/dist/src/commands/install.js +95 -0
  13. package/dist/src/commands/order.js +78 -0
  14. package/dist/src/commands/orders.js +22 -0
  15. package/dist/src/commands/pay.js +26 -0
  16. package/dist/src/commands/readyz.js +8 -0
  17. package/dist/src/commands/refund.js +20 -0
  18. package/dist/src/commands/services.js +317 -0
  19. package/dist/src/main.js +606 -0
  20. package/dist/src/render/feishu.js +201 -0
  21. package/dist/src/render/ide.js +321 -0
  22. package/dist/src/render/index.js +57 -0
  23. package/dist/src/render/interaction.js +49 -0
  24. package/dist/src/render/markdown.js +83 -0
  25. package/dist/src/render/output.js +42 -0
  26. package/dist/src/render/plain_chat.js +60 -0
  27. package/dist/src/render/plan.js +31 -0
  28. package/dist/src/render/qr.js +32 -0
  29. package/dist/src/render/sink.js +6 -0
  30. package/dist/src/render/status.js +37 -0
  31. package/dist/src/render/telegram.js +172 -0
  32. package/dist/src/render/terminal.js +148 -0
  33. package/dist/src/render/terminal_image.js +19 -0
  34. package/dist/src/state/cart_session.js +151 -0
  35. package/dist/src/state/client_context.js +73 -0
  36. package/dist/src/state/config.js +82 -0
  37. package/dist/src/state/device_authority.js +217 -0
  38. package/dist/src/state/operation_journal.js +80 -0
  39. package/docs/agent/buyer/cart-checkout.json +56 -94
  40. package/docs/agent/buyer/catalog-list.json +47 -0
  41. package/docs/agent/buyer/install-and-setup.json +82 -0
  42. package/docs/agent/buyer/orders-refunds.json +76 -0
  43. package/docs/agent/buyer/payment-flow.json +77 -0
  44. package/docs/agent/buyer/quickstart.json +143 -75
  45. package/docs/agent/buyer/render-hosts.json +79 -0
  46. package/package.json +32 -13
  47. package/skills/itpay-buyer/SKILL.md +107 -238
  48. package/docs/agent/buyer/account-portal.json +0 -81
  49. package/docs/agent/buyer/catalog-search.json +0 -106
  50. package/docs/agent/buyer/human-claim-ui.json +0 -77
  51. package/docs/agent/buyer/payment-qr.json +0 -97
  52. package/docs/agent/buyer/payment-wait.json +0 -84
  53. package/docs/agent/buyer/product-recommendation.json +0 -80
  54. package/docs/agent/buyer/qr-refresh.json +0 -67
  55. package/docs/agent/buyer/recovery.json +0 -85
  56. package/docs/agent/buyer/safety-policy.json +0 -70
  57. package/docs/agent/buyer/secure-delivery.json +0 -90
  58. package/docs/agent/buyer/vault-agent-read.json +0 -95
  59. package/install.ps1 +0 -65
  60. package/install.sh +0 -66
  61. package/lib/account-status.js +0 -157
  62. package/lib/buyer.js +0 -2332
  63. package/lib/client-context.js +0 -126
  64. package/lib/docs.js +0 -200
  65. package/lib/env.js +0 -723
  66. package/lib/http.js +0 -151
  67. package/lib/ops.js +0 -135
  68. package/lib/render-human.js +0 -718
  69. package/lib/runtime.js +0 -1456
@@ -0,0 +1,83 @@
1
+ // Markdown renderer for Codex / Claude Code hosts.
2
+ // Always produces a full output: header, order table, branded QR image,
3
+ // clickable links, checkout/payment IDs, and next-step instructions.
4
+ // Every section is mandatory — the agent must display all of it.
5
+ export function renderMarkdown(plan, options = {}) {
6
+ const out = options.output ?? ((line) => process.stdout.write(line + "\n"));
7
+ const handoff = buildAgentChatHandoff(plan);
8
+ out(`> AGENT ACTION REQUIRED: ${handoff.instruction}\n\n${handoff.markdown}`);
9
+ }
10
+ export function buildAgentChatHandoff(plan) {
11
+ const lines = ["### ItPay 付款"];
12
+ const attach = plan.ideImageAttach;
13
+ if (attach?.status === "downloaded" && attach.localPath) {
14
+ lines.push("", `![ItPay 付款二维码](<${attach.localPath}>)`);
15
+ }
16
+ else {
17
+ lines.push("", `> 二维码暂不可用:${attach?.error ?? attach?.status ?? "not prepared"}`);
18
+ }
19
+ const paymentLink = plan.platform.links[0]?.url;
20
+ if (paymentLink)
21
+ lines.push("", `[打开 ItPay 付款页面](${paymentLink})`);
22
+ const totalMinor = plan.orderItems?.reduce((sum, item) => sum + item.amountMinor * item.quantity, 0) ?? 0;
23
+ lines.push("", `金额:**${formatPrice(totalMinor, plan.orderCurrency ?? "CNY")}**`);
24
+ if (plan.orderItems?.length) {
25
+ lines.push(`项目:${plan.orderItems.map((item) => `${item.title} x${item.quantity}`).join("、")}`);
26
+ }
27
+ if (plan.afterActionCommand) {
28
+ lines.push("", `付款后查询:\`${plan.afterActionCommand}\``);
29
+ }
30
+ return {
31
+ type: "send_payment_handoff",
32
+ must_send_to_user: true,
33
+ instruction: "把下面的 Markdown 原样发送到当前聊天;二维码和付款链接都对用户可见后,才能等待付款。不要只展示工具调用或文件路径。",
34
+ markdown: lines.join("\n"),
35
+ ...(plan.afterActionCommand ? { after_visible_action: { command: plan.afterActionCommand } } : {}),
36
+ };
37
+ }
38
+ export function renderInteractionMarkdown(request) {
39
+ if (request.kind === "input") {
40
+ return renderInputMarkdown(request);
41
+ }
42
+ return renderSelectorMarkdown(request);
43
+ }
44
+ function renderInputMarkdown(req) {
45
+ const lines = [];
46
+ lines.push(`### :envelope: ${req.title}`);
47
+ lines.push("");
48
+ lines.push(`> ${req.prompt}`);
49
+ lines.push("");
50
+ for (const field of req.fields) {
51
+ lines.push(`- **${field.label}** (\`${field.id}\`, ${field.inputType}${field.required ? ", 必填" : ""})`);
52
+ if (field.placeholder)
53
+ lines.push(` placeholder: ${field.placeholder}`);
54
+ if (field.description)
55
+ lines.push(` ${field.description}`);
56
+ }
57
+ const template = buildInputTemplate(req.fields);
58
+ lines.push("");
59
+ lines.push("```json");
60
+ lines.push(JSON.stringify(template, null, 2));
61
+ lines.push("```");
62
+ return lines.join("\n");
63
+ }
64
+ function renderSelectorMarkdown(req) {
65
+ const lines = [];
66
+ const mode = req.selectionMode === "multiple" ? "多选" : "单选";
67
+ lines.push(`### ${req.title}`);
68
+ lines.push("");
69
+ lines.push(`> ${req.prompt} (${mode})`);
70
+ lines.push("");
71
+ for (const opt of req.options) {
72
+ lines.push(`- ${opt.label} -> \`${opt.value}\``);
73
+ if (opt.description)
74
+ lines.push(` ${opt.description}`);
75
+ }
76
+ return lines.join("\n");
77
+ }
78
+ function buildInputTemplate(fields) {
79
+ return Object.fromEntries(fields.map((field) => [field.id, `<${field.inputType}>`]));
80
+ }
81
+ function formatPrice(amountMinor, currency) {
82
+ return `${(amountMinor / 100).toFixed(2)} ${currency}`;
83
+ }
@@ -0,0 +1,42 @@
1
+ // Terminal output helpers. No HTTP, no SDK calls — only formatting.
2
+ export function formatMoney(amountMinor, currency) {
3
+ const major = (amountMinor / 100).toFixed(2);
4
+ return `${major} ${currency}`;
5
+ }
6
+ export function renderReady(payload) {
7
+ return `backend ${payload.status} (version ${payload.version})`;
8
+ }
9
+ export function renderOrder(order) {
10
+ const lines = [];
11
+ lines.push(`order ${order.order_id}`);
12
+ lines.push(` status: ${order.status}`);
13
+ lines.push(` checkout: ${order.checkout_id}`);
14
+ lines.push(` amount: ${formatMoney(order.amount_minor, order.currency)}`);
15
+ if (order.paid_at) {
16
+ lines.push(` paid_at: ${order.paid_at}`);
17
+ }
18
+ if (order.items.length > 0) {
19
+ lines.push(" items:");
20
+ for (const item of order.items) {
21
+ lines.push(` - ${item.title} × ${item.quantity}`);
22
+ }
23
+ }
24
+ if (order.delivery_artifacts.length > 0) {
25
+ lines.push(" delivery_artifacts:");
26
+ for (const artifact of order.delivery_artifacts) {
27
+ lines.push(` - ${artifact.delivery_artifact_id} ${artifact.status} type=${artifact.artifact_type} redacted=${artifact.sensitive_content_redacted}`);
28
+ }
29
+ }
30
+ return lines.join("\n");
31
+ }
32
+ export function renderRefund(refund) {
33
+ return [
34
+ `refund ${refund.refund_request_id}`,
35
+ ` order: ${refund.order_id}`,
36
+ ` status: ${refund.status}`,
37
+ ` amount: ${formatMoney(refund.amount_minor, refund.currency)}`,
38
+ refund.reason ? ` reason: ${refund.reason}` : "",
39
+ ]
40
+ .filter((line) => line.length > 0)
41
+ .join("\n");
42
+ }
@@ -0,0 +1,60 @@
1
+ // Plain-chat renderer for Discord / WhatsApp / `plain-chat` hosts.
2
+ // V1 had this fallback as `image_or_link_then_human_reply` — a short
3
+ // text block with the brand QR URL and the human-open link. No inline
4
+ // buttons, no callbacks. The human pastes the link into their own
5
+ // browser or wallet and reports back.
6
+ import { ideImageAttachBlock } from "./ide.js";
7
+ export function renderPlainChat(plan, options = {}) {
8
+ const out = options.output ?? ((line) => process.stdout.write(line));
9
+ const brand = plan.preferredQRSources.find((src) => src.length > 0);
10
+ const lines = [];
11
+ lines.push(plan.summary);
12
+ if (plan.checkoutID)
13
+ lines.push(`checkout_id: ${plan.checkoutID}`);
14
+ if (plan.paymentIntentID)
15
+ lines.push(`payment_intent_id: ${plan.paymentIntentID}`);
16
+ lines.push(`open: ${plan.url}`);
17
+ if (brand)
18
+ lines.push(`qr_image: ${brand}`);
19
+ if (plan.mobileWalletURL)
20
+ lines.push(`wallet: ${plan.mobileWalletURL}`);
21
+ for (const media of plan.platform.media ?? []) {
22
+ lines.push(`image: ${media.url}`);
23
+ }
24
+ for (const link of plan.platform.links) {
25
+ lines.push(`- ${link.label}: ${link.url}`);
26
+ }
27
+ for (const interaction of plan.platform.interactions ?? []) {
28
+ lines.push("");
29
+ lines.push(renderInteractionText(interaction));
30
+ }
31
+ if (plan.ideImageAttach) {
32
+ lines.push("");
33
+ for (const line of ideImageAttachBlock(plan.ideImageAttach)) {
34
+ lines.push(line);
35
+ }
36
+ }
37
+ out(lines.join("\n") + "\n");
38
+ }
39
+ export function renderPlainChatInteraction(request, options = {}) {
40
+ const out = options.output ?? ((line) => process.stdout.write(line));
41
+ out(renderInteractionText(request) + "\n");
42
+ }
43
+ function renderInteractionText(request) {
44
+ const lines = [`${request.title}`, request.prompt];
45
+ for (const media of request.media ?? []) {
46
+ lines.push(`image: ${media.url}`);
47
+ }
48
+ if (request.kind === "input") {
49
+ for (const field of request.fields) {
50
+ lines.push(`field ${field.id}: ${field.label} (${field.inputType}${field.required ? ", required" : ""})`);
51
+ }
52
+ lines.push(`reply_json: ${JSON.stringify(Object.fromEntries(request.fields.map((field) => [field.id, `<${field.inputType}>`])))}`);
53
+ return lines.join("\n");
54
+ }
55
+ lines.push(`selection_mode: ${request.selectionMode ?? "single"}`);
56
+ request.options.forEach((option, index) => {
57
+ lines.push(`${index + 1}. ${option.label} [${option.id}] => ${option.value}`);
58
+ });
59
+ return lines.join("\n");
60
+ }
@@ -0,0 +1,31 @@
1
+ // V3 render plan: the contract between `buy` / `checkout` and the
2
+ // per-host renderers. The CLI builds a `RenderPlan` from the canonical
3
+ // V3 checkout response, then `selectPlatform` picks the host-specific
4
+ // `RenderPlatform` payload. Each renderer is pure: it takes a plan and
5
+ // a sink and writes to stdout/stderr. No HTTP, no state mutation.
6
+ //
7
+ // Mirrors V1's `lib/render-human.js` shape: `kind` is the high-level
8
+ // action the buyer needs to take (`auth_qr` or `payment_qr`); `host`
9
+ // is the consumer; `links` and `presentation` carry the brand assets.
10
+ // Pick the platform key for a given host. Discord / WhatsApp and
11
+ // unrecognised hosts fall back to plain-chat, matching V1.
12
+ export function platformKeyForHost(host) {
13
+ switch (host) {
14
+ case "terminal":
15
+ return "terminal";
16
+ case "codex":
17
+ case "claude-code":
18
+ return "markdown";
19
+ case "telegram":
20
+ return "telegram";
21
+ case "feishu":
22
+ return "feishu";
23
+ case "lark":
24
+ return "lark";
25
+ case "discord":
26
+ case "whatsapp":
27
+ case "plain-chat":
28
+ default:
29
+ return "plain_chat";
30
+ }
31
+ }
@@ -0,0 +1,32 @@
1
+ // QR helpers used by the terminal renderer. The CLI never invents a
2
+ // brand QR on its own — it consumes the brand URLs the V3 backend
3
+ // hands back (qr_png_url / preferred_qr_url / mobile_wallet_url).
4
+ // For `auth_qr` (provider authorization) the CLI is allowed to
5
+ // generate a local QR from the brand URL because no provider QR
6
+ // exists yet. For `payment_qr` the CLI must not regenerate the QR.
7
+ import QRCode from "qrcode";
8
+ import fs from "node:fs/promises";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { randomUUID } from "node:crypto";
12
+ function terminalQRType(format) {
13
+ return format === "utf8" ? "utf8" : "terminal";
14
+ }
15
+ export async function renderTerminalQR(url, format) {
16
+ return QRCode.toString(url, {
17
+ type: format === "unicode" ? "utf8" : terminalQRType(format),
18
+ small: true,
19
+ errorCorrectionLevel: "L",
20
+ });
21
+ }
22
+ export async function writeLocalPNG(url) {
23
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "itpay-v3-"));
24
+ const filePath = path.join(dir, `itpay-${randomUUID()}.png`);
25
+ await QRCode.toFile(filePath, url, {
26
+ type: "png",
27
+ errorCorrectionLevel: "M",
28
+ margin: 2,
29
+ width: 512,
30
+ });
31
+ return { filePath, mimeType: "image/png" };
32
+ }
@@ -0,0 +1,6 @@
1
+ // Centralised output sink used by every command. Lets tests silence
2
+ // stdout while still letting the real CLI write to the terminal.
3
+ export const defaultOutput = (line) => process.stdout.write(line);
4
+ export function resolveOutput(sink) {
5
+ return sink ?? defaultOutput;
6
+ }
@@ -0,0 +1,37 @@
1
+ // Maps backend enum values to short terminal hints. Keep this file
2
+ // narrow on purpose: presentation is a CLI concern, not business truth.
3
+ export const CHECKOUT_STATUS_HINTS = {
4
+ draft: "Checkout is still being assembled.",
5
+ quote_bound: "Show the checkout QR to the buyer to start the human flow.",
6
+ auth_required: "Buyer must complete provider authorization before payment.",
7
+ payment_required: "Frontend should create a payment intent.",
8
+ payment_pending: "Provider is processing the payment. Wait for the verify callback.",
9
+ payment_succeeded: "Payment verified. Awaiting delivery.",
10
+ completed: "Order is complete and delivered.",
11
+ failed: "Checkout failed. See server logs for the underlying reason.",
12
+ expired: "Checkout expired. Create a new checkout to retry.",
13
+ refunded: "Order has been fully refunded.",
14
+ };
15
+ export const ORDER_STATUS_HINTS = {
16
+ pending_payment: "Order is waiting for payment verification.",
17
+ paid: "Payment verified. Delivery in progress.",
18
+ delivery_pending: "Order is queued for delivery preparation.",
19
+ delivered: "Order is delivered. No further action needed.",
20
+ failed: "Order failed. Contact support.",
21
+ partially_refunded: "A partial refund has been issued.",
22
+ refunded: "Order has been fully refunded.",
23
+ cancelled: "Order has been cancelled.",
24
+ };
25
+ export const REFUND_STATUS_HINTS = {
26
+ requested: "Refund request received. Awaiting review.",
27
+ policy_review_required: "Refund requires human/admin review.",
28
+ accepted: "Refund accepted by the platform. Provider call in flight.",
29
+ provider_pending: "Refund is being processed by the provider.",
30
+ succeeded: "Refund completed.",
31
+ failed: "Refund failed. See server logs.",
32
+ cancelled: "Refund was cancelled before it completed.",
33
+ };
34
+ export function hintFor(kind, status) {
35
+ const table = kind === "checkout" ? CHECKOUT_STATUS_HINTS : kind === "order" ? ORDER_STATUS_HINTS : REFUND_STATUS_HINTS;
36
+ return table[status] ?? `Status: ${status}`;
37
+ }
@@ -0,0 +1,172 @@
1
+ // Telegram renderer for the V3 CLI. Builds an `openclaw_message`
2
+ // command payload the agent can hand to the OpenClaw gateway, plus
3
+ // a structured `presentation.blocks` for native inline buttons.
4
+ //
5
+ // V1 only rendered `auth_qr` and `payment_qr`; V3 also has
6
+ // `checkout_qr` (a buyer scans a branded QR from the CLI/agent to
7
+ // land on the human checkout page). We emit the same button shape
8
+ // for all three kinds but adjust the button intent.
9
+ import { ideImageAttachBlock } from "./ide.js";
10
+ function buttonsFor(plan) {
11
+ if (plan.kind === "payment_qr" && plan.paymentIntentID) {
12
+ return [
13
+ { label: "支付遇到问题 / 刷新", kind: "callback", intent: "refresh_payment_qr", ref: plan.paymentIntentID },
14
+ { label: "我已付款,查询状态", kind: "callback", intent: "check_payment_status", ref: plan.paymentIntentID },
15
+ ];
16
+ }
17
+ if (plan.kind === "auth_qr" && plan.checkoutID) {
18
+ return [
19
+ { label: "打开授权页面", kind: "url", url: plan.url },
20
+ { label: "查询授权状态", kind: "callback", intent: "check_checkout_status", ref: plan.checkoutID },
21
+ ];
22
+ }
23
+ // checkout_qr
24
+ return [
25
+ { label: "打开 ItPay 收银台", kind: "url", url: plan.url },
26
+ ...(plan.checkoutID
27
+ ? [{ label: "查询 Checkout 状态", kind: "callback", intent: "check_checkout_status", ref: plan.checkoutID }]
28
+ : []),
29
+ ];
30
+ }
31
+ function format(plan) {
32
+ return plan.kind === "payment_qr" ? "photo_text_inline_buttons" : "text_inline_buttons";
33
+ }
34
+ export function renderTelegram(plan, options) {
35
+ const out = options.output ?? ((line) => process.stdout.write(line));
36
+ const buttons = buttonsFor(plan);
37
+ const media = collectTelegramMedia(plan);
38
+ const text = plan.kind === "payment_qr"
39
+ ? `ItPay payment QR — ${plan.summary}`
40
+ : plan.kind === "auth_qr"
41
+ ? `ItPay auth required — ${plan.summary}`
42
+ : `ItPay checkout QR — ${plan.summary}`;
43
+ const presentation = {
44
+ format: format(plan),
45
+ media,
46
+ text,
47
+ links: plan.platform.links,
48
+ buttons,
49
+ interactions: plan.platform.interactions ?? [],
50
+ blocks: [
51
+ { type: "text", text },
52
+ ...(media.length > 0 ? [{ type: "image", url: media[0].url }] : []),
53
+ { type: "buttons", buttons },
54
+ ],
55
+ ...(plan.ideImageAttach
56
+ ? {
57
+ ide_image_attach: {
58
+ status: plan.ideImageAttach.status,
59
+ local_path: plan.ideImageAttach.localPath,
60
+ mirrors: plan.ideImageAttach.mirrors,
61
+ mime_type: plan.ideImageAttach.mimeType,
62
+ source: plan.ideImageAttach.source,
63
+ ...(plan.ideImageAttach.caption ? { caption: plan.ideImageAttach.caption } : {}),
64
+ must_render_reason: plan.ideImageAttach.mustRenderReason,
65
+ ...(plan.ideImageAttach.error ? { error: plan.ideImageAttach.error } : {}),
66
+ action: "agent_must_render_into_ide_chat",
67
+ instructions: ideImageAttachBlock(plan.ideImageAttach).filter((l) => l.length > 0),
68
+ },
69
+ }
70
+ : {}),
71
+ };
72
+ const openclawMessage = {
73
+ command: [
74
+ "openclaw",
75
+ "message",
76
+ "send",
77
+ "--channel",
78
+ "telegram",
79
+ "--target",
80
+ options.target,
81
+ "--message",
82
+ text,
83
+ ...(media.length > 0 ? ["--media", media[0].url] : []),
84
+ "--presentation",
85
+ JSON.stringify(presentation),
86
+ ],
87
+ // Hint: agents that cannot run openclaw must stop and tell the user
88
+ // they lack the native Telegram buttons tool — never downgrade.
89
+ if_unavailable: "Current agent cannot run `openclaw message send`. Stop and tell the user the native Telegram inline-button tool is missing; do not downgrade to a markdown table or plain link.",
90
+ };
91
+ out(JSON.stringify({ presentation, openclaw_message: openclawMessage }, null, 2) + "\n");
92
+ }
93
+ export function renderTelegramInteraction(request, options) {
94
+ const out = options.output ?? ((line) => process.stdout.write(line));
95
+ const media = (request.media ?? []).map((item) => ({
96
+ url: item.url,
97
+ mimeType: item.mimeType ?? "image/png",
98
+ }));
99
+ const buttons = request.kind === "selector"
100
+ ? request.options.map((option) => selectorButton(request.id, option))
101
+ : [];
102
+ const text = `${request.title} — ${request.prompt}`;
103
+ const presentation = {
104
+ format: request.kind === "selector" ? "text_inline_buttons" : "text",
105
+ media,
106
+ text,
107
+ buttons,
108
+ input_request: request.kind === "input"
109
+ ? {
110
+ type: "itpay_input_request",
111
+ id: request.id,
112
+ submit_label: request.submitLabel ?? "Submit",
113
+ fields: request.fields,
114
+ }
115
+ : undefined,
116
+ selector_request: request.kind === "selector"
117
+ ? {
118
+ type: "itpay_selector_request",
119
+ id: request.id,
120
+ selection_mode: request.selectionMode ?? "single",
121
+ submit_label: request.submitLabel ?? "Confirm",
122
+ options: request.options,
123
+ }
124
+ : undefined,
125
+ blocks: [
126
+ { type: "text", text },
127
+ ...(media.map((item) => ({ type: "image", url: item.url }))),
128
+ ...(request.kind === "input"
129
+ ? [{ type: "input_request", request_id: request.id, fields: request.fields }]
130
+ : [{ type: "buttons", buttons }]),
131
+ ],
132
+ };
133
+ const openclawMessage = {
134
+ command: [
135
+ "openclaw",
136
+ "message",
137
+ "send",
138
+ "--channel",
139
+ "telegram",
140
+ "--target",
141
+ options.target,
142
+ "--message",
143
+ text,
144
+ ...(media.length > 0 ? ["--media", media[0].url] : []),
145
+ "--presentation",
146
+ JSON.stringify(presentation),
147
+ ],
148
+ if_unavailable: "Current agent cannot run `openclaw message send`. Stop and tell the user the native Telegram input/button tool is missing; do not downgrade silently.",
149
+ };
150
+ out(JSON.stringify({ presentation, openclaw_message: openclawMessage }, null, 2) + "\n");
151
+ }
152
+ function collectTelegramMedia(plan) {
153
+ const media = (plan.platform.media ?? []).map((item) => ({
154
+ url: item.url,
155
+ mimeType: item.mimeType ?? "image/png",
156
+ }));
157
+ if (plan.kind === "payment_qr" || media.length === 0) {
158
+ media.unshift({
159
+ url: plan.preferredQRSources.find((src) => src.length > 0) ?? plan.url,
160
+ mimeType: "image/png",
161
+ });
162
+ }
163
+ return media;
164
+ }
165
+ function selectorButton(requestID, option) {
166
+ return {
167
+ label: option.label,
168
+ kind: "callback",
169
+ intent: "submit_selector_option",
170
+ ref: `${requestID}:${option.id}`,
171
+ };
172
+ }
@@ -0,0 +1,148 @@
1
+ // Terminal renderer for the V3 CLI.
2
+ // QR display:
3
+ // 1. Branded QR PNG from server — download + save to local file
4
+ // 2. Terminal QR — scannable ASCII QR from checkout URL (always)
5
+ // All sections MANDATORY, never skipped.
6
+ import { renderTerminalQR, writeLocalPNG } from "./qr.js";
7
+ import { renderInlineTerminalImage } from "./terminal_image.js";
8
+ import { ideImageAttachBlock } from "./ide.js";
9
+ import { copyFile } from "node:fs/promises";
10
+ export async function renderTerminal(plan, options) {
11
+ const out = options.output ?? ((line) => process.stdout.write(line));
12
+ const isPayment = plan.kind === "payment_qr";
13
+ out(`\n${"━".repeat(56)}\n`);
14
+ out(isPayment ? " ITP 待付款\n" : " ITP 收银台\n");
15
+ out(`${"━".repeat(56)}\n\n`);
16
+ // --- Order table ---
17
+ if (plan.orderItems && plan.orderItems.length > 0) {
18
+ out(" 订单详情:\n");
19
+ let total = 0;
20
+ for (const item of plan.orderItems) {
21
+ const price = fmtPrice(item.amountMinor, item.currency);
22
+ const lineTotal = item.amountMinor * item.quantity;
23
+ total += lineTotal;
24
+ out(` ${item.title} ×${item.quantity} ${price}\n`);
25
+ }
26
+ out(` ${"─".repeat(40)}\n`);
27
+ out(` 合计: ${fmtPrice(total, plan.orderCurrency ?? "CNY")}\n\n`);
28
+ }
29
+ // --- Payment info ---
30
+ if (isPayment) {
31
+ out(` 支付方式: ${plan.paymentMethod ?? "—"} 状态: ${plan.paymentStatus ?? "—"}\n`);
32
+ if (plan.paymentIntentID)
33
+ out(` 支付 ID: ${plan.paymentIntentID}\n`);
34
+ out("\n");
35
+ }
36
+ // --- Checkout IDs + links ---
37
+ out(` Checkout: ${plan.checkoutID ?? "—"}\n`);
38
+ out(` URL: ${plan.url}\n`);
39
+ for (const link of plan.platform.links) {
40
+ out(` ${link.label}: ${link.url}\n`);
41
+ }
42
+ out("\n");
43
+ // --- Branded QR PNG download ---
44
+ await downloadServerQR(plan, options, out);
45
+ // --- Terminal QR (always, from checkout URL) ---
46
+ try {
47
+ const ascii = await renderTerminalQR(plan.url, options.format);
48
+ out(ascii + "\n");
49
+ }
50
+ catch {
51
+ out(" [QR render failed]\n\n");
52
+ }
53
+ // --- Interactions ---
54
+ for (const interaction of plan.platform.interactions ?? []) {
55
+ await renderTerminalInteraction(interaction, options);
56
+ }
57
+ // --- After-action ---
58
+ if (plan.afterActionCommand) {
59
+ out(`\n ${plan.afterActionLabel ?? "Next step:"}\n`);
60
+ out(` ${plan.afterActionCommand}\n`);
61
+ }
62
+ // --- IDE image attach (mandatory) ---
63
+ if (plan.ideImageAttach) {
64
+ out("\n ── IDE 图片回显(必须执行) ──\n");
65
+ for (const line of ideImageAttachBlock(plan.ideImageAttach)) {
66
+ if (line === "") {
67
+ out("\n");
68
+ }
69
+ else {
70
+ out(` ${line.replace(/^> ?/, "")}\n`);
71
+ }
72
+ }
73
+ }
74
+ out(`\n${"━".repeat(56)}\n`);
75
+ }
76
+ async function downloadServerQR(plan, options, out) {
77
+ // Use the precomputed IDE image attach when one was attached by
78
+ // ensureIdeImageAttach (single source of truth). This avoids writing
79
+ // a second copy of the PNG and keeps the path the agent received
80
+ // consistent with what the terminal prints.
81
+ const attach = plan.ideImageAttach;
82
+ if (attach && attach.status === "downloaded" && attach.localPath) {
83
+ out(` Branded QR: ${attach.localPath}\n`);
84
+ if (attach.mirrors.length > 0) {
85
+ out(` QR mirrors: ${attach.mirrors.join(", ")}\n`);
86
+ }
87
+ out(` QR PNG URL: ${attach.source}\n`);
88
+ out(` Attach status: ${attach.status}\n`);
89
+ const target = options.qrFilePath ?? attach.localPath;
90
+ if (options.isTTY) {
91
+ renderInlineTerminalImage(target, out);
92
+ }
93
+ return;
94
+ }
95
+ if (attach && attach.status === "failed") {
96
+ out(` Branded QR: MISSING (${attach.error ?? "unknown error"})\n`);
97
+ }
98
+ if (attach && attach.status === "disabled") {
99
+ out(` Branded QR: disabled (ITPAY_IDE_IMAGE_ATTACH=0)\n`);
100
+ }
101
+ // Fallback: synthesise a local QR for the terminal so the human
102
+ // still gets a scannable image even when the backend brand PNG
103
+ // could not be obtained.
104
+ try {
105
+ const localPNG = await writeLocalPNG(plan.url);
106
+ const filePath = options.qrFilePath ?? localPNG.filePath;
107
+ if (options.qrFilePath) {
108
+ await copyFile(localPNG.filePath, filePath);
109
+ }
110
+ out(` QR file: ${filePath}\n`);
111
+ if (options.isTTY) {
112
+ renderInlineTerminalImage(filePath, out);
113
+ }
114
+ }
115
+ catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+ export async function renderTerminalInteraction(request, options) {
120
+ const out = options.output ?? ((line) => process.stdout.write(line));
121
+ out(`\nITP INPUT REQUIRED [${request.kind}]\n`);
122
+ out(` title: ${request.title}\n`);
123
+ out(` prompt: ${request.prompt}\n`);
124
+ if (request.kind === "input") {
125
+ for (const field of request.fields) {
126
+ out(` field: ${field.id} (${field.inputType}${field.required ? " required" : ""}) - ${field.label}\n`);
127
+ if (field.placeholder)
128
+ out(` placeholder: ${field.placeholder}\n`);
129
+ if (field.description)
130
+ out(` note: ${field.description}\n`);
131
+ }
132
+ out(` reply_json: ${JSON.stringify(buildInputTemplate(request.fields))}\n`);
133
+ return;
134
+ }
135
+ out(` select: ${request.selectionMode ?? "single"}\n`);
136
+ request.options.forEach((option, index) => {
137
+ out(` [${index + 1}] ${option.label} -> ${option.value}\n`);
138
+ if (option.description)
139
+ out(` ${option.description}\n`);
140
+ });
141
+ out(` reply_hint: reply with option id(s) for request ${request.id}\n`);
142
+ }
143
+ function buildInputTemplate(fields) {
144
+ return Object.fromEntries(fields.map((field) => [field.id, `<${field.inputType}>`]));
145
+ }
146
+ function fmtPrice(amountMinor, currency) {
147
+ return `${(amountMinor / 100).toFixed(2)} ${currency}`;
148
+ }
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ export function supportsInlineTerminalImages() {
4
+ return Boolean(process.env.ITERM_SESSION_ID) || process.env.TERM_PROGRAM === "iTerm.app";
5
+ }
6
+ export function renderInlineTerminalImage(filePath, out) {
7
+ if (!supportsInlineTerminalImages())
8
+ return;
9
+ try {
10
+ const buf = readFileSync(filePath);
11
+ const name = path.basename(filePath);
12
+ const nameB64 = Buffer.from(name).toString("base64");
13
+ const dataB64 = buf.toString("base64");
14
+ out(`\u001b]1337;File=name=${nameB64};size=${buf.length};inline=1;preserveAspectRatio=1:${dataB64}\u0007\n`);
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ }