@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.
- package/README.md +166 -15
- package/dist/cli.js +707 -132
- 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.
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
491
|
-
const s = Math.max(0, Math.ceil(
|
|
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,
|
|
499
|
-
if (
|
|
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(
|
|
571
|
+
if (type.includes("json") || /^\s*[[{]/.test(text2)) {
|
|
502
572
|
try {
|
|
503
|
-
return JSON.parse(
|
|
573
|
+
return JSON.parse(text2);
|
|
504
574
|
} catch {
|
|
505
|
-
return
|
|
575
|
+
return text2;
|
|
506
576
|
}
|
|
507
577
|
}
|
|
508
|
-
return
|
|
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(
|
|
534
|
-
let out =
|
|
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
|
|
736
|
-
this.#write(writer,
|
|
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,
|
|
740
|
-
writer.write(this.redactor.redact(
|
|
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": "
|
|
907
|
+
"sha256": "0ffaad2b25733d28b2cc07dde8152400c3c68974b3515e71e116ce599312b0fa",
|
|
831
908
|
"repository": "git@github.com:Funnelchat20/developers-portal.git",
|
|
832
|
-
"branch": "
|
|
833
|
-
"commit": "
|
|
834
|
-
"paths":
|
|
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
|
|
1213
|
-
if (FORMULA_START.test(
|
|
1214
|
-
return /[",\r\n]/.test(
|
|
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(
|
|
1376
|
-
const buffer = Buffer.from(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
2443
|
+
text2 += `${JSON.stringify(conversation)}
|
|
1955
2444
|
`;
|
|
1956
2445
|
rows++;
|
|
1957
2446
|
}
|
|
1958
|
-
file.append(
|
|
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/
|
|
2122
|
-
var
|
|
2123
|
-
var
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
};
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
async function
|
|
2142
|
-
const
|
|
2143
|
-
const
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
}
|
|
2148
|
-
ctx.
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
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
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2191
|
-
|
|
2192
|
-
() =>
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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
|
|
2212
|
-
|
|
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
|
-
|
|
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(
|
|
2261
|
-
|
|
2262
|
-
|
|
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: (
|
|
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: (
|
|
2875
|
+
sub.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
|
|
2301
2876
|
}
|
|
2302
2877
|
}
|
|
2303
2878
|
try {
|