@flow-engineer/flow 0.0.1 → 0.2.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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +611 -0
  3. package/coding-agents/skill.md +353 -0
  4. package/coding-agents/test-skill.md +223 -0
  5. package/coding-agents/tools-skill.md +89 -0
  6. package/dist/flow-messaging/src/client.d.ts +40 -0
  7. package/dist/flow-messaging/src/client.js +142 -0
  8. package/dist/flow-messaging/src/index.d.ts +72 -0
  9. package/dist/flow-messaging/src/index.js +605 -0
  10. package/dist/simulators/src/index.d.ts +2 -0
  11. package/dist/simulators/src/index.js +2 -0
  12. package/dist/simulators/src/page.html +1245 -0
  13. package/dist/simulators/src/person.html +100 -0
  14. package/dist/simulators/src/platforms.d.ts +29 -0
  15. package/dist/simulators/src/platforms.js +72 -0
  16. package/dist/simulators/src/server.d.ts +201 -0
  17. package/dist/simulators/src/server.js +759 -0
  18. package/dist/src/agent.d.ts +504 -0
  19. package/dist/src/agent.js +818 -0
  20. package/dist/src/channels.d.ts +178 -0
  21. package/dist/src/channels.js +243 -0
  22. package/dist/src/conversation.d.ts +361 -0
  23. package/dist/src/conversation.js +1487 -0
  24. package/dist/src/db.d.ts +18 -0
  25. package/dist/src/db.js +57 -0
  26. package/dist/src/events.d.ts +17 -0
  27. package/dist/src/events.js +20 -0
  28. package/dist/src/http.d.ts +45 -0
  29. package/dist/src/http.js +269 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +11 -0
  32. package/dist/src/model.d.ts +21 -0
  33. package/dist/src/model.js +96 -0
  34. package/dist/src/names.d.ts +22 -0
  35. package/dist/src/names.js +43 -0
  36. package/dist/src/nudges.d.ts +23 -0
  37. package/dist/src/nudges.js +117 -0
  38. package/dist/src/postgres.d.ts +20 -0
  39. package/dist/src/postgres.js +62 -0
  40. package/dist/src/schedules.d.ts +60 -0
  41. package/dist/src/schedules.js +249 -0
  42. package/dist/src/skills.d.ts +12 -0
  43. package/dist/src/skills.js +69 -0
  44. package/dist/src/store.d.ts +214 -0
  45. package/dist/src/store.js +448 -0
  46. package/dist/src/talk.d.ts +57 -0
  47. package/dist/src/talk.js +166 -0
  48. package/dist/src/time.d.ts +71 -0
  49. package/dist/src/time.js +170 -0
  50. package/dist/src/tools.d.ts +43 -0
  51. package/dist/src/tools.js +26 -0
  52. package/dist/src/traces.d.ts +31 -0
  53. package/dist/src/traces.js +303 -0
  54. package/dist/src/types.d.ts +260 -0
  55. package/dist/src/types.js +2 -0
  56. package/dist/testing/src/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +580 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +117 -0
  60. package/dist/testing/src/check-endpoint.d.ts +31 -0
  61. package/dist/testing/src/check-endpoint.js +158 -0
  62. package/dist/testing/src/chinwag.d.ts +2 -0
  63. package/dist/testing/src/chinwag.js +6 -0
  64. package/dist/testing/src/cli.d.ts +2 -0
  65. package/dist/testing/src/cli.js +592 -0
  66. package/dist/testing/src/client.d.ts +117 -0
  67. package/dist/testing/src/client.js +956 -0
  68. package/dist/testing/src/flow-login.d.ts +143 -0
  69. package/dist/testing/src/flow-login.js +249 -0
  70. package/dist/testing/src/http-agent.d.ts +47 -0
  71. package/dist/testing/src/http-agent.js +155 -0
  72. package/dist/testing/src/index.d.ts +3 -0
  73. package/dist/testing/src/index.js +2 -0
  74. package/dist/testing/src/new.d.ts +13 -0
  75. package/dist/testing/src/new.js +147 -0
  76. package/dist/testing/src/print-run.d.ts +3 -0
  77. package/dist/testing/src/print-run.js +22 -0
  78. package/dist/testing/src/protocol.d.ts +159 -0
  79. package/dist/testing/src/protocol.js +28 -0
  80. package/dist/testing/src/report.d.ts +74 -0
  81. package/dist/testing/src/report.js +90 -0
  82. package/dist/testing/src/show.d.ts +36 -0
  83. package/dist/testing/src/show.js +280 -0
  84. package/dist/testing/src/suite.d.ts +87 -0
  85. package/dist/testing/src/suite.js +198 -0
  86. package/docs/agents.md +85 -0
  87. package/examples/cafe/agent/AGENTS.md +10 -0
  88. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  89. package/examples/cafe/src/agent.ts +43 -0
  90. package/examples/cafe/src/channels.ts +20 -0
  91. package/examples/cafe/src/main.ts +30 -0
  92. package/examples/cafe/src/tools.ts +62 -0
  93. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  94. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  95. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  96. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  97. package/examples/http-agent/server.mjs +32 -0
  98. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  99. package/flow-messaging/README.md +191 -0
  100. package/package.json +63 -8
  101. package/spec/agent-endpoint.openapi.yaml +148 -0
  102. package/spec/nudges.openapi.yaml +167 -0
  103. package/spec/request.schema.json +121 -0
  104. package/spec/response.schema.json +284 -0
