diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 462683ece..2d1be01b8 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -20,6 +20,7 @@ Skip to **Credentials** if all of these are already in place: - `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist - `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:` - `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies +- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill) Otherwise continue. Every step below is safe to re-run. @@ -95,7 +96,7 @@ If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenti - **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp? -- **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code +- **QR code in browser** (Recommended) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser - **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number) - **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) @@ -114,11 +115,13 @@ rm -rf store/auth/ For QR code in browser (recommended): ```bash -pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser +pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts ``` (Bash timeout: 150000ms) +The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`. + Tell the user: > A browser window will open with a QR code. @@ -130,11 +133,13 @@ Tell the user: For QR code in terminal: ```bash -pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal +pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr ``` (Bash timeout: 150000ms) +The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above. + Tell the user: > 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** @@ -220,10 +225,10 @@ Not supported (WhatsApp linked device limitation): edit messages, delete message ### QR code expired -QR codes expire after ~60 seconds. Re-run the auth command: +QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`: ```bash -rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser +pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean ``` ### Pairing code not working @@ -236,10 +241,10 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met Ensure: digits only (no `+`), phone has internet, WhatsApp is updated. -If pairing code keeps failing, switch to QR-browser auth instead: +WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate: ```bash -rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser +pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean ``` ### "waiting for this message" on reactions diff --git a/.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts b/.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts new file mode 100644 index 000000000..a1ac035b4 --- /dev/null +++ b/.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts @@ -0,0 +1,246 @@ +/** + * scripts/wa-qr-browser.ts — serve WhatsApp pairing QR in the browser. + * + * Wraps `setup/index.ts --step whatsapp-auth -- --method qr` and renders the + * rotating QR string as a PNG in a small local HTTP page. Avoids the unreadable + * ASCII terminal QR. macOS / desktop-Linux only — no headless support needed. + * + * Usage: + * pnpm exec tsx scripts/wa-qr-browser.ts [--clean] [--port 8765] + * + * --clean rm -rf store/auth/ before spawning the auth step. + * --port N bind to port N (default 8765, falls back to a free port). + */ +import { spawn, exec } from 'node:child_process'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import QRCode from 'qrcode'; + +type Status = 'waiting' | 'ready' | 'success' | 'failed'; +type State = { + qr: string | null; + status: Status; + error?: string; + version: number; +}; + +const state: State = { qr: null, status: 'waiting', version: 0 }; + +const args = process.argv.slice(2); +const clean = args.includes('--clean'); +const portIdx = args.indexOf('--port'); +const requestedPort = portIdx >= 0 ? Number(args[portIdx + 1]) : 8765; + +if (clean) { + fs.rmSync(path.join(process.cwd(), 'store', 'auth'), { + recursive: true, + force: true, + }); + console.log('[wa-qr-browser] cleaned store/auth/'); +} + +function htmlPage(): string { + return ` + + + + + WhatsApp pairing + + + +
+

Scan with WhatsApp

+
QR code
+
Waiting for QR…
+
    +
  1. Open WhatsApp on your phone
  2. +
  3. Settings → Linked Devices → Link a Device
  4. +
  5. Point the camera at this QR code
  6. +
+
+ + +`; +} + +const server = http.createServer(async (req, res) => { + const url = req.url ?? '/'; + if (url === '/' || url.startsWith('/?')) { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(htmlPage()); + return; + } + if (url === '/qr.json') { + res.setHeader('content-type', 'application/json'); + res.setHeader('cache-control', 'no-store'); + res.end(JSON.stringify(state)); + return; + } + if (url.startsWith('/qr.png')) { + if (!state.qr) { + res.statusCode = 404; + res.end(); + return; + } + try { + const buf = await QRCode.toBuffer(state.qr, { width: 360, margin: 1 }); + res.setHeader('content-type', 'image/png'); + res.setHeader('cache-control', 'no-store'); + res.end(buf); + } catch (e) { + res.statusCode = 500; + res.end(String(e)); + } + return; + } + res.statusCode = 404; + res.end(); +}); + +function listen(port: number): Promise { + return new Promise((resolve, reject) => { + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE' && port === requestedPort) { + server.listen(0, () => { + const addr = server.address(); + if (addr && typeof addr === 'object') resolve(addr.port); + else reject(new Error('unexpected address')); + }); + } else { + reject(err); + } + }); + server.listen(port, () => { + const addr = server.address(); + if (addr && typeof addr === 'object') resolve(addr.port); + else reject(new Error('unexpected address')); + }); + }); +} + +const port = await listen(requestedPort); +const url = `http://localhost:${port}`; +console.log(`[wa-qr-browser] QR server on ${url}`); + +const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'; +exec(`${opener} ${url}`, (err) => { + if (err) console.log(`[wa-qr-browser] could not auto-open browser: ${err.message}`); + else console.log('[wa-qr-browser] opening browser…'); +}); + +const child = spawn( + 'pnpm', + ['exec', 'tsx', 'setup/index.ts', '--step', 'whatsapp-auth', '--', '--method', 'qr'], + { stdio: ['inherit', 'pipe', 'inherit'] }, +); + +let stdoutBuf = ''; +child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + process.stdout.write(text); + stdoutBuf += text; + + const blockRe = /=== NANOCLAW SETUP: (\w+) ===\n([\s\S]*?)\n=== END ===/g; + let m: RegExpExecArray | null; + let lastEnd = 0; + while ((m = blockRe.exec(stdoutBuf)) !== null) { + const [, name, body] = m; + const fields: Record = {}; + for (const line of body.split('\n')) { + const kv = line.match(/^(\w+):\s*(.*)$/); + if (kv) fields[kv[1]] = kv[2]; + } + handleBlock(name, fields); + lastEnd = m.index + m[0].length; + } + if (lastEnd > 0) stdoutBuf = stdoutBuf.slice(lastEnd); +}); + +function handleBlock(name: string, fields: Record): void { + if (name === 'WHATSAPP_AUTH_QR' && fields.QR) { + state.qr = fields.QR; + state.status = 'ready'; + state.version++; + return; + } + if (name === 'WHATSAPP_AUTH') { + if (fields.STATUS === 'success') { + state.status = 'success'; + console.log('[wa-qr-browser] authenticated'); + setTimeout(() => server.close(() => process.exit(0)), 3000); + } else if (fields.STATUS === 'skipped') { + state.status = 'success'; + state.error = `already authenticated (${fields.REASON ?? 'unknown'})`; + console.log(`[wa-qr-browser] ${state.error}`); + setTimeout(() => server.close(() => process.exit(0)), 3000); + } else if (fields.STATUS === 'failed') { + state.status = 'failed'; + state.error = fields.ERROR ?? 'unknown error'; + console.error(`[wa-qr-browser] failed: ${state.error}`); + } + } +} + +child.on('exit', (code) => { + if (state.status === 'success') return; + if (state.status !== 'failed') { + state.status = 'failed'; + state.error = `auth process exited (code=${code ?? 'null'})`; + } + setTimeout(() => { + server.close(() => process.exit(1)); + }, 3000); +}); + +process.on('SIGINT', () => { + console.log('\n[wa-qr-browser] aborting…'); + child.kill('SIGTERM'); + server.close(() => process.exit(130)); +});