@flowapt/flowiq-cli 0.4.0 → 0.4.1

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
@@ -214,12 +214,22 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
214
214
  semantic labels + body text and you confirm each slot once; the mapping is
215
215
  saved to `.flowiq/campaigns/<campaign>.json` and reused.
216
216
  - **Media-header templates (image/video/document) ARE supported** (v0.3.3) —
217
- exactly like the dashboard. The header image defaults to the template's own
218
- stored image (`template_data.header.default_url`); override with
217
+ exactly like the dashboard. The header media defaults to the template's own
218
+ stored URL (`template_data.header.default_url`); override with
219
219
  `--header-media <public-url>` on `map`/`preview`/`send`. Passed to
220
220
  `/api/send-template` as `headerMedia`, the same field the dashboard uses. A
221
- media-header template with no resolvable image is refused (pass
221
+ media-header template with no resolvable media is refused (pass
222
222
  `--header-media`).
223
+ - **Header media is VERIFIED, not assumed (v0.4.1).** The CLI probes the
224
+ resolved URL's `content-type` and compares it to the template's header
225
+ format: a match prints e.g. `header video (video ✓ video/mp4)`; a
226
+ **mismatch ABORTS** (e.g. a VIDEO template whose resolved media serves
227
+ `image/png` — every recipient would get a broken header). Templates
228
+ created before the 3 Aug 2026 `create-meta-template` mime fix can carry a
229
+ broken Meta-side default (a truncated png stored for a video template);
230
+ the abort message says so — fix by passing `--header-media <real video>`.
231
+ `map` refuses to save a mismatched default into the campaign file. An
232
+ unreachable URL only warns (`media unverified`), never blocks.
223
233
  - **`list-remote <org>` (v0.3.9)**: list the org's broadcasts **newest-first** with
224
234
  the **full broadcastId** per row + template, status, recipient count and SAST
225
235
  created time — the discovery step `status` / `retry` need (previously the id
@@ -687,6 +697,16 @@ Media headers: pass `media_header.file_url` (a public URL) — the edge function
687
697
  uploads it to Meta server-side. Approval is async; re-`pull` for the
688
698
  authoritative Meta status.
689
699
 
700
+ **Media mime is detected from the file's actual bytes (3 Aug 2026).**
701
+ `media_header.file_type` is optional and can never override what the file
702
+ really is; a file that contradicts the HEADER format is **refused** (e.g. an
703
+ mp4 on a `format: "IMAGE"` header, or a png on `"VIDEO"`). Before this fix the
704
+ upload defaulted to `image/png` regardless of the file, so a VIDEO template
705
+ still APPROVED but Meta stored a truncated 1MB "png" as its default header
706
+ media — a silently broken template (`bc` inherited the dud as the default send
707
+ media). Video headers submitted through the CLI before 3 Aug 2026 should be
708
+ re-checked: `bc` now flags them at send time.
709
+
690
710
  `template_data` is FlowIQ's own send-time mapping (slot labels, default header
691
711
  image) stored alongside the template. Omit it and a default is synthesized
692
712
  server-side (`auto_synthesized: true`; the response says
package/TEAM-GUIDE.md CHANGED
@@ -93,7 +93,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
93
93
  | 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/…`) |
94
94
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
95
95
  | 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>}}`) |
96
- | 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. |
96
+ | 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. |
97
97
  | 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.** |
98
98
  | 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) |
99
99
  | 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.4.0",
3
+ "version": "0.4.1",
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,6 +335,51 @@ async function introspect(orgId, templateName) {
335
335
  }
336
336
 
337
337
  /** Validation V-1..V-13. Returns {valid, skipped, aborts}. */
