@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.
- package/README.md +138 -15
- package/dist/cli.js +588 -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,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.
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
491
|
-
const s = Math.max(0, Math.ceil(
|
|
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,
|
|
499
|
-
if (
|
|
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(
|
|
564
|
+
if (type.includes("json") || /^\s*[[{]/.test(text2)) {
|
|
502
565
|
try {
|
|
503
|
-
return JSON.parse(
|
|
566
|
+
return JSON.parse(text2);
|
|
504
567
|
} catch {
|
|
505
|
-
return
|
|
568
|
+
return text2;
|
|
506
569
|
}
|
|
507
570
|
}
|
|
508
|
-
return
|
|
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(
|
|
534
|
-
let out =
|
|
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
|
|
736
|
-
this.#write(writer,
|
|
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,
|
|
740
|
-
writer.write(this.redactor.redact(
|
|
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": "
|
|
900
|
+
"sha256": "0ffaad2b25733d28b2cc07dde8152400c3c68974b3515e71e116ce599312b0fa",
|
|
831
901
|
"repository": "git@github.com:Funnelchat20/developers-portal.git",
|
|
832
|
-
"branch": "
|
|
833
|
-
"commit": "
|
|
834
|
-
"paths":
|
|
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
|
|
1213
|
-
if (FORMULA_START.test(
|
|
1214
|
-
return /[",\r\n]/.test(
|
|
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(
|
|
1376
|
-
const buffer = Buffer.from(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
2329
|
+
text2 += `${JSON.stringify(conversation)}
|
|
1955
2330
|
`;
|
|
1956
2331
|
rows++;
|
|
1957
2332
|
}
|
|
1958
|
-
file.append(
|
|
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/
|
|
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
|
-
}
|
|
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
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2191
|
-
|
|
2192
|
-
() =>
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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
|
|
2212
|
-
|
|
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
|
-
|
|
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(
|
|
2261
|
-
|
|
2262
|
-
|
|
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: (
|
|
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: (
|
|
2756
|
+
sub.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
|
|
2301
2757
|
}
|
|
2302
2758
|
}
|
|
2303
2759
|
try {
|