@flowapt/flowiq-cli 0.10.0 → 0.12.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 +123 -2
- package/TEAM-GUIDE.md +23 -2
- package/package.json +1 -1
- package/src/batch-f.test.mjs +63 -0
- package/src/batch-g.test.mjs +35 -0
- package/src/commands/agent-test.js +44 -3
- package/src/commands/agents.js +31 -0
- package/src/commands/broadcast.js +19 -0
- package/src/commands/contacts.js +43 -2
- package/src/commands/links.js +63 -20
- package/src/commands/members.js +108 -0
- package/src/commands/messages.js +69 -3
- package/src/commands/notes.js +63 -0
- package/src/commands/org.js +77 -10
- package/src/commands/plans.js +50 -0
- package/src/commands/products.js +58 -0
- package/src/commands/prompts.js +46 -1
- package/src/commands/segments.js +47 -14
- package/src/commands/store-api.js +11 -3
- package/src/commands/tag.js +89 -8
- package/src/commands/templates.js +66 -5
- package/src/index.js +232 -9
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,108 @@
|
|
|
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
|
+
// `flowiq members invite <org> <email> [--role member]` — the app's door for
|
|
41
|
+
// someone with no account: an invitations row they are shown at sign-up.
|
|
42
|
+
export async function invite(orgId, email, opts = {}) {
|
|
43
|
+
requireOrg(orgId);
|
|
44
|
+
if (!email) { console.error("Error: flowiq members invite <org> <email> [--role member]"); process.exit(1); }
|
|
45
|
+
let resp;
|
|
46
|
+
try { resp = await http.post("members", { action: "invite", organization_id: orgId, email, role: opts.role || "member", dry_run: !!opts.dryRun }); } catch (e) { fail("Invite failed", e); }
|
|
47
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
48
|
+
if (resp.dry_run) { console.log(`Would invite ${resp.would_insert.email} to ${resp.organization_name} as ${resp.would_insert.role}.`); return; }
|
|
49
|
+
if (resp.unchanged) { console.log(resp.note); return; }
|
|
50
|
+
console.log(`✓ invited ${resp.email} as ${resp.role} (invitation ${resp.invitation_id}).\n ${resp.note}.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function role(orgId, emailOrId, newRole, opts = {}) {
|
|
54
|
+
requireOrg(orgId);
|
|
55
|
+
if (!emailOrId || !newRole) { console.error("Error: flowiq members role <org> <email> admin|member|viewer"); process.exit(1); }
|
|
56
|
+
let resp;
|
|
57
|
+
try { resp = await http.post("members", { action: "role", organization_id: orgId, ...who(emailOrId), role: newRole }); } catch (e) { fail("Role change failed", e); }
|
|
58
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
59
|
+
if (resp.unchanged) { console.log(`Already ${resp.role}.`); return; }
|
|
60
|
+
console.log(`✓ ${resp.email}: ${resp.from} → ${resp.to}. Undo: flowiq members role ${orgId} ${resp.email} ${resp.from}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function rename(emailOrId, name, opts = {}) {
|
|
64
|
+
if (!emailOrId || !name) { console.error("Error: flowiq members rename <email> \"Full Name\""); process.exit(1); }
|
|
65
|
+
let resp;
|
|
66
|
+
try { resp = await http.post("members", { action: "rename", ...who(emailOrId), full_name: name }); } catch (e) { fail("Rename failed", e); }
|
|
67
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
68
|
+
console.log(`✓ ${resp.email}: "${resp.from ?? ""}" → "${resp.to}" (profiles.full_name, shown in every org they belong to)`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function remove(orgId, emailOrId, opts = {}) {
|
|
72
|
+
requireOrg(orgId);
|
|
73
|
+
if (!emailOrId) { console.error("Error: flowiq members remove <org> <email> --confirm"); process.exit(1); }
|
|
74
|
+
let resp;
|
|
75
|
+
try { resp = await http.post("members", { action: "remove", organization_id: orgId, ...who(emailOrId), confirm: !!opts.confirm }); } catch (e) { fail("Remove failed", e); }
|
|
76
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
77
|
+
if (resp.dry_run) { console.log(`Would remove ${resp.email} (${resp.role}${resp.is_owner ? ", OWNER" : ""}) from this org. Add --confirm.`); return; }
|
|
78
|
+
console.log(`✓ removed ${resp.email} (${resp.role}). Undo: flowiq members add ${orgId} ${resp.email} --role ${resp.role}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `flowiq users lookup <email>` — the account behind an address, across orgs.
|
|
82
|
+
export async function lookup(email, opts = {}) {
|
|
83
|
+
if (!email) { console.error("Error: flowiq users lookup <email>"); process.exit(1); }
|
|
84
|
+
let resp;
|
|
85
|
+
try { resp = await http.get("members", { email }); }
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (e.status === 404 || /No account/.test(e.message)) {
|
|
88
|
+
console.log(`No account for ${email}.`);
|
|
89
|
+
const inv = e.body?.invitations || [];
|
|
90
|
+
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)}`); }
|
|
91
|
+
else console.log(" no invitation on record either — they were never invited, or were invited under another address.");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
fail("Lookup failed", e);
|
|
95
|
+
}
|
|
96
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
97
|
+
const u = resp.user;
|
|
98
|
+
console.log(`${u.email} ${u.id}${resp.super_admin ? " (Flowapt super admin)" : ""}`);
|
|
99
|
+
console.log(` name: ${resp.full_name ?? "(no profile name)"}${resp.profile_deleted ? " (profile deleted)" : ""}`);
|
|
100
|
+
console.log(` signs in with: ${u.providers.join(", ")}${u.email_confirmed_at ? "" : " (email NOT confirmed)"}${u.banned_until ? ` BANNED until ${u.banned_until}` : ""}`);
|
|
101
|
+
console.log(` created: ${fmtSast(u.created_at)} SAST · last sign-in ${u.last_sign_in_at ? `${fmtSast(u.last_sign_in_at)} SAST` : "never"}`);
|
|
102
|
+
if (!resp.organizations.length) console.log(" organizations: none");
|
|
103
|
+
else {
|
|
104
|
+
console.log(` organizations: ${resp.organizations.length}`);
|
|
105
|
+
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)" : ""}`);
|
|
106
|
+
}
|
|
107
|
+
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.`);
|
|
108
|
+
}
|
package/src/commands/messages.js
CHANGED
|
@@ -77,6 +77,72 @@ export async function pull(contactId, opts = {}) {
|
|
|
77
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
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
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 purge <org> --subject-regex <re> | --text <s> | --sender-type a,b [--since] [--before] --confirm`
|
|
98
|
+
// Org-wide delete by pattern (the 71 Lego auto-replies across 69 contacts).
|
|
99
|
+
export async function purge(orgId, opts = {}) {
|
|
100
|
+
requireOrg(orgId);
|
|
101
|
+
const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
102
|
+
if (!opts.subjectRegex && !opts.text && !senderTypes) { console.error("Error: give at least one of --subject-regex, --text, --sender-type."); process.exit(1); }
|
|
103
|
+
const body = { action: "delete", organization_id: orgId, subject_regex: opts.subjectRegex, text: opts.text, sender_types: senderTypes, since: opts.since, before: opts.before };
|
|
104
|
+
let preview;
|
|
105
|
+
try { preview = await http.post("messages", { ...body, dry_run: true }); } catch (e) { fail("Purge failed", e); }
|
|
106
|
+
if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
|
|
107
|
+
console.log(`${preview.organization_name}: ${preview.count} message(s) across ${preview.contacts} contact(s) match${preview.capped ? " (capped at 5,000 a run; re-run for the rest)" : ""}`);
|
|
108
|
+
for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
|
|
109
|
+
if (preview.top_subjects.length) { console.log(" subjects:"); for (const s of preview.top_subjects) console.log(` ${String(s.n).padStart(5)} × ${s.subject}`); }
|
|
110
|
+
if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
|
|
111
|
+
for (const c of preview.sample_contacts.slice(0, 8)) console.log(` ${String(c.n).padStart(5)} × ${c.contact?.full_name ?? ""} ${c.contact?.whatsapp_id ?? ""} (${c.contact_id})`);
|
|
112
|
+
if (!preview.count) return;
|
|
113
|
+
if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the organization id).`); return; }
|
|
114
|
+
const readline = await import("node:readline");
|
|
115
|
+
const typed = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`This cannot be undone. Type the organization id (${orgId}) to delete ${preview.count} message(s): `, (a) => { rl.close(); resolve(a.trim()); }); });
|
|
116
|
+
if (typed !== orgId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
|
|
117
|
+
let resp;
|
|
118
|
+
try { resp = await http.post("messages", { ...body, dry_run: false, confirm: true }); } catch (e) { fail("Purge failed", e); }
|
|
119
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
120
|
+
console.log(`✓ deleted ${resp.deleted} message(s) across ${resp.contacts} contact(s). The audit row keeps ids, types and times (flowiq audit --endpoint messages).`);
|
|
121
|
+
if (resp.error) console.error(` ⚠ ${resp.error}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `flowiq messages delete <contact_id> [--before 30d] [--sender-type a,b] --confirm`
|
|
125
|
+
export async function remove(contactId, opts = {}) {
|
|
126
|
+
if (!UUID_RE.test(contactId)) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
|
|
127
|
+
const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
128
|
+
let preview;
|
|
129
|
+
try { preview = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: true }); }
|
|
130
|
+
catch (e) { fail("Delete failed", e); }
|
|
131
|
+
if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
|
|
132
|
+
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(", ")}` : ""}`);
|
|
133
|
+
for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
|
|
134
|
+
if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
|
|
135
|
+
if (!preview.count) return;
|
|
136
|
+
if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the contact id).`); return; }
|
|
137
|
+
const readline = await import("node:readline");
|
|
138
|
+
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()); }); });
|
|
139
|
+
if (typed !== contactId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
|
|
140
|
+
let resp;
|
|
141
|
+
try { resp = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: false, confirm: true }); }
|
|
142
|
+
catch (e) { fail("Delete failed", e); }
|
|
143
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
144
|
+
console.log(`✓ deleted ${resp.deleted} message(s). The audit row keeps their ids, types and times (flowiq audit --endpoint messages).`);
|
|
145
|
+
if (resp.error) console.error(` ⚠ ${resp.error}`);
|
|
80
146
|
}
|
|
81
147
|
|
|
82
148
|
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); }
|
|
@@ -98,12 +164,12 @@ export async function search(orgId, textParts, opts = {}) {
|
|
|
98
164
|
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
99
165
|
if (text.length < 2) { console.error("Error: give at least 2 characters to search for."); process.exit(1); }
|
|
100
166
|
let resp;
|
|
101
|
-
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 }); }
|
|
167
|
+
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, tool_calls: opts.toolCalls ? "1" : undefined, limit: opts.limit }); }
|
|
102
168
|
catch (e) { fail("Search failed", e); }
|
|
103
169
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
104
|
-
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)"}`);
|
|
170
|
+
console.log(`${resp.organization_name}: ${resp.count} ${opts.toolCalls ? "tool call(s) whose request or response contains" : "message(s) containing"} "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates || opts.toolCalls ? "" : " (template sends excluded; --include-templates)"}`);
|
|
105
171
|
for (const r of resp.rows) {
|
|
106
|
-
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)}`);
|
|
172
|
+
console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(opts.toolCalls ? (r.tool_name ?? "tool") : r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${opts.toolCalls ? String(r.message).replace(/\s+/g, " ").slice(0, 160) : snippetAround(r.message, text)}`);
|
|
107
173
|
}
|
|
108
174
|
if (resp.rows.length) console.log(`\n contact ids: flowiq messages search … --json | jq '.rows[].contact_id' · full chat: flowiq messages pull <contact_id>`);
|
|
109
175
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `flowiq steer list|add|clear <contact_id>` + `flowiq notes list|add
|
|
2
|
+
// <contact_id>` — the internal rows in a contact's chat (server: api/cli/notes.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
|
+
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); }
|
|
9
|
+
const requireContact = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid contact UUID.`); process.exit(1); } };
|
|
10
|
+
|
|
11
|
+
async function listKind(contactId, kind, opts) {
|
|
12
|
+
requireContact(contactId);
|
|
13
|
+
let resp;
|
|
14
|
+
try { resp = await http.get("notes", { contact_id: contactId, kind, limit: opts.limit }); } catch (e) { fail("List failed", e); }
|
|
15
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
16
|
+
const label = kind === "notes" ? "internal note" : "steer";
|
|
17
|
+
console.log(`${resp.contact_name || "(no name)"} ${resp.contact_whatsapp_id || ""} on ${resp.organization_name}: ${resp.count} ${label}(s)${kind === "steers" ? ` · AI ${resp.bot_status === false ? "OFF" : "on"}${resp.steer_messages_flag ? "" : " · steer_messages flag off (inbox hides the control; vert still applies rows)"}` : ""}`);
|
|
18
|
+
for (const r of resp.rows) {
|
|
19
|
+
const head = kind === "notes" ? `${r.author ?? "?"}` : `${r.mode}${r.mode === "once" ? (r.consumed ? ", consumed" : ", pending") : ""}`;
|
|
20
|
+
console.log(`\n ${fmtSast(r.created_at)} SAST · ${head}${kind === "steers" && !r.active ? " · inactive" : ""}\n ${r.id}\n ${String(r.text).split("\n").join("\n ")}`);
|
|
21
|
+
}
|
|
22
|
+
if (kind === "steers" && resp.rows.some((r) => r.active)) console.log(`\n Remove: flowiq steer clear ${contactId} [--id <id>] --confirm`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const steerList = (contactId, opts = {}) => listKind(contactId, "steers", opts);
|
|
26
|
+
export const notesList = (contactId, opts = {}) => listKind(contactId, "notes", opts);
|
|
27
|
+
|
|
28
|
+
export async function steerAdd(contactId, textParts, opts = {}) {
|
|
29
|
+
requireContact(contactId);
|
|
30
|
+
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
31
|
+
if (!text) { console.error("Error: flowiq steer add <contact_id> \"instruction for the agent\" [--once]"); process.exit(1); }
|
|
32
|
+
let resp;
|
|
33
|
+
try { resp = await http.post("notes", { action: "add_steer", contact_id: contactId, text, mode: opts.once ? "once" : "persistent" }); } catch (e) { fail("Steer failed", e); }
|
|
34
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
35
|
+
console.log(`✓ ${resp.mode === "once" ? "one-shot" : "ongoing"} steer added (${resp.id}) as ${resp.author}. ${resp.mode === "once" ? "The agent applies it on the next reply, then it is spent." : "The agent applies it on EVERY reply until: flowiq steer clear " + contactId + " --id " + resp.id + " --confirm"}`);
|
|
36
|
+
for (const w of resp.warnings || []) console.error(` ⚠ ${w}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function steerClear(contactId, opts = {}) {
|
|
40
|
+
requireContact(contactId);
|
|
41
|
+
const ids = opts.id ? [].concat(opts.id) : undefined;
|
|
42
|
+
let resp;
|
|
43
|
+
try { resp = await http.post("notes", { action: "clear_steers", contact_id: contactId, steer_ids: ids, confirm: !!opts.confirm }); } catch (e) { fail("Clear failed", e); }
|
|
44
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
45
|
+
if (resp.dry_run) {
|
|
46
|
+
if (!resp.count) { console.log("No steers to remove."); return; }
|
|
47
|
+
console.log(`Would delete ${resp.count} steer(s):`);
|
|
48
|
+
for (const s of resp.steers) console.log(` ${s.id} ${s.mode}${s.consumed ? " (consumed)" : ""} ${String(s.text).slice(0, 80)}`);
|
|
49
|
+
console.log("Add --confirm to delete (the texts are kept in the audit row).");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
console.log(`✓ deleted ${resp.deleted} steer(s).`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function notesAdd(contactId, textParts, opts = {}) {
|
|
56
|
+
requireContact(contactId);
|
|
57
|
+
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
58
|
+
if (!text) { console.error("Error: flowiq notes add <contact_id> \"note for the team\""); process.exit(1); }
|
|
59
|
+
let resp;
|
|
60
|
+
try { resp = await http.post("notes", { action: "add_note", contact_id: contactId, text }); } catch (e) { fail("Note failed", e); }
|
|
61
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
62
|
+
console.log(`✓ internal note added (${resp.id}) as ${resp.author}; it shows on the Customer panel, never to the customer.`);
|
|
63
|
+
}
|
package/src/commands/org.js
CHANGED
|
@@ -13,31 +13,78 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
13
13
|
export async function list(search, opts = {}) {
|
|
14
14
|
let resp;
|
|
15
15
|
try {
|
|
16
|
-
resp = await http.get("org", { list: "1", search: search || undefined });
|
|
16
|
+
resp = await http.get("org", { list: "1", search: search || undefined, number: opts.number || undefined });
|
|
17
17
|
} catch (e) {
|
|
18
18
|
console.error(`Lookup failed: ${e.message}`);
|
|
19
|
+
if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
21
22
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// A number lookup shows parked orgs too: the number is the question.
|
|
25
|
+
const orgs = (resp.orgs || []).filter((o) => (opts.all || opts.number ? true : !o.inactive));
|
|
24
26
|
if (!orgs.length) {
|
|
25
|
-
console.log(search ? `No organizations match "${search}".` : "No organizations found.");
|
|
27
|
+
console.log(opts.number ? `No organization has WhatsApp number / phone id / WABA ${opts.number}.` : search ? `No organizations match "${search}".` : "No organizations found.");
|
|
26
28
|
return;
|
|
27
29
|
}
|
|
28
30
|
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
29
31
|
const nameW = Math.min(34, Math.max(4, ...orgs.map((o) => (o.name || "").length)));
|
|
30
|
-
|
|
32
|
+
const byNumber = !!opts.number;
|
|
33
|
+
console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} ${byNumber ? `${pad("NUMBER", 16)}${pad("PHONE ID", 18)}${pad("WABA", 18)}` : `${pad("PLATFORM", 9)}STORE`}`);
|
|
31
34
|
for (const o of orgs) {
|
|
32
35
|
console.log(
|
|
33
36
|
`${pad((o.name || "").slice(0, nameW), nameW)} ${pad(o.id, 36)} ` +
|
|
34
|
-
|
|
37
|
+
(byNumber
|
|
38
|
+
? `${pad(o.phone_number || "—", 16)}${pad(o.phone_id || "—", 18)}${pad(o.waba_id || "—", 18)}`
|
|
39
|
+
: `${pad(o.platform || "—", 9)}${o.store || ""}`) +
|
|
40
|
+
`${o.inactive ? " (inactive)" : ""}`
|
|
35
41
|
);
|
|
36
42
|
}
|
|
37
43
|
const hidden = (resp.orgs || []).length - orgs.length;
|
|
38
44
|
console.log(`\n${orgs.length} organization(s)${hidden ? ` · ${hidden} inactive hidden (--all to show)` : ""}`);
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
// `flowiq org agent <org> on|off [--commit] [--yes]` — the agent master switch
|
|
48
|
+
// (organizations.wati_webhook_status). Off = no customer gets the AI. On with
|
|
49
|
+
// vert.active:false + an allowlist = test mode. On with vert.active true =
|
|
50
|
+
// live for everyone, which is the shape that needs --yes.
|
|
51
|
+
export async function agentSwitch(orgId, state, opts = {}) {
|
|
52
|
+
requireOrg(orgId);
|
|
53
|
+
const s = String(state || "").toLowerCase();
|
|
54
|
+
if (!["on", "off"].includes(s)) { console.error('Error: pass "on" or "off", e.g. flowiq org agent <org> off --commit'); process.exit(1); }
|
|
55
|
+
let resp;
|
|
56
|
+
try { resp = await http.post("org", { action: "agent_switch", organization_id: orgId, enabled: s === "on", dry_run: !opts.commit, confirm: !!opts.yes }); }
|
|
57
|
+
catch (e) { fail("Switch failed", e); }
|
|
58
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
59
|
+
console.log(`${resp.organization_name} — agent master switch`);
|
|
60
|
+
console.log(` now: ${resp.from ? "ON" : "OFF"} · vert.active ${resp.vert_active ?? "unset"}${resp.allowed_whatsapp_ids?.length ? ` · allowlist ${resp.allowed_whatsapp_ids.join(", ")}` : ""} · active agent ${resp.active_whatsapp_agent ?? "NONE"}`);
|
|
61
|
+
if (resp.unchanged) { console.log(` already ${resp.to ? "ON" : "OFF"} — nothing to do.`); return; }
|
|
62
|
+
console.log(` ${resp.dry_run ? "would set" : "✓ set"}: ${resp.to ? "ON" : "OFF"} → ${resp.mode}`);
|
|
63
|
+
if (resp.dry_run) {
|
|
64
|
+
console.log(`\nDRY RUN — add --commit to switch${resp.needs_confirm ? ", and --yes because this makes the agent LIVE for every customer (set vert.allowed_whatsapp_ids + vert.active false first for a test mode)" : ""}.`);
|
|
65
|
+
} else {
|
|
66
|
+
console.log(`\nUndo: flowiq org agent ${orgId} ${resp.from ? "on" : "off"} --commit${resp.from ? " --yes" : ""}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// `flowiq org park <org> [--unpark] --confirm` — organizations.inactive.
|
|
71
|
+
export async function park(orgId, opts = {}) {
|
|
72
|
+
requireOrg(orgId);
|
|
73
|
+
let resp;
|
|
74
|
+
try { resp = await http.post("org", { action: "park", organization_id: orgId, inactive: !opts.unpark, confirm: !!opts.confirm }); }
|
|
75
|
+
catch (e) { fail("Park failed", e); }
|
|
76
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
77
|
+
if (resp.unchanged) { console.log(`${resp.organization_name} is already ${resp.inactive ? "parked (inactive)" : "active"}.`); return; }
|
|
78
|
+
const verb = opts.unpark ? "unpark" : "park";
|
|
79
|
+
if (resp.dry_run) {
|
|
80
|
+
console.log(`Would ${verb} ${resp.organization_name} (inactive ${resp.from} → ${resp.to}).${resp.master_switch && !opts.unpark ? " Its agent master switch is still ON — parking hides the org from censuses and crons, it does not switch the agent off (flowiq org agent <org> off --commit)." : ""}`);
|
|
81
|
+
console.log("Add --confirm to write.");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
console.log(`✓ ${verb}ed ${resp.organization_name} (inactive ${resp.from} → ${resp.to}).${resp.master_switch && !opts.unpark ? " ⚠ agent master switch still ON." : ""}`);
|
|
85
|
+
console.log(`Undo: flowiq org park ${orgId}${opts.unpark ? "" : " --unpark"} --confirm`);
|
|
86
|
+
}
|
|
87
|
+
|
|
41
88
|
export async function info(orgId, opts = {}) {
|
|
42
89
|
if (!UUID_RE.test(orgId)) {
|
|
43
90
|
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
@@ -150,8 +197,9 @@ export async function whatsapp(orgId, opts = {}) {
|
|
|
150
197
|
console.log(`${resp.checked} Meta number(s) checked at Meta, ${resp.unhealthy} with a problem (${resp.skipped_not_meta_or_unconnected} org(s) skipped: not Meta or not connected)`);
|
|
151
198
|
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
152
199
|
console.log("");
|
|
153
|
-
console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("
|
|
154
|
-
for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "
|
|
200
|
+
console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("LIMIT", 14)} PROBLEMS`);
|
|
201
|
+
for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "—", 14)} ${(o.problems || []).join("; ")}${o.notes?.length ? `${o.problems?.length ? " · " : ""}info: ${o.notes.join("; ")}` : ""}`);
|
|
202
|
+
console.log(`\n LIMIT = the business portfolio's messaging limit (Meta sets it per portfolio now; the per-number tier field is deprecated). "—" = Meta returned neither field for this number.`);
|
|
155
203
|
return;
|
|
156
204
|
}
|
|
157
205
|
const resp = await view(orgId, "whatsapp");
|
|
@@ -162,11 +210,11 @@ export async function whatsapp(orgId, opts = {}) {
|
|
|
162
210
|
const p = w.phone || {};
|
|
163
211
|
console.log(` number: ${p.display_phone_number ?? "?"} · "${p.verified_name ?? "?"}" · phone ${w.phone_id} · WABA ${w.waba_id ?? "?"}`);
|
|
164
212
|
console.log(` status: ${p.status ?? "?"} · platform ${p.platform_type ?? "?"} · account mode ${p.account_mode ?? "?"}`);
|
|
165
|
-
console.log(` quality: ${p.quality_rating ?? "?"} · limit
|
|
213
|
+
console.log(` quality: ${p.quality_rating ?? "?"} · messaging limit ${w.messaging_limit ?? "not returned"}${w.messaging_limit_source ? ` (${w.messaging_limit_source})` : ""}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
|
|
166
214
|
console.log(` name: ${p.name_status ?? "?"}${p.new_name_status && p.new_name_status !== "NONE" ? ` · pending "${p.new_display_name}" (${p.new_name_status})` : ""} · OBA ${yn(p.is_official_business_account)} · code verification ${p.code_verification_status ?? "?"}`);
|
|
167
215
|
console.log(` webhooks: ${Array.isArray(w.subscribed_apps) ? (w.subscribed_apps.length ? w.subscribed_apps.join(", ") : "NO app subscribed") : w.subscribed_apps ?? "?"}`);
|
|
168
216
|
if (w.waba && !w.waba.error) console.log(` waba: ${w.waba.name ?? ""} · review ${w.waba.account_review_status ?? "?"} · ${w.waba.ownership_type ?? ""}`);
|
|
169
|
-
console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}`);
|
|
217
|
+
console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}${w.notes?.length ? ` (info: ${w.notes.join("; ")})` : ""}`);
|
|
170
218
|
}
|
|
171
219
|
|
|
172
220
|
export async function health(orgId, opts = {}) {
|
|
@@ -175,8 +223,10 @@ export async function health(orgId, opts = {}) {
|
|
|
175
223
|
const h = resp.health;
|
|
176
224
|
console.log(`${resp.organization_name} — is the agent ready? ${h.ready ? "✓ yes" : `✗ ${h.problems.length} problem(s)`}`);
|
|
177
225
|
for (const p of h.problems) console.log(` ✗ ${p}`);
|
|
226
|
+
for (const w of h.warnings || []) console.log(` ⚠ ${w}`);
|
|
178
227
|
console.log(` switch: master ${h.master_switch ? "ON" : "OFF"} · vert.active ${yn(h.vert_active)}${h.allowed_whatsapp_ids ? ` (only ${h.allowed_whatsapp_ids})` : ""} · provider ${h.provider}${h.coexistence ? ` · ${h.coexistence}` : ""}${h.phone_number ? ` · ${h.phone_number}` : ""}`);
|
|
179
|
-
|
|
228
|
+
const spare = h.keys.openai_spare;
|
|
229
|
+
console.log(` keys: OpenAI ${h.keys.openai.state}${h.keys.openai.detail ? ` (${h.keys.openai.detail})` : ""}${spare ? ` · spare ${spare.state}${spare.detail ? ` (${spare.detail})` : ""}` : ""} · Claude ${h.keys.anthropic_present ? "present" : "none"}`);
|
|
180
230
|
console.log(` agent: ${h.agent ? `${h.agent.name} · ${h.agent.prompt_chars.toLocaleString()} prompt chars · prompt ${h.agent.use_settings_prompt === false ? "OFF" : "on"} · ${h.agent.model ?? "house model"} · product_lookup ${yn(h.agent.product_lookup)} · tickets ${yn(h.agent.ticket_tool_status)}` : "NONE"}`);
|
|
181
231
|
console.log(` catalogue: ${h.catalogue.products} product rows · ${h.catalogue.embedded} with embeddings${h.catalogue.last_sync ? ` · last sync ${when(h.catalogue.last_sync.created_at)} ${h.catalogue.last_sync.success === false ? "FAILED" : "ok"} (+${h.catalogue.last_sync.added ?? 0} ~${h.catalogue.last_sync.updated ?? 0} -${h.catalogue.last_sync.deleted ?? 0})` : " · no sync recorded"}`);
|
|
182
232
|
const s = h.store;
|
|
@@ -187,6 +237,22 @@ export async function health(orgId, opts = {}) {
|
|
|
187
237
|
|
|
188
238
|
export async function automations(orgId, opts = {}) {
|
|
189
239
|
requireOrg(orgId);
|
|
240
|
+
// --set <type> --template <name> / --body k=v,… / --buttons k=v,…: repoint
|
|
241
|
+
// one type at a new template version or fix its param mapping.
|
|
242
|
+
if (opts.set) {
|
|
243
|
+
const parseMap = (v, flag) => { if (v === undefined) return undefined; if (v === "none" || v === "null") return null; const out = {}; for (const pair of String(v).split(",").map((s) => s.trim()).filter(Boolean)) { const i = pair.indexOf("="); if (i < 1) { console.error(`Error: ${flag} takes param1=full_name,param2=order_number (got "${pair}")`); process.exit(1); } out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim(); } return out; };
|
|
244
|
+
const body = { action: "automations_set", organization_id: orgId, type: opts.set, template_name: opts.template, body_parameters: parseMap(opts.body, "--body"), button_parameters: parseMap(opts.buttons, "--buttons"), dry_run: !opts.commit };
|
|
245
|
+
if (body.template_name === undefined && body.body_parameters === undefined && body.button_parameters === undefined) { console.error("Error: with --set <type> pass --template <name>, --body k=v,… and/or --buttons k=v,… (\"none\" clears a mapping)."); process.exit(1); }
|
|
246
|
+
let resp;
|
|
247
|
+
try { resp = await http.post("org", body); } catch (e) { fail("Set failed", e); }
|
|
248
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
249
|
+
if (resp.unchanged) { console.log(`${opts.set}: nothing to change (template ${resp.current?.template_name ?? "none"}, mapping ${JSON.stringify(resp.current?.meta_parameter_mapping ?? null)}).`); return; }
|
|
250
|
+
console.log(`${resp.organization_name} · ${resp.type}:`);
|
|
251
|
+
for (const c of resp.changes) console.log(` ${c}`);
|
|
252
|
+
if (resp.warning) console.log(` ⚠ ${resp.warning}`);
|
|
253
|
+
console.log(resp.dry_run ? "\nDRY RUN — add --commit to write." : "\n✓ written (the next order event sends with these values).");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
190
256
|
if (opts.enable || opts.disable) {
|
|
191
257
|
const type = opts.enable || opts.disable;
|
|
192
258
|
let resp;
|
|
@@ -208,6 +274,7 @@ export async function automations(orgId, opts = {}) {
|
|
|
208
274
|
if (t.warning) console.log(` ⚠ ${t.warning}`);
|
|
209
275
|
}
|
|
210
276
|
console.log(`\nflowiq org automations ${orgId} --enable <type> | --disable <type> [--commit] (the whole feature: --enable/--disable with no type)`);
|
|
277
|
+
console.log(`flowiq org automations ${orgId} --set <type> --template <name> --body param1=full_name,param2=order_number --buttons param1=short_code [--commit]`);
|
|
211
278
|
}
|
|
212
279
|
|
|
213
280
|
export async function abandoned(orgId, opts = {}) {
|
package/src/commands/plans.js
CHANGED
|
@@ -230,6 +230,56 @@ export async function show(planId, opts = {}) {
|
|
|
230
230
|
console.log(`\nChange status: flowiq plans status ${p.id} --to <status> [--comment "..."] [--commit]`);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// `flowiq plans create <org> --topic … --type utility|marketing --date YYYY-MM-DD [--time HH:MM] …`
|
|
234
|
+
// Files a plan on the client's Planning board (the MCP submit_plan row). Staff
|
|
235
|
+
// are exempt from the client lead time. Dry run unless --commit.
|
|
236
|
+
export function parseButton(spec) {
|
|
237
|
+
// type:text[=value] e.g. quick_reply:VIEW MORE · url:CLICK HERE=https://x · phone:Call us=+27… · copy_code:SAVE10
|
|
238
|
+
const m = /^(quick_reply|url|phone|copy_code):(.*)$/.exec(String(spec).trim());
|
|
239
|
+
if (!m) { console.error(`Error: --button must be type:text[=value] (got "${spec}"); types quick_reply | url | phone | copy_code`); process.exit(1); }
|
|
240
|
+
const [, type, rest] = m;
|
|
241
|
+
if (type === "copy_code") return { type, code: rest.trim() };
|
|
242
|
+
const eq = rest.indexOf("=");
|
|
243
|
+
const text = (eq >= 0 ? rest.slice(0, eq) : rest).trim();
|
|
244
|
+
const value = eq >= 0 ? rest.slice(eq + 1).trim() : "";
|
|
245
|
+
if (type === "url") return { type, text, url: value };
|
|
246
|
+
if (type === "phone") return { type, text, phone_number: value };
|
|
247
|
+
return { type, text };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function create(orgId, opts = {}) {
|
|
251
|
+
if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
252
|
+
const buttons = (opts.button || []).map(parseButton);
|
|
253
|
+
for (const f of opts.reply || []) {
|
|
254
|
+
const i = f.indexOf("=");
|
|
255
|
+
if (i < 1) { console.error(`Error: --reply must be <button text>=<follow-up message> (got "${f}")`); process.exit(1); }
|
|
256
|
+
const b = buttons.find((x) => x.type === "quick_reply" && x.text.toLowerCase() === f.slice(0, i).trim().toLowerCase());
|
|
257
|
+
if (!b) { console.error(`Error: --reply names "${f.slice(0, i).trim()}" but no quick_reply button has that text`); process.exit(1); }
|
|
258
|
+
b.follow_up = f.slice(i + 1).trim();
|
|
259
|
+
}
|
|
260
|
+
const body = {
|
|
261
|
+
action: "create", organization_id: orgId, topic: opts.topic, type: opts.type, scheduled_date: opts.date, scheduled_time: opts.time,
|
|
262
|
+
product_focus: opts.product, audience: opts.audience, line_1: opts.line1, line_2: opts.line2, follow_up_message: opts.followUp, landing_page_url: opts.landing,
|
|
263
|
+
message: opts.message, buttons: buttons.length ? buttons : undefined, graphic_brief: opts.graphic, media_url: opts.media, save_as_draft: !!opts.draft, dry_run: !opts.commit,
|
|
264
|
+
};
|
|
265
|
+
let resp;
|
|
266
|
+
try { resp = await http.post("plans", body); }
|
|
267
|
+
catch (e) {
|
|
268
|
+
console.error(`Create failed: ${e.message}`);
|
|
269
|
+
for (const p of e.body?.problems || []) console.error(` - ${p}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
273
|
+
console.log(`${resp.organization_name}: "${resp.topic}" (${resp.type}) · ${resp.send} · audience: ${resp.audience} · files as ${resp.files_as}`);
|
|
274
|
+
console.log(` copy: ${String(resp.copy).replace(/\n/g, "\n ")}`);
|
|
275
|
+
if (resp.buttons?.length) console.log(` buttons: ${resp.buttons.map((b) => `${b.type}:${b.text ?? b.code}${b.url ? `=${b.url}` : ""}`).join(" · ")}`);
|
|
276
|
+
if (resp.follow_ups && Object.keys(resp.follow_ups).length) console.log(` follow-ups: ${JSON.stringify(resp.follow_ups)}`);
|
|
277
|
+
console.log(` graphic: ${resp.graphic}`);
|
|
278
|
+
for (const n of resp.notes || []) console.log(` ⚠ ${n}`);
|
|
279
|
+
if (resp.dry_run) { console.log("\nDRY RUN — nothing filed. Add --commit to file it on the Planning board."); return; }
|
|
280
|
+
console.log(`\n✓ filed plan ${resp.plan_id} (${resp.status}). ${resp.alert?.note ?? ""}\n flowiq plans show ${resp.plan_id}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
233
283
|
export async function status(planId, opts = {}) {
|
|
234
284
|
if (!UUID_RE.test(planId)) {
|
|
235
285
|
console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);
|