@flowapt/flowiq-cli 0.4.0 → 0.4.2
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 +32 -3
- package/TEAM-GUIDE.md +13 -1
- package/package.json +1 -1
- package/src/commands/broadcast.js +68 -4
- package/src/commands/tag.js +1 -0
- package/src/index.js +2 -0
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
|
|
218
|
-
stored
|
|
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
|
|
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
|
|
@@ -395,6 +405,9 @@ flowiq tag segment <org_id> --min-orders 2 --min-spent 1000 --region ZA-GP --tag
|
|
|
395
405
|
|
|
396
406
|
# Attributes — broadcast permission (all_contacts / allow_broadcast_true|false / no_broadcast_permission)
|
|
397
407
|
flowiq tag attributes <org_id> --filter allow_broadcast_true --tag broadcastable --commit
|
|
408
|
+
# Big org (cohort > ~20k)? Chunk it: repeat this until it reports "matched 0" —
|
|
409
|
+
# each run tags the next 10k untagged contacts (see the bullet below on the 50k cap / DB timeout)
|
|
410
|
+
flowiq tag attributes <org_id> --filter allow_broadcast_true --limit 10000 --exclude broadcastable --tag broadcastable --commit --yes
|
|
398
411
|
|
|
399
412
|
# Message activity — ≥ N messages of a sender type, optional date range
|
|
400
413
|
flowiq tag messages <org_id> --min-count 3 --sender user-whatsapp --tag engaged --commit
|
|
@@ -412,6 +425,12 @@ flowiq tag remove <org_id> vip,old-promo --confirm # remove tag(s) from ALL con
|
|
|
412
425
|
- **`cohort` is top-N, not a threshold** — `top_spenders --limit 500` tags the
|
|
413
426
|
top 500 by spend, not "everyone above £X". Use `tag segment --min-spent` for
|
|
414
427
|
a threshold.
|
|
428
|
+
- **Big orgs: chunk `attributes` commits with `--limit` + `--exclude <your-tag>`**
|
|
429
|
+
(flags added 3 Aug 2026, in-repo pending publish). The tagging RPC hard-caps at 50,000
|
|
430
|
+
per call AND a single large commit updates row-by-row, so a ~50k commit dies
|
|
431
|
+
on the DB statement timeout (full rollback, nothing written). `--limit 10000
|
|
432
|
+
--exclude <the-tag-you're-adding>` makes each run pick the next 10k untagged
|
|
433
|
+
matches — re-run until `matched 0`. Idempotent and safe to re-run.
|
|
415
434
|
- **Undo** any tag with `flowiq tag remove <org> <tag> --confirm`.
|
|
416
435
|
|
|
417
436
|
### Keywords — `flowiq keywords pull|push|list` (alias `kw`)
|
|
@@ -687,6 +706,16 @@ Media headers: pass `media_header.file_url` (a public URL) — the edge function
|
|
|
687
706
|
uploads it to Meta server-side. Approval is async; re-`pull` for the
|
|
688
707
|
authoritative Meta status.
|
|
689
708
|
|
|
709
|
+
**Media mime is detected from the file's actual bytes (3 Aug 2026).**
|
|
710
|
+
`media_header.file_type` is optional and can never override what the file
|
|
711
|
+
really is; a file that contradicts the HEADER format is **refused** (e.g. an
|
|
712
|
+
mp4 on a `format: "IMAGE"` header, or a png on `"VIDEO"`). Before this fix the
|
|
713
|
+
upload defaulted to `image/png` regardless of the file, so a VIDEO template
|
|
714
|
+
still APPROVED but Meta stored a truncated 1MB "png" as its default header
|
|
715
|
+
media — a silently broken template (`bc` inherited the dud as the default send
|
|
716
|
+
media). Video headers submitted through the CLI before 3 Aug 2026 should be
|
|
717
|
+
re-checked: `bc` now flags them at send time.
|
|
718
|
+
|
|
690
719
|
`template_data` is FlowIQ's own send-time mapping (slot labels, default header
|
|
691
720
|
image) stored alongside the template. Omit it and a default is synthesized
|
|
692
721
|
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
|
|
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`) |
|
|
@@ -179,6 +179,18 @@ real order line-items (accurate + windowable) rather than the order-history text
|
|
|
179
179
|
And a commit over 2000 contacts asks you to type the tag name to confirm
|
|
180
180
|
(add `--yes` to skip). Everything is appends-only and reversible with `tag remove`.
|
|
181
181
|
|
|
182
|
+
**Tagging a very large audience (20k+, e.g. "everyone opted in") with `attributes`?**
|
|
183
|
+
One giant commit will fail on a database timeout (nothing writes). Chunk it instead:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
# repeat this exact line until it prints "matched 0" — each run tags the NEXT 10k
|
|
187
|
+
flowiq tag attributes <org_id> --filter allow_broadcast_true \
|
|
188
|
+
--limit 10000 --exclude my-campaign-tag --tag my-campaign-tag --commit --yes
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`--exclude` names the same tag you're adding, so every run skips the people who
|
|
192
|
+
already have it. Safe to re-run as many times as you like.
|
|
193
|
+
|
|
182
194
|
## Keys, rotation, logging out
|
|
183
195
|
|
|
184
196
|
- **`flowiq auth refresh`** — rotates this device's key in place (a fresh key
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/commands/tag.js
CHANGED
|
@@ -132,6 +132,7 @@ export async function attributes(orgId, opts = {}) {
|
|
|
132
132
|
const payload = {
|
|
133
133
|
mode: "attributes", organization_id: orgId, filter_type: opts.filter || "all_contacts",
|
|
134
134
|
include_tags: splitList(opts.include), exclude_tags: splitList(opts.exclude),
|
|
135
|
+
limit: opts.limit ? Number(opts.limit) : undefined, offset: opts.offset ? Number(opts.offset) : 0,
|
|
135
136
|
};
|
|
136
137
|
await confirmLargeWrite(orgId, payload, opts, `Attributes ${payload.filter_type}`);
|
|
137
138
|
await runMatch(`Attributes ${payload.filter_type}`, payload, opts);
|
package/src/index.js
CHANGED
|
@@ -550,6 +550,8 @@ export function run(argv) {
|
|
|
550
550
|
.option("--filter <f>", "attribute filter", "all_contacts")
|
|
551
551
|
.option("--include <csv>", "only contacts carrying any of these tags")
|
|
552
552
|
.option("--exclude <csv>", "exclude contacts carrying any of these tags")
|
|
553
|
+
.option("--limit <n>", "max contacts per run (default 50000 — the RPC cap; a 50k commit can hit the DB statement timeout, so chunk big orgs: --limit 10000 --exclude <tag>, re-run until 0 match)")
|
|
554
|
+
.option("--offset <n>", "skip the first N", "0")
|
|
553
555
|
.option("--tag <name>", "tag to apply (required with --commit)")
|
|
554
556
|
.option("--commit", "write the tag (omit = dry-run count + sample)")
|
|
555
557
|
.option("--yes", "skip the type-the-tag confirm on large (>2000) writes")
|