@flowapt/flowiq-cli 0.3.3 → 0.3.5
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 +15 -1
- package/TEAM-GUIDE.md +2 -0
- package/package.json +1 -1
- package/src/commands/broadcast.js +79 -0
- package/src/index.js +5 -0
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|list` (alias `bc`)
|
|
163
|
+
### Broadcast — `flowiq broadcast map|preview|send|resume|status|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
|
|
@@ -194,6 +194,20 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
|
|
|
194
194
|
`/api/send-template` as `headerMedia`, the same field the dashboard uses. A
|
|
195
195
|
media-header template with no resolvable image is refused (pass
|
|
196
196
|
`--header-media`).
|
|
197
|
+
- **`status <org> <broadcastId>` (v0.3.5)**: live delivery counts for a broadcast
|
|
198
|
+
by its id — `read` / `delivered` / `sent` / `failed` (+ % reached) from
|
|
199
|
+
`helpdesk_messages`. Built for the `--python` fire-and-forget engine (which
|
|
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>`.
|
|
202
|
+
- **`--python` engine (v0.3.4, `send --tag` only)**: hand the whole send to the
|
|
203
|
+
**python `/meta-broadcast` endpoint** — the *same* sender the dashboard's "Python
|
|
204
|
+
endpoint" toggle uses — via a staff-gated proxy (the master key stays server-side
|
|
205
|
+
in `VITE_PYTHON_RENDER_API_KEY`; the CLI never holds it). Python resolves the tag
|
|
206
|
+
+ eligibility (`allow_broadcast=true`, not blocked; **no 2000-cap, paginated**) and
|
|
207
|
+
sends **fire-and-forget**, returning a `broadcastId`. Dry-run (no `--commit`) asks
|
|
208
|
+
python for the eligible count + a sample. Trade-off vs the default per-row engine:
|
|
209
|
+
no CLI write-ahead-log / `resume` (python owns the broadcast record), and archived
|
|
210
|
+
contacts aren't separately filtered. Media headers work on both engines.
|
|
197
211
|
- **v1 scope**: Meta orgs, POSITIONAL templates, text / no header / media header.
|
|
198
212
|
NAMED and carousel templates and WATI orgs are refused with a clear message.
|
|
199
213
|
- **Validation before anything sends**: APPROVED-only, every slot mapped,
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -92,6 +92,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
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
93
|
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
|
|
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) |
|
|
96
|
+
| Check how a broadcast is landing (read/delivered/sent/failed) | `flowiq bc status <org_id> <broadcastId>` (the `broadcastId` a `--python` send prints) |
|
|
95
97
|
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
96
98
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
|
97
99
|
| 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.5",
|
|
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": {
|
|
@@ -731,6 +731,54 @@ export function collectKV(pair, mapAcc) {
|
|
|
731
731
|
return mapAcc;
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
+
/** --python engine: hand the send to the python /meta-broadcast endpoint via the
|
|
735
|
+
* staff-gated proxy. Fire-and-forget — python resolves the tag + eligibility
|
|
736
|
+
* (allow_broadcast=true, not blocked) and sends in the background, returning a
|
|
737
|
+
* broadcastId. No CLI write-ahead log / resume for this engine (python owns the
|
|
738
|
+
* broadcast record). Dry-run (no --commit) asks python for the eligible count. */
|
|
739
|
+
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
|
|
740
|
+
const reqBody = (dryRun) => ({
|
|
741
|
+
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
742
|
+
body_parameters: bodyLiterals,
|
|
743
|
+
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
744
|
+
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
745
|
+
dry_run: dryRun,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// Dry-run: python fetches the eligible contacts (no send) → count + sample.
|
|
749
|
+
let dry;
|
|
750
|
+
try { dry = await http.post("broadcast", reqBody(true)); }
|
|
751
|
+
catch (e) { console.error(`Python dry-run failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
752
|
+
const total = dry.total_contacts_found ?? 0;
|
|
753
|
+
|
|
754
|
+
console.log("");
|
|
755
|
+
console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
|
|
756
|
+
console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
|
|
757
|
+
const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
|
|
758
|
+
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
759
|
+
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
760
|
+
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
|
|
761
|
+
}
|
|
762
|
+
console.log("");
|
|
763
|
+
|
|
764
|
+
if (!commitStage) {
|
|
765
|
+
console.log("DRY RUN — nothing sent. Add --commit to fire the python broadcast.");
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (!total) { console.log("Nothing eligible under this tag."); return; }
|
|
769
|
+
if (!opts.yes) {
|
|
770
|
+
const answer = await ask(`Type the tag ("${tag}") to fire the python broadcast to ${total} contact(s): `);
|
|
771
|
+
if (answer !== tag) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let out;
|
|
775
|
+
try { out = await http.post("broadcast", reqBody(false)); }
|
|
776
|
+
catch (e) { console.error(`Python broadcast failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
777
|
+
console.log("");
|
|
778
|
+
console.log(`✅ ${out.message || "Broadcast started"}${out.broadcastId ? ` · broadcastId ${out.broadcastId}` : ""}`);
|
|
779
|
+
console.log(` Python is sending in the background${out.mode ? ` (${out.mode})` : ""} and tracking it under that broadcast id — no CLI resume for this engine.`);
|
|
780
|
+
}
|
|
781
|
+
|
|
734
782
|
async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
735
783
|
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
736
784
|
const campaign = opts.campaign || slugify(opts.tag, "tag-campaign");
|
|
@@ -764,6 +812,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
764
812
|
if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
|
|
765
813
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
766
814
|
|
|
815
|
+
// --python: hand the whole send to the python /meta-broadcast engine (the same
|
|
816
|
+
// sender the dashboard's "Python endpoint" toggle uses) via the staff-gated
|
|
817
|
+
// proxy. Python resolves the tag + eligibility + sends fire-and-forget.
|
|
818
|
+
if (opts.python) {
|
|
819
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
820
|
+
}
|
|
821
|
+
|
|
767
822
|
// resolve the tag server-side (broadcast-safe recipients only)
|
|
768
823
|
let resolved;
|
|
769
824
|
try { resolved = await http.post("broadcast", { action: "resolve-tag", organization_id: orgId, tag }); }
|
|
@@ -877,6 +932,7 @@ export async function preview(orgId, opts = {}) {
|
|
|
877
932
|
|
|
878
933
|
export async function send(orgId, opts = {}) {
|
|
879
934
|
if (opts.tag && opts.csv) { console.error("Error: --tag and --csv are mutually exclusive."); process.exit(1); }
|
|
935
|
+
if (opts.python && !opts.tag) { console.error("Error: --python requires --tag (the python engine is tag-based)."); process.exit(1); }
|
|
880
936
|
if (opts.tag) return runTagPipeline(orgId, opts, { commitStage: !!opts.commit });
|
|
881
937
|
await runPipeline(orgId, opts, { commitStage: !!opts.commit, isResume: !!opts.resume });
|
|
882
938
|
}
|
|
@@ -887,6 +943,29 @@ export async function resume(orgId, opts = {}) {
|
|
|
887
943
|
await runPipeline(orgId, opts, { commitStage: !!opts.commit, isResume: true });
|
|
888
944
|
}
|
|
889
945
|
|
|
946
|
+
/** Live delivery status for a broadcast by its broadcastId (e.g. from a --python
|
|
947
|
+
* fire-and-forget send). Read-only — reads the `broadcasts` row + message_status
|
|
948
|
+
* breakdown. This is the visibility the fire-and-forget engine otherwise loses. */
|
|
949
|
+
export async function status(orgId, broadcastId, opts = {}) {
|
|
950
|
+
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
951
|
+
if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId a --python send printed)."); process.exit(1); }
|
|
952
|
+
let resp;
|
|
953
|
+
try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId }); }
|
|
954
|
+
catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
955
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
956
|
+
const b = resp.broadcast, d = resp.delivery;
|
|
957
|
+
const reached = d.read + d.delivered + d.sent;
|
|
958
|
+
console.log(`Broadcast ${b.id} — ${resp.organization_name}`);
|
|
959
|
+
console.log(` template: ${b.template_name}${b.broadcast_name ? ` (${b.broadcast_name})` : ""}${b.media_url ? " · media header" : ""}`);
|
|
960
|
+
console.log(` status: ${b.status ?? "?"} · created ${b.created_at}`);
|
|
961
|
+
console.log(` recipients: ${b.total_recipients ?? "?"} · ${d.linked} message(s) linked`);
|
|
962
|
+
console.log(` ✅ reached ${reached} (read ${d.read} · delivered ${d.delivered} · sent ${d.sent})`);
|
|
963
|
+
console.log(` ❌ failed ${d.failed}${d.other ? ` · other/pending ${d.other}` : ""}`);
|
|
964
|
+
if (b.total_recipients) {
|
|
965
|
+
console.log(` ${((reached / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients reached${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
890
969
|
export async function list() {
|
|
891
970
|
let files;
|
|
892
971
|
try { files = (await fs.readdir(CAMPAIGN_DIR)).filter((f) => f.endsWith(".json") && !f.endsWith(".report.json")); }
|
package/src/index.js
CHANGED
|
@@ -391,6 +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: send via the python /meta-broadcast engine (same as the dashboard's Python toggle; fire-and-forget, python-tracked)")
|
|
394
395
|
.option("--commit", "actually send (omit to dry-run)")
|
|
395
396
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
396
397
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
@@ -410,6 +411,10 @@ export function run(argv) {
|
|
|
410
411
|
.option("--rate <n>", "max messages per second (hard cap 10)", "8")
|
|
411
412
|
.option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
|
|
412
413
|
.action((orgId, opts) => broadcastCmd.resume(orgId, opts));
|
|
414
|
+
broadcast.command("status <organization_id> <broadcast_id>")
|
|
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("--json", "raw JSON")
|
|
417
|
+
.action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
|
|
413
418
|
broadcast.command("list")
|
|
414
419
|
.description("List local campaigns + sent counts")
|
|
415
420
|
.action(() => broadcastCmd.list());
|