@flowapt/flowiq-cli 0.3.8 → 0.4.0

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
@@ -186,7 +186,7 @@ flowiq ct list
186
186
  - **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`, …).
187
187
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
188
188
 
189
- ### Broadcast — `flowiq broadcast map|preview|send|resume|status|retry|list` (alias `bc`)
189
+ ### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|retry|list` (alias `bc`)
190
190
 
191
191
  Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
192
192
  template's variables **per row** from the CSV's own columns. The
@@ -220,13 +220,35 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
220
220
  `/api/send-template` as `headerMedia`, the same field the dashboard uses. A
221
221
  media-header template with no resolvable image is refused (pass
222
222
  `--header-media`).
223
- - **`status <org> <broadcastId>` (v0.3.5)**: live delivery counts for a broadcast
224
- by its id — `read` / `delivered` / `sent` / `failed` (+ % reached) from
225
- `helpdesk_messages`. Built for the `--python` fire-and-forget engine (which
226
- returns a `broadcastId` but has no CLI status log), but works for any broadcast
227
- id (dashboard sends included). **`--failures` (v0.3.7)** additionally lists each
228
- failed recipient + the Meta error reason (`error_code` / `error_title`) with a
229
- by-reason rollup — makes a fire-and-forget send fully auditable.
223
+ - **`list-remote <org>` (v0.3.9)**: list the org's broadcasts **newest-first** with
224
+ the **full broadcastId** per row + template, status, recipient count and SAST
225
+ created time — the discovery step `status` / `retry` need (previously the id
226
+ existed only in the send output or the DB). `--limit <n>` (default 25, max 200),
227
+ `--template <substr>` (case-insensitive contains), `--since <date>`, `--json`.
228
+ Read-only. NOTE: plain `bc list` (no org) still lists your **local campaign
229
+ files** — the remote verb is named after `pinboard list-remote`.
230
+ - **`status <org> <broadcastId>` (v0.3.5)**: live delivery **funnel** for a
231
+ broadcast by its id — `accepted` → `delivered` → `read`, plus `pending` and
232
+ `failed`, with percentages, from `helpdesk_messages`. Built for the `--python`
233
+ fire-and-forget engine (which returns a `broadcastId` but has no CLI status
234
+ log), but works for any broadcast id (dashboard sends included).
235
+ **`--failures` (v0.3.7)** additionally lists each failed recipient + the Meta
236
+ error reason (`error_code` / `error_title`) with a by-reason rollup — makes a
237
+ fire-and-forget send fully auditable.
238
+ - **Delivery counting fixed 3 Aug 2026.** It previously counted
239
+ `message_status='delivered'`, a value present on **6 rows in the whole
240
+ table**, so *delivered always displayed 0* (the reached TOTAL was right; the
241
+ breakdown was not). Delivery now comes from the boolean receipt columns
242
+ (`delivered_receipt_received` / `read_receipt_received`) that the webhooks
243
+ flip — the same signal as the inbox ticks. **Booleans, not the
244
+ `delivered_at`/`read_at` timestamps**: those were added recently and are only
245
+ partially backfilled (2025: 323,325 delivered by boolean, **0** by
246
+ timestamp), and no row ever carries a timestamp without the boolean.
247
+ - The stages are **cumulative, not disjoint** (`accepted ⊇ delivered ⊇ read`) —
248
+ don't add them up. `delivered` counts read-without-a-delivered-receipt too
249
+ (~65k such rows exist: WhatsApp can skip straight to the read receipt).
250
+ - **`read` is a FLOOR, never exact** — recipients can disable read receipts in
251
+ WhatsApp. The JSON carries a `read_caveat` string saying so.
230
252
  - **`retry <org> <broadcastId>` (v0.3.7)**: re-send a broadcast to **only its
231
253
  failed recipients** — reconstructs the send from the `broadcasts` row (template +
232
254
  params + header) and re-fires via the python engine, creating a NEW broadcast.
