@funnelchat-hq/cli 0.1.1 → 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 +166 -15
  2. package/dist/cli.js +707 -132
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -52,10 +52,12 @@ var ExitCode = {
52
52
  NOT_FOUND: 4,
53
53
  /** A limit the CLI does not wait for: the daily cap or a `Retry-After` too long. Resumable. */
54
54
  RATE_LIMITED: 5,
55
- /** The API failed (`5xx`, `422`…) or could not be reached, after the retries. */
55
+ /** The API failed (`5xx`, `422`…) or could not be reached, after the retries; or a sent message failed. */
56
56
  API: 6,
57
57
  /** The output already exists: the CLI never overwrites a file. */
58
58
  EXISTS: 7,
59
+ /** The CLI waited and it did not happen: no number connected within `connect --timeout`. */
60
+ TIMEOUT: 8,
59
61
  /** Interrupted with Ctrl+C. An export can be resumed. */
60
62
  INTERRUPTED: 130
61
63
  };
@@ -124,6 +126,58 @@ function withRequestId(hint, requestId) {
124
126
  return hint ? `${hint}
125
127
  ${cite}` : cite;
126
128
  }
129
+ var KNOWN_API_ERRORS = {
130
+ // send
131
+ window_closed: {
132
+ message: "WhatsApp does not let this number send free text to this contact now: on an official number, free text only goes out within 24 hours of the contact's last message, so the first message to a new contact has to be a template.",
133
+ hint: "Nothing was sent. Ask the contact to write to you first, or send from a number connected by QR."
134
+ },
135
+ recipient_mismatch: { message: "The phone is not the one of that contact.", hint: "Nothing was sent." },
136
+ recipient_unsupported: { message: "That recipient is a group, a broadcast list or a channel: `send` writes to a person.", hint: "Nothing was sent." },
137
+ antiblock_duplicate_content: {
138
+ message: "The same text was already sent too many times to a contact that did not answer: Funnelchat stopped it to protect the number.",
139
+ hint: "Nothing was sent."
140
+ },
141
+ antiblock_unanswered_limit: {
142
+ message: "Too many messages in a row to a contact that did not answer: Funnelchat stopped it to protect the number.",
143
+ hint: "Nothing was sent. Wait for the contact to answer."
144
+ },
145
+ device_unavailable: { message: "The number is not connected or cannot send now.", hint: "Nothing was sent. Check it with `funnelchat numbers list`." },
146
+ not_sent: { message: "The message could not be registered for sending.", hint: "Nothing was sent." },
147
+ mac_lockout_24h: { message: "The account used up its monthly active contacts (MACs) and is locked for 24 hours.", hint: "Nothing was sent. Retrying does not help: see `funnelchat mac status`." },
148
+ mac_limit_reached: { message: "The account used up its monthly active contacts (MACs).", hint: "Nothing was sent. Retrying does not help: see `funnelchat mac status`." },
149
+ // connect
150
+ numbers_not_included: { message: "Your plan does not include WhatsApp numbers.", hint: "No link was created. Change the plan from the panel." },
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
+ 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
+ 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." },
161
+ // groups
162
+ provider_unsupported: { message: "The number cannot do that: only a number connected by QR can follow groups." },
163
+ group_not_found: {
164
+ message: "That group is not one of the number's groups (or it belongs to another workspace of the account).",
165
+ hint: "List them with `funnelchat groups` and use the uid it shows."
166
+ },
167
+ device_not_found: { message: "That number is not one of the account (or of this key's workspace).", hint: "See them with `funnelchat numbers list`." },
168
+ device_not_connected: { message: "The number is not connected.", hint: "See it with `funnelchat numbers list`." },
169
+ device_not_group_admin: { message: "The number is not an admin of that group." },
170
+ unsupported_group_type: { message: "That uid is a community or a channel, not a group." },
171
+ import_permission_required: { message: "Following a group needs the Groups and campaigns \u2192 Manage groups permission (`communities:operate`).", hint: "Create a key with that permission." },
172
+ import_rate_limited: { message: "This number already followed 5 groups in the last minute." },
173
+ import_in_progress: { message: "That group is being imported right now.", hint: "Try again in a few seconds." }
174
+ };
175
+ function exitCodeOf(status) {
176
+ if (status === 401 || status === 403) return ExitCode.AUTH;
177
+ if (status === 404) return ExitCode.NOT_FOUND;
178
+ if (status === 429) return ExitCode.RATE_LIMITED;
179
+ return ExitCode.API;
180
+ }
127
181
  function describe(init, apiCode, apiMessage, fieldErrors) {
128
182
  const where = `${init.method} ${init.route}`;
129
183
  const said = apiMessage ? `: ${apiMessage}` : ".";
@@ -135,6 +189,10 @@ function describe(init, apiCode, apiMessage, fieldErrors) {
135
189
  hint: "Check that the key is complete (with the `<id>|` at the start) and still active, and run `funnelchat login` again."
136
190
  };
137
191
  }
