@flow-engineer/flow 0.2.0 → 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.
@@ -1,18 +1,24 @@
1
1
  // Flow Framework channels on Flow Messaging (api.flow.engineer), Flow's unified messaging API: WhatsApp,
2
- // Telegram and US SMS from senders Flow hosts, and a webhook that turns Flow's signed events into
3
- // messages for the agent.
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.
4
5
  //
5
- // const fm = flowMessaging(); // FLOW_MESSAGING_KEY, FLOW_MESSAGING_WEBHOOK_SECRET
6
- // const agent = createAgent({ ..., channels: [fm.whatsapp(), fm.telegram(), fm.sms()] });
7
- // http.createServer(fm.webhook(agent)).listen(8080);
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
8
9
  //
9
10
  // Addresses are what Flow Framework joins people by: the phone number on WhatsApp and SMS, the numeric
10
- // user id on Telegram. A reply goes into the Flow conversation the person last wrote in; with none
11
- // known yet (a nudge, or after a restart) it starts one from the channel's sender, which Flow puts
12
- // into the open conversation if there is one. What this process learns (conversations, contacts,
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,
13
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";
14
20
  import { sms as smsChannel, whatsapp as whatsappChannel } from "../../src/index.js";
15
- import { Client, FlowMessagingError, verifySignature } from "./client.js";
21
+ import { Client, FlowMessagingError, idSegment, verifySignature } from "./client.js";
16
22
  export { API_VERSION, FlowMessagingError, verifySignature } from "./client.js";
17
23
  /** Telegram as Flow sends it: an inline keyboard of up to 10 buttons (or one link), photos, Markdown, no read receipts for bots. */
