@funnelchat-hq/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +530 -2
  3. package/dist/cli.js +2797 -0
  4. package/package.json +62 -4
package/dist/cli.js ADDED
@@ -0,0 +1,2797 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/http/pacer.ts
4
+ var DEFAULT_RATE = 1;
5
+ var MAX_RATE = 10;
6
+ var Pacer = class {
7
+ constructor(ratePerSecond, clock, sleep) {
8
+ this.clock = clock;
9
+ this.sleep = sleep;
10
+ this.intervalMs = 1e3 / ratePerSecond;
11
+ }
12
+ clock;
13
+ sleep;
14
+ intervalMs;
15
+ #next = 0;
16
+ async wait(signal) {
17
+ const now = this.clock();
18
+ const start = Math.max(now, this.#next);
19
+ this.#next = start + this.intervalMs;
20
+ if (start > now) await this.sleep(start - now, signal);
21
+ }
22
+ };
23
+ var realSleep = (ms, signal) => new Promise((resolve2, reject) => {
24
+ if (signal?.aborted) {
25
+ reject(new Error("aborted"));
26
+ return;
27
+ }
28
+ const onAbort = () => {
29
+ clearTimeout(timer);
30
+ reject(new Error("aborted"));
31
+ };
32
+ const timer = setTimeout(() => {
33
+ signal?.removeEventListener("abort", onAbort);
34
+ resolve2();
35
+ }, ms);
36
+ signal?.addEventListener("abort", onAbort, { once: true });
37
+ });
38
+
39
+ // src/main.ts
40
+ import { Command, CommanderError } from "commander";
41
+
42
+ // src/errors.ts
43
+ var ExitCode = {
44
+ OK: 0,
45
+ /** Something the CLI did not expect. */
46
+ UNEXPECTED: 1,
47
+ /** Wrong command, option or value; or `--resume` with options that differ from the original run. */
48
+ USAGE: 2,
49
+ /** No key, a key the API rejects (`401`), or a key without the permission the route needs (`403`). */
50
+ AUTH: 3,
51
+ /** The resource does not exist in the account (`404`). */
52
+ NOT_FOUND: 4,
53
+ /** A limit the CLI does not wait for: the daily cap or a `Retry-After` too long. Resumable. */
54
+ RATE_LIMITED: 5,
55
+ /** The API failed (`5xx`, `422`…) or could not be reached, after the retries; or a sent message failed. */
56
+ API: 6,
57
+ /** The output already exists: the CLI never overwrites a file. */
58
+ EXISTS: 7,
59
+ /** The CLI waited and it did not happen: no number connected within `connect --timeout`. */
60
+ TIMEOUT: 8,
61
+ /** Interrupted with Ctrl+C. An export can be resumed. */
62
+ INTERRUPTED: 130
63
+ };
64
+ var CliError = class extends Error {
65
+ exitCode;
66
+ code;
67
+ hint;
68
+ details;
69
+ constructor(message, options) {
70
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
71
+ this.name = new.target.name;
72
+ this.exitCode = options.exitCode;
73
+ this.code = options.code;
74
+ this.hint = options.hint;
75
+ this.details = { ...options.details };
76
+ }
77
+ };
78
+ function usageError(message, hint) {
79
+ return new CliError(message, { exitCode: ExitCode.USAGE, code: "usage", ...hint ? { hint } : {} });
80
+ }
81
+ var ApiError = class extends CliError {
82
+ status;
83
+ /** The stable `error` code of the body (`daily_cap_exceeded`, `permission_denied`…), when there is one. */
84
+ apiCode;
85
+ /** Seconds from `Retry-After`, when present. */
86
+ retryAfter;
87
+ fieldErrors;
88
+ route;
89
+ domain;
90
+ /** `X-Request-Id` of the response, when it had one. */
91
+ requestId;
92
+ constructor(init) {
93
+ const record = isRecord(init.body) ? init.body : void 0;
94
+ const apiCode = typeof record?.error === "string" && record.error !== "" ? record.error : void 0;
95
+ const apiMessage = typeof record?.message === "string" && record.message !== "" ? record.message : void 0;
96
+ const fieldErrors = isRecord(record?.errors) ? record.errors : void 0;
97
+ const described = describe(init, apiCode, apiMessage, fieldErrors);
98
+ const { exitCode, message } = init.override ?? described;
99
+ const hint = withRequestId(init.override ? void 0 : described.hint, init.requestId);
100
+ super(message, {
101
+ exitCode,
102
+ code: apiCode ?? `http_${init.status}`,
103
+ ...hint ? { hint } : {},
104
+ details: {
105
+ status: init.status,
106
+ route: `${init.method} ${init.route}`,
107
+ ...apiCode ? { api_error: apiCode } : {},
108
+ ...init.retryAfter !== void 0 ? { retry_after: init.retryAfter } : {},
109
+ ...fieldErrors ? { errors: fieldErrors } : {},
110
+ ...init.requestId ? { request_id: init.requestId } : {},
111
+ ...init.override?.details
112
+ }
113
+ });
114
+ this.status = init.status;
115
+ this.apiCode = apiCode;
116
+ this.retryAfter = init.retryAfter;
117
+ this.fieldErrors = fieldErrors;
118
+ this.route = init.route;
119
+ this.domain = init.domain;
120
+ this.requestId = init.requestId;
121
+ }
122
+ };
123
+ function withRequestId(hint, requestId) {
124
+ if (!requestId) return hint;
125
+ const cite = `Request id (X-Request-Id): ${requestId}. Quote it, with the approximate time, if you report this to support.`;
126
+ return hint ? `${hint}
127
+ ${cite}` : cite;
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
+ }
174
+ function describe(init, apiCode, apiMessage, fieldErrors) {
175
+ const where = `${init.method} ${init.route}`;
176
+ const said = apiMessage ? `: ${apiMessage}` : ".";
177
+ const { status } = init;
178
+ if (status === 401) {
179
+ return {
180
+ exitCode: ExitCode.AUTH,
181
+ message: `The API rejected the key (401 on ${where})${said}`,
182
+ hint: "Check that the key is complete (with the `<id>|` at the start) and still active, and run `funnelchat login` again."
183
+ };
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
+ }
189
+ if (status === 403) {
190
+ const permission = init.permission ? ` This route needs \`${init.permission}\`.` : "";
191
+ return {
192
+ exitCode: ExitCode.AUTH,
193
+ message: `The key cannot use ${where} (403${apiCode ? ` ${apiCode}` : ""})${said}`,
194
+ hint: apiCode === "route_not_available" ? "No key enables it for now." : `Create a key with the missing permission.${permission}`
195
+ };
196
+ }
197
+ if (status === 404 && apiCode === "route_not_found") {
198
+ return {
199
+ exitCode: ExitCode.NOT_FOUND,
200
+ message: `The route does not exist in the API (404 route_not_found on ${where}).`,
201
+ hint: "The CLI only calls routes of the API reference: check that the base URL is the public API's (https://api.funnelchat.com) and that you have the latest version of the CLI."
202
+ };
203
+ }
204
+ if (status === 404) {
205
+ return { exitCode: ExitCode.NOT_FOUND, message: `Not found in your account (404 on ${where})${said}` };
206
+ }
207
+ if (status === 429) {
208
+ return { exitCode: ExitCode.RATE_LIMITED, message: `The API asked to wait (429${apiCode ? ` ${apiCode}` : ""} on ${where})${said}` };
209
+ }
210
+ if (status === 422 && fieldErrors) {
211
+ const first = Object.entries(fieldErrors)[0];
212
+ const detail = first ? ` ${first[0]}: ${first[1]?.[0] ?? ""}` : "";
213
+ return { exitCode: ExitCode.API, message: `The API did not accept the request (422 on ${where})${said}${detail ? `
214
+ ${detail}` : ""}` };
215
+ }
216
+ if (status === 500 && apiCode === "edge_misconfigured") {
217
+ return {
218
+ exitCode: ExitCode.API,
219
+ message: `The API is not available right now (500 edge_misconfigured on ${where}): the API's entry point is not configured to reach this area.`,
220
+ hint: "It is a problem on Funnelchat's side: please report it."
221
+ };
222
+ }
223
+ if (status === 502 && apiCode === "origin_unreachable") {
224
+ return {
225
+ exitCode: ExitCode.API,
226
+ message: `The API is not available right now (502 origin_unreachable on ${where}): the API's entry point could not reach the area that serves the route.`,
227
+ hint: "Try again in a few minutes; if it persists, please report it."
228
+ };
229
+ }
230
+ return { exitCode: ExitCode.API, message: `The API answered ${status}${apiCode ? ` ${apiCode}` : ""} on ${where}${said}` };
231
+ }
232
+ var ConnectionError = class extends CliError {
233
+ constructor(message, cause) {
234
+ super(message, { exitCode: ExitCode.API, code: "connection_error", hint: "Check your connection and the base URL, and try again.", cause });
235
+ }
236
+ };
237
+ var InterruptedError = class extends CliError {
238
+ constructor() {
239
+ super("Interrupted.", { exitCode: ExitCode.INTERRUPTED, code: "interrupted" });
240
+ }
241
+ };
242
+ function parseRetryAfter(value, now = Date.now()) {
243
+ if (value === null || value === void 0) return void 0;
244
+ const trimmed = value.trim();
245
+ if (trimmed === "") return void 0;
246
+ if (/^\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
247
+ const date = Date.parse(trimmed);
248
+ if (Number.isNaN(date)) return void 0;
249
+ return Math.max(0, Math.ceil((date - now) / 1e3));
250
+ }
251
+ function isRecord(value) {
252
+ return typeof value === "object" && value !== null && !Array.isArray(value);
253
+ }
254
+
255
+ // src/version.ts
256
+ var VERSION = "0.2.0";
257
+ var PACKAGE_NAME = "@funnelchat-hq/cli";
258
+ var USER_AGENT_PRODUCT = "funnelchat-cli";
259
+
260
+ // src/http/pagination.ts
261
+ function extractItems(body) {
262
+ if (Array.isArray(body)) return body;
263
+ if (isRecord(body) && Array.isArray(body.data)) return body.data;
264
+ return [];
265
+ }
266
+ function toInt(value) {
267
+ if (typeof value === "number" && Number.isInteger(value)) return value;
268
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
269
+ return null;
270
+ }
271
+ function nextPageQuery(body) {
272
+ if (!isRecord(body)) return null;
273
+ for (const holder of [body.meta, body]) {
274
+ if (!isRecord(holder)) continue;
275
+ if ("next_cursor" in holder) {
276
+ return typeof holder.next_cursor === "string" && holder.next_cursor !== "" ? { cursor: holder.next_cursor } : null;
277
+ }
278
+ const current = toInt(holder.current_page);
279
+ const last = toInt(holder.last_page);
280
+ if (current !== null && last !== null) return current < last ? { page: current + 1 } : null;
281
+ }
282
+ const links = isRecord(body.links) ? body.links : void 0;
283
+ const nextUrl = links?.next ?? body.next_page_url;
284
+ if (typeof nextUrl === "string" && nextUrl !== "") {
285
+ const params = new URL(nextUrl, "https://placeholder.invalid").searchParams;
286
+ const cursor = params.get("cursor");
287
+ if (cursor) return { cursor };
288
+ const page = toInt(params.get("page"));
289
+ if (page !== null) return { page };
290
+ }
291
+ return null;
292
+ }
293
+ function followingPage(body, query) {
294
+ if (extractItems(body).length === 0) return null;
295
+ const next = nextPageQuery(body);
296
+ if (next === null) return null;
297
+ const unchanged = Object.entries(next).every(([key, value]) => String(query[key]) === String(value));
298
+ return unchanged ? null : next;
299
+ }
300
+ function totalOf(body) {
301
+ if (!isRecord(body) || !isRecord(body.meta)) return null;
302
+ return toInt(body.meta.total);
303
+ }
304
+
305
+ // src/http/url.ts
306
+ function serializeQuery(query) {
307
+ if (!query) return "";
308
+ const params = new URLSearchParams();
309
+ for (const [key, value] of Object.entries(query)) append(params, key, value);
310
+ return params.toString();
311
+ }
312
+ function append(params, key, value) {
313
+ if (value === void 0 || value === null) return;
314
+ if (Array.isArray(value)) {
315
+ const scalarKey = key.endsWith("[]") ? key : `${key}[]`;
316
+ const base = key.endsWith("[]") ? key.slice(0, -2) : key;
317
+ value.forEach((item, index) => {
318
+ if (isPlainObject(item) || Array.isArray(item)) append(params, `${base}[${index}]`, item);
319
+ else append(params, scalarKey, item);
320
+ });
321
+ return;
322
+ }
323
+ if (value instanceof Date) {
324
+ params.append(key, value.toISOString());
325
+ return;
326
+ }
327
+ if (isPlainObject(value)) {
328
+ for (const [child, childValue] of Object.entries(value)) append(params, `${key}[${child}]`, childValue);
329
+ return;
330
+ }
331
+ if (typeof value === "boolean") {
332
+ params.append(key, value ? "1" : "0");
333
+ return;
334
+ }
335
+ params.append(key, String(value));
336
+ }
337
+ function isPlainObject(value) {
338
+ if (typeof value !== "object" || value === null) return false;
339
+ const proto = Object.getPrototypeOf(value);
340
+ return proto === Object.prototype || proto === null;
341
+ }
342
+ function interpolatePath(template, params) {
343
+ return template.replace(/\{([^}]+)\}/g, (_, name) => {
344
+ const value = params?.[name];
345
+ if (value === void 0 || value === null || value === "") {
346
+ throw new TypeError(`Missing the path parameter "${name}" for ${template}.`);
347
+ }
348
+ return encodeURIComponent(String(value));
349
+ });
350
+ }
351
+ function joinUrl(baseUrl, path, query) {
352
+ const base = baseUrl.replace(/\/+$/, "");
353
+ const suffix = path.startsWith("/") ? path : `/${path}`;
354
+ if (!query) return `${base}${suffix}`;
355
+ return `${base}${suffix}${suffix.includes("?") ? "&" : "?"}${query}`;
356
+ }
357
+
358
+ // src/http/client.ts
359
+ var DEFAULT_TIMEOUT_MS = 3e4;
360
+ var DEFAULT_MAX_RETRIES = 4;
361
+ var DEFAULT_MAX_WAIT_SECONDS = 60;
362
+ var INITIAL_BACKOFF_MS = 1e3;
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.";
365
+ var ApiClient = class {
366
+ baseUrl;
367
+ /** Requests sent, retries included. */
368
+ requests = 0;
369
+ #apiKey;
370
+ #options;
371
+ constructor(options) {
372
+ this.#apiKey = options.apiKey;
373
+ this.#options = options;
374
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
375
+ }
376
+ /** What `JSON.stringify(client)` shows: never the key. */
377
+ toJSON() {
378
+ return { baseUrl: this.baseUrl };
379
+ }
380
+ /** One `GET` of `route`, with retries. Returns the parsed body. */
381
+ async get(route, options = {}) {
382
+ return await this.#send(route, options);
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
+ }
389
+ /** One page of a listing route, and the query of the page after it. */
390
+ async page(route, options = {}) {
391
+ const body = await this.#send(route, options);
392
+ return { items: extractItems(body), body, next: followingPage(body, options.query ?? {}) };
393
+ }
394
+ /** Every item of every page of a listing route. */
395
+ async *all(route, options = {}) {
396
+ let query = { ...options.query };
397
+ for (; ; ) {
398
+ const page = await this.page(route, { ...options, query });
399
+ yield* page.items;
400
+ if (!page.next) return;
401
+ query = mergeNext(query, page.next);
402
+ }
403
+ }
404
+ async #send(route, options) {
405
+ const opts = this.#options;
406
+ const method = route.method;
407
+ const pathname = interpolatePath(route.path, options.path);
408
+ const url = joinUrl(this.baseUrl, pathname, serializeQuery(options.query));
409
+ const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_MAX_RETRIES);
410
+ const maxWait = opts.maxWaitSeconds ?? DEFAULT_MAX_WAIT_SECONDS;
411
+ const retriesAllowed = method === "GET";
412
+ const random = opts.random ?? Math.random;
413
+ const headers = {
414
+ Accept: "application/json",
415
+ "Accept-Language": "es",
416
+ "User-Agent": `${USER_AGENT_PRODUCT}/${VERSION} node/${process.version}`,
417
+ Authorization: `Bearer ${this.#apiKey}`
418
+ };
419
+ const payload = options.body === void 0 ? void 0 : JSON.stringify(options.body);
420
+ if (payload !== void 0) headers["Content-Type"] = "application/json";
421
+ for (let attempt = 0; ; attempt++) {
422
+ const canRetry = retriesAllowed && attempt < maxRetries;
423
+ await opts.pacer.wait(opts.signal);
424
+ this.requests++;
425
+ const started = opts.clock();
426
+ opts.log?.(`\u2192 ${method} ${url}${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`);
427
+ let response;
428
+ let text2;
429
+ try {
430
+ ({ response, text: text2 } = await this.#attempt(method, url, headers, payload));
431
+ } catch (error2) {
432
+ if (error2 instanceof InterruptedError) throw error2;
433
+ if (canRetry) {
434
+ const delay2 = backoffMs(attempt, random);
435
+ opts.log?.(`\u2190 ${describeConnectionError(error2)}; retrying in ${formatSeconds(delay2 / 1e3)}`);
436
+ await this.#sleep(delay2);
437
+ continue;
438
+ }
439
+ if (!retriesAllowed && error2 instanceof ConnectionError) error2.hint = [error2.hint, WRITE_MAY_HAVE_ARRIVED].filter(Boolean).join("\n");
440
+ throw error2;
441
+ }
442
+ const requestId = response.headers.get("x-request-id") ?? void 0;
443
+ opts.log?.(`\u2190 ${response.status} in ${opts.clock() - started} ms${requestId ? ` \xB7 X-Request-Id ${requestId}` : ""}`);
444
+ const body = parseBody(response, text2);
445
+ if (response.ok) return body;
446
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"), opts.clock());
447
+ const error = new ApiError({
448
+ status: response.status,
449
+ body,
450
+ retryAfter,
451
+ method,
452
+ route: route.path,
453
+ domain: route.domain,
454
+ permission: route.permission,
455
+ requestId
456
+ });
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
+ }
461
+ if (!retryable) throw error;
462
+ if (retryAfter !== void 0 && retryAfter > maxWait) {
463
+ throw stopForLongWait(error, retryAfter, opts.clock(), method);
464
+ }
465
+ if (!canRetry) {
466
+ if (response.status === 429) error.hint = [`Wait ${formatSeconds(retryAfter ?? 60)} and try again.`, error.hint].filter(Boolean).join("\n");
467
+ throw error;
468
+ }
469
+ const delay = retryAfter !== void 0 ? Math.ceil(retryAfter * 1e3) : backoffMs(attempt, random);
470
+ opts.log?.(` ${response.status}${error.apiCode ? ` ${error.apiCode}` : ""}: waiting ${formatSeconds(delay / 1e3)}${retryAfter !== void 0 ? " (Retry-After)" : ""}`);
471
+ await this.#sleep(delay);
472
+ }
473
+ }
474
+ async #sleep(ms) {
475
+ try {
476
+ await this.#options.sleep(ms, this.#options.signal);
477
+ } catch (error) {
478
+ if (this.#options.signal?.aborted) throw new InterruptedError();
479
+ throw error;
480
+ }
481
+ }
482
+ async #attempt(method, url, headers, payload) {
483
+ const signal = this.#options.signal;
484
+ if (signal?.aborted) throw new InterruptedError();
485
+ const timeoutMs = this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
486
+ const controller2 = new AbortController();
487
+ const onAbort = () => controller2.abort();
488
+ signal?.addEventListener("abort", onAbort, { once: true });
489
+ let timedOut = false;
490
+ const timer = setTimeout(() => {
491
+ timedOut = true;
492
+ controller2.abort();
493
+ }, timeoutMs);
494
+ const classify = (cause) => {
495
+ if (signal?.aborted) return new InterruptedError();
496
+ if (timedOut) return new ConnectionError(`The Funnelchat API did not answer within ${timeoutMs / 1e3} s.`, cause);
497
+ return new ConnectionError(`Could not connect to the Funnelchat API (${hostOf(url)}).`, cause);
498
+ };
499
+ try {
500
+ let response;
501
+ try {
502
+ response = await this.#options.fetch(url, { method, headers, signal: controller2.signal, ...payload !== void 0 ? { body: payload } : {} });
503
+ } catch (error) {
504
+ throw classify(error);
505
+ }
506
+ try {
507
+ return { response, text: await response.text() };
508
+ } catch (error) {
509
+ throw classify(error);
510
+ }
511
+ } finally {
512
+ clearTimeout(timer);
513
+ signal?.removeEventListener("abort", onAbort);
514
+ }
515
+ }
516
+ };
517
+ function mergeNext(query, next) {
518
+ const merged = { ...query, ...next };
519
+ if ("cursor" in next) delete merged.page;
520
+ if ("page" in next) delete merged.cursor;
521
+ return merged;
522
+ }
523
+ function backoffMs(attempt, random = Math.random) {
524
+ const base = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
525
+ return Math.round(base * (1 - 0.25 * random()));
526
+ }
527
+ function stopForLongWait(error, retryAfter, now, method) {
528
+ const at = new Date(now + retryAfter * 1e3);
529
+ const when = `${formatSeconds(retryAfter)} (at ${at.toISOString().slice(11, 16)} UTC on ${at.toISOString().slice(0, 10)})`;
530
+ let message;
531
+ if (error.apiCode === "daily_cap_exceeded") {
532
+ const domain = error.domain ?? "this area";
533
+ message = `The daily cap of calls to ${domain} was reached for this key. It resets in ${when}.`;
534
+ } else {
535
+ message = `${error.message} The API asks to wait ${when}, longer than the CLI waits on its own.`;
536
+ }
537
+ return new ApiError({
538
+ status: error.status,
539
+ body: error.apiCode ? { error: error.apiCode } : null,
540
+ retryAfter,
541
+ method,
542
+ route: error.route,
543
+ domain: error.domain,
544
+ permission: null,
545
+ requestId: error.requestId,
546
+ override: {
547
+ message,
548
+ exitCode: error.status === 429 ? ExitCode.RATE_LIMITED : ExitCode.API,
549
+ details: { resets_at: at.toISOString() }
550
+ }
551
+ });
552
+ }
553
+ function formatSeconds(seconds2) {
554
+ const s = Math.max(0, Math.ceil(seconds2));
555
+ if (s < 60) return `${s} s`;
556
+ const h = Math.floor(s / 3600);
557
+ const m = Math.floor(s % 3600 / 60);
558
+ if (h === 0) return `${m} min${s % 60 ? ` ${s % 60} s` : ""}`;
559
+ return `${h} h${m ? ` ${m} min` : ""}`;
560
+ }
561
+ function parseBody(response, text2) {
562
+ if (text2 === "") return null;
563
+ const type = response.headers.get("content-type") ?? "";
564
+ if (type.includes("json") || /^\s*[[{]/.test(text2)) {
565
+ try {
566
+ return JSON.parse(text2);
567
+ } catch {
568
+ return text2;
569
+ }
570
+ }
571
+ return text2.length <= 300 ? text2 : `${text2.slice(0, 300)}\u2026`;
572
+ }
573
+ function hostOf(url) {
574
+ try {
575
+ return new URL(url).host;
576
+ } catch {
577
+ return url;
578
+ }
579
+ }
580
+ function describeConnectionError(error) {
581
+ return error instanceof Error ? error.message : "network error";
582
+ }
583
+
584
+ // src/http/redact.ts
585
+ var KEY_SHAPE = /fc_[a-z]+_[A-Za-z0-9]{4,}/g;
586
+ var REDACTED = "[key hidden]";
587
+ var Redactor = class {
588
+ #secrets = [];
589
+ /** Adds a value to blank everywhere: the whole key and its secret part. */
590
+ add(key) {
591
+ if (!key) return;
592
+ const parts = [key, key.includes("|") ? key.slice(key.indexOf("|") + 1) : ""];
593
+ for (const part of parts) if (part.length >= 6 && !this.#secrets.includes(part)) this.#secrets.push(part);
594
+ this.#secrets.sort((a, b) => b.length - a.length);
595
+ }
596
+ redact(text2) {
597
+ let out = text2;
598
+ for (const secret of this.#secrets) out = out.split(secret).join(REDACTED);
599
+ return out.replace(KEY_SHAPE, REDACTED);
600
+ }
601
+ };
602
+
603
+ // src/config/store.ts
604
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "fs";
605
+ import { homedir } from "os";
606
+ import { join } from "path";
607
+ var CONFIG_FILE_NAME = "config.json";
608
+ function configDir(env, platform = process.platform) {
609
+ if (env.FUNNELCHAT_CONFIG_DIR) return env.FUNNELCHAT_CONFIG_DIR;
610
+ if (platform === "win32" && env.APPDATA) return join(env.APPDATA, "funnelchat");
611
+ const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME !== "" ? env.XDG_CONFIG_HOME : join(env.HOME ?? homedir(), ".config");
612
+ return join(base, "funnelchat");
613
+ }
614
+ function configPath(env) {
615
+ return join(configDir(env), CONFIG_FILE_NAME);
616
+ }
617
+ function loadConfig(env) {
618
+ const path = configPath(env);
619
+ if (!existsSync(path)) return { path, config: { version: 1, profiles: {} }, insecurePermissions: false };
620
+ let parsed;
621
+ try {
622
+ parsed = JSON.parse(readFileSync(path, "utf8"));
623
+ } catch (cause) {
624
+ throw new CliError(`Could not read the configuration file ${path}: it is not valid JSON.`, {
625
+ exitCode: ExitCode.USAGE,
626
+ code: "config_unreadable",
627
+ hint: "Fix it or delete it and run `funnelchat login` again.",
628
+ cause
629
+ });
630
+ }
631
+ const profiles = {};
632
+ if (isRecord(parsed) && isRecord(parsed.profiles)) {
633
+ for (const [name, value] of Object.entries(parsed.profiles)) {
634
+ if (!isRecord(value)) continue;
635
+ profiles[name] = {
636
+ ...typeof value.api_key === "string" ? { api_key: value.api_key } : {},
637
+ ...typeof value.base_url === "string" ? { base_url: value.base_url } : {},
638
+ ...typeof value.saved_at === "string" ? { saved_at: value.saved_at } : {}
639
+ };
640
+ }
641
+ }
642
+ const current = isRecord(parsed) && typeof parsed.current_profile === "string" ? parsed.current_profile : void 0;
643
+ const insecurePermissions = process.platform !== "win32" && (statSync(path).mode & 63) !== 0;
644
+ return { path, config: { version: 1, ...current ? { current_profile: current } : {}, profiles }, insecurePermissions };
645
+ }
646
+ function saveConfig(env, config) {
647
+ const dir = configDir(env);
648
+ mkdirSync(dir, { recursive: true, mode: 448 });
649
+ const path = join(dir, CONFIG_FILE_NAME);
650
+ const tmp = join(dir, `.${CONFIG_FILE_NAME}.${process.pid}.tmp`);
651
+ const fd = openSync(tmp, "w", 384);
652
+ try {
653
+ writeSync(fd, `${JSON.stringify(config, null, 2)}
654
+ `);
655
+ fsyncSync(fd);
656
+ } finally {
657
+ closeSync(fd);
658
+ }
659
+ try {
660
+ chmodSync(tmp, 384);
661
+ renameSync(tmp, path);
662
+ chmodSync(path, 384);
663
+ } catch (error) {
664
+ rmSync(tmp, { force: true });
665
+ throw error;
666
+ }
667
+ return path;
668
+ }
669
+
670
+ // src/config/credentials.ts
671
+ var PRODUCTION_URL = "https://api.funnelchat.com";
672
+ var STAGING_URL = "https://api-staging.funnelchat.com";
673
+ var DEFAULT_PROFILE = "default";
674
+ var KEY_SHAPE2 = /^\d+\|fc_[a-z]+_[A-Za-z0-9]+$/;
675
+ function activeProfileName(flags, env, config) {
676
+ return flags.profile ?? nonEmpty(env.FUNNELCHAT_PROFILE) ?? config.current_profile ?? DEFAULT_PROFILE;
677
+ }
678
+ function checkKeyShape(key) {
679
+ const trimmed = key.trim();
680
+ if (!KEY_SHAPE2.test(trimmed)) {
681
+ throw new CliError("The key does not have the shape of a Funnelchat key (`<id>|fc_live_\u2026`).", {
682
+ exitCode: ExitCode.AUTH,
683
+ code: "malformed_key",
684
+ hint: "Copy it in full from the Developers screen of your account, including the `<id>|` at the start."
685
+ });
686
+ }
687
+ return trimmed;
688
+ }
689
+ function resolveBaseUrl(flags, env, profileUrl) {
690
+ if (flags.baseUrl && flags.staging) throw usageError("Use --base-url or --staging, not both.");
691
+ const raw = flags.baseUrl ?? (flags.staging ? STAGING_URL : void 0) ?? nonEmpty(env.FUNNELCHAT_BASE_URL) ?? profileUrl ?? PRODUCTION_URL;
692
+ return checkBaseUrl(raw);
693
+ }
694
+ function checkBaseUrl(raw) {
695
+ let url;
696
+ try {
697
+ url = new URL(raw);
698
+ } catch {
699
+ throw usageError(`The base URL is not valid: ${raw}`);
700
+ }
701
+ const local = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname);
702
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
703
+ throw usageError(
704
+ `The base URL must be https (${raw}).`,
705
+ "http is only accepted for this machine (for example the sandbox at http://127.0.0.1:4010), so the key never travels unencrypted."
706
+ );
707
+ }
708
+ if (url.search || url.hash) throw usageError(`The base URL takes no query and no #: ${raw}`);
709
+ return url.toString().replace(/\/+$/, "");
710
+ }
711
+ function resolveCredentials(flags, env, config) {
712
+ const profileName = activeProfileName(flags, env, config);
713
+ const profile = config.profiles[profileName];
714
+ const envKey = nonEmpty(env.FUNNELCHAT_API_KEY);
715
+ if (flags.profile === void 0 && envKey) {
716
+ return { apiKey: checkKeyShape(envKey), keySource: "env", profile: profileName, baseUrl: resolveBaseUrl(flags, env, void 0) };
717
+ }
718
+ if (profile?.api_key) {
719
+ return { apiKey: checkKeyShape(profile.api_key), keySource: "profile", profile: profileName, baseUrl: resolveBaseUrl(flags, env, profile.base_url) };
720
+ }
721
+ if (flags.profile !== void 0) {
722
+ throw new CliError(`The "${flags.profile}" profile has no saved key.`, {
723
+ exitCode: ExitCode.AUTH,
724
+ code: "no_key",
725
+ hint: `Save it with \`funnelchat login --profile ${flags.profile}\`.`
726
+ });
727
+ }
728
+ throw new CliError("No API key.", {
729
+ exitCode: ExitCode.AUTH,
730
+ code: "no_key",
731
+ hint: "Set the FUNNELCHAT_API_KEY variable or save it with `funnelchat login`."
732
+ });
733
+ }
734
+ function nonEmpty(value) {
735
+ return value && value.trim() !== "" ? value.trim() : void 0;
736
+ }
737
+
738
+ // src/output.ts
739
+ var Output = class {
740
+ constructor(stdout, stderr, redactor, options) {
741
+ this.stdout = stdout;
742
+ this.stderr = stderr;
743
+ this.redactor = redactor;
744
+ this.options = options;
745
+ }
746
+ stdout;
747
+ stderr;
748
+ redactor;
749
+ options;
750
+ get json() {
751
+ return this.options.json;
752
+ }
753
+ /** The result of a command: `data` with `--json`, the human lines otherwise. */
754
+ result(data, human) {
755
+ if (this.options.json) this.#write(this.stdout, `${JSON.stringify(data, null, 2)}
756
+ `);
757
+ else this.#lines(this.stdout, human());
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
+ }
766
+ /** Progress of a long command (stderr). Silent with `--quiet` or `--json`. */
767
+ progress(line) {
768
+ if (!this.options.quiet && !this.options.json) this.#lines(this.stderr, line);
769
+ }
770
+ /** A note for people (stderr). Silent with `--json`. */
771
+ note(line) {
772
+ if (!this.options.json) this.#lines(this.stderr, line);
773
+ }
774
+ /** A warning (stderr), always shown. */
775
+ warn(line) {
776
+ this.#lines(this.stderr, `warning: ${line}`);
777
+ }
778
+ /** `--verbose` only (stderr). */
779
+ verbose(line) {
780
+ if (this.options.verbose) this.#lines(this.stderr, line);
781
+ }
782
+ error(error) {
783
+ const cliError = toCliError(error);
784
+ if (this.options.json) {
785
+ const body = {
786
+ error: {
787
+ code: cliError.code,
788
+ message: cliError.message,
789
+ ...cliError.hint ? { hint: cliError.hint } : {},
790
+ exit_code: cliError.exitCode,
791
+ ...cliError.details
792
+ }
793
+ };
794
+ this.#write(this.stderr, `${JSON.stringify(body)}
795
+ `);
796
+ } else {
797
+ this.#lines(this.stderr, [`error: ${cliError.message}`, ...cliError.hint ? cliError.hint.split("\n").map((l) => ` ${l}`) : []]);
798
+ if (this.options.verbose && cliError.exitCode === ExitCode.UNEXPECTED && error instanceof Error && error.stack) {
799
+ this.#lines(this.stderr, error.stack);
800
+ }
801
+ }
802
+ return cliError.exitCode;
803
+ }
804
+ #lines(writer, lines) {
805
+ const text2 = Array.isArray(lines) ? lines.join("\n") : lines;
806
+ this.#write(writer, text2.endsWith("\n") ? text2 : `${text2}
807
+ `);
808
+ }
809
+ #write(writer, text2) {
810
+ writer.write(this.redactor.redact(text2));
811
+ }
812
+ };
813
+ function toCliError(error) {
814
+ if (error instanceof CliError) return error;
815
+ const message = error instanceof Error ? error.message : String(error);
816
+ return new CliError(`Unexpected error: ${message}`, { exitCode: ExitCode.UNEXPECTED, code: "unexpected", cause: error });
817
+ }
818
+ function table(headers, rows) {
819
+ const cells = rows.map((row) => row.map((cell) => cell === null || cell === void 0 || cell === "" ? "-" : String(cell)));
820
+ const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((row) => (row[i] ?? "").length)));
821
+ const line = (row) => row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ");
822
+ return [line(headers), ...cells.map(line)];
823
+ }
824
+
825
+ // src/context.ts
826
+ var Context = class {
827
+ constructor(io, globals) {
828
+ this.io = io;
829
+ this.globals = globals;
830
+ this.redactor.add(io.env.FUNNELCHAT_API_KEY);
831
+ this.out = new Output(io.stdout, io.stderr, this.redactor, { json: globals.json === true, quiet: globals.quiet === true, verbose: globals.verbose === true });
832
+ }
833
+ io;
834
+ globals;
835
+ redactor = new Redactor();
836
+ out;
837
+ #config;
838
+ #credentials;
839
+ #client;
840
+ #warnedConfig = false;
841
+ config() {
842
+ if (!this.#config) {
843
+ this.#config = loadConfig(this.io.env);
844
+ for (const profile of Object.values(this.#config.config.profiles)) this.redactor.add(profile.api_key);
845
+ if (this.#config.insecurePermissions && !this.#warnedConfig) {
846
+ this.#warnedConfig = true;
847
+ this.out.warn(`other users of this machine can read ${this.#config.path}. Fix it with: chmod 600 "${this.#config.path}"`);
848
+ }
849
+ }
850
+ return this.#config;
851
+ }
852
+ credentials() {
853
+ if (!this.#credentials) {
854
+ this.#credentials = resolveCredentials(this.globals, this.io.env, this.config().config);
855
+ this.redactor.add(this.#credentials.apiKey);
856
+ }
857
+ return this.#credentials;
858
+ }
859
+ client(overrides = {}) {
860
+ if (!overrides.apiKey && this.#client) return this.#client;
861
+ const apiKey = overrides.apiKey ?? this.credentials().apiKey;
862
+ const baseUrl = overrides.baseUrl ?? this.credentials().baseUrl;
863
+ this.redactor.add(apiKey);
864
+ const client = new ApiClient({
865
+ apiKey,
866
+ baseUrl,
867
+ fetch: this.io.fetch,
868
+ pacer: new Pacer(this.rate(), this.io.clock, this.io.sleep),
869
+ sleep: this.io.sleep,
870
+ clock: this.io.clock,
871
+ maxWaitSeconds: this.maxWait(),
872
+ log: (line) => this.out.verbose(line),
873
+ ...this.io.signal ? { signal: this.io.signal } : {}
874
+ });
875
+ if (!overrides.apiKey) this.#client = client;
876
+ return client;
877
+ }
878
+ /** Requests per second: `--rate`, then `FUNNELCHAT_RATE`, then 1. */
879
+ rate() {
880
+ const raw = this.globals.rate ?? this.io.env.FUNNELCHAT_RATE;
881
+ if (raw === void 0 || raw === "") return DEFAULT_RATE;
882
+ const value = Number(raw);
883
+ if (!Number.isFinite(value) || value <= 0 || value > MAX_RATE) {
884
+ throw usageError(`--rate must be a number greater than 0 and up to ${MAX_RATE} (requests per second): ${raw}`);
885
+ }
886
+ return value;
887
+ }
888
+ /** The longest `Retry-After` waited for by itself: `--max-wait`, by default 60 s. */
889
+ maxWait() {
890
+ const raw = this.globals.maxWait;
891
+ if (raw === void 0) return 60;
892
+ const value = Number(raw);
893
+ if (!Number.isInteger(value) || value < 0 || value > 3600) throw usageError(`--max-wait must be an integer from 0 to 3600 (seconds): ${raw}`);
894
+ return value;
895
+ }
896
+ };
897
+
898
+ // src/generated/routes.ts
899
+ var SPEC_SOURCE = {
900
+ "sha256": "0ffaad2b25733d28b2cc07dde8152400c3c68974b3515e71e116ce599312b0fa",
901
+ "repository": "git@github.com:Funnelchat20/developers-portal.git",
902
+ "branch": "main",
903
+ "commit": "99928866cbac41d5b89d31d19773acfabdbbe5dd",
904
+ "paths": 218
905
+ };
906
+ var routes = {
907
+ "me": {
908
+ "id": "me",
909
+ "method": "GET",
910
+ "path": "/api/v1/me",
911
+ "summary": "Ver mi cuenta",
912
+ "domain": "accounts",
913
+ "permission": "accounts:read",
914
+ "pagination": "none",
915
+ "plainArray": false,
916
+ "perPageMax": null,
917
+ "query": [],
918
+ "body": [],
919
+ "pending": null
920
+ },
921
+ "devicesList": {
922
+ "id": "devicesList",
923
+ "method": "GET",
924
+ "path": "/api/v1/devices",
925
+ "summary": "Listar n\xFAmeros",
926
+ "domain": "accounts",
927
+ "permission": "accounts:read",
928
+ "pagination": "page",
929
+ "plainArray": true,
930
+ "perPageMax": 100,
931
+ "query": [
932
+ "perPage"
933
+ ],
934
+ "body": [],
935
+ "pending": null
936
+ },
937
+ "deviceHealth": {
938
+ "id": "deviceHealth",
939
+ "method": "GET",
940
+ "path": "/api/v1/devices/{device}/health",
941
+ "summary": "Ver la salud de un n\xFAmero",
942
+ "domain": "accounts",
943
+ "permission": "accounts:read",
944
+ "pagination": "none",
945
+ "plainArray": false,
946
+ "perPageMax": null,
947
+ "query": [],
948
+ "body": [],
949
+ "pending": null
950
+ },
951
+ "contactsList": {
952
+ "id": "contactsList",
953
+ "method": "GET",
954
+ "path": "/api/v1/contacts",
955
+ "summary": "Listar contactos",
956
+ "domain": "conversations",
957
+ "permission": "conversations:read",
958
+ "pagination": "page",
959
+ "plainArray": false,
960
+ "perPageMax": 100,
961
+ "query": [
962
+ "perPage",
963
+ "orderBy",
964
+ "orderByMethod",
965
+ "tag_ids[]",
966
+ "tag_mode",
967
+ "created_from",
968
+ "created_to",
969
+ "only_with_phone"
970
+ ],
971
+ "body": [],
972
+ "pending": null
973
+ },
974
+ "contactTags": {
975
+ "id": "contactTags",
976
+ "method": "GET",
977
+ "path": "/api/v1/contacts/{contact}/tags",
978
+ "summary": "Listar las etiquetas de un contacto",
979
+ "domain": "conversations",
980
+ "permission": "conversations:read",
981
+ "pagination": "page",
982
+ "plainArray": false,
983
+ "perPageMax": 100,
984
+ "query": [
985
+ "perPage"
986
+ ],
987
+ "body": [],
988
+ "pending": null
989
+ },
990
+ "contactCustomFields": {
991
+ "id": "contactCustomFields",
992
+ "method": "GET",
993
+ "path": "/api/v1/contacts/{contact}/custom-fields",
994
+ "summary": "Listar los campos personalizados de un contacto",
995
+ "domain": "conversations",
996
+ "permission": "conversations:read",
997
+ "pagination": "page",
998
+ "plainArray": false,
999
+ "perPageMax": 100,
1000
+ "query": [
1001
+ "perPage"
1002
+ ],
1003
+ "body": [],
1004
+ "pending": null
1005
+ },
1006
+ "customFieldsList": {
1007
+ "id": "customFieldsList",
1008
+ "method": "GET",
1009
+ "path": "/api/v1/custom-fields",
1010
+ "summary": "Listar campos personalizados",
1011
+ "domain": "conversations",
1012
+ "permission": "conversations:read",
1013
+ "pagination": "page",
1014
+ "plainArray": false,
1015
+ "perPageMax": 100,
1016
+ "query": [
1017
+ "perPage",
1018
+ "orderBy",
1019
+ "orderByMethod"
1020
+ ],
1021
+ "body": [],
1022
+ "pending": null
1023
+ },
1024
+ "conversationsList": {
1025
+ "id": "conversationsList",
1026
+ "method": "GET",
1027
+ "path": "/api/v1/conversations",
1028
+ "summary": "Listar conversaciones",
1029
+ "domain": "conversations",
1030
+ "permission": "conversations:read",
1031
+ "pagination": "cursor",
1032
+ "plainArray": false,
1033
+ "perPageMax": 100,
1034
+ "query": [
1035
+ "perPage",
1036
+ "orderBy",
1037
+ "deviceId",
1038
+ "contactId",
1039
+ "updated_since"
1040
+ ],
1041
+ "body": [],
1042
+ "pending": null
1043
+ },
1044
+ "contactMessages": {
1045
+ "id": "contactMessages",
1046
+ "method": "GET",
1047
+ "path": "/api/v1/contacts/{contactId}/messages",
1048
+ "summary": "Listar los mensajes de un contacto",
1049
+ "domain": "conversations",
1050
+ "permission": "conversations:read",
1051
+ "pagination": "cursor",
1052
+ "plainArray": false,
1053
+ "perPageMax": 100,
1054
+ "query": [
1055
+ "perPage",
1056
+ "updated_since"
1057
+ ],
1058
+ "body": [],
1059
+ "pending": null
1060
+ },
1061
+ "templatesList": {
1062
+ "id": "templatesList",
1063
+ "method": "GET",
1064
+ "path": "/api/v1/templates",
1065
+ "summary": "Listar plantillas",
1066
+ "domain": "conversations",
1067
+ "permission": "conversations:read",
1068
+ "pagination": "page",
1069
+ "plainArray": false,
1070
+ "perPageMax": 100,
1071
+ "query": [
1072
+ "perPage",
1073
+ "deviceId",
1074
+ "status"
1075
+ ],
1076
+ "body": [],
1077
+ "pending": null
1078
+ },
1079
+ "macStatus": {
1080
+ "id": "macStatus",
1081
+ "method": "GET",
1082
+ "path": "/api/v1/mac/status",
1083
+ "summary": "Ver el consumo de MACs",
1084
+ "domain": "conversations",
1085
+ "permission": "conversations:read",
1086
+ "pagination": "none",
1087
+ "plainArray": false,
1088
+ "perPageMax": null,
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
+ ],
1171
+ "pending": null
1172
+ }
1173
+ };
1174
+
1175
+ // src/commands/account.ts
1176
+ var ACCOUNT_FIELDS = ["id", "name", "email", "role", "timezone", "locale", "effective_business_numbers_limit", "effective_agents_limit"];
1177
+ function accountSummary(me) {
1178
+ const record = isRecord(me) && isRecord(me.data) && !("id" in me) ? me.data : me;
1179
+ const out = {};
1180
+ if (!isRecord(record)) return out;
1181
+ for (const field of ACCOUNT_FIELDS) if (field in record) out[field] = record[field];
1182
+ return out;
1183
+ }
1184
+ async function login(ctx, flags) {
1185
+ const { config } = ctx.config();
1186
+ const profile = activeProfileName(ctx.globals, ctx.io.env, config);
1187
+ const baseUrl = resolveBaseUrl(ctx.globals, ctx.io.env, config.profiles[profile]?.base_url);
1188
+ const raw = ctx.io.stdin.isTTY && ctx.io.stdin.setRawMode ? await promptHidden(ctx.io.stdin, ctx.io.stderr, "Paste your Funnelchat API key (it is not shown) and press Enter: ") : await readAll(ctx.io.stdin);
1189
+ const key = checkKeyShape(raw);
1190
+ ctx.redactor.add(key);
1191
+ let account = null;
1192
+ let verified = false;
1193
+ if (flags.verify !== false) {
1194
+ try {
1195
+ account = accountSummary(await ctx.client({ apiKey: key, baseUrl }).get(routes.me));
1196
+ verified = true;
1197
+ } catch (error) {
1198
+ if (error instanceof ApiError && error.status === 403) {
1199
+ ctx.out.warn("the key is valid, but it lacks the accounts:read permission: `funnelchat whoami` and `funnelchat numbers` will not work with it.");
1200
+ verified = true;
1201
+ } else {
1202
+ throw error;
1203
+ }
1204
+ }
1205
+ }
1206
+ config.profiles[profile] = { api_key: key, base_url: baseUrl, saved_at: new Date(ctx.io.clock()).toISOString() };
1207
+ config.current_profile = profile;
1208
+ const path = saveConfig(ctx.io.env, config);
1209
+ if (ctx.io.env.FUNNELCHAT_API_KEY) ctx.out.warn("FUNNELCHAT_API_KEY is set: while it is, it wins over the saved profile (except with --profile).");
1210
+ ctx.out.result({ profile, base_url: baseUrl, config_file: path, verified, account }, () => [
1211
+ `Key saved in the "${profile}" profile (${path}, 0600 permissions).`,
1212
+ ...account ? [`Account: ${String(account.name ?? "-")} (${String(account.email ?? "-")})`] : [],
1213
+ ...baseUrl !== PRODUCTION_URL ? [`Base URL: ${baseUrl}`] : [],
1214
+ ...!verified ? ["The key was not checked against the API (--no-verify)."] : []
1215
+ ]);
1216
+ }
1217
+ function logout(ctx) {
1218
+ const { config } = ctx.config();
1219
+ const profile = activeProfileName(ctx.globals, ctx.io.env, config);
1220
+ const existed = profile in config.profiles;
1221
+ delete config.profiles[profile];
1222
+ if (config.current_profile === profile) delete config.current_profile;
1223
+ const path = existed ? saveConfig(ctx.io.env, config) : ctx.config().path;
1224
+ ctx.out.result(
1225
+ { profile, removed: existed, config_file: path },
1226
+ () => existed ? `Deleted the key of the "${profile}" profile from ${path}.` : `The "${profile}" profile had no saved key.`
1227
+ );
1228
+ if (ctx.io.env.FUNNELCHAT_API_KEY) ctx.out.warn("FUNNELCHAT_API_KEY is still set in your environment.");
1229
+ }
1230
+ async function whoami(ctx) {
1231
+ const credentials = ctx.credentials();
1232
+ const account = accountSummary(await ctx.client().get(routes.me));
1233
+ const keySource = credentials.keySource === "env" ? "from the FUNNELCHAT_API_KEY variable" : `from the "${credentials.profile}" profile`;
1234
+ ctx.out.result({ account, key_source: credentials.keySource, profile: credentials.keySource === "profile" ? credentials.profile : null, base_url: credentials.baseUrl }, () => [
1235
+ `Account: ${String(account.name ?? "-")} (${String(account.email ?? "-")})`,
1236
+ ` id ${String(account.id ?? "-")} \xB7 role ${String(account.role ?? "-")} \xB7 time zone ${String(account.timezone ?? "-")} \xB7 language ${String(account.locale ?? "-")}`,
1237
+ `Key: ${keySource} (not shown)`,
1238
+ `Base URL: ${credentials.baseUrl}`
1239
+ ]);
1240
+ }
1241
+ function profilesList(ctx) {
1242
+ const { config, path } = ctx.config();
1243
+ const active = activeProfileName(ctx.globals, ctx.io.env, config);
1244
+ const rows = Object.entries(config.profiles).map(([name, profile]) => ({
1245
+ name,
1246
+ active: name === active,
1247
+ base_url: profile.base_url ?? PRODUCTION_URL,
1248
+ has_key: Boolean(profile.api_key),
1249
+ saved_at: profile.saved_at ?? null
1250
+ }));
1251
+ ctx.out.result(
1252
+ { config_file: path, active_profile: active, env_key: Boolean(ctx.io.env.FUNNELCHAT_API_KEY), profiles: rows },
1253
+ () => rows.length === 0 ? `No saved profiles (${path}). Save one with \`funnelchat login\`.` : [
1254
+ ...table(
1255
+ ["", "profile", "base url", "key", "saved"],
1256
+ rows.map((r) => [r.active ? "*" : "", r.name, r.base_url, r.has_key ? "yes" : "no", r.saved_at])
1257
+ ),
1258
+ ...ctx.io.env.FUNNELCHAT_API_KEY ? ["", "FUNNELCHAT_API_KEY is set: it wins over the active profile (except with --profile)."] : []
1259
+ ]
1260
+ );
1261
+ }
1262
+ function profilesUse(ctx, name) {
1263
+ const { config } = ctx.config();
1264
+ if (!config.profiles[name]) throw usageError(`There is no "${name}" profile.`, `Create it with \`funnelchat login --profile ${name}\`.`);
1265
+ config.current_profile = name;
1266
+ const path = saveConfig(ctx.io.env, config);
1267
+ ctx.out.result({ active_profile: name, config_file: path }, () => `Active profile: "${name}".`);
1268
+ }
1269
+ function promptHidden(stdin, stderr, question) {
1270
+ stderr.write(question);
1271
+ return new Promise((resolve2, reject) => {
1272
+ let value = "";
1273
+ stdin.setEncoding?.("utf8");
1274
+ stdin.setRawMode?.(true);
1275
+ stdin.resume();
1276
+ const finish = (error) => {
1277
+ stdin.setRawMode?.(false);
1278
+ stdin.pause();
1279
+ stdin.removeAllListeners("data");
1280
+ stderr.write("\n");
1281
+ if (error) reject(error);
1282
+ else resolve2(value.replace(/\[20[01]~/g, ""));
1283
+ };
1284
+ stdin.on("data", (chunk) => {
1285
+ for (const ch of String(chunk)) {
1286
+ if (ch === "\r" || ch === "\n" || ch === "") return finish();
1287
+ if (ch === "") return finish(new InterruptedError());
1288
+ if (ch === "\x7F" || ch === "\b") value = value.slice(0, -1);
1289
+ else if (ch >= " ") value += ch;
1290
+ }
1291
+ });
1292
+ });
1293
+ }
1294
+ function readAll(stdin) {
1295
+ return new Promise((resolve2, reject) => {
1296
+ let value = "";
1297
+ stdin.setEncoding?.("utf8");
1298
+ stdin.on("data", (chunk) => {
1299
+ value += String(chunk);
1300
+ });
1301
+ stdin.on("end", () => resolve2(value.trim()));
1302
+ stdin.on("error", reject);
1303
+ stdin.resume();
1304
+ });
1305
+ }
1306
+
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
+ }
1321
+ function positiveInt(value, flag) {
1322
+ if (!/^\d+$/.test(value) || Number(value) < 1 || !Number.isSafeInteger(Number(value))) {
1323
+ throw usageError(`${flag} must be a positive integer: ${value}`);
1324
+ }
1325
+ return Number(value);
1326
+ }
1327
+ function idList(values, flag) {
1328
+ const ids = [];
1329
+ for (const value of values ?? []) {
1330
+ for (const piece of value.split(",")) {
1331
+ const trimmed = piece.trim();
1332
+ if (trimmed === "") continue;
1333
+ const id = positiveInt(trimmed, flag);
1334
+ if (!ids.includes(id)) ids.push(id);
1335
+ }
1336
+ }
1337
+ return ids;
1338
+ }
1339
+ function collect(value, previous) {
1340
+ return [...previous ?? [], value];
1341
+ }
1342
+ function explicitOptions(command, names) {
1343
+ return new Set(names.filter((name) => command.getOptionValueSource(name) === "cli"));
1344
+ }
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
+
1547
+ // src/dates.ts
1548
+ var DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
1549
+ var DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i;
1550
+ function isValidDay(value) {
1551
+ const match = DAY.exec(value);
1552
+ if (!match) return false;
1553
+ const date = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
1554
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
1555
+ }
1556
+ function toApiDateTime(date) {
1557
+ return `${date.toISOString().slice(0, 19)}+00:00`;
1558
+ }
1559
+ function parseSince(value, flag = "--since") {
1560
+ if (isValidDay(value)) return `${value}T00:00:00+00:00`;
1561
+ if (DATE_TIME.test(value)) {
1562
+ const date = new Date(value.replace(" ", "T"));
1563
+ if (!Number.isNaN(date.getTime())) return toApiDateTime(date);
1564
+ }
1565
+ throw usageError(
1566
+ `${flag} must be a date (YYYY-MM-DD, from 00:00 UTC) or a date and time with a zone (2026-10-01T09:00:00-03:00): ${value}`
1567
+ );
1568
+ }
1569
+ function parseContactBound(value, flag) {
1570
+ if (isValidDay(value)) return value;
1571
+ if (DATE_TIME.test(value)) {
1572
+ const date = new Date(value.replace(" ", "T"));
1573
+ if (!Number.isNaN(date.getTime())) return toApiDateTime(date);
1574
+ }
1575
+ throw usageError(`${flag} must be a YYYY-MM-DD date or a date and time with a zone: ${value}`);
1576
+ }
1577
+ function timestampForName(now) {
1578
+ return new Date(now).toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
1579
+ }
1580
+
1581
+ // src/export/csv.ts
1582
+ var CSV_BOM = "\uFEFF";
1583
+ var FORMULA_START = /^[=+\-@\t\r]/;
1584
+ var PLAIN_NUMBER = /^[+-]?\d+(\.\d+)?$/;
1585
+ function csvCell(value) {
1586
+ if (value === null || value === void 0) return "";
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;
1590
+ }
1591
+ function csvRow(values) {
1592
+ return `${values.map(csvCell).join(",")}\r
1593
+ `;
1594
+ }
1595
+
1596
+ // src/export/files.ts
1597
+ import { createHash } from "crypto";
1598
+ import {
1599
+ closeSync as closeSync2,
1600
+ existsSync as existsSync2,
1601
+ fstatSync,
1602
+ fsyncSync as fsyncSync2,
1603
+ ftruncateSync,
1604
+ linkSync,
1605
+ openSync as openSync2,
1606
+ readSync,
1607
+ renameSync as renameSync2,
1608
+ rmSync as rmSync2,
1609
+ statSync as statSync2,
1610
+ unlinkSync,
1611
+ writeSync as writeSync2
1612
+ } from "fs";
1613
+ import { basename, dirname, join as join2 } from "path";
1614
+ function outputExists(path) {
1615
+ return new CliError(`${path} already exists: the CLI never overwrites a file.`, {
1616
+ exitCode: ExitCode.EXISTS,
1617
+ code: "output_exists",
1618
+ hint: "Choose another folder with --output-dir, or move that file.",
1619
+ details: { path }
1620
+ });
1621
+ }
1622
+ function sha256File(path) {
1623
+ const hash = createHash("sha256");
1624
+ const fd = openSync2(path, "r");
1625
+ try {
1626
+ const buffer = Buffer.allocUnsafe(1 << 20);
1627
+ for (; ; ) {
1628
+ const read = readSync(fd, buffer, 0, buffer.length, null);
1629
+ if (read === 0) break;
1630
+ hash.update(buffer.subarray(0, read));
1631
+ }
1632
+ } finally {
1633
+ closeSync2(fd);
1634
+ }
1635
+ return hash.digest("hex");
1636
+ }
1637
+ var LINK_UNSUPPORTED = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "ENOSYS", "EINVAL"]);
1638
+ function publish(partPath, finalPath) {
1639
+ try {
1640
+ linkSync(partPath, finalPath);
1641
+ } catch (error) {
1642
+ const code2 = error.code ?? "";
1643
+ if (code2 === "EEXIST") {
1644
+ if (sameFile(partPath, finalPath)) {
1645
+ unlinkSync(partPath);
1646
+ return;
1647
+ }
1648
+ throw outputExists(finalPath);
1649
+ }
1650
+ if (!LINK_UNSUPPORTED.has(code2)) throw error;
1651
+ if (existsSync2(finalPath)) throw outputExists(finalPath);
1652
+ renameSync2(partPath, finalPath);
1653
+ return;
1654
+ }
1655
+ unlinkSync(partPath);
1656
+ }
1657
+ function sameFile(a, b) {
1658
+ try {
1659
+ const sa = statSync2(a);
1660
+ const sb = statSync2(b);
1661
+ return sa.ino === sb.ino && sa.dev === sb.dev;
1662
+ } catch {
1663
+ return false;
1664
+ }
1665
+ }
1666
+ function writeNewFile(finalPath, content) {
1667
+ if (existsSync2(finalPath)) throw outputExists(finalPath);
1668
+ const partPath = `${finalPath}.part`;
1669
+ const fd = openSync2(partPath, "w", 420);
1670
+ try {
1671
+ writeSync2(fd, content);
1672
+ fsyncSync2(fd);
1673
+ } finally {
1674
+ closeSync2(fd);
1675
+ }
1676
+ const entry = { path: basename(finalPath), rows: 0, bytes: Buffer.byteLength(content), sha256: sha256File(partPath) };
1677
+ publish(partPath, finalPath);
1678
+ return entry;
1679
+ }
1680
+ function writeAtomic(path, content) {
1681
+ const tmp = join2(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
1682
+ const fd = openSync2(tmp, "w", 384);
1683
+ try {
1684
+ writeSync2(fd, content);
1685
+ fsyncSync2(fd);
1686
+ } finally {
1687
+ closeSync2(fd);
1688
+ }
1689
+ try {
1690
+ renameSync2(tmp, path);
1691
+ } catch (error) {
1692
+ rmSync2(tmp, { force: true });
1693
+ throw error;
1694
+ }
1695
+ }
1696
+ var PartFile = class _PartFile {
1697
+ finalPath;
1698
+ partPath;
1699
+ #fd;
1700
+ #offset;
1701
+ #rows;
1702
+ constructor(finalPath, fd, position) {
1703
+ this.finalPath = finalPath;
1704
+ this.partPath = `${finalPath}.part`;
1705
+ this.#fd = fd;
1706
+ this.#offset = position.offset;
1707
+ this.#rows = position.rows;
1708
+ }
1709
+ /** Starts a new file. Fails if the final name already exists. */
1710
+ static create(finalPath) {
1711
+ if (existsSync2(finalPath)) throw outputExists(finalPath);
1712
+ return new _PartFile(finalPath, openSync2(`${finalPath}.part`, "w", 420), { offset: 0, rows: 0 });
1713
+ }
1714
+ /**
1715
+ * Reopens a `.part` at the position of the last checkpoint and drops whatever was written after it.
1716
+ * Returns a {@link FileEntry} instead when the file was already published (the run stopped right
1717
+ * after publishing it, before saving the state).
1718
+ */
1719
+ static resume(finalPath, position) {
1720
+ const partPath = `${finalPath}.part`;
1721
+ if (existsSync2(finalPath)) {
1722
+ if (existsSync2(partPath) && sameFile(partPath, finalPath)) unlinkSync(partPath);
1723
+ if (existsSync2(partPath)) throw outputExists(finalPath);
1724
+ const size2 = statSync2(finalPath).size;
1725
+ if (size2 !== position.offset) {
1726
+ throw inconsistent(`${basename(finalPath)} is ${size2} bytes and the saved state says ${position.offset}`);
1727
+ }
1728
+ return { path: basename(finalPath), rows: position.rows, bytes: size2, sha256: sha256File(finalPath) };
1729
+ }
1730
+ if (!existsSync2(partPath)) {
1731
+ if (position.offset === 0) return _PartFile.create(finalPath);
1732
+ throw inconsistent(`falta ${basename(partPath)}`);
1733
+ }
1734
+ const fd = openSync2(partPath, "r+");
1735
+ const size = fstatSync(fd).size;
1736
+ if (size < position.offset) {
1737
+ closeSync2(fd);
1738
+ throw inconsistent(`${basename(partPath)} is ${size} bytes and the saved state says ${position.offset}`);
1739
+ }
1740
+ if (size > position.offset) ftruncateSync(fd, position.offset);
1741
+ return new _PartFile(finalPath, fd, position);
1742
+ }
1743
+ get position() {
1744
+ return { offset: this.#offset, rows: this.#rows };
1745
+ }
1746
+ get rows() {
1747
+ return this.#rows;
1748
+ }
1749
+ /** Appends whole rows (each already ending in a newline). */
1750
+ append(text2, rows) {
1751
+ const buffer = Buffer.from(text2, "utf8");
1752
+ let written = 0;
1753
+ while (written < buffer.length) {
1754
+ written += writeSync2(this.fd, buffer, written, buffer.length - written, this.#offset + written);
1755
+ }
1756
+ this.#offset += buffer.length;
1757
+ this.#rows += rows;
1758
+ }
1759
+ /** Flushes to disk: call it before saving the position in the export state. */
1760
+ sync() {
1761
+ fsyncSync2(this.fd);
1762
+ }
1763
+ /** Syncs, closes, hashes and publishes the file under its final name. */
1764
+ finalize() {
1765
+ this.sync();
1766
+ this.close();
1767
+ const entry = { path: basename(this.finalPath), rows: this.#rows, bytes: this.#offset, sha256: sha256File(this.partPath) };
1768
+ publish(this.partPath, this.finalPath);
1769
+ return entry;
1770
+ }
1771
+ close() {
1772
+ if (this.#fd !== null) {
1773
+ closeSync2(this.#fd);
1774
+ this.#fd = null;
1775
+ }
1776
+ }
1777
+ get fd() {
1778
+ if (this.#fd === null) throw new Error(`${this.partPath} is already closed.`);
1779
+ return this.#fd;
1780
+ }
1781
+ };
1782
+ function inconsistent(detail) {
1783
+ return new CliError(`Cannot resume: the state of the export does not match the files (${detail}).`, {
1784
+ exitCode: ExitCode.USAGE,
1785
+ code: "resume_inconsistent",
1786
+ hint: "Start the export again in another folder."
1787
+ });
1788
+ }
1789
+
1790
+ // src/export/session.ts
1791
+ import { closeSync as closeSync3, existsSync as existsSync3, mkdirSync as mkdirSync2, openSync as openSync3, readdirSync, readFileSync as readFileSync2, rmSync as rmSync3, statSync as statSync3, unlinkSync as unlinkSync2, writeSync as writeSync3 } from "fs";
1792
+ import { join as join3, resolve } from "path";
1793
+ var STATE_FILE = ".funnelchat-export.json";
1794
+ var LOCK_FILE = ".funnelchat-export.lock";
1795
+ var MANIFEST_FILE = "manifest.json";
1796
+ var ExportSession = class _ExportSession {
1797
+ dir;
1798
+ state;
1799
+ #locked = true;
1800
+ constructor(dir, state) {
1801
+ this.dir = dir;
1802
+ this.state = state;
1803
+ }
1804
+ static open(options) {
1805
+ const dir = resolve(options.cwd, options.dir);
1806
+ const statePath = join3(dir, STATE_FILE);
1807
+ if (!options.resume) {
1808
+ if (existsSync3(dir)) {
1809
+ if (!statSync3(dir).isDirectory()) throw exists(dir, "Choose a folder with --output-dir.");
1810
+ if (readdirSync(dir).length > 0) {
1811
+ throw exists(
1812
+ dir,
1813
+ existsSync3(statePath) ? `There is an unfinished export in that folder: resume it with
1814
+ funnelchat ${options.kind} export --output-dir ${quote(options.dir)} --resume` : "Choose a new or empty folder with --output-dir: the CLI never overwrites files."
1815
+ );
1816
+ }
1817
+ }
1818
+ mkdirSync2(dir, { recursive: true });
1819
+ lock(dir);
1820
+ const session2 = new _ExportSession(dir, options.initial());
1821
+ session2.save();
1822
+ return { session: session2 };
1823
+ }
1824
+ if (!existsSync3(statePath)) {
1825
+ if (existsSync3(join3(dir, MANIFEST_FILE))) return { alreadyComplete: join3(dir, MANIFEST_FILE) };
1826
+ throw usageError(`There is no export to resume in ${dir}.`, "Pass the same --output-dir as the export that was interrupted.");
1827
+ }
1828
+ const state = readState(statePath);
1829
+ if (state.kind !== options.kind) {
1830
+ throw usageError(`That folder holds a ${state.kind} export, not a ${options.kind} one.`, `Resume it with \`funnelchat ${state.kind} export --output-dir ${quote(options.dir)} --resume\`.`);
1831
+ }
1832
+ if (state.base_url !== options.baseUrl) {
1833
+ throw usageError(`That export was made against ${state.base_url} and the base URL is now ${options.baseUrl}.`, "Resume it with the same base URL (and the same key) as the original.");
1834
+ }
1835
+ const differences = [...options.explicit].filter((key) => JSON.stringify(options.options[key] ?? null) !== JSON.stringify(state.options[key] ?? null)).map((key) => `${key}: original ${JSON.stringify(state.options[key] ?? null)}, now ${JSON.stringify(options.options[key] ?? null)}`);
1836
+ if (differences.length) {
1837
+ throw usageError(
1838
+ `The options do not match those of the original export (${differences.join("; ")}).`,
1839
+ "With --resume there is no need to repeat the options: the original ones are used."
1840
+ );
1841
+ }
1842
+ lock(dir);
1843
+ const session = new _ExportSession(dir, state);
1844
+ session.state.runs += 1;
1845
+ session.save();
1846
+ return { session };
1847
+ }
1848
+ path(name) {
1849
+ return join3(this.dir, name);
1850
+ }
1851
+ save() {
1852
+ writeAtomic(join3(this.dir, STATE_FILE), `${JSON.stringify(this.state, null, 2)}
1853
+ `);
1854
+ }
1855
+ /** Writes the manifest (never over an existing one), deletes the state and releases the lock. */
1856
+ complete(manifest) {
1857
+ const entry = writeNewFile(join3(this.dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
1858
+ `);
1859
+ rmSync3(join3(this.dir, STATE_FILE), { force: true });
1860
+ this.release();
1861
+ return entry;
1862
+ }
1863
+ release() {
1864
+ if (!this.#locked) return;
1865
+ this.#locked = false;
1866
+ rmSync3(join3(this.dir, LOCK_FILE), { force: true });
1867
+ }
1868
+ };
1869
+ function readState(path) {
1870
+ let parsed;
1871
+ try {
1872
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
1873
+ } catch {
1874
+ throw usageError(`The state of the export (${path}) cannot be read.`, "Start the export again in another folder.");
1875
+ }
1876
+ if (!isRecord(parsed) || parsed.state_version !== 1 || typeof parsed.kind !== "string") {
1877
+ throw usageError(`The state of the export (${path}) is not from this version of the CLI.`, "Start the export again in another folder.");
1878
+ }
1879
+ return parsed;
1880
+ }
1881
+ function lock(dir) {
1882
+ const path = join3(dir, LOCK_FILE);
1883
+ for (let attempt = 0; attempt < 2; attempt++) {
1884
+ try {
1885
+ const fd = openSync3(path, "wx", 384);
1886
+ writeSync3(fd, String(process.pid));
1887
+ closeSync3(fd);
1888
+ return;
1889
+ } catch (error) {
1890
+ if (error.code !== "EEXIST") throw error;
1891
+ const pid = Number(readFileSync2(path, "utf8").trim());
1892
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && isAlive(pid)) {
1893
+ throw new CliError(`Another run of the CLI (process ${pid}) is using ${dir}.`, {
1894
+ exitCode: ExitCode.EXISTS,
1895
+ code: "export_locked",
1896
+ hint: "Wait for it to finish, or stop it, and try again."
1897
+ });
1898
+ }
1899
+ unlinkSync2(path);
1900
+ }
1901
+ }
1902
+ }
1903
+ function isAlive(pid) {
1904
+ try {
1905
+ process.kill(pid, 0);
1906
+ return true;
1907
+ } catch (error) {
1908
+ return error.code === "EPERM";
1909
+ }
1910
+ }
1911
+ function exists(dir, hint) {
1912
+ return new CliError(`The folder ${dir} already exists and is not empty.`, { exitCode: ExitCode.EXISTS, code: "output_exists", hint, details: { path: dir } });
1913
+ }
1914
+ function quote(value) {
1915
+ return /^[\w./:@=-]+$/.test(value) ? value : `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
1916
+ }
1917
+ function baseManifest(state, used, finishedAt) {
1918
+ return {
1919
+ manifest_version: 1,
1920
+ kind: state.kind,
1921
+ generator: { name: PACKAGE_NAME, version: VERSION },
1922
+ api: {
1923
+ base_url: state.base_url,
1924
+ spec: { source: SPEC_SOURCE.branch ? `developers-portal ${SPEC_SOURCE.branch}` : null, commit: SPEC_SOURCE.commit, sha256: SPEC_SOURCE.sha256 },
1925
+ routes: used.map((route) => ({ route: `${route.method} ${route.path}`, pending: route.pending?.source ?? null }))
1926
+ },
1927
+ started_at: state.started_at,
1928
+ finished_at: finishedAt,
1929
+ runs: state.runs,
1930
+ api_calls: state.api_calls,
1931
+ options: state.options
1932
+ };
1933
+ }
1934
+
1935
+ // src/commands/export-common.ts
1936
+ function formatCount(n) {
1937
+ return n.toLocaleString("en-US");
1938
+ }
1939
+ function countOf(n, singular, plural = `${singular}s`) {
1940
+ return `${formatCount(n)} ${n === 1 ? singular : plural}`;
1941
+ }
1942
+ function finishExport(ctx, session, manifest, summary) {
1943
+ const entry = session.complete(manifest);
1944
+ ctx.out.result({ status: "complete", output_dir: session.dir, manifest_file: entry, manifest }, () => [
1945
+ `Export complete: ${summary}.`,
1946
+ `Manifest: ${session.path(entry.path)} (sha256 ${entry.sha256})`,
1947
+ `Files: ${manifest.files.length} \xB7 API calls: ${manifest.api_calls.toLocaleString("en-US")}`
1948
+ ]);
1949
+ }
1950
+ function withResumeHint(error, resumeCommand) {
1951
+ const cliError = toCliError(error);
1952
+ if (cliError.exitCode === ExitCode.USAGE || cliError.exitCode === ExitCode.EXISTS) return cliError;
1953
+ const resume = `What was exported up to the last complete page is saved. To continue from there:
1954
+ ${resumeCommand}`;
1955
+ cliError.hint = cliError.hint ? `${cliError.hint}
1956
+ ${resume}` : resume;
1957
+ cliError.details.resume_command = resumeCommand;
1958
+ return cliError;
1959
+ }
1960
+
1961
+ // src/commands/contacts.ts
1962
+ var OPTION_NAMES = {
1963
+ format: "format",
1964
+ tag: "tag_ids",
1965
+ tagMode: "tag_mode",
1966
+ createdFrom: "created_from",
1967
+ createdTo: "created_to",
1968
+ onlyWithPhone: "only_with_phone",
1969
+ tags: "with_tags",
1970
+ customFields: "with_custom_fields"
1971
+ };
1972
+ var CONTACTS_FLAG_NAMES = Object.keys(OPTION_NAMES);
1973
+ var BASE_COLUMNS = [
1974
+ "id",
1975
+ "name",
1976
+ "phone",
1977
+ "email",
1978
+ "whatsapp_id",
1979
+ "whatsapp_username",
1980
+ "country_code",
1981
+ "status",
1982
+ "created_at",
1983
+ "device_id",
1984
+ "adviser_id",
1985
+ "active_conversation",
1986
+ "unread_messages",
1987
+ "favorite",
1988
+ "lead_score",
1989
+ "lead_score_category"
1990
+ ];
1991
+ var PER_PAGE = 100;
1992
+ function normalize(flags) {
1993
+ const format = (flags.format ?? "csv").toLowerCase();
1994
+ if (format !== "csv" && format !== "jsonl") throw usageError(`--format must be csv or jsonl: ${flags.format}`);
1995
+ const tagMode = flags.tagMode?.toLowerCase() ?? null;
1996
+ if (tagMode !== null && !["any", "all", "exclude"].includes(tagMode)) throw usageError(`--tag-mode must be any, all or exclude: ${flags.tagMode}`);
1997
+ const tagIds = idList(flags.tag, "--tag");
1998
+ if (tagMode !== null && tagIds.length === 0) throw usageError("--tag-mode needs at least one --tag.");
1999
+ if (Boolean(flags.createdFrom) !== Boolean(flags.createdTo)) {
2000
+ throw usageError("--created-from and --created-to go together: the API needs both.", 'For "from a date until today", put today in --created-to.');
2001
+ }
2002
+ return {
2003
+ format,
2004
+ tag_ids: tagIds,
2005
+ tag_mode: tagMode,
2006
+ created_from: flags.createdFrom ? parseContactBound(flags.createdFrom, "--created-from") : null,
2007
+ created_to: flags.createdTo ? parseContactBound(flags.createdTo, "--created-to") : null,
2008
+ only_with_phone: flags.onlyWithPhone === true,
2009
+ with_tags: flags.tags !== false,
2010
+ with_custom_fields: flags.customFields !== false
2011
+ };
2012
+ }
2013
+ function listQuery(options) {
2014
+ return {
2015
+ perPage: PER_PAGE,
2016
+ orderBy: "id",
2017
+ orderByMethod: "asc",
2018
+ ...options.tag_ids.length ? { "tag_ids[]": options.tag_ids } : {},
2019
+ ...options.tag_mode ? { tag_mode: options.tag_mode } : {},
2020
+ ...options.created_from ? { created_from: options.created_from, created_to: options.created_to } : {},
2021
+ ...options.only_with_phone ? { only_with_phone: true } : {}
2022
+ };
2023
+ }
2024
+ async function contactsExport(ctx, flags, explicitFlags) {
2025
+ const resume = flags.resume === true;
2026
+ if (resume && !flags.outputDir) throw usageError("--resume needs the --output-dir of the export that was interrupted.");
2027
+ const options = normalize(flags);
2028
+ const { baseUrl } = ctx.credentials();
2029
+ const client = ctx.client();
2030
+ const dir = flags.outputDir ?? `funnelchat-contacts-export-${timestampForName(ctx.io.clock())}`;
2031
+ const opened = ExportSession.open({
2032
+ dir,
2033
+ cwd: ctx.io.cwd,
2034
+ kind: "contacts",
2035
+ resume,
2036
+ baseUrl,
2037
+ options,
2038
+ explicit: new Set([...explicitFlags].map((flag) => OPTION_NAMES[flag]).filter((name) => Boolean(name))),
2039
+ initial: () => ({
2040
+ state_version: 1,
2041
+ kind: "contacts",
2042
+ cli_version: VERSION,
2043
+ base_url: baseUrl,
2044
+ options,
2045
+ started_at: new Date(ctx.io.clock()).toISOString(),
2046
+ runs: 1,
2047
+ api_calls: 0,
2048
+ columns: null,
2049
+ next: null,
2050
+ done: false,
2051
+ last_id: null,
2052
+ file: { offset: 0, rows: 0 },
2053
+ header_written: false,
2054
+ api_total_at_start: null,
2055
+ vanished: 0,
2056
+ observed: { first_created_at: null, last_created_at: null }
2057
+ })
2058
+ });
2059
+ if ("alreadyComplete" in opened) {
2060
+ ctx.out.result({ status: "already_complete", manifest: opened.alreadyComplete }, () => `The export was already complete: ${opened.alreadyComplete}`);
2061
+ return;
2062
+ }
2063
+ const session = opened.session;
2064
+ const state = session.state;
2065
+ const o = state.options;
2066
+ const callsBefore = state.api_calls;
2067
+ const countCalls = () => {
2068
+ state.api_calls = callsBefore + client.requests;
2069
+ };
2070
+ const resumeCommand = `funnelchat contacts export --output-dir ${quote(dir)} --resume`;
2071
+ const fileName = o.format === "csv" ? "contacts.csv" : "contacts.jsonl";
2072
+ let file = null;
2073
+ try {
2074
+ ctx.out.progress(resume ? `Resuming the contacts export in ${session.dir}` : `Exporting contacts to ${session.dir}`);
2075
+ if (o.format === "csv" && o.with_custom_fields && state.columns === null) {
2076
+ const columns = [];
2077
+ for await (const field of client.all(routes.customFieldsList, { query: { perPage: PER_PAGE, orderBy: "created_at", orderByMethod: "asc" } })) {
2078
+ if (typeof field.id === "number") columns.push({ id: field.id, name: typeof field.name === "string" ? field.name : String(field.id) });
2079
+ }
2080
+ state.columns = columns;
2081
+ countCalls();
2082
+ session.save();
2083
+ }
2084
+ const opened2 = state.file.offset === 0 && !state.header_written ? PartFile.create(session.path(fileName)) : PartFile.resume(session.path(fileName), state.file);
2085
+ let entry;
2086
+ if (opened2 instanceof PartFile) {
2087
+ file = opened2;
2088
+ if (o.format === "csv" && !state.header_written) {
2089
+ file.append(CSV_BOM + csvRow(csvHeader(o, state.columns ?? [])), 0);
2090
+ file.sync();
2091
+ state.header_written = true;
2092
+ state.file = file.position;
2093
+ session.save();
2094
+ }
2095
+ const baseQuery = listQuery(o);
2096
+ while (!state.done) {
2097
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
2098
+ const query = mergeNext(baseQuery, state.next ?? { page: 1 });
2099
+ const page = await client.page(routes.contactsList, { query });
2100
+ if (state.api_total_at_start === null) state.api_total_at_start = totalOf(page.body);
2101
+ let text2 = "";
2102
+ let rows = 0;
2103
+ for (const contact of page.items) {
2104
+ const id = typeof contact.id === "number" ? contact.id : Number(contact.id);
2105
+ if (state.last_id !== null && id <= state.last_id) continue;
2106
+ const enriched = await enrich(client, contact, id, o);
2107
+ if (enriched === null) {
2108
+ state.vanished++;
2109
+ } else {
2110
+ text2 += o.format === "csv" ? csvRow(csvValues(enriched, o, state.columns ?? [])) : `${JSON.stringify(enriched)}
2111
+ `;
2112
+ rows++;
2113
+ observe(state, contact.created_at);
2114
+ }
2115
+ state.last_id = state.last_id === null ? id : Math.max(state.last_id, id);
2116
+ }
2117
+ file.append(text2, rows);
2118
+ file.sync();
2119
+ state.file = file.position;
2120
+ state.next = page.next;
2121
+ state.done = page.next === null;
2122
+ countCalls();
2123
+ session.save();
2124
+ ctx.out.progress(` contacts: ${formatCount(state.file.rows)}${state.api_total_at_start !== null ? ` of ${formatCount(state.api_total_at_start)}` : ""}`);
2125
+ }
2126
+ entry = file.finalize();
2127
+ file = null;
2128
+ } else {
2129
+ entry = opened2;
2130
+ }
2131
+ const manifest = {
2132
+ ...baseManifest(state, usedRoutes(o), new Date(ctx.io.clock()).toISOString()),
2133
+ range: {
2134
+ created_from: o.created_from,
2135
+ created_to: o.created_to,
2136
+ observed_first_created_at: state.observed.first_created_at,
2137
+ observed_last_created_at: state.observed.last_created_at
2138
+ },
2139
+ counts: {
2140
+ contacts: entry.rows,
2141
+ ...state.api_total_at_start !== null ? { api_total_at_start: state.api_total_at_start } : {},
2142
+ vanished_during_export: state.vanished
2143
+ },
2144
+ files: [entry],
2145
+ notes: [
2146
+ "Contacts ordered by ascending id, as GET /api/v1/contacts pages them (by page number).",
2147
+ "If contacts are deleted during the export, paging by page number can skip some: compare counts.contacts with counts.api_total_at_start.",
2148
+ ...o.with_tags ? ["Tags: GET /api/v1/contacts/{contact}/tags, one request per contact (the listing does not include them)."] : [],
2149
+ ...o.with_custom_fields ? ["Custom fields: GET /api/v1/contacts/{contact}/custom-fields, one request per contact (the listing does not include them)."] : [],
2150
+ ...o.format === "csv" ? ["CSV with a UTF-8 BOM and CRLF line endings. A cell that starts with =, +, - or @ (and is not a number) gets a ' in front so the spreadsheet does not run it as a formula."] : []
2151
+ ]
2152
+ };
2153
+ finishExport(ctx, session, manifest, `${countOf(entry.rows, "contact")} in ${session.path(entry.path)}`);
2154
+ } catch (error) {
2155
+ throw withResumeHint(error, resumeCommand);
2156
+ } finally {
2157
+ file?.close();
2158
+ session.release();
2159
+ }
2160
+ }
2161
+ async function enrich(client, contact, id, o) {
2162
+ try {
2163
+ const tags = o.with_tags ? await all(client, routes.contactTags, id) : void 0;
2164
+ const customFields = o.with_custom_fields ? await all(client, routes.contactCustomFields, id) : void 0;
2165
+ return { ...contact, ...tags ? { tags } : {}, ...customFields ? { custom_fields: customFields } : {} };
2166
+ } catch (error) {
2167
+ if (error instanceof ApiError && error.status === 404 && error.apiCode !== "route_not_found") return null;
2168
+ throw error;
2169
+ }
2170
+ }
2171
+ async function all(client, route, contact) {
2172
+ const items = [];
2173
+ for await (const item of client.all(route, { path: { contact }, query: { perPage: PER_PAGE } })) items.push(item);
2174
+ return items;
2175
+ }
2176
+ function observe(state, createdAt) {
2177
+ if (typeof createdAt !== "string" || createdAt === "") return;
2178
+ const o = state.observed;
2179
+ if (o.first_created_at === null || createdAt < o.first_created_at) o.first_created_at = createdAt;
2180
+ if (o.last_created_at === null || createdAt > o.last_created_at) o.last_created_at = createdAt;
2181
+ }
2182
+ function csvHeader(o, columns) {
2183
+ return [...BASE_COLUMNS, ...o.with_tags ? ["tags", "tag_ids"] : [], ...columns.map((c) => `custom_field:${c.name}`)];
2184
+ }
2185
+ function csvValues(contact, o, columns) {
2186
+ const leadScore = isRecord(contact.lead_score) ? contact.lead_score : void 0;
2187
+ const base = BASE_COLUMNS.map((column) => {
2188
+ if (column === "lead_score") return leadScore?.score;
2189
+ if (column === "lead_score_category") return leadScore?.category;
2190
+ return contact[column];
2191
+ });
2192
+ const tags = Array.isArray(contact.tags) ? contact.tags : [];
2193
+ const tagCells = o.with_tags ? [tags.map((t) => t.name).join("; "), tags.map((t) => t.id).join(";")] : [];
2194
+ const fields = Array.isArray(contact.custom_fields) ? contact.custom_fields : [];
2195
+ const fieldCells = columns.map((column) => {
2196
+ const field = fields.find((f) => f.id === column.id) ?? fields.find((f) => f.name === column.name);
2197
+ return field?.value;
2198
+ });
2199
+ return [...base, ...tagCells, ...fieldCells];
2200
+ }
2201
+ function usedRoutes(o) {
2202
+ return [
2203
+ routes.contactsList,
2204
+ ...o.with_tags ? [routes.contactTags] : [],
2205
+ ...o.with_custom_fields ? [routes.contactCustomFields] : [],
2206
+ ...o.with_custom_fields && o.format === "csv" ? [routes.customFieldsList] : []
2207
+ ];
2208
+ }
2209
+
2210
+ // src/commands/conversations.ts
2211
+ import { readFileSync as readFileSync3, rmSync as rmSync4 } from "fs";
2212
+ var OPTION_NAMES2 = {
2213
+ contact: "contact_ids",
2214
+ device: "device_id",
2215
+ since: "since",
2216
+ partSize: "part_size"
2217
+ };
2218
+ var CONVERSATIONS_FLAG_NAMES = Object.keys(OPTION_NAMES2);
2219
+ var PER_PAGE2 = 100;
2220
+ var DEFAULT_PART_SIZE = 5e4;
2221
+ var CONVERSATIONS_FILE = "conversations.jsonl";
2222
+ function partName(index) {
2223
+ return `messages-${String(index).padStart(5, "0")}.jsonl`;
2224
+ }
2225
+ function normalize2(flags) {
2226
+ const contactIds = idList(flags.contact, "--contact");
2227
+ const deviceId = flags.device ? positiveInt(flags.device, "--device") : null;
2228
+ const partSize = flags.partSize ? positiveInt(flags.partSize, "--part-size") : DEFAULT_PART_SIZE;
2229
+ return { contact_ids: contactIds, device_id: deviceId, since: flags.since ? parseSince(flags.since) : null, part_size: partSize };
2230
+ }
2231
+ async function conversationsExport(ctx, flags, explicitFlags) {
2232
+ const resume = flags.resume === true;
2233
+ if (resume && !flags.outputDir) throw usageError("--resume needs the --output-dir of the export that was interrupted.");
2234
+ const options = normalize2(flags);
2235
+ const { baseUrl } = ctx.credentials();
2236
+ const client = ctx.client();
2237
+ const dir = flags.outputDir ?? `funnelchat-conversations-export-${timestampForName(ctx.io.clock())}`;
2238
+ const opened = ExportSession.open({
2239
+ dir,
2240
+ cwd: ctx.io.cwd,
2241
+ kind: "conversations",
2242
+ resume,
2243
+ baseUrl,
2244
+ options,
2245
+ explicit: new Set([...explicitFlags].map((flag) => OPTION_NAMES2[flag]).filter((name) => Boolean(name))),
2246
+ initial: () => ({
2247
+ state_version: 1,
2248
+ kind: "conversations",
2249
+ cli_version: VERSION,
2250
+ base_url: baseUrl,
2251
+ options,
2252
+ started_at: new Date(ctx.io.clock()).toISOString(),
2253
+ runs: 1,
2254
+ api_calls: 0,
2255
+ phase: "list",
2256
+ list: { next: null, done: false, contact_index: 0, file: { offset: 0, rows: 0 } },
2257
+ conversations_file: null,
2258
+ messages: { contact_index: 0, next: null, part_index: 1, part: { offset: 0, rows: 0 }, parts: [], total: 0 },
2259
+ vanished_contacts: [],
2260
+ observed: { first_message_time: null, last_message_time: null }
2261
+ })
2262
+ });
2263
+ if ("alreadyComplete" in opened) {
2264
+ ctx.out.result({ status: "already_complete", manifest: opened.alreadyComplete }, () => `The export was already complete: ${opened.alreadyComplete}`);
2265
+ return;
2266
+ }
2267
+ const session = opened.session;
2268
+ const state = session.state;
2269
+ const o = state.options;
2270
+ const callsBefore = state.api_calls;
2271
+ const save = () => {
2272
+ state.api_calls = callsBefore + client.requests;
2273
+ session.save();
2274
+ };
2275
+ const checkAbort = () => {
2276
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
2277
+ };
2278
+ const resumeCommand = `funnelchat conversations export --output-dir ${quote(dir)} --resume`;
2279
+ let file = null;
2280
+ try {
2281
+ ctx.out.progress(resume ? `Resuming the conversations export in ${session.dir}` : `Exporting conversations to ${session.dir}`);
2282
+ if (state.phase === "list") {
2283
+ const resumed = state.list.file.offset === 0 ? PartFile.create(session.path(CONVERSATIONS_FILE)) : PartFile.resume(session.path(CONVERSATIONS_FILE), state.list.file);
2284
+ if (resumed instanceof PartFile) {
2285
+ file = resumed;
2286
+ const seen = new Set(readContactIds(file.partPath, state.list.file.offset));
2287
+ if (o.contact_ids.length) {
2288
+ for (let i = state.list.contact_index; i < o.contact_ids.length; i++) {
2289
+ checkAbort();
2290
+ const id = o.contact_ids[i];
2291
+ const page = await client.page(routes.conversationsList, { query: { contactId: id, perPage: 1 } });
2292
+ const conversation = page.items.find((item) => conversationContactId(item) === id);
2293
+ if (!conversation) {
2294
+ throw new CliError(`Contact ${id} does not exist in the account, or the key cannot see it.`, {
2295
+ exitCode: ExitCode.NOT_FOUND,
2296
+ code: "contact_not_found",
2297
+ hint: "Check the id: it is the `id` of GET /api/v1/contacts."
2298
+ });
2299
+ }
2300
+ if (!seen.has(id)) {
2301
+ file.append(`${JSON.stringify(conversation)}
2302
+ `, 1);
2303
+ seen.add(id);
2304
+ }
2305
+ file.sync();
2306
+ state.list.file = file.position;
2307
+ state.list.contact_index = i + 1;
2308
+ save();
2309
+ }
2310
+ state.list.done = true;
2311
+ save();
2312
+ }
2313
+ const baseQuery = {
2314
+ perPage: PER_PAGE2,
2315
+ orderBy: "asc",
2316
+ ...o.device_id !== null ? { deviceId: o.device_id } : {},
2317
+ ...o.since ? { updated_since: o.since } : {}
2318
+ };
2319
+ while (!state.list.done) {
2320
+ checkAbort();
2321
+ const query = state.list.next ? mergeNext(baseQuery, state.list.next) : baseQuery;
2322
+ const page = await client.page(routes.conversationsList, { query });
2323
+ let text2 = "";
2324
+ let rows = 0;
2325
+ for (const conversation of page.items) {
2326
+ const id = conversationContactId(conversation);
2327
+ if (!Number.isSafeInteger(id) || seen.has(id)) continue;
2328
+ seen.add(id);
2329
+ text2 += `${JSON.stringify(conversation)}
2330
+ `;
2331
+ rows++;
2332
+ }
2333
+ file.append(text2, rows);
2334
+ file.sync();
2335
+ state.list.file = file.position;
2336
+ state.list.next = page.next;
2337
+ state.list.done = page.next === null;
2338
+ save();
2339
+ ctx.out.progress(` conversations: ${formatCount(state.list.file.rows)}`);
2340
+ }
2341
+ state.conversations_file = file.finalize();
2342
+ file = null;
2343
+ } else {
2344
+ state.conversations_file = resumed;
2345
+ }
2346
+ state.phase = "messages";
2347
+ save();
2348
+ }
2349
+ const conversationsFile = state.conversations_file;
2350
+ const contactIds = readContactIds(session.path(conversationsFile.path));
2351
+ const m = state.messages;
2352
+ const rotate = () => {
2353
+ if (!file) return;
2354
+ m.parts.push(file.finalize());
2355
+ file = null;
2356
+ m.part_index++;
2357
+ m.part = { offset: 0, rows: 0 };
2358
+ save();
2359
+ };
2360
+ const currentPart = () => {
2361
+ while (!file) {
2362
+ const name = session.path(partName(m.part_index));
2363
+ const reopened = m.part.offset === 0 ? PartFile.create(name) : PartFile.resume(name, m.part);
2364
+ if (reopened instanceof PartFile) {
2365
+ file = reopened;
2366
+ } else {
2367
+ m.parts.push(reopened);
2368
+ m.part_index++;
2369
+ m.part = { offset: 0, rows: 0 };
2370
+ save();
2371
+ }
2372
+ }
2373
+ return file;
2374
+ };
2375
+ if (m.part.offset > 0) currentPart();
2376
+ if (file && file.rows >= o.part_size) rotate();
2377
+ for (let i = m.contact_index; i < contactIds.length; i++) {
2378
+ const contactId = contactIds[i];
2379
+ let messages = 0;
2380
+ for (; ; ) {
2381
+ checkAbort();
2382
+ const query = { perPage: PER_PAGE2, ...o.since ? { updated_since: o.since } : {}, ...m.next ?? {} };
2383
+ let page;
2384
+ try {
2385
+ page = await client.page(routes.contactMessages, { path: { contactId }, query });
2386
+ } catch (error) {
2387
+ if (error instanceof ApiError && error.status === 404 && error.apiCode !== "route_not_found") {
2388
+ state.vanished_contacts.push(contactId);
2389
+ m.next = null;
2390
+ break;
2391
+ }
2392
+ throw error;
2393
+ }
2394
+ const rows = o.device_id === null ? page.items : page.items.filter((message) => Number(message.device_id) === o.device_id);
2395
+ if (rows.length) {
2396
+ const part = currentPart();
2397
+ part.append(rows.map((message) => `${JSON.stringify(message)}
2398
+ `).join(""), rows.length);
2399
+ part.sync();
2400
+ m.part = part.position;
2401
+ for (const message of rows) observe2(state, message.time);
2402
+ }
2403
+ m.total += rows.length;
2404
+ messages += rows.length;
2405
+ m.next = page.next;
2406
+ if (!page.next) m.contact_index = i + 1;
2407
+ save();
2408
+ if (file && file.rows >= o.part_size) rotate();
2409
+ if (!page.next) break;
2410
+ }
2411
+ m.contact_index = i + 1;
2412
+ m.next = null;
2413
+ save();
2414
+ ctx.out.progress(` conversation ${formatCount(i + 1)} of ${formatCount(contactIds.length)} (contact ${contactId}): ${countOf(messages, "message")} \xB7 total ${formatCount(m.total)}`);
2415
+ }
2416
+ if (file) {
2417
+ if (file.rows > 0) rotate();
2418
+ else {
2419
+ file.close();
2420
+ rmSync4(file.partPath, { force: true });
2421
+ file = null;
2422
+ }
2423
+ }
2424
+ const manifest = {
2425
+ ...baseManifest(state, [routes.conversationsList, routes.contactMessages], new Date(ctx.io.clock()).toISOString()),
2426
+ range: {
2427
+ since: o.since,
2428
+ until: null,
2429
+ observed_first_message_time: state.observed.first_message_time,
2430
+ observed_last_message_time: state.observed.last_message_time
2431
+ },
2432
+ counts: {
2433
+ conversations: contactIds.length,
2434
+ messages: m.total,
2435
+ message_parts: m.parts.length,
2436
+ vanished_contacts: state.vanished_contacts.length
2437
+ },
2438
+ files: [conversationsFile, ...m.parts],
2439
+ notes: [
2440
+ "conversations.jsonl: one conversation per line, as GET /api/v1/conversations returns it (by contact_id, without repeats).",
2441
+ "messages-NNNNN.jsonl: one message per line, as GET /api/v1/contacts/{contactId}/messages returns it; conversations in the order of conversations.jsonl and, within each one, from the newest message to the oldest (descending id, the API's order).",
2442
+ ...o.since ? ['since is sent as updated_since: conversations and messages created or changed since that date. The API has no "until" filter.'] : [],
2443
+ ...o.device_id !== null ? ["device_id picks the conversations on the API side (deviceId) and the messages on the CLI side: the API does not filter messages by number."] : [],
2444
+ "observed_*_message_time: the time field of the messages, as the API returns it (YYYY-MM-DD HH:MM:SS, without a time zone)."
2445
+ ]
2446
+ };
2447
+ finishExport(
2448
+ ctx,
2449
+ session,
2450
+ manifest,
2451
+ `${countOf(contactIds.length, "conversation")} and ${countOf(m.total, "message")} in ${countOf(m.parts.length, "part")}, in ${session.dir}`
2452
+ );
2453
+ } catch (error) {
2454
+ throw withResumeHint(error, resumeCommand);
2455
+ } finally {
2456
+ file?.close();
2457
+ session.release();
2458
+ }
2459
+ }
2460
+ function conversationContactId(row) {
2461
+ return Number(row.contact_id ?? row.id);
2462
+ }
2463
+ function readContactIds(path, limit) {
2464
+ const content = readFileSync3(path).subarray(0, limit).toString("utf8");
2465
+ const ids = /* @__PURE__ */ new Set();
2466
+ for (const line of content.split("\n")) {
2467
+ if (line.trim() === "") continue;
2468
+ const parsed = JSON.parse(line);
2469
+ const id = isRecord(parsed) ? conversationContactId(parsed) : NaN;
2470
+ if (Number.isSafeInteger(id)) ids.add(id);
2471
+ }
2472
+ return [...ids];
2473
+ }
2474
+ function observe2(state, time) {
2475
+ if (typeof time !== "string" || time === "") return;
2476
+ const o = state.observed;
2477
+ if (o.first_message_time === null || time < o.first_message_time) o.first_message_time = time;
2478
+ if (o.last_message_time === null || time > o.last_message_time) o.last_message_time = time;
2479
+ }
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
+
2518
+ // src/commands/mac.ts
2519
+ async function macStatus(ctx) {
2520
+ const body = await ctx.client().get(routes.macStatus);
2521
+ const status = isRecord(body) ? body : { value: body };
2522
+ const n = (value) => typeof value === "number" ? value.toLocaleString("en-US") : "-";
2523
+ const count = status.current_count ?? status.mac_count;
2524
+ const limit = status.limit ?? status.cycle_capacity;
2525
+ const remaining = status.remaining ?? status.total_available;
2526
+ ctx.out.result({ mac: status }, () => [
2527
+ `MACs in the period: ${n(count)} of ${n(limit)} (${n(status.usage_percentage)} %) \xB7 ${n(remaining)} left`,
2528
+ ` state ${String(status.state ?? "-")} \xB7 cap enforcement ${status.enforcement_enabled === true ? "on" : status.enforcement_enabled === false ? "off" : "-"}`,
2529
+ ...typeof status.inactive_contacts_count === "number" ? [` contacts inactive by MAC: ${n(status.inactive_contacts_count)} (sends with a key to them get 429 mac_lockout_24h)`] : []
2530
+ ]);
2531
+ }
2532
+
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;
2577
+ }
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);
2595
+ continue;
2596
+ }
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;
2601
+ }
2602
+ } catch (error) {
2603
+ throw alreadySent(error, ids, status);
2604
+ }
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
+ });
2630
+ }
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;
2654
+ }
2655
+
2656
+ // src/commands/templates.ts
2657
+ var STATUSES = ["PENDING", "APPROVED", "REJECTED", "PENDING_DELETION", "PAUSED", "DISABLED", "ARCHIVED"];
2658
+ async function templatesList(ctx, flags) {
2659
+ const status = flags.status?.toUpperCase();
2660
+ if (status && !STATUSES.includes(status)) throw usageError(`--status must be one of ${STATUSES.join(", ")}: ${flags.status}`);
2661
+ const query = {
2662
+ perPage: 100,
2663
+ ...flags.device ? { deviceId: positiveInt(flags.device, "--device") } : {},
2664
+ ...status ? { status } : {}
2665
+ };
2666
+ const templates = [];
2667
+ for await (const template of ctx.client().all(routes.templatesList, { query })) templates.push(template);
2668
+ ctx.out.result(
2669
+ { templates },
2670
+ () => templates.length === 0 ? "No templates match those filters." : table(
2671
+ ["id", "name", "language", "category", "status", "number"],
2672
+ templates.map((t) => [t.id, t.name, t.language_code, t.category, t.status, t.device_id])
2673
+ )
2674
+ );
2675
+ }
2676
+
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
+ `;
2684
+ var EXIT_CODES_HELP = `
2685
+ Exit codes:
2686
+ 0 all good
2687
+ 1 unexpected error
2688
+ 2 wrong usage (invalid option or value)
2689
+ 3 no key, a key the API rejects (401) or a missing permission (403)
2690
+ 4 not found in your account (404)
2691
+ 5 an API limit the CLI does not wait for (daily cap or a long Retry-After): it can be resumed
2692
+ 6 API or network error, after the retries; or the message sent failed (send)
2693
+ 7 the output already exists: the CLI never overwrites a file
2694
+ 8 no number connected within the time (connect)
2695
+ 130 interrupted with Ctrl+C: an export can be resumed
2696
+
2697
+ Environment variables:
2698
+ FUNNELCHAT_API_KEY the key (wins over the saved profile, except with --profile)
2699
+ FUNNELCHAT_PROFILE the profile to use
2700
+ FUNNELCHAT_BASE_URL the base URL
2701
+ FUNNELCHAT_RATE requests per second
2702
+ FUNNELCHAT_CONFIG_DIR where to keep the configuration
2703
+
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.`;
2706
+ async function run(argv, io) {
2707
+ let exitCode = ExitCode.OK;
2708
+ const program = new Command("funnelchat");
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)
2714
+ });
2715
+ const action = (fn) => async (...args) => {
2716
+ const command = args[args.length - 1];
2717
+ const ctx = new Context(io, command.optsWithGlobals());
2718
+ try {
2719
+ await fn(ctx, command, ...args.slice(0, -1));
2720
+ exitCode = ExitCode.OK;
2721
+ } catch (error) {
2722
+ exitCode = ctx.out.error(error);
2723
+ }
2724
+ };
2725
+ program.command("login").description("Saves your API key in a profile (a file with 0600 permissions). Reads it from the keyboard without showing it, or from standard input.").option("--no-verify", "Does not check it against the API before saving it.").action(action((ctx, command) => login(ctx, command.opts())));
2726
+ program.command("logout").description("Deletes the saved key of the active profile (or of --profile).").action(action((ctx) => logout(ctx)));
2727
+ program.command("whoami").description("Shows which account the key belongs to, without showing the key.").action(action((ctx) => whoami(ctx)));
2728
+ const profiles = program.command("profiles").description("Saved profiles.");
2729
+ profiles.command("list", { isDefault: true }).description("Lists the profiles (never shows the keys).").action(action((ctx) => profilesList(ctx)));
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())));
2736
+ const numbers = program.command("numbers").description("Your WhatsApp numbers.");
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)));
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 ?? [])));
2739
+ const contacts = program.command("contacts").description("Contacts.");
2740
+ contacts.command("export").description("Exports the contacts with their tags and custom fields, as CSV or JSONL.").option("-o, --output-dir <dir>", "New or empty folder for the export (funnelchat-contacts-export-<date> by default).").option("--format <format>", "csv (default) or jsonl.").option("--tag <id>", "Only contacts with this tag (can be repeated or comma-separated).", collect).option("--tag-mode <mode>", "With several --tag: any (default), all or exclude (none of them).").option("--created-from <date>", "Created from this date (YYYY-MM-DD, the whole day). Goes with --created-to.").option("--created-to <date>", "Created up to this date (YYYY-MM-DD, the whole day). Goes with --created-from.").option("--only-with-phone", "Only contacts that can be messaged.").option("--no-tags", "Without tags (one request less per contact).").option("--no-custom-fields", "Without custom fields (one request less per contact).").option("--resume", "Resumes an interrupted export in --output-dir, from the last saved page.").action(
2741
+ action((ctx, command) => contactsExport(ctx, command.opts(), explicitOptions(command, CONTACTS_FLAG_NAMES)))
2742
+ );
2743
+ const conversations = program.command("conversations").description("Conversations and their messages.");
2744
+ conversations.command("export").description("Exports the messages of every conversation in JSONL parts, with a manifest.").option("-o, --output-dir <dir>", "New or empty folder for the export (funnelchat-conversations-export-<date> by default).").option("--contact <id>", "Only the conversation of this contact (can be repeated or comma-separated).", collect).option("--device <id>", "Only conversations of this number, and of them only its messages.").option("--since <date>", "Conversations and messages created or changed since this date (YYYY-MM-DD in UTC, or a date and time with a zone).").option("--part-size <messages>", `Messages per part (${DEFAULT_PART_SIZE.toLocaleString("en-US")} by default).`).option("--resume", "Resumes an interrupted export in --output-dir, from the last saved cursor.").action(
2745
+ action(
2746
+ (ctx, command) => conversationsExport(ctx, command.opts(), explicitOptions(command, CONVERSATIONS_FLAG_NAMES))
2747
+ )
2748
+ );
2749
+ const templates = program.command("templates").description("WhatsApp templates.");
2750
+ templates.command("list").description("Lists the templates, with their status and their number.").option("--device <id>", "Only those of this number.").option("--status <status>", "APPROVED, PENDING, REJECTED, PAUSED, DISABLED\u2026").action(action((ctx, command) => templatesList(ctx, command.opts())));
2751
+ const mac = program.command("mac").description("Monthly active contacts (MACs).");
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)));
2753
+ for (const command of program.commands) {
2754
+ command.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
2755
+ for (const sub of command.commands) {
2756
+ sub.exitOverride().configureOutput({ writeOut: (text2) => io.stdout.write(text2), writeErr: (text2) => io.stderr.write(text2) });
2757
+ }
2758
+ }
2759
+ try {
2760
+ await program.parseAsync(argv, { from: "user" });
2761
+ } catch (error) {
2762
+ if (error instanceof CommanderError) {
2763
+ if (["commander.helpDisplayed", "commander.version", "commander.help"].includes(error.code)) return ExitCode.OK;
2764
+ if (error.code === "commander.executeSubCommandAsync") return ExitCode.UNEXPECTED;
2765
+ return ExitCode.USAGE;
2766
+ }
2767
+ throw error;
2768
+ }
2769
+ return exitCode;
2770
+ }
2771
+
2772
+ // src/cli.ts
2773
+ var controller = new AbortController();
2774
+ var interrupted = false;
2775
+ process.on("SIGINT", () => {
2776
+ if (interrupted) process.exit(130);
2777
+ interrupted = true;
2778
+ process.stderr.write("\nStopping\u2026 (Ctrl+C again to quit now)\n");
2779
+ controller.abort();
2780
+ });
2781
+ if (typeof globalThis.fetch !== "function") {
2782
+ process.stderr.write("error: this version of Node has no fetch. Use Node 18 or later.\n");
2783
+ process.exit(1);
2784
+ }
2785
+ var code = await run(process.argv.slice(2), {
2786
+ stdout: process.stdout,
2787
+ stderr: process.stderr,
2788
+ stdin: process.stdin,
2789
+ env: process.env,
2790
+ cwd: process.cwd(),
2791
+ fetch: globalThis.fetch,
2792
+ sleep: realSleep,
2793
+ clock: Date.now,
2794
+ signal: controller.signal
2795
+ });
2796
+ process.exitCode = code;
2797
+ //# sourceMappingURL=cli.js.map