@agent-cards/commerce-sdk 0.0.0-stage → 0.2.0
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/LICENSE +38 -0
- package/README.md +320 -2
- package/dist/adapters.d.ts +35 -0
- package/dist/adapters.js +23 -0
- package/dist/buy-tool.d.ts +186 -0
- package/dist/buy-tool.js +619 -0
- package/dist/client.d.ts +272 -0
- package/dist/client.js +431 -0
- package/dist/definition.d.ts +100 -0
- package/dist/definition.js +104 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.js +190 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/message.d.ts +78 -0
- package/dist/message.js +76 -0
- package/dist/messages.d.ts +48 -0
- package/dist/messages.js +206 -0
- package/dist/model-view.d.ts +73 -0
- package/dist/model-view.js +111 -0
- package/dist/verify.d.ts +41 -0
- package/dist/verify.js +137 -0
- package/examples/anthropic.mjs +55 -0
- package/examples/openrouter.mjs +53 -0
- package/examples/walkthrough.mjs +97 -0
- package/package.json +41 -4
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Node or Express `(req, res)` handler for an Agentcard webhook endpoint. It
|
|
3
|
+
* reads the raw bytes, checks the signature, hands the event to your callback,
|
|
4
|
+
* and answers once the callback is done: 200 when it returns, 500 when it
|
|
5
|
+
* throws, so Agentcard delivers the event again instead of losing it.
|
|
6
|
+
*/
|
|
7
|
+
import { type AgentcardEvent, type VerifyOptions } from './verify.js';
|
|
8
|
+
import { type PurchaseMessage } from './message.js';
|
|
9
|
+
/** The parts of a Node or Express request the handler reads. */
|
|
10
|
+
export interface WebhookRequest {
|
|
11
|
+
headers: Record<string, string | string[] | undefined>;
|
|
12
|
+
/** The bytes from `express.raw`, the text from `express.text`, or a body a JSON parser already parsed. */
|
|
13
|
+
body?: unknown;
|
|
14
|
+
/** The bytes a parser kept beside the parsed body: `express.json({ verify })`, NestJS and Firebase keep them here. */
|
|
15
|
+
rawBody?: unknown;
|
|
16
|
+
/** True once the request stream has been read to the end. */
|
|
17
|
+
readableEnded?: boolean;
|
|
18
|
+
on?(event: string, listener: (...args: any[]) => void): unknown;
|
|
19
|
+
}
|
|
20
|
+
/** The parts of a Node or Express response the handler writes. */
|
|
21
|
+
export interface WebhookResponse {
|
|
22
|
+
statusCode: number;
|
|
23
|
+
setHeader(name: string, value: string): unknown;
|
|
24
|
+
end(body?: string): unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface PurchaseAgentWebhookOptions extends Pick<VerifyOptions, 'toleranceSeconds'> {
|
|
27
|
+
/** Receives every verified event that is not a purchase message, such as `merchant.connected`. */
|
|
28
|
+
onEvent?: (event: AgentcardEvent) => void | Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Receives what went wrong: an error thrown by `onMessage` or `onEvent`, whose
|
|
31
|
+
* delivery then answers 500, a purchase message the package cannot read, or a
|
|
32
|
+
* request whose raw body was already gone. The event is undefined when no
|
|
33
|
+
* event was read. A reporter that throws or rejects is ignored. Default:
|
|
34
|
+
* `console.error`.
|
|
35
|
+
*/
|
|
36
|
+
onError?: (error: unknown, event: AgentcardEvent | undefined) => void | Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A `(req, res)` handler for your Agentcard webhook endpoint. A delivery with a
|
|
40
|
+
* missing, malformed, stale or wrong signature gets a 400 and reaches no
|
|
41
|
+
* callback. A verified delivery runs `onMessage` for a purchase message, or
|
|
42
|
+
* `opts.onEvent` for any other event, and the handler answers when the
|
|
43
|
+
* callback is done: 200 when it returns, and 500 when it throws, so Agentcard
|
|
44
|
+
* delivers the event again. Agentcard waits 10 seconds for an answer, so keep
|
|
45
|
+
* the callback short. Agentcard delivers at least once, and again after a 500,
|
|
46
|
+
* so handle each `message.eventId` once.
|
|
47
|
+
*/
|
|
48
|
+
export declare function purchaseAgentWebhook(secret: string, onMessage: (message: PurchaseMessage, event: AgentcardEvent) => void | Promise<void>, opts?: PurchaseAgentWebhookOptions): (req: WebhookRequest, res: WebhookResponse) => Promise<void>;
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Node or Express `(req, res)` handler for an Agentcard webhook endpoint. It
|
|
3
|
+
* reads the raw bytes, checks the signature, hands the event to your callback,
|
|
4
|
+
* and answers once the callback is done: 200 when it returns, 500 when it
|
|
5
|
+
* throws, so Agentcard delivers the event again instead of losing it.
|
|
6
|
+
*/
|
|
7
|
+
import { AgentcardSignatureError, InvalidEventBodyError, toleranceOf, verifyAgentcardWebhook, } from './verify.js';
|
|
8
|
+
import { readPurchaseMessage } from './message.js';
|
|
9
|
+
/** A signed Agentcard delivery is a few kilobytes; a body past this size did not come from Agentcard. */
|
|
10
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
11
|
+
const RAW_BODY_GONE = 'The webhook request reached the handler with its body already parsed and no raw bytes kept, so the signature cannot be checked. ' +
|
|
12
|
+
"Mount the route with express.raw({ type: 'application/json' }) before any express.json(), or keep the bytes with " +
|
|
13
|
+
'express.json({ verify: (req, _res, buf) => { Object.assign(req, { rawBody: buf }); } }).';
|
|
14
|
+
class BodyTooLargeError extends Error {
|
|
15
|
+
}
|
|
16
|
+
function send(res, status, body) {
|
|
17
|
+
res.statusCode = status;
|
|
18
|
+
res.setHeader('content-type', 'application/json');
|
|
19
|
+
res.end(JSON.stringify(body));
|
|
20
|
+
}
|
|
21
|
+
function headerValue(value) {
|
|
22
|
+
return Array.isArray(value) ? value.join(',') : value;
|
|
23
|
+
}
|
|
24
|
+
function toBytes(chunk) {
|
|
25
|
+
if (chunk instanceof Uint8Array)
|
|
26
|
+
return chunk;
|
|
27
|
+
if (typeof chunk === 'string')
|
|
28
|
+
return new TextEncoder().encode(chunk);
|
|
29
|
+
throw new TypeError('The request stream yielded something other than bytes.');
|
|
30
|
+
}
|
|
31
|
+
/** Reads the request stream to its end. Past the size cap it keeps draining and keeps nothing. */
|
|
32
|
+
function readStream(req) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let size = 0;
|
|
36
|
+
let tooLarge = false;
|
|
37
|
+
let failed;
|
|
38
|
+
req.on('data', (chunk) => {
|
|
39
|
+
if (tooLarge || failed !== undefined)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
const bytes = toBytes(chunk);
|
|
43
|
+
size += bytes.length;
|
|
44
|
+
if (size > MAX_BODY_BYTES) {
|
|
45
|
+
tooLarge = true;
|
|
46
|
+
chunks.length = 0;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
chunks.push(bytes);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
failed = err;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
req.on('end', () => {
|
|
56
|
+
if (failed !== undefined)
|
|
57
|
+
return reject(failed);
|
|
58
|
+
if (tooLarge)
|
|
59
|
+
return reject(new BodyTooLargeError());
|
|
60
|
+
const out = new Uint8Array(size);
|
|
61
|
+
let offset = 0;
|
|
62
|
+
for (const chunk of chunks) {
|
|
63
|
+
out.set(chunk, offset);
|
|
64
|
+
offset += chunk.length;
|
|
65
|
+
}
|
|
66
|
+
resolve(out);
|
|
67
|
+
});
|
|
68
|
+
req.on('error', reject);
|
|
69
|
+
// A client that disconnects mid-body ends the read; after 'end' this reject is a no-op.
|
|
70
|
+
req.on('close', () => reject(new Error('The request closed before its body ended.')));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function savedBody(req) {
|
|
74
|
+
if (typeof req.rawBody === 'string' || req.rawBody instanceof Uint8Array)
|
|
75
|
+
return req.rawBody;
|
|
76
|
+
if (typeof req.body === 'string' || req.body instanceof Uint8Array)
|
|
77
|
+
return req.body;
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The raw body, wherever it is: bytes a parser kept, bytes or text from a raw
|
|
82
|
+
* or text parser, or the unread stream. Undefined when a JSON parser already
|
|
83
|
+
* consumed the stream and kept no bytes. Every source is held to the same
|
|
84
|
+
* size cap, text counted in UTF-8 bytes.
|
|
85
|
+
*/
|
|
86
|
+
async function readRawBody(req) {
|
|
87
|
+
const saved = savedBody(req);
|
|
88
|
+
if (saved !== undefined) {
|
|
89
|
+
const size = typeof saved === 'string' ? new TextEncoder().encode(saved).length : saved.length;
|
|
90
|
+
if (size > MAX_BODY_BYTES)
|
|
91
|
+
throw new BodyTooLargeError();
|
|
92
|
+
return saved;
|
|
93
|
+
}
|
|
94
|
+
if (req.readableEnded === true || typeof req.on !== 'function')
|
|
95
|
+
return undefined;
|
|
96
|
+
return readStream(req);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A `(req, res)` handler for your Agentcard webhook endpoint. A delivery with a
|
|
100
|
+
* missing, malformed, stale or wrong signature gets a 400 and reaches no
|
|
101
|
+
* callback. A verified delivery runs `onMessage` for a purchase message, or
|
|
102
|
+
* `opts.onEvent` for any other event, and the handler answers when the
|
|
103
|
+
* callback is done: 200 when it returns, and 500 when it throws, so Agentcard
|
|
104
|
+
* delivers the event again. Agentcard waits 10 seconds for an answer, so keep
|
|
105
|
+
* the callback short. Agentcard delivers at least once, and again after a 500,
|
|
106
|
+
* so handle each `message.eventId` once.
|
|
107
|
+
*/
|
|
108
|
+
export function purchaseAgentWebhook(secret, onMessage, opts = {}) {
|
|
109
|
+
if (typeof secret !== 'string' || secret === '') {
|
|
110
|
+
throw new TypeError('Pass the webhook endpoint secret Agentcard returned when you created the endpoint.');
|
|
111
|
+
}
|
|
112
|
+
if (typeof onMessage !== 'function') {
|
|
113
|
+
throw new TypeError('Pass onMessage, the function that receives each purchase message.');
|
|
114
|
+
}
|
|
115
|
+
const verifyOpts = { toleranceSeconds: toleranceOf(opts) };
|
|
116
|
+
const report = (error, event) => {
|
|
117
|
+
try {
|
|
118
|
+
const reported = opts.onError ? opts.onError(error, event) : console.error('Agentcard webhook:', error);
|
|
119
|
+
// A reporter that rejects must not leave an unhandled rejection, which ends a plain Node server's process.
|
|
120
|
+
if (reported && typeof reported.then === 'function')
|
|
121
|
+
reported.then(undefined, () => { });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// A failing reporter must never take the process down with it.
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/** Runs the callback for one verified event. True when the delivery is done, false when the callback threw. */
|
|
128
|
+
const dispatch = async (event) => {
|
|
129
|
+
let message;
|
|
130
|
+
try {
|
|
131
|
+
message = readPurchaseMessage(event);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// A purchase message the package cannot read reads the same way on every delivery, so it is not sent again.
|
|
135
|
+
report(error, event);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (message)
|
|
140
|
+
await onMessage(message, event);
|
|
141
|
+
else if (opts.onEvent)
|
|
142
|
+
await opts.onEvent(event);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
report(error, event);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const handle = async (req, res) => {
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = await readRawBody(req);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof BodyTooLargeError)
|
|
157
|
+
return send(res, 413, { error: 'body_too_large' });
|
|
158
|
+
return send(res, 400, { error: 'unreadable_body' });
|
|
159
|
+
}
|
|
160
|
+
if (raw === undefined) {
|
|
161
|
+
report(new Error(RAW_BODY_GONE), undefined);
|
|
162
|
+
return send(res, 500, { error: 'raw_body_unavailable' });
|
|
163
|
+
}
|
|
164
|
+
let event;
|
|
165
|
+
try {
|
|
166
|
+
event = verifyAgentcardWebhook(raw, headerValue(req.headers['agentcard-signature']), secret, verifyOpts);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof AgentcardSignatureError)
|
|
170
|
+
return send(res, 400, { error: 'invalid_signature', reason: error.reason });
|
|
171
|
+
if (error instanceof InvalidEventBodyError)
|
|
172
|
+
return send(res, 400, { error: 'invalid_body' });
|
|
173
|
+
report(error, undefined);
|
|
174
|
+
return send(res, 500, { error: 'verification_failed' });
|
|
175
|
+
}
|
|
176
|
+
// The answer waits for the callback, so a callback that fails gets the event delivered again.
|
|
177
|
+
if (await dispatch(event))
|
|
178
|
+
return send(res, 200, { received: true });
|
|
179
|
+
return send(res, 500, { error: 'callback_failed' });
|
|
180
|
+
};
|
|
181
|
+
// The handler never rejects: an unhandled rejection would end a plain Node server's process.
|
|
182
|
+
return async (req, res) => {
|
|
183
|
+
try {
|
|
184
|
+
await handle(req, res);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
report(error, undefined);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatureReason, type AgentcardEvent, type VerifyOptions, } from './verify.js';
|
|
2
|
+
export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
|
|
3
|
+
export { purchaseAgentWebhook, type PurchaseAgentWebhookOptions, type WebhookRequest, type WebhookResponse, } from './handler.js';
|
|
4
|
+
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, } from './client.js';
|
|
5
|
+
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
|
|
6
|
+
export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
|
|
7
|
+
export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
|
|
8
|
+
export { forModel, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
|
|
9
|
+
export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { verifyAgentcardWebhook, AgentcardSignatureError, } from './verify.js';
|
|
2
|
+
export { readPurchaseMessage, } from './message.js';
|
|
3
|
+
export { purchaseAgentWebhook, } from './handler.js';
|
|
4
|
+
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, } from './client.js';
|
|
5
|
+
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
|
|
6
|
+
export { toMessages, messageStream, } from './messages.js';
|
|
7
|
+
export { buyTool, } from './buy-tool.js';
|
|
8
|
+
export { forModel, stripLinks, } from './model-view.js';
|
|
9
|
+
export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `purchase.message`: one event type for every moment of a purchase that a
|
|
3
|
+
* shopper should hear about. Each message carries the text to send as is, a
|
|
4
|
+
* note for the company's agent, and the same values as facts.
|
|
5
|
+
*/
|
|
6
|
+
import { type AgentcardEvent } from './verify.js';
|
|
7
|
+
/**
|
|
8
|
+
* The moment a message is about:
|
|
9
|
+
* - `order_placed`: the shopper approved, and Agentcard placed the order.
|
|
10
|
+
* - `order_failed`: the order failed after the approval, and nothing more happens for that approval.
|
|
11
|
+
* - `order_not_placed_yet`: the shopper approved, and the order is not placed yet.
|
|
12
|
+
* - `approval_expiring`: the approval link expires soon; `text` carries the link.
|
|
13
|
+
* - `approval_expired`: the approval expired before the shopper answered, and nothing was charged.
|
|
14
|
+
* - `card_link_unfinished`: the shopper has not added a card from their card link; `text` carries the link again.
|
|
15
|
+
* - `card_link_expired`: the card link expired before the shopper added a card.
|
|
16
|
+
* - `cart_idle`: a cart sat without being sent for approval; `facts.nudgeNumber` counts the nudges.
|
|
17
|
+
*/
|
|
18
|
+
export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'approval_expiring' | 'approval_expired' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle';
|
|
19
|
+
/** The message's values one by one, for a company that writes its own copy. A fact that does not apply is absent. */
|
|
20
|
+
export interface PurchaseFacts {
|
|
21
|
+
merchantName?: string;
|
|
22
|
+
/** The total in the currency's minor unit: 2306 is $23.06. */
|
|
23
|
+
totalCents?: number;
|
|
24
|
+
/** The three-letter currency code. */
|
|
25
|
+
currency?: string;
|
|
26
|
+
cardBrand?: string;
|
|
27
|
+
cardLast4?: string;
|
|
28
|
+
orderId?: string;
|
|
29
|
+
/** Why an order failed or was not placed, in words. */
|
|
30
|
+
reason?: string;
|
|
31
|
+
approvalUrl?: string;
|
|
32
|
+
/** When the approval link stops working, as an ISO 8601 timestamp. */
|
|
33
|
+
approvalExpiresAt?: string;
|
|
34
|
+
cardLinkUrl?: string;
|
|
35
|
+
/** Until when the order is retried, as an ISO 8601 timestamp. */
|
|
36
|
+
retryUntil?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Until when the message holds, as an ISO 8601 timestamp: the expiry of any link or window its text
|
|
39
|
+
* names. Absent when the message stays true. A message read after it is stale: drop it.
|
|
40
|
+
*/
|
|
41
|
+
validUntil?: string;
|
|
42
|
+
/** 1 for a cart's first nudge, 2 for its second. */
|
|
43
|
+
nudgeNumber?: number;
|
|
44
|
+
}
|
|
45
|
+
/** A `purchase.message` event, read. */
|
|
46
|
+
export interface PurchaseMessage {
|
|
47
|
+
/** The event id. Agentcard delivers at least once: handle each id once. */
|
|
48
|
+
eventId: string;
|
|
49
|
+
/**
|
|
50
|
+
* The moment the message is about: one of the kinds above, or a kind Agentcard adds later,
|
|
51
|
+
* read as its own name. Give a `switch` over it a default branch that sends `text`.
|
|
52
|
+
*/
|
|
53
|
+
kind: PurchaseMessageKind | (string & {});
|
|
54
|
+
/** The shopper's Agentcard user id. */
|
|
55
|
+
userId: string;
|
|
56
|
+
/** Your own id for the shopper, when you gave Agentcard one. */
|
|
57
|
+
externalUserId: string | null;
|
|
58
|
+
/** Ready to send to the shopper as is. */
|
|
59
|
+
text: string;
|
|
60
|
+
/**
|
|
61
|
+
* For your agent: the facts of what happened, marked as written by the system and not typed by
|
|
62
|
+
* the shopper. Empty when the message carries no note; send `text` then.
|
|
63
|
+
*/
|
|
64
|
+
note: string;
|
|
65
|
+
facts: PurchaseFacts;
|
|
66
|
+
/** The OAuth client id of the app the shopper connected through. */
|
|
67
|
+
clientId: string | null;
|
|
68
|
+
/** False for a sandbox event. */
|
|
69
|
+
livemode: boolean;
|
|
70
|
+
/** Unix seconds. */
|
|
71
|
+
created: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The message a `purchase.message` event carries, or null for any other event
|
|
75
|
+
* type. Pass the event `verifyAgentcardWebhook` returned. Throws a TypeError
|
|
76
|
+
* when a purchase message lacks its kind, user_id or text.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readPurchaseMessage(event: AgentcardEvent): PurchaseMessage | null;
|
package/dist/message.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `purchase.message`: one event type for every moment of a purchase that a
|
|
3
|
+
* shopper should hear about. Each message carries the text to send as is, a
|
|
4
|
+
* note for the company's agent, and the same values as facts.
|
|
5
|
+
*/
|
|
6
|
+
import { isRecord } from './verify.js';
|
|
7
|
+
/** The event type every purchase message arrives as. */
|
|
8
|
+
const PURCHASE_MESSAGE_EVENT = 'purchase.message';
|
|
9
|
+
const STRING_FACTS = [
|
|
10
|
+
['merchant_name', 'merchantName'],
|
|
11
|
+
['currency', 'currency'],
|
|
12
|
+
['card_brand', 'cardBrand'],
|
|
13
|
+
['card_last4', 'cardLast4'],
|
|
14
|
+
['order_id', 'orderId'],
|
|
15
|
+
['reason', 'reason'],
|
|
16
|
+
['approval_url', 'approvalUrl'],
|
|
17
|
+
['approval_expires_at', 'approvalExpiresAt'],
|
|
18
|
+
['card_link_url', 'cardLinkUrl'],
|
|
19
|
+
['retry_until', 'retryUntil'],
|
|
20
|
+
['valid_until', 'validUntil'],
|
|
21
|
+
];
|
|
22
|
+
const NUMBER_FACTS = [
|
|
23
|
+
['total_cents', 'totalCents'],
|
|
24
|
+
['nudge_number', 'nudgeNumber'],
|
|
25
|
+
];
|
|
26
|
+
function readFacts(raw) {
|
|
27
|
+
const facts = {};
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
return facts;
|
|
30
|
+
for (const [wire, name] of STRING_FACTS) {
|
|
31
|
+
const value = raw[wire];
|
|
32
|
+
if (typeof value === 'string' && value !== '')
|
|
33
|
+
facts[name] = value;
|
|
34
|
+
}
|
|
35
|
+
for (const [wire, name] of NUMBER_FACTS) {
|
|
36
|
+
const value = raw[wire];
|
|
37
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
38
|
+
facts[name] = value;
|
|
39
|
+
}
|
|
40
|
+
return facts;
|
|
41
|
+
}
|
|
42
|
+
function requiredString(data, field) {
|
|
43
|
+
const value = data[field];
|
|
44
|
+
if (typeof value !== 'string' || value === '') {
|
|
45
|
+
throw new TypeError(`The purchase.message event has no ${field}. Every purchase message carries kind, user_id and text.`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The message a `purchase.message` event carries, or null for any other event
|
|
51
|
+
* type. Pass the event `verifyAgentcardWebhook` returned. Throws a TypeError
|
|
52
|
+
* when a purchase message lacks its kind, user_id or text.
|
|
53
|
+
*/
|
|
54
|
+
export function readPurchaseMessage(event) {
|
|
55
|
+
if (event.type !== PURCHASE_MESSAGE_EVENT)
|
|
56
|
+
return null;
|
|
57
|
+
const data = event.data;
|
|
58
|
+
const kind = requiredString(data, 'kind');
|
|
59
|
+
const userId = requiredString(data, 'user_id');
|
|
60
|
+
const text = requiredString(data, 'text');
|
|
61
|
+
// Without a note, the text alone still tells the shopper what happened.
|
|
62
|
+
const note = typeof data.note === 'string' ? data.note : '';
|
|
63
|
+
const connection = isRecord(data.connection) ? data.connection : {};
|
|
64
|
+
return {
|
|
65
|
+
eventId: event.id,
|
|
66
|
+
kind,
|
|
67
|
+
userId,
|
|
68
|
+
externalUserId: typeof data.external_user_id === 'string' && data.external_user_id !== '' ? data.external_user_id : null,
|
|
69
|
+
text,
|
|
70
|
+
note,
|
|
71
|
+
facts: readFacts(data.facts),
|
|
72
|
+
clientId: typeof connection.client_id === 'string' && connection.client_id !== '' ? connection.client_id : null,
|
|
73
|
+
livemode: event.livemode,
|
|
74
|
+
created: event.created,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
|
|
3
|
+
* own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
|
|
4
|
+
* paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
|
|
5
|
+
* items a blank line separates.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
|
|
9
|
+
* out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
|
|
10
|
+
* lines it holds, and so does a list whose items blank lines separate.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function toMessages(text: string): string[];
|
|
17
|
+
/** What `messageStream` hands back: push each piece of text as it arrives, then end once it stops. */
|
|
18
|
+
export interface MessageStream {
|
|
19
|
+
/** Adds the next piece of text. Each message the piece completes goes to `onMessage` in order, after the one before it settled. */
|
|
20
|
+
push(delta: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* The text is whole: the last message goes to `onMessage`. Resolves once every `onMessage` call
|
|
23
|
+
* settled, and rejects with the first error one threw or rejected with. A push after `end` throws.
|
|
24
|
+
*/
|
|
25
|
+
end(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface MessageStreamOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Hears each message, once and in order, as soon as the text after it ends it. A returned promise
|
|
30
|
+
* is awaited before the next message goes out, so an asynchronous send never delivers out of order.
|
|
31
|
+
* After a send fails, no later message is sent, and `end` rejects with the failure.
|
|
32
|
+
*/
|
|
33
|
+
onMessage: (message: string) => void | Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
|
|
37
|
+
* `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
|
|
38
|
+
* paragraph inside a list waits for the first characters of the line after the blank, which say
|
|
39
|
+
* whether the list goes on. `end` hands over the last message and waits for every send.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
|
|
43
|
+
* stream.on('text', (delta) => paragraphs.push(delta));
|
|
44
|
+
* await stream.done();
|
|
45
|
+
* await paragraphs.end();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function messageStream(options: MessageStreamOptions): MessageStream;
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
|
|
3
|
+
* own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
|
|
4
|
+
* paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
|
|
5
|
+
* items a blank line separates.
|
|
6
|
+
*/
|
|
7
|
+
/** A line holding nothing but spaces or tabs. */
|
|
8
|
+
const BLANK = /^[ \t]*$/;
|
|
9
|
+
/** A list item: `-`, `*` or `+`, or a number followed by `.` or `)`, then a space or the line's end. */
|
|
10
|
+
const LIST_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
|
|
11
|
+
/** A list item whose marker a space already follows, the only kind a line still being written is sure to stay. */
|
|
12
|
+
const LIST_ITEM_SPACED = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/;
|
|
13
|
+
/** A line indented under a list item: two spaces or a tab, then text. */
|
|
14
|
+
const INDENTED = /^(?: {2,}|\t)\S/;
|
|
15
|
+
/** The start of a line that can still grow into a list item or an indented line once more text arrives. */
|
|
16
|
+
const MAYBE_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)]?)?$|^[ \t]*$/;
|
|
17
|
+
/** A code fence: three or more backticks or tildes, indented by at most three spaces. */
|
|
18
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
19
|
+
/** How the line after a blank line inside a list reads: it continues the list, it ends it, or it is too short to tell. */
|
|
20
|
+
function afterListBlank(line, complete) {
|
|
21
|
+
// `1.` alone is an item once the line ends there, and the start of `1.5 cups` while it is still arriving.
|
|
22
|
+
if ((complete ? LIST_ITEM : LIST_ITEM_SPACED).test(line) || INDENTED.test(line))
|
|
23
|
+
return 'continues';
|
|
24
|
+
if (!complete && MAYBE_ITEM.test(line))
|
|
25
|
+
return 'unknown';
|
|
26
|
+
return 'ends';
|
|
27
|
+
}
|
|
28
|
+
/** True when `line` closes the fence `open` opened: the same character, at least as many times, and nothing after. */
|
|
29
|
+
function closesFence(line, open) {
|
|
30
|
+
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
31
|
+
return !!match && match[1][0] === open[0] && match[1].length >= open.length;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reads text line by line and closes each message once nothing later can change it. It keeps only
|
|
35
|
+
* the message being built, so text fed in pieces costs what the text costs once.
|
|
36
|
+
*/
|
|
37
|
+
class Scanner {
|
|
38
|
+
onClose;
|
|
39
|
+
current = [];
|
|
40
|
+
fence = null;
|
|
41
|
+
inList = false;
|
|
42
|
+
blankInList = false;
|
|
43
|
+
constructor(onClose) {
|
|
44
|
+
this.onClose = onClose;
|
|
45
|
+
}
|
|
46
|
+
close() {
|
|
47
|
+
const message = this.current.join('\n').trim();
|
|
48
|
+
this.current = [];
|
|
49
|
+
this.inList = false;
|
|
50
|
+
this.blankInList = false;
|
|
51
|
+
if (message)
|
|
52
|
+
this.onClose(message);
|
|
53
|
+
}
|
|
54
|
+
/** One whole line. */
|
|
55
|
+
line(line) {
|
|
56
|
+
if (this.fence !== null) {
|
|
57
|
+
this.current.push(line);
|
|
58
|
+
if (closesFence(line, this.fence))
|
|
59
|
+
this.fence = null;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (BLANK.test(line)) {
|
|
63
|
+
if (this.current.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
// A blank line ends the paragraph, unless it sits between two items of one list.
|
|
66
|
+
if (this.inList)
|
|
67
|
+
this.blankInList = true;
|
|
68
|
+
else
|
|
69
|
+
this.close();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (this.blankInList) {
|
|
73
|
+
if (afterListBlank(line, true) === 'ends')
|
|
74
|
+
this.close();
|
|
75
|
+
else
|
|
76
|
+
this.current.push('');
|
|
77
|
+
this.blankInList = false;
|
|
78
|
+
}
|
|
79
|
+
this.current.push(line);
|
|
80
|
+
if (LIST_ITEM.test(line))
|
|
81
|
+
this.inList = true;
|
|
82
|
+
else if (this.current.length === 1)
|
|
83
|
+
this.inList = false;
|
|
84
|
+
const open = FENCE.exec(line);
|
|
85
|
+
if (open)
|
|
86
|
+
this.fence = open[1];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The start of a line still being written. After a blank line inside a list, its first characters
|
|
90
|
+
* can already say the list ended, and the list goes out as soon as they do. Only the start counts:
|
|
91
|
+
* no marker, indent or number runs past 16 characters.
|
|
92
|
+
*/
|
|
93
|
+
partial(start) {
|
|
94
|
+
if (this.fence === null && this.blankInList && afterListBlank(start.slice(0, 16), false) === 'ends')
|
|
95
|
+
this.close();
|
|
96
|
+
}
|
|
97
|
+
/** The text is whole: the message being built closes. */
|
|
98
|
+
end() {
|
|
99
|
+
this.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
|
|
104
|
+
* out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
|
|
105
|
+
* lines it holds, and so does a list whose items blank lines separate.
|
|
106
|
+
*
|
|
107
|
+
* ```ts
|
|
108
|
+
* toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function toMessages(text) {
|
|
112
|
+
const messages = [];
|
|
113
|
+
const scanner = new Scanner((message) => messages.push(message));
|
|
114
|
+
for (const line of String(text ?? '').replace(/\r\n?/g, '\n').split('\n'))
|
|
115
|
+
scanner.line(line);
|
|
116
|
+
scanner.end();
|
|
117
|
+
return messages;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
|
|
121
|
+
* `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
|
|
122
|
+
* paragraph inside a list waits for the first characters of the line after the blank, which say
|
|
123
|
+
* whether the list goes on. `end` hands over the last message and waits for every send.
|
|
124
|
+
*
|
|
125
|
+
* ```ts
|
|
126
|
+
* const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
|
|
127
|
+
* stream.on('text', (delta) => paragraphs.push(delta));
|
|
128
|
+
* await stream.done();
|
|
129
|
+
* await paragraphs.end();
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export function messageStream(options) {
|
|
133
|
+
const onMessage = options?.onMessage;
|
|
134
|
+
if (typeof onMessage !== 'function')
|
|
135
|
+
throw new TypeError('Pass onMessage as a function that takes each message.');
|
|
136
|
+
// The line still being written. A trailing \r waits here too, in case its \n is in the next piece.
|
|
137
|
+
let open = '';
|
|
138
|
+
let ended = false;
|
|
139
|
+
let failure = null;
|
|
140
|
+
// Messages waiting for the send before them. A send that returns no promise goes out at once.
|
|
141
|
+
const queue = [];
|
|
142
|
+
let running = null;
|
|
143
|
+
const drain = () => {
|
|
144
|
+
while (queue.length > 0 && !failure) {
|
|
145
|
+
const message = queue.shift();
|
|
146
|
+
let sent;
|
|
147
|
+
try {
|
|
148
|
+
sent = onMessage(message);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
failure = { error };
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (sent && typeof sent.then === 'function') {
|
|
155
|
+
return Promise.resolve(sent).then(() => drain() ?? undefined, (error) => {
|
|
156
|
+
failure = { error };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
};
|
|
162
|
+
const kick = () => {
|
|
163
|
+
if (running)
|
|
164
|
+
return;
|
|
165
|
+
const pending = drain();
|
|
166
|
+
if (pending) {
|
|
167
|
+
running = pending.then(() => {
|
|
168
|
+
running = null;
|
|
169
|
+
kick();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const scanner = new Scanner((message) => {
|
|
174
|
+
queue.push(message);
|
|
175
|
+
kick();
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
push(delta) {
|
|
179
|
+
if (ended)
|
|
180
|
+
throw new Error('This message stream has ended. Start a new one for the next text.');
|
|
181
|
+
if (typeof delta !== 'string' || delta === '')
|
|
182
|
+
return;
|
|
183
|
+
const text = open + delta;
|
|
184
|
+
const cut = text.endsWith('\r') ? text.length - 1 : text.length;
|
|
185
|
+
const lines = text.slice(0, cut).replace(/\r\n?/g, '\n').split('\n');
|
|
186
|
+
open = (lines.pop() ?? '') + text.slice(cut);
|
|
187
|
+
for (const line of lines)
|
|
188
|
+
scanner.line(line);
|
|
189
|
+
// A held \r is a line ending still arriving, not text that could end a list.
|
|
190
|
+
scanner.partial(open.endsWith('\r') ? open.slice(0, -1) : open);
|
|
191
|
+
},
|
|
192
|
+
async end() {
|
|
193
|
+
if (!ended) {
|
|
194
|
+
ended = true;
|
|
195
|
+
for (const line of open.replace(/\r\n?/g, '\n').split('\n'))
|
|
196
|
+
scanner.line(line);
|
|
197
|
+
open = '';
|
|
198
|
+
scanner.end();
|
|
199
|
+
}
|
|
200
|
+
while (running)
|
|
201
|
+
await running;
|
|
202
|
+
if (failure)
|
|
203
|
+
throw failure.error;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|