@flowapt/flowiq-cli 0.3.6 → 0.3.7

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
@@ -160,7 +160,7 @@ flowiq ct list
160
160
  - **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime — known: `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `supabase_anon_key`, `openai_api_key`, …).
161
161
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
162
162
 
163
- ### Broadcast — `flowiq broadcast map|preview|send|resume|status|list` (alias `bc`)
163
+ ### Broadcast — `flowiq broadcast map|preview|send|resume|status|retry|list` (alias `bc`)
164
164
 
165
165
  Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
166
166
  template's variables **per row** from the CSV's own columns. The
@@ -198,7 +198,15 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
198
198
  by its id — `read` / `delivered` / `sent` / `failed` (+ % reached) from
199
199
  `helpdesk_messages`. Built for the `--python` fire-and-forget engine (which
200
200
  returns a `broadcastId` but has no CLI status log), but works for any broadcast
201
- id (dashboard sends included). `flowiq bc status <org> <broadcastId>`.
201
+ id (dashboard sends included). **`--failures` (v0.3.7)** additionally lists each
202
+ failed recipient + the Meta error reason (`error_code` / `error_title`) with a
203
+ by-reason rollup — makes a fire-and-forget send fully auditable.
204
+ - **`retry <org> <broadcastId>` (v0.3.7)**: re-send a broadcast to **only its
205
+ failed recipients** — reconstructs the send from the `broadcasts` row (template +
206
+ params + header) and re-fires via the python engine, creating a NEW broadcast.
207
+ DRY-RUN by default (shows the failed count + reason breakdown); `--commit` fires.
208
+ Note: permanent failures (e.g. `131026` undeliverable, opt-outs) just fail again —
209
+ retry earns its keep on transient (throttle/throughput) failures. Capped at 500.
202
210
  - **Engine auto-routing (v0.3.6):** any `send --tag` of **more than 10** eligible
203
211
  recipients **always uses the python engine** (a `>2000` tag routes there too).
204
212
  Only a ≤10 send stays on the resumable per-row Node engine. `--python` forces
@@ -242,8 +250,12 @@ flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
242
250
  ```
243
251
 
244
252
  Values are shared across the tag (use `{{first_name}}` etc. for per-contact
245
- personalization — resolved server-side). Opted-out / archived / blocked
246
- contacts under the tag are excluded automatically. Tags with >2000 contacts
253
+ personalization — resolved server-side). Supported per-contact tokens:
254
+ `{{first_name}}` `{{full_name}}` `{{email}}` `{{phone_number}}`
255
+ `{{whatsapp_id}}` `{{contact_id}}`, plus `{{attributes.<key>}}` for any
256
+ top-level key of the contact's `attributes` JSON (missing / object-valued
257
+ keys render as empty — both engines, since 21 Jul 2026). Opted-out /
258
+ archived / blocked contacts under the tag are excluded automatically. Tags with >2000 contacts
247
259
  are refused — slice them with `flowiq segments` first.
248
260
 
249
261
  ### Segments — `flowiq segments plan|apply|list|untag` (alias `seg`)