@@ -416,9 +438,10 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
416
438
  collapses to 1 at runtime — rejected).
417
439
  - `field:"attributes"` actions are warned (full jsonb replace; constant
418
440
  values only) but applied — this CLI is their only safe editing surface.
419
- - **Action types accepted** (all seven the runtime implements):
441
+ - **Action types accepted** (all nine the runtime implements):
420
442
  `send_message` · `update_contact_field` · `add_contact_tag` ·
421
- `remove_contact_tag` · `set_agent` · `delay` · `combined`.
443
+ `remove_contact_tag` · `set_agent` · `delay` · `combined` ·
444
+ `update_ticket_status` · `renotify_ticket`.
422
445
  Per-type rules: tag actions need a non-empty `tags[]` (or a single `tag`
423
446
  string) or the runtime writes no tag at all; `set_agent.agent_id` must be
424
447
  an agent UUID, or `null`/`""` to CLEAR the contact's binding (warned, since
@@ -429,9 +452,41 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
429
452
  *(Before v0.3.9 only the first two + `combined` were accepted, so a
430
453
  zero-edit pull→push failed for any org using tag / set_agent / delay
431
454
  actions.)*
455
+ - **Ticket actions** (added 30 Jul 2026, for escalation follow-up buttons):
456
+ `update_ticket_status` changes the CONTACT's open/in_progress tickets —
457
+ `{ "type": "update_ticket_status", "status": "resolved", "scope": "all_open" }`.
458
+ `status` ∈ `open`/`in_progress`/`resolved`/`closed` (default `resolved`);
459
+ `scope` ∈ `all_open` (default) / `latest_open`. Appends the same
460
+ `data.status_history[]` audit entries as the agent's `manage_tickets` tool
461
+ (`by:"keyword"`). Canonical use: the follow-up template's **"Query solved"**
462
+ button resolving the escalation ticket. `renotify_ticket` re-fires the
463
+ human-needed team notifications for the contact's latest ticket via
464
+ ticket-tool `mode:"renotify"` (60s server-side rate limit; a resolved/closed
465
+ ticket is reopened first) — canonical use: the **"I still need help"**
466
+ button. Both send the action's `text` (if any) after the ticket work.
432
467
  - `action_config.link_preview: false` disables WhatsApp's link-preview card
433
468
  on that action's text send (absent/`true` = preview on, the default).
434
469
  Passed through verbatim; also toggleable per action in the dashboard.
470
+ - **`action_config.when` — conditional actions.** An action only runs if its
471
+ condition passes, checked against the **live contact row** when the keyword
472
+ fires (no tag to maintain, nothing goes stale):
473
+ ```json
474
+ { "type": "send_message", "when": { "field": "email", "op": "is_not_empty" } }
475
+ ```
476
+ `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).
477
+ `op` ∈ `is_empty` / `is_not_empty` / `equals` / `contains` — the last two
478
+ need a `value` and are case-insensitive. Rejected on push otherwise
479
+ (V-21/V-22), because the runtime SKIPS an action it can't evaluate.
480
+ Typical pattern — one keyword, two possible replies, tag either way:
481
+ ```
482
+ action 1 send_message when email is_not_empty
483
+ "I've got your email as {{contact.email}} — reply YES to use it."
484
+ action 2 send_message when email is_empty
485
+ "Pop in your email address to finish."
486
+ action 3 add_contact_tag (no when → always runs)
487
+ ```
488
+ There is **no dashboard field** for conditions yet — the UI preserves them
489
+ on save but can't edit them, so treat `flowiq kw` as their owner.
435
490
  - Scheduled keywords: the keyword-scheduler cron reconciles `active` from
436
491
  `start_date`/`end_date` within ~30 min of your push.
437
492
 
@@ -712,7 +767,7 @@ flowiq agent config <organization_id> --test-contact-number 27000000001 --test-c
712
767
  Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
713
768
  the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
714
769
  `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
