@flowapt/flowiq-cli 0.4.7 → 0.4.9
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 +105 -7
- package/TEAM-GUIDE.md +12 -1
- package/package.json +1 -1
- package/src/commands/broadcast.js +151 -9
- package/src/commands/links.js +144 -0
- package/src/commands/messages.js +5 -2
- package/src/index.js +23 -0
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`)
|
|
@@ -319,8 +320,29 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
|
319
320
|
python for the eligible count + a sample. Trade-off vs the default per-row engine:
|
|
320
321
|
no CLI write-ahead-log / `resume` (python owns the broadcast record), and archived
|
|
321
322
|
contacts aren't separately filtered. Media headers work on both engines.
|
|
322
|
-
- **
|
|
323
|
-
|
|
323
|
+
- **Scope**: Meta orgs, POSITIONAL templates, text / no header / media header,
|
|
324
|
+
and **carousel templates (tag mode only, v0.4.9)**. NAMED templates and WATI
|
|
325
|
+
orgs are refused with a clear message; carousels in CSV mode are refused with
|
|
326
|
+
a pointer to tag mode.
|
|
327
|
+
- **Carousel templates (v0.4.9, `send --tag` only, always the python engine):**
|
|
328
|
+
the CLI introspects every card (header format, body variables, buttons) and
|
|
329
|
+
builds the per-card send payload python's `/meta-broadcast` expects. Card
|
|
330
|
+
media defaults to each card's **stored template image** (written by
|
|
331
|
+
`create-meta-template` at creation), overridable with repeatable
|
|
332
|
+
`--card-media <url>` (one per card, in card order); every resolved URL is
|
|
333
|
+
content-type probed against that card's own IMAGE/VIDEO format (mismatch
|
|
334
|
+
aborts, per card). Cards whose body carries `{{n}}` variables, or whose URL
|
|
335
|
+
buttons carry a variable, **require `--cards-file <path>`** — a JSON array
|
|
336
|
+
with one entry per card:
|
|
337
|
+
`[{"header_media":"<url>","body_params":{"param1":"…"},"button_payloads":{"btn0":"…"},"url_vars":{"btn1":"<value>"}}, …]`
|
|
338
|
+
(all keys optional per card; quick-reply payloads default to the button text;
|
|
339
|
+
`{{first_name}}`-style tokens inside card values are resolved per contact).
|
|
340
|
+
Carousel sends route to the **python engine at any size** — the per-row Node
|
|
341
|
+
engine cannot send carousels — so there is no CLI write-ahead log / `resume`
|
|
342
|
+
for them; audit via `bc status <broadcastId>`. `--at` scheduling works: the
|
|
343
|
+
card payload is frozen into the queued request. `bc retry` on a carousel
|
|
344
|
+
broadcast re-resolves card media from the template's stored defaults and does
|
|
345
|
+
NOT replay per-card body/url values — retry only carousels that need none.
|
|
324
346
|
- **Validation before anything sends**: APPROVED-only, every slot mapped,
|
|
325
347
|
contiguous params (the Meta `#132000` guard — a stray key is structurally
|
|
326
348
|
impossible), phone validity, illegal characters (Meta `#100`; `reject` by
|
|
@@ -480,6 +502,47 @@ flowiq tag remove <org_id> vip,old-promo --confirm # remove tag(s) from ALL con
|
|
|
480
502
|
matches — re-run until `matched 0`. Idempotent and safe to re-run.
|
|
481
503
|
- **Undo** any tag with `flowiq tag remove <org> <tag> --confirm`.
|
|
482
504
|
|
|
505
|
+
### Links — `flowiq links shorten|list` (v0.4.8)
|
|
506
|
+
|
|
507
|
+
Short links with campaign UTM tags — the terminal half of the in-app **URL
|
|
508
|
+
Shortener**. Same engine server-side (`api/_url-shorten-engine.js`), so a link
|
|
509
|
+
minted here is identical to one minted in the dialog.
|
|
510
|
+
|
|
511
|
+
```bash
|
|
512
|
+
# Dry run FIRST — shows the exact destination each short link will carry
|
|
513
|
+
flowiq links shorten <org> --url "https://shop.co.za/product/x" \
|
|
514
|
+
--campaign 13Aug_Seeds --content 13Aug_Seeds
|
|
515
|
+
|
|
516
|
+
flowiq links shorten <org> --url "https://shop.co.za/product/x" \
|
|
517
|
+
--url "https://shop.co.za/product/y" \
|
|
518
|
+
--campaign 13Aug_Seeds --content 13Aug_Seeds --domain linklnk.io --commit
|
|
519
|
+
|
|
520
|
+
flowiq links shorten <org> --file ./slide2.txt --campaign 13Aug_Seeds_VM --content 13Aug_Seeds_VM --commit
|
|
521
|
+
flowiq links list <org> --campaign 13Aug_Seeds
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
- **`utm_source=whatsapp` and `utm_medium=whatsapp_paid` are FIXED** (the dialog
|
|
525
|
+
sets them too) — a link built any other way stops matching the attribution
|
|
526
|
+
queries. You supply `--campaign` and `--content`, and they must be **supplied
|
|
527
|
+
together**: a campaign with no content silently under-tags every link.
|
|
528
|
+
- **Dry-run by default.** `--commit` mints. The dry run prints the full
|
|
529
|
+
destination URL per link and whether an identical row already exists.
|
|
530
|
+
- **A new UTM tag = a NEW row with its own code and its own click count.** Reuse
|
|
531
|
+
is keyed on the exact `(org, full_url)` pair, which is what makes per-campaign
|
|
532
|
+
attribution work. Re-running the same command is idempotent (`↻ reused`).
|
|
533
|
+
- **Pasting an existing short link is REFUSED** (`chatcart.io` / `linklnk.io` /
|
|
534
|
+
`yapi.store`) with "pass the destination URL". Shortening a short link would
|
|
535
|
+
add a second hop and split the click count — and would silently keep the OLD
|
|
536
|
+
campaign tag, which is the trap this guard exists for.
|
|
537
|
+
- **`--file`** shortens every URL found in a text file — the fast path for a
|
|
538
|
+
multi-link message body. It does NOT rewrite the file; it prints the mapping.
|
|
539
|
+
- `--domain` picks the short host: `chatcart.io` (default), `linklnk.io`,
|
|
540
|
+
`yapi.store`. All three resolve identically — match whatever that campaign's
|
|
541
|
+
other links already use.
|
|
542
|
+
- `--campaign` on **`list`** is an EXACT match, not a prefix: asking for
|
|
543
|
+
`13Aug_Seeds` does not return the `13Aug_Seeds_VM` rows.
|
|
544
|
+
- Scope group `broadcast`. Audited (`links` / `shorten`); dry runs are not.
|
|
545
|
+
|
|
483
546
|
### Keywords — `flowiq keywords pull|push|list` (alias `kw`)
|
|
484
547
|
|
|
485
548
|
Round-trips an org's **keyword auto-reply engine** — the `keywords` +
|
|
@@ -504,10 +567,10 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
504
567
|
collapses to 1 at runtime — rejected).
|
|
505
568
|
- `field:"attributes"` actions are warned (full jsonb replace; constant
|
|
506
569
|
values only) but applied — this CLI is their only safe editing surface.
|
|
507
|
-
- **Action types accepted** (all
|
|
570
|
+
- **Action types accepted** (all ten the runtime implements):
|
|
508
571
|
`send_message` · `update_contact_field` · `add_contact_tag` ·
|
|
509
572
|
`remove_contact_tag` · `set_agent` · `delay` · `combined` ·
|
|
510
|
-
`update_ticket_status` · `renotify_ticket`.
|
|
573
|
+
`update_ticket_status` · `renotify_ticket` · `assign_chat`.
|
|
511
574
|
Per-type rules: tag actions need a non-empty `tags[]` (or a single `tag`
|
|
512
575
|
string) or the runtime writes no tag at all; `set_agent.agent_id` must be
|
|
513
576
|
an agent UUID, or `null`/`""` to CLEAR the contact's binding (warned, since
|
|
@@ -530,6 +593,18 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
530
593
|
ticket-tool `mode:"renotify"` (60s server-side rate limit; a resolved/closed
|
|
531
594
|
ticket is reopened first) — canonical use: the **"I still need help"**
|
|
532
595
|
button. Both send the action's `text` (if any) after the ticket work.
|
|
596
|
+
- **Assign-chat action** (added 13 Aug 2026, for keyword-driven handovers —
|
|
597
|
+
e.g. a broadcast quick-reply button handing the conversation to a person):
|
|
598
|
+
`{ "type": "assign_chat", "team_id": "<team-uuid>", "assignee_user_id":
|
|
599
|
+
"<user-uuid>", "notify_team": true, "notify_member": true }` — writes the
|
|
600
|
+
contact's `team_id` and/or `assignee` (at least ONE of the two is required,
|
|
601
|
+
both UUID-shaped; `assignee_user_id` is the profiles/auth user id, not an
|
|
602
|
+
`organization_members` id), then invokes `team-assignment-notify` for each
|
|
603
|
+
target unless its `notify_*` flag is explicitly `false`. The notify fn
|
|
604
|
+
re-checks the org's team/member notification feature flags AND the target's
|
|
605
|
+
own notify config (Teams page) server-side, so a target with notifications
|
|
606
|
+
off is assigned silently. Also editable in the dashboard (Keyword Actions →
|
|
607
|
+
action type "Assign Chat").
|
|
533
608
|
- `action_config.link_preview: false` disables WhatsApp's link-preview card
|
|
534
609
|
on that action's text send (absent/`true` = preview on, the default).
|
|
535
610
|
Passed through verbatim; also toggleable per action in the dashboard.
|
|
@@ -570,6 +645,16 @@ flowiq m pull <contact_id> --count 25
|
|
|
570
645
|
|
|
571
646
|
`--count` defaults to 25, max 200.
|
|
572
647
|
|
|
648
|
+
**When you ask for more than exists, you get the WHOLE history (16 Aug 2026).**
|
|
649
|
+
If the contact has fewer `user-*` messages than `--count`, there is nothing
|
|
650
|
+
older to withhold, so the pull returns every row from the first message onward
|
|
651
|
+
and reports `reached_start: true` (`user msgs: 1/25 requested — that is ALL of
|
|
652
|
+
them; this pull is the entire history`). Before this, the anchor sat on the
|
|
653
|
+
oldest inbound message and silently dropped everything before it — so on a
|
|
654
|
+
contact whose conversation OPENS with an outbound (a delivery notification, a
|
|
655
|
+
broadcast), the message that STARTED the conversation was missing while the
|
|
656
|
+
pull still printed success.
|
|
657
|
+
|
|
573
658
|
### Audit log — `flowiq audit [org] | audit show <id>` (v0.3.8)
|
|
574
659
|
|
|
575
660
|
**Who changed what, when — with the full before/after content.** Every
|
|
@@ -990,11 +1075,24 @@ flowiq agent config <organization_id> --test-contact-number 27000000001 --test-c
|
|
|
990
1075
|
Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
|
|
991
1076
|
the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
|
|
992
1077
|
`view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
|
|
993
|
-
`shopify_products_web_chat`, `ticket_tool_status`, `
|
|
1078
|
+
`shopify_products_web_chat`, `ticket_tool_status`, `product_lookup`,
|
|
1079
|
+
`collapse_product_variants`),
|
|
994
1080
|
`discount.enabled`, and the `flowiq test` contact
|
|
995
1081
|
(`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
|
|
996
1082
|
rejected; every change is reported before → after.
|
|
997
1083
|
|
|
1084
|
+
**`--tool product_lookup=true` (added 11 Aug 2026).** Turns on the `product_lookup`
|
|
1085
|
+
tool: a typo-tolerant **pg_trgm fuzzy match on `product_title`** (plus badge/tag
|
|
1086
|
+
search), as opposed to `get_product_info`, which is **semantic**. This matters far
|
|
1087
|
+
beyond typo tolerance: `get_product_info` needs BOTH a populated
|
|
1088
|
+
`products_all.embedding` column AND a working OpenAI key (it embeds the query at
|
|
1089
|
+
call time), so on an org missing either one, **every** product question fails and
|
|
1090
|
+
the agent cannot quote a price, confirm stock, or send a product link.
|
|
1091
|
+
`product_lookup` needs neither and reads the row directly. Reach for it whenever an
|
|
1092
|
+
agent answers product questions with "I can't pull the live menu". Found on Ouma
|
|
1093
|
+
Bets Gebak (11 Aug 2026): 25 product rows, 0 embeddings, and an unfunded OpenAI
|
|
1094
|
+
account — enabling this flag restored product answers immediately.
|
|
1095
|
+
|
|
998
1096
|
**`--tool collapse_product_variants=true` (added 4 Aug 2026).** Not a tool toggle —
|
|
999
1097
|
it changes what `get_product_info` RETURNS. OFF (the default) the result cap counts
|
|
1000
1098
|
**variant rows**, so on a catalogue with several packaging/size variants per product
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -80,6 +80,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
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
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
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 |
|
|
83
|
+
| Make a keyword/button **hand the chat to a team or person** (assign in the inbox + email/WhatsApp them) | keyword action `{"type":"assign_chat","team_id":"…","assignee_user_id":"…","notify_member":true}` — see *Keywords* in `flowiq guide --reference`. Also in the dashboard (action type "Assign Chat") |
|
|
83
84
|
| **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
|
|
84
85
|
| What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0 |
|
|
85
86
|
| Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
|
|
@@ -93,8 +94,13 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
93
94
|
| See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
|
|
94
95
|
| Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
|
|
95
96
|
| Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
|
|
97
|
+
| Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
|
|
96
98
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
97
99
|
| Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
|
|
100
|
+
| **Make short links for a campaign** (with UTM tracking) | `flowiq links shorten <org_id> --url "https://shop.co.za/product/x" --campaign 13Aug_Seeds --content 13Aug_Seeds` (dry run) → `… --commit`. `utm_source`/`utm_medium` are set for you; campaign + content must be given together |
|
|
101
|
+
| Short links for every URL in a message | `flowiq links shorten <org_id> --file ./message.txt --campaign X --content X --commit` |
|
|
102
|
+
| Check which links a campaign has, and their clicks | `flowiq links list <org_id> --campaign 13Aug_Seeds` |
|
|
103
|
+
| 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 |
|
|
98
104
|
| Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
|
|
99
105
|
| 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` |
|
|
100
106
|
| Tag everyone who bought a product (accurate, windowable) | `flowiq seg plan <org_id> --tag-prefix whey --bought "Whey" --window 90d` → `flowiq seg apply … --commit` |
|
|
@@ -108,6 +114,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
108
114
|
| 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` |
|
|
109
115
|
| 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). |
|
|
110
116
|
| 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. |
|
|
117
|
+
| 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. |
|
|
111
118
|
| 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.** |
|
|
112
119
|
| 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) |
|
|
113
120
|
| 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`) |
|
|
@@ -117,7 +124,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
117
124
|
| **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
|
|
118
125
|
| See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
|
|
119
126
|
| 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. |
|
|
120
|
-
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
127
|
+
| 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 |
|
|
121
128
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
|
122
129
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
|
123
130
|
| Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
|
|
@@ -162,6 +169,10 @@ flowiq ct push <slug>
|
|
|
162
169
|
Custom tools define real HTTP calls the agent can execute, so the server
|
|
163
170
|
validates hard (names, URLs, methods, parameter shapes) and warns about typo'd
|
|
164
171
|
keys or `{{placeholders}}` it doesn't recognise. Take the warnings seriously.
|
|
172
|
+
Nested object/array schemas are supported. For ChatCart retailer tools, use
|
|
173
|
+
`{{retailer_tools_internal_key}}` only as the `x-api-key` auth value on the
|
|
174
|
+
trusted `express.chatcart.io/retailer-tools/*` gateway; org/contact identity is
|
|
175
|
+
injected server-side and mutations use `{{whatsapp_message_id}}` for idempotency.
|
|
165
176
|
|
|
166
177
|
### Example: tag a segment of contacts (Advanced Tagging)
|
|
167
178
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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": {
|
|
@@ -380,11 +380,121 @@ function headerMismatchMessage(template, headerMedia, contentType, { explicit })
|
|
|
380
380
|
: `${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
381
|
}
|
|
382
382
|
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// carousel card resolution (18 Aug 2026 — tag mode, python engine only)
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Builds the card_overrides[] payload python's /meta-broadcast expects: one
|
|
387
|
+
// entry per card, in card order. Media precedence per card: --cards-file entry
|
|
388
|
+
// header_media → --card-media (positional) → the template's stored default
|
|
389
|
+
// (template_data.carousel.cards[i].header_url, surfaced by introspect). Every
|
|
390
|
+
// resolved URL is content-type probed against that CARD's own header format —
|
|
391
|
+
// a carousel can mix IMAGE and VIDEO cards, so this is per-card, not global.
|
|
392
|
+
// Cards with {{n}} body variables or URL-button variables REQUIRE a
|
|
393
|
+
// --cards-file entry (python emits '' for a missing url var → Meta rejects the
|
|
394
|
+
// whole message, so we refuse client-side). Exported for the test harness.
|
|
395
|
+
|
|
396
|
+
export async function resolveCarouselCards(template, { cardMediaFlags = [], cardsFileEntries = null } = {}, probe = probeContentType) {
|
|
397
|
+
const cards = template.carousel_cards || [];
|
|
398
|
+
const aborts = [], warnings = [], lines = [];
|
|
399
|
+
const overrides = [];
|
|
400
|
+
if (!cards.length) {
|
|
401
|
+
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");
|
|
402
|
+
return { aborts, warnings, overrides: null, lines };
|
|
403
|
+
}
|
|
404
|
+
if (cardMediaFlags.length && cardMediaFlags.length !== cards.length) {
|
|
405
|
+
aborts.push(`--card-media given ${cardMediaFlags.length} time(s) but the template has ${cards.length} cards — pass one per card, in card order`);
|
|
406
|
+
}
|
|
407
|
+
if (cardsFileEntries !== null && (!Array.isArray(cardsFileEntries) || cardsFileEntries.length !== cards.length)) {
|
|
408
|
+
aborts.push(`--cards-file must be a JSON array with exactly ${cards.length} entries (one per card, in card order)`);
|
|
409
|
+
}
|
|
410
|
+
if (aborts.length) return { aborts, warnings, overrides: null, lines };
|
|
411
|
+
|
|
412
|
+
for (let i = 0; i < cards.length; i++) {
|
|
413
|
+
const card = cards[i];
|
|
414
|
+
const fileEntry = (cardsFileEntries?.[i] && typeof cardsFileEntries[i] === "object") ? cardsFileEntries[i] : {};
|
|
415
|
+
const media = fileEntry.header_media || cardMediaFlags[i] || card.header_media_default || null;
|
|
416
|
+
if (!media) {
|
|
417
|
+
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`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const contentType = await probe(media);
|
|
421
|
+
let mediaLabel;
|
|
422
|
+
if (!contentType) {
|
|
423
|
+
warnings.push(`card ${i + 1}: media unverified — ${media} did not answer a type probe`);
|
|
424
|
+
mediaLabel = `${card.header_format} · unverified`;
|
|
425
|
+
} else if (contentType.split("/")[0] !== card.header_format) {
|
|
426
|
+
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}`);
|
|
427
|
+
continue;
|
|
428
|
+
} else {
|
|
429
|
+
mediaLabel = `${card.header_format} ✓ ${contentType}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Per-card body variables must be supplied — python sends only what we pass.
|
|
433
|
+
let bodyParams = null;
|
|
434
|
+
if (card.body_positions.length) {
|
|
435
|
+
bodyParams = fileEntry.body_params && typeof fileEntry.body_params === "object" ? fileEntry.body_params : null;
|
|
436
|
+
const badKeys = bodyParams ? Object.keys(bodyParams).filter((k) => !/^param\d+$/.test(k)) : [];
|
|
437
|
+
if (badKeys.length) { aborts.push(`card ${i + 1}: body_params keys must be param1..N (got ${badKeys.join(", ")})`); continue; }
|
|
438
|
+
const missing = card.body_positions.filter((p) => !bodyParams?.[`param${p}`]);
|
|
439
|
+
if (missing.length) {
|
|
440
|
+
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.`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// URL buttons WITH a variable need a send-side value; static URL / QUICK_REPLY do not
|
|
446
|
+
// (quick-reply payloads default to the button text server-side).
|
|
447
|
+
const urlVarButtons = (card.buttons || []).filter((b) => b.type === "URL" && b.has_url_var);
|
|
448
|
+
let urlVars = null;
|
|
449
|
+
if (urlVarButtons.length) {
|
|
450
|
+
urlVars = fileEntry.url_vars && typeof fileEntry.url_vars === "object" ? fileEntry.url_vars : null;
|
|
451
|
+
const missing = urlVarButtons.filter((b) => {
|
|
452
|
+
const v = urlVars?.[`btn${b.index}`] ?? urlVars?.[String(b.index)];
|
|
453
|
+
return v === undefined || v === null || String(v) === "";
|
|
454
|
+
});
|
|
455
|
+
if (missing.length) {
|
|
456
|
+
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(",")}}})`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const buttonPayloads = fileEntry.button_payloads && typeof fileEntry.button_payloads === "object" ? fileEntry.button_payloads : null;
|
|
461
|
+
|
|
462
|
+
overrides.push({
|
|
463
|
+
file_url: media,
|
|
464
|
+
...(bodyParams ? { body_parameters: bodyParams } : {}),
|
|
465
|
+
...(buttonPayloads ? { button_payloads: buttonPayloads } : {}),
|
|
466
|
+
...(urlVars ? { url_vars: urlVars } : {}),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Preview lines for the dry-run.
|
|
470
|
+
let bodyPreview = card.body_text || "";
|
|
471
|
+
for (const [k, v] of Object.entries(bodyParams || {})) bodyPreview = bodyPreview.replaceAll(`{{${k.replace("param", "")}}}`, String(v));
|
|
472
|
+
const btnBits = (card.buttons || []).map((b) => {
|
|
473
|
+
if (b.type === "URL" && b.has_url_var) {
|
|
474
|
+
const v = urlVars?.[`btn${b.index}`] ?? urlVars?.[String(b.index)] ?? "";
|
|
475
|
+
return `[${b.text ?? "URL"} → ${String(b.url || "").replace(/\{\{[^}]+\}\}/, String(v))}]`;
|
|
476
|
+
}
|
|
477
|
+
if (b.type === "URL") return `[${b.text ?? "URL"} → ${b.url}]`;
|
|
478
|
+
return `[${b.text ?? b.type}]`;
|
|
479
|
+
}).join(" ");
|
|
480
|
+
lines.push(` Card ${i + 1} (${mediaLabel}): ${media}`);
|
|
481
|
+
if (bodyPreview) lines.push(` ${bodyPreview.replace(/\n/g, " ")}`);
|
|
482
|
+
if (btnBits) lines.push(` ${btnBits}`);
|
|
483
|
+
}
|
|
484
|
+
return { aborts, warnings, overrides: aborts.length ? null : overrides, lines };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function printCarouselPlan(cardOverrides, cardLines) {
|
|
488
|
+
if (!cardOverrides?.length) return;
|
|
489
|
+
console.log(` Carousel (${cardOverrides.length} cards — media re-uploaded to Meta once per broadcast):`);
|
|
490
|
+
for (const l of cardLines) console.log(l);
|
|
491
|
+
}
|
|
492
|
+
|
|
383
493
|
function validateRows(template, mapping, headers, rows, illegalChars, headerMedia) {
|
|
384
494
|
const aborts = [];
|
|
385
495
|
if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
|
|
386
496
|
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
|
|
497
|
+
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
498
|
// Media headers (image/video/document) ARE supported — they just need an image
|
|
389
499
|
// URL, exactly like the dashboard. Resolved as --header-media / saved mapping /
|
|
390
500
|
// the template's stored default_url. Only block if a media header has none.
|
|
@@ -793,12 +903,13 @@ export function collectKV(pair, mapAcc) {
|
|
|
793
903
|
* (allow_broadcast=true, not blocked) and sends in the background, returning a
|
|
794
904
|
* broadcastId. No CLI write-ahead log / resume for this engine (python owns the
|
|
795
905
|
* 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 }) {
|
|
906
|
+
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
|
|
797
907
|
const reqBody = (dryRun) => ({
|
|
798
908
|
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
799
909
|
body_parameters: bodyLiterals,
|
|
800
910
|
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
801
911
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
912
|
+
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
802
913
|
dry_run: dryRun,
|
|
803
914
|
});
|
|
804
915
|
|
|
@@ -813,6 +924,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
|
|
|
813
924
|
console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
|
|
814
925
|
const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
|
|
815
926
|
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
927
|
+
printCarouselPlan(cardOverrides, cardLines);
|
|
816
928
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
817
929
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
|
|
818
930
|
}
|
|
@@ -867,12 +979,28 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
867
979
|
? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
|
|
868
980
|
: null;
|
|
869
981
|
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" : ""}`);
|
|
982
|
+
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
983
|
const aborts = [];
|
|
872
984
|
if (headerCheck.status === "mismatch") aborts.push(headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia }));
|
|
873
985
|
if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
|
|
874
986
|
if (template.parameter_format !== "POSITIONAL") aborts.push("NAMED templates are not supported in v1");
|
|
875
|
-
|
|
987
|
+
// Carousel templates (18 Aug 2026): supported in tag mode via the python
|
|
988
|
+
// engine. Resolve + verify every card BEFORE any other gate so a dry run
|
|
989
|
+
// reports the full card plan (or every card problem at once).
|
|
990
|
+
let carouselPlan = null;
|
|
991
|
+
if (template.is_carousel) {
|
|
992
|
+
let cardsFileEntries = null;
|
|
993
|
+
let cardsFileBroken = false;
|
|
994
|
+
if (opts.cardsFile) {
|
|
995
|
+
try { cardsFileEntries = JSON.parse(await fs.readFile(opts.cardsFile, "utf8")); }
|
|
996
|
+
catch (e) { aborts.push(`--cards-file ${opts.cardsFile}: ${e.message}`); cardsFileBroken = true; }
|
|
997
|
+
}
|
|
998
|
+
if (!cardsFileBroken) {
|
|
999
|
+
carouselPlan = await resolveCarouselCards(template, { cardMediaFlags: opts.cardMedia || [], cardsFileEntries });
|
|
1000
|
+
for (const w of carouselPlan.warnings) console.log(`⚠ ${w}`);
|
|
1001
|
+
aborts.push(...carouselPlan.aborts);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
876
1004
|
// Media headers supported — need an image (--header-media / saved / default_url).
|
|
877
1005
|
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
1006
|
const keys = Object.keys(bodyLiterals);
|
|
@@ -885,14 +1013,22 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
885
1013
|
// gated by exactly the same checks as an immediate one (APPROVED, positional,
|
|
886
1014
|
// param arithmetic, header-media type). Instead of sending we queue the send
|
|
887
1015
|
// for later; python resolves the tag at FIRE time, so the audience is fresh.
|
|
888
|
-
|
|
1016
|
+
const cardOverrides = carouselPlan?.overrides ?? null;
|
|
1017
|
+
const cardLines = carouselPlan?.lines ?? [];
|
|
1018
|
+
|
|
1019
|
+
if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
|
|
889
1020
|
|
|
890
1021
|
// ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
|
|
891
1022
|
// python /meta-broadcast engine (the proven bulk sender). --python forces it at
|
|
892
1023
|
// any size; only a ≤10 send stays on the resumable per-row Node engine.
|
|
1024
|
+
// Carousels are python at ANY size — the per-row Node engine
|
|
1025
|
+
// (/api/send-template) has no carousel support.
|
|
893
1026
|
const PYTHON_MIN = 10;
|
|
894
|
-
if (opts.python) {
|
|
895
|
-
|
|
1027
|
+
if (template.is_carousel && !opts.python) {
|
|
1028
|
+
console.log("Carousel template → PYTHON engine at any size (the per-row Node engine cannot send carousels).");
|
|
1029
|
+
}
|
|
1030
|
+
if (opts.python || template.is_carousel) {
|
|
1031
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
|
|
896
1032
|
}
|
|
897
1033
|
|
|
898
1034
|
// resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
|
|
@@ -985,6 +1121,10 @@ export async function map(orgId, opts = {}) {
|
|
|
985
1121
|
let intro;
|
|
986
1122
|
try { intro = await introspect(orgId, opts.template); }
|
|
987
1123
|
catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
1124
|
+
if (intro.template.is_carousel) {
|
|
1125
|
+
console.error("Carousel templates are TAG-MODE only (no CSV mapping) — use: flowiq bc send <org> --tag <tag> --template <name>.");
|
|
1126
|
+
process.exit(1);
|
|
1127
|
+
}
|
|
988
1128
|
let csv;
|
|
989
1129
|
try { csv = await loadCsv(opts.csv); }
|
|
990
1130
|
catch (e) { console.error(`CSV error: ${e.message}`); process.exit(1); }
|
|
@@ -1050,7 +1190,7 @@ const fmtSast = (iso) =>
|
|
|
1050
1190
|
new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
|
|
1051
1191
|
|
|
1052
1192
|
/** 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 }) {
|
|
1193
|
+
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
|
|
1054
1194
|
const when = parseSastAt(opts.at);
|
|
1055
1195
|
if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
|
|
1056
1196
|
if (new Date(when.iso).getTime() <= Date.now()) {
|
|
@@ -1061,13 +1201,14 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1061
1201
|
|
|
1062
1202
|
console.log("");
|
|
1063
1203
|
renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1204
|
+
printCarouselPlan(cardOverrides, cardLines);
|
|
1064
1205
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
1065
1206
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
|
|
1066
1207
|
}
|
|
1067
1208
|
console.log("");
|
|
1068
1209
|
console.log(`Schedule: ${fmtSast(when.iso)} SAST${when.explicitOffset ? "" : " (--at read as SAST)"}`);
|
|
1069
1210
|
console.log(`Audience: tag "${tag}" — resolved when it FIRES, not now (so late joiners are included)`);
|
|
1070
|
-
console.log(`Engine: python /meta-broadcast`);
|
|
1211
|
+
console.log(`Engine: python /meta-broadcast${cardOverrides ? ` · carousel, ${cardOverrides.length} cards (frozen into the queued request)` : ""}`);
|
|
1071
1212
|
console.log(needsApproval
|
|
1072
1213
|
? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
|
|
1073
1214
|
: `Approval: none — fires automatically at the scheduled time`);
|
|
@@ -1090,6 +1231,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1090
1231
|
body_parameters: bodyLiterals,
|
|
1091
1232
|
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
1092
1233
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1234
|
+
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1093
1235
|
});
|
|
1094
1236
|
} catch (e) {
|
|
1095
1237
|
console.error(`Schedule failed: ${e.message}`);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// `flowiq links` — short links + campaign UTM tags from the terminal.
|
|
2
|
+
//
|
|
3
|
+
// The terminal half of the in-app URL Shortener. Same engine server-side
|
|
4
|
+
// (api/_url-shorten-engine.js), so a link minted here is identical to one minted
|
|
5
|
+
// in the dialog: utm_source=whatsapp, utm_medium=whatsapp_paid fixed, campaign +
|
|
6
|
+
// content yours.
|
|
7
|
+
//
|
|
8
|
+
// flowiq links shorten <org> --url <u> [--url <u2>…] --campaign 13Aug_Seeds --content 13Aug_Seeds
|
|
9
|
+
// flowiq links shorten <org> --file ./message.txt --campaign X --content X --domain linklnk.io
|
|
10
|
+
// flowiq links list <org> [--campaign X] [--limit 25]
|
|
11
|
+
//
|
|
12
|
+
// Dry-run FIRST (`--dry-run`) on anything going into a real campaign: it shows
|
|
13
|
+
// the exact destination URL each short link will carry, and whether an identical
|
|
14
|
+
// row already exists (so you can see you are about to reuse an OLD campaign tag).
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import { http } from "../http.js";
|
|
18
|
+
|
|
19
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
const URL_PATTERN = /(https?:\/\/[^\s\)]+)/g;
|
|
21
|
+
|
|
22
|
+
function requireOrg(orgId) {
|
|
23
|
+
if (!UUID_RE.test(orgId)) {
|
|
24
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID. Find it with: flowiq org list <name>`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function shorten(orgId, opts = {}) {
|
|
30
|
+
requireOrg(orgId);
|
|
31
|
+
|
|
32
|
+
// Collect URLs: repeatable --url, and/or every URL found in --file.
|
|
33
|
+
const urls = [...(opts.url || [])];
|
|
34
|
+
if (opts.file) {
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = fs.readFileSync(opts.file, "utf8");
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(`Error: could not read --file ${opts.file}: ${e.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const found = text.match(URL_PATTERN) || [];
|
|
43
|
+
if (!found.length) console.error(`⚠ No URLs found in ${opts.file}`);
|
|
44
|
+
urls.push(...found);
|
|
45
|
+
}
|
|
46
|
+
if (!urls.length) {
|
|
47
|
+
console.error("Error: pass at least one --url <url>, or --file <path> containing URLs.");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Dedupe while preserving order — the same URL twice would otherwise print twice.
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const unique = urls.filter((u) => {
|
|
54
|
+
const k = u.trim();
|
|
55
|
+
if (seen.has(k)) return false;
|
|
56
|
+
seen.add(k);
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const dryRun = opts.dryRun !== false && opts.commit !== true;
|
|
61
|
+
|
|
62
|
+
let resp;
|
|
63
|
+
try {
|
|
64
|
+
resp = await http.post("links", {
|
|
65
|
+
organization_id: orgId,
|
|
66
|
+
action: "shorten",
|
|
67
|
+
urls: unique,
|
|
68
|
+
campaign: opts.campaign,
|
|
69
|
+
content: opts.content,
|
|
70
|
+
domain: opts.domain,
|
|
71
|
+
dry_run: dryRun,
|
|
72
|
+
});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error(`Shorten failed: ${e.message}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
79
|
+
|
|
80
|
+
const utm = resp.utm
|
|
81
|
+
? `utm_source=${resp.utm.source} · utm_medium=${resp.utm.medium} · utm_campaign=${resp.utm.campaign} · utm_content=${resp.utm.content}`
|
|
82
|
+
: "no UTM tags (plain short link)";
|
|
83
|
+
console.log(`${resp.organization_name} · ${resp.domain}`);
|
|
84
|
+
console.log(`${utm}\n`);
|
|
85
|
+
|
|
86
|
+
for (const r of resp.results) {
|
|
87
|
+
const label = {
|
|
88
|
+
created: "✓ created",
|
|
89
|
+
reused: "↻ reused ",
|
|
90
|
+
would_create: "· would create",
|
|
91
|
+
would_reuse: "· would REUSE an existing row",
|
|
92
|
+
refused_already_short: "✗ refused",
|
|
93
|
+
invalid_url: "✗ invalid url",
|
|
94
|
+
failed: "✗ failed",
|
|
95
|
+
}[r.state] || r.state;
|
|
96
|
+
console.log(`${label} ${r.short_url || r.input}`);
|
|
97
|
+
if (r.full_url) console.log(` → ${r.full_url}`);
|
|
98
|
+
if (r.note) console.log(` ⚠ ${r.note}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log("");
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
console.log(`DRY RUN — nothing written. Re-run with --commit to mint these links.`);
|
|
104
|
+
} else {
|
|
105
|
+
console.log(`${resp.created} created · ${resp.reused} reused${resp.failed ? ` · ${resp.failed} failed` : ""}`);
|
|
106
|
+
}
|
|
107
|
+
// A reuse is not automatically wrong (idempotent re-run), but it means the code
|
|
108
|
+
// you are about to paste already existed — with whatever campaign tag it was
|
|
109
|
+
// born with. Worth a second look when you asked for a NEW campaign.
|
|
110
|
+
const reusedRows = resp.results.filter((r) => r.state === "reused" || r.state === "would_reuse");
|
|
111
|
+
if (reusedRows.length && resp.utm) {
|
|
112
|
+
console.log(`⚠ ${reusedRows.length} url(s) already had a row with this exact destination — those keep their existing click history.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function list(orgId, opts = {}) {
|
|
117
|
+
requireOrg(orgId);
|
|
118
|
+
let resp;
|
|
119
|
+
try {
|
|
120
|
+
resp = await http.post("links", {
|
|
121
|
+
organization_id: orgId,
|
|
122
|
+
action: "list",
|
|
123
|
+
campaign: opts.campaign,
|
|
124
|
+
limit: opts.limit,
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error(`List failed: ${e.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
132
|
+
|
|
133
|
+
if (!resp.links.length) {
|
|
134
|
+
console.log(opts.campaign ? `No links on ${resp.organization_name} for campaign "${opts.campaign}".` : `No links on ${resp.organization_name}.`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
138
|
+
console.log(`${pad("CODE", 10)}${pad("CLICKS", 8)}${pad("CREATED", 12)}URL`);
|
|
139
|
+
for (const l of resp.links) {
|
|
140
|
+
const day = String(l.created_at || "").slice(0, 10);
|
|
141
|
+
console.log(`${pad(l.code, 10)}${pad(l.clicks ?? 0, 8)}${pad(day, 12)}${l.full_url}`);
|
|
142
|
+
}
|
|
143
|
+
console.log(`\n${resp.count} link(s)${opts.campaign ? ` · campaign "${opts.campaign}"` : ""}`);
|
|
144
|
+
}
|
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()) {
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as customToolsCmd from "./commands/custom-tools.js";
|
|
|
27
27
|
import * as broadcastCmd from "./commands/broadcast.js";
|
|
28
28
|
import * as segmentsCmd from "./commands/segments.js";
|
|
29
29
|
import * as tagCmd from "./commands/tag.js";
|
|
30
|
+
import * as linksCmd from "./commands/links.js";
|
|
30
31
|
import * as keywordsCmd from "./commands/keywords.js";
|
|
31
32
|
import * as guideCmd from "./commands/guide.js";
|
|
32
33
|
import * as auditCmd from "./commands/audit.js";
|
|
@@ -431,6 +432,8 @@ export function run(argv) {
|
|
|
431
432
|
.option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
|
|
432
433
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
433
434
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
435
|
+
.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]), [])
|
|
436
|
+
.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")
|
|
434
437
|
.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")
|
|
435
438
|
.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")
|
|
436
439
|
.option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
|
|
@@ -534,6 +537,26 @@ export function run(argv) {
|
|
|
534
537
|
.option("--confirm", "required alongside --commit")
|
|
535
538
|
.action((orgId, id, opts) => segmentsCmd.untag(orgId, id, opts));
|
|
536
539
|
|
|
540
|
+
// links (short links + campaign UTM tags — the URL Shortener dialog in the terminal; dry-run default)
|
|
541
|
+
const links = program.command("links")
|
|
542
|
+
.description("Short links with campaign UTM tags (dry-run by default; --commit to mint)");
|
|
543
|
+
links.command("shorten <organization_id>")
|
|
544
|
+
.description("Shorten one or more URLs, tagging each with utm_campaign/utm_content")
|
|
545
|
+
.option("--url <url>", "URL to shorten (repeatable)", (v, acc) => (acc || []).concat([v]), [])
|
|
546
|
+
.option("--file <path>", "shorten every URL found in this text file")
|
|
547
|
+
.option("--campaign <value>", "utm_campaign (e.g. 13Aug_Seeds) — must be paired with --content")
|
|
548
|
+
.option("--content <value>", "utm_content — must be paired with --campaign")
|
|
549
|
+
.option("--domain <host>", "short domain: chatcart.io (default) | linklnk.io | yapi.store")
|
|
550
|
+
.option("--commit", "actually mint the links (omit = dry-run preview)")
|
|
551
|
+
.option("--json", "raw JSON output")
|
|
552
|
+
.action((orgId, opts) => linksCmd.shorten(orgId, opts));
|
|
553
|
+
links.command("list <organization_id>")
|
|
554
|
+
.description("List the org's short links, newest first")
|
|
555
|
+
.option("--campaign <value>", "only links carrying this exact utm_campaign")
|
|
556
|
+
.option("--limit <n>", "max rows (default 25, max 200)")
|
|
557
|
+
.option("--json", "raw JSON output")
|
|
558
|
+
.action((orgId, opts) => linksCmd.list(orgId, opts));
|
|
559
|
+
|
|
537
560
|
// tag (the Advanced Tagging dialog in the terminal — ONE named tag on a matched set; dry-run default)
|
|
538
561
|
const tag = program.command("tag")
|
|
539
562
|
.description("Advanced Tagging: apply one named tag to a matched set of contacts (dry-run by default; --commit to write)");
|