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 <noreply@anthropic.com>
This commit is contained in:
Gavriel Cohen
2026-02-12 22:49:04 +02:00
parent 6863c0bf6b
commit acdc6454db
4 changed files with 265 additions and 30 deletions

View File

@@ -5,7 +5,7 @@ description: Run initial NanoClaw setup. Use when user wants to install dependen
# NanoClaw Setup # 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. **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 > 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=<your-token>` > 2. Add it to `.env` yourself as `CLAUDE_CODE_OAUTH_TOKEN=<your-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 ```bash
echo "CLAUDE_CODE_OAUTH_TOKEN=<token>" > .env echo "CLAUDE_CODE_OAUTH_TOKEN=<token>" > .env
@@ -114,7 +114,7 @@ Tell the user to add their key from https://console.anthropic.com/
**Verify:** **Verify:**
```bash ```bash
KEY=$(grep "^ANTHROPIC_API_KEY=" .env | cut -d= -f2) 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 ## 4. Build Container Image
@@ -141,23 +141,131 @@ fi
**USER ACTION REQUIRED** **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: The auth script writes status to `store/auth-status.txt`:
> A QR code will appear below. On your phone: - `already_authenticated` — credentials already exist
> 1. Open WhatsApp - `pairing_code:<CODE>` — pairing code generated, waiting for user to enter it
> 2. Tap **Settings → Linked Devices → Link a Device** - `authenticated` — successfully authenticated
> 3. Scan the QR code - `failed:<reason>` — 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 ```bash
rm -rf store/auth store/qr-data.txt store/auth-status.txt
npm run auth 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 ## 6. Configure Assistant Name and Main Channel
@@ -191,10 +299,11 @@ Store their choice for use in the steps below.
> >
> Options: > Options:
> 1. Personal chat (Message Yourself) - Recommended > 1. Personal chat (Message Yourself) - Recommended
> 2. Solo WhatsApp group (just me) > 2. DM with a specific phone number (e.g. your other phone)
> 3. Group with other people (I understand the security implications) > 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. > 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): **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: Groups are synced on startup via `groupFetchAllParticipating`. Query the database for recent groups:
```bash ```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 that the chat JID is in the database: `sqlite3 store/messages.db "SELECT * FROM registered_groups"`
- Check `logs/nanoclaw.log` for errors - 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**: **WhatsApp disconnected**:
- The service will show a macOS notification - The service will show a macOS notification
- Run `npm run auth` to re-authenticate - Run `npm run auth` to re-authenticate

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html><head><title>NanoClaw - WhatsApp Auth</title>
<style>
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { margin: 0 0 8px; }
.timer { font-size: 18px; color: #666; margin: 12px 0; }
.timer.urgent { color: #e74c3c; font-weight: bold; }
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
svg { width: 280px; height: 280px; }
</style></head><body>
<div class="card">
<h2>Scan with WhatsApp</h2>
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
<div id="qr">{{QR_SVG}}</div>
<div class="instructions">Settings → Linked Devices → Link a Device</div>
</div>
<script>
let seconds = 60;
const countdown = document.getElementById('countdown');
const timer = document.getElementById('timer');
const interval = setInterval(() => {
seconds--;
countdown.textContent = seconds;
if (seconds <= 10) timer.classList.add('urgent');
if (seconds <= 0) {
clearInterval(interval);
timer.textContent = 'QR code expired — re-run auth to get a new one';
timer.classList.add('urgent');
}
}, 1000);
</script></body></html>

View File

@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import makeWASocket, { import makeWASocket, {
Browsers,
DisconnectReason, DisconnectReason,
WASocket, WASocket,
makeCacheableSignalKeyStore, makeCacheableSignalKeyStore,
@@ -62,7 +63,7 @@ export class WhatsAppChannel implements Channel {
}, },
printQRInTerminal: false, printQRInTerminal: false,
logger, logger,
browser: ['NanoClaw', 'Chrome', '1.0.0'], browser: Browsers.macOS('Chrome'),
}); });
this.sock.ev.on('connection.update', (update) => { 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('creds.update', saveCreds);
this.sock.ev.on('messages.upsert', ({ messages }) => { this.sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) { for (const msg of messages) {
if (!msg.message) continue; if (!msg.message) continue;
const rawJid = msg.key.remoteJid; const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue; if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID JID to phone JID if applicable // Translate LID JID to phone JID if applicable
const chatJid = this.translateJid(rawJid); const chatJid = await this.translateJid(rawJid);
const timestamp = new Date( const timestamp = new Date(
Number(msg.messageTimestamp) * 1000, Number(msg.messageTimestamp) * 1000,
@@ -256,14 +257,30 @@ export class WhatsAppChannel implements Channel {
} }
} }
private translateJid(jid: string): string { private async translateJid(jid: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid; if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0]; const lidUser = jid.split('@')[0].split(':')[0];
const phoneJid = this.lidToPhoneMap[lidUser];
if (phoneJid) { // Check local cache first
logger.debug({ lidJid: jid, phoneJid }, 'Translated LID to phone JID'); const cached = this.lidToPhoneMap[lidUser];
return phoneJid; 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; return jid;
} }

View File

@@ -10,25 +10,42 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import pino from 'pino'; import pino from 'pino';
import qrcode from 'qrcode-terminal'; import qrcode from 'qrcode-terminal';
import readline from 'readline';
import makeWASocket, { import makeWASocket, {
Browsers,
DisconnectReason, DisconnectReason,
makeCacheableSignalKeyStore, makeCacheableSignalKeyStore,
useMultiFileAuthState, useMultiFileAuthState,
} from '@whiskeysockets/baileys'; } from '@whiskeysockets/baileys';
const AUTH_DIR = './store/auth'; const AUTH_DIR = './store/auth';
const QR_FILE = './store/qr-data.txt';
const STATUS_FILE = './store/auth-status.txt';
const logger = pino({ const logger = pino({
level: 'warn', // Quiet logging - only show errors level: 'warn', // Quiet logging - only show errors
}); });
async function authenticate(): Promise<void> { // Check for --pairing-code flag and phone number
fs.mkdirSync(AUTH_DIR, { recursive: true }); const usePairingCode = process.argv.includes('--pairing-code');
const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone');
function askQuestion(prompt: string): Promise<string> {
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<void> {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
if (state.creds.registered) { if (state.creds.registered) {
fs.writeFileSync(STATUS_FILE, 'already_authenticated');
console.log('✓ Already authenticated with WhatsApp'); console.log('✓ Already authenticated with WhatsApp');
console.log( console.log(
' To re-authenticate, delete the store/auth folder and run again.', ' To re-authenticate, delete the store/auth folder and run again.',
@@ -36,8 +53,6 @@ async function authenticate(): Promise<void> {
process.exit(0); process.exit(0);
} }
console.log('Starting WhatsApp authentication...\n');
const sock = makeWASocket({ const sock = makeWASocket({
auth: { auth: {
creds: state.creds, creds: state.creds,
@@ -45,13 +60,34 @@ async function authenticate(): Promise<void> {
}, },
printQRInTerminal: false, printQRInTerminal: false,
logger, 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) => { sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update; const { connection, lastDisconnect, qr } = update;
if (qr) { 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('Scan this QR code with WhatsApp:\n');
console.log(' 1. Open WhatsApp on your phone'); console.log(' 1. Open WhatsApp on your phone');
console.log(' 2. Tap Settings → Linked Devices → Link a Device'); console.log(' 2. Tap Settings → Linked Devices → Link a Device');
@@ -63,15 +99,29 @@ async function authenticate(): Promise<void> {
const reason = (lastDisconnect?.error as any)?.output?.statusCode; const reason = (lastDisconnect?.error as any)?.output?.statusCode;
if (reason === DisconnectReason.loggedOut) { if (reason === DisconnectReason.loggedOut) {
fs.writeFileSync(STATUS_FILE, 'failed:logged_out');
console.log('\n✗ Logged out. Delete store/auth and try again.'); console.log('\n✗ Logged out. Delete store/auth and try again.');
process.exit(1); 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 { } else {
fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`);
console.log('\n✗ Connection failed. Please try again.'); console.log('\n✗ Connection failed. Please try again.');
process.exit(1); process.exit(1);
} }
} }
if (connection === 'open') { 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('\n✓ Successfully authenticated with WhatsApp!');
console.log(' Credentials saved to store/auth/'); console.log(' Credentials saved to store/auth/');
console.log(' You can now start the NanoClaw service.\n'); console.log(' You can now start the NanoClaw service.\n');
@@ -84,6 +134,23 @@ async function authenticate(): Promise<void> {
sock.ev.on('creds.update', saveCreds); sock.ev.on('creds.update', saveCreds);
} }
async function authenticate(): Promise<void> {
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) => { authenticate().catch((err) => {
console.error('Authentication failed:', err.message); console.error('Authentication failed:', err.message);
process.exit(1); process.exit(1);