@funnelchat-hq/cli 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +32 -4
  2. package/dist/cli.js +139 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,6 +23,8 @@ The phone does not need to be a contact yet: the send creates it. Send it to you
23
23
 
24
24
  **Handing it to an AI agent?** Agents are good at running commands. Two things help them: `funnelchat connect --no-wait` prints the link and ends at once (the agent shows it to you, and `funnelchat numbers list` says when the number is connected), and `--json` on any command gives one JSON document to read.
25
25
 
26
+ **Already have a number in Funnelchat?** Look first with `funnelchat numbers list`. If one is connected, use it: `send` takes it. If it is disconnected, `funnelchat connect --device <id>` reconnects that same number, without adding another. With none, connect yours by QR (`funnelchat connect`) or ask for a US number of Funnelchat on the official WhatsApp API (`funnelchat connect --us-number`).
27
+
26
28
  The same key also **follows your WhatsApp groups** (`funnelchat groups`), **exports your conversations and your contacts** to files you can verify, and checks your numbers, templates and MACs:
27
29
 
28
30
  ```bash
@@ -79,7 +81,7 @@ The CLI uses an API key of your account: `<id>|fc_live_...`, complete, as the **
79
81
 
80
82
  | Command | Permission |
81
83
  |---|---|
82
- | `connect` | `accounts:operate` (WhatsApp numbers → Create, connect and delete) and `accounts:read` to wait for the number |
84
+ | `connect` | `accounts:operate` (WhatsApp numbers → Create, connect and delete) and `accounts:read` to wait for the number; with `--device`, `accounts:read` always (to know how the number connects) |
83
85
  | `send` | `conversations:send`; to a phone that is not a contact yet, `conversations:write` (it creates the contact); `conversations:read` to watch the status; `accounts:read` to pick the number without `--device` |
84
86
  | `groups` | `communities:read`; `accounts:read` without `--device` |
85
87
  | `groups follow` | `communities:operate` (Groups and campaigns → Manage groups); `accounts:read` without `--device` |
@@ -138,7 +140,7 @@ Every command takes `--json` for use from scripts and `--help` to see all its op
138
140
 
139
141
  ### `funnelchat connect`
140
142
 
141
- Connects a WhatsApp number to your account by QR code. It creates a connect link (`POST /api/v1/connect-links` with `type: "qr"`), prints it, and waits until the number is connected.
143
+ Connects a WhatsApp number to your account by QR code. It creates a connect link (`POST /api/v1/connect-links` with `type: "qr"`), prints it, and waits until the number is connected. With `--us-number`, the link gives a new US number of Funnelchat instead; with `--device <id>`, it reconnects a number you already have.
142
144
 
143
145
  ```bash
144
146
  funnelchat connect
@@ -161,6 +163,8 @@ Send a first message: funnelchat send <phone> "Hello from Funnelchat"
161
163
  |---|---|
162
164
  | `--no-wait` | Prints the link and ends, without waiting. For scripts and agents: `funnelchat numbers list` then shows when the number is connected. |
163
165
  | `--timeout <seconds>` | How long to wait for the number. By default, 900 (15 minutes). |
166
+ | `--us-number` | A new US number of Funnelchat on the official WhatsApp API, instead of a QR. |
167
+ | `--device <id>` | Reconnects that number of your account, with a link of its own type. It never adds a number. Not with `--us-number`. |
164
168
 
165
169
  - **The link** is a page on Funnelchat's domain. Opening it does not use it; tapping its button creates the number and shows the QR, which renews itself every 5 seconds. Treat it as a secret: it comes only in this answer, and whoever has it can connect a number to your account.
166
170
  - **How it knows:** it lists your numbers before creating the link and then every 5 seconds (`GET /api/v1/devices`), and stops at the first one that was not connected before. Each listing counts toward the daily cap of accounts (about 180 calls in 15 minutes, of 10,000 a day).
