@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.
- package/LICENSE +202 -21
- package/README.md +28 -12
- package/coding-agents/skill.md +32 -0
- package/coding-agents/test-skill.md +10 -10
- package/dist/flow-messaging/src/client.d.ts +7 -1
- package/dist/flow-messaging/src/client.js +13 -1
- package/dist/flow-messaging/src/index.d.ts +38 -3
- package/dist/flow-messaging/src/index.js +235 -39
- package/dist/src/names.d.ts +5 -0
- package/dist/src/names.js +5 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +580 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +117 -0
- package/dist/testing/src/chinwag.js +1 -1
- package/dist/testing/src/cli.js +42 -13
- package/dist/testing/src/client.d.ts +2 -0
- package/dist/testing/src/client.js +4 -3
- package/dist/testing/src/flow-login.d.ts +143 -0
- package/dist/testing/src/flow-login.js +249 -0
- package/dist/testing/src/new.d.ts +4 -0
- package/dist/testing/src/new.js +38 -13
- package/docs/agents.md +85 -0
- package/examples/cafe/src/agent.ts +6 -2
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/main.ts +17 -3
- package/flow-messaging/README.md +66 -27
- package/package.json +3 -2
- package/spec/agent-endpoint.openapi.yaml +2 -2
- package/spec/nudges.openapi.yaml +2 -2
|
@@ -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
|
|
3
|
-
//
|
|
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
|
|
6
|
-
// const agent = createAgent({ ..., channels: [fm.whatsapp(), fm.telegram(), fm.sms()] });
|
|
7
|
-
//
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/src/names.d.ts
CHANGED
|
@@ -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>;
|