@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 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",
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.buttonValue != null ? { buttonParameters: { param1: row.buttonValue } } : {}),
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
- async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
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
- ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
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 buttonLiteral = (opts.button && opts.button.param1) ?? cfg?.button_param_literal ?? null;
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
- if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
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
- if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
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
- if (opts.python || template.is_carousel) {
1274
- return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
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
- ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
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 param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
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
+ });