@diegoaltoworks/chatter 0.36.0 → 0.37.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.
@@ -150,6 +150,126 @@ function loadBaileys() {
150
150
  return cached;
151
151
  }
152
152
 
153
+ // src/channels/whatsapp/pairing.ts
154
+ var RESTART_REQUIRED_STATUS = 515;
155
+ var MAX_PAIRING_RECONNECTS = 12;
156
+ var PAIRING_RECONNECT_DELAY_MS = 2e3;
157
+ var PAIRING_RECONNECT_MAX_DELAY_MS = 8e3;
158
+ var RESTART_REQUIRED_DELAY_MS = 500;
159
+ function pairingReconnectDelayMs(statusCode, attempt) {
160
+ if (statusCode === RESTART_REQUIRED_STATUS) return RESTART_REQUIRED_DELAY_MS;
161
+ return Math.min(
162
+ PAIRING_RECONNECT_DELAY_MS * 2 ** Math.max(0, attempt - 1),
163
+ PAIRING_RECONNECT_MAX_DELAY_MS
164
+ );
165
+ }
166
+ function decidePairingClose(input) {
167
+ if (input.statusCode === input.loggedOutCode) return { kind: "fatal", reason: "loggedOut" };
168
+ if (input.attempts >= input.maxAttempts) return { kind: "fatal", reason: "exhausted" };
169
+ const attempt = input.attempts + 1;
170
+ return {
171
+ kind: "reconnect",
172
+ attempt,
173
+ delayMs: pairingReconnectDelayMs(input.statusCode, attempt)
174
+ };
175
+ }
176
+ function describeError(error) {
177
+ return error instanceof Error ? error.message : String(error);
178
+ }
179
+ function runPairing(deps) {
180
+ const maxAttempts = deps.maxAttempts ?? MAX_PAIRING_RECONNECTS;
181
+ const schedule = deps.schedule ?? ((fn, ms) => {
182
+ setTimeout(fn, ms);
183
+ });
184
+ return new Promise((resolve) => {
185
+ let attempts = 0;
186
+ let generation = 0;
187
+ let codeRequested = false;
188
+ let settled = false;
189
+ const settle = (result) => {
190
+ if (settled) return;
191
+ settled = true;
192
+ resolve(result);
193
+ };
194
+ const status = (message) => deps.onStatus?.(message);
195
+ const requestCode = async (sock) => {
196
+ if (settled || codeRequested || !deps.phoneNumber) return;
197
+ try {
198
+ const code = await sock.requestPairingCode(deps.phoneNumber);
199
+ codeRequested = true;
200
+ deps.onPairingCode?.(code);
201
+ } catch (error) {
202
+ settle({
203
+ ok: false,
204
+ reason: "error",
205
+ message: `Failed to request pairing code: ${describeError(error)}`
206
+ });
207
+ }
208
+ };
209
+ const handleUpdate = async (update, sock) => {
210
+ const { connection, lastDisconnect, qr } = update;
211
+ if (qr && !deps.phoneNumber && deps.onQr) {
212
+ try {
213
+ await deps.onQr(qr);
214
+ } catch (error) {
215
+ status(`QR render failed: ${describeError(error)}`);
216
+ }
217
+ }
218
+ if (connection === "open") {
219
+ settle({ ok: true, userId: sock.user?.id });
220
+ return;
221
+ }
222
+ if (connection === "close") {
223
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
224
+ const action = decidePairingClose({
225
+ statusCode,
226
+ loggedOutCode: deps.loggedOutCode,
227
+ attempts,
228
+ maxAttempts
229
+ });
230
+ if (action.kind === "fatal") {
231
+ settle({
232
+ ok: false,
233
+ reason: action.reason,
234
+ message: action.reason === "loggedOut" ? "Pairing rejected (logged out). Run wa-pair again for a fresh code." : `Gave up after ${maxAttempts} reconnect attempts (last close: ${statusCode ?? "?"}). Run wa-pair again.`
235
+ });
236
+ return;
237
+ }
238
+ attempts = action.attempt;
239
+ generation += 1;
240
+ sock.end?.();
241
+ status(
242
+ statusCode === RESTART_REQUIRED_STATUS ? `Registered \u2014 WhatsApp asked for a restart (515). Reconnecting to finish the link (${action.attempt}/${maxAttempts})...` : `Connection closed (${statusCode ?? "?"}) \u2014 reconnecting with the stored session (${action.attempt}/${maxAttempts})...`
243
+ );
244
+ schedule(() => void attach(), action.delayMs);
245
+ }
246
+ };
247
+ const attach = async () => {
248
+ if (settled) return;
249
+ const mine = generation;
250
+ const attempt = attempts;
251
+ let sock;
252
+ try {
253
+ sock = await deps.connect(attempt);
254
+ } catch (error) {
255
+ settle({ ok: false, reason: "error", message: describeError(error) });
256
+ return;
257
+ }
258
+ sock.ev.on("connection.update", (update) => {
259
+ if (mine !== generation || settled) return;
260
+ void handleUpdate(update, sock);
261
+ });
262
+ if (deps.phoneNumber && !codeRequested) {
263
+ schedule(() => {
264
+ if (mine !== generation) return;
265
+ void requestCode(sock);
266
+ }, deps.requestCodeDelayMs ?? 3e3);
267
+ }
268
+ };
269
+ void attach();
270
+ });
271
+ }
272
+
153
273
  // bin/wa-pair.ts
