cc-haha/adapters/whatsapp/protocol.ts
程序员阿江(Relakkes) ddcfa5faae feat(adapters): add WhatsApp linked-device support (#573)
Add a WhatsApp adapter backed by Baileys linked-device auth, plus desktop QR binding UI, server config endpoints, sidecar startup wiring, tests, and documentation.

Constraint: Uses WhatsApp Web linked-device auth, not Meta WhatsApp Business Cloud API.

Tested:
- cd adapters && bun run check:adapters
- bun test src/server/__tests__/adapters.test.ts
- cd desktop && bun run check:desktop
- bun run check:native
- bun run check:persistence-upgrade
- bun run check:docs

Not-tested:
- Live WhatsApp QR pairing, because no WhatsApp account/device was exercised here.
- bun run check:server, because the existing src/server/__tests__/conversations.test.ts timeout still fails independently.

Confidence: medium
Scope-risk: moderate
2026-06-09 21:31:46 +08:00

181 lines
4.5 KiB
TypeScript

import * as crypto from 'node:crypto'
import * as path from 'node:path'
import {
clearWhatsAppAuth,
closeWhatsAppSocket,
createWhatsAppSocket,
isWhatsAppLoggedOut,
waitForWhatsAppCredsSave,
type WhatsAppSocket,
} from './session.js'
export type WhatsAppLoginStartResult = {
sessionKey: string
qr?: string
message: string
}
export type WhatsAppLoginPollResult =
| {
connected: true
accountJid: string
authDir: string
message: string
}
| {
connected: false
status: 'waiting' | 'expired' | 'error'
qr?: string
message: string
}
type LoginSession = {
authDir: string
sock: WhatsAppSocket
qr?: string
connected: boolean
accountJid?: string
error?: string
createdAt: number
}
const LOGIN_TTL_MS = 2 * 60 * 1000
const WAIT_FOR_QR_MS = 20_000
const sessions = new Map<string, LoginSession>()
export async function startWhatsAppLoginWithQr(options: {
authDir: string
force?: boolean
}): Promise<WhatsAppLoginStartResult> {
cleanupExpiredSessions()
const authDir = path.resolve(options.authDir)
if (options.force) {
closeSessionsForAuthDir(authDir)
clearWhatsAppAuth(authDir)
}
const sessionKey = crypto.randomUUID()
const session: LoginSession = {
authDir,
sock: undefined as unknown as WhatsAppSocket,
connected: false,
createdAt: Date.now(),
}
const qrPromise = waitForQr(session)
const sock = await createWhatsAppSocket({
authDir,
onQr: (qr) => {
session.qr = qr
},
})
session.sock = sock
sessions.set(sessionKey, session)
sock.ev.on('connection.update', async (update) => {
if (update.qr) {
session.qr = update.qr
}
if (update.connection === 'open') {
await waitForWhatsAppCredsSave(authDir)
session.connected = true
session.accountJid = sock.user?.id ?? ''
}
if (update.connection === 'close') {
if (isWhatsAppLoggedOut(update.lastDisconnect?.error)) {
session.error = 'WhatsApp session logged out. Please scan again.'
} else if (!session.connected) {
session.error = 'WhatsApp login connection closed. Please retry.'
}
}
})
await qrPromise
return {
sessionKey,
qr: session.qr,
message: session.qr
? 'Scan this QR in WhatsApp > Linked devices.'
: 'Waiting for WhatsApp QR code...',
}
}
export async function pollWhatsAppLoginWithQr(options: {
sessionKey: string
}): Promise<WhatsAppLoginPollResult> {
cleanupExpiredSessions()
const session = sessions.get(options.sessionKey)
if (!session) {
return {
connected: false,
status: 'expired',
message: 'WhatsApp login session expired. Generate a new QR code.',
}
}
if (session.connected) {
await waitForWhatsAppCredsSave(session.authDir)
const accountJid = session.accountJid || session.sock.user?.id || ''
closeWhatsAppSocket(session.sock, 'WhatsApp login complete')
sessions.delete(options.sessionKey)
return {
connected: true,
accountJid,
authDir: session.authDir,
message: 'WhatsApp linked successfully.',
}
}
if (session.error) {
closeWhatsAppSocket(session.sock, 'WhatsApp login error')
sessions.delete(options.sessionKey)
return {
connected: false,
status: 'error',
message: session.error,
}
}
return {
connected: false,
status: 'waiting',
qr: session.qr,
message: session.qr
? 'Waiting for WhatsApp scan confirmation...'
: 'Waiting for WhatsApp QR code...',
}
}
export async function logoutWhatsAppAuth(authDir: string): Promise<void> {
closeSessionsForAuthDir(path.resolve(authDir))
clearWhatsAppAuth(authDir)
}
function waitForQr(session: LoginSession): Promise<void> {
return new Promise((resolve) => {
const started = Date.now()
const timer = setInterval(() => {
if (session.qr || session.connected || Date.now() - started > WAIT_FOR_QR_MS) {
clearInterval(timer)
resolve()
}
}, 250)
})
}
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [sessionKey, session] of sessions) {
if (now - session.createdAt <= LOGIN_TTL_MS) continue
closeWhatsAppSocket(session.sock, 'WhatsApp login expired')
sessions.delete(sessionKey)
}
}
function closeSessionsForAuthDir(authDir: string): void {
for (const [sessionKey, session] of sessions) {
if (path.resolve(session.authDir) !== authDir) continue
closeWhatsAppSocket(session.sock, 'WhatsApp login superseded')
sessions.delete(sessionKey)
}
}