@@ -168,6 +172,30 @@ Send a first message: funnelchat send <phone> "Hello from Funnelchat"
168
172
  - **No QR in the terminal:** the page shows the QR that WhatsApp scans.
169
173
  - If nobody scans the QR within 10 minutes of tapping the button, the link fails and the number it created is deleted: run `funnelchat connect` again. If the CLI stops waiting before (`--timeout`, exit code `8`, or Ctrl+C), the link still works until it expires.
170
174
 
175
+ **A US number of Funnelchat** (`--us-number`): the link is `type: "meta"` with `allowed_connection_types: ["dedicated"]` and `provision_us_number: true`.
176
+
177
+ ```bash
178
+ funnelchat connect --us-number
179
+ ```
180
+
181
+ - **On the page,** the person taps the button to use a Funnelchat US number and logs in with Facebook (Meta's signup, the same as the panel's). The number is new and dedicated to the API (it is not in the WhatsApp Business app).
182
+ - **Meta charges its messages** to the payment method of the WhatsApp Business account: it has to be added in Meta.
183
+ - If the Facebook login or Meta fails, the same link can be tried again, up to 5 failed attempts.
184
+ - **It is an official number:** free text only reaches a contact who wrote to it in the last 24 hours (`422 window_closed` otherwise), so write to it first from the phone you send to.
185
+ - Connecting an official number of your own by Facebook is done from the panel.
186
+
187
+ **Reconnecting a number** (`--device <id>`): the link carries `device_id`, so it reconnects that number and takes no new place.
188
+
189
+ ```bash
190
+ funnelchat numbers list # the id, and whether it is connected
191
+ funnelchat connect --device 4
192
+ ```
193
+
194
+ - **Of its own type:** a number connected by QR gets a QR link (`type: "qr"`); an official one gets a Facebook link (`type: "meta"`, with its connection, `coexistence` or `dedicated`, as `allowed_connection_types`). The CLI lists your numbers first to know which, even with `--no-wait`.
195
+ - **Already connected:** the CLI says so and creates no link (exit code `0`).
196
+ - **Not one of your numbers:** exit code `4`, and no link is created.
197
+ - The wait ends when that number is connected.
198
+
171
199
  ### `funnelchat send <phone> <text>`
172
200
 
173
201
  Sends a text message to a phone and shows its status live, until it is `read`, it `failed` or `--timeout` ends.
