@flow-engineer/flow 0.3.3 → 0.4.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.
@@ -0,0 +1,547 @@
1
+ // Business events: a business's backend posts what happened (an order shipped, a price dropped) to
2
+ // one route, signed; the agent turns each into a message by its delivery level. Critical goes at
3
+ // once; standard waits for the end of quiet hours; optional also merges by collapseKey, one a day
4
+ // per key, and is dropped when its topic closes. A newer event with the same collapseKey replaces
5
+ // one still held; `notification.cancelled` drops one not yet sent; `expiresAt` drops one held too
6
+ // long. Outside WhatsApp's window the template goes; inside it the agent may phrase it, every value
7
+ // and the link checked in code (else the template, else its text). A watch (the person asked to be
8
+ // told about a topic) raises a message about it: kept in quiet hours but never dropped as stale,
9
+ // merged away or capped; "as soon as" makes it critical. Nudges (messages the business starts
10
+ // from what people watch) are capped per day, per topic per day and per week, unless raised. A
11
+ // copy (`sentBy`: the business sent it itself) is never sent: it is the agent's context and counts
12
+ // as already told. Sends go through the agent's nudge(), so STOP and the no-contact rules apply.
13
+ //
14
+ // What an app's events mean (its catalog, its templates and wording, its own rules) stays in the
15
+ // app: `catalog` names every type it accepts, `rules` and `onEvent` decide the rest.
16
+ import { createPhrasing } from "./phrasing.js";
17
+ import { quietHoursEnd } from "./quiet.js";
18
+ import { verifyEventSignature } from "./signature.js";
19
+ const DAY = 24 * 3_600_000;
20
+ export const DELIVERY_LEVELS = ["critical", "standard", "optional"];
21
+ /** The event cancelling one not yet sent: `data.cancels` is its id. */
22
+ export const CANCELLED_EVENT = "notification.cancelled";
23
+ /** What the events side keeps. Pass your own (with extra fields of your app's) to share it. */
24
+ export function createEventState() {
25
+ return {
26
+ seen: new Set(),
27
+ cancelled: new Set(),
28
+ outbox: [],
29
+ watches: [],
30
+ /** Closed topics. */
31
+ closed: new Set(),
32
+ /** `${address} ${topic}`: no more nudges about it. */
33
+ muted: new Set(),
34
+ nudges: [],
35
+ /** `${address} ${collapseKey|eventId}` -> when an optional last went. */
36
+ optionalLast: new Map(),
37
+ /** The last nudged topic per address, for "not interested". */
38
+ lastNudged: new Map(),
39
+ /** Address -> copies the business sent itself, newest last. */
40
+ copies: new Map(),
41
+ /** `${address}\n${text}` -> when it reached them: the same message never goes again within a day. */
42
+ told: new Map(),
43
+ /** Conversation id -> address. */
44
+ addresses: new Map(),
45
+ };
46
+ }
47
+ const E164 = /^\+\d{8,15}$/;
48
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
49
+ const isNotification = (t) => t !== undefined && t.kind !== "event";
50
+ const norm = (t) => (typeof t === "string" ? t.trim().toLowerCase().replace(/\s+/g, " ") : "");
51
+ /** The default note for a phrased message. */
52
+ function defaultPrompt(m, options, stopLine) {
53
+ return [
54
+ options.nudge
55
+ ? `An update this person asked to be kept posted on. Pass it on in one message in your own words, fitting the chat, or send exactly the message below, and end it with this line as written: ${stopLine}`
56
+ : "A notification for this person. Pass it on in one message in your own words, fitting the chat, or send exactly the message below.",
57
+ "The business sent this for this person's own account just now, so it holds even where earlier messages in this chat said otherwise.",
58
+ `message: ${m.text}`,
59
+ `values: ${m.values.join(" | ")}`,
60
+ `link: ${m.link.label} -> ${m.link.url}`,
61
+ "Keep every value exactly as written and put the link in the send tool's link field (its label as the button), never as a URL in the text. No other claims, no tool calls needed.",
62
+ ].join("\n");
63
+ }
64
+ function defaultNote(copies, options) {
65
+ const when = new Intl.DateTimeFormat("en-GB", { timeZone: options.timezone, day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
66
+ return [
67
+ "The business sent this person these messages itself, from this number, for their own account. You did not send them, but they are part of this chat and they hold, even where a tool or an earlier message says otherwise. When they reply about one, answer from it: say what it said and offer its button; check a tool only for what changed since.",
68
+ ...copies.map((c) => `- ${when.format(c.at)} ${options.timezoneName} (${c.type}): "${c.text}" (values: ${c.values.join(" | ")}; button: ${c.link.label} -> ${c.link.url})`),
69
+ ].join("\n");
70
+ }
71
+ /**
72
+ * The events side of an agent: mount `routes` and `onTick` on createAgent, `phrasing.check` in the
73
+ * channel's send, `context` in the agent's context, then `attach(agent)`.
74
+ */
75
+ export function flowEvents(options) {
76
+ const now = options.now ?? Date.now;
77
+ const state = options.state ?? createEventState();
78
+ const channel = options.channel ?? "whatsapp";
79
+ const zone = options.timezone ?? "UTC";
80
+ const quiet = options.quietHours === undefined ? { from: "22:00", to: "08:00" } : options.quietHours;
81
+ const caps = options.caps ?? { perDay: 2, perKeyPerDay: 1, perWeek: 5 };
82
+ const watchMs = (options.watchDays ?? 30) * DAY;
83
+ const noun = options.topicNoun ?? "topic";
84
+ const zoneName = options.timezoneName ?? zone;
85
+ const mutedReason = options.mutedReason ?? "muted (not interested)";
86
+ const senders = options.sentBy === undefined ? [] : Array.isArray(options.sentBy) ? options.sentBy : [options.sentBy];
87
+ const business = options.businessName ?? (options.sentBy === undefined ? "the business" : [options.sentBy].flat()[0]);
88
+ const stopLine = options.wording?.stopLine ?? "Reply *not interested* to stop these.";
89
+ const prompt = options.wording?.prompt ?? ((m, o) => defaultPrompt(m, o, stopLine));
90
+ const copiesKept = options.copies?.kept ?? 10;
91
+ const copiesShown = options.copies?.shown ?? 5;
92
+ const copyMs = (options.copies?.days ?? 7) * DAY;
93
+ const note = options.copies?.note ?? defaultNote;
94
+ const addressOf = options.addressOf ?? ((e) => (typeof e.data?.user?.phone === "string" ? e.data.user.phone : undefined));
95
+ const consented = options.consented ?? ((e) => e.data?.consent?.whatsappUpdates === true);
96
+ const logName = options.logName ?? "flow event";
97
+ const report = options.log ??
98
+ ((fields) => {
99
+ // A key may carry a number (`<event>:<nudge>:+1555...`): masked, as every number is.
100
+ console.log(JSON.stringify({ severity: fields.outcome === "failed" ? "WARNING" : "INFO", msg: logName, ...fields }).replace(/\+\d{8,15}/g, "+<phone>"));
101
+ });
102
+ const phrasing = createPhrasing();
103
+ let sender = null;
104
+ let chain = Promise.resolve();
105
+ const later = (work) => {
106
+ chain = chain.then(work).catch((error) => console.error(`${logName}:`, error));
107
+ return chain;
108
+ };
109
+ const to = (address) => ({ channel, address });
110
+ const fieldOf = (e, name) => (typeof e[name] === "string" ? e[name] : typeof e.data?.[name] === "string" ? e.data[name] : undefined);
111
+ /** Whether this envelope is a copy the business sent itself (`sentBy`, or the same in data.sentBy). */
112
+ const isCopy = (e) => (e.sentBy !== undefined && senders.includes(e.sentBy)) || (e.data?.sentBy !== undefined && senders.includes(e.data.sentBy));
113
+ /** Why an envelope is malformed, naming the field; null when it is fine. */
114
+ function invalid(body) {
115
+ if (!body || typeof body !== "object" || Array.isArray(body))
116
+ return { field: "(body)", error: "not a JSON object" };
117
+ const e = body;
118
+ for (const f of ["id", "type", "occurredAt", "subject"])
119
+ if (typeof e[f] !== "string" || !e[f])
120
+ return { field: f, error: "required string" };
121
+ if (Number.isNaN(Date.parse(e.occurredAt)))
122
+ return { field: "occurredAt", error: "not an ISO time" };
123
+ if (!e.data || typeof e.data !== "object")
124
+ return { field: "data", error: "required object" };
125
+ const kind = options.catalog[e.type];
126
+ if (!kind && e.type !== CANCELLED_EVENT)
127
+ return { field: "type", error: `unknown type ${String(e.type).slice(0, 80)}` };
128
+ if (e.delivery !== undefined && !DELIVERY_LEVELS.includes(e.delivery))
129
+ return { field: "delivery", error: "critical, standard or optional" };
130
+ for (const [field, v] of [["sentBy", e.sentBy], ["data.sentBy", e.data.sentBy]]) {
131
+ if (v === undefined)
132
+ continue;
133
+ if (!senders.includes(v))
134
+ return { field, error: senders.length ? `only ${senders.map((s) => JSON.stringify(s)).join(" or ")}, or leave it out` : "not accepted here" };
135
+ if (!isNotification(kind))
136
+ return { field, error: "only on notification events" };
137
+ }
138
+ for (const f of ["expiresAt", "collapseKey"])
139
+ if (e[f] !== undefined && typeof e[f] !== "string")
140
+ return { field: f, error: "a string, or leave it out" };
141
+ if (e.expiresAt !== undefined && Number.isNaN(Date.parse(e.expiresAt)))
142
+ return { field: "expiresAt", error: "not an ISO time" };
143
+ if (e.type === CANCELLED_EVENT)
144
+ return typeof e.data.cancels === "string" ? null : { field: "data.cancels", error: "required string" };
145
+ if (isNotification(kind)) {
146
+ const address = addressOf(e);
147
+ if (typeof address !== "string" || !E164.test(address))
148
+ return { field: "data.user.phone", error: "required, E.164" };
149
+ const made = kind.message(e);
150
+ if ("missing" in made)
151
+ return { field: made.missing, error: "required value" };
152
+ return null;
153
+ }
154
+ return kind?.kind === "event" && kind.validate ? kind.validate(e) : null;
155
+ }
156
+ /** The template, as is (also outside the window). */
157
+ async function sendTemplate(address, m, key) {
158
+ const r = await sender.nudge({ to: to(address), template: m.template, idempotencyKey: `${key}:template`, respectQuietHours: false });
159
+ report({ event: key, via: "template", template: m.template.name, outcome: r.status, ...(r.reason ? { reason: r.reason } : {}) });
160
+ return r;
161
+ }
162
+ /** The agent's own words inside the window, checked against the values and link: whether it went, and the nudge's status. */
163
+ async function phrased(p, nudge) {
164
+ const expectation = phrasing.expect(p.address, p.message.values, p.message.link.url);
165
+ let result;
166
+ try {
167
+ result = await sender.nudge({ to: to(p.address), prompt: prompt(p.message, { nudge }), idempotencyKey: `${p.eventId}:phrased`, respectQuietHours: false });
168
+ }
169
+ finally {
170
+ phrasing.done(p.address);
171
+ }
172
+ report({ event: p.eventId, via: "phrased", outcome: expectation.delivered ? "sent" : result.status, ...(result.reason ? { reason: result.reason } : {}) });
173
+ return { delivered: expectation.delivered, status: result.status };
174
+ }
175
+ /** The template's words as a plain message with its link, for when the template failed (not approved on this sender): only inside the window. */
176
+ async function sendText(p, nudge) {
177
+ const m = p.message;
178
+ const inText = options.wording?.linkInText === true;
179
+ const text = [m.text, ...(inText ? [`${m.link.label}: ${m.link.url}`] : []), ...(nudge ? [stopLine] : [])].join("\n\n");
180
+ const r = await sender.nudge({ to: to(p.address), text, ...(inText ? {} : { link: m.link }), idempotencyKey: `${p.eventId}:text`, respectQuietHours: false });
181
+ report({ event: p.eventId, via: "text", outcome: r.status, ...(r.reason ? { reason: r.reason } : {}) });
182
+ return r.status;
183
+ }
184
+ /** Inside the window the agent's own words, checked; else (or when it fails) the template; with no template, its words as text. */
185
+ async function sendNotification(p) {
186
+ if (p.exact) {
187
+ const status = (await sendTemplate(p.address, p.message, p.eventId)).status;
188
+ return status === "failed" ? sendText(p, false) : status;
189
+ }
190
+ const r = await phrased(p, false);
191
+ if (r.delivered)
192
+ return "sent";
193
+ if (r.status === "dropped")
194
+ return "dropped";
195
+ const status = (await sendTemplate(p.address, p.message, p.eventId)).status;
196
+ return status === "failed" ? sendText(p, false) : status;
197
+ }
198
+ /** The template; when it failed (not approved on the sender), inside the window its words as the agent's, else as text. */
199
+ async function sendNudge(p) {
200
+ let status = (await sendTemplate(p.address, p.message, p.eventId)).status;
201
+ if (status === "failed") {
202
+ const r = await phrased(p, true);
203
+ status = r.delivered ? "sent" : r.status === "dropped" ? "dropped" : await sendText(p, true);
204
+ }
205
+ if (status === "sent") {
206
+ // A raised nudge (they asked about this topic) does not use up the caps.
207
+ if (!p.raised)
208
+ state.nudges.push({ address: p.address, topic: p.topic, kind: p.nudge, at: now() });
209
+ if (p.topic !== undefined)
210
+ state.lastNudged.set(p.address, p.topic);
211
+ }
212
+ return status;
213
+ }
214
+ /** The business sent this exact message to this person within a day. */
215
+ const told = (address, text) => {
216
+ const at = state.told.get(`${address}\n${text}`);
217
+ return at !== undefined && now() - at < DAY;
218
+ };
219
+ const muted = (address, topic) => topic !== undefined && state.muted.has(`${address} ${topic}`);
220
+ /** Why it is no longer worth sending (cancelled, expired, told, closed, muted, capped), or null when it still goes. */
221
+ function whyNot(p) {
222
+ if (state.cancelled.has(p.eventId))
223
+ return "cancelled";
224
+ if (p.expiresAt !== undefined && p.expiresAt <= now())
225
+ return "expired";
226
+ if (p.delivery !== "nudge" && told(p.address, p.message.text))
227
+ return `already told: ${business} sent it`;
228
+ if (p.sticky)
229
+ return null;
230
+ const closed = p.topic !== undefined && state.closed.has(p.topic);
231
+ if (closed && p.liveOnly)
232
+ return `${noun} closed`;
233
+ if (p.delivery === "critical")
234
+ return null;
235
+ if (closed && !p.raised)
236
+ return `${noun} closed`;
237
+ if (p.raised)
238
+ return p.delivery === "nudge" && muted(p.address, p.topic) ? mutedReason : null;
239
+ if (p.delivery === "optional") {
240
+ const last = state.optionalLast.get(`${p.address} ${p.collapseKey ?? p.eventId}`);
241
+ if (last !== undefined && now() - last < DAY)
242
+ return "optional: one a day per collapse key";
243
+ }
244
+ if (p.delivery === "nudge") {
245
+ if (muted(p.address, p.topic))
246
+ return mutedReason;
247
+ const mine = state.nudges.filter((n) => n.address === p.address);
248
+ const t = now();
249
+ if (mine.filter((n) => t - n.at < DAY).length >= caps.perDay)
250
+ return `cap: ${caps.perDay} nudges a day`;
251
+ if (mine.filter((n) => n.topic === p.topic && t - n.at < DAY).length >= caps.perKeyPerDay)
252
+ return `cap: ${caps.perKeyPerDay} per ${noun} a day`;
253
+ if (mine.filter((n) => t - n.at < 7 * DAY).length >= caps.perWeek)
254
+ return `cap: ${caps.perWeek} nudges a week`;
255
+ }
256
+ return null;
257
+ }
258
+ /** Off production, an address not on the test list gets nothing. */
259
+ function listed(p) {
260
+ if (!options.testList || options.testList.allows(p.address))
261
+ return true;
262
+ const info = { event: p.eventId, type: p.type ?? (p.nudge ? `nudge.${p.nudge}` : "notification"), address: p.address };
263
+ if (options.onUnlisted)
264
+ options.onUnlisted(info);
265
+ else
266
+ report({ event: info.event, type: info.type, outcome: "not sent", reason: "not on the test list" });
267
+ return false;
268
+ }
269
+ async function go(p) {
270
+ // Checked again when a held send comes due, so what was held is never sent past the list.
271
+ if (!listed(p))
272
+ return;
273
+ const why = whyNot(p);
274
+ if (why) {
275
+ report({ event: p.eventId, delivery: p.delivery, ...(p.nudge ? { nudge: p.nudge } : {}), outcome: "not sent", reason: why });
276
+ return;
277
+ }
278
+ if (p.delivery === "nudge")
279
+ await sendNudge(p);
280
+ else {
281
+ const status = await sendNotification(p);
282
+ if (p.delivery === "optional" && status === "sent")
283
+ state.optionalLast.set(`${p.address} ${p.collapseKey ?? p.eventId}`, now());
284
+ }
285
+ }
286
+ /** Now, or held to the end of quiet hours (`exempt`: never waits). A newer one replaces a held one with its collapseKey. */
287
+ async function schedule(p, exempt = p.delivery === "critical") {
288
+ // The test list first, at receipt: an unlisted address is neither held nor sent (go checks it again).
289
+ if (!listed(p))
290
+ return;
291
+ if (p.collapseKey && p.delivery !== "critical" && !p.raised)
292
+ drop((o) => o.address === p.address && o.collapseKey === p.collapseKey);
293
+ const quietEnd = exempt || !quiet ? null : quietHoursEnd(quiet, zone, now());
294
+ if (quietEnd !== null) {
295
+ state.outbox.push({ ...p, notBefore: quietEnd });
296
+ report({ event: p.eventId, delivery: p.delivery, ...(p.nudge ? { nudge: p.nudge } : {}), outcome: "held", until: new Date(quietEnd).toISOString(), reason: `quiet hours (${zoneName})` });
297
+ }
298
+ else
299
+ await go(p);
300
+ }
301
+ /** Removes held sends that match; how many. In place, so a shared state keeps its array. */
302
+ function drop(match) {
303
+ const before = state.outbox.length;
304
+ const kept = state.outbox.filter((p) => !match(p));
305
+ state.outbox.splice(0, state.outbox.length, ...kept);
306
+ return before - kept.length;
307
+ }
308
+ /** Within its days, and (when its topic closed) a day after the close at most. */
309
+ const alive = (w, t = now()) => t - w.createdAt < watchMs;
310
+ /** This person's watch on a topic: by topic, else by the topic's name. */
311
+ function watchFor(address, topic, title, titleOf) {
312
+ const name = norm(title);
313
+ const t = now();
314
+ return state.watches.find((w) => {
315
+ if (w.address !== address || !w.topic)
316
+ return false;
317
+ if (!alive(w, t) || (w.endedAt !== undefined && t - w.endedAt >= DAY))
318
+ return false;
319
+ if (muted(address, w.topic))
320
+ return false; // "not interested" on it takes the ask back
321
+ if (topic)
322
+ return w.topic === topic;
323
+ return name !== "" && (norm(w.title) === name || norm(titleOf?.(w.topic)) === name);
324
+ });
325
+ }
326
+ /** The raise rule: a watch keeps a message (optional becomes standard); "as soon as" makes it critical. Never lowers. */
327
+ function raise(event, type, delivery, watch) {
328
+ if (!watch || delivery === "critical")
329
+ return { delivery };
330
+ const raised = watch.urgent ? "urgent" : "watch";
331
+ report({ event, type, outcome: "raised", from: delivery, to: raised === "urgent" ? "critical" : "kept", reason: raised === "urgent" ? "they asked to be told as soon as" : `they asked to be told about this ${noun}` });
332
+ return { delivery: raised === "urgent" ? "critical" : delivery === "optional" ? "standard" : delivery, raised };
333
+ }
334
+ /** A copy of a message the business sent itself: never sent; the agent's context; told for what is held and what comes later. */
335
+ async function copied(e, kind) {
336
+ const address = addressOf(e);
337
+ const message = kind.message(e);
338
+ const extra = options.rules?.copy?.(e, message, api) ?? undefined;
339
+ const sentAt = Date.parse(e.occurredAt);
340
+ const copies = [...(state.copies.get(address) ?? []), { eventId: e.id, type: e.type, at: Number.isNaN(sentAt) ? now() : sentAt, text: message.text, values: message.values, link: message.link }];
341
+ state.copies.set(address, copies.slice(-copiesKept));
342
+ state.told.set(`${address}\n${message.text}`, now());
343
+ const collapseKey = fieldOf(e, "collapseKey");
344
+ if (collapseKey)
345
+ state.optionalLast.set(`${address} ${collapseKey}`, now());
346
+ const dropped = drop((p) => p.address === address && p.delivery !== "nudge" && (p.message.text === message.text || (collapseKey !== undefined && p.collapseKey === collapseKey) || (extra ? extra(p) : false)));
347
+ report({ event: e.id, type: e.type, outcome: "recorded", reason: `sent by ${business}: kept as context, not sent`, droppedHeld: dropped });
348
+ // Someone who never wrote gets their conversation now, so the copy is there when they reply.
349
+ if (sender?.reach) {
350
+ try {
351
+ const { conversationId } = await sender.reach(to(address));
352
+ state.addresses.set(conversationId, address);
353
+ }
354
+ catch (error) {
355
+ report({ event: e.id, type: e.type, outcome: "failed", reason: `reach: ${error instanceof Error ? error.message.slice(0, 200) : "error"}` });
356
+ }
357
+ }
358
+ }
359
+ async function notification(e, kind) {
360
+ if (isCopy(e))
361
+ return copied(e, kind);
362
+ if (state.cancelled.has(e.id) || !consented(e)) {
363
+ report({ event: e.id, type: e.type, outcome: "not sent", reason: state.cancelled.has(e.id) ? "cancelled" : channel === "whatsapp" ? "no WhatsApp consent" : "no consent" });
364
+ return;
365
+ }
366
+ const expires = fieldOf(e, "expiresAt");
367
+ const expiresAt = expires ? Date.parse(expires) : undefined;
368
+ if (expiresAt !== undefined && expiresAt <= now()) {
369
+ report({ event: e.id, type: e.type, outcome: "not sent", reason: "expired" });
370
+ return;
371
+ }
372
+ const message = kind.message(e);
373
+ const topic = kind.topic?.(e);
374
+ const send = {
375
+ eventId: e.id,
376
+ type: e.type,
377
+ address: addressOf(e),
378
+ delivery: e.delivery ?? kind.level,
379
+ ...(kind.liveOnly ? { liveOnly: true } : {}),
380
+ topic,
381
+ collapseKey: fieldOf(e, "collapseKey"),
382
+ notBefore: now(),
383
+ expiresAt,
384
+ exact: e.data?.wording === "exact",
385
+ message,
386
+ };
387
+ if (options.rules?.notification && (await options.rules.notification(e, send, api)) === "handled")
388
+ return;
389
+ const r = raise(e.id, e.type, send.delivery, watchFor(send.address, topic, kind.title?.(e), titleOf));
390
+ await schedule({ ...send, delivery: r.delivery, ...(r.raised ? { raised: r.raised } : {}) }, r.delivery === "critical");
391
+ }
392
+ let titleOf;
393
+ async function process(e) {
394
+ const kind = options.catalog[e.type];
395
+ if (e.type === CANCELLED_EVENT && !kind) {
396
+ state.cancelled.add(e.data.cancels);
397
+ drop((p) => p.eventId === e.data.cancels);
398
+ }
399
+ else if (isNotification(kind))
400
+ await notification(e, kind);
401
+ else
402
+ await options.onEvent?.(e, api);
403
+ }
404
+ /** POST /v1/events: 202 accepted (also a duplicate), 400 names the field, 401 a bad signature. */
405
+ async function handler(req) {
406
+ if (req.method !== "POST")
407
+ return json(405, { error: "POST only" });
408
+ if (!options.secret)
409
+ return json(503, { error: "events are not configured" });
410
+ const raw = await req.text();
411
+ if (!verifyEventSignature(options.secret, req.headers.get("flow-signature"), raw, now()))
412
+ return json(401, { error: "bad or missing Flow-Signature" });
413
+ let body;
414
+ try {
415
+ body = JSON.parse(raw);
416
+ }
417
+ catch {
418
+ return json(400, { error: "not JSON", field: "(body)" });
419
+ }
420
+ const problem = invalid(body);
421
+ if (problem)
422
+ return json(400, problem);
423
+ const key = req.headers.get("idempotency-key");
424
+ if (key && key !== body.id && state.seen.has(`key:${key}`))
425
+ return json(202, { status: "duplicate" });
426
+ const fresh = accept(body);
427
+ if (key && key !== body.id)
428
+ state.seen.add(`key:${key}`);
429
+ return json(202, { status: fresh ? "accepted" : "duplicate" });
430
+ }
431
+ /** Records an event (false: a duplicate id, already taken) and processes it in order, in the background. */
432
+ function accept(e) {
433
+ if (state.seen.has(e.id))
434
+ return false;
435
+ state.seen.add(e.id);
436
+ later(() => process(e));
437
+ return true;
438
+ }
439
+ /** Held sends that came due, one by one, then the app's onTick. */
440
+ function tick() {
441
+ return later(async () => {
442
+ const t = now();
443
+ const due = state.outbox.filter((p) => p.notBefore <= t);
444
+ drop((p) => p.notBefore <= t);
445
+ for (const p of due)
446
+ await go(p);
447
+ await options.onTick?.(t, api);
448
+ });
449
+ }
450
+ const api = {
451
+ state,
452
+ phrasing: phrasing,
453
+ handler,
454
+ /** For createAgent's `routes`. */
455
+ routes: { "/v1/events": handler },
456
+ /** For createAgent's `onTick`. */
457
+ onTick: async () => void (await tick()),
458
+ attach(s) {
459
+ sender = s;
460
+ },
461
+ invalid,
462
+ accept,
463
+ tick,
464
+ /** Resolves once everything accepted so far is processed. */
465
+ idle: () => chain.then(() => undefined),
466
+ report: (fields) => report(fields),
467
+ schedule,
468
+ drop,
469
+ raise,
470
+ /** This person's live watch on a topic (by topic, else by its name, with `titleOf` the app's names for topics). */
471
+ watchFor: (address, topic, title) => watchFor(address, topic, title, titleOf),
472
+ /** How a topic's name is found, for watches matched by name. */
473
+ setTitles(fn) {
474
+ titleOf = fn;
475
+ },
476
+ /** Whether a watch is within its days. */
477
+ alive: (w, t) => alive(w, t),
478
+ told,
479
+ muted,
480
+ isCopy,
481
+ /**
482
+ * Saves a watch; `same` finds one this person already has (it is refreshed: another 30 days, its
483
+ * title kept, urgent kept). "saved" or "already watching".
484
+ */
485
+ watch(w, same) {
486
+ const watch = { ...w, id: `w_${state.watches.length + 1}`, createdAt: w.createdAt ?? now() };
487
+ const found = state.watches.find((x) => x.address === watch.address && (same ? same(x) : x.topic !== undefined && x.topic === watch.topic));
488
+ if (!found) {
489
+ state.watches.push(watch);
490
+ return { outcome: "saved", watch };
491
+ }
492
+ if (watch.title && !found.title)
493
+ found.title = watch.title;
494
+ if (watch.urgent)
495
+ found.urgent = true;
496
+ found.createdAt = watch.createdAt;
497
+ return { outcome: "already watching", watch: found };
498
+ },
499
+ /**
500
+ * A topic closed (sold, cancelled, ended): held sends about it are dropped, except a critical or
501
+ * raised one, a sticky one, and never a live-only one; its watches end. How many were dropped.
502
+ */
503
+ close(topic) {
504
+ state.closed.add(topic);
505
+ const dropped = drop((p) => p.topic === topic && !p.sticky && (p.liveOnly === true || (p.delivery !== "critical" && p.raised === undefined)));
506
+ for (const w of state.watches)
507
+ if (w.topic === topic && w.endedAt === undefined)
508
+ w.endedAt = now();
509
+ return dropped;
510
+ },
511
+ /** No more nudges to this person about this topic. */
512
+ mute(address, topic) {
513
+ state.muted.add(`${address} ${topic}`);
514
+ },
515
+ /** "Not interested" on a nudge: that topic is muted, its watch and held nudges go. The topic, or null. */
516
+ notInterested(address) {
517
+ const topic = state.lastNudged.get(address);
518
+ if (!topic)
519
+ return null;
520
+ state.muted.add(`${address} ${topic}`);
521
+ state.watches.splice(0, state.watches.length, ...state.watches.filter((w) => !(w.address === address && w.topic === topic)));
522
+ drop((p) => p.address === address && p.topic === topic && p.delivery === "nudge");
523
+ return topic;
524
+ },
525
+ /** STOP: their watches end and held nudges go (the agent itself stops every send). */
526
+ stopped(address) {
527
+ state.watches.splice(0, state.watches.length, ...state.watches.filter((w) => w.address !== address));
528
+ drop((p) => p.address === address && p.delivery === "nudge");
529
+ },
530
+ /** Which address a conversation is (from tool calls, copies). */
531
+ remember(conversationId, address) {
532
+ state.addresses.set(conversationId, address);
533
+ },
534
+ /** The note on recent copies for this person; "" when none. */
535
+ copiesNote(address) {
536
+ const t = now();
537
+ const recent = (state.copies.get(address) ?? []).filter((c) => t - c.at < copyMs).slice(-copiesShown);
538
+ return recent.length ? note(recent, { timezone: zone, timezoneName: zoneName }) : "";
539
+ },
540
+ /** The turn's context: recent copies for this conversation's person ("" when none). `address` when the caller knows it. */
541
+ context(conversationId, address) {
542
+ const known = state.addresses.get(conversationId) ?? address;
543
+ return known ? api.copiesNote(known) : "";
544
+ },
545
+ };
546
+ return api;
547
+ }
@@ -0,0 +1,5 @@
1
+ export { CANCELLED_EVENT, createEventState, DELIVERY_LEVELS, flowEvents, type AppEventType, type BusinessEvent, type DeliveryLevel, type EventCatalog, type EventCopy, type EventMessage, type EventProblem, type EventSender, type EventState, type EventType, type FlowEvents, type FlowEventsOptions, type NotificationType, type PendingSend, type SentNudge, type Watch, } from "./engine.ts";
2
+ export { createPhrasing, type Phrasing, type PhrasingExpectation } from "./phrasing.ts";
3
+ export { quietHoursEnd, type EventQuietHours } from "./quiet.ts";
4
+ export { eventSignature, signEvent, SIGNATURE_TOLERANCE_MS, verifyEventSignature } from "./signature.ts";
5
+ export { eventTestList, parseTestNumbers, type EventTestList } from "./testlist.ts";
@@ -0,0 +1,5 @@
1
+ export { CANCELLED_EVENT, createEventState, DELIVERY_LEVELS, flowEvents, } from "./engine.js";
2
+ export { createPhrasing } from "./phrasing.js";
3
+ export { quietHoursEnd } from "./quiet.js";
4
+ export { eventSignature, signEvent, SIGNATURE_TOLERANCE_MS, verifyEventSignature } from "./signature.js";
5
+ export { eventTestList, parseTestNumbers } from "./testlist.js";
@@ -0,0 +1,13 @@
1
+ import type { Outbound } from "../types.ts";
2
+ export interface PhrasingExpectation {
3
+ values: string[];
4
+ url: string;
5
+ delivered: boolean;
6
+ }
7
+ export declare function createPhrasing(): {
8
+ expect(address: string, values: string[], url: string): PhrasingExpectation;
9
+ done(address: string): void;
10
+ /** Why this message may not go during a notification turn, or null. */
11
+ check(address: string, outbound: Outbound): string | null;
12
+ };
13
+ export type Phrasing = ReturnType<typeof createPhrasing>;
@@ -0,0 +1,33 @@
1
+ // The wording check: inside WhatsApp's window the agent may phrase a business event's message
2
+ // itself, keeping every value and the button link unchanged. While such a turn runs, the channel's
3
+ // send asks this registry (`check`); a message that drops a value or changes the link is refused,
4
+ // and the template (or its text) goes instead.
5
+ const key = (address) => address.replace(/\D/g, "");
6
+ export function createPhrasing() {
7
+ const open = new Map();
8
+ return {
9
+ expect(address, values, url) {
10
+ const e = { values, url, delivered: false };
11
+ open.set(key(address), e);
12
+ return e;
13
+ },
14
+ done(address) {
15
+ open.delete(key(address));
16
+ },
17
+ /** Why this message may not go during a notification turn, or null. */
18
+ check(address, outbound) {
19
+ const e = open.get(key(address));
20
+ if (!e || outbound.type !== "message")
21
+ return null;
22
+ if (e.delivered)
23
+ return "the notification already went; send nothing more this turn";
24
+ const lost = e.values.filter((v) => !(outbound.text ?? "").includes(v));
25
+ if (lost.length)
26
+ return `a notification keeps every value exactly (${lost.join(", ")} missing)`;
27
+ if (outbound.link?.url !== e.url)
28
+ return "a notification keeps its link exactly, in the link field";
29
+ e.delivered = true;
30
+ return null;
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,8 @@
1
+ export interface EventQuietHours {
2
+ /** "22:00": they start. */
3
+ from: string;
4
+ /** "08:00": they end (the next day when it is before `from`). */
5
+ to: string;
6
+ }
7
+ /** When quiet hours end, if `now` is inside them in `zone`; else null. */
8
+ export declare function quietHoursEnd(hours: EventQuietHours, zone: string, now: number): number | null;
@@ -0,0 +1,25 @@
1
+ // Quiet hours for business events, in one zone the business names (its customers' zone): a
2
+ // standard or optional message that comes inside them is held and sent when they end.
3
+ const minutes = (hhmm) => {
4
+ const [h, m] = hhmm.split(":").map(Number);
5
+ return h * 60 + m;
6
+ };
7
+ /** Minutes since local midnight in `zone`, and that local midnight as an instant. */
8
+ function local(zone, now) {
9
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-GB", { timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
10
+ .formatToParts(now)
11
+ .map((p) => [p.type, p.value]));
12
+ const mins = Number(parts.hour) * 60 + Number(parts.minute);
13
+ const startOfMinute = now - Number(parts.second) * 1000 - (now % 1000);
14
+ return { mins, midnight: startOfMinute - mins * 60_000 };
15
+ }
16
+ /** When quiet hours end, if `now` is inside them in `zone`; else null. */
17
+ export function quietHoursEnd(hours, zone, now) {
18
+ const f = minutes(hours.from);
19
+ const t = minutes(hours.to);
20
+ const { mins, midnight } = local(zone, now);
21
+ const inside = f > t ? mins >= f || mins < t : mins >= f && mins < t;
22
+ if (!inside)
23
+ return null;
24
+ return midnight + t * 60_000 + (mins >= t ? 24 * 60 * 60_000 : 0);
25
+ }
@@ -0,0 +1,8 @@
1
+ /** How far a signature's `t` may be from now. */
2
+ export declare const SIGNATURE_TOLERANCE_MS: number;
3
+ /** The hex HMAC for `t` (unix seconds) and the raw body. */
4
+ export declare function eventSignature(secret: string, t: number, body: string): string;
5
+ /** The Flow-Signature header for this body, signed now (ms since the epoch). */
6
+ export declare function signEvent(secret: string, body: string, now?: number): string;
7
+ /** Whether the header signs this body with this secret, at most 5 minutes from `now` (ms). */
8
+ export declare function verifyEventSignature(secret: string, header: string | null | undefined, body: string, now?: number): boolean;