@funnelchat-hq/cli 0.1.1 → 0.2.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 +138 -15
  2. package/dist/cli.js +588 -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,51 @@ 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
+ // groups
155
+ provider_unsupported: { message: "The number cannot do that: only a number connected by QR can follow groups." },
156
+ group_not_found: {
157
+ message: "That group is not one of the number's groups (or it belongs to another workspace of the account).",
158
+ hint: "List them with `funnelchat groups` and use the uid it shows."
159
+ },
160
+ 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`." },
161
+ device_not_connected: { message: "The number is not connected.", hint: "See it with `funnelchat numbers list`." },
162
+ device_not_group_admin: { message: "The number is not an admin of that group." },
163
+ unsupported_group_type: { message: "That uid is a community or a channel, not a group." },
164
+ 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." },
165
+ import_rate_limited: { message: "This number already followed 5 groups in the last minute." },
166
+ import_in_progress: { message: "That group is being imported right now.", hint: "Try again in a few seconds." }
167
+ };
168
+ function exitCodeOf(status) {
169
+ if (status === 401 || status === 403) return ExitCode.AUTH;
170
+ if (status === 404) return ExitCode.NOT_FOUND;
171
+ if (status === 429) return ExitCode.RATE_LIMITED;
172
+ return ExitCode.API;
173
+ }
127
174
  function describe(init, apiCode, apiMessage, fieldErrors) {
128
175
  const where = `${init.method} ${init.route}`;
129
176
  const said = apiMessage ? `: ${apiMessage}` : ".";
@@ -135,6 +182,10 @@ function describe(init, apiCode, apiMessage, fieldErrors) {
135
182
  hint: "Check that the key is complete (with the `<id>|` at the start) and still active, and run `funnelchat login` again."
136
183
  };
137
184
  }
185
+ const known = apiCode !== void 0 && Object.hasOwn(KNOWN_API_ERRORS, apiCode) ? KNOWN_API_ERRORS[apiCode] : void 0;
186
+ if (known) {
187
+ return { exitCode: exitCodeOf(status), message: `${known.message} (${status} ${apiCode} on ${where})`, ...known.hint ? { hint: known.hint } : {} };
188
+ }
138
189
  if (status === 403) {
139
190
  const permission = init.permission ? ` This route needs \`${init.permission}\`.` : "";
