Files
openfang/packages/whatsapp-gateway/index.js
iven 92e5def702
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
初始化提交
2026-03-01 16:24:24 +08:00

353 lines
12 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const http = require('node:http');
const { randomUUID } = require('node:crypto');
// ---------------------------------------------------------------------------
// 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';
// ---------------------------------------------------------------------------
// Baileys connection
// ---------------------------------------------------------------------------
async function startConnection() {
// Dynamic imports — Baileys is ESM-only in v6+
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } =
await import('@whiskeysockets/baileys');
const QRCode = (await import('qrcode')).default || await import('qrcode');
const pino = (await import('pino')).default || await import('pino');
const logger = pino({ level: 'warn' });
const authDir = require('node:path').join(__dirname, 'auth_store');
const { state, saveCreds } = await useMultiFileAuthState(
require('node:path').join(__dirname, 'auth_store')
);
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
connStatus = 'disconnected';
statusMessage = 'Logged out. Generate a new QR code to reconnect.';
qrDataUrl = '';
sock = null;
// Remove auth store so next connect gets a fresh QR
const fs = require('node:fs');
const path = require('node:path');
const authPath = path.join(__dirname, 'auth_store');
if (fs.existsSync(authPath)) {
fs.rmSync(authPath, { recursive: true, force: true });
}
} else if (statusCode === DisconnectReason.restartRequired ||
statusCode === DisconnectReason.timedOut) {
// Recoverable — reconnect automatically
console.log('[gateway] Reconnecting...');
statusMessage = 'Reconnecting...';
setTimeout(() => startConnection(), 2000);
} else {
// QR expired or other non-recoverable close
qrExpired = true;
connStatus = 'disconnected';
statusMessage = 'QR code expired. Click "Generate New QR" to retry.';
qrDataUrl = '';
}
}
if (connection === 'open') {
connStatus = 'connected';
qrExpired = false;
qrDataUrl = '';
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 sender = msg.key.remoteJid || '';
const text = msg.message?.conversation
|| msg.message?.extendedTextMessage?.text
|| msg.message?.imageMessage?.caption
|| '';
if (!text) continue;
// Extract phone number from JID (e.g. "1234567890@s.whatsapp.net" → "+1234567890")
const phone = '+' + sender.replace(/@.*$/, '');
const pushName = msg.pushName || phone;
console.log(`[gateway] Incoming from ${pushName} (${phone}): ${text.substring(0, 80)}`);
// Forward to OpenFang agent
try {
const response = await forwardToOpenFang(text, phone, pushName);
if (response && sock) {
// Send agent response back to WhatsApp
await sock.sendMessage(sender, { text: response });
console.log(`[gateway] Replied to ${pushName}`);
}
} catch (err) {
console.error(`[gateway] Forward/reply failed:`, err.message);
}
}
});
}
// ---------------------------------------------------------------------------
// Forward incoming message to OpenFang API, return agent response
// ---------------------------------------------------------------------------
function forwardToOpenFang(text, phone, pushName) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
message: text,
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');
}
// Normalize phone → JID: "+1234567890" → "1234567890@s.whatsapp.net"
const jid = to.replace(/^\+/, '').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 path = url.pathname;
try {
// POST /login/start — start Baileys connection, return QR
if (req.method === 'POST' && path === '/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' && path === '/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' && path === '/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' && path === '/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} ${path} 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}`);
console.log('[gateway] 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));
});