338
+ // ---------------------------------------------------------------------------
339
+ // header-media verification
340
+ // ---------------------------------------------------------------------------
341
+ // A resolved header-media URL is NOT confirmation it is the right KIND of
342
+ // media. The template's stored default can be broken — most notably the
343
+ // pre-3-Aug-2026 create-meta-template mime bug, which left VIDEO templates
344
+ // with a truncated image/png as their Meta-side default. Probe the URL's
345
+ // content-type and compare it to the template's header format before trusting
346
+ // it: match → "(video ✓ video/mp4)"; mismatch → hard abort (a send would
347
+ // deliver a broken header to every recipient); unreachable → warn only.
348
+
349
+ async function probeContentType(url) {
350
+ const attempt = async (method, headers) => {
351
+ const ctrl = new AbortController();
352
+ const t = setTimeout(() => ctrl.abort(), 8000);
353
+ try {
354
+ const r = await fetch(url, { method, headers, redirect: "follow", signal: ctrl.signal });
355
+ if (!r.ok && r.status !== 206) return null;
356
+ const ct = (r.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
357
+ if (method === "GET") { try { await r.body?.cancel(); } catch { /* stream already closed */ } }
358
+ return ct || null;
359
+ } catch { return null; } finally { clearTimeout(t); }
360
+ };
361
+ // HEAD first; some hosts refuse it, so fall back to a 1-byte ranged GET.
362
+ return (await attempt("HEAD")) ?? (await attempt("GET", { Range: "bytes=0-0" }));
363
+ }
364
+
365
+ async function verifyHeaderMedia(template, headerMedia) {
366
+ if (!headerMedia) return { label: "", status: null };
367
+ const contentType = await probeContentType(headerMedia);
368
+ if (!contentType) return { label: " (media unverified — URL did not answer a type probe)", status: "unknown" };
369
+ const family = contentType.split("/")[0];
370
+ const want = template.header_type === "image" ? "image" : template.header_type === "video" ? "video" : null;
371
+ const ok = want ? family === want : (family !== "image" && family !== "video"); // document: any non-image/video
372
+ if (ok) return { label: ` (${template.header_type} ✓ ${contentType})`, status: "ok", contentType };
373
+ return { label: ` (⚠ media serves ${contentType}, not ${template.header_type})`, status: "mismatch", contentType };
374
+ }
375
+
376
+ function headerMismatchMessage(template, headerMedia, contentType, { explicit }) {
377
+ const base = `template header is ${template.header_type.toUpperCase()} but the resolved media (${headerMedia}) serves ${contentType}`;
378
+ return explicit
379
+ ? `${base} — point --header-media at the real ${template.header_type}`
380
+ : `${base} — the template's stored default header media is broken (created before the 3 Aug 2026 create-meta-template mime fix?); pass --header-media <url> with the real ${template.header_type}`;
381
+ }
382
+
338
383
  function validateRows(template, mapping, headers, rows, illegalChars, headerMedia) {
339
384
  const aborts = [];
340
385
  if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
@@ -533,7 +578,12 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
533
578
  const headerMedia = isMediaHeader
534
579
  ? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
535
580
  : 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" : ""}`);
581
+ const headerCheck = await verifyHeaderMedia(template, headerMedia);
582
+ 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" : ""}`);
583
+ if (headerCheck.status === "mismatch") {
584
+ console.error(`ABORT — ${headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia })}`);
585
+ process.exit(1);
586
+ }
537
587
 
538
588
  // 4. CSV
539
589
  let csv;
@@ -799,8 +849,10 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
799
849
  const headerMedia = isMediaHeader
800
850
  ? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
801
851
  : 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" : ""}`);
852
+ const headerCheck = await verifyHeaderMedia(template, headerMedia);
853
+ 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" : ""}`);
803
854
  const aborts = [];
855
+ if (headerCheck.status === "mismatch") aborts.push(headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia }));
804
856
  if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
805
857
  if (template.parameter_format !== "POSITIONAL") aborts.push("NAMED templates are not supported in v1");
806
858
  if (template.is_carousel) aborts.push("carousel templates are not supported in v1");
@@ -920,10 +972,22 @@ export async function map(orgId, opts = {}) {
920
972
  }
921
973
  const built = await interactiveMapping(intro.template, csv.headers, campaign);
922
974
  const t = intro.template;
923
- const headerMedia = !["text", "none"].includes(t.header_type)
975
+ let headerMedia = !["text", "none"].includes(t.header_type)
924
976
  ? (opts.headerMedia || t.header_media_default || null) : null;
925
977
  if (!["text", "none"].includes(t.header_type)) {
926
- console.log(headerMedia ? `Header image (${t.header_type}): ${headerMedia}` : `⚠ ${t.header_type} header but no image — pass --header-media <url> on send.`);
978
+ const check = await verifyHeaderMedia(t, headerMedia);
979
+ if (check.status === "mismatch") {
980
+ if (opts.headerMedia) {
981
+ console.error(`ABORT — ${headerMismatchMessage(t, headerMedia, check.contentType, { explicit: true })}`);
982
+ process.exit(1);
983
+ }
984
+ // The template's stored default is broken — do NOT copy it into the
985
+ // campaign file (that is how a dud default poisons every later send).
986
+ console.log(`⚠ ${headerMismatchMessage(t, headerMedia, check.contentType, { explicit: false })}. NOT saved to the campaign.`);
987
+ headerMedia = null;
988
+ } else {
989
+ console.log(headerMedia ? `Header media (${t.header_type}): ${headerMedia}${check.label}` : `⚠ ${t.header_type} header but no media — pass --header-media <url> on send.`);
990
+ }
927
991
  }
928
992
  await saveCampaign({
929
993
  schema_version: 1, campaign, organization_id: orgId,