diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index b8a3f430a444..abe0a7dcb66a 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -16,6 +16,8 @@ * * Usage: * node bridge.js --port 3000 --session ~/.hermes/whatsapp/session + * node bridge.js --pair-only + * node bridge.js --pair-with-number "+15551234567" */ import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from '@whiskeysockets/baileys'; @@ -27,6 +29,11 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from import { randomBytes } from 'crypto'; import qrcode from 'qrcode-terminal'; import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; +import { + formatPairingCodeForDisplay, + parsePairWithNumberArg, + validateE164ForPairing, +} from './pairing-args.js'; import { createOutboundIdTracker } from './outbound_ids.js'; import { classifyOwnerMessageGate } from './owner_message_gate.js'; @@ -65,7 +72,31 @@ const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.herme const IMAGE_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'image_cache'); const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'document_cache'); const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache'); -const PAIR_ONLY = args.includes('--pair-only'); +const PAIR_ONLY_QR = args.includes('--pair-only'); +const pairWithNumberParse = parsePairWithNumberArg(process.argv); +if (pairWithNumberParse.error) { + console.error(pairWithNumberParse.error); + process.exit(1); +} +const PAIR_PHONE_MODE = Boolean(pairWithNumberParse.found && pairWithNumberParse.value); +/** Digits-only phone for Baileys (no '+'); see pairing-args.js */ +let BAILEYS_PAIR_PHONE_DIGITS = ''; +if (PAIR_PHONE_MODE) { + const v = validateE164ForPairing(pairWithNumberParse.value); + if (!v.ok) { + console.error(v.error); + process.exit(1); + } + BAILEYS_PAIR_PHONE_DIGITS = v.digits; +} + +if (PAIR_PHONE_MODE && PAIR_ONLY_QR) { + console.warn( + 'Warning: --pair-only (QR pairing) is ignored when --pair-with-number is provided.', + ); +} + +const PAIR_EXIT_MODE = PAIR_ONLY_QR || PAIR_PHONE_MODE; const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat" const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || ''); const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n'; @@ -148,6 +179,9 @@ function rememberSentId(id) { let sock = null; let connectionState = 'disconnected'; +/** Ensures pairing-code request runs at most once across reconnect attempts. */ +let pairPhoneCredentialRequestStarted = false; + async function startSocket() { const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); const { version } = await fetchLatestBaileysVersion(); @@ -174,7 +208,7 @@ async function startSocket() { sock.ev.on('connection.update', (update) => { const { connection, lastDisconnect, qr } = update; - if (qr) { + if (qr && !PAIR_PHONE_MODE) { console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n'); qrcode.generate(qr, { small: true }); console.log('\nWaiting for scan...\n'); @@ -199,14 +233,50 @@ async function startSocket() { } else if (connection === 'open') { connectionState = 'connected'; console.log('✅ WhatsApp connected!'); - if (PAIR_ONLY) { - console.log('✅ Pairing complete. Credentials saved.'); + if (PAIR_EXIT_MODE) { + if (PAIR_PHONE_MODE) { + console.log('✓ Paired successfully. Auth saved.'); + } else { + console.log('✅ Pairing complete. Credentials saved.'); + } // Give Baileys a moment to flush creds, then exit cleanly setTimeout(() => process.exit(0), 2000); } } }); + if (PAIR_PHONE_MODE && !pairPhoneCredentialRequestStarted) { + pairPhoneCredentialRequestStarted = true; + (async () => { + try { + if (typeof sock.requestPairingCode !== 'function') { + console.error( + '[bridge] Baileys does not expose sock.requestPairingCode(). Upgrade @whiskeysockets/baileys to a release that supports phone-number pairing (6.x+).', + ); + process.exit(1); + } + await sock.waitForSocketOpen(); + if (sock.authState.creds.registered) { + return; + } + const rawCode = await sock.requestPairingCode(BAILEYS_PAIR_PHONE_DIGITS); + const display = formatPairingCodeForDisplay(rawCode); + console.log(`\nPairing code: ${display}\n`); + console.log(`On the customer's phone:`); + console.log(` 1. Open WhatsApp`); + console.log(` 2. Settings → Linked Devices → Link a Device → "Link with phone number instead"`); + console.log(` 3. Enter: ${display}\n`); + console.log('Waiting for pairing to complete...\n'); + } catch (err) { + const msg = err?.message ?? String(err); + console.error( + `[bridge] Failed to request WhatsApp pairing code (check network coverage and phone number): ${msg}`, + ); + process.exit(1); + } + })(); + } + sock.ev.on('messages.upsert', async ({ messages, type }) => { // In self-chat mode, your own messages commonly arrive as 'append' rather // than 'notify'. Accept both and filter agent echo-backs below. @@ -633,9 +703,13 @@ app.get('/health', (req, res) => { }); // Start -if (PAIR_ONLY) { - // Pair-only mode: just connect, show QR, save creds, exit. No HTTP server. - console.log('📱 WhatsApp pairing mode'); +if (PAIR_EXIT_MODE) { + // Pair-only mode: QR or phone pairing code — no HTTP server, exit once connected. + console.log( + PAIR_PHONE_MODE + ? '📱 WhatsApp pairing mode (pairing code / phone number)' + : '📱 WhatsApp pairing mode (QR code)', + ); console.log(`📁 Session: ${SESSION_DIR}`); console.log(); startSocket(); diff --git a/scripts/whatsapp-bridge/package.json b/scripts/whatsapp-bridge/package.json index cb2f6b22ede7..9687393aa92b 100644 --- a/scripts/whatsapp-bridge/package.json +++ b/scripts/whatsapp-bridge/package.json @@ -5,7 +5,8 @@ "private": true, "type": "module", "scripts": { - "start": "node bridge.js" + "start": "node bridge.js", + "test": "node --test ./*.test.mjs" }, "dependencies": { "@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770", diff --git a/scripts/whatsapp-bridge/pairing-args.js b/scripts/whatsapp-bridge/pairing-args.js new file mode 100644 index 000000000000..fe870625bcdf --- /dev/null +++ b/scripts/whatsapp-bridge/pairing-args.js @@ -0,0 +1,46 @@ +/** E.164-like phone number for pairing: country code plus 10–15 digits total. */ +export const E164_PAIRING_REGEX = /^\+?[1-9]\d{9,14}$/; + +/** + * @param {string} raw - CLI value passed to `--pair-with-number` + * @returns {{ ok: true, digits: string } | { ok: false, error: string }} + */ +export function validateE164ForPairing(raw) { + const s = String(raw ?? '').trim(); + if (!E164_PAIRING_REGEX.test(s)) { + return { + ok: false, + error: 'Invalid phone number. Use E.164 (e.g. +15551234567): country code, digits only besides an optional leading +.', + }; + } + const digits = s.startsWith('+') ? s.slice(1) : s; + return { ok: true, digits }; +} + +/** + * @param {string} code - Raw pairing code from Baileys (typically 8 Crockford chars) + */ +export function formatPairingCodeForDisplay(code) { + const c = String(code || '').replace(/\s/g, '').toUpperCase(); + if (c.length === 8) return `${c.slice(0, 4)}-${c.slice(4)}`; + return c; +} + +/** + * @param {string[]} argv - e.g. process.argv + */ +export function parsePairWithNumberArg(argv) { + const args = argv.slice(2); + const idx = args.indexOf('--pair-with-number'); + if (idx === -1) return { found: false, value: null }; + const v = args[idx + 1]; + if (!v || v.startsWith('--')) { + return { + found: true, + value: null, + error: + '--pair-with-number requires a phone number argument (E.164, e.g. +15551234567)', + }; + } + return { found: true, value: v }; +} diff --git a/scripts/whatsapp-bridge/pairing-args.test.mjs b/scripts/whatsapp-bridge/pairing-args.test.mjs new file mode 100644 index 000000000000..af1c75b8af62 --- /dev/null +++ b/scripts/whatsapp-bridge/pairing-args.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatPairingCodeForDisplay, + parsePairWithNumberArg, + validateE164ForPairing, +} from './pairing-args.js'; + +test('validateE164ForPairing accepts E.164 with +', () => { + assert.deepEqual(validateE164ForPairing('+15551234567'), { ok: true, digits: '15551234567' }); +}); + +test('validateE164ForPairing accepts E.164 without +', () => { + assert.deepEqual(validateE164ForPairing('15551234567'), { ok: true, digits: '15551234567' }); +}); + +test('validateE164ForPairing accepts UK-format E.164 with +', () => { + assert.deepEqual(validateE164ForPairing('+447911123456'), { ok: true, digits: '447911123456' }); +}); + +test('validateE164ForPairing rejects leading zero', () => { + assert.equal(validateE164ForPairing('+015551234567').ok, false); +}); + +test('validateE164ForPairing rejects spaces', () => { + assert.equal(validateE164ForPairing('+1 555 123 4567').ok, false); +}); + +test('validateE164ForPairing rejects hyphenated numbers', () => { + assert.equal(validateE164ForPairing('+1-555-1234567').ok, false); +}); + +test('validateE164ForPairing rejects too-short numbers', () => { + assert.equal(validateE164ForPairing('+155512345').ok, false); +}); + +test('formatPairingCodeForDisplay hyphenates length-8 codes', () => { + assert.equal(formatPairingCodeForDisplay('abcdefgh'), 'ABCD-EFGH'); +}); + +test('formatPairingCodeForDisplay leaves non-8 length unchanged', () => { + assert.equal(formatPairingCodeForDisplay('abc'), 'ABC'); +}); + +test('parsePairWithNumberArg returns found false when absent', () => { + assert.deepEqual(parsePairWithNumberArg(['node', 'bridge.js']), { found: false, value: null }); +}); + +test('parsePairWithNumberArg parses value', () => { + assert.deepEqual( + parsePairWithNumberArg(['node', 'bridge.js', '--pair-with-number', '+15551234567']), + { + found: true, + value: '+15551234567', + }, + ); +}); + +test('parsePairWithNumberArg errors when value missing', () => { + const r = parsePairWithNumberArg(['node', 'bridge.js', '--pair-with-number']); + assert.equal(r.found, true); + assert.ok(r.error); +}); + +test('parsePairWithNumberArg errors when value is empty', () => { + const r = parsePairWithNumberArg(['node', 'bridge.js', '--pair-with-number', '']); + assert.equal(r.found, true); + assert.ok(r.error); +});