@flowapt/flowiq-cli 0.3.1 → 0.3.3

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
@@ -187,9 +187,15 @@ 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
- - **v1 scope**: Meta orgs, POSITIONAL templates, text/no header. NAMED,
191
- carousel, media-header templates and WATI orgs are refused with a clear
192
- message.
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
+ - **v1 scope**: Meta orgs, POSITIONAL templates, text / no header / media header.
198
+ NAMED and carousel templates and WATI orgs are refused with a clear message.
193
199
  - **Validation before anything sends**: APPROVED-only, every slot mapped,
194
200
  contiguous params (the Meta `#132000` guard — a stray key is structurally
195
201
  impossible), phone validity, illegal characters (Meta `#100`; `reject` by
@@ -238,6 +244,14 @@ flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_tru
238
244
  # as `tag attributes --filter`). --split N (2..100) replaces --batch-size;
239
245
  # --seed makes the shuffle reproducible, --ordered keeps server order (no shuffle).
240
246
 
247
+ # Cohort from EXISTING TAGS → batched (v0.3.2 — combine tags, then split, no id file):
248
+ flowiq seg plan <org_id> --tag-prefix 18-july-clearance-bc \
249
+ --from-tag "9-jul-loyalty-list" --exclude "recent-campaign,opt-out" --batch-size 1000
250
+ # contacts carrying ANY of the --from-tag tags, MINUS anyone carrying any --exclude
251
+ # tag, minus archived/blocked/opted-out → sliced into 1000-sized batch tags
252
+ # (18-july-clearance-bc-batch-01, -02, …). --from-tag pairs with --split too.
253
+ # This is the "existing tags → batched send list" flow, entirely in the CLI.
254
+
241
255
  # Cohort by ORDER-COUNT CRITERIA (v0.2.6 — server-resolved, no id file needed):
242
256
  flowiq seg plan <org_id> --tag-prefix repeat-60d --min-orders 2 --window 60d
243
257
  # "everyone with 2+ orders in the last 60 days" — counted from the captured
@@ -262,11 +276,13 @@ flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own ta
262
276
 
263
277
  - **Cohort sources:** `--from-attribute <filter>` (a broadcast-permission
264
278
  audience — `all_contacts` / `allow_broadcast_true` / `allow_broadcast_false`
265
- / `no_broadcast_permission`, resolved server-side past the 1000-row cap);
266
- `--min-orders [--window]` (the windowed order count the app's Advanced Tagging
267
- UI cannot express — its Min Orders filter is lifetime-only); `--bought`
268
- (product line-items); or explicit **contact UUIDs** (a snapshot JSON's
269
- `contact_ids[]` or a plain file). Exactly one per plan.
279
+ / `no_broadcast_permission`, resolved server-side in one RPC call);
280
+ **`--from-tag "a,b" [--exclude "c,d"]`** (contacts carrying ANY of the include
281
+ tags minus anyone carrying an exclude tag — the "existing tags → batched send
282
+ list" source, v0.3.2); `--min-orders [--window]` (the windowed order count the
283
+ app's Advanced Tagging UI cannot express — its Min Orders filter is
284
+ lifetime-only); `--bought` (product line-items); or explicit **contact UUIDs**
285
+ (a snapshot JSON's `contact_ids[]` or a plain file). Exactly one per plan.
270
286
  - **`--split N` = exactly N even cohorts** (2..100), an alternative to
271
287
  `--batch-size` fixed chunks. It **shuffles by default** so the cohorts are
272
288
  balanced (not skewed by contact age / signup order); `--seed S` makes the
package/TEAM-GUIDE.md CHANGED
@@ -88,8 +88,10 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
88
88
  | Advanced Tagging in the terminal (one named tag on a matched set) | `flowiq tag field\|cohort\|segment\|attributes\|messages <org_id> …` (dry-run) → add `--tag <name> --commit` |
89
89
  | List / remove tags | `flowiq tag list <org_id>` · `flowiq tag remove <org_id> <tag> --confirm` |
90
90
  | Split your whole broadcast list into N even cohorts (e.g. 3 for A/B/C or waves) | `flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3` → `flowiq seg apply <org_id> bcast --commit` (makes `bcast-batch-01/02/03`, ~even, shuffled) |
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/…`) |
91
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` |
92
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. |
93
95
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
94
96
  | Export an org's full chat history | `flowiq export chats <org_id>` |
95
97
  | 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.1",
3
+ "version": "0.3.3",
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
- if (!["text", "none"].includes(template.header_type)) aborts.push(`V-3: media-header templates (${template.header_type}) are not supported in v1`);
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
- console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${template.url_button?.present ? ", dynamic URL button" : ""}`);
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() });
@@ -730,12 +747,17 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
730
747
  try { intro = await introspect(orgId, templateName); }
731
748
  catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
732
749
  const template = intro.template;
733
- console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${template.url_button?.present ? ", dynamic URL button" : ""}`);
750
+ const isMediaHeader = !["text", "none"].includes(template.header_type);
751
+ const headerMedia = isMediaHeader
752
+ ? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
753
+ : null;
754
+ 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
755
  const aborts = [];
