@flowapt/flowiq-cli 0.7.5 → 0.8.1
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 +43 -6
- package/TEAM-GUIDE.md +2 -0
- package/package.json +1 -1
- package/src/campaign-naming.js +13 -0
- package/src/campaign-naming.test.mjs +13 -0
- package/src/commands/broadcast.js +115 -1
- package/src/commands/templates.js +67 -2
- package/src/index.js +11 -2
- package/src/tracked-link.test.mjs +57 -0
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ flowiq prompts push <slug>
|
|
|
82
82
|
Notes:
|
|
83
83
|
- `system_prompt` is regenerated server-side from `prompt_sections` on
|
|
84
84
|
push using the `## SECTION: <title>` format.
|
|
85
|
-
- Sections support optional `hidden: boolean` + `channels: string[]`
|
|
85
|
+
- Sections support optional `hidden: boolean` + `channels: string[]` (`web`, `whatsapp`, `messenger`, `instagram`, `email`; empty = every channel)
|
|
86
86
|
(`web` / `whatsapp` / `messenger` / `instagram`) — validated on push.
|
|
87
87
|
- **Time-boxed sections:** optional `active_from` / `active_until`
|
|
88
88
|
(`"YYYY-MM-DD"` or `"YYYY-MM-DDTHH:mm"`) + `active_tz` (IANA, default
|
|
@@ -462,6 +462,27 @@ flowiq bc send <org_id> --tag new-arrivals-batch-01 --template new_arrivals_v3 \
|
|
|
462
462
|
--button param1=<cycle-harmony-code> --button param2=<neuroshyft-code> --commit
|
|
463
463
|
```
|
|
464
464
|
|
|
465
|
+
**Pasted links (v0.8.0, first on npm as 0.8.1):** you can paste the REAL link instead of a code:
|
|
466
|
+
|
|
467
|
+
```bash
|
|
468
|
+
flowiq bc send <org_id> --tag spring-batch-01 --template updates_v8 \
|
|
469
|
+
--body param1="Hi {{first_name}}" \
|
|
470
|
+
--button param1="https://shop.co.za/collections/spring?ref=ig&utm_source=klaviyo"
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The dry run shows what will go out: `→ linklnk.io/<code-on-commit>`, the tags
|
|
474
|
+
(`utm_campaign=18Sep_UpdatesV8 · utm_content=ClickHere · utm_source=whatsapp`),
|
|
475
|
+
anything it replaced (`⚠ replaced utm_source=klaviyo with whatsapp`) and where
|
|
476
|
+
the link really lands (a store redirect that drops the tags is pointed past;
|
|
477
|
+
a `/discount/` link is kept). Your other params (`ref`, a discount code, a
|
|
478
|
+
variant) are kept. `--commit` mints the code, then sends. `--link-campaign
|
|
479
|
+
"Spring Promotion"` names the campaign tag (default: the template name; `--at`
|
|
480
|
+
supplies the date); `--keep-link` sends the link exactly as pasted with no
|
|
481
|
+
FlowIQ tags (clicks are counted, the sale will not show as WhatsApp). Only a
|
|
482
|
+
button whose base is one of our short domains (`linklnk.io/{{1}}`) takes a
|
|
483
|
+
link; a button that already points at the store takes the part after the
|
|
484
|
+
slash. A whole short link pasted as the value is reduced to its code.
|
|
485
|
+
|
|
465
486
|
**Link buttons (v0.7.5):** every button whose URL carries a `{{1}}` needs its own
|
|
466
487
|
`--button paramN=` value (`param1` = the first such button, `param2` = the
|
|
467
488
|
second). The dry run prints each button's final URL and stops if one is
|
|
@@ -1241,25 +1262,41 @@ flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send,
|
|
|
1241
1262
|
(`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
|
|
1242
1263
|
(`flowiq audit --endpoint team-updates`).
|
|
1243
1264
|
|
|
1244
|
-
### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
|
|
1265
|
+
### WhatsApp templates — `flowiq templates pull|list|show|create|status|attempts` (alias `tpl`)
|
|
1245
1266
|
|
|
1246
1267
|
Read an org's live templates straight from Meta (read-only), render any single
|
|
1247
|
-
row **including an unsubmitted DRAFT**,
|
|
1248
|
-
`create-meta-template` edge function
|
|
1268
|
+
row **including an unsubmitted DRAFT**, submit new ones through the
|
|
1269
|
+
`create-meta-template` edge function, and read the **submission ledger** — every
|
|
1270
|
+
create call, including the ones that failed and why.
|
|
1249
1271
|
|
|
1250
1272
|
```bash
|
|
1251
1273
|
flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
|
|
1252
1274
|
flowiq templates status <organization_id> --name booking # poll approval
|
|
1253
1275
|
flowiq templates show <organization_id> heritage_day_v2 # render ONE row, drafts included
|
|
1254
1276
|
flowiq templates create <organization_id> --request-file req.json
|
|
1277
|
+
flowiq templates attempts <organization_id> --failed # why a submit was refused (v0.8.1)
|
|
1255
1278
|
```
|
|
1256
1279
|
|
|
1280
|
+
**`attempts` is the only record of a FAILED submission (v0.8.1).** A refused
|
|
1281
|
+
submission writes no template row (so `status` cannot show it), never reaches
|
|
1282
|
+
Meta (so `pull` cannot either), and the server logs that used to be the only
|
|
1283
|
+
evidence expire after 24 hours. Every create call since 21 Sep 2026 — from the
|
|
1284
|
+
dashboard or this CLI — now writes a `template_submission_attempts` row, and
|
|
1285
|
+
`attempts` prints them newest first in SAST: `✓` submitted (Meta id + status at
|
|
1286
|
+
submit) or `✗` FAILED with the HTTP status, the short label and the sentence
|
|
1287
|
+
that names the cause, e.g. `HEADER format is DOCUMENT but the media at file_url
|
|
1288
|
+
is video/mp4 — point file_url at a document (pdf)`. That sentence is what to
|
|
1289
|
+
tell the client. `--failed` filters to refusals, `--name <substr>` to one
|
|
1290
|
+
template, `--limit <n>` (default 20, max 100), `--json` for the rows.
|
|
1291
|
+
|
|
1257
1292
|
**`show` is the only way to read a DRAFT from the terminal (v0.6.7).** A draft
|
|
1258
1293
|
never reaches Meta, so `templates pull` cannot see it and neither can the
|
|
1259
1294
|
broadcast introspector — before this, reviewing one meant opening the dialog.
|
|
1260
1295
|
`show` prints the body with its examples substituted (as the customer will read
|
|
1261
|
-
it), then
|
|
1262
|
-
|
|
1296
|
+
it), then — for a carousel draft only (v0.8.1; a standard draft used to print
|
|
1297
|
+
the dialog's two default cards as a phantom carousel) — every carousel card:
|
|
1298
|
+
media URL, source filename, whether the Meta asset handle is present, card
|
|
1299
|
+
body, and each button's resolved URL. `--raw`
|
|
1263
1300
|
also prints the escaped body so invisible whitespace is visible; `--json` gives
|
|
1264
1301
|
the row.
|
|
1265
1302
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -167,6 +167,7 @@ several.
|
|
|
167
167
|
| 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). |
|
|
168
168
|
| 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. |
|
|
169
169
|
| 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. |
|
|
170
|
+
| Send a broadcast whose link button should open a page on the store, tracked | Paste the real link: `flowiq bc send <org_id> --tag <batch-tag> --template <name> --button param1="https://shop.co.za/collections/spring?ref=ig"` (dry run shows `→ linklnk.io/<code>`, the campaign tag and anything replaced) → `… --commit` mints the code and sends. `--link-campaign "Spring Promotion"` names the tag (default: the template name); `--keep-link` sends your link untouched with no FlowIQ tags (clicks counted, the sale will not show as WhatsApp) |
|
|
170
171
|
| Send a template with TWO link buttons (e.g. one per product) and track clicks per batch | Mint two short links per batch with the same campaign and a different `--content` (e.g. `--content CycleHarmony_b01` and `--content NeuroShyft_b01`), then `flowiq bc send <org_id> --tag <batch-tag> --template <name> --button param1=<first-code> --button param2=<second-code>`. `param1` is the first link button on the template, `param2` the second. The dry run prints both final URLs and stops if either is missing. Tag mode only (v0.7.5). |
|
|
171
172
|
| 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.** |
|
|
172
173
|
| 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) |
|
|
@@ -184,6 +185,7 @@ several.
|
|
|
184
185
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
|
185
186
|
| **Read a template you have not submitted yet (a DRAFT)** | `flowiq tpl show <org_id> <template_name>` — the ONLY way to see a draft from the terminal (`pull` reads Meta, and a draft never gets there). Renders the message as the customer will read it, every carousel card, and a **Checks** list of the things Meta would bounce it for |
|
|
186
187
|
| Check a carousel before submitting it | `flowiq tpl show <org_id> <name>` and read **Checks**. It catches a card link using the wrong `{{n}}`, a missing greeting example, a bold/italic marker against a space (WhatsApp shows the literal `_`), wrong card counts and over-length text. It cannot know a card names a product you do not stock — read the card text against the shop yourself |
|
|
188
|
+
| **A template would not submit and you want to know why** | `flowiq tpl attempts <org_id> --failed` — the submission ledger. Every create call since 21 Sep 2026, from the dashboard or the CLI, with the reason a refused one was refused (e.g. `HEADER format is DOCUMENT but the media at file_url is video/mp4`). A failed submit writes no template row and never reaches Meta, so this is the ONLY place it shows |
|
|
187
189
|
| Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
|
|
188
190
|
| Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
|
|
189
191
|
| Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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": {
|
package/src/campaign-naming.js
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
// • `flowiq bc --campaign`, which names the LOCAL .flowiq/campaigns/<x>.json
|
|
22
22
|
// file and the `bc-<x>` contact tag, not a UTM.
|
|
23
23
|
// `--raw-campaign` bypasses normalisation entirely when you genuinely need it.
|
|
24
|
+
//
|
|
25
|
+
// MIRROR: api/_shared/campaign-naming.js is a byte-identical copy of this file
|
|
26
|
+
// (the server normalises the tag for the dashboard's tracked links and for
|
|
27
|
+
// `bc send --button <url>`; the npm tarball cannot reach api/). Edit BOTH, or
|
|
28
|
+
// `npm test` fails on the parity check in campaign-naming.test.mjs.
|
|
24
29
|
|
|
25
30
|
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
26
31
|
|
|
@@ -162,3 +167,11 @@ export function resolveContent(input) {
|
|
|
162
167
|
notes: value && value !== original ? [`content "${original}" → "${value}"`] : [],
|
|
163
168
|
};
|
|
164
169
|
}
|
|
170
|
+
|
|
171
|
+
/** A button label → utm_content: "CLICK HERE" → ClickHere, "Shop now" → ShopNow, "Plano" → Plano. */
|
|
172
|
+
export function contentFromLabel(label) {
|
|
173
|
+
const words = String(label ?? "").replace(/[^A-Za-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean);
|
|
174
|
+
return words
|
|
175
|
+
.map((w) => (/^[A-Z0-9]+$/.test(w) && w.length > 1 ? w[0] + w.slice(1).toLowerCase() : w[0].toUpperCase() + w.slice(1)))
|
|
176
|
+
.join("");
|
|
177
|
+
}
|
|
@@ -74,3 +74,16 @@ test("dateToken / isConforming", () => {
|
|
|
74
74
|
assert.ok(!isConforming("SpringPromotion"));
|
|
75
75
|
assert.ok(!isConforming("9Sep_Spring_Promotion"));
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
// The server carries a byte-identical copy (api/_shared/campaign-naming.js) so the
|
|
79
|
+
// dashboard's tracked links and `bc send --button <url>` name campaigns exactly the
|
|
80
|
+
// way `links shorten` does. Skipped inside the npm tarball, where api/ does not exist.
|
|
81
|
+
test("api/_shared/campaign-naming.js is a byte-identical mirror", async (t) => {
|
|
82
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
83
|
+
const { fileURLToPath } = await import("node:url");
|
|
84
|
+
const path = await import("node:path");
|
|
85
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const mirror = path.join(here, "..", "..", "api", "_shared", "campaign-naming.js");
|
|
87
|
+
if (!existsSync(mirror)) { t.skip("api/ not present (npm tarball)"); return; }
|
|
88
|
+
assert.equal(readFileSync(mirror, "utf8"), readFileSync(path.join(here, "campaign-naming.js"), "utf8"));
|
|
89
|
+
});
|
|
@@ -663,6 +663,101 @@ export function checkButtonParams(template, buttonParams) {
|
|
|
663
663
|
return { aborts, warnings };
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// Tracked links from a PASTED url (v0.8.0, 18 Sep 2026)
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
// `--button param1=https://store.co.za/collections/new?ref=ig` — a real link
|
|
670
|
+
// instead of a pre-minted code. The server (api/cli/links.js `tracked_link`, the
|
|
671
|
+
// same engine the dashboard's send dialog uses) keeps their params, puts our
|
|
672
|
+
// utm_source / utm_medium on, names utm_campaign `Date_Campaign` (default: the
|
|
673
|
+
// template name, --link-campaign to override) and utm_content after the button,
|
|
674
|
+
// follows the link to where it lands, and mints the code on --commit. The dry
|
|
675
|
+
// run prints all of it first. `--keep-link` sends the link exactly as pasted
|
|
676
|
+
// (a plain forward, no tags). Only a button whose base is one of OUR short
|
|
677
|
+
// domains (`linklnk.io/{{1}}`) can take a full link; a client-domain button
|
|
678
|
+
// takes the part after the slash.
|
|
679
|
+
const OUR_SHORT_HOST_RE = /(^|\.)(linklnk\.io|chatcart\.io|yapi\.store)$/i;
|
|
680
|
+
const SHORT_LINK_RE = /^https?:\/\/(?:[a-z0-9-]+\.)*(?:linklnk\.io|chatcart\.io|yapi\.store)\/([A-Za-z0-9_-]{3,64})\/?$/i;
|
|
681
|
+
|
|
682
|
+
export function isHttpUrl(value) {
|
|
683
|
+
const v = String(value ?? "").trim();
|
|
684
|
+
if (!/^https?:\/\//i.test(v)) return false;
|
|
685
|
+
try { new URL(v); return true; } catch { return false; }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** A whole short link pasted as a button value → its bare code (else null). */
|
|
689
|
+
export function codeFromShortLink(value) {
|
|
690
|
+
const m = String(value ?? "").trim().match(SHORT_LINK_RE);
|
|
691
|
+
return m ? m[1] : null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Which --button values are pasted links, and whether their button can take one. */
|
|
695
|
+
export function planTrackedLinks(template, buttonParams) {
|
|
696
|
+
const plan = [];
|
|
697
|
+
const aborts = [];
|
|
698
|
+
const buttons = Array.isArray(template?.url_buttons) && template.url_buttons.length
|
|
699
|
+
? template.url_buttons
|
|
700
|
+
: (template?.url_button?.present ? [{ param: "param1", url_base: template.url_button.url_base, text: template.url_button.text }] : []);
|
|
701
|
+
for (const [param, value] of Object.entries(buttonParams || {})) {
|
|
702
|
+
if (!isHttpUrl(value)) continue;
|
|
703
|
+
const btn = buttons.find((b) => b.param === param);
|
|
704
|
+
if (!btn) continue; // a value with no button is already a warning
|
|
705
|
+
let host = null;
|
|
706
|
+
try { host = new URL(String(btn.url_base || "").replace(/\{\{[^}]+\}\}/g, "x")).host; } catch { /* no host */ }
|
|
707
|
+
if (!host || !OUR_SHORT_HOST_RE.test(host)) {
|
|
708
|
+
aborts.push(`--button ${param} is a full link, but the template's "${btn.text ?? param}" button already points at ${host || "a fixed address"} — pass only the part after the slash`);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
plan.push({ param, url: String(value).trim(), label: btn.text || param, host });
|
|
712
|
+
}
|
|
713
|
+
return { plan, aborts };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** `--at "2026-08-05 09:00"` → `2026-08-05` for the campaign tag's date (else undefined). */
|
|
717
|
+
export function linkDateFromAt(at) {
|
|
718
|
+
const m = String(at ?? "").trim().match(/^(\d{4}-\d{2}-\d{2})/);
|
|
719
|
+
return m ? m[1] : undefined;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Today in THIS machine's time zone as YYYY-MM-DD — sent with every tracked
|
|
723
|
+
* link so the server (UTC) never dates a late-evening SAST send yesterday. */
|
|
724
|
+
export function localDateIso(d = new Date()) {
|
|
725
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function resolveTrackedButtons(orgId, plan, { campaign, dateIso, keep, dryRun }) {
|
|
729
|
+
const out = {};
|
|
730
|
+
console.log("");
|
|
731
|
+
console.log(dryRun ? "Tracked links — the pasted url(s) become short codes on --commit:" : "Tracked links — minting short codes:");
|
|
732
|
+
for (const p of plan) {
|
|
733
|
+
let resp;
|
|
734
|
+
try {
|
|
735
|
+
resp = await http.post("links", {
|
|
736
|
+
organization_id: orgId, action: "tracked_link", url: p.url,
|
|
737
|
+
campaign, content: p.label, mode: keep ? "keep" : "tracked", domain: p.host,
|
|
738
|
+
...(dateIso ? { date: dateIso } : {}), dry_run: dryRun,
|
|
739
|
+
});
|
|
740
|
+
} catch (e) {
|
|
741
|
+
console.error(`ABORT — tracked link for ${p.param} failed: ${e.message}`);
|
|
742
|
+
if (e.body?.error) console.error(` ${e.body.error}`);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
const r = resp.result || {};
|
|
746
|
+
if (!resp.success) { console.error(`ABORT — --button ${p.param} ${p.url}: ${r.note || r.state || "refused"}`); process.exit(1); }
|
|
747
|
+
const code = r.code || "<code-on-commit>";
|
|
748
|
+
console.log(` Button "${p.label}" (${p.param}): ${p.url}`);
|
|
749
|
+
console.log(` → ${p.host}/${code}${r.state === "reused" || r.state === "would_reuse" ? " (existing row — keeps its click history)" : ""}`);
|
|
750
|
+
if (resp.utm) console.log(` utm_campaign=${resp.utm.campaign} · utm_content=${resp.utm.content} · utm_source=${resp.utm.source} · utm_medium=${resp.utm.medium}`);
|
|
751
|
+
else console.log(" kept as pasted — no FlowIQ tags (clicks are counted; the sale will not show as WhatsApp)");
|
|
752
|
+
for (const rp of r.replaced || []) console.log(` ⚠ replaced ${rp.key}=${rp.from} with ${rp.to}`);
|
|
753
|
+
for (const n of r.notes || []) console.log(` ⚠ ${n}`);
|
|
754
|
+
if (r.full_url && r.full_url !== p.url) console.log(` final: ${r.full_url}`);
|
|
755
|
+
for (const n of resp.utm?.naming || []) console.log(` · ${n}`);
|
|
756
|
+
out[p.param] = code;
|
|
757
|
+
}
|
|
758
|
+
return out;
|
|
759
|
+
}
|
|
760
|
+
|
|
666
761
|
// ---------------------------------------------------------------------------
|
|
667
762
|
// CSV → python fan-out builders (v0.6.0). Exported for the test harness.
|
|
668
763
|
// ---------------------------------------------------------------------------
|
|
@@ -1364,7 +1459,12 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1364
1459
|
}
|
|
1365
1460
|
const bodyLiterals = Object.keys(opts.body || {}).length ? opts.body : (cfg?.body_params_literal ?? {});
|
|
1366
1461
|
const buttonParams = resolveButtonParams(opts.button, cfg);
|
|
1367
|
-
|
|
1462
|
+
// A whole short link pasted as a value is just its code.
|
|
1463
|
+
for (const [k, v] of Object.entries(buttonParams)) {
|
|
1464
|
+
const code = codeFromShortLink(v);
|
|
1465
|
+
if (code) { buttonParams[k] = code; console.log(`ℹ --button ${k}: using the code ${code} from the short link you pasted`); }
|
|
1466
|
+
}
|
|
1467
|
+
let buttonLiteral = buttonParams.param1 ?? null;
|
|
1368
1468
|
|
|
1369
1469
|
// introspect + validate params arithmetically (the #132000 guard, pre-send)
|
|
1370
1470
|
let intro;
|
|
@@ -1406,8 +1506,22 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1406
1506
|
const buttonCheck = checkButtonParams(template, buttonParams);
|
|
1407
1507
|
for (const w of buttonCheck.warnings) console.log(`⚠ ${w}`);
|
|
1408
1508
|
aborts.push(...buttonCheck.aborts);
|
|
1509
|
+
const linkPlan = planTrackedLinks(template, buttonParams);
|
|
1510
|
+
aborts.push(...linkPlan.aborts);
|
|
1409
1511
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
1410
1512
|
|
|
1513
|
+
// Pasted links → tracked short codes (dry run previews, --commit mints).
|
|
1514
|
+
if (linkPlan.plan.length) {
|
|
1515
|
+
const codes = await resolveTrackedButtons(orgId, linkPlan.plan, {
|
|
1516
|
+
campaign: opts.linkCampaign || templateName,
|
|
1517
|
+
dateIso: linkDateFromAt(opts.at) || localDateIso(),
|
|
1518
|
+
keep: !!opts.keepLink,
|
|
1519
|
+
dryRun: !commitStage,
|
|
1520
|
+
});
|
|
1521
|
+
Object.assign(buttonParams, codes);
|
|
1522
|
+
buttonLiteral = buttonParams.param1 ?? null;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1411
1525
|
// SCHEDULE (--at): validation above has already run, so a scheduled send is
|
|
1412
1526
|
// gated by exactly the same checks as an immediate one (APPROVED, positional,
|
|
1413
1527
|
// param arithmetic, header-media type). Instead of sending we queue the send
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `flowiq templates pull <org_id>` / `list` — read WhatsApp templates from Meta.
|
|
2
2
|
// `flowiq templates create <org> --request-file f.json` / `status` — §12 (create).
|
|
3
|
-
//
|
|
3
|
+
// `flowiq templates attempts <org>` — the submission ledger, failures included.
|
|
4
|
+
// Pull is read-only via /cli/templates; create/status/show/attempts use /cli/meta-templates.
|
|
4
5
|
|
|
5
6
|
import fs from "node:fs/promises";
|
|
6
7
|
import path from "node:path";
|
|
@@ -257,7 +258,10 @@ function renderDraft(d, opts) {
|
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
|
|
261
|
+
// A standard-mode draft still carries the dialog's two DEFAULT cards in
|
|
262
|
+
// carouselCards (the form snapshot is saved whole), so only carousel mode
|
|
263
|
+
// renders them — otherwise `show` invents a 2-card carousel that never existed.
|
|
264
|
+
const cards = d.templateMode === "carousel" && Array.isArray(d.carouselCards) ? d.carouselCards : [];
|
|
261
265
|
if (cards.length) {
|
|
262
266
|
console.log(`\n CAROUSEL — ${cards.length} card(s)`);
|
|
263
267
|
cards.forEach((c, i) => {
|
|
@@ -355,3 +359,64 @@ export async function show(orgId, name, opts = {}) {
|
|
|
355
359
|
if (td.carousel?.cards) console.log(` carousel: ${td.carousel.cards.length} card(s) with stored header media`);
|
|
356
360
|
console.log(`\n For the message structure as Meta holds it: flowiq templates pull ${orgId}`);
|
|
357
361
|
}
|
|
362
|
+
|
|
363
|
+
// ── attempts ────────────────────────────────────────────────────────────────
|
|
364
|
+
// The submission ledger: one row per create-meta-template call (dashboard or
|
|
365
|
+
// CLI), including the ones our own guards or Meta refused. A FAILED submission
|
|
366
|
+
// writes no `templates` row and never reaches Meta, so until this existed the
|
|
367
|
+
// only record was the edge fn's logs — gone after 24 h. Yoga Life's two 10:11
|
|
368
|
+
// failures on 21 Sep 2026 were recoverable that day and would not have been the next.
|
|
369
|
+
function sast(iso) {
|
|
370
|
+
if (!iso) return "?";
|
|
371
|
+
try {
|
|
372
|
+
return new Date(iso).toLocaleString("en-ZA", {
|
|
373
|
+
timeZone: "Africa/Johannesburg", year: "numeric", month: "2-digit", day: "2-digit",
|
|
374
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
|
375
|
+
}).replace(",", "");
|
|
376
|
+
} catch { return iso; }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function attempts(orgId, opts = {}) {
|
|
380
|
+
if (!UUID_RE.test(orgId)) {
|
|
381
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID. Find it with: flowiq org list <name>`);
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
let resp;
|
|
385
|
+
try {
|
|
386
|
+
resp = await http.get("meta-templates", {
|
|
387
|
+
organization_id: orgId,
|
|
388
|
+
attempts: "1",
|
|
389
|
+
failed: opts.failed ? "1" : undefined,
|
|
390
|
+
limit: opts.limit ? String(opts.limit) : undefined,
|
|
391
|
+
name: opts.name,
|
|
392
|
+
});
|
|
393
|
+
} catch (e) {
|
|
394
|
+
console.error(`Attempts lookup failed: ${e.message}`);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
if (!("attempts" in (resp || {}))) {
|
|
398
|
+
console.error("The API did not return a submission ledger — api/cli/meta-templates.js on the server is older than this CLI (needs the ?attempts=1 branch, CLI v0.8.1). Retry once the Vercel deploy of origin/main has landed.");
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
402
|
+
|
|
403
|
+
const rows = resp.attempts || [];
|
|
404
|
+
if (!rows.length) {
|
|
405
|
+
console.log(opts.failed ? "(no failed submissions on record for this org)" : "(no template submissions on record for this org — the ledger started 21 Sep 2026)");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
console.log(`${rows.length} submission${rows.length === 1 ? "" : "s"}${opts.failed ? " (failed only)" : ""}, newest first (SAST):`);
|
|
409
|
+
for (const a of rows) {
|
|
410
|
+
const mark = a.outcome === "submitted" ? "✓" : "✗";
|
|
411
|
+
const who = a.source ? ` via ${a.source}` : "";
|
|
412
|
+
console.log(`\n ${mark} ${sast(a.created_at)} ${a.template_name || "(unnamed)"}${a.category ? ` [${a.category}]` : ""}${who}`);
|
|
413
|
+
if (a.outcome === "submitted") {
|
|
414
|
+
console.log(` submitted to Meta — id ${a.meta_template_id || "?"}, status at submit ${a.meta_status || "?"}`);
|
|
415
|
+
} else {
|
|
416
|
+
console.log(` FAILED (HTTP ${a.http_status ?? "?"}) ${a.error || ""}`);
|
|
417
|
+
if (a.message && a.message !== a.error) console.log(` ${a.message}`);
|
|
418
|
+
if (a.meta_error?.error_user_msg && a.meta_error.error_user_msg !== a.message) console.log(` Meta: ${a.meta_error.error_user_msg}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
console.log(`\nA failed row is the reason the template is not in \`templates status\` — fix what the message names and submit again.`);
|
|
422
|
+
}
|
package/src/index.js
CHANGED
|
@@ -274,7 +274,7 @@ export function run(argv) {
|
|
|
274
274
|
// templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
|
|
275
275
|
const templates = program.command("templates")
|
|
276
276
|
.alias("tpl")
|
|
277
|
-
.description("Read an org's WhatsApp templates (pull/list/show — show also reads DRAFTS); create + submit to Meta (create/status)");
|
|
277
|
+
.description("Read an org's WhatsApp templates (pull/list/show — show also reads DRAFTS); create + submit to Meta (create/status); attempts = the submission ledger, failures included");
|
|
278
278
|
templates.command("pull <organization_id>")
|
|
279
279
|
.description("Fetch every live WhatsApp template from Meta into a local JSON snapshot")
|
|
280
280
|
.action((orgId) => templatesCmd.pull(orgId));
|
|
@@ -294,6 +294,13 @@ export function run(argv) {
|
|
|
294
294
|
.option("--raw", "also print the raw body text (escaped), so invisible whitespace is visible")
|
|
295
295
|
.option("--json", "raw JSON row output")
|
|
296
296
|
.action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
|
|
297
|
+
templates.command("attempts <organization_id>")
|
|
298
|
+
.description("The submission ledger — every create call, dashboard or CLI, INCLUDING the ones that failed and why (a failed submit writes no template row and never reaches Meta)")
|
|
299
|
+
.option("--failed", "only the failed submissions")
|
|
300
|
+
.option("--name <substr>", "filter by template name substring")
|
|
301
|
+
.option("--limit <n>", "rows to return (default 20, max 100)")
|
|
302
|
+
.option("--json", "raw JSON output")
|
|
303
|
+
.action((orgId, opts) => templatesCmd.attempts(orgId, opts));
|
|
297
304
|
|
|
298
305
|
// org (read-only org summary for the prompt-builder skill, creds stripped)
|
|
299
306
|
const org = program.command("org").description("Org info (read), create a new organization, read/set its feature flags");
|
|
@@ -620,7 +627,9 @@ export function run(argv) {
|
|
|
620
627
|
.option("--csv <file>", "path to the recipients CSV (per-row values)")
|
|
621
628
|
.option("--tag <tag>", "send to every broadcast-safe contact carrying this tag (e.g. a segments batch tag)")
|
|
622
629
|
.option("--body <k=v>", "with --tag: body param (repeatable), e.g. --body param1=\"Hi {{first_name}}\"", broadcastCmd.collectKV, {})
|
|
623
|
-
.option("--button <k=v>", "with --tag: dynamic URL button value, repeatable for a second button, e.g. --button param1=<code> --button param2=<code
|
|
630
|
+
.option("--button <k=v>", "with --tag: dynamic URL button value, repeatable for a second button, e.g. --button param1=<code> --button param2=<code>. v0.8.0: paste the REAL link instead (--button param1=https://store.co.za/collections/new) and it becomes a tracked short code on --commit", broadcastCmd.collectKV, {})
|
|
631
|
+
.option("--link-campaign <name>", "with a pasted --button link: the utm_campaign, normalised to Date_Campaign (default: the template name, e.g. new_arrivals_v3 → 18Sep_NewArrivalsV3)")
|
|
632
|
+
.option("--keep-link", "with a pasted --button link: send it exactly as pasted — a plain forward, no FlowIQ utm tags (clicks are counted; the sale will not show as WhatsApp)")
|
|
624
633
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
625
634
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
626
635
|
.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]), [])
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { isHttpUrl, codeFromShortLink, planTrackedLinks, linkDateFromAt, localDateIso } from "./commands/broadcast.js";
|
|
4
|
+
|
|
5
|
+
const ours = { dynamic_url_buttons: 2, url_button: { present: true, url_base: "https://linklnk.io/{{1}}", text: "Shop" },
|
|
6
|
+
url_buttons: [{ param: "param1", url_base: "https://linklnk.io/{{1}}", text: "Shop" }, { param: "param2", url_base: "https://barkyn.linklnk.io/{{1}}", text: "Plano" }] };
|
|
7
|
+
const theirs = { dynamic_url_buttons: 1, url_button: { present: true, url_base: "https://store.co.za/{{1}}", text: "Shop" },
|
|
8
|
+
url_buttons: [{ param: "param1", url_base: "https://store.co.za/{{1}}", text: "Shop" }] };
|
|
9
|
+
|
|
10
|
+
test("a pasted link is recognised, a code or a path is not", () => {
|
|
11
|
+
assert.equal(isHttpUrl("https://store.co.za/collections/new?ref=ig"), true);
|
|
12
|
+
assert.equal(isHttpUrl("aB3xY9"), false);
|
|
13
|
+
assert.equal(isHttpUrl("collections/new"), false);
|
|
14
|
+
assert.equal(isHttpUrl("store.co.za/collections/new"), false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a whole short link pasted as a value gives back its code", () => {
|
|
18
|
+
assert.equal(codeFromShortLink("https://linklnk.io/aB3xY9"), "aB3xY9");
|
|
19
|
+
assert.equal(codeFromShortLink("https://barkyn.linklnk.io/pV2zqM/"), "pV2zqM");
|
|
20
|
+
assert.equal(codeFromShortLink("https://store.co.za/aB3xY9"), null);
|
|
21
|
+
assert.equal(codeFromShortLink("aB3xY9"), null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("links on our-domain buttons are planned, codes are left alone", () => {
|
|
25
|
+
const { plan, aborts } = planTrackedLinks(ours, { param1: "https://store.co.za/a?ref=ig", param2: "abc123" });
|
|
26
|
+
assert.deepEqual(aborts, []);
|
|
27
|
+
assert.deepEqual(plan, [{ param: "param1", url: "https://store.co.za/a?ref=ig", label: "Shop", host: "linklnk.io" }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a subdomain of ours counts as ours", () => {
|
|
31
|
+
const { plan } = planTrackedLinks(ours, { param1: "x", param2: "https://barkyn.com/plan" });
|
|
32
|
+
assert.equal(plan[0].host, "barkyn.linklnk.io");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a full link on a client-domain button aborts with the reason", () => {
|
|
36
|
+
const { plan, aborts } = planTrackedLinks(theirs, { param1: "https://store.co.za/collections/new" });
|
|
37
|
+
assert.deepEqual(plan, []);
|
|
38
|
+
assert.match(aborts[0], /already points at store.co.za/);
|
|
39
|
+
assert.match(aborts[0], /part after the slash/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("older server shape (url_button only) still plans param1", () => {
|
|
43
|
+
const { plan } = planTrackedLinks({ url_button: ours.url_button }, { param1: "https://store.co.za/a" });
|
|
44
|
+
assert.equal(plan.length, 1);
|
|
45
|
+
assert.equal(plan[0].param, "param1");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("--at gives the campaign tag its date", () => {
|
|
49
|
+
assert.equal(linkDateFromAt("2026-09-24 10:00"), "2026-09-24");
|
|
50
|
+
assert.equal(linkDateFromAt(undefined), undefined);
|
|
51
|
+
assert.equal(linkDateFromAt("tomorrow"), undefined);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the tag's date is the machine's local day, not UTC", () => {
|
|
55
|
+
assert.equal(localDateIso(new Date(2026, 8, 18, 0, 47)), "2026-09-18");
|
|
56
|
+
assert.match(localDateIso(), /^\d{4}-\d{2}-\d{2}$/);
|
|
57
|
+
});
|