@flowapt/flowiq-cli 0.9.9 → 0.11.0
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 +209 -0
- package/TEAM-GUIDE.md +25 -0
- package/package.json +1 -1
- package/src/batch-cde.test.mjs +73 -0
- package/src/batch-f.test.mjs +63 -0
- package/src/commands/agent-test.js +15 -3
- package/src/commands/agents.js +31 -0
- package/src/commands/broadcast.js +9 -0
- package/src/commands/contacts.js +173 -0
- package/src/commands/flows.js +82 -0
- package/src/commands/links.js +63 -20
- package/src/commands/members.js +95 -0
- package/src/commands/messages.js +106 -0
- package/src/commands/notes.js +63 -0
- package/src/commands/org.js +190 -5
- package/src/commands/products.js +58 -0
- package/src/commands/prompts.js +46 -1
- package/src/commands/rules.js +67 -0
- package/src/commands/segments.js +47 -14
- package/src/commands/tag.js +77 -7
- package/src/commands/templates.js +66 -5
- package/src/commands/tickets.js +69 -0
- package/src/commands/widgets.js +97 -0
- package/src/index.js +286 -6
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// `flowiq contacts find|show|census|lint|merge|optout|optin|set|export` — the
|
|
2
|
+
// contact record from the terminal (server: api/cli/contacts.js).
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { http } from "../http.js";
|
|
7
|
+
import { fmtSast } from "./audit.js";
|
|
8
|
+
|
|
9
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
|
|
11
|
+
function requireOrg(orgId) {
|
|
12
|
+
if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
13
|
+
}
|
|
14
|
+
function fail(prefix, e) {
|
|
15
|
+
console.error(`${prefix}: ${e.message}`);
|
|
16
|
+
if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
|
|
17
|
+
if (e.body?.needs_confirm) console.error(" Re-run with --confirm.");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
21
|
+
const flags = (c) => [c.archived && "archived", c.blocked && "blocked", c.bot_status === false && "AI off", c.allow_broadcast === false && "opted out", c.has_unread_messages && "unread"].filter(Boolean).join(", ");
|
|
22
|
+
|
|
23
|
+
function printContact(c) {
|
|
24
|
+
console.log(`${c.full_name || "(no name)"} ${c.id}`);
|
|
25
|
+
console.log(` whatsapp: ${c.whatsapp_id ?? "—"}${c.phone_number && c.phone_number !== c.whatsapp_id ? ` (phone ${c.phone_number})` : ""}`);
|
|
26
|
+
if (c.email) console.log(` email: ${c.email}`);
|
|
27
|
+
if (c.instagram_username || c.messenger_psid || c.tiktok_username) console.log(` channels: ${[c.instagram_username && `instagram @${c.instagram_username}`, c.messenger_psid && "messenger", c.tiktok_username && `tiktok @${c.tiktok_username}`].filter(Boolean).join(", ")}`);
|
|
28
|
+
console.log(` state: ${flags(c) || "active"}${c.team ? ` · team ${c.team}` : ""}${c.assignee_email ? ` · assigned to ${c.assignee_email}` : ""}`);
|
|
29
|
+
if (c.latest_message) console.log(` latest: ${fmtSast(c.latest_message.created_at)} SAST · ${c.latest_message.sender_type} · ${c.latest_message.snippet.replace(/\s+/g, " ")}`);
|
|
30
|
+
if (c.open_ticket) console.log(` ticket: ${c.open_ticket.status} since ${fmtSast(c.open_ticket.created_at)} · ${c.open_ticket.summary ?? ""} (${c.open_ticket.id})`);
|
|
31
|
+
if (c.orders_count) console.log(` orders: ${c.orders_count}${c.total_spent ? ` · spent ${c.total_spent}` : ""}${c.last_order_date ? ` · last ${c.last_order_date}` : ""}`);
|
|
32
|
+
if (Array.isArray(c.tags) && c.tags.length) console.log(` tags: ${c.tags.slice(0, 12).join(", ")}${c.tags.length > 12 ? ` +${c.tags.length - 12}` : ""}`);
|
|
33
|
+
console.log(` created: ${fmtSast(c.created_at)} SAST`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function find(orgId, opts = {}) {
|
|
37
|
+
requireOrg(orgId);
|
|
38
|
+
if (!opts.email && !opts.phone && !opts.name && !opts.id) { console.error("Error: pass --email, --phone, --name or --id."); process.exit(1); }
|
|
39
|
+
let resp;
|
|
40
|
+
try { resp = await http.get("contacts", { organization_id: orgId, find: "1", email: opts.email, phone: opts.phone, name: opts.name, contact_id: opts.id, limit: opts.limit }); }
|
|
41
|
+
catch (e) { fail("Lookup failed", e); }
|
|
42
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
43
|
+
if (!resp.contacts.length) { console.log(`No contact on ${resp.organization_name} matches (archived and blocked contacts were included in the search).`); return; }
|
|
44
|
+
console.log(`${resp.count} contact(s) on ${resp.organization_name}${resp.count === resp.limit ? ` (limit ${resp.limit}, narrow the search or --limit)` : ""}:\n`);
|
|
45
|
+
for (const c of resp.contacts) { printContact(c); console.log(""); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function show(contactId, opts = {}) {
|
|
49
|
+
if (!UUID_RE.test(contactId || "")) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
|
|
50
|
+
if (!UUID_RE.test(opts.org || "")) { console.error("Error: --org <organization_id> is required (a contact id alone is not scoped)."); process.exit(1); }
|
|
51
|
+
return find(opts.org, { id: contactId, json: opts.json });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function census(orgId, opts = {}) {
|
|
55
|
+
requireOrg(orgId);
|
|
56
|
+
let resp;
|
|
57
|
+
try { resp = await http.get("contacts", { organization_id: orgId, archived_census: "1" }); } catch (e) { fail("Census failed", e); }
|
|
58
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
59
|
+
console.log(`${resp.organization_name}: ${resp.archived} archived chat(s)`);
|
|
60
|
+
console.log(` with unread messages: ${resp.archived_with_unread}`);
|
|
61
|
+
console.log(` customer wrote last: ${resp.archived_customer_wrote_last} (of ${resp.customer_last_sampled} checked)`);
|
|
62
|
+
console.log("Archiving is manual and writes no log; the inbox hides these on every tab but Archived.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function lint(orgId, opts = {}) {
|
|
66
|
+
requireOrg(orgId);
|
|
67
|
+
let resp;
|
|
68
|
+
try { resp = await http.get("contacts", { organization_id: orgId, lint: "1" }); } catch (e) { fail("Lint failed", e); }
|
|
69
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
70
|
+
console.log(`${resp.organization_name}: ${resp.scanned} numbers scanned, ${resp.count} malformed${resp.synthetic_skipped ? ` (${resp.synthetic_skipped} test / stress / web-chat identities skipped)` : ""}`);
|
|
71
|
+
if (!resp.count) return;
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(` ${pad("NUMBER", 18)} ${pad("PROBLEM", 30)} ${pad("SUGGESTED", 16)} ${pad("TWIN", 8)} CONTACT`);
|
|
74
|
+
for (const f of resp.findings) {
|
|
75
|
+
console.log(` ${pad(f.whatsapp_id, 18)} ${pad(f.problem, 30)} ${pad(f.suggested ?? "—", 16)} ${pad(f.twin ? "yes" : "no", 8)} ${f.full_name ?? ""}${f.archived ? " (archived)" : ""} ${f.id}`);
|
|
76
|
+
if (f.twin) console.log(` ${" ".repeat(18)} twin ${f.twin.full_name ?? "(no name)"} ${f.twin.id}${f.twin.latest_message_at ? `, last message ${fmtSast(f.twin.latest_message_at)}` : ""} → flowiq contacts merge ${orgId} ${f.twin.id} ${f.id} --commit`);
|
|
77
|
+
}
|
|
78
|
+
console.log("");
|
|
79
|
+
console.log(`Repair a number with no twin: flowiq contacts set ${orgId} <contact_id> --whatsapp-id <suggested> --commit`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function merge(orgId, keepId, removeId, opts = {}) {
|
|
83
|
+
requireOrg(orgId);
|
|
84
|
+
let resp;
|
|
85
|
+
try { resp = await http.post("contacts", { action: "merge", organization_id: orgId, keep_id: keepId, remove_id: removeId, dry_run: !opts.commit }); }
|
|
86
|
+
catch (e) { fail("Merge failed", e); }
|
|
87
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
88
|
+
const line = (c) => `${c.full_name || "(no name)"} · ${c.whatsapp_id ?? "no whatsapp"}${c.email ? ` · ${c.email}` : ""}${c.instagram_username ? ` · @${c.instagram_username}` : ""} · ${c.messages} message(s) · ${c.orders_count ?? 0} order(s)`;
|
|
89
|
+
if (resp.dry_run) {
|
|
90
|
+
console.log(`KEEP ${keepId}\n ${line(resp.keep)}`);
|
|
91
|
+
console.log(`REMOVE ${removeId}\n ${line(resp.remove)}`);
|
|
92
|
+
for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
|
|
93
|
+
console.log("\nDRY RUN — nothing changed. Add --commit to merge (the removed contact's messages, tickets and orders move to the kept one; the app's merge_contacts_v2 does the work).");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(`✓ merged ${resp.removed.full_name || removeId} into ${resp.keep?.full_name || keepId} on ${resp.organization_name}: ${resp.keep?.messages ?? "?"} message(s) now on the kept contact`);
|
|
97
|
+
for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
|
|
98
|
+
if (resp.rpc?.merge_id) console.log(` merge id ${resp.rpc.merge_id} (undo in the inbox: contact history → Undo merge)`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readIds(opts) {
|
|
102
|
+
const ids = [...(opts.contact || [])];
|
|
103
|
+
if (opts.idsFile) {
|
|
104
|
+
const text = await fs.readFile(path.resolve(opts.idsFile), "utf8");
|
|
105
|
+
ids.push(...text.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
|
|
106
|
+
}
|
|
107
|
+
const bad = ids.filter((id) => !UUID_RE.test(id));
|
|
108
|
+
if (bad.length) { console.error(`Error: not contact UUIDs: ${bad.slice(0, 5).join(", ")}`); process.exit(1); }
|
|
109
|
+
if (!ids.length) { console.error("Error: pass --contact <id> (repeatable) or --ids-file <file>."); process.exit(1); }
|
|
110
|
+
return [...new Set(ids)];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function optFlag(orgId, opts, action) {
|
|
114
|
+
requireOrg(orgId);
|
|
115
|
+
const ids = await readIds(opts);
|
|
116
|
+
let resp;
|
|
117
|
+
try { resp = await http.post("contacts", { action, organization_id: orgId, contact_ids: ids, reason: opts.reason, dry_run: !opts.commit, confirm: !!opts.confirm }); }
|
|
118
|
+
catch (e) { fail(`${action === "optout" ? "Opt-out" : "Opt-in"} failed`, e); }
|
|
119
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
120
|
+
console.log(`${resp.organization_name}: ${resp.requested} requested · ${resp.will_change} to change · ${resp.already} already ${action === "optout" ? "opted out" : "opted in"}${resp.missing?.length ? ` · ${resp.missing.length} not found in this org` : ""}`);
|
|
121
|
+
for (const c of resp.contacts.slice(0, 30)) console.log(` ${pad(c.whatsapp_id ?? "—", 16)} ${c.full_name ?? ""} ${c.id}`);
|
|
122
|
+
if (resp.contacts.length > 30) console.log(` … ${resp.contacts.length - 30} more`);
|
|
123
|
+
if (resp.dry_run) { console.log(`\nDRY RUN — nothing changed. Add --commit${action === "optin" ? " --confirm" : ""} to write allow_broadcast=${action === "optin"} and the inbox marker.`); return; }
|
|
124
|
+
console.log(`\n✓ ${resp.changed} contact(s) ${action === "optout" ? "opted out of" : "opted back in to"} broadcasts.`);
|
|
125
|
+
}
|
|
126
|
+
export const optout = (orgId, opts) => optFlag(orgId, opts, "optout");
|
|
127
|
+
export const optin = (orgId, opts) => optFlag(orgId, opts, "optin");
|
|
128
|
+
|
|
129
|
+
export async function set(orgId, contactId, opts = {}) {
|
|
130
|
+
requireOrg(orgId);
|
|
131
|
+
if (!opts.whatsappId) { console.error("Error: --whatsapp-id <digits> is the only field set here today."); process.exit(1); }
|
|
132
|
+
let resp;
|
|
133
|
+
try { resp = await http.post("contacts", { action: "set", organization_id: orgId, contact_id: contactId, whatsapp_id: opts.whatsappId, dry_run: !opts.commit }); }
|
|
134
|
+
catch (e) { fail("Set failed", e); }
|
|
135
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
136
|
+
if (resp.unchanged) { console.log("Already that number; nothing to do."); return; }
|
|
137
|
+
if (resp.dry_run) { console.log(`${resp.contact.full_name || contactId}: ${resp.contact.whatsapp_id} → ${resp.would_set.whatsapp_id}${resp.would_set.phone_number ? " (phone_number too)" : ""}\nDRY RUN — add --commit to write.`); return; }
|
|
138
|
+
console.log(`✓ ${resp.contact.full_name || contactId} is now ${resp.contact.whatsapp_id} on ${resp.organization_name}.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function csvCell(v) {
|
|
142
|
+
if (v == null) return "";
|
|
143
|
+
const s = Array.isArray(v) ? v.join("|") : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
144
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
145
|
+
}
|
|
146
|
+
export function toCsv(rows, columns) {
|
|
147
|
+
const lines = [columns.join(",")];
|
|
148
|
+
for (const r of rows) lines.push(columns.map((c) => csvCell(r[c])).join(","));
|
|
149
|
+
return lines.join("\n") + "\n";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function exportContacts(orgId, opts = {}) {
|
|
153
|
+
requireOrg(orgId);
|
|
154
|
+
const rows = [];
|
|
155
|
+
let cursor = null, orgName = null;
|
|
156
|
+
for (;;) {
|
|
157
|
+
let resp;
|
|
158
|
+
try { resp = await http.get("contacts", { organization_id: orgId, export: "1", subscribers: opts.subscribers ? "1" : undefined, cursor_id: cursor ?? undefined }); }
|
|
159
|
+
catch (e) { fail("Export failed", e); }
|
|
160
|
+
orgName ||= resp.organization_name;
|
|
161
|
+
rows.push(...resp.page);
|
|
162
|
+
process.stderr.write(`\r ${rows.length} contacts…`);
|
|
163
|
+
if (resp.done || !resp.next_cursor) break;
|
|
164
|
+
cursor = resp.next_cursor;
|
|
165
|
+
}
|
|
166
|
+
process.stderr.write("\n");
|
|
167
|
+
const columns = ["id", "full_name", "whatsapp_id", "phone_number", "email", "allow_broadcast", "archived", "blocked", "bot_status", "tags", "orders_count", "total_spent", "last_order_date", "latest_message_at", "created_at", "attributes"];
|
|
168
|
+
const slug = String(orgName || orgId).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
169
|
+
const out = opts.out ? path.resolve(opts.out) : path.resolve(process.cwd(), ".flowiq", "contacts", `${slug}${opts.subscribers ? "-subscribers" : ""}.${opts.json ? "json" : "csv"}`);
|
|
170
|
+
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
171
|
+
await fs.writeFile(out, opts.json ? JSON.stringify(rows, null, 2) + "\n" : toCsv(rows, columns), "utf8");
|
|
172
|
+
console.log(`Wrote ${out} (${rows.length} contact(s)${opts.subscribers ? ", subscribers only: opted in, not archived, not blocked" : ""}). Excel opens the CSV directly.`);
|
|
173
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `flowiq flows list|show|status|enable|disable` — the Flows automation engine
|
|
2
|
+
// (server: api/cli/flows.js).
|
|
3
|
+
|
|
4
|
+
import { http } from "../http.js";
|
|
5
|
+
import { fmtSast } from "./audit.js";
|
|
6
|
+
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
9
|
+
function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); if (e.body?.needs_confirm) console.error(" Re-run with --confirm."); process.exit(1); }
|
|
10
|
+
const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
|
|
11
|
+
|
|
12
|
+
const triggerLabel = (t) => [t.event ?? t.type, t.source && `(${t.source})`, t.tag && `tag ${t.tag}`].filter(Boolean).join(" ");
|
|
13
|
+
|
|
14
|
+
export async function list(orgId, opts = {}) {
|
|
15
|
+
requireOrg(orgId);
|
|
16
|
+
let resp;
|
|
17
|
+
try { resp = await http.get("flows", { organization_id: orgId }); } catch (e) { fail("List failed", e); }
|
|
18
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
19
|
+
if (!resp.count) { console.log(`${resp.organization_name} has no flows.`); return; }
|
|
20
|
+
console.log(`${resp.organization_name}: ${resp.count} flow(s), ${resp.enabled} enabled`);
|
|
21
|
+
console.log("");
|
|
22
|
+
for (const f of resp.flows) {
|
|
23
|
+
const templates = f.steps.map((s) => s.template_name).filter(Boolean);
|
|
24
|
+
console.log(` ${f.enabled ? "ON " : "off"} ${pad(f.name, 40)} ${pad(triggerLabel(f.trigger), 34)} ${templates.join(" → ") || "(no template step)"}`);
|
|
25
|
+
console.log(` ${f.id}${f.stats ? ` · triggered ${f.stats.total_triggered ?? 0} · sent ${f.stats.total_sent ?? 0} · failed ${f.stats.total_failed ?? 0}${f.stats.last_triggered_at ? ` · last ${fmtSast(f.stats.last_triggered_at)}` : ""}` : ""}`);
|
|
26
|
+
for (const w of f.warnings || []) console.log(` ⚠ ${w}`);
|
|
27
|
+
}
|
|
28
|
+
console.log(`\nflowiq flows status ${orgId} --since 14d · flows show <flow_id>`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function show(flowId, opts = {}) {
|
|
32
|
+
if (!UUID_RE.test(flowId || "")) { console.error(`Error: "${flowId}" is not a valid flow UUID.`); process.exit(1); }
|
|
33
|
+
let resp;
|
|
34
|
+
try { resp = await http.get("flows", { flow_id: flowId }); } catch (e) { fail("Read failed", e); }
|
|
35
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
36
|
+
const f = resp.flow;
|
|
37
|
+
console.log(`${f.name} ${f.id} on ${resp.organization_name}: ${f.enabled ? "ENABLED" : "disabled"}`);
|
|
38
|
+
if (f.description) console.log(` ${f.description}`);
|
|
39
|
+
console.log(` trigger: ${triggerLabel(f.trigger)}${f.trigger.conditions ? ` · ${f.trigger.conditions} condition(s)` : ""}`);
|
|
40
|
+
for (const s of f.steps) {
|
|
41
|
+
console.log(` step ${s.index}: ${s.action_type ?? s.type}${s.template_name ? ` ${s.template_name}` : ""}${s.delay ? ` · delay ${s.delay}` : ""}${s.body_param_keys.length ? ` · body ${s.body_param_keys.join(",")}` : ""}${s.button_param_keys.length ? ` · button ${s.button_param_keys.join(",")}` : ""}`);
|
|
42
|
+
if (s.lint) console.log(` ⚠ ${s.lint}`);
|
|
43
|
+
}
|
|
44
|
+
if (f.stats) console.log(` stats: ${JSON.stringify(f.stats)}`);
|
|
45
|
+
console.log(` updated: ${fmtSast(f.updated_at)} SAST`);
|
|
46
|
+
console.log("\n configuration:");
|
|
47
|
+
console.log(JSON.stringify(f.configuration, null, 2).split("\n").map((l) => " " + l).join("\n"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function status(orgId, opts = {}) {
|
|
51
|
+
requireOrg(orgId);
|
|
52
|
+
let resp;
|
|
53
|
+
try { resp = await http.get("flows", { organization_id: orgId, status: "1", since: opts.since, flow_id: opts.flow, failed: opts.failed ? "1" : undefined }); }
|
|
54
|
+
catch (e) { fail("Status failed", e); }
|
|
55
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
56
|
+
console.log(`${resp.organization_name}: ${resp.rows} execution(s) since ${fmtSast(resp.since)} SAST${resp.capped ? " (capped at 5,000)" : ""}`);
|
|
57
|
+
console.log(` by status: ${Object.entries(resp.by_status).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"}`);
|
|
58
|
+
if (resp.by_flow.length) {
|
|
59
|
+
console.log("");
|
|
60
|
+
console.log(` ${pad("FLOW", 40)} ${pad("TOTAL", 7)} ${pad("DONE", 7)} ${pad("PENDING", 8)} ${pad("CANCEL", 7)} FAILED`);
|
|
61
|
+
for (const f of resp.by_flow) console.log(` ${pad(f.name ?? f.flow_id, 40)} ${pad(f.total, 7)} ${pad(f.completed ?? 0, 7)} ${pad(f.pending ?? 0, 8)} ${pad(f.cancelled ?? 0, 7)} ${f.failed ?? 0}`);
|
|
62
|
+
}
|
|
63
|
+
if (resp.error_rollup.length) {
|
|
64
|
+
console.log("\n failures by message:");
|
|
65
|
+
for (const e of resp.error_rollup) console.log(` ${String(e.n).padStart(5)} × ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
if (resp.failed) {
|
|
68
|
+
console.log(`\n failed executions (${resp.failed.length} shown):`);
|
|
69
|
+
for (const r of resp.failed) console.log(` ${fmtSast(r.triggered_at ?? r.created_at)} ${pad(r.flow_name ?? "", 30)} contact ${r.contact_id} ${String(r.error_message ?? "").slice(0, 100)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function toggle(flowId, opts, action) {
|
|
74
|
+
if (!UUID_RE.test(flowId || "")) { console.error(`Error: "${flowId}" is not a valid flow UUID.`); process.exit(1); }
|
|
75
|
+
let resp;
|
|
76
|
+
try { resp = await http.post("flows", { action, flow_id: flowId, confirm: !!opts.confirm }); } catch (e) { fail(`${action} failed`, e); }
|
|
77
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
78
|
+
if (resp.unchanged) { console.log(`Flow is already ${resp.enabled ? "enabled" : "disabled"}.`); return; }
|
|
79
|
+
console.log(`✓ "${resp.name}" ${resp.enabled ? "ENABLED" : "disabled"} (was ${resp.was ? "enabled" : "disabled"}).`);
|
|
80
|
+
}
|
|
81
|
+
export const enable = (id, opts) => toggle(id, opts, "enable");
|
|
82
|
+
export const disable = (id, opts) => toggle(id, opts, "disable");
|
package/src/commands/links.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
//
|
|
8
8
|
// flowiq links shorten <org> --url <u> [--url <u2>…] --campaign "Spring Promotion"
|
|
9
9
|
// flowiq links shorten <org> --file ./message.txt --campaign X --content ViewMore --domain linklnk.io
|
|
10
|
-
// flowiq links
|
|
10
|
+
// flowiq links shorten <org> --url <u> --slug spring-sale --commit (readable code)
|
|
11
|
+
// flowiq links list <org> [--campaign X] [--code abc123] [--to /collections/x] [--limit 25] [--all]
|
|
12
|
+
// flowiq links repoint <org> <code> --to <url> [--commit] (move a live code)
|
|
11
13
|
//
|
|
12
14
|
// NAMING — you do not have to get the tag right yourself. The house convention
|
|
13
15
|
// is `Date_Campaign` (`9Sep_SpringPromotion`: the date the broadcast GOES OUT,
|
|
@@ -114,11 +116,13 @@ export async function shorten(orgId, opts = {}) {
|
|
|
114
116
|
campaign,
|
|
115
117
|
content,
|
|
116
118
|
domain: opts.domain,
|
|
119
|
+
slug: opts.slug,
|
|
117
120
|
dry_run: dryRun,
|
|
118
121
|
confirm_new_campaign: !!opts.newCampaign,
|
|
119
122
|
});
|
|
120
123
|
} catch (e) {
|
|
121
124
|
console.error(`Shorten failed: ${e.message}`);
|
|
125
|
+
if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
|
|
122
126
|
process.exit(1);
|
|
123
127
|
}
|
|
124
128
|
|
|
@@ -127,7 +131,7 @@ export async function shorten(orgId, opts = {}) {
|
|
|
127
131
|
const utm = resp.utm
|
|
128
132
|
? `utm_source=${resp.utm.source} · utm_medium=${resp.utm.medium} · utm_campaign=${resp.utm.campaign} · utm_content=${resp.utm.content}`
|
|
129
133
|
: "no UTM tags (plain short link)";
|
|
130
|
-
console.log(`${resp.organization_name} · ${resp.domain}`);
|
|
134
|
+
console.log(`${resp.organization_name} · ${resp.domain}${resp.slug ? ` · slug ${resp.slug}` : ""}`);
|
|
131
135
|
console.log(`${utm}\n`);
|
|
132
136
|
if (resp.campaign_conflicts?.length) {
|
|
133
137
|
const c = resp.campaign_conflicts[0];
|
|
@@ -169,30 +173,69 @@ export async function shorten(orgId, opts = {}) {
|
|
|
169
173
|
|
|
170
174
|
export async function list(orgId, opts = {}) {
|
|
171
175
|
requireOrg(orgId);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
176
|
+
// --all walks every page (500 a call) so a whole campaign or a whole org can
|
|
177
|
+
// be exported; without it the first page is the answer and has_more says so.
|
|
178
|
+
const pageSize = opts.all ? 500 : (opts.limit ? Number(opts.limit) : 25);
|
|
179
|
+
const links = [];
|
|
180
|
+
let offset = 0, resp, hasMore = false;
|
|
181
|
+
for (;;) {
|
|
182
|
+
try {
|
|
183
|
+
resp = await http.post("links", {
|
|
184
|
+
organization_id: orgId,
|
|
185
|
+
action: "list",
|
|
186
|
+
campaign: opts.campaign,
|
|
187
|
+
code: opts.code,
|
|
188
|
+
to: opts.to,
|
|
189
|
+
limit: pageSize,
|
|
190
|
+
offset,
|
|
191
|
+
});
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.error(`List failed: ${e.message}`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
links.push(...(resp.links || []));
|
|
197
|
+
hasMore = !!resp.has_more;
|
|
198
|
+
if (!opts.all || !hasMore || !(resp.links || []).length) break;
|
|
199
|
+
offset += (resp.links || []).length;
|
|
200
|
+
if (offset > 50000) break;
|
|
183
201
|
}
|
|
184
202
|
|
|
185
|
-
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
203
|
+
if (opts.json) { console.log(JSON.stringify({ ...resp, count: links.length, links, has_more: hasMore }, null, 2)); return; }
|
|
186
204
|
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
const what = [opts.campaign && `campaign "${opts.campaign}"`, opts.code && `code "${opts.code}"`, opts.to && `destination containing "${opts.to}"`].filter(Boolean).join(", ");
|
|
206
|
+
if (!links.length) {
|
|
207
|
+
console.log(what ? `No links on ${resp.organization_name} for ${what}.` : `No links on ${resp.organization_name}.`);
|
|
189
208
|
return;
|
|
190
209
|
}
|
|
191
210
|
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
const codeW = Math.min(50, Math.max(10, ...links.map((l) => String(l.code || "").length + 2)));
|
|
212
|
+
console.log(`${pad("CODE", codeW)}${pad("CLICKS", 8)}${pad("CREATED", 12)}URL`);
|
|
213
|
+
for (const l of links) {
|
|
194
214
|
const day = String(l.created_at || "").slice(0, 10);
|
|
195
|
-
console.log(`${pad(l.code,
|
|
215
|
+
console.log(`${pad(l.code, codeW)}${pad(l.clicks ?? 0, 8)}${pad(day, 12)}${l.full_url}${l.is_active === false ? " (inactive)" : ""}${l.per_message ? " (per-message)" : ""}`);
|
|
216
|
+
}
|
|
217
|
+
console.log(`\n${links.length} link(s)${what ? ` · ${what}` : ""}${hasMore ? ` · more exist (--all, or --limit up to 500)` : ""}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// `flowiq links repoint <org> <code> --to <url> [--commit]` — move where a
|
|
221
|
+
// live code sends people. The code stays in every message that carries it.
|
|
222
|
+
export async function repoint(orgId, code, opts = {}) {
|
|
223
|
+
requireOrg(orgId);
|
|
224
|
+
if (!code) { console.error("Error: pass the short code, e.g. flowiq links repoint <org> VVSSh2 --to https://…"); process.exit(1); }
|
|
225
|
+
if (!opts.to) { console.error("Error: --to <url> is required (the new destination page)."); process.exit(1); }
|
|
226
|
+
let resp;
|
|
227
|
+
try {
|
|
228
|
+
resp = await http.post("links", { organization_id: orgId, action: "repoint", code, url: opts.to, dry_run: !opts.commit, confirm: !!opts.commit });
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.error(`Repoint failed: ${e.message}`);
|
|
231
|
+
if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
|
|
232
|
+
process.exit(1);
|
|
196
233
|
}
|
|
197
|
-
console.log(
|
|
234
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
235
|
+
console.log(`${resp.organization_name} · ${resp.code} (${resp.clicks ?? 0} clicks${resp.is_active === false ? ", inactive" : ""})`);
|
|
236
|
+
console.log(` from: ${resp.from}`);
|
|
237
|
+
console.log(` to: ${resp.to}`);
|
|
238
|
+
if (resp.unchanged) { console.log("\nAlready points there — nothing to do."); return; }
|
|
239
|
+
if (resp.dry_run) console.log("\nDRY RUN — nothing changed. Add --commit to repoint.");
|
|
240
|
+
else console.log(`\n✓ repointed. Undo: flowiq links repoint ${orgId} ${resp.code} --to '${resp.from}' --commit`);
|
|
198
241
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `flowiq members list|add|role|rename|remove` + `flowiq users lookup` — an
|
|
2
|
+
// org's people and one user across orgs (server: api/cli/members.js).
|
|
3
|
+
|
|
4
|
+
import { http } from "../http.js";
|
|
5
|
+
import { fmtSast } from "./audit.js";
|
|
6
|
+
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
9
|
+
function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
10
|
+
const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
|
|
11
|
+
const who = (v) => (UUID_RE.test(v || "") ? { user_id: v } : { email: v });
|
|
12
|
+
|
|
13
|
+
export async function list(orgId, opts = {}) {
|
|
14
|
+
requireOrg(orgId);
|
|
15
|
+
let resp;
|
|
16
|
+
try { resp = await http.get("members", { organization_id: orgId }); } catch (e) { fail("List failed", e); }
|
|
17
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
18
|
+
console.log(`${resp.organization_name}: ${resp.count} member(s)${resp.pending_invitations.length ? ` · ${resp.pending_invitations.length} pending invitation(s)` : ""}`);
|
|
19
|
+
console.log("");
|
|
20
|
+
console.log(` ${pad("ROLE", 7)} ${pad("NAME", 24)} ${pad("EMAIL", 34)} ${pad("LAST SIGN-IN", 18)} SINCE`);
|
|
21
|
+
const order = { admin: 0, member: 1, viewer: 2 };
|
|
22
|
+
for (const m of [...resp.members].sort((a, b) => (order[a.role] ?? 9) - (order[b.role] ?? 9) || String(a.full_name || "").localeCompare(String(b.full_name || "")))) {
|
|
23
|
+
const flags = [m.is_owner && "owner", m.status !== "active" && m.status, m.view_only && "view-only", m.account_missing && "NO ACCOUNT", m.profile_deleted && "profile deleted"].filter(Boolean).join(", ");
|
|
24
|
+
console.log(` ${pad(m.role, 7)} ${pad((m.full_name || "—").slice(0, 24), 24)} ${pad((m.email || "?").slice(0, 34), 34)} ${pad(m.last_sign_in_at ? fmtSast(m.last_sign_in_at) : "never", 18)} ${String(m.since || "").slice(0, 10)}${flags ? ` (${flags})` : ""}`);
|
|
25
|
+
}
|
|
26
|
+
for (const i of resp.pending_invitations) console.log(` ${pad("invite", 7)} ${pad("—", 24)} ${pad(i.email, 34)} ${pad("pending", 18)} ${String(i.created_at || "").slice(0, 10)} (${i.role})`);
|
|
27
|
+
console.log(`\nflowiq members add ${orgId} <email> --role member · role ${orgId} <email> admin|member|viewer · remove ${orgId} <email> --confirm`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function add(orgId, emailOrId, opts = {}) {
|
|
31
|
+
requireOrg(orgId);
|
|
32
|
+
if (!emailOrId) { console.error("Error: pass the person's email (they must already have an account)."); process.exit(1); }
|
|
33
|
+
let resp;
|
|
34
|
+
try { resp = await http.post("members", { action: "add", organization_id: orgId, ...who(emailOrId), role: opts.role || "member" }); } catch (e) { fail("Add failed", e); }
|
|
35
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
36
|
+
if (resp.unchanged) { console.log(resp.note); return; }
|
|
37
|
+
console.log(`✓ ${resp.email} is now a ${resp.role}${resp.reactivated ? " again" : ""}. Undo: flowiq members remove ${orgId} ${resp.email} --confirm`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function role(orgId, emailOrId, newRole, opts = {}) {
|
|
41
|
+
requireOrg(orgId);
|
|
42
|
+
if (!emailOrId || !newRole) { console.error("Error: flowiq members role <org> <email> admin|member|viewer"); process.exit(1); }
|
|
43
|
+
let resp;
|
|
44
|
+
try { resp = await http.post("members", { action: "role", organization_id: orgId, ...who(emailOrId), role: newRole }); } catch (e) { fail("Role change failed", e); }
|
|
45
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
46
|
+
if (resp.unchanged) { console.log(`Already ${resp.role}.`); return; }
|
|
47
|
+
console.log(`✓ ${resp.email}: ${resp.from} → ${resp.to}. Undo: flowiq members role ${orgId} ${resp.email} ${resp.from}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function rename(emailOrId, name, opts = {}) {
|
|
51
|
+
if (!emailOrId || !name) { console.error("Error: flowiq members rename <email> \"Full Name\""); process.exit(1); }
|
|
52
|
+
let resp;
|
|
53
|
+
try { resp = await http.post("members", { action: "rename", ...who(emailOrId), full_name: name }); } catch (e) { fail("Rename failed", e); }
|
|
54
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
55
|
+
console.log(`✓ ${resp.email}: "${resp.from ?? ""}" → "${resp.to}" (profiles.full_name, shown in every org they belong to)`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function remove(orgId, emailOrId, opts = {}) {
|
|
59
|
+
requireOrg(orgId);
|
|
60
|
+
if (!emailOrId) { console.error("Error: flowiq members remove <org> <email> --confirm"); process.exit(1); }
|
|
61
|
+
let resp;
|
|
62
|
+
try { resp = await http.post("members", { action: "remove", organization_id: orgId, ...who(emailOrId), confirm: !!opts.confirm }); } catch (e) { fail("Remove failed", e); }
|
|
63
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
64
|
+
if (resp.dry_run) { console.log(`Would remove ${resp.email} (${resp.role}${resp.is_owner ? ", OWNER" : ""}) from this org. Add --confirm.`); return; }
|
|
65
|
+
console.log(`✓ removed ${resp.email} (${resp.role}). Undo: flowiq members add ${orgId} ${resp.email} --role ${resp.role}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// `flowiq users lookup <email>` — the account behind an address, across orgs.
|
|
69
|
+
export async function lookup(email, opts = {}) {
|
|
70
|
+
if (!email) { console.error("Error: flowiq users lookup <email>"); process.exit(1); }
|
|
71
|
+
let resp;
|
|
72
|
+
try { resp = await http.get("members", { email }); }
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e.status === 404 || /No account/.test(e.message)) {
|
|
75
|
+
console.log(`No account for ${email}.`);
|
|
76
|
+
const inv = e.body?.invitations || [];
|
|
77
|
+
if (inv.length) { console.log(` invitations:`); for (const i of inv) console.log(` ${i.status.padEnd(9)} ${i.role.padEnd(7)} org ${i.organization_id} ${String(i.created_at).slice(0, 10)}`); }
|
|
78
|
+
else console.log(" no invitation on record either — they were never invited, or were invited under another address.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
fail("Lookup failed", e);
|
|
82
|
+
}
|
|
83
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
84
|
+
const u = resp.user;
|
|
85
|
+
console.log(`${u.email} ${u.id}${resp.super_admin ? " (Flowapt super admin)" : ""}`);
|
|
86
|
+
console.log(` name: ${resp.full_name ?? "(no profile name)"}${resp.profile_deleted ? " (profile deleted)" : ""}`);
|
|
87
|
+
console.log(` signs in with: ${u.providers.join(", ")}${u.email_confirmed_at ? "" : " (email NOT confirmed)"}${u.banned_until ? ` BANNED until ${u.banned_until}` : ""}`);
|
|
88
|
+
console.log(` created: ${fmtSast(u.created_at)} SAST · last sign-in ${u.last_sign_in_at ? `${fmtSast(u.last_sign_in_at)} SAST` : "never"}`);
|
|
89
|
+
if (!resp.organizations.length) console.log(" organizations: none");
|
|
90
|
+
else {
|
|
91
|
+
console.log(` organizations: ${resp.organizations.length}`);
|
|
92
|
+
for (const o of resp.organizations) console.log(` ${pad(o.role, 7)} ${pad((o.organization_name || o.organization_id).slice(0, 30), 30)} ${o.organization_id}${o.status !== "active" ? ` (${o.status})` : ""}${o.inactive ? " (org inactive)" : ""}`);
|
|
93
|
+
}
|
|
94
|
+
console.log(`\n Password-reset emails are sent by Supabase Auth and are not recorded here; a stuck login is usually a wrong provider (Google vs password) or an unconfirmed email.`);
|
|
95
|
+
}
|
package/src/commands/messages.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { http } from "../http.js";
|
|
7
|
+
import { fmtSast } from "./audit.js";
|
|
7
8
|
|
|
8
9
|
const MSG_DIR = path.resolve(process.cwd(), ".flowiq", "messages");
|
|
9
10
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -66,4 +67,109 @@ export async function pull(contactId, opts = {}) {
|
|
|
66
67
|
for (const [k, v] of Object.entries(resp.sender_breakdown).sort()) {
|
|
67
68
|
console.log(` ${k.padEnd(16)} ${v}`);
|
|
68
69
|
}
|
|
70
|
+
// Delivery footer: a wamid with no receipt and no status is a message Meta
|
|
71
|
+
// accepted and dropped; it looks identical to a delivered one in the JSON.
|
|
72
|
+
const rows = Array.isArray(resp.messages) ? resp.messages : Array.isArray(resp.rows) ? resp.rows : [];
|
|
73
|
+
const out = rows.filter((m) => ["bot-whatsapp", "human-whatsapp"].includes(m.sender_type));
|
|
74
|
+
if (out.length) {
|
|
75
|
+
const delivered = out.filter((m) => m.delivered_receipt_received === true || m.read_receipt_received === true).length;
|
|
76
|
+
const failed = out.filter((m) => m.message_status === "failed").length;
|
|
77
|
+
const noStatus = out.filter((m) => m.whatsapp_message_id && m.message_status == null && !m.sent_receipt_received && !m.delivered_receipt_received && !m.read_receipt_received).length;
|
|
78
|
+
console.log(` delivery: ${out.length} outbound WhatsApp · ${delivered} delivered/read · ${failed} failed · ${noStatus} with NO status at all${noStatus ? " ⚠ accepted by Meta, never confirmed (flowiq messages failures <org>)" : ""}`);
|
|
79
|
+
}
|
|
80
|
+
// Who sent each bot row: the agent, a broadcast, or an automation (the
|
|
81
|
+
// notification_* columns are in the JSON; this names them so "was 'Hey MRS'
|
|
82
|
+
// the agent or the order confirmation?" is answered here, not in SQL).
|
|
83
|
+
const sources = {};
|
|
84
|
+
for (const m of rows.filter((x) => String(x.sender_type || "").startsWith("bot-"))) sources[sentBy(m)] = (sources[sentBy(m)] || 0) + 1;
|
|
85
|
+
const keys = Object.keys(sources);
|
|
86
|
+
if (keys.length) console.log(` sent by: ${keys.sort().map((k) => `${k} ${sources[k]}`).join(" · ")}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function sentBy(m) {
|
|
90
|
+
if (m.broadcast_id) return "broadcast";
|
|
91
|
+
if (m.notification_template) return `automation: ${m.notification_template}${m.notification_event ? ` (${m.notification_event})` : ""}`;
|
|
92
|
+
if (m.notification_rule_id) return `rule ${String(m.notification_rule_id).slice(0, 8)}`;
|
|
93
|
+
if (m.notification_event) return `automation (${m.notification_event})`;
|
|
94
|
+
return "agent";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `flowiq messages delete <contact_id> [--before 30d] [--sender-type a,b] --confirm`
|
|
98
|
+
export async function remove(contactId, opts = {}) {
|
|
99
|
+
if (!UUID_RE.test(contactId)) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
|
|
100
|
+
const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
101
|
+
let preview;
|
|
102
|
+
try { preview = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: true }); }
|
|
103
|
+
catch (e) { fail("Delete failed", e); }
|
|
104
|
+
if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
|
|
105
|
+
console.log(`${preview.contact_name || "(no name)"} ${preview.contact_whatsapp_id || ""} on ${preview.organization_name}: ${preview.count} message(s)${preview.before ? ` older than ${fmtSast(preview.before)} SAST` : ""}${senderTypes ? ` of type ${senderTypes.join(", ")}` : ""}`);
|
|
106
|
+
for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
|
|
107
|
+
if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
|
|
108
|
+
if (!preview.count) return;
|
|
109
|
+
if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the contact id).`); return; }
|
|
110
|
+
const readline = await import("node:readline");
|
|
111
|
+
const typed = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`This cannot be undone. Type the contact id (${contactId}) to delete ${preview.count} message(s): `, (a) => { rl.close(); resolve(a.trim()); }); });
|
|
112
|
+
if (typed !== contactId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
|
|
113
|
+
let resp;
|
|
114
|
+
try { resp = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: false, confirm: true }); }
|
|
115
|
+
catch (e) { fail("Delete failed", e); }
|
|
116
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
117
|
+
console.log(`✓ deleted ${resp.deleted} message(s). The audit row keeps their ids, types and times (flowiq audit --endpoint messages).`);
|
|
118
|
+
if (resp.error) console.error(` ⚠ ${resp.error}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
122
|
+
const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
|
|
123
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
124
|
+
|
|
125
|
+
// Show the match in context, not the whole message.
|
|
126
|
+
export function snippetAround(text, needle, width = 90) {
|
|
127
|
+
const t = String(text ?? "").replace(/\s+/g, " ");
|
|
128
|
+
const i = t.toLowerCase().indexOf(String(needle).toLowerCase());
|
|
129
|
+
if (i < 0) return t.slice(0, width);
|
|
130
|
+
const start = Math.max(0, i - Math.floor(width / 3));
|
|
131
|
+
const s = t.slice(start, start + width);
|
|
132
|
+
return `${start > 0 ? "…" : ""}${s}${start + width < t.length ? "…" : ""}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function search(orgId, textParts, opts = {}) {
|
|
136
|
+
requireOrg(orgId);
|
|
137
|
+
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
138
|
+
if (text.length < 2) { console.error("Error: give at least 2 characters to search for."); process.exit(1); }
|
|
139
|
+
let resp;
|
|
140
|
+
try { resp = await http.get("messages", { organization_id: orgId, search: text, since: opts.since, until: opts.until, sender_type: opts.senderType, include_templates: opts.includeTemplates ? "1" : undefined, limit: opts.limit }); }
|
|
141
|
+
catch (e) { fail("Search failed", e); }
|
|
142
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
143
|
+
console.log(`${resp.organization_name}: ${resp.count} message(s) containing "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates ? "" : " (template sends excluded; --include-templates)"}`);
|
|
144
|
+
for (const r of resp.rows) {
|
|
145
|
+
console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${snippetAround(r.message, text)}`);
|
|
146
|
+
}
|
|
147
|
+
if (resp.rows.length) console.log(`\n contact ids: flowiq messages search … --json | jq '.rows[].contact_id' · full chat: flowiq messages pull <contact_id>`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function failures(orgId, opts = {}) {
|
|
151
|
+
requireOrg(orgId);
|
|
152
|
+
let resp;
|
|
153
|
+
try { resp = await http.get("messages", { organization_id: orgId, failures: "1", since: opts.since }); } catch (e) { fail("Failures read failed", e); }
|
|
154
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
155
|
+
const f = resp.failed, v = resp.vanished;
|
|
156
|
+
console.log(`${resp.organization_name}: ${f.total} failed send(s) since ${fmtSast(resp.since)} SAST${f.capped ? " (capped)" : ""}`);
|
|
157
|
+
if (f.by_reason.length) { console.log(" by reason:"); for (const r of f.by_reason) console.log(` ${String(r.n).padStart(6)} × ${r.key}`); }
|
|
158
|
+
if (f.by_media.length > 1 || (f.by_media[0] && f.by_media[0].key !== "text")) { console.log(" by media:"); for (const r of f.by_media) console.log(` ${String(r.n).padStart(6)} × ${r.key}`); }
|
|
159
|
+
if (f.by_contact.length) { console.log(" top contacts:"); for (const c of f.by_contact.slice(0, 10)) console.log(` ${String(c.n).padStart(6)} × ${c.contact?.full_name ?? ""} ${c.contact?.whatsapp_id ?? ""} (${c.contact_id})`); }
|
|
160
|
+
console.log(`\n${v.total} message(s) accepted by Meta but never confirmed (wamid, no receipt, no status, older than 15 min)${v.capped ? " (capped)" : ""}`);
|
|
161
|
+
if (v.by_media.length) for (const r of v.by_media) console.log(` ${String(r.n).padStart(6)} × ${r.key}`);
|
|
162
|
+
for (const r of v.sample.slice(0, 8)) console.log(` ${fmtSast(r.created_at)} ${pad(r.sender_type, 15)} ${r.contact?.full_name ?? ""} ${r.contact?.whatsapp_id ?? ""}${r.media_url ? " media" : ""}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function stats(orgId, opts = {}) {
|
|
166
|
+
requireOrg(orgId);
|
|
167
|
+
let resp;
|
|
168
|
+
try { resp = await http.get("messages", { organization_id: orgId, stats: "1", since: opts.since, until: opts.until }); } catch (e) { fail("Stats failed", e); }
|
|
169
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
170
|
+
console.log(`${resp.organization_name}: WhatsApp volume by month (SAST), ${fmtSast(resp.since).slice(0, 10)} to ${fmtSast(resp.until).slice(0, 10)}`);
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log(` ${pad("MONTH", 8)} ${pad("INBOUND", 9)} ${pad("CONTACTS", 9)} ${pad("OUTBOUND", 9)} ${pad("FREE-FORM", 10)} ${pad("TEMPLATE", 9)} ${pad("BOT", 8)} ${pad("HUMAN", 8)} REACHED`);
|
|
173
|
+
for (const m of resp.months) console.log(` ${pad(m.month, 8)} ${pad(m.inbound, 9)} ${pad(m.inbound_contacts, 9)} ${pad(m.outbound, 9)} ${pad(`${m.free_form}${m.free_form_pct != null ? ` (${m.free_form_pct}%)` : ""}`, 10)} ${pad(m.template, 9)} ${pad(m.bot, 8)} ${pad(m.human, 8)} ${m.outbound_contacts}`);
|
|
174
|
+
console.log(`\n ${resp.classifier}. Free-form = sent inside a customer's 24 h window (Meta's in-window charge from 1 Oct 2026).`);
|
|
69
175
|
}
|