d93385b373
CI / Check / macos-latest (push) Waiting to run
CI / Check / ubuntu-latest (push) Waiting to run
CI / Check / windows-latest (push) Waiting to run
CI / Test / macos-latest (push) Waiting to run
CI / Test / ubuntu-latest (push) Waiting to run
CI / Test / windows-latest (push) Waiting to run
CI / Clippy (push) Waiting to run
CI / Format (push) Waiting to run
CI / Security Audit (push) Waiting to run
CI / Secrets Scan (push) Waiting to run
CI / Install Script Smoke Test (push) Waiting to run
388 lines
14 KiB
JavaScript
388 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import http from 'node:http';
|
|
import { randomUUID } from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import makeWASocket, { useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } from '@whiskeysockets/baileys';
|
|
import QRCode from 'qrcode';
|
|
import pino from 'pino';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config from environment
|
|
// ---------------------------------------------------------------------------
|
|
const PORT = parseInt(process.env.WHATSAPP_GATEWAY_PORT || '3009', 10);
|
|
const OPENFANG_URL = (process.env.OPENFANG_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');
|
|
const DEFAULT_AGENT = process.env.OPENFANG_DEFAULT_AGENT || 'assistant';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
let sock = null; // Baileys socket
|
|
let sessionId = ''; // current session identifier
|
|
let qrDataUrl = ''; // latest QR code as data:image/png;base64,...
|
|
let connStatus = 'disconnected'; // disconnected | qr_ready | connected
|
|
let qrExpired = false;
|
|
let statusMessage = 'Not started';
|
|
let reconnectAttempt = 0; // exponential backoff counter
|
|
const MAX_RECONNECT_DELAY = 60_000; // cap at 60s
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Baileys connection
|
|
// ---------------------------------------------------------------------------
|
|
async function startConnection() {
|
|
const logger = pino({ level: 'warn' });
|
|
const authDir = path.join(__dirname, 'auth_store');
|
|
|
|
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
const { version } = await fetchLatestBaileysVersion();
|
|
|
|
sessionId = randomUUID();
|
|
qrDataUrl = '';
|
|
qrExpired = false;
|
|
connStatus = 'disconnected';
|
|
statusMessage = 'Connecting...';
|
|
|
|
sock = makeWASocket({
|
|
version,
|
|
auth: state,
|
|
logger,
|
|
printQRInTerminal: true,
|
|
browser: ['OpenFang', 'Desktop', '1.0.0'],
|
|
});
|
|
|
|
// Save credentials whenever they update
|
|
sock.ev.on('creds.update', saveCreds);
|
|
|
|
// Connection state changes (QR code, connected, disconnected)
|
|
sock.ev.on('connection.update', async (update) => {
|
|
const { connection, lastDisconnect, qr } = update;
|
|
|
|
if (qr) {
|
|
// New QR code generated — convert to data URL
|
|
try {
|
|
qrDataUrl = await QRCode.toDataURL(qr, { width: 256, margin: 2 });
|
|
connStatus = 'qr_ready';
|
|
qrExpired = false;
|
|
statusMessage = 'Scan this QR code with WhatsApp → Linked Devices';
|
|
console.log('[gateway] QR code ready — waiting for scan');
|
|
} catch (err) {
|
|
console.error('[gateway] QR generation failed:', err.message);
|
|
}
|
|
}
|
|
|
|
if (connection === 'close') {
|
|
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
const reason = lastDisconnect?.error?.output?.payload?.message || 'unknown';
|
|
console.log(`[gateway] Connection closed: ${reason} (${statusCode})`);
|
|
|
|
if (statusCode === DisconnectReason.loggedOut) {
|
|
// User logged out from phone — clear auth and stop (truly non-recoverable)
|
|
connStatus = 'disconnected';
|
|
statusMessage = 'Logged out. Generate a new QR code to reconnect.';
|
|
qrDataUrl = '';
|
|
sock = null;
|
|
reconnectAttempt = 0;
|
|
// Remove auth store so next connect gets a fresh QR
|
|
const authPath = path.join(__dirname, 'auth_store');
|
|
if (fs.existsSync(authPath)) {
|
|
fs.rmSync(authPath, { recursive: true, force: true });
|
|
}
|
|
} else {
|
|
// All other disconnect reasons are recoverable — reconnect with backoff
|
|
// Covers: restartRequired(515), timedOut(408), connectionClosed(428),
|
|
// connectionLost(408), connectionReplaced(440), badSession(500), etc.
|
|
reconnectAttempt++;
|
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempt - 1), MAX_RECONNECT_DELAY);
|
|
console.log(`[gateway] Reconnecting in ${delay}ms (attempt ${reconnectAttempt})...`);
|
|
statusMessage = `Reconnecting (attempt ${reconnectAttempt})...`;
|
|
connStatus = 'disconnected';
|
|
setTimeout(() => startConnection(), delay);
|
|
}
|
|
}
|
|
|
|
if (connection === 'open') {
|
|
connStatus = 'connected';
|
|
qrExpired = false;
|
|
qrDataUrl = '';
|
|
reconnectAttempt = 0;
|
|
statusMessage = 'Connected to WhatsApp';
|
|
console.log('[gateway] Connected to WhatsApp!');
|
|
}
|
|
});
|
|
|
|
// Incoming messages → forward to OpenFang
|
|
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
|
if (type !== 'notify') return;
|
|
|
|
for (const msg of messages) {
|
|
// Skip messages from self and status broadcasts
|
|
if (msg.key.fromMe) continue;
|
|
if (msg.key.remoteJid === 'status@broadcast') continue;
|
|
|
|
const remoteJid = msg.key.remoteJid || '';
|
|
const isGroup = remoteJid.endsWith('@g.us');
|
|
|
|
let text = msg.message?.conversation
|
|
|| msg.message?.extendedTextMessage?.text
|
|
|| msg.message?.imageMessage?.caption
|
|
|| '';
|
|
|
|
// Detect media type if no text
|
|
if (!text) {
|
|
const m = msg.message;
|
|
if (m?.imageMessage) text = '[Image received]' + (m.imageMessage.caption ? ': ' + m.imageMessage.caption : '');
|
|
else if (m?.audioMessage) text = '[Voice note received]';
|
|
else if (m?.videoMessage) text = '[Video received]' + (m.videoMessage.caption ? ': ' + m.videoMessage.caption : '');
|
|
else if (m?.documentMessage) text = '[Document received: ' + (m.documentMessage.fileName || 'file') + ']';
|
|
else if (m?.stickerMessage) text = '[Sticker received]';
|
|
else continue; // Only skip truly empty messages
|
|
}
|
|
|
|
// For groups: real sender is in participant; for DMs: it's remoteJid
|
|
const senderJid = isGroup ? (msg.key.participant || '') : remoteJid;
|
|
const phone = '+' + senderJid.replace(/@.*$/, '');
|
|
const pushName = msg.pushName || phone;
|
|
|
|
const metadata = {
|
|
channel: 'whatsapp',
|
|
sender: phone,
|
|
sender_name: pushName,
|
|
};
|
|
if (isGroup) {
|
|
metadata.group_jid = remoteJid;
|
|
metadata.is_group = true;
|
|
console.log(`[gateway] Group msg from ${pushName} (${phone}) in ${remoteJid}: ${text.substring(0, 80)}`);
|
|
} else {
|
|
console.log(`[gateway] Incoming from ${pushName} (${phone}): ${text.substring(0, 80)}`);
|
|
}
|
|
|
|
// Forward to OpenFang agent
|
|
try {
|
|
const response = await forwardToOpenFang(text, phone, pushName, metadata);
|
|
if (response && sock) {
|
|
// Reply in the same context: group → group, DM → DM
|
|
const replyJid = isGroup ? remoteJid : senderJid.replace(/@.*$/, '') + '@s.whatsapp.net';
|
|
await sock.sendMessage(replyJid, { text: response });
|
|
console.log(`[gateway] Replied to ${pushName}${isGroup ? ' in group ' + remoteJid : ''}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[gateway] Forward/reply failed:`, err.message);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Forward incoming message to OpenFang API, return agent response
|
|
// ---------------------------------------------------------------------------
|
|
function forwardToOpenFang(text, phone, pushName, metadata) {
|
|
return new Promise((resolve, reject) => {
|
|
const payload = JSON.stringify({
|
|
message: text,
|
|
metadata: metadata || {
|
|
channel: 'whatsapp',
|
|
sender: phone,
|
|
sender_name: pushName,
|
|
},
|
|
});
|
|
|
|
const url = new URL(`${OPENFANG_URL}/api/agents/${encodeURIComponent(DEFAULT_AGENT)}/message`);
|
|
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port || 4200,
|
|
path: url.pathname,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(payload),
|
|
},
|
|
timeout: 120_000, // LLM calls can be slow
|
|
},
|
|
(res) => {
|
|
let body = '';
|
|
res.on('data', (chunk) => (body += chunk));
|
|
res.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
// The /api/agents/{id}/message endpoint returns { response: "..." }
|
|
resolve(data.response || data.message || data.text || '');
|
|
} catch {
|
|
resolve(body.trim() || '');
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
req.on('error', reject);
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
reject(new Error('OpenFang API timeout'));
|
|
});
|
|
req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Send a message via Baileys (called by OpenFang for outgoing)
|
|
// ---------------------------------------------------------------------------
|
|
async function sendMessage(to, text) {
|
|
if (!sock || connStatus !== 'connected') {
|
|
throw new Error('WhatsApp not connected');
|
|
}
|
|
|
|
// If already a full JID (group or user), use as-is; otherwise normalize phone → JID
|
|
const jid = to.includes('@') ? to : to.replace(/^\+/, '') + '@s.whatsapp.net';
|
|
|
|
await sock.sendMessage(jid, { text });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP server
|
|
// ---------------------------------------------------------------------------
|
|
function parseBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', (chunk) => (body += chunk));
|
|
req.on('end', () => {
|
|
try {
|
|
resolve(body ? JSON.parse(body) : {});
|
|
} catch (e) {
|
|
reject(new Error('Invalid JSON'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function jsonResponse(res, status, data) {
|
|
const body = JSON.stringify(data);
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
'Access-Control-Allow-Origin': '*',
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
// CORS preflight
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
});
|
|
return res.end();
|
|
}
|
|
|
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
const pathname = url.pathname;
|
|
|
|
try {
|
|
// POST /login/start — start Baileys connection, return QR
|
|
if (req.method === 'POST' && pathname === '/login/start') {
|
|
// If already connected, just return success
|
|
if (connStatus === 'connected') {
|
|
return jsonResponse(res, 200, {
|
|
qr_data_url: '',
|
|
session_id: sessionId,
|
|
message: 'Already connected to WhatsApp',
|
|
connected: true,
|
|
});
|
|
}
|
|
|
|
// Start a new connection (resets any existing)
|
|
await startConnection();
|
|
|
|
// Wait briefly for QR to generate (Baileys emits it quickly)
|
|
let waited = 0;
|
|
while (!qrDataUrl && connStatus !== 'connected' && waited < 15_000) {
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
waited += 300;
|
|
}
|
|
|
|
return jsonResponse(res, 200, {
|
|
qr_data_url: qrDataUrl,
|
|
session_id: sessionId,
|
|
message: statusMessage,
|
|
connected: connStatus === 'connected',
|
|
});
|
|
}
|
|
|
|
// GET /login/status — poll for connection status
|
|
if (req.method === 'GET' && pathname === '/login/status') {
|
|
return jsonResponse(res, 200, {
|
|
connected: connStatus === 'connected',
|
|
message: statusMessage,
|
|
expired: qrExpired,
|
|
});
|
|
}
|
|
|
|
// POST /message/send — send outgoing message via Baileys
|
|
if (req.method === 'POST' && pathname === '/message/send') {
|
|
const body = await parseBody(req);
|
|
const { to, text } = body;
|
|
|
|
if (!to || !text) {
|
|
return jsonResponse(res, 400, { error: 'Missing "to" or "text" field' });
|
|
}
|
|
|
|
await sendMessage(to, text);
|
|
return jsonResponse(res, 200, { success: true, message: 'Sent' });
|
|
}
|
|
|
|
// GET /health — health check
|
|
if (req.method === 'GET' && pathname === '/health') {
|
|
return jsonResponse(res, 200, {
|
|
status: 'ok',
|
|
connected: connStatus === 'connected',
|
|
session_id: sessionId || null,
|
|
});
|
|
}
|
|
|
|
// 404
|
|
jsonResponse(res, 404, { error: 'Not found' });
|
|
} catch (err) {
|
|
console.error(`[gateway] ${req.method} ${pathname} error:`, err.message);
|
|
jsonResponse(res, 500, { error: err.message });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, '127.0.0.1', () => {
|
|
console.log(`[gateway] WhatsApp Web gateway listening on http://127.0.0.1:${PORT}`);
|
|
console.log(`[gateway] OpenFang URL: ${OPENFANG_URL}`);
|
|
console.log(`[gateway] Default agent: ${DEFAULT_AGENT}`);
|
|
|
|
// Auto-connect if credentials already exist from a previous session
|
|
const credsPath = path.join(__dirname, 'auth_store', 'creds.json');
|
|
if (fs.existsSync(credsPath)) {
|
|
console.log('[gateway] Found existing credentials — auto-connecting...');
|
|
startConnection().catch((err) => {
|
|
console.error('[gateway] Auto-connect failed:', err.message);
|
|
statusMessage = 'Auto-connect failed. Use POST /login/start to retry.';
|
|
});
|
|
} else {
|
|
console.log('[gateway] No credentials found. Waiting for POST /login/start to begin QR flow...');
|
|
}
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', () => {
|
|
console.log('\n[gateway] Shutting down...');
|
|
if (sock) sock.end();
|
|
server.close(() => process.exit(0));
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
if (sock) sock.end();
|
|
server.close(() => process.exit(0));
|
|
});
|