@flowapt/flowiq-cli 0.4.8 → 0.6.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 +149 -19
- package/TEAM-GUIDE.md +15 -4
- package/package.json +1 -1
- package/src/commands/broadcast.js +349 -44
- package/src/commands/hours.js +150 -0
- package/src/commands/messages.js +5 -2
- package/src/commands/messaging-webhooks.js +7 -0
- package/src/index.js +31 -4
package/README.md
CHANGED
|
@@ -187,8 +187,9 @@ flowiq ct list
|
|
|
187
187
|
|
|
188
188
|
- **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
|
|
189
189
|
- **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
|
|
190
|
-
- Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`headers`/`injected_parameters`, valid `auth_type`/`channels`. Bad payloads are rejected before anything writes.
|
|
191
|
-
- **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime
|
|
190
|
+
- Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`headers`/`injected_parameters`, valid `auth_type`/`channels`. Recursive JSON schemas are supported, including arrays of objects and integer/min/max constraints. Bad payloads are rejected before anything writes.
|
|
191
|
+
- **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime; known values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `supabase_anon_key`, `openai_api_key`).
|
|
192
|
+
- **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
|
|
192
193
|
- `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
|
|
193
194
|
|
|
194
195
|
### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|retry|list` (alias `bc`)
|
|
@@ -198,6 +199,16 @@ template's variables **per row** from the CSV's own columns. The
|
|
|
198
199
|
highest-stakes command in the CLI — built as a map → preview → commit
|
|
199
200
|
pipeline with several layers of deliberate friction.
|
|
200
201
|
|
|
202
|
+
**v0.6.0 — the CSV send is a server-side fan-out.** A commit imports every
|
|
203
|
+
sendable row as a contact carrying the campaign tag `bc-<campaign>` plus that
|
|
204
|
+
row's values as campaign-scoped attributes (`bc_<campaign>_param1…` /
|
|
205
|
+
`_button`), then fires **ONE** python `/meta-broadcast` call whose params are
|
|
206
|
+
`{{attributes.bc_<campaign>_paramN}}` tokens — python resolves them **per
|
|
207
|
+
contact** (body AND URL-button) and paces the send server-side. The CLI
|
|
208
|
+
returns in seconds; closing the laptop no longer matters. A 1,846-row CSV
|
|
209
|
+
that took ~77 min on the old per-row loop is now python-paced like any big
|
|
210
|
+
tag send.
|
|
211
|
+
|
|
201
212
|
```bash
|
|
202
213
|
# 1) Build the column→param mapping interactively (saved per campaign, no send)
|
|
203
214
|
flowiq bc map <org_id> --template rewards_referral_v1 --csv ./people.csv --campaign july-referrals
|
|
@@ -205,11 +216,15 @@ flowiq bc map <org_id> --template rewards_referral_v1 --csv ./people.csv --campa
|
|
|
205
216
|
# 2) Dry-run: validation + exact rendered messages for sample rows (default)
|
|
206
217
|
flowiq bc send <org_id> --template rewards_referral_v1 --csv ./people.csv --campaign july-referrals
|
|
207
218
|
|
|
208
|
-
# 3) Commit:
|
|
219
|
+
# 3) Commit: import+tag the rows, fire ONE python broadcast (returns a broadcastId)
|
|
209
220
|
flowiq bc send <org_id> --template … --csv … --campaign july-referrals --commit
|
|
210
221
|
# → prompts: type the campaign name to confirm
|
|
222
|
+
# → then audit with: flowiq bc status <org_id> <broadcastId> [--failures]
|
|
223
|
+
|
|
224
|
+
# 4) Re-fire ONLY the failed recipients (python campaigns have no per-row resume):
|
|
225
|
+
flowiq bc retry <org_id> <broadcastId> --commit
|
|
211
226
|
|
|
212
|
-
#
|
|
227
|
+
# (pre-v0.6.0 campaigns with a per-row status log still finish on the old loop:)
|
|
213
228
|
flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
|
|
214
229
|
|
|
215
230
|
# SCHEDULE a tag send for later instead of sending now (--at is SAST):
|
|
@@ -306,10 +321,31 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
|
306
321
|
DRY-RUN by default (shows the failed count + reason breakdown); `--commit` fires.
|
|
307
322
|
Note: permanent failures (e.g. `131026` undeliverable, opt-outs) just fail again —
|
|
308
323
|
retry earns its keep on transient (throttle/throughput) failures. Capped at 500.
|
|
324
|
+
- **CSV fan-out semantics (v0.6.0)** — the things the engine change makes true:
|
|
325
|
+
- **Per-row values persist on the contact** as `bc_<campaign>_paramN` /
|
|
326
|
+
`_button` attributes (and the `bc-<campaign>` tag) — deliberate: they're the
|
|
327
|
+
audit trail of exactly what each person was sent. Re-running a campaign
|
|
328
|
+
overwrites them with the current CSV's values. Names are safe: a blank/missing
|
|
329
|
+
CSV name can never null a real contact's name (the upsert RPC preserves it).
|
|
330
|
+
- **One campaign = one audience.** A campaign that already fanned out refuses
|
|
331
|
+
a re-run (`--resend` overrides, and resends to EVERY tagged contact — for
|
|
332
|
+
failures use `bc retry`, which re-fires only the failed ones). Smoke-test
|
|
333
|
+
with `--limit` under a THROWAWAY `--campaign`: a limited fan-off still
|
|
334
|
+
records the campaign as fired, and the full run would need `--resend`.
|
|
335
|
+
- **The audience is the TAG, not the file.** If an earlier run of the same
|
|
336
|
+
campaign tagged rows since removed from the CSV, python still resolves them —
|
|
337
|
+
the CLI cross-checks python's count against the CSV after tagging and makes
|
|
338
|
+
you type `YES` when the tag carries MORE people than the file.
|
|
339
|
+
- No per-row write-ahead log or `resume` on fresh sends — the campaign log
|
|
340
|
+
records ONE `handoff` line (broadcastId); delivery/failure detail lives in
|
|
341
|
+
`bc status <org> <broadcastId> [--failures]` and `bc retry`.
|
|
342
|
+
- `--rate` / `--skip-upsert` only apply to pre-v0.6.0 `resume`; fresh sends
|
|
343
|
+
ignore them (python paces itself; the upsert IS the mechanism).
|
|
309
344
|
- **Engine auto-routing (v0.3.6):** any `send --tag` of **more than 10** eligible
|
|
310
345
|
recipients **always uses the python engine** (a `>2000` tag routes there too).
|
|
311
|
-
Only a ≤10 send stays on the
|
|
312
|
-
python at any size.
|
|
346
|
+
Only a ≤10 tag send stays on the per-row Node engine. `--python` forces
|
|
347
|
+
python at any size. **CSV sends are ALWAYS python from v0.6.0** (per-row values
|
|
348
|
+
ride as contact attributes — see above).
|
|
313
349
|
- **`--python` engine (v0.3.4, `send --tag` only)**: hand the whole send to the
|
|
314
350
|
**python `/meta-broadcast` endpoint** — the *same* sender the dashboard's "Python
|
|
315
351
|
endpoint" toggle uses — via a staff-gated proxy (the master key stays server-side
|
|
@@ -319,8 +355,29 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
|
319
355
|
python for the eligible count + a sample. Trade-off vs the default per-row engine:
|
|
320
356
|
no CLI write-ahead-log / `resume` (python owns the broadcast record), and archived
|
|
321
357
|
contacts aren't separately filtered. Media headers work on both engines.
|
|
322
|
-
- **
|
|
323
|
-
|
|
358
|
+
- **Scope**: Meta orgs, POSITIONAL templates, text / no header / media header,
|
|
359
|
+
and **carousel templates (tag mode only, v0.4.9)**. NAMED templates and WATI
|
|
360
|
+
orgs are refused with a clear message; carousels in CSV mode are refused with
|
|
361
|
+
a pointer to tag mode.
|
|
362
|
+
- **Carousel templates (v0.4.9, `send --tag` only, always the python engine):**
|
|
363
|
+
the CLI introspects every card (header format, body variables, buttons) and
|
|
364
|
+
builds the per-card send payload python's `/meta-broadcast` expects. Card
|
|
365
|
+
media defaults to each card's **stored template image** (written by
|
|
366
|
+
`create-meta-template` at creation), overridable with repeatable
|
|
367
|
+
`--card-media <url>` (one per card, in card order); every resolved URL is
|
|
368
|
+
content-type probed against that card's own IMAGE/VIDEO format (mismatch
|
|
369
|
+
aborts, per card). Cards whose body carries `{{n}}` variables, or whose URL
|
|
370
|
+
buttons carry a variable, **require `--cards-file <path>`** — a JSON array
|
|
371
|
+
with one entry per card:
|
|
372
|
+
`[{"header_media":"<url>","body_params":{"param1":"…"},"button_payloads":{"btn0":"…"},"url_vars":{"btn1":"<value>"}}, …]`
|
|
373
|
+
(all keys optional per card; quick-reply payloads default to the button text;
|
|
374
|
+
`{{first_name}}`-style tokens inside card values are resolved per contact).
|
|
375
|
+
Carousel sends route to the **python engine at any size** — the per-row Node
|
|
376
|
+
engine cannot send carousels — so there is no CLI write-ahead log / `resume`
|
|
377
|
+
for them; audit via `bc status <broadcastId>`. `--at` scheduling works: the
|
|
378
|
+
card payload is frozen into the queued request. `bc retry` on a carousel
|
|
379
|
+
broadcast re-resolves card media from the template's stored defaults and does
|
|
380
|
+
NOT replay per-card body/url values — retry only carousels that need none.
|
|
324
381
|
- **Validation before anything sends**: APPROVED-only, every slot mapped,
|
|
325
382
|
contiguous params (the Meta `#132000` guard — a stray key is structurally
|
|
326
383
|
impossible), phone validity, illegal characters (Meta `#100`; `reject` by
|
|
@@ -328,17 +385,19 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
|
328
385
|
blocked contacts are skipped, always.
|
|
329
386
|
- **Dry-run is the default**; `--commit` + typing the campaign name is the
|
|
330
387
|
only way to send (`--yes` for CI skips the typing, never the dry-run).
|
|
331
|
-
- **
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
(
|
|
336
|
-
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
388
|
+
- **Campaign status log** (`.flowiq/campaigns/<campaign>.status.jsonl`): a
|
|
389
|
+
fresh (v0.6.0) send records a `meta` header + ONE `handoff` line carrying the
|
|
390
|
+
`broadcastId` — the re-run guard and your local pointer to `bc status`.
|
|
391
|
+
Pre-v0.6.0 campaigns keep their per-row `sending`/`sent`/`failed` lines and
|
|
392
|
+
finish on `resume` (ambiguous mid-crash rows are still never auto-resent;
|
|
393
|
+
`--retry-failed` adds confirmed failures).
|
|
394
|
+
- **Error handling**: all pre-send validation is unchanged and client-side;
|
|
395
|
+
per-recipient send errors are python-side — audit them with
|
|
396
|
+
`bc status <org> <broadcastId> --failures`, re-fire with `bc retry`.
|
|
397
|
+
- Contacts are **imported/updated** via `/cli/contacts-upsert` (≤4000/batch,
|
|
398
|
+
retries then aborts) carrying the campaign tag + attributes; the python
|
|
399
|
+
params are pure `{{attributes.*}}` tokens, so the payload never needs a
|
|
400
|
+
`name` key — the classic `#132000` footgun stays structurally impossible.
|
|
342
401
|
|
|
343
402
|
**Tag mode** — send to everyone carrying a tag (e.g. a `segments` batch tag)
|
|
344
403
|
instead of a CSV. Same guards, same status log, same resume:
|
|
@@ -623,6 +682,16 @@ flowiq m pull <contact_id> --count 25
|
|
|
623
682
|
|
|
624
683
|
`--count` defaults to 25, max 200.
|
|
625
684
|
|
|
685
|
+
**When you ask for more than exists, you get the WHOLE history (16 Aug 2026).**
|
|
686
|
+
If the contact has fewer `user-*` messages than `--count`, there is nothing
|
|
687
|
+
older to withhold, so the pull returns every row from the first message onward
|
|
688
|
+
and reports `reached_start: true` (`user msgs: 1/25 requested — that is ALL of
|
|
689
|
+
them; this pull is the entire history`). Before this, the anchor sat on the
|
|
690
|
+
oldest inbound message and silently dropped everything before it — so on a
|
|
691
|
+
contact whose conversation OPENS with an outbound (a delivery notification, a
|
|
692
|
+
broadcast), the message that STARTED the conversation was missing while the
|
|
693
|
+
pull still printed success.
|
|
694
|
+
|
|
626
695
|
### Audit log — `flowiq audit [org] | audit show <id>` (v0.3.8)
|
|
627
696
|
|
|
628
697
|
**Who changed what, when — with the full before/after content.** Every
|
|
@@ -727,6 +796,40 @@ Validation: `platform` ∈ `{whatsapp, web}`, `type` from the per-platform
|
|
|
727
796
|
allowed enum, valid http(s) URL, no duplicate (platform, type, url)
|
|
728
797
|
tuples.
|
|
729
798
|
|
|
799
|
+
**Auth fields (19 Aug 2026).** Each row also carries `auth_type`
|
|
800
|
+
(`none` | `bearer` | `basic` | `api_key` | `oauth2_client_credentials`),
|
|
801
|
+
`auth_config`, `headers` and `secret` — how FlowIQ authenticates when it POSTs
|
|
802
|
+
the event, plus an optional HMAC signing key. Pull returns all four; push
|
|
803
|
+
writes all four.
|
|
804
|
+
|
|
805
|
+
**Because push is FULL-REPLACE, a body that omits these fields STRIPS the
|
|
806
|
+
credentials off every webhook.** Always pull-edit-push the same file; never
|
|
807
|
+
hand-write a push body. The dry-run diff includes an auth fingerprint, so
|
|
808
|
+
changing only a credential correctly shows as `to insert 1 / to delete 1`
|
|
809
|
+
rather than "unchanged".
|
|
810
|
+
|
|
811
|
+
```jsonc
|
|
812
|
+
{
|
|
813
|
+
"url": "https://middleware.example.com/notification/log",
|
|
814
|
+
"type": "sent_message",
|
|
815
|
+
"platform": "whatsapp",
|
|
816
|
+
"auth_type": "oauth2_client_credentials",
|
|
817
|
+
"auth_config": {
|
|
818
|
+
"token_url": "https://middleware.example.com/oauth2/token",
|
|
819
|
+
"client_id": "acme_flowapt",
|
|
820
|
+
"client_secret": "…",
|
|
821
|
+
"scope": "notify:write", // optional
|
|
822
|
+
"client_auth": "basic" // optional: "basic" (default) | "body"
|
|
823
|
+
},
|
|
824
|
+
"headers": { "X-Tenant": "acme" }, // optional static headers
|
|
825
|
+
"secret": "…" // optional → X-Flowiq-Signature: sha256=<hmac>
|
|
826
|
+
}
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
Push refuses an `auth_type` whose credentials are incomplete (e.g. `bearer`
|
|
830
|
+
with no `auth_config.token`), because that would make every delivery fail
|
|
831
|
+
closed — FlowIQ never falls back to an unauthenticated send.
|
|
832
|
+
|
|
730
833
|
### FlowMod prompts — `flowiq flowmod pull|push <slug>` (alias `fm`)
|
|
731
834
|
|
|
732
835
|
Round-trips a FlowMod org's **master-group** prompts + config. FlowMod groups
|
|
@@ -802,6 +905,33 @@ Editable fields: `name`, `description`, `status` (open / in_progress / done),
|
|
|
802
905
|
null), `organizations[]`, `media[]`, `messages[]`, `created_by`. Array columns
|
|
803
906
|
are full-replace within the row.
|
|
804
907
|
|
|
908
|
+
### Client hours — `flowiq hours log|list|summary` (v0.5.0)
|
|
909
|
+
|
|
910
|
+
The client-hours ledger. Every org's package includes **10 hours of Flowapt
|
|
911
|
+
work per calendar month**; every piece of client work gets logged against it —
|
|
912
|
+
manually here, or automatically by Claude sessions via the FlowIQ MCP's
|
|
913
|
+
`log_client_hours` tool. Clients see their own usage on the Client Console;
|
|
914
|
+
the cross-org view lives in the super-admin Changelog dialog → Client hours.
|
|
915
|
+
|
|
916
|
+
```bash
|
|
917
|
+
flowiq hours log <org_id> --hours 1.5 --desc "Rebuilt the abandoned-cart copy" # fractions fine
|
|
918
|
+
flowiq hours log <org_id> --minutes 45 --desc "Fixed template header" --date 2026-08-18
|
|
919
|
+
flowiq hours list <org_id> # this month's entries + package standing
|
|
920
|
+
flowiq hours list <org_id> --month 2026-07 # a past month
|
|
921
|
+
flowiq hours summary # every org with logged work this month
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
- `log` takes exactly ONE of `--minutes <n>` (whole minutes) or `--hours <h>`
|
|
925
|
+
(fractions fine — `--hours 1.5` = 90 min). `--desc` is required and should be
|
|
926
|
+
plain language the client could read (it shows on their Client Console).
|
|
927
|
+
- `--by` overrides who did the work (default: your staff login email);
|
|
928
|
+
`--date YYYY-MM-DD` backdates an entry (it counts toward THAT month);
|
|
929
|
+
`--session` tags the session/task name.
|
|
930
|
+
- `list` / `summary` default to the current month and print each org's usage
|
|
931
|
+
against the 10h package, flagging any org that has gone over.
|
|
932
|
+
- Corrections (delete/edit) live in the Changelog → Client hours page, not the
|
|
933
|
+
CLI. Every `log` is audited.
|
|
934
|
+
|
|
805
935
|
### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
|
|
806
936
|
|
|
807
937
|
Read an org's live templates straight from Meta (read-only), and submit new
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -60,7 +60,11 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
60
60
|
would change before anything writes.
|
|
61
61
|
3. **Pushes are full-replace.** What's in your file becomes the whole surface
|
|
62
62
|
(all prompt sections, all tools, all webhooks). Deleting an entry from the
|
|
63
|
-
file deletes it from the platform.
|
|
63
|
+
file deletes it from the platform. This is also why you must never
|
|
64
|
+
hand-write a push body: messaging webhooks carry their own credentials
|
|
65
|
+
(`auth_type` / `auth_config` / `headers` / `secret`), and a body that leaves
|
|
66
|
+
those fields out strips the auth off every webhook for that org. Pull, edit
|
|
67
|
+
that same file, push it.
|
|
64
68
|
4. **`--agent <id>` targets a specific agent**; without it you get the org's
|
|
65
69
|
*active* agent. Files remember which agent they came from, so a push always
|
|
66
70
|
goes back to the agent you pulled — never "whatever is active now".
|
|
@@ -101,7 +105,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
101
105
|
| Short links for every URL in a message | `flowiq links shorten <org_id> --file ./message.txt --campaign X --content X --commit` |
|
|
102
106
|
| Check which links a campaign has, and their clicks | `flowiq links list <org_id> --campaign 13Aug_Seeds` |
|
|
103
107
|
| A link already looks short (`linklnk.io/abc123`) — can I re-tag it? | No: paste the DESTINATION url instead. Re-shortening keeps the old campaign tag and splits the click count, so the CLI refuses it |
|
|
104
|
-
| Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
|
|
108
|
+
| Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit`. **v0.6.0: the commit imports every row as a contact (tag `bc-<campaign>` + the row's values as attributes) and fires ONE python broadcast** — it returns in seconds with a `broadcastId`; python sends in the background. Check delivery with `bc status`, re-fire failures with `bc retry`. A campaign that already fired refuses a re-run (`--resend` deliberately resends to everyone tagged). |
|
|
105
109
|
| Tag every repeat buyer (e.g. 2+ orders in the last 60 days) | `flowiq seg plan <org_id> --tag-prefix repeat-60d --min-orders 2 --window 60d` → `flowiq seg apply <org_id> repeat-60d` (dry run) → `… --commit` |
|
|
106
110
|
| Tag everyone who bought a product (accurate, windowable) | `flowiq seg plan <org_id> --tag-prefix whey --bought "Whey" --window 90d` → `flowiq seg apply … --commit` |
|
|
107
111
|
| Advanced Tagging in the terminal (one named tag on a matched set) | `flowiq tag field\|cohort\|segment\|attributes\|messages <org_id> …` (dry-run) → add `--tag <name> --commit` |
|
|
@@ -114,6 +118,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
114
118
|
| See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm` |
|
|
115
119
|
| 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>}}`). Sending a **different template to the SAME tag**? Add a distinct `--campaign <name>` — otherwise the CLI aborts (a campaign belongs to one template; reusing it would send the first template's image + skip everyone it already reached). |
|
|
116
120
|
| 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. |
|
|
121
|
+
| Send a **CAROUSEL** template (v0.4.9) | Tag mode only: `flowiq bc send <org_id> --tag <batch-tag> --template <carousel_name> --body param1="Hi {{first_name}}" --commit`. Card images are automatic (each card's stored template image, type-verified per card); override with repeatable `--card-media <url>` (one per card, in order). If the cards carry `{{n}}` body variables or URL-button variables, the CLI tells you exactly what to put in a `--cards-file <path>` JSON (one entry per card: `header_media` / `body_params` / `button_payloads` / `url_vars`). Always sends via the python engine (any size); no CSV mode for carousels. |
|
|
117
122
|
| 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.** |
|
|
118
123
|
| 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) |
|
|
119
124
|
| 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`) |
|
|
@@ -123,16 +128,18 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
123
128
|
| **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
|
|
124
129
|
| See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
|
|
125
130
|
| Know what the log does and doesn't keep | Anything that CHANGES something is logged — including `flowiq test` (it creates the test contact and clears conversations) and `flowiq auth refresh`. Reads and dry-runs are not. The log never stores the content itself: chats, store data and agent replies are recorded as "who read what", never copied. |
|
|
126
|
-
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
131
|
+
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON. Ask for more than exists (`--count 100`) and you get the entire history — it says "that is ALL of them" when there is nothing older |
|
|
127
132
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
|
128
133
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
|
129
134
|
| Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
|
|
130
|
-
| Manage outbound messaging webhooks | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
|
|
135
|
+
| Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
|
|
131
136
|
| Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
|
|
132
137
|
| See a client's pending change requests | `flowiq au pull <org_id>` |
|
|
133
138
|
| Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
|
|
134
139
|
| Check an org's platform + active agent | `flowiq org info <org_id>` |
|
|
135
140
|
| Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
|
|
141
|
+
| Log hours you worked for a client (every package = 10h Flowapt work/month) | `flowiq hours log <org_id> --hours 1.5 --desc "what you did"` — plain language, the client sees it on their Client Console |
|
|
142
|
+
| Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
|
|
136
143
|
| Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
|
|
137
144
|
|
|
138
145
|
The flow is the same everywhere: **pull → edit the JSON → push**. Slugs are the
|
|
@@ -168,6 +175,10 @@ flowiq ct push <slug>
|
|
|
168
175
|
Custom tools define real HTTP calls the agent can execute, so the server
|
|
169
176
|
validates hard (names, URLs, methods, parameter shapes) and warns about typo'd
|
|
170
177
|
keys or `{{placeholders}}` it doesn't recognise. Take the warnings seriously.
|
|
178
|
+
Nested object/array schemas are supported. For ChatCart retailer tools, use
|
|
179
|
+
`{{retailer_tools_internal_key}}` only as the `x-api-key` auth value on the
|
|
180
|
+
trusted `express.chatcart.io/retailer-tools/*` gateway; org/contact identity is
|
|
181
|
+
injected server-side and mutations use `{{whatsapp_message_id}}` for idempotency.
|
|
171
182
|
|
|
172
183
|
### Example: tag a segment of contacts (Advanced Tagging)
|
|
173
184
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -6,17 +6,23 @@
|
|
|
6
6
|
// send <org> --template T --csv F [--commit] full pipeline; DRY-RUN by default
|
|
7
7
|
// resume <org> --campaign C [--commit] continue from the status log
|
|
8
8
|
//
|
|
9
|
-
// Pipeline
|
|
10
|
-
// parse CSV → build/load column→param
|
|
11
|
-
// with its OWN values) → validate
|
|
12
|
-
// (opted-out/archived/blocked are
|
|
13
|
-
// contacts (/cli/contacts-upsert,
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
9
|
+
// Pipeline (v0.6.0 — python server-side fan-out): introspect template (live
|
|
10
|
+
// Meta + DB labels, via /cli/broadcast) → parse CSV → build/load column→param
|
|
11
|
+
// MAPPING (each row fills the template with its OWN values) → validate
|
|
12
|
+
// (V-1..V-13) → broadcast-safety classify (opted-out/archived/blocked are
|
|
13
|
+
// skipped) → preview → [--commit] upsert contacts (/cli/contacts-upsert,
|
|
14
|
+
// ≤4000/batch, 3× retry then ABORT) carrying the campaign TAG bc-<campaign>
|
|
15
|
+
// plus each row's values as campaign-scoped ATTRIBUTES
|
|
16
|
+
// (bc_<campaign>_paramN / _button) → ONE python /meta-broadcast call with
|
|
17
|
+
// {{attributes.bc_<campaign>_paramN}} tokens, which python resolves PER
|
|
18
|
+
// CONTACT (broadcast.py substitute_contact_fields — body AND URL-button
|
|
19
|
+
// params). The send is fire-and-forget: python owns pacing + the broadcast
|
|
20
|
+
// record; audit via `bc status <org> <broadcastId>` / `bc retry`.
|
|
21
|
+
//
|
|
22
|
+
// The OLD per-row Node loop (write-ahead status log, `resume`) survives ONLY
|
|
23
|
+
// to finish campaigns started before v0.6.0 — a fresh CSV send never uses it.
|
|
24
|
+
// A python campaign records ONE {type:"handoff"} line instead; re-running it
|
|
25
|
+
// needs --resend (the tag fan-out cannot skip already-reached rows).
|
|
20
26
|
//
|
|
21
27
|
// #132000 structural guard: the payload builder whitelists /^param\d+$/ keys
|
|
22
28
|
// only — a stray "name" key (which would add a body param on a POSITIONAL
|
|
@@ -308,16 +314,18 @@ async function saveCampaign(cfg) {
|
|
|
308
314
|
async function loadStatusLog(campaign) {
|
|
309
315
|
const map = new Map();
|
|
310
316
|
let header = null;
|
|
317
|
+
let handoff = null; // last {type:"handoff"} line — a completed python fan-off
|
|
311
318
|
try {
|
|
312
319
|
const text = await fs.readFile(logPath(campaign), "utf8");
|
|
313
320
|
for (const line of text.split("\n")) {
|
|
314
321
|
if (!line.trim()) continue;
|
|
315
322
|
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
316
323
|
if (obj.type === "meta") { header = obj; continue; }
|
|
324
|
+
if (obj.type === "handoff") { handoff = obj; continue; }
|
|
317
325
|
if (obj.type === "row" && obj.number) map.set(obj.number, obj);
|
|
318
326
|
}
|
|
319
327
|
} catch { /* no log yet */ }
|
|
320
|
-
return { header, map };
|
|
328
|
+
return { header, map, handoff };
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
async function appendLog(campaign, obj) {
|
|
@@ -380,11 +388,121 @@ function headerMismatchMessage(template, headerMedia, contentType, { explicit })
|
|
|
380
388
|
: `${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
389
|
}
|
|
382
390
|
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// carousel card resolution (18 Aug 2026 — tag mode, python engine only)
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Builds the card_overrides[] payload python's /meta-broadcast expects: one
|
|
395
|
+
// entry per card, in card order. Media precedence per card: --cards-file entry
|
|
396
|
+
// header_media → --card-media (positional) → the template's stored default
|
|
397
|
+
// (template_data.carousel.cards[i].header_url, surfaced by introspect). Every
|
|
398
|
+
// resolved URL is content-type probed against that CARD's own header format —
|
|
399
|
+
// a carousel can mix IMAGE and VIDEO cards, so this is per-card, not global.
|
|
400
|
+
// Cards with {{n}} body variables or URL-button variables REQUIRE a
|
|
401
|
+
// --cards-file entry (python emits '' for a missing url var → Meta rejects the
|
|
402
|
+
// whole message, so we refuse client-side). Exported for the test harness.
|
|
403
|
+
|
|
404
|
+
export async function resolveCarouselCards(template, { cardMediaFlags = [], cardsFileEntries = null } = {}, probe = probeContentType) {
|
|
405
|
+
const cards = template.carousel_cards || [];
|
|
406
|
+
const aborts = [], warnings = [], lines = [];
|
|
407
|
+
const overrides = [];
|
|
408
|
+
if (!cards.length) {
|
|
409
|
+
aborts.push("carousel template, but the server returned no card details — api.flowiq.live needs the carousel-aware deploy (api/cli/_introspect.js); until then use the dashboard");
|
|
410
|
+
return { aborts, warnings, overrides: null, lines };
|
|
411
|
+
}
|
|
412
|
+
if (cardMediaFlags.length && cardMediaFlags.length !== cards.length) {
|
|
413
|
+
aborts.push(`--card-media given ${cardMediaFlags.length} time(s) but the template has ${cards.length} cards — pass one per card, in card order`);
|
|
414
|
+
}
|
|
415
|
+
if (cardsFileEntries !== null && (!Array.isArray(cardsFileEntries) || cardsFileEntries.length !== cards.length)) {
|
|
416
|
+
aborts.push(`--cards-file must be a JSON array with exactly ${cards.length} entries (one per card, in card order)`);
|
|
417
|
+
}
|
|
418
|
+
if (aborts.length) return { aborts, warnings, overrides: null, lines };
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < cards.length; i++) {
|
|
421
|
+
const card = cards[i];
|
|
422
|
+
const fileEntry = (cardsFileEntries?.[i] && typeof cardsFileEntries[i] === "object") ? cardsFileEntries[i] : {};
|
|
423
|
+
const media = fileEntry.header_media || cardMediaFlags[i] || card.header_media_default || null;
|
|
424
|
+
if (!media) {
|
|
425
|
+
aborts.push(`card ${i + 1}: no media resolved — the template stores no default for this card; pass --card-media <url> (one per card, in order) or a --cards-file entry with header_media`);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const contentType = await probe(media);
|
|
429
|
+
let mediaLabel;
|
|
430
|
+
if (!contentType) {
|
|
431
|
+
warnings.push(`card ${i + 1}: media unverified — ${media} did not answer a type probe`);
|
|
432
|
+
mediaLabel = `${card.header_format} · unverified`;
|
|
433
|
+
} else if (contentType.split("/")[0] !== card.header_format) {
|
|
434
|
+
aborts.push(`card ${i + 1}: the template card header is ${card.header_format.toUpperCase()} but the resolved media (${media}) serves ${contentType} — point it at a real ${card.header_format}`);
|
|
435
|
+
continue;
|
|
436
|
+
} else {
|
|
437
|
+
mediaLabel = `${card.header_format} ✓ ${contentType}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Per-card body variables must be supplied — python sends only what we pass.
|
|
441
|
+
let bodyParams = null;
|
|
442
|
+
if (card.body_positions.length) {
|
|
443
|
+
bodyParams = fileEntry.body_params && typeof fileEntry.body_params === "object" ? fileEntry.body_params : null;
|
|
444
|
+
const badKeys = bodyParams ? Object.keys(bodyParams).filter((k) => !/^param\d+$/.test(k)) : [];
|
|
445
|
+
if (badKeys.length) { aborts.push(`card ${i + 1}: body_params keys must be param1..N (got ${badKeys.join(", ")})`); continue; }
|
|
446
|
+
const missing = card.body_positions.filter((p) => !bodyParams?.[`param${p}`]);
|
|
447
|
+
if (missing.length) {
|
|
448
|
+
aborts.push(`card ${i + 1}: its body carries variable(s) ${missing.map((p) => `{{${p}}}`).join(", ")} — supply them via --cards-file (entry ${i}: {"body_params":{${card.body_positions.map((p) => `"param${p}":"…"`).join(",")}}}). {{first_name}}-style tokens inside the values are resolved per contact.`);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// URL buttons WITH a variable need a send-side value; static URL / QUICK_REPLY do not
|
|
454
|
+
// (quick-reply payloads default to the button text server-side).
|
|
455
|
+
const urlVarButtons = (card.buttons || []).filter((b) => b.type === "URL" && b.has_url_var);
|
|
456
|
+
let urlVars = null;
|
|
457
|
+
if (urlVarButtons.length) {
|
|
458
|
+
urlVars = fileEntry.url_vars && typeof fileEntry.url_vars === "object" ? fileEntry.url_vars : null;
|
|
459
|
+
const missing = urlVarButtons.filter((b) => {
|
|
460
|
+
const v = urlVars?.[`btn${b.index}`] ?? urlVars?.[String(b.index)];
|
|
461
|
+
return v === undefined || v === null || String(v) === "";
|
|
462
|
+
});
|
|
463
|
+
if (missing.length) {
|
|
464
|
+
aborts.push(`card ${i + 1}: button(s) ${missing.map((b) => `"${b.text ?? "URL"}"`).join(", ")} carry a URL variable — supply via --cards-file (entry ${i}: {"url_vars":{${missing.map((b) => `"btn${b.index}":"<value>"`).join(",")}}})`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const buttonPayloads = fileEntry.button_payloads && typeof fileEntry.button_payloads === "object" ? fileEntry.button_payloads : null;
|
|
469
|
+
|
|
470
|
+
overrides.push({
|
|
471
|
+
file_url: media,
|
|
472
|
+
...(bodyParams ? { body_parameters: bodyParams } : {}),
|
|
473
|
+
...(buttonPayloads ? { button_payloads: buttonPayloads } : {}),
|
|
474
|
+
...(urlVars ? { url_vars: urlVars } : {}),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Preview lines for the dry-run.
|
|
478
|
+
let bodyPreview = card.body_text || "";
|
|
479
|
+
for (const [k, v] of Object.entries(bodyParams || {})) bodyPreview = bodyPreview.replaceAll(`{{${k.replace("param", "")}}}`, String(v));
|
|
480
|
+
const btnBits = (card.buttons || []).map((b) => {
|
|
481
|
+
if (b.type === "URL" && b.has_url_var) {
|
|
482
|
+
const v = urlVars?.[`btn${b.index}`] ?? urlVars?.[String(b.index)] ?? "";
|
|
483
|
+
return `[${b.text ?? "URL"} → ${String(b.url || "").replace(/\{\{[^}]+\}\}/, String(v))}]`;
|
|
484
|
+
}
|
|
485
|
+
if (b.type === "URL") return `[${b.text ?? "URL"} → ${b.url}]`;
|
|
486
|
+
return `[${b.text ?? b.type}]`;
|
|
487
|
+
}).join(" ");
|
|
488
|
+
lines.push(` Card ${i + 1} (${mediaLabel}): ${media}`);
|
|
489
|
+
if (bodyPreview) lines.push(` ${bodyPreview.replace(/\n/g, " ")}`);
|
|
490
|
+
if (btnBits) lines.push(` ${btnBits}`);
|
|
491
|
+
}
|
|
492
|
+
return { aborts, warnings, overrides: aborts.length ? null : overrides, lines };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function printCarouselPlan(cardOverrides, cardLines) {
|
|
496
|
+
if (!cardOverrides?.length) return;
|
|
497
|
+
console.log(` Carousel (${cardOverrides.length} cards — media re-uploaded to Meta once per broadcast):`);
|
|
498
|
+
for (const l of cardLines) console.log(l);
|
|
499
|
+
}
|
|
500
|
+
|
|
383
501
|
function validateRows(template, mapping, headers, rows, illegalChars, headerMedia) {
|
|
384
502
|
const aborts = [];
|
|
385
503
|
if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
|
|
386
504
|
if (template.parameter_format !== "POSITIONAL") aborts.push("V-2: NAMED templates are not supported in v1");
|
|
387
|
-
if (template.is_carousel) aborts.push("V-3: carousel templates are not supported
|
|
505
|
+
if (template.is_carousel) aborts.push("V-3: carousel templates are TAG-MODE only — use: flowiq bc send <org> --tag <tag> --template <name> (the python engine sends carousels; per-row CSV carousel sends are not supported)");
|
|
388
506
|
// Media headers (image/video/document) ARE supported — they just need an image
|
|
389
507
|
// URL, exactly like the dashboard. Resolved as --header-media / saved mapping /
|
|
390
508
|
// the template's stored default_url. Only block if a media header has none.
|
|
@@ -492,11 +610,54 @@ function renderPreview(template, sample, headerMedia) {
|
|
|
492
610
|
}
|
|
493
611
|
}
|
|
494
612
|
|
|
495
|
-
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
// CSV → python fan-out builders (v0.6.0). Exported for the test harness.
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
/** The campaign's contact tag — what python resolves the audience from. */
|
|
618
|
+
export const csvCampaignTag = (campaign) => `bc-${campaign}`;
|
|
619
|
+
|
|
620
|
+
/** Campaign-scoped attribute key for one template slot ("param1".."paramN" | "button"). */
|
|
621
|
+
export const csvAttrKey = (campaign, slot) => `bc_${campaign}_${slot}`;
|
|
622
|
+
|
|
623
|
+
/** workSet rows → /cli/contacts-upsert contact rows: tag + per-row values as
|
|
624
|
+
* attributes. full_name only when the CSV actually carries a non-empty name
|
|
625
|
+
* (the RPC COALESCEs, so an omitted name can never null a real one). */
|
|
626
|
+
export function buildCsvContactRows(workSet, campaign) {
|
|
627
|
+
const tag = csvCampaignTag(campaign);
|
|
628
|
+
return workSet.map((r) => {
|
|
629
|
+
const attributes = {};
|
|
630
|
+
for (const [k, v] of Object.entries(r.values)) {
|
|
631
|
+
// Same structural guard as the old per-row payload builder (V-12).
|
|
632
|
+
if (!/^param\d+$/.test(k)) throw new Error(`internal: stray value key "${k}" — refusing (V-12)`);
|
|
633
|
+
attributes[csvAttrKey(campaign, k)] = v;
|
|
634
|
+
}
|
|
635
|
+
if (r.buttonValue != null) attributes[csvAttrKey(campaign, "button")] = r.buttonValue;
|
|
636
|
+
return {
|
|
637
|
+
whatsapp_id: r.number,
|
|
638
|
+
...(typeof r.contactName === "string" && r.contactName.trim() ? { full_name: r.contactName.trim() } : {}),
|
|
639
|
+
tags: [tag],
|
|
640
|
+
attributes,
|
|
641
|
+
};
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The ONE shared param set handed to python — every value is an
|
|
646
|
+
* {{attributes.bc_<campaign>_slot}} token python resolves per contact. */
|
|
647
|
+
export function buildCsvTokenParams(template, campaign) {
|
|
648
|
+
const body = {};
|
|
649
|
+
for (const pos of template.body_positions) {
|
|
650
|
+
body[`param${pos}`] = `{{attributes.${csvAttrKey(campaign, `param${pos}`)}}}`;
|
|
651
|
+
}
|
|
652
|
+
const button = template.url_button?.present ? `{{attributes.${csvAttrKey(campaign, "button")}}}` : null;
|
|
653
|
+
return { body, button };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function upsertContacts(orgId, contactRows, batchCap) {
|
|
496
657
|
let created = 0, updated = 0, skippedN = 0, dropped = 0;
|
|
497
|
-
const uniq =
|
|
658
|
+
const uniq = contactRows;
|
|
498
659
|
for (let i = 0; i < uniq.length; i += batchCap) {
|
|
499
|
-
const chunk = uniq.slice(i, i + batchCap)
|
|
660
|
+
const chunk = uniq.slice(i, i + batchCap);
|
|
500
661
|
let resp = null, lastErr = null;
|
|
501
662
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
502
663
|
try { resp = await http.post("contacts-upsert", { organization_id: orgId, contacts: chunk }); break; }
|
|
@@ -639,9 +800,16 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
639
800
|
let sendable = await applySafetyExclusions(orgId, valid, skipped);
|
|
640
801
|
if (opts.limit) sendable = sendable.slice(0, Number(opts.limit));
|
|
641
802
|
|
|
642
|
-
// resume folding
|
|
643
|
-
const { header: logHeader, map: statusMap } = await loadStatusLog(campaign);
|
|
803
|
+
// resume folding (pre-v0.6.0 per-row campaigns only — python campaigns have no per-row log)
|
|
804
|
+
const { header: logHeader, map: statusMap, handoff } = await loadStatusLog(campaign);
|
|
644
805
|
if (isResume) {
|
|
806
|
+
if (handoff) {
|
|
807
|
+
console.error(`Campaign "${campaign}" already fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
|
|
808
|
+
console.error("There is no per-row resume on the python engine — python owns the send. Audit / re-fire failures with:");
|
|
809
|
+
console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
|
|
810
|
+
console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
|
|
811
|
+
process.exit(1);
|
|
812
|
+
}
|
|
645
813
|
if (!logHeader) { console.error(`No status log for campaign "${campaign}" — nothing to resume.`); process.exit(1); }
|
|
646
814
|
if (logHeader.csv_fingerprint && logHeader.csv_fingerprint !== csv.header_fingerprint) {
|
|
647
815
|
console.error("CSV headers changed since this campaign started — start a NEW campaign instead of resuming.");
|
|
@@ -668,37 +836,142 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
668
836
|
console.log(`Summary: ${sendable.length} sendable · skipped ${skipped.length} (${Object.entries(skippedByReason).map(([k, v]) => `${k}: ${v}`).join(", ") || "none"})`);
|
|
669
837
|
if (statusMap.size) console.log(`Status log: ${[...statusMap.values()].filter((s) => s.status === "sent").length} already sent · work set ${workSet.length}`);
|
|
670
838
|
if (ambiguous.length) console.log(`⚠ ${ambiguous.length} number(s) have an AMBIGUOUS in-flight status (crash mid-send?) — never auto-resent: ${ambiguous.slice(0, 5).join(", ")}${ambiguous.length > 5 ? "…" : ""}. Check the chat, then edit the status log if they must be retried.`);
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
839
|
+
// ── OLD-STYLE RESUME (pre-v0.6.0 per-row campaign): finish it on the Node loop ──
|
|
840
|
+
if (isResume) {
|
|
841
|
+
const rate = Math.min(Number(opts.rate ?? 8) || 8, HARD_RATE_CAP);
|
|
842
|
+
console.log(`Send plan: ${workSet.length} messages @ ≤${rate}/s (per-row Node loop — pre-v0.6.0 campaign)`);
|
|
843
|
+
if (!commitStage) {
|
|
844
|
+
console.log("");
|
|
845
|
+
console.log("DRY RUN — nothing was sent, no contacts written. Add --commit to send.");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (!workSet.length) { console.log("Nothing to send — all rows are already sent/skipped."); return; }
|
|
849
|
+
if (!opts.yes) {
|
|
850
|
+
console.log("");
|
|
851
|
+
const answer = await ask(`Type the campaign name ("${campaign}") to send ${workSet.length} message(s): `);
|
|
852
|
+
if (answer !== campaign) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
|
|
853
|
+
}
|
|
854
|
+
await executeSendLoop({
|
|
855
|
+
orgId, campaign, templateName, workSet, statusMap, rate, headerMedia,
|
|
856
|
+
skippedCount: skipped.length,
|
|
857
|
+
writeMetaHeader: !logHeader,
|
|
858
|
+
metaExtras: { csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length },
|
|
859
|
+
});
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ── FRESH SEND: python server-side fan-out (v0.6.0) ────────────────────────
|
|
864
|
+
// Per-row values ride as campaign-scoped contact attributes; ONE python
|
|
865
|
+
// /meta-broadcast call resolves {{attributes.bc_<campaign>_paramN}} per
|
|
866
|
+
// contact and paces the send server-side. No laptop in the loop.
|
|
867
|
+
const tag = csvCampaignTag(campaign);
|
|
868
|
+
const tokens = buildCsvTokenParams(template, campaign);
|
|
869
|
+
const batchCap = Math.min(Number(opts.upsertBatch ?? CONTACT_BATCH_CAP) || CONTACT_BATCH_CAP, CONTACT_BATCH_CAP);
|
|
870
|
+
if (opts.skipUpsert) console.log("⚠ --skip-upsert is ignored on the python CSV path — the upsert IS how per-row values reach python.");
|
|
871
|
+
console.log(`Engine: PYTHON server-side fan-out (yapi.store/meta-broadcast) — fire-and-forget, python-paced`);
|
|
872
|
+
console.log(`Tag: "${tag}" · per-row values → attributes bc_${campaign}_param1..${template.body_var_count}${tokens.button ? " + _button" : ""}`);
|
|
873
|
+
console.log(`Plan: upsert+tag ${workSet.length} contact(s) in ${Math.ceil(Math.max(1, workSet.length) / batchCap)} batch(es) → one python broadcast`);
|
|
874
|
+
|
|
875
|
+
// A prior fan-off means the tag already reached its audience — re-running
|
|
876
|
+
// resends to EVERY tagged contact (python cannot skip per row).
|
|
877
|
+
if (handoff && !opts.resend) {
|
|
878
|
+
console.log("");
|
|
879
|
+
console.log(`⚠ Campaign "${campaign}" ALREADY fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
|
|
880
|
+
if (!commitStage) {
|
|
881
|
+
console.log(" A --commit re-run would resend to EVERY tagged contact (no per-row skip). Audit with bc status; re-fire failures with bc retry; pass --resend to deliberately resend to all.");
|
|
882
|
+
} else {
|
|
883
|
+
console.error(" Refusing to resend to the whole tag. Audit / re-fire failures with:");
|
|
884
|
+
console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
|
|
885
|
+
console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
|
|
886
|
+
console.error(" To deliberately resend to EVERYONE tagged, pass --resend.");
|
|
887
|
+
process.exit(1);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// A pre-v0.6.0 per-row log means part of the audience was already reached
|
|
891
|
+
// row-by-row — the tag fan-out cannot skip them, so this campaign must be
|
|
892
|
+
// finished on the old engine (bc resume) or restarted under a fresh slug.
|
|
893
|
+
if (statusMap.size > 0) {
|
|
894
|
+
const sentAlready = [...statusMap.values()].filter((s) => s.status === "sent").length;
|
|
895
|
+
if (!commitStage) {
|
|
896
|
+
console.log("");
|
|
897
|
+
console.log(`⚠ This campaign has a pre-v0.6.0 per-row status log (${sentAlready} already sent). A --commit here would be refused — finish it with \`flowiq bc resume\`, or start a fresh --campaign.`);
|
|
898
|
+
} else {
|
|
899
|
+
console.error("");
|
|
900
|
+
console.error(`Refusing: campaign "${campaign}" has a pre-v0.6.0 per-row status log (${sentAlready} row(s) already sent).`);
|
|
901
|
+
console.error("The python fan-out sends to the WHOLE tag and cannot skip already-sent rows. Either:");
|
|
902
|
+
console.error(` flowiq bc resume ${orgId} --campaign ${campaign} --commit # finish on the old per-row engine`);
|
|
903
|
+
console.error(" …or re-run under a NEW --campaign slug (fresh tag, everyone in the CSV gets it).");
|
|
904
|
+
process.exit(1);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
674
907
|
|
|
675
908
|
if (!commitStage) {
|
|
676
909
|
console.log("");
|
|
677
|
-
console.log("DRY RUN — nothing was sent, no contacts written. Add --commit to send.");
|
|
910
|
+
console.log("DRY RUN — nothing was sent, no contacts written or tagged. Add --commit to send.");
|
|
678
911
|
return;
|
|
679
912
|
}
|
|
680
|
-
if (!workSet.length) { console.log("Nothing to send — all rows
|
|
913
|
+
if (!workSet.length) { console.log("Nothing to send — all rows were skipped."); return; }
|
|
681
914
|
|
|
682
|
-
// 8. gate
|
|
915
|
+
// 8. gate (BEFORE any write — the upsert itself tags real contacts)
|
|
683
916
|
if (!opts.yes) {
|
|
684
917
|
console.log("");
|
|
685
|
-
const answer = await ask(`Type the campaign name ("${campaign}") to
|
|
686
|
-
if (answer !== campaign) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
|
|
918
|
+
const answer = await ask(`Type the campaign name ("${campaign}") to tag ${workSet.length} contact(s) and fire ONE python broadcast: `);
|
|
919
|
+
if (answer !== campaign) { console.log("Mismatch — aborted, nothing written, nothing sent."); process.exit(0); }
|
|
687
920
|
}
|
|
688
921
|
|
|
689
|
-
// 9. upsert
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
922
|
+
// 9. upsert: tag + per-row attributes (the fan-out's data plane)
|
|
923
|
+
try { await upsertContacts(orgId, buildCsvContactRows(workSet, campaign), batchCap); }
|
|
924
|
+
catch (e) { console.error(e.message); process.exit(1); }
|
|
925
|
+
|
|
926
|
+
// 10. python dry-run cross-check: what does the tag ACTUALLY resolve to?
|
|
927
|
+
const reqBody = (dryRun) => ({
|
|
928
|
+
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
929
|
+
body_parameters: tokens.body,
|
|
930
|
+
...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
|
|
931
|
+
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
932
|
+
dry_run: dryRun,
|
|
933
|
+
});
|
|
934
|
+
let pyTotal = null;
|
|
935
|
+
try {
|
|
936
|
+
const dry = await http.post("broadcast", reqBody(true));
|
|
937
|
+
pyTotal = dry.total_contacts_found ?? null;
|
|
938
|
+
} catch (e) {
|
|
939
|
+
console.error(`Python dry-run failed after tagging: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
|
|
940
|
+
console.error(`Nothing sent. The ${workSet.length} contact(s) ARE tagged "${tag}" — re-run to retry the fan-off.`);
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
if (pyTotal != null && pyTotal !== workSet.length) {
|
|
944
|
+
if (pyTotal > workSet.length) {
|
|
945
|
+
console.log(`⚠ Tag "${tag}" resolves to ${pyTotal} contact(s) — ${pyTotal - workSet.length} MORE than this CSV (tagged by an earlier run of this campaign?). They will ALSO receive the send.`);
|
|
946
|
+
if (!opts.yes) {
|
|
947
|
+
const extra = await ask(`Type YES to send to all ${pyTotal}: `);
|
|
948
|
+
if (extra !== "YES") { console.log(`Aborted — nothing sent. The tag still carries ${pyTotal} contact(s); use a fresh --campaign for a clean audience.`); process.exit(0); }
|
|
949
|
+
}
|
|
950
|
+
} else {
|
|
951
|
+
console.log(`Note: python resolves ${pyTotal} of ${workSet.length} tagged (the rest are opted-out/blocked server-side).`);
|
|
952
|
+
}
|
|
693
953
|
}
|
|
694
954
|
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
955
|
+
// 11. fire — ONE call; python paces + tracks the rest
|
|
956
|
+
let out;
|
|
957
|
+
try { out = await http.post("broadcast", reqBody(false)); }
|
|
958
|
+
catch (e) { console.error(`Python broadcast failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`); process.exit(1); }
|
|
959
|
+
if (!logHeader) {
|
|
960
|
+
await appendLog(campaign, {
|
|
961
|
+
type: "meta", campaign, organization_id: orgId, template_name: templateName, engine: "python",
|
|
962
|
+
csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length,
|
|
963
|
+
started_at: new Date().toISOString(),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
await appendLog(campaign, {
|
|
967
|
+
type: "handoff", engine: "python", tag, broadcast_id: out.broadcastId ?? out.broadcast_id ?? null,
|
|
968
|
+
tagged: workSet.length, python_total: pyTotal, at: new Date().toISOString(),
|
|
701
969
|
});
|
|
970
|
+
console.log("");
|
|
971
|
+
console.log(`✅ ${out.message || "Broadcast started"}${out.broadcastId ? ` · broadcastId ${out.broadcastId}` : ""}`);
|
|
972
|
+
console.log(` Python is sending in the background to tag "${tag}" (${pyTotal ?? workSet.length} contact(s)) and tracking it under that broadcast id.`);
|
|
973
|
+
console.log(` Delivery: flowiq bc status ${orgId} ${out.broadcastId || "<broadcast_id>"} [--failures]`);
|
|
974
|
+
console.log(` Failures: flowiq bc retry ${orgId} ${out.broadcastId || "<broadcast_id>"} --commit`);
|
|
702
975
|
}
|
|
703
976
|
|
|
704
977
|
/** The paced, write-ahead-logged per-row send loop + final report. */
|
|
@@ -793,12 +1066,13 @@ export function collectKV(pair, mapAcc) {
|
|
|
793
1066
|
* (allow_broadcast=true, not blocked) and sends in the background, returning a
|
|
794
1067
|
* broadcastId. No CLI write-ahead log / resume for this engine (python owns the
|
|
795
1068
|
* broadcast record). Dry-run (no --commit) asks python for the eligible count. */
|
|
796
|
-
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
|
|
1069
|
+
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
|
|
797
1070
|
const reqBody = (dryRun) => ({
|
|
798
1071
|
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
799
1072
|
body_parameters: bodyLiterals,
|
|
800
1073
|
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
801
1074
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1075
|
+
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
802
1076
|
dry_run: dryRun,
|
|
803
1077
|
});
|
|
804
1078
|
|
|
@@ -813,6 +1087,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
|
|
|
813
1087
|
console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
|
|
814
1088
|
const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
|
|
815
1089
|
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1090
|
+
printCarouselPlan(cardOverrides, cardLines);
|
|
816
1091
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
817
1092
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
|
|
818
1093
|
}
|
|
@@ -867,12 +1142,28 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
867
1142
|
? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
|
|
868
1143
|
: null;
|
|
869
1144
|
const headerCheck = await verifyHeaderMedia(template, headerMedia);
|
|
870
|
-
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" : ""}`);
|
|
1145
|
+
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" : ""}${template.is_carousel ? `, CAROUSEL (${template.carousel_cards?.length ?? "?"} cards)` : ""}`);
|
|
871
1146
|
const aborts = [];
|
|
872
1147
|
if (headerCheck.status === "mismatch") aborts.push(headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia }));
|
|
873
1148
|
if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
|
|
874
1149
|
if (template.parameter_format !== "POSITIONAL") aborts.push("NAMED templates are not supported in v1");
|
|
875
|
-
|
|
1150
|
+
// Carousel templates (18 Aug 2026): supported in tag mode via the python
|
|
1151
|
+
// engine. Resolve + verify every card BEFORE any other gate so a dry run
|
|
1152
|
+
// reports the full card plan (or every card problem at once).
|
|
1153
|
+
let carouselPlan = null;
|
|
1154
|
+
if (template.is_carousel) {
|
|
1155
|
+
let cardsFileEntries = null;
|
|
1156
|
+
let cardsFileBroken = false;
|
|
1157
|
+
if (opts.cardsFile) {
|
|
1158
|
+
try { cardsFileEntries = JSON.parse(await fs.readFile(opts.cardsFile, "utf8")); }
|
|
1159
|
+
catch (e) { aborts.push(`--cards-file ${opts.cardsFile}: ${e.message}`); cardsFileBroken = true; }
|
|
1160
|
+
}
|
|
1161
|
+
if (!cardsFileBroken) {
|
|
1162
|
+
carouselPlan = await resolveCarouselCards(template, { cardMediaFlags: opts.cardMedia || [], cardsFileEntries });
|
|
1163
|
+
for (const w of carouselPlan.warnings) console.log(`⚠ ${w}`);
|
|
1164
|
+
aborts.push(...carouselPlan.aborts);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
876
1167
|
// Media headers supported — need an image (--header-media / saved / default_url).
|
|
877
1168
|
if (isMediaHeader && !headerMedia) aborts.push(`template has a ${template.header_type} header but no image resolved — pass --header-media <url> (the template has no stored default header image)`);
|
|
878
1169
|
const keys = Object.keys(bodyLiterals);
|
|
@@ -885,14 +1176,22 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
885
1176
|
// gated by exactly the same checks as an immediate one (APPROVED, positional,
|
|
886
1177
|
// param arithmetic, header-media type). Instead of sending we queue the send
|
|
887
1178
|
// for later; python resolves the tag at FIRE time, so the audience is fresh.
|
|
888
|
-
|
|
1179
|
+
const cardOverrides = carouselPlan?.overrides ?? null;
|
|
1180
|
+
const cardLines = carouselPlan?.lines ?? [];
|
|
1181
|
+
|
|
1182
|
+
if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
|
|
889
1183
|
|
|
890
1184
|
// ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
|
|
891
1185
|
// python /meta-broadcast engine (the proven bulk sender). --python forces it at
|
|
892
1186
|
// any size; only a ≤10 send stays on the resumable per-row Node engine.
|
|
1187
|
+
// Carousels are python at ANY size — the per-row Node engine
|
|
1188
|
+
// (/api/send-template) has no carousel support.
|
|
893
1189
|
const PYTHON_MIN = 10;
|
|
894
|
-
if (opts.python) {
|
|
895
|
-
|
|
1190
|
+
if (template.is_carousel && !opts.python) {
|
|
1191
|
+
console.log("Carousel template → PYTHON engine at any size (the per-row Node engine cannot send carousels).");
|
|
1192
|
+
}
|
|
1193
|
+
if (opts.python || template.is_carousel) {
|
|
1194
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
|
|
896
1195
|
}
|
|
897
1196
|
|
|
898
1197
|
// resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
|
|
@@ -985,6 +1284,10 @@ export async function map(orgId, opts = {}) {
|
|
|
985
1284
|
let intro;
|
|
986
1285
|
try { intro = await introspect(orgId, opts.template); }
|
|
987
1286
|
catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
1287
|
+
if (intro.template.is_carousel) {
|
|
1288
|
+
console.error("Carousel templates are TAG-MODE only (no CSV mapping) — use: flowiq bc send <org> --tag <tag> --template <name>.");
|
|
1289
|
+
process.exit(1);
|
|
1290
|
+
}
|
|
988
1291
|
let csv;
|
|
989
1292
|
try { csv = await loadCsv(opts.csv); }
|
|
990
1293
|
catch (e) { console.error(`CSV error: ${e.message}`); process.exit(1); }
|
|
@@ -1050,7 +1353,7 @@ const fmtSast = (iso) =>
|
|
|
1050
1353
|
new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
|
|
1051
1354
|
|
|
1052
1355
|
/** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
|
|
1053
|
-
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
|
|
1356
|
+
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
|
|
1054
1357
|
const when = parseSastAt(opts.at);
|
|
1055
1358
|
if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
|
|
1056
1359
|
if (new Date(when.iso).getTime() <= Date.now()) {
|
|
@@ -1061,13 +1364,14 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1061
1364
|
|
|
1062
1365
|
console.log("");
|
|
1063
1366
|
renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1367
|
+
printCarouselPlan(cardOverrides, cardLines);
|
|
1064
1368
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
1065
1369
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
|
|
1066
1370
|
}
|
|
1067
1371
|
console.log("");
|
|
1068
1372
|
console.log(`Schedule: ${fmtSast(when.iso)} SAST${when.explicitOffset ? "" : " (--at read as SAST)"}`);
|
|
1069
1373
|
console.log(`Audience: tag "${tag}" — resolved when it FIRES, not now (so late joiners are included)`);
|
|
1070
|
-
console.log(`Engine: python /meta-broadcast`);
|
|
1374
|
+
console.log(`Engine: python /meta-broadcast${cardOverrides ? ` · carousel, ${cardOverrides.length} cards (frozen into the queued request)` : ""}`);
|
|
1071
1375
|
console.log(needsApproval
|
|
1072
1376
|
? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
|
|
1073
1377
|
: `Approval: none — fires automatically at the scheduled time`);
|
|
@@ -1090,6 +1394,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1090
1394
|
body_parameters: bodyLiterals,
|
|
1091
1395
|
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
1092
1396
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1397
|
+
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1093
1398
|
});
|
|
1094
1399
|
} catch (e) {
|
|
1095
1400
|
console.error(`Schedule failed: ${e.message}`);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `flowiq hours log <org> --minutes N|--hours H --desc "…"` / `list <org>` /
|
|
2
|
+
// `summary`. The client-hours ledger: every org's package includes 10 hours of
|
|
3
|
+
// Flowapt work per calendar month, and every piece of client work gets logged
|
|
4
|
+
// against it (manually here, or automatically by Claude via the FlowIQ MCP).
|
|
5
|
+
// All writes happen server-side (/cli/hours) with the staff token.
|
|
6
|
+
|
|
7
|
+
import { http } from "../http.js";
|
|
8
|
+
|
|
9
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
|
|
11
|
+
|
|
12
|
+
function fmtHours(minutes) {
|
|
13
|
+
const h = minutes / 60;
|
|
14
|
+
return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireOrg(orgId) {
|
|
18
|
+
if (!UUID_RE.test(orgId || "")) {
|
|
19
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <search>\`).`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requireMonth(month) {
|
|
25
|
+
if (month && !MONTH_RE.test(month)) {
|
|
26
|
+
console.error(`Error: --month must be YYYY-MM (got "${month}").`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** --minutes and --hours are alternatives; exactly one must yield an integer minute count. */
|
|
32
|
+
function resolveMinutes(opts) {
|
|
33
|
+
const hasMinutes = opts.minutes !== undefined;
|
|
34
|
+
const hasHours = opts.hours !== undefined;
|
|
35
|
+
if (hasMinutes === hasHours) {
|
|
36
|
+
console.error("Error: pass exactly one of --minutes <n> or --hours <h> (e.g. --hours 1.5).");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
let minutes;
|
|
40
|
+
if (hasMinutes) {
|
|
41
|
+
minutes = Number(opts.minutes);
|
|
42
|
+
if (!Number.isInteger(minutes)) {
|
|
43
|
+
console.error(`Error: --minutes must be a whole number (got "${opts.minutes}"). Use --hours for fractions.`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
const h = Number(opts.hours);
|
|
48
|
+
if (!Number.isFinite(h)) {
|
|
49
|
+
console.error(`Error: --hours must be a number (got "${opts.hours}").`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
minutes = Math.round(h * 60);
|
|
53
|
+
}
|
|
54
|
+
if (minutes < 1 || minutes > 6000) {
|
|
55
|
+
console.error(`Error: the entry must be between 1 minute and 100 hours (got ${minutes} min).`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
return minutes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function log(orgId, opts) {
|
|
62
|
+
requireOrg(orgId);
|
|
63
|
+
const minutes = resolveMinutes(opts);
|
|
64
|
+
const description = (opts.desc || "").trim();
|
|
65
|
+
if (!description) {
|
|
66
|
+
console.error('Error: --desc "what was done" is required.');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
if (opts.date && !/^\d{4}-\d{2}-\d{2}$/.test(opts.date)) {
|
|
70
|
+
console.error(`Error: --date must be YYYY-MM-DD (got "${opts.date}").`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let resp;
|
|
75
|
+
try {
|
|
76
|
+
resp = await http.post("hours", {
|
|
77
|
+
organization_id: orgId,
|
|
78
|
+
description,
|
|
79
|
+
minutes,
|
|
80
|
+
performed_by: opts.by || undefined,
|
|
81
|
+
session: opts.session || undefined,
|
|
82
|
+
// A bare date logs at noon UTC so it can never slip into a neighbouring
|
|
83
|
+
// day (or month) in any sane timezone rendering.
|
|
84
|
+
performed_at: opts.date ? `${opts.date}T12:00:00Z` : undefined,
|
|
85
|
+
});
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error(`Log failed: ${e.message}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const used = resp.month_total_minutes;
|
|
92
|
+
const allowance = resp.allowance_hours * 60;
|
|
93
|
+
console.log(`Logged ${fmtHours(minutes)} for ${resp.organization.name}`);
|
|
94
|
+
console.log(` entry: ${resp.id}`);
|
|
95
|
+
console.log(` month: ${resp.month} — ${fmtHours(used)} of ${resp.allowance_hours}h package used${used > allowance ? ` ⚠ ${fmtHours(used - allowance)} OVER` : ` (${fmtHours(allowance - used)} left)`}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function list(orgId, opts) {
|
|
99
|
+
requireOrg(orgId);
|
|
100
|
+
requireMonth(opts.month);
|
|
101
|
+
let resp;
|
|
102
|
+
try {
|
|
103
|
+
resp = await http.get("hours", { organization_id: orgId, month: opts.month });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.error(`List failed: ${e.message}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if (opts.json) {
|
|
109
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const over = resp.remaining_minutes < 0;
|
|
113
|
+
console.log(`${resp.organization.name} — ${resp.month}`);
|
|
114
|
+
console.log(` package: ${resp.allowance_hours}h/month · used ${fmtHours(resp.total_minutes)} · ${over ? `⚠ ${fmtHours(-resp.remaining_minutes)} OVER` : `${fmtHours(resp.remaining_minutes)} left`}`);
|
|
115
|
+
if (!resp.entries.length) {
|
|
116
|
+
console.log(" (no entries this month)");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log("");
|
|
120
|
+
for (const e of resp.entries) {
|
|
121
|
+
const when = String(e.performed_at).slice(0, 10);
|
|
122
|
+
const src = e.source === "manual" ? "ui" : e.source;
|
|
123
|
+
console.log(` ${when} ${fmtHours(e.minutes).padStart(6)} [${src}] ${e.performed_by} — ${e.description}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function summary(opts) {
|
|
128
|
+
requireMonth(opts.month);
|
|
129
|
+
let resp;
|
|
130
|
+
try {
|
|
131
|
+
resp = await http.get("hours", { summary: "1", month: opts.month });
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.error(`Summary failed: ${e.message}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
if (opts.json) {
|
|
137
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!resp.organizations.length) {
|
|
141
|
+
console.log(`(no hours logged for any org in ${resp.month})`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const allowance = resp.allowance_hours * 60;
|
|
145
|
+
console.log(`Client hours — ${resp.month} (package: ${resp.allowance_hours}h/month per org)\n`);
|
|
146
|
+
for (const o of resp.organizations) {
|
|
147
|
+
const flag = o.total_minutes > allowance ? ` ⚠ ${fmtHours(o.total_minutes - allowance)} OVER` : "";
|
|
148
|
+
console.log(` ${fmtHours(o.total_minutes).padStart(6)} (${String(o.entries).padStart(2)} entr${o.entries === 1 ? "y" : "ies"}) ${o.name || o.id}${flag}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/commands/messages.js
CHANGED
|
@@ -56,8 +56,11 @@ export async function pull(contactId, opts = {}) {
|
|
|
56
56
|
if (resp.organization_name) {
|
|
57
57
|
console.log(` organization: ${resp.organization_name} (${resp.organization_id})`);
|
|
58
58
|
}
|
|
59
|
-
console.log(
|
|
60
|
-
|
|
59
|
+
console.log(
|
|
60
|
+
` user msgs: ${resp.user_messages_found}/${resp.user_messages_requested} requested` +
|
|
61
|
+
(resp.reached_start ? ` — that is ALL of them; this pull is the entire history` : '')
|
|
62
|
+
);
|
|
63
|
+
console.log(` anchor: ${resp.anchor_created_at}${resp.reached_start ? ' (start of history)' : ''}`);
|
|
61
64
|
console.log(` total rows: ${resp.total_rows}`);
|
|
62
65
|
console.log(` by sender:`);
|
|
63
66
|
for (const [k, v] of Object.entries(resp.sender_breakdown).sort()) {
|
|
@@ -97,8 +97,15 @@ export async function push(identifier, opts = {}) {
|
|
|
97
97
|
try {
|
|
98
98
|
resp = await http.post("messaging-webhooks", {
|
|
99
99
|
organization_id: data.organization_id,
|
|
100
|
+
// auth_type / auth_config / headers / secret ride along deliberately:
|
|
101
|
+
// push is FULL-REPLACE (delete-all then insert), so dropping them here
|
|
102
|
+
// would silently strip every webhook's credentials on the next push.
|
|
100
103
|
messaging_webhooks: data.messaging_webhooks.map((w) => ({
|
|
101
104
|
url: w.url, type: w.type, platform: w.platform,
|
|
105
|
+
auth_type: w.auth_type ?? "none",
|
|
106
|
+
auth_config: w.auth_config ?? null,
|
|
107
|
+
headers: w.headers ?? null,
|
|
108
|
+
secret: w.secret ?? null,
|
|
102
109
|
})),
|
|
103
110
|
dry_run: !!opts.dryRun,
|
|
104
111
|
});
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as webhooksCmd from "./commands/webhooks.js";
|
|
|
15
15
|
import * as flowmodCmd from "./commands/flowmod.js";
|
|
16
16
|
import * as groupsCmd from "./commands/groups.js";
|
|
17
17
|
import * as pinboardCmd from "./commands/pinboard.js";
|
|
18
|
+
import * as hoursCmd from "./commands/hours.js";
|
|
18
19
|
import * as templatesCmd from "./commands/templates.js";
|
|
19
20
|
import * as orgCmd from "./commands/org.js";
|
|
20
21
|
import * as agentConfigCmd from "./commands/agent-config.js";
|
|
@@ -191,6 +192,29 @@ export function run(argv) {
|
|
|
191
192
|
.description("List every task in the DB (optionally filter: open | in_progress | done) to find an id")
|
|
192
193
|
.action((status) => pinboardCmd.listRemote(status));
|
|
193
194
|
|
|
195
|
+
// hours (client-hours ledger — every org's package = 10h Flowapt work/month)
|
|
196
|
+
const hours = program.command("hours")
|
|
197
|
+
.description("Client-hours ledger: log + report Flowapt work against each org's 10h/month package");
|
|
198
|
+
hours.command("log <organization_id>")
|
|
199
|
+
.description("Log work done for a client org (exactly one of --minutes / --hours)")
|
|
200
|
+
.option("--minutes <n>", "duration in whole minutes")
|
|
201
|
+
.option("--hours <h>", "duration in hours (fractions fine, e.g. 1.5)")
|
|
202
|
+
.option("--desc <text>", "what was done, in plain language the client could read")
|
|
203
|
+
.option("--by <name>", "who did the work (default: your staff login email)")
|
|
204
|
+
.option("--session <name>", "session/task name the work happened in")
|
|
205
|
+
.option("--date <YYYY-MM-DD>", "when the work was done (default: now)")
|
|
206
|
+
.action((orgId, opts) => hoursCmd.log(orgId, opts));
|
|
207
|
+
hours.command("list <organization_id>")
|
|
208
|
+
.description("One org's entries + package standing for a month (default: current)")
|
|
209
|
+
.option("--month <YYYY-MM>", "which month to report")
|
|
210
|
+
.option("--json", "raw JSON output")
|
|
211
|
+
.action((orgId, opts) => hoursCmd.list(orgId, opts));
|
|
212
|
+
hours.command("summary")
|
|
213
|
+
.description("Cross-org month totals — every org with logged work, vs the 10h package")
|
|
214
|
+
.option("--month <YYYY-MM>", "which month to report")
|
|
215
|
+
.option("--json", "raw JSON output")
|
|
216
|
+
.action((opts) => hoursCmd.summary(opts));
|
|
217
|
+
|
|
194
218
|
// templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
|
|
195
219
|
const templates = program.command("templates")
|
|
196
220
|
.alias("tpl")
|
|
@@ -432,6 +456,8 @@ export function run(argv) {
|
|
|
432
456
|
.option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
|
|
433
457
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
434
458
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
459
|
+
.option("--card-media <url>", "with --tag, carousel templates: card image/video URL in card order (repeatable — one per card; default: each card's stored template image)", (v, acc) => (acc || []).concat([v]), [])
|
|
460
|
+
.option("--cards-file <path>", "with --tag, carousel templates: JSON file of per-card overrides [{header_media?, body_params?, button_payloads?, url_vars?}] — required when cards carry {{n}} body variables or URL-button variables")
|
|
435
461
|
.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")
|
|
436
462
|
.option("--at <when>", "with --tag: SCHEDULE instead of sending now — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). Fires automatically; audience is resolved at send time")
|
|
437
463
|
.option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
|
|
@@ -439,11 +465,12 @@ export function run(argv) {
|
|
|
439
465
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
440
466
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
441
467
|
.option("--illegal-chars <mode>", "reject | strip — newlines/tabs/4+ spaces in values (Meta #100)", "reject")
|
|
442
|
-
.option("--rate <n>", "max messages per second
|
|
468
|
+
.option("--rate <n>", "max messages per second — ONLY the pre-v0.6.0 per-row resume path; python paces fresh sends itself", "8")
|
|
443
469
|
.option("--upsert-batch <n>", "contacts per upsert call (max 4000)", "4000")
|
|
444
|
-
.option("--skip-upsert", "
|
|
445
|
-
.option("--limit <n>", "only process the first N valid rows (smoke
|
|
446
|
-
.option("--
|
|
470
|
+
.option("--skip-upsert", "pre-v0.6.0 resume only — ignored on fresh CSV sends (the upsert IS how per-row values reach python)")
|
|
471
|
+
.option("--limit <n>", "only process the first N valid rows (smoke-test under a THROWAWAY --campaign — a limited python fan-off still records the campaign as fired)")
|
|
472
|
+
.option("--resend", "re-fire a CSV campaign that already fanned out via python — resends to EVERY tagged contact, incl. those already reached (bc retry re-fires failures only)")
|
|
473
|
+
.option("--resume", "continue a pre-v0.6.0 per-row campaign from its status log (python campaigns have no per-row resume — use bc status / bc retry)")
|
|
447
474
|
.option("--retry-failed", "with --resume: also re-attempt rows previously marked failed")
|
|
448
475
|
.action((orgId, opts) => broadcastCmd.send(orgId, opts));
|
|
449
476
|
broadcast.command("resume <organization_id>")
|