@flowapt/flowiq-cli 0.3.5 → 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 +20 -4
- package/TEAM-GUIDE.md +4 -2
- package/package.json +1 -1
- package/src/commands/broadcast.js +59 -6
- package/src/index.js +8 -1
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,19 @@ 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). `
|
|
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.
|
|
210
|
+
- **Engine auto-routing (v0.3.6):** any `send --tag` of **more than 10** eligible
|
|
211
|
+
recipients **always uses the python engine** (a `>2000` tag routes there too).
|
|
212
|
+
Only a ≤10 send stays on the resumable per-row Node engine. `--python` forces
|
|
213
|
+
python at any size. (CSV per-row sends stay on Node — python takes uniform params.)
|
|
202
214
|
- **`--python` engine (v0.3.4, `send --tag` only)**: hand the whole send to the
|
|
203
215
|
**python `/meta-broadcast` endpoint** — the *same* sender the dashboard's "Python
|
|
204
216
|
endpoint" toggle uses — via a staff-gated proxy (the master key stays server-side
|
|
@@ -238,8 +250,12 @@ flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
|
|
|
238
250
|
```
|
|
239
251
|
|
|
240
252
|
Values are shared across the tag (use `{{first_name}}` etc. for per-contact
|
|
241
|
-
personalization — resolved server-side).
|
|
242
|
-
|
|
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
|
|
243
259
|
are refused — slice them with `flowiq segments` first.
|
|
244
260
|
|
|
245
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
|
-
| 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) |
|
|
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.
|
|
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": {
|
|
@@ -812,17 +812,29 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
812
812
|
if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
|
|
813
813
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
814
814
|
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
815
|
+
// ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
|
|
816
|
+
// python /meta-broadcast engine (the proven bulk sender). --python forces it at
|
|
817
|
+
// any size; only a ≤10 send stays on the resumable per-row Node engine.
|
|
818
|
+
const PYTHON_MIN = 10;
|
|
818
819
|
if (opts.python) {
|
|
819
820
|
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
820
821
|
}
|
|
821
822
|
|
|
822
|
-
// resolve the tag server-side (broadcast-safe recipients only)
|
|
823
|
+
// resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
|
|
824
|
+
// 413s the Node resolver — that's by definition >10, so route straight to python.
|
|
823
825
|
let resolved;
|
|
824
826
|
try { resolved = await http.post("broadcast", { action: "resolve-tag", organization_id: orgId, tag }); }
|
|
825
|
-
catch (e) {
|
|
827
|
+
catch (e) {
|
|
828
|
+
if (e.status === 413) {
|
|
829
|
+
console.log(`Tag "${tag}" has >2000 contacts → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
|
|
830
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
831
|
+
}
|
|
832
|
+
console.error(`Tag resolution failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1);
|
|
833
|
+
}
|
|
834
|
+
if (resolved.recipients.length > PYTHON_MIN) {
|
|
835
|
+
console.log(`Tag "${tag}": ${resolved.recipients.length} sendable (>${PYTHON_MIN}) → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
|
|
836
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
837
|
+
}
|
|
826
838
|
console.log(`Tag "${tag}" on ${resolved.organization_name}: ${resolved.tagged_total} contact(s), ${resolved.recipients.length} sendable, ${resolved.excluded.length} excluded (opt-out/archived/blocked).`);
|
|
827
839
|
if (!resolved.recipients.length) { console.log("Nothing sendable under this tag."); return; }
|
|
828
840
|
|
|
@@ -950,7 +962,7 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
950
962
|
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
951
963
|
if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId a --python send printed)."); process.exit(1); }
|
|
952
964
|
let resp;
|
|
953
|
-
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 }); }
|
|
954
966
|
catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
955
967
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
956
968
|
const b = resp.broadcast, d = resp.delivery;
|
|
@@ -964,6 +976,47 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
964
976
|
if (b.total_recipients) {
|
|
965
977
|
console.log(` ${((reached / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients reached${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
|
|
966
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`);
|
|
967
1020
|
}
|
|
968
1021
|
|
|
969
1022
|
export async function list() {
|
package/src/index.js
CHANGED
|
@@ -391,7 +391,7 @@ export function run(argv) {
|
|
|
391
391
|
.option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
|
|
392
392
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
393
393
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
394
|
-
.option("--python", "with --tag:
|
|
394
|
+
.option("--python", "with --tag: force the python /meta-broadcast engine (same as the dashboard's Python toggle). NOTE: any tag send >10 recipients ALWAYS uses python automatically")
|
|
395
395
|
.option("--commit", "actually send (omit to dry-run)")
|
|
396
396
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
397
397
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
@@ -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());
|