mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
169 lines
5.1 KiB
TypeScript
169 lines
5.1 KiB
TypeScript
import * as fs from 'node:fs'
|
|
import * as path from 'node:path'
|
|
import {
|
|
DisconnectReason,
|
|
fetchLatestBaileysVersion,
|
|
makeCacheableSignalKeyStore,
|
|
makeWASocket,
|
|
useMultiFileAuthState,
|
|
} from '@whiskeysockets/baileys'
|
|
|
|
export type WhatsAppSocket = ReturnType<typeof makeWASocket>
|
|
|
|
const CREDS_FILE = 'creds.json'
|
|
const CREDS_BACKUP_FILE = 'creds.json.bak'
|
|
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401
|
|
|
|
const credsSaveQueues = new Map<string, Promise<void>>()
|
|
|
|
export function hasWhatsAppAuth(authDir: string): boolean {
|
|
return fs.existsSync(resolveCredsPath(authDir))
|
|
}
|
|
|
|
export function clearWhatsAppAuth(authDir: string): void {
|
|
fs.rmSync(authDir, { recursive: true, force: true })
|
|
}
|
|
|
|
export function getWhatsAppDisconnectStatus(error: unknown): number | undefined {
|
|
const candidate = error as {
|
|
output?: { statusCode?: unknown }
|
|
statusCode?: unknown
|
|
} | null
|
|
const status = candidate?.output?.statusCode ?? candidate?.statusCode
|
|
return typeof status === 'number' ? status : undefined
|
|
}
|
|
|
|
export function isWhatsAppLoggedOut(error: unknown): boolean {
|
|
return getWhatsAppDisconnectStatus(error) === LOGGED_OUT_STATUS
|
|
}
|
|
|
|
export async function createWhatsAppSocket(options: {
|
|
authDir: string
|
|
onQr?: (qr: string) => void
|
|
verbose?: boolean
|
|
}): Promise<WhatsAppSocket> {
|
|
const authDir = path.resolve(options.authDir)
|
|
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 })
|
|
maybeRestoreCredsFromBackup(authDir)
|
|
|
|
const logger = makeBaileysLogger(options.verbose ? 'info' : 'silent')
|
|
const { state, saveCreds } = await useMultiFileAuthState(authDir)
|
|
const { version } = await fetchLatestBaileysVersion()
|
|
const sock = makeWASocket({
|
|
auth: {
|
|
creds: state.creds,
|
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
|
},
|
|
version,
|
|
logger,
|
|
printQRInTerminal: false,
|
|
browser: ['cc-haha', 'desktop', '1.0'],
|
|
syncFullHistory: false,
|
|
markOnlineOnConnect: false,
|
|
})
|
|
|
|
sock.ev.on('creds.update', () => enqueueSaveCreds(authDir, saveCreds))
|
|
if (options.onQr) {
|
|
sock.ev.on('connection.update', (update) => {
|
|
if (update.qr) options.onQr?.(update.qr)
|
|
})
|
|
}
|
|
const ws = sock.ws as unknown as { on?: (event: string, handler: (err: Error) => void) => void }
|
|
ws.on?.('error', (err) => {
|
|
console.error('[WhatsApp] WebSocket error:', err.message)
|
|
})
|
|
|
|
return sock
|
|
}
|
|
|
|
export function closeWhatsAppSocket(sock: WhatsAppSocket, reason = 'closed'): void {
|
|
try {
|
|
const closable = sock as unknown as { end?: (error?: Error) => void }
|
|
closable.end?.(new Error(reason))
|
|
} catch {
|
|
// best-effort shutdown
|
|
}
|
|
}
|
|
|
|
export async function waitForWhatsAppCredsSave(authDir: string): Promise<void> {
|
|
await (credsSaveQueues.get(path.resolve(authDir)) ?? Promise.resolve())
|
|
}
|
|
|
|
function resolveCredsPath(authDir: string): string {
|
|
return path.join(path.resolve(authDir), CREDS_FILE)
|
|
}
|
|
|
|
function resolveCredsBackupPath(authDir: string): string {
|
|
return path.join(path.resolve(authDir), CREDS_BACKUP_FILE)
|
|
}
|
|
|
|
function maybeRestoreCredsFromBackup(authDir: string): void {
|
|
const credsPath = resolveCredsPath(authDir)
|
|
const backupPath = resolveCredsBackupPath(authDir)
|
|
if (isValidJsonFile(credsPath) || !isValidJsonFile(backupPath)) return
|
|
fs.copyFileSync(backupPath, credsPath)
|
|
fs.chmodSync(credsPath, 0o600)
|
|
}
|
|
|
|
function enqueueSaveCreds(authDir: string, saveCreds: () => Promise<void> | void): void {
|
|
const resolved = path.resolve(authDir)
|
|
const prev = credsSaveQueues.get(resolved) ?? Promise.resolve()
|
|
const next = prev
|
|
.then(() => safeSaveCreds(resolved, saveCreds))
|
|
.catch((err) => {
|
|
console.warn('[WhatsApp] Failed to save credentials:', err instanceof Error ? err.message : err)
|
|
})
|
|
.finally(() => {
|
|
if (credsSaveQueues.get(resolved) === next) {
|
|
credsSaveQueues.delete(resolved)
|
|
}
|
|
})
|
|
credsSaveQueues.set(resolved, next)
|
|
}
|
|
|
|
async function safeSaveCreds(authDir: string, saveCreds: () => Promise<void> | void): Promise<void> {
|
|
backupCreds(authDir)
|
|
await Promise.resolve(saveCreds())
|
|
fs.chmodSync(resolveCredsPath(authDir), 0o600)
|
|
}
|
|
|
|
function backupCreds(authDir: string): void {
|
|
const credsPath = resolveCredsPath(authDir)
|
|
if (!isValidJsonFile(credsPath)) return
|
|
try {
|
|
const backupPath = resolveCredsBackupPath(authDir)
|
|
fs.copyFileSync(credsPath, backupPath)
|
|
fs.chmodSync(backupPath, 0o600)
|
|
} catch {
|
|
// keep the previous backup
|
|
}
|
|
}
|
|
|
|
function isValidJsonFile(filePath: string): boolean {
|
|
try {
|
|
JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function makeBaileysLogger(level: 'silent' | 'info'): any {
|
|
const noop = () => {}
|
|
const sink = level === 'silent'
|
|
? { trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop }
|
|
: {
|
|
trace: console.debug.bind(console),
|
|
debug: console.debug.bind(console),
|
|
info: console.info.bind(console),
|
|
warn: console.warn.bind(console),
|
|
error: console.error.bind(console),
|
|
fatal: console.error.bind(console),
|
|
}
|
|
return {
|
|
level,
|
|
child: () => makeBaileysLogger(level),
|
|
...sink,
|
|
}
|
|
}
|