levix-bot 2.3.0 → 3.0.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "levix-bot",
3
- "version": "2.3.0",
3
+ "version": "3.0.0-beta",
4
4
  "description": "A self-hosted personal WhatsApp bot with an AI agent, group moderation and a web control panel.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -1825,6 +1825,48 @@ td.wrap {
1825
1825
  .steps li::marker {
1826
1826
  color: var(--blue-bright);
1827
1827
  font-family: var(--mono);
1828
+ }
1829
+
1830
+ .pair-chooser {
1831
+ margin: 16px 0 8px;
1832
+ }
1833
+
1834
+ .pair-methods {
1835
+ display: flex;
1836
+ flex-wrap: wrap;
1837
+ gap: 12px 20px;
1838
+ margin: 8px 0 12px;
1839
+ }
1840
+
1841
+ .pair-option {
1842
+ display: flex;
1843
+ align-items: center;
1844
+ gap: 8px;
1845
+ cursor: pointer;
1846
+ }
1847
+
1848
+ .pair-phone-wrap {
1849
+ display: grid;
1850
+ gap: 6px;
1851
+ max-width: 360px;
1852
+ margin-bottom: 12px;
1853
+ }
1854
+
1855
+ .pair-phone-wrap[hidden] {
1856
+ display: none;
1857
+ }
1858
+
1859
+ .pairing-code {
1860
+ font-family: var(--mono);
1861
+ font-size: 2rem;
1862
+ letter-spacing: 0.18em;
1863
+ font-weight: 700;
1864
+ padding: 18px 24px;
1865
+ border-radius: 14px;
1866
+ background: var(--panel-raised);
1867
+ color: var(--text);
1868
+ border: 1px solid var(--line);
1869
+ }
1828
1870
  font-weight: 700;
1829
1871
  }
1830
1872
 