715
- `shopify_products_web_chat`), `discount.enabled`, and the `flowiq test` contact
770
+ `shopify_products_web_chat`, `ticket_tool_status`), `discount.enabled`, and the `flowiq test` contact
716
771
  (`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
717
772
  rejected; every change is reported before → after.
718
773
 
package/TEAM-GUIDE.md CHANGED
@@ -78,6 +78,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
78
78
  | Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
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
+ | 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 |
81
83
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
82
84
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
83
85
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
@@ -93,7 +95,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
93
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>}}`) |
94
96
  | 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
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.** |
96
- | Check how a broadcast is landing (read/delivered/sent/failed) | `flowiq bc status <org_id> <broadcastId>` (the `broadcastId` a `--python` send prints) |
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
+ | 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`) |
97
100
  | See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
98
101
  | Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
99
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.8",
3
+ "version": "0.4.0",
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": {
@@ -94,8 +94,13 @@ export async function list(orgId, opts = {}) {
94
94
  }
95
95
  }
96
96
  console.log("");
97
- console.log(` ids: ${entries.map((e) => e.id.slice(0, 8)).join(" ")}`);
98
- console.log(` detail: flowiq audit show <audit_id> (full id, or the 8-char prefix won't work — copy from --json)`);
97
+ // Print FULL ids: `audit show` requires a uuid, so an 8-char prefix here just
98
+ // sent you back to --json to fetch the real one (`prompts history` already
99
+ // prints full ids — this now matches it).
100
+ console.log(` detail: flowiq audit show <audit_id> [--content]`);
101
+ for (const e of entries) {
102
+ console.log(` ${e.id} ${e.endpoint} ${e.action}`);
103
+ }
99
104
  console.log("");
100
105
  }
101
106
 
@@ -955,6 +955,38 @@ export async function resume(orgId, opts = {}) {
955
955
  await runPipeline(orgId, opts, { commitStage: !!opts.commit, isResume: true });
956
956
  }
957
957
 
