新增大量WhatsApp适配器相关代码,包括账号管理、会话处理、消息收发、验证授权、媒体处理、互动命令、审批流程、健康检测等完整功能模块,搭建基础的Baileys协议WhatsApp接入能力
356 lines
11 KiB
JavaScript
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);
|
|
}); |