@@ -388,7 +416,7 @@ MACs in the period: 120 of 1,000 (12 %) · 880 left
388
416
  {
389
417
  "manifest_version": 1,
390
418
  "kind": "conversations",
391
- "generator": { "name": "@funnelchat-hq/cli", "version": "0.2.0" },
419
+ "generator": { "name": "@funnelchat-hq/cli", "version": "0.3.0" },
392
420
  "api": { "base_url": "https://api.funnelchat.com", "spec": { "commit": "99928866…", "sha256": "0ffaad2…" }, "routes": [ … ] },
393
421
  "started_at": "2026-10-16T12:00:00.000Z",
394
422
  "finished_at": "2026-10-16T12:41:10.000Z",
@@ -502,7 +530,7 @@ npm run build
502
530
 
503
531
  To develop you need Node 20 or later (for the test tools); the CLI it builds runs on Node 18.
504
532
 
505
- **The tests** use simulated answers, with the same shapes as the API (pages by number and by cursor). They cover paging, resuming, `429`s, that the key never shows up in any output or any file, that no file is overwritten, the manifest's SHA-256, and the first use: `connect` waiting for the number, and `send` watching the status until it is read.
533
+ **The tests** use simulated answers, with the same shapes as the API (pages by number and by cursor). They cover paging, resuming, `429`s, that the key never shows up in any output or any file, that no file is overwritten, the manifest's SHA-256, and the first use: `connect` waiting for the number (also a US number and a reconnection), and `send` watching the status until it is read.
506
534
 
507
535
  **End to end against the sandbox.** With an already built checkout of [funnelchat-sandbox](https://github.com/Funnelchat20/funnelchat-sandbox) (`npm install && npm run build` in its folder), this test starts it in the same process, on a free port, and runs the CLI against it. It does not touch the sandbox's repository:
508
536
 
package/dist/cli.js CHANGED
@@ -151,6 +151,13 @@ var KNOWN_API_ERRORS = {
151
151
  number_limit_reached: { message: "Every number of your plan is in use.", hint: "No link was created. Delete a number or change the plan from the panel." },
152
152
  connect_not_allowed: { message: "Only the owner of the account can connect numbers, and this key is not the owner's.", hint: "No link was created." },
153
153
  too_many_open_links: { message: "The account already has 20 connect links open.", hint: "No link was created. They end when they are used or expire." },
154
+ // connect --device and --us-number
155
+ invalid_reference_device: { message: "That number is not one of your account's numbers.", hint: "No link was created. See them with `funnelchat numbers list`." },
156
+ connection_type_mismatch: {
157
+ message: "The link is not of the number's type: a number connected by QR reconnects by QR, and an official one through Meta.",
158
+ hint: "No link was created. The number may have changed since it was listed: check it with `funnelchat numbers list` and try again."
159
+ },
160
+ meta_connect_disabled: { message: "Connecting official numbers by link is not enabled right now.", hint: "No link was created. Try again later." },
154
161
  // groups
155
162
  provider_unsupported: { message: "The number cannot do that: only a number connected by QR can follow groups." },
156
163
  group_not_found: {
@@ -253,7 +260,7 @@ function isRecord(value) {
253
260
  }
254
261
 
255
262
  // src/version.ts
256
- var VERSION = "0.2.0";
263
+ var VERSION = "0.3.0";
257
264
  var PACKAGE_NAME = "@funnelchat-hq/cli";
258
265
  var USER_AGENT_PRODUCT = "funnelchat-cli";
259
266
 
@@ -1102,7 +1109,10 @@ var routes = {
1102
1109
  "perPageMax": null,
1103
1110
  "query": [],
1104
1111
  "body": [
1105
- "type"
1112
+ "type",
1113
+ "allowed_connection_types",
1114
+ "provision_us_number",
1115
+ "device_id"
1106
1116
  ],
1107
1117
  "pending": null
1108
1118
  },
@@ -1476,14 +1486,117 @@ function str(value) {
1476
1486
  // src/commands/connect.ts
1477
1487
  var CONNECT_TIMEOUT_SECONDS = 15 * 60;
1478
1488
  var POLL_MS = 5e3;
1489
+ var SECRET = "Whoever has the link can connect a number to your account: do not share it.";
1490
+ function expiry(link) {
1491
+ return typeof link.expires_at === "string" ? ` and expires at ${link.expires_at}` : "";
1492
+ }
1493
+ var QR_PLAN = {
1494
+ body: { type: "qr" },
1495
+ instructions: (link) => [
1496
+ "Open this link, tap the button and scan the QR code with WhatsApp on the phone you are connecting (Linked devices \u2192 Link a device):",
1497
+ "",
1498
+ ` ${link.url}`,
1499
+ "",
1500
+ `It works once${expiry(link)}; after tapping the button there are 10 minutes to scan the QR.`,
1501
+ SECRET
1502
+ ],
1503
+ timeoutHint: "If the QR was not scanned within 10 minutes of tapping the button, the link failed: run `funnelchat connect` again.",
1504
+ target: null,
1505
+ official: false
1506
+ };
1507
+ var US_NUMBER_PLAN = {
1508
+ body: { type: "meta", allowed_connection_types: ["dedicated"], provision_us_number: true },
1509
+ instructions: (link) => [
1510
+ "Open this link, tap the button to use a Funnelchat US number and log in with Facebook:",
1511
+ "",
1512
+ ` ${link.url}`,
1513
+ "",
1514
+ "It is a new number on the official WhatsApp API. Meta charges its messages, so the WhatsApp Business account needs a payment method in Meta.",
1515
+ `It works once${expiry(link)}.`,
1516
+ SECRET
1517
+ ],
1518
+ timeoutHint: "If the Facebook login or Meta failed, the same link can be tried again (up to 5 failed attempts).",
1519
+ target: null,
1520
+ official: true
1521
+ };
1522
+ function reconnectPlan(device) {
1523
+ const id = String(device.id);
1524
+ const label = deviceLabel(device);
1525
+ const reconnects = `It reconnects number ${id}: it does not add a new one.`;
1526
+ const secret = "Whoever has the link can connect a phone to that number of your account: do not share it.";
1527
+ if (connectionOf(device) === "qr") {
1528
+ return {
1529
+ body: { type: "qr", device_id: Number(device.id) },
1530
+ instructions: (link) => [
1531
+ `Open this link, tap the button and scan the QR code with WhatsApp on the phone of ${label} (Linked devices \u2192 Link a device):`,
1532
+ "",
1533
+ ` ${link.url}`,
1534
+ "",
1535
+ `It works once${expiry(link)}; after tapping the button there are 10 minutes to scan the QR. ${reconnects}`,
1536
+ secret
1537
+ ],
1538
+ timeoutHint: `If the QR was not scanned within 10 minutes of tapping the button, the link failed: run \`funnelchat connect --device ${id}\` again.`,
1539
+ target: device,
1540
+ official: false
1541
+ };
1542
+ }
1543
+ return {
1544
+ body: { type: "meta", allowed_connection_types: allowedConnectionTypes(device), device_id: Number(device.id) },
1545
+ instructions: (link) => [
1546
+ `Open this link and log in with Facebook to reconnect ${label} to the official WhatsApp API:`,
1547
+ "",
1548
+ ` ${link.url}`,
1549
+ "",
1550
+ `It works once${expiry(link)}. ${reconnects}`,
1551
+ secret
1552
+ ],
1553
+ timeoutHint: "If the Facebook login or Meta failed, the same link can be tried again (up to 5 failed attempts).",
1554
+ target: device,
1555
+ official: true
1556
+ };
1557
+ }
1558
+ function allowedConnectionTypes(device) {
1559
+ if (device.connection === "coexistence" || device.connection === "dedicated") return [device.connection];
1560
+ if (typeof device.is_coexistence === "boolean") return [device.is_coexistence ? "coexistence" : "dedicated"];
1561
+ return ["coexistence", "dedicated"];
1562
+ }
1479
1563
  async function connect(ctx, flags) {
1564
+ if (flags.usNumber && flags.device !== void 0) {
1565
+ throw usageError(
1566
+ "--us-number and --device do not go together: --us-number adds a new number, --device reconnects one you have.",
1567
+ "No link was created. Reconnect with `funnelchat connect --device <id>`, or ask for a US number with `funnelchat connect --us-number`."
1568
+ );
1569
+ }
1570
+ const deviceId = flags.device === void 0 ? null : positiveInt(flags.device, "--device");
1480
1571
  const wait = flags.wait !== false;
1481
1572
  const timeout = flags.timeout === void 0 ? CONNECT_TIMEOUT_SECONDS : seconds(flags.timeout, "--timeout");
1482
1573
  const client = ctx.client();
1483
- const before = wait ? await listDevices(ctx) : [];
1574
+ const before = wait || deviceId !== null ? await listDevices(ctx) : [];
1484
1575
  const known = new Set(before.map((d) => Number(d.id)));
1485
1576
  const connectedBefore = new Set(before.filter(isConnected).map((d) => Number(d.id)));
1486
- const body = await client.post(routes.connectLinkCreate, { body: { type: "qr" } });
1577
+ let plan;
1578
+ if (deviceId !== null) {
1579
+ const device = before.find((d) => Number(d.id) === deviceId);
1580
+ if (!device) {
1581
+ throw new CliError(`Number ${deviceId} is not one of your account's numbers.`, {
1582
+ exitCode: ExitCode.NOT_FOUND,
1583
+ code: "device_not_found",
1584
+ hint: "No link was created. See your numbers and their ids with `funnelchat numbers list`.",
1585
+ details: { device_id: deviceId }
1586
+ });
1587
+ }
1588
+ if (isConnected(device)) {
1589
+ ctx.out.result({ link: null, device, already_connected: true }, () => [
1590
+ `${deviceLabel(device)}, id ${deviceId}, is already connected: there is nothing to reconnect and no link was created.`,
1591
+ `Send a message: funnelchat send <phone> "Hello from Funnelchat" --device ${deviceId}`
1592
+ ]);
1593
+ return;
1594
+ }
1595
+ plan = reconnectPlan(device);
1596
+ } else {
1597
+ plan = flags.usNumber ? US_NUMBER_PLAN : QR_PLAN;
1598
+ }
1599
+ const body = await client.post(routes.connectLinkCreate, { body: plan.body });
1487
1600
  const data = isRecord(body) && isRecord(body.data) ? body.data : isRecord(body) ? body : {};
1488
1601
  if (typeof data.url !== "string" || data.url === "") {
1489
1602
  throw new CliError("The API created the connect link but did not return its URL.", {
@@ -1494,16 +1607,10 @@ async function connect(ctx, flags) {
1494
1607
  });
1495
1608
  }
1496
1609
  const link = { id: data.id ?? null, url: data.url, status: data.status ?? null, expires_at: data.expires_at ?? null };
1497
- const instructions = [
1498
- "Open this link, tap the button and scan the QR code with WhatsApp on the phone you are connecting (Linked devices \u2192 Link a device):",
1499
- "",
1500
- ` ${link.url}`,
1501
- "",
1502
- `It works once${typeof link.expires_at === "string" ? ` and expires at ${link.expires_at}` : ""}; after tapping the button there are 10 minutes to scan the QR.`,
1503
- "Whoever has the link can connect a number to your account: do not share it."
1504
- ];
1610
+ const instructions = plan.instructions(link);
1611
+ const result = plan.target ? { link, device: plan.target } : { link };
1505
1612
  if (!wait) {
1506
- ctx.out.result({ link }, () => [...instructions, "", "Once it is connected, `funnelchat numbers list` shows it."]);
1613
+ ctx.out.result(result, () => [...instructions, "", "Once it is connected, `funnelchat numbers list` shows it."]);
1507
1614
  return;
1508
1615
  }
1509
1616
  ctx.out.prompt(instructions);
@@ -1516,18 +1623,25 @@ async function connect(ctx, flags) {
1516
1623
  throw new CliError(`No number connected within ${timeout} s.`, {
1517
1624
  exitCode: ExitCode.TIMEOUT,
1518
1625
  code: "connect_timeout",
1519
- hint: "If the QR was not scanned within 10 minutes of tapping the button, the link failed: run `funnelchat connect` again."
1626
+ hint: plan.timeoutHint
1520
1627
  });
1521
1628
  }
1522
1629
  await pause(ctx, Math.min(POLL_MS, left));
1523
1630
  const devices = await listDevices(ctx);
1524
- const connected = devices.filter((d) => isConnected(d) && !connectedBefore.has(Number(d.id)));
1525
- const device = connected.find((d) => !known.has(Number(d.id))) ?? connected[0];
1631
+ let device;
1632
+ if (plan.target) {
1633
+ device = devices.find((d) => Number(d.id) === Number(plan.target?.id) && isConnected(d));
1634
+ } else {
1635
+ const connected = devices.filter((d) => isConnected(d) && !connectedBefore.has(Number(d.id)));
1636
+ device = connected.find((d) => !known.has(Number(d.id))) ?? connected[0];
1637
+ }
1526
1638
  if (device) {
1639
+ const found = device;
1527
1640
  const several = devices.filter(isConnected).length > 1;
1528
- ctx.out.result({ link, device }, () => [
1529
- `Connected: ${deviceLabel(device)}, id ${String(device.id)}.`,
1530
- `Send a first message: funnelchat send <phone> "Hello from Funnelchat"${several ? ` --device ${String(device.id)}` : ""}`
1641
+ ctx.out.result({ link, device: found }, () => [
1642
+ `${plan.target ? "Reconnected" : "Connected"}: ${deviceLabel(found)}, id ${String(found.id)}.`,
1643
+ `Send a first message: funnelchat send <phone> "Hello from Funnelchat"${several ? ` --device ${String(found.id)}` : ""}`,
1644
+ ...plan.official ? ["It is an official number: free text only reaches a contact who wrote to it in the last 24 hours, so first write to it from the phone you send to."] : []
1531
1645
  ]);
1532
1646
  return;
1533
1647
  }
@@ -2728,7 +2842,12 @@ async function run(argv, io) {
2728
2842
  const profiles = program.command("profiles").description("Saved profiles.");
2729
2843
  profiles.command("list", { isDefault: true }).description("Lists the profiles (never shows the keys).").action(action((ctx) => profilesList(ctx)));
2730
2844
  profiles.command("use").argument("<name>", "Profile to activate.").description("Chooses the profile used by default.").action(action((ctx, _command, name) => profilesUse(ctx, name)));
2731
- program.command("connect").description("Creates a link to connect a WhatsApp number by QR code, prints it and waits until the number connects.").option("--no-wait", "Prints the link and ends, without waiting (for scripts and agents).").option("--timeout <seconds>", `How long to wait for the number (${CONNECT_TIMEOUT_SECONDS} by default).`).action(action((ctx, command) => connect(ctx, command.opts())));
2845
+ program.command("connect").description(
2846
+ "Creates a link to connect a WhatsApp number by QR code, prints it and waits until the number connects. With --us-number the link gives a new US number of Funnelchat instead; with --device it reconnects a number you already have."
2847
+ ).option("--no-wait", "Prints the link and ends, without waiting (for scripts and agents).").option("--timeout <seconds>", `How long to wait for the number (${CONNECT_TIMEOUT_SECONDS} by default).`).option(
2848
+ "--us-number",
2849
+ "A new US number of Funnelchat on the official WhatsApp API, instead of a QR: the page asks to log in with Facebook, and Meta charges the messages (it needs a payment method in Meta)."
2850
+ ).option("--device <id>", "Reconnects this number of your account with a link of its own type (QR or official); it never adds a number. Not with --us-number.").action(action((ctx, command) => connect(ctx, command.opts())));
2732
2851
  program.command("send").argument("<phone>", "Phone with the country code, for example 5491122334455.").argument("<text...>", "The text of the message (in quotes, or as several words).").description("Sends a text message to a phone, even one that is not a contact yet, and shows its status live: queued, sent, delivered, read.").option("--device <id>", "Number to send from (by default the only connected one).").option("--timeout <seconds>", `How long to watch the status (${SEND_TIMEOUT_SECONDS} by default; 0 does not watch).`).action(action((ctx, command, phone, text2) => send(ctx, phone, text2, command.opts())));
2733
2852
  const groups = program.command("groups").description("WhatsApp groups of a number connected by QR.");
2734
2853
  groups.command("list", { isDefault: true }).description("Lists the groups of the number as WhatsApp has them, and which ones you follow.").option("--device <id>", "Number whose groups to list (by default the only one connected by QR).").action(action((ctx, command) => groupsList(ctx, command.opts())));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelchat-hq/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Funnelchat CLI: connect a WhatsApp number, send a message and watch it get read, follow groups, and export conversations and contacts through the public API",
5
5
  "license": "MIT",
6
6
  "author": {