154
274
  var args = process.argv.slice(2);
155
275
  function flagValue(name) {
@@ -199,10 +319,17 @@ var db = (0, import_client.createClient)({
199
319
  url: process.env.TURSO_URL,
200
320
  authToken: process.env.TURSO_AUTH_TOKEN || ""
201
321
  });
202
- var MAX_RECONNECTS = 12;
203
- var codeRequested = false;
204
- var reconnects = 0;
205
- var didReset = false;
322
+ async function renderQr(qr) {
323
+ const qrcodeTerminal = await import("qrcode-terminal").catch(() => void 0);
324
+ if (!qrcodeTerminal) {
325
+ console.error(
326
+ "\u274C QR mode requires the optional peer dependency 'qrcode-terminal'. Install it with `bun add qrcode-terminal`, or re-run with --code <phoneNumber> for pairing-code mode instead."
327
+ );
328
+ process.exit(1);
329
+ }
330
+ console.log("\nScan this QR code: WhatsApp -> Settings -> Linked Devices -> Link a Device.\n");
331
+ qrcodeTerminal.default.generate(qr, { small: true });
332
+ }
206
333
  async function start() {
207
334
  const baileys = await loadBaileys().catch((error) => {
208
335
  console.error(`\u274C ${error.message}`);
@@ -213,106 +340,73 @@ async function start() {
213
340
  initAuthCreds: baileys.initAuthCreds,
214
341
  appStateSyncKeyFromObject: (value) => baileys.proto.Message.AppStateSyncKeyData.fromObject(value)
215
342
  };
216
- const { state, saveCreds, clear } = await useTursoAuthState(
217
- db,
218
- sessionSecret,
219
- sessionId,
220
- runtime
221
- );
222
- if (reset && !didReset) {
223
- didReset = true;
343
+ if (reset) {
344
+ const { clear } = await useTursoAuthState(db, sessionSecret, sessionId, runtime);
224
345
  await clear();
225
346
  console.log(`Session "${sessionId}" wiped. Pairing fresh...`);
226
- return start();
227
- }
228
- if (state.creds.registered && !codeRequested) {
229
- console.log(
230
- `Already paired. Session "${sessionId}" is connected-ready. Run with --reset to re-pair.`
231
- );
232
- process.exit(0);
233
347
  }
234
348
  const { version } = await baileys.fetchLatestBaileysVersion();
235
- const noop = () => void 0;
236
- const logger = {
237
- level: "silent",
238
- trace: noop,
239
- debug: noop,
240
- info: noop,
241
- warn: noop,
242
- error: noop,
243
- fatal: noop
244
- };
245
- logger.child = () => logger;
246
- const sock = baileys.makeWASocket({
247
- version,
248
- auth: {
249
- creds: state.creds,
250
- // biome-ignore lint/suspicious/noExplicitAny: structural pino-compatible logger
251
- keys: baileys.makeCacheableSignalKeyStore(state.keys, logger)
252
- },
253
- // biome-ignore lint/suspicious/noExplicitAny: structural pino-compatible logger
254
- logger,
255
- markOnlineOnConnect: false
256
- });
257
- sock.ev.on("creds.update", saveCreds);
258
- sock.ev.on("connection.update", async (update) => {
259
- const { connection, lastDisconnect, qr } = update;
260
- if (qr && !phoneNumber) {
261
- const qrcodeTerminal = await import("qrcode-terminal").catch(() => void 0);
262
- if (!qrcodeTerminal) {
263
- console.error(
264
- "\u274C QR mode requires the optional peer dependency 'qrcode-terminal'. Install it with `bun add qrcode-terminal`, or re-run with --code <phoneNumber> for pairing-code mode instead."
265
- );
266
- process.exit(1);
267
- }
268
- console.log(
269
- "\nScan this QR code: WhatsApp -> Settings -> Linked Devices -> Link a Device.\n"
270
- );
271
- qrcodeTerminal.default.generate(qr, { small: true });
272
- }
273
- if (connection === "open") {
349
+ const connect = async (attempt) => {
350
+ const { state, saveCreds } = await useTursoAuthState(
351
+ db,
352
+ sessionSecret,
353
+ sessionId,
354
+ runtime
355
+ );
356
+ if (attempt === 0 && state.creds.registered) {
274
357
  console.log(
275
- `
276
- \u2705 Paired session "${sessionId}" as ${sock.user?.id}. Session saved (encrypted).`
358
+ `Already paired. Session "${sessionId}" is connected-ready. Run with --reset to re-pair.`
277
359
  );
278
- console.log("The running server will pick this session up on its next connect.");
279
360
  process.exit(0);
280
361
  }
281
- if (connection === "close") {
282
- const statusCode = lastDisconnect?.error?.output?.statusCode;
283
- if (codeRequested && reconnects < MAX_RECONNECTS) {
284
- reconnects++;
285
- console.log(
286
- `Connection closed (${statusCode ?? "?"}) - reconnecting (code STILL VALID, keep going on the phone) (${reconnects}/${MAX_RECONNECTS})...`
287
- );
288
- setTimeout(() => void start(), 2e3);
289
- return;
290
- }
291
- if (statusCode === baileys.DisconnectReason.loggedOut) {
292
- console.error("\u274C Pairing rejected (logged out). Run wa-pair again for a fresh code.");
293
- } else {
294
- console.error(`\u274C Connection closed (${statusCode ?? "?"}). Run wa-pair again.`);
295
- }
296
- process.exit(1);
297
- }
362
+ const noop = () => void 0;
363
+ const logger = {
364
+ level: "silent",
365
+ trace: noop,
366
+ debug: noop,
367
+ info: noop,
368
+ warn: noop,
369
+ error: noop,
370
+ fatal: noop
371
+ };
372
+ logger.child = () => logger;
373
+ const sock = baileys.makeWASocket({
374
+ version,
375
+ auth: {
376
+ creds: state.creds,
377
+ // biome-ignore lint/suspicious/noExplicitAny: structural pino-compatible logger
378
+ keys: baileys.makeCacheableSignalKeyStore(state.keys, logger)
379
+ },
380
+ // biome-ignore lint/suspicious/noExplicitAny: structural pino-compatible logger
381
+ logger,
382
+ markOnlineOnConnect: false
383
+ });
384
+ sock.ev.on("creds.update", saveCreds);
385
+ return sock;
386
+ };
387
+ const result = await runPairing({
388
+ connect,
389
+ loggedOutCode: baileys.DisconnectReason.loggedOut,
390
+ phoneNumber,
391
+ onQr: renderQr,
392
+ onPairingCode: (code) => {
393
+ console.log("\n==============================================");
394
+ console.log(` Pairing code for +${phoneNumber}: ${code}`);
395
+ console.log("==============================================");
396
+ console.log("On the phone: WhatsApp -> Settings -> Linked Devices ->");
397
+ console.log("Link a Device -> 'Link with phone number instead' -> enter the code.");
398
+ console.log("Waiting for confirmation (leave this running)...");
399
+ },
400
+ onStatus: (message) => console.log(message)
298
401
  });
299
- if (phoneNumber && !codeRequested && !state.creds.registered) {
300
- setTimeout(async () => {
301
- try {
302
- const code = await sock.requestPairingCode(phoneNumber);
303
- codeRequested = true;
304
- console.log("\n==============================================");
305
- console.log(` Pairing code for +${phoneNumber}: ${code}`);
306
- console.log("==============================================");
307
- console.log("On the phone: WhatsApp -> Settings -> Linked Devices ->");
308
- console.log("Link a Device -> 'Link with phone number instead' -> enter the code.");
309
- console.log("Waiting for confirmation (leave this running)...");
310
- } catch (error) {
311
- console.error("\u274C Failed to request pairing code:", error);
312
- process.exit(1);
313
- }
314
- }, 3e3);
402
+ if (!result.ok) {
403
+ console.error(`\u274C ${result.message}`);
404
+ process.exit(1);
315
405
  }
406
+ console.log(`
407
+ \u2705 Paired session "${sessionId}" as ${result.userId}. Session saved (encrypted).`);
408
+ console.log("The running server will pick this session up on its next connect.");
409
+ process.exit(0);
316
410
  }
317
411
  start().catch((error) => {
318
412
  console.error("\u274C Unexpected error:", error);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pairing loop for the `wa-pair` CLI — the transport-independent half, kept
3
+ * out of the bin so it can be unit-tested against a faked socket.
4
+ *
5
+ * Both pairing modes close the socket mid-flow as a matter of course:
6
+ *
7
+ * - QR mode: the moment a scan registers the device, WhatsApp ALWAYS closes
8
+ * with 515 ("restart required"). The link only completes if the client comes
9
+ * straight back with the SAME credentials it just registered — the phone is
10
+ * sitting on "linking" waiting for exactly that. Exiting there (or wiping
11
+ * creds and starting over) fails the pairing every time.
12
+ * - Pairing-code mode: the socket bounces while the code is being typed.
13
+ *
14
+ * So a close during pairing is normal, not fatal: reconnect with stored creds,
15
+ * bounded, until `open` confirms the link. Only an explicit logout or running
16
+ * out of attempts ends the run.
17
+ */
18
+ /** Baileys' "restart required" — emitted immediately after a QR scan registers the device. */
19
+ export declare const RESTART_REQUIRED_STATUS = 515;
20
+ /** Bounded so a genuinely broken pairing terminates instead of looping forever. */
21
+ export declare const MAX_PAIRING_RECONNECTS = 12;
22
+ /**
23
+ * Backoff between pairing attempts. Deliberately tighter than the transport's
24
+ * 5s→10min ladder (`reconnectDelayMs`): that one protects a long-lived
25
+ * connection from hammering WhatsApp during an outage, whereas here a human is
26
+ * holding a phone that gives the link a limited window — a 515 restart has to
27
+ * be answered at once, and other bounces within seconds.
28
+ */
29
+ export declare function pairingReconnectDelayMs(statusCode: number | undefined, attempt: number): number;
30
+ export type PairingCloseAction = {
31
+ kind: "reconnect";
32
+ attempt: number;
33
+ delayMs: number;
34
+ } | {
35
+ kind: "fatal";
36
+ reason: "loggedOut" | "exhausted";
37
+ };
38
+ export interface PairingCloseInput {
39
+ statusCode?: number;
40
+ /** `DisconnectReason.loggedOut` from the loaded Baileys build. */
41
+ loggedOutCode: number;
42
+ /** Reconnects already spent. */
43
+ attempts: number;
44
+ maxAttempts: number;
45
+ }
46
+ /**
47
+ * Pure: what to do about a `connection: "close"` during pairing. A logout is
48
+ * final (the credentials just got revoked — retrying re-revokes them); every
49
+ * other close is transient until the attempt budget runs out.
50
+ */
51
+ export declare function decidePairingClose(input: PairingCloseInput): PairingCloseAction;
52
+ /** The slice of a Baileys socket the pairing loop uses. */
53
+ export interface PairingSocket {
54
+ ev: {
55
+ on: (event: "connection.update", listener: (update: PairingConnectionUpdate) => void) => void;
56
+ };
57
+ user?: {
58
+ id?: string;
59
+ } | null;
60
+ requestPairingCode: (phoneNumber: string) => Promise<string>;
61
+ /** Closes a superseded socket so it stops writing to the session. */
62
+ end?: (error?: Error) => void;
63
+ }
64
+ export interface PairingConnectionUpdate {
65
+ connection?: string;
66
+ qr?: string;
67
+ lastDisconnect?: {
68
+ error?: unknown;
69
+ } | null;
70
+ }
71
+ export type PairingResult = {
72
+ ok: true;
73
+ userId?: string;
74
+ } | {
75
+ ok: false;
76
+ reason: "loggedOut" | "exhausted" | "error";
77
+ message: string;
78
+ };
79
+ export interface PairingRunDeps {
80
+ /**
81
+ * Opens a socket for `attempt` (0 = first). MUST reuse the stored
82
+ * credentials — a reconnect that starts from blank creds shows a fresh QR
83
+ * instead of finishing the link the phone is waiting on.
84
+ */
85
+ connect: (attempt: number) => PairingSocket | Promise<PairingSocket>;
86
+ loggedOutCode: number;
87
+ /** Digits-only number for pairing-code mode; omit for QR mode. */
88
+ phoneNumber?: string;
89
+ maxAttempts?: number;
90
+ /** Delay before asking for a pairing code, giving the socket time to handshake. */
91
+ requestCodeDelayMs?: number;
92
+ /** Overridable for tests; defaults to `setTimeout`. */
93
+ schedule?: (fn: () => void, ms: number) => void;
94
+ /** Renders the QR (QR mode only). A throw is reported, never swallowed. */
95
+ onQr?: (qr: string) => void | Promise<void>;
96
+ onPairingCode?: (code: string) => void;
97
+ /** Progress lines for the operator (reconnect notices, QR render failures). */
98
+ onStatus?: (message: string) => void;
99
+ }
100
+ /**
101
+ * Runs one pairing to a verdict: resolves `{ ok: true }` when the connection
102
+ * reaches `open` (the link is live), or `{ ok: false }` on logout, attempt
103
+ * exhaustion, or a connect/pairing-code failure. Never rejects.
104
+ */
105
+ export declare function runPairing(deps: PairingRunDeps): Promise<PairingResult>;
106
+ //# sourceMappingURL=pairing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/pairing.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=pairing.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing.test.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/pairing.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegoaltoworks/chatter",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Embeddable AI chatbot framework with RAG, authentication, and customizable widgets",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",