@flowapt/flowiq-cli 0.3.2 → 0.3.4
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 +18 -3
- package/TEAM-GUIDE.md +2 -0
- package/package.json +1 -1
- package/src/commands/broadcast.js +100 -14
- package/src/index.js +4 -0
package/README.md
CHANGED
|
@@ -187,9 +187,24 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
|
|
|
187
187
|
leading-zero handled). The mapper auto-suggests from the template's
|
|
188
188
|
semantic labels + body text and you confirm each slot once; the mapping is
|
|
189
189
|
saved to `.flowiq/campaigns/<campaign>.json` and reused.
|
|
190
|
-
- **
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
- **Media-header templates (image/video/document) ARE supported** (v0.3.3) —
|
|
191
|
+
exactly like the dashboard. The header image defaults to the template's own
|
|
192
|
+
stored image (`template_data.header.default_url`); override with
|
|
193
|
+
`--header-media <public-url>` on `map`/`preview`/`send`. Passed to
|
|
194
|
+
`/api/send-template` as `headerMedia`, the same field the dashboard uses. A
|
|
195
|
+
media-header template with no resolvable image is refused (pass
|
|
196
|
+
`--header-media`).
|
|
197
|
+
- **`--python` engine (v0.3.4, `send --tag` only)**: hand the whole send to the
|
|
198
|
+
**python `/meta-broadcast` endpoint** — the *same* sender the dashboard's "Python
|
|
199
|
+
endpoint" toggle uses — via a staff-gated proxy (the master key stays server-side
|
|
200
|
+
in `VITE_PYTHON_RENDER_API_KEY`; the CLI never holds it). Python resolves the tag
|
|
201
|
+
+ eligibility (`allow_broadcast=true`, not blocked; **no 2000-cap, paginated**) and
|
|
202
|
+
sends **fire-and-forget**, returning a `broadcastId`. Dry-run (no `--commit`) asks
|
|
203
|
+
python for the eligible count + a sample. Trade-off vs the default per-row engine:
|
|
204
|
+
no CLI write-ahead-log / `resume` (python owns the broadcast record), and archived
|
|
205
|
+
contacts aren't separately filtered. Media headers work on both engines.
|
|
206
|
+
- **v1 scope**: Meta orgs, POSITIONAL templates, text / no header / media header.
|
|
207
|
+
NAMED and carousel templates and WATI orgs are refused with a clear message.
|
|
193
208
|
- **Validation before anything sends**: APPROVED-only, every slot mapped,
|
|
194
209
|
contiguous params (the Meta `#132000` guard — a stray key is structurally
|
|
195
210
|
impossible), phone validity, illegal characters (Meta `#100`; `reject` by
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -91,6 +91,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
91
91
|
| Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
|
|
92
92
|
| Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
|
|
93
93
|
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
|
|
94
|
+
| Send a broadcast whose template has an IMAGE header | Same as above — the image is automatic (the template's own header image). Override with `--header-media <public-image-url>` if needed. |
|
|
95
|
+
| 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) |
|
|
94
96
|
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
95
97
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
|
96
98
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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": {
|
|
@@ -335,12 +335,17 @@ async function introspect(orgId, templateName) {
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
/** Validation V-1..V-13. Returns {valid, skipped, aborts}. */
|
|
338
|
-
function validateRows(template, mapping, headers, rows, illegalChars) {
|
|
338
|
+
function validateRows(template, mapping, headers, rows, illegalChars, headerMedia) {
|
|
339
339
|
const aborts = [];
|
|
340
340
|
if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
|
|
341
341
|
if (template.parameter_format !== "POSITIONAL") aborts.push("V-2: NAMED templates are not supported in v1");
|
|
342
342
|
if (template.is_carousel) aborts.push("V-3: carousel templates are not supported in v1");
|
|
343
|
-
|
|
343
|
+
// Media headers (image/video/document) ARE supported — they just need an image
|
|
344
|
+
// URL, exactly like the dashboard. Resolved as --header-media / saved mapping /
|
|
345
|
+
// the template's stored default_url. Only block if a media header has none.
|
|
346
|
+
if (!["text", "none"].includes(template.header_type) && !headerMedia) {
|
|
347
|
+
aborts.push(`V-3: template has a ${template.header_type} header but no image resolved — pass --header-media <url> (the template has no stored default header image)`);
|
|
348
|
+
}
|
|
344
349
|
for (const pos of template.body_positions) {
|
|
345
350
|
if (!mapping.body_params?.[`param${pos}`]?.source) aborts.push(`V-4: body param${pos} is unmapped`);
|
|
346
351
|
}
|
|
@@ -428,12 +433,13 @@ async function applySafetyExclusions(orgId, valid, skipped) {
|
|
|
428
433
|
return keep;
|
|
429
434
|
}
|
|
430
435
|
|
|
431
|
-
function renderPreview(template, sample) {
|
|
436
|
+
function renderPreview(template, sample, headerMedia) {
|
|
432
437
|
let bodyText = template.body_text;
|
|
433
438
|
for (const [k, v] of Object.entries(sample.values)) {
|
|
434
439
|
bodyText = bodyText.replaceAll(`{{${k.replace("param", "")}}}`, v);
|
|
435
440
|
}
|
|
436
441
|
console.log(`Row ${sample.rownum} → ${sample.number} [VALID]${sample.sanitized ? " (sanitized)" : ""}`);
|
|
442
|
+
if (headerMedia) console.log(` Header (${template.header_type}): ${headerMedia}`);
|
|
437
443
|
console.log(` Body:\n ${bodyText.replace(/\n/g, "\n ")}`);
|
|
438
444
|
if (template.url_button?.present && sample.buttonValue != null) {
|
|
439
445
|
const url = template.url_button.url_base.replace(/\{\{[^}]+\}\}/, sample.buttonValue);
|
|
@@ -472,7 +478,7 @@ function classifySendError(errText) {
|
|
|
472
478
|
return "row_fatal";
|
|
473
479
|
}
|
|
474
480
|
|
|
475
|
-
async function sendOne(apiUrl, orgId, templateName, row) {
|
|
481
|
+
async function sendOne(apiUrl, orgId, templateName, row, headerMedia) {
|
|
476
482
|
// #132000 structural guard: whitelist param\d+ keys only.
|
|
477
483
|
const bodyParameters = {};
|
|
478
484
|
for (const [k, v] of Object.entries(row.values)) {
|
|
@@ -484,6 +490,9 @@ async function sendOne(apiUrl, orgId, templateName, row) {
|
|
|
484
490
|
whatsappNumber: row.number,
|
|
485
491
|
bodyParameters,
|
|
486
492
|
...(row.buttonValue != null ? { buttonParameters: { param1: row.buttonValue } } : {}),
|
|
493
|
+
// Static media header (same field the dashboard passes for image/video/doc
|
|
494
|
+
// headers) — /api/send-template renders it onto the HEADER component.
|
|
495
|
+
...(headerMedia ? { headerMedia } : {}),
|
|
487
496
|
};
|
|
488
497
|
// Org-UUID branch: NO auth header (a staff key here would 401 on the fiq_ branch).
|
|
489
498
|
const resp = await fetch(`${apiUrl.replace(/\/$/, "")}/send-template?organizationId=${orgId}`, {
|
|
@@ -517,7 +526,14 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
517
526
|
try { intro = await introspect(orgId, templateName); }
|
|
518
527
|
catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
519
528
|
const template = intro.template;
|
|
520
|
-
|
|
529
|
+
// Media-header templates send a static image (frontend behaviour): prefer an
|
|
530
|
+
// explicit --header-media, then a previously-saved one, then the template's
|
|
531
|
+
// stored default_url. Non-media headers → null.
|
|
532
|
+
const isMediaHeader = !["text", "none"].includes(template.header_type);
|
|
533
|
+
const headerMedia = isMediaHeader
|
|
534
|
+
? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
|
|
535
|
+
: null;
|
|
536
|
+
console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${headerMedia ? " (image ✓)" : ""}${template.url_button?.present ? ", dynamic URL button" : ""}`);
|
|
521
537
|
|
|
522
538
|
// 4. CSV
|
|
523
539
|
let csv;
|
|
@@ -550,6 +566,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
550
566
|
recipient: built.recipient,
|
|
551
567
|
body_params: built.body_params,
|
|
552
568
|
button_params: built.button_params,
|
|
569
|
+
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
553
570
|
options: { illegal_chars: opts.illegalChars || "reject" },
|
|
554
571
|
};
|
|
555
572
|
await saveCampaign(cfg);
|
|
@@ -558,7 +575,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
558
575
|
}
|
|
559
576
|
|
|
560
577
|
// 6. validate
|
|
561
|
-
const { aborts, valid, skipped } = validateRows(template, mapping, csv.headers, csv.rows, opts.illegalChars || mapping.options?.illegal_chars || "reject");
|
|
578
|
+
const { aborts, valid, skipped } = validateRows(template, mapping, csv.headers, csv.rows, opts.illegalChars || mapping.options?.illegal_chars || "reject", headerMedia);
|
|
562
579
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
563
580
|
|
|
564
581
|
// 6b. broadcast-safety exclusions (live)
|
|
@@ -590,7 +607,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
590
607
|
console.log("");
|
|
591
608
|
const nPreview = Math.max(1, Number(opts.rows ?? 3));
|
|
592
609
|
const samples = [sendable[0], ...sendable.slice(1).filter((r, i, a) => a.findIndex((x) => x.values.param1 === r.values.param1) === i)].filter(Boolean).slice(0, nPreview);
|
|
593
|
-
for (const s of samples) { renderPreview(template, s); console.log(""); }
|
|
610
|
+
for (const s of samples) { renderPreview(template, s, headerMedia); console.log(""); }
|
|
594
611
|
console.log(`Summary: ${sendable.length} sendable · skipped ${skipped.length} (${Object.entries(skippedByReason).map(([k, v]) => `${k}: ${v}`).join(", ") || "none"})`);
|
|
595
612
|
if (statusMap.size) console.log(`Status log: ${[...statusMap.values()].filter((s) => s.status === "sent").length} already sent · work set ${workSet.length}`);
|
|
596
613
|
if (ambiguous.length) console.log(`⚠ ${ambiguous.length} number(s) have an AMBIGUOUS in-flight status (crash mid-send?) — never auto-resent: ${ambiguous.slice(0, 5).join(", ")}${ambiguous.length > 5 ? "…" : ""}. Check the chat, then edit the status log if they must be retried.`);
|
|
@@ -620,7 +637,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
620
637
|
|
|
621
638
|
// 10-11. send loop + report (shared with the --tag pipeline)
|
|
622
639
|
await executeSendLoop({
|
|
623
|
-
orgId, campaign, templateName, workSet, statusMap, rate,
|
|
640
|
+
orgId, campaign, templateName, workSet, statusMap, rate, headerMedia,
|
|
624
641
|
skippedCount: skipped.length,
|
|
625
642
|
writeMetaHeader: !logHeader,
|
|
626
643
|
metaExtras: { csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length },
|
|
@@ -628,7 +645,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
628
645
|
}
|
|
629
646
|
|
|
630
647
|
/** The paced, write-ahead-logged per-row send loop + final report. */
|
|
631
|
-
async function executeSendLoop({ orgId, campaign, templateName, workSet, statusMap, rate, skippedCount, writeMetaHeader, metaExtras }) {
|
|
648
|
+
async function executeSendLoop({ orgId, campaign, templateName, workSet, statusMap, rate, skippedCount, writeMetaHeader, metaExtras, headerMedia }) {
|
|
632
649
|
const { api_url } = await loadConfig();
|
|
633
650
|
if (writeMetaHeader) {
|
|
634
651
|
await appendLog(campaign, {
|
|
@@ -648,7 +665,7 @@ async function executeSendLoop({ orgId, campaign, templateName, workSet, statusM
|
|
|
648
665
|
// WRITE-AHEAD: sending line BEFORE the POST.
|
|
649
666
|
await appendLog(campaign, { type: "row", number: row.number, rownum: row.rownum, status: "sending", attempt, at: new Date().toISOString() });
|
|
650
667
|
let out;
|
|
651
|
-
try { out = await sendOne(api_url, orgId, templateName, row); }
|
|
668
|
+
try { out = await sendOne(api_url, orgId, templateName, row, headerMedia); }
|
|
652
669
|
catch (e) {
|
|
653
670
|
// network/timeout with no response = UNKNOWN outcome → ambiguous, never auto-retried
|
|
654
671
|
await appendLog(campaign, { type: "row", number: row.number, rownum: row.rownum, status: "unknown", error: e.message, attempt, at: new Date().toISOString() });
|
|
@@ -714,6 +731,54 @@ export function collectKV(pair, mapAcc) {
|
|
|
714
731
|
return mapAcc;
|
|
715
732
|
}
|
|
716
733
|
|
|
734
|
+
/** --python engine: hand the send to the python /meta-broadcast endpoint via the
|
|
735
|
+
* staff-gated proxy. Fire-and-forget — python resolves the tag + eligibility
|
|
736
|
+
* (allow_broadcast=true, not blocked) and sends in the background, returning a
|
|
737
|
+
* broadcastId. No CLI write-ahead log / resume for this engine (python owns the
|
|
738
|
+
* broadcast record). Dry-run (no --commit) asks python for the eligible count. */
|
|
739
|
+
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
|
|
740
|
+
const reqBody = (dryRun) => ({
|
|
741
|
+
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
742
|
+
body_parameters: bodyLiterals,
|
|
743
|
+
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
744
|
+
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
745
|
+
dry_run: dryRun,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// Dry-run: python fetches the eligible contacts (no send) → count + sample.
|
|
749
|
+
let dry;
|
|
750
|
+
try { dry = await http.post("broadcast", reqBody(true)); }
|
|
751
|
+
catch (e) { console.error(`Python dry-run failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
752
|
+
const total = dry.total_contacts_found ?? 0;
|
|
753
|
+
|
|
754
|
+
console.log("");
|
|
755
|
+
console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
|
|
756
|
+
console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
|
|
757
|
+
const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
|
|
758
|
+
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
759
|
+
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
760
|
+
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
|
|
761
|
+
}
|
|
762
|
+
console.log("");
|
|
763
|
+
|
|
764
|
+
if (!commitStage) {
|
|
765
|
+
console.log("DRY RUN — nothing sent. Add --commit to fire the python broadcast.");
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (!total) { console.log("Nothing eligible under this tag."); return; }
|
|
769
|
+
if (!opts.yes) {
|
|
770
|
+
const answer = await ask(`Type the tag ("${tag}") to fire the python broadcast to ${total} contact(s): `);
|
|
771
|
+
if (answer !== tag) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let out;
|
|
775
|
+
try { out = await http.post("broadcast", reqBody(false)); }
|
|
776
|
+
catch (e) { console.error(`Python broadcast failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
777
|
+
console.log("");
|
|
778
|
+
console.log(`✅ ${out.message || "Broadcast started"}${out.broadcastId ? ` · broadcastId ${out.broadcastId}` : ""}`);
|
|
779
|
+
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.`);
|
|
780
|
+
}
|
|
781
|
+
|
|
717
782
|
async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
718
783
|
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
719
784
|
const campaign = opts.campaign || slugify(opts.tag, "tag-campaign");
|
|
@@ -730,18 +795,30 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
730
795
|
try { intro = await introspect(orgId, templateName); }
|
|
731
796
|
catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
732
797
|
const template = intro.template;
|
|
733
|
-
|
|
798
|
+
const isMediaHeader = !["text", "none"].includes(template.header_type);
|
|
799
|
+
const headerMedia = isMediaHeader
|
|
800
|
+
? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
|
|
801
|
+
: null;
|
|
802
|
+
console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${headerMedia ? " (image ✓)" : ""}${template.url_button?.present ? ", dynamic URL button" : ""}`);
|
|
734
803
|
const aborts = [];
|
|
735
804
|
if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
|
|
736
805
|
if (template.parameter_format !== "POSITIONAL") aborts.push("NAMED templates are not supported in v1");
|
|
737
806
|
if (template.is_carousel) aborts.push("carousel templates are not supported in v1");
|
|
738
|
-
|
|
807
|
+
// Media headers supported — need an image (--header-media / saved / default_url).
|
|
808
|
+
if (isMediaHeader && !headerMedia) aborts.push(`template has a ${template.header_type} header but no image resolved — pass --header-media <url> (the template has no stored default header image)`);
|
|
739
809
|
const keys = Object.keys(bodyLiterals);
|
|
740
810
|
if (keys.some((k) => !/^param\d+$/.test(k))) aborts.push(`--body keys must be param1..N (got ${keys.join(", ")})`);
|
|
741
811
|
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.`);
|
|
742
812
|
if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
|
|
743
813
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
744
814
|
|
|
815
|
+
// --python: hand the whole send to the python /meta-broadcast engine (the same
|
|
816
|
+
// sender the dashboard's "Python endpoint" toggle uses) via the staff-gated
|
|
817
|
+
// proxy. Python resolves the tag + eligibility + sends fire-and-forget.
|
|
818
|
+
if (opts.python) {
|
|
819
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
820
|
+
}
|
|
821
|
+
|
|
745
822
|
// resolve the tag server-side (broadcast-safe recipients only)
|
|
746
823
|
let resolved;
|
|
747
824
|
try { resolved = await http.post("broadcast", { action: "resolve-tag", organization_id: orgId, tag }); }
|
|
@@ -769,7 +846,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
769
846
|
});
|
|
770
847
|
|
|
771
848
|
console.log("");
|
|
772
|
-
renderPreview(template, sendable[0]);
|
|
849
|
+
renderPreview(template, sendable[0], headerMedia);
|
|
773
850
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
774
851
|
console.log(" ({{first_name}}-style tokens above are resolved PER CONTACT server-side)");
|
|
775
852
|
}
|
|
@@ -799,11 +876,12 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
799
876
|
organization_id: orgId, organization_slug: slugify(resolved.organization_name, orgId.slice(0, 8)),
|
|
800
877
|
template_name: templateName, tag,
|
|
801
878
|
body_params_literal: bodyLiterals, button_param_literal: buttonLiteral,
|
|
879
|
+
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
802
880
|
created_at: cfg?.created_at ?? new Date().toISOString(),
|
|
803
881
|
});
|
|
804
882
|
|
|
805
883
|
await executeSendLoop({
|
|
806
|
-
orgId, campaign, templateName, workSet, statusMap, rate,
|
|
884
|
+
orgId, campaign, templateName, workSet, statusMap, rate, headerMedia,
|
|
807
885
|
skippedCount: resolved.excluded.length,
|
|
808
886
|
writeMetaHeader: !logHeader,
|
|
809
887
|
metaExtras: { tag, tagged_total: resolved.tagged_total, valid_rows: sendable.length },
|
|
@@ -829,6 +907,12 @@ export async function map(orgId, opts = {}) {
|
|
|
829
907
|
return;
|
|
830
908
|
}
|
|
831
909
|
const built = await interactiveMapping(intro.template, csv.headers, campaign);
|
|
910
|
+
const t = intro.template;
|
|
911
|
+
const headerMedia = !["text", "none"].includes(t.header_type)
|
|
912
|
+
? (opts.headerMedia || t.header_media_default || null) : null;
|
|
913
|
+
if (!["text", "none"].includes(t.header_type)) {
|
|
914
|
+
console.log(headerMedia ? `Header image (${t.header_type}): ${headerMedia}` : `⚠ ${t.header_type} header but no image — pass --header-media <url> on send.`);
|
|
915
|
+
}
|
|
832
916
|
await saveCampaign({
|
|
833
917
|
schema_version: 1, campaign, organization_id: orgId,
|
|
834
918
|
organization_slug: slugify(intro.organization_name, orgId.slice(0, 8)),
|
|
@@ -836,6 +920,7 @@ export async function map(orgId, opts = {}) {
|
|
|
836
920
|
created_at: new Date().toISOString(),
|
|
837
921
|
csv: { path: opts.csv, delimiter: csv.delimiter, headers: csv.headers, header_fingerprint: csv.header_fingerprint },
|
|
838
922
|
recipient: built.recipient, body_params: built.body_params, button_params: built.button_params,
|
|
923
|
+
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
839
924
|
options: { illegal_chars: "reject" },
|
|
840
925
|
});
|
|
841
926
|
console.log(`Saved mapping: ${cfgPath(campaign)}`);
|
|
@@ -847,6 +932,7 @@ export async function preview(orgId, opts = {}) {
|
|
|
847
932
|
|
|
848
933
|
export async function send(orgId, opts = {}) {
|
|
849
934
|
if (opts.tag && opts.csv) { console.error("Error: --tag and --csv are mutually exclusive."); process.exit(1); }
|
|
935
|
+
if (opts.python && !opts.tag) { console.error("Error: --python requires --tag (the python engine is tag-based)."); process.exit(1); }
|
|
850
936
|
if (opts.tag) return runTagPipeline(orgId, opts, { commitStage: !!opts.commit });
|
|
851
937
|
await runPipeline(orgId, opts, { commitStage: !!opts.commit, isResume: !!opts.resume });
|
|
852
938
|
}
|
package/src/index.js
CHANGED
|
@@ -371,6 +371,7 @@ export function run(argv) {
|
|
|
371
371
|
.requiredOption("--template <name>", "approved Meta template name")
|
|
372
372
|
.requiredOption("--csv <file>", "path to the recipients CSV")
|
|
373
373
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename)")
|
|
374
|
+
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
374
375
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
375
376
|
.action((orgId, opts) => broadcastCmd.map(orgId, opts));
|
|
376
377
|
broadcast.command("preview <organization_id>")
|
|
@@ -378,6 +379,7 @@ export function run(argv) {
|
|
|
378
379
|
.option("--template <name>", "template (default: from the saved campaign)")
|
|
379
380
|
.option("--csv <file>", "CSV (default: from the saved campaign)")
|
|
380
381
|
.option("--campaign <name>", "campaign id / config file slug")
|
|
382
|
+
.option("--header-media <url>", "override the media-header image URL (default: the template's stored image)")
|
|
381
383
|
.option("--rows <n>", "how many sample rows to render", "3")
|
|
382
384
|
.action((orgId, opts) => broadcastCmd.preview(orgId, opts));
|
|
383
385
|
broadcast.command("send <organization_id>")
|
|
@@ -388,6 +390,8 @@ export function run(argv) {
|
|
|
388
390
|
.option("--body <k=v>", "with --tag: body param (repeatable), e.g. --body param1=\"Hi {{first_name}}\"", broadcastCmd.collectKV, {})
|
|
389
391
|
.option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
|
|
390
392
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
393
|
+
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
394
|
+
.option("--python", "with --tag: send via the python /meta-broadcast engine (same as the dashboard's Python toggle; fire-and-forget, python-tracked)")
|
|
391
395
|
.option("--commit", "actually send (omit to dry-run)")
|
|
392
396
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
393
397
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|