192
+ const known = apiCode !== void 0 && Object.hasOwn(KNOWN_API_ERRORS, apiCode) ? KNOWN_API_ERRORS[apiCode] : void 0;
193
+ if (known) {
194
+ return { exitCode: exitCodeOf(status), message: `${known.message} (${status} ${apiCode} on ${where})`, ...known.hint ? { hint: known.hint } : {} };
195
+ }
138
196
  if (status === 403) {
139
197
  const permission = init.permission ? ` This route needs \`${init.permission}\`.` : "";
140
198
  return {
@@ -202,7 +260,7 @@ function isRecord(value) {
202
260
  }
203
261
 
204
262
  // src/version.ts
205
- var VERSION = "0.1.1";
263
+ var VERSION = "0.3.0";
206
264
  var PACKAGE_NAME = "@funnelchat-hq/cli";
207
265
  var USER_AGENT_PRODUCT = "funnelchat-cli";
208
266
 
@@ -310,6 +368,7 @@ var DEFAULT_MAX_RETRIES = 4;
310
368
  var DEFAULT_MAX_WAIT_SECONDS = 60;
311
369
  var INITIAL_BACKOFF_MS = 1e3;
312
370
  var MAX_BACKOFF_MS = 3e4;
371
+ var WRITE_MAY_HAVE_ARRIVED = "It was a write: Funnelchat may have received it. Check before repeating it.";
313
372
  var ApiClient = class {
314
373
  baseUrl;
315
374
  /** Requests sent, retries included. */
@@ -329,6 +388,11 @@ var ApiClient = class {
329
388
  async get(route, options = {}) {
330
389
  return await this.#send(route, options);
331
390
  }
391
+ /** One write (`POST`) of `route` with a JSON `body`, sent once and never retried. Returns the parsed body. */
392
+ async post(route, options = {}) {
393
+ if (route.method !== "POST") throw new TypeError(`${route.method} ${route.path} is not a write.`);
394
+ return await this.#send(route, options);
395
+ }
332
396
  /** One page of a listing route, and the query of the page after it. */
333
397
  async page(route, options = {}) {
334
398
  const body = await this.#send(route, options);
@@ -359,6 +423,8 @@ var ApiClient = class {
359
423
  "User-Agent": `${USER_AGENT_PRODUCT}/${VERSION} node/${process.version}`,
360
424
  Authorization: `Bearer ${this.#apiKey}`
361
425
  };
426
+ const payload = options.body === void 0 ? void 0 : JSON.stringify(options.body);
427
+ if (payload !== void 0) headers["Content-Type"] = "application/json";
362
428
  for (let attempt = 0; ; attempt++) {
363
429
  const canRetry = retriesAllowed && attempt < maxRetries;
364
430
  await opts.pacer.wait(opts.signal);
@@ -366,9 +432,9 @@ var ApiClient = class {
366
432
  const started = opts.clock();
367
433
  opts.log?.(`\u2192 ${method} ${url}${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`);
368
434
  let response;
369
- let text;
435
+ let text2;
370
436
  try {
371
- ({ response, text } = await this.#attempt(method, url, headers));
437
+ ({ response, text: text2 } = await this.#attempt(method, url, headers, payload));
372
438
  } catch (error2) {
373
439
  if (error2 instanceof InterruptedError) throw error2;
374
440
  if (canRetry) {
@@ -377,11 +443,12 @@ var ApiClient = class {
377
443
  await this.#sleep(delay2);
378
444
  continue;
379
445
  }
446
+ if (!retriesAllowed && error2 instanceof ConnectionError) error2.hint = [error2.hint, WRITE_MAY_HAVE_ARRIVED].filter(Boolean).join("\n");
380
447
  throw error2;
381
448
  }
382
449
  const requestId = response.headers.get("x-request-id") ?? void 0;
383
450
  opts.log?.(`\u2190 ${response.status} in ${opts.clock() - started} ms${requestId ? ` \xB7 X-Request-Id ${requestId}` : ""}`);
384
- const body = parseBody(response, text);
451
+ const body = parseBody(response, text2);
385
452
  if (response.ok) return body;
386
453
  const retryAfter = parseRetryAfter(response.headers.get("retry-after"), opts.clock());
387
454
  const error = new ApiError({
@@ -395,9 +462,12 @@ var ApiClient = class {
395
462
  requestId
396
463
  });
397
464
  const retryable = response.status === 429 || response.status >= 500 && error.apiCode !== "edge_misconfigured";
465
+ if (!retriesAllowed && response.status >= 500 && error.apiCode !== "edge_misconfigured" && error.apiCode !== "gate_unavailable") {
466
+ error.hint = [WRITE_MAY_HAVE_ARRIVED, error.hint].filter(Boolean).join("\n");
467
+ }
398
468
  if (!retryable) throw error;
399
469
  if (retryAfter !== void 0 && retryAfter > maxWait) {
400
- throw stopForLongWait(error, retryAfter, opts.clock());
470
+ throw stopForLongWait(error, retryAfter, opts.clock(), method);
401
471
  }
402
472
  if (!canRetry) {
403
473
  if (response.status === 429) error.hint = [`Wait ${formatSeconds(retryAfter ?? 60)} and try again.`, error.hint].filter(Boolean).join("\n");
@@ -416,7 +486,7 @@ var ApiClient = class {
416
486
  throw error;
417
487
  }
418
488
  }
419
- async #attempt(method, url, headers) {
489
+ async #attempt(method, url, headers, payload) {
420
490
  const signal = this.#options.signal;
421
491
  if (signal?.aborted) throw new InterruptedError();
422
492
  const timeoutMs = this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -436,7 +506,7 @@ var ApiClient = class {
436
506
  try {
437
507
  let response;
438
508
  try {
439
- response = await this.#options.fetch(url, { method, headers, signal: controller2.signal });
509
+ response = await this.#options.fetch(url, { method, headers, signal: controller2.signal, ...payload !== void 0 ? { body: payload } : {} });
440
510
  } catch (error) {
441
511
  throw classify(error);
442
512
  }
@@ -461,7 +531,7 @@ function backoffMs(attempt, random = Math.random) {
461
531
  const base = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
462
532
  return Math.round(base * (1 - 0.25 * random()));
463
533
  }
464
- function stopForLongWait(error, retryAfter, now) {
534
+ function stopForLongWait(error, retryAfter, now, method) {
465
535
  const at = new Date(now + retryAfter * 1e3);
466
536
  const when = `${formatSeconds(retryAfter)} (at ${at.toISOString().slice(11, 16)} UTC on ${at.toISOString().slice(0, 10)})`;
467
537
  let message;
@@ -475,7 +545,7 @@ function stopForLongWait(error, retryAfter, now) {
475
545
  status: error.status,
476
546
  body: error.apiCode ? { error: error.apiCode } : null,
477
547
  retryAfter,
478
- method: "GET",
548
+ method,
479
549
  route: error.route,
480
550
  domain: error.domain,
481
551
  permission: null,
@@ -487,25 +557,25 @@ function stopForLongWait(error, retryAfter, now) {
487
557
  }
488
558
  });
489
559
  }
490
- function formatSeconds(seconds) {
491
- const s = Math.max(0, Math.ceil(seconds));
560
+ function formatSeconds(seconds2) {
561
+ const s = Math.max(0, Math.ceil(seconds2));
492
562
  if (s < 60) return `${s} s`;
493
563
  const h = Math.floor(s / 3600);
494
564
  const m = Math.floor(s % 3600 / 60);
495
565
  if (h === 0) return `${m} min${s % 60 ? ` ${s % 60} s` : ""}`;
496
566
  return `${h} h${m ? ` ${m} min` : ""}`;
497
567
  }
498
- function parseBody(response, text) {
499
- if (text === "") return null;
568
+ function parseBody(response, text2) {
569
+ if (text2 === "") return null;
500
570
  const type = response.headers.get("content-type") ?? "";
501
- if (type.includes("json") || /^\s*[[{]/.test(text)) {
571
+ if (type.includes("json") || /^\s*[[{]/.test(text2)) {
502
572
  try {
503
- return JSON.parse(text);
573
+ return JSON.parse(text2);
504
574
  } catch {
505
- return text;
575
+ return text2;
506
576
  }
507
577
  }
508
- return text.length <= 300 ? text : `${text.slice(0, 300)}\u2026`;
578
+ return text2.length <= 300 ? text2 : `${text2.slice(0, 300)}\u2026`;
509
579
  }
510
580
  function hostOf(url) {
511
581
  try {
@@ -530,8 +600,8 @@ var Redactor = class {
530
600
  for (const part of parts) if (part.length >= 6 && !this.#secrets.includes(part)) this.#secrets.push(part);
531
601
  this.#secrets.sort((a, b) => b.length - a.length);
532
602
  }
533
- redact(text) {
534
- let out = text;
603
+ redact(text2) {
604
+ let out = text2;
535
605
  for (const secret of this.#secrets) out = out.split(secret).join(REDACTED);
536
606
  return out.replace(KEY_SHAPE, REDACTED);
537
607
  }
@@ -693,6 +763,13 @@ var Output = class {
693
763
  `);
694
764
  else this.#lines(this.stdout, human());
695
765
  }
766
+ /**
767
+ * What the person has to act on now, before the command ends (the link of `connect`): stdout, or stderr
768
+ * with `--json` so stdout keeps a single JSON document. Shown even with `--quiet`.
769
+ */
770
+ prompt(lines) {
771
+ this.#lines(this.options.json ? this.stderr : this.stdout, lines);
772
+ }
696
773
  /** Progress of a long command (stderr). Silent with `--quiet` or `--json`. */
697
774
  progress(line) {
698
775
  if (!this.options.quiet && !this.options.json) this.#lines(this.stderr, line);
@@ -732,12 +809,12 @@ var Output = class {
732
809
  return cliError.exitCode;
733
810
  }
734
811
  #lines(writer, lines) {
735
- const text = Array.isArray(lines) ? lines.join("\n") : lines;
736
- this.#write(writer, text.endsWith("\n") ? text : `${text}
812
+ const text2 = Array.isArray(lines) ? lines.join("\n") : lines;
813
+ this.#write(writer, text2.endsWith("\n") ? text2 : `${text2}
737
814
  `);
738
815
  }
739
- #write(writer, text) {
740
- writer.write(this.redactor.redact(text));
816
+ #write(writer, text2) {
817
+ writer.write(this.redactor.redact(text2));
741
818
  }
742
819
  };
743
820
  function toCliError(error) {
@@ -827,11 +904,11 @@ var Context = class {
827
904
 
828
905
  // src/generated/routes.ts
829
906
  var SPEC_SOURCE = {
830
- "sha256": "8bbbae569b4cab520884db98f28204156c1bd6c629578bee609343c193e0dadb",
907
+ "sha256": "0ffaad2b25733d28b2cc07dde8152400c3c68974b3515e71e116ce599312b0fa",
831
908
  "repository": "git@github.com:Funnelchat20/developers-portal.git",
832
- "branch": "feat/openapi-unificada",
833
- "commit": "0ef4abb008870ed8c7cae49a08900a81e5e57ed8",
834
- "paths": 191
909
+ "branch": "main",
910
+ "commit": "99928866cbac41d5b89d31d19773acfabdbbe5dd",
911
+ "paths": 218
835
912
  };
836
913
  var routes = {
837
914
  "me": {
@@ -845,6 +922,7 @@ var routes = {
845
922
  "plainArray": false,
846
923
  "perPageMax": null,
847
924
  "query": [],
925
+ "body": [],
848
926
  "pending": null
849
927
  },
850
928
  "devicesList": {
@@ -860,6 +938,7 @@ var routes = {
860
938
  "query": [
861
939
  "perPage"
862
940
  ],
941
+ "body": [],
863
942
  "pending": null
864
943
  },
865
944
  "deviceHealth": {
@@ -873,6 +952,7 @@ var routes = {
873
952
  "plainArray": false,
874
953
  "perPageMax": null,
875
954
  "query": [],
955
+ "body": [],
876
956
  "pending": null
877
957
  },
878
958
  "contactsList": {
@@ -895,6 +975,7 @@ var routes = {
895
975
  "created_to",
896
976
  "only_with_phone"
897
977
  ],
978
+ "body": [],
898
979
  "pending": null
899
980
  },
900
981
  "contactTags": {
@@ -910,6 +991,7 @@ var routes = {
910
991
  "query": [
911
992
  "perPage"
912
993
  ],
994
+ "body": [],
913
995
  "pending": null
914
996
  },
915
997
  "contactCustomFields": {
@@ -925,6 +1007,7 @@ var routes = {
925
1007
  "query": [
926
1008
  "perPage"
927
1009
  ],
1010
+ "body": [],
928
1011
  "pending": null
929
1012
  },
930
1013
  "customFieldsList": {
@@ -942,6 +1025,7 @@ var routes = {
942
1025
  "orderBy",
943
1026
  "orderByMethod"
944
1027
  ],
1028
+ "body": [],
945
1029
  "pending": null
946
1030
  },
947
1031
  "conversationsList": {
@@ -961,6 +1045,7 @@ var routes = {
961
1045
  "contactId",
962
1046
  "updated_since"
963
1047
  ],
1048
+ "body": [],
964
1049
  "pending": null
965
1050
  },
966
1051
  "contactMessages": {
@@ -977,6 +1062,7 @@ var routes = {
977
1062
  "perPage",
978
1063
  "updated_since"
979
1064
  ],
1065
+ "body": [],
980
1066
  "pending": null
981
1067
  },
982
1068
  "templatesList": {
@@ -994,6 +1080,7 @@ var routes = {
994
1080
  "deviceId",
995
1081
  "status"
996
1082
  ],
1083
+ "body": [],
997
1084
  "pending": null
998
1085
  },
999
1086
  "macStatus": {
@@ -1007,6 +1094,90 @@ var routes = {
1007
1094
  "plainArray": false,
1008
1095
  "perPageMax": null,
1009
1096
  "query": [],
1097
+ "body": [],
1098
+ "pending": null
1099
+ },
1100
+ "connectLinkCreate": {
1101
+ "id": "connectLinkCreate",
1102
+ "method": "POST",
1103
+ "path": "/api/v1/connect-links",
1104
+ "summary": "Crear un link de alta",
1105
+ "domain": "accounts",
1106
+ "permission": "accounts:operate",
1107
+ "pagination": "none",
1108
+ "plainArray": false,
1109
+ "perPageMax": null,
1110
+ "query": [],
1111
+ "body": [
1112
+ "type",
1113
+ "allowed_connection_types",
1114
+ "provision_us_number",
1115
+ "device_id"
1116
+ ],
1117
+ "pending": null
1118
+ },
1119
+ "sendMessage": {
1120
+ "id": "sendMessage",
1121
+ "method": "POST",
1122
+ "path": "/api/v1/message/send-message",
1123
+ "summary": "Enviar un mensaje",
1124
+ "domain": "conversations",
1125
+ "permission": "conversations:send",
1126
+ "pagination": "none",
1127
+ "plainArray": false,
1128
+ "perPageMax": null,
1129
+ "query": [],
1130
+ "body": [
1131
+ "device_id",
1132
+ "phone",
1133
+ "text"
1134
+ ],
1135
+ "pending": null
1136
+ },
1137
+ "messageShow": {
1138
+ "id": "messageShow",
1139
+ "method": "GET",
1140
+ "path": "/api/v1/contacts/{contact}/messages/{message}",
1141
+ "summary": "Ver un mensaje",
1142
+ "domain": "conversations",
1143
+ "permission": "conversations:read",
1144
+ "pagination": "none",
1145
+ "plainArray": false,
1146
+ "perPageMax": null,
1147
+ "query": [],
1148
+ "body": [],
1149
+ "pending": null
1150
+ },
1151
+ "deviceGroups": {
1152
+ "id": "deviceGroups",
1153
+ "method": "GET",
1154
+ "path": "/api/v1/devices/{device}/groups",
1155
+ "summary": "Listar los grupos que administra un n\xFAmero",
1156
+ "domain": "communities",
1157
+ "permission": "communities:read",
1158
+ "pagination": "page",
1159
+ "plainArray": false,
1160
+ "perPageMax": null,
1161
+ "query": [
1162
+ "pageSize"
1163
+ ],
1164
+ "body": [],
1165
+ "pending": null
1166
+ },
1167
+ "groupImport": {
1168
+ "id": "groupImport",
1169
+ "method": "POST",
1170
+ "path": "/api/v1/groups/{group}/import",
1171
+ "summary": "Importar un grupo de un n\xFAmero",
1172
+ "domain": "communities",
1173
+ "permission": "communities:operate",
1174
+ "pagination": "none",
1175
+ "plainArray": false,
1176
+ "perPageMax": null,
1177
+ "query": [],
1178
+ "body": [
1179
+ "device_id"
1180
+ ],
1010
1181
  "pending": null
1011
1182
  }
1012
1183
  };
@@ -1144,6 +1315,19 @@ function readAll(stdin) {
1144
1315
  }
1145
1316
 
1146
1317
  // src/commands/common.ts
1318
+ function seconds(value, flag) {
1319
+ if (!/^\d+$/.test(value) || Number(value) > 86400) throw usageError(`${flag} must be a whole number of seconds, from 0 to 86400: ${value}`);
1320
+ return Number(value);
1321
+ }
1322
+ async function pause(ctx, ms) {
1323
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
1324
+ try {
1325
+ await ctx.io.sleep(Math.max(0, ms), ctx.io.signal);
1326
+ } catch (error) {
1327
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
1328
+ throw error;
1329
+ }
1330
+ }
1147
1331
  function positiveInt(value, flag) {
1148
1332
  if (!/^\d+$/.test(value) || Number(value) < 1 || !Number.isSafeInteger(Number(value))) {
1149
1333
  throw usageError(`${flag} must be a positive integer: ${value}`);
@@ -1169,6 +1353,311 @@ function explicitOptions(command, names) {
1169
1353
  return new Set(names.filter((name) => command.getOptionValueSource(name) === "cli"));
1170
1354
  }
1171
1355
 
1356
+ // src/commands/numbers.ts
1357
+ var OFFICIAL_PROVIDER = 2;
1358
+ var CAPABILITY_LABELS = {
1359
+ contacts: "contacts",
1360
+ conversations: "conversations",
1361
+ templates: "templates",
1362
+ health: "health and limit"
1363
+ };
1364
+ function capabilitiesOf(device) {
1365
+ const official = Number(device.provider) === OFFICIAL_PROVIDER;
1366
+ return ["contacts", "conversations", ...official ? ["templates", "health"] : []];
1367
+ }
1368
+ function connectionOf(device) {
1369
+ return Number(device.provider) === OFFICIAL_PROVIDER ? "official" : "qr";
1370
+ }
1371
+ async function listDevices(ctx) {
1372
+ const devices = [];
1373
+ for await (const device of ctx.client().all(routes.devicesList, { query: { perPage: 100 } })) devices.push(device);
1374
+ return devices;
1375
+ }
1376
+ function isConnected(device) {
1377
+ return device.connected === true || Number(device.connected) === 1;
1378
+ }
1379
+ function deviceLabel(device) {
1380
+ const alias = typeof device.alias === "string" && device.alias !== "" ? device.alias : null;
1381
+ const number = typeof device.number === "string" && device.number !== "" ? device.number : null;
1382
+ if (alias && number) return `${alias} (${number})`;
1383
+ return alias ?? number ?? `number ${String(device.id)}`;
1384
+ }
1385
+ async function pickDevice(ctx, flag, options) {
1386
+ if (flag !== void 0) return { id: positiveInt(flag, "--device"), device: null };
1387
+ let devices;
1388
+ try {
1389
+ devices = await listDevices(ctx);
1390
+ } catch (error) {
1391
+ if (error instanceof ApiError && error.status === 403) {
1392
+ error.hint = [error.hint, "Or choose the number with --device <id>: without it the CLI lists your numbers, which needs accounts:read."].filter(Boolean).join("\n");
1393
+ }
1394
+ throw error;
1395
+ }
1396
+ const kind = options.qrOnly ? "connected by QR" : "connected";
1397
+ const candidates = devices.filter((d) => isConnected(d) && (!options.qrOnly || connectionOf(d) === "qr"));
1398
+ const [only] = candidates;
1399
+ if (candidates.length === 1 && only) return { id: Number(only.id), device: only };
1400
+ if (candidates.length === 0) {
1401
+ throw new CliError(`The account has no number ${kind}.`, {
1402
+ exitCode: ExitCode.NOT_FOUND,
1403
+ code: "no_connected_number",
1404
+ hint: `Connect one with \`funnelchat connect\`, or choose one with --device <id> (\`funnelchat numbers list\` shows them).`
1405
+ });
1406
+ }
1407
+ throw usageError(
1408
+ `The account has ${candidates.length} numbers ${kind}: choose one with --device <id>.`,
1409
+ candidates.map((d) => `${String(d.id)} ${deviceLabel(d)}`).join("\n")
1410
+ );
1411
+ }
1412
+ async function numbersList(ctx) {
1413
+ const devices = await listDevices(ctx);
1414
+ const rows = devices.map((device) => ({
1415
+ ...device,
1416
+ connection: connectionOf(device),
1417
+ cli_capabilities: capabilitiesOf(device)
1418
+ }));
1419
+ ctx.out.result(
1420
+ { numbers: rows },
1421
+ () => rows.length === 0 ? "The account has no numbers." : table(
1422
+ ["id", "alias", "number", "connection", "connected", "health", "what the CLI reads"],
1423
+ rows.map((d) => [
1424
+ d.id,
1425
+ d.alias,
1426
+ d.number,
1427
+ d.connection === "official" ? "official" : "QR",
1428
+ Number(d.connected) === 1 ? "yes" : "no",
1429
+ isRecord(d.health) ? String(d.health.state ?? "-") : null,
1430
+ d.cli_capabilities.map((c) => CAPABILITY_LABELS[c]).join(", ")
1431
+ ])
1432
+ )
1433
+ );
1434
+ }
1435
+ async function numbersHealth(ctx, ids) {
1436
+ let targets;
1437
+ const wanted = idList(ids, "number");
1438
+ if (wanted.length) {
1439
+ targets = wanted.map((id) => ({ id, label: String(id) }));
1440
+ } else {
1441
+ const devices = (await listDevices(ctx)).filter((d) => connectionOf(d) === "official");
1442
+ targets = devices.map((d) => ({ id: Number(d.id), label: String(d.alias || d.number || d.id) }));
1443
+ if (targets.length === 0) {
1444
+ ctx.out.result({ numbers: [] }, () => "The account has no official numbers: health and limit exist only for the official WhatsApp API.");
1445
+ return;
1446
+ }
1447
+ }
1448
+ const results = [];
1449
+ for (const target of targets) {
1450
+ try {
1451
+ const health = await ctx.client().get(routes.deviceHealth, { path: { device: target.id } });
1452
+ results.push({ device_id: target.id, ...isRecord(health) ? health : { health } });
1453
+ } catch (error) {
1454
+ if (error instanceof ApiError && error.status === 422 && error.apiCode === "device_not_meta") {
1455
+ results.push({ device_id: target.id, error: "device_not_meta" });
1456
+ continue;
1457
+ }
1458
+ throw error;
1459
+ }
1460
+ }
1461
+ ctx.out.result(
1462
+ { numbers: results },
1463
+ () => table(
1464
+ ["id", "state", "quality", "limit", "per day", "throughput", "updated", "stale"],
1465
+ results.map((r) => {
1466
+ if (r.error === "device_not_meta") return [r.device_id, "not an official number", null, null, null, null, null, null];
1467
+ const h = isRecord(r.health) ? r.health : {};
1468
+ return [
1469
+ r.device_id,
1470
+ str(h.state),
1471
+ str(h.quality_level),
1472
+ str(h.messaging_limit_tier),
1473
+ h.messaging_limit_per_day === null || h.messaging_limit_per_day === void 0 ? "no limit reported" : Number(h.messaging_limit_per_day).toLocaleString("en-US"),
1474
+ str(r.throughput_level),
1475
+ str(h.last_fetched_at),
1476
+ h.stale === true ? "yes" : h.stale === false ? "no" : null
1477
+ ];
1478
+ })
1479
+ )
1480
+ );
1481
+ }
1482
+ function str(value) {
1483
+ return value === null || value === void 0 || value === "" ? null : String(value);
1484
+ }
1485
+
1486
+ // src/commands/connect.ts
1487
+ var CONNECT_TIMEOUT_SECONDS = 15 * 60;
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
+ }
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");
1571
+ const wait = flags.wait !== false;
1572
+ const timeout = flags.timeout === void 0 ? CONNECT_TIMEOUT_SECONDS : seconds(flags.timeout, "--timeout");
1573
+ const client = ctx.client();
1574
+ const before = wait || deviceId !== null ? await listDevices(ctx) : [];
1575
+ const known = new Set(before.map((d) => Number(d.id)));
1576
+ const connectedBefore = new Set(before.filter(isConnected).map((d) => Number(d.id)));
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 });
1600
+ const data = isRecord(body) && isRecord(body.data) ? body.data : isRecord(body) ? body : {};
1601
+ if (typeof data.url !== "string" || data.url === "") {
1602
+ throw new CliError("The API created the connect link but did not return its URL.", {
1603
+ exitCode: ExitCode.API,
1604
+ code: "connect_link_without_url",
1605
+ hint: "Please report it. The link ends on its own when it expires.",
1606
+ details: { connect_link_id: data.id ?? null }
1607
+ });
1608
+ }
1609
+ const link = { id: data.id ?? null, url: data.url, status: data.status ?? null, expires_at: data.expires_at ?? null };
1610
+ const instructions = plan.instructions(link);
1611
+ const result = plan.target ? { link, device: plan.target } : { link };
1612
+ if (!wait) {
1613
+ ctx.out.result(result, () => [...instructions, "", "Once it is connected, `funnelchat numbers list` shows it."]);
1614
+ return;
1615
+ }
1616
+ ctx.out.prompt(instructions);
1617
+ ctx.out.progress("\nWaiting for the number to connect\u2026 (Ctrl+C to stop)");
1618
+ const deadline = ctx.io.clock() + timeout * 1e3;
1619
+ try {
1620
+ for (; ; ) {
1621
+ const left = deadline - ctx.io.clock();
1622
+ if (left <= 0) {
1623
+ throw new CliError(`No number connected within ${timeout} s.`, {
1624
+ exitCode: ExitCode.TIMEOUT,
1625
+ code: "connect_timeout",
1626
+ hint: plan.timeoutHint
1627
+ });
1628
+ }
1629
+ await pause(ctx, Math.min(POLL_MS, left));
1630
+ const devices = await listDevices(ctx);
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
+ }
1638
+ if (device) {
1639
+ const found = device;
1640
+ const several = devices.filter(isConnected).length > 1;
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."] : []
1645
+ ]);
1646
+ return;
1647
+ }
1648
+ }
1649
+ } catch (error) {
1650
+ throw stillOpen(error, link);
1651
+ }
1652
+ }
1653
+ function stillOpen(error, link) {
1654
+ const cliError = error instanceof InterruptedError ? new CliError("Stopped waiting for the number (Ctrl+C).", { exitCode: ExitCode.INTERRUPTED, code: "interrupted" }) : toCliError(error);
1655
+ const open = `${typeof link.expires_at === "string" ? `The link stays usable until ${link.expires_at}` : "The link stays usable until it expires"}: once the number connects, \`funnelchat numbers list\` shows it.`;
1656
+ cliError.hint = [cliError.hint, open].filter(Boolean).join("\n");
1657
+ cliError.details.connect_link_id = link.id;
1658
+ return cliError;
1659
+ }
1660
+
1172
1661
  // src/dates.ts
1173
1662
  var DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
1174
1663
  var DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i;
@@ -1209,9 +1698,9 @@ var FORMULA_START = /^[=+\-@\t\r]/;
1209
1698
  var PLAIN_NUMBER = /^[+-]?\d+(\.\d+)?$/;
1210
1699
  function csvCell(value) {
1211
1700
  if (value === null || value === void 0) return "";
1212
- let text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
1213
- if (FORMULA_START.test(text) && !PLAIN_NUMBER.test(text)) text = `'${text}`;
1214
- return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1701
+ let text2 = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
1702
+ if (FORMULA_START.test(text2) && !PLAIN_NUMBER.test(text2)) text2 = `'${text2}`;
1703
+ return /[",\r\n]/.test(text2) ? `"${text2.replace(/"/g, '""')}"` : text2;
1215
1704
  }
1216
1705
  function csvRow(values) {
1217
1706
  return `${values.map(csvCell).join(",")}\r
@@ -1372,8 +1861,8 @@ var PartFile = class _PartFile {
1372
1861
  return this.#rows;
1373
1862
  }
1374
1863
  /** Appends whole rows (each already ending in a newline). */
1375
- append(text, rows) {
1376
- const buffer = Buffer.from(text, "utf8");
1864
+ append(text2, rows) {
1865
+ const buffer = Buffer.from(text2, "utf8");
1377
1866
  let written = 0;
1378
1867
  while (written < buffer.length) {
1379
1868
  written += writeSync2(this.fd, buffer, written, buffer.length - written, this.#offset + written);
@@ -1723,7 +2212,7 @@ async function contactsExport(ctx, flags, explicitFlags) {
1723
2212
  const query = mergeNext(baseQuery, state.next ?? { page: 1 });
1724
2213
  const page = await client.page(routes.contactsList, { query });
1725
2214
  if (state.api_total_at_start === null) state.api_total_at_start = totalOf(page.body);
1726
- let text = "";
2215
+ let text2 = "";
1727
2216
  let rows = 0;
1728
2217
  for (const contact of page.items) {
1729
2218
  const id = typeof contact.id === "number" ? contact.id : Number(contact.id);
@@ -1732,14 +2221,14 @@ async function contactsExport(ctx, flags, explicitFlags) {
1732
2221
  if (enriched === null) {
1733
2222
  state.vanished++;
1734
2223
  } else {
1735
- text += o.format === "csv" ? csvRow(csvValues(enriched, o, state.columns ?? [])) : `${JSON.stringify(enriched)}
2224
+ text2 += o.format === "csv" ? csvRow(csvValues(enriched, o, state.columns ?? [])) : `${JSON.stringify(enriched)}
1736
2225
  `;
1737
2226
  rows++;
1738
2227
  observe(state, contact.created_at);
1739
2228
  }
1740
2229
  state.last_id = state.last_id === null ? id : Math.max(state.last_id, id);
1741
2230
  }
1742
- file.append(text, rows);
2231
+ file.append(text2, rows);
1743
2232
  file.sync();
1744
2233
  state.file = file.position;
1745
2234
  state.next = page.next;
@@ -1945,17 +2434,17 @@ async function conversationsExport(ctx, flags, explicitFlags) {
1945
2434
  checkAbort();
1946
2435
  const query = state.list.next ? mergeNext(baseQuery, state.list.next) : baseQuery;
1947
2436
  const page = await client.page(routes.conversationsList, { query });
1948
- let text = "";
2437
+ let text2 = "";
1949
2438
  let rows = 0;
1950
2439
  for (const conversation of page.items) {
1951
2440
  const id = conversationContactId(conversation);
1952
2441
  if (!Number.isSafeInteger(id) || seen.has(id)) continue;
1953
2442
  seen.add(id);
1954
- text += `${JSON.stringify(conversation)}
2443
+ text2 += `${JSON.stringify(conversation)}
1955
2444
  `;
1956
2445
  rows++;
1957
2446
  }
1958
- file.append(text, rows);
2447
+ file.append(text2, rows);
1959
2448
  file.sync();
1960
2449
  state.list.file = file.position;
1961
2450
  state.list.next = page.next;
@@ -2103,6 +2592,43 @@ function observe2(state, time) {
2103
2592
  if (o.last_message_time === null || time > o.last_message_time) o.last_message_time = time;
2104
2593
  }
2105
2594
 
2595
+ // src/commands/groups.ts
2596
+ var PAGE_SIZE = 100;
2597
+ var text = (value) => typeof value === "string" && value !== "" ? value : typeof value === "number" ? String(value) : null;
2598
+ async function groupsList(ctx, flags) {
2599
+ const picked = await pickDevice(ctx, flags.device, { qrOnly: true });
2600
+ const groups = [];
2601
+ for await (const group of ctx.client().all(routes.deviceGroups, { path: { device: picked.id }, query: { pageSize: PAGE_SIZE } })) {
2602
+ groups.push(group);
2603
+ }
2604
+ const from = picked.device ? deviceLabel(picked.device) : `number ${picked.id}`;
2605
+ const device = flags.device !== void 0 ? ` --device ${picked.id}` : "";
2606
+ ctx.out.result(
2607
+ { device_id: picked.id, groups },
2608
+ () => groups.length === 0 ? `${from} is in no WhatsApp group (the list is reused for up to a minute: a group just created can take that long to show).` : [
2609
+ ...table(
2610
+ ["uid", "name", "followed"],
2611
+ groups.map((g) => [text(g.uid), text(g.name), text(g.device_id) ? "yes" : "no"])
2612
+ ),
2613
+ "",
2614
+ `Groups of ${from}, as WhatsApp has them (reused for up to a minute). Follow one with: funnelchat groups follow <uid>${device}`
2615
+ ]
2616
+ );
2617
+ }
2618
+ async function groupsFollow(ctx, group, flags) {
2619
+ const uid = group.trim();
2620
+ if (uid === "") throw usageError("The group needs its uid, as `funnelchat groups` shows it.");
2621
+ const picked = await pickDevice(ctx, flags.device, { qrOnly: true });
2622
+ const body = await ctx.client().post(routes.groupImport, { path: { group: uid }, body: { device_id: picked.id } });
2623
+ const data = isRecord(body) && isRecord(body.data) ? body.data : isRecord(body) ? body : {};
2624
+ const name = text(data.name) ? `"${text(data.name)}" (${text(data.uid) ?? uid})` : text(data.uid) ?? uid;
2625
+ if (data.is_admin === false) ctx.out.warn("the number is not an admin of this group: it cannot send messages to it or manage it.");
2626
+ ctx.out.result({ device_id: picked.id, group: data }, () => [
2627
+ data.imported === false ? `Already following ${name}: it was in your account.` : `Following ${name}.`,
2628
+ "Who joins and who leaves arrives as the group_participant.joined and group_participant.left webhook events."
2629
+ ]);
2630
+ }
2631
+
2106
2632
  // src/commands/mac.ts
2107
2633
  async function macStatus(ctx) {
2108
2634
  const body = await ctx.client().get(routes.macStatus);
@@ -2118,98 +2644,127 @@ async function macStatus(ctx) {
2118
2644
  ]);
2119
2645
  }
2120
2646
 
2121
- // src/commands/numbers.ts
2122
- var OFFICIAL_PROVIDER = 2;
2123
- var CAPABILITY_LABELS = {
2124
- contacts: "contacts",
2125
- conversations: "conversations",
2126
- templates: "templates",
2127
- health: "health and limit"
2128
- };
2129
- function capabilitiesOf(device) {
2130
- const official = Number(device.provider) === OFFICIAL_PROVIDER;
2131
- return ["contacts", "conversations", ...official ? ["templates", "health"] : []];
2132
- }
2133
- function connectionOf(device) {
2134
- return Number(device.provider) === OFFICIAL_PROVIDER ? "official" : "qr";
2135
- }
2136
- async function listDevices(ctx) {
2137
- const devices = [];
2138
- for await (const device of ctx.client().all(routes.devicesList, { query: { perPage: 100 } })) devices.push(device);
2139
- return devices;
2140
- }
2141
- async function numbersList(ctx) {
2142
- const devices = await listDevices(ctx);
2143
- const rows = devices.map((device) => ({
2144
- ...device,
2145
- connection: connectionOf(device),
2146
- cli_capabilities: capabilitiesOf(device)
2147
- }));
2148
- ctx.out.result(
2149
- { numbers: rows },
2150
- () => rows.length === 0 ? "The account has no numbers." : table(
2151
- ["id", "alias", "number", "connection", "connected", "health", "what the CLI reads"],
2152
- rows.map((d) => [
2153
- d.id,
2154
- d.alias,
2155
- d.number,
2156
- d.connection === "official" ? "official" : "QR",
2157
- Number(d.connected) === 1 ? "yes" : "no",
2158
- isRecord(d.health) ? String(d.health.state ?? "-") : null,
2159
- d.cli_capabilities.map((c) => CAPABILITY_LABELS[c]).join(", ")
2160
- ])
2161
- )
2162
- );
2163
- }
2164
- async function numbersHealth(ctx, ids) {
2165
- let targets;
2166
- const wanted = idList(ids, "number");
2167
- if (wanted.length) {
2168
- targets = wanted.map((id) => ({ id, label: String(id) }));
2169
- } else {
2170
- const devices = (await listDevices(ctx)).filter((d) => connectionOf(d) === "official");
2171
- targets = devices.map((d) => ({ id: Number(d.id), label: String(d.alias || d.number || d.id) }));
2172
- if (targets.length === 0) {
2173
- ctx.out.result({ numbers: [] }, () => "The account has no official numbers: health and limit exist only for the official WhatsApp API.");
2174
- return;
2175
- }
2647
+ // src/commands/send.ts
2648
+ var SEND_TIMEOUT_SECONDS = 90;
2649
+ var FIRST_POLL_MS = 1e3;
2650
+ var MAX_POLL_MS = 1e4;
2651
+ function normalizePhone(value) {
2652
+ const digits = value.trim().replace(/^\+/, "").replace(/[\s\-.()]/g, "");
2653
+ if (!/^\d{7,15}$/.test(digits)) {
2654
+ throw usageError(`The phone must have the country code and only digits, for example 5491122334455: ${value}`);
2655
+ }
2656
+ return digits;
2657
+ }
2658
+ function messageOf(body) {
2659
+ if (!isRecord(body)) return null;
2660
+ if (isRecord(body.message)) return body.message;
2661
+ if (isRecord(body.data)) return body.data;
2662
+ return "status" in body || "id" in body ? body : null;
2663
+ }
2664
+ var statusOf = (message) => typeof message.status === "string" ? message.status : null;
2665
+ var isFinal = (status) => status === null || status === "read" || status === "failed";
2666
+ var shown = (status) => status ?? "unknown";
2667
+ async function send(ctx, phoneArg, words, flags) {
2668
+ const phone = normalizePhone(phoneArg);
2669
+ const text2 = words.join(" ");
2670
+ if (text2.trim() === "") throw usageError("The message has no text.");
2671
+ const timeout = flags.timeout === void 0 ? SEND_TIMEOUT_SECONDS : seconds(flags.timeout, "--timeout");
2672
+ const picked = await pickDevice(ctx, flags.device, { qrOnly: false });
2673
+ const from = picked.device ? deviceLabel(picked.device) : `number ${picked.id}`;
2674
+ const client = ctx.client();
2675
+ ctx.out.progress(`Sending to ${phone} from ${from}\u2026`);
2676
+ let answer;
2677
+ try {
2678
+ answer = await client.post(routes.sendMessage, { body: { device_id: picked.id, phone, text: text2 } });
2679
+ } catch (error) {
2680
+ throw explainSendError(error);
2681
+ }
2682
+ let message = messageOf(answer);
2683
+ const messageId = message?.id;
2684
+ const contactId = message?.contact_id;
2685
+ if (!message || messageId === void 0 || messageId === null || contactId === void 0 || contactId === null) {
2686
+ ctx.out.result(
2687
+ { device_id: picked.id, phone, message, outcome: "not_watched" },
2688
+ () => `Sent to ${phone}, but the API did not return the message id: its status cannot be watched.`
2689
+ );
2690
+ return;
2176
2691
  }
2177
- const results = [];
2178
- for (const target of targets) {
2179
- try {
2180
- const health = await ctx.client().get(routes.deviceHealth, { path: { device: target.id } });
2181
- results.push({ device_id: target.id, ...isRecord(health) ? health : { health } });
2182
- } catch (error) {
2183
- if (error instanceof ApiError && error.status === 422 && error.apiCode === "device_not_meta") {
2184
- results.push({ device_id: target.id, error: "device_not_meta" });
2692
+ const started = ctx.io.clock();
2693
+ const elapsed = () => Math.round((ctx.io.clock() - started) / 1e3);
2694
+ let status = statusOf(message);
2695
+ const statuses = [{ status, seconds: 0 }];
2696
+ ctx.out.progress(` ${shown(status)}`);
2697
+ const deadline = started + timeout * 1e3;
2698
+ let delay = FIRST_POLL_MS;
2699
+ const ids = { message_id: messageId, contact_id: contactId };
2700
+ try {
2701
+ while (!isFinal(status) && ctx.io.clock() < deadline) {
2702
+ await pause(ctx, Math.min(delay, deadline - ctx.io.clock()));
2703
+ const read = messageOf(await client.get(routes.messageShow, { path: { contact: String(contactId), message: String(messageId) } }));
2704
+ if (!read) continue;
2705
+ message = read;
2706
+ const next = statusOf(read);
2707
+ if (next === status) {
2708
+ delay = Math.min(delay * 2, MAX_POLL_MS);
2185
2709
  continue;
2186
2710
  }
2187
- throw error;
2711
+ status = next;
2712
+ statuses.push({ status, seconds: elapsed() });
2713
+ ctx.out.progress(` ${shown(status).padEnd(10)} after ${elapsed()} s`);
2714
+ delay = FIRST_POLL_MS;
2188
2715
  }
2716
+ } catch (error) {
2717
+ throw alreadySent(error, ids, status);
2189
2718
  }
2190
- ctx.out.result(
2191
- { numbers: results },
2192
- () => table(
2193
- ["id", "state", "quality", "limit", "per day", "throughput", "updated", "stale"],
2194
- results.map((r) => {
2195
- if (r.error === "device_not_meta") return [r.device_id, "not an official number", null, null, null, null, null, null];
2196
- const h = isRecord(r.health) ? r.health : {};
2197
- return [
2198
- r.device_id,
2199
- str(h.state),
2200
- str(h.quality_level),
2201
- str(h.messaging_limit_tier),
2202
- h.messaging_limit_per_day === null || h.messaging_limit_per_day === void 0 ? "no limit reported" : Number(h.messaging_limit_per_day).toLocaleString("en-US"),
2203
- str(r.throughput_level),
2204
- str(h.last_fetched_at),
2205
- h.stale === true ? "yes" : h.stale === false ? "no" : null
2206
- ];
2207
- })
2208
- )
2209
- );
2719
+ if (status === "failed") {
2720
+ const failure = isRecord(message.error) ? message.error : {};
2721
+ const why = [failure.title, failure.reason ?? failure.code].filter((v) => v !== null && v !== void 0 && v !== "").map(String);
2722
+ throw new CliError(`The message failed${why.length ? `: ${why.join(" \xB7 ")}` : "."}`, {
2723
+ exitCode: ExitCode.API,
2724
+ code: "message_failed",
2725
+ hint: `Message ${String(messageId)} to ${phone} (contact ${String(contactId)}).`,
2726
+ details: { ...ids, status, error: message.error ?? null, statuses }
2727
+ });
2728
+ }
2729
+ const outcome = status === "read" ? "read" : status === null ? "unknown" : timeout === 0 ? "not_watched" : "timeout";
2730
+ const where = `GET /api/v1/contacts/${String(contactId)}/messages/${String(messageId)}`;
2731
+ ctx.out.result({ device_id: picked.id, phone, message, statuses, outcome }, () => {
2732
+ const head = `Message ${String(messageId)} to ${phone}`;
2733
+ if (outcome === "read") return `${head}: read ${elapsed()} s after sending.`;
2734
+ if (outcome === "unknown") {
2735
+ return [`${head}: the provider did not confirm or reject the send. It may have arrived, and its status will not change.`];
2736
+ }
2737
+ if (outcome === "not_watched") return [`${head}: ${shown(status)} (not watched: --timeout 0).`, `Its status: ${where}`];
2738
+ return [
2739
+ `${head}: ${shown(status)} after ${timeout} s; stopped watching (--timeout).`,
2740
+ ...status === "delivered" ? ["If the recipient turned read receipts off, it stays delivered."] : [],
2741
+ `Its status keeps changing in Funnelchat: ${where}, or the message.status_changed webhook.`
2742
+ ];
2743
+ });
2210
2744
  }
2211
- function str(value) {
2212
- return value === null || value === void 0 || value === "" ? null : String(value);
2745
+ function explainSendError(error) {
2746
+ if (!(error instanceof ApiError)) return error;
2747
+ const add = (line) => {
2748
+ error.hint = [line, error.hint].filter(Boolean).join("\n");
2749
+ };
2750
+ if (error.status === 403 && (error.apiCode === void 0 || error.apiCode === "permission_denied")) {
2751
+ add("Sending needs conversations:send; to a phone that is not a contact yet it also needs conversations:write, because the send creates the contact.");
2752
+ } else if (error.status === 404 && error.apiCode !== "route_not_found") {
2753
+ add("Nothing was sent. The phone may be a contact of another workspace of your account, or --device not one of your numbers.");
2754
+ } else if (error.status === 429 && error.apiCode === "rate_limited") {
2755
+ add("Nothing was sent: the number is sending too fast (per second, or per minute to the same contact).");
2756
+ } else if (error.status === 422 && error.fieldErrors) {
2757
+ add("Nothing was sent.");
2758
+ }
2759
+ return error;
2760
+ }
2761
+ function alreadySent(error, ids, status) {
2762
+ const cliError = error instanceof InterruptedError ? new CliError(`Stopped watching the message (Ctrl+C); its last status was ${shown(status)}.`, { exitCode: ExitCode.INTERRUPTED, code: "interrupted" }) : toCliError(error);
2763
+ if (error instanceof ApiError && error.status === 403) cliError.hint = [cliError.hint, "Watching the status needs conversations:read."].filter(Boolean).join("\n");
2764
+ const sent = `The message was sent (id ${String(ids.message_id)}, contact ${String(ids.contact_id)}): do not send it again. Its status: GET /api/v1/contacts/${String(ids.contact_id)}/messages/${String(ids.message_id)}`;
2765
+ cliError.hint = [cliError.hint, sent].filter(Boolean).join("\n");
2766
+ Object.assign(cliError.details, ids, { last_status: status });
2767
+ return cliError;
2213
2768
  }
2214
2769
 
2215
2770
  // src/commands/templates.ts
@@ -2234,6 +2789,12 @@ async function templatesList(ctx, flags) {
2234
2789
  }
2235
2790
 
2236
2791
  // src/main.ts
2792
+ var FIRST_USE_HELP = `
2793
+ First use:
2794
+ funnelchat login paste your key; it is not shown
2795
+ funnelchat connect open the link and scan the QR with WhatsApp
2796
+ funnelchat send 5491122334455 "Hello from Funnelchat" and watch it: queued, sent, delivered, read
2797
+ `;
2237
2798
  var EXIT_CODES_HELP = `
2238
2799
  Exit codes:
2239
2800
  0 all good
@@ -2242,8 +2803,9 @@ Exit codes:
2242
2803
  3 no key, a key the API rejects (401) or a missing permission (403)
2243
2804
  4 not found in your account (404)
2244
2805
  5 an API limit the CLI does not wait for (daily cap or a long Retry-After): it can be resumed
2245
- 6 API or network error, after the retries
2806
+ 6 API or network error, after the retries; or the message sent failed (send)
2246
2807
  7 the output already exists: the CLI never overwrites a file
2808
+ 8 no number connected within the time (connect)
2247
2809
  130 interrupted with Ctrl+C: an export can be resumed
2248
2810
 
2249
2811
  Environment variables:
@@ -2253,13 +2815,16 @@ Environment variables:
2253
2815
  FUNNELCHAT_RATE requests per second
2254
2816
  FUNNELCHAT_CONFIG_DIR where to keep the configuration
2255
2817
 
2256
- The CLI only reads: it sends no messages and changes nothing in your account.`;
2818
+ Only connect, send and groups follow change something in your account; every other command only reads.
2819
+ A write is sent once and never retried on its own.`;
2257
2820
  async function run(argv, io) {
2258
2821
  let exitCode = ExitCode.OK;
2259
2822
  const program = new Command("funnelchat");
2260
- program.description("Export conversations and contacts from your Funnelchat account through the public API, and check your numbers, templates and MACs.").version(VERSION, "-V, --version", "Shows the version.").helpOption("-h, --help", "Shows the help.").helpCommand("help [command]", "Shows the help of a command.").option("--profile <name>", "Configuration profile to use.").option("--base-url <url>", "Base URL of the API (https://api.funnelchat.com by default).").option("--staging", "Uses the staging API (https://api-staging.funnelchat.com).").option("--json", "JSON output, for scripts.").option("-v, --verbose", "Shows every request to the API (never the key).").option("-q, --quiet", "No progress messages.").option("--rate <requests>", "Requests per second at most (1 by default).").option("--max-wait <seconds>", "How long to wait for a Retry-After on its own before stopping (60 by default).").addHelpText("after", EXIT_CODES_HELP).showSuggestionAfterError(true).exitOverride().configureOutput({
2261
- writeOut: (text) => io.stdout.write(text),
2262
- writeErr: (text) => io.stderr.write(text)
2823
+ program.description(
2824
+ "Connect a WhatsApp number, send a message and watch it get read, and follow groups, through the Funnelchat public API. Also exports conversations and contacts, and checks numbers, templates and MACs."
2825
+ ).version(VERSION, "-V, --version", "Shows the version.").helpOption("-h, --help", "Shows the help.").helpCommand("help [command]", "Shows the help of a command.").option("--profile <name>", "Configuration profile to use.").option("--base-url <url>", "Base URL of the API (https://api.funnelchat.com by default).").option("--staging", "Uses the staging API (https://api-staging.funnelchat.com).").option("--json", "JSON output, for scripts.").option("-v, --verbose", "Shows every request to the API (never the key).").option("-q, --quiet", "No progress messages.").option("--rate <requests>", "Requests per second at most (1 by default).").option("--max-wait <seconds>", "How long to wait for a Retry-After on its own before stopping (60 by default).").addHelpText("after", FIRST_USE_HELP + EXIT_CODES_HELP).showSuggestionAfterError(true).exitOverride().configureOutput({
2826
+ writeOut: (text2) => io.stdout.write(text2),
2827
+ writeErr: (text2) => io.stderr.write(text2)
2263
2828
  });
2264
2829
  const action = (fn) => async (...args) => {
2265
2830
  const command = args[args.length - 1];
@@ -2277,6 +2842,16 @@ async function run(argv, io) {
2277
2842
  const profiles = program.command("profiles").description("Saved profiles.");
2278
2843
  profiles.command("list", { isDefault: true }).description("Lists the profiles (never shows the keys).").action(action((ctx) => profilesList(ctx)));
2279
2844
  profiles.command("use").argument("<name>", "Profile to activate.").description("Chooses the profile used by default.").action(action((ctx, _command, name) => profilesUse(ctx, name)));
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())));
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())));
2852
+ const groups = program.command("groups").description("WhatsApp groups of a number connected by QR.");
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())));
2854
+ groups.command("follow").argument("<group>", "The uid of the group, as `funnelchat groups` shows it.").description("Follows a group: imports it to your account, so who joins and leaves arrives as webhook events.").option("--device <id>", "Number the group is on (by default the only one connected by QR).").action(action((ctx, command, group) => groupsFollow(ctx, group, command.opts())));
2280
2855
  const numbers = program.command("numbers").description("Your WhatsApp numbers.");
2281
2856
  numbers.command("list").description("Lists the numbers, with their connection and what the CLI can export or read for each one.").action(action((ctx) => numbersList(ctx)));
2282
2857
  numbers.command("health").argument("[numbers...]", "Ids of the numbers (all the official ones by default).").description("Health, quality, messaging limit and throughput of the official numbers.").action(action((ctx, _command, ids) => numbersHealth(ctx, ids ?? [])));
@@ -2295,9 +2870,9 @@ async function run(argv, io) {
2295
2870
  const mac = program.command("mac").description("Monthly active contacts (MACs).");
2296
2871
  mac.command("status").description("How many MACs you have used in the period, the limit and how many are left.").action(action((ctx) => macStatus(ctx)));
2297
2872
  for (const command of program.commands) {
2298
- command.exitOverride().configureOutput({ writeOut: (text) => io.stdout.write(text), writeErr: (text) => io.stderr.write(text) });
2873
+ command.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
2299
2874
  for (const sub of command.commands) {
2300
- sub.exitOverride().configureOutput({ writeOut: (text) => io.stdout.write(text), writeErr: (text) => io.stderr.write(text) });
2875
+ sub.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
2301
2876
  }
2302
2877
  }
2303
2878
  try {