@flow-engineer/flow 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +594 -2
- package/coding-agents/skill.md +321 -0
- package/coding-agents/test-skill.md +223 -0
- package/coding-agents/tools-skill.md +89 -0
- package/dist/flow-messaging/src/client.d.ts +34 -0
- package/dist/flow-messaging/src/client.js +130 -0
- package/dist/flow-messaging/src/index.d.ts +37 -0
- package/dist/flow-messaging/src/index.js +409 -0
- package/dist/simulators/src/index.d.ts +2 -0
- package/dist/simulators/src/index.js +2 -0
- package/dist/simulators/src/page.html +1245 -0
- package/dist/simulators/src/person.html +100 -0
- package/dist/simulators/src/platforms.d.ts +29 -0
- package/dist/simulators/src/platforms.js +72 -0
- package/dist/simulators/src/server.d.ts +201 -0
- package/dist/simulators/src/server.js +759 -0
- package/dist/src/agent.d.ts +504 -0
- package/dist/src/agent.js +818 -0
- package/dist/src/channels.d.ts +178 -0
- package/dist/src/channels.js +243 -0
- package/dist/src/conversation.d.ts +361 -0
- package/dist/src/conversation.js +1487 -0
- package/dist/src/db.d.ts +18 -0
- package/dist/src/db.js +57 -0
- package/dist/src/events.d.ts +17 -0
- package/dist/src/events.js +20 -0
- package/dist/src/http.d.ts +45 -0
- package/dist/src/http.js +269 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +11 -0
- package/dist/src/model.d.ts +21 -0
- package/dist/src/model.js +96 -0
- package/dist/src/names.d.ts +17 -0
- package/dist/src/names.js +38 -0
- package/dist/src/nudges.d.ts +23 -0
- package/dist/src/nudges.js +117 -0
- package/dist/src/postgres.d.ts +20 -0
- package/dist/src/postgres.js +62 -0
- package/dist/src/schedules.d.ts +60 -0
- package/dist/src/schedules.js +249 -0
- package/dist/src/skills.d.ts +12 -0
- package/dist/src/skills.js +69 -0
- package/dist/src/store.d.ts +214 -0
- package/dist/src/store.js +448 -0
- package/dist/src/talk.d.ts +57 -0
- package/dist/src/talk.js +166 -0
- package/dist/src/time.d.ts +71 -0
- package/dist/src/time.js +170 -0
- package/dist/src/tools.d.ts +43 -0
- package/dist/src/tools.js +26 -0
- package/dist/src/traces.d.ts +31 -0
- package/dist/src/traces.js +303 -0
- package/dist/src/types.d.ts +260 -0
- package/dist/src/types.js +2 -0
- package/dist/testing/src/check-endpoint.d.ts +31 -0
- package/dist/testing/src/check-endpoint.js +158 -0
- package/dist/testing/src/chinwag.d.ts +2 -0
- package/dist/testing/src/chinwag.js +6 -0
- package/dist/testing/src/cli.d.ts +2 -0
- package/dist/testing/src/cli.js +563 -0
- package/dist/testing/src/client.d.ts +115 -0
- package/dist/testing/src/client.js +955 -0
- package/dist/testing/src/http-agent.d.ts +47 -0
- package/dist/testing/src/http-agent.js +155 -0
- package/dist/testing/src/index.d.ts +3 -0
- package/dist/testing/src/index.js +2 -0
- package/dist/testing/src/new.d.ts +9 -0
- package/dist/testing/src/new.js +122 -0
- package/dist/testing/src/print-run.d.ts +3 -0
- package/dist/testing/src/print-run.js +22 -0
- package/dist/testing/src/protocol.d.ts +159 -0
- package/dist/testing/src/protocol.js +28 -0
- package/dist/testing/src/report.d.ts +74 -0
- package/dist/testing/src/report.js +90 -0
- package/dist/testing/src/show.d.ts +36 -0
- package/dist/testing/src/show.js +280 -0
- package/dist/testing/src/suite.d.ts +87 -0
- package/dist/testing/src/suite.js +198 -0
- package/examples/cafe/agent/AGENTS.md +10 -0
- package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
- package/examples/cafe/src/agent.ts +39 -0
- package/examples/cafe/src/main.ts +16 -0
- package/examples/cafe/src/tools.ts +62 -0
- package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
- package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
- package/examples/cafe/tests/tools/get_menu.yaml +14 -0
- package/examples/cafe/tests/tools/place_order.yaml +29 -0
- package/examples/http-agent/server.mjs +32 -0
- package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
- package/flow-messaging/README.md +152 -0
- package/package.json +65 -4
- package/spec/agent-endpoint.openapi.yaml +148 -0
- package/spec/nudges.openapi.yaml +167 -0
- package/spec/request.schema.json +121 -0
- package/spec/response.schema.json +284 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const API_VERSION = "2026-11-01";
|
|
2
|
+
/** The spec's typed error ({"error": {type, message, channel_code, retry_after, ...}}), or "network". */
|
|
3
|
+
export declare class FlowMessagingError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly channelCode?: string;
|
|
7
|
+
readonly retryAfter?: number;
|
|
8
|
+
constructor(status: number, type: string, message: string, extra?: {
|
|
9
|
+
channelCode?: string;
|
|
10
|
+
retryAfter?: number;
|
|
11
|
+
});
|
|
12
|
+
/** Worth trying again later: rate limits, Flow's or the channel's 5xx, no answer. */
|
|
13
|
+
get retryable(): boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare class Client {
|
|
16
|
+
private readonly apiKey;
|
|
17
|
+
private readonly baseUrl;
|
|
18
|
+
constructor(options: {
|
|
19
|
+
apiKey: string;
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
});
|
|
22
|
+
get<T>(path: string): Promise<T>;
|
|
23
|
+
/** A file's bytes: GET /v1/files/{id} answers them or redirects to a signed URL, which fetch follows without the key. */
|
|
24
|
+
download(fileId: string): Promise<Uint8Array>;
|
|
25
|
+
/** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
|
|
26
|
+
post<T>(path: string, body: unknown, tries?: number): Promise<T>;
|
|
27
|
+
private request;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Flow-Signature: `t=<unix seconds>,v1=<hex>[,v1=<hex>]`. Each v1 is HMAC-SHA256, under one of the
|
|
31
|
+
* endpoint's signing secrets, of `t.<t>.<raw body>`; any match within five minutes of now passes
|
|
32
|
+
* (two secrets overlap while one is rotated).
|
|
33
|
+
*/
|
|
34
|
+
export declare function verifySignature(body: Buffer, header: string | undefined, secrets: string[], nowSeconds?: number): boolean;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A small client for Flow Messaging (api.flow.engineer), against its OpenAPI spec, version
|
|
2
|
+
// 2026-11-01: plain fetch, only the calls the channels and the webhook make.
|
|
3
|
+
//
|
|
4
|
+
// Every POST carries an Idempotency-Key, and a retry repeats the same key, so a send is never made
|
|
5
|
+
// twice. A 429 or a 5xx is tried again in place after its Retry-After when that is short; a longer
|
|
6
|
+
// wait, or any other error, is raised as FlowMessagingError (the spec's typed error). Keys and
|
|
7
|
+
// message text are never logged.
|
|
8
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
9
|
+
export const API_VERSION = "2026-11-01";
|
|
10
|
+
const TRIES = 3;
|
|
11
|
+
/** The longest Retry-After waited out in place (seconds); a longer one is the caller's. */
|
|
12
|
+
const MAX_WAIT = 10;
|
|
13
|
+
const TIMEOUT_MS = 20_000;
|
|
14
|
+
/** How far a webhook's signed timestamp may be from now (seconds), as the spec says. */
|
|
15
|
+
const SIGNATURE_TOLERANCE = 300;
|
|
16
|
+
/** The spec's typed error ({"error": {type, message, channel_code, retry_after, ...}}), or "network". */
|
|
17
|
+
export class FlowMessagingError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
type;
|
|
20
|
+
channelCode;
|
|
21
|
+
retryAfter;
|
|
22
|
+
constructor(status, type, message, extra = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "FlowMessagingError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.type = type;
|
|
27
|
+
this.channelCode = extra.channelCode;
|
|
28
|
+
this.retryAfter = extra.retryAfter;
|
|
29
|
+
}
|
|
30
|
+
/** Worth trying again later: rate limits, Flow's or the channel's 5xx, no answer. */
|
|
31
|
+
get retryable() {
|
|
32
|
+
return this.status === 0 || this.status === 429 || this.status >= 500 || (this.type === "idempotency_conflict" && this.channelCode === "in_progress");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class Client {
|
|
36
|
+
apiKey;
|
|
37
|
+
baseUrl;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.apiKey = options.apiKey;
|
|
40
|
+
let end = options.baseUrl.length;
|
|
41
|
+
while (end > 0 && options.baseUrl[end - 1] === "/")
|
|
42
|
+
end--;
|
|
43
|
+
this.baseUrl = options.baseUrl.slice(0, end);
|
|
44
|
+
}
|
|
45
|
+
get(path) {
|
|
46
|
+
return this.request("GET", path);
|
|
47
|
+
}
|
|
48
|
+
/** A file's bytes: GET /v1/files/{id} answers them or redirects to a signed URL, which fetch follows without the key. */
|
|
49
|
+
async download(fileId) {
|
|
50
|
+
if (!this.apiKey)
|
|
51
|
+
throw new Error("set FLOW_MESSAGING_KEY (or pass apiKey) to reach Flow Messaging");
|
|
52
|
+
const response = await fetch(`${this.baseUrl}/v1/files/${fileId}`, { headers: { authorization: `Bearer ${this.apiKey}`, "flow-version": API_VERSION }, signal: AbortSignal.timeout(60_000) });
|
|
53
|
+
if (!response.ok)
|
|
54
|
+
throw await errorOf(response);
|
|
55
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
56
|
+
}
|
|
57
|
+
/** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
|
|
58
|
+
post(path, body, tries = TRIES) {
|
|
59
|
+
return this.request("POST", path, body, randomUUID(), tries);
|
|
60
|
+
}
|
|
61
|
+
async request(method, path, body, idempotencyKey, tries = TRIES) {
|
|
62
|
+
if (!this.apiKey)
|
|
63
|
+
throw new Error("set FLOW_MESSAGING_KEY (or pass apiKey) to reach Flow Messaging");
|
|
64
|
+
const headers = { authorization: `Bearer ${this.apiKey}`, "flow-version": API_VERSION };
|
|
65
|
+
if (body !== undefined)
|
|
66
|
+
headers["content-type"] = "application/json";
|
|
67
|
+
if (idempotencyKey)
|
|
68
|
+
headers["idempotency-key"] = idempotencyKey;
|
|
69
|
+
let last;
|
|
70
|
+
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
71
|
+
let response;
|
|
72
|
+
try {
|
|
73
|
+
response = await fetch(this.baseUrl + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
last = new FlowMessagingError(0, "network", `no answer from Flow Messaging (${error instanceof Error ? error.name : "error"})`);
|
|
77
|
+
if (attempt < tries)
|
|
78
|
+
await wait(2 ** (attempt - 1));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (response.ok) {
|
|
82
|
+
const text = await response.text();
|
|
83
|
+
return (text ? JSON.parse(text) : {});
|
|
84
|
+
}
|
|
85
|
+
last = await errorOf(response);
|
|
86
|
+
const pause = last.retryAfter ?? 2 ** (attempt - 1);
|
|
87
|
+
if (!last.retryable || attempt === tries || pause > MAX_WAIT)
|
|
88
|
+
throw last;
|
|
89
|
+
await wait(pause);
|
|
90
|
+
}
|
|
91
|
+
throw last;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function errorOf(response) {
|
|
95
|
+
let body = {};
|
|
96
|
+
try {
|
|
97
|
+
const parsed = (await response.json());
|
|
98
|
+
if (parsed && typeof parsed.error === "object")
|
|
99
|
+
body = parsed.error;
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
const header = Number(response.headers.get("retry-after"));
|
|
103
|
+
const retryAfter = typeof body.retry_after === "number" ? body.retry_after : Number.isFinite(header) && response.headers.has("retry-after") ? header : undefined;
|
|
104
|
+
const type = body.type ?? (response.status >= 500 ? "api_error" : "invalid_request");
|
|
105
|
+
return new FlowMessagingError(response.status, type, body.message ?? `Flow Messaging answered ${response.status}`, { channelCode: body.channel_code, retryAfter });
|
|
106
|
+
}
|
|
107
|
+
const wait = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
108
|
+
/**
|
|
109
|
+
* Flow-Signature: `t=<unix seconds>,v1=<hex>[,v1=<hex>]`. Each v1 is HMAC-SHA256, under one of the
|
|
110
|
+
* endpoint's signing secrets, of `t.<t>.<raw body>`; any match within five minutes of now passes
|
|
111
|
+
* (two secrets overlap while one is rotated).
|
|
112
|
+
*/
|
|
113
|
+
export function verifySignature(body, header, secrets, nowSeconds = Date.now() / 1000) {
|
|
114
|
+
let stamp = "";
|
|
115
|
+
const given = [];
|
|
116
|
+
for (const part of (header ?? "").split(",")) {
|
|
117
|
+
const [key, value = ""] = part.trim().split("=", 2);
|
|
118
|
+
if (key === "t")
|
|
119
|
+
stamp = value;
|
|
120
|
+
else if (key === "v1" && value)
|
|
121
|
+
given.push(value);
|
|
122
|
+
}
|
|
123
|
+
if (!/^\d+$/.test(stamp) || !given.length || Math.abs(nowSeconds - Number(stamp)) > SIGNATURE_TOLERANCE)
|
|
124
|
+
return false;
|
|
125
|
+
const signed = Buffer.concat([Buffer.from(`t.${stamp}.`), body]);
|
|
126
|
+
return secrets.some((secret) => {
|
|
127
|
+
const expected = Buffer.from(createHmac("sha256", secret).update(signed).digest("hex"));
|
|
128
|
+
return given.some((value) => value.length === expected.length && timingSafeEqual(Buffer.from(value), expected));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type Agent, type Channel, type ChannelInfo, type WhatsAppFeature } from "../../src/index.ts";
|
|
3
|
+
export { API_VERSION, FlowMessagingError, verifySignature } from "./client.ts";
|
|
4
|
+
export interface FlowMessagingOptions {
|
|
5
|
+
/** The app's API key, `fk_live_...` or `fk_test_...`. Default: FLOW_MESSAGING_KEY (or the older FLOW_MESSAGING_API_KEY). */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
/** The webhook endpoint's signing secret(s), comma-separated while one is rotated. Default: FLOW_MESSAGING_WEBHOOK_SECRET. */
|
|
8
|
+
webhookSecret?: string;
|
|
9
|
+
/** Default: FLOW_MESSAGING_BASE_URL (or the older FLOW_MESSAGING_API_URL), else https://api.flow.engineer. */
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FlowChannelOptions {
|
|
13
|
+
/** The Flow sender (`snd_...`) this channel starts conversations from. Default: FLOW_MESSAGING_<CHANNEL>_SENDER. */
|
|
14
|
+
sender?: string;
|
|
15
|
+
/** The agent's name for the channel. Default: whatsapp, telegram or sms. */
|
|
16
|
+
name?: string;
|
|
17
|
+
instructions?: string;
|
|
18
|
+
}
|
|
19
|
+
/** What the webhook needs of an agent. */
|
|
20
|
+
export type WebhookAgent = Pick<Agent, "message" | "reacted" | "undelivered">;
|
|
21
|
+
/** Telegram as Flow sends it: an inline keyboard of up to 10 buttons (or one link), photos, Markdown, no read receipts for bots. */
|
|
22
|
+
export declare const TELEGRAM: ChannelInfo;
|
|
23
|
+
export declare function flowMessaging(options?: FlowMessagingOptions): {
|
|
24
|
+
whatsapp: (o?: FlowChannelOptions & {
|
|
25
|
+
disable?: WhatsAppFeature[];
|
|
26
|
+
}) => Channel;
|
|
27
|
+
telegram: (o?: FlowChannelOptions) => Channel;
|
|
28
|
+
sms: (o?: FlowChannelOptions) => Channel;
|
|
29
|
+
webhook: (agent: WebhookAgent) => (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
30
|
+
receive: (agent: WebhookAgent, request: {
|
|
31
|
+
body: Buffer | string;
|
|
32
|
+
headers: Record<string, string | string[] | undefined>;
|
|
33
|
+
}) => Promise<{
|
|
34
|
+
status: number;
|
|
35
|
+
body: string;
|
|
36
|
+
}>;
|
|
37
|
+
};
|
|
@@ -0,0 +1,409 @@
|
|
|
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.
|
|
4
|
+
//
|
|
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);
|
|
8
|
+
//
|
|
9
|
+
// 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,
|
|
13
|
+
// event ids) is kept in memory only.
|
|
14
|
+
import { sms as smsChannel, whatsapp as whatsappChannel } from "../../src/index.js";
|
|
15
|
+
import { Client, FlowMessagingError, verifySignature } from "./client.js";
|
|
16
|
+
export { API_VERSION, FlowMessagingError, verifySignature } from "./client.js";
|
|
17
|
+
/** Telegram as Flow sends it: an inline keyboard of up to 10 buttons (or one link), photos, Markdown, no read receipts for bots. */
|
|
18
|
+
export const TELEGRAM = {
|
|
19
|
+
reactions: true,
|
|
20
|
+
status: false,
|
|
21
|
+
formatting: "markdown",
|
|
22
|
+
maxLength: 4096,
|
|
23
|
+
replies: true,
|
|
24
|
+
buttons: { max: 10, labelMax: 20 },
|
|
25
|
+
linkButton: { labelMax: 20 },
|
|
26
|
+
images: { captionMax: 1024 },
|
|
27
|
+
readReceipts: false,
|
|
28
|
+
};
|
|
29
|
+
/** Flow sends a list as buttons, whose labels take at most 20 characters, and has no row descriptions. */
|
|
30
|
+
const LIST_TITLE_MAX = 20;
|
|
31
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
32
|
+
/** Event ids remembered to drop Flow's redeliveries (deliveries are at least once). */
|
|
33
|
+
const SEEN_MAX = 10_000;
|
|
34
|
+
export function flowMessaging(options = {}) {
|
|
35
|
+
const env = (...names) => names.map((n) => process.env[n]).find(Boolean);
|
|
36
|
+
const apiKey = options.apiKey ?? env("FLOW_MESSAGING_KEY", "FLOW_MESSAGING_API_KEY") ?? "";
|
|
37
|
+
const secrets = (options.webhookSecret ?? process.env.FLOW_MESSAGING_WEBHOOK_SECRET ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
38
|
+
const client = new Client({ apiKey, baseUrl: options.baseUrl ?? env("FLOW_MESSAGING_BASE_URL", "FLOW_MESSAGING_API_URL") ?? "https://api.flow.engineer" });
|
|
39
|
+
const routes = [];
|
|
40
|
+
const flowConversation = new Map(); // channel + address -> Flow conversation id
|
|
41
|
+
const frameworkConversation = new Map(); // channel + address -> Flow Framework conversation id
|
|
42
|
+
const contacts = new Map(); // Flow contact id -> address
|
|
43
|
+
const templates = new Map(); // sender -> approved templates
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const handling = new Map();
|
|
46
|
+
const key = (channel, address) => `${channel}\u0000${address}`;
|
|
47
|
+
function register(kind, o) {
|
|
48
|
+
const route = { kind, name: o.name ?? kind, sender: o.sender ?? (process.env[`FLOW_MESSAGING_${kind.toUpperCase()}_SENDER`] || undefined) };
|
|
49
|
+
routes.push(route);
|
|
50
|
+
return route;
|
|
51
|
+
}
|
|
52
|
+
/** The Flow conversation to send into: the one the person wrote in, else the one a message they quote is in. */
|
|
53
|
+
async function conversationFor(channel, to, messageId) {
|
|
54
|
+
const known = flowConversation.get(key(channel, to));
|
|
55
|
+
if (known || !messageId)
|
|
56
|
+
return known;
|
|
57
|
+
const found = (await client.get(`/v1/messages/${messageId}`)).conversation;
|
|
58
|
+
flowConversation.set(key(channel, to), found);
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
function recipient(kind, to) {
|
|
62
|
+
if (to.startsWith("ct_"))
|
|
63
|
+
return { contact: to };
|
|
64
|
+
if (kind === "telegram")
|
|
65
|
+
return { telegram_user_id: to };
|
|
66
|
+
return { phone: to.startsWith("+") ? to : `+${to}` };
|
|
67
|
+
}
|
|
68
|
+
/** One message: into the person's conversation, or a new one from the channel's sender. */
|
|
69
|
+
async function send(route, to, content, replyTo, about = replyTo) {
|
|
70
|
+
try {
|
|
71
|
+
const conversation = await conversationFor(route.name, to, about);
|
|
72
|
+
let sent;
|
|
73
|
+
if (conversation)
|
|
74
|
+
sent = await client.post(`/v1/conversations/${conversation}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
|
|
75
|
+
else {
|
|
76
|
+
if (!route.sender)
|
|
77
|
+
throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
|
|
78
|
+
sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content });
|
|
79
|
+
}
|
|
80
|
+
flowConversation.set(key(route.name, to), sent.conversation);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw error instanceof FlowMessagingError ? new Error(reason(error)) : error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Typing and read receipts are a courtesy: Flow's spec says to ignore their failures. */
|
|
87
|
+
async function courtesy(route, to, action, body, messageId) {
|
|
88
|
+
try {
|
|
89
|
+
const conversation = await conversationFor(route.name, to, messageId);
|
|
90
|
+
if (conversation)
|
|
91
|
+
await client.post(`/v1/conversations/${conversation}/${action}`, body, 1);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
console.warn(`@flow-engineer/flow/flow-messaging: ${action} on ${route.name} failed (${error instanceof FlowMessagingError ? error.type : "error"})`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A message as Flow content. A link is a url button (WhatsApp's cta_url), a photo a media message
|
|
99
|
+
* with the text as its caption. Flow's button messages have no image header, so a photo with
|
|
100
|
+
* buttons or a link goes just before them, on its own.
|
|
101
|
+
*/
|
|
102
|
+
function contents(kind, message) {
|
|
103
|
+
const buttons = message.link
|
|
104
|
+
? [{ url: message.link.url, label: message.link.label }]
|
|
105
|
+
: message.buttons?.length
|
|
106
|
+
? message.buttons.map((label) => ({ id: label, label }))
|
|
107
|
+
: message.list?.rows.map((row) => ({ id: row.id, label: row.title }));
|
|
108
|
+
const photo = message.image && { type: "media", kind: "image", url: message.image.url };
|
|
109
|
+
if (photo && !buttons)
|
|
110
|
+
return [{ ...photo, ...(message.text ? { caption: message.text } : {}) }];
|
|
111
|
+
const main = buttons ? { type: "buttons", text: message.text, buttons } : { type: "text", text: message.text, format: kind === "telegram" ? "markdown" : "plain" };
|
|
112
|
+
return photo ? [photo, main] : [main];
|
|
113
|
+
}
|
|
114
|
+
async function template(sender, wanted) {
|
|
115
|
+
if (wanted.name.startsWith("tpl_")) {
|
|
116
|
+
if (!wanted.language)
|
|
117
|
+
throw new Error(`template ${wanted.name} needs its language`);
|
|
118
|
+
return { id: wanted.name, language: wanted.language };
|
|
119
|
+
}
|
|
120
|
+
const find = () => templates.get(sender)?.find((t) => t.name === wanted.name && (!wanted.language || t.language === wanted.language));
|
|
121
|
+
if (!find()) {
|
|
122
|
+
const all = [];
|
|
123
|
+
let after = "";
|
|
124
|
+
for (;;) {
|
|
125
|
+
const page = await client.get(`/v1/templates?sender=${sender}&status=approved&limit=100${after ? `&after=${after}` : ""}`);
|
|
126
|
+
all.push(...page.data);
|
|
127
|
+
if (!page.has_more || !page.data.length)
|
|
128
|
+
break;
|
|
129
|
+
after = page.data.at(-1).id;
|
|
130
|
+
}
|
|
131
|
+
templates.set(sender, all);
|
|
132
|
+
}
|
|
133
|
+
const found = find();
|
|
134
|
+
if (!found)
|
|
135
|
+
throw new Error(`no approved template named ${wanted.name}${wanted.language ? ` in ${wanted.language}` : ""} on this sender`);
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
function provider(route) {
|
|
139
|
+
return {
|
|
140
|
+
sendMessage: async (to, message) => {
|
|
141
|
+
const [first, ...rest] = contents(route.kind, message);
|
|
142
|
+
await send(route, to, first, message.replyTo);
|
|
143
|
+
for (const next of rest)
|
|
144
|
+
await send(route, to, next);
|
|
145
|
+
},
|
|
146
|
+
react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
|
|
147
|
+
typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
|
|
148
|
+
markRead: (to, messageIds) => courtesy(route, to, "read", { up_to: messageIds.at(-1) }, messageIds.at(-1)),
|
|
149
|
+
sendTemplate: async (to, wanted) => {
|
|
150
|
+
if (!route.sender)
|
|
151
|
+
throw new Error(`a template needs the channel's sender (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
|
|
152
|
+
const found = await template(route.sender, wanted);
|
|
153
|
+
const params = templateParams(wanted);
|
|
154
|
+
await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) });
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** WhatsApp from a Flow sender: buttons (a list past 3), lists, link buttons, photos, quotes, reactions, typing, blue ticks, templates. */
|
|
159
|
+
function whatsapp(o = {}) {
|
|
160
|
+
const route = register("whatsapp", o);
|
|
161
|
+
const channel = whatsappChannel(provider(route), { name: route.name, disable: o.disable, instructions: o.instructions });
|
|
162
|
+
return { ...channel, info: { ...channel.info, ...(channel.info.lists ? { lists: { ...channel.info.lists, titleMax: LIST_TITLE_MAX } } : {}) } };
|
|
163
|
+
}
|
|
164
|
+
/** US SMS from a Flow number: plain text; STOP, START and HELP are Flow's. */
|
|
165
|
+
function sms(o = {}) {
|
|
166
|
+
const route = register("sms", o);
|
|
167
|
+
return smsChannel(provider(route), { name: route.name, instructions: o.instructions });
|
|
168
|
+
}
|
|
169
|
+
/** A Telegram bot connected to Flow. Flow Framework has no Telegram kind yet, so it is a custom channel. */
|
|
170
|
+
function telegram(o = {}) {
|
|
171
|
+
const route = register("telegram", o);
|
|
172
|
+
const p = provider(route);
|
|
173
|
+
return {
|
|
174
|
+
name: route.name,
|
|
175
|
+
kind: "custom",
|
|
176
|
+
info: TELEGRAM,
|
|
177
|
+
instructions: o.instructions,
|
|
178
|
+
send: (to, outbound) => {
|
|
179
|
+
switch (outbound.type) {
|
|
180
|
+
case "message":
|
|
181
|
+
return p.sendMessage(to, {
|
|
182
|
+
text: outbound.text,
|
|
183
|
+
...(outbound.replyTo ? { replyTo: outbound.replyTo } : {}),
|
|
184
|
+
...(outbound.buttons ? { buttons: outbound.buttons } : {}),
|
|
185
|
+
...(outbound.link ? { link: outbound.link } : {}),
|
|
186
|
+
...(outbound.image ? { image: outbound.image } : {}),
|
|
187
|
+
});
|
|
188
|
+
case "reaction":
|
|
189
|
+
return p.react(to, outbound.messageId, outbound.emoji);
|
|
190
|
+
case "typing":
|
|
191
|
+
return p.typing(to, outbound.on);
|
|
192
|
+
case "template":
|
|
193
|
+
throw new Error("Telegram has no templates");
|
|
194
|
+
// read: Telegram bots cannot send read receipts; status: Telegram has no status line.
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ---- the webhook ----
|
|
200
|
+
function routeOf(event) {
|
|
201
|
+
const c = event.conversation;
|
|
202
|
+
if (!c)
|
|
203
|
+
return undefined;
|
|
204
|
+
return routes.find((r) => r.sender === c.sender) ?? routes.find((r) => r.kind === c.channel && !r.sender);
|
|
205
|
+
}
|
|
206
|
+
async function addressOf(route, contactId) {
|
|
207
|
+
const known = contacts.get(contactId);
|
|
208
|
+
if (known)
|
|
209
|
+
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;
|
|
212
|
+
contacts.set(contactId, address);
|
|
213
|
+
return address;
|
|
214
|
+
}
|
|
215
|
+
async function quoted(id) {
|
|
216
|
+
try {
|
|
217
|
+
const m = await client.get(`/v1/messages/${id}`);
|
|
218
|
+
const text = textOf(m.content);
|
|
219
|
+
return text ? { text, fromAgent: m.direction === "out" } : undefined;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return undefined; // past Flow's retention, or not known to it: the message reads without the quote
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function incoming(m) {
|
|
226
|
+
const c = m.content;
|
|
227
|
+
let base;
|
|
228
|
+
if (c.type === "text")
|
|
229
|
+
base = { text: String(c.text ?? "") };
|
|
230
|
+
else if (c.type === "button_reply") {
|
|
231
|
+
const id = String(c.button_id ?? "");
|
|
232
|
+
const title = String(c.label ?? id);
|
|
233
|
+
base = { text: title, choice: id && id !== title ? { id, title } : { title } };
|
|
234
|
+
}
|
|
235
|
+
else if (c.type === "voice" && c.transcript)
|
|
236
|
+
base = { text: String(c.transcript) }; // Flow transcribed it already
|
|
237
|
+
else if ((c.type === "voice" || c.type === "media") && c.file_id && c.kind !== "video") {
|
|
238
|
+
// Photos, audio and documents go as attachments, their bytes fetched from Flow only when the agent can use them.
|
|
239
|
+
const kind = c.type === "voice" || c.kind === "audio" ? "audio" : c.kind === "image" ? "image" : "document";
|
|
240
|
+
const fileId = String(c.file_id);
|
|
241
|
+
const attachment = { kind, mime: String(c.mime_type ?? "application/octet-stream"), ...(c.filename ? { name: String(c.filename) } : {}), data: () => client.download(fileId) };
|
|
242
|
+
base = { text: String(c.caption ?? ""), attachments: [attachment] };
|
|
243
|
+
}
|
|
244
|
+
else if (c.type === "media" || c.type === "file_blocked") {
|
|
245
|
+
// A video (Flow Framework reads none), or a file Flow did not keep: the caption, and what it was.
|
|
246
|
+
const what = String(c.kind ?? "file");
|
|
247
|
+
base = { text: [c.caption, c.type === "file_blocked" ? `[${what}: not received (${String(c.reason)})]` : `[${what}]`].filter(Boolean).join(" ") };
|
|
248
|
+
}
|
|
249
|
+
else if (c.type === "location")
|
|
250
|
+
base = { text: `[location: ${[c.name, c.address].filter(Boolean).join(", ")} (${c.lat}, ${c.lng})]` };
|
|
251
|
+
else if (c.type === "contact_card")
|
|
252
|
+
base = { text: `[contact: ${String(c.name ?? "")}]` };
|
|
253
|
+
else
|
|
254
|
+
return null; // reactions arrive as reaction.added
|
|
255
|
+
if (m.reply_to) {
|
|
256
|
+
const replyTo = await quoted(m.reply_to);
|
|
257
|
+
if (replyTo)
|
|
258
|
+
base.replyTo = replyTo;
|
|
259
|
+
}
|
|
260
|
+
return base;
|
|
261
|
+
}
|
|
262
|
+
async function handle(agent, event) {
|
|
263
|
+
if (event.type === "contact.deleted") {
|
|
264
|
+
// Flow erased the contact and their conversations: forget what this process keeps about them.
|
|
265
|
+
const id = event.data?.contact;
|
|
266
|
+
if (!id)
|
|
267
|
+
return;
|
|
268
|
+
const address = contacts.get(id);
|
|
269
|
+
contacts.delete(id);
|
|
270
|
+
for (const r of routes) {
|
|
271
|
+
for (const a of address ? [address, id] : [id]) {
|
|
272
|
+
flowConversation.delete(key(r.name, a));
|
|
273
|
+
frameworkConversation.delete(key(r.name, a));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const route = routeOf(event);
|
|
279
|
+
if (!route || !event.conversation)
|
|
280
|
+
return;
|
|
281
|
+
const address = await addressOf(route, event.conversation.contact);
|
|
282
|
+
const person = key(route.name, address);
|
|
283
|
+
if (event.type === "message.received" && event.data?.message) {
|
|
284
|
+
flowConversation.set(person, event.conversation.id);
|
|
285
|
+
const message = await incoming(event.data.message);
|
|
286
|
+
if (!message)
|
|
287
|
+
return;
|
|
288
|
+
const arrived = await agent.message({ channel: route.name, from: address, message, messageId: event.data.message.id });
|
|
289
|
+
if (arrived)
|
|
290
|
+
frameworkConversation.set(person, arrived.conversationId);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const conversationId = frameworkConversation.get(person);
|
|
294
|
+
if (!conversationId)
|
|
295
|
+
return; // not heard from since this process started: nothing to tell
|
|
296
|
+
if (event.type === "reaction.added" && event.data?.message_id && event.data.emoji) {
|
|
297
|
+
const target = await quoted(event.data.message_id);
|
|
298
|
+
if (target?.fromAgent)
|
|
299
|
+
agent.reacted(conversationId, event.data.emoji, target.text);
|
|
300
|
+
}
|
|
301
|
+
else if (event.type === "message.failed" && event.data?.message) {
|
|
302
|
+
const m = event.data.message;
|
|
303
|
+
const text = textOf(m.content);
|
|
304
|
+
const type = m.error?.type ?? "channel_error";
|
|
305
|
+
if (text)
|
|
306
|
+
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
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* One delivery: checks Flow-Signature, drops a redelivered event, hands the event to the agent.
|
|
311
|
+
* 2xx tells Flow it arrived; a failure answers 500, so Flow delivers it again.
|
|
312
|
+
*/
|
|
313
|
+
const noSecret = () => new Error("@flow-engineer/flow/flow-messaging: set FLOW_MESSAGING_WEBHOOK_SECRET (or pass webhookSecret)");
|
|
314
|
+
async function receive(agent, request) {
|
|
315
|
+
if (!secrets.length)
|
|
316
|
+
throw noSecret();
|
|
317
|
+
const raw = typeof request.body === "string" ? Buffer.from(request.body) : request.body;
|
|
318
|
+
const signature = request.headers["flow-signature"];
|
|
319
|
+
if (!verifySignature(raw, Array.isArray(signature) ? signature.join(",") : signature, secrets))
|
|
320
|
+
return { status: 401, body: '{"error":"bad signature"}' };
|
|
321
|
+
let event;
|
|
322
|
+
try {
|
|
323
|
+
event = JSON.parse(raw.toString("utf8"));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return { status: 400, body: '{"error":"not JSON"}' };
|
|
327
|
+
}
|
|
328
|
+
if (typeof event?.id !== "string" || typeof event.type !== "string")
|
|
329
|
+
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
|
+
try {
|
|
338
|
+
await handled;
|
|
339
|
+
seen.add(event.id);
|
|
340
|
+
if (seen.size > SEEN_MAX)
|
|
341
|
+
seen.delete(seen.values().next().value);
|
|
342
|
+
return { status: 200, body: "{}" };
|
|
343
|
+
}
|
|
344
|
+
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"}`);
|
|
346
|
+
return { status: 500, body: '{"error":"failed"}' };
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
handling.delete(event.id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** A Node http handler for the webhook endpoint: give it the requests to the URL Flow posts to. */
|
|
353
|
+
function webhook(agent) {
|
|
354
|
+
if (!secrets.length)
|
|
355
|
+
throw noSecret();
|
|
356
|
+
return async (req, res) => {
|
|
357
|
+
const answer = (status, body) => res.writeHead(status, { "content-type": "application/json" }).end(body);
|
|
358
|
+
if (req.method !== "POST")
|
|
359
|
+
return void answer(405, '{"error":"POST only"}');
|
|
360
|
+
const chunks = [];
|
|
361
|
+
let size = 0;
|
|
362
|
+
for await (const chunk of req) {
|
|
363
|
+
size += chunk.length;
|
|
364
|
+
if (size > MAX_BODY_BYTES)
|
|
365
|
+
return void answer(413, '{"error":"too large"}');
|
|
366
|
+
chunks.push(chunk);
|
|
367
|
+
}
|
|
368
|
+
const result = await receive(agent, { body: Buffer.concat(chunks), headers: req.headers });
|
|
369
|
+
answer(result.status, result.body);
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return { whatsapp, telegram, sms, webhook, receive };
|
|
373
|
+
}
|
|
374
|
+
/** The text of a message's content, as the agent knew it. */
|
|
375
|
+
function textOf(content) {
|
|
376
|
+
if (!content)
|
|
377
|
+
return "";
|
|
378
|
+
return String(content.text ?? content.caption ?? content.label ?? "");
|
|
379
|
+
}
|
|
380
|
+
/** A template's values as Flow's `params`: header, body (or the older `variables`, by number) and button values, each only when given. */
|
|
381
|
+
function templateParams(t) {
|
|
382
|
+
const h = t.header;
|
|
383
|
+
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 } : {}) };
|
|
384
|
+
let body = t.body;
|
|
385
|
+
if (!body) {
|
|
386
|
+
const values = Object.entries(t.variables ?? {});
|
|
387
|
+
if (values.every(([k]) => /^\d+$/.test(k)))
|
|
388
|
+
values.sort(([a], [b]) => Number(a) - Number(b));
|
|
389
|
+
body = values.map(([, v]) => v);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
...(header ? { header } : {}),
|
|
393
|
+
...(body.length ? { body } : {}),
|
|
394
|
+
...(t.buttons?.length ? { buttons: t.buttons.map(({ index, value }) => ({ index, value })) } : {}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** Why a send did not reach the person, for the agent: Flow Framework's outside_window, else Flow's type and whether to resend. */
|
|
398
|
+
function reason(e) {
|
|
399
|
+
const what = e.channelCode ? `${e.type} (${e.channelCode})` : e.type;
|
|
400
|
+
if (e.type === "outside_window" && e.channelCode === "quiet_hours")
|
|
401
|
+
return `${what}: it is night where they are; send it later`;
|
|
402
|
+
if (e.type === "outside_window")
|
|
403
|
+
return "outside_window";
|
|
404
|
+
if (e.channelCode === "channel_timeout_unknown_outcome")
|
|
405
|
+
return `${what}: it may have arrived; ask them before sending it again`;
|
|
406
|
+
if (e.retryable)
|
|
407
|
+
return `${what}: Flow Messaging could not send it now; it may go through later`;
|
|
408
|
+
return `${what}: ${e.message} It will fail again: don't resend it.`;
|
|
409
|
+
}
|
|
@@ -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";
|