@flow-engineer/flow 0.2.2 → 0.2.3
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/dist/flow-messaging/src/client.d.ts +2 -2
- package/dist/flow-messaging/src/client.js +3 -3
- package/dist/flow-messaging/src/index.js +10 -8
- package/dist/src/channels.d.ts +4 -4
- package/dist/src/channels.js +4 -3
- package/dist/src/conversation.d.ts +1 -0
- package/dist/src/conversation.js +30 -4
- package/dist/src/types.d.ts +3 -0
- package/flow-messaging/README.md +3 -1
- package/package.json +1 -1
|
@@ -28,8 +28,8 @@ export declare class Client {
|
|
|
28
28
|
get<T>(path: string): Promise<T>;
|
|
29
29
|
/** A file's bytes: GET /v1/files/{id} answers them or redirects to a signed URL, which fetch follows without the key. */
|
|
30
30
|
download(fileId: string): Promise<Uint8Array>;
|
|
31
|
-
/** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
|
|
32
|
-
post<T>(path: string, body: unknown, tries?: number): Promise<T>;
|
|
31
|
+
/** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
|
|
32
|
+
post<T>(path: string, body: unknown, tries?: number, idempotencyKey?: string): Promise<T>;
|
|
33
33
|
private request;
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
@@ -66,9 +66,9 @@ export class Client {
|
|
|
66
66
|
throw await errorOf(response);
|
|
67
67
|
return new Uint8Array(await response.arrayBuffer());
|
|
68
68
|
}
|
|
69
|
-
/** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
|
|
70
|
-
post(path, body, tries = TRIES) {
|
|
71
|
-
return this.request("POST", path, body,
|
|
69
|
+
/** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
|
|
70
|
+
post(path, body, tries = TRIES, idempotencyKey = randomUUID()) {
|
|
71
|
+
return this.request("POST", path, body, idempotencyKey, tries);
|
|
72
72
|
}
|
|
73
73
|
async request(method, path, body, idempotencyKey, tries = TRIES) {
|
|
74
74
|
if (!this.apiKey)
|
|
@@ -97,16 +97,16 @@ export function flowMessaging(options = {}) {
|
|
|
97
97
|
return { phone: to.startsWith("+") ? to : `+${to}` };
|
|
98
98
|
}
|
|
99
99
|
/** One message: into the person's conversation, or a new one from the channel's sender. */
|
|
100
|
-
async function send(route, to, content, replyTo, about = replyTo) {
|
|
100
|
+
async function send(route, to, content, replyTo, about = replyTo, idempotencyKey) {
|
|
101
101
|
try {
|
|
102
102
|
const conversation = await conversationFor(route.name, to, about);
|
|
103
103
|
let sent;
|
|
104
104
|
if (conversation)
|
|
105
|
-
sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
|
|
105
|
+
sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) }, undefined, idempotencyKey);
|
|
106
106
|
else {
|
|
107
107
|
if (!route.sender)
|
|
108
108
|
throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
|
|
109
|
-
sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content });
|
|
109
|
+
sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content }, undefined, idempotencyKey);
|
|
110
110
|
}
|
|
111
111
|
flowConversation.set(key(route.name, to), sent.conversation);
|
|
112
112
|
}
|
|
@@ -176,19 +176,20 @@ export function flowMessaging(options = {}) {
|
|
|
176
176
|
return {
|
|
177
177
|
sendMessage: async (to, message) => {
|
|
178
178
|
const [first, ...rest] = contents(route.kind, message);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
const key = message.idempotencyKey;
|
|
180
|
+
await send(route, to, first, message.replyTo, message.replyTo, key);
|
|
181
|
+
for (const [i, next] of rest.entries())
|
|
182
|
+
await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`);
|
|
182
183
|
},
|
|
183
184
|
react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
|
|
184
185
|
typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
|
|
185
186
|
markRead: (to, messageIds) => courtesy(route, to, "read", { up_to: messageIds.at(-1) }, messageIds.at(-1)),
|
|
186
|
-
sendTemplate: async (to, wanted) => {
|
|
187
|
+
sendTemplate: async (to, wanted, idempotencyKey) => {
|
|
187
188
|
if (!route.sender)
|
|
188
189
|
throw new Error(`a template needs the channel's sender (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
|
|
189
190
|
const found = await template(route.sender, wanted);
|
|
190
191
|
const params = templateParams(wanted);
|
|
191
|
-
await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) });
|
|
192
|
+
await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) }, undefined, undefined, idempotencyKey);
|
|
192
193
|
},
|
|
193
194
|
};
|
|
194
195
|
}
|
|
@@ -232,6 +233,7 @@ export function flowMessaging(options = {}) {
|
|
|
232
233
|
...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
|
|
233
234
|
...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
|
|
234
235
|
...(outbound.image && info.images ? { image: outbound.image } : {}),
|
|
236
|
+
...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
|
|
235
237
|
});
|
|
236
238
|
case "reaction":
|
|
237
239
|
return p.react(to, outbound.messageId, outbound.emoji);
|
package/dist/src/channels.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface Message {
|
|
|
76
76
|
image?: {
|
|
77
77
|
url: string;
|
|
78
78
|
};
|
|
79
|
+
/** Given for a send the business starts (a nudge, a scheduled text): the same in every process, so a provider that takes one sends it once. */
|
|
80
|
+
idempotencyKey?: string;
|
|
79
81
|
}
|
|
80
82
|
export interface WhatsAppProvider {
|
|
81
83
|
sendMessage(to: string, message: Message): Promise<void>;
|
|
@@ -90,12 +92,10 @@ export interface WhatsAppProvider {
|
|
|
90
92
|
react?(to: string, messageId: string, emoji: string): Promise<void>;
|
|
91
93
|
typing?(to: string, on: boolean): Promise<void>;
|
|
92
94
|
markRead?(to: string, messageIds: string[]): Promise<void>;
|
|
93
|
-
sendTemplate?(to: string, template: Template): Promise<void>;
|
|
95
|
+
sendTemplate?(to: string, template: Template, idempotencyKey?: string): Promise<void>;
|
|
94
96
|
}
|
|
95
97
|
export interface SmsProvider {
|
|
96
|
-
sendMessage(to: string, message:
|
|
97
|
-
text: string;
|
|
98
|
-
}): Promise<void>;
|
|
98
|
+
sendMessage(to: string, message: Pick<Message, "text" | "idempotencyKey">): Promise<void>;
|
|
99
99
|
}
|
|
100
100
|
/** Your own UI. `message` carries the text, any buttons, and your params by name. */
|
|
101
101
|
export interface CustomUIProvider {
|
package/dist/src/channels.js
CHANGED
|
@@ -93,8 +93,8 @@ export function whatsapp(provider, options = {}) {
|
|
|
93
93
|
case "template": {
|
|
94
94
|
if (!provider.sendTemplate || off.has("templates"))
|
|
95
95
|
throw new Error("this WhatsApp provider sends no templates");
|
|
96
|
-
const { type: _type, ...template } = outbound;
|
|
97
|
-
return provider.sendTemplate(to, template);
|
|
96
|
+
const { type: _type, idempotencyKey, ...template } = outbound;
|
|
97
|
+
return provider.sendTemplate(to, template, idempotencyKey);
|
|
98
98
|
}
|
|
99
99
|
case "status":
|
|
100
100
|
return;
|
|
@@ -112,7 +112,7 @@ export function sms(provider, options = {}) {
|
|
|
112
112
|
phone: true,
|
|
113
113
|
send: (to, outbound) => {
|
|
114
114
|
if (outbound.type === "message")
|
|
115
|
-
return provider.sendMessage(to, { text: outbound.text });
|
|
115
|
+
return provider.sendMessage(to, { text: outbound.text, ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}) });
|
|
116
116
|
if (outbound.type === "template")
|
|
117
117
|
throw new Error("SMS has no templates");
|
|
118
118
|
// Reactions, typing, read receipts and status do not exist on SMS.
|
|
@@ -159,6 +159,7 @@ function message(outbound) {
|
|
|
159
159
|
...(outbound.list ? { list: outbound.list } : {}),
|
|
160
160
|
...(outbound.link ? { link: outbound.link } : {}),
|
|
161
161
|
...(outbound.image ? { image: outbound.image } : {}),
|
|
162
|
+
...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
|
|
162
163
|
};
|
|
163
164
|
}
|
|
164
165
|
/**
|
|
@@ -268,6 +268,7 @@ export declare class Conversation {
|
|
|
268
268
|
private send;
|
|
269
269
|
/** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
|
|
270
270
|
private sendDirect;
|
|
271
|
+
private directSettled;
|
|
271
272
|
/**
|
|
272
273
|
* The history the model is sent: all of it while it fits the budget, else the latest entries
|
|
273
274
|
* that do, from the start of one (never between a call and its result), after a line that says
|
package/dist/src/conversation.js
CHANGED
|
@@ -443,7 +443,7 @@ export class Conversation {
|
|
|
443
443
|
}
|
|
444
444
|
else if (row.kind === "schedule") {
|
|
445
445
|
if (p.fixed)
|
|
446
|
-
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run });
|
|
446
|
+
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run, schedule: p.schedule });
|
|
447
447
|
else
|
|
448
448
|
this.notes.push({ text: p.text, inbox: row.id, owed: { run: p.run, mode: p.mode, channel: p.channel } });
|
|
449
449
|
}
|
|
@@ -573,8 +573,14 @@ export class Conversation {
|
|
|
573
573
|
this.note(`[Chinwag] ${task.tool} (${task.taskId}) was still running in the background when the agent restarted. It may or may not have finished; check before telling them it is done.`);
|
|
574
574
|
this.persist(() => store.removeTask(this.id, task.taskId));
|
|
575
575
|
}
|
|
576
|
-
for (const row of await store.openInbox(this.id))
|
|
577
|
-
|
|
576
|
+
for (const row of await store.openInbox(this.id)) {
|
|
577
|
+
// Already in the history (its row was not marked done): answered, so not answered again.
|
|
578
|
+
const p = row.payload;
|
|
579
|
+
if (row.kind === "message" && this.messageIds.get(p.id) === p.channel)
|
|
580
|
+
this.persist(() => store.doneInbox([row.id]));
|
|
581
|
+
else
|
|
582
|
+
this.enqueue(row);
|
|
583
|
+
}
|
|
578
584
|
const row = await store.conversation(this.id);
|
|
579
585
|
this.resumeTurn = row?.turnOpen === true;
|
|
580
586
|
if ((this.resumeTurn || this.hasWork()) && !this.running)
|
|
@@ -1136,6 +1142,12 @@ export class Conversation {
|
|
|
1136
1142
|
}
|
|
1137
1143
|
/** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
|
|
1138
1144
|
async sendDirect(d) {
|
|
1145
|
+
// Settled already (by an earlier process, or another one running beside this): never sent twice.
|
|
1146
|
+
if (await this.directSettled(d)) {
|
|
1147
|
+
if (d.inbox !== undefined)
|
|
1148
|
+
this.persist(() => this.settings.store.doneInbox([d.inbox]));
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1139
1151
|
const settle = (status, reason) => {
|
|
1140
1152
|
this.settle(d, status, reason);
|
|
1141
1153
|
this.flush([d]);
|
|
@@ -1163,7 +1175,9 @@ export class Conversation {
|
|
|
1163
1175
|
}
|
|
1164
1176
|
if (covered)
|
|
1165
1177
|
return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
|
|
1166
|
-
|
|
1178
|
+
// The same key in every process, so the channel's provider sends it once.
|
|
1179
|
+
const idempotencyKey = d.nudge ? `nudge:${d.nudge}` : d.run ? `run:${d.run}` : `inbox:${this.id}:${d.inbox}`;
|
|
1180
|
+
const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : { type: "message", text: d.text ?? "", idempotencyKey };
|
|
1167
1181
|
let failure = null;
|
|
1168
1182
|
if (this.closed)
|
|
1169
1183
|
return settle("failed", "this person was forgotten");
|
|
@@ -1195,6 +1209,18 @@ export class Conversation {
|
|
|
1195
1209
|
this.settings.emit(this.id, "message.out", { text: d.text ?? d.template?.name, channel: d.channel, direct: true });
|
|
1196
1210
|
settle("sent");
|
|
1197
1211
|
}
|
|
1212
|
+
async directSettled(d) {
|
|
1213
|
+
const { store } = this.settings;
|
|
1214
|
+
if (d.nudge) {
|
|
1215
|
+
const status = (await store.nudge(d.nudge))?.status;
|
|
1216
|
+
return status !== undefined && !["queued", "held", "scheduled"].includes(status);
|
|
1217
|
+
}
|
|
1218
|
+
if (d.run && d.schedule) {
|
|
1219
|
+
const outcome = (await store.runs(d.schedule)).find((r) => r.id === d.run)?.outcome;
|
|
1220
|
+
return outcome !== undefined && outcome !== "queued" && outcome !== "held";
|
|
1221
|
+
}
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1198
1224
|
/**
|
|
1199
1225
|
* The history the model is sent: all of it while it fits the budget, else the latest entries
|
|
1200
1226
|
* that do, from the start of one (never between a call and its result), after a line that says
|
package/dist/src/types.d.ts
CHANGED
|
@@ -107,6 +107,8 @@ export type Outbound = {
|
|
|
107
107
|
};
|
|
108
108
|
/** The channel's own params (see Channel.params), as the agent filled them in. */
|
|
109
109
|
fields?: Record<string, unknown>;
|
|
110
|
+
/** The same in every process for one send the business starts (a nudge, a scheduled text), for providers that take one. */
|
|
111
|
+
idempotencyKey?: string;
|
|
110
112
|
}
|
|
111
113
|
/** The agent has picked these messages up (WhatsApp's blue ticks). */
|
|
112
114
|
| {
|
|
@@ -122,6 +124,7 @@ export type Outbound = {
|
|
|
122
124
|
/** A template the owner set up with the channel (WhatsApp), sent as is, even outside its window. */
|
|
123
125
|
| ({
|
|
124
126
|
type: "template";
|
|
127
|
+
idempotencyKey?: string;
|
|
125
128
|
} & Template)
|
|
126
129
|
/** A short line of what the agent is doing, shown in the loading indicator; null clears it. */
|
|
127
130
|
| {
|
package/flow-messaging/README.md
CHANGED
|
@@ -148,7 +148,9 @@ Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret
|
|
|
148
148
|
## What it does for you
|
|
149
149
|
|
|
150
150
|
- Every send carries its own Idempotency-Key. A 429 or 5xx is tried again with the same key after
|
|
151
|
-
a short Retry-After, so a message never goes out twice.
|
|
151
|
+
a short Retry-After, so a message never goes out twice. A nudge or a scheduled text keeps one key
|
|
152
|
+
from its id (`nudge:<id>`, `run:<id>`) in every process, so a restart or a second instance does not
|
|
153
|
+
send it again.
|
|
152
154
|
- Flow's `409 outside_window` becomes the framework's `outside_window`, so the agent knows to send a
|
|
153
155
|
template.
|
|
154
156
|
- A permanent refusal (opted out, no consent, ...) reaches the agent once, with "don't resend it".
|