@flowapt/flowiq-cli 0.7.3 → 0.7.4
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 +42 -1
- package/TEAM-GUIDE.md +2 -0
- package/package.json +1 -1
- package/src/commands/broadcast.js +157 -5
- package/src/index.js +14 -0
- package/src/reply-routing.test.mjs +58 -0
package/README.md
CHANGED
|
@@ -193,7 +193,7 @@ flowiq ct list
|
|
|
193
193
|
- **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
|
|
194
194
|
- `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
|
|
195
195
|
|
|
196
|
-
### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|retry|list` (alias `bc`)
|
|
196
|
+
### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|route|retry|list` (alias `bc`)
|
|
197
197
|
|
|
198
198
|
Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
|
|
199
199
|
template's variables **per row** from the CSV's own columns. The
|
|
@@ -237,6 +237,16 @@ flowiq bc scheduled list <org_id> # open rows (pending + await
|
|
|
237
237
|
flowiq bc scheduled list <org_id> --all # incl. fired / cancelled
|
|
238
238
|
flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (--at re-times it)
|
|
239
239
|
flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
240
|
+
|
|
241
|
+
# REPLY ROUTING (v0.7.4): assign EVERY reply to this broadcast to a team / person.
|
|
242
|
+
flowiq bc send <org_id> --tag <batch-tag> --template <name> --body param1="…" \
|
|
243
|
+
--route-team "Spa" --commit # typed replies AND button taps → the Spa team
|
|
244
|
+
flowiq bc send … --route-team "Spa" --route-member kim@client.com --route-window 48h --no-route-notify
|
|
245
|
+
flowiq bc send … --csv people.csv --campaign 17Sep_Spa --route-team "Spa" --at "2026-09-18 09:00" --commit
|
|
246
|
+
|
|
247
|
+
flowiq bc route <org_id> <broadcastId> # show what a broadcast routes to + how many chats it has routed
|
|
248
|
+
flowiq bc route <org_id> <broadcastId> --team "Spa" --commit # set it on a broadcast that already went out
|
|
249
|
+
flowiq bc route <org_id> <broadcastId> --clear --commit # switch it off
|
|
240
250
|
```
|
|
241
251
|
|
|
242
252
|
- **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
|
|
@@ -295,6 +305,37 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
|
295
305
|
template. In **tag mode the campaign slug defaults to the tag**, so to send
|
|
296
306
|
template A then template B to the SAME tag, pass a distinct `--campaign` for
|
|
297
307
|
each. `resume` passes no `--template`, so it is never affected.
|
|
308
|
+
- **Reply routing — `--route-team` / `--route-member` on `send`, and `bc route` (v0.7.4)**:
|
|
309
|
+
"every reply to this broadcast goes to team X / person Y". The first message a
|
|
310
|
+
recipient sends inside the window (default **72 h** after the send, `--route-window
|
|
311
|
+
48h|3d`, max 14 d) assigns the chat — **once per recipient per broadcast** — and
|
|
312
|
+
notifies the target through the normal assignment notification (the Teams-page
|
|
313
|
+
notify settings + the org's two notification flags still decide whether anything is
|
|
314
|
+
actually sent; `--no-route-notify` assigns silently). It is the fix for the gap a
|
|
315
|
+
quick-reply button + exact keyword + `assign_chat` cannot close: that route only
|
|
316
|
+
catches button TAPS, and a broad keyword mutes the AI agent. Routing is a side
|
|
317
|
+
effect — **keywords and the AI agent answer exactly as they would have**, and it
|
|
318
|
+
also applies to a chat whose agent is switched off. Skipped: business auto-replies
|
|
319
|
+
("thank you for contacting…", out of office) and opt-outs (`stop`). An internal
|
|
320
|
+
note in the chat says why it was assigned. **An existing assignment is
|
|
321
|
+
overwritten**, same as the keyword action.
|
|
322
|
+
- `--route-team` takes a team name (case-insensitive; a unique partial match is
|
|
323
|
+
fine) or id; `--route-member` takes an email, a name or an id and must be an
|
|
324
|
+
ACTIVE member of that org. A typo stops the run in the **dry run** with the org's
|
|
325
|
+
teams listed — nothing is tagged, queued or sent.
|
|
326
|
+
- The routing is stored on the broadcast (`broadcasts.reply_routing`), so it
|
|
327
|
+
**forces the python engine at any size** (the per-row Node engine writes one
|
|
328
|
+
broadcast per recipient). Works in tag mode, CSV mode and with `--at` (the queued
|
|
329
|
+
send carries it and it is applied the moment the send fires).
|
|
330
|
+
- After a commit the CLI prints `Replies → … ✓ saved`. If it prints **REPLY ROUTING
|
|
331
|
+
NOT SAVED / NOT CONFIRMED**, the messages still went out — fix it straight away
|
|
332
|
+
with `bc route <org> <broadcastId> --team … --commit`.
|
|
333
|
+
- `bc route` with no flags shows the current routing and how many chats it has
|
|
334
|
+
routed; with `--team/--member` sets it; `--clear` removes it. Dry-run unless
|
|
335
|
+
`--commit`, audited. Only replies arriving AFTER the change are routed — nothing
|
|
336
|
+
is applied retroactively. `bc status` prints the routing too.
|
|
337
|
+
- Using routing AND a button keyword with `assign_chat` on the same campaign is
|
|
338
|
+
harmless but notifies twice on a tap — drop the keyword's assign when routing is on.
|
|
298
339
|
- **`list-remote <org>` (v0.3.9)**: list the org's broadcasts **newest-first** with
|
|
299
340
|
the **full broadcastId** per row + template, status, recipient count and SAST
|
|
300
341
|
created time — the discovery step `status` / `retry` need (previously the id
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -172,6 +172,8 @@ several.
|
|
|
172
172
|
| 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`) |
|
|
173
173
|
| See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
|
|
174
174
|
| Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
|
|
175
|
+
| Make EVERY reply to a broadcast land with a team or a person (client asks "all replies to the Spa team") | Add `--route-team "Spa"` (and/or `--route-member kim@client.com`) to the `bc send`. The first reply from each recipient within 72 hours (`--route-window 48h` to change) assigns the chat and notifies them, typed replies and button taps alike. The AI agent and keywords still answer as normal, so nothing is muted. The dry run shows `Replies → team Spa …`; a wrong team name stops the run and lists the org's teams. Works with `--csv` and `--at` too. |
|
|
176
|
+
| Add, check or remove that routing on a broadcast that already went out | `flowiq bc route <org_id> <broadcastId>` shows it and how many chats it has routed · `… --team "Spa" --commit` sets it · `… --clear --commit` removes it. Only replies from now on are affected. |
|
|
175
177
|
| **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
|
|
176
178
|
| **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
|
|
177
179
|
| See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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": {
|
|
@@ -944,6 +944,10 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
+
// Reply routing: resolved before the dry-run exit so the preview shows it, and
|
|
948
|
+
// before the upsert so a bad team name never tags a single contact.
|
|
949
|
+
const replyRouting = isResume ? null : await checkRouting(orgId, opts);
|
|
950
|
+
|
|
947
951
|
if (!commitStage) {
|
|
948
952
|
console.log("");
|
|
949
953
|
console.log(`DRY RUN — nothing was ${scheduledAt ? "scheduled" : "sent"}, no contacts written or tagged. Add --commit to ${scheduledAt ? "queue it" : "send"}.`);
|
|
@@ -968,6 +972,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
968
972
|
body_parameters: tokens.body,
|
|
969
973
|
...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
|
|
970
974
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
975
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
971
976
|
dry_run: dryRun,
|
|
972
977
|
});
|
|
973
978
|
let pyTotal = null;
|
|
@@ -1013,6 +1018,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
1013
1018
|
body_parameters: tokens.body,
|
|
1014
1019
|
...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
|
|
1015
1020
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1021
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
1016
1022
|
});
|
|
1017
1023
|
} catch (e) {
|
|
1018
1024
|
console.error(`Schedule failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
|
|
@@ -1035,6 +1041,10 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
1035
1041
|
console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
|
|
1036
1042
|
}
|
|
1037
1043
|
console.log(` Also visible/manageable on the dashboard's Scheduled sends page.`);
|
|
1044
|
+
if (replyRouting) {
|
|
1045
|
+
if (resp.reply_routing?.saved) console.log(` Replies → ${describeRouting(resp.reply_routing.requested)} ✓ queued with the send (applied the moment it fires)`);
|
|
1046
|
+
else console.log(` ⚠ REPLY ROUTING NOT CONFIRMED by the server — set it after it fires with: flowiq bc route ${orgId} <broadcast_id> --team … --commit`);
|
|
1047
|
+
}
|
|
1038
1048
|
return;
|
|
1039
1049
|
}
|
|
1040
1050
|
|
|
@@ -1052,6 +1062,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
1052
1062
|
console.log(` Python is sending in the background to tag "${tag}" (${pyTotal ?? workSet.length} contact(s)) and tracking it under that broadcast id.`);
|
|
1053
1063
|
console.log(` Delivery: flowiq bc status ${orgId} ${out.broadcastId || "<broadcast_id>"} [--failures]`);
|
|
1054
1064
|
console.log(` Failures: flowiq bc retry ${orgId} ${out.broadcastId || "<broadcast_id>"} --commit`);
|
|
1065
|
+
if (replyRouting) reportRoutingResult(orgId, out.reply_routing, out.broadcastId ?? out.broadcast_id ?? null);
|
|
1055
1066
|
}
|
|
1056
1067
|
|
|
1057
1068
|
/** The paced, write-ahead-logged per-row send loop + final report. */
|
|
@@ -1146,13 +1157,98 @@ export function collectKV(pair, mapAcc) {
|
|
|
1146
1157
|
* (allow_broadcast=true, not blocked) and sends in the background, returning a
|
|
1147
1158
|
* broadcastId. No CLI write-ahead log / resume for this engine (python owns the
|
|
1148
1159
|
* broadcast record). Dry-run (no --commit) asks python for the eligible count. */
|
|
1149
|
-
|
|
1160
|
+
// ── Reply routing (v0.7.4) ───────────────────────────────────────────────────
|
|
1161
|
+
// `--route-team` / `--route-member` = "assign every reply to this broadcast to …".
|
|
1162
|
+
// The server resolves names → ids, stores it on the broadcast, and the agent runtime
|
|
1163
|
+
// applies it once per recipient inside the window WITHOUT muting keywords or the AI.
|
|
1164
|
+
|
|
1165
|
+
/** "72" | "72h" | "3d" → whole hours, or { error }. */
|
|
1166
|
+
export function parseRouteWindow(input) {
|
|
1167
|
+
if (input === undefined || input === null || input === "") return { hours: null };
|
|
1168
|
+
const m = String(input).trim().toLowerCase().match(/^(\d{1,4})\s*(h|hr|hrs|hours?|d|days?)?$/);
|
|
1169
|
+
if (!m) return { error: `"${input}" is not a window — use hours or days, e.g. 72, 72h or 3d` };
|
|
1170
|
+
const n = Number(m[1]);
|
|
1171
|
+
const hours = m[2] && m[2].startsWith("d") ? n * 24 : n;
|
|
1172
|
+
if (hours < 1 || hours > 336) return { error: `the window must be between 1 hour and 14 days (got ${hours} h)` };
|
|
1173
|
+
return { hours };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** The send flags → the request's reply_routing object (null when no routing asked for). */
|
|
1177
|
+
export function routingFromOpts(opts = {}, keys = { team: "routeTeam", member: "routeMember", window: "routeWindow", notify: "routeNotify" }) {
|
|
1178
|
+
const team = typeof opts[keys.team] === "string" ? opts[keys.team].trim() : "";
|
|
1179
|
+
const member = typeof opts[keys.member] === "string" ? opts[keys.member].trim() : "";
|
|
1180
|
+
const hasWindow = opts[keys.window] !== undefined && opts[keys.window] !== null && opts[keys.window] !== "";
|
|
1181
|
+
const noNotify = opts[keys.notify] === false;
|
|
1182
|
+
if (!team && !member) {
|
|
1183
|
+
if (hasWindow || noNotify) return { error: "the routing window / no-notify flags need a team or a member to route to" };
|
|
1184
|
+
return { routing: null };
|
|
1185
|
+
}
|
|
1186
|
+
const win = parseRouteWindow(opts[keys.window]);
|
|
1187
|
+
if (win.error) return { error: win.error };
|
|
1188
|
+
return {
|
|
1189
|
+
routing: {
|
|
1190
|
+
...(team ? { team } : {}),
|
|
1191
|
+
...(member ? { member } : {}),
|
|
1192
|
+
...(win.hours ? { window_hours: win.hours } : {}),
|
|
1193
|
+
...(noNotify ? { notify: false } : {}),
|
|
1194
|
+
},
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
export function describeRouting(d) {
|
|
1199
|
+
if (!d) return "none";
|
|
1200
|
+
const who = [d.team_name ? `team ${d.team_name}` : null, d.member_name || d.member_email || null].filter(Boolean).join(" + ") || "(unnamed target)";
|
|
1201
|
+
const notes = [];
|
|
1202
|
+
if (d.team_name) notes.push(d.notify_team === false ? "team not notified" : "team notified");
|
|
1203
|
+
if (d.member_name || d.member_email) notes.push(d.notify_member === false ? "person not notified" : "person notified");
|
|
1204
|
+
return `${who} · first reply within ${d.window_hours ?? 72} h · ${notes.join(", ")}`;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Resolve the routing on the server BEFORE anything is tagged, queued or sent. Exits on a bad team/member. */
|
|
1208
|
+
async function checkRouting(orgId, opts) {
|
|
1209
|
+
const parsed = routingFromOpts(opts);
|
|
1210
|
+
if (parsed.error) { console.error(`Error: ${parsed.error}`); process.exit(1); }
|
|
1211
|
+
if (!parsed.routing) return null;
|
|
1212
|
+
let resp;
|
|
1213
|
+
try { resp = await http.post("broadcast", { action: "resolve-routing", organization_id: orgId, reply_routing: parsed.routing }); }
|
|
1214
|
+
catch (e) {
|
|
1215
|
+
console.error(`Reply routing refused: ${e.body?.error || e.message}`);
|
|
1216
|
+
if (/unknown action|Unknown action|not supported/i.test(String(e.body?.error || "")) || e.status === 404) {
|
|
1217
|
+
console.error(" (the server does not know reply routing yet — run `flowiq doctor`)");
|
|
1218
|
+
}
|
|
1219
|
+
process.exit(1);
|
|
1220
|
+
}
|
|
1221
|
+
if (!resp?.reply_routing) {
|
|
1222
|
+
console.error("Reply routing refused: the server did not confirm it (it may predate v0.7.4 — run `flowiq doctor`). Nothing sent.");
|
|
1223
|
+
process.exit(1);
|
|
1224
|
+
}
|
|
1225
|
+
console.log(`Replies → ${describeRouting(resp.reply_routing)}`);
|
|
1226
|
+
console.log(` (assigned once per recipient; keywords and the AI agent still answer as normal)`);
|
|
1227
|
+
return parsed.routing;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** After a commit: say plainly whether the routing landed. */
|
|
1231
|
+
function reportRoutingResult(orgId, rr, broadcastId) {
|
|
1232
|
+
if (!rr) {
|
|
1233
|
+
console.log(" ⚠ REPLY ROUTING NOT CONFIRMED by the server — replies will NOT be assigned.");
|
|
1234
|
+
if (broadcastId) console.log(` Fix now: flowiq bc route ${orgId} ${broadcastId} --team … --commit`);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
if (rr.saved) console.log(` Replies → ${describeRouting(rr.requested)} ✓ saved`);
|
|
1238
|
+
else {
|
|
1239
|
+
console.log(` ⚠ REPLY ROUTING NOT SAVED: ${rr.error || "unknown reason"}`);
|
|
1240
|
+
if (broadcastId) console.log(` Fix now: flowiq bc route ${orgId} ${broadcastId} --team … --commit`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1150
1245
|
const reqBody = (dryRun) => ({
|
|
1151
1246
|
action: "send-python", organization_id: orgId, tag, template_name: templateName,
|
|
1152
1247
|
body_parameters: bodyLiterals,
|
|
1153
1248
|
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
1154
1249
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1155
1250
|
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1251
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
1156
1252
|
dry_run: dryRun,
|
|
1157
1253
|
});
|
|
1158
1254
|
|
|
@@ -1189,6 +1285,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
|
|
|
1189
1285
|
console.log("");
|
|
1190
1286
|
console.log(`✅ ${out.message || "Broadcast started"}${out.broadcastId ? ` · broadcastId ${out.broadcastId}` : ""}`);
|
|
1191
1287
|
console.log(` Python is sending in the background${out.mode ? ` (${out.mode})` : ""} and tracking it under that broadcast id — no CLI resume for this engine.`);
|
|
1288
|
+
if (replyRouting) reportRoutingResult(orgId, out.reply_routing, out.broadcastId ?? out.broadcast_id ?? null);
|
|
1192
1289
|
}
|
|
1193
1290
|
|
|
1194
1291
|
async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
@@ -1259,7 +1356,11 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1259
1356
|
const cardOverrides = carouselPlan?.overrides ?? null;
|
|
1260
1357
|
const cardLines = carouselPlan?.lines ?? [];
|
|
1261
1358
|
|
|
1262
|
-
|
|
1359
|
+
// Reply routing is resolved HERE — after the template checks, before any engine is
|
|
1360
|
+
// picked — so a mistyped team stops the run in the dry run, not after a send.
|
|
1361
|
+
const replyRouting = await checkRouting(orgId, opts);
|
|
1362
|
+
|
|
1363
|
+
if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1263
1364
|
|
|
1264
1365
|
// ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
|
|
1265
1366
|
// python /meta-broadcast engine (the proven bulk sender). --python forces it at
|
|
@@ -1270,8 +1371,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
1270
1371
|
if (template.is_carousel && !opts.python) {
|
|
1271
1372
|
console.log("Carousel template → PYTHON engine at any size (the per-row Node engine cannot send carousels).");
|
|
1272
1373
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1374
|
+
// Reply routing lives on ONE broadcasts row, which only the python engine creates per
|
|
1375
|
+
// send (the per-row Node engine writes a row per recipient) → python at any size.
|
|
1376
|
+
if (replyRouting && !opts.python && !template.is_carousel) {
|
|
1377
|
+
console.log("Reply routing → PYTHON engine at any size (the routing is stored on the single broadcast python creates).");
|
|
1378
|
+
}
|
|
1379
|
+
if (opts.python || template.is_carousel || replyRouting) {
|
|
1380
|
+
return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
|
|
1275
1381
|
}
|
|
1276
1382
|
|
|
1277
1383
|
// resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
|
|
@@ -1433,7 +1539,7 @@ const fmtSast = (iso) =>
|
|
|
1433
1539
|
new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
|
|
1434
1540
|
|
|
1435
1541
|
/** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
|
|
1436
|
-
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
|
|
1542
|
+
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1437
1543
|
const when = parseSastAt(opts.at);
|
|
1438
1544
|
if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
|
|
1439
1545
|
if (new Date(when.iso).getTime() <= Date.now()) {
|
|
@@ -1475,6 +1581,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1475
1581
|
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
1476
1582
|
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1477
1583
|
...(cardOverrides ? { card_overrides: cardOverrides } : {}),
|
|
1584
|
+
...(replyRouting ? { reply_routing: replyRouting } : {}),
|
|
1478
1585
|
});
|
|
1479
1586
|
} catch (e) {
|
|
1480
1587
|
console.error(`Schedule failed: ${e.message}`);
|
|
@@ -1491,6 +1598,10 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
|
|
|
1491
1598
|
console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
|
|
1492
1599
|
console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
|
|
1493
1600
|
}
|
|
1601
|
+
if (replyRouting) {
|
|
1602
|
+
if (resp.reply_routing?.saved) console.log(` Replies → ${describeRouting(resp.reply_routing.requested)} ✓ queued with the send (applied the moment it fires)`);
|
|
1603
|
+
else console.log(` ⚠ REPLY ROUTING NOT CONFIRMED by the server — cancel and re-schedule, or set it after it fires with: flowiq bc route ${orgId} <broadcast_id> --team … --commit`);
|
|
1604
|
+
}
|
|
1494
1605
|
}
|
|
1495
1606
|
|
|
1496
1607
|
/** List scheduled broadcasts (queue rows). Read-only. */
|
|
@@ -1515,6 +1626,7 @@ export async function scheduledList(orgId, opts = {}) {
|
|
|
1515
1626
|
console.log(` ${fmtSast(s.scheduled_for)} SAST · ${s.status}${flag}`);
|
|
1516
1627
|
console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
|
|
1517
1628
|
if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
|
|
1629
|
+
if (s.reply_routing) console.log(` replies → ${describeRouting(s.reply_routing)}`);
|
|
1518
1630
|
if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
|
|
1519
1631
|
if (s.error_message) console.log(` error: ${s.error_message}`);
|
|
1520
1632
|
}
|
|
@@ -1628,6 +1740,11 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
1628
1740
|
if (b.total_recipients) {
|
|
1629
1741
|
console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
|
|
1630
1742
|
}
|
|
1743
|
+
if (resp.reply_routing) {
|
|
1744
|
+
const rr = resp.reply_routing;
|
|
1745
|
+
console.log(` replies → ${describeRouting(rr)}${rr.enabled === false ? " (switched OFF)" : ""}`);
|
|
1746
|
+
console.log(` ${rr.routed} chat(s) routed so far${rr.set_by ? ` · set by ${rr.set_by}` : ""}`);
|
|
1747
|
+
}
|
|
1631
1748
|
if (opts.failures && resp.failures) {
|
|
1632
1749
|
console.log("");
|
|
1633
1750
|
console.log(` Failures by reason:`);
|
|
@@ -1641,6 +1758,41 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
1641
1758
|
}
|
|
1642
1759
|
}
|
|
1643
1760
|
|
|
1761
|
+
/** Show / set / clear reply routing on a broadcast that already exists. Dry run unless --commit. */
|
|
1762
|
+
export async function route(orgId, broadcastId, opts = {}) {
|
|
1763
|
+
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
1764
|
+
if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (see `flowiq bc list-remote <org>`)."); process.exit(1); }
|
|
1765
|
+
const parsed = routingFromOpts(opts, { team: "team", member: "member", window: "window", notify: "notify" });
|
|
1766
|
+
if (parsed.error) { console.error(`Error: ${parsed.error}`); process.exit(1); }
|
|
1767
|
+
if (opts.clear && parsed.routing) { console.error("Error: pass --clear OR a --team/--member, not both."); process.exit(1); }
|
|
1768
|
+
let resp;
|
|
1769
|
+
try {
|
|
1770
|
+
resp = await http.post("broadcast", {
|
|
1771
|
+
action: "route", organization_id: orgId, broadcast_id: broadcastId,
|
|
1772
|
+
...(parsed.routing ? { reply_routing: parsed.routing } : {}),
|
|
1773
|
+
...(opts.clear ? { clear: true } : {}),
|
|
1774
|
+
commit: !!opts.commit,
|
|
1775
|
+
});
|
|
1776
|
+
} catch (e) { console.error(`Route failed: ${e.body?.error || e.message}`); process.exit(1); }
|
|
1777
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
1778
|
+
const b = resp.broadcast;
|
|
1779
|
+
console.log(`Broadcast ${b.id} — ${resp.organization_name}`);
|
|
1780
|
+
console.log(` template: ${b.template_name} · sent ${fmtSast(b.created_at)} SAST · ${b.total_recipients ?? "?"} recipient(s)`);
|
|
1781
|
+
console.log(` replies → ${resp.current ? describeRouting(resp.current) : "no routing set"}`);
|
|
1782
|
+
console.log(` ${resp.routed} chat(s) routed so far`);
|
|
1783
|
+
if (resp.mode === "show") {
|
|
1784
|
+
console.log("");
|
|
1785
|
+
console.log(`Set it: flowiq bc route ${orgId} ${b.id} --team "<team>" [--member <email>] [--window 72h] [--no-notify] --commit`);
|
|
1786
|
+
console.log(`Clear it: flowiq bc route ${orgId} ${b.id} --clear --commit`);
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
console.log("");
|
|
1790
|
+
console.log(` ${resp.mode === "clear" ? "CLEAR" : "SET"} → ${resp.mode === "clear" ? "no routing (replies are no longer assigned)" : describeRouting(resp.next)}`);
|
|
1791
|
+
for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
|
|
1792
|
+
if (resp.dry_run) { console.log(""); console.log("DRY RUN — nothing changed. Add --commit to apply."); return; }
|
|
1793
|
+
console.log(` ✓ saved. Only replies that arrive from now on are routed; chats already routed are left as they are.`);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1644
1796
|
/** Re-send a broadcast to ONLY its failed recipients (transient-failure recovery).
|
|
1645
1797
|
* Reconstructs the send from the broadcasts row + re-fires via python. The dry-run
|
|
1646
1798
|
* shows the failed count + reason breakdown; --commit creates a NEW broadcast. */
|
package/src/index.js
CHANGED
|
@@ -628,6 +628,10 @@ export function run(argv) {
|
|
|
628
628
|
.option("--python", "with --tag: force the python /meta-broadcast engine (same as the dashboard's Python toggle). NOTE: any tag send >10 recipients ALWAYS uses python automatically")
|
|
629
629
|
.option("--at <when>", "SCHEDULE instead of sending now (tag AND CSV mode, v0.6.1) — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). CSV: rows are imported+tagged NOW, the python call is queued server-side and shows on the dashboard's Scheduled sends page. Fires automatically; the tag is re-resolved at send time")
|
|
630
630
|
.option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
|
|
631
|
+
.option("--route-team <team>", "REPLY ROUTING (v0.7.4): assign every reply to this broadcast to this team (name or id). Once per recipient, typed replies AND button taps, without muting keywords or the AI agent. Forces the python engine")
|
|
632
|
+
.option("--route-member <who>", "REPLY ROUTING: also/instead assign to this person (email, name or id) — must be an active member of the org")
|
|
633
|
+
.option("--route-window <dur>", "REPLY ROUTING: how long after the send a reply still counts, e.g. 72h or 3d (default 72h, max 14d)")
|
|
634
|
+
.option("--no-route-notify", "REPLY ROUTING: assign silently — do not send the team/person their assignment notification")
|
|
631
635
|
.option("--commit", "actually send (omit to dry-run)")
|
|
632
636
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
633
637
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
@@ -679,6 +683,16 @@ export function run(argv) {
|
|
|
679
683
|
.option("--limit <n>", "with --failures: how many failed rows to print", "40")
|
|
680
684
|
.option("--json", "raw JSON")
|
|
681
685
|
.action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
|
|
686
|
+
broadcast.command("route <organization_id> <broadcast_id>")
|
|
687
|
+
.description("Reply routing on an EXISTING broadcast: no flags = show it; --team/--member = set it; --clear = switch it off. DRY-RUN unless --commit. Only replies arriving after the change are routed")
|
|
688
|
+
.option("--team <team>", "assign replies to this team (name or id)")
|
|
689
|
+
.option("--member <who>", "assign replies to this person (email, name or id)")
|
|
690
|
+
.option("--window <dur>", "how long after the SEND a reply still counts, e.g. 72h or 3d (default 72h, max 14d)")
|
|
691
|
+
.option("--no-notify", "assign silently (no assignment notification)")
|
|
692
|
+
.option("--clear", "remove the routing from this broadcast")
|
|
693
|
+
.option("--commit", "apply (omit to dry-run)")
|
|
694
|
+
.option("--json", "raw JSON")
|
|
695
|
+
.action((orgId, broadcastId, opts) => broadcastCmd.route(orgId, broadcastId, opts));
|
|
682
696
|
broadcast.command("retry <organization_id> <broadcast_id>")
|
|
683
697
|
.description("Re-send a broadcast to ONLY its failed recipients (transient-failure recovery). DRY-RUN by default; --commit re-fires via python")
|
|
684
698
|
.option("--commit", "actually re-send (omit to see the failed count + reasons)")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Reply routing flag parsing (v0.7.4) — pure functions only, no network.
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { parseRouteWindow, routingFromOpts, describeRouting } from "./commands/broadcast.js";
|
|
5
|
+
|
|
6
|
+
test("window: hours, h suffix, days", () => {
|
|
7
|
+
assert.deepEqual(parseRouteWindow("72"), { hours: 72 });
|
|
8
|
+
assert.deepEqual(parseRouteWindow("72h"), { hours: 72 });
|
|
9
|
+
assert.deepEqual(parseRouteWindow(" 3D "), { hours: 72 });
|
|
10
|
+
assert.deepEqual(parseRouteWindow("14 days"), { hours: 336 });
|
|
11
|
+
assert.deepEqual(parseRouteWindow(undefined), { hours: null });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("window: rejects garbage, zero and anything past 14 days", () => {
|
|
15
|
+
assert.ok(parseRouteWindow("soon").error);
|
|
16
|
+
assert.ok(parseRouteWindow("0").error);
|
|
17
|
+
assert.ok(parseRouteWindow("15d").error);
|
|
18
|
+
assert.ok(parseRouteWindow("-5").error);
|
|
19
|
+
assert.ok(parseRouteWindow("1.5h").error);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("no routing flags = no routing, and the send request stays untouched", () => {
|
|
23
|
+
assert.deepEqual(routingFromOpts({ routeNotify: true }), { routing: null });
|
|
24
|
+
assert.deepEqual(routingFromOpts({}), { routing: null });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a window or --no-route-notify without a target is an error, never a silent no-op", () => {
|
|
28
|
+
assert.ok(routingFromOpts({ routeWindow: "48h" }).error);
|
|
29
|
+
assert.ok(routingFromOpts({ routeNotify: false }).error);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("team + member + window + silent", () => {
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
routingFromOpts({ routeTeam: " Spa ", routeMember: "kim@example.com", routeWindow: "2d", routeNotify: false }),
|
|
35
|
+
{ routing: { team: "Spa", member: "kim@example.com", window_hours: 48, notify: false } },
|
|
36
|
+
);
|
|
37
|
+
// Defaults are left to the server (72 h, notify on) — the client sends only what was typed.
|
|
38
|
+
assert.deepEqual(routingFromOpts({ routeTeam: "Spa", routeNotify: true }), { routing: { team: "Spa" } });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a bad window stops the run", () => {
|
|
42
|
+
assert.ok(routingFromOpts({ routeTeam: "Spa", routeWindow: "whenever" }).error);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("`bc route` reads its own flag names", () => {
|
|
46
|
+
const keys = { team: "team", member: "member", window: "window", notify: "notify" };
|
|
47
|
+
assert.deepEqual(routingFromOpts({ team: "Spa", window: "24", notify: true }, keys), { routing: { team: "Spa", window_hours: 24 } });
|
|
48
|
+
assert.deepEqual(routingFromOpts({ notify: true }, keys), { routing: null });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("describeRouting reads like a sentence", () => {
|
|
52
|
+
assert.equal(describeRouting(null), "none");
|
|
53
|
+
assert.equal(
|
|
54
|
+
describeRouting({ team_name: "Spa", member_name: null, window_hours: 72, notify_team: true, notify_member: null }),
|
|
55
|
+
"team Spa · first reply within 72 h · team notified",
|
|
56
|
+
);
|
|
57
|
+
assert.match(describeRouting({ team_name: "Spa", member_name: "Kim", window_hours: 24, notify_team: false, notify_member: false }), /team Spa \+ Kim .* 24 h .* team not notified, person not notified/);
|
|
58
|
+
});
|