@flowapt/flowiq-cli 0.3.9 → 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 +62 -14
- package/TEAM-GUIDE.md +3 -2
- package/package.json +1 -1
- package/src/commands/broadcast.js +81 -8
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
|
|
@@ -227,13 +237,28 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
|
|
|
227
237
|
`--template <substr>` (case-insensitive contains), `--since <date>`, `--json`.
|
|
228
238
|
Read-only. NOTE: plain `bc list` (no org) still lists your **local campaign
|
|
229
239
|
files** — the remote verb is named after `pinboard list-remote`.
|
|
230
|
-
- **`status <org> <broadcastId>` (v0.3.5)**: live delivery
|
|
231
|
-
by its id — `
|
|
232
|
-
`helpdesk_messages`. Built for the `--python`
|
|
233
|
-
returns a `broadcastId` but has no CLI status
|
|
234
|
-
id (dashboard sends included).
|
|
235
|
-
failed recipient + the Meta
|
|
236
|
-
by-reason rollup — makes a
|
|
240
|
+
- **`status <org> <broadcastId>` (v0.3.5)**: live delivery **funnel** for a
|
|
241
|
+
broadcast by its id — `accepted` → `delivered` → `read`, plus `pending` and
|
|
242
|
+
`failed`, with percentages, from `helpdesk_messages`. Built for the `--python`
|
|
243
|
+
fire-and-forget engine (which returns a `broadcastId` but has no CLI status
|
|
244
|
+
log), but works for any broadcast id (dashboard sends included).
|
|
245
|
+
**`--failures` (v0.3.7)** additionally lists each failed recipient + the Meta
|
|
246
|
+
error reason (`error_code` / `error_title`) with a by-reason rollup — makes a
|
|
247
|
+
fire-and-forget send fully auditable.
|
|
248
|
+
- **Delivery counting fixed 3 Aug 2026.** It previously counted
|
|
249
|
+
`message_status='delivered'`, a value present on **6 rows in the whole
|
|
250
|
+
table**, so *delivered always displayed 0* (the reached TOTAL was right; the
|
|
251
|
+
breakdown was not). Delivery now comes from the boolean receipt columns
|
|
252
|
+
(`delivered_receipt_received` / `read_receipt_received`) that the webhooks
|
|
253
|
+
flip — the same signal as the inbox ticks. **Booleans, not the
|
|
254
|
+
`delivered_at`/`read_at` timestamps**: those were added recently and are only
|
|
255
|
+
partially backfilled (2025: 323,325 delivered by boolean, **0** by
|
|
256
|
+
timestamp), and no row ever carries a timestamp without the boolean.
|
|
257
|
+
- The stages are **cumulative, not disjoint** (`accepted ⊇ delivered ⊇ read`) —
|
|
258
|
+
don't add them up. `delivered` counts read-without-a-delivered-receipt too
|
|
259
|
+
(~65k such rows exist: WhatsApp can skip straight to the read receipt).
|
|
260
|
+
- **`read` is a FLOOR, never exact** — recipients can disable read receipts in
|
|
261
|
+
WhatsApp. The JSON carries a `read_caveat` string saying so.
|
|
237
262
|
- **`retry <org> <broadcastId>` (v0.3.7)**: re-send a broadcast to **only its
|
|
238
263
|
failed recipients** — reconstructs the send from the `broadcasts` row (template +
|
|
239
264
|
params + header) and re-fires via the python engine, creating a NEW broadcast.
|
|
@@ -423,9 +448,10 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
423
448
|
collapses to 1 at runtime — rejected).
|
|
424
449
|
- `field:"attributes"` actions are warned (full jsonb replace; constant
|
|
425
450
|
values only) but applied — this CLI is their only safe editing surface.
|
|
426
|
-
- **Action types accepted** (all
|
|
451
|
+
- **Action types accepted** (all nine the runtime implements):
|
|
427
452
|
`send_message` · `update_contact_field` · `add_contact_tag` ·
|
|
428
|
-
`remove_contact_tag` · `set_agent` · `delay` · `combined
|
|
453
|
+
`remove_contact_tag` · `set_agent` · `delay` · `combined` ·
|
|
454
|
+
`update_ticket_status` · `renotify_ticket`.
|
|
429
455
|
Per-type rules: tag actions need a non-empty `tags[]` (or a single `tag`
|
|
430
456
|
string) or the runtime writes no tag at all; `set_agent.agent_id` must be
|
|
431
457
|
an agent UUID, or `null`/`""` to CLEAR the contact's binding (warned, since
|
|
@@ -436,6 +462,18 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
436
462
|
*(Before v0.3.9 only the first two + `combined` were accepted, so a
|
|
437
463
|
zero-edit pull→push failed for any org using tag / set_agent / delay
|
|
438
464
|
actions.)*
|
|
465
|
+
- **Ticket actions** (added 30 Jul 2026, for escalation follow-up buttons):
|
|
466
|
+
`update_ticket_status` changes the CONTACT's open/in_progress tickets —
|
|
467
|
+
`{ "type": "update_ticket_status", "status": "resolved", "scope": "all_open" }`.
|
|
468
|
+
`status` ∈ `open`/`in_progress`/`resolved`/`closed` (default `resolved`);
|
|
469
|
+
`scope` ∈ `all_open` (default) / `latest_open`. Appends the same
|
|
470
|
+
`data.status_history[]` audit entries as the agent's `manage_tickets` tool
|
|
471
|
+
(`by:"keyword"`). Canonical use: the follow-up template's **"Query solved"**
|
|
472
|
+
button resolving the escalation ticket. `renotify_ticket` re-fires the
|
|
473
|
+
human-needed team notifications for the contact's latest ticket via
|
|
474
|
+
ticket-tool `mode:"renotify"` (60s server-side rate limit; a resolved/closed
|
|
475
|
+
ticket is reopened first) — canonical use: the **"I still need help"**
|
|
476
|
+
button. Both send the action's `text` (if any) after the ticket work.
|
|
439
477
|
- `action_config.link_preview: false` disables WhatsApp's link-preview card
|
|
440
478
|
on that action's text send (absent/`true` = preview on, the default).
|
|
441
479
|
Passed through verbatim; also toggleable per action in the dashboard.
|
|
@@ -445,7 +483,7 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
445
483
|
```json
|
|
446
484
|
{ "type": "send_message", "when": { "field": "email", "op": "is_not_empty" } }
|
|
447
485
|
```
|
|
448
|
-
`field` ∈ `email` / `phone_number` / `full_name`
|
|
486
|
+
`field` ∈ `email` / `phone_number` / `full_name` (allowlist; `city` was removed 03 Aug 2026 — contacts has no such column, so a city condition could never evaluate).
|
|
449
487
|
`op` ∈ `is_empty` / `is_not_empty` / `equals` / `contains` — the last two
|
|
450
488
|
need a `value` and are case-insensitive. Rejected on push otherwise
|
|
451
489
|
(V-21/V-22), because the runtime SKIPS an action it can't evaluate.
|
|
@@ -659,6 +697,16 @@ Media headers: pass `media_header.file_url` (a public URL) — the edge function
|
|
|
659
697
|
uploads it to Meta server-side. Approval is async; re-`pull` for the
|
|
660
698
|
authoritative Meta status.
|
|
661
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
|
+
|
|
662
710
|
`template_data` is FlowIQ's own send-time mapping (slot labels, default header
|
|
663
711
|
image) stored alongside the template. Omit it and a default is synthesized
|
|
664
712
|
server-side (`auto_synthesized: true`; the response says
|
|
@@ -739,7 +787,7 @@ flowiq agent config <organization_id> --test-contact-number 27000000001 --test-c
|
|
|
739
787
|
Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
|
|
740
788
|
the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
|
|
741
789
|
`view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
|
|
742
|
-
`shopify_products_web_chat`), `discount.enabled`, and the `flowiq test` contact
|
|
790
|
+
`shopify_products_web_chat`, `ticket_tool_status`), `discount.enabled`, and the `flowiq test` contact
|
|
743
791
|
(`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
|
|
744
792
|
rejected; every change is reported before → after.
|
|
745
793
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -79,6 +79,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
79
79
|
| Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
|
|
80
80
|
| Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
|
|
81
81
|
| Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
82
|
+
| Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
82
83
|
| See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
|
|
83
84
|
| Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
|
|
84
85
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
@@ -92,10 +93,10 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
92
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/…`) |
|
|
93
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` |
|
|
94
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>}}`) |
|
|
95
|
-
| 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. |
|
|
96
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.** |
|
|
97
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) |
|
|
98
|
-
| Check how a broadcast is landing (read/
|
|
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`) |
|
|
99
100
|
| See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
|
|
100
101
|
| Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
|
|
101
102
|
| **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "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
|
-
|
|
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,
|
|
@@ -998,15 +1062,24 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
998
1062
|
catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
999
1063
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
1000
1064
|
const b = resp.broadcast, d = resp.delivery;
|
|
1001
|
-
|
|
1065
|
+
// Cumulative funnel (v0.4.0+). Falls back to the old disjoint fields when
|
|
1066
|
+
// talking to a server that predates the 3 Aug 2026 fix.
|
|
1067
|
+
const accepted = d.accepted ?? (d.read + d.delivered + d.sent);
|
|
1068
|
+
const delivered = d.delivered_total ?? d.delivered;
|
|
1069
|
+
const read = d.read_total ?? d.read;
|
|
1070
|
+
const pending = d.pending ?? d.other ?? 0;
|
|
1071
|
+
const pctOf = (n) => (accepted > 0 ? `${((n / accepted) * 100).toFixed(1)}%` : "–");
|
|
1002
1072
|
console.log(`Broadcast ${b.id} — ${resp.organization_name}`);
|
|
1003
1073
|
console.log(` template: ${b.template_name}${b.broadcast_name ? ` (${b.broadcast_name})` : ""}${b.media_url ? " · media header" : ""}`);
|
|
1004
1074
|
console.log(` status: ${b.status ?? "?"} · created ${b.created_at}`);
|
|
1005
1075
|
console.log(` recipients: ${b.total_recipients ?? "?"} · ${d.linked} message(s) linked`);
|
|
1006
|
-
console.log(` ✅
|
|
1007
|
-
console.log(`
|
|
1076
|
+
console.log(` ✅ accepted ${accepted}`);
|
|
1077
|
+
console.log(` 📬 delivered ${delivered} (${pctOf(delivered)} of accepted)`);
|
|
1078
|
+
console.log(` 👀 read ${read} (${pctOf(read)}) — floor only, recipients can disable read receipts`);
|
|
1079
|
+
console.log(` ⏳ pending ${pending} (accepted, no delivery receipt yet)`);
|
|
1080
|
+
console.log(` ❌ failed ${d.failed}${d.failed_pct != null ? ` (${d.failed_pct}% of linked)` : ""}`);
|
|
1008
1081
|
if (b.total_recipients) {
|
|
1009
|
-
console.log(` ${((
|
|
1082
|
+
console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
|
|
1010
1083
|
}
|
|
1011
1084
|
if (opts.failures && resp.failures) {
|
|
1012
1085
|
console.log("");
|