140
191
  return {
@@ -202,7 +253,7 @@ function isRecord(value) {
202
253
  }
203
254
 
204
255
  // src/version.ts
205
- var VERSION = "0.1.1";
256
+ var VERSION = "0.2.0";
206
257
  var PACKAGE_NAME = "@funnelchat-hq/cli";
207
258
  var USER_AGENT_PRODUCT = "funnelchat-cli";
208
259
 
@@ -310,6 +361,7 @@ var DEFAULT_MAX_RETRIES = 4;
310
361
  var DEFAULT_MAX_WAIT_SECONDS = 60;
311
362
  var INITIAL_BACKOFF_MS = 1e3;
312
363
  var MAX_BACKOFF_MS = 3e4;
364
+ var WRITE_MAY_HAVE_ARRIVED = "It was a write: Funnelchat may have received it. Check before repeating it.";
313
365
  var ApiClient = class {
314
366
  baseUrl;
315
367
  /** Requests sent, retries included. */
@@ -329,6 +381,11 @@ var ApiClient = class {
329
381
  async get(route, options = {}) {
330
382
  return await this.#send(route, options);
331
383
  }
384
+ /** One write (`POST`) of `route` with a JSON `body`, sent once and never retried. Returns the parsed body. */
385
+ async post(route, options = {}) {
386
+ if (route.method !== "POST") throw new TypeError(`${route.method} ${route.path} is not a write.`);
387
+ return await this.#send(route, options);
388
+ }
332
389
  /** One page of a listing route, and the query of the page after it. */
333
390
  async page(route, options = {}) {
334
391
  const body = await this.#send(route, options);
@@ -359,6 +416,8 @@ var ApiClient = class {
359
416
  "User-Agent": `${USER_AGENT_PRODUCT}/${VERSION} node/${process.version}`,
360
417
  Authorization: `Bearer ${this.#apiKey}`
361
418
  };
419
+ const payload = options.body === void 0 ? void 0 : JSON.stringify(options.body);
420
+ if (payload !== void 0) headers["Content-Type"] = "application/json";
362
421
  for (let attempt = 0; ; attempt++) {
363
422
  const canRetry = retriesAllowed && attempt < maxRetries;
364
423
  await opts.pacer.wait(opts.signal);
@@ -366,9 +425,9 @@ var ApiClient = class {
366
425
  const started = opts.clock();
367
426
  opts.log?.(`\u2192 ${method} ${url}${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`);
368
427
  let response;
369
- let text;
428
+ let text2;
370
429
  try {
371
- ({ response, text } = await this.#attempt(method, url, headers));
430
+ ({ response, text: text2 } = await this.#attempt(method, url, headers, payload));
372
431
  } catch (error2) {
373
432
  if (error2 instanceof InterruptedError) throw error2;
374
433
  if (canRetry) {
@@ -377,11 +436,12 @@ var ApiClient = class {
377
436
  await this.#sleep(delay2);
378
437
  continue;
379
438
  }
439
+ if (!retriesAllowed && error2 instanceof ConnectionError) error2.hint = [error2.hint, WRITE_MAY_HAVE_ARRIVED].filter(Boolean).join("\n");
380
440
  throw error2;
381
441
  }
382
442
  const requestId = response.headers.get("x-request-id") ?? void 0;
383
443
  opts.log?.(`\u2190 ${response.status} in ${opts.clock() - started} ms${requestId ? ` \xB7 X-Request-Id ${requestId}` : ""}`);
384
- const body = parseBody(response, text);
444
+ const body = parseBody(response, text2);
385
445
  if (response.ok) return body;
386
446
  const retryAfter = parseRetryAfter(response.headers.get("retry-after"), opts.clock());
387
447
  const error = new ApiError({
@@ -395,9 +455,12 @@ var ApiClient = class {
395
455
  requestId
396
456
  });
397
457
  const retryable = response.status === 429 || response.status >= 500 && error.apiCode !== "edge_misconfigured";
458
+ if (!retriesAllowed && response.status >= 500 && error.apiCode !== "edge_misconfigured" && error.apiCode !== "gate_unavailable") {
459
+ error.hint = [WRITE_MAY_HAVE_ARRIVED, error.hint].filter(Boolean).join("\n");
460
+ }
398
461
  if (!retryable) throw error;
399
462
  if (retryAfter !== void 0 && retryAfter > maxWait) {
400
- throw stopForLongWait(error, retryAfter, opts.clock());
463
+ throw stopForLongWait(error, retryAfter, opts.clock(), method);
401
464
  }
402
465
  if (!canRetry) {
403
466
  if (response.status === 429) error.hint = [`Wait ${formatSeconds(retryAfter ?? 60)} and try again.`, error.hint].filter(Boolean).join("\n");
@@ -416,7 +479,7 @@ var ApiClient = class {
416
479
  throw error;
417
480
  }
418
481
  }
419
- async #attempt(method, url, headers) {
482
+ async #attempt(method, url, headers, payload) {
420
483
  const signal = this.#options.signal;
421
484
  if (signal?.aborted) throw new InterruptedError();
422
485
  const timeoutMs = this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -436,7 +499,7 @@ var ApiClient = class {
436
499
  try {
437
500
  let response;
438
501
  try {
439
- response = await this.#options.fetch(url, { method, headers, signal: controller2.signal });
502
+ response = await this.#options.fetch(url, { method, headers, signal: controller2.signal, ...payload !== void 0 ? { body: payload } : {} });
440
503
  } catch (error) {
441
504
  throw classify(error);
442
505
  }
@@ -461,7 +524,7 @@ function backoffMs(attempt, random = Math.random) {
461
524
  const base = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
462
525
  return Math.round(base * (1 - 0.25 * random()));
463
526
  }
464
- function stopForLongWait(error, retryAfter, now) {
527
+ function stopForLongWait(error, retryAfter, now, method) {
465
528
  const at = new Date(now + retryAfter * 1e3);
466
529
  const when = `${formatSeconds(retryAfter)} (at ${at.toISOString().slice(11, 16)} UTC on ${at.toISOString().slice(0, 10)})`;
467
530
  let message;
@@ -475,7 +538,7 @@ function stopForLongWait(error, retryAfter, now) {
475
538
  status: error.status,
476
539
  body: error.apiCode ? { error: error.apiCode } : null,
477
540
  retryAfter,
478
- method: "GET",
541
+ method,
479
542
  route: error.route,
480
543
  domain: error.domain,
481
544
  permission: null,
@@ -487,25 +550,25 @@ function stopForLongWait(error, retryAfter, now) {
487
550
  }
488
551
  });
489
552
  }
490
- function formatSeconds(seconds) {
491
- const s = Math.max(0, Math.ceil(seconds));
553
+ function formatSeconds(seconds2) {
554
+ const s = Math.max(0, Math.ceil(seconds2));
492
555
  if (s < 60) return `${s} s`;
493
556
  const h = Math.floor(s / 3600);
494
557
  const m = Math.floor(s % 3600 / 60);
495
558
  if (h === 0) return `${m} min${s % 60 ? ` ${s % 60} s` : ""}`;
496
559
  return `${h} h${m ? ` ${m} min` : ""}`;
497
560
  }
498
- function parseBody(response, text) {
499
- if (text === "") return null;
561
+ function parseBody(response, text2) {
562
+ if (text2 === "") return null;
500
563
  const type = response.headers.get("content-type") ?? "";
501
- if (type.includes("json") || /^\s*[[{]/.test(text)) {
564
+ if (type.includes("json") || /^\s*[[{]/.test(text2)) {
502
565
  try {
503
- return JSON.parse(text);
566
+ return JSON.parse(text2);
504
567
  } catch {
505
- return text;
568
+ return text2;
506
569
  }
507
570
  }
508
- return text.length <= 300 ? text : `${text.slice(0, 300)}\u2026`;
571
+ return text2.length <= 300 ? text2 : `${text2.slice(0, 300)}\u2026`;
509
572
  }
510
573
  function hostOf(url) {
511
574
  try {
@@ -530,8 +593,8 @@ var Redactor = class {
530
593
  for (const part of parts) if (part.length >= 6 && !this.#secrets.includes(part)) this.#secrets.push(part);
531
594
  this.#secrets.sort((a, b) => b.length - a.length);
532
595
  }
533
- redact(text) {
534
- let out = text;
596
+ redact(text2) {
597
+ let out = text2;
535
598
  for (const secret of this.#secrets) out = out.split(secret).join(REDACTED);
536
599
  return out.replace(KEY_SHAPE, REDACTED);
537
600
  }
@@ -693,6 +756,13 @@ var Output = class {
693
756
  `);
694
757
  else this.#lines(this.stdout, human());
695
758
  }
759
+ /**
760
+ * What the person has to act on now, before the command ends (the link of `connect`): stdout, or stderr
761
+ * with `--json` so stdout keeps a single JSON document. Shown even with `--quiet`.
762
+ */
763
+ prompt(lines) {
764
+ this.#lines(this.options.json ? this.stderr : this.stdout, lines);
765
+ }
696
766
  /** Progress of a long command (stderr). Silent with `--quiet` or `--json`. */
697
767
  progress(line) {
698
768
  if (!this.options.quiet && !this.options.json) this.#lines(this.stderr, line);
@@ -732,12 +802,12 @@ var Output = class {
732
802
  return cliError.exitCode;
733
803
  }
734
804
  #lines(writer, lines) {
735
- const text = Array.isArray(lines) ? lines.join("\n") : lines;
736
- this.#write(writer, text.endsWith("\n") ? text : `${text}
805
+ const text2 = Array.isArray(lines) ? lines.join("\n") : lines;
806
+ this.#write(writer, text2.endsWith("\n") ? text2 : `${text2}
737
807
  `);
738
808
  }
739
- #write(writer, text) {
740
- writer.write(this.redactor.redact(text));
809
+ #write(writer, text2) {
810
+ writer.write(this.redactor.redact(text2));
741
811
  }
742
812
  };
743
813
  function toCliError(error) {
@@ -827,11 +897,11 @@ var Context = class {
827
897
 
828
898
  // src/generated/routes.ts
829
899
  var SPEC_SOURCE = {
830
- "sha256": "8bbbae569b4cab520884db98f28204156c1bd6c629578bee609343c193e0dadb",
900
+ "sha256": "0ffaad2b25733d28b2cc07dde8152400c3c68974b3515e71e116ce599312b0fa",
831
901
  "repository": "git@github.com:Funnelchat20/developers-portal.git",
832
- "branch": "feat/openapi-unificada",
833
- "commit": "0ef4abb008870ed8c7cae49a08900a81e5e57ed8",
834
- "paths": 191
902
+ "branch": "main",
903
+ "commit": "99928866cbac41d5b89d31d19773acfabdbbe5dd",
904
+ "paths": 218
835
905
  };
836
906
  var routes = {
837
907
  "me": {
@@ -845,6 +915,7 @@ var routes = {
845
915
  "plainArray": false,
846
916
  "perPageMax": null,
847
917
  "query": [],
918
+ "body": [],
848
919
  "pending": null
849
920
  },
850
921
  "devicesList": {
@@ -860,6 +931,7 @@ var routes = {
860
931
  "query": [
861
932
  "perPage"
862
933
  ],
934
+ "body": [],
863
935
  "pending": null
864
936
  },
865
937
  "deviceHealth": {
@@ -873,6 +945,7 @@ var routes = {
873
945
  "plainArray": false,
874
946
  "perPageMax": null,
875
947
  "query": [],
948
+ "body": [],
876
949
  "pending": null
877
950
  },
878
951
  "contactsList": {
@@ -895,6 +968,7 @@ var routes = {
895
968
  "created_to",
896
969
  "only_with_phone"
897
970
  ],
971
+ "body": [],
898
972
  "pending": null
899
973
  },
900
974
  "contactTags": {
@@ -910,6 +984,7 @@ var routes = {
910
984
  "query": [
911
985
  "perPage"
912
986
  ],
987
+ "body": [],
913
988
  "pending": null
914
989
  },
915
990
  "contactCustomFields": {
@@ -925,6 +1000,7 @@ var routes = {
925
1000
  "query": [
926
1001
  "perPage"
927
1002
  ],
1003
+ "body": [],
928
1004
  "pending": null
929
1005
  },
930
1006
  "customFieldsList": {
@@ -942,6 +1018,7 @@ var routes = {
942
1018
  "orderBy",
943
1019
  "orderByMethod"
944
1020
  ],
1021
+ "body": [],
945
1022
  "pending": null
946
1023
  },
947
1024
  "conversationsList": {
@@ -961,6 +1038,7 @@ var routes = {
961
1038
  "contactId",
962
1039
  "updated_since"
963
1040
  ],
1041
+ "body": [],
964
1042
  "pending": null
965
1043
  },
966
1044
  "contactMessages": {
@@ -977,6 +1055,7 @@ var routes = {
977
1055
  "perPage",
978
1056
  "updated_since"
979
1057
  ],
1058
+ "body": [],
980
1059
  "pending": null
981
1060
  },
982
1061
  "templatesList": {
@@ -994,6 +1073,7 @@ var routes = {
994
1073
  "deviceId",
995
1074
  "status"
996
1075
  ],
1076
+ "body": [],
997
1077
  "pending": null
998
1078
  },
999
1079
  "macStatus": {
@@ -1007,6 +1087,87 @@ var routes = {
1007
1087
  "plainArray": false,
1008
1088
  "perPageMax": null,
1009
1089
  "query": [],
1090
+ "body": [],
1091
+ "pending": null
1092
+ },
1093
+ "connectLinkCreate": {
1094
+ "id": "connectLinkCreate",
1095
+ "method": "POST",
1096
+ "path": "/api/v1/connect-links",
1097
+ "summary": "Crear un link de alta",
1098
+ "domain": "accounts",
1099
+ "permission": "accounts:operate",
1100
+ "pagination": "none",
1101
+ "plainArray": false,
1102
+ "perPageMax": null,
1103
+ "query": [],
1104
+ "body": [
1105
+ "type"
1106
+ ],
1107
+ "pending": null
1108
+ },
1109
+ "sendMessage": {
1110
+ "id": "sendMessage",
1111
+ "method": "POST",
1112
+ "path": "/api/v1/message/send-message",
1113
+ "summary": "Enviar un mensaje",
1114
+ "domain": "conversations",
1115
+ "permission": "conversations:send",
1116
+ "pagination": "none",
1117
+ "plainArray": false,
1118
+ "perPageMax": null,
1119
+ "query": [],
1120
+ "body": [
1121
+ "device_id",
1122
+ "phone",
1123
+ "text"
1124
+ ],
1125
+ "pending": null
1126
+ },
1127
+ "messageShow": {
1128
+ "id": "messageShow",
1129
+ "method": "GET",
1130
+ "path": "/api/v1/contacts/{contact}/messages/{message}",
1131
+ "summary": "Ver un mensaje",
1132
+ "domain": "conversations",
1133
+ "permission": "conversations:read",
1134
+ "pagination": "none",
1135
+ "plainArray": false,
1136
+ "perPageMax": null,
1137
+ "query": [],
1138
+ "body": [],
1139
+ "pending": null
1140
+ },
1141
+ "deviceGroups": {
1142
+ "id": "deviceGroups",
1143
+ "method": "GET",
1144
+ "path": "/api/v1/devices/{device}/groups",
1145
+ "summary": "Listar los grupos que administra un n\xFAmero",
1146
+ "domain": "communities",
1147
+ "permission": "communities:read",
1148
+ "pagination": "page",
1149
+ "plainArray": false,
1150
+ "perPageMax": null,
1151
+ "query": [
1152
+ "pageSize"
1153
+ ],
1154
+ "body": [],
1155
+ "pending": null
1156
+ },
1157
+ "groupImport": {
1158
+ "id": "groupImport",
1159
+ "method": "POST",
1160
+ "path": "/api/v1/groups/{group}/import",
1161
+ "summary": "Importar un grupo de un n\xFAmero",
1162
+ "domain": "communities",
1163
+ "permission": "communities:operate",
1164
+ "pagination": "none",
1165
+ "plainArray": false,
1166
+ "perPageMax": null,
1167
+ "query": [],
1168
+ "body": [
1169
+ "device_id"
1170
+ ],
1010
1171
  "pending": null
1011
1172
  }
1012
1173
  };
@@ -1144,6 +1305,19 @@ function readAll(stdin) {
1144
1305
  }
1145
1306
 
1146
1307
  // src/commands/common.ts
1308
+ function seconds(value, flag) {
1309
+ if (!/^\d+$/.test(value) || Number(value) > 86400) throw usageError(`${flag} must be a whole number of seconds, from 0 to 86400: ${value}`);
1310
+ return Number(value);
1311
+ }
1312
+ async function pause(ctx, ms) {
1313
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
1314
+ try {
1315
+ await ctx.io.sleep(Math.max(0, ms), ctx.io.signal);
1316
+ } catch (error) {
1317
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
1318
+ throw error;
1319
+ }
1320
+ }
1147
1321
  function positiveInt(value, flag) {
1148
1322
  if (!/^\d+$/.test(value) || Number(value) < 1 || !Number.isSafeInteger(Number(value))) {
1149
1323
  throw usageError(`${flag} must be a positive integer: ${value}`);
@@ -1169,6 +1343,207 @@ function explicitOptions(command, names) {
1169
1343
  return new Set(names.filter((name) => command.getOptionValueSource(name) === "cli"));
1170
1344
  }
1171
1345
 
1346
+ // src/commands/numbers.ts
1347
+ var OFFICIAL_PROVIDER = 2;
1348
+ var CAPABILITY_LABELS = {
1349
+ contacts: "contacts",
1350
+ conversations: "conversations",
1351
+ templates: "templates",
1352
+ health: "health and limit"
1353
+ };
1354
+ function capabilitiesOf(device) {
1355
+ const official = Number(device.provider) === OFFICIAL_PROVIDER;
1356
+ return ["contacts", "conversations", ...official ? ["templates", "health"] : []];
1357
+ }
1358
+ function connectionOf(device) {
1359
+ return Number(device.provider) === OFFICIAL_PROVIDER ? "official" : "qr";
1360
+ }
1361
+ async function listDevices(ctx) {
1362
+ const devices = [];
1363
+ for await (const device of ctx.client().all(routes.devicesList, { query: { perPage: 100 } })) devices.push(device);
1364
+ return devices;
1365
+ }
1366
+ function isConnected(device) {
1367
+ return device.connected === true || Number(device.connected) === 1;
1368
+ }
1369
+ function deviceLabel(device) {
1370
+ const alias = typeof device.alias === "string" && device.alias !== "" ? device.alias : null;
1371
+ const number = typeof device.number === "string" && device.number !== "" ? device.number : null;
1372
+ if (alias && number) return `${alias} (${number})`;
1373
+ return alias ?? number ?? `number ${String(device.id)}`;
1374
+ }
1375
+ async function pickDevice(ctx, flag, options) {
1376
+ if (flag !== void 0) return { id: positiveInt(flag, "--device"), device: null };
1377
+ let devices;
1378
+ try {
1379
+ devices = await listDevices(ctx);
1380
+ } catch (error) {
1381
+ if (error instanceof ApiError && error.status === 403) {
1382
+ 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");
1383
+ }
1384
+ throw error;
1385
+ }
1386
+ const kind = options.qrOnly ? "connected by QR" : "connected";
1387
+ const candidates = devices.filter((d) => isConnected(d) && (!options.qrOnly || connectionOf(d) === "qr"));
1388
+ const [only] = candidates;
1389
+ if (candidates.length === 1 && only) return { id: Number(only.id), device: only };
1390
+ if (candidates.length === 0) {
1391
+ throw new CliError(`The account has no number ${kind}.`, {
1392
+ exitCode: ExitCode.NOT_FOUND,
1393
+ code: "no_connected_number",
1394
+ hint: `Connect one with \`funnelchat connect\`, or choose one with --device <id> (\`funnelchat numbers list\` shows them).`
1395
+ });
1396
+ }
1397
+ throw usageError(
1398
+ `The account has ${candidates.length} numbers ${kind}: choose one with --device <id>.`,
1399
+ candidates.map((d) => `${String(d.id)} ${deviceLabel(d)}`).join("\n")
1400
+ );
1401
+ }
1402
+ async function numbersList(ctx) {
1403
+ const devices = await listDevices(ctx);
1404
+ const rows = devices.map((device) => ({
1405
+ ...device,
1406
+ connection: connectionOf(device),
1407
+ cli_capabilities: capabilitiesOf(device)
1408
+ }));
1409
+ ctx.out.result(
1410
+ { numbers: rows },
1411
+ () => rows.length === 0 ? "The account has no numbers." : table(
1412
+ ["id", "alias", "number", "connection", "connected", "health", "what the CLI reads"],
1413
+ rows.map((d) => [
1414
+ d.id,
1415
+ d.alias,
1416
+ d.number,
1417
+ d.connection === "official" ? "official" : "QR",
1418
+ Number(d.connected) === 1 ? "yes" : "no",
1419
+ isRecord(d.health) ? String(d.health.state ?? "-") : null,
1420
+ d.cli_capabilities.map((c) => CAPABILITY_LABELS[c]).join(", ")
1421
+ ])
1422
+ )
1423
+ );
1424
+ }
1425
+ async function numbersHealth(ctx, ids) {
1426
+ let targets;
1427
+ const wanted = idList(ids, "number");
1428
+ if (wanted.length) {
1429
+ targets = wanted.map((id) => ({ id, label: String(id) }));
1430
+ } else {
1431
+ const devices = (await listDevices(ctx)).filter((d) => connectionOf(d) === "official");
1432
+ targets = devices.map((d) => ({ id: Number(d.id), label: String(d.alias || d.number || d.id) }));
1433
+ if (targets.length === 0) {
1434
+ ctx.out.result({ numbers: [] }, () => "The account has no official numbers: health and limit exist only for the official WhatsApp API.");
1435
+ return;
1436
+ }
1437
+ }
1438
+ const results = [];
1439
+ for (const target of targets) {
1440
+ try {
1441
+ const health = await ctx.client().get(routes.deviceHealth, { path: { device: target.id } });
1442
+ results.push({ device_id: target.id, ...isRecord(health) ? health : { health } });
1443
+ } catch (error) {
1444
+ if (error instanceof ApiError && error.status === 422 && error.apiCode === "device_not_meta") {
1445
+ results.push({ device_id: target.id, error: "device_not_meta" });
1446
+ continue;
1447
+ }
1448
+ throw error;
1449
+ }
1450
+ }
1451
+ ctx.out.result(
1452
+ { numbers: results },
1453
+ () => table(
1454
+ ["id", "state", "quality", "limit", "per day", "throughput", "updated", "stale"],
1455
+ results.map((r) => {
1456
+ if (r.error === "device_not_meta") return [r.device_id, "not an official number", null, null, null, null, null, null];
1457
+ const h = isRecord(r.health) ? r.health : {};
1458
+ return [
1459
+ r.device_id,
1460
+ str(h.state),
1461
+ str(h.quality_level),
1462
+ str(h.messaging_limit_tier),
1463
+ 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"),
1464
+ str(r.throughput_level),
1465
+ str(h.last_fetched_at),
1466
+ h.stale === true ? "yes" : h.stale === false ? "no" : null
1467
+ ];
1468
+ })
1469
+ )
1470
+ );
1471
+ }
1472
+ function str(value) {
1473
+ return value === null || value === void 0 || value === "" ? null : String(value);
1474
+ }
1475
+
1476
+ // src/commands/connect.ts
1477
+ var CONNECT_TIMEOUT_SECONDS = 15 * 60;
1478
+ var POLL_MS = 5e3;
1479
+ async function connect(ctx, flags) {
1480
+ const wait = flags.wait !== false;
1481
+ const timeout = flags.timeout === void 0 ? CONNECT_TIMEOUT_SECONDS : seconds(flags.timeout, "--timeout");
1482
+ const client = ctx.client();
1483
+ const before = wait ? await listDevices(ctx) : [];
1484
+ const known = new Set(before.map((d) => Number(d.id)));
1485
+ const connectedBefore = new Set(before.filter(isConnected).map((d) => Number(d.id)));
1486
+ const body = await client.post(routes.connectLinkCreate, { body: { type: "qr" } });
1487
+ const data = isRecord(body) && isRecord(body.data) ? body.data : isRecord(body) ? body : {};
1488
+ if (typeof data.url !== "string" || data.url === "") {
1489
+ throw new CliError("The API created the connect link but did not return its URL.", {
1490
+ exitCode: ExitCode.API,
1491
+ code: "connect_link_without_url",
1492
+ hint: "Please report it. The link ends on its own when it expires.",
1493
+ details: { connect_link_id: data.id ?? null }
1494
+ });
1495
+ }
1496
+ 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
+ ];
1505
+ if (!wait) {
1506
+ ctx.out.result({ link }, () => [...instructions, "", "Once it is connected, `funnelchat numbers list` shows it."]);
1507
+ return;
1508
+ }
1509
+ ctx.out.prompt(instructions);
1510
+ ctx.out.progress("\nWaiting for the number to connect\u2026 (Ctrl+C to stop)");
1511
+ const deadline = ctx.io.clock() + timeout * 1e3;
1512
+ try {
1513
+ for (; ; ) {
1514
+ const left = deadline - ctx.io.clock();
1515
+ if (left <= 0) {
1516
+ throw new CliError(`No number connected within ${timeout} s.`, {
1517
+ exitCode: ExitCode.TIMEOUT,
1518
+ 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."
1520
+ });
1521
+ }
1522
+ await pause(ctx, Math.min(POLL_MS, left));
1523
+ 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];
1526
+ if (device) {
1527
+ 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)}` : ""}`
1531
+ ]);
1532
+ return;
1533
+ }
1534
+ }
1535
+ } catch (error) {
1536
+ throw stillOpen(error, link);
1537
+ }
1538
+ }
1539
+ function stillOpen(error, link) {
1540
+ const cliError = error instanceof InterruptedError ? new CliError("Stopped waiting for the number (Ctrl+C).", { exitCode: ExitCode.INTERRUPTED, code: "interrupted" }) : toCliError(error);
1541
+ 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.`;
1542
+ cliError.hint = [cliError.hint, open].filter(Boolean).join("\n");
1543
+ cliError.details.connect_link_id = link.id;
1544
+ return cliError;
1545
+ }
1546
+
1172
1547
  // src/dates.ts
1173
1548
  var DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
1174
1549
  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 +1584,9 @@ var FORMULA_START = /^[=+\-@\t\r]/;
1209
1584
  var PLAIN_NUMBER = /^[+-]?\d+(\.\d+)?$/;
1210
1585
  function csvCell(value) {
1211
1586
  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;
1587
+ let text2 = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
1588
+ if (FORMULA_START.test(text2) && !PLAIN_NUMBER.test(text2)) text2 = `'${text2}`;
1589
+ return /[",\r\n]/.test(text2) ? `"${text2.replace(/"/g, '""')}"` : text2;
1215
1590
  }
1216
1591
  function csvRow(values) {
1217
1592
  return `${values.map(csvCell).join(",")}\r
@@ -1372,8 +1747,8 @@ var PartFile = class _PartFile {
1372
1747
  return this.#rows;
1373
1748
  }
1374
1749
  /** Appends whole rows (each already ending in a newline). */
1375
- append(text, rows) {
1376
- const buffer = Buffer.from(text, "utf8");
1750
+ append(text2, rows) {
1751
+ const buffer = Buffer.from(text2, "utf8");
1377
1752
  let written = 0;
1378
1753
  while (written < buffer.length) {
1379
1754
  written += writeSync2(this.fd, buffer, written, buffer.length - written, this.#offset + written);
@@ -1723,7 +2098,7 @@ async function contactsExport(ctx, flags, explicitFlags) {
1723
2098
  const query = mergeNext(baseQuery, state.next ?? { page: 1 });
1724
2099
  const page = await client.page(routes.contactsList, { query });
1725
2100
  if (state.api_total_at_start === null) state.api_total_at_start = totalOf(page.body);
1726
- let text = "";
2101
+ let text2 = "";
1727
2102
  let rows = 0;
1728
2103
  for (const contact of page.items) {
1729
2104
  const id = typeof contact.id === "number" ? contact.id : Number(contact.id);
@@ -1732,14 +2107,14 @@ async function contactsExport(ctx, flags, explicitFlags) {
1732
2107
  if (enriched === null) {
1733
2108
  state.vanished++;
1734
2109
  } else {
1735
- text += o.format === "csv" ? csvRow(csvValues(enriched, o, state.columns ?? [])) : `${JSON.stringify(enriched)}
2110
+ text2 += o.format === "csv" ? csvRow(csvValues(enriched, o, state.columns ?? [])) : `${JSON.stringify(enriched)}
1736
2111
  `;
1737
2112
  rows++;
1738
2113
  observe(state, contact.created_at);
1739
2114
  }
1740
2115
  state.last_id = state.last_id === null ? id : Math.max(state.last_id, id);
1741
2116
  }
1742
- file.append(text, rows);
2117
+ file.append(text2, rows);
1743
2118
  file.sync();
1744
2119
  state.file = file.position;
1745
2120
  state.next = page.next;
@@ -1945,17 +2320,17 @@ async function conversationsExport(ctx, flags, explicitFlags) {
1945
2320
  checkAbort();
1946
2321
  const query = state.list.next ? mergeNext(baseQuery, state.list.next) : baseQuery;
1947
2322
  const page = await client.page(routes.conversationsList, { query });
1948
- let text = "";
2323
+ let text2 = "";
1949
2324
  let rows = 0;
1950
2325
  for (const conversation of page.items) {
1951
2326
  const id = conversationContactId(conversation);
1952
2327
  if (!Number.isSafeInteger(id) || seen.has(id)) continue;
1953
2328
  seen.add(id);
1954
- text += `${JSON.stringify(conversation)}
2329
+ text2 += `${JSON.stringify(conversation)}
1955
2330
  `;
1956
2331
  rows++;
1957
2332
  }
1958
- file.append(text, rows);
2333
+ file.append(text2, rows);
1959
2334
  file.sync();
1960
2335
  state.list.file = file.position;
1961
2336
  state.list.next = page.next;
@@ -2103,6 +2478,43 @@ function observe2(state, time) {
2103
2478
  if (o.last_message_time === null || time > o.last_message_time) o.last_message_time = time;
2104
2479
  }
2105
2480
 
2481
+ // src/commands/groups.ts
2482
+ var PAGE_SIZE = 100;
2483
+ var text = (value) => typeof value === "string" && value !== "" ? value : typeof value === "number" ? String(value) : null;
2484
+ async function groupsList(ctx, flags) {
2485
+ const picked = await pickDevice(ctx, flags.device, { qrOnly: true });
2486
+ const groups = [];
2487
+ for await (const group of ctx.client().all(routes.deviceGroups, { path: { device: picked.id }, query: { pageSize: PAGE_SIZE } })) {
2488
+ groups.push(group);
2489
+ }
2490
+ const from = picked.device ? deviceLabel(picked.device) : `number ${picked.id}`;
2491
+ const device = flags.device !== void 0 ? ` --device ${picked.id}` : "";
2492
+ ctx.out.result(
2493
+ { device_id: picked.id, groups },
2494
+ () => 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).` : [
2495
+ ...table(
2496
+ ["uid", "name", "followed"],
2497
+ groups.map((g) => [text(g.uid), text(g.name), text(g.device_id) ? "yes" : "no"])
2498
+ ),
2499
+ "",
2500
+ `Groups of ${from}, as WhatsApp has them (reused for up to a minute). Follow one with: funnelchat groups follow <uid>${device}`
2501
+ ]
2502
+ );
2503
+ }
2504
+ async function groupsFollow(ctx, group, flags) {
2505
+ const uid = group.trim();
2506
+ if (uid === "") throw usageError("The group needs its uid, as `funnelchat groups` shows it.");
2507
+ const picked = await pickDevice(ctx, flags.device, { qrOnly: true });
2508
+ const body = await ctx.client().post(routes.groupImport, { path: { group: uid }, body: { device_id: picked.id } });
2509
+ const data = isRecord(body) && isRecord(body.data) ? body.data : isRecord(body) ? body : {};
2510
+ const name = text(data.name) ? `"${text(data.name)}" (${text(data.uid) ?? uid})` : text(data.uid) ?? uid;
2511
+ 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.");
2512
+ ctx.out.result({ device_id: picked.id, group: data }, () => [
2513
+ data.imported === false ? `Already following ${name}: it was in your account.` : `Following ${name}.`,
2514
+ "Who joins and who leaves arrives as the group_participant.joined and group_participant.left webhook events."
2515
+ ]);
2516
+ }
2517
+
2106
2518
  // src/commands/mac.ts
2107
2519
  async function macStatus(ctx) {
2108
2520
  const body = await ctx.client().get(routes.macStatus);
@@ -2118,98 +2530,127 @@ async function macStatus(ctx) {
2118
2530
  ]);
2119
2531
  }
2120
2532
 
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
- }
2533
+ // src/commands/send.ts
2534
+ var SEND_TIMEOUT_SECONDS = 90;
2535
+ var FIRST_POLL_MS = 1e3;
2536
+ var MAX_POLL_MS = 1e4;
2537
+ function normalizePhone(value) {
2538
+ const digits = value.trim().replace(/^\+/, "").replace(/[\s\-.()]/g, "");
2539
+ if (!/^\d{7,15}$/.test(digits)) {
2540
+ throw usageError(`The phone must have the country code and only digits, for example 5491122334455: ${value}`);
2541
+ }
2542
+ return digits;
2543
+ }
2544
+ function messageOf(body) {
2545
+ if (!isRecord(body)) return null;
2546
+ if (isRecord(body.message)) return body.message;
2547
+ if (isRecord(body.data)) return body.data;
2548
+ return "status" in body || "id" in body ? body : null;
2549
+ }
2550
+ var statusOf = (message) => typeof message.status === "string" ? message.status : null;
2551
+ var isFinal = (status) => status === null || status === "read" || status === "failed";
2552
+ var shown = (status) => status ?? "unknown";
2553
+ async function send(ctx, phoneArg, words, flags) {
2554
+ const phone = normalizePhone(phoneArg);
2555
+ const text2 = words.join(" ");
2556
+ if (text2.trim() === "") throw usageError("The message has no text.");
2557
+ const timeout = flags.timeout === void 0 ? SEND_TIMEOUT_SECONDS : seconds(flags.timeout, "--timeout");
2558
+ const picked = await pickDevice(ctx, flags.device, { qrOnly: false });
2559
+ const from = picked.device ? deviceLabel(picked.device) : `number ${picked.id}`;
2560
+ const client = ctx.client();
2561
+ ctx.out.progress(`Sending to ${phone} from ${from}\u2026`);
2562
+ let answer;
2563
+ try {
2564
+ answer = await client.post(routes.sendMessage, { body: { device_id: picked.id, phone, text: text2 } });
2565
+ } catch (error) {
2566
+ throw explainSendError(error);
2567
+ }
2568
+ let message = messageOf(answer);
2569
+ const messageId = message?.id;
2570
+ const contactId = message?.contact_id;
2571
+ if (!message || messageId === void 0 || messageId === null || contactId === void 0 || contactId === null) {
2572
+ ctx.out.result(
2573
+ { device_id: picked.id, phone, message, outcome: "not_watched" },
2574
+ () => `Sent to ${phone}, but the API did not return the message id: its status cannot be watched.`
2575
+ );
2576
+ return;
2176
2577
  }
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" });
2578
+ const started = ctx.io.clock();
2579
+ const elapsed = () => Math.round((ctx.io.clock() - started) / 1e3);
2580
+ let status = statusOf(message);
2581
+ const statuses = [{ status, seconds: 0 }];
2582
+ ctx.out.progress(` ${shown(status)}`);
2583
+ const deadline = started + timeout * 1e3;
2584
+ let delay = FIRST_POLL_MS;
2585
+ const ids = { message_id: messageId, contact_id: contactId };
2586
+ try {
2587
+ while (!isFinal(status) && ctx.io.clock() < deadline) {
2588
+ await pause(ctx, Math.min(delay, deadline - ctx.io.clock()));
2589
+ const read = messageOf(await client.get(routes.messageShow, { path: { contact: String(contactId), message: String(messageId) } }));
2590
+ if (!read) continue;
2591
+ message = read;
2592
+ const next = statusOf(read);
2593
+ if (next === status) {
2594
+ delay = Math.min(delay * 2, MAX_POLL_MS);
2185
2595
  continue;
2186
2596
  }
2187
- throw error;
2597
+ status = next;
2598
+ statuses.push({ status, seconds: elapsed() });
2599
+ ctx.out.progress(` ${shown(status).padEnd(10)} after ${elapsed()} s`);
2600
+ delay = FIRST_POLL_MS;
2188
2601
  }
2602
+ } catch (error) {
2603
+ throw alreadySent(error, ids, status);
2189
2604
  }
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
- );
2605
+ if (status === "failed") {
2606
+ const failure = isRecord(message.error) ? message.error : {};
2607
+ const why = [failure.title, failure.reason ?? failure.code].filter((v) => v !== null && v !== void 0 && v !== "").map(String);
2608
+ throw new CliError(`The message failed${why.length ? `: ${why.join(" \xB7 ")}` : "."}`, {
2609
+ exitCode: ExitCode.API,
2610
+ code: "message_failed",
2611
+ hint: `Message ${String(messageId)} to ${phone} (contact ${String(contactId)}).`,
2612
+ details: { ...ids, status, error: message.error ?? null, statuses }
2613
+ });
2614
+ }
2615
+ const outcome = status === "read" ? "read" : status === null ? "unknown" : timeout === 0 ? "not_watched" : "timeout";
2616
+ const where = `GET /api/v1/contacts/${String(contactId)}/messages/${String(messageId)}`;
2617
+ ctx.out.result({ device_id: picked.id, phone, message, statuses, outcome }, () => {
2618
+ const head = `Message ${String(messageId)} to ${phone}`;
2619
+ if (outcome === "read") return `${head}: read ${elapsed()} s after sending.`;
2620
+ if (outcome === "unknown") {
2621
+ return [`${head}: the provider did not confirm or reject the send. It may have arrived, and its status will not change.`];
2622
+ }
2623
+ if (outcome === "not_watched") return [`${head}: ${shown(status)} (not watched: --timeout 0).`, `Its status: ${where}`];
2624
+ return [
2625
+ `${head}: ${shown(status)} after ${timeout} s; stopped watching (--timeout).`,
2626
+ ...status === "delivered" ? ["If the recipient turned read receipts off, it stays delivered."] : [],
2627
+ `Its status keeps changing in Funnelchat: ${where}, or the message.status_changed webhook.`
2628
+ ];
2629
+ });
2210
2630
  }
2211
- function str(value) {
2212
- return value === null || value === void 0 || value === "" ? null : String(value);
2631
+ function explainSendError(error) {
2632
+ if (!(error instanceof ApiError)) return error;
2633
+ const add = (line) => {
2634
+ error.hint = [line, error.hint].filter(Boolean).join("\n");
2635
+ };
2636
+ if (error.status === 403 && (error.apiCode === void 0 || error.apiCode === "permission_denied")) {
2637
+ 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.");
2638
+ } else if (error.status === 404 && error.apiCode !== "route_not_found") {
2639
+ add("Nothing was sent. The phone may be a contact of another workspace of your account, or --device not one of your numbers.");
2640
+ } else if (error.status === 429 && error.apiCode === "rate_limited") {
2641
+ add("Nothing was sent: the number is sending too fast (per second, or per minute to the same contact).");
2642
+ } else if (error.status === 422 && error.fieldErrors) {
2643
+ add("Nothing was sent.");
2644
+ }
2645
+ return error;
2646
+ }
2647
+ function alreadySent(error, ids, status) {
2648
+ 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);
2649
+ if (error instanceof ApiError && error.status === 403) cliError.hint = [cliError.hint, "Watching the status needs conversations:read."].filter(Boolean).join("\n");
2650
+ 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)}`;
2651
+ cliError.hint = [cliError.hint, sent].filter(Boolean).join("\n");
2652
+ Object.assign(cliError.details, ids, { last_status: status });
2653
+ return cliError;
2213
2654
  }
2214
2655
 
2215
2656
  // src/commands/templates.ts
@@ -2234,6 +2675,12 @@ async function templatesList(ctx, flags) {
2234
2675
  }
2235
2676
 
2236
2677
  // src/main.ts
2678
+ var FIRST_USE_HELP = `
2679
+ First use:
2680
+ funnelchat login paste your key; it is not shown
2681
+ funnelchat connect open the link and scan the QR with WhatsApp
2682
+ funnelchat send 5491122334455 "Hello from Funnelchat" and watch it: queued, sent, delivered, read
2683
+ `;
2237
2684
  var EXIT_CODES_HELP = `
2238
2685
  Exit codes:
2239
2686
  0 all good
@@ -2242,8 +2689,9 @@ Exit codes:
2242
2689
  3 no key, a key the API rejects (401) or a missing permission (403)
2243
2690
  4 not found in your account (404)
2244
2691
  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
2692
+ 6 API or network error, after the retries; or the message sent failed (send)
2246
2693
  7 the output already exists: the CLI never overwrites a file
2694
+ 8 no number connected within the time (connect)
2247
2695
  130 interrupted with Ctrl+C: an export can be resumed
2248
2696
 
2249
2697
  Environment variables:
@@ -2253,13 +2701,16 @@ Environment variables:
2253
2701
  FUNNELCHAT_RATE requests per second
2254
2702
  FUNNELCHAT_CONFIG_DIR where to keep the configuration
2255
2703
 
2256
- The CLI only reads: it sends no messages and changes nothing in your account.`;
2704
+ Only connect, send and groups follow change something in your account; every other command only reads.
2705
+ A write is sent once and never retried on its own.`;
2257
2706
  async function run(argv, io) {
2258
2707
  let exitCode = ExitCode.OK;
2259
2708
  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)
2709
+ program.description(
2710
+ "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."
2711
+ ).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({
2712
+ writeOut: (text2) => io.stdout.write(text2),
2713
+ writeErr: (text2) => io.stderr.write(text2)
2263
2714
  });
2264
2715
  const action = (fn) => async (...args) => {
2265
2716
  const command = args[args.length - 1];
@@ -2277,6 +2728,11 @@ async function run(argv, io) {
2277
2728
  const profiles = program.command("profiles").description("Saved profiles.");
2278
2729
  profiles.command("list", { isDefault: true }).description("Lists the profiles (never shows the keys).").action(action((ctx) => profilesList(ctx)));
2279
2730
  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())));
2732
+ 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
+ const groups = program.command("groups").description("WhatsApp groups of a number connected by QR.");
2734
+ 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())));
2735
+ 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
2736
  const numbers = program.command("numbers").description("Your WhatsApp numbers.");
2281
2737
  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
2738
  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 +2751,9 @@ async function run(argv, io) {
2295
2751
  const mac = program.command("mac").description("Monthly active contacts (MACs).");
2296
2752
  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
2753
  for (const command of program.commands) {
2298
- command.exitOverride().configureOutput({ writeOut: (text) => io.stdout.write(text), writeErr: (text) => io.stderr.write(text) });
2754
+ command.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
2299
2755
  for (const sub of command.commands) {
2300
- sub.exitOverride().configureOutput({ writeOut: (text) => io.stdout.write(text), writeErr: (text) => io.stderr.write(text) });
2756
+ sub.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
2301
2757
  }
2302
2758
  }
2303
2759
  try {