ForcePilot/backend/package/yuxi/channels/adapters/whatsapp/baileys-bridge/index.js
Kris e9b57546ea feat(whatsapp): 新增WhatsApp适配器完整功能模块
新增大量WhatsApp适配器相关代码,包括账号管理、会话处理、消息收发、验证授权、媒体处理、互动命令、审批流程、健康检测等完整功能模块,搭建基础的Baileys协议WhatsApp接入能力
2026-05-12 00:51:58 +08:00

356 lines
11 KiB
JavaScript

const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore } = require('@whiskeysockets/baileys');
const express = require('express');
const QRCode = require('qrcode');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { buildSendRoutes } = require('./send');
const { setupMonitor } = require('./monitor');
const PORT = parseInt(process.env.WHATSAPP_BRIDGE_PORT || '9080', 10);
const AUTH_DIR = process.env.WHATSAPP_AUTH_DIR || path.join(process.env.HOME || '/tmp', '.forcepilot/whatsapp/auth');
const BRIDGE_TOKEN = process.env.WHATSAPP_BRIDGE_TOKEN || '';
const CONNECT_TIMEOUT_MS = parseInt(process.env.WHATSAPP_CONNECT_TIMEOUT_MS || '20000', 10);
const QUERY_TIMEOUT_MS = parseInt(process.env.WHATSAPP_QUERY_TIMEOUT_MS || '30000', 10);
const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.https_proxy || '';
const HTTP_PROXY = process.env.HTTP_PROXY || process.env.http_proxy || '';
const KEEP_ALIVE_INTERVAL_MS = parseInt(process.env.WHATSAPP_KEEPALIVE_INTERVAL_MS || '30000', 10);
const SYNC_FULL_HISTORY = process.env.WHATSAPP_SYNC_FULL_HISTORY === 'true';
const MARK_ONLINE_ON_CONNECT = process.env.WHATSAPP_MARK_ONLINE_ON_CONNECT !== 'false';
const RECONNECT_BASE_DELAY = 1000;
const RECONNECT_MAX_DELAY = 30000;
const RECONNECT_RESET_AFTER = 60000;
const SESSION_CONFLICT_DELAY = 5000;
let sock = null;
let connectionState = 'close';
let pendingQR = null;
let qrResolve = null;
let sseClients = [];
let reconnectAttempts = 0;
let lastConnectionTime = 0;
function notifySSE(event, data) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
sseClients = sseClients.filter(client => {
try {
client.write(payload);
return true;
} catch {
return false;
}
});
}
function atomicSaveCreds(creds) {
const credsPath = path.join(AUTH_DIR, 'creds.json');
const tmpPath = credsPath + '.tmp';
const bakPath = credsPath + '.bak';
try {
fs.writeFileSync(tmpPath, JSON.stringify(creds), { encoding: 'utf8' });
if (fs.existsSync(credsPath)) {
try { fs.copyFileSync(credsPath, bakPath); } catch {}
}
fs.renameSync(tmpPath, credsPath);
} catch (e) {
console.error('Failed to save credentials:', e.message);
throw e;
}
}
function loadAuthWithFallback() {
const credsPath = path.join(AUTH_DIR, 'creds.json');
const bakPath = credsPath + '.bak';
if (fs.existsSync(credsPath)) {
try {
return JSON.parse(fs.readFileSync(credsPath, 'utf8'));
} catch {
console.warn('creds.json corrupted, trying backup...');
}
}
if (fs.existsSync(bakPath)) {
try {
const backupCreds = JSON.parse(fs.readFileSync(bakPath, 'utf8'));
fs.copyFileSync(bakPath, credsPath);
console.log('Restored credentials from backup');
return backupCreds;
} catch {
console.error('Backup credentials also corrupted');
}
}
return null;
}
function authMiddleware(req, res, next) {
if (!BRIDGE_TOKEN) return next();
const token = req.headers['x-bridge-token'];
if (token !== BRIDGE_TOKEN) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
function clearCredentials() {
const filesToRemove = [
'creds.json',
'creds.json.bak',
'creds.json.tmp',
'app-state-sync-key.json',
'pre-keys.json',
'sender-key-store.json',
];
for (const f of filesToRemove) {
try {
const filepath = path.join(AUTH_DIR, f);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
console.log(`Cleared credential: ${f}`);
}
} catch (e) {
console.error(`Failed to clear credential ${f}:`, e.message);
}
}
}
async function initBaileys() {
fs.mkdirSync(AUTH_DIR, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const cachedKeys = makeCacheableSignalKeyStore(state.keys, console);
const { version, isLatest } = await fetchLatestBaileysVersion();
console.log(`Baileys version: ${version.join('.')}, isLatest: ${isLatest}`);
const socketConfig = {
version,
auth: {
creds: state.creds,
keys: cachedKeys,
},
printQRInTerminal: false,
browser: ['ForcePilot', 'Chrome', '1.0.0'],
markOnlineOnConnect: MARK_ONLINE_ON_CONNECT,
syncFullHistory: SYNC_FULL_HISTORY,
connectTimeoutMs: CONNECT_TIMEOUT_MS,
defaultQueryTimeoutMs: QUERY_TIMEOUT_MS,
keepAliveIntervalMs: KEEP_ALIVE_INTERVAL_MS,
};
const proxyUrl = HTTPS_PROXY || HTTP_PROXY;
if (proxyUrl) {
console.log(`Using proxy: ${proxyUrl}`);
const Agent = proxyUrl.startsWith('https') ? HttpsProxyAgent : HttpProxyAgent;
const agent = new Agent(proxyUrl);
socketConfig.agent = agent;
}
sock = makeWASocket(socketConfig);
setupMonitor(sock, notifySSE);
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
QRCode.toDataURL(qr, (err, url) => {
if (err) {
console.error('QR generation error:', err);
return;
}
pendingQR = url;
notifySSE('qr', { qr: url });
if (qrResolve) {
qrResolve({ qr: url });
qrResolve = null;
}
});
}
if (connection === 'open') {
connectionState = 'open';
pendingQR = null;
reconnectAttempts = 0;
lastConnectionTime = Date.now();
notifySSE('connection', { status: 'open' });
console.log('WhatsApp connected');
}
if (connection === 'close') {
connectionState = 'close';
const statusCode = lastDisconnect?.error?.output?.statusCode;
const isLoggedOut = statusCode === DisconnectReason.loggedOut;
const isConflict = statusCode === 440;
const shouldReconnect = !isLoggedOut && !isConflict;
const payload = {
status: 'close',
reason: lastDisconnect?.error?.message || 'unknown',
shouldReconnect,
statusCode,
};
if (isLoggedOut) {
console.log('WhatsApp logged out (401), clearing credentials...');
payload.reason = 'logged_out_401';
notifySSE('connection', payload);
clearCredentials();
} else if (isConflict) {
console.log('WhatsApp session conflict (440), delaying reconnect...');
payload.reason = 'session_conflict_440';
notifySSE('connection', payload);
} else {
notifySSE('connection', payload);
}
console.log('WhatsApp connection closed, reconnect:', shouldReconnect);
if (shouldReconnect) {
if (Date.now() - lastConnectionTime > RECONNECT_RESET_AFTER) {
reconnectAttempts = 0;
}
const delay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts),
RECONNECT_MAX_DELAY
);
const actualDelay = isConflict ? Math.max(delay, SESSION_CONFLICT_DELAY) : delay;
reconnectAttempts++;
console.log(`Reconnecting in ${actualDelay}ms (attempt ${reconnectAttempts})`);
setTimeout(() => initBaileys(), actualDelay);
} else {
reconnectAttempts = 0;
}
}
if (connection === 'connecting') {
connectionState = 'connecting';
notifySSE('connection', { status: 'connecting' });
}
});
sock.ev.on('creds.update', atomicSaveCreds);
}
const app = express();
app.use(express.json({ limit: '50mb' }));
app.use('/api', authMiddleware);
// Health check
app.get('/health', (req, res) => {
const jid = sock?.user?.id ? `${sock.user.id.split(':')[0]}@s.whatsapp.net` : '';
res.json({
status: 'running',
connected: connectionState === 'open',
jid,
connectionState,
});
});
// QR login routes
app.post('/api/qr/login', (req, res) => {
if (connectionState === 'open') {
res.json({ status: 'already_connected', jid: sock?.user?.id || '' });
return;
}
if (pendingQR) {
res.json({ status: 'pending', qr: pendingQR });
return;
}
res.json({ status: 'waiting', message: 'QR generation in progress, try again' });
});
app.post('/api/qr/wait', (req, res) => {
const timeout = (req.body?.timeout || 120) * 1000;
if (connectionState === 'open') {
res.json({ success: true, jid: sock?.user?.id || '' });
return;
}
const timer = setTimeout(() => {
sock.ev.removeListener('connection.update', listener);
res.json({ success: false, error: 'QR scan timeout' });
}, timeout);
const listener = (update) => {
if (update.connection === 'open') {
clearTimeout(timer);
sock.ev.removeListener('connection.update', listener);
res.json({ success: true, jid: sock?.user?.id || '' });
}
};
sock.ev.on('connection.update', listener);
});
app.get('/api/qr/status', (req, res) => {
res.json({
connected: connectionState === 'open',
connectionState,
hasPendingQR: !!pendingQR,
jid: sock?.user?.id || '',
});
});
// Logout
app.post('/api/logout', async (req, res) => {
try {
if (sock) {
await sock.logout();
}
res.json({ success: true });
} catch (e) {
res.json({ success: false, error: e.message });
}
});
// Message sending and other routes
buildSendRoutes(app, () => sock, notifySSE);
// SSE endpoint for inbound messages
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(':ok\n\n');
sseClients.push(res);
req.on('close', () => {
sseClients = sseClients.filter(c => c !== res);
});
});
// Start server
async function start() {
await initBaileys();
app.listen(PORT, () => {
console.log(`Baileys bridge listening on port ${PORT}`);
});
}
start().catch(err => {
console.error('Failed to start Baileys bridge:', err);
process.exit(1);
});
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down...');
if (sock) {
try { await sock.logout(); } catch {}
}
process.exit(0);
});