958
+ /** List an org's broadcasts newest-first — the discovery step for `status`/`retry`,
959
+ * which need a broadcastId you otherwise only have if you kept the send output.
960
+ * Read-only. (`list` without an org stays the LOCAL campaign-file listing; this is
961
+ * the remote verb, named after `pinboard list-remote`.) */
962
+ export async function listRemote(orgId, opts = {}) {
963
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
964
+ let resp;
965
+ try {
966
+ resp = await http.post("broadcast", {
967
+ action: "list", organization_id: orgId,
968
+ ...(opts.limit ? { limit: Number(opts.limit) } : {}),
969
+ ...(opts.template ? { template: opts.template } : {}),
970
+ ...(opts.since ? { since: opts.since } : {}),
971
+ });
972
+ } catch (e) { console.error(`Listing failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
973
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
974
+ const rows = resp.broadcasts || [];
975
+ if (!rows.length) { console.log(`No broadcasts found for ${resp.organization_name}${opts.template || opts.since ? " matching the filters" : ""}.`); return; }
976
+ // Full ids one per row, deliberately — an id you can't paste into `bc status`
977
+ // is useless (the same lesson the audit listing learned).
978
+ const sast = (iso) => { try { return new Date(iso).toLocaleString("sv-SE", { timeZone: "Africa/Johannesburg" }).slice(0, 16); } catch { return iso; } };
979
+ console.log(`Broadcasts — ${resp.organization_name} (${rows.length} shown, newest first; times SAST)`);
980
+ for (const b of rows) {
981
+ console.log("");
982
+ console.log(` ${b.id}`);
983
+ console.log(` ${sast(b.created_at)} ${b.template_name ?? "?"}${b.broadcast_name && b.broadcast_name !== b.template_name ? ` (${b.broadcast_name})` : ""}${b.media_url ? " · media header" : ""}`);
984
+ console.log(` status ${b.status ?? "?"} · ${b.total_recipients ?? "?"} recipient(s)`);
985
+ }
986
+ console.log("");
987
+ console.log(`Delivery detail: flowiq bc status ${orgId} <broadcast_id> [--failures]`);
988
+ }
989
+
958
990
  /** Live delivery status for a broadcast by its broadcastId (e.g. from a --python
959
991
  * fire-and-forget send). Read-only — reads the `broadcasts` row + message_status
960
992
  * breakdown. This is the visibility the fire-and-forget engine otherwise loses. */
@@ -966,15 +998,24 @@ export async function status(orgId, broadcastId, opts = {}) {
966
998
  catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
967
999
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
968
1000
  const b = resp.broadcast, d = resp.delivery;
969
- const reached = d.read + d.delivered + d.sent;
1001
+ // Cumulative funnel (v0.4.0+). Falls back to the old disjoint fields when
1002
+ // talking to a server that predates the 3 Aug 2026 fix.
1003
+ const accepted = d.accepted ?? (d.read + d.delivered + d.sent);
1004
+ const delivered = d.delivered_total ?? d.delivered;
1005
+ const read = d.read_total ?? d.read;
1006
+ const pending = d.pending ?? d.other ?? 0;
1007
+ const pctOf = (n) => (accepted > 0 ? `${((n / accepted) * 100).toFixed(1)}%` : "–");
970
1008
  console.log(`Broadcast ${b.id} — ${resp.organization_name}`);
971
1009
  console.log(` template: ${b.template_name}${b.broadcast_name ? ` (${b.broadcast_name})` : ""}${b.media_url ? " · media header" : ""}`);
972
1010
  console.log(` status: ${b.status ?? "?"} · created ${b.created_at}`);
973
1011
  console.log(` recipients: ${b.total_recipients ?? "?"} · ${d.linked} message(s) linked`);
974
- console.log(` ✅ reached ${reached} (read ${d.read} · delivered ${d.delivered} · sent ${d.sent})`);
975
- console.log(` ❌ failed ${d.failed}${d.other ? ` · other/pending ${d.other}` : ""}`);
1012
+ console.log(` ✅ accepted ${accepted}`);
1013
+ console.log(` 📬 delivered ${delivered} (${pctOf(delivered)} of accepted)`);
1014
+ console.log(` 👀 read ${read} (${pctOf(read)}) — floor only, recipients can disable read receipts`);
1015
+ console.log(` ⏳ pending ${pending} (accepted, no delivery receipt yet)`);
1016
+ console.log(` ❌ failed ${d.failed}${d.failed_pct != null ? ` (${d.failed_pct}% of linked)` : ""}`);
976
1017
  if (b.total_recipients) {
977
- console.log(` ${((reached / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients reached${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
1018
+ console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
978
1019
  }
979
1020
  if (opts.failures && resp.failures) {
980
1021
  console.log("");
package/src/index.js CHANGED
@@ -445,6 +445,13 @@ export function run(argv) {
445
445
  .option("--rate <n>", "max messages per second (hard cap 10)", "8")
446
446
  .option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
447
447
  .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
448
+ broadcast.command("list-remote <organization_id>")
449
+ .description("List the org's broadcasts newest-first (full broadcastId + template + status + recipients) — the discovery step for `bc status` / `bc retry`")
450
+ .option("--limit <n>", "how many to show (default 25, max 200)")
451
+ .option("--template <substr>", "filter: template_name contains this (case-insensitive)")
452
+ .option("--since <date>", "filter: created on/after this date, e.g. 2026-07-01")
453
+ .option("--json", "raw JSON")
454
+ .action((orgId, opts) => broadcastCmd.listRemote(orgId, opts));
448
455
  broadcast.command("status <organization_id> <broadcast_id>")
449
456
  .description("Live delivery counts (read/delivered/sent/failed) for a broadcast by its broadcastId — e.g. from a --python fire-and-forget send")
450
457
  .option("--failures", "also list each FAILED recipient + the Meta error reason (makes a python send auditable)")