@flow-engineer/flow 0.0.1 → 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 +595 -0
- 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 +62 -8
- 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,178 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { ChannelInfo, ListRow, Outbound, Template, TemplateDefinition } from "./types.ts";
|
|
3
|
+
export type ChannelKind = "sms" | "whatsapp" | "custom";
|
|
4
|
+
/** A channel as the agent uses it. Made by whatsapp(), sms() or customUI(), or written by hand. */
|
|
5
|
+
export interface Channel {
|
|
6
|
+
/** The agent's name for it; its send tool is always send_<name> (lowercase, other characters as _). "key" and "phone" are reserved. */
|
|
7
|
+
name: string;
|
|
8
|
+
kind: ChannelKind;
|
|
9
|
+
info: ChannelInfo;
|
|
10
|
+
/** Addresses are phone numbers the channel verified, so the same number on two channels is one person. */
|
|
11
|
+
phone?: boolean;
|
|
12
|
+
/** Extra fields of this channel's send tool, passed through to its provider as they are. */
|
|
13
|
+
params?: Record<string, z.ZodType>;
|
|
14
|
+
/** Your own guidance for the agent on this channel, added to its prompt while the person uses it. */
|
|
15
|
+
instructions?: string;
|
|
16
|
+
/** Opt-out keywords this channel honours (SMS and WhatsApp have them by default). */
|
|
17
|
+
keywords?: Keywords;
|
|
18
|
+
send(address: string, outbound: Outbound): Promise<void> | void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Keywords a person texts on their own (the whole message, in any case, with only spaces,
|
|
22
|
+
* punctuation or emoji around it: "Stop." is one, "stop by tomorrow" is not), answered with no
|
|
23
|
+
* model call. `stop` records a hard no-contact rule: nothing the
|
|
24
|
+
* business starts (nudges, scheduled messages, their own reminders) goes to them on any channel
|
|
25
|
+
* until they text a `start` word. `help` sends `replies.help`; without one the agent answers it.
|
|
26
|
+
* A reply left out sends nothing (the provider may send its own).
|
|
27
|
+
*/
|
|
28
|
+
export interface Keywords {
|
|
29
|
+
stop: string[];
|
|
30
|
+
start: string[];
|
|
31
|
+
help: string[];
|
|
32
|
+
replies?: {
|
|
33
|
+
stop?: string;
|
|
34
|
+
start?: string;
|
|
35
|
+
help?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* SMS's default: STOP to opt out, START to resume, HELP or INFO for help. Which words carriers or
|
|
40
|
+
* regulators expect differs by country; check your market's rules and pass more when they ask.
|
|
41
|
+
*/
|
|
42
|
+
export declare const SMS_KEYWORDS: Keywords;
|
|
43
|
+
/**
|
|
44
|
+
* The fuller list some US carrier rules expect on SMS, opt in:
|
|
45
|
+
* `sms(provider, { keywords: US_CARRIER_KEYWORDS })`, or `{ ...US_CARRIER_KEYWORDS, replies }`.
|
|
46
|
+
*/
|
|
47
|
+
export declare const US_CARRIER_KEYWORDS: Keywords;
|
|
48
|
+
/** WhatsApp has no carrier keywords; a business honours a plain STOP all the same. */
|
|
49
|
+
export declare const WHATSAPP_KEYWORDS: Keywords;
|
|
50
|
+
/** Which keyword a message is, if it is one: the whole message, with only spaces, punctuation or emoji around it. */
|
|
51
|
+
export declare function keywordOf(keywords: Keywords | undefined, text: string): "stop" | "start" | "help" | null;
|
|
52
|
+
/** A channel without its send: what the simulators and the test cloud need to play it. */
|
|
53
|
+
export interface ChannelSummary {
|
|
54
|
+
name: string;
|
|
55
|
+
kind: ChannelKind;
|
|
56
|
+
info: ChannelInfo;
|
|
57
|
+
}
|
|
58
|
+
/** One message, as the agent's send tool wrote it. */
|
|
59
|
+
export interface Message {
|
|
60
|
+
text: string;
|
|
61
|
+
/** The person's message this one quotes. */
|
|
62
|
+
replyTo?: string;
|
|
63
|
+
buttons?: string[];
|
|
64
|
+
list?: {
|
|
65
|
+
button: string;
|
|
66
|
+
rows: ListRow[];
|
|
67
|
+
};
|
|
68
|
+
/** A button that opens this page. Never with buttons or a list. */
|
|
69
|
+
link?: {
|
|
70
|
+
label: string;
|
|
71
|
+
url: string;
|
|
72
|
+
};
|
|
73
|
+
/** A photo, the text its caption. Never with a list. */
|
|
74
|
+
image?: {
|
|
75
|
+
url: string;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export interface WhatsAppProvider {
|
|
79
|
+
sendMessage(to: string, message: Message): Promise<void>;
|
|
80
|
+
/** Which parts of a message this provider can send. Default: all. */
|
|
81
|
+
features?: {
|
|
82
|
+
buttons?: boolean;
|
|
83
|
+
lists?: boolean;
|
|
84
|
+
replies?: boolean;
|
|
85
|
+
links?: boolean;
|
|
86
|
+
images?: boolean;
|
|
87
|
+
};
|
|
88
|
+
react?(to: string, messageId: string, emoji: string): Promise<void>;
|
|
89
|
+
typing?(to: string, on: boolean): Promise<void>;
|
|
90
|
+
markRead?(to: string, messageIds: string[]): Promise<void>;
|
|
91
|
+
sendTemplate?(to: string, template: Template): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
export interface SmsProvider {
|
|
94
|
+
sendMessage(to: string, message: {
|
|
95
|
+
text: string;
|
|
96
|
+
}): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
/** Your own UI. `message` carries the text, any buttons, and your params by name. */
|
|
99
|
+
export interface CustomUIProvider {
|
|
100
|
+
/** What your UI can show. Default: Markdown, no buttons. */
|
|
101
|
+
info?: Omit<ChannelInfo, "reactions" | "status" | "readReceipts">;
|
|
102
|
+
params?: Record<string, z.ZodType>;
|
|
103
|
+
/** Your own guidance for the agent on this channel, such as how to use your params. */
|
|
104
|
+
instructions?: string;
|
|
105
|
+
sendMessage(to: string, message: Message & Record<string, unknown>): Promise<void>;
|
|
106
|
+
react?(to: string, messageId: string, emoji: string): Promise<void>;
|
|
107
|
+
typing?(to: string, on: boolean): Promise<void>;
|
|
108
|
+
markRead?(to: string, messageIds: string[]): Promise<void>;
|
|
109
|
+
/** A short line of what the agent is doing, in your loading indicator; null clears it. */
|
|
110
|
+
status?(to: string, text: string | null): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
/** WhatsApp's own limits, the same for every provider. */
|
|
113
|
+
export declare const WHATSAPP: ChannelInfo;
|
|
114
|
+
/** SMS: plain text; providers join up to 10 segments into one text (1,530 GSM-7 characters). */
|
|
115
|
+
export declare const SMS: ChannelInfo;
|
|
116
|
+
export type WhatsAppFeature = "buttons" | "lists" | "links" | "images" | "replies" | "reactions" | "typing" | "readReceipts" | "templates";
|
|
117
|
+
export declare function whatsapp(provider: WhatsAppProvider, options?: {
|
|
118
|
+
name?: string;
|
|
119
|
+
disable?: WhatsAppFeature[];
|
|
120
|
+
instructions?: string;
|
|
121
|
+
keywords?: false | Partial<Keywords>;
|
|
122
|
+
}): Channel;
|
|
123
|
+
export declare function sms(provider: SmsProvider, options?: {
|
|
124
|
+
name?: string;
|
|
125
|
+
instructions?: string;
|
|
126
|
+
keywords?: false | Partial<Keywords>;
|
|
127
|
+
}): Channel;
|
|
128
|
+
export declare function customUI(name: string, provider: CustomUIProvider): Channel;
|
|
129
|
+
/**
|
|
130
|
+
* A message as this channel can show it: without a link button the link's URL goes on a line after
|
|
131
|
+
* the text, and without images the text goes alone. So a provider only gets what it declared.
|
|
132
|
+
*/
|
|
133
|
+
export declare function fitMessage<M extends {
|
|
134
|
+
text: string;
|
|
135
|
+
link?: {
|
|
136
|
+
label: string;
|
|
137
|
+
url: string;
|
|
138
|
+
};
|
|
139
|
+
image?: {
|
|
140
|
+
url: string;
|
|
141
|
+
};
|
|
142
|
+
}>(info: ChannelInfo, message: M): M;
|
|
143
|
+
/** A declared template filled with the values of one send: what the person sees. */
|
|
144
|
+
export interface FilledTemplate {
|
|
145
|
+
header?: {
|
|
146
|
+
kind: "text";
|
|
147
|
+
text: string;
|
|
148
|
+
} | {
|
|
149
|
+
kind: "image" | "video" | "document";
|
|
150
|
+
url?: string;
|
|
151
|
+
fileId?: string;
|
|
152
|
+
};
|
|
153
|
+
body: string;
|
|
154
|
+
footer?: string;
|
|
155
|
+
/** A quick reply's tap comes back with its payload (its text when the send gave none). */
|
|
156
|
+
buttons: Array<{
|
|
157
|
+
type: "quick_reply";
|
|
158
|
+
text: string;
|
|
159
|
+
payload: string;
|
|
160
|
+
} | {
|
|
161
|
+
type: "url";
|
|
162
|
+
text: string;
|
|
163
|
+
url: string;
|
|
164
|
+
} | {
|
|
165
|
+
type: "phone";
|
|
166
|
+
text: string;
|
|
167
|
+
phone: string;
|
|
168
|
+
}>;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A declared template filled with one send's values. Throws, saying what is wrong, when the values
|
|
172
|
+
* don't fit it (Meta would refuse the send): a missing or extra body value, header value or button
|
|
173
|
+
* value.
|
|
174
|
+
*/
|
|
175
|
+
export declare function fillTemplate(definition: TemplateDefinition, template: Template): FilledTemplate;
|
|
176
|
+
/** The declared template a send is of: by name, and by language when the send names one. */
|
|
177
|
+
export declare function declaredTemplate(definitions: TemplateDefinition[] | undefined, template: Template): TemplateDefinition | undefined;
|
|
178
|
+
export declare function summarize(channel: Channel): ChannelSummary;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Channels: where people reach an agent. Flow Framework knows each platform (what WhatsApp can show, its
|
|
2
|
+
// limits, its 24-hour window); a provider only says how to send through its own API. A provider
|
|
3
|
+
// implements one sendMessage, which gets everything the agent's send tool carries, plus the
|
|
4
|
+
// optional actions that are not messages (react, typing, markRead, sendTemplate). A feature the
|
|
5
|
+
// provider leaves out, or switches off, is never offered to the agent.
|
|
6
|
+
//
|
|
7
|
+
// whatsapp(new MyWhatsApp(keys)) every WhatsApp feature the provider has
|
|
8
|
+
// whatsapp(new MyWhatsApp(keys), { disable: ["reactions"] })
|
|
9
|
+
// sms(new MySms(keys))
|
|
10
|
+
// customUI("app", new MyApp()) your own web or mobile UI, with your params
|
|
11
|
+
/**
|
|
12
|
+
* SMS's default: STOP to opt out, START to resume, HELP or INFO for help. Which words carriers or
|
|
13
|
+
* regulators expect differs by country; check your market's rules and pass more when they ask.
|
|
14
|
+
*/
|
|
15
|
+
export const SMS_KEYWORDS = { stop: ["STOP"], start: ["START"], help: ["HELP", "INFO"] };
|
|
16
|
+
/**
|
|
17
|
+
* The fuller list some US carrier rules expect on SMS, opt in:
|
|
18
|
+
* `sms(provider, { keywords: US_CARRIER_KEYWORDS })`, or `{ ...US_CARRIER_KEYWORDS, replies }`.
|
|
19
|
+
*/
|
|
20
|
+
export const US_CARRIER_KEYWORDS = {
|
|
21
|
+
stop: ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"],
|
|
22
|
+
start: ["START", "UNSTOP"],
|
|
23
|
+
help: ["HELP", "INFO"],
|
|
24
|
+
};
|
|
25
|
+
/** WhatsApp has no carrier keywords; a business honours a plain STOP all the same. */
|
|
26
|
+
export const WHATSAPP_KEYWORDS = { stop: ["STOP", "UNSUBSCRIBE"], start: ["START"], help: [] };
|
|
27
|
+
/** Which keyword a message is, if it is one: the whole message, with only spaces, punctuation or emoji around it. */
|
|
28
|
+
export function keywordOf(keywords, text) {
|
|
29
|
+
if (!keywords)
|
|
30
|
+
return null;
|
|
31
|
+
const bare = (s) => s.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "").replace(/\s+/g, " ").toUpperCase();
|
|
32
|
+
const word = bare(text);
|
|
33
|
+
if (!word)
|
|
34
|
+
return null;
|
|
35
|
+
for (const kind of ["stop", "start", "help"])
|
|
36
|
+
if (keywords[kind].some((k) => bare(k) === word))
|
|
37
|
+
return kind;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** A channel factory's `keywords` option: false for none, or changes to the defaults. */
|
|
41
|
+
function keywordsFrom(defaults, option) {
|
|
42
|
+
return option === false ? undefined : { ...defaults, ...option };
|
|
43
|
+
}
|
|
44
|
+
/** WhatsApp's own limits, the same for every provider. */
|
|
45
|
+
export const WHATSAPP = {
|
|
46
|
+
reactions: true,
|
|
47
|
+
status: false,
|
|
48
|
+
formatting: "whatsapp",
|
|
49
|
+
maxLength: 4096,
|
|
50
|
+
replies: true,
|
|
51
|
+
buttons: { max: 3, labelMax: 20 },
|
|
52
|
+
lists: { maxRows: 10, titleMax: 24, buttonMax: 20 },
|
|
53
|
+
// An interactive cta_url message: one URL button, its label up to 20 characters.
|
|
54
|
+
linkButton: { labelMax: 20 },
|
|
55
|
+
// An image's caption is at most 1,024 characters (as is an interactive message's body).
|
|
56
|
+
images: { captionMax: 1024 },
|
|
57
|
+
readReceipts: true,
|
|
58
|
+
replyWindowHours: 24,
|
|
59
|
+
};
|
|
60
|
+
/** SMS: plain text; providers join up to 10 segments into one text (1,530 GSM-7 characters). */
|
|
61
|
+
export const SMS = { reactions: false, status: false, formatting: "plain", maxLength: 1530 };
|
|
62
|
+
export function whatsapp(provider, options = {}) {
|
|
63
|
+
const off = new Set(options.disable ?? []);
|
|
64
|
+
const has = (feature, available) => available && !off.has(feature);
|
|
65
|
+
const f = provider.features ?? {};
|
|
66
|
+
const info = {
|
|
67
|
+
...WHATSAPP,
|
|
68
|
+
reactions: has("reactions", Boolean(provider.react)),
|
|
69
|
+
readReceipts: has("readReceipts", Boolean(provider.markRead)),
|
|
70
|
+
replies: has("replies", f.replies !== false),
|
|
71
|
+
buttons: has("buttons", f.buttons !== false) ? WHATSAPP.buttons : undefined,
|
|
72
|
+
lists: has("lists", f.lists !== false) ? WHATSAPP.lists : undefined,
|
|
73
|
+
linkButton: has("links", f.links !== false) ? WHATSAPP.linkButton : undefined,
|
|
74
|
+
images: has("images", f.images !== false) ? WHATSAPP.images : undefined,
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
name: options.name ?? "whatsapp",
|
|
78
|
+
kind: "whatsapp",
|
|
79
|
+
info,
|
|
80
|
+
instructions: options.instructions,
|
|
81
|
+
keywords: keywordsFrom(WHATSAPP_KEYWORDS, options.keywords),
|
|
82
|
+
phone: true,
|
|
83
|
+
send: (to, outbound) => {
|
|
84
|
+
switch (outbound.type) {
|
|
85
|
+
case "message":
|
|
86
|
+
return provider.sendMessage(to, message(outbound));
|
|
87
|
+
case "reaction":
|
|
88
|
+
return info.reactions ? provider.react?.(to, outbound.messageId, outbound.emoji) : undefined;
|
|
89
|
+
case "typing":
|
|
90
|
+
return has("typing", true) ? provider.typing?.(to, outbound.on) : undefined;
|
|
91
|
+
case "read":
|
|
92
|
+
return info.readReceipts ? provider.markRead?.(to, outbound.messageIds) : undefined;
|
|
93
|
+
case "template": {
|
|
94
|
+
if (!provider.sendTemplate || off.has("templates"))
|
|
95
|
+
throw new Error("this WhatsApp provider sends no templates");
|
|
96
|
+
const { type: _type, ...template } = outbound;
|
|
97
|
+
return provider.sendTemplate(to, template);
|
|
98
|
+
}
|
|
99
|
+
case "status":
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function sms(provider, options = {}) {
|
|
106
|
+
return {
|
|
107
|
+
name: options.name ?? "sms",
|
|
108
|
+
kind: "sms",
|
|
109
|
+
info: SMS,
|
|
110
|
+
instructions: options.instructions,
|
|
111
|
+
keywords: keywordsFrom(SMS_KEYWORDS, options.keywords),
|
|
112
|
+
phone: true,
|
|
113
|
+
send: (to, outbound) => {
|
|
114
|
+
if (outbound.type === "message")
|
|
115
|
+
return provider.sendMessage(to, { text: outbound.text });
|
|
116
|
+
if (outbound.type === "template")
|
|
117
|
+
throw new Error("SMS has no templates");
|
|
118
|
+
// Reactions, typing, read receipts and status do not exist on SMS.
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function customUI(name, provider) {
|
|
123
|
+
const info = {
|
|
124
|
+
formatting: "markdown",
|
|
125
|
+
...provider.info,
|
|
126
|
+
reactions: Boolean(provider.react),
|
|
127
|
+
status: Boolean(provider.status),
|
|
128
|
+
readReceipts: Boolean(provider.markRead),
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
name,
|
|
132
|
+
kind: "custom",
|
|
133
|
+
info,
|
|
134
|
+
params: provider.params,
|
|
135
|
+
instructions: provider.instructions,
|
|
136
|
+
send: (to, outbound) => {
|
|
137
|
+
switch (outbound.type) {
|
|
138
|
+
case "message":
|
|
139
|
+
return provider.sendMessage(to, { ...message(outbound), ...outbound.fields });
|
|
140
|
+
case "reaction":
|
|
141
|
+
return provider.react?.(to, outbound.messageId, outbound.emoji);
|
|
142
|
+
case "typing":
|
|
143
|
+
return provider.typing?.(to, outbound.on);
|
|
144
|
+
case "read":
|
|
145
|
+
return provider.markRead?.(to, outbound.messageIds);
|
|
146
|
+
case "status":
|
|
147
|
+
return provider.status?.(to, outbound.text);
|
|
148
|
+
case "template":
|
|
149
|
+
throw new Error(`${name} has no templates`);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function message(outbound) {
|
|
155
|
+
return {
|
|
156
|
+
text: outbound.text,
|
|
157
|
+
...(outbound.replyTo ? { replyTo: outbound.replyTo } : {}),
|
|
158
|
+
...(outbound.buttons ? { buttons: outbound.buttons } : {}),
|
|
159
|
+
...(outbound.list ? { list: outbound.list } : {}),
|
|
160
|
+
...(outbound.link ? { link: outbound.link } : {}),
|
|
161
|
+
...(outbound.image ? { image: outbound.image } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A message as this channel can show it: without a link button the link's URL goes on a line after
|
|
166
|
+
* the text, and without images the text goes alone. So a provider only gets what it declared.
|
|
167
|
+
*/
|
|
168
|
+
export function fitMessage(info, message) {
|
|
169
|
+
const { link, image, ...rest } = message;
|
|
170
|
+
return {
|
|
171
|
+
...rest,
|
|
172
|
+
...(link && !info.linkButton ? { text: `${message.text}\n${link.url}` } : {}),
|
|
173
|
+
...(link && info.linkButton ? { link } : {}),
|
|
174
|
+
...(image && info.images ? { image } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A declared template filled with one send's values. Throws, saying what is wrong, when the values
|
|
179
|
+
* don't fit it (Meta would refuse the send): a missing or extra body value, header value or button
|
|
180
|
+
* value.
|
|
181
|
+
*/
|
|
182
|
+
export function fillTemplate(definition, template) {
|
|
183
|
+
const fail = (problem) => {
|
|
184
|
+
throw new Error(`flow: template "${definition.name}" (${definition.language}) ${problem}`);
|
|
185
|
+
};
|
|
186
|
+
if (template.body && template.variables)
|
|
187
|
+
fail("got both body and variables: give one");
|
|
188
|
+
const values = template.body ?? numbered(template.variables);
|
|
189
|
+
const wanted = new Set([...definition.body.matchAll(/\{\{(\d+)\}\}/g)].map((m) => m[1])).size;
|
|
190
|
+
if (values.length !== wanted)
|
|
191
|
+
fail(`takes ${wanted} body value${wanted === 1 ? "" : "s"}, got ${values.length}`);
|
|
192
|
+
const body = definition.body.replace(/\{\{(\d+)\}\}/g, (_m, n) => values[Number(n) - 1] ?? "");
|
|
193
|
+
const shape = definition.header;
|
|
194
|
+
const given = template.header;
|
|
195
|
+
let header;
|
|
196
|
+
if (shape?.kind === "text" && shape.text.includes("{{1}}")) {
|
|
197
|
+
if (given?.kind !== "text")
|
|
198
|
+
fail("takes a text value for its header");
|
|
199
|
+
header = { kind: "text", text: shape.text.replace("{{1}}", given.text) };
|
|
200
|
+
}
|
|
201
|
+
else if (shape && shape.kind !== "text") {
|
|
202
|
+
if (given?.kind !== shape.kind || !(given.url || given.fileId))
|
|
203
|
+
fail(`takes a ${shape.kind} for its header, by url or fileId`);
|
|
204
|
+
header = given;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
if (given)
|
|
208
|
+
fail("takes no header value");
|
|
209
|
+
header = shape;
|
|
210
|
+
}
|
|
211
|
+
const buttonValues = new Map((template.buttons ?? []).map((b) => [b.index, b.value]));
|
|
212
|
+
for (const index of buttonValues.keys()) {
|
|
213
|
+
const button = definition.buttons?.[index];
|
|
214
|
+
if (!button || button.type === "phone" || (button.type === "url" && !button.url.endsWith("{{1}}")))
|
|
215
|
+
fail(`takes no value for button ${index}`);
|
|
216
|
+
}
|
|
217
|
+
const buttons = (definition.buttons ?? []).map((button, index) => {
|
|
218
|
+
const value = buttonValues.get(index);
|
|
219
|
+
if (button.type === "quick_reply")
|
|
220
|
+
return { ...button, payload: value ?? button.text };
|
|
221
|
+
if (button.type === "url" && button.url.endsWith("{{1}}")) {
|
|
222
|
+
if (value === undefined)
|
|
223
|
+
fail(`takes the end of button ${index}'s URL ("${button.text}")`);
|
|
224
|
+
return { ...button, url: button.url.slice(0, -5) + value };
|
|
225
|
+
}
|
|
226
|
+
return button;
|
|
227
|
+
});
|
|
228
|
+
return { ...(header ? { header } : {}), body, ...(definition.footer ? { footer: definition.footer } : {}), buttons };
|
|
229
|
+
}
|
|
230
|
+
/** The declared template a send is of: by name, and by language when the send names one. */
|
|
231
|
+
export function declaredTemplate(definitions, template) {
|
|
232
|
+
return definitions?.find((d) => d.name === template.name && (!template.language || d.language === template.language));
|
|
233
|
+
}
|
|
234
|
+
/** The older `variables` as body values: by number when every key is one, else in the order given. */
|
|
235
|
+
function numbered(variables) {
|
|
236
|
+
const entries = Object.entries(variables ?? {});
|
|
237
|
+
if (entries.every(([k]) => /^\d+$/.test(k)))
|
|
238
|
+
entries.sort(([a], [b]) => Number(a) - Number(b));
|
|
239
|
+
return entries.map(([, v]) => v);
|
|
240
|
+
}
|
|
241
|
+
export function summarize(channel) {
|
|
242
|
+
return { name: channel.name, kind: channel.kind, info: channel.info };
|
|
243
|
+
}
|