Add Maria WhatsApp+RAG bridge as a service (LXC 171)
Move the /tmp prototype (Baileys bridge + RAG consumer) into git as a proper sibling project to discord-bridge/: own systemd --user units (whatsapp bridge, rag consumer, dashboard, periodic Drive sync timer), a filesystem document store with a stdlib control dashboard (start/ stop/restart, document CRUD, reindex, Google Drive sync via rclone), and an idempotent ops/install.sh following the same conventions. Co-Authored-By: Claude Agent <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
// Punte WhatsApp (Baileys) pentru Maria — implicit doar self-chat (vezi
|
||||
// TEST_MODE_SELF_CHAT_ONLY). Sesiunea WhatsApp (auth/) NU intra in git —
|
||||
// contine chei de sesiune, e in ~/.maria-bridge/whatsapp-auth/ (vezi config.py).
|
||||
|
||||
const express = require('express');
|
||||
const pino = require('pino');
|
||||
const QRCode = require('qrcode');
|
||||
const path = require('path');
|
||||
|
||||
let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion;
|
||||
|
||||
const PORT = parseInt(process.env.BRIDGE_PORT || '8099', 10);
|
||||
const HOST = process.env.BRIDGE_HOST || '127.0.0.1';
|
||||
const AUTH_DIR = process.env.MARIA_BRIDGE_DIR
|
||||
? path.join(process.env.MARIA_BRIDGE_DIR, 'whatsapp-auth')
|
||||
: path.join(__dirname, 'auth');
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const TEST_MODE_SELF_CHAT_ONLY = (process.env.TEST_MODE_SELF_CHAT_ONLY || 'true') !== 'false';
|
||||
|
||||
const logger = pino({ level: 'warn' });
|
||||
|
||||
let sock = null;
|
||||
let connected = false;
|
||||
let phoneNumber = null;
|
||||
let ownJid = null;
|
||||
let currentQR = null;
|
||||
let currentPairingCode = null;
|
||||
let reconnectAttempts = 0;
|
||||
let messageQueue = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
async function startConnection() {
|
||||
if (!makeWASocket) {
|
||||
const baileys = await import('@whiskeysockets/baileys');
|
||||
makeWASocket = baileys.default;
|
||||
useMultiFileAuthState = baileys.useMultiFileAuthState;
|
||||
DisconnectReason = baileys.DisconnectReason;
|
||||
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
|
||||
}
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
defaultQueryTimeoutMs: 60000,
|
||||
});
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
sock.ev.on('connection.update', async (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
try {
|
||||
currentQR = await QRCode.toDataURL(qr);
|
||||
console.log('[whatsapp] QR code generated — scan with WhatsApp to link');
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Failed to generate QR code:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
connected = true;
|
||||
currentQR = null;
|
||||
reconnectAttempts = 0;
|
||||
phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id?.split('@')[0] || null;
|
||||
ownJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null;
|
||||
console.log(`[whatsapp] Connected as ${phoneNumber} (self-chat-only mode: ${TEST_MODE_SELF_CHAT_ONLY})`);
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
connected = false;
|
||||
phoneNumber = null;
|
||||
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
||||
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
||||
|
||||
console.log(`[whatsapp] Disconnected (status: ${statusCode})`);
|
||||
|
||||
if (shouldReconnect && !shuttingDown) {
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
console.log(`[whatsapp] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||
setTimeout(startConnection, delay);
|
||||
} else {
|
||||
console.error(`[whatsapp] Max reconnect attempts reached (${MAX_RECONNECT_ATTEMPTS})`);
|
||||
}
|
||||
} else if (statusCode === DisconnectReason.loggedOut) {
|
||||
console.log('[whatsapp] Logged out — delete auth dir and restart to re-link');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.key.remoteJid === 'status@broadcast') continue;
|
||||
const isGroup = msg.key.remoteJid.endsWith('@g.us');
|
||||
const isSelfChat = ownJid && msg.key.remoteJid === ownJid;
|
||||
if (TEST_MODE_SELF_CHAT_ONLY && !isSelfChat) continue;
|
||||
if (msg.key.fromMe && !isGroup && !isSelfChat) continue;
|
||||
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
|
||||
if (!text) continue;
|
||||
|
||||
messageQueue.push({
|
||||
from: msg.key.remoteJid,
|
||||
participant: msg.key.participant || null,
|
||||
pushName: msg.pushName || null,
|
||||
text,
|
||||
timestamp: msg.messageTimestamp,
|
||||
id: msg.key.id,
|
||||
isGroup,
|
||||
fromMe: msg.key.fromMe || false,
|
||||
});
|
||||
|
||||
console.log(`[whatsapp] Message from ${msg.pushName || 'unknown'} in ${msg.key.remoteJid}: ${text.substring(0, 80)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
app.get('/status', (_req, res) => {
|
||||
res.json({
|
||||
connected,
|
||||
phone: phoneNumber,
|
||||
selfChatOnly: TEST_MODE_SELF_CHAT_ONLY,
|
||||
qr: connected ? null : currentQR,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/pair', async (req, res) => {
|
||||
if (connected) {
|
||||
return res.json({ error: 'already connected' });
|
||||
}
|
||||
const { phone } = req.body || {};
|
||||
if (!phone) {
|
||||
return res.status(400).json({ error: 'missing "phone" in body' });
|
||||
}
|
||||
if (!sock) {
|
||||
return res.status(503).json({ error: 'socket not ready yet, try again in a few seconds' });
|
||||
}
|
||||
try {
|
||||
const code = await sock.requestPairingCode(phone.replace(/\D/g, ''));
|
||||
currentPairingCode = code;
|
||||
console.log(`[whatsapp] Pairing code for ${phone}: ${code}`);
|
||||
res.json({ ok: true, code });
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Pairing code error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/pair-code', (_req, res) => {
|
||||
if (connected) return res.json({ error: 'already connected' });
|
||||
if (!currentPairingCode) return res.json({ error: 'no pairing code yet — POST /pair first' });
|
||||
res.json({ code: currentPairingCode });
|
||||
});
|
||||
|
||||
app.get('/qr', (_req, res) => {
|
||||
if (connected) {
|
||||
return res.json({ error: 'already connected' });
|
||||
}
|
||||
if (!currentQR) {
|
||||
return res.json({ error: 'no QR code available yet' });
|
||||
}
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><title>WhatsApp QR</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>body{display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#111;flex-direction:column;font-family:sans-serif;color:#fff}
|
||||
img{width:400px;height:400px;border-radius:12px}p{margin-top:16px;opacity:.6}</style></head>
|
||||
<body><img src="${currentQR}" alt="QR Code"/><p>Scan with WhatsApp → Linked Devices</p></body></html>`;
|
||||
res.type('html').send(html);
|
||||
});
|
||||
|
||||
app.post('/send', async (req, res) => {
|
||||
const { to, text } = req.body || {};
|
||||
|
||||
if (!to || !text) {
|
||||
return res.status(400).json({ ok: false, error: 'missing "to" or "text" in body' });
|
||||
}
|
||||
if (!connected || !sock) {
|
||||
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sock.sendMessage(to, { text });
|
||||
res.json({ ok: true, id: result.key.id });
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Send failed:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/react', async (req, res) => {
|
||||
const { to, id, emoji, fromMe, participant } = req.body || {};
|
||||
|
||||
if (!to || !id || emoji == null) {
|
||||
return res.status(400).json({ ok: false, error: 'missing "to", "id", or "emoji" in body' });
|
||||
}
|
||||
if (!connected || !sock) {
|
||||
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
|
||||
}
|
||||
|
||||
try {
|
||||
const key = { remoteJid: to, id, fromMe: fromMe || false };
|
||||
if (participant) key.participant = participant;
|
||||
await sock.sendMessage(to, { react: { text: emoji, key } });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] React failed:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/messages', (_req, res) => {
|
||||
const messages = messageQueue.splice(0);
|
||||
res.json({ messages });
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
console.log(`[whatsapp] Bridge API listening on http://${HOST}:${PORT}`);
|
||||
startConnection().catch((err) => {
|
||||
console.error('[whatsapp] Failed to start connection:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`[whatsapp] Received ${signal}, shutting down...`);
|
||||
shuttingDown = true;
|
||||
if (sock) {
|
||||
sock.end(undefined);
|
||||
}
|
||||
server.close(() => {
|
||||
console.log('[whatsapp] HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => process.exit(1), 5000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "maria-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp bridge for Maria RAG support bot, using Baileys",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^6.7.16",
|
||||
"express": "^4.21.0",
|
||||
"pino": "^9.6.0",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user