@flowapt/flowiq-cli 0.7.4 → 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
@@ -455,8 +455,22 @@ instead of a CSV. Same guards, same status log, same resume:
455
455
  ```bash
456
456
  flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
457
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
458
463
  ```
459
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
+
460
474
  Values are shared across the tag (use `{{first_name}}` etc. for per-contact
461
475
  personalization — resolved server-side). Supported per-contact tokens:
462
476
  `{{first_name}}` `{{full_name}}` `{{email}}` `{{phone_number}}`
package/TEAM-GUIDE.md CHANGED
@@ -167,6 +167,7 @@ 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`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.7.4",
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 } : {}),
@@ -1241,11 +1296,12 @@ function reportRoutingResult(orgId, rr, broadcastId) {
1241
1296
  }
1242
1297
  }
1243
1298
 
1244
- async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
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);
1245
1301
  const reqBody = (dryRun) => ({
1246
1302
  action: "send-python", organization_id: orgId, tag, template_name: templateName,
1247
1303
  body_parameters: bodyLiterals,
1248
- ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
1304
+ ...(buttons ? { button_parameters: buttons } : {}),
1249
1305
  ...(headerMedia ? { header_media: headerMedia } : {}),
1250
1306
  ...(cardOverrides ? { card_overrides: cardOverrides } : {}),
1251
1307
  ...(replyRouting ? { reply_routing: replyRouting } : {}),
@@ -1262,7 +1318,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
1262
1318
  console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
1263
1319
  console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
1264
1320
  const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
1265
- 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);
1266
1322
  printCarouselPlan(cardOverrides, cardLines);
1267
1323
  if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
1268
1324
  console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
@@ -1307,7 +1363,8 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1307
1363
  process.exit(1);
1308
1364
  }
1309
1365
  const bodyLiterals = Object.keys(opts.body || {}).length ? opts.body : (cfg?.body_params_literal ?? {});
1310
- 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;
1311
1368
 
1312
1369
  // introspect + validate params arithmetically (the #132000 guard, pre-send)
1313
1370
  let intro;
@@ -1319,7 +1376,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1319
1376
  ? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
1320
1377
  : null;
1321
1378
  const headerCheck = await verifyHeaderMedia(template, headerMedia);
1322
- 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)` : ""}`);
1323
1380
  const aborts = [];
1324
1381
  if (headerCheck.status === "mismatch") aborts.push(headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia }));
1325
1382
  if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
@@ -1346,7 +1403,9 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1346
1403
  const keys = Object.keys(bodyLiterals);
1347
1404
  if (keys.some((k) => !/^param\d+$/.test(k))) aborts.push(`--body keys must be param1..N (got ${keys.join(", ")})`);
1348
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.`);
1349
- 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);
1350
1409
  if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
1351
1410
 
1352
1411
  // SCHEDULE (--at): validation above has already run, so a scheduled send is
@@ -1360,7 +1419,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1360
1419
  // picked — so a mistyped team stops the run in the dry run, not after a send.
1361
1420
  const replyRouting = await checkRouting(orgId, opts);
1362
1421
 
1363
- if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
1422
+ if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage, cardOverrides, cardLines, replyRouting });
1364
1423
 
1365
1424
  // ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
1366
1425
  // python /meta-broadcast engine (the proven bulk sender). --python forces it at
@@ -1377,7 +1436,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1377
1436
  console.log("Reply routing → PYTHON engine at any size (the routing is stored on the single broadcast python creates).");
1378
1437
  }
1379
1438
  if (opts.python || template.is_carousel || replyRouting) {
1380
- return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
1439
+ return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage, cardOverrides, cardLines, replyRouting });
1381
1440
  }
1382
1441
 
1383
1442
  // resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
@@ -1387,13 +1446,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1387
1446
  catch (e) {
1388
1447
  if (e.status === 413) {
1389
1448
  console.log(`Tag "${tag}" has >2000 contacts → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
1390
- return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
1449
+ return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage });
1391
1450
  }
1392
1451
  console.error(`Tag resolution failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1);
1393
1452
  }
1394
1453
  if (resolved.recipients.length > PYTHON_MIN) {
1395
1454
  console.log(`Tag "${tag}": ${resolved.recipients.length} sendable (>${PYTHON_MIN}) → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
1396
- return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
1455
+ return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage });
1397
1456
  }
1398
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).`);
1399
1458
  if (!resolved.recipients.length) { console.log("Nothing sendable under this tag."); return; }
@@ -1401,7 +1460,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1401
1460
  const values = {};
1402
1461
  for (const [k, v] of Object.entries(bodyLiterals)) values[k] = String(v);
1403
1462
  let sendable = resolved.recipients.map((r, i) => ({
1404
- 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,
1405
1464
  }));
1406
1465
  if (opts.limit) sendable = sendable.slice(0, Number(opts.limit));
1407
1466
 
@@ -1447,7 +1506,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1447
1506
  schema_version: 1, campaign, mode: "tag",
1448
1507
  organization_id: orgId, organization_slug: slugify(resolved.organization_name, orgId.slice(0, 8)),
1449
1508
  template_name: templateName, tag,
1450
- body_params_literal: bodyLiterals, button_param_literal: buttonLiteral,
1509
+ body_params_literal: bodyLiterals, button_param_literal: buttonLiteral, button_params_literal: buttonParams,
1451
1510
  ...(headerMedia ? { header_media: headerMedia } : {}),
1452
1511
  created_at: cfg?.created_at ?? new Date().toISOString(),
1453
1512
  });
@@ -1539,7 +1598,8 @@ const fmtSast = (iso) =>
1539
1598
  new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
1540
1599
 
1541
1600
  /** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
1542
- async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
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);
1543
1603
  const when = parseSastAt(opts.at);
1544
1604
  if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
1545
1605
  if (new Date(when.iso).getTime() <= Date.now()) {
@@ -1549,7 +1609,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
1549
1609
  const needsApproval = !!opts.needsApproval;
1550
1610
 
1551
1611
  console.log("");
1552
- 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);
1553
1613
  printCarouselPlan(cardOverrides, cardLines);
1554
1614
  if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
1555
1615
  console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
@@ -1578,7 +1638,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
1578
1638
  scheduled_for: when.iso,
1579
1639
  needs_approval: needsApproval,
1580
1640
  body_parameters: bodyLiterals,
1581
- ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
1641
+ ...(buttons ? { button_parameters: buttons } : {}),
1582
1642
  ...(headerMedia ? { header_media: headerMedia } : {}),
1583
1643
  ...(cardOverrides ? { card_overrides: cardOverrides } : {}),
1584
1644
  ...(replyRouting ? { reply_routing: replyRouting } : {}),
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]), [])