@@ -0,0 +1,605 @@
1
+ // Flow Framework channels on Flow Messaging (api.flow.engineer), Flow's unified messaging API: WhatsApp,
2
+ // Telegram, iMessage and US SMS from senders Flow hosts. Events reach the agent one of two ways: the live
3
+ // stream (an outbound WebSocket, so a laptop needs no public URL or tunnel) or a webhook that checks
4
+ // Flow's signature. Both hand each event to the same handler.
5
+ //
6
+ // const fm = flowMessaging(); // FLOW_MESSAGING_KEY (and FLOW_MESSAGING_WEBHOOK_SECRET for a webhook)
7
+ // const agent = createAgent({ ..., channels: [fm.whatsapp(), fm.telegram(), fm.imessage(), fm.sms()] });
8
+ // fm.start(agent); // the stream, or the webhook on PORT when a webhook secret is set
9
+ //
10
+ // Addresses are what Flow Framework joins people by: the phone number on WhatsApp and SMS, the numeric
11
+ // user id on Telegram, the phone number on iMessage, else the email address that is the person's
12
+ // handle there. A reply goes into the Flow conversation the person last wrote in; with none known
13
+ // yet (a nudge, or after a restart) it starts one from the channel's sender, which Flow puts into the
14
+ // open conversation if there is one. On Flow's shared iMessage sender, as on WhatsApp after 24
15
+ // hours, the agent can only answer someone who wrote first: a start to anyone else fails. What this process learns (conversations, contacts,
16
+ // event ids) is kept in memory only.
17
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { createServer } from "node:http";
19
+ import { dirname } from "node:path";
20
+ import { sms as smsChannel, whatsapp as whatsappChannel } from "../../src/index.js";
21
+ import { Client, FlowMessagingError, idSegment, verifySignature } from "./client.js";
22
+ export { API_VERSION, FlowMessagingError, verifySignature } from "./client.js";
23
+ /** Telegram as Flow sends it: an inline keyboard of up to 10 buttons (or one link), photos, Markdown, no read receipts for bots. */
24
+ export const TELEGRAM = {
25
+ reactions: true,
26
+ status: false,
27
+ formatting: "markdown",
28
+ maxLength: 4096,
29
+ replies: true,
30
+ buttons: { max: 10, labelMax: 20 },
31
+ linkButton: { labelMax: 20 },
32
+ images: { captionMax: 1024 },
33
+ readReceipts: false,
34
+ };
35
+ /**
36
+ * iMessage as Flow sends it: plain text up to 9999 characters, photos, inline replies, tapbacks
37
+ * (other emoji go as emoji reactions), typing and read receipts (the whole conversation). No buttons,
38
+ * lists or templates. No reply window is set: the line only answers people who wrote to it, and a
39
+ * send it will not deliver comes back as Not delivered (outside_window).
40
+ */
41
+ export const IMESSAGE = {
42
+ reactions: true,
43
+ status: false,
44
+ formatting: "plain",
45
+ maxLength: 9999,
46
+ replies: true,
47
+ images: { captionMax: 1024 },
48
+ readReceipts: true,
49
+ };
50
+ /** Flow sends a list as buttons, whose labels take at most 20 characters, and has no row descriptions. */
51
+ const LIST_TITLE_MAX = 20;
52
+ const MAX_BODY_BYTES = 1_000_000;
53
+ /** Event ids remembered to drop Flow's redeliveries (deliveries are at least once). */
54
+ const SEEN_MAX = 10_000;
55
+ /** The events the handler acts on; the stream asks for only these. */
56
+ const HANDLED = ["message.received", "message.failed", "reaction.added", "contact.deleted"];
57
+ /** The longest wait between two tries to reconnect the stream (ms). */
58
+ const STREAM_BACKOFF_MAX = 30_000;
59
+ /** Tries of one event before the stream gives up on it and goes on to the next. */
60
+ const STREAM_EVENT_TRIES = 5;
61
+ /** Close codes after which reconnecting unchanged fails again: authentication, permission, a bad request. */
62
+ const STREAM_FINAL = new Set([4401, 4403, 4400]);
63
+ export function flowMessaging(options = {}) {
64
+ const env = (...names) => names.map((n) => process.env[n]).find(Boolean);
65
+ const apiKey = options.apiKey ?? env("FLOW_MESSAGING_KEY", "FLOW_MESSAGING_API_KEY") ?? "";
66
+ const secrets = (options.webhookSecret ?? process.env.FLOW_MESSAGING_WEBHOOK_SECRET ?? "").split(",").map((s) => s.trim()).filter(Boolean);
67
+ const client = new Client({ apiKey, baseUrl: options.baseUrl ?? env("FLOW_MESSAGING_BASE_URL", "FLOW_MESSAGING_API_URL") ?? "https://api.flow.engineer" });
68
+ const routes = [];
69
+ const flowConversation = new Map(); // channel + address -> Flow conversation id
70
+ const frameworkConversation = new Map(); // channel + address -> Flow Framework conversation id
71
+ const contacts = new Map(); // Flow contact id -> address
72
+ const templates = new Map(); // sender -> approved templates
73
+ const seen = new Set();
74
+ const handling = new Map();
75
+ const key = (channel, address) => `${channel}\u0000${address}`;
76
+ function register(kind, o) {
77
+ const route = { kind, name: o.name ?? kind, sender: o.sender ?? (process.env[`FLOW_MESSAGING_${kind.toUpperCase()}_SENDER`] || undefined) };
78
+ routes.push(route);
79
+ return route;
80
+ }
81
+ /** The Flow conversation to send into: the one the person wrote in, else the one a message they quote is in. */
82
+ async function conversationFor(channel, to, messageId) {
83
+ const known = flowConversation.get(key(channel, to));
84
+ if (known || !messageId)
85
+ return known;
86
+ const found = (await client.get(`/v1/messages/${idSegment("msg", messageId)}`)).conversation;
87
+ flowConversation.set(key(channel, to), found);
88
+ return found;
89
+ }
90
+ function recipient(kind, to) {
91
+ if (to.startsWith("ct_"))
92
+ return { contact: to };
93
+ if (kind === "telegram")
94
+ return { telegram_user_id: to };
95
+ if (kind === "imessage" && !/^\+?[0-9]{7,15}$/.test(to))
96
+ return { handle: to }; // an email address
97
+ return { phone: to.startsWith("+") ? to : `+${to}` };
98
+ }
99
+ /** One message: into the person's conversation, or a new one from the channel's sender. */
100
+ async function send(route, to, content, replyTo, about = replyTo) {
101
+ try {
102
+ const conversation = await conversationFor(route.name, to, about);
103
+ let sent;
104
+ if (conversation)
105
+ sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
106
+ else {
107
+ if (!route.sender)
108
+ throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
109
+ sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content });
110
+ }
111
+ flowConversation.set(key(route.name, to), sent.conversation);
112
+ }
113
+ catch (error) {
114
+ throw error instanceof FlowMessagingError ? new Error(reason(error)) : error;
115
+ }
116
+ }
117
+ /** Typing and read receipts are a courtesy: Flow's spec says to ignore their failures. */
118
+ async function courtesy(route, to, action, body, messageId) {
119
+ try {
120
+ const conversation = await conversationFor(route.name, to, messageId);
121
+ if (conversation)
122
+ await client.post(`/v1/conversations/${idSegment("conv", conversation)}/${action}`, body, 1);
123
+ }
124
+ catch (error) {
125
+ console.warn(`@flow-engineer/flow/flow-messaging: ${action} on ${route.name} failed (${error instanceof FlowMessagingError ? error.type : "error"})`);
126
+ }
127
+ }
128
+ /**
129
+ * A message as Flow content. A link is a url button (WhatsApp's cta_url), a photo a media message
130
+ * with the text as its caption. Flow's button messages have no image header, so a photo with
131
+ * buttons or a link goes just before them, on its own.
132
+ */
133
+ function contents(kind, message) {
134
+ const buttons = message.link
135
+ ? [{ url: message.link.url, label: message.link.label }]
136
+ : message.buttons?.length
137
+ ? message.buttons.map((label) => ({ id: label, label }))
138
+ : message.list?.rows.map((row) => ({ id: row.id, label: row.title }));
139
+ const photo = message.image && { type: "media", kind: "image", url: message.image.url };
140
+ if (photo && !buttons)
141
+ return [{ ...photo, ...(message.text ? { caption: message.text } : {}) }];
142
+ const main = buttons ? { type: "buttons", text: message.text, buttons } : { type: "text", text: message.text, format: kind === "telegram" ? "markdown" : "plain" };
143
+ return photo ? [photo, main] : [main];
144
+ }
145
+ async function template(sender, wanted) {
146
+ if (wanted.name.startsWith("tpl_")) {
147
+ if (!wanted.language)
148
+ throw new Error(`template ${wanted.name} needs its language`);
149
+ return { id: wanted.name, language: wanted.language };
150
+ }
151
+ const find = () => templates.get(sender)?.find((t) => t.name === wanted.name && (!wanted.language || t.language === wanted.language));
152
+ if (!find()) {
153
+ const all = [];
154
+ let after = "";
155
+ for (;;) {
156
+ const page = await client.get(`/v1/templates?${new URLSearchParams({ sender: idSegment("snd", sender), status: "approved", limit: "100", ...(after ? { after } : {}) })}`);
157
+ all.push(...page.data);
158
+ if (!page.has_more || !page.data.length)
159
+ break;
160
+ after = page.data.at(-1).id;
161
+ }
162
+ templates.set(sender, all);
163
+ }
164
+ const found = find();
165
+ if (!found)
166
+ throw new Error(`no approved template named ${wanted.name}${wanted.language ? ` in ${wanted.language}` : ""} on this sender`);
167
+ return found;
168
+ }
169
+ function provider(route) {
170
+ return {
171
+ sendMessage: async (to, message) => {
172
+ const [first, ...rest] = contents(route.kind, message);
173
+ await send(route, to, first, message.replyTo);
174
+ for (const next of rest)
175
+ await send(route, to, next);
176
+ },
177
+ react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
178
+ typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
179
+ markRead: (to, messageIds) => courtesy(route, to, "read", { up_to: messageIds.at(-1) }, messageIds.at(-1)),
180
+ sendTemplate: async (to, wanted) => {
181
+ if (!route.sender)
182
+ throw new Error(`a template needs the channel's sender (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
183
+ const found = await template(route.sender, wanted);
184
+ const params = templateParams(wanted);
185
+ await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) });
186
+ },
187
+ };
188
+ }
189
+ /** WhatsApp from a Flow sender: buttons (a list past 3), lists, link buttons, photos, quotes, reactions, typing, blue ticks, templates. */
190
+ function whatsapp(o = {}) {
191
+ const route = register("whatsapp", o);
192
+ const channel = whatsappChannel(provider(route), { name: route.name, disable: o.disable, instructions: o.instructions });
193
+ return { ...channel, info: { ...channel.info, ...(channel.info.lists ? { lists: { ...channel.info.lists, titleMax: LIST_TITLE_MAX } } : {}) } };
194
+ }
195
+ /** US SMS from a Flow number: plain text; STOP, START and HELP are Flow's. */
196
+ function sms(o = {}) {
197
+ const route = register("sms", o);
198
+ return smsChannel(provider(route), { name: route.name, instructions: o.instructions });
199
+ }
200
+ /** A Telegram bot connected to Flow. Flow Framework has no Telegram kind yet, so it is a custom channel. */
201
+ function telegram(o = {}) {
202
+ return custom(register("telegram", o), TELEGRAM, o);
203
+ }
204
+ /**
205
+ * An iMessage line connected to Flow (Flow's shared one by default). Flow Framework has no iMessage
206
+ * kind yet, so it is a custom channel. Its addresses are not marked as phone numbers, since a handle
207
+ * may be an email address: the person on iMessage is their own, not joined with their WhatsApp or SMS.
208
+ */
209
+ function imessage(o = {}) {
210
+ return custom(register("imessage", o), IMESSAGE, o);
211
+ }
212
+ /** A Flow channel Flow Framework has no kind for: text, quotes, buttons and links as `info` allows, photos, reactions, typing, read receipts. */
213
+ function custom(route, info, o) {
214
+ const p = provider(route);
215
+ return {
216
+ name: route.name,
217
+ kind: "custom",
218
+ info,
219
+ instructions: o.instructions,
220
+ send: (to, outbound) => {
221
+ switch (outbound.type) {
222
+ case "message":
223
+ return p.sendMessage(to, {
224
+ text: outbound.text,
225
+ ...(outbound.replyTo ? { replyTo: outbound.replyTo } : {}),
226
+ ...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
227
+ ...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
228
+ ...(outbound.image && info.images ? { image: outbound.image } : {}),
229
+ });
230
+ case "reaction":
231
+ return p.react(to, outbound.messageId, outbound.emoji);
232
+ case "typing":
233
+ return p.typing(to, outbound.on);
234
+ case "read":
235
+ return info.readReceipts ? p.markRead(to, outbound.messageIds) : undefined; // Telegram bots cannot send them
236
+ case "template":
237
+ throw new Error(`${route.kind === "telegram" ? "Telegram" : "iMessage"} has no templates`);
238
+ // status: neither has a status line.
239
+ }
240
+ },
241
+ };
242
+ }
243
+ // ---- the webhook ----
244
+ function routeOf(event) {
245
+ const c = event.conversation;
246
+ if (!c)
247
+ return undefined;
248
+ return routes.find((r) => r.sender === c.sender) ?? routes.find((r) => r.kind === c.channel && !r.sender);
249
+ }
250
+ async function addressOf(route, contactId) {
251
+ const known = contacts.get(contactId);
252
+ if (known)
253
+ return known;
254
+ const contact = await client.get(`/v1/contacts/${idSegment("ct", contactId)}`);
255
+ const a = contact.address;
256
+ const address = (route.kind === "telegram" ? a?.telegram_user_id : route.kind === "imessage" ? (a?.phone ?? a?.handle) : a?.phone) ?? contactId;
257
+ contacts.set(contactId, address);
258
+ return address;
259
+ }
260
+ async function quoted(id) {
261
+ try {
262
+ const m = await client.get(`/v1/messages/${idSegment("msg", id)}`);
263
+ const text = textOf(m.content);
264
+ return text ? { text, fromAgent: m.direction === "out" } : undefined;
265
+ }
266
+ catch {
267
+ return undefined; // past Flow's retention, or not known to it: the message reads without the quote
268
+ }
269
+ }
270
+ async function incoming(m) {
271
+ const c = m.content;
272
+ let base;
273
+ if (c.type === "text")
274
+ base = { text: String(c.text ?? "") };
275
+ else if (c.type === "button_reply") {
276
+ const id = String(c.button_id ?? "");
277
+ const title = String(c.label ?? id);
278
+ base = { text: title, choice: id && id !== title ? { id, title } : { title } };
279
+ }
280
+ else if (c.type === "voice" && c.transcript)
281
+ base = { text: String(c.transcript) }; // Flow transcribed it already
282
+ else if ((c.type === "voice" || c.type === "media") && c.file_id && c.kind !== "video") {
283
+ // Photos, audio and documents go as attachments, their bytes fetched from Flow only when the agent can use them.
284
+ const kind = c.type === "voice" || c.kind === "audio" ? "audio" : c.kind === "image" ? "image" : "document";
285
+ const fileId = String(c.file_id);
286
+ const attachment = { kind, mime: String(c.mime_type ?? "application/octet-stream"), ...(c.filename ? { name: String(c.filename) } : {}), data: () => client.download(fileId) };
287
+ base = { text: String(c.caption ?? ""), attachments: [attachment] };
288
+ }
289
+ else if (c.type === "media" || c.type === "file_blocked") {
290
+ // A video (Flow Framework reads none), or a file Flow did not keep: the caption, and what it was.
291
+ const what = String(c.kind ?? "file");
292
+ base = { text: [c.caption, c.type === "file_blocked" ? `[${what}: not received (${String(c.reason)})]` : `[${what}]`].filter(Boolean).join(" ") };
293
+ }
294
+ else if (c.type === "location")
295
+ base = { text: `[location: ${[c.name, c.address].filter(Boolean).join(", ")} (${c.lat}, ${c.lng})]` };
296
+ else if (c.type === "contact_card")
297
+ base = { text: `[contact: ${String(c.name ?? "")}]` };
298
+ else
299
+ return null; // reactions arrive as reaction.added
300
+ if (m.reply_to) {
301
+ const replyTo = await quoted(m.reply_to);
302
+ if (replyTo)
303
+ base.replyTo = replyTo;
304
+ }
305
+ return base;
306
+ }
307
+ async function handle(agent, event) {
308
+ if (event.type === "contact.deleted") {
309
+ // Flow erased the contact and their conversations: forget what this process keeps about them.
310
+ const id = event.data?.contact;
311
+ if (!id)
312
+ return;
313
+ const address = contacts.get(id);
314
+ contacts.delete(id);
315
+ for (const r of routes) {
316
+ for (const a of address ? [address, id] : [id]) {
317
+ flowConversation.delete(key(r.name, a));
318
+ frameworkConversation.delete(key(r.name, a));
319
+ }
320
+ }
321
+ return;
322
+ }
323
+ const route = routeOf(event);
324
+ if (!route || !event.conversation)
325
+ return;
326
+ const address = await addressOf(route, event.conversation.contact);
327
+ const person = key(route.name, address);
328
+ if (event.type === "message.received" && event.data?.message) {
329
+ flowConversation.set(person, event.conversation.id);
330
+ const message = await incoming(event.data.message);
331
+ if (!message)
332
+ return;
333
+ const arrived = await agent.message({ channel: route.name, from: address, message, messageId: event.data.message.id });
334
+ if (arrived)
335
+ frameworkConversation.set(person, arrived.conversationId);
336
+ return;
337
+ }
338
+ const conversationId = frameworkConversation.get(person);
339
+ if (!conversationId)
340
+ return; // not heard from since this process started: nothing to tell
341
+ if (event.type === "reaction.added" && event.data?.message_id && event.data.emoji) {
342
+ const target = await quoted(event.data.message_id);
343
+ if (target?.fromAgent)
344
+ agent.reacted(conversationId, event.data.emoji, target.text);
345
+ }
346
+ else if (event.type === "message.failed" && event.data?.message) {
347
+ const m = event.data.message;
348
+ const text = textOf(m.content);
349
+ const type = m.error?.type ?? "channel_error";
350
+ if (text)
351
+ agent.undelivered(conversationId, text, reason({ type, channelCode: m.error?.channel_code, message: m.error?.message ?? "The channel did not take it.", retryable: type === "api_error" || type === "rate_limited" }));
352
+ }
353
+ }
354
+ /** Hands one event to the agent once: a redelivery (or the same event by webhook and stream) is dropped. */
355
+ async function once(agent, event) {
356
+ if (seen.has(event.id))
357
+ return;
358
+ let handled = handling.get(event.id);
359
+ if (!handled) {
360
+ handled = handle(agent, event).finally(() => handling.delete(event.id));
361
+ handling.set(event.id, handled);
362
+ }
363
+ await handled;
364
+ seen.add(event.id);
365
+ if (seen.size > SEEN_MAX)
366
+ seen.delete(seen.values().next().value);
367
+ }
368
+ const failed = (event, error) => console.warn(`@flow-engineer/flow/flow-messaging: event ${event.id} (${event.type}) failed: ${error instanceof FlowMessagingError ? error.type : error instanceof Error ? error.name : "error"}`);
369
+ /**
370
+ * One delivery: checks Flow-Signature, drops a redelivered event, hands the event to the agent.
371
+ * 2xx tells Flow it arrived; a failure answers 500, so Flow delivers it again.
372
+ */
373
+ const noSecret = () => new Error("@flow-engineer/flow/flow-messaging: set FLOW_MESSAGING_WEBHOOK_SECRET (or pass webhookSecret)");
374
+ async function receive(agent, request) {
375
+ if (!secrets.length)
376
+ throw noSecret();
377
+ const raw = typeof request.body === "string" ? Buffer.from(request.body) : request.body;
378
+ const signature = request.headers["flow-signature"];
379
+ if (!verifySignature(raw, Array.isArray(signature) ? signature.join(",") : signature, secrets))
380
+ return { status: 401, body: '{"error":"bad signature"}' };
381
+ let event;
382
+ try {
383
+ event = JSON.parse(raw.toString("utf8"));
384
+ }
385
+ catch {
386
+ return { status: 400, body: '{"error":"not JSON"}' };
387
+ }
388
+ if (typeof event?.id !== "string" || typeof event.type !== "string")
389
+ return { status: 400, body: '{"error":"not an event"}' };
390
+ try {
391
+ await once(agent, event);
392
+ return { status: 200, body: "{}" };
393
+ }
394
+ catch (error) {
395
+ failed(event, error);
396
+ return { status: 500, body: '{"error":"failed"}' };
397
+ }
398
+ }
399
+ /** A Node http handler for the webhook endpoint: give it the requests to the URL Flow posts to. */
400
+ function webhook(agent) {
401
+ if (!secrets.length)
402
+ throw noSecret();
403
+ return async (req, res) => {
404
+ const answer = (status, body) => res.writeHead(status, { "content-type": "application/json" }).end(body);
405
+ if (req.method !== "POST")
406
+ return void answer(405, '{"error":"POST only"}');
407
+ const chunks = [];
408
+ let size = 0;
409
+ for await (const chunk of req) {
410
+ size += chunk.length;
411
+ if (size > MAX_BODY_BYTES)
412
+ return void answer(413, '{"error":"too large"}');
413
+ chunks.push(chunk);
414
+ }
415
+ const result = await receive(agent, { body: Buffer.concat(chunks), headers: req.headers });
416
+ answer(result.status, result.body);
417
+ };
418
+ }
419
+ // ---- the stream ----
420
+ /**
421
+ * The live event stream (GET /v1/stream): an outbound WebSocket, so the agent needs no public URL.
422
+ * Events are handled one at a time, in log order, by the webhook's handler. There is no ack frame
423
+ * for events: the stream resumes from `after`, so an event counts as acknowledged once the agent
424
+ * has handled it and the cursor (in memory, or `cursorFile`) has moved past it. A failed event, a
425
+ * dropped socket or Flow's `reconnect` frame reconnects from that cursor, and Flow replays what
426
+ * came after it: at once the first time, then with a backoff up to 30 s, or after Flow's
427
+ * retry_after. An event that fails 5 times in a row is passed over, so it cannot hold up every
428
+ * later one. A refused key or request stops the stream (`stopped`). Replies go over HTTP
429
+ * (with Idempotency-Keys), so they do not depend on the stream being up.
430
+ */
431
+ function stream(agent, o = {}) {
432
+ if (!apiKey)
433
+ throw new Error("@flow-engineer/flow/flow-messaging: set FLOW_MESSAGING_KEY (or pass apiKey) to open the stream");
434
+ let cursor = o.cursorFile ? readCursor(o.cursorFile) : undefined;
435
+ let socket;
436
+ let timer;
437
+ let closed = false;
438
+ let tries = 0;
439
+ let failing;
440
+ let generation = 0;
441
+ let queue = Promise.resolve();
442
+ let stop;
443
+ const stopped = new Promise((resolve) => (stop = resolve));
444
+ const moveCursor = (id) => {
445
+ cursor = id;
446
+ if (o.cursorFile)
447
+ writeCursor(o.cursorFile, id);
448
+ };
449
+ function connect() {
450
+ const mine = ++generation;
451
+ const url = new URL(`${client.baseUrl.replace(/^http/, "ws")}/v1/stream`);
452
+ if (cursor)
453
+ url.searchParams.set("after", cursor);
454
+ for (const type of HANDLED)
455
+ url.searchParams.append("type", type);
456
+ // Node's WebSocket cannot set headers: the key goes as a subprotocol beside "flow", which Flow selects.
457
+ const ws = new WebSocket(url, ["flow", `flow.key.${apiKey}`]);
458
+ socket = ws;
459
+ let refusal;
460
+ ws.onopen = () => {
461
+ if (!failing)
462
+ tries = 0; // a failing event keeps its backoff across reconnects
463
+ };
464
+ ws.onmessage = (message) => {
465
+ let frame;
466
+ try {
467
+ frame = JSON.parse(String(message.data));
468
+ }
469
+ catch {
470
+ return;
471
+ }
472
+ if (frame.type === "event" && frame.event && typeof frame.event.id === "string") {
473
+ const event = frame.event;
474
+ queue = queue.then(() => take(event, mine, ws));
475
+ }
476
+ else if (frame.type === "error" && !frame.ref)
477
+ refusal = frame.error ?? {};
478
+ };
479
+ ws.onerror = () => { }; // a close always follows
480
+ ws.onclose = ({ code, reason }) => {
481
+ if (socket === ws)
482
+ socket = undefined;
483
+ if (closed || mine !== generation)
484
+ return;
485
+ if (STREAM_FINAL.has(code)) {
486
+ if (code === 4400 && refusal?.param === "after" && cursor) {
487
+ // The cursor's event has left Flow's log (past retention): start again from now.
488
+ console.warn("@flow-engineer/flow/flow-messaging: the last handled event is no longer in Flow's log; the stream starts again from now");
489
+ moveCursor(undefined);
490
+ return void later(0);
491
+ }
492
+ closed = true;
493
+ const type = refusal?.type ?? (code === 4401 ? "authentication" : code === 4403 ? "permission" : "invalid_request");
494
+ const why = refusal?.message ?? reason ?? "Flow Messaging refused the stream.";
495
+ console.warn(`@flow-engineer/flow/flow-messaging: the stream stopped: ${type} (${code})`);
496
+ return void stop({ code, type, message: why });
497
+ }
498
+ // 1012 follows a reconnect frame: at once. 4429 and 45xx say when (retry_after). Else back off.
499
+ if (code === 1012)
500
+ return void later(0);
501
+ if (refusal?.retry_after !== undefined)
502
+ return void later(refusal.retry_after * 1000);
503
+ later(Math.min(STREAM_BACKOFF_MAX, tries === 0 ? 0 : 1000 * 2 ** (tries - 1)));
504
+ tries++;
505
+ };
506
+ }
507
+ function later(ms) {
508
+ if (closed)
509
+ return;
510
+ timer = setTimeout(connect, ms);
511
+ }
512
+ /** Handles one event of connection `mine`; one that fails makes the stream reconnect from the cursor. */
513
+ async function take(event, mine, ws) {
514
+ if (closed || mine !== generation)
515
+ return; // a newer connection replays it from the cursor
516
+ try {
517
+ await once(agent, event);
518
+ }
519
+ catch (error) {
520
+ failed(event, error);
521
+ const times = failing?.id === event.id ? failing.times + 1 : 1;
522
+ if (times < STREAM_EVENT_TRIES) {
523
+ failing = { id: event.id, times };
524
+ if (mine === generation)
525
+ ws.close(1000, "event failed; resuming");
526
+ return;
527
+ }
528
+ console.warn(`@flow-engineer/flow/flow-messaging: event ${event.id} failed ${times} times; going on without it (it stays in Flow's log, GET /v1/events)`);
529
+ }
530
+ failing = undefined;
531
+ tries = 0;
532
+ moveCursor(event.id);
533
+ }
534
+ connect();
535
+ return {
536
+ stopped,
537
+ async close() {
538
+ closed = true;
539
+ clearTimeout(timer);
540
+ socket?.close(1000, "closing");
541
+ await queue;
542
+ },
543
+ };
544
+ }
545
+ /**
546
+ * Starts receiving events: the webhook on `port` (default PORT, else 8080) when a webhook secret is
547
+ * set, which means Flow posts to a public URL; otherwise the stream, which needs no URL.
548
+ */
549
+ function start(agent, o = {}) {
550
+ if (!secrets.length)
551
+ return { mode: "stream", ...stream(agent, o) };
552
+ const server = createServer(webhook(agent)).listen(o.port ?? Number(process.env.PORT ?? 8080));
553
+ return { mode: "webhook", stopped: new Promise(() => { }), close: () => new Promise((resolve) => server.close(() => resolve())) };
554
+ }
555
+ return { whatsapp, telegram, imessage, sms, webhook, receive, stream, start };
556
+ }
557
+ function readCursor(file) {
558
+ try {
559
+ const id = readFileSync(file, "utf8").trim();
560
+ return /^evt_[A-Za-z0-9_]+$/.test(id) ? id : undefined;
561
+ }
562
+ catch {
563
+ return undefined;
564
+ }
565
+ }
566
+ function writeCursor(file, id) {
567
+ mkdirSync(dirname(file), { recursive: true });
568
+ writeFileSync(file, id ? `${id}\n` : "");
569
+ }
570
+ /** The text of a message's content, as the agent knew it. */
571
+ function textOf(content) {
572
+ if (!content)
573
+ return "";
574
+ return String(content.text ?? content.caption ?? content.label ?? "");
575
+ }
576
+ /** A template's values as Flow's `params`: header, body (or the older `variables`, by number) and button values, each only when given. */
577
+ function templateParams(t) {
578
+ const h = t.header;
579
+ const header = !h ? undefined : h.kind === "text" ? { kind: "text", text: h.text } : { kind: h.kind, ...(h.url ? { url: h.url } : {}), ...(h.fileId ? { file_id: h.fileId } : {}) };
580
+ let body = t.body;
581
+ if (!body) {
582
+ const values = Object.entries(t.variables ?? {});
583
+ if (values.every(([k]) => /^\d+$/.test(k)))
584
+ values.sort(([a], [b]) => Number(a) - Number(b));
585
+ body = values.map(([, v]) => v);
586
+ }
587
+ return {
588
+ ...(header ? { header } : {}),
589
+ ...(body.length ? { body } : {}),
590
+ ...(t.buttons?.length ? { buttons: t.buttons.map(({ index, value }) => ({ index, value })) } : {}),
591
+ };
592
+ }
593
+ /** Why a send did not reach the person, for the agent: Flow Framework's outside_window, else Flow's type and whether to resend. */
594
+ function reason(e) {
595
+ const what = e.channelCode ? `${e.type} (${e.channelCode})` : e.type;
596
+ if (e.type === "outside_window" && e.channelCode === "quiet_hours")
597
+ return `${what}: it is night where they are; send it later`;
598
+ if (e.type === "outside_window")
599
+ return "outside_window";
600
+ if (e.channelCode === "channel_timeout_unknown_outcome")
601
+ return `${what}: it may have arrived; ask them before sending it again`;
602
+ if (e.retryable)
603
+ return `${what}: Flow Messaging could not send it now; it may go through later`;
604
+ return `${what}: ${e.message} It will fail again: don't resend it.`;
605
+ }
@@ -0,0 +1,2 @@
1
+ export { playgroundClock, simulators, type AgentConnection, type PlaygroundClock, type Line, type LinkCard, type Playground, type PlaygroundAgent, type SimulatorsOptions, type TestCloudSimulators } from "./server.ts";
2
+ export { PLATFORMS, platform, channelOf, smsSegments, type Platform } from "./platforms.ts";
@@ -0,0 +1,2 @@
1
+ export { playgroundClock, simulators } from "./server.js";
2
+ export { PLATFORMS, platform, channelOf, smsSegments } from "./platforms.js";