From acdc6454db850017f1033995c03f33f06037baee Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Thu, 12 Feb 2026 22:49:04 +0200 Subject: [PATCH] fix: WhatsApp auth improvements and LID translation for DMs - Add pairing code auth with 515 reconnect handling (Baileys stream error after pairing is now retried instead of failing) - Use Browsers.macOS('Chrome') identifier for WhatsApp compatibility - Fix LID-to-phone translation for DMs using signalRepository.getPNForLID - Strip device suffix (:0) from resolved phone JIDs - Update setup skill with three auth options (browser QR, pairing code, terminal QR), DM channel type, and LID troubleshooting Co-Authored-By: Claude Opus 4.6 --- .claude/skills/setup/SKILL.md | 153 ++++++++++++++++++++++++++---- .claude/skills/setup/qr-auth.html | 32 +++++++ src/channels/whatsapp.ts | 33 +++++-- src/whatsapp-auth.ts | 77 ++++++++++++++- 4 files changed, 265 insertions(+), 30 deletions(-) create mode 100644 .claude/skills/setup/qr-auth.html diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 63c2dd0..3ac99b2 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -5,7 +5,7 @@ description: Run initial NanoClaw setup. Use when user wants to install dependen # NanoClaw Setup -Run all commands automatically. Only pause when user action is required (scanning QR codes). +Run all commands automatically. Only pause when user action is required (WhatsApp authentication, configuration choices). **UX Note:** When asking the user questions, prefer using the `AskUserQuestion` tool instead of just outputting text. This integrates with Claude's built-in question/answer system for a better experience. @@ -89,7 +89,7 @@ Tell the user: > 1. Paste it here and I'll add it to `.env` for you, or > 2. Add it to `.env` yourself as `CLAUDE_CODE_OAUTH_TOKEN=` -If they give you the token, add it to `.env`: +If they give you the token, add it to `.env`. **Never echo the full token in commands or output** — use the Write tool to write the `.env` file directly, or tell the user to add it themselves: ```bash echo "CLAUDE_CODE_OAUTH_TOKEN=" > .env @@ -114,7 +114,7 @@ Tell the user to add their key from https://console.anthropic.com/ **Verify:** ```bash KEY=$(grep "^ANTHROPIC_API_KEY=" .env | cut -d= -f2) -[ -n "$KEY" ] && echo "API key configured: ${KEY:0:10}...${KEY: -4}" || echo "Missing" +[ -n "$KEY" ] && echo "API key configured: ${KEY:0:7}..." || echo "Missing" ``` ## 4. Build Container Image @@ -141,23 +141,131 @@ fi **USER ACTION REQUIRED** -**IMPORTANT:** Run this command in the **foreground**. The QR code is multi-line ASCII art that must be displayed in full. Do NOT run in background or truncate the output. +The auth script supports two methods: QR code scanning and pairing code (phone number). Ask the user which they prefer. -Tell the user: -> A QR code will appear below. On your phone: -> 1. Open WhatsApp -> 2. Tap **Settings → Linked Devices → Link a Device** -> 3. Scan the QR code +The auth script writes status to `store/auth-status.txt`: +- `already_authenticated` — credentials already exist +- `pairing_code:` — pairing code generated, waiting for user to enter it +- `authenticated` — successfully authenticated +- `failed:` — authentication failed -Run with a long Bash tool timeout (120000ms) so the user has time to scan. Do NOT use the `timeout` shell command (it's not available on macOS). +The script automatically handles error 515 (stream error after pairing) by reconnecting — this is normal and expected during pairing code auth. + +### Ask the user which method to use + +> How would you like to authenticate WhatsApp? +> +> 1. **QR code in browser** (Recommended) — Opens a page with the QR code to scan +> 2. **Pairing code** — Enter a numeric code on your phone, no camera needed +> 3. **QR code in terminal** — Run the auth command yourself in another terminal + +### Option A: QR Code in Browser (Recommended) + +Clean any stale auth state and start auth in background: ```bash +rm -rf store/auth store/qr-data.txt store/auth-status.txt npm run auth ``` -Wait for the script to output "Successfully authenticated" then continue. +Run this with `run_in_background: true`. -If it says "Already authenticated", skip to the next step. +Poll for QR data (up to 15 seconds): + +```bash +for i in $(seq 1 15); do if [ -f store/qr-data.txt ]; then echo "qr_ready"; exit 0; fi; STATUS=$(cat store/auth-status.txt 2>/dev/null || echo "waiting"); if [ "$STATUS" = "already_authenticated" ]; then echo "$STATUS"; exit 0; fi; sleep 1; done; echo "timeout" +``` + +If `already_authenticated`, skip to the next step. + +If QR data is ready, generate the QR as SVG and inject it into the HTML template: + +```bash +node -e " +const QR = require('qrcode'); +const fs = require('fs'); +const qrData = fs.readFileSync('store/qr-data.txt', 'utf8'); +QR.toString(qrData, { type: 'svg' }, (err, svg) => { + if (err) process.exit(1); + const template = fs.readFileSync('.claude/skills/setup/qr-auth.html', 'utf8'); + fs.writeFileSync('store/qr-auth.html', template.replace('{{QR_SVG}}', svg)); + console.log('done'); +}); +" +``` + +Then open it: + +```bash +open store/qr-auth.html +``` + +Tell the user: +> A browser window should have opened with the QR code. It expires in about 60 seconds. +> +> Scan it with WhatsApp: **Settings → Linked Devices → Link a Device** + +Then poll for completion (up to 120 seconds): + +```bash +for i in $(seq 1 60); do STATUS=$(cat store/auth-status.txt 2>/dev/null || echo "waiting"); if [ "$STATUS" = "authenticated" ] || [ "$STATUS" = "already_authenticated" ]; then echo "$STATUS"; exit 0; elif echo "$STATUS" | grep -q "^failed:"; then echo "$STATUS"; exit 0; fi; sleep 2; done; echo "timeout" +``` + +- If `authenticated`, success — clean up with `rm -f store/qr-auth.html` and continue. +- If `failed:qr_timeout`, offer to retry (re-run the auth and regenerate the HTML page). +- If `failed:logged_out`, delete `store/auth/` and retry. + +### Option B: Pairing Code + +Ask the user for their phone number (with country code, no + or spaces, e.g. `14155551234`). + +Clean any stale auth state and start: + +```bash +rm -rf store/auth store/qr-data.txt store/auth-status.txt +npx tsx src/whatsapp-auth.ts --pairing-code --phone PHONE_NUMBER +``` + +Run this with `run_in_background: true`. + +Poll for the pairing code (up to 15 seconds): + +```bash +for i in $(seq 1 15); do STATUS=$(cat store/auth-status.txt 2>/dev/null || echo "waiting"); if echo "$STATUS" | grep -q "^pairing_code:"; then echo "$STATUS"; exit 0; elif [ "$STATUS" = "authenticated" ] || [ "$STATUS" = "already_authenticated" ]; then echo "$STATUS"; exit 0; elif echo "$STATUS" | grep -q "^failed:"; then echo "$STATUS"; exit 0; fi; sleep 1; done; echo "timeout" +``` + +Extract the code from the status (e.g. `pairing_code:ABC12DEF` → `ABC12DEF`) and tell the user: + +> Your pairing code: **CODE_HERE** +> +> 1. Open WhatsApp on your phone +> 2. Tap **Settings → Linked Devices → Link a Device** +> 3. Tap **"Link with phone number instead"** +> 4. Enter the code: **CODE_HERE** + +Then poll for completion (up to 120 seconds): + +```bash +for i in $(seq 1 60); do STATUS=$(cat store/auth-status.txt 2>/dev/null || echo "waiting"); if [ "$STATUS" = "authenticated" ] || [ "$STATUS" = "already_authenticated" ]; then echo "$STATUS"; exit 0; elif echo "$STATUS" | grep -q "^failed:"; then echo "$STATUS"; exit 0; fi; sleep 2; done; echo "timeout" +``` + +- If `authenticated` or `already_authenticated`, success — continue to next step. +- If `failed:logged_out`, delete `store/auth/` and retry. +- If `failed:515` or timeout, the 515 reconnect should handle this automatically. If it persists, the user may need to temporarily stop other WhatsApp-connected apps on the same device. + +### Option C: QR Code in Terminal + +Tell the user to run the auth command in another terminal window: + +> Open another terminal and run: +> ``` +> cd PROJECT_PATH && npm run auth +> ``` +> Scan the QR code that appears, then let me know when it says "Successfully authenticated". + +Replace `PROJECT_PATH` with the actual project path (use `pwd`). + +Wait for the user to confirm authentication succeeded, then continue to the next step. ## 6. Configure Assistant Name and Main Channel @@ -191,10 +299,11 @@ Store their choice for use in the steps below. > > Options: > 1. Personal chat (Message Yourself) - Recommended -> 2. Solo WhatsApp group (just me) -> 3. Group with other people (I understand the security implications) +> 2. DM with a specific phone number (e.g. your other phone) +> 3. Solo WhatsApp group (just me) +> 4. Group with other people (I understand the security implications) -If they choose option 3, ask a follow-up: +If they choose option 4, ask a follow-up: > You've chosen a group with other people. This means everyone in that group will have admin privileges over NanoClaw. > @@ -222,9 +331,13 @@ npm run dev **For personal chat** (they chose option 1): -Personal chats are NOT synced to the database on startup — only groups are. Instead, ask the user for their phone number (with country code, no + or spaces, e.g. `14155551234`), then construct the JID as `{number}@s.whatsapp.net`. +Personal chats are NOT synced to the database on startup — only groups are. The JID for "Message Yourself" is the bot's own number. Use the number from the WhatsApp auth step and construct the JID as `{number}@s.whatsapp.net`. -**For group** (they chose option 2 or 3): +**For DM with a specific number** (they chose option 2): + +Ask the user for the phone number (with country code, no + or spaces, e.g. `14155551234`), then construct the JID as `{number}@s.whatsapp.net`. + +**For group** (they chose option 3 or 4): Groups are synced on startup via `groupFetchAllParticipating`. Query the database for recent groups: ```bash @@ -472,6 +585,12 @@ The user should receive a response in WhatsApp. - Check that the chat JID is in the database: `sqlite3 store/messages.db "SELECT * FROM registered_groups"` - Check `logs/nanoclaw.log` for errors +**Messages sent but not received by NanoClaw (DMs)**: +- WhatsApp may use LID (Linked Identity) JIDs for DMs instead of phone numbers +- Check logs for `Translated LID to phone JID` — if missing, the LID isn't being resolved +- The `translateJid` method in `src/channels/whatsapp.ts` uses `sock.signalRepository.lidMapping.getPNForLID()` to resolve LIDs +- Verify the registered JID doesn't have a device suffix (should be `number@s.whatsapp.net`, not `number:0@s.whatsapp.net`) + **WhatsApp disconnected**: - The service will show a macOS notification - Run `npm run auth` to re-authenticate diff --git a/.claude/skills/setup/qr-auth.html b/.claude/skills/setup/qr-auth.html new file mode 100644 index 0000000..489b6a9 --- /dev/null +++ b/.claude/skills/setup/qr-auth.html @@ -0,0 +1,32 @@ + +NanoClaw - WhatsApp Auth + +
+