18
24
  export const TELEGRAM = {
@@ -26,11 +32,34 @@ export const TELEGRAM = {
26
32
  images: { captionMax: 1024 },
27
33
  readReceipts: false,
28
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
+ };
29
50
  /** Flow sends a list as buttons, whose labels take at most 20 characters, and has no row descriptions. */
30
51
  const LIST_TITLE_MAX = 20;
31
52
  const MAX_BODY_BYTES = 1_000_000;
32
53
  /** Event ids remembered to drop Flow's redeliveries (deliveries are at least once). */
33
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]);
34
63
  export function flowMessaging(options = {}) {
35
64
  const env = (...names) => names.map((n) => process.env[n]).find(Boolean);
36
65
  const apiKey = options.apiKey ?? env("FLOW_MESSAGING_KEY", "FLOW_MESSAGING_API_KEY") ?? "";
@@ -54,7 +83,7 @@ export function flowMessaging(options = {}) {
54
83
  const known = flowConversation.get(key(channel, to));
55
84
  if (known || !messageId)
56
85
  return known;
57
- const found = (await client.get(`/v1/messages/${messageId}`)).conversation;
86
+ const found = (await client.get(`/v1/messages/${idSegment("msg", messageId)}`)).conversation;
58
87
  flowConversation.set(key(channel, to), found);
59
88
  return found;
60
89
  }
@@ -63,6 +92,8 @@ export function flowMessaging(options = {}) {
63
92
  return { contact: to };
64
93
  if (kind === "telegram")
65
94
  return { telegram_user_id: to };
95
+ if (kind === "imessage" && !/^\+?[0-9]{7,15}$/.test(to))
96
+ return { handle: to }; // an email address
66
97
  return { phone: to.startsWith("+") ? to : `+${to}` };
67
98
  }
68
99
  /** One message: into the person's conversation, or a new one from the channel's sender. */
@@ -71,7 +102,7 @@ export function flowMessaging(options = {}) {
71
102
  const conversation = await conversationFor(route.name, to, about);
72
103
  let sent;
73
104
  if (conversation)
74
- sent = await client.post(`/v1/conversations/${conversation}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
105
+ sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
75
106
  else {
76
107
  if (!route.sender)
77
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)`);
@@ -88,7 +119,7 @@ export function flowMessaging(options = {}) {
88
119
  try {
89
120
  const conversation = await conversationFor(route.name, to, messageId);
90
121
  if (conversation)
91
- await client.post(`/v1/conversations/${conversation}/${action}`, body, 1);
122
+ await client.post(`/v1/conversations/${idSegment("conv", conversation)}/${action}`, body, 1);
92
123
  }
93
124
  catch (error) {
94
125
  console.warn(`@flow-engineer/flow/flow-messaging: ${action} on ${route.name} failed (${error instanceof FlowMessagingError ? error.type : "error"})`);
@@ -122,7 +153,7 @@ export function flowMessaging(options = {}) {
122
153
  const all = [];
123
154
  let after = "";
124
155
  for (;;) {
125
- const page = await client.get(`/v1/templates?sender=${sender}&status=approved&limit=100${after ? `&after=${after}` : ""}`);
156
+ const page = await client.get(`/v1/templates?${new URLSearchParams({ sender: idSegment("snd", sender), status: "approved", limit: "100", ...(after ? { after } : {}) })}`);
126
157
  all.push(...page.data);
127
158
  if (!page.has_more || !page.data.length)
128
159
  break;
@@ -168,12 +199,23 @@ export function flowMessaging(options = {}) {
168
199
  }
169
200
  /** A Telegram bot connected to Flow. Flow Framework has no Telegram kind yet, so it is a custom channel. */
170
201
  function telegram(o = {}) {
171
- const route = register("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) {
172
214
  const p = provider(route);
173
215
  return {
174
216
  name: route.name,
175
217
  kind: "custom",
176
- info: TELEGRAM,
218
+ info,
177
219
  instructions: o.instructions,
178
220
  send: (to, outbound) => {
179
221
  switch (outbound.type) {
@@ -181,17 +223,19 @@ export function flowMessaging(options = {}) {
181
223
  return p.sendMessage(to, {
182
224
  text: outbound.text,
183
225
  ...(outbound.replyTo ? { replyTo: outbound.replyTo } : {}),
184
- ...(outbound.buttons ? { buttons: outbound.buttons } : {}),
185
- ...(outbound.link ? { link: outbound.link } : {}),
186
- ...(outbound.image ? { image: outbound.image } : {}),
226
+ ...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
227
+ ...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
228
+ ...(outbound.image && info.images ? { image: outbound.image } : {}),
187
229
  });
188
230
  case "reaction":
189
231
  return p.react(to, outbound.messageId, outbound.emoji);
190
232
  case "typing":
191
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
192
236
  case "template":
193
- throw new Error("Telegram has no templates");
194
- // read: Telegram bots cannot send read receipts; status: Telegram has no status line.
237
+ throw new Error(`${route.kind === "telegram" ? "Telegram" : "iMessage"} has no templates`);
238
+ // status: neither has a status line.
195
239
  }
196
240
  },
197
241
  };
@@ -207,14 +251,15 @@ export function flowMessaging(options = {}) {
207
251
  const known = contacts.get(contactId);
208
252
  if (known)
209
253
  return known;
210
- const contact = await client.get(`/v1/contacts/${contactId}`);
211
- const address = (route.kind === "telegram" ? contact.address?.telegram_user_id : contact.address?.phone) ?? contactId;
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;
212
257
  contacts.set(contactId, address);
213
258
  return address;
214
259
  }
215
260
  async function quoted(id) {
216
261
  try {
217
- const m = await client.get(`/v1/messages/${id}`);
262
+ const m = await client.get(`/v1/messages/${idSegment("msg", id)}`);
218
263
  const text = textOf(m.content);
219
264
  return text ? { text, fromAgent: m.direction === "out" } : undefined;
220
265
  }
@@ -306,6 +351,21 @@ export function flowMessaging(options = {}) {
306
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" }));
307
352
  }
308
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"}`);
309
369
  /**
310
370
  * One delivery: checks Flow-Signature, drops a redelivered event, hands the event to the agent.
311
371
  * 2xx tells Flow it arrived; a failure answers 500, so Flow delivers it again.
@@ -327,27 +387,14 @@ export function flowMessaging(options = {}) {
327
387
  }
328
388
  if (typeof event?.id !== "string" || typeof event.type !== "string")
329
389
  return { status: 400, body: '{"error":"not an event"}' };
330
- if (seen.has(event.id))
331
- return { status: 200, body: "{}" };
332
- let handled = handling.get(event.id);
333
- if (!handled) {
334
- handled = handle(agent, event);
335
- handling.set(event.id, handled);
336
- }
337
390
  try {
338
- await handled;
339
- seen.add(event.id);
340
- if (seen.size > SEEN_MAX)
341
- seen.delete(seen.values().next().value);
391
+ await once(agent, event);
342
392
  return { status: 200, body: "{}" };
343
393
  }
344
394
  catch (error) {
345
- console.warn(`@flow-engineer/flow/flow-messaging: event ${event.id} (${event.type}) failed: ${error instanceof FlowMessagingError ? error.type : error instanceof Error ? error.name : "error"}`);
395
+ failed(event, error);
346
396
  return { status: 500, body: '{"error":"failed"}' };
347
397
  }
348
- finally {
349
- handling.delete(event.id);
350
- }
351
398
  }
352
399
  /** A Node http handler for the webhook endpoint: give it the requests to the URL Flow posts to. */
353
400
  function webhook(agent) {
@@ -369,7 +416,156 @@ export function flowMessaging(options = {}) {
369
416
  answer(result.status, result.body);
370
417
  };
371
418
  }
372
- return { whatsapp, telegram, sms, webhook, receive };
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` : "");
373
569
  }
374
570
  /** The text of a message's content, as the agent knew it. */
375
571
  function textOf(content) {
@@ -1,5 +1,10 @@
1
1
  /** A setting from the environment: FLOW_FRAMEWORK_<name>, else CHINWAG_<name> from before the rename. */
2
2
  export declare function setting(name: string, env?: NodeJS.ProcessEnv): string | undefined;
3
+ /**
4
+ * The command to print in a next step. `npx flow` would fetch another npm package wherever this one is
5
+ * not installed, so a hint always names the package.
6
+ */
7
+ export declare const RUN = "npx @flow-engineer/flow";
3
8
  /** An agent's results folder, in the agent's folder: written here, and git-ignored by `flow new`. */
4
9
  export declare const RESULTS_DIR = ".flow";
5
10
  /** The results folder from before the rename, still read until a run writes {@link RESULTS_DIR}. */
package/dist/src/names.js CHANGED
@@ -8,6 +8,11 @@ import { join } from "node:path";
8
8
  export function setting(name, env = process.env) {
9
9
  return env[`FLOW_FRAMEWORK_${name}`] ?? env[`CHINWAG_${name}`];
10
10
  }
11
+ /**
12
+ * The command to print in a next step. `npx flow` would fetch another npm package wherever this one is
13
+ * not installed, so a hint always names the package.
14
+ */
15
+ export const RUN = "npx @flow-engineer/flow";
11
16
  /** An agent's results folder, in the agent's folder: written here, and git-ignored by `flow new`. */
12
17
  export const RESULTS_DIR = ".flow";
13
18
  /** The results folder from before the rename, still read until a run writes {@link RESULTS_DIR}. */
@@ -0,0 +1,4 @@
1
+ export declare const ACCOUNT_COMMANDS: Set<string>;
2
+ export declare const ACCOUNT_LINES = " flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]\n Sign in to flow.engineer: opens the browser, prints the link and code,\n and waits up to --timeout (default 120) for approval; --wait goes on\n waiting for the same code, --no-wait returns at once\n flow logout [--json] End this machine's sign-in and delete its token\n flow whoami [--json] Show the account you are signed in as\n flow setup [--replace] [--ci] [--json]\n Write this agent's keys (test cloud, traces, Flow Messaging) and each\n open channel's sender into ./.env (in an agent's folder: package.json)\n flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]\n Print the link that opens a chat with this agent from a phone; signs in\n and runs setup first when this folder is not set up yet";
3
+ /** Runs one account command; resolves to the exit status. */
4
+ export declare function accountCli(command: string, args: string[]): Promise<number>;