@flowapt/flowiq-cli 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -2
- package/TEAM-GUIDE.md +23 -2
- package/package.json +1 -1
- package/src/batch-f.test.mjs +63 -0
- package/src/batch-g.test.mjs +35 -0
- package/src/commands/agent-test.js +44 -3
- package/src/commands/agents.js +31 -0
- package/src/commands/broadcast.js +19 -0
- package/src/commands/contacts.js +43 -2
- package/src/commands/links.js +63 -20
- package/src/commands/members.js +108 -0
- package/src/commands/messages.js +69 -3
- package/src/commands/notes.js +63 -0
- package/src/commands/org.js +77 -10
- package/src/commands/plans.js +50 -0
- package/src/commands/products.js +58 -0
- package/src/commands/prompts.js +46 -1
- package/src/commands/segments.js +47 -14
- package/src/commands/store-api.js +11 -3
- package/src/commands/tag.js +89 -8
- package/src/commands/templates.js +66 -5
- package/src/index.js +232 -9
package/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
|
|
@@ -2148,7 +2158,7 @@ flowiq rules disable <rule_id>
|
|
|
2148
2158
|
### Org views — `flowiq org channels|whatsapp|health|automations|abandoned` (0.10.0)
|
|
2149
2159
|
|
|
2150
2160
|
```bash
|
|
2151
|
-
flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads, Zernio
|
|
2161
|
+
flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads (FlowIQ app or Zernio route), Zernio
|
|
2152
2162
|
flowiq org channels --all # census across every active org
|
|
2153
2163
|
flowiq org whatsapp <org> # the number's health straight from Meta: status, quality, tier, name status, webhook subscription
|
|
2154
2164
|
flowiq org whatsapp --all # every Meta org, unhealthy first
|
|
@@ -2160,7 +2170,13 @@ flowiq org abandoned <org> --disable 2 --commit
|
|
|
2160
2170
|
```
|
|
2161
2171
|
|
|
2162
2172
|
`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".
|
|
2173
|
+
exhausted or invalid key shows as such rather than "present". It probes the
|
|
2174
|
+
spare key (`feature_flags.spare_openai_key`) too: the agent, product search and
|
|
2175
|
+
both product syncs fall back to it, so a dead primary with a working spare is a
|
|
2176
|
+
`⚠` warning, not a problem (1 Oct 2026). The webhook count covers both hosts our
|
|
2177
|
+
Shopify webhooks use (`auth.flowiq.live` and the older `supabase.co`); before
|
|
2178
|
+
1 Oct 2026 it counted `supabase.co` only and read 0 on every org registered by
|
|
2179
|
+
`flowiq webhooks push`.
|
|
2164
2180
|
|
|
2165
2181
|
### Messages, org-wide — `flowiq messages search|failures|stats` (0.10.0)
|
|
2166
2182
|
|
|
@@ -2182,6 +2198,111 @@ flowiq messages stats <org> --since 6m
|
|
|
2182
2198
|
(`cli_message_volume_summary`): template = broadcast_id, JSON payload, or no
|
|
2183
2199
|
customer message in the preceding 24 h.
|
|
2184
2200
|
|
|
2201
|
+
### Members · users · steers · notes · products (0.11.0)
|
|
2202
|
+
|
|
2203
|
+
Batch F of the request list. Three new topics, plus a long tail of verbs on
|
|
2204
|
+
existing ones (next section).
|
|
2205
|
+
|
|
2206
|
+
```bash
|
|
2207
|
+
flowiq members list <org> # role · name · email · last sign-in · pending invitations
|
|
2208
|
+
flowiq members add <org> <email> --role member # an EXISTING account (no account → invite from the app)
|
|
2209
|
+
flowiq members role <org> <email> admin|member|viewer # the last admin cannot be demoted
|
|
2210
|
+
flowiq members rename <email> "Full Name" # profiles.full_name, shown in every org
|
|
2211
|
+
flowiq members remove <org> <email> --confirm # owner + last admin refused
|
|
2212
|
+
flowiq users lookup <email> # sign-in method, confirmation, last sign-in, every org + role
|
|
2213
|
+
flowiq steer list <contact_id> · steer add <contact_id> "Offer the 10% code" [--once] · steer clear <contact_id> [--id <id>] --confirm
|
|
2214
|
+
flowiq notes list <contact_id> · notes add <contact_id> "Vet to CS: …"
|
|
2215
|
+
flowiq products status <org> · products search <org> "whey protein" · products reconcile <org> --confirm
|
|
2216
|
+
```
|
|
2217
|
+
|
|
2218
|
+
- **`steer`** is the inbox's supervisor instruction: vert injects every
|
|
2219
|
+
unconsumed steer into the agent's history. An ongoing steer applies to EVERY
|
|
2220
|
+
reply until it is deleted, and the inbox has no delete, which is why
|
|
2221
|
+
`steer clear` exists (dry-run; the texts stay in the audit row).
|
|
2222
|
+
- **`products search`** calls the agent's own `fuzzy_match_products` RPC with
|
|
2223
|
+
the agent's threshold (0.15), so "what does the agent find for X?" is answered
|
|
2224
|
+
without a test send. `products status` counts rows with no embedding (the
|
|
2225
|
+
agent cannot find those by meaning) and shows the last syncs; `reconcile`
|
|
2226
|
+
queues the org on `products-reconcile-scheduler` (re-sync + re-embed).
|
|
2227
|
+
- **`members add` takes an existing account only.** Someone who has never
|
|
2228
|
+
signed up goes through the app's invite flow (an invitation row + email);
|
|
2229
|
+
the CLI says so rather than minting a login.
|
|
2230
|
+
|
|
2231
|
+
### The 0.11.0 long tail (verbs added to existing topics)
|
|
2232
|
+
|
|
2233
|
+
```bash
|
|
2234
|
+
flowiq links shorten <org> --url <u> --slug spring-sale --commit # readable code; a taken slug falls back and says so
|
|
2235
|
+
flowiq links list <org> --code <code> | --to /collections/x | --all # one code · by destination · every page (500 a call)
|
|
2236
|
+
flowiq links repoint <org> <code> --to <url> --commit # move a live code (clicks + messages keep it)
|
|
2237
|
+
flowiq org list --number 27609751832 # the org behind a WhatsApp number / phone id / WABA
|
|
2238
|
+
flowiq org agent <org> on|off --commit [--yes] # the master switch; ON for everyone needs --yes
|
|
2239
|
+
flowiq org park <org> [--unpark] --confirm # organizations.inactive
|
|
2240
|
+
flowiq org flags set <org> product-tag-notes @notes.json # {tag: sentence}; instagram_comments.* are objects now
|
|
2241
|
+
flowiq agent flags [--tool product_lookup] [--model luna] # every active agent's model + tool gates
|
|
2242
|
+
flowiq tag ids <org> --ids-file ids.txt --tag vip --commit # one tag on an explicit id list
|
|
2243
|
+
flowiq tag holders <org> vip [--all] [--csv] # who carries a tag
|
|
2244
|
+
flowiq tag field <org> --field whatsapp_id --any 27794975464 --exact # whole value, not a substring
|
|
2245
|
+
flowiq tag remove <org> vip --contacts a,b --confirm # only those contacts lose it
|
|
2246
|
+
flowiq prompts push <slug> --dry-run # added / removed / changed sections, nothing written
|
|
2247
|
+
flowiq bc scheduled list <org> --detail # the queued send's body / button values + header
|
|
2248
|
+
flowiq templates show <org> <name> # falls back to Meta when FlowIQ has no row
|
|
2249
|
+
flowiq templates attempts <org> --name x # + Meta's status / category history ("CATEGORY MOVED")
|
|
2250
|
+
flowiq test send <org> "hi" --tools --sender "Sofia" # tool arguments + results; renames a synthetic test contact
|
|
2251
|
+
flowiq messages pull <contact_id> # footer: sent by agent / broadcast / automation: <template>
|
|
2252
|
+
flowiq messages delete <contact_id> [--before 30d] [--sender-type tool-call] --confirm
|
|
2253
|
+
```
|
|
2254
|
+
|
|
2255
|
+
- **`seg apply` sends one request per 4,000 ids** (the server already split its
|
|
2256
|
+
DB calls; one request still timed out on 18k), and a failure names the chunk;
|
|
2257
|
+
re-running is idempotent. **`seg plan --start-index N`** writes
|
|
2258
|
+
`<slug>-from-NN.json` instead of overwriting the earlier plan (the record of
|
|
2259
|
+
who got the earlier batches); an applied plan file is never overwritten.
|
|
2260
|
+
- **`bc send` audit rows and the `[CLI]` email now carry the audience** (python
|
|
2261
|
+
reports no count on a real fire; the server counts the tag's broadcast-safe
|
|
2262
|
+
contacts itself).
|
|
2263
|
+
- **`org agent`** is `organizations.wati_webhook_status`. Off = no customer gets
|
|
2264
|
+
the AI. On with `vert.active false` + `vert.allowed_whatsapp_ids` = test mode.
|
|
2265
|
+
On with `vert.active` not false = live for every customer, which needs
|
|
2266
|
+
`--yes`; the command prints which of the three it would produce.
|
|
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
|
+
|
|
2185
2306
|
### Guide — `flowiq guide`
|
|
2186
2307
|
|
|
2187
2308
|
Read the bundled docs in the terminal — no digging through node_modules.
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -143,12 +143,33 @@ 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
|
-
| 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
|
+
| 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>` |
|
|
150
151
|
| 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** |
|
|
151
152
|
| 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 |
|
|
153
|
+
| Who is on a client's account, and give someone access | `flowiq members list <org_id>` · `flowiq members add <org_id> <email> --role member` (they must already have an account; otherwise invite from the app) · `flowiq members role <org_id> <email> admin` |
|
|
154
|
+
| "I can't log in" / which orgs is this person on? | `flowiq users lookup <email>`: sign-in method, email confirmed or not, last sign-in, every org and role |
|
|
155
|
+
| Tell the agent how to handle ONE customer, or stop an ongoing steer | `flowiq steer add <contact_id> "Offer the 10% code once"` (`--once` for the next reply only) · `flowiq steer list <contact_id>` · `flowiq steer clear <contact_id> --confirm` |
|
|
156
|
+
| Leave a note for the team on a contact | `flowiq notes add <contact_id> "Vet to CS: …"` |
|
|
157
|
+
| The agent cannot find a product the store has | `flowiq products search <org_id> "<what the customer typed>"` (the agent's own match) · `flowiq products status <org_id>` (rows with no embedding) · `flowiq products reconcile <org_id> --confirm` |
|
|
158
|
+
| Which org owns this WhatsApp number? | `flowiq org list --number 27609751832` |
|
|
159
|
+
| Switch an org's agent off (or on) for every customer | `flowiq org agent <org_id> off --commit` · back on: `flowiq org agent <org_id> on --commit --yes` (test mode first: `org flags set <org> vert.allowed_whatsapp_ids <numbers>` + `vert.active false`) |
|
|
160
|
+
| A short link points at the wrong page | `flowiq links list <org_id> --code <code>` to see it · `flowiq links repoint <org_id> <code> --to <url> --commit` |
|
|
161
|
+
| Check a prompt edit before it goes live | `flowiq prompts push <slug> --dry-run` — added / removed / changed sections against the live agent |
|
|
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` |
|
|
163
|
+
| Why did the agent answer that? | `flowiq test send <org_id> "<the question>" --tools` shows each tool's arguments and result |
|
|
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` |
|
|
152
173
|
> **Publishing the CLI (maintainers only):** publish from a clean clone, never
|
|
153
174
|
> from your working tree — `npm publish` packs whatever is on disk. A
|
|
154
175
|
> `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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,63 @@
|
|
|
1
|
+
// node --test src/batch-f.test.mjs — pure helpers behind the 0.11.0 topics.
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { diffSections } from "./commands/prompts.js";
|
|
5
|
+
import { sentBy } from "./commands/messages.js";
|
|
6
|
+
process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
|
|
7
|
+
process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
|
|
8
|
+
const reg = await import("../../api/_shared/feature-flag-registry.js");
|
|
9
|
+
|
|
10
|
+
test("diffSections reports added / removed / changed by id and ignores untouched sections", () => {
|
|
11
|
+
const live = [
|
|
12
|
+
{ id: "a", title: "Main", content: "hello" },
|
|
13
|
+
{ id: "b", title: "Hours", content: "9-5", hidden: false },
|
|
14
|
+
{ id: "c", title: "Old", content: "gone" },
|
|
15
|
+
];
|
|
16
|
+
const local = [
|
|
17
|
+
{ id: "a", title: "Main", content: "hello" },
|
|
18
|
+
{ id: "b", title: "Hours", content: "9-6", hidden: true, channels: ["web"] },
|
|
19
|
+
{ id: "d", title: "New", content: "x".repeat(12) },
|
|
20
|
+
];
|
|
21
|
+
const d = diffSections(live, local);
|
|
22
|
+
assert.deepEqual(d.added, [{ title: "New", chars: 12 }]);
|
|
23
|
+
assert.deepEqual(d.removed, [{ title: "Old", chars: 4 }]);
|
|
24
|
+
assert.equal(d.changed.length, 1);
|
|
25
|
+
assert.equal(d.changed[0].title, "Hours");
|
|
26
|
+
assert.match(d.changed[0].what.join("; "), /content 3 → 3 chars; hidden false → true; channels "all" → \["web"\]/);
|
|
27
|
+
assert.equal(d.unchanged, false);
|
|
28
|
+
assert.equal(diffSections(live, live).unchanged, true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("diffSections notices a pure reorder", () => {
|
|
32
|
+
const live = [{ id: "a", title: "A", content: "1" }, { id: "b", title: "B", content: "2" }];
|
|
33
|
+
const d = diffSections(live, [live[1], live[0]]);
|
|
34
|
+
assert.equal(d.reordered, true);
|
|
35
|
+
assert.equal(d.unchanged, false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("sentBy labels broadcast, automation, rule and agent rows", () => {
|
|
39
|
+
assert.equal(sentBy({ broadcast_id: "x" }), "broadcast");
|
|
40
|
+
assert.equal(sentBy({ notification_template: "order_confirm_v1", notification_event: "orders/paid" }), "automation: order_confirm_v1 (orders/paid)");
|
|
41
|
+
assert.equal(sentBy({ notification_rule_id: "38f20704-aaaa-bbbb-cccc-dddddddddddd" }), "rule 38f20704");
|
|
42
|
+
assert.equal(sentBy({ sender_type: "bot-whatsapp" }), "agent");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("product-tag-notes validator takes a {tag: sentence} map and refuses empties", () => {
|
|
46
|
+
const spec = reg.FLAG_REGISTRY["product-tag-notes"];
|
|
47
|
+
assert.deepEqual(reg.validateFlagValue(spec, '{"Free2024":" Ships free. "}'), { value: { Free2024: "Ships free." } });
|
|
48
|
+
assert.match(reg.validateFlagValue(spec, { Free2024: "" }).error, /non-empty sentence/);
|
|
49
|
+
assert.match(reg.validateFlagValue(spec, {}).error, /empty/);
|
|
50
|
+
assert.match(reg.validateFlagValue(spec, "true").error, /must be an object/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("instagram_comments legs are objects with a bounded message", () => {
|
|
54
|
+
const leg = reg.FLAG_REGISTRY["instagram_comments.private_reply"];
|
|
55
|
+
assert.equal(leg.type, "object");
|
|
56
|
+
assert.match(reg.validateFlagValue(leg, "true").error, /must be an object/);
|
|
57
|
+
assert.deepEqual(reg.validateFlagValue(leg, { enabled: true, message: " hi " }), { value: { enabled: true, message: "hi" } });
|
|
58
|
+
assert.match(reg.validateFlagValue(leg, { enabled: true }).error, /message is empty/);
|
|
59
|
+
assert.match(reg.validateFlagValue(leg, { enabled: true, message: "x".repeat(1001) }).error, /over 1000/);
|
|
60
|
+
const whole = reg.FLAG_REGISTRY["instagram_comments"];
|
|
61
|
+
assert.deepEqual(reg.validateFlagValue(whole, { enabled: true, ads_only: false, public_reply: { enabled: false } }), { value: { enabled: true, ads_only: false, public_reply: { enabled: false } } });
|
|
62
|
+
assert.match(reg.validateFlagValue(whole, { bogus: 1 }).error, /not a known key/);
|
|
63
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -72,10 +72,22 @@ function checkExpectations(turn, replyText) {
|
|
|
72
72
|
return results;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
function printTurn(sc, t, userText, r, checks) {
|
|
75
|
+
function printTurn(sc, t, userText, r, checks, opts = {}) {
|
|
76
76
|
if (t === 0) console.log(`\n━━ ${sc.tq ? sc.tq + " · " : ""}${sc.title || sc.id} ━━`);
|
|
77
77
|
console.log(`\n 🧑 ${userText}`);
|
|
78
78
|
if (r.tool_calls?.length) console.log(` ⚙ tools: ${r.tool_calls.join(", ")}`);
|
|
79
|
+
// --tools: what each tool was asked and what it answered (the tool-call rows
|
|
80
|
+
// vert writes). Without it a wrong answer could not be traced to the tool
|
|
81
|
+
// result that produced it.
|
|
82
|
+
if (opts.tools && r.tool_details?.length) {
|
|
83
|
+
for (const d of r.tool_details) {
|
|
84
|
+
console.log(` ⚙ ${d.tool_name}${d.duration_ms != null ? ` (${d.duration_ms} ms)` : ""}${d.error ? ` ERROR ${d.error}` : ""}`);
|
|
85
|
+
if (d.request != null) console.log(` in: ${String(d.request).split("\n").join("\n ")}`);
|
|
86
|
+
if (d.response != null) console.log(` out: ${String(d.response).split("\n").join("\n ")}`);
|
|
87
|
+
}
|
|
88
|
+
} else if (opts.tools && r.tool_calls?.length) {
|
|
89
|
+
console.log(` (tool arguments / results are not stored for this org: switch on feature flag tool_logging, or the API predates 1 Oct 2026)`);
|
|
90
|
+
}
|
|
79
91
|
if (r.reply) console.log(` 🤖 ${r.reply.split("\n").join("\n ")}`);
|
|
80
92
|
else console.log(` ⚠ no reply — status=${r.status}${r.raw?.error ? " (" + r.raw.error + ")" : ""}`);
|
|
81
93
|
for (const c of checks || []) {
|
|
@@ -103,7 +115,7 @@ export async function send(orgId, messageParts, opts = {}) {
|
|
|
103
115
|
printWarnings(r);
|
|
104
116
|
if (opts.clear && r.previous_memory_cutoff !== undefined) console.log(`# cleared first; previous memory_cutoff: ${r.previous_memory_cutoff ?? "(none)"}`);
|
|
105
117
|
console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
|
|
106
|
-
printTurn({ title: "send", tq: "" }, 0, message, r, []);
|
|
118
|
+
printTurn({ title: "send", tq: "" }, 0, message, r, [], { tools: !!opts.tools });
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
async function runPack(orgId, pack, opts) {
|
|
@@ -128,7 +140,7 @@ async function runPack(orgId, pack, opts) {
|
|
|
128
140
|
}
|
|
129
141
|
const checks = checkExpectations(turn, r.reply);
|
|
130
142
|
scResult.turns.push({ ...r, checks });
|
|
131
|
-
if (!opts.json) printTurn(sc, t, turn.text, r, checks);
|
|
143
|
+
if (!opts.json) printTurn(sc, t, turn.text, r, checks, { tools: !!opts.tools });
|
|
132
144
|
}
|
|
133
145
|
results.push(scResult);
|
|
134
146
|
}
|
|
@@ -153,6 +165,35 @@ export async function qa(orgId, opts = {}) {
|
|
|
153
165
|
await runPack(orgId, pack, opts);
|
|
154
166
|
}
|
|
155
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
|
+
|
|
156
197
|
export async function clear(orgId, opts = {}) {
|
|
157
198
|
requireOrg(orgId);
|
|
158
199
|
let resp;
|
package/src/commands/agents.js
CHANGED
|
@@ -32,6 +32,37 @@ export async function list(orgId, opts = {}) {
|
|
|
32
32
|
console.log("\n★ = active agent. Pass any id as --agent <id> to prompts/questionnaires/fine-tuning/agent config.");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// `flowiq agent flags [--tool product_lookup] [--model x] [--all] [--json]` —
|
|
36
|
+
// every active org's active agent: model, reasoning, prompt switch and the
|
|
37
|
+
// tool gates, one row per org. Filters narrow to the orgs that matter.
|
|
38
|
+
export async function flags(opts = {}) {
|
|
39
|
+
let resp;
|
|
40
|
+
try { resp = await http.get("agents", { census: "flags", all: opts.all ? "1" : undefined }); }
|
|
41
|
+
catch (e) { console.error(`Census failed: ${e.message}`); process.exit(1); }
|
|
42
|
+
let rows = resp.agents || [];
|
|
43
|
+
if (opts.tool) {
|
|
44
|
+
const [k, v] = String(opts.tool).split("=");
|
|
45
|
+
const want = v === undefined ? true : !["false", "0", "off", "no"].includes(v.toLowerCase());
|
|
46
|
+
rows = rows.filter((r) => !r.missing && (r.tools?.[k] === want || (want === false && r.tools?.[k] === undefined)));
|
|
47
|
+
}
|
|
48
|
+
if (opts.model) rows = rows.filter((r) => String(r.model || "").includes(opts.model));
|
|
49
|
+
if (opts.json) { console.log(JSON.stringify({ ...resp, count: rows.length, agents: rows }, null, 2)); return; }
|
|
50
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
51
|
+
const short = { product_lookup: "lookup", ticket_tool_status: "tickets", postal_code_tool_status: "postal", restock_tool: "restock", view_cart_tool: "cart", block_tool_status: "block", email_request_tool: "email-req", shopify_products_web_chat: "web-products", woo_order_build: "woo-order", woo_tip_field: "woo-tip", woo_order_note_field: "woo-note", collapse_product_variants: "collapse", silent_option: "silent" };
|
|
52
|
+
console.log(`${rows.length} active agent(s)${opts.tool ? ` with ${opts.tool}` : ""}${opts.model ? ` on model ~${opts.model}` : ""}`);
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log(` ${pad("ORG", 26)} ${pad("AGENT", 18)} ${pad("MODEL", 20)} ${pad("REASON", 7)} ${pad("PROMPT", 9)} ${pad("SW", 3)} TOOLS ON`);
|
|
55
|
+
for (const r of rows) {
|
|
56
|
+
if (r.missing) { console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ⚠ active_whatsapp_agent ${r.agent_id} does not exist`); continue; }
|
|
57
|
+
const on = Object.entries(r.tools || {}).filter(([, v]) => v).map(([k]) => short[k] || k);
|
|
58
|
+
if (r.discount) on.push("discount");
|
|
59
|
+
if (r.custom_tools) on.push(`${r.custom_tools} custom`);
|
|
60
|
+
if (r.disabled_base_tools?.length) on.push(`-${r.disabled_base_tools.join(",-")}`);
|
|
61
|
+
console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ${pad(String(r.agent_name || "").slice(0, 18), 18)} ${pad(r.model || "house", 20)} ${pad(r.reasoning_effort || "—", 7)} ${pad(`${r.prompt_chars.toLocaleString()}${r.use_settings_prompt ? "" : " OFF"}`, 9)} ${pad(r.master_switch ? "on" : "OFF", 3)} ${on.join(" ") || "—"}`);
|
|
62
|
+
}
|
|
63
|
+
console.log(`\n SW = agent master switch · PROMPT = chars (OFF = use_settings_prompt false) · columns: ${(resp.tool_columns || []).map((k) => `${short[k] || k}=${k}`).join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
export async function create(orgId, opts = {}) {
|
|
36
67
|
if (!UUID_RE.test(orgId)) {
|
|
37
68
|
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
@@ -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);
|
|
@@ -1815,6 +1825,15 @@ export async function scheduledList(orgId, opts = {}) {
|
|
|
1815
1825
|
console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
|
|
1816
1826
|
if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
|
|
1817
1827
|
if (s.reply_routing) console.log(` replies → ${describeRouting(s.reply_routing)}`);
|
|
1828
|
+
if (opts.detail) {
|
|
1829
|
+
const bp = s.body_parameters && Object.keys(s.body_parameters).length ? s.body_parameters : null;
|
|
1830
|
+
const bt = s.button_parameters && Object.keys(s.button_parameters).length ? s.button_parameters : null;
|
|
1831
|
+
console.log(` body: ${bp ? Object.entries(bp).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") : "(no body values)"}`);
|
|
1832
|
+
if (bt) console.log(` buttons: ${Object.entries(bt).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ")}`);
|
|
1833
|
+
if (s.header_media) console.log(` header: ${typeof s.header_media === "string" ? s.header_media : JSON.stringify(s.header_media)}`);
|
|
1834
|
+
if (s.card_overrides != null) console.log(` cards: ${s.card_overrides} card override(s)`);
|
|
1835
|
+
if (!bp && !bt && !s.header_media && s.engine !== "python") console.log(` (a node-engine row carries its values inside the request; --json shows them)`);
|
|
1836
|
+
}
|
|
1818
1837
|
if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
|
|
1819
1838
|
if (s.error_message) console.log(` error: ${s.error_message}`);
|
|
1820
1839
|
}
|
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 = {}) {
|