Scan with WhatsApp

+
Expires in 60s
+
{{QR_SVG}}
+
Settings → Linked Devices → Link a Device
+
+ diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index e3d7b69..d737996 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import makeWASocket, { + Browsers, DisconnectReason, WASocket, makeCacheableSignalKeyStore, @@ -62,7 +63,7 @@ export class WhatsAppChannel implements Channel { }, printQRInTerminal: false, logger, - browser: ['NanoClaw', 'Chrome', '1.0.0'], + browser: Browsers.macOS('Chrome'), }); this.sock.ev.on('connection.update', (update) => { @@ -141,14 +142,14 @@ export class WhatsAppChannel implements Channel { this.sock.ev.on('creds.update', saveCreds); - this.sock.ev.on('messages.upsert', ({ messages }) => { + this.sock.ev.on('messages.upsert', async ({ messages }) => { for (const msg of messages) { if (!msg.message) continue; const rawJid = msg.key.remoteJid; if (!rawJid || rawJid === 'status@broadcast') continue; // Translate LID JID to phone JID if applicable - const chatJid = this.translateJid(rawJid); + const chatJid = await this.translateJid(rawJid); const timestamp = new Date( Number(msg.messageTimestamp) * 1000, @@ -256,14 +257,30 @@ export class WhatsAppChannel implements Channel { } } - private translateJid(jid: string): string { + private async translateJid(jid: string): Promise { if (!jid.endsWith('@lid')) return jid; const lidUser = jid.split('@')[0].split(':')[0]; - const phoneJid = this.lidToPhoneMap[lidUser]; - if (phoneJid) { - logger.debug({ lidJid: jid, phoneJid }, 'Translated LID to phone JID'); - return phoneJid; + + // Check local cache first + const cached = this.lidToPhoneMap[lidUser]; + if (cached) { + logger.debug({ lidJid: jid, phoneJid: cached }, 'Translated LID to phone JID (cached)'); + return cached; } + + // Query Baileys' signal repository for the mapping + try { + const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); + if (pn) { + const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; + this.lidToPhoneMap[lidUser] = phoneJid; + logger.info({ lidJid: jid, phoneJid }, 'Translated LID to phone JID (signalRepository)'); + return phoneJid; + } + } catch (err) { + logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); + } + return jid; } diff --git a/src/whatsapp-auth.ts b/src/whatsapp-auth.ts index 824566d..fba0899 100644 --- a/src/whatsapp-auth.ts +++ b/src/whatsapp-auth.ts @@ -10,25 +10,42 @@ import fs from 'fs'; import path from 'path'; import pino from 'pino'; import qrcode from 'qrcode-terminal'; +import readline from 'readline'; import makeWASocket, { + Browsers, DisconnectReason, makeCacheableSignalKeyStore, useMultiFileAuthState, } from '@whiskeysockets/baileys'; const AUTH_DIR = './store/auth'; +const QR_FILE = './store/qr-data.txt'; +const STATUS_FILE = './store/auth-status.txt'; const logger = pino({ level: 'warn', // Quiet logging - only show errors }); -async function authenticate(): Promise { - fs.mkdirSync(AUTH_DIR, { recursive: true }); +// Check for --pairing-code flag and phone number +const usePairingCode = process.argv.includes('--pairing-code'); +const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone'); +function askQuestion(prompt: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function connectSocket(phoneNumber?: string): Promise { const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); if (state.creds.registered) { + fs.writeFileSync(STATUS_FILE, 'already_authenticated'); console.log('✓ Already authenticated with WhatsApp'); console.log( ' To re-authenticate, delete the store/auth folder and run again.', @@ -36,8 +53,6 @@ async function authenticate(): Promise { process.exit(0); } - console.log('Starting WhatsApp authentication...\n'); - const sock = makeWASocket({ auth: { creds: state.creds, @@ -45,13 +60,34 @@ async function authenticate(): Promise { }, printQRInTerminal: false, logger, - browser: ['NanoClaw', 'Chrome', '1.0.0'], + browser: Browsers.macOS('Chrome'), }); + if (usePairingCode && phoneNumber && !state.creds.me) { + // Request pairing code after a short delay for connection to initialize + // Only on first connect (not reconnect after 515) + setTimeout(async () => { + try { + const code = await sock.requestPairingCode(phoneNumber!); + console.log(`\n🔗 Your pairing code: ${code}\n`); + console.log(' 1. Open WhatsApp on your phone'); + console.log(' 2. Tap Settings → Linked Devices → Link a Device'); + console.log(' 3. Tap "Link with phone number instead"'); + console.log(` 4. Enter this code: ${code}\n`); + fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`); + } catch (err: any) { + console.error('Failed to request pairing code:', err.message); + process.exit(1); + } + }, 3000); + } + sock.ev.on('connection.update', (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { + // Write raw QR data to file so the setup skill can render it + fs.writeFileSync(QR_FILE, qr); console.log('Scan this QR code with WhatsApp:\n'); console.log(' 1. Open WhatsApp on your phone'); console.log(' 2. Tap Settings → Linked Devices → Link a Device'); @@ -63,15 +99,29 @@ async function authenticate(): Promise { const reason = (lastDisconnect?.error as any)?.output?.statusCode; if (reason === DisconnectReason.loggedOut) { + fs.writeFileSync(STATUS_FILE, 'failed:logged_out'); console.log('\n✗ Logged out. Delete store/auth and try again.'); process.exit(1); + } else if (reason === DisconnectReason.timedOut) { + fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout'); + console.log('\n✗ QR code timed out. Please try again.'); + process.exit(1); + } else if (reason === 515) { + // 515 = stream error, often happens after pairing succeeds but before + // registration completes. Reconnect to finish the handshake. + console.log('\n⟳ Stream error (515) after pairing — reconnecting...'); + connectSocket(phoneNumber); } else { + fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`); console.log('\n✗ Connection failed. Please try again.'); process.exit(1); } } if (connection === 'open') { + fs.writeFileSync(STATUS_FILE, 'authenticated'); + // Clean up QR file now that we're connected + try { fs.unlinkSync(QR_FILE); } catch {} console.log('\n✓ Successfully authenticated with WhatsApp!'); console.log(' Credentials saved to store/auth/'); console.log(' You can now start the NanoClaw service.\n'); @@ -84,6 +134,23 @@ async function authenticate(): Promise { sock.ev.on('creds.update', saveCreds); } +async function authenticate(): Promise { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + + // Clean up any stale QR/status files from previous runs + try { fs.unlinkSync(QR_FILE); } catch {} + try { fs.unlinkSync(STATUS_FILE); } catch {} + + let phoneNumber = phoneArg; + if (usePairingCode && !phoneNumber) { + phoneNumber = await askQuestion('Enter your phone number (with country code, no + or spaces, e.g. 14155551234): '); + } + + console.log('Starting WhatsApp authentication...\n'); + + await connectSocket(phoneNumber); +} + authenticate().catch((err) => { console.error('Authentication failed:', err.message); process.exit(1);