@flowapt/flowiq-cli 0.11.0 → 0.12.1
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 +62 -0
- package/TEAM-GUIDE.md +12 -1
- package/package.json +1 -1
- package/src/batch-g.test.mjs +35 -0
- package/src/commands/agent-test.js +29 -0
- package/src/commands/broadcast.js +10 -0
- package/src/commands/contacts.js +43 -2
- package/src/commands/members.js +13 -0
- package/src/commands/messages.js +30 -3
- package/src/commands/org.js +22 -4
- package/src/commands/plans.js +50 -0
- package/src/commands/store-api.js +11 -3
- package/src/commands/tag.js +14 -3
- package/src/http-retry.test.mjs +48 -0
- package/src/http.js +40 -1
- package/src/index.js +85 -3
package/README.md
CHANGED
|
@@ -261,6 +261,16 @@ flowiq bc route <org_id> <broadcastId> --team "Spa" --commit # set i
|
|
|
261
261
|
flowiq bc route <org_id> <broadcastId> --clear --commit # switch it off
|
|
262
262
|
```
|
|
263
263
|
|
|
264
|
+
**Coexistence numbers are sent at a steady pace (2 Oct 2026).** On an org whose number
|
|
265
|
+
is shared with the WhatsApp Business app (`feature_flags.coexistence.enabled`), every
|
|
266
|
+
send engine (python, `/api/broadcast`, `/api/send-template`) sends one message every
|
|
267
|
+
200 ms (5 a second) instead of in bursts, and the `bc send` dry run says so with the
|
|
268
|
+
time it will take: `🐢 Coexistence number: sent one at a time at 5 a second, about 5 min
|
|
269
|
+
for 1500 contact(s)`. On every org, a send Meta refuses with **130429** (its send-speed
|
|
270
|
+
limit) is retried after 2 / 5 / 10 s before it is recorded as failed; Meta never sent it,
|
|
271
|
+
so a retry cannot double-message anyone. `flowiq org whatsapp` notes a coexistence
|
|
272
|
+
number (Meta's own `throughput` still reads STANDARD on them). Server-side, no new flags.
|
|
273
|
+
|
|
264
274
|
- **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
|
|
265
275
|
send instead of firing it. It writes the same `api_request_queue` row the
|
|
266
276
|
dashboard writes, replayed by the `process-api-queue` cron (every 3 min, so it
|
|
@@ -2255,6 +2265,58 @@ flowiq messages delete <contact_id> [--before 30d] [--sender-type tool-call] --c
|
|
|
2255
2265
|
On with `vert.active` not false = live for every customer, which needs
|
|
2256
2266
|
`--yes`; the command prints which of the three it would produce.
|
|
2257
2267
|
|
|
2268
|
+
### Batch G (0.12.0): the 1 October filings
|
|
2269
|
+
|
|
2270
|
+
Eighteen requests filed by the team on 1 Oct 2026, each reproduced against the
|
|
2271
|
+
code before the fix.
|
|
2272
|
+
|
|
2273
|
+
```bash
|
|
2274
|
+
flowiq contacts show <contact_id> # no --org needed any more: the org is resolved from the id
|
|
2275
|
+
flowiq contacts lint <org> # now also flags a 27 + 11 digit number (SA is 27 + 9) and suggests the real one
|
|
2276
|
+
flowiq contacts ai off <org> --tag <tag> [--commit] # AI off/on for every contact on a tag (or --contact / --ids-file), agent-off rows written
|
|
2277
|
+
flowiq contacts add <org> --whatsapp-id 27637186304 --name Lau --no-broadcast --commit
|
|
2278
|
+
flowiq members invite <org> someone@client.co.za --role viewer # an invitation they accept at sign-up (no account needed)
|
|
2279
|
+
flowiq messages search <org> "being updated at the same time" --tool-calls # inside tool-call request / response
|
|
2280
|
+
flowiq messages failures <org> # rows with only an error_message no longer roll up as "?"
|
|
2281
|
+
flowiq messages purge <org> --subject-regex '^Automatic reply' --sender-type user-email --confirm # org-wide delete by pattern
|
|
2282
|
+
flowiq org automations <org> --set payment_received --template payment_received_v3 --body param1=full_name,param2=order_number --buttons param1=short_code --commit
|
|
2283
|
+
flowiq org whatsapp <org> | --all # LIMIT = the business portfolio's messaging limit (Meta's new field); code verification EXPIRED is info, not a problem
|
|
2284
|
+
flowiq org health <org> # flags an active agent with no human_notify (a handover alerts nobody)
|
|
2285
|
+
flowiq agent config <org> # human_notify / order_notify / tool_instructions print "(unset)" instead of vanishing
|
|
2286
|
+
flowiq tag field <org> --field whatsapp_id --any 2779…,2782… # a digit-only list is matched whole (exact) automatically; 0.7 s instead of a timeout
|
|
2287
|
+
flowiq test seed <org> --from <contact_id> --until "2026-10-01 09:08" --commit # copy a real chat onto a test contact; test unseed removes it
|
|
2288
|
+
flowiq plans create <org> --topic "Heritage Day" --type utility --date 2026-10-09 --time 10:00 --line-1 … --line-2 … --follow-up … --landing https://… --commit
|
|
2289
|
+
flowiq woo get <org> products --all --fields id,name,permalink,status # past the 3.5 MB cap; the truncation message now says how to trim and resume
|
|
2290
|
+
```
|
|
2291
|
+
|
|
2292
|
+
- **`contacts ai off`** writes `bot_status` and the same `agent-off` row the
|
|
2293
|
+
inbox toggle writes, so the chat shows who switched it. It warns about the
|
|
2294
|
+
reactivation cron: an AI-off contact is turned back on once their last
|
|
2295
|
+
customer message is 12 h old and the agent-off row is older than 24 h
|
|
2296
|
+
(unless `bot_reactivation.enabled` is false on the org).
|
|
2297
|
+
- **`messages purge`** matches an email subject regex (`more_data.subject`),
|
|
2298
|
+
a text substring, sender types and a time window; 5,000 rows a run; the dry
|
|
2299
|
+
run lists subjects and contacts; `--confirm` asks you to type the org id.
|
|
2300
|
+
- **`plans create`** files the same row the MCP's `submit_plan` writes and
|
|
2301
|
+
alerts the team the same way; staff are exempt from the client lead time
|
|
2302
|
+
(a sub-24 h send is noted, not refused).
|
|
2303
|
+
- Not a CLI fix: #186 (Shopify store analytics need the `read_reports` scope
|
|
2304
|
+
on the FlowIQ app).
|
|
2305
|
+
|
|
2306
|
+
### 0.12.1: dropped connections and `insights status <org>`
|
|
2307
|
+
|
|
2308
|
+
- **A read survives a dropped connection.** A bare `Request failed: fetch failed`
|
|
2309
|
+
(the connection dropped, often when several commands run at once) is retried
|
|
2310
|
+
twice with a short backoff on every GET. A write (POST / PUT / DELETE) is never
|
|
2311
|
+
repeated blind: it may have reached the server, so the error says to check the
|
|
2312
|
+
result before retrying. Measured on 2 Oct 2026: 30 of 57 month-by-month
|
|
2313
|
+
`shopify get --all` pulls failed this way five at a time on 0.11.0; twelve run
|
|
2314
|
+
six at a time on 0.12.1 all came back (6,538 orders, `flowiq gaps` #198).
|
|
2315
|
+
- **`flowiq insights status <org>` no longer times out.** The server used to
|
|
2316
|
+
count 30 days of messages for every org and keep one; it now counts only the
|
|
2317
|
+
org asked for (0.2 s), and the all-orgs listing reads each org on its own index
|
|
2318
|
+
(about 1 s for 60 orgs). Server-side, so it works on any CLI version (#130).
|
|
2319
|
+
|
|
2258
2320
|
### Guide — `flowiq guide`
|
|
2259
2321
|
|
|
2260
2322
|
Read the bundled docs in the terminal — no digging through node_modules.
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -143,7 +143,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
143
143
|
| Did the agent say something wrong to anyone today? | `flowiq messages search <org_id> "the words" --since 24h` |
|
|
144
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
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
|
|
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`. A number shared with the WhatsApp Business app says "coexistence number … paced to 5 a second". |
|
|
147
|
+
| Why did a broadcast lose sends to error 130429? | The number is on **coexistence** (shared with the WhatsApp Business app) and was sent to in bursts. Since 2 Oct 2026 every engine sends those numbers one at a time (5 a second, the `bc send` dry run shows how long it takes) and retries a 130429 after a pause. Resend the old failures with `flowiq bc retry <org_id> <broadcastId>`. |
|
|
147
148
|
| Which channels are connected (Messenger, Instagram, email…)? | `flowiq org channels <org_id>`; every org: `flowiq org channels --all` |
|
|
148
149
|
| Is this client's agent ready to switch on? | `flowiq org health <org_id>` (master switch, keys probed incl. the spare key, prompt, catalogue, webhooks, order sync, traffic; ⚠ lines are warnings, ✗ lines block) |
|
|
149
150
|
| 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>` |
|
|
@@ -161,6 +162,14 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
161
162
|
| Tag a list of contact ids, or see who carries a tag | `flowiq tag ids <org_id> --ids-file ids.txt --tag vip --commit` · `flowiq tag holders <org_id> vip --csv` |
|
|
162
163
|
| Why did the agent answer that? | `flowiq test send <org_id> "<the question>" --tools` shows each tool's arguments and result |
|
|
163
164
|
| Was that message the agent, a broadcast or an automation? | `flowiq messages pull <contact_id>` ends with a "sent by" line per source |
|
|
165
|
+
| Stop the agent answering everyone who got a broadcast (a whole tag) | `flowiq contacts ai off <org_id> --tag <tag>` (dry run) → `--commit`; back on with `ai on`. Read the reactivation warning it prints |
|
|
166
|
+
| Add a tester or a team member as a contact | `flowiq contacts add <org_id> --whatsapp-id 27637186304 --name Lau --no-broadcast --commit` |
|
|
167
|
+
| Invite a client user who has no FlowIQ account | `flowiq members invite <org_id> <email> --role member` (they accept it when they sign up; FlowIQ sends no email, so tell them) |
|
|
168
|
+
| Remove imported junk emails (auto-replies) across the whole inbox | `flowiq messages purge <org_id> --subject-regex '^Automatic reply' --sender-type user-email` (dry run) → `--confirm` |
|
|
169
|
+
| Point an order message at a new template version, or fix its params | `flowiq org automations <org_id> --set <type> --template <name> --body param1=full_name,param2=order_number --buttons param1=short_code --commit` |
|
|
170
|
+
| Reproduce what the agent or the Coworker said to a real customer | `flowiq test seed <org_id> --from <contact_id> --until "YYYY-MM-DD HH:MM" --commit`, open the seeded contact in the inbox, then `flowiq test unseed <org_id> <id> --confirm` |
|
|
171
|
+
| File a broadcast plan for a client from the terminal | `flowiq plans create <org_id> --topic … --type utility\|marketing --date YYYY-MM-DD …` (dry run) → `--commit` |
|
|
172
|
+
| What did a tool actually return? | `flowiq messages search <org_id> "<text>" --tool-calls` |
|
|
164
173
|
> **Publishing the CLI (maintainers only):** publish from a clean clone, never
|
|
165
174
|
> from your working tree — `npm publish` packs whatever is on disk. A
|
|
166
175
|
> `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
|
|
@@ -450,6 +459,8 @@ Three things worth knowing:
|
|
|
450
459
|
| "Organization has no active_whatsapp_agent" | Target the agent directly: `--agent <id>` (ids from `flowiq agent list <org_id>`) |
|
|
451
460
|
| Login browser page says the code expired | Codes live 10 minutes — just re-run `flowiq auth login` |
|
|
452
461
|
| Pushed the wrong thing | Everything is pull→push, so re-pull an older copy if you have one, or check with Matt — server logs record every push with who/what/when |
|
|
462
|
+
| `could not reach the API after 3 tries` | The connection dropped (0.12.1 already retried the read twice). Run it again, and run fewer commands at once |
|
|
463
|
+
| `lost its connection … may still have finished the write` | A write is never retried for you: check the result (`list` / `status` / `pull`, or `flowiq audit`) before running it again |
|
|
453
464
|
|
|
454
465
|
ChatCart rollout: Pick n Pay uses retailer `pnp` and the private connection link.
|
|
455
466
|
Enable its gateway before adding it to the agent tools. Cart retries use
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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,35 @@
|
|
|
1
|
+
// node --test src/batch-g.test.mjs — pure helpers behind the 0.12.0 verbs.
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { withFields } from "./commands/store-api.js";
|
|
5
|
+
import { parseButton } from "./commands/plans.js";
|
|
6
|
+
process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
|
|
7
|
+
process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
|
|
8
|
+
const { lintNumber } = await import("../../api/cli/contacts.js");
|
|
9
|
+
|
|
10
|
+
test("lintNumber flags a wrong national length and suggests the last-n twin", () => {
|
|
11
|
+
assert.deepEqual(lintNumber("2729829037707"), { problem: "27 + 11 digits (a +27 number is 27 + 9)", suggested: "27829037707" });
|
|
12
|
+
assert.deepEqual(lintNumber("2782903770"), { problem: "27 + 8 digits (a +27 number is 27 + 9)", suggested: null });
|
|
13
|
+
assert.equal(lintNumber("27829037707"), null);
|
|
14
|
+
assert.equal(lintNumber("351912345678"), null);
|
|
15
|
+
assert.equal(lintNumber("447700900123"), null);
|
|
16
|
+
// variable-length countries stay untouched
|
|
17
|
+
assert.equal(lintNumber("4915112345678"), null);
|
|
18
|
+
assert.equal(lintNumber("12125551234"), null);
|
|
19
|
+
// the older rules still win first
|
|
20
|
+
assert.match(lintNumber("270821234567").problem, /trunk 0/);
|
|
21
|
+
assert.match(lintNumber("2727821234567").problem, /doubled/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("withFields adds the platform's field-trim parameter and leaves other query keys", () => {
|
|
25
|
+
assert.deepEqual(withFields({ status: "any" }, "id, name ,permalink", "_fields"), { status: "any", _fields: "id,name,permalink" });
|
|
26
|
+
assert.deepEqual(withFields({ status: "any" }, undefined, "fields"), { status: "any" });
|
|
27
|
+
assert.deepEqual(withFields(undefined, "id", "fields"), { fields: "id" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("parseButton reads the four button shapes", () => {
|
|
31
|
+
assert.deepEqual(parseButton("quick_reply:VIEW MORE"), { type: "quick_reply", text: "VIEW MORE" });
|
|
32
|
+
assert.deepEqual(parseButton("url:CLICK HERE=https://x.co/a=b"), { type: "url", text: "CLICK HERE", url: "https://x.co/a=b" });
|
|
33
|
+
assert.deepEqual(parseButton("phone:Call us=+27 60 975 1832"), { type: "phone", text: "Call us", phone_number: "+27 60 975 1832" });
|
|
34
|
+
assert.deepEqual(parseButton("copy_code:SAVE10"), { type: "copy_code", code: "SAVE10" });
|
|
35
|
+
});
|
|
@@ -165,6 +165,35 @@ export async function qa(orgId, opts = {}) {
|
|
|
165
165
|
await runPack(orgId, pack, opts);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// `flowiq test seed <org> --from <contact_id> [--until "2026-10-01 09:08"] [--name X] [--rows 60] [--with-tools] [--ai-on] [--commit]`
|
|
169
|
+
export async function seed(orgId, opts = {}) {
|
|
170
|
+
requireOrg(orgId);
|
|
171
|
+
if (!UUID_RE.test(opts.from || "")) { console.error("Error: --from <contact_id> is required (the real chat to copy)."); process.exit(1); }
|
|
172
|
+
let until = opts.until;
|
|
173
|
+
if (until && /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(until)) until = until.replace(" ", "T") + (until.length === 10 ? "T23:59:59" : "") + "+02:00";
|
|
174
|
+
let r;
|
|
175
|
+
try { r = await http.post("agent-test", { action: "seed", organization_id: orgId, agent_id: opts.agent, from_contact_id: opts.from, until, name: opts.name, rows: opts.rows, with_tools: !!opts.withTools, ai_on: !!opts.aiOn, copy_attributes: !!opts.copyAttributes, dry_run: !opts.commit }); }
|
|
176
|
+
catch (e) { console.error(`Seed failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
177
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
178
|
+
console.log(`${r.organization_name}: copy ${r.rows} row(s) from ${r.from.name ?? r.from.whatsapp_id} (${r.from.id})${r.until ? ` up to ${r.until}` : ""}${r.first ? `, ${r.first.slice(0, 16)} → ${r.last.slice(0, 16)}` : ""}`);
|
|
179
|
+
console.log(` by sender: ${Object.entries(r.by_sender).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"}`);
|
|
180
|
+
console.log(` new contact: ${r.new_contact.whatsapp_id} "${r.new_contact.full_name}" · AI ${r.new_contact.bot_status ? "on" : "OFF"} · no broadcasts · tags ${r.new_contact.tags.join(", ")}`);
|
|
181
|
+
if (r.dry_run) { console.log("\nDRY RUN — add --commit to create the seeded contact."); return; }
|
|
182
|
+
console.log(`\n✓ seeded ${r.contact_id} (${r.copied} rows). Open it in the inbox or: flowiq test send ${orgId} "<question>" is NOT routed to it (the agent's test contact is separate); the Coworker reads it from the inbox. Remove: flowiq test unseed ${orgId} ${r.contact_id} --confirm`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// `flowiq test unseed <org> <contact_id> --confirm`
|
|
186
|
+
export async function unseed(orgId, contactId, opts = {}) {
|
|
187
|
+
requireOrg(orgId);
|
|
188
|
+
if (!UUID_RE.test(contactId || "")) { console.error("Error: pass the seeded contact id."); process.exit(1); }
|
|
189
|
+
let r;
|
|
190
|
+
try { r = await http.post("agent-test", { action: "unseed", organization_id: orgId, agent_id: opts.agent, contact_id: contactId, confirm: !!opts.confirm }); }
|
|
191
|
+
catch (e) { console.error(`Unseed failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
192
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
193
|
+
if (r.dry_run) { console.log(`Would delete ${r.whatsapp_id} "${r.name}" and its ${r.messages} message(s)${r.seeded_from ? ` (seeded from ${r.seeded_from})` : ""}. Add --confirm.`); return; }
|
|
194
|
+
console.log(`✓ deleted the seeded contact and ${r.messages} message(s).`);
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
export async function clear(orgId, opts = {}) {
|
|
169
198
|
requireOrg(orgId);
|
|
170
199
|
let resp;
|
|
@@ -1129,6 +1129,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
|
|
|
1129
1129
|
try {
|
|
1130
1130
|
const dry = await http.post("broadcast", reqBody(true));
|
|
1131
1131
|
pyTotal = dry.total_contacts_found ?? null;
|
|
1132
|
+
printPacing(dry.pacing, pyTotal);
|
|
1132
1133
|
} catch (e) {
|
|
1133
1134
|
console.error(`Python dry-run failed after tagging: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
|
|
1134
1135
|
console.error(`Nothing sent. The ${workSet.length} contact(s) ARE tagged "${tag}" — re-run to retry the fan-off.`);
|
|
@@ -1391,6 +1392,14 @@ function reportRoutingResult(orgId, rr, broadcastId) {
|
|
|
1391
1392
|
}
|
|
1392
1393
|
}
|
|
1393
1394
|
|
|
1395
|
+
// Coexistence numbers (shared with the WhatsApp Business app) are sent one at a time
|
|
1396
|
+
// at a fixed pace by python (flowiq gaps #196); the server says so on the dry run.
|
|
1397
|
+
export function printPacing(pacing, total) {
|
|
1398
|
+
if (!pacing?.coexistence) return;
|
|
1399
|
+
const time = pacing.estimated_minutes != null ? `, about ${pacing.estimated_minutes} min for ${total} contact(s)` : "";
|
|
1400
|
+
console.log(`🐢 Coexistence number: sent one at a time at ${pacing.per_second} a second${time}. Meta's 130429 throttles are retried, not dropped.`);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1394
1403
|
async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams = null, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
|
|
1395
1404
|
const buttons = buttonParams && Object.keys(buttonParams).length ? buttonParams : (buttonLiteral ? { param1: buttonLiteral } : null);
|
|
1396
1405
|
const reqBody = (dryRun) => ({
|
|
@@ -1412,6 +1421,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
|
|
|
1412
1421
|
console.log("");
|
|
1413
1422
|
console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
|
|
1414
1423
|
console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
|
|
1424
|
+
printPacing(dry.pacing, total);
|
|
1415
1425
|
const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
|
|
1416
1426
|
renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral, buttonParams: buttons }, headerMedia);
|
|
1417
1427
|
printCarouselPlan(cardOverrides, cardLines);
|
package/src/commands/contacts.js
CHANGED
|
@@ -47,8 +47,49 @@ export async function find(orgId, opts = {}) {
|
|
|
47
47
|
|
|
48
48
|
export async function show(contactId, opts = {}) {
|
|
49
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
|
|
51
|
-
return find(opts.org, { id: contactId, json: opts.json });
|
|
50
|
+
if (opts.org && !UUID_RE.test(opts.org)) { console.error(`Error: "${opts.org}" is not a valid organization UUID.`); process.exit(1); }
|
|
51
|
+
if (opts.org) return find(opts.org, { id: contactId, json: opts.json });
|
|
52
|
+
// No --org: the server resolves the contact's own org (one indexed read).
|
|
53
|
+
let resp;
|
|
54
|
+
try { resp = await http.get("contacts", { find: "1", contact_id: contactId }); } catch (e) { fail("Lookup failed", e); }
|
|
55
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
56
|
+
if (!resp.contacts.length) { console.log(`No contact ${contactId}.`); return; }
|
|
57
|
+
console.log(`${resp.organization_name} (${resp.organization_id}):\n`);
|
|
58
|
+
for (const c of resp.contacts) { printContact(c); console.log(""); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `flowiq contacts ai off|on <org> --tag <tag> | --contact <id> | --ids-file f [--reason] [--commit]`
|
|
62
|
+
export async function ai(state, orgId, opts = {}) {
|
|
63
|
+
requireOrg(orgId);
|
|
64
|
+
const s = String(state || "").toLowerCase();
|
|
65
|
+
if (!["on", "off"].includes(s)) { console.error('Error: flowiq contacts ai off|on <org> --tag <tag> (or --contact / --ids-file)'); process.exit(1); }
|
|
66
|
+
if (!opts.tag && !opts.contact?.length && !opts.idsFile) { console.error("Error: pass --tag <tag>, --contact <id> (repeatable) or --ids-file <file>."); process.exit(1); }
|
|
67
|
+
if (opts.tag && (opts.contact?.length || opts.idsFile)) { console.error("Error: --tag or an id list, not both."); process.exit(1); }
|
|
68
|
+
const ids = opts.tag ? undefined : await readIds(opts);
|
|
69
|
+
let resp;
|
|
70
|
+
try { resp = await http.post("contacts", { action: "ai", organization_id: orgId, enabled: s === "on", tag: opts.tag, contact_ids: ids, reason: opts.reason, dry_run: !opts.commit }); }
|
|
71
|
+
catch (e) { fail("AI switch failed", e); }
|
|
72
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
73
|
+
console.log(`${resp.organization_name}: ${resp.matched} contact(s)${resp.tag ? ` on tag ${resp.tag}` : ""} · ${resp.to_change} to switch ${s.toUpperCase()} · ${resp.already} already ${s}${resp.not_found ? ` · ${resp.not_found} not in this org` : ""}`);
|
|
74
|
+
for (const c of resp.sample) console.log(` ${pad(c.whatsapp_id ?? "—", 16)} ${c.name ?? ""} ${c.id}`);
|
|
75
|
+
if (resp.to_change > resp.sample.length) console.log(` … ${resp.to_change - resp.sample.length} more`);
|
|
76
|
+
if (resp.caveat) console.log(`\n⚠ ${resp.caveat}`);
|
|
77
|
+
if (resp.dry_run) { console.log(`\nDRY RUN — nothing changed. Add --commit to set bot_status=${s === "on"} and write the agent-${s === "on" ? "on" : "off"} row on each chat.`); return; }
|
|
78
|
+
console.log(`\n✓ AI ${s.toUpperCase()} for ${resp.changed} contact(s).${resp.tag ? ` Undo: flowiq contacts ai ${s === "on" ? "off" : "on"} ${orgId} --tag ${resp.tag} --commit` : ""}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `flowiq contacts add <org> --whatsapp-id <n> [--name] [--email] [--no-broadcast] [--tag a,b] [--ai-off] [--commit]`
|
|
82
|
+
export async function add(orgId, opts = {}) {
|
|
83
|
+
requireOrg(orgId);
|
|
84
|
+
if (!opts.whatsappId) { console.error("Error: --whatsapp-id <digits> is required (country code first, e.g. 27637186304)."); process.exit(1); }
|
|
85
|
+
let resp;
|
|
86
|
+
try { resp = await http.post("contacts", { action: "add", organization_id: orgId, whatsapp_id: opts.whatsappId, full_name: opts.name, email: opts.email, allow_broadcast: opts.broadcast !== false, tags: opts.tag ? String(opts.tag).split(",").map((t) => t.trim()).filter(Boolean) : [], bot_status: opts.aiOff ? false : true, dry_run: !opts.commit }); }
|
|
87
|
+
catch (e) { fail("Add failed", e); }
|
|
88
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
89
|
+
const r = resp.would_insert ?? resp.inserted;
|
|
90
|
+
console.log(`${resp.organization_name}: ${r.whatsapp_id} ${r.full_name ?? "(no name)"}${r.email ? ` · ${r.email}` : ""} · broadcasts ${r.allow_broadcast ? "yes" : "NO"} · AI ${r.bot_status ? "on" : "off"}${r.tags?.length ? ` · tags ${r.tags.join(", ")}` : ""}`);
|
|
91
|
+
if (resp.dry_run) { console.log("DRY RUN — add --commit to create the contact."); return; }
|
|
92
|
+
console.log(`✓ created ${resp.contact_id}. Show: flowiq contacts show ${resp.contact_id}`);
|
|
52
93
|
}
|
|
53
94
|
|
|
54
95
|
export async function census(orgId, opts = {}) {
|
package/src/commands/members.js
CHANGED
|
@@ -37,6 +37,19 @@ export async function add(orgId, emailOrId, opts = {}) {
|
|
|
37
37
|
console.log(`✓ ${resp.email} is now a ${resp.role}${resp.reactivated ? " again" : ""}. Undo: flowiq members remove ${orgId} ${resp.email} --confirm`);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// `flowiq members invite <org> <email> [--role member]` — the app's door for
|
|
41
|
+
// someone with no account: an invitations row they are shown at sign-up.
|
|
42
|
+
export async function invite(orgId, email, opts = {}) {
|
|
43
|
+
requireOrg(orgId);
|
|
44
|
+
if (!email) { console.error("Error: flowiq members invite <org> <email> [--role member]"); process.exit(1); }
|
|
45
|
+
let resp;
|
|
46
|
+
try { resp = await http.post("members", { action: "invite", organization_id: orgId, email, role: opts.role || "member", dry_run: !!opts.dryRun }); } catch (e) { fail("Invite failed", e); }
|
|
47
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
48
|
+
if (resp.dry_run) { console.log(`Would invite ${resp.would_insert.email} to ${resp.organization_name} as ${resp.would_insert.role}.`); return; }
|
|
49
|
+
if (resp.unchanged) { console.log(resp.note); return; }
|
|
50
|
+
console.log(`✓ invited ${resp.email} as ${resp.role} (invitation ${resp.invitation_id}).\n ${resp.note}.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
export async function role(orgId, emailOrId, newRole, opts = {}) {
|
|
41
54
|
requireOrg(orgId);
|
|
42
55
|
if (!emailOrId || !newRole) { console.error("Error: flowiq members role <org> <email> admin|member|viewer"); process.exit(1); }
|
package/src/commands/messages.js
CHANGED
|
@@ -94,6 +94,33 @@ export function sentBy(m) {
|
|
|
94
94
|
return "agent";
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// `flowiq messages purge <org> --subject-regex <re> | --text <s> | --sender-type a,b [--since] [--before] --confirm`
|
|
98
|
+
// Org-wide delete by pattern (the 71 Lego auto-replies across 69 contacts).
|
|
99
|
+
export async function purge(orgId, opts = {}) {
|
|
100
|
+
requireOrg(orgId);
|
|
101
|
+
const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
102
|
+
if (!opts.subjectRegex && !opts.text && !senderTypes) { console.error("Error: give at least one of --subject-regex, --text, --sender-type."); process.exit(1); }
|
|
103
|
+
const body = { action: "delete", organization_id: orgId, subject_regex: opts.subjectRegex, text: opts.text, sender_types: senderTypes, since: opts.since, before: opts.before };
|
|
104
|
+
let preview;
|
|
105
|
+
try { preview = await http.post("messages", { ...body, dry_run: true }); } catch (e) { fail("Purge failed", e); }
|
|
106
|
+
if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
|
|
107
|
+
console.log(`${preview.organization_name}: ${preview.count} message(s) across ${preview.contacts} contact(s) match${preview.capped ? " (capped at 5,000 a run; re-run for the rest)" : ""}`);
|
|
108
|
+
for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
|
|
109
|
+
if (preview.top_subjects.length) { console.log(" subjects:"); for (const s of preview.top_subjects) console.log(` ${String(s.n).padStart(5)} × ${s.subject}`); }
|
|
110
|
+
if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
|
|
111
|
+
for (const c of preview.sample_contacts.slice(0, 8)) console.log(` ${String(c.n).padStart(5)} × ${c.contact?.full_name ?? ""} ${c.contact?.whatsapp_id ?? ""} (${c.contact_id})`);
|
|
112
|
+
if (!preview.count) return;
|
|
113
|
+
if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the organization id).`); return; }
|
|
114
|
+
const readline = await import("node:readline");
|
|
115
|
+
const typed = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`This cannot be undone. Type the organization id (${orgId}) to delete ${preview.count} message(s): `, (a) => { rl.close(); resolve(a.trim()); }); });
|
|
116
|
+
if (typed !== orgId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
|
|
117
|
+
let resp;
|
|
118
|
+
try { resp = await http.post("messages", { ...body, dry_run: false, confirm: true }); } catch (e) { fail("Purge failed", e); }
|
|
119
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
120
|
+
console.log(`✓ deleted ${resp.deleted} message(s) across ${resp.contacts} contact(s). The audit row keeps ids, types and times (flowiq audit --endpoint messages).`);
|
|
121
|
+
if (resp.error) console.error(` ⚠ ${resp.error}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
// `flowiq messages delete <contact_id> [--before 30d] [--sender-type a,b] --confirm`
|
|
98
125
|
export async function remove(contactId, opts = {}) {
|
|
99
126
|
if (!UUID_RE.test(contactId)) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
|
|
@@ -137,12 +164,12 @@ export async function search(orgId, textParts, opts = {}) {
|
|
|
137
164
|
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
138
165
|
if (text.length < 2) { console.error("Error: give at least 2 characters to search for."); process.exit(1); }
|
|
139
166
|
let resp;
|
|
140
|
-
try { resp = await http.get("messages", { organization_id: orgId, search: text, since: opts.since, until: opts.until, sender_type: opts.senderType, include_templates: opts.includeTemplates ? "1" : undefined, limit: opts.limit }); }
|
|
167
|
+
try { resp = await http.get("messages", { organization_id: orgId, search: text, since: opts.since, until: opts.until, sender_type: opts.senderType, include_templates: opts.includeTemplates ? "1" : undefined, tool_calls: opts.toolCalls ? "1" : undefined, limit: opts.limit }); }
|
|
141
168
|
catch (e) { fail("Search failed", e); }
|
|
142
169
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
143
|
-
console.log(`${resp.organization_name}: ${resp.count} message(s) containing "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates ? "" : " (template sends excluded; --include-templates)"}`);
|
|
170
|
+
console.log(`${resp.organization_name}: ${resp.count} ${opts.toolCalls ? "tool call(s) whose request or response contains" : "message(s) containing"} "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates || opts.toolCalls ? "" : " (template sends excluded; --include-templates)"}`);
|
|
144
171
|
for (const r of resp.rows) {
|
|
145
|
-
console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${snippetAround(r.message, text)}`);
|
|
172
|
+
console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(opts.toolCalls ? (r.tool_name ?? "tool") : r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${opts.toolCalls ? String(r.message).replace(/\s+/g, " ").slice(0, 160) : snippetAround(r.message, text)}`);
|
|
146
173
|
}
|
|
147
174
|
if (resp.rows.length) console.log(`\n contact ids: flowiq messages search … --json | jq '.rows[].contact_id' · full chat: flowiq messages pull <contact_id>`);
|
|
148
175
|
}
|
package/src/commands/org.js
CHANGED
|
@@ -197,8 +197,9 @@ export async function whatsapp(orgId, opts = {}) {
|
|
|
197
197
|
console.log(`${resp.checked} Meta number(s) checked at Meta, ${resp.unhealthy} with a problem (${resp.skipped_not_meta_or_unconnected} org(s) skipped: not Meta or not connected)`);
|
|
198
198
|
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
199
199
|
console.log("");
|
|
200
|
-
console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("
|
|
201
|
-
for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "
|
|
200
|
+
console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("LIMIT", 14)} PROBLEMS`);
|
|
201
|
+
for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "—", 14)} ${(o.problems || []).join("; ")}${o.notes?.length ? `${o.problems?.length ? " · " : ""}info: ${o.notes.join("; ")}` : ""}`);
|
|
202
|
+
console.log(`\n LIMIT = the business portfolio's messaging limit (Meta sets it per portfolio now; the per-number tier field is deprecated). "—" = Meta returned neither field for this number.`);
|
|
202
203
|
return;
|
|
203
204
|
}
|
|
204
205
|
const resp = await view(orgId, "whatsapp");
|
|
@@ -209,11 +210,11 @@ export async function whatsapp(orgId, opts = {}) {
|
|
|
209
210
|
const p = w.phone || {};
|
|
210
211
|
console.log(` number: ${p.display_phone_number ?? "?"} · "${p.verified_name ?? "?"}" · phone ${w.phone_id} · WABA ${w.waba_id ?? "?"}`);
|
|
211
212
|
console.log(` status: ${p.status ?? "?"} · platform ${p.platform_type ?? "?"} · account mode ${p.account_mode ?? "?"}`);
|
|
212
|
-
console.log(` quality: ${p.quality_rating ?? "?"} · limit
|
|
213
|
+
console.log(` quality: ${p.quality_rating ?? "?"} · messaging limit ${w.messaging_limit ?? "not returned"}${w.messaging_limit_source ? ` (${w.messaging_limit_source})` : ""}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
|
|
213
214
|
console.log(` name: ${p.name_status ?? "?"}${p.new_name_status && p.new_name_status !== "NONE" ? ` · pending "${p.new_display_name}" (${p.new_name_status})` : ""} · OBA ${yn(p.is_official_business_account)} · code verification ${p.code_verification_status ?? "?"}`);
|
|
214
215
|
console.log(` webhooks: ${Array.isArray(w.subscribed_apps) ? (w.subscribed_apps.length ? w.subscribed_apps.join(", ") : "NO app subscribed") : w.subscribed_apps ?? "?"}`);
|
|
215
216
|
if (w.waba && !w.waba.error) console.log(` waba: ${w.waba.name ?? ""} · review ${w.waba.account_review_status ?? "?"} · ${w.waba.ownership_type ?? ""}`);
|
|
216
|
-
console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}`);
|
|
217
|
+
console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}${w.notes?.length ? ` (info: ${w.notes.join("; ")})` : ""}`);
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
export async function health(orgId, opts = {}) {
|
|
@@ -236,6 +237,22 @@ export async function health(orgId, opts = {}) {
|
|
|
236
237
|
|
|
237
238
|
export async function automations(orgId, opts = {}) {
|
|
238
239
|
requireOrg(orgId);
|
|
240
|
+
// --set <type> --template <name> / --body k=v,… / --buttons k=v,…: repoint
|
|
241
|
+
// one type at a new template version or fix its param mapping.
|
|
242
|
+
if (opts.set) {
|
|
243
|
+
const parseMap = (v, flag) => { if (v === undefined) return undefined; if (v === "none" || v === "null") return null; const out = {}; for (const pair of String(v).split(",").map((s) => s.trim()).filter(Boolean)) { const i = pair.indexOf("="); if (i < 1) { console.error(`Error: ${flag} takes param1=full_name,param2=order_number (got "${pair}")`); process.exit(1); } out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim(); } return out; };
|
|
244
|
+
const body = { action: "automations_set", organization_id: orgId, type: opts.set, template_name: opts.template, body_parameters: parseMap(opts.body, "--body"), button_parameters: parseMap(opts.buttons, "--buttons"), dry_run: !opts.commit };
|
|
245
|
+
if (body.template_name === undefined && body.body_parameters === undefined && body.button_parameters === undefined) { console.error("Error: with --set <type> pass --template <name>, --body k=v,… and/or --buttons k=v,… (\"none\" clears a mapping)."); process.exit(1); }
|
|
246
|
+
let resp;
|
|
247
|
+
try { resp = await http.post("org", body); } catch (e) { fail("Set failed", e); }
|
|
248
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
249
|
+
if (resp.unchanged) { console.log(`${opts.set}: nothing to change (template ${resp.current?.template_name ?? "none"}, mapping ${JSON.stringify(resp.current?.meta_parameter_mapping ?? null)}).`); return; }
|
|
250
|
+
console.log(`${resp.organization_name} · ${resp.type}:`);
|
|
251
|
+
for (const c of resp.changes) console.log(` ${c}`);
|
|
252
|
+
if (resp.warning) console.log(` ⚠ ${resp.warning}`);
|
|
253
|
+
console.log(resp.dry_run ? "\nDRY RUN — add --commit to write." : "\n✓ written (the next order event sends with these values).");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
239
256
|
if (opts.enable || opts.disable) {
|
|
240
257
|
const type = opts.enable || opts.disable;
|
|
241
258
|
let resp;
|
|
@@ -257,6 +274,7 @@ export async function automations(orgId, opts = {}) {
|
|
|
257
274
|
if (t.warning) console.log(` ⚠ ${t.warning}`);
|
|
258
275
|
}
|
|
259
276
|
console.log(`\nflowiq org automations ${orgId} --enable <type> | --disable <type> [--commit] (the whole feature: --enable/--disable with no type)`);
|
|
277
|
+
console.log(`flowiq org automations ${orgId} --set <type> --template <name> --body param1=full_name,param2=order_number --buttons param1=short_code [--commit]`);
|
|
260
278
|
}
|
|
261
279
|
|
|
262
280
|
export async function abandoned(orgId, opts = {}) {
|
package/src/commands/plans.js
CHANGED
|
@@ -230,6 +230,56 @@ export async function show(planId, opts = {}) {
|
|
|
230
230
|
console.log(`\nChange status: flowiq plans status ${p.id} --to <status> [--comment "..."] [--commit]`);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// `flowiq plans create <org> --topic … --type utility|marketing --date YYYY-MM-DD [--time HH:MM] …`
|
|
234
|
+
// Files a plan on the client's Planning board (the MCP submit_plan row). Staff
|
|
235
|
+
// are exempt from the client lead time. Dry run unless --commit.
|
|
236
|
+
export function parseButton(spec) {
|
|
237
|
+
// type:text[=value] e.g. quick_reply:VIEW MORE · url:CLICK HERE=https://x · phone:Call us=+27… · copy_code:SAVE10
|
|
238
|
+
const m = /^(quick_reply|url|phone|copy_code):(.*)$/.exec(String(spec).trim());
|
|
239
|
+
if (!m) { console.error(`Error: --button must be type:text[=value] (got "${spec}"); types quick_reply | url | phone | copy_code`); process.exit(1); }
|
|
240
|
+
const [, type, rest] = m;
|
|
241
|
+
if (type === "copy_code") return { type, code: rest.trim() };
|
|
242
|
+
const eq = rest.indexOf("=");
|
|
243
|
+
const text = (eq >= 0 ? rest.slice(0, eq) : rest).trim();
|
|
244
|
+
const value = eq >= 0 ? rest.slice(eq + 1).trim() : "";
|
|
245
|
+
if (type === "url") return { type, text, url: value };
|
|
246
|
+
if (type === "phone") return { type, text, phone_number: value };
|
|
247
|
+
return { type, text };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function create(orgId, opts = {}) {
|
|
251
|
+
if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
252
|
+
const buttons = (opts.button || []).map(parseButton);
|
|
253
|
+
for (const f of opts.reply || []) {
|
|
254
|
+
const i = f.indexOf("=");
|
|
255
|
+
if (i < 1) { console.error(`Error: --reply must be <button text>=<follow-up message> (got "${f}")`); process.exit(1); }
|
|
256
|
+
const b = buttons.find((x) => x.type === "quick_reply" && x.text.toLowerCase() === f.slice(0, i).trim().toLowerCase());
|
|
257
|
+
if (!b) { console.error(`Error: --reply names "${f.slice(0, i).trim()}" but no quick_reply button has that text`); process.exit(1); }
|
|
258
|
+
b.follow_up = f.slice(i + 1).trim();
|
|
259
|
+
}
|
|
260
|
+
const body = {
|
|
261
|
+
action: "create", organization_id: orgId, topic: opts.topic, type: opts.type, scheduled_date: opts.date, scheduled_time: opts.time,
|
|
262
|
+
product_focus: opts.product, audience: opts.audience, line_1: opts.line1, line_2: opts.line2, follow_up_message: opts.followUp, landing_page_url: opts.landing,
|
|
263
|
+
message: opts.message, buttons: buttons.length ? buttons : undefined, graphic_brief: opts.graphic, media_url: opts.media, save_as_draft: !!opts.draft, dry_run: !opts.commit,
|
|
264
|
+
};
|
|
265
|
+
let resp;
|
|
266
|
+
try { resp = await http.post("plans", body); }
|
|
267
|
+
catch (e) {
|
|
268
|
+
console.error(`Create failed: ${e.message}`);
|
|
269
|
+
for (const p of e.body?.problems || []) console.error(` - ${p}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
273
|
+
console.log(`${resp.organization_name}: "${resp.topic}" (${resp.type}) · ${resp.send} · audience: ${resp.audience} · files as ${resp.files_as}`);
|
|
274
|
+
console.log(` copy: ${String(resp.copy).replace(/\n/g, "\n ")}`);
|
|
275
|
+
if (resp.buttons?.length) console.log(` buttons: ${resp.buttons.map((b) => `${b.type}:${b.text ?? b.code}${b.url ? `=${b.url}` : ""}`).join(" · ")}`);
|
|
276
|
+
if (resp.follow_ups && Object.keys(resp.follow_ups).length) console.log(` follow-ups: ${JSON.stringify(resp.follow_ups)}`);
|
|
277
|
+
console.log(` graphic: ${resp.graphic}`);
|
|
278
|
+
for (const n of resp.notes || []) console.log(` ⚠ ${n}`);
|
|
279
|
+
if (resp.dry_run) { console.log("\nDRY RUN — nothing filed. Add --commit to file it on the Planning board."); return; }
|
|
280
|
+
console.log(`\n✓ filed plan ${resp.plan_id} (${resp.status}). ${resp.alert?.note ?? ""}\n flowiq plans show ${resp.plan_id}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
233
283
|
export async function status(planId, opts = {}) {
|
|
234
284
|
if (!UUID_RE.test(planId)) {
|
|
235
285
|
console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);
|
|
@@ -107,7 +107,7 @@ function banner(resp) {
|
|
|
107
107
|
|
|
108
108
|
if (resp.truncated) {
|
|
109
109
|
console.error(`⚠ TRUNCATED — ${resp.truncated_reason}. Not all records were returned.`);
|
|
110
|
-
console.error(" Narrow the query, or raise --max-pages and page through with the store's own cursor.");
|
|
110
|
+
console.error(" Narrow the query, trim records with --fields id,name,…, or raise --max-pages and page through with the store's own cursor.");
|
|
111
111
|
}
|
|
112
112
|
// Shopify's *Count fields return {count, precision}. `AT_LEAST` means the
|
|
113
113
|
// number is a CAP (10,000), not an answer — and it is quiet enough to be read
|
|
@@ -213,6 +213,14 @@ async function send(payload, opts, sentQuery) {
|
|
|
213
213
|
// shopify
|
|
214
214
|
// ---------------------------------------------------------------------------
|
|
215
215
|
|
|
216
|
+
// --fields id,name,… = the platform's own field-trim parameter (Shopify REST
|
|
217
|
+
// `fields`, Woo `_fields`): the way past the 3.5 MB response cap on a fat
|
|
218
|
+
// record type (Revive's 894 products stopped at 200 without it, 30 Sep 2026).
|
|
219
|
+
export function withFields(query, fields, key) {
|
|
220
|
+
if (!fields) return query;
|
|
221
|
+
return { ...(query || {}), [key]: String(fields).split(",").map((s) => s.trim()).filter(Boolean).join(",") };
|
|
222
|
+
}
|
|
223
|
+
|
|
216
224
|
export async function shopifyGet(orgId, path, opts = {}) {
|
|
217
225
|
requireOrg(orgId);
|
|
218
226
|
await send({
|
|
@@ -220,7 +228,7 @@ export async function shopifyGet(orgId, path, opts = {}) {
|
|
|
220
228
|
organization_id: orgId,
|
|
221
229
|
mode: "rest",
|
|
222
230
|
path,
|
|
223
|
-
query: opts.q
|
|
231
|
+
query: withFields(opts.q, opts.fields, "fields"),
|
|
224
232
|
all: !!opts.all,
|
|
225
233
|
max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
|
|
226
234
|
api_version: opts.apiVersion,
|
|
@@ -446,7 +454,7 @@ export async function wooGet(orgId, path, opts = {}) {
|
|
|
446
454
|
mode: "rest",
|
|
447
455
|
path,
|
|
448
456
|
namespace: opts.namespace,
|
|
449
|
-
query: opts.q
|
|
457
|
+
query: withFields(opts.q, opts.fields, "_fields"),
|
|
450
458
|
all: !!opts.all,
|
|
451
459
|
max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
|
|
452
460
|
}, opts);
|
package/src/commands/tag.js
CHANGED
|
@@ -96,12 +96,23 @@ export async function field(orgId, opts = {}) {
|
|
|
96
96
|
const keywords = splitList(opts.any || opts.all);
|
|
97
97
|
if (!keywords) { console.error("Error: pass --any \"a,b\" (OR) or --all \"a,b\" (AND)."); process.exit(1); }
|
|
98
98
|
if (opts.exact && opts.all) { console.error("Error: --exact matches whole values, so it is an OR over --any (drop --all)."); process.exit(1); }
|
|
99
|
+
// A list of phone numbers against whatsapp_id / phone_number is a whole-value
|
|
100
|
+
// match by nature. The substring RPC runs one ILIKE per keyword over every
|
|
101
|
+
// contact and timed out at 77 numbers on a 95k-contact org (Kiah, 30 Sep
|
|
102
|
+
// 2026: 8.2 s measured); the exact path is an indexed IN and is what
|
|
103
|
+
// everyone meant anyway, so it is chosen for a digit-only list.
|
|
104
|
+
let exact = !!opts.exact;
|
|
105
|
+
const field = opts.field || "order_history";
|
|
106
|
+
if (!exact && !opts.all && ["whatsapp_id", "phone_number"].includes(field) && keywords.every((k) => /^\d{8,15}$/.test(k))) {
|
|
107
|
+
exact = true;
|
|
108
|
+
console.error(`(whole-number match: ${keywords.length} number(s) against ${field}; the substring search would scan every contact. Pass --any with a partial number for a substring match.)`);
|
|
109
|
+
}
|
|
99
110
|
const payload = {
|
|
100
|
-
mode: "field", organization_id: orgId, field_name:
|
|
101
|
-
keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive, exact
|
|
111
|
+
mode: "field", organization_id: orgId, field_name: field,
|
|
112
|
+
keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive, exact,
|
|
102
113
|
};
|
|
103
114
|
await confirmLargeWrite(orgId, payload, opts, "Field search");
|
|
104
|
-
await runMatch(
|
|
115
|
+
await runMatch(exact ? "Exact field match" : "Field search", payload, opts);
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
async function readIds(csv, file) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// node --test src/http-retry.test.mjs
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { fetchWithRetry, isNetworkError } from "./http.js";
|
|
5
|
+
|
|
6
|
+
const dropped = () => Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } });
|
|
7
|
+
const noSleep = async () => {};
|
|
8
|
+
|
|
9
|
+
test("isNetworkError: a dropped connection yes, a programming error no", () => {
|
|
10
|
+
assert.equal(isNetworkError(dropped()), true);
|
|
11
|
+
assert.equal(isNetworkError(new TypeError("Invalid URL")), false);
|
|
12
|
+
assert.equal(isNetworkError(new Error("boom")), false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("fetchWithRetry: a GET is retried after a dropped connection", async () => {
|
|
16
|
+
let calls = 0;
|
|
17
|
+
const fetchImpl = async () => { calls++; if (calls < 3) throw dropped(); return { ok: true }; };
|
|
18
|
+
const resp = await fetchWithRetry("u", {}, { method: "GET", endpoint: "store-api", fetchImpl, sleep: noSleep });
|
|
19
|
+
assert.deepEqual(resp, { ok: true });
|
|
20
|
+
assert.equal(calls, 3);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("fetchWithRetry: a GET gives up after 3 tries and says it is the network", async () => {
|
|
24
|
+
let calls = 0;
|
|
25
|
+
const fetchImpl = async () => { calls++; throw dropped(); };
|
|
26
|
+
await assert.rejects(
|
|
27
|
+
fetchWithRetry("u", {}, { method: "GET", endpoint: "store-api", fetchImpl, sleep: noSleep }),
|
|
28
|
+
(e) => e.code === "ENETWORK" && /after 3 tries/.test(e.message) && /ECONNRESET/.test(e.message)
|
|
29
|
+
);
|
|
30
|
+
assert.equal(calls, 3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("fetchWithRetry: a write is never repeated and says check before retrying", async () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
const fetchImpl = async () => { calls++; throw dropped(); };
|
|
36
|
+
await assert.rejects(
|
|
37
|
+
fetchWithRetry("u", {}, { method: "POST", endpoint: "prompts", fetchImpl, sleep: noSleep }),
|
|
38
|
+
(e) => e.code === "ENETWORK" && /may still have finished the write/.test(e.message)
|
|
39
|
+
);
|
|
40
|
+
assert.equal(calls, 1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("fetchWithRetry: a non-network error is not retried", async () => {
|
|
44
|
+
let calls = 0;
|
|
45
|
+
const fetchImpl = async () => { calls++; throw new TypeError("Invalid URL"); };
|
|
46
|
+
await assert.rejects(fetchWithRetry("u", {}, { method: "GET", fetchImpl, sleep: noSleep }), /Invalid URL/);
|
|
47
|
+
assert.equal(calls, 1);
|
|
48
|
+
});
|
package/src/http.js
CHANGED
|
@@ -118,6 +118,45 @@ export function isVercelChallenge(status, headers, text) {
|
|
|
118
118
|
return (status === 403 || status === 429) && /Vercel Security Checkpoint|vercel-challenge|_vercel\/challenge/i.test(text || "");
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// A dropped connection surfaces from undici as a bare "TypeError: fetch failed"
|
|
122
|
+
// (the real reason sits in err.cause). Several CLI calls in parallel hit it
|
|
123
|
+
// (2 Oct 2026: 30 of 57 month-by-month `shopify get --all` pulls, `flowiq gaps`
|
|
124
|
+
// #198), and every one succeeded when simply re-run. A read is safe to repeat,
|
|
125
|
+
// so GETs retry twice with a short backoff; a write is never repeated blind,
|
|
126
|
+
// because it may have reached the server before the connection dropped.
|
|
127
|
+
const NETWORK_ERROR = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|UND_ERR_|socket hang up|other side closed/i;
|
|
128
|
+
|
|
129
|
+
export function isNetworkError(e) {
|
|
130
|
+
const text = [e?.message, e?.code, e?.cause?.code, e?.cause?.message].filter(Boolean).join(" ");
|
|
131
|
+
return NETWORK_ERROR.test(text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function fetchWithRetry(url, init, { method = "GET", endpoint = "", fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), attempts } = {}) {
|
|
135
|
+
const max = attempts ?? (method === "GET" ? 3 : 1);
|
|
136
|
+
for (let i = 1; ; i++) {
|
|
137
|
+
try {
|
|
138
|
+
return await fetchImpl(url, init);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
if (!isNetworkError(e)) throw e;
|
|
141
|
+
const reason = e?.cause?.code || e?.cause?.message || e?.message || "network error";
|
|
142
|
+
if (i < max) {
|
|
143
|
+
await sleep(400 * 2 ** (i - 1) + Math.floor(Math.random() * 250));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const err = new Error(
|
|
147
|
+
method === "GET"
|
|
148
|
+
? `${method} ${endpoint} could not reach the API after ${max} tries (${reason}). ` +
|
|
149
|
+
`This is the network, not the request: run it again, and run fewer at once.`
|
|
150
|
+
: `${method} ${endpoint} lost its connection (${reason}). The server may still have finished the write: ` +
|
|
151
|
+
`check the result (the command's list / status / pull verb, or flowiq audit) before retrying.`
|
|
152
|
+
);
|
|
153
|
+
err.code = "ENETWORK";
|
|
154
|
+
err.cause = e;
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
async function call(method, endpoint, { query, body } = {}) {
|
|
122
161
|
const cfg = await loadConfig();
|
|
123
162
|
if (!cfg.token) {
|
|
@@ -148,7 +187,7 @@ async function call(method, endpoint, { query, body } = {}) {
|
|
|
148
187
|
init.body = JSON.stringify(body);
|
|
149
188
|
}
|
|
150
189
|
|
|
151
|
-
const resp = await
|
|
190
|
+
const resp = await fetchWithRetry(url, init, { method, endpoint });
|
|
152
191
|
noteServerContract(resp);
|
|
153
192
|
const text = await resp.text();
|
|
154
193
|
let parsed, isJson = true;
|
package/src/index.js
CHANGED
|
@@ -148,9 +148,20 @@ export function run(argv) {
|
|
|
148
148
|
.option("--until <when>", "upper bound")
|
|
149
149
|
.option("--sender-type <list>", "comma list, e.g. bot-whatsapp or user-whatsapp,user-web (default: conversation rows only)")
|
|
150
150
|
.option("--include-templates", "also match template / broadcast payloads")
|
|
151
|
+
.option("--tool-calls", "search inside tool-call rows' request / response instead of message text (what a tool was asked and answered)")
|
|
151
152
|
.option("--limit <n>", "max rows (default 200, max 1000)")
|
|
152
153
|
.option("--json", "raw JSON (rows carry contact_id)")
|
|
153
154
|
.action((orgId, text, opts) => messagesCmd.search(orgId, text, opts));
|
|
155
|
+
msgs.command("purge <organization_id>")
|
|
156
|
+
.description("DELETE messages org-wide by pattern: email subject regex, text, sender types, time window (5,000 a run). Dry run by default; --confirm asks you to type the org id")
|
|
157
|
+
.option("--subject-regex <re>", "case-insensitive regex on the email subject (more_data.subject), e.g. '^Automatic reply'")
|
|
158
|
+
.option("--text <substr>", "message text contains this")
|
|
159
|
+
.option("--sender-type <list>", "comma list, e.g. user-email")
|
|
160
|
+
.option("--since <when>", "only rows newer than this (30d | YYYY-MM-DD)")
|
|
161
|
+
.option("--before <when>", "only rows older than this")
|
|
162
|
+
.option("--confirm", "actually delete (typed confirmation follows)")
|
|
163
|
+
.option("--json", "raw JSON")
|
|
164
|
+
.action((orgId, opts) => messagesCmd.purge(orgId, opts));
|
|
154
165
|
msgs.command("failures <organization_id>")
|
|
155
166
|
.description("What failed to send and why (Meta error codes by reason / media / contact), plus sends Meta accepted and never confirmed")
|
|
156
167
|
.option("--since <when>", "7d (default) | 24h | 30d | YYYY-MM-DD")
|
|
@@ -484,9 +495,13 @@ export function run(argv) {
|
|
|
484
495
|
.option("--json", "raw JSON")
|
|
485
496
|
.action((orgId, opts) => orgCmd.health(orgId, opts));
|
|
486
497
|
org.command("automations <organization_id>")
|
|
487
|
-
.description("Automated order messages (automated_messages_config): each type, its template and whether that template is approved")
|
|
498
|
+
.description("Automated order messages (automated_messages_config): each type, its template and whether that template is approved; --set changes a type's template or mapping")
|
|
488
499
|
.option("--enable [type]", "switch a type on (or the whole feature with no type); dry run unless --commit")
|
|
489
500
|
.option("--disable [type]", "switch a type off (or the whole feature)")
|
|
501
|
+
.option("--set <type>", "change this type: with --template / --body / --buttons")
|
|
502
|
+
.option("--template <name>", "with --set: the template name to send (must exist in FlowIQ's templates; approval is checked and warned)")
|
|
503
|
+
.option("--body <map>", "with --set: body param mapping, param1=full_name,param2=order_number (\"none\" clears)")
|
|
504
|
+
.option("--buttons <map>", "with --set: button param mapping, param1=short_code (\"none\" clears)")
|
|
490
505
|
.option("--commit", "write the change")
|
|
491
506
|
.option("--json", "raw JSON")
|
|
492
507
|
.action((orgId, opts) => orgCmd.automations(orgId, opts));
|
|
@@ -694,6 +709,27 @@ export function run(argv) {
|
|
|
694
709
|
.option("--json", "print the raw response")
|
|
695
710
|
.option("--out <file>", "also write the full plan JSON to a file")
|
|
696
711
|
.action((planId, opts) => plansCmd.show(planId, opts));
|
|
712
|
+
plans.command("create <organization_id>")
|
|
713
|
+
.description("File a plan on the client's Planning board (the MCP submit_plan row; staff are exempt from the client lead time). Dry run unless --commit")
|
|
714
|
+
.requiredOption("--topic <text>", "campaign name (under 120 chars)")
|
|
715
|
+
.requiredOption("--type <t>", "utility | marketing")
|
|
716
|
+
.requiredOption("--date <YYYY-MM-DD>", "send date (SAST)")
|
|
717
|
+
.option("--time <HH:MM>", "send time SAST (omit = any time that day)")
|
|
718
|
+
.option("--product <text>", "product or collection it is about")
|
|
719
|
+
.option("--audience <text>", "who it goes to, in words (omit = the whole opted-in list)")
|
|
720
|
+
.option("--line-1 <text>", "utility: first sentence after \"Hi [Name],\"")
|
|
721
|
+
.option("--line-2 <text>", "utility: second paragraph")
|
|
722
|
+
.option("--follow-up <text>", "utility: the message VIEW MORE sends")
|
|
723
|
+
.option("--landing <url>", "utility: the https:// page CLICK HERE opens")
|
|
724
|
+
.option("--message <text>", "marketing: the full first message (≤ 1,024 chars)")
|
|
725
|
+
.option("--button <spec>", "marketing button, repeatable: quick_reply:TEXT | url:TEXT=https://… | phone:TEXT=+27… | copy_code:CODE", (v, acc) => (acc || []).concat([v]), [])
|
|
726
|
+
.option("--reply <text=follow-up>", "marketing: the message a quick_reply button sends (repeatable)", (v, acc) => (acc || []).concat([v]), [])
|
|
727
|
+
.option("--graphic <brief>", "have Flowapt design the image: describe it")
|
|
728
|
+
.option("--media <url>", "use your own public https:// image or video instead")
|
|
729
|
+
.option("--draft", "save as a draft (nobody alerted) instead of filing for review")
|
|
730
|
+
.option("--commit", "file it")
|
|
731
|
+
.option("--json", "raw JSON")
|
|
732
|
+
.action((orgId, opts) => plansCmd.create(orgId, opts));
|
|
697
733
|
plans.command("status <plan_id>")
|
|
698
734
|
.description("Change a plan's status (dry run unless --commit); approved/rejected also record you as the reviewer")
|
|
699
735
|
.requiredOption("--to <status>", "draft | pending_review | approved | rejected | scheduled | sent | cancelled")
|
|
@@ -710,9 +746,29 @@ export function run(argv) {
|
|
|
710
746
|
.option("--limit <n>", "max matches (default 20, max 100)").option("--json", "raw JSON")
|
|
711
747
|
.action((orgId, opts) => contactsCmd.find(orgId, opts));
|
|
712
748
|
contacts.command("show <contact_id>")
|
|
713
|
-
.description("One contact's record + inbox state (
|
|
714
|
-
.
|
|
749
|
+
.description("One contact's record + inbox state (the org is resolved from the id; --org to pin it)")
|
|
750
|
+
.option("--org <organization_id>", "the contact's org (optional since 0.12.0)").option("--json", "raw JSON")
|
|
715
751
|
.action((contactId, opts) => contactsCmd.show(contactId, opts));
|
|
752
|
+
contacts.command("add <organization_id>")
|
|
753
|
+
.description("Create one contact by hand (a tester, a team member for a test send). Dry run unless --commit")
|
|
754
|
+
.requiredOption("--whatsapp-id <digits>", "the number with country code, e.g. 27637186304")
|
|
755
|
+
.option("--name <name>", "full_name")
|
|
756
|
+
.option("--email <email>", "email")
|
|
757
|
+
.option("--no-broadcast", "allow_broadcast false (default: true, like the inbox)")
|
|
758
|
+
.option("--ai-off", "bot_status false (default: on)")
|
|
759
|
+
.option("--tag <csv>", "tags to start with")
|
|
760
|
+
.option("--commit", "create it")
|
|
761
|
+
.option("--json", "raw JSON")
|
|
762
|
+
.action((orgId, opts) => contactsCmd.add(orgId, opts));
|
|
763
|
+
contacts.command("ai <on|off> <organization_id>")
|
|
764
|
+
.description("Switch the AI agent on or off for every contact on a tag (or an id list), writing the agent-on/off row each chat shows. Dry run unless --commit")
|
|
765
|
+
.option("--tag <tag>", "every contact carrying this tag")
|
|
766
|
+
.option("--contact <id>", "contact UUID (repeatable)", (v, acc) => (acc || []).concat([v]), [])
|
|
767
|
+
.option("--ids-file <path>", "file of contact UUIDs")
|
|
768
|
+
.option("--reason <text>", "noted on the agent-on/off row and the audit")
|
|
769
|
+
.option("--commit", "write bot_status + the marker rows")
|
|
770
|
+
.option("--json", "raw JSON")
|
|
771
|
+
.action((state, orgId, opts) => contactsCmd.ai(state, orgId, opts));
|
|
716
772
|
contacts.command("census <organization_id>")
|
|
717
773
|
.description("Archived chats that still carry unread messages or where the customer wrote last")
|
|
718
774
|
.option("--json", "raw JSON")
|
|
@@ -908,6 +964,24 @@ export function run(argv) {
|
|
|
908
964
|
.option("--timeout <ms>", "per-turn HTTP timeout")
|
|
909
965
|
.option("--json", "print raw JSON results")
|
|
910
966
|
.action((orgId, opts) => testCmd.qa(orgId, opts));
|
|
967
|
+
test.command("seed <organization_id>")
|
|
968
|
+
.description("Copy a real chat onto a NEW test contact (test_seed_*, tag test-contact, no broadcasts, AI off) to reproduce an agent or Coworker answer. Dry run unless --commit")
|
|
969
|
+
.requiredOption("--from <contact_id>", "the real contact whose thread is copied")
|
|
970
|
+
.option("--until <time>", "copy rows up to this SAST time, e.g. \"2026-10-01 09:08\" or an ISO time")
|
|
971
|
+
.option("--rows <n>", "newest rows to copy (default 60, max 500)")
|
|
972
|
+
.option("--name <name>", "the seeded contact's display name (default: \"<name> (seeded test)\")")
|
|
973
|
+
.option("--with-tools", "also copy tool-call rows")
|
|
974
|
+
.option("--copy-attributes", "copy the contact's attributes (dog name, weight, …)")
|
|
975
|
+
.option("--ai-on", "leave the agent ON for the seeded contact (default off)")
|
|
976
|
+
.option("--agent <id>", "scope to a specific agent (audit only)")
|
|
977
|
+
.option("--commit", "create it")
|
|
978
|
+
.option("--json", "raw JSON")
|
|
979
|
+
.action((orgId, opts) => testCmd.seed(orgId, opts));
|
|
980
|
+
test.command("unseed <organization_id> <contact_id>")
|
|
981
|
+
.description("DELETE a seeded test contact (test_seed_* only) and its copied rows")
|
|
982
|
+
.option("--confirm", "actually delete")
|
|
983
|
+
.option("--json", "raw JSON")
|
|
984
|
+
.action((orgId, contactId, opts) => testCmd.unseed(orgId, contactId, opts));
|
|
911
985
|
test.command("clear <organization_id>")
|
|
912
986
|
.description("Clear the test conversation (hide history via memory_cutoff)")
|
|
913
987
|
.option("--agent <id>", "target a specific agent's test contact")
|
|
@@ -1267,6 +1341,12 @@ export function run(argv) {
|
|
|
1267
1341
|
.description("Members with role, name, email, last sign-in; pending invitations")
|
|
1268
1342
|
.option("--json", "raw JSON")
|
|
1269
1343
|
.action((orgId, opts) => membersCmd.list(orgId, opts));
|
|
1344
|
+
members.command("invite <organization_id> <email>")
|
|
1345
|
+
.description("Invite someone who has NO account yet (an invitations row they accept at sign-up; FlowIQ sends no email)")
|
|
1346
|
+
.option("--role <role>", "admin | member | viewer", "member")
|
|
1347
|
+
.option("--dry-run", "show what would be written")
|
|
1348
|
+
.option("--json", "raw JSON")
|
|
1349
|
+
.action((orgId, email, opts) => membersCmd.invite(orgId, email, opts));
|
|
1270
1350
|
members.command("add <organization_id> <email>")
|
|
1271
1351
|
.description("Add an EXISTING account to the org (someone with no account must be invited from the app)")
|
|
1272
1352
|
.option("--role <role>", "admin | member | viewer", "member")
|
|
@@ -1363,6 +1443,7 @@ export function run(argv) {
|
|
|
1363
1443
|
shopify.command("get <organization_id> <path>")
|
|
1364
1444
|
.description('GET any Shopify Admin REST endpoint, e.g. `get <org> orders.json --q status=any` (".json" optional)')
|
|
1365
1445
|
.option("--q <k=v>", "query parameter (repeatable), e.g. --q status=any --q limit=250", storeApiCmd.collectQuery, {})
|
|
1446
|
+
.option("--fields <csv>", "return only these fields per record (Shopify REST `fields`); the way past the 3.5 MB cap on big lists")
|
|
1366
1447
|
.option("--all", "follow Link rel=next and return every page (bounded by --max-pages and a size/time cap)")
|
|
1367
1448
|
.option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
|
|
1368
1449
|
.option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
|
|
@@ -1398,6 +1479,7 @@ export function run(argv) {
|
|
|
1398
1479
|
woo.command("get <organization_id> <path>")
|
|
1399
1480
|
.description("GET any WooCommerce REST endpoint, e.g. `get <org> orders --q status=completed`")
|
|
1400
1481
|
.option("--q <k=v>", "query parameter (repeatable), e.g. --q status=completed --q per_page=100", storeApiCmd.collectQuery, {})
|
|
1482
|
+
.option("--fields <csv>", "return only these fields per record (Woo `_fields`), e.g. id,name,permalink,status; the way past the 3.5 MB cap on a big catalogue")
|
|
1401
1483
|
.option("--namespace <ns>", "REST namespace (default wc/v3; also wc-analytics, wp/v2)")
|
|
1402
1484
|
.option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
|
|
1403
1485
|
.option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
|