@flowapt/flowiq-cli 0.7.4 → 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 +57 -6
- package/TEAM-GUIDE.md +3 -0
- package/package.json +1 -1
- package/src/button-params.test.mjs +50 -0
- package/src/campaign-naming.js +13 -0
- package/src/campaign-naming.test.mjs +13 -0
- package/src/commands/broadcast.js +190 -16
- 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
|
|
@@ -455,8 +455,43 @@ instead of a CSV. Same guards, same status log, same resume:
|
|
|
455
455
|
```bash
|
|
456
456
|
flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
|
|
457
457
|
--body param1="Hi {{first_name}}" [--button param1=<short-code>] --commit
|
|
458
|
+
|
|
459
|
+
# A template with TWO link buttons (v0.7.5), e.g. one per product: one --button per button,
|
|
460
|
+
# in the order the buttons appear on the template.
|
|
461
|
+
flowiq bc send <org_id> --tag new-arrivals-batch-01 --template new_arrivals_v3 \
|
|
462
|
+
--button param1=<cycle-harmony-code> --button param2=<neuroshyft-code> --commit
|
|
458
463
|
```
|
|
459
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
|
+
|
|
486
|
+
**Link buttons (v0.7.5):** every button whose URL carries a `{{1}}` needs its own
|
|
487
|
+
`--button paramN=` value (`param1` = the first such button, `param2` = the
|
|
488
|
+
second). The dry run prints each button's final URL and stops if one is
|
|
489
|
+
missing (WhatsApp rejects the whole message when a link button is empty); a
|
|
490
|
+
value with no button to go to is ignored with a warning. Swapping the codes per
|
|
491
|
+
batch is how one campaign gets per-batch click counts: mint each batch's links
|
|
492
|
+
with the same `--campaign` and a different `--content`. CSV mode fills one link
|
|
493
|
+
button only, so a two-button template is refused there.
|
|
494
|
+
|
|
460
495
|
Values are shared across the tag (use `{{first_name}}` etc. for per-contact
|
|
461
496
|
personalization — resolved server-side). Supported per-contact tokens:
|
|
462
497
|
`{{first_name}}` `{{full_name}}` `{{email}}` `{{phone_number}}`
|
|
@@ -1227,25 +1262,41 @@ flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send,
|
|
|
1227
1262
|
(`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
|
|
1228
1263
|
(`flowiq audit --endpoint team-updates`).
|
|
1229
1264
|
|
|
1230
|
-
### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
|
|
1265
|
+
### WhatsApp templates — `flowiq templates pull|list|show|create|status|attempts` (alias `tpl`)
|
|
1231
1266
|
|
|
1232
1267
|
Read an org's live templates straight from Meta (read-only), render any single
|
|
1233
|
-
row **including an unsubmitted DRAFT**,
|
|
1234
|
-
`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.
|
|
1235
1271
|
|
|
1236
1272
|
```bash
|
|
1237
1273
|
flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
|
|
1238
1274
|
flowiq templates status <organization_id> --name booking # poll approval
|
|
1239
1275
|
flowiq templates show <organization_id> heritage_day_v2 # render ONE row, drafts included
|
|
1240
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)
|
|
1241
1278
|
```
|
|
1242
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
|
+
|
|
1243
1292
|
**`show` is the only way to read a DRAFT from the terminal (v0.6.7).** A draft
|
|
1244
1293
|
never reaches Meta, so `templates pull` cannot see it and neither can the
|
|
1245
1294
|
broadcast introspector — before this, reviewing one meant opening the dialog.
|
|
1246
1295
|
`show` prints the body with its examples substituted (as the customer will read
|
|
1247
|
-
it), then
|
|
1248
|
-
|
|
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`
|
|
1249
1300
|
also prints the escaped body so invisible whitespace is visible; `--json` gives
|
|
1250
1301
|
the row.
|
|
1251
1302
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -167,6 +167,8 @@ 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) |
|
|
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). |
|
|
170
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.** |
|
|
171
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) |
|
|
172
174
|
| 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`) |
|
|
@@ -183,6 +185,7 @@ several.
|
|
|
183
185
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
|
184
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 |
|
|
185
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 |
|
|
186
189
|
| Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
|
|
187
190
|
| Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
|
|
188
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": {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveButtonParams, checkButtonParams } from "./commands/broadcast.js";
|
|
4
|
+
|
|
5
|
+
const oneBtn = { dynamic_url_buttons: 1, url_button: { present: true } };
|
|
6
|
+
const twoBtn = { dynamic_url_buttons: 2, url_button: { present: true } };
|
|
7
|
+
const noBtn = { dynamic_url_buttons: 0, url_button: { present: false } };
|
|
8
|
+
|
|
9
|
+
test("flags win over the saved campaign", () => {
|
|
10
|
+
assert.deepEqual(resolveButtonParams({ param1: "a", param2: "b" }, { button_params_literal: { param1: "x" } }), { param1: "a", param2: "b" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("saved multi-button values, then the pre-0.7.5 single value", () => {
|
|
14
|
+
assert.deepEqual(resolveButtonParams({}, { button_params_literal: { param1: "x", param2: "y" } }), { param1: "x", param2: "y" });
|
|
15
|
+
assert.deepEqual(resolveButtonParams({}, { button_param_literal: "old" }), { param1: "old" });
|
|
16
|
+
assert.deepEqual(resolveButtonParams(undefined, null), {});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("empty values are dropped", () => {
|
|
20
|
+
assert.deepEqual(resolveButtonParams({ param1: "a", param2: "" }, null), { param1: "a" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("one dynamic button: same message as before", () => {
|
|
24
|
+
assert.deepEqual(checkButtonParams(oneBtn, {}).aborts, ["template has a dynamic URL button — pass --button param1=<code>"]);
|
|
25
|
+
assert.deepEqual(checkButtonParams(oneBtn, { param1: "abc" }), { aborts: [], warnings: [] });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("two dynamic buttons need both values", () => {
|
|
29
|
+
const r = checkButtonParams(twoBtn, { param1: "abc" });
|
|
30
|
+
assert.equal(r.aborts.length, 1);
|
|
31
|
+
assert.match(r.aborts[0], /2 dynamic URL buttons/);
|
|
32
|
+
assert.match(r.aborts[0], /missing param2/);
|
|
33
|
+
assert.deepEqual(checkButtonParams(twoBtn, { param1: "a", param2: "b" }), { aborts: [], warnings: [] });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a value with no button is a warning, not an abort", () => {
|
|
37
|
+
const r = checkButtonParams(oneBtn, { param1: "a", param2: "b" });
|
|
38
|
+
assert.deepEqual(r.aborts, []);
|
|
39
|
+
assert.match(r.warnings[0], /param2 ignored/);
|
|
40
|
+
assert.match(checkButtonParams(noBtn, { param1: "a" }).warnings[0], /0 dynamic URL buttons/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("non-param keys abort", () => {
|
|
44
|
+
assert.match(checkButtonParams(oneBtn, { code: "a", param1: "b" }).aborts[0], /param1\.\.N/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("older server without dynamic_url_buttons falls back to url_button", () => {
|
|
48
|
+
assert.equal(checkButtonParams({ url_button: { present: true } }, {}).aborts.length, 1);
|
|
49
|
+
assert.equal(checkButtonParams({ url_button: { present: false } }, {}).aborts.length, 0);
|
|
50
|
+
});
|
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
|
+
});
|
|
@@ -502,6 +502,7 @@ function validateRows(template, mapping, headers, rows, illegalChars, headerMedi
|
|
|
502
502
|
const aborts = [];
|
|
503
503
|
if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
|
|
504
504
|
if (template.parameter_format !== "POSITIONAL") aborts.push("V-2: NAMED templates are not supported in v1");
|
|
505
|
+
if ((template.dynamic_url_buttons ?? 0) > 1) aborts.push(`V-3: this template has ${template.dynamic_url_buttons} dynamic URL buttons; CSV mode fills one — use tag mode: flowiq bc send <org> --tag <tag> --template <name> --button param1=<code> --button param2=<code>`);
|
|
505
506
|
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)");
|
|
506
507
|
// Media headers (image/video/document) ARE supported — they just need an image
|
|
507
508
|
// URL, exactly like the dashboard. Resolved as --header-media / saved mapping /
|
|
@@ -604,12 +605,159 @@ function renderPreview(template, sample, headerMedia) {
|
|
|
604
605
|
console.log(`Row ${sample.rownum} → ${sample.number} [VALID]${sample.sanitized ? " (sanitized)" : ""}`);
|
|
605
606
|
if (headerMedia) console.log(` Header (${template.header_type}): ${headerMedia}`);
|
|
606
607
|
console.log(` Body:\n ${bodyText.replace(/\n/g, "\n ")}`);
|
|
608
|
+
// Two-dynamic-button templates (17 Sep 2026): show every button with its own value.
|
|
609
|
+
if (Array.isArray(template.url_buttons) && template.url_buttons.length && sample.buttonParams) {
|
|
610
|
+
for (const b of template.url_buttons) {
|
|
611
|
+
const v = sample.buttonParams[b.param];
|
|
612
|
+
if (v == null) continue;
|
|
613
|
+
console.log(` Button "${b.text ?? "URL"}" (${b.param}) → ${b.url_base.replace(/\{\{[^}]+\}\}/, v)}`);
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
607
617
|
if (template.url_button?.present && sample.buttonValue != null) {
|
|
608
618
|
const url = template.url_button.url_base.replace(/\{\{[^}]+\}\}/, sample.buttonValue);
|
|
609
619
|
console.log(` Button "${template.url_button.text ?? "URL"}" → ${url}`);
|
|
610
620
|
}
|
|
611
621
|
}
|
|
612
622
|
|
|
623
|
+
/** Tag-mode URL-button values: every `--button paramN=…` flag, else the saved
|
|
624
|
+
* campaign's values (new `button_params_literal`, or the pre-0.7.5 single
|
|
625
|
+
* `button_param_literal` as param1). Empty values are dropped. */
|
|
626
|
+
export function resolveButtonParams(flagButtons, cfg) {
|
|
627
|
+
const out = {};
|
|
628
|
+
const src = flagButtons && Object.keys(flagButtons).length
|
|
629
|
+
? flagButtons
|
|
630
|
+
: (cfg?.button_params_literal && Object.keys(cfg.button_params_literal).length
|
|
631
|
+
? cfg.button_params_literal
|
|
632
|
+
: (cfg?.button_param_literal ? { param1: cfg.button_param_literal } : {}));
|
|
633
|
+
for (const [k, v] of Object.entries(src)) {
|
|
634
|
+
if (v == null || String(v) === "") continue;
|
|
635
|
+
out[k] = String(v);
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Checks tag-mode button values against the template's dynamic URL buttons.
|
|
641
|
+
* Every button needs its own value (python / send-template send '' for a
|
|
642
|
+
* missing one and Meta rejects the message); a value with no button to go to
|
|
643
|
+
* is a warning (it is ignored by both engines). */
|
|
644
|
+
export function checkButtonParams(template, buttonParams) {
|
|
645
|
+
const aborts = [];
|
|
646
|
+
const warnings = [];
|
|
647
|
+
const keys = Object.keys(buttonParams || {});
|
|
648
|
+
const bad = keys.filter((k) => !/^param\d+$/.test(k));
|
|
649
|
+
if (bad.length) aborts.push(`--button keys must be param1..N (got ${bad.join(", ")})`);
|
|
650
|
+
const need = Number.isInteger(template.dynamic_url_buttons)
|
|
651
|
+
? template.dynamic_url_buttons
|
|
652
|
+
: (template.url_button?.present ? 1 : 0);
|
|
653
|
+
const missing = [];
|
|
654
|
+
for (let i = 1; i <= need; i++) if (!buttonParams?.[`param${i}`]) missing.push(`param${i}`);
|
|
655
|
+
if (missing.length) {
|
|
656
|
+
const how = Array.from({ length: need }, (_, i) => `--button param${i + 1}=<code>`).join(" ");
|
|
657
|
+
aborts.push(need === 1
|
|
658
|
+
? "template has a dynamic URL button — pass --button param1=<code>"
|
|
659
|
+
: `template has ${need} dynamic URL buttons — pass ${how} (missing ${missing.join(", ")})`);
|
|
660
|
+
}
|
|
661
|
+
const extra = keys.filter((k) => /^param\d+$/.test(k) && Number(k.slice(5)) > need);
|
|
662
|
+
if (extra.length) warnings.push(`--button ${extra.join(", ")} ignored: template has ${need} dynamic URL button${need === 1 ? "" : "s"}`);
|
|
663
|
+
return { aborts, warnings };
|
|
664
|
+
}
|
|
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
|
+
|
|
613
761
|
// ---------------------------------------------------------------------------
|
|
614
762
|
// CSV → python fan-out builders (v0.6.0). Exported for the test harness.
|
|
615
763
|
// ---------------------------------------------------------------------------
|
|
@@ -695,7 +843,9 @@ async function sendOne(apiUrl, orgId, templateName, row, headerMedia) {
|
|
|
695
843
|
templateName,
|
|
696
844
|
whatsappNumber: row.number,
|
|
697
845
|
bodyParameters,
|
|
698
|
-
...(row.
|
|
846
|
+
...(row.buttonParams && Object.keys(row.buttonParams).length
|
|
847
|
+
? { buttonParameters: row.buttonParams }
|
|
848
|
+
: (row.buttonValue != null ? { buttonParameters: { param1: row.buttonValue } } : {})),
|
|
699
849
|
// Static media header (same field the dashboard passes for image/video/doc
|
|
700
850
|
// headers) — /api/send-template renders it onto the HEADER component.
|
|
701
851
|
...(headerMedia ? { headerMedia } : {}),
|
|
@@ -1241,11 +1391,12 @@ function reportRoutingResult(orgId, rr, broadcastId) {
|
|
|
1241
1391
|
}
|
|
1242
1392
|
}
|
|
1243
1393
|
|
|
1244
|
-
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1394
|
+
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams = null, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1395
|
+
const buttons = buttonParams && Object.keys(buttonParams).length ? buttonParams : (buttonLiteral ? { param1: buttonLiteral } : null);
|
|
1245
1396
|
const reqBody = (dryRun) => ({
|
|
1246
1397
|
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
1247
1398
|
body_parameters: bodyLiterals,
|
|
1248
|
-
...(
|
|
1399
|
+
...(buttons ? { button_parameters: buttons } : {}),
|
|
1249
1400
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1250
1401
|
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1251
1402
|
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
@@ -1262,7 +1413,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
|
|
|
1262
1413
|
console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
|
|
1263
1414
|
console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
|
|
1264
1415
|
const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
|
|
1265
|
-
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1416
|
+
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral, buttonParams: buttons }, headerMedia);
|
|
1266
1417
|
printCarouselPlan(cardOverrides, cardLines);
|
|
1267
1418
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
1268
1419
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
|
|
@@ -1307,7 +1458,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1307
1458
|
process.exit(1);
|
|
1308
1459
|
}
|
|
1309
1460
|
const bodyLiterals = Object.keys(opts.body || {}).length ? opts.body : (cfg?.body_params_literal ?? {});
|
|
1310
|
-
const
|
|
1461
|
+
const buttonParams = resolveButtonParams(opts.button, cfg);
|
|
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;
|
|
1311
1468
|
|
|
1312
1469
|
// introspect + validate params arithmetically (the #132000 guard, pre-send)
|
|
1313
1470
|
let intro;
|
|
@@ -1319,7 +1476,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1319
1476
|
? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
|
|
1320
1477
|
: null;
|
|
1321
1478
|
const headerCheck = await verifyHeaderMedia(template, headerMedia);
|
|
1322
|
-
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)` : ""}`);
|
|
1479
|
+
console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${headerCheck.label}${(template.dynamic_url_buttons ?? 0) > 1 ? `, ${template.dynamic_url_buttons} dynamic URL buttons` : (template.url_button?.present ? ", dynamic URL button" : "")}${template.is_carousel ? `, CAROUSEL (${template.carousel_cards?.length ?? "?"} cards)` : ""}`);
|
|
1323
1480
|
const aborts = [];
|
|
1324
1481
|
if (headerCheck.status === "mismatch") aborts.push(headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia }));
|
|
1325
1482
|
if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
|
|
@@ -1346,9 +1503,25 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1346
1503
|
const keys = Object.keys(bodyLiterals);
|
|
1347
1504
|
if (keys.some((k) => !/^param\d+$/.test(k))) aborts.push(`--body keys must be param1..N (got ${keys.join(", ")})`);
|
|
1348
1505
|
if (keys.length !== template.body_var_count) aborts.push(`template expects ${template.body_var_count} body param(s), you provided ${keys.length} (--body paramN=…). Values may use {{first_name}}/{{full_name}}/{{email}}/{{phone_number}} — resolved per contact.`);
|
|
1349
|
-
|
|
1506
|
+
const buttonCheck = checkButtonParams(template, buttonParams);
|
|
1507
|
+
for (const w of buttonCheck.warnings) console.log(`⚠ ${w}`);
|
|
1508
|
+
aborts.push(...buttonCheck.aborts);
|
|
1509
|
+
const linkPlan = planTrackedLinks(template, buttonParams);
|
|
1510
|
+
aborts.push(...linkPlan.aborts);
|
|
1350
1511
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
1351
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
|
+
|
|
1352
1525
|
// SCHEDULE (--at): validation above has already run, so a scheduled send is
|
|
1353
1526
|
// gated by exactly the same checks as an immediate one (APPROVED, positional,
|
|
1354
1527
|
// param arithmetic, header-media type). Instead of sending we queue the send
|
|
@@ -1360,7 +1533,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1360
1533
|
// picked — so a mistyped team stops the run in the dry run, not after a send.
|
|
1361
1534
|
const replyRouting = await checkRouting(orgId, opts);
|
|
1362
1535
|
|
|
1363
|
-
if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1536
|
+
if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1364
1537
|
|
|
1365
1538
|
// ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
|
|
1366
1539
|
// python /meta-broadcast engine (the proven bulk sender). --python forces it at
|
|
@@ -1377,7 +1550,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1377
1550
|
console.log("Reply routing → PYTHON engine at any size (the routing is stored on the single broadcast python creates).");
|
|
1378
1551
|
}
|
|
1379
1552
|
if (opts.python || template.is_carousel || replyRouting) {
|
|
1380
|
-
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1553
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1381
1554
|
}
|
|
1382
1555
|
|
|
1383
1556
|
// resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
|
|
@@ -1387,13 +1560,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1387
1560
|
catch (e) {
|
|
1388
1561
|
if (e.status === 413) {
|
|
1389
1562
|
console.log(`Tag "${tag}" has >2000 contacts → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
|
|
1390
|
-
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
1563
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage });
|
|
1391
1564
|
}
|
|
1392
1565
|
console.error(`Tag resolution failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1);
|
|
1393
1566
|
}
|
|
1394
1567
|
if (resolved.recipients.length > PYTHON_MIN) {
|
|
1395
1568
|
console.log(`Tag "${tag}": ${resolved.recipients.length} sendable (>${PYTHON_MIN}) → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
|
|
1396
|
-
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
1569
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams, commitStage });
|
|
1397
1570
|
}
|
|
1398
1571
|
console.log(`Tag "${tag}" on ${resolved.organization_name}: ${resolved.tagged_total} contact(s), ${resolved.recipients.length} sendable, ${resolved.excluded.length} excluded (opt-out/archived/blocked).`);
|
|
1399
1572
|
if (!resolved.recipients.length) { console.log("Nothing sendable under this tag."); return; }
|
|
@@ -1401,7 +1574,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1401
1574
|
const values = {};
|
|
1402
1575
|
for (const [k, v] of Object.entries(bodyLiterals)) values[k] = String(v);
|
|
1403
1576
|
let sendable = resolved.recipients.map((r, i) => ({
|
|
1404
|
-
rownum: i + 1, number: r.number, values, buttonValue: buttonLiteral, contactName: r.full_name,
|
|
1577
|
+
rownum: i + 1, number: r.number, values, buttonValue: buttonLiteral, buttonParams, contactName: r.full_name,
|
|
1405
1578
|
}));
|
|
1406
1579
|
if (opts.limit) sendable = sendable.slice(0, Number(opts.limit));
|
|
1407
1580
|
|
|
@@ -1447,7 +1620,7 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1447
1620
|
schema_version: 1, campaign, mode: "tag",
|
|
1448
1621
|
organization_id: orgId, organization_slug: slugify(resolved.organization_name, orgId.slice(0, 8)),
|
|
1449
1622
|
template_name: templateName, tag,
|
|
1450
|
-
body_params_literal: bodyLiterals, button_param_literal: buttonLiteral,
|
|
1623
|
+
body_params_literal: bodyLiterals, button_param_literal: buttonLiteral, button_params_literal: buttonParams,
|
|
1451
1624
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1452
1625
|
created_at: cfg?.created_at ?? new Date().toISOString(),
|
|
1453
1626
|
});
|
|
@@ -1539,7 +1712,8 @@ const fmtSast = (iso) =>
|
|
|
1539
1712
|
new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
|
|
1540
1713
|
|
|
1541
1714
|
/** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
|
|
1542
|
-
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1715
|
+
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams = null, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1716
|
+
const buttons = buttonParams && Object.keys(buttonParams).length ? buttonParams : (buttonLiteral ? { param1: buttonLiteral } : null);
|
|
1543
1717
|
const when = parseSastAt(opts.at);
|
|
1544
1718
|
if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
|
|
1545
1719
|
if (new Date(when.iso).getTime() <= Date.now()) {
|
|
@@ -1549,7 +1723,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1549
1723
|
const needsApproval = !!opts.needsApproval;
|
|
1550
1724
|
|
|
1551
1725
|
console.log("");
|
|
1552
|
-
renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1726
|
+
renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral, buttonParams: buttons }, headerMedia);
|
|
1553
1727
|
printCarouselPlan(cardOverrides, cardLines);
|
|
1554
1728
|
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
1555
1729
|
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
|
|
@@ -1578,7 +1752,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1578
1752
|
scheduled_for: when.iso,
|
|
1579
1753
|
needs_approval: needsApproval,
|
|
1580
1754
|
body_parameters: bodyLiterals,
|
|
1581
|
-
...(
|
|
1755
|
+
...(buttons ? { button_parameters: buttons } : {}),
|
|
1582
1756
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1583
1757
|
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1584
1758
|
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
@@ -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
|
|
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
|
+
});
|