@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 +14 -0
- package/TEAM-GUIDE.md +1 -0
- package/package.json +1 -1
- package/src/button-params.test.mjs +50 -0
- package/src/commands/broadcast.js +76 -16
- package/src/index.js +1 -1
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.
|
|
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 } : {}),
|
|
@@ -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
|
-
...(
|
|
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
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
|
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]), [])
|