@flowapt/flowiq-cli 0.9.9 → 0.10.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 +136 -0
- package/TEAM-GUIDE.md +13 -0
- package/package.json +1 -1
- package/src/batch-cde.test.mjs +73 -0
- package/src/commands/contacts.js +173 -0
- package/src/commands/flows.js +82 -0
- package/src/commands/messages.js +67 -0
- package/src/commands/org.js +136 -0
- package/src/commands/rules.js +67 -0
- package/src/commands/tickets.js +69 -0
- package/src/commands/widgets.js +97 -0
- package/src/index.js +139 -0
package/README.md
CHANGED
|
@@ -2046,6 +2046,142 @@ channel gets a warning on every run. Point the agent at a fake number with
|
|
|
2046
2046
|
CLI creates is born with `allow_broadcast=false` and the `test-contact` tag, so it
|
|
2047
2047
|
never lands on a broadcast list (stress contacts too).
|
|
2048
2048
|
|
|
2049
|
+
### Contacts — `flowiq contacts find|show|census|lint|merge|optout|optin|set|export` (0.10.0)
|
|
2050
|
+
|
|
2051
|
+
The contact record from the terminal. `find` searches every contact of the org,
|
|
2052
|
+
archived and blocked included (the inbox hides archived chats on every tab but
|
|
2053
|
+
Archived), and prints the inbox state with each match.
|
|
2054
|
+
|
|
2055
|
+
```bash
|
|
2056
|
+
flowiq contacts find <org> --email sofia@ # or --phone 3519 / --name "Sofia" / --id <uuid>
|
|
2057
|
+
flowiq contacts show <contact_id> --org <org>
|
|
2058
|
+
flowiq contacts census <org> # archived chats still carrying unread / customer-last messages
|
|
2059
|
+
flowiq contacts lint <org> # malformed numbers + the repair + whether a twin contact holds it
|
|
2060
|
+
flowiq contacts set <org> <contact_id> --whatsapp-id 27821234567 --commit
|
|
2061
|
+
flowiq contacts merge <org> <keep_id> <remove_id> --commit # the app's merge_contacts_v2
|
|
2062
|
+
flowiq contacts optout <org> --contact <id> --reason "asked on 30 Sep" --commit
|
|
2063
|
+
flowiq contacts optin <org> --ids-file ids.txt --commit --confirm
|
|
2064
|
+
flowiq contacts export <org> [--subscribers] [--out contacts.csv]
|
|
2065
|
+
```
|
|
2066
|
+
|
|
2067
|
+
- **`find`** prints id, number, email, other channel identities, state
|
|
2068
|
+
(archived / blocked / AI off / opted out / unread), team, assignee, the
|
|
2069
|
+
latest message and any open ticket. Max 100 matches.
|
|
2070
|
+
- **`lint`** flags a doubled country code (`351351…`), a trunk 0 kept after the
|
|
2071
|
+
code (`270…`), non-digits and bad lengths, with the suggested repair. When
|
|
2072
|
+
another contact already holds the repaired number it names the twin and
|
|
2073
|
+
prints the `merge` command instead. Test, stress and web-chat identities
|
|
2074
|
+
are counted and skipped.
|
|
2075
|
+
- **`set`** changes one contact's WhatsApp number (and `phone_number` when it
|
|
2076
|
+
was the same). Refused when another contact holds the number: merge instead.
|
|
2077
|
+
- **`merge`** is dry-run by default and shows both records with message and
|
|
2078
|
+
order counts; `--commit` runs `merge_contacts_v2` (identities released, every
|
|
2079
|
+
child row repointed, the losing contact deactivated, undo in the inbox).
|
|
2080
|
+
- **`optout` / `optin`** write `allow_broadcast` and the same inbox marker row
|
|
2081
|
+
the app writes (`contact-opted-out` / `contact-opted-in`). Already-set
|
|
2082
|
+
contacts are skipped so the count is honest. Opting someone back IN needs
|
|
2083
|
+
`--confirm` on top of `--commit`.
|
|
2084
|
+
- **`export`** writes `.flowiq/contacts/<org>.csv` (Excel opens it). `--subscribers`
|
|
2085
|
+
= opted in, not archived, not blocked.
|
|
2086
|
+
|
|
2087
|
+
### Widgets — `flowiq widgets list|show|pull|push|embed|activate` (0.10.0)
|
|
2088
|
+
|
|
2089
|
+
```bash
|
|
2090
|
+
flowiq widgets list <org>
|
|
2091
|
+
flowiq widgets pull <widget_id> # → .flowiq/widgets/<org>-<id8>.json
|
|
2092
|
+
flowiq widgets push .flowiq/widgets/lego-ddb5ab43.json # dry run: every field that would change
|
|
2093
|
+
flowiq widgets push … --commit
|
|
2094
|
+
flowiq widgets embed <org> [widget_id] # the <script> snippet a client pastes
|
|
2095
|
+
flowiq widgets activate <widget_id> # the widget an org-only embed resolves to
|
|
2096
|
+
```
|
|
2097
|
+
|
|
2098
|
+
A push is an **id-keyed patch, never a full replace**: scalar columns are set,
|
|
2099
|
+
jsonb bags (`home_copy`, `home_design`, `launcher`, `team_avatars`, …) are
|
|
2100
|
+
merged at their top level and a key set to `null` is removed. These render on
|
|
2101
|
+
live storefronts, so the dry run prints each change before `--commit`. Avatar
|
|
2102
|
+
and icon uploads are not here yet (file upload with circle compositing).
|
|
2103
|
+
|
|
2104
|
+
### Tickets — `flowiq tickets list|show|status` (0.10.0)
|
|
2105
|
+
|
|
2106
|
+
```bash
|
|
2107
|
+
flowiq tickets list <org> # open + in_progress, newest first
|
|
2108
|
+
flowiq tickets list <org> --older-than 48h --status open # stale escalations
|
|
2109
|
+
flowiq tickets list <org> --contact <contact_id> --status all
|
|
2110
|
+
flowiq tickets show <ticket_id>
|
|
2111
|
+
flowiq tickets status <ticket_id> --set resolved --note "…" --commit
|
|
2112
|
+
```
|
|
2113
|
+
|
|
2114
|
+
`status` writes what the app writes (`resolved_by/at`, `last_updated_*`, reopen
|
|
2115
|
+
fields, a `status_history` entry) and, when the org runs
|
|
2116
|
+
`reenable_bot_on_ticket_resolve`, switches the contact's AI back on with the
|
|
2117
|
+
inbox's three guards (open → closed transition, latest ticket only, bot not
|
|
2118
|
+
already on). Dry run by default.
|
|
2119
|
+
|
|
2120
|
+
### Flows — `flowiq flows list|show|status|enable|disable` (0.10.0)
|
|
2121
|
+
|
|
2122
|
+
```bash
|
|
2123
|
+
flowiq flows list <org> # trigger, templates, stats, param-key lint per flow
|
|
2124
|
+
flowiq flows show <flow_id> # full configuration
|
|
2125
|
+
flowiq flows status <org> --since 14d [--failed] [--flow <id>] # execution funnel + verbatim error rollup
|
|
2126
|
+
flowiq flows enable <flow_id> --confirm # arms real sends
|
|
2127
|
+
flowiq flows disable <flow_id>
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
The lint flags a `send_message` step whose `body_parameters` keys are not
|
|
2131
|
+
`param1..N` (`b1..b4`, `1,b1`, …): `bc send` refuses those shapes, the flow
|
|
2132
|
+
engine hands them to Meta where jsonb key order decides the slots.
|
|
2133
|
+
|
|
2134
|
+
### Rules — `flowiq rules list|show|results|enable|disable` (0.10.0)
|
|
2135
|
+
|
|
2136
|
+
The Automations rules engine (Shoppers → Rules, `custom_notification_rules`):
|
|
2137
|
+
the delayed-send engine behind tag-based, order-stage, webhook and
|
|
2138
|
+
Instagram-comment sends.
|
|
2139
|
+
|
|
2140
|
+
```bash
|
|
2141
|
+
flowiq rules list <org> [--trigger tag_added]
|
|
2142
|
+
flowiq rules show <rule_id>
|
|
2143
|
+
flowiq rules results <rule_id> --since 30d # what it queued: status counts, errors, recent sends
|
|
2144
|
+
flowiq rules enable <rule_id> --confirm
|
|
2145
|
+
flowiq rules disable <rule_id>
|
|
2146
|
+
```
|
|
2147
|
+
|
|
2148
|
+
### Org views — `flowiq org channels|whatsapp|health|automations|abandoned` (0.10.0)
|
|
2149
|
+
|
|
2150
|
+
```bash
|
|
2151
|
+
flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads, Zernio
|
|
2152
|
+
flowiq org channels --all # census across every active org
|
|
2153
|
+
flowiq org whatsapp <org> # the number's health straight from Meta: status, quality, tier, name status, webhook subscription
|
|
2154
|
+
flowiq org whatsapp --all # every Meta org, unhealthy first
|
|
2155
|
+
flowiq org health <org> # is the agent ready? master switch, keys (OpenAI probed), agent, catalogue + embeddings, store webhooks + order sync, traffic
|
|
2156
|
+
flowiq org automations <org> # automated order messages, each type checked against the org's templates
|
|
2157
|
+
flowiq org automations <org> --enable order_shipped --commit # or --disable; no type = the whole feature
|
|
2158
|
+
flowiq org abandoned <org> # abandoned-cart recovery per sequence + the last 30 days of sends
|
|
2159
|
+
flowiq org abandoned <org> --disable 2 --commit
|
|
2160
|
+
```
|
|
2161
|
+
|
|
2162
|
+
`health` probes the org's OpenAI key with a one-token embedding, so an
|
|
2163
|
+
exhausted or invalid key shows as such rather than "present".
|
|
2164
|
+
|
|
2165
|
+
### Messages, org-wide — `flowiq messages search|failures|stats` (0.10.0)
|
|
2166
|
+
|
|
2167
|
+
```bash
|
|
2168
|
+
flowiq messages search <org> "flash sale" --since 24h [--sender-type bot-whatsapp] [--include-templates]
|
|
2169
|
+
flowiq messages failures <org> --since 7d
|
|
2170
|
+
flowiq messages stats <org> --since 6m
|
|
2171
|
+
```
|
|
2172
|
+
|
|
2173
|
+
- **`search`** answers "did the agent say X to anyone today?" without knowing
|
|
2174
|
+
the contact: time · sender · contact · the match in context. Template and
|
|
2175
|
+
broadcast payloads are excluded unless `--include-templates`.
|
|
2176
|
+
- **`failures`** rolls up Meta-failed sends by error code, media type and
|
|
2177
|
+
contact, and lists the sends Meta accepted (wamid) but never confirmed.
|
|
2178
|
+
`messages pull` now ends with the same delivery footer.
|
|
2179
|
+
- **`stats`** is the monthly volume: inbound, outbound, free-form (sent inside
|
|
2180
|
+
a customer's 24 h window, Meta's in-window charge from 1 Oct 2026) vs
|
|
2181
|
+
template, bot vs human. The classifier lives in ONE RPC
|
|
2182
|
+
(`cli_message_volume_summary`): template = broadcast_id, JSON payload, or no
|
|
2183
|
+
customer message in the preceding 24 h.
|
|
2184
|
+
|
|
2049
2185
|
### Guide — `flowiq guide`
|
|
2050
2186
|
|
|
2051
2187
|
Read the bundled docs in the terminal — no digging through node_modules.
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -134,6 +134,19 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
134
134
|
| Which changelog rows has nobody emailed the team about yet? | `flowiq updates status` / `flowiq updates uncovered` — anything left 20h+ goes out automatically at 08:30 SAST as a plain digest |
|
|
135
135
|
| Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
|
|
136
136
|
| **The CLI can't do something I need** (or does it wrong, or should do more) | `flowiq gaps report "<command> cannot <thing>" --command "<command>" --error "<exact error>" --workaround "<what you did instead>"`. If it is already on the list you are told its number: `flowiq gaps hit <number> --note "…"` |
|
|
137
|
+
| Find a customer the inbox cannot show (archived, blocked, wrong tab) | `flowiq contacts find <org_id> --email <part>` (or `--phone`, `--name`): every match with its state, team, latest message and open ticket |
|
|
138
|
+
| A customer's number is wrong (country code doubled, leading 0 kept) | `flowiq contacts lint <org_id>` finds them with the repair; `flowiq contacts set <org_id> <contact_id> --whatsapp-id <number> --commit` fixes one; if a twin contact holds the number, `flowiq contacts merge <org_id> <keep> <remove> --commit` |
|
|
139
|
+
| Opt a customer out of broadcasts (or back in) | `flowiq contacts optout <org_id> --contact <id> --reason "…" --commit` · back in: `flowiq contacts optin … --commit --confirm` |
|
|
140
|
+
| Export the contact list for a client | `flowiq contacts export <org_id> --subscribers` → a CSV Excel opens |
|
|
141
|
+
| Change a web chat widget's copy / colours, or get the embed code | `flowiq widgets pull <widget_id>` → edit → `flowiq widgets push <file>` (dry run) → `--commit` · `flowiq widgets embed <org_id>` |
|
|
142
|
+
| Which escalations are still open, and close one | `flowiq tickets list <org_id> --older-than 48h` · `flowiq tickets status <ticket_id> --set resolved --note "…" --commit` |
|
|
143
|
+
| Did the agent say something wrong to anyone today? | `flowiq messages search <org_id> "the words" --since 24h` |
|
|
144
|
+
| Why did messages not arrive / what failed to send? | `flowiq messages failures <org_id> --since 7d` (Meta error codes by reason, media and contact, plus sends Meta accepted and never confirmed) |
|
|
145
|
+
| How many messages did an org send last month, free-form vs template? | `flowiq messages stats <org_id> --since 6m` |
|
|
146
|
+
| Is a client's WhatsApp number healthy / connected / rate-limited? | `flowiq org whatsapp <org_id>` (straight from Meta); every org: `flowiq org whatsapp --all` |
|
|
147
|
+
| Which channels are connected (Messenger, Instagram, email…)? | `flowiq org channels <org_id>`; every org: `flowiq org channels --all` |
|
|
148
|
+
| Is this client's agent ready to switch on? | `flowiq org health <org_id>` (master switch, keys probed, prompt, catalogue, webhooks, order sync, traffic) |
|
|
149
|
+
| What automated messages / flows / rules does an org have armed? | `flowiq org automations <org_id>` · `flowiq flows list <org_id>` (+ `flows status` for failures) · `flowiq rules list <org_id>` (+ `rules results <rule_id>`) · `flowiq org abandoned <org_id>` |
|
|
137
150
|
| See what the team has asked the CLI to do | `flowiq gaps list` (open, most-hit first) · `flowiq gaps list --mine` · `flowiq gaps show <number>` · in the app: Changelog → **CLI requests** |
|
|
138
151
|
| Close a request (Matt / Gidon) | `flowiq gaps status <number> --to done --fixed-in 0.9.8 --note "what shipped"` — emails everyone who reported or hit it |
|
|
139
152
|
> **Publishing the CLI (maintainers only):** publish from a clean clone, never
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// node --test src/batch-cde.test.mjs — pure helpers behind the 0.10.0 topics.
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { toCsv } from "./commands/contacts.js";
|
|
5
|
+
import { embedSnippet } from "./commands/widgets.js";
|
|
6
|
+
import { snippetAround } from "./commands/messages.js";
|
|
7
|
+
// The server modules build a Supabase client at import time; dummies keep the
|
|
8
|
+
// pure helpers testable without a key on the machine.
|
|
9
|
+
process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
|
|
10
|
+
process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
|
|
11
|
+
const { lintNumber } = await import("../../api/cli/contacts.js");
|
|
12
|
+
const { buildUpdate } = await import("../../api/cli/widgets.js");
|
|
13
|
+
const { lintParams, summariseFlow } = await import("../../api/cli/flows.js");
|
|
14
|
+
const { describeSendAt } = await import("../../api/cli/rules.js");
|
|
15
|
+
|
|
16
|
+
test("lintNumber catches the doubled country code and the kept trunk 0", () => {
|
|
17
|
+
assert.deepEqual(lintNumber("351351912345678"), { problem: "country code 351 doubled", suggested: "351912345678" });
|
|
18
|
+
assert.deepEqual(lintNumber("270821234567"), { problem: "trunk 0 kept after +27", suggested: "27821234567" });
|
|
19
|
+
assert.equal(lintNumber("27821234567"), null);
|
|
20
|
+
assert.equal(lintNumber("351912345678"), null);
|
|
21
|
+
assert.equal(lintNumber("447700900123"), null);
|
|
22
|
+
assert.equal(lintNumber("12125551234"), null);
|
|
23
|
+
assert.match(lintNumber("+27 82 123 4567").problem, /non-digit/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("toCsv quotes commas, quotes and newlines; arrays join with |", () => {
|
|
27
|
+
const csv = toCsv([{ a: 'He said "hi", ok', b: ["x", "y"], c: null }], ["a", "b", "c"]);
|
|
28
|
+
assert.equal(csv, 'a,b,c\n"He said ""hi"", ok",x|y,\n');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("embedSnippet matches the app's snippet shape", () => {
|
|
32
|
+
const s = embedSnippet("org-1", "w-1");
|
|
33
|
+
assert.match(s, /widget\/loader\.js/);
|
|
34
|
+
assert.match(s, /data-organization-id="org-1"/);
|
|
35
|
+
assert.match(s, /data-widget-id="w-1"/);
|
|
36
|
+
assert.doesNotMatch(embedSnippet("org-1", null), /data-widget-id/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("snippetAround centres the match", () => {
|
|
40
|
+
const s = snippetAround("a".repeat(200) + " flash sale " + "b".repeat(200), "flash sale", 60);
|
|
41
|
+
assert.match(s, /^…a+ flash sale b+…$/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("buildUpdate patches scalars, merges jsonb bags and removes null keys", () => {
|
|
45
|
+
const cur = { name: "A", home_copy: { greeting: "Hi", tagline: "x" }, launcher: null };
|
|
46
|
+
const { update, changes, errors } = buildUpdate(cur, { name: "B", home_copy: { tagline: null, intro: "yo" }, launcher: { size: "lg" }, organization_id: "nope" });
|
|
47
|
+
assert.deepEqual(errors.map((e) => e.split(" ")[0]), ["organization_id"]);
|
|
48
|
+
assert.equal(update.name, "B");
|
|
49
|
+
assert.deepEqual(update.home_copy, { greeting: "Hi", intro: "yo" });
|
|
50
|
+
assert.deepEqual(update.launcher, { size: "lg" });
|
|
51
|
+
assert.deepEqual(changes.map((c) => c.field), ["name", "home_copy.tagline", "home_copy.intro", "launcher.size"]);
|
|
52
|
+
assert.deepEqual(buildUpdate(cur, { name: "A" }).changes, []);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("lintParams accepts param1..N and flags anything else", () => {
|
|
56
|
+
assert.equal(lintParams({ param1: "a", param2: "b" }), null);
|
|
57
|
+
assert.match(lintParams({ b1: "a", b2: "b" }), /jsonb order/);
|
|
58
|
+
assert.match(lintParams({ param1: "a", param3: "b" }), /skip a slot/);
|
|
59
|
+
assert.equal(lintParams({}), null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("summariseFlow reads the trigger and the template steps", () => {
|
|
63
|
+
const f = summariseFlow({ id: "f", name: "n", enabled: true, configuration: { trigger: { type: "event", event: "subscription.renewed", source: "stitch" }, steps: [{ type: "action", action_type: "send_message", config: { template_name: "t_v1", body_parameters: { param1: "x" } } }] } });
|
|
64
|
+
assert.equal(f.trigger.event, "subscription.renewed");
|
|
65
|
+
assert.equal(f.steps[0].template_name, "t_v1");
|
|
66
|
+
assert.deepEqual(f.warnings, []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("describeSendAt reads delay, time, quiet hours", () => {
|
|
70
|
+
assert.equal(describeSendAt({ delay_minutes: 720, quiet_hours: { tz: "Europe/Madrid", start_hour: 10, end_hour: 20 } }), "+720min · quiet 10-20 Europe/Madrid");
|
|
71
|
+
assert.equal(describeSendAt({ delay_days: 3, send_at_time: "10:00", skip_weekends: true }), "+3d · at 10:00 · skip weekends");
|
|
72
|
+
assert.equal(describeSendAt(null), "immediately");
|
|
73
|
+
});
|
|
@@ -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/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,70 @@ 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
|
+
}
|
|
81
|
+
|
|
82
|
+
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); }
|
|
83
|
+
const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
|
|
84
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
85
|
+
|
|
86
|
+
// Show the match in context, not the whole message.
|
|
87
|
+
export function snippetAround(text, needle, width = 90) {
|
|
88
|
+
const t = String(text ?? "").replace(/\s+/g, " ");
|
|
89
|
+
const i = t.toLowerCase().indexOf(String(needle).toLowerCase());
|
|
90
|
+
if (i < 0) return t.slice(0, width);
|
|
91
|
+
const start = Math.max(0, i - Math.floor(width / 3));
|
|
92
|
+
const s = t.slice(start, start + width);
|
|
93
|
+
return `${start > 0 ? "…" : ""}${s}${start + width < t.length ? "…" : ""}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function search(orgId, textParts, opts = {}) {
|
|
97
|
+
requireOrg(orgId);
|
|
98
|
+
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
99
|
+
if (text.length < 2) { console.error("Error: give at least 2 characters to search for."); process.exit(1); }
|
|
100
|
+
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 }); }
|
|
102
|
+
catch (e) { fail("Search failed", e); }
|
|
103
|
+
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)"}`);
|
|
105
|
+
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)}`);
|
|
107
|
+
}
|
|
108
|
+
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
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function failures(orgId, opts = {}) {
|
|
112
|
+
requireOrg(orgId);
|
|
113
|
+
let resp;
|
|
114
|
+
try { resp = await http.get("messages", { organization_id: orgId, failures: "1", since: opts.since }); } catch (e) { fail("Failures read failed", e); }
|
|
115
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
116
|
+
const f = resp.failed, v = resp.vanished;
|
|
117
|
+
console.log(`${resp.organization_name}: ${f.total} failed send(s) since ${fmtSast(resp.since)} SAST${f.capped ? " (capped)" : ""}`);
|
|
118
|
+
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}`); }
|
|
119
|
+
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}`); }
|
|
120
|
+
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})`); }
|
|
121
|
+
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)" : ""}`);
|
|
122
|
+
if (v.by_media.length) for (const r of v.by_media) console.log(` ${String(r.n).padStart(6)} × ${r.key}`);
|
|
123
|
+
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" : ""}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function stats(orgId, opts = {}) {
|
|
127
|
+
requireOrg(orgId);
|
|
128
|
+
let resp;
|
|
129
|
+
try { resp = await http.get("messages", { organization_id: orgId, stats: "1", since: opts.since, until: opts.until }); } catch (e) { fail("Stats failed", e); }
|
|
130
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
131
|
+
console.log(`${resp.organization_name}: WhatsApp volume by month (SAST), ${fmtSast(resp.since).slice(0, 10)} to ${fmtSast(resp.until).slice(0, 10)}`);
|
|
132
|
+
console.log("");
|
|
133
|
+
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`);
|
|
134
|
+
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}`);
|
|
135
|
+
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
136
|
}
|
package/src/commands/org.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// are stripped server-side; this only prints derived booleans + public urls.
|
|
4
4
|
|
|
5
5
|
import { http } from "../http.js";
|
|
6
|
+
import { fmtSast } from "./audit.js";
|
|
6
7
|
|
|
7
8
|
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
9
|
|
|
@@ -97,3 +98,138 @@ export async function create(opts = {}) {
|
|
|
97
98
|
console.log(` flowiq agent create ${resp.id} --name "<AgentName>" --make-active`);
|
|
98
99
|
console.log(` flowiq prompts pull ${resp.id} # then edit + push`);
|
|
99
100
|
}
|
|
101
|
+
|
|
102
|
+
// ── views (1 Oct 2026): channels · whatsapp · health · automations · abandoned ──
|
|
103
|
+
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); }
|
|
104
|
+
const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
|
|
105
|
+
const yn = (v) => (v ? "yes" : "no");
|
|
106
|
+
const when = (v) => (v ? `${fmtSast(v)} SAST` : "—");
|
|
107
|
+
|
|
108
|
+
async function view(orgId, name) {
|
|
109
|
+
requireOrg(orgId);
|
|
110
|
+
try { return await http.get("org", { organization_id: orgId, view: name }); } catch (e) { fail(`${name} read failed`, e); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function channels(orgId, opts = {}) {
|
|
114
|
+
if (opts.all) return channelsCensus(opts);
|
|
115
|
+
const resp = await view(orgId, "channels");
|
|
116
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
117
|
+
const c = resp.channels;
|
|
118
|
+
console.log(`${resp.organization_name} — channels`);
|
|
119
|
+
const w = c.whatsapp;
|
|
120
|
+
console.log(` whatsapp ${w.connected ? "connected" : "NOT connected"} · provider ${w.provider}${w.phone_number ? ` · ${w.phone_number}` : ""}${w.phone_id ? ` (phone ${w.phone_id})` : ""}${w.connection_type ? ` · ${w.connection_type}` : ""}`);
|
|
121
|
+
console.log(` master switch ${w.master_switch ? "ON" : "OFF"} · vert.active ${w.vert_active ? "yes" : "no"}${w.allowed_whatsapp_ids ? ` (only ${w.allowed_whatsapp_ids})` : ""} · connected ${when(w.connected_at)} · registered ${when(w.last_registered_at)}`);
|
|
122
|
+
const m = c.messenger;
|
|
123
|
+
console.log(` messenger ${m.connected ? "connected" : "not connected"} · via ${m.provider}${m.page_name ? ` · ${m.page_name} (${m.page_id})` : m.page_id ? ` · page ${m.page_id}` : ""} · agent ${yn(m.use_agent)}${m.connected_at ? ` · since ${when(m.connected_at)}` : ""}${m.token_expires_at ? ` · token expires ${when(m.token_expires_at)}` : ""}${m.disconnected_at ? ` · disconnected ${when(m.disconnected_at)} (${m.disconnect_reason ?? "?"})` : ""}`);
|
|
124
|
+
const i = c.instagram;
|
|
125
|
+
console.log(` instagram ${i.connected ? "connected" : "not connected"} · via ${i.provider}${i.username ? ` · @${i.username}` : ""}${i.account_id ? ` (${i.account_id})` : ""}${i.login_method ? ` · ${i.login_method}` : ""} · agent ${yn(i.use_agent)}${i.connected_at ? ` · since ${when(i.connected_at)}` : ""}${i.token_expires_at ? ` · token expires ${when(i.token_expires_at)}` : ""}${i.disconnected_at ? ` · disconnected ${when(i.disconnected_at)} (${i.disconnect_reason ?? "?"})` : ""}`);
|
|
126
|
+
const t = c.tiktok;
|
|
127
|
+
console.log(` tiktok ${t.connected ? "connected" : "not connected"}${t.username ? ` · @${t.username}` : ""} · agent ${yn(t.use_agent)}${t.connected_at ? ` · since ${when(t.connected_at)}` : ""}${t.disconnected_at ? ` · disconnected ${when(t.disconnected_at)} (${t.disconnect_reason ?? "?"})` : ""}`);
|
|
128
|
+
const e = c.email;
|
|
129
|
+
console.log(` email ${e.provider ? `${e.provider}, ${e.connected ? "connected" : "not connected"}` : "no provider"}${e.mailbox ? ` · ${e.mailbox}` : ""} · agent ${yn(e.use_agent)}${e.connected_at ? ` · since ${when(e.connected_at)}` : ""}`);
|
|
130
|
+
console.log(` meta ads ${c.meta_ads.connected ? `connected${c.meta_ads.account_id ? ` · ${c.meta_ads.account_id}` : ""}` : "not connected"}`);
|
|
131
|
+
if (c.zernio_profile_id) console.log(` zernio profile ${c.zernio_profile_id}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function channelsCensus(opts) {
|
|
135
|
+
let resp;
|
|
136
|
+
try { resp = await http.get("org", { census: "channels", all: opts.inactive ? "1" : undefined }); } catch (e) { fail("Census failed", e); }
|
|
137
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
138
|
+
console.log(`${resp.count} org(s): whatsapp ${Object.entries(resp.providers).map(([k, v]) => `${k} ${v}`).join(", ")} · messenger ${resp.messenger_connected} · instagram ${resp.instagram_connected} · tiktok ${resp.tiktok_connected} · email ${resp.email_connected} · meta ads ${resp.meta_ads_connected}`);
|
|
139
|
+
console.log("");
|
|
140
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
141
|
+
console.log(` ${pad("ORG", 30)} ${pad("WHATSAPP", 12)} ${pad("SWITCH", 7)} ${pad("MESSENGER", 10)} ${pad("INSTAGRAM", 10)} ${pad("TIKTOK", 7)} ${pad("EMAIL", 8)} ADS`);
|
|
142
|
+
for (const o of resp.orgs) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(`${o.whatsapp}${o.whatsapp_connected ? "" : " (off)"}`, 12)} ${pad(o.master_switch ? "on" : "OFF", 7)} ${pad(o.messenger ?? "—", 10)} ${pad(o.instagram ?? "—", 10)} ${pad(o.tiktok ? "yes" : "—", 7)} ${pad(o.email ?? "—", 8)} ${o.meta_ads ? "yes" : "—"}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function whatsapp(orgId, opts = {}) {
|
|
146
|
+
if (opts.all) {
|
|
147
|
+
let resp;
|
|
148
|
+
try { resp = await http.get("org", { census: "whatsapp", all: opts.inactive ? "1" : undefined }); } catch (e) { fail("Census failed", e); }
|
|
149
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
150
|
+
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
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
152
|
+
console.log("");
|
|
153
|
+
console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("TIER", 12)} PROBLEMS`);
|
|
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 ?? "?", 12)} ${(o.problems || []).join("; ")}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const resp = await view(orgId, "whatsapp");
|
|
158
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
159
|
+
const w = resp.whatsapp;
|
|
160
|
+
console.log(`${resp.organization_name} — WhatsApp number health (from Meta)`);
|
|
161
|
+
if (w.note && !w.phone) { console.log(` ${w.note}`); return; }
|
|
162
|
+
const p = w.phone || {};
|
|
163
|
+
console.log(` number: ${p.display_phone_number ?? "?"} · "${p.verified_name ?? "?"}" · phone ${w.phone_id} · WABA ${w.waba_id ?? "?"}`);
|
|
164
|
+
console.log(` status: ${p.status ?? "?"} · platform ${p.platform_type ?? "?"} · account mode ${p.account_mode ?? "?"}`);
|
|
165
|
+
console.log(` quality: ${p.quality_rating ?? "?"} · limit tier ${p.messaging_limit_tier ?? "?"}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
|
|
166
|
+
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
|
+
console.log(` webhooks: ${Array.isArray(w.subscribed_apps) ? (w.subscribed_apps.length ? w.subscribed_apps.join(", ") : "NO app subscribed") : w.subscribed_apps ?? "?"}`);
|
|
168
|
+
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("; ")}`}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function health(orgId, opts = {}) {
|
|
173
|
+
const resp = await view(orgId, "health");
|
|
174
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
175
|
+
const h = resp.health;
|
|
176
|
+
console.log(`${resp.organization_name} — is the agent ready? ${h.ready ? "✓ yes" : `✗ ${h.problems.length} problem(s)`}`);
|
|
177
|
+
for (const p of h.problems) console.log(` ✗ ${p}`);
|
|
178
|
+
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
|
+
console.log(` keys: OpenAI ${h.keys.openai.state}${h.keys.openai.detail ? ` (${h.keys.openai.detail})` : ""} · Claude ${h.keys.anthropic_present ? "present" : "none"}`);
|
|
180
|
+
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
|
+
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
|
+
const s = h.store;
|
|
183
|
+
console.log(` store: ${s.platform ?? "none"}${s.shopify_domain ? ` ${s.shopify_domain}` : ""}${s.flowiq_webhooks != null ? ` · ${s.flowiq_webhooks} FlowIQ webhooks` : ""}${s.webhooks_note ? ` · ${s.webhooks_note}` : ""}`);
|
|
184
|
+
if (s.newest_synced_order || s.store_newest_order) console.log(` newest synced order ${s.newest_synced_order ? `#${s.newest_synced_order.order_number} ${when(s.newest_synced_order.created_at)}` : "none"} · store newest ${s.store_newest_order && !s.store_newest_order.error ? `${s.store_newest_order.name} ${when(s.store_newest_order.created_at)}` : s.store_newest_order?.error ? `read failed (${s.store_newest_order.error})` : "—"}`);
|
|
185
|
+
console.log(` traffic: ${h.traffic.contacts} contacts (${h.traffic.bot_on} with AI on) · inbound 24h ${h.traffic.inbound_24h} · 7d ${h.traffic.inbound_7d} · last bot reply ${when(h.traffic.last_bot_reply_at)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function automations(orgId, opts = {}) {
|
|
189
|
+
requireOrg(orgId);
|
|
190
|
+
if (opts.enable || opts.disable) {
|
|
191
|
+
const type = opts.enable || opts.disable;
|
|
192
|
+
let resp;
|
|
193
|
+
try { resp = await http.post("org", { action: "automations_set", organization_id: orgId, type: type === true ? undefined : type, enabled: !!opts.enable, dry_run: !opts.commit }); }
|
|
194
|
+
catch (e) { fail("Set failed", e); }
|
|
195
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
196
|
+
if (resp.unchanged) { console.log(`${resp.field} is already ${resp.value}.`); return; }
|
|
197
|
+
console.log(`${resp.dry_run ? "Would set" : "✓ set"} ${resp.field}: ${resp.from} → ${resp.to} on ${resp.organization_name}${resp.dry_run ? "\nDRY RUN — add --commit to write." : ""}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const resp = await view(orgId, "automations");
|
|
201
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
202
|
+
const a = resp.automations;
|
|
203
|
+
if (!a.configured) { console.log(`${resp.organization_name} has no automated_messages_config.`); return; }
|
|
204
|
+
console.log(`${resp.organization_name} — automated order messages: ${a.enabled ? "ON" : "OFF"} (${a.types.filter((t) => t.enabled).length} of ${a.types.length} types on)`);
|
|
205
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
206
|
+
for (const t of a.types) {
|
|
207
|
+
console.log(` ${t.enabled ? "ON " : "off"} ${pad(t.type, 26)} ${pad(t.template_name ?? "(no template)", 34)} ${pad(t.template_state ?? "", 14)} ${t.trigger_event ?? ""}${t.body_mapping ? ` · body ${JSON.stringify(t.body_mapping)}` : ""}`);
|
|
208
|
+
if (t.warning) console.log(` ⚠ ${t.warning}`);
|
|
209
|
+
}
|
|
210
|
+
console.log(`\nflowiq org automations ${orgId} --enable <type> | --disable <type> [--commit] (the whole feature: --enable/--disable with no type)`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function abandoned(orgId, opts = {}) {
|
|
214
|
+
requireOrg(orgId);
|
|
215
|
+
if (opts.enable !== undefined || opts.disable !== undefined) {
|
|
216
|
+
const seq = opts.enable ?? opts.disable;
|
|
217
|
+
let resp;
|
|
218
|
+
try { resp = await http.post("org", { action: "abandoned_set", organization_id: orgId, sequence: seq === true ? undefined : Number(seq), enabled: opts.enable !== undefined, dry_run: !opts.commit }); }
|
|
219
|
+
catch (e) { fail("Set failed", e); }
|
|
220
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
221
|
+
if (resp.unchanged) { console.log(`${resp.field} is already ${resp.value}.`); return; }
|
|
222
|
+
console.log(`${resp.dry_run ? "Would set" : "✓ set"} ${resp.field}: ${resp.from} → ${resp.to} on ${resp.organization_name}${resp.dry_run ? "\nDRY RUN — add --commit to write." : ""}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const resp = await view(orgId, "abandoned");
|
|
226
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
227
|
+
const a = resp.abandoned;
|
|
228
|
+
if (!a.configured) { console.log(`${resp.organization_name} has no abandoned_checkout_config (no recovery messages).`); return; }
|
|
229
|
+
console.log(`${resp.organization_name} — abandoned-cart recovery: ${a.enabled ? "ON" : "OFF"}${a.use_utm != null ? ` · utm ${yn(a.use_utm)}` : ""}`);
|
|
230
|
+
for (const s of a.sequences) {
|
|
231
|
+
console.log(` ${s.enabled ? "ON " : "off"} sequence ${s.sequence}: ${s.template_name ?? "(no template)"} (${s.template_state ?? "?"}) · after ${s.delay_minutes} min${s.cart_filter ? ` · ${s.cart_filter}` : ""}${s.include_discount ? ` · discount ${s.discount_code ?? (s.auto_create_discount ? "auto" : "?")}` : ""}${s.include_image ? " · image" : ""}${s.link_mode ? ` · ${s.link_mode}` : ""}`);
|
|
232
|
+
console.log(` last 30 days: ${s.last_30_days.sent} sent, ${s.last_30_days.not_sent} not sent`);
|
|
233
|
+
}
|
|
234
|
+
console.log(`\nflowiq org abandoned ${orgId} --enable <n> | --disable <n> [--commit] (the whole feature: --enable/--disable with no number)`);
|
|
235
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `flowiq rules list|show|results|enable|disable` — the Automations rules
|
|
2
|
+
// engine, custom_notification_rules (server: api/cli/rules.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
|
+
|
|
11
|
+
export async function list(orgId, opts = {}) {
|
|
12
|
+
if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
13
|
+
let resp;
|
|
14
|
+
try { resp = await http.get("rules", { organization_id: orgId, trigger: opts.trigger }); } catch (e) { fail("List failed", e); }
|
|
15
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
16
|
+
if (!resp.count) { console.log(`${resp.organization_name} has no automation rules${opts.trigger ? ` with trigger ${opts.trigger}` : ""}.`); return; }
|
|
17
|
+
console.log(`${resp.organization_name}: ${resp.count} rule(s), ${resp.enabled} enabled`);
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log(` ${pad("", 3)} ${pad("NAME", 32)} ${pad("TRIGGER", 34)} ${pad("SENDS", 30)} WHEN`);
|
|
20
|
+
for (const r of resp.rules) {
|
|
21
|
+
console.log(` ${r.enabled ? "ON " : "off"} ${pad(r.name, 32)} ${pad(`${r.trigger_type}=${r.trigger_value ?? ""}`, 34)} ${pad(r.template_name ?? r.action_type, 30)} ${r.send_at}${r.conditions.length ? ` · conditions: ${r.conditions.join(", ")}` : ""}`);
|
|
22
|
+
console.log(` ${r.id}`);
|
|
23
|
+
}
|
|
24
|
+
console.log(`\nflowiq rules show <rule_id> · rules results <rule_id> --since 30d`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function show(ruleId, opts = {}) {
|
|
28
|
+
if (!UUID_RE.test(ruleId || "")) { console.error(`Error: "${ruleId}" is not a valid rule UUID.`); process.exit(1); }
|
|
29
|
+
let resp;
|
|
30
|
+
try { resp = await http.get("rules", { rule_id: ruleId }); } catch (e) { fail("Read failed", e); }
|
|
31
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
32
|
+
const r = resp.rule;
|
|
33
|
+
console.log(`${r.name} ${r.id} on ${resp.organization_name}: ${r.enabled ? "ENABLED" : "disabled"}`);
|
|
34
|
+
console.log(` trigger: ${r.trigger_type} = ${r.trigger_value ?? "(any)"}`);
|
|
35
|
+
console.log(` action: ${r.action_type}${r.template_name ? ` · template ${r.template_name}` : ""}`);
|
|
36
|
+
console.log(` send at: ${r.send_at}`);
|
|
37
|
+
if (r.body_parameters) console.log(` body: ${JSON.stringify(r.body_parameters)}`);
|
|
38
|
+
if (r.button_parameters) console.log(` buttons: ${JSON.stringify(r.button_parameters)}`);
|
|
39
|
+
if (r.conditions_raw) console.log(` conditions: ${JSON.stringify(r.conditions_raw)}`);
|
|
40
|
+
if (r.action_config) console.log(` config: ${JSON.stringify(r.action_config)}`);
|
|
41
|
+
console.log(` priority: ${r.priority} · created ${fmtSast(r.created_at)} SAST`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function results(ruleId, opts = {}) {
|
|
45
|
+
if (!UUID_RE.test(ruleId || "")) { console.error(`Error: "${ruleId}" is not a valid rule UUID.`); process.exit(1); }
|
|
46
|
+
let resp;
|
|
47
|
+
try { resp = await http.get("rules", { rule_id: ruleId, results: "1", since: opts.since }); } catch (e) { fail("Results failed", e); }
|
|
48
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
49
|
+
console.log(`${resp.rule.name} (${resp.organization_name}): ${resp.queued} queued send(s) since ${fmtSast(resp.since)} SAST${resp.capped ? " (capped)" : ""}`);
|
|
50
|
+
console.log(` by status: ${Object.entries(resp.by_status).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"}`);
|
|
51
|
+
for (const e of resp.error_rollup) console.log(` ${String(e.n).padStart(5)} × ${e.message}`);
|
|
52
|
+
if (resp.recent.length) {
|
|
53
|
+
console.log("\n recent:");
|
|
54
|
+
for (const q of resp.recent) console.log(` ${fmtSast(q.scheduled_for)} ${pad(q.status, 10)} ${q.template ?? ""} → ${(q.contact_ids || []).join(",")}${q.error_message ? ` ${String(q.error_message).slice(0, 80)}` : ""}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function toggle(ruleId, opts, action) {
|
|
59
|
+
if (!UUID_RE.test(ruleId || "")) { console.error(`Error: "${ruleId}" is not a valid rule UUID.`); process.exit(1); }
|
|
60
|
+
let resp;
|
|
61
|
+
try { resp = await http.post("rules", { action, rule_id: ruleId, confirm: !!opts.confirm }); } catch (e) { fail(`${action} failed`, e); }
|
|
62
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
63
|
+
if (resp.unchanged) { console.log(`Rule is already ${resp.enabled ? "enabled" : "disabled"}.`); return; }
|
|
64
|
+
console.log(`✓ "${resp.name}" ${resp.enabled ? "ENABLED" : "disabled"} (was ${resp.was ? "enabled" : "disabled"}).`);
|
|
65
|
+
}
|
|
66
|
+
export const enable = (id, opts) => toggle(id, opts, "enable");
|
|
67
|
+
export const disable = (id, opts) => toggle(id, opts, "disable");
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `flowiq tickets list|show|status` — escalation tickets (server: api/cli/tickets.js).
|
|
2
|
+
|
|
3
|
+
import { http } from "../http.js";
|
|
4
|
+
import { fmtSast } from "./audit.js";
|
|
5
|
+
|
|
6
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
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
|
+
|
|
10
|
+
export function parseOlderThan(v) {
|
|
11
|
+
if (v == null || v === "") return undefined;
|
|
12
|
+
const m = /^(\d+)([hdw])$/i.exec(String(v).trim());
|
|
13
|
+
if (!m) { console.error(`Error: --older-than takes 48h / 3d / 1w (got "${v}").`); process.exit(1); }
|
|
14
|
+
return Number(m[1]) * { h: 1, d: 24, w: 168 }[m[2].toLowerCase()];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function list(orgId, opts = {}) {
|
|
18
|
+
if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
19
|
+
let resp;
|
|
20
|
+
try { resp = await http.get("tickets", { organization_id: orgId, status: opts.status, contact_id: opts.contact, older_than_hours: parseOlderThan(opts.olderThan), limit: opts.limit }); }
|
|
21
|
+
catch (e) { fail("List failed", e); }
|
|
22
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
23
|
+
if (!resp.tickets.length) { console.log(`No ${resp.statuses.join("/")} ticket(s) on ${resp.organization_name}${opts.olderThan ? ` older than ${opts.olderThan}` : ""}.`); return; }
|
|
24
|
+
console.log(`${resp.organization_name}: ${resp.capped ? `${resp.tickets.length} of ${resp.total}` : resp.tickets.length} ${resp.statuses.join("/")} ticket(s), newest first`);
|
|
25
|
+
console.log("");
|
|
26
|
+
console.log(` ${pad("OPENED (SAST)", 17)} ${pad("AGE", 6)} ${pad("STATUS", 12)} ${pad("CONTACT", 26)} ${pad("TEAM", 14)} SUMMARY`);
|
|
27
|
+
for (const t of resp.tickets) {
|
|
28
|
+
const who = `${t.contact?.full_name ?? "?"}${t.contact?.whatsapp_id ? ` ${t.contact.whatsapp_id}` : ""}`.slice(0, 26);
|
|
29
|
+
console.log(` ${pad(fmtSast(t.created_at), 17)} ${pad(`${t.age_hours}h`, 6)} ${pad(t.status, 12)} ${pad(who, 26)} ${pad(t.team ?? "", 14)} ${String(t.summary ?? "").replace(/\s+/g, " ").slice(0, 70)}`);
|
|
30
|
+
console.log(` ${" ".repeat(17)} ${t.id}${t.contact?.bot_status === false ? " AI off on contact" : ""}${t.reopened_count ? ` reopened ${t.reopened_count}x` : ""}`);
|
|
31
|
+
}
|
|
32
|
+
if (resp.capped) console.log(`\n ${resp.total - resp.tickets.length} more; narrow with --contact / --older-than or raise --limit (max 500).`);
|
|
33
|
+
console.log(`\nflowiq tickets show <ticket_id> · flowiq tickets status <ticket_id> --set resolved --commit`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function show(ticketId, opts = {}) {
|
|
37
|
+
if (!UUID_RE.test(ticketId || "")) { console.error(`Error: "${ticketId}" is not a valid ticket UUID.`); process.exit(1); }
|
|
38
|
+
let resp;
|
|
39
|
+
try { resp = await http.get("tickets", { ticket_id: ticketId }); } catch (e) { fail("Read failed", e); }
|
|
40
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
41
|
+
const t = resp.ticket;
|
|
42
|
+
console.log(`Ticket ${t.id} on ${resp.organization_name}: ${t.status}${t.type ? ` (${t.type})` : ""}`);
|
|
43
|
+
console.log(` contact: ${t.contact?.full_name ?? "?"} ${t.contact?.whatsapp_id ?? ""} (${t.contact_id})${t.contact?.bot_status === false ? " · AI off" : ""}`);
|
|
44
|
+
console.log(` opened: ${fmtSast(t.created_at)} SAST (${t.age_hours}h ago)${t.team ? ` · team ${t.team}` : ""}`);
|
|
45
|
+
console.log(` summary: ${t.summary ?? "(none)"}`);
|
|
46
|
+
if (t.resolved_at) console.log(` resolved: ${fmtSast(t.resolved_at)} SAST by ${t.resolved_by ?? "?"}`);
|
|
47
|
+
if (t.csat_sent_at) console.log(` csat sent: ${fmtSast(t.csat_sent_at)} SAST`);
|
|
48
|
+
if (t.reopened_count) console.log(` reopened: ${t.reopened_count}x (${t.data?.reopen_reason ?? ""})`);
|
|
49
|
+
if (Array.isArray(t.data?.status_history) && t.data.status_history.length) {
|
|
50
|
+
console.log(" history:");
|
|
51
|
+
for (const h of t.data.status_history.slice(-10)) console.log(` ${fmtSast(h.at ?? h.timestamp ?? h.changed_at)} ${h.from ?? "?"} → ${h.to ?? h.status ?? "?"} by ${h.by ?? "?"}${h.note ? ` (${h.note})` : ""}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function status(ticketId, opts = {}) {
|
|
56
|
+
if (!UUID_RE.test(ticketId || "")) { console.error(`Error: "${ticketId}" is not a valid ticket UUID.`); process.exit(1); }
|
|
57
|
+
if (!opts.set) { console.error("Error: --set open|in_progress|resolved|closed is required."); process.exit(1); }
|
|
58
|
+
let resp;
|
|
59
|
+
try { resp = await http.post("tickets", { action: "status", ticket_id: ticketId, status: opts.set, note: opts.note, dry_run: !opts.commit }); }
|
|
60
|
+
catch (e) { fail("Status change failed", e); }
|
|
61
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
62
|
+
if (resp.unchanged) { console.log(`Ticket is already ${opts.set}.`); return; }
|
|
63
|
+
if (resp.dry_run) {
|
|
64
|
+
console.log(`Ticket ${ticketId} (${resp.ticket.contact?.full_name ?? resp.ticket.contact_id}, ${resp.organization_name}): ${resp.ticket.status} → ${resp.would_set.status}${resp.would_reenable_bot ? "\n the org re-enables AI on resolve: the contact's agent would be switched back on" : ""}`);
|
|
65
|
+
console.log("DRY RUN — add --commit to write.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log(`✓ ticket ${ticketId}: ${resp.previous_status} → ${resp.ticket.status}${resp.bot_reenabled ? " · contact's AI re-enabled" : ""}`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// `flowiq widgets list|show|pull|push|embed|activate` — the web chat widgets
|
|
2
|
+
// (server: api/cli/widgets.js). A push is an id-keyed PATCH, never a full
|
|
3
|
+
// replace: these render on live storefronts.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { http } from "../http.js";
|
|
8
|
+
import { fmtSast } from "./audit.js";
|
|
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;
|
|
11
|
+
const WIDGET_DIR = path.resolve(process.cwd(), ".flowiq", "widgets");
|
|
12
|
+
export const WIDGET_EMBED_HOST = "https://widget.flowapt.com";
|
|
13
|
+
// The bags a pull writes into the file; a push sends back only what differs.
|
|
14
|
+
const PULL_META = ["id", "organization_id", "created_by", "created_at"];
|
|
15
|
+
|
|
16
|
+
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); }
|
|
17
|
+
const requireUuid = (v, what) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid ${what} UUID.`); process.exit(1); } };
|
|
18
|
+
|
|
19
|
+
export function embedSnippet(orgId, widgetId) {
|
|
20
|
+
return `<!-- FlowIQ Chat Widget -->\n<script\n src="${WIDGET_EMBED_HOST}/widget/loader.js"\n data-organization-id="${orgId}"${widgetId ? `\n data-widget-id="${widgetId}"` : ""}\n></script>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function list(orgId, opts = {}) {
|
|
24
|
+
requireUuid(orgId, "organization");
|
|
25
|
+
let resp;
|
|
26
|
+
try { resp = await http.get("widgets", { organization_id: orgId }); } catch (e) { fail("List failed", e); }
|
|
27
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
28
|
+
if (!resp.count) { console.log(`${resp.organization_name} has no web chat widget.`); return; }
|
|
29
|
+
console.log(`${resp.organization_name}: ${resp.count} widget(s)`);
|
|
30
|
+
for (const w of resp.widgets) {
|
|
31
|
+
console.log(` ${w.id} ${w.name ?? "(unnamed)"}${w.is_active ? " [active: the org-only embed resolves to this one]" : ""}`);
|
|
32
|
+
console.log(` agent ${w.agent_name ?? w.agent_id ?? "(org active agent)"}${w.agent_display_name ? ` shown as "${w.agent_display_name}"` : ""} · ${w.widget_position ?? "bottom-right"} · header "${w.header_text ?? ""}"${w.checkout_storefront ? " · checkout storefront" : ""} · created ${fmtSast(w.created_at)}`);
|
|
33
|
+
}
|
|
34
|
+
console.log(`\nflowiq widgets pull <widget_id> · widgets embed ${orgId} [<widget_id>]`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchWidget(widgetId) {
|
|
38
|
+
requireUuid(widgetId, "widget");
|
|
39
|
+
try { return await http.get("widgets", { widget_id: widgetId }); } catch (e) { fail("Widget read failed", e); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function show(widgetId, opts = {}) {
|
|
43
|
+
const resp = await fetchWidget(widgetId);
|
|
44
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
45
|
+
const w = resp.widget;
|
|
46
|
+
console.log(`${w.name ?? "(unnamed)"} ${w.id} on ${resp.organization_name}${w.is_active ? " [active]" : ""}`);
|
|
47
|
+
const scalar = Object.entries(w).filter(([k, v]) => !PULL_META.includes(k) && (v === null || typeof v !== "object"));
|
|
48
|
+
for (const [k, v] of scalar) console.log(` ${k.padEnd(26)} ${v === null ? "(unset)" : String(v).replace(/\s+/g, " ").slice(0, 140)}`);
|
|
49
|
+
const bags = Object.entries(w).filter(([k, v]) => v !== null && typeof v === "object");
|
|
50
|
+
for (const [k, v] of bags) console.log(` ${k.padEnd(26)} ${JSON.stringify(v).slice(0, 200)}${JSON.stringify(v).length > 200 ? "…" : ""}`);
|
|
51
|
+
console.log(`\nFull config: flowiq widgets pull ${w.id}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function pull(widgetId, opts = {}) {
|
|
55
|
+
const resp = await fetchWidget(widgetId);
|
|
56
|
+
const w = resp.widget;
|
|
57
|
+
const slug = String(resp.organization_name || w.organization_id).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
58
|
+
const file = opts.out ? path.resolve(opts.out) : path.join(WIDGET_DIR, `${slug}-${w.id.slice(0, 8)}.json`);
|
|
59
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
60
|
+
await fs.writeFile(file, JSON.stringify({ organization_name: resp.organization_name, widget: w }, null, 2) + "\n", "utf8");
|
|
61
|
+
console.log(`Wrote ${file}\n edit the fields under "widget", then: flowiq widgets push ${file} (dry run) → --commit`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function push(file, opts = {}) {
|
|
65
|
+
let data;
|
|
66
|
+
try { data = JSON.parse(await fs.readFile(path.resolve(file), "utf8")); } catch (e) { console.error(`Cannot read ${file}: ${e.message}`); process.exit(1); }
|
|
67
|
+
const w = data.widget || data;
|
|
68
|
+
requireUuid(w.id, "widget");
|
|
69
|
+
const patch = {};
|
|
70
|
+
for (const [k, v] of Object.entries(w)) if (!PULL_META.includes(k)) patch[k] = v;
|
|
71
|
+
let resp;
|
|
72
|
+
try { resp = await http.post("widgets", { action: "push", widget_id: w.id, patch, dry_run: !opts.commit }); } catch (e) { fail("Push failed", e); }
|
|
73
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
74
|
+
if (resp.unchanged) { console.log("Nothing differs from the live widget."); return; }
|
|
75
|
+
console.log(`${resp.dry_run ? "Would change" : "Changed"} ${resp.changes.length} field(s) on widget ${resp.widget_id} (${resp.organization_name}):`);
|
|
76
|
+
for (const c of resp.changes) console.log(` ${c.field.padEnd(30)} ${JSON.stringify(c.before)?.slice(0, 60) ?? "(unset)"} → ${JSON.stringify(c.after)?.slice(0, 60) ?? "(unset)"}`);
|
|
77
|
+
if (resp.dry_run) console.log("\nDRY RUN — nothing written. Add --commit to apply (the storefront picks it up on the next widget load).");
|
|
78
|
+
else console.log("\n✓ live. Nested keys merge; a key set to null was removed.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function embed(orgId, widgetId, opts = {}) {
|
|
82
|
+
requireUuid(orgId, "organization");
|
|
83
|
+
if (widgetId) requireUuid(widgetId, "widget");
|
|
84
|
+
const snippet = embedSnippet(orgId, widgetId || null);
|
|
85
|
+
if (opts.json) { console.log(JSON.stringify({ organization_id: orgId, widget_id: widgetId || null, snippet })); return; }
|
|
86
|
+
console.log(snippet);
|
|
87
|
+
if (!widgetId) console.error("\n(org-only embed: resolves to the org's single widget, else the one marked active; pass the widget id to pin one)");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function activate(widgetId, opts = {}) {
|
|
91
|
+
requireUuid(widgetId, "widget");
|
|
92
|
+
let resp;
|
|
93
|
+
try { resp = await http.post("widgets", { action: "activate", widget_id: widgetId }); } catch (e) { fail("Activate failed", e); }
|
|
94
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
95
|
+
if (resp.unchanged) { console.log("Already the active widget."); return; }
|
|
96
|
+
console.log(`✓ ${widgetId} is now the active widget${resp.previously_active?.length ? ` (was: ${resp.previously_active.map((p) => p.name ?? p.id).join(", ")})` : ""}.`);
|
|
97
|
+
}
|
package/src/index.js
CHANGED
|
@@ -28,6 +28,11 @@ import * as agentsCmd from "./commands/agents.js";
|
|
|
28
28
|
import * as agentUpdatesCmd from "./commands/agent-updates.js";
|
|
29
29
|
import * as plansCmd from "./commands/plans.js";
|
|
30
30
|
import * as gapsCmd from "./commands/gaps.js";
|
|
31
|
+
import * as contactsCmd from "./commands/contacts.js";
|
|
32
|
+
import * as widgetsCmd from "./commands/widgets.js";
|
|
33
|
+
import * as ticketsCmd from "./commands/tickets.js";
|
|
34
|
+
import * as flowsCmd from "./commands/flows.js";
|
|
35
|
+
import * as rulesCmd from "./commands/rules.js";
|
|
31
36
|
import * as exportCmd from "./commands/export.js";
|
|
32
37
|
import * as testCmd from "./commands/agent-test.js";
|
|
33
38
|
import * as knowledgeCmd from "./commands/knowledge.js";
|
|
@@ -133,6 +138,26 @@ export function run(argv) {
|
|
|
133
138
|
.description("Pull last N user-message windows of conversation for a contact (default N=25, max 200)")
|
|
134
139
|
.option("--count <n>", "number of most-recent user-* messages to anchor on", "25")
|
|
135
140
|
.action((contactId, opts) => messagesCmd.pull(contactId, opts));
|
|
141
|
+
msgs.command("search <organization_id> <text...>")
|
|
142
|
+
.description("Org-wide text search: did the agent (or anyone) say X to anyone? Template sends excluded unless asked")
|
|
143
|
+
.option("--since <when>", "24h (default) | 7d | 2w | YYYY-MM-DD")
|
|
144
|
+
.option("--until <when>", "upper bound")
|
|
145
|
+
.option("--sender-type <list>", "comma list, e.g. bot-whatsapp or user-whatsapp,user-web (default: conversation rows only)")
|
|
146
|
+
.option("--include-templates", "also match template / broadcast payloads")
|
|
147
|
+
.option("--limit <n>", "max rows (default 200, max 1000)")
|
|
148
|
+
.option("--json", "raw JSON (rows carry contact_id)")
|
|
149
|
+
.action((orgId, text, opts) => messagesCmd.search(orgId, text, opts));
|
|
150
|
+
msgs.command("failures <organization_id>")
|
|
151
|
+
.description("What failed to send and why (Meta error codes by reason / media / contact), plus sends Meta accepted and never confirmed")
|
|
152
|
+
.option("--since <when>", "7d (default) | 24h | 30d | YYYY-MM-DD")
|
|
153
|
+
.option("--json", "raw JSON")
|
|
154
|
+
.action((orgId, opts) => messagesCmd.failures(orgId, opts));
|
|
155
|
+
msgs.command("stats <organization_id>")
|
|
156
|
+
.description("Monthly WhatsApp volume: inbound, outbound, free-form (in-window) vs template, bot vs human")
|
|
157
|
+
.option("--since <when>", "6m (default) | 30d | YYYY-MM-DD")
|
|
158
|
+
.option("--until <when>", "upper bound (default now)")
|
|
159
|
+
.option("--json", "raw JSON")
|
|
160
|
+
.action((orgId, opts) => messagesCmd.stats(orgId, opts));
|
|
136
161
|
|
|
137
162
|
// fine-tuning (agents.fine_tuning text + agents.fine_tuning_entries jsonb)
|
|
138
163
|
const ft = program.command("fine-tuning")
|
|
@@ -418,6 +443,36 @@ export function run(argv) {
|
|
|
418
443
|
.description("Show the org's platform, storefront url + active agent (raw creds stripped)")
|
|
419
444
|
.option("--json", "print the raw JSON payload")
|
|
420
445
|
.action((orgId, opts) => orgCmd.info(orgId, opts));
|
|
446
|
+
org.command("channels [organization_id]")
|
|
447
|
+
.description("Channel connections: WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads; --all = every org")
|
|
448
|
+
.option("--all", "census across every active org (no organization_id needed)")
|
|
449
|
+
.option("--inactive", "with --all: include inactive orgs")
|
|
450
|
+
.option("--json", "raw JSON")
|
|
451
|
+
.action((orgId, opts) => orgCmd.channels(orgId, opts));
|
|
452
|
+
org.command("whatsapp [organization_id]")
|
|
453
|
+
.description("WhatsApp number health straight from Meta: status, quality, limit tier, name status, webhook subscription; --all = every Meta org")
|
|
454
|
+
.option("--all", "census across every active Meta org")
|
|
455
|
+
.option("--inactive", "with --all: include inactive orgs")
|
|
456
|
+
.option("--json", "raw JSON")
|
|
457
|
+
.action((orgId, opts) => orgCmd.whatsapp(orgId, opts));
|
|
458
|
+
org.command("health <organization_id>")
|
|
459
|
+
.description("Is this client's agent ready? Master switch, keys (probed), agent, catalogue + embeddings, store webhooks + order sync, traffic")
|
|
460
|
+
.option("--json", "raw JSON")
|
|
461
|
+
.action((orgId, opts) => orgCmd.health(orgId, opts));
|
|
462
|
+
org.command("automations <organization_id>")
|
|
463
|
+
.description("Automated order messages (automated_messages_config): each type, its template and whether that template is approved")
|
|
464
|
+
.option("--enable [type]", "switch a type on (or the whole feature with no type); dry run unless --commit")
|
|
465
|
+
.option("--disable [type]", "switch a type off (or the whole feature)")
|
|
466
|
+
.option("--commit", "write the change")
|
|
467
|
+
.option("--json", "raw JSON")
|
|
468
|
+
.action((orgId, opts) => orgCmd.automations(orgId, opts));
|
|
469
|
+
org.command("abandoned <organization_id>")
|
|
470
|
+
.description("Abandoned-cart recovery config per sequence (template, delay, cart filter, discount) + the last 30 days of sends")
|
|
471
|
+
.option("--enable [sequence]", "switch a sequence on (or the whole feature with no number); dry run unless --commit")
|
|
472
|
+
.option("--disable [sequence]", "switch a sequence off (or the whole feature)")
|
|
473
|
+
.option("--commit", "write the change")
|
|
474
|
+
.option("--json", "raw JSON")
|
|
475
|
+
.action((orgId, opts) => orgCmd.abandoned(orgId, opts));
|
|
421
476
|
|
|
422
477
|
// org flags — organizations.feature_flags (the Settings → Profile switches).
|
|
423
478
|
// Registry-allowlisted writes, credentials never read or written, dangerous
|
|
@@ -616,6 +671,90 @@ export function run(argv) {
|
|
|
616
671
|
.option("--commit", "apply the change (default is a dry run)")
|
|
617
672
|
.action((planId, opts) => plansCmd.status(planId, opts));
|
|
618
673
|
|
|
674
|
+
// contacts (1 Oct 2026): the contact record from the terminal
|
|
675
|
+
const contacts = program.command("contacts").description("Find a contact (incl. archived / blocked) with its inbox state, lint numbers, merge, opt out / in, fix a number, export");
|
|
676
|
+
contacts.command("find <organization_id>")
|
|
677
|
+
.description("Find contacts by email / phone / name / id, archived and blocked included, with team, assignee, latest message and open ticket")
|
|
678
|
+
.option("--email <substr>", "email contains").option("--phone <digits>", "number contains (digits only)").option("--name <substr>", "name contains").option("--id <uuid>", "one contact by id")
|
|
679
|
+
.option("--limit <n>", "max matches (default 20, max 100)").option("--json", "raw JSON")
|
|
680
|
+
.action((orgId, opts) => contactsCmd.find(orgId, opts));
|
|
681
|
+
contacts.command("show <contact_id>")
|
|
682
|
+
.description("One contact's record + inbox state (needs --org)")
|
|
683
|
+
.requiredOption("--org <organization_id>", "the contact's org").option("--json", "raw JSON")
|
|
684
|
+
.action((contactId, opts) => contactsCmd.show(contactId, opts));
|
|
685
|
+
contacts.command("census <organization_id>")
|
|
686
|
+
.description("Archived chats that still carry unread messages or where the customer wrote last")
|
|
687
|
+
.option("--json", "raw JSON")
|
|
688
|
+
.action((orgId, opts) => contactsCmd.census(orgId, opts));
|
|
689
|
+
contacts.command("lint <organization_id>")
|
|
690
|
+
.description("Malformed WhatsApp numbers (doubled country code, kept trunk 0, bad length) with the repair and whether a twin contact holds it")
|
|
691
|
+
.option("--json", "raw JSON")
|
|
692
|
+
.action((orgId, opts) => contactsCmd.lint(orgId, opts));
|
|
693
|
+
contacts.command("merge <organization_id> <keep_id> <remove_id>")
|
|
694
|
+
.description("Merge <remove_id> into <keep_id> (messages, tickets, orders move; the app's merge_contacts_v2). Dry run unless --commit")
|
|
695
|
+
.option("--commit", "do it").option("--json", "raw JSON")
|
|
696
|
+
.action((orgId, keep, remove, opts) => contactsCmd.merge(orgId, keep, remove, opts));
|
|
697
|
+
contacts.command("optout <organization_id>")
|
|
698
|
+
.description("Opt contacts OUT of broadcasts (allow_broadcast=false + the inbox marker). Dry run unless --commit")
|
|
699
|
+
.option("--contact <id>", "contact id (repeatable)", (v, a) => (a || []).concat(v), []).option("--ids-file <file>", "file of contact ids (whitespace / comma separated)")
|
|
700
|
+
.option("--reason <text>", "recorded on the marker and the audit row").option("--commit", "write").option("--json", "raw JSON")
|
|
701
|
+
.action((orgId, opts) => contactsCmd.optout(orgId, opts));
|
|
702
|
+
contacts.command("optin <organization_id>")
|
|
703
|
+
.description("Opt contacts back IN to broadcasts (they will receive sends again). Dry run unless --commit --confirm")
|
|
704
|
+
.option("--contact <id>", "contact id (repeatable)", (v, a) => (a || []).concat(v), []).option("--ids-file <file>", "file of contact ids")
|
|
705
|
+
.option("--reason <text>", "recorded on the marker and the audit row").option("--commit", "write").option("--confirm", "required with --commit").option("--json", "raw JSON")
|
|
706
|
+
.action((orgId, opts) => contactsCmd.optin(orgId, opts));
|
|
707
|
+
contacts.command("set <organization_id> <contact_id>")
|
|
708
|
+
.description("Repair a contact's WhatsApp number (refused when another contact holds it: merge instead). Dry run unless --commit")
|
|
709
|
+
.option("--whatsapp-id <digits>", "the corrected number, country code first").option("--commit", "write").option("--json", "raw JSON")
|
|
710
|
+
.action((orgId, contactId, opts) => contactsCmd.set(orgId, contactId, opts));
|
|
711
|
+
contacts.command("export <organization_id>")
|
|
712
|
+
.description("Export the org's contacts to CSV (Excel opens it) → .flowiq/contacts/<org>.csv")
|
|
713
|
+
.option("--subscribers", "only opted-in, not archived, not blocked").option("--out <file>", "write here instead").option("--json", "write JSON instead of CSV")
|
|
714
|
+
.action((orgId, opts) => contactsCmd.exportContacts(orgId, opts));
|
|
715
|
+
|
|
716
|
+
// widgets (1 Oct 2026): web chat widgets
|
|
717
|
+
const widgets = program.command("widgets").description("Web chat widgets: list, show, pull / push the config (id-keyed patch), embed code, set the active one");
|
|
718
|
+
widgets.command("list <organization_id>").description("The org's widgets").option("--json", "raw JSON").action((orgId, opts) => widgetsCmd.list(orgId, opts));
|
|
719
|
+
widgets.command("show <widget_id>").description("One widget's settings").option("--json", "raw JSON").action((id, opts) => widgetsCmd.show(id, opts));
|
|
720
|
+
widgets.command("pull <widget_id>").description("Write the full config to .flowiq/widgets/<org>-<id>.json for editing").option("--out <file>", "write here instead").action((id, opts) => widgetsCmd.pull(id, opts));
|
|
721
|
+
widgets.command("push <file>").description("Push a pulled file back: only differing fields, jsonb bags merged at their top level (null removes a key). Dry run unless --commit")
|
|
722
|
+
.option("--commit", "write").option("--json", "raw JSON").action((file, opts) => widgetsCmd.push(file, opts));
|
|
723
|
+
widgets.command("embed <organization_id> [widget_id]").description("The <script> snippet a client pastes (widget id optional: the org-only embed resolves to the active widget)").option("--json", "raw JSON").action((orgId, wid, opts) => widgetsCmd.embed(orgId, wid, opts));
|
|
724
|
+
widgets.command("activate <widget_id>").description("Make this the widget an org-only embed resolves to").option("--json", "raw JSON").action((id, opts) => widgetsCmd.activate(id, opts));
|
|
725
|
+
|
|
726
|
+
// tickets (1 Oct 2026): escalation tickets
|
|
727
|
+
const tickets = program.command("tickets").description("Escalation tickets: list open / stale ones, show one, change its status");
|
|
728
|
+
tickets.command("list <organization_id>")
|
|
729
|
+
.description("Tickets newest first (default open + in_progress)")
|
|
730
|
+
.option("--status <list>", "open,in_progress (default) | resolved | closed | all").option("--contact <id>", "one contact's tickets")
|
|
731
|
+
.option("--older-than <age>", "only tickets opened longer ago than 48h / 3d / 1w").option("--limit <n>", "max rows (default 100, max 500)").option("--json", "raw JSON")
|
|
732
|
+
.action((orgId, opts) => ticketsCmd.list(orgId, opts));
|
|
733
|
+
tickets.command("show <ticket_id>").description("One ticket in full").option("--json", "raw JSON").action((id, opts) => ticketsCmd.show(id, opts));
|
|
734
|
+
tickets.command("status <ticket_id>")
|
|
735
|
+
.description("Change a ticket's status the way the app does (stamps, history; honours reenable_bot_on_ticket_resolve). Dry run unless --commit")
|
|
736
|
+
.requiredOption("--set <status>", "open | in_progress | resolved | closed").option("--note <text>", "resolution / reopen note").option("--commit", "write").option("--json", "raw JSON")
|
|
737
|
+
.action((id, opts) => ticketsCmd.status(id, opts));
|
|
738
|
+
|
|
739
|
+
// flows (1 Oct 2026): the Flows automation engine
|
|
740
|
+
const flows = program.command("flows").description("Flows: list (trigger, templates, param-key lint), show, execution status + failures, enable / disable");
|
|
741
|
+
flows.command("list <organization_id>").description("Every flow with trigger, templates, stats and a lint of its body_parameters keys").option("--json", "raw JSON").action((orgId, opts) => flowsCmd.list(orgId, opts));
|
|
742
|
+
flows.command("show <flow_id>").description("One flow with its full configuration").option("--json", "raw JSON").action((id, opts) => flowsCmd.show(id, opts));
|
|
743
|
+
flows.command("status <organization_id>")
|
|
744
|
+
.description("Execution funnel per status and per flow, with the verbatim error rollup")
|
|
745
|
+
.option("--since <when>", "14d (default) | 7d | 2w | YYYY-MM-DD").option("--flow <id>", "one flow").option("--failed", "also list the failed executions").option("--json", "raw JSON")
|
|
746
|
+
.action((orgId, opts) => flowsCmd.status(orgId, opts));
|
|
747
|
+
flows.command("enable <flow_id>").description("Enable a flow (arms real sends: needs --confirm)").option("--confirm", "required").option("--json", "raw JSON").action((id, opts) => flowsCmd.enable(id, opts));
|
|
748
|
+
flows.command("disable <flow_id>").description("Disable a flow").option("--json", "raw JSON").action((id, opts) => flowsCmd.disable(id, opts));
|
|
749
|
+
|
|
750
|
+
// rules (1 Oct 2026): the Automations rules engine (custom_notification_rules)
|
|
751
|
+
const rules = program.command("rules").description("Automation rules (Shoppers → Rules): list, show, what each has queued, enable / disable");
|
|
752
|
+
rules.command("list <organization_id>").description("Every rule: trigger, template, delay / quiet hours, conditions").option("--trigger <type>", "tag_added | shopify_order_stage | external_webhook | instagram_comment").option("--json", "raw JSON").action((orgId, opts) => rulesCmd.list(orgId, opts));
|
|
753
|
+
rules.command("show <rule_id>").description("One rule in full").option("--json", "raw JSON").action((id, opts) => rulesCmd.show(id, opts));
|
|
754
|
+
rules.command("results <rule_id>").description("What the rule queued (api_request_queue): status counts, errors, recent sends").option("--since <when>", "30d (default) | 7d | YYYY-MM-DD").option("--json", "raw JSON").action((id, opts) => rulesCmd.results(id, opts));
|
|
755
|
+
rules.command("enable <rule_id>").description("Enable a rule (arms real template sends: needs --confirm)").option("--confirm", "required").option("--json", "raw JSON").action((id, opts) => rulesCmd.enable(id, opts));
|
|
756
|
+
rules.command("disable <rule_id>").description("Disable a rule").option("--json", "raw JSON").action((id, opts) => rulesCmd.disable(id, opts));
|
|
757
|
+
|
|
619
758
|
// gaps (CLI gap reports + feature requests, filed by anyone incl. a Claude session)
|
|
620
759
|
const gaps = program.command("gaps")
|
|
621
760
|
.alias("requests")
|