@flowapt/flowiq-cli 0.9.7 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -10
- package/TEAM-GUIDE.md +21 -12
- package/package.json +1 -1
- package/src/agent-config.test.mjs +39 -0
- package/src/batch-a.test.mjs +36 -0
- package/src/commands/agent-config.js +141 -9
- package/src/commands/agent-test.js +23 -9
- package/src/commands/agent-updates.js +4 -3
- package/src/commands/audit.js +8 -1
- package/src/commands/broadcast.js +18 -2
- package/src/commands/doctor.js +13 -4
- package/src/commands/gaps.js +2 -2
- package/src/commands/keywords.js +43 -2
- package/src/commands/links.js +8 -0
- package/src/commands/prompts.js +25 -3
- package/src/commands/store-api.js +53 -6
- package/src/commands/templates.js +48 -5
- package/src/http.js +34 -8
- package/src/index.js +36 -9
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// expect/expectNot checks client-side, and prints the transcript.
|
|
5
5
|
//
|
|
6
6
|
// test resolve <org_id> [--agent <id>]
|
|
7
|
-
// test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json]
|
|
7
|
+
// test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json] [--force]
|
|
8
8
|
// test scenario <org_id> <file.json> [--agent] [--no-clear-between] [--json] [--sender] [--timeout]
|
|
9
9
|
// test qa <org_id> [--agent] ... (bundled generic smoke pack)
|
|
10
|
-
// test clear <org_id> [--agent]
|
|
10
|
+
// test clear <org_id> [--agent] [--force]
|
|
11
11
|
// test stress <org_id> [pack.json] [--agent] [--concurrency 6] [--timeout] [--out] [--contact]
|
|
12
12
|
// test cleanup <org_id> [--agent] --confirm (deletes stress contacts)
|
|
13
13
|
// test extract <org_id> [--out] (rebuild transcripts from DB)
|
|
@@ -28,7 +28,9 @@ function requireOrg(orgId) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// force: the server refuses send / clear on a test contact that had real
|
|
32
|
+
// (non-web) inbound in the last 6 h, i.e. a live customer conversation.
|
|
33
|
+
async function runTurn(orgId, { agent, message, image, clear, sender, timeout, force }) {
|
|
32
34
|
return http.post("agent-test", {
|
|
33
35
|
action: "send",
|
|
34
36
|
organization_id: orgId,
|
|
@@ -38,11 +40,18 @@ async function runTurn(orgId, { agent, message, image, clear, sender, timeout })
|
|
|
38
40
|
clear: !!clear,
|
|
39
41
|
sender,
|
|
40
42
|
timeout_ms: timeout,
|
|
43
|
+
force: !!force,
|
|
41
44
|
});
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
async function clearConv(orgId, agent) {
|
|
45
|
-
return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent });
|
|
47
|
+
async function clearConv(orgId, agent, force) {
|
|
48
|
+
return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent, force: !!force });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let warned = false;
|
|
52
|
+
function printWarnings(r) {
|
|
53
|
+
if (warned) return;
|
|
54
|
+
for (const w of r?.warnings || []) { console.error(`⚠ ${w}`); warned = true; }
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
// A bare string is ONE needle, not a list of characters. Iterating a string
|
|
@@ -88,9 +97,11 @@ export async function send(orgId, messageParts, opts = {}) {
|
|
|
88
97
|
if (!message) { console.error("usage: test send <org_id> <message...>"); process.exit(1); }
|
|
89
98
|
let r;
|
|
90
99
|
try {
|
|
91
|
-
r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout });
|
|
100
|
+
r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout, force: opts.force });
|
|
92
101
|
} catch (e) { console.error(`Send failed: ${e.message}`); process.exit(1); }
|
|
93
102
|
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
103
|
+
printWarnings(r);
|
|
104
|
+
if (opts.clear && r.previous_memory_cutoff !== undefined) console.log(`# cleared first; previous memory_cutoff: ${r.previous_memory_cutoff ?? "(none)"}`);
|
|
94
105
|
console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
|
|
95
106
|
printTurn({ title: "send", tq: "" }, 0, message, r, []);
|
|
96
107
|
}
|
|
@@ -103,14 +114,15 @@ async function runPack(orgId, pack, opts) {
|
|
|
103
114
|
for (let s = 0; s < scenarios.length; s++) {
|
|
104
115
|
const sc = scenarios[s];
|
|
105
116
|
if (clearBetween) {
|
|
106
|
-
try { await clearConv(orgId, opts.agent); } catch (e) { console.error(` (clear failed: ${e.message})`); }
|
|
117
|
+
try { printWarnings(await clearConv(orgId, opts.agent, opts.force)); } catch (e) { console.error(` (clear failed: ${e.message})`); }
|
|
107
118
|
}
|
|
108
119
|
const scResult = { id: sc.id, tq: sc.tq, title: sc.title, turns: [] };
|
|
109
120
|
for (let t = 0; t < sc.turns.length; t++) {
|
|
110
121
|
const turn = sc.turns[t];
|
|
111
122
|
let r;
|
|
112
123
|
try {
|
|
113
|
-
r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout });
|
|
124
|
+
r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout, force: opts.force });
|
|
125
|
+
printWarnings(r);
|
|
114
126
|
} catch (e) {
|
|
115
127
|
r = { status: "error", reply: null, tool_calls: [], raw: { error: e.message } };
|
|
116
128
|
}
|
|
@@ -144,10 +156,12 @@ export async function qa(orgId, opts = {}) {
|
|
|
144
156
|
export async function clear(orgId, opts = {}) {
|
|
145
157
|
requireOrg(orgId);
|
|
146
158
|
let resp;
|
|
147
|
-
try { resp = await clearConv(orgId, opts.agent); }
|
|
159
|
+
try { resp = await clearConv(orgId, opts.agent, opts.force); }
|
|
148
160
|
catch (e) { console.error(`Clear failed: ${e.message}`); process.exit(1); }
|
|
161
|
+
printWarnings(resp);
|
|
149
162
|
const tools = Number(resp.tools_cleared || 0);
|
|
150
163
|
console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; ${tools} remembered tool result(s) set aside in more_data.tool_usage_history_cleared; onboarding/disclaimer state NOT reset).`);
|
|
164
|
+
console.log(`Previous memory_cutoff: ${resp.previous_memory_cutoff ?? "(none)"}${resp.previous_memory_cutoff ? " (to undo, set contacts.memory_cutoff back to it)" : ""}`);
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
// ---------- phase 2: stress + extract ----------
|
|
@@ -320,8 +320,8 @@ export async function resolve(updateId, opts = {}) {
|
|
|
320
320
|
process.exit(1);
|
|
321
321
|
}
|
|
322
322
|
const status = opts.status || "resolved";
|
|
323
|
-
if (!["resolved", "declined"].includes(status)) {
|
|
324
|
-
console.error(`Error: --status must be resolved or
|
|
323
|
+
if (!["resolved", "declined", "in_progress"].includes(status)) {
|
|
324
|
+
console.error(`Error: --status must be resolved, declined or in_progress (got "${status}").`);
|
|
325
325
|
process.exit(1);
|
|
326
326
|
}
|
|
327
327
|
// R-3: a client-facing note is mandatory on resolve (--yes does NOT waive it).
|
|
@@ -332,7 +332,7 @@ export async function resolve(updateId, opts = {}) {
|
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
if (!opts.yes) {
|
|
335
|
-
console.log(`About to mark update ${updateId} as ${status.toUpperCase()}.`);
|
|
335
|
+
console.log(`About to mark update ${updateId} as ${status.toUpperCase()}${status === "in_progress" ? " (not closed: no resolution stamp)" : ""}.`);
|
|
336
336
|
if (opts.note) console.log(`The client will read: FlowIQ: ${opts.note}`);
|
|
337
337
|
if (opts.internal) console.log(`Staff-only note: ${opts.internal}`);
|
|
338
338
|
const answer = await confirmPrompt("Type y to confirm: ");
|
|
@@ -363,6 +363,7 @@ export async function resolve(updateId, opts = {}) {
|
|
|
363
363
|
console.log(` title: ${resp.title}`);
|
|
364
364
|
console.log(` was: ${resp.previous_status}`);
|
|
365
365
|
console.log(` by: ${resp.resolved_by_email ?? resp.resolved_by}`);
|
|
366
|
+
if (resp.status === "in_progress") console.log(` still open: resolve it later with --note once the fix is proven.`);
|
|
366
367
|
if (resp.superadmin_comment) console.log(` client: FlowIQ: ${resp.superadmin_comment}`);
|
|
367
368
|
if (resp.internal_notes) console.log(` internal: ${resp.internal_notes}`);
|
|
368
369
|
if (resp.already_resolved) console.log(` ⚠ was already terminal (${resp.previous_status}) — note/status overwritten.`);
|
package/src/commands/audit.js
CHANGED
|
@@ -61,6 +61,7 @@ export async function list(orgId, opts = {}) {
|
|
|
61
61
|
user: opts.user,
|
|
62
62
|
since: opts.since,
|
|
63
63
|
until: opts.until,
|
|
64
|
+
before: opts.before,
|
|
64
65
|
limit: opts.limit,
|
|
65
66
|
});
|
|
66
67
|
} catch (e) {
|
|
@@ -80,7 +81,10 @@ export async function list(orgId, opts = {}) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
console.log("");
|
|
83
|
-
|
|
84
|
+
const total = resp.total ?? entries.length;
|
|
85
|
+
console.log(resp.capped
|
|
86
|
+
? `${entries.length} of ${total.toLocaleString()} matching entries (newest first; limit ${resp.limit}${resp.requested_limit > resp.limit ? `, the most one call returns — you asked for ${resp.requested_limit}` : ""})`
|
|
87
|
+
: `${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
|
|
84
88
|
console.log("");
|
|
85
89
|
console.log(` ${pad("WHEN (SAST)", 17)} ${pad("WHO", 24)} ${pad("WHAT", 26)} SUMMARY`);
|
|
86
90
|
console.log(` ${"-".repeat(17)} ${"-".repeat(24)} ${"-".repeat(26)} ${"-".repeat(40)}`);
|
|
@@ -101,6 +105,9 @@ export async function list(orgId, opts = {}) {
|
|
|
101
105
|
for (const e of entries) {
|
|
102
106
|
console.log(` ${e.id} ${e.endpoint} ${e.action}`);
|
|
103
107
|
}
|
|
108
|
+
if (resp.capped && resp.next_before) {
|
|
109
|
+
console.log(`\n ${(total - entries.length).toLocaleString()} older match(es) not shown: add --before ${resp.next_before}`);
|
|
110
|
+
}
|
|
104
111
|
console.log("");
|
|
105
112
|
}
|
|
106
113
|
|
|
@@ -1935,15 +1935,19 @@ export async function listRemote(orgId, opts = {}) {
|
|
|
1935
1935
|
...(opts.limit ? { limit: Number(opts.limit) } : {}),
|
|
1936
1936
|
...(opts.template ? { template: opts.template } : {}),
|
|
1937
1937
|
...(opts.since ? { since: opts.since } : {}),
|
|
1938
|
+
...(opts.minRecipients ? { min_recipients: Number(opts.minRecipients) } : {}),
|
|
1938
1939
|
});
|
|
1939
1940
|
} catch (e) { console.error(`Listing failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
1940
1941
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
1941
1942
|
const rows = resp.broadcasts || [];
|
|
1942
|
-
if (!rows.length) { console.log(`No broadcasts found for ${resp.organization_name}${opts.template || opts.since ? " matching the filters" : ""}.`); return; }
|
|
1943
|
+
if (!rows.length) { console.log(`No broadcasts found for ${resp.organization_name}${opts.template || opts.since || opts.minRecipients ? " matching the filters" : ""}.`); return; }
|
|
1943
1944
|
// Full ids one per row, deliberately — an id you can't paste into `bc status`
|
|
1944
1945
|
// is useless (the same lesson the audit listing learned).
|
|
1945
1946
|
const sast = (iso) => { try { return new Date(iso).toLocaleString("sv-SE", { timeZone: "Africa/Johannesburg" }).slice(0, 16); } catch { return iso; } };
|
|
1946
|
-
|
|
1947
|
+
const shown = resp.capped
|
|
1948
|
+
? `${rows.length} of ${resp.total.toLocaleString()} shown`
|
|
1949
|
+
: `${rows.length} shown`;
|
|
1950
|
+
console.log(`Broadcasts — ${resp.organization_name} (${shown}${resp.min_recipients ? `, ${resp.min_recipients}+ recipients` : ""}, newest first; times SAST)`);
|
|
1947
1951
|
for (const b of rows) {
|
|
1948
1952
|
console.log("");
|
|
1949
1953
|
console.log(` ${b.id}`);
|
|
@@ -1951,6 +1955,9 @@ export async function listRemote(orgId, opts = {}) {
|
|
|
1951
1955
|
console.log(` status ${b.status ?? "?"} · ${b.total_recipients ?? "?"} recipient(s)`);
|
|
1952
1956
|
}
|
|
1953
1957
|
console.log("");
|
|
1958
|
+
if (resp.capped) {
|
|
1959
|
+
console.log(`⚠ ${(resp.total - rows.length).toLocaleString()} older broadcast(s) not shown (limit ${resp.limit}). Narrow with --since, --template${resp.min_recipients ? "" : " or --min-recipients 2 (hides one-recipient sends like welcomes and cart reminders)"}.`);
|
|
1960
|
+
}
|
|
1954
1961
|
console.log(`Delivery detail: flowiq bc status ${orgId} <broadcast_id> [--failures]`);
|
|
1955
1962
|
}
|
|
1956
1963
|
|
|
@@ -1984,6 +1991,15 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
1984
1991
|
if (b.total_recipients) {
|
|
1985
1992
|
console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
|
|
1986
1993
|
}
|
|
1994
|
+
const p = resp.payload;
|
|
1995
|
+
if (p && (Object.keys(p.body_parameters || {}).length || Object.keys(p.button_parameters || {}).length || Object.keys(p.header_parameters || {}).length)) {
|
|
1996
|
+
console.log(` sent with:`);
|
|
1997
|
+
const slot = (k) => String(k).replace(/^param/, "{{") + (String(k).startsWith("param") ? "}}" : "");
|
|
1998
|
+
for (const [k, v] of Object.entries(p.header_parameters || {})) console.log(` header ${slot(k)} ${String(v).replace(/\s+/g, " ")}`);
|
|
1999
|
+
for (const [k, v] of Object.entries(p.body_parameters || {})) console.log(` body ${slot(k)} ${String(v).replace(/\s+/g, " ")}`);
|
|
2000
|
+
for (const [k, v] of Object.entries(p.button_parameters || {})) console.log(` button ${slot(k)} ${v}`);
|
|
2001
|
+
for (const l of p.links || []) console.log(` link ${l.short_code} → ${l.destination} (${l.clicks} click${l.clicks === 1 ? "" : "s"})`);
|
|
2002
|
+
}
|
|
1987
2003
|
if (resp.reply_routing) {
|
|
1988
2004
|
const rr = resp.reply_routing;
|
|
1989
2005
|
console.log(` replies → ${describeRouting(rr)}${rr.enabled === false ? " (switched OFF)" : ""}`);
|
package/src/commands/doctor.js
CHANGED
|
@@ -101,10 +101,19 @@ export async function doctor(opts = {}) {
|
|
|
101
101
|
}
|
|
102
102
|
} catch (e) {
|
|
103
103
|
lines.push(` ${BAD} auth ${e.message.split("\n")[0]}`);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
if (e.code === "EVERCELCHALLENGE") {
|
|
105
|
+
problems.push({
|
|
106
|
+
what: "Vercel's bot protection is challenging this network; the key was never checked",
|
|
107
|
+
fix: "wait a few minutes or switch network (hotspot), then flowiq doctor again; do NOT re-login",
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
// Only a JSON 401/403 from our API means the key itself was refused.
|
|
111
|
+
const keyRefused = (e.status === 401 || e.status === 403) && e.code !== "ENONJSON";
|
|
112
|
+
problems.push({
|
|
113
|
+
what: keyRefused ? "the API rejected this key" : "the API could not be reached properly",
|
|
114
|
+
fix: keyRefused ? "flowiq auth login" : "check api_url / network, then retry",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
|
package/src/commands/gaps.js
CHANGED
|
@@ -244,7 +244,7 @@ export async function show(ref, opts = {}) {
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
console.log(`\nSame problem? flowiq gaps hit ${r.number} --note "what you were doing"`);
|
|
247
|
-
console.log(`Maintainers (${(resp.editors || []).join(", ")}): flowiq gaps status ${r.number} --to done --
|
|
247
|
+
console.log(`Maintainers (${(resp.editors || []).join(", ")}): flowiq gaps status ${r.number} --to done --fixed-in <x.y.z> --note "…"`);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
// ── hit ─────────────────────────────────────────────────────────────────────
|
|
@@ -324,7 +324,7 @@ export async function status(ref, opts = {}) {
|
|
|
324
324
|
ref: r0,
|
|
325
325
|
status: to,
|
|
326
326
|
note: opts.note ?? null,
|
|
327
|
-
version: opts.
|
|
327
|
+
version: opts.fixedIn ?? null,
|
|
328
328
|
duplicate_of: dupRef,
|
|
329
329
|
notify: opts.notify !== false,
|
|
330
330
|
});
|
package/src/commands/keywords.js
CHANGED
|
@@ -37,6 +37,32 @@ async function resolvePushPath(identifier) {
|
|
|
37
37
|
throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const sastDay = (iso) => {
|
|
41
|
+
try { return new Date(iso).toLocaleString("sv-SE", { timeZone: "Africa/Johannesburg" }).slice(0, 16); }
|
|
42
|
+
catch { return String(iso).slice(0, 16); }
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The keyword-scheduler cron (every 30 min) flips `active` on at start_date and
|
|
46
|
+
// off at end_date; the runtime itself reads `active` only. So an ACTIVE row with
|
|
47
|
+
// a future start is answering already, and an active row past its end has not
|
|
48
|
+
// been switched off yet.
|
|
49
|
+
export function scheduleLabel(k, now = Date.now()) {
|
|
50
|
+
const start = k.start_date ? Date.parse(k.start_date) : null;
|
|
51
|
+
const end = k.end_date ? Date.parse(k.end_date) : null;
|
|
52
|
+
const parts = [];
|
|
53
|
+
if (start && start > now) {
|
|
54
|
+
parts.push(k.active === false
|
|
55
|
+
? ` · starts ${sastDay(k.start_date)} SAST`
|
|
56
|
+
: ` · ⚠ LIVE BEFORE ITS START (${sastDay(k.start_date)} SAST): active, so it answers now`);
|
|
57
|
+
}
|
|
58
|
+
if (end) {
|
|
59
|
+
parts.push(end <= now && k.active !== false
|
|
60
|
+
? ` · ⚠ PAST ITS END (${sastDay(k.end_date)} SAST) but still active`
|
|
61
|
+
: ` · ends ${sastDay(k.end_date)}${end > now ? " SAST" : ""}`);
|
|
62
|
+
}
|
|
63
|
+
return parts.join("");
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
export async function pull(orgId) {
|
|
41
67
|
if (!UUID_RE.test(orgId)) {
|
|
42
68
|
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
@@ -59,9 +85,24 @@ export async function pull(orgId) {
|
|
|
59
85
|
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
60
86
|
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
61
87
|
console.log(` keywords: ${resp.keyword_count}`);
|
|
62
|
-
|
|
88
|
+
// Active rows first, in the order the runtime tries them (first match wins),
|
|
89
|
+
// then the inactive ones.
|
|
90
|
+
const all = resp.keywords || [];
|
|
91
|
+
const order = resp.runtime_order || [];
|
|
92
|
+
const byId = new Map(all.map((k) => [k.id, k]));
|
|
93
|
+
const listed = [
|
|
94
|
+
...order.map((id) => byId.get(id)).filter(Boolean),
|
|
95
|
+
...all.filter((k) => !order.includes(k.id)),
|
|
96
|
+
];
|
|
97
|
+
if (order.length) console.log(` (active rows in runtime order: the first matching row answers)`);
|
|
98
|
+
for (const k of listed) {
|
|
63
99
|
const acts = (k.keyword_actions || []).length;
|
|
64
|
-
console.log(` ${String(k.type).padEnd(28)} [${(k.keywords || []).join(", ")}] · ${acts} action(s)${k.active === false ? " · INACTIVE" : ""}${k
|
|
100
|
+
console.log(` ${String(k.type).padEnd(28)} [${(k.keywords || []).join(", ")}] · ${acts} action(s)${k.active === false ? " · INACTIVE" : ""}${scheduleLabel(k)}`);
|
|
101
|
+
}
|
|
102
|
+
for (const c of resp.collisions || []) {
|
|
103
|
+
console.log("");
|
|
104
|
+
console.log(` ⚠ TRIGGER COLLISION: ${c.triggers.map((t) => `"${t}"`).join(", ")} matches both "${c.winner.type}" and "${c.shadowed.type}".`);
|
|
105
|
+
console.log(` ${c.note}.`);
|
|
65
106
|
}
|
|
66
107
|
if (resp.has_attribute_actions) {
|
|
67
108
|
console.log("");
|
package/src/commands/links.js
CHANGED
|
@@ -115,6 +115,7 @@ export async function shorten(orgId, opts = {}) {
|
|
|
115
115
|
content,
|
|
116
116
|
domain: opts.domain,
|
|
117
117
|
dry_run: dryRun,
|
|
118
|
+
confirm_new_campaign: !!opts.newCampaign,
|
|
118
119
|
});
|
|
119
120
|
} catch (e) {
|
|
120
121
|
console.error(`Shorten failed: ${e.message}`);
|
|
@@ -128,6 +129,13 @@ export async function shorten(orgId, opts = {}) {
|
|
|
128
129
|
: "no UTM tags (plain short link)";
|
|
129
130
|
console.log(`${resp.organization_name} · ${resp.domain}`);
|
|
130
131
|
console.log(`${utm}\n`);
|
|
132
|
+
if (resp.campaign_conflicts?.length) {
|
|
133
|
+
const c = resp.campaign_conflicts[0];
|
|
134
|
+
console.log(`⚠ CAMPAIGN FORK: this org already runs ${resp.campaign_conflicts.map((x) => `"${x.campaign}" (${x.links} link${x.links === 1 ? "" : "s"})`).join(", ")},`);
|
|
135
|
+
console.log(` the same campaign as "${resp.utm?.campaign}" spelled differently. The Broadcasts tab would show two rows.`);
|
|
136
|
+
console.log(` Reuse it: --campaign ${c.campaign} --raw-campaign or confirm a separate campaign: --new-campaign`);
|
|
137
|
+
console.log("");
|
|
138
|
+
}
|
|
131
139
|
|
|
132
140
|
for (const r of resp.results) {
|
|
133
141
|
const label = {
|
package/src/commands/prompts.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { http } from "../http.js";
|
|
9
10
|
import { fmtSast } from "./audit.js";
|
|
10
11
|
|
|
@@ -54,15 +55,16 @@ export async function pull(orgId, opts = {}) {
|
|
|
54
55
|
process.exit(1);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
await fs.mkdir(PROMPTS_DIR, { recursive: true });
|
|
58
58
|
// Non-active agents get an agent-suffixed filename so pulling multiple agents
|
|
59
59
|
// from the same org doesn't clobber each other's working copy. The active
|
|
60
|
-
// agent keeps the plain <org>.json name (backwards-compatible).
|
|
60
|
+
// agent keeps the plain <org>.json name (backwards-compatible). --out writes
|
|
61
|
+
// anywhere else (a scratch copy that leaves the working file alone).
|
|
61
62
|
const orgSlug = slugify(resp.organization_name, resp.organization_id);
|
|
62
63
|
const slug = resp.is_active === false
|
|
63
64
|
? `${orgSlug}-${slugify(resp.agent_name, resp.agent_id.slice(0, 8))}`
|
|
64
65
|
: orgSlug;
|
|
65
|
-
const filePath = path.join(PROMPTS_DIR, `${slug}.json`);
|
|
66
|
+
const filePath = opts.out ? path.resolve(opts.out) : path.join(PROMPTS_DIR, `${slug}.json`);
|
|
67
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
66
68
|
const overwriting = await fileExists(filePath);
|
|
67
69
|
await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
68
70
|
|
|
@@ -82,6 +84,18 @@ export async function pull(orgId, opts = {}) {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/** Give every section without an id a fresh uuid. Returns the titles it touched. */
|
|
88
|
+
export function mintSectionIds(sections) {
|
|
89
|
+
const touched = [];
|
|
90
|
+
for (const sec of sections) {
|
|
91
|
+
if (sec && typeof sec === "object" && (typeof sec.id !== "string" || !sec.id.trim())) {
|
|
92
|
+
sec.id = randomUUID();
|
|
93
|
+
touched.push(sec.title || "(untitled)");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return touched;
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
export async function push(identifier) {
|
|
86
100
|
let filePath;
|
|
87
101
|
try {
|
|
@@ -107,6 +121,14 @@ export async function push(identifier) {
|
|
|
107
121
|
process.exit(1);
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
// A section added by hand has no id yet. Mint one and write it back to the
|
|
125
|
+
// file BEFORE pushing, so a later pull or push keeps the same id.
|
|
126
|
+
const minted = mintSectionIds(data.prompt_sections);
|
|
127
|
+
if (minted.length) {
|
|
128
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
129
|
+
console.log(`Added an id to ${minted.length} new section(s) in ${filePath}: ${minted.map((t) => `"${t}"`).join(", ")}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
let resp;
|
|
111
133
|
try {
|
|
112
134
|
resp = await http.post("prompts", {
|
|
@@ -265,6 +265,45 @@ async function rawGet(orgId, path, query, { label } = {}) {
|
|
|
265
265
|
return resp.data;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// Shopify's own product search (title, SKU, vendor, tags…), so a partial title
|
|
269
|
+
// on a 3,594-product catalogue is found without scanning pages. Anything
|
|
270
|
+
// Shopify's search syntax takes works here too: "sku:75461", "handle:foo".
|
|
271
|
+
const STOCK_SEARCH_GQL = `query StockSearch($q: String!) {
|
|
272
|
+
products(first: 50, query: $q) {
|
|
273
|
+
edges { node {
|
|
274
|
+
legacyResourceId title status
|
|
275
|
+
variants(first: 100) { edges { node {
|
|
276
|
+
legacyResourceId title sku price inventoryPolicy inventoryQuantity
|
|
277
|
+
inventoryItem { legacyResourceId }
|
|
278
|
+
} } }
|
|
279
|
+
} }
|
|
280
|
+
}
|
|
281
|
+
}`;
|
|
282
|
+
|
|
283
|
+
async function searchProducts(orgId, search) {
|
|
284
|
+
console.error(` $ flowiq shopify gql <org> --query '${STOCK_SEARCH_GQL.replace(/\s+/g, " ")}' --var q=${JSON.stringify(search)} # Shopify product search`);
|
|
285
|
+
const resp = await http.post("store-api", {
|
|
286
|
+
platform: "shopify", organization_id: orgId, mode: "graphql",
|
|
287
|
+
graphql: { query: STOCK_SEARCH_GQL, variables: { q: search } },
|
|
288
|
+
});
|
|
289
|
+
if (resp.graphql_errors?.length) throw new Error(`Shopify search failed: ${resp.graphql_errors.map((e) => e.message).join("; ")}`);
|
|
290
|
+
// Map to the REST shape the rest of the recipe reads.
|
|
291
|
+
return (resp.data?.products?.edges || []).map(({ node: p }) => ({
|
|
292
|
+
id: Number(p.legacyResourceId),
|
|
293
|
+
title: p.title,
|
|
294
|
+
status: String(p.status || "").toLowerCase(),
|
|
295
|
+
variants: (p.variants?.edges || []).map(({ node: v }) => ({
|
|
296
|
+
id: Number(v.legacyResourceId),
|
|
297
|
+
title: v.title,
|
|
298
|
+
sku: v.sku,
|
|
299
|
+
price: v.price,
|
|
300
|
+
inventory_policy: String(v.inventoryPolicy || "").toLowerCase(),
|
|
301
|
+
inventory_quantity: v.inventoryQuantity,
|
|
302
|
+
inventory_item_id: v.inventoryItem?.legacyResourceId ? Number(v.inventoryItem.legacyResourceId) : null,
|
|
303
|
+
})),
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
|
|
268
307
|
export async function shopifyStock(orgId, search, opts = {}) {
|
|
269
308
|
requireOrg(orgId);
|
|
270
309
|
if (!search || !String(search).trim()) {
|
|
@@ -278,17 +317,22 @@ export async function shopifyStock(orgId, search, opts = {}) {
|
|
|
278
317
|
const p = await rawGet(orgId, "products.json", { title: search, limit: 50 }, { label: "find the product" });
|
|
279
318
|
products = (p?.products || []).filter((x) =>
|
|
280
319
|
String(x.title).toLowerCase().includes(String(search).toLowerCase()));
|
|
320
|
+
let fromSearch = false;
|
|
281
321
|
if (!products.length) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
322
|
+
// No exact title: ask Shopify's search, then keep the titles holding every
|
|
323
|
+
// word of the search (or, when none do, what Shopify ranked closest).
|
|
324
|
+
const found = await searchProducts(orgId, String(search));
|
|
325
|
+
const words = String(search).toLowerCase().split(/\s+/).filter((w) => w && !w.includes(":"));
|
|
326
|
+
const strict = found.filter((x) => words.every((w) => String(x.title).toLowerCase().includes(w)));
|
|
327
|
+
products = strict.length ? strict : found;
|
|
328
|
+
fromSearch = products.length > 0 && !strict.length && words.length > 0;
|
|
286
329
|
}
|
|
287
330
|
if (!products.length) {
|
|
288
|
-
console.error(`\
|
|
289
|
-
`
|
|
331
|
+
console.error(`\nShopify's product search found nothing for "${search}" (it searches the whole catalogue: ` +
|
|
332
|
+
`title, SKU, vendor, tags). Try fewer words, or "sku:<sku>" / "handle:<handle>".`);
|
|
290
333
|
process.exit(1);
|
|
291
334
|
}
|
|
335
|
+
if (fromSearch) console.error(` (no title holds every word of "${search}"; showing Shopify's closest matches)`);
|
|
292
336
|
const itemIds = products.flatMap((x) => (x.variants || []).map((v) => v.inventory_item_id)).filter(Boolean);
|
|
293
337
|
// 2. inventory_item_id → per-location levels 3. location_id → names
|
|
294
338
|
levels = itemIds.length
|
|
@@ -335,6 +379,9 @@ export async function shopifyStock(orgId, search, opts = {}) {
|
|
|
335
379
|
}
|
|
336
380
|
console.error("");
|
|
337
381
|
console.log(renderTable(rows) ?? "(no variants)");
|
|
382
|
+
if (products.flatMap((x) => x.variants || []).length > 50) {
|
|
383
|
+
console.error(`\n⚠ per-location levels cover the first 50 variants only; narrow the search for the rest.`);
|
|
384
|
+
}
|
|
338
385
|
console.error(`\n${products.length} product(s) · ${rows.length} variant(s). ` +
|
|
339
386
|
`"not tracked" means Shopify holds no inventory record at that location — it is not a zero.`);
|
|
340
387
|
}
|
|
@@ -91,6 +91,10 @@ export async function create(orgId, opts = {}) {
|
|
|
91
91
|
} catch (e) {
|
|
92
92
|
console.error(`Create failed: ${e.message}`);
|
|
93
93
|
if (e.body) console.error(JSON.stringify(e.body, null, 2));
|
|
94
|
+
if (e.status === 504 || /TIMEOUT/i.test(e.message)) {
|
|
95
|
+
console.error(`\n⚠ The template may already be created at Meta (the upload can outlive the proxy). Do NOT retry yet; check first:`);
|
|
96
|
+
console.error(` flowiq templates status ${organizationId} --live --name ${templateRequest.name}`);
|
|
97
|
+
}
|
|
94
98
|
process.exit(1);
|
|
95
99
|
}
|
|
96
100
|
|
|
@@ -103,6 +107,8 @@ export async function status(orgId, opts = {}) {
|
|
|
103
107
|
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
104
108
|
process.exit(1);
|
|
105
109
|
}
|
|
110
|
+
if (opts.watch && !opts.live) opts.live = true;
|
|
111
|
+
if (opts.live) return liveStatus(orgId, opts);
|
|
106
112
|
let resp;
|
|
107
113
|
try {
|
|
108
114
|
resp = await http.get("meta-templates", { organization_id: orgId, name: opts.name });
|
|
@@ -112,18 +118,55 @@ export async function status(orgId, opts = {}) {
|
|
|
112
118
|
}
|
|
113
119
|
const rows = resp.templates || [];
|
|
114
120
|
if (!rows.length) {
|
|
115
|
-
|
|
121
|
+
// An empty FlowIQ table is not "no templates": templates made in Meta's
|
|
122
|
+
// own tools, or on a swapped WABA, exist only at Meta (Health Matrix, 22 Sep).
|
|
123
|
+
console.log(opts.name
|
|
124
|
+
? `No template matching "${opts.name}" is recorded in FlowIQ. Meta may still hold one: flowiq templates status ${orgId} --live --name ${opts.name}`
|
|
125
|
+
: `No templates are recorded in FlowIQ for this org. Meta may still hold some: flowiq templates status ${orgId} --live (or templates pull ${orgId}).`);
|
|
116
126
|
return;
|
|
117
127
|
}
|
|
118
|
-
console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""}:`);
|
|
128
|
+
console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""} recorded in FlowIQ:`);
|
|
119
129
|
for (const t of rows) {
|
|
120
130
|
const name = t.template_name || t.name || "(unnamed)";
|
|
121
|
-
|
|
131
|
+
// meta_status is what Meta's status webhook last reported; `status` is often
|
|
132
|
+
// left at its submit-time value.
|
|
133
|
+
const st = t.meta_status || t.status || "(no status)";
|
|
122
134
|
const cat = t.category ? ` [${t.category}]` : "";
|
|
123
135
|
console.log(` ${name.padEnd(40)} ${String(st).padEnd(16)}${cat}`);
|
|
124
136
|
}
|
|
125
|
-
console.log(
|
|
126
|
-
console.log(
|
|
137
|
+
console.log(`\nThis is FlowIQ's record (Meta's webhook keeps it current). Straight from Meta, with rejection reasons:`);
|
|
138
|
+
console.log(` flowiq templates status ${orgId} --live${opts.name ? ` --name ${opts.name}` : ""} [--watch]`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const TERMINAL = new Set(["APPROVED", "REJECTED", "DISABLED", "PAUSED", "DELETED", "LIMIT_EXCEEDED"]);
|
|
142
|
+
|
|
143
|
+
async function liveStatus(orgId, opts) {
|
|
144
|
+
const intervalMs = 15000, maxMs = 15 * 60 * 1000, started = Date.now();
|
|
145
|
+
for (;;) {
|
|
146
|
+
let resp;
|
|
147
|
+
try {
|
|
148
|
+
resp = await http.get("templates", { organization_id: orgId, name: opts.name });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.error(`Live status failed: ${e.message}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
const rows = resp.templates || [];
|
|
154
|
+
const stamp = new Date().toLocaleTimeString("en-GB", { timeZone: "Africa/Johannesburg" });
|
|
155
|
+
if (!rows.length) {
|
|
156
|
+
console.log(`${stamp} Meta holds no template${opts.name ? ` matching "${opts.name}"` : "s"} on ${resp.organization_name}'s WABA.`);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(`${stamp} ${rows.length} template${rows.length === 1 ? "" : "s"} at Meta (${resp.organization_name})${opts.name ? ` matching "${opts.name}"` : ""}:`);
|
|
159
|
+
for (const t of rows) {
|
|
160
|
+
const moved = t.previous_category && t.previous_category !== t.category ? ` (was ${t.previous_category})` : "";
|
|
161
|
+
const why = t.rejected_reason && t.rejected_reason !== "NONE" ? ` reason: ${t.rejected_reason}` : "";
|
|
162
|
+
console.log(` ${String(t.name).padEnd(40)} ${String(t.status).padEnd(16)} [${t.category}${moved}] ${t.language}${t.parameter_format ? ` · ${t.parameter_format}` : ""}${why}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const pending = rows.filter((t) => !TERMINAL.has(String(t.status)));
|
|
166
|
+
if (!opts.watch || (rows.length && !pending.length)) return;
|
|
167
|
+
if (Date.now() - started > maxMs) { console.log(`Still not decided after 15 min; stopped watching.`); return; }
|
|
168
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
169
|
+
}
|
|
127
170
|
}
|
|
128
171
|
|
|
129
172
|
export async function list() {
|
package/src/http.js
CHANGED
|
@@ -70,7 +70,7 @@ export function isBelowServerMinimum() {
|
|
|
70
70
|
* serialised — and always name the HTTP status, since a 504 tells you "retry or
|
|
71
71
|
* narrow it" while a 400 never will.
|
|
72
72
|
*/
|
|
73
|
-
export function describeError(parsed, method, endpoint, status) {
|
|
73
|
+
export function describeError(parsed, method, endpoint, status, { json = true } = {}) {
|
|
74
74
|
const pick = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
75
75
|
const nested = (v) => {
|
|
76
76
|
if (!v || typeof v !== "object") return null;
|
|
@@ -95,12 +95,27 @@ export function describeError(parsed, method, endpoint, status) {
|
|
|
95
95
|
if (!detail) return where;
|
|
96
96
|
|
|
97
97
|
// A timeout is transient and the remedy is specific, so say so rather than
|
|
98
|
-
// leaving the reader to work out whether they hit a hard limit.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
// leaving the reader to work out whether they hit a hard limit. Only a JSON
|
|
99
|
+
// body is scanned for the word: an HTML page (a Vercel challenge) carries
|
|
100
|
+
// "timeout" in its inline script and is not one.
|
|
101
|
+
const timedOut = status === 504 || (json && /TIMEOUT|timed out/i.test(detail));
|
|
102
|
+
if (!timedOut) return `${detail} [${where}]`;
|
|
103
|
+
// A write can finish on the server after the proxy gave up, so "retry" is the
|
|
104
|
+
// wrong first move there (a template created twice, a send fired twice).
|
|
105
|
+
if (method !== "GET" && endpoint !== "store-api") {
|
|
106
|
+
return `${detail} [${where}] — this is a TIMEOUT: the server may still have finished the write. ` +
|
|
107
|
+
`Check the result (the command's list / status / pull verb, or flowiq audit) before retrying.`;
|
|
108
|
+
}
|
|
109
|
+
return `${detail} [${where}] — this is a TIMEOUT, not a limit: retry, or narrow the request ` +
|
|
110
|
+
`(fewer pages via --max-pages, a smaller --q limit, or a tighter filter).`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Vercel's bot protection answers with an HTML challenge page (403/429),
|
|
114
|
+
* before the request ever reaches our API. It is about this network, not the key. */
|
|
115
|
+
export function isVercelChallenge(status, headers, text) {
|
|
116
|
+
const mitigated = headers?.get?.("x-vercel-mitigated") || "";
|
|
117
|
+
if (/challenge/i.test(mitigated)) return true;
|
|
118
|
+
return (status === 403 || status === 429) && /Vercel Security Checkpoint|vercel-challenge|_vercel\/challenge/i.test(text || "");
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
async function call(method, endpoint, { query, body } = {}) {
|
|
@@ -138,8 +153,19 @@ async function call(method, endpoint, { query, body } = {}) {
|
|
|
138
153
|
const text = await resp.text();
|
|
139
154
|
let parsed, isJson = true;
|
|
140
155
|
try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
|
|
156
|
+
if (!resp.ok && isVercelChallenge(resp.status, resp.headers, text)) {
|
|
157
|
+
const err = new Error(
|
|
158
|
+
`Vercel is challenging this network (bot protection) [${method} ${endpoint} → HTTP ${resp.status}]. ` +
|
|
159
|
+
`This is not your key and the request never reached the API: wait a few minutes, or try another ` +
|
|
160
|
+
`network (a phone hotspot), then retry.`
|
|
161
|
+
);
|
|
162
|
+
err.status = resp.status;
|
|
163
|
+
err.code = "EVERCELCHALLENGE";
|
|
164
|
+
err.body = parsed;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
141
167
|
if (!resp.ok) {
|
|
142
|
-
const err = new Error(describeError(parsed, method, endpoint, resp.status));
|
|
168
|
+
const err = new Error(describeError(parsed, method, endpoint, resp.status, { json: isJson }));
|
|
143
169
|
err.status = resp.status;
|
|
144
170
|
err.body = parsed;
|
|
145
171
|
throw err;
|