@@ -190,7 +190,7 @@
190
190
  const SESSION_LABELS = {
191
191
  idle: { tone: "off", label: "Not started", tag: "danger", tagText: "Not started" },
192
192
  starting: { tone: "warn", label: "Connecting", tag: "warn", tagText: "Connecting" },
193
- waiting_for_qr: { tone: "warn", label: "Waiting for scan", tag: "warn", tagText: "Waiting for scan" },
193
+ waiting_for_qr: { tone: "warn", label: "Waiting to link", tag: "warn", tagText: "Waiting to link" },
194
194
  linking: { tone: "warn", label: "Linking", tag: "warn", tagText: "Linking" },
195
195
  connected: { tone: "on", label: "Connected", tag: "ok", tagText: "Linked" },
196
196
  reconnecting: { tone: "warn", label: "Reconnecting", tag: "warn", tagText: "Reconnecting" },
@@ -284,6 +284,28 @@
284
284
  // --- connection ---------------------------------------------------------
285
285
 
286
286
  let qrRendered = null;
287
+ let pairingRendered = null;
288
+
289
+ function formatPairingCode(code) {
290
+ const raw = String(code || "").replace(/\s|-/g, "");
291
+ if (raw.length === 8) return `${raw.slice(0, 4)}-${raw.slice(4)}`;
292
+ return raw;
293
+ }
294
+
295
+ function preferredPairMethod() {
296
+ return /Android|iPhone|Mobile/i.test(navigator.userAgent) ? "pairing" : "qr";
297
+ }
298
+
299
+ function selectedPairMethod() {
300
+ const picked = document.querySelector('input[name="pair-method"]:checked');
301
+ return picked ? picked.value : preferredPairMethod();
302
+ }
303
+
304
+ function syncPairChooser() {
305
+ const pairing = selectedPairMethod() === "pairing";
306
+ const wrap = $("#pair-phone-wrap");
307
+ if (wrap) wrap.hidden = !pairing;
308
+ }
287
309
 
288
310
  function renderQr(text) {
289
311
  if (!text || qrRendered === text) return;
@@ -307,6 +329,31 @@
307
329
  $("#qr-note").textContent = "Waiting for a QR code…";
308
330
  }
309
331
 
332
+ function renderPairingCode(code, phone) {
333
+ if (!code || pairingRendered === code) return;
334
+ pairingRendered = code;
335
+ const el = $("#pairing-code");
336
+ if (el) el.textContent = formatPairingCode(code);
337
+ const phoneEl = $("#pairing-phone");
338
+ if (phoneEl) {
339
+ phoneEl.textContent = phone ? `For WhatsApp +${phone}` : "";
340
+ }
341
+ const note = $("#pairing-note");
342
+ if (note) {
343
+ note.textContent = "Link with phone number instead — not the QR camera.";
344
+ }
345
+ }
346
+
347
+ function clearPairingCode() {
348
+ pairingRendered = null;
349
+ const el = $("#pairing-code");
350
+ if (el) el.textContent = "••••-••••";
351
+ const phoneEl = $("#pairing-phone");
352
+ if (phoneEl) phoneEl.textContent = "";
353
+ const note = $("#pairing-note");
354
+ if (note) note.textContent = "Waiting for a pairing code…";
355
+ }
356
+
310
357
  function retryHint(snapshot) {
311
358
  if (snapshot.state !== "reconnecting" || !snapshot.nextRetryAt) return "";
312
359
  const seconds = Math.max(0, Math.round((snapshot.nextRetryAt - Date.now()) / 1000));
@@ -378,10 +425,21 @@
378
425
  // that can only be cleared by unlinking.
379
426
  $("#unlink-btn").disabled = !snapshot.canUnlink;
380
427
 
381
- // The QR frame is only up while a code can actually be scanned.
382
- const pairing = snapshot.state === "waiting_for_qr";
383
- $("#qr-panel").style.display = pairing ? "block" : "none";
384
- if (!pairing) clearQr();
428
+ const chooser = $("#pair-chooser");
429
+ if (chooser) chooser.style.display = snapshot.canStart ? "" : "none";
430
+
431
+ // Offer QR or pairing code only while a link attempt is actually waiting.
432
+ const waiting = snapshot.state === "waiting_for_qr";
433
+ $("#qr-panel").style.display = waiting ? "block" : "none";
434
+ const showCode = waiting && (snapshot.hasPairingCode || snapshot.pairingMethod === "pairing");
435
+ const qrBox = $("#qr-box");
436
+ const pairingBox = $("#pairing-box");
437
+ if (qrBox) qrBox.hidden = showCode;
438
+ if (pairingBox) pairingBox.hidden = !showCode;
439
+ if (!waiting) {
440
+ clearQr();
441
+ clearPairingCode();
442
+ }
385
443
  }
386
444
 
387
445
  VIEWS.connection = {
@@ -390,10 +448,13 @@
390
448
  // offer before this tab existed — a reload has to show the real backend
391
449
  // state, not an empty frame.
392
450
  load: guard(async () => {
393
- const { session, qr } = await api("/bot/session");
451
+ const { session, qr, pairingCode } = await api("/bot/session");
394
452
  setStatus(session);
395
453
  renderConnection();
396
- if (qr && session.state === "waiting_for_qr") renderQr(qr);
454
+ if (session.state === "waiting_for_qr") {
455
+ if (pairingCode) renderPairingCode(pairingCode, session.pairingPhone);
456
+ else if (qr) renderQr(qr);
457
+ }
397
458
  }),
398
459
  };
399
460
 
@@ -415,7 +476,10 @@
415
476
  // request must not leave a dead button behind.
416
477
  $("#start-btn").disabled = true;
417
478
  try {
418
- const { session } = await api("/bot/session/start", { method: "POST" });
479
+ const method = selectedPairMethod();
480
+ const body = { method };
481
+ if (method === "pairing") body.phone = ($("#pair-phone")?.value || "").trim();
482
+ const { session } = await api("/bot/session/start", { method: "POST", body });
419
483
  setStatus(session);
420
484
  toast("Session starting…", "ok");
421
485
  } finally {
@@ -451,7 +515,7 @@
451
515
  $("#unlink-btn").addEventListener(
452
516
  "click",
453
517
  confirmed(
454
- "Unlink this WhatsApp account? The bot stops answering until you start a new session and scan a QR code.",
518
+ "Unlink this WhatsApp account? The bot stops answering until you start a new session and link again.",
455
519
  async () => {
456
520
  const { session } = await api("/bot/logout", { method: "POST" });
457
521
  setStatus(session);
@@ -1287,6 +1351,16 @@
1287
1351
 
1288
1352
  socket.on("qr_cleared", () => clearQr());
1289
1353
 
1354
+ socket.on("pairing_code", (payload) => {
1355
+ const code = payload && payload.code;
1356
+ if (code) renderPairingCode(code, payload.phone);
1357
+ if (currentView !== "connection") {
1358
+ toast("A pairing code is ready — open Connection to enter it.");
1359
+ }
1360
+ });
1361
+
1362
+ socket.on("pairing_code_cleared", () => clearPairingCode());
1363
+
1290
1364
  // A socket that dropped may have missed transitions while it was away.
1291
1365
  socket.on("connect", () => {
1292
1366
  if (currentView === "connection") VIEWS.connection.load();
@@ -1297,6 +1371,15 @@
1297
1371
  initTheme();
1298
1372
  initRouting();
1299
1373
  initConnectionActions();
1374
+ {
1375
+ const def = preferredPairMethod();
1376
+ const radio = document.querySelector(`input[name="pair-method"][value="${def}"]`);
1377
+ if (radio) radio.checked = true;
1378
+ document.querySelectorAll('input[name="pair-method"]').forEach((el) => {
1379
+ el.addEventListener("change", syncPairChooser);
1380
+ });
1381
+ syncPairChooser();
1382
+ }
1300
1383
  initCommandActions();
1301
1384
  initAiActions();
1302
1385
  initGroupActions();
@@ -88,7 +88,11 @@ export async function bootstrapPanel({ core } = {}) {
88
88
  */
89
89
  export function resolveBindAddress() {
90
90
  const configured = settings.get("bind_address");
91
- return configured || "0.0.0.0";
91
+ if (configured) return configured;
92
+ // The phone is the host: keep the panel on loopback until a later phase
93
+ // opens it in a WebView. LAN bind stays an explicit setting.
94
+ if (process.env.LEVIX_ANDROID === "1") return "127.0.0.1";
95
+ return "0.0.0.0";
92
96
  }
93
97
 
94
98
  /** The address a person should type, given how this install is reachable. */
@@ -1,13 +1,16 @@
1
1
  // Decide whether the WhatsApp session should be started during process boot.
2
2
  //
3
3
  // Baileys writes a `creds` row as soon as auth state is initialized, even before
4
- // a QR is successfully paired. Only `creds.me.id` proves that this installation
5
- // was actually linked to a WhatsApp account.
4
+ // a QR is successfully paired. `requestPairingCode` also writes `creds.me.id`
5
+ // before the phone accepts the code. A finished link has `me.id` and is not
6
+ // still `registered: false`.
6
7
  export function hasPairedCredentials(rawCreds) {
7
8
  if (!rawCreds) return false;
8
9
  try {
9
10
  const creds = typeof rawCreds === "string" ? JSON.parse(rawCreds) : rawCreds;
10
- return Boolean(creds?.me?.id);
11
+ if (!creds?.me?.id) return false;
12
+ if (creds.registered === false) return false;
13
+ return true;
11
14
  } catch {
12
15
  return false;
13
16
  }
@@ -22,8 +22,13 @@ export function setRecentMessageGetter(fn) {
22
22
  getRecentMessage = fn;
23
23
  }
24
24
 
25
- export const getBaileysConfig = (cachedGroupMetadata) => ({
26
- browser: Browsers.windows("Desktop"),
25
+ // QR pairing is happy with a Windows desktop companion. Pairing *codes*
26
+ // are not: WhatsApp rejects them unless the companion looks like Chrome.
27
+ export const BAILEYS_BROWSER_QR = Browsers.windows("Desktop");
28
+ export const BAILEYS_BROWSER_PAIRING = Browsers.macOS("Chrome");
29
+
30
+ export const getBaileysConfig = (cachedGroupMetadata, { pairingCode = false } = {}) => ({
31
+ browser: pairingCode ? BAILEYS_BROWSER_PAIRING : BAILEYS_BROWSER_QR,
27
32
  markOnlineOnConnect: false,
28
33
  logger: pino({ level: "silent" }),
29
34
  cachedGroupMetadata,
@@ -78,6 +78,45 @@ function withTimeout(promise, ms) {
78
78
  ]);
79
79
  }
80
80
 
81
+ const ARABIC_INDIC = /[\u0660-\u0669]/g;
82
+ const EASTERN_ARABIC = /[\u06F0-\u06F9]/g;
83
+
84
+ /** Digits-only international number for Baileys `requestPairingCode`. */
85
+ export function normalizePairingPhone(input) {
86
+ let value = String(input ?? "");
87
+ value = value.replace(ARABIC_INDIC, (ch) => String(ch.charCodeAt(0) - 0x0660));
88
+ value = value.replace(EASTERN_ARABIC, (ch) => String(ch.charCodeAt(0) - 0x06f0));
89
+ const digits = value.replace(/\D/g, "");
90
+ if (digits.startsWith("0")) {
91
+ const error = new Error(
92
+ "Drop the leading 0 and include the country code (e.g. 2010… not 010…)."
93
+ );
94
+ error.code = "PAIRING_PHONE";
95
+ throw error;
96
+ }
97
+ if (digits.length < 8 || digits.length > 15) {
98
+ const error = new Error(
99
+ "Enter the WhatsApp number with country code, digits only (e.g. 2010…)."
100
+ );
101
+ error.code = "PAIRING_PHONE";
102
+ throw error;
103
+ }
104
+ return digits;
105
+ }
106
+
107
+ /** How this start should pair, if the install is not linked yet. */
108
+ export function parseStartOptions({ method, phone } = {}) {
109
+ if (method == null || method === "" || method === "qr") {
110
+ return { method: "qr", phone: null };
111
+ }
112
+ if (method === "pairing") {
113
+ return { method: "pairing", phone: normalizePairingPhone(phone) };
114
+ }
115
+ const error = new Error("Choose QR code or pairing code.");
116
+ error.code = "PAIRING_PHONE";
117
+ throw error;
118
+ }
119
+
81
120
  export const SESSION_STATES = Object.freeze({
82
121
  IDLE: "idle",
83
122
  STARTING: "starting",
@@ -150,6 +189,9 @@ export class WhatsAppSession {
150
189
  #proxyConfig = null;
151
190
 
152
191
  #qr = null;
192
+ #pairingCode = null;
193
+ #pairingIntent = { method: "qr", phone: null };
194
+ #pairingCodeRequest = null;
153
195
  #reason = null;
154
196
  #detail = null;
155
197
  #lastDisconnect = null;
@@ -173,6 +215,7 @@ export class WhatsAppSession {
173
215
  emit = () => {},
174
216
  retryDelaysMs = RETRY_SCHEDULE_MS,
175
217
  log = logger,
218
+ pairingCodeDelayMs = 1500,
176
219
  } = {}) {
177
220
  this.createSocket = createSocket;
178
221
  this.attachListeners = attachListeners;
@@ -185,6 +228,7 @@ export class WhatsAppSession {
185
228
  this.clearCredentials = clearCredentials;
186
229
  this.retryDelaysMs = retryDelaysMs;
187
230
  this.log = log;
231
+ this.pairingCodeDelayMs = pairingCodeDelayMs;
188
232
  }
189
233
 
190
234
  // -------------------------------------------------------------------------
@@ -212,6 +256,13 @@ export class WhatsAppSession {
212
256
  return this.#qr;
213
257
  }
214
258
 
259
+ /**
260
+ * The 8-character pairing code, or null. Same rule as `qr`: not in getState().
261
+ */
262
+ get pairingCode() {
263
+ return this.#pairingCode;
264
+ }
265
+
215
266
  /** Everything the dashboard needs to render the Connection screen. */
216
267
  getState() {
217
268
  const terminal =
@@ -241,6 +292,10 @@ export class WhatsAppSession {
241
292
  connected: this.#state === S.CONNECTED,
242
293
  terminal,
243
294
  hasQr: !!this.#qr,
295
+ hasPairingCode: !!this.#pairingCode,
296
+ pairingMethod: this.#pairingIntent?.method || "qr",
297
+ pairingPhone:
298
+ this.#pairingIntent?.method === "pairing" ? this.#pairingIntent.phone : null,
244
299
  reason: this.#reason,
245
300
  detail: this.#detail,
246
301
  lastDisconnect: this.#lastDisconnect,
@@ -258,7 +313,7 @@ export class WhatsAppSession {
258
313
  * Bring the session up. Idempotent: if a socket exists or is being made, this
259
314
  * returns the state it is already in and creates nothing.
260
315
  */
261
- async start({ reason = "manual" } = {}) {
316
+ async start({ reason = "manual", method, phone } = {}) {
262
317
  if (this.#shuttingDown) return this.getState();
263
318
 
264
319
  if (this.#starting) {
@@ -267,6 +322,8 @@ export class WhatsAppSession {
267
322
  }
268
323
  if (BUSY_STATES.has(this.#state)) return this.getState();
269
324
 
325
+ this.#pairingIntent = parseStartOptions({ method, phone });
326
+
270
327
  // A manual start clears whatever the last failure left behind.
271
328
  this.#cancelRetry();
272
329
  this.#attempt = 0;
@@ -433,7 +490,10 @@ export class WhatsAppSession {
433
490
 
434
491
  let created;
435
492
  try {
436
- created = await this.createSocket({ proxy });
493
+ created = await this.createSocket({
494
+ proxy,
495
+ pairingCode: this.#pairingIntent.method === "pairing",
496
+ });
437
497
  } catch (error) {
438
498
  this.log.error({ err: error }, "[Session] failed to create the WhatsApp socket");
439
499
  this.#transition(S.ERROR, {
@@ -473,9 +533,15 @@ export class WhatsAppSession {
473
533
  this.#onHandshakeRejected(generation, sock, response),
474
534
  });
475
535
 
536
+ const waitingForCode =
537
+ this.#pairing && this.#pairingIntent.method === "pairing";
476
538
  this.#transition(this.#pairing ? S.WAITING_FOR_QR : S.STARTING, {
477
539
  reason,
478
- detail: this.#pairing ? "Waiting for a QR code" : "Connecting to WhatsApp",
540
+ detail: this.#pairing
541
+ ? waitingForCode
542
+ ? "Waiting for a pairing code…"
543
+ : "Waiting for a QR code"
544
+ : "Connecting to WhatsApp",
479
545
  });
480
546
 
481
547
  this.log.info("[Session] WhatsApp socket created");
@@ -580,7 +646,7 @@ export class WhatsAppSession {
580
646
 
581
647
  const { connection, lastDisconnect, qr, isNewLogin } = update || {};
582
648
 
583
- if (qr) this.#onQr(qr);
649
+ if (qr) await this.#onQr(qr);
584
650
 
585
651
  // The phone scanned the code. Pairing succeeded even though the connection
586
652
  // is about to be torn down and restarted — this is WhatsApp's own login
@@ -598,7 +664,18 @@ export class WhatsAppSession {
598
664
  else if (connection === "close") await this.#onClose(lastDisconnect);
599
665
  }
600
666
 
601
- #onQr(qr) {
667
+ async #onQr(qr) {
668
+ // Pair-device readiness. In pairing-code mode the QR must not be shown;
669
+ // requestPairingCode waits for this stanza (or queues until it arrives).
670
+ if (
671
+ this.#pairing &&
672
+ this.#pairingIntent.method === "pairing" &&
673
+ this.#pairingIntent.phone
674
+ ) {
675
+ await this.#requestPairingCode();
676
+ return;
677
+ }
678
+
602
679
  this.#qr = qr;
603
680
  try {
604
681
  saveQrCode(qr);
@@ -612,6 +689,56 @@ export class WhatsAppSession {
612
689
  this.emitEvent("qr", qr);
613
690
  }
614
691
 
692
+ async #requestPairingCode() {
693
+ if (this.#pairingCode || this.#pairingCodeRequest) return;
694
+ const sock = this.#socket;
695
+ const phone = this.#pairingIntent.phone;
696
+ if (!phone) return;
697
+ if (typeof sock?.requestPairingCode !== "function") {
698
+ this.log.error("[Session] this socket cannot request a pairing code");
699
+ this.#transition(S.ERROR, {
700
+ reason: "pairing_unsupported",
701
+ detail: "Pairing code is not available on this connection. Use QR instead.",
702
+ });
703
+ return;
704
+ }
705
+
706
+ this.#pairingCodeRequest = (async () => {
707
+ try {
708
+ if (this.pairingCodeDelayMs > 0) {
709
+ await new Promise((resolve) => setTimeout(resolve, this.pairingCodeDelayMs));
710
+ }
711
+ if (!this.#pairing || this.#shuttingDown) return;
712
+ const raw = await sock.requestPairingCode(phone);
713
+ if (!this.#pairing || this.#shuttingDown) return;
714
+ this.#pairingCode = String(raw || "").replace(/\s|-/g, "");
715
+ this.#transition(S.WAITING_FOR_QR, {
716
+ reason: "pairing_code",
717
+ detail:
718
+ "Enter the code in WhatsApp → Linked devices → Link with phone number.",
719
+ });
720
+ this.emitEvent("pairing_code", {
721
+ code: this.#pairingCode,
722
+ phone,
723
+ });
724
+ } catch (error) {
725
+ this.log.error(
726
+ { err: error?.message },
727
+ "[Session] pairing code request failed"
728
+ );
729
+ if (!this.#pairing || this.#shuttingDown) return;
730
+ this.#transition(S.ERROR, {
731
+ reason: "pairing_failed",
732
+ detail: "Could not get a pairing code. Check the number and try again.",
733
+ });
734
+ } finally {
735
+ this.#pairingCodeRequest = null;
736
+ }
737
+ })();
738
+
739
+ await this.#pairingCodeRequest;
740
+ }
741
+
615
742
  async #onOpen() {
616
743
  this.#attempt = 0;
617
744
  this.#nextRetryAt = null;
@@ -797,14 +924,17 @@ export class WhatsAppSession {
797
924
  * GET /qr would happily serve it to somebody.
798
925
  */
799
926
  #clearQr() {
800
- const had = this.#qr !== null;
927
+ const hadQr = this.#qr !== null;
928
+ const hadCode = this.#pairingCode !== null;
801
929
  this.#qr = null;
930
+ this.#pairingCode = null;
802
931
  try {
803
932
  deleteQrCode();
804
933
  } catch (error) {
805
934
  this.log.debug({ err: error?.message }, "[Session] failed to delete the stored QR");
806
935
  }
807
- if (had) this.emitEvent("qr_cleared", {});
936
+ if (hadQr) this.emitEvent("qr_cleared", {});
937
+ if (hadCode) this.emitEvent("pairing_code_cleared", {});
808
938
  }
809
939
 
810
940
  #transition(state, { reason = null, detail = null } = {}) {
@@ -54,7 +54,7 @@ function withThumbnailSupport(sock) {
54
54
  * rather than read here, so this factory stays a pure function of its
55
55
  * arguments and a test can hand it a fake.
56
56
  */
57
- export async function createWhatsAppSocket({ proxy = null } = {}) {
57
+ export async function createWhatsAppSocket({ proxy = null, pairingCode = false } = {}) {
58
58
  logger.info(
59
59
  proxy
60
60
  ? `[Socket] Initializing WhatsApp socket through ${proxy.label}`
@@ -64,16 +64,15 @@ export async function createWhatsAppSocket({ proxy = null } = {}) {
64
64
  const { state, saveCreds, clearAll } = await useDatabaseAuthState();
65
65
 
66
66
  // Whether this install is already paired. NOT `store.hasCredentials()`: the
67
- // auth state writes a `creds` row on its very first call, paired or not, so
68
- // that row proves nothing. `creds.me.id` is only filled in by a successful
69
- // pairing, which is exactly the question the session manager is asking —
70
- // it decides whether a close is a failed pairing attempt or a dropped
71
- // connection worth retrying.
72
- const isPaired = !!state?.creds?.me?.id;
67
+ // auth state writes a `creds` row on its very first call, paired or not.
68
+ // `requestPairingCode` also writes `creds.me.id` *before* the phone accepts
69
+ // the code, so `me.id` alone is not proof. `registered === false` means the
70
+ // handshake never finished.
71
+ const isPaired = !!state?.creds?.me?.id && state?.creds?.registered !== false;
73
72
 
74
73
  const cachedGroupMetadata = (jid) => groupMetadataCache.get(jid);
75
74
 
76
- const config = getBaileysConfig(cachedGroupMetadata);
75
+ const config = getBaileysConfig(cachedGroupMetadata, { pairingCode });
77
76
 
78
77
  // Three separate hooks, because Baileys has three separate network paths:
79
78
  // agent -> the `ws` WebSocket (https.request, classic agent)
package/src/index.js CHANGED
@@ -185,6 +185,10 @@ async function startWithPanel({ open }) {
185
185
  // the Connection screen offers a Start button.
186
186
  const core = await bootstrapCore({ autoStart: false });
187
187
  liveSession = core.session;
188
+ if (process.env.LEVIX_ANDROID === "1") {
189
+ console.log("Database ready");
190
+ console.log("Commands loaded " + core.commandCount);
191
+ }
188
192
 
189
193
  let panel;
190
194
  try {
@@ -202,6 +206,10 @@ async function startWithPanel({ open }) {
202
206
  const url = panelUrl({ port: panel.port, firstRun });
203
207
 
204
208
  logger.info(`✅ Control panel: ${url}`);
209
+ if (process.env.LEVIX_ANDROID === "1") {
210
+ console.log("Panel listening " + url);
211
+ console.log("Levix ready");
212
+ }
205
213
 
206
214
  line();
207
215
  line(` ${brand.name} is running`);
@@ -850,7 +850,12 @@ router.post("/security/password", (req, res) => {
850
850
  // pairing was already waiting showed an empty frame until Baileys happened to
851
851
  // issue the next one.
852
852
  router.get("/bot/session", (req, res) => {
853
- res.json({ success: true, session: sessionState(), qr: session?.qr ?? null });
853
+ res.json({
854
+ success: true,
855
+ session: sessionState(),
856
+ qr: session?.qr ?? null,
857
+ pairingCode: session?.pairingCode ?? null,
858
+ });
854
859
  });
855
860
 
856
861
  // Idempotent by construction: the manager returns the state it is already in
@@ -862,7 +867,17 @@ router.post(
862
867
  if (!session) {
863
868
  return res.status(503).json({ success: false, error: "Session manager is not ready" });
864
869
  }
865
- const state = await session.start({ reason: "dashboard" });
870
+ let state;
871
+ try {
872
+ state = await session.start({
873
+ reason: "dashboard",
874
+ method: req.body?.method,
875
+ phone: req.body?.phone,
876
+ });
877
+ } catch (error) {
878
+ if (error.code === "PAIRING_PHONE") return badRequest(res, error.message);
879
+ throw error;
880
+ }
866
881
  logger.info(`[Dashboard] Start session requested — now ${state.state}`);
867
882
  res.json({ success: true, session: state });
868
883
  })
@@ -19,6 +19,12 @@ function isPackaged() {
19
19
  }
20
20
  }
21
21
 
22
+ // Worker-thread transports load targets by module path. That fails inside a
23
+ // SEA, and Android's Bionic Node is happier with in-process streams too.
24
+ function useInProcessLogs() {
25
+ return isPackaged() || process.env.LEVIX_ANDROID === "1";
26
+ }
27
+
22
28
  const base = {
23
29
  level: "trace",
24
30
  serializers: {
@@ -38,7 +44,7 @@ const PRETTY = {
38
44
  const combined = path.join(logDir, "combined.log");
39
45
  const errors = path.join(logDir, "error.log");
40
46
 
41
- const logger = isPackaged()
47
+ const logger = useInProcessLogs()
42
48
  ? pino(
43
49
  base,
44
50
  pino.multistream([
@@ -319,6 +319,31 @@
319
319
 
320
320
  <div class="hint" id="conn-proxy"></div>
321
321
 
322
+ <div id="pair-chooser" class="pair-chooser">
323
+ <p class="hint">How should this phone link?</p>
324
+ <div class="pair-methods">
325
+ <label class="pair-option">
326
+ <input type="radio" name="pair-method" value="qr" />
327
+ QR code
328
+ </label>
329
+ <label class="pair-option">
330
+ <input type="radio" name="pair-method" value="pairing" />
331
+ Pairing code
332
+ </label>
333
+ </div>
334
+ <div id="pair-phone-wrap" class="pair-phone-wrap" hidden>
335
+ <label for="pair-phone">WhatsApp number with country code</label>
336
+ <input
337
+ id="pair-phone"
338
+ type="tel"
339
+ inputmode="numeric"
340
+ autocomplete="tel"
341
+ placeholder="2010…"
342
+ />
343
+ <p class="hint">This WhatsApp’s own number. Country code, no leading 0 (Egypt: 2010… not 010…).</p>
344
+ </div>
345
+ </div>
346
+
322
347
  <div class="inline action-cluster">
323
348
  <button class="btn btn-primary" id="start-btn" type="button">
324
349
  Start session
@@ -347,15 +372,25 @@
347
372
  </div>
348
373
 
349
374
  <div id="qr-panel" style="display: none">
350
- <div class="qr-box">
375
+ <div class="qr-box" id="qr-box">
351
376
  <div class="qr-frame" id="qr-frame"></div>
352
377
  <div class="hint" id="qr-note">Waiting for a QR code…</div>
353
- <ol class="steps">
378
+ <ol class="steps" id="qr-steps">
354
379
  <li>Open WhatsApp on the phone the bot should use.</li>
355
380
  <li>Settings → Linked devices → Link a device.</li>
356
381
  <li>Point the camera at the code above.</li>
357
382
  </ol>
358
383
  </div>
384
+ <div class="qr-box" id="pairing-box" hidden>
385
+ <div class="pairing-code" id="pairing-code">••••-••••</div>
386
+ <div class="hint" id="pairing-phone"></div>
387
+ <div class="hint" id="pairing-note">Waiting for a pairing code…</div>
388
+ <ol class="steps">
389
+ <li>Open WhatsApp on that number → Settings → Linked devices.</li>
390
+ <li>Link a device → Link with phone number instead.</li>
391
+ <li>Type the code above. Get a new code if it fails.</li>
392
+ </ol>
393
+ </div>
359
394
  </div>
360
395
  </div>
361
396
  </section>