package/TEAM-GUIDE.md CHANGED
@@ -90,10 +90,12 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
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
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
- | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
93
+ | 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>}}`) |
94
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
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). **Any tag send over 10 recipients uses python automatically.** |
96
96
  | Check how a broadcast is landing (read/delivered/sent/failed) | `flowiq bc status <org_id> <broadcastId>` (the `broadcastId` a `--python` send prints) |
97
+ | See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
98
+ | Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
97
99
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
98
100
  | Export an org's full chat history | `flowiq export chats <org_id>` |
99
101
  | 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.6",
3
+ "version": "0.3.7",
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": {
@@ -962,7 +962,7 @@ export async function status(orgId, broadcastId, opts = {}) {
962
962
  if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
963
963
  if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId a --python send printed)."); process.exit(1); }
964
964
  let resp;
965
- try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId }); }
965
+ try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId, include_failures: !!opts.failures }); }
966
966
  catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
967
967
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
968
968
  const b = resp.broadcast, d = resp.delivery;
@@ -976,6 +976,47 @@ export async function status(orgId, broadcastId, opts = {}) {
976
976
  if (b.total_recipients) {
977
977
  console.log(` ${((reached / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients reached${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
978
978
  }
979
+ if (opts.failures && resp.failures) {
980
+ console.log("");
981
+ console.log(` Failures by reason:`);
982
+ for (const [reason, n] of Object.entries(resp.failure_reasons || {}).sort((a, b2) => b2[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
983
+ const cap = opts.limit ? Number(opts.limit) : 40;
984
+ console.log("");
985
+ console.log(` Failed recipients${resp.failures.length > cap ? ` (showing ${cap} of ${resp.failures.length}; --json for all)` : ` (${resp.failures.length})`}:`);
986
+ for (const f of resp.failures.slice(0, cap)) {
987
+ console.log(` ${(f.number || "—").padEnd(14)} ${(f.name || "").slice(0, 22).padEnd(22)} ${f.error_code ?? "?"} ${f.error_title || ""}`);
988
+ }
989
+ }
990
+ }
991
+
992
+ /** Re-send a broadcast to ONLY its failed recipients (transient-failure recovery).
993
+ * Reconstructs the send from the broadcasts row + re-fires via python. The dry-run
994
+ * shows the failed count + reason breakdown; --commit creates a NEW broadcast. */
995
+ export async function retry(orgId, broadcastId, opts = {}) {
996
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
997
+ if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId to retry)."); process.exit(1); }
998
+ let dry;
999
+ try { dry = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: true }); }
1000
+ catch (e) { console.error(`Retry check failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1001
+ console.log(`Broadcast ${broadcastId} (${dry.broadcast?.template_name ?? "?"}) — ${dry.failed_count} failed recipient(s).`);
1002
+ for (const [reason, n] of Object.entries(dry.failure_reasons || {}).sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
1003
+ if (!dry.failed_count) { console.log("Nothing to retry."); return; }
1004
+ console.log("");
1005
+ console.log("Note: permanent failures (131026 undeliverable / opt-outs) just fail again — retry helps transient (throttle/throughput) failures.");
1006
+ if (!opts.commit) {
1007
+ console.log(`DRY RUN — would re-send to ${dry.failed_count} failed recipient(s) via the python engine. Add --commit to retry.`);
1008
+ return;
1009
+ }
1010
+ if (!opts.yes) {
1011
+ const a = await ask(`Type "retry" to re-send to ${dry.failed_count} failed recipient(s): `);
1012
+ if (a !== "retry") { console.log("Aborted — nothing re-sent."); process.exit(0); }
1013
+ }
1014
+ let out;
1015
+ try { out = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: false }); }
1016
+ catch (e) { console.error(`Retry failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1017
+ console.log("");
1018
+ console.log(`✅ ${out.message || `Retry fired for ${out.retried} recipient(s)`}${out.broadcastId ? ` · new broadcastId ${out.broadcastId}` : ""}`);
1019
+ if (out.broadcastId) console.log(` Watch it: flowiq bc status ${orgId} ${out.broadcastId} --failures`);
979
1020
  }
980
1021
 
981
1022
  export async function list() {
package/src/index.js CHANGED
@@ -413,8 +413,15 @@ export function run(argv) {
413
413
  .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
414
414
  broadcast.command("status <organization_id> <broadcast_id>")
415
415
  .description("Live delivery counts (read/delivered/sent/failed) for a broadcast by its broadcastId — e.g. from a --python fire-and-forget send")
416
+ .option("--failures", "also list each FAILED recipient + the Meta error reason (makes a python send auditable)")
417
+ .option("--limit <n>", "with --failures: how many failed rows to print", "40")
416
418
  .option("--json", "raw JSON")
417
419
  .action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
420
+ broadcast.command("retry <organization_id> <broadcast_id>")
421
+ .description("Re-send a broadcast to ONLY its failed recipients (transient-failure recovery). DRY-RUN by default; --commit re-fires via python")
422
+ .option("--commit", "actually re-send (omit to see the failed count + reasons)")
423
+ .option("--yes", "skip the type-'retry' confirm gate")
424
+ .action((orgId, broadcastId, opts) => broadcastCmd.retry(orgId, broadcastId, opts));
418
425
  broadcast.command("list")
419
426
  .description("List local campaigns + sent counts")
420
427
  .action(() => broadcastCmd.list());