Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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');
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion scripts/whatsapp-bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions scripts/whatsapp-bridge/pairing-args.js
Original file line number Diff line number Diff line change
@@ -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 };
}
69 changes: 69 additions & 0 deletions scripts/whatsapp-bridge/pairing-args.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading