@flowapt/flowiq-cli 0.7.3 → 0.7.5
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/README.md +56 -1
- package/TEAM-GUIDE.md +3 -0
- package/package.json +1 -1
- package/src/button-params.test.mjs +50 -0
- package/src/commands/broadcast.js +229 -17
- package/src/index.js +15 -1
- package/src/reply-routing.test.mjs +58 -0
package/README.md
CHANGED
|
@@ -193,7 +193,7 @@ flowiq ct list
|
|
|
193
193
|
- **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
|
|
194
194
|
- `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
|
|
195
195
|
|
|
196
|
-
### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|retry|list` (alias `bc`)
|
|
196
|
+
### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|route|retry|list` (alias `bc`)
|
|
197
197
|
|
|
198
198
|
Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
|
|
199
199
|
template's variables **per row** from the CSV's own columns. The
|
|
@@ -237,6 +237,16 @@ flowiq bc scheduled list <org_id> # open rows (pending + await
|
|
|
237
237
|
flowiq bc scheduled list <org_id> --all # incl. fired / cancelled
|
|
238
238
|
flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (--at re-times it)
|
|
239
239
|
flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
240
|
+
|
|
241
|
+
# REPLY ROUTING (v0.7.4): assign EVERY reply to this broadcast to a team / person.
|
|
242
|
+
flowiq bc send <org_id> --tag <batch-tag> --template <name> --body param1="…" \
|
|
243
|
+
--route-team "Spa" --commit # typed replies AND button taps → the Spa team
|
|
244
|
+
flowiq bc send … --route-team "Spa" --route-member kim@client.com --route-window 48h --no-route-notify
|
|
245
|
+
flowiq bc send … --csv people.csv --campaign 17Sep_Spa --route-team "Spa" --at "2026-09-18 09:00" --commit
|
|
246
|
+
|
|
247
|
+
flowiq bc route <org_id> <broadcastId> # show what a broadcast routes to + how many chats it has routed
|
|
248
|
+
flowiq bc route <org_id> <broadcastId> --team "Spa" --commit # set it on a broadcast that already went out
|
|
249
|
+
flowiq bc route <org_id> <broadcastId> --clear --commit # switch it off
|
|
240
250
|
```
|
|
241
251
|
|
|
242
252
|
- **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
|
|
@@ -295,6 +305,37 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
|
295
305
|
template. In **tag mode the campaign slug defaults to the tag**, so to send
|
|
296
306
|
template A then template B to the SAME tag, pass a distinct `--campaign` for
|
|
297
307
|
each. `resume` passes no `--template`, so it is never affected.
|
|
308
|
+
- **Reply routing — `--route-team` / `--route-member` on `send`, and `bc route` (v0.7.4)**:
|
|
309
|
+
"every reply to this broadcast goes to team X / person Y". The first message a
|
|
310
|
+
recipient sends inside the window (default **72 h** after the send, `--route-window
|
|
311
|
+
48h|3d`, max 14 d) assigns the chat — **once per recipient per broadcast** — and
|
|
312
|
+
notifies the target through the normal assignment notification (the Teams-page
|
|
313
|
+
notify settings + the org's two notification flags still decide whether anything is
|
|
314
|
+
actually sent; `--no-route-notify` assigns silently). It is the fix for the gap a
|
|
315
|
+
quick-reply button + exact keyword + `assign_chat` cannot close: that route only
|
|
316
|
+
catches button TAPS, and a broad keyword mutes the AI agent. Routing is a side
|
|
317
|
+
effect — **keywords and the AI agent answer exactly as they would have**, and it
|
|
318
|
+
also applies to a chat whose agent is switched off. Skipped: business auto-replies
|
|
319
|
+
("thank you for contacting…", out of office) and opt-outs (`stop`). An internal
|
|
320
|
+
note in the chat says why it was assigned. **An existing assignment is
|
|
321
|
+
overwritten**, same as the keyword action.
|
|
322
|
+
- `--route-team` takes a team name (case-insensitive; a unique partial match is
|
|
323
|
+
fine) or id; `--route-member` takes an email, a name or an id and must be an
|
|
324
|
+
ACTIVE member of that org. A typo stops the run in the **dry run** with the org's
|
|
325
|
+
teams listed — nothing is tagged, queued or sent.
|
|
326
|
+
- The routing is stored on the broadcast (`broadcasts.reply_routing`), so it
|
|
327
|
+
**forces the python engine at any size** (the per-row Node engine writes one
|
|
328
|
+
broadcast per recipient). Works in tag mode, CSV mode and with `--at` (the queued
|
|
329
|
+
send carries it and it is applied the moment the send fires).
|
|
330
|
+
- After a commit the CLI prints `Replies → … ✓ saved`. If it prints **REPLY ROUTING
|
|
331
|
+
NOT SAVED / NOT CONFIRMED**, the messages still went out — fix it straight away
|
|
332
|
+
with `bc route <org> <broadcastId> --team … --commit`.
|
|
333
|
+
- `bc route` with no flags shows the current routing and how many chats it has
|
|
334
|
+
routed; with `--team/--member` sets it; `--clear` removes it. Dry-run unless
|
|
335
|
+
`--commit`, audited. Only replies arriving AFTER the change are routed — nothing
|
|
336
|
+
is applied retroactively. `bc status` prints the routing too.
|
|
337
|
+
- Using routing AND a button keyword with `assign_chat` on the same campaign is
|
|
338
|
+
harmless but notifies twice on a tap — drop the keyword's assign when routing is on.
|
|
298
339
|
- **`list-remote <org>` (v0.3.9)**: list the org's broadcasts **newest-first** with
|
|
299
340
|
the **full broadcastId** per row + template, status, recipient count and SAST
|
|
300
341
|
created time — the discovery step `status` / `retry` need (previously the id
|
|
@@ -414,8 +455,22 @@ instead of a CSV. Same guards, same status log, same resume:
|
|
|
414
455
|
```bash
|
|
415
456
|
flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
|
|
416
457
|
--body param1="Hi {{first_name}}" [--button param1=<short-code>] --commit
|
|
458
|
+
|
|
459
|
+
# A template with TWO link buttons (v0.7.5), e.g. one per product: one --button per button,
|
|
460
|
+
# in the order the buttons appear on the template.
|
|
461
|
+
flowiq bc send <org_id> --tag new-arrivals-batch-01 --template new_arrivals_v3 \
|
|
462
|
+
--button param1=<cycle-harmony-code> --button param2=<neuroshyft-code> --commit
|
|
417
463
|
```
|
|
418
464
|
|
|
465
|
+
**Link buttons (v0.7.5):** every button whose URL carries a `{{1}}` needs its own
|
|
466
|
+
`--button paramN=` value (`param1` = the first such button, `param2` = the
|
|
467
|
+
second). The dry run prints each button's final URL and stops if one is
|
|
468
|
+
missing (WhatsApp rejects the whole message when a link button is empty); a
|
|
469
|
+
value with no button to go to is ignored with a warning. Swapping the codes per
|
|
470
|
+
batch is how one campaign gets per-batch click counts: mint each batch's links
|
|
471
|
+
with the same `--campaign` and a different `--content`. CSV mode fills one link
|
|
472
|
+
button only, so a two-button template is refused there.
|
|
473
|
+
|
|
419
474
|
Values are shared across the tag (use `{{first_name}}` etc. for per-contact
|
|
420
475
|
personalization — resolved server-side). Supported per-contact tokens:
|
|
421
476
|
`{{first_name}}` `{{full_name}}` `{{email}}` `{{phone_number}}`
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -167,11 +167,14 @@ several.
|
|
|
167
167
|
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`). Sending a **different template to the SAME tag**? Add a distinct `--campaign <name>` — otherwise the CLI aborts (a campaign belongs to one template; reusing it would send the first template's image + skip everyone it already reached). |
|
|
168
168
|
| Send a broadcast whose template has an IMAGE/VIDEO/DOCUMENT header | Same as above — the media is automatic (the template's own stored header). Override with `--header-media <public-url>` if needed. The CLI verifies the resolved media's actual type against the header format — `header video (video ✓ video/mp4)` means verified; a mismatch (e.g. a video template whose stored default is secretly a png — templates made before 3 Aug 2026 can carry this) ABORTS and tells you to pass `--header-media` with the real file. |
|
|
169
169
|
| Send a **CAROUSEL** template (v0.4.9) | Tag mode only: `flowiq bc send <org_id> --tag <batch-tag> --template <carousel_name> --body param1="Hi {{first_name}}" --commit`. Card images are automatic (each card's stored template image, type-verified per card); override with repeatable `--card-media <url>` (one per card, in order). If the cards carry `{{n}}` body variables or URL-button variables, the CLI tells you exactly what to put in a `--cards-file <path>` JSON (one entry per card: `header_media` / `body_params` / `button_payloads` / `url_vars`). Always sends via the python engine (any size); no CSV mode for carousels. |
|
|
170
|
+
| Send a template with TWO link buttons (e.g. one per product) and track clicks per batch | Mint two short links per batch with the same campaign and a different `--content` (e.g. `--content CycleHarmony_b01` and `--content NeuroShyft_b01`), then `flowiq bc send <org_id> --tag <batch-tag> --template <name> --button param1=<first-code> --button param2=<second-code>`. `param1` is the first link button on the template, `param2` the second. The dry run prints both final URLs and stops if either is missing. Tag mode only (v0.7.5). |
|
|
170
171
|
| Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
|
|
171
172
|
| Find a broadcast's id (don't have the `broadcastId`?) | `flowiq bc list-remote <org_id>` — the org's broadcasts newest-first with full ids (`--template <substr>` / `--since <date>` / `--limit <n>` to narrow) |
|
|
172
173
|
| Check how a broadcast is landing (accepted → delivered → read, plus failed/pending) | `flowiq bc status <org_id> <broadcastId>` (from the send output, or `bc list-remote`) |
|
|
173
174
|
| See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
|
|
174
175
|
| Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
|
|
176
|
+
| Make EVERY reply to a broadcast land with a team or a person (client asks "all replies to the Spa team") | Add `--route-team "Spa"` (and/or `--route-member kim@client.com`) to the `bc send`. The first reply from each recipient within 72 hours (`--route-window 48h` to change) assigns the chat and notifies them, typed replies and button taps alike. The AI agent and keywords still answer as normal, so nothing is muted. The dry run shows `Replies → team Spa …`; a wrong team name stops the run and lists the org's teams. Works with `--csv` and `--at` too. |
|
|
177
|
+
| Add, check or remove that routing on a broadcast that already went out | `flowiq bc route <org_id> <broadcastId>` shows it and how many chats it has routed · `… --team "Spa" --commit` sets it · `… --clear --commit` removes it. Only replies from now on are affected. |
|
|
175
178
|
| **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
|
|
176
179
|
| **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
|
|
177
180
|
| See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveButtonParams, checkButtonParams } from "./commands/broadcast.js";
|
|
4
|
+
|
|
5
|
+
const oneBtn = { dynamic_url_buttons: 1, url_button: { present: true } };
|
|
6
|
+
const twoBtn = { dynamic_url_buttons: 2, url_button: { present: true } };
|
|
7
|
+
const noBtn = { dynamic_url_buttons: 0, url_button: { present: false } };
|
|
8
|
+
|
|
9
|
+
test("flags win over the saved campaign", () => {
|
|
10
|
+
assert.deepEqual(resolveButtonParams({ param1: "a", param2: "b" }, { button_params_literal: { param1: "x" } }), { param1: "a", param2: "b" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("saved multi-button values, then the pre-0.7.5 single value", () => {
|
|
14
|
+
assert.deepEqual(resolveButtonParams({}, { button_params_literal: { param1: "x", param2: "y" } }), { param1: "x", param2: "y" });
|
|
15
|
+
assert.deepEqual(resolveButtonParams({}, { button_param_literal: "old" }), { param1: "old" });
|
|
16
|
+
assert.deepEqual(resolveButtonParams(undefined, null), {});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("empty values are dropped", () => {
|
|
20
|
+
assert.deepEqual(resolveButtonParams({ param1: "a", param2: "" }, null), { param1: "a" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("one dynamic button: same message as before", () => {
|
|
24
|
+
assert.deepEqual(checkButtonParams(oneBtn, {}).aborts, ["template has a dynamic URL button — pass --button param1=<code>"]);
|
|
25
|
+
assert.deepEqual(checkButtonParams(oneBtn, { param1: "abc" }), { aborts: [], warnings: [] });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("two dynamic buttons need both values", () => {
|
|
29
|
+
const r = checkButtonParams(twoBtn, { param1: "abc" });
|
|
30
|
+
assert.equal(r.aborts.length, 1);
|
|
31
|
+
assert.match(r.aborts[0], /2 dynamic URL buttons/);
|
|
32
|
+
assert.match(r.aborts[0], /missing param2/);
|
|
33
|
+
assert.deepEqual(checkButtonParams(twoBtn, { param1: "a", param2: "b" }), { aborts: [], warnings: [] });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a value with no button is a warning, not an abort", () => {
|
|
37
|
+
const r = checkButtonParams(oneBtn, { param1: "a", param2: "b" });
|
|
38
|
+
assert.deepEqual(r.aborts, []);
|
|
39
|
+
assert.match(r.warnings[0], /param2 ignored/);
|
|
40
|
+
assert.match(checkButtonParams(noBtn, { param1: "a" }).warnings[0], /0 dynamic URL buttons/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("non-param keys abort", () => {
|
|
44
|
+
assert.match(checkButtonParams(oneBtn, { code: "a", param1: "b" }).aborts[0], /param1\.\.N/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("older server without dynamic_url_buttons falls back to url_button", () => {
|
|
48
|
+
assert.equal(checkButtonParams({ url_button: { present: true } }, {}).aborts.length, 1);
|
|
49
|
+
assert.equal(checkButtonParams({ url_button: { present: false } }, {}).aborts.length, 0);
|
|
50
|
+
});
|
|
@@ -502,6 +502,7 @@ function validateRows(template, mapping, headers, rows, illegalChars, headerMedi
|
|
|
502
502
|
const aborts = [];
|
|
503
503
|
if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
|
|
504
504
|
if (template.parameter_format !== "POSITIONAL") aborts.push("V-2: NAMED templates are not supported in v1");
|
|
505
|
+
if ((template.dynamic_url_buttons ?? 0) > 1) aborts.push(`V-3: this template has ${template.dynamic_url_buttons} dynamic URL buttons; CSV mode fills one — use tag mode: flowiq bc send <org> --tag <tag> --template <name> --button param1=<code> --button param2=<code>`);
|
|
505
506
|
if (template.is_carousel) aborts.push("V-3: carousel templates are TAG-MODE only — use: flowiq bc send <org> --tag <tag> --template <name> (the python engine sends carousels; per-row CSV carousel sends are not supported)");
|
|
506
507
|
// Media headers (image/video/document) ARE supported — they just need an image
|
|
507
508
|
// URL, exactly like the dashboard. Resolved as --header-media / saved mapping /
|
|
@@ -604,12 +605,64 @@ function renderPreview(template, sample, headerMedia) {
|
|
|
604
605
|
console.log(`Row ${sample.rownum} → ${sample.number} [VALID]${sample.sanitized ? " (sanitized)" : ""}`);
|
|
605
606
|
if (headerMedia) console.log(` Header (${template.header_type}): ${headerMedia}`);
|
|
606
607
|
console.log(` Body:\n ${bodyText.replace(/\n/g, "\n ")}`);
|
|
608
|
+
// Two-dynamic-button templates (17 Sep 2026): show every button with its own value.
|
|
609
|
+
if (Array.isArray(template.url_buttons) && template.url_buttons.length && sample.buttonParams) {
|
|
610
|
+
for (const b of template.url_buttons) {
|
|
611
|
+
const v = sample.buttonParams[b.param];
|
|
612
|
+
if (v == null) continue;
|
|
613
|
+
console.log(` Button "${b.text ?? "URL"}" (${b.param}) → ${b.url_base.replace(/\{\{[^}]+\}\}/, v)}`);
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
607
617
|
if (template.url_button?.present && sample.buttonValue != null) {
|
|
608
618
|
const url = template.url_button.url_base.replace(/\{\{[^}]+\}\}/, sample.buttonValue);
|
|
609
619
|
console.log(` Button "${template.url_button.text ?? "URL"}" → ${url}`);
|
|
610
620
|
}
|
|
611
621
|
}
|
|
612
622
|
|
|
623
|
+
/** Tag-mode URL-button values: every `--button paramN=…` flag, else the saved
|
|
624
|
+
* campaign's values (new `button_params_literal`, or the pre-0.7.5 single
|
|
625
|
+
* `button_param_literal` as param1). Empty values are dropped. */
|
|
626
|
+
export function resolveButtonParams(flagButtons, cfg) {
|
|
627
|
+
const out = {};
|
|
628
|
+
const src = flagButtons && Object.keys(flagButtons).length
|
|
629
|
+
? flagButtons
|
|
630
|
+
: (cfg?.button_params_literal && Object.keys(cfg.button_params_literal).length
|
|
631
|
+
? cfg.button_params_literal
|
|
632
|
+
: (cfg?.button_param_literal ? { param1: cfg.button_param_literal } : {}));
|
|
633
|
+
for (const [k, v] of Object.entries(src)) {
|
|
634
|
+
if (v == null || String(v) === "") continue;
|
|
635
|
+
out[k] = String(v);
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Checks tag-mode button values against the template's dynamic URL buttons.
|
|
641
|
+
* Every button needs its own value (python / send-template send '' for a
|
|
642
|
+
* missing one and Meta rejects the message); a value with no button to go to
|
|
643
|
+
* is a warning (it is ignored by both engines). */
|
|
644
|
+
export function checkButtonParams(template, buttonParams) {
|
|
645
|
+
const aborts = [];
|
|
646
|
+
const warnings = [];
|
|
647
|
+
const keys = Object.keys(buttonParams || {});
|
|
648
|
+
const bad = keys.filter((k) => !/^param\d+$/.test(k));
|
|
649
|
+
if (bad.length) aborts.push(`--button keys must be param1..N (got ${bad.join(", ")})`);
|
|
650
|
+
const need = Number.isInteger(template.dynamic_url_buttons)
|
|
651
|
+
? template.dynamic_url_buttons
|
|
652
|
+
: (template.url_button?.present ? 1 : 0);
|
|
653
|
+
const missing = [];
|
|
654
|
+
for (let i = 1; i <= need; i++) if (!buttonParams?.[`param${i}`]) missing.push(`param${i}`);
|
|
655
|
+
if (missing.length) {
|
|
656
|
+
const how = Array.from({ length: need }, (_, i) => `--button param${i + 1}=<code>`).join(" ");
|
|
657
|
+
aborts.push(need === 1
|
|
658
|
+
? "template has a dynamic URL button — pass --button param1=<code>"
|
|
659
|
+
: `template has ${need} dynamic URL buttons — pass ${how} (missing ${missing.join(", ")})`);
|
|
660
|
+
}
|
|
661
|
+
const extra = keys.filter((k) => /^param\d+$/.test(k) && Number(k.slice(5)) > need);
|
|
662
|
+
if (extra.length) warnings.push(`--button ${extra.join(", ")} ignored: template has ${need} dynamic URL button${need === 1 ? "" : "s"}`);
|
|
663
|
+
return { aborts, warnings };
|
|
664
|
+
}
|
|
665
|
+
|
|
613
666
|
// ---------------------------------------------------------------------------
|
|
614
667
|
// CSV → python fan-out builders (v0.6.0). Exported for the test harness.
|
|
615
668
|
// ---------------------------------------------------------------------------
|
|
@@ -695,7 +748,9 @@ async function sendOne(apiUrl, orgId, templateName, row, headerMedia) {
|
|
|
695
748
|
templateName,
|
|
696
749
|
whatsappNumber: row.number,
|
|
697
750
|
bodyParameters,
|
|
698
|
-
...(row.
|
|
751
|
+
...(row.buttonParams && Object.keys(row.buttonParams).length
|
|
752
|
+
? { buttonParameters: row.buttonParams }
|
|
753
|
+
: (row.buttonValue != null ? { buttonParameters: { param1: row.buttonValue } } : {})),
|
|
699
754
|
// Static media header (same field the dashboard passes for image/video/doc
|
|
700
755
|
// headers) — /api/send-template renders it onto the HEADER component.
|
|
701
756
|
...(headerMedia ? { headerMedia } : {}),
|
|
@@ -944,6 +999,10 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
944
999
|
}
|
|
945
1000
|
}
|
|
946
1001
|
|
|
1002
|
+
// Reply routing: resolved before the dry-run exit so the preview shows it, and
|
|
1003
|
+
// before the upsert so a bad team name never tags a single contact.
|
|
1004
|
+
const replyRouting = isResume ? null : await checkRouting(orgId, opts);
|
|
1005
|
+
|
|
947
1006
|
if (!commitStage) {
|
|
948
1007
|
console.log("");
|
|
949
1008
|
console.log(`DRY RUN — nothing was ${scheduledAt ? "scheduled" : "sent"}, no contacts written or tagged. Add --commit to ${scheduledAt ? "queue it" : "send"}.`);
|
|
@@ -968,6 +1027,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
968
1027
|
body_parameters: tokens.body,
|
|
969
1028
|
...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
|
|
970
1029
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1030
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
971
1031
|
dry_run: dryRun,
|
|
972
1032
|
});
|
|
973
1033
|
let pyTotal = null;
|
|
@@ -1013,6 +1073,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
1013
1073
|
body_parameters: tokens.body,
|
|
1014
1074
|
...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
|
|
1015
1075
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1076
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
1016
1077
|
});
|
|
1017
1078
|
} catch (e) {
|
|
1018
1079
|
console.error(`Schedule failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
|
|
@@ -1035,6 +1096,10 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
1035
1096
|
console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
|
|
1036
1097
|
}
|
|
1037
1098
|
console.log(` Also visible/manageable on the dashboard's Scheduled sends page.`);
|
|
1099
|
+
if (replyRouting) {
|
|
1100
|
+
if (resp.reply_routing?.saved) console.log(` Replies → ${describeRouting(resp.reply_routing.requested)} ✓ queued with the send (applied the moment it fires)`);
|
|
1101
|
+
else console.log(` ⚠ REPLY ROUTING NOT CONFIRMED by the server — set it after it fires with: flowiq bc route ${orgId} <broadcast_id> --team … --commit`);
|
|
1102
|
+
}
|
|
1038
1103
|
return;
|
|
1039
1104
|
}
|
|
1040
1105
|
|
|
@@ -1052,6 +1117,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
1052
1117
|
console.log(` Python is sending in the background to tag "${tag}" (${pyTotal ?? workSet.length} contact(s)) and tracking it under that broadcast id.`);
|
|
1053
1118
|
console.log(` Delivery: flowiq bc status ${orgId} ${out.broadcastId || "<broadcast_id>"} [--failures]`);
|
|
1054
1119
|
console.log(` Failures: flowiq bc retry ${orgId} ${out.broadcastId || "<broadcast_id>"} --commit`);
|
|
1120
|
+
if (replyRouting) reportRoutingResult(orgId, out.reply_routing, out.broadcastId ?? out.broadcast_id ?? null);
|
|
1055
1121
|
}
|
|
1056
1122
|
|
|
1057
1123
|
/** The paced, write-ahead-logged per-row send loop + final report. */
|
|
@@ -1146,13 +1212,99 @@ export function collectKV(pair, mapAcc) {
|
|
|
1146
1212
|
* (allow_broadcast=true, not blocked) and sends in the background, returning a
|
|
1147
1213
|
* broadcastId. No CLI write-ahead log / resume for this engine (python owns the
|
|
1148
1214
|
* broadcast record). Dry-run (no --commit) asks python for the eligible count. */
|
|
1149
|
-
|
|
1215
|
+
// ── Reply routing (v0.7.4) ───────────────────────────────────────────────────
|
|
1216
|
+
// `--route-team` / `--route-member` = "assign every reply to this broadcast to …".
|
|
1217
|
+
// The server resolves names → ids, stores it on the broadcast, and the agent runtime
|
|
1218
|
+
// applies it once per recipient inside the window WITHOUT muting keywords or the AI.
|
|
1219
|
+
|
|
1220
|
+
/** "72" | "72h" | "3d" → whole hours, or { error }. */
|
|
1221
|
+
export function parseRouteWindow(input) {
|
|
1222
|
+
if (input === undefined || input === null || input === "") return { hours: null };
|
|
1223
|
+
const m = String(input).trim().toLowerCase().match(/^(\d{1,4})\s*(h|hr|hrs|hours?|d|days?)?$/);
|
|
1224
|
+
if (!m) return { error: `"${input}" is not a window — use hours or days, e.g. 72, 72h or 3d` };
|
|
1225
|
+
const n = Number(m[1]);
|
|
1226
|
+
const hours = m[2] && m[2].startsWith("d") ? n * 24 : n;
|
|
1227
|
+
if (hours < 1 || hours > 336) return { error: `the window must be between 1 hour and 14 days (got ${hours} h)` };
|
|
1228
|
+
return { hours };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** The send flags → the request's reply_routing object (null when no routing asked for). */
|
|
1232
|
+
export function routingFromOpts(opts = {}, keys = { team: "routeTeam", member: "routeMember", window: "routeWindow", notify: "routeNotify" }) {
|
|
1233
|
+
const team = typeof opts[keys.team] === "string" ? opts[keys.team].trim() : "";
|
|
1234
|
+
const member = typeof opts[keys.member] === "string" ? opts[keys.member].trim() : "";
|
|
1235
|
+
const hasWindow = opts[keys.window] !== undefined && opts[keys.window] !== null && opts[keys.window] !== "";
|
|
1236
|
+
const noNotify = opts[keys.notify] === false;
|
|
1237
|
+
if (!team && !member) {
|
|
1238
|
+
if (hasWindow || noNotify) return { error: "the routing window / no-notify flags need a team or a member to route to" };
|
|
1239
|
+
return { routing: null };
|
|
1240
|
+
}
|
|
1241
|
+
const win = parseRouteWindow(opts[keys.window]);
|
|
1242
|
+
if (win.error) return { error: win.error };
|
|
1243
|
+
return {
|
|
1244
|
+
routing: {
|
|
1245
|
+
...(team ? { team } : {}),
|
|
1246
|
+
...(member ? { member } : {}),
|
|
1247
|
+
...(win.hours ? { window_hours: win.hours } : {}),
|
|
1248
|
+
...(noNotify ? { notify: false } : {}),
|
|
1249
|
+
},
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
export function describeRouting(d) {
|
|
1254
|
+
if (!d) return "none";
|
|
1255
|
+
const who = [d.team_name ? `team ${d.team_name}` : null, d.member_name || d.member_email || null].filter(Boolean).join(" + ") || "(unnamed target)";
|
|
1256
|
+
const notes = [];
|
|
1257
|
+
if (d.team_name) notes.push(d.notify_team === false ? "team not notified" : "team notified");
|
|
1258
|
+
if (d.member_name || d.member_email) notes.push(d.notify_member === false ? "person not notified" : "person notified");
|
|
1259
|
+
return `${who} · first reply within ${d.window_hours ?? 72} h · ${notes.join(", ")}`;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Resolve the routing on the server BEFORE anything is tagged, queued or sent. Exits on a bad team/member. */
|
|
1263
|
+
async function checkRouting(orgId, opts) {
|
|
1264
|
+
const parsed = routingFromOpts(opts);
|
|
1265
|
+
if (parsed.error) { console.error(`Error: ${parsed.error}`); process.exit(1); }
|
|
1266
|
+
if (!parsed.routing) return null;
|
|
1267
|
+
let resp;
|
|
1268
|
+
try { resp = await http.post("broadcast", { action: "resolve-routing", organization_id: orgId, reply_routing: parsed.routing }); }
|
|
1269
|
+
catch (e) {
|
|
1270
|
+
console.error(`Reply routing refused: ${e.body?.error || e.message}`);
|
|
1271
|
+
if (/unknown action|Unknown action|not supported/i.test(String(e.body?.error || "")) || e.status === 404) {
|
|
1272
|
+
console.error(" (the server does not know reply routing yet — run `flowiq doctor`)");
|
|
1273
|
+
}
|
|
1274
|
+
process.exit(1);
|
|
1275
|
+
}
|
|
1276
|
+
if (!resp?.reply_routing) {
|
|
1277
|
+
console.error("Reply routing refused: the server did not confirm it (it may predate v0.7.4 — run `flowiq doctor`). Nothing sent.");
|
|
1278
|
+
process.exit(1);
|
|
1279
|
+
}
|
|
1280
|
+
console.log(`Replies → ${describeRouting(resp.reply_routing)}`);
|
|
1281
|
+
console.log(` (assigned once per recipient; keywords and the AI agent still answer as normal)`);
|
|
1282
|
+
return parsed.routing;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** After a commit: say plainly whether the routing landed. */
|
|
1286
|
+
function reportRoutingResult(orgId, rr, broadcastId) {
|
|
1287
|
+
if (!rr) {
|
|
1288
|
+
console.log(" ⚠ REPLY ROUTING NOT CONFIRMED by the server — replies will NOT be assigned.");
|
|
1289
|
+
if (broadcastId) console.log(` Fix now: flowiq bc route ${orgId} ${broadcastId} --team … --commit`);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (rr.saved) console.log(` Replies → ${describeRouting(rr.requested)} ✓ saved`);
|
|
1293
|
+
else {
|
|
1294
|
+
console.log(` ⚠ REPLY ROUTING NOT SAVED: ${rr.error || "unknown reason"}`);
|
|
1295
|
+
if (broadcastId) console.log(` Fix now: flowiq bc route ${orgId} ${broadcastId} --team … --commit`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams = null, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1300
|
+
const buttons = buttonParams && Object.keys(buttonParams).length ? buttonParams : (buttonLiteral ? { param1: buttonLiteral } : null);
|
|
1150
1301
|
const reqBody = (dryRun) => ({
|
|
1151
1302
|
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
1152
1303
|
body_parameters: bodyLiterals,
|
|
1153
|
-
...(
|
|
1304
|
+
...(buttons ? { button_parameters: buttons } : {}),
|
|
1154
1305
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1155
1306
|
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1307
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
1156
1308
|
dry_run: dryRun,
|
|
1157
1309
|
});
|
|
1158
1310
|
|
|
@@ -1166,7 +1318,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
|
|
|
1166
1318
|
console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
|
|
1167
1319
|
console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
|
|
1168
1320
|
const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
|
|
1169
|
-
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1321
|
+
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral, buttonParams: buttons }, headerMedia);
|
|
1170
1322
|
printCarouselPlan(cardOverrides, cardLines);
|
|
1171
1323
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
1172
1324
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
|
|
@@ -1189,6 +1341,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
|
|
|
1189
1341
|
console.log("");
|
|
1190
1342
|
console.log(`✅ ${out.message || "Broadcast started"}${out.broadcastId ? ` · broadcastId ${out.broadcastId}` : ""}`);
|
|
1191
1343
|
console.log(` Python is sending in the background${out.mode ? ` (${out.mode})` : ""} and tracking it under that broadcast id — no CLI resume for this engine.`);
|
|
1344
|
+
if (replyRouting) reportRoutingResult(orgId, out.reply_routing, out.broadcastId ?? out.broadcast_id ?? null);
|
|
1192
1345
|
}
|
|
1193
1346
|
|
|
1194
1347
|
async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
@@ -1210,7 +1363,8 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1210
1363
|
process.exit(1);
|
|
1211
1364
|
}
|
|
1212
1365
|
const bodyLiterals = Object.keys(opts.body || {}).length ? opts.body : (cfg?.body_params_literal ?? {});
|
|
1213
|
-
const
|
|
1366
|
+
const buttonParams = resolveButtonParams(opts.button, cfg);
|
|
1367
|
+
const buttonLiteral = buttonParams.param1 ?? null;
|
|
1214
1368
|
|
|
1215
1369
|
// introspect + validate params arithmetically (the #132000 guard, pre-send)
|
|
1216
1370
|
let intro;
|
|
@@ -1222,7 +1376,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1222
1376
|
? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
|
|
1223
1377
|
: null;
|
|
1224
1378
|
const headerCheck = await verifyHeaderMedia(template, headerMedia);
|
|
1225
|
-
console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${headerCheck.label}${template.url_button?.present ? ", dynamic URL button" : ""}${template.is_carousel ? `, CAROUSEL (${template.carousel_cards?.length ?? "?"} cards)` : ""}`);
|
|
1379
|
+
console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${headerCheck.label}${(template.dynamic_url_buttons ?? 0) > 1 ? `, ${template.dynamic_url_buttons} dynamic URL buttons` : (template.url_button?.present ? ", dynamic URL button" : "")}${template.is_carousel ? `, CAROUSEL (${template.carousel_cards?.length ?? "?"} cards)` : ""}`);
|
|
1226
1380
|
const aborts = [];
|
|
1227
1381
|
if (headerCheck.status === "mismatch") aborts.push(headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia }));
|
|
1228
1382
|
if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
|
|
@@ -1249,7 +1403,9 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1249
1403
|
const keys = Object.keys(bodyLiterals);
|
|
1250
1404
|
if (keys.some((k) => !/^param\d+$/.test(k))) aborts.push(`--body keys must be param1..N (got ${keys.join(", ")})`);
|
|
1251
1405
|
if (keys.length !== template.body_var_count) aborts.push(`template expects ${template.body_var_count} body param(s), you provided ${keys.length} (--body paramN=…). Values may use {{first_name}}/{{full_name}}/{{email}}/{{phone_number}} — resolved per contact.`);
|
|
1252
|
-
|
|
1406
|
+
const buttonCheck = checkButtonParams(template, buttonParams);
|
|
1407
|
+
for (const w of buttonCheck.warnings) console.log(`⚠ ${w}`);
|
|
1408
|
+
aborts.push(...buttonCheck.aborts);
|
|
1253
1409
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
1254
1410
|
|
|
1255
1411
|
// SCHEDULE (--at): validation above has already run, so a scheduled send is
|
|
@@ -1259,7 +1415,11 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1259
1415
|
const cardOverrides = carouselPlan?.overrides ?? null;
|
|
1260
1416
|
const cardLines = carouselPlan?.lines ?? [];
|
|
1261
1417
|
|
|
1262
|
-
|
|
1418
|
+
// Reply routing is resolved HERE — after the template checks, before any engine is
|
|
1419
|
+
// picked — so a mistyped team stops the run in the dry run, not after a send.
|
|
1420
|
+
const replyRouting = await checkRouting(orgId, opts);
|
|
1421
|
+
|
|
1422
|
+
if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1263
1423
|
|
|
1264
1424
|
// ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
|
|
1265
1425
|
// python /meta-broadcast engine (the proven bulk sender). --python forces it at
|
|
@@ -1270,8 +1430,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1270
1430
|
if (template.is_carousel && !opts.python) {
|
|
1271
1431
|
console.log("Carousel template → PYTHON engine at any size (the per-row Node engine cannot send carousels).");
|
|
1272
1432
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1433
|
+
// Reply routing lives on ONE broadcasts row, which only the python engine creates per
|
|
1434
|
+
// send (the per-row Node engine writes a row per recipient) → python at any size.
|
|
1435
|
+
if (replyRouting && !opts.python && !template.is_carousel) {
|
|
1436
|
+
console.log("Reply routing → PYTHON engine at any size (the routing is stored on the single broadcast python creates).");
|
|
1437
|
+
}
|
|
1438
|
+
if (opts.python || template.is_carousel || replyRouting) {
|
|
1439
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1275
1440
|
}
|
|
1276
1441
|
|
|
1277
1442
|
// resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
|
|
@@ -1281,13 +1446,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1281
1446
|
catch (e) {
|
|
1282
1447
|
if (e.status === 413) {
|
|
1283
1448
|
console.log(`Tag "${tag}" has >2000 contacts → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
|
|
1284
|
-
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
1449
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage });
|
|
1285
1450
|
}
|
|
1286
1451
|
console.error(`Tag resolution failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1);
|
|
1287
1452
|
}
|
|
1288
1453
|
if (resolved.recipients.length > PYTHON_MIN) {
|
|
1289
1454
|
console.log(`Tag "${tag}": ${resolved.recipients.length} sendable (>${PYTHON_MIN}) → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
|
|
1290
|
-
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
1455
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage });
|
|
1291
1456
|
}
|
|
1292
1457
|
console.log(`Tag "${tag}" on ${resolved.organization_name}: ${resolved.tagged_total} contact(s), ${resolved.recipients.length} sendable, ${resolved.excluded.length} excluded (opt-out/archived/blocked).`);
|
|
1293
1458
|
if (!resolved.recipients.length) { console.log("Nothing sendable under this tag."); return; }
|
|
@@ -1295,7 +1460,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1295
1460
|
const values = {};
|
|
1296
1461
|
for (const [k, v] of Object.entries(bodyLiterals)) values[k] = String(v);
|
|
1297
1462
|
let sendable = resolved.recipients.map((r, i) => ({
|
|
1298
|
-
rownum: i + 1, number: r.number, values, buttonValue: buttonLiteral, contactName: r.full_name,
|
|
1463
|
+
rownum: i + 1, number: r.number, values, buttonValue: buttonLiteral, buttonParams, contactName: r.full_name,
|
|
1299
1464
|
}));
|
|
1300
1465
|
if (opts.limit) sendable = sendable.slice(0, Number(opts.limit));
|
|
1301
1466
|
|
|
@@ -1341,7 +1506,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1341
1506
|
schema_version: 1, campaign, mode: "tag",
|
|
1342
1507
|
organization_id: orgId, organization_slug: slugify(resolved.organization_name, orgId.slice(0, 8)),
|
|
1343
1508
|
template_name: templateName, tag,
|
|
1344
|
-
body_params_literal: bodyLiterals, button_param_literal: buttonLiteral,
|
|
1509
|
+
body_params_literal: bodyLiterals, button_param_literal: buttonLiteral, button_params_literal: buttonParams,
|
|
1345
1510
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1346
1511
|
created_at: cfg?.created_at ?? new Date().toISOString(),
|
|
1347
1512
|
});
|
|
@@ -1433,7 +1598,8 @@ const fmtSast = (iso) =>
|
|
|
1433
1598
|
new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
|
|
1434
1599
|
|
|
1435
1600
|
/** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
|
|
1436
|
-
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
|
|
1601
|
+
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams = null, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1602
|
+
const buttons = buttonParams && Object.keys(buttonParams).length ? buttonParams : (buttonLiteral ? { param1: buttonLiteral } : null);
|
|
1437
1603
|
const when = parseSastAt(opts.at);
|
|
1438
1604
|
if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
|
|
1439
1605
|
if (new Date(when.iso).getTime() <= Date.now()) {
|
|
@@ -1443,7 +1609,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1443
1609
|
const needsApproval = !!opts.needsApproval;
|
|
1444
1610
|
|
|
1445
1611
|
console.log("");
|
|
1446
|
-
renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1612
|
+
renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral, buttonParams: buttons }, headerMedia);
|
|
1447
1613
|
printCarouselPlan(cardOverrides, cardLines);
|
|
1448
1614
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
1449
1615
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
|
|
@@ -1472,9 +1638,10 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1472
1638
|
scheduled_for: when.iso,
|
|
1473
1639
|
needs_approval: needsApproval,
|
|
1474
1640
|
body_parameters: bodyLiterals,
|
|
1475
|
-
...(
|
|
1641
|
+
...(buttons ? { button_parameters: buttons } : {}),
|
|
1476
1642
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1477
1643
|
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1644
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
1478
1645
|
});
|
|
1479
1646
|
} catch (e) {
|
|
1480
1647
|
console.error(`Schedule failed: ${e.message}`);
|
|
@@ -1491,6 +1658,10 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1491
1658
|
console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
|
|
1492
1659
|
console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
|
|
1493
1660
|
}
|
|
1661
|
+
if (replyRouting) {
|
|
1662
|
+
if (resp.reply_routing?.saved) console.log(` Replies → ${describeRouting(resp.reply_routing.requested)} ✓ queued with the send (applied the moment it fires)`);
|
|
1663
|
+
else console.log(` ⚠ REPLY ROUTING NOT CONFIRMED by the server — cancel and re-schedule, or set it after it fires with: flowiq bc route ${orgId} <broadcast_id> --team … --commit`);
|
|
1664
|
+
}
|
|
1494
1665
|
}
|
|
1495
1666
|
|
|
1496
1667
|
/** List scheduled broadcasts (queue rows). Read-only. */
|
|
@@ -1515,6 +1686,7 @@ export async function scheduledList(orgId, opts = {}) {
|
|
|
1515
1686
|
console.log(` ${fmtSast(s.scheduled_for)} SAST · ${s.status}${flag}`);
|
|
1516
1687
|
console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
|
|
1517
1688
|
if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
|
|
1689
|
+
if (s.reply_routing) console.log(` replies → ${describeRouting(s.reply_routing)}`);
|
|
1518
1690
|
if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
|
|
1519
1691
|
if (s.error_message) console.log(` error: ${s.error_message}`);
|
|
1520
1692
|
}
|
|
@@ -1628,6 +1800,11 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
1628
1800
|
if (b.total_recipients) {
|
|
1629
1801
|
console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
|
|
1630
1802
|
}
|
|
1803
|
+
if (resp.reply_routing) {
|
|
1804
|
+
const rr = resp.reply_routing;
|
|
1805
|
+
console.log(` replies → ${describeRouting(rr)}${rr.enabled === false ? " (switched OFF)" : ""}`);
|
|
1806
|
+
console.log(` ${rr.routed} chat(s) routed so far${rr.set_by ? ` · set by ${rr.set_by}` : ""}`);
|
|
1807
|
+
}
|
|
1631
1808
|
if (opts.failures && resp.failures) {
|
|
1632
1809
|
console.log("");
|
|
1633
1810
|
console.log(` Failures by reason:`);
|
|
@@ -1641,6 +1818,41 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
1641
1818
|
}
|
|
1642
1819
|
}
|
|
1643
1820
|
|
|
1821
|
+
/** Show / set / clear reply routing on a broadcast that already exists. Dry run unless --commit. */
|
|
1822
|
+
export async function route(orgId, broadcastId, opts = {}) {
|
|
1823
|
+
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
1824
|
+
if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (see `flowiq bc list-remote <org>`)."); process.exit(1); }
|
|
1825
|
+
const parsed = routingFromOpts(opts, { team: "team", member: "member", window: "window", notify: "notify" });
|
|
1826
|
+
if (parsed.error) { console.error(`Error: ${parsed.error}`); process.exit(1); }
|
|
1827
|
+
if (opts.clear && parsed.routing) { console.error("Error: pass --clear OR a --team/--member, not both."); process.exit(1); }
|
|
1828
|
+
let resp;
|
|
1829
|
+
try {
|
|
1830
|
+
resp = await http.post("broadcast", {
|
|
1831
|
+
action: "route", organization_id: orgId, broadcast_id: broadcastId,
|
|
1832
|
+
...(parsed.routing ? { reply_routing: parsed.routing } : {}),
|
|
1833
|
+
...(opts.clear ? { clear: true } : {}),
|
|
1834
|
+
commit: !!opts.commit,
|
|
1835
|
+
});
|
|
1836
|
+
} catch (e) { console.error(`Route failed: ${e.body?.error || e.message}`); process.exit(1); }
|
|
1837
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
1838
|
+
const b = resp.broadcast;
|
|
1839
|
+
console.log(`Broadcast ${b.id} — ${resp.organization_name}`);
|
|
1840
|
+
console.log(` template: ${b.template_name} · sent ${fmtSast(b.created_at)} SAST · ${b.total_recipients ?? "?"} recipient(s)`);
|
|
1841
|
+
console.log(` replies → ${resp.current ? describeRouting(resp.current) : "no routing set"}`);
|
|
1842
|
+
console.log(` ${resp.routed} chat(s) routed so far`);
|
|
1843
|
+
if (resp.mode === "show") {
|
|
1844
|
+
console.log("");
|
|
1845
|
+
console.log(`Set it: flowiq bc route ${orgId} ${b.id} --team "<team>" [--member <email>] [--window 72h] [--no-notify] --commit`);
|
|
1846
|
+
console.log(`Clear it: flowiq bc route ${orgId} ${b.id} --clear --commit`);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
console.log("");
|
|
1850
|
+
console.log(` ${resp.mode === "clear" ? "CLEAR" : "SET"} → ${resp.mode === "clear" ? "no routing (replies are no longer assigned)" : describeRouting(resp.next)}`);
|
|
1851
|
+
for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
|
|
1852
|
+
if (resp.dry_run) { console.log(""); console.log("DRY RUN — nothing changed. Add --commit to apply."); return; }
|
|
1853
|
+
console.log(` ✓ saved. Only replies that arrive from now on are routed; chats already routed are left as they are.`);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1644
1856
|
/** Re-send a broadcast to ONLY its failed recipients (transient-failure recovery).
|
|
1645
1857
|
* Reconstructs the send from the broadcasts row + re-fires via python. The dry-run
|
|
1646
1858
|
* shows the failed count + reason breakdown; --commit creates a NEW broadcast. */
|
package/src/index.js
CHANGED
|
@@ -620,7 +620,7 @@ export function run(argv) {
|
|
|
620
620
|
.option("--csv <file>", "path to the recipients CSV (per-row values)")
|
|
621
621
|
.option("--tag <tag>", "send to every broadcast-safe contact carrying this tag (e.g. a segments batch tag)")
|
|
622
622
|
.option("--body <k=v>", "with --tag: body param (repeatable), e.g. --body param1=\"Hi {{first_name}}\"", broadcastCmd.collectKV, {})
|
|
623
|
-
.option("--button <k=v>", "with --tag: dynamic URL button
|
|
623
|
+
.option("--button <k=v>", "with --tag: dynamic URL button value, repeatable for a second button, e.g. --button param1=<code> --button param2=<code>", broadcastCmd.collectKV, {})
|
|
624
624
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
625
625
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
626
626
|
.option("--card-media <url>", "with --tag, carousel templates: card image/video URL in card order (repeatable — one per card; default: each card's stored template image)", (v, acc) => (acc || []).concat([v]), [])
|
|
@@ -628,6 +628,10 @@ export function run(argv) {
|
|
|
628
628
|
.option("--python", "with --tag: force the python /meta-broadcast engine (same as the dashboard's Python toggle). NOTE: any tag send >10 recipients ALWAYS uses python automatically")
|
|
629
629
|
.option("--at <when>", "SCHEDULE instead of sending now (tag AND CSV mode, v0.6.1) — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). CSV: rows are imported+tagged NOW, the python call is queued server-side and shows on the dashboard's Scheduled sends page. Fires automatically; the tag is re-resolved at send time")
|
|
630
630
|
.option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
|
|
631
|
+
.option("--route-team <team>", "REPLY ROUTING (v0.7.4): assign every reply to this broadcast to this team (name or id). Once per recipient, typed replies AND button taps, without muting keywords or the AI agent. Forces the python engine")
|
|
632
|
+
.option("--route-member <who>", "REPLY ROUTING: also/instead assign to this person (email, name or id) — must be an active member of the org")
|
|
633
|
+
.option("--route-window <dur>", "REPLY ROUTING: how long after the send a reply still counts, e.g. 72h or 3d (default 72h, max 14d)")
|
|
634
|
+
.option("--no-route-notify", "REPLY ROUTING: assign silently — do not send the team/person their assignment notification")
|
|
631
635
|
.option("--commit", "actually send (omit to dry-run)")
|
|
632
636
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
633
637
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
@@ -679,6 +683,16 @@ export function run(argv) {
|
|
|
679
683
|
.option("--limit <n>", "with --failures: how many failed rows to print", "40")
|
|
680
684
|
.option("--json", "raw JSON")
|
|
681
685
|
.action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
|
|
686
|
+
broadcast.command("route <organization_id> <broadcast_id>")
|
|
687
|
+
.description("Reply routing on an EXISTING broadcast: no flags = show it; --team/--member = set it; --clear = switch it off. DRY-RUN unless --commit. Only replies arriving after the change are routed")
|
|
688
|
+
.option("--team <team>", "assign replies to this team (name or id)")
|
|
689
|
+
.option("--member <who>", "assign replies to this person (email, name or id)")
|
|
690
|
+
.option("--window <dur>", "how long after the SEND a reply still counts, e.g. 72h or 3d (default 72h, max 14d)")
|
|
691
|
+
.option("--no-notify", "assign silently (no assignment notification)")
|
|
692
|
+
.option("--clear", "remove the routing from this broadcast")
|
|
693
|
+
.option("--commit", "apply (omit to dry-run)")
|
|
694
|
+
.option("--json", "raw JSON")
|
|
695
|
+
.action((orgId, broadcastId, opts) => broadcastCmd.route(orgId, broadcastId, opts));
|
|
682
696
|
broadcast.command("retry <organization_id> <broadcast_id>")
|
|
683
697
|
.description("Re-send a broadcast to ONLY its failed recipients (transient-failure recovery). DRY-RUN by default; --commit re-fires via python")
|
|
684
698
|
.option("--commit", "actually re-send (omit to see the failed count + reasons)")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Reply routing flag parsing (v0.7.4) — pure functions only, no network.
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { parseRouteWindow, routingFromOpts, describeRouting } from "./commands/broadcast.js";
|
|
5
|
+
|
|
6
|
+
test("window: hours, h suffix, days", () => {
|
|
7
|
+
assert.deepEqual(parseRouteWindow("72"), { hours: 72 });
|
|
8
|
+
assert.deepEqual(parseRouteWindow("72h"), { hours: 72 });
|
|
9
|
+
assert.deepEqual(parseRouteWindow(" 3D "), { hours: 72 });
|
|
10
|
+
assert.deepEqual(parseRouteWindow("14 days"), { hours: 336 });
|
|
11
|
+
assert.deepEqual(parseRouteWindow(undefined), { hours: null });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("window: rejects garbage, zero and anything past 14 days", () => {
|
|
15
|
+
assert.ok(parseRouteWindow("soon").error);
|
|
16
|
+
assert.ok(parseRouteWindow("0").error);
|
|
17
|
+
assert.ok(parseRouteWindow("15d").error);
|
|
18
|
+
assert.ok(parseRouteWindow("-5").error);
|
|
19
|
+
assert.ok(parseRouteWindow("1.5h").error);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("no routing flags = no routing, and the send request stays untouched", () => {
|
|
23
|
+
assert.deepEqual(routingFromOpts({ routeNotify: true }), { routing: null });
|
|
24
|
+
assert.deepEqual(routingFromOpts({}), { routing: null });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a window or --no-route-notify without a target is an error, never a silent no-op", () => {
|
|
28
|
+
assert.ok(routingFromOpts({ routeWindow: "48h" }).error);
|
|
29
|
+
assert.ok(routingFromOpts({ routeNotify: false }).error);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("team + member + window + silent", () => {
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
routingFromOpts({ routeTeam: " Spa ", routeMember: "kim@example.com", routeWindow: "2d", routeNotify: false }),
|
|
35
|
+
{ routing: { team: "Spa", member: "kim@example.com", window_hours: 48, notify: false } },
|
|
36
|
+
);
|
|
37
|
+
// Defaults are left to the server (72 h, notify on) — the client sends only what was typed.
|
|
38
|
+
assert.deepEqual(routingFromOpts({ routeTeam: "Spa", routeNotify: true }), { routing: { team: "Spa" } });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a bad window stops the run", () => {
|
|
42
|
+
assert.ok(routingFromOpts({ routeTeam: "Spa", routeWindow: "whenever" }).error);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("`bc route` reads its own flag names", () => {
|
|
46
|
+
const keys = { team: "team", member: "member", window: "window", notify: "notify" };
|
|
47
|
+
assert.deepEqual(routingFromOpts({ team: "Spa", window: "24", notify: true }, keys), { routing: { team: "Spa", window_hours: 24 } });
|
|
48
|
+
assert.deepEqual(routingFromOpts({ notify: true }, keys), { routing: null });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("describeRouting reads like a sentence", () => {
|
|
52
|
+
assert.equal(describeRouting(null), "none");
|
|
53
|
+
assert.equal(
|
|
54
|
+
describeRouting({ team_name: "Spa", member_name: null, window_hours: 72, notify_team: true, notify_member: null }),
|
|
55
|
+
"team Spa · first reply within 72 h · team notified",
|
|
56
|
+
);
|
|
57
|
+
assert.match(describeRouting({ team_name: "Spa", member_name: "Kim", window_hours: 24, notify_team: false, notify_member: false }), /team Spa \+ Kim .* 24 h .* team not notified, person not notified/);
|
|
58
|
+
});
|