735
756
  if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
736
757
  if (template.parameter_format !== "POSITIONAL") aborts.push("NAMED templates are not supported in v1");
737
758
  if (template.is_carousel) aborts.push("carousel templates are not supported in v1");
738
- if (!["text", "none"].includes(template.header_type)) aborts.push(`media-header templates (${template.header_type}) are not supported in v1`);
759
+ // Media headers supported — need an image (--header-media / saved / default_url).
760
+ 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
761
  const keys = Object.keys(bodyLiterals);
740
762
  if (keys.some((k) => !/^param\d+$/.test(k))) aborts.push(`--body keys must be param1..N (got ${keys.join(", ")})`);
741
763
  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.`);
@@ -769,7 +791,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
769
791
  });
770
792
 
771
793
  console.log("");
772
- renderPreview(template, sendable[0]);
794
+ renderPreview(template, sendable[0], headerMedia);
773
795
  if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
774
796
  console.log(" ({{first_name}}-style tokens above are resolved PER CONTACT server-side)");
775
797
  }
@@ -799,11 +821,12 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
799
821
  organization_id: orgId, organization_slug: slugify(resolved.organization_name, orgId.slice(0, 8)),
800
822
  template_name: templateName, tag,
801
823
  body_params_literal: bodyLiterals, button_param_literal: buttonLiteral,
824
+ ...(headerMedia ? { header_media: headerMedia } : {}),
802
825
  created_at: cfg?.created_at ?? new Date().toISOString(),
803
826
  });
804
827
 
805
828
  await executeSendLoop({
806
- orgId, campaign, templateName, workSet, statusMap, rate,
829
+ orgId, campaign, templateName, workSet, statusMap, rate, headerMedia,
807
830
  skippedCount: resolved.excluded.length,
808
831
  writeMetaHeader: !logHeader,
809
832
  metaExtras: { tag, tagged_total: resolved.tagged_total, valid_rows: sendable.length },
@@ -829,6 +852,12 @@ export async function map(orgId, opts = {}) {
829
852
  return;
830
853
  }
831
854
  const built = await interactiveMapping(intro.template, csv.headers, campaign);
855
+ const t = intro.template;
856
+ const headerMedia = !["text", "none"].includes(t.header_type)
857
+ ? (opts.headerMedia || t.header_media_default || null) : null;
858
+ if (!["text", "none"].includes(t.header_type)) {
859
+ console.log(headerMedia ? `Header image (${t.header_type}): ${headerMedia}` : `⚠ ${t.header_type} header but no image — pass --header-media <url> on send.`);
860
+ }
832
861
  await saveCampaign({
833
862
  schema_version: 1, campaign, organization_id: orgId,
834
863
  organization_slug: slugify(intro.organization_name, orgId.slice(0, 8)),
@@ -836,6 +865,7 @@ export async function map(orgId, opts = {}) {
836
865
  created_at: new Date().toISOString(),
837
866
  csv: { path: opts.csv, delimiter: csv.delimiter, headers: csv.headers, header_fingerprint: csv.header_fingerprint },
838
867
  recipient: built.recipient, body_params: built.body_params, button_params: built.button_params,
868
+ ...(headerMedia ? { header_media: headerMedia } : {}),
839
869
  options: { illegal_chars: "reject" },
840
870
  });
841
871
  console.log(`Saved mapping: ${cfgPath(campaign)}`);
@@ -3,8 +3,9 @@
3
3
  // runbook). This command TAGS ONLY — it never sends. The send per tag is
4
4
  // `flowiq broadcast send --tag <batch-tag>` (or the dashboard broadcaster).
5
5
  //
6
- // plan <org> --tag-prefix X (--from-segment file | --ids-file file
7
- // | --min-orders N [--window 60d]) # exclusions → slice → plan file (no DB write)
6
+ // plan <org> --tag-prefix X (--from-attribute F | --from-tag "a,b" [--exclude "c,d"]
7
+ // | --min-orders N [--window 60d] | --bought "P"
8
+ // | --from-segment file | --ids-file file) # exclusions → slice → plan file (no DB write)
8
9
  // apply <org> <slug> [--commit] [--yes] # append the batch tags (dry-run default)
9
10
  // list <org> [--prefix X] # VERIFY: tag → contact count
10
11
  // untag <org> <slug> --commit --confirm # ROLLBACK: remove the plan's own tags
@@ -132,18 +133,20 @@ export async function plan(orgId, opts = {}) {
132
133
  if (opts.seed !== undefined && !Number.isFinite(Number(opts.seed))) { console.error("Error: --seed must be a number."); process.exit(1); }
133
134
  }
134
135
 
135
- // Cohort source: a server-resolved criterion (--from-attribute / --min-orders
136
- // / --bought) OR an explicit id list (--from-segment / --ids-file). Exactly one.
136
+ // Cohort source: a server-resolved criterion (--from-attribute / --from-tag /
137
+ // --min-orders / --bought) OR an explicit id list (--from-segment / --ids-file).
138
+ // Exactly one.
137
139
  const orderMode = opts.minOrders !== undefined;
138
140
  const productMode = opts.bought !== undefined;
139
141
  const attributeMode = opts.fromAttribute !== undefined;
140
- const criteriaMode = orderMode || productMode || attributeMode;
141
- const sourceCount = [orderMode, productMode, attributeMode, !!opts.fromSegment, !!opts.idsFile].filter(Boolean).length;
142
+ const tagMode = opts.fromTag !== undefined;
143
+ const criteriaMode = orderMode || productMode || attributeMode || tagMode;
144
+ const sourceCount = [orderMode, productMode, attributeMode, tagMode, !!opts.fromSegment, !!opts.idsFile].filter(Boolean).length;
142
145
  if (sourceCount === 0) {
143
- console.error("Error: a cohort source is required — one of --from-attribute / --min-orders / --bought / --from-segment / --ids-file."); process.exit(1);
146
+ console.error("Error: a cohort source is required — one of --from-attribute / --from-tag / --min-orders / --bought / --from-segment / --ids-file."); process.exit(1);
144
147
  }
145
148
  if (sourceCount > 1) {
146
- console.error("Error: pick exactly ONE cohort source (--from-attribute / --min-orders / --bought / --from-segment / --ids-file)."); process.exit(1);
149
+ console.error("Error: pick exactly ONE cohort source (--from-attribute / --from-tag / --min-orders / --bought / --from-segment / --ids-file)."); process.exit(1);
147
150
  }
148
151
 
149
152
  let cohort;
@@ -157,6 +160,12 @@ export async function plan(orgId, opts = {}) {
157
160
  }
158
161
  cohortSpec = { attribute };
159
162
  cohort = { ids: [], badUuids: [], sourceMeta: { type: "attribute_cohort", attribute } };
163
+ } else if (tagMode) {
164
+ const includeTags = String(opts.fromTag).split(",").map((s) => s.trim()).filter(Boolean);
165
+ if (!includeTags.length) { console.error("Error: --from-tag needs at least one tag (comma-separate several)."); process.exit(1); }
166
+ const excludeTags = opts.exclude ? String(opts.exclude).split(",").map((s) => s.trim()).filter(Boolean) : [];
167
+ cohortSpec = { include_tags: includeTags, exclude_tags: excludeTags };
168
+ cohort = { ids: [], badUuids: [], sourceMeta: { type: "tag_cohort", include_tags: includeTags, exclude_tags: excludeTags } };
160
169
  } else {
161
170
  let windowDays = null;
162
171
  if (opts.window) {
@@ -209,6 +218,10 @@ export async function plan(orgId, opts = {}) {
209
218
  no_broadcast_permission: "contacts with no broadcast permission set",
210
219
  }[resp.cohort.attribute] || resp.cohort.attribute;
211
220
  console.log(`Cohort: ${label} → ${resp.cohort.resolved_count} contact(s) (server-resolved).`);
221
+ } else if (resp.cohort.kind === "tag") {
222
+ const inc = resp.cohort.include_tags.join(", ");
223
+ const exc = resp.cohort.exclude_tags && resp.cohort.exclude_tags.length ? `, excluding [${resp.cohort.exclude_tags.join(", ")}]` : "";
224
+ console.log(`Cohort: contacts tagged any of [${inc}]${exc} → ${resp.cohort.resolved_count} contact(s) (server-resolved).`);
212
225
  } else {
213
226
  const w = resp.cohort.window_days ? `in the last ${resp.cohort.window_days} days` : "across all captured history";
214
227
  if (resp.cohort.kind === "product") {
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,7 @@ 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)")
391
394
  .option("--commit", "actually send (omit to dry-run)")
392
395
  .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
393
396
  .option("--force-remap", "ignore the saved mapping and rebuild interactively")
@@ -421,6 +424,8 @@ export function run(argv) {
421
424
  .option("--from-segment <path>", "a segments snapshot JSON (uses its contact_ids[])")
422
425
  .option("--ids-file <path>", "a plain newline/CSV file of contact UUIDs")
423
426
  .option("--from-attribute <filter>", "SERVER-RESOLVED cohort: every contact matching a broadcast-permission filter (all_contacts | allow_broadcast_true | allow_broadcast_false | no_broadcast_permission)")
427
+ .option("--from-tag <csv>", "SERVER-RESOLVED cohort: contacts carrying ANY of these tag(s) (comma-separated)")
428
+ .option("--exclude <csv>", "with --from-tag: drop contacts carrying ANY of these tag(s)")
424
429
  .option("--min-orders <n>", "SERVER-RESOLVED cohort: contacts with ≥ n captured orders (instead of an id file)")
425
430
  .option("--bought <terms>", "SERVER-RESOLVED cohort: contacts who bought these product(s) (comma-separated name substrings) from real order line-items")
426
431
  .option("--match <mode>", "with --bought: any (bought any listed product) | all (bought every one)", "any")