// 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). // // Asocierea se poate face in doua feluri, ambele expuse in dashboard: // - cod QR (GET /status -> qr, sau pagina GET /qr) // - cod de 8 caractere legat de numar (POST /pair {phone}), pentru cand // telefonul e in mana si camera nu poate scana ecranul serverului. // Imaginile primite (capturi cu erori) se descarca in ~/.maria-bridge/media/ si // se pun in coada cu `media`; consumer-ul le trece prin OCR inainte de RAG. const express = require('express'); const pino = require('pino'); const QRCode = require('qrcode'); const fs = require('fs'); const path = require('path'); let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage, Browsers; 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 MEDIA_DIR = process.env.MARIA_BRIDGE_DIR ? path.join(process.env.MARIA_BRIDGE_DIR, 'media') : path.join(__dirname, 'media'); const MAX_RECONNECT_ATTEMPTS = 5; const TEST_MODE_SELF_CHAT_ONLY = (process.env.TEST_MODE_SELF_CHAT_ONLY || 'true') !== 'false'; // Self-chat-ul e comod la primele teste, dar acolo SUPPORT_JID e chiar propriul // numar: escaladarile si raspunsurile suportului ajung in acelasi chat cu // intrebarile clientului. Pe 'false' ramane doar ALLOWED_GROUP_JIDS. const ALLOW_SELF_CHAT = (process.env.ALLOW_SELF_CHAT || 'true') !== 'false'; // Grupuri in care Maria are voie sa raspunda chiar si in modul self-chat. Testarea // intr-un grup dedicat scoate zgomotul din chatul „Eu" — dar numai grupurile de // aici trec, ca sa nu inceapa Maria sa vorbeasca in cele 197 de grupuri ale contului. const ALLOWED_GROUP_JIDS = new Set( (process.env.ALLOWED_GROUP_JIDS || '').split(/[,;]/).map((s) => s.trim()).filter(Boolean), ); // WhatsApp invalideaza codul de asociere in cateva minute. Il tinem afisat doar // atat: un cod expirat afisat in dashboard trimite omul sa tasteze degeaba. const PAIRING_CODE_TTL_MS = 3 * 60 * 1000; const MAX_MEDIA_MB = parseFloat(process.env.MAX_MEDIA_MB || '8'); // Fisierele descarcate sunt temporare: consumer-ul le sterge dupa OCR. Curatarea // de la pornire prinde ce a ramas dupa un restart in mijlocul procesarii. const MEDIA_TTL_MS = 24 * 60 * 60 * 1000; const logger = pino({ level: 'warn' }); let sock = null; let connected = false; let phoneNumber = null; let ownJid = null; let ownLid = null; // acelasi cont, adresat in formatul nou LID let currentQR = null; let pairing = null; // { code, phone, at } — vezi PAIRING_CODE_TTL_MS let reconnectAttempts = 0; let messageQueue = []; // Acelasi mesaj din self-chat soseste si pe `@s.whatsapp.net` si pe // `@lid` — doua livrari, acelasi `key.id`. Fara filtrul asta, consumer-ul // raspundea de doua ori la fiecare intrebare. let seenIds = []; const SEEN_IDS_MAX = 400; let shuttingDown = false; function alreadySeen(id) { if (!id) return false; if (seenIds.includes(id)) return true; seenIds.push(id); if (seenIds.length > SEEN_IDS_MAX) seenIds = seenIds.slice(-SEEN_IDS_MAX); return false; } function activePairing() { if (!pairing) return null; if (Date.now() - pairing.at > PAIRING_CODE_TTL_MS) return null; return pairing; } function pruneMediaDir() { try { fs.mkdirSync(MEDIA_DIR, { recursive: true }); const now = Date.now(); for (const name of fs.readdirSync(MEDIA_DIR)) { const p = path.join(MEDIA_DIR, name); try { if (now - fs.statSync(p).mtimeMs > MEDIA_TTL_MS) fs.unlinkSync(p); } catch (_) { /* fisier disparut intre listare si stat */ } } } catch (err) { console.error('[whatsapp] Nu pot curata directorul media:', err.message); } } // Mesajele reale sosesc uneori impachetate (mesaje efemere, "vezi o data"). // Fara despachetare, o captura de ecran trimisa cu dispariție automata pare // un mesaj fara continut si se arunca tacut. function unwrap(message) { let m = message; for (let i = 0; i < 5 && m; i++) { const inner = m.ephemeralMessage?.message || m.viewOnceMessage?.message || m.viewOnceMessageV2?.message || m.viewOnceMessageV2Extension?.message || m.documentWithCaptionMessage?.message; if (!inner) break; m = inner; } return m || {}; } function extForMime(mimetype) { const map = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', 'image/bmp': '.bmp', }; return map[(mimetype || '').split(';')[0].toLowerCase()] || '.bin'; } /** Nodul de imagine al unui mesaj, fie el `imageMessage` sau un document cu mimetype de imagine. */ function imageNode(message) { const m = unwrap(message); if (m.imageMessage) return { node: m.imageMessage, kind: 'image' }; const doc = m.documentMessage; if (doc && (doc.mimetype || '').startsWith('image/')) return { node: doc, kind: 'document' }; return null; } async function saveImage(msg, node) { const bytes = Number(node.fileLength || 0); if (bytes > MAX_MEDIA_MB * 1024 * 1024) { throw new Error(`imagine prea mare (${(bytes / 1048576).toFixed(1)} MB > ${MAX_MEDIA_MB} MB)`); } const buffer = await downloadMediaMessage( msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }, ); fs.mkdirSync(MEDIA_DIR, { recursive: true }); const safeId = String(msg.key.id || Date.now()).replace(/[^A-Za-z0-9_-]/g, ''); const file = path.join(MEDIA_DIR, `${Date.now()}_${safeId}${extForMime(node.mimetype)}`); fs.writeFileSync(file, buffer, { mode: 0o600 }); return { path: file, mimetype: node.mimetype || null, bytes: buffer.length }; } async function startConnection() { if (!makeWASocket) { const baileys = await import('@whiskeysockets/baileys'); makeWASocket = baileys.default; useMultiFileAuthState = baileys.useMultiFileAuthState; DisconnectReason = baileys.DisconnectReason; fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion; downloadMediaMessage = baileys.downloadMediaMessage; Browsers = baileys.Browsers; } const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); const { version } = await fetchLatestBaileysVersion(); sock = makeWASocket({ version, auth: state, logger, printQRInTerminal: false, // Asocierea prin cod de 8 caractere e refuzata de WhatsApp daca descriptorul // de browser nu e unul cunoscut; `Browsers.ubuntu('Chrome')` e cel folosit de // exemplele Baileys. Nu afecteaza asocierea prin QR. browser: Browsers.ubuntu('Chrome'), 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; pairing = null; reconnectAttempts = 0; phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id?.split('@')[0] || null; ownJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null; // WhatsApp adreseaza conturile si prin LID (Linked ID), nu doar prin numar. Pe // conturile noi, self-chat-ul soseste ca `@lid`, deci comparatia doar cu // `@s.whatsapp.net` arunca toate mesajele. Sufixul de dispozitiv (":13") // apare in credentiale dar nu si in remoteJid-ul mesajelor — il taiem. const rawLid = sock.user?.lid || sock.authState?.creds?.me?.lid || null; ownLid = rawLid ? `${rawLid.split(':')[0].split('@')[0]}@lid` : null; console.log(`[whatsapp] Connected as ${phoneNumber} (lid: ${ownLid || 'n/a'}, self-chat-only mode: ${TEST_MODE_SELF_CHAT_ONLY}, self-chat: ${ALLOW_SELF_CHAT ? 'da' : 'oprit'}, grupuri: ${[...ALLOWED_GROUP_JIDS].join(', ') || '-'})`); } if (connection === 'close') { connected = false; phoneNumber = null; ownLid = null; const statusCode = lastDisconnect?.error?.output?.statusCode; const shouldReconnect = statusCode !== DisconnectReason.loggedOut; console.log(`[whatsapp] Disconnected (status: ${statusCode})`); if (shouldReconnect && !shuttingDown) { // Dupa introducerea codului de asociere WhatsApp cere explicit un restart // (515). Nu e o eroare si nu trebuie sa consume din bugetul de reincercari, // altfel o asociere reusita poate opri puntea la a cincea incercare. const isRestartRequired = statusCode === DisconnectReason.restartRequired; if (isRestartRequired) { console.log('[whatsapp] Restart cerut de server (asociere finalizata?) — reconectare imediata'); setTimeout(startConnection, 500); } else 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', async ({ 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) || (ownLid && msg.key.remoteJid === ownLid); // Mesajele aruncate se logheaza. Fara asta, un JID care nu se potriveste cu // ownJid (ex. formatul @lid) face bridge-ul sa taca fara nicio urma in log. const isAllowedGroup = isGroup && ALLOWED_GROUP_JIDS.has(msg.key.remoteJid); if (isSelfChat && !ALLOW_SELF_CHAT) { console.log(`[whatsapp] ignorat (self-chat oprit): remoteJid=${msg.key.remoteJid}`); continue; } if (TEST_MODE_SELF_CHAT_ONLY && !isSelfChat && !isAllowedGroup) { console.log(`[whatsapp] ignorat (nu e self-chat): remoteJid=${msg.key.remoteJid} ownJid=${ownJid} ownLid=${ownLid}`); continue; } if (msg.key.fromMe && !isGroup && !isSelfChat) { console.log(`[whatsapp] ignorat (fromMe, nu e self-chat): remoteJid=${msg.key.remoteJid}`); continue; } if (alreadySeen(msg.key.id)) { console.log(`[whatsapp] ignorat (duplicat ${msg.key.id}): remoteJid=${msg.key.remoteJid}`); continue; } const inner = unwrap(msg.message); const img = imageNode(msg.message); let text = inner.conversation || inner.extendedTextMessage?.text || ''; let media = null; if (img) { // Legenda imaginii e adesea tot ce spune omul („nu merge asta"). text = img.node.caption || text || ''; try { media = await saveImage(msg, img.node); console.log(`[whatsapp] Imagine primita de la ${msg.key.remoteJid}: ${media.path} (${media.bytes} octeti, ${media.mimetype})`); } catch (err) { console.error(`[whatsapp] Descarcarea imaginii a esuat (${msg.key.remoteJid}): ${err.message}`); media = { error: err.message }; } } if (!text && !media) { console.log(`[whatsapp] ignorat (fara text): remoteJid=${msg.key.remoteJid} tipuri=${Object.keys(inner || {}).join(',')}`); continue; } messageQueue.push({ from: msg.key.remoteJid, participant: msg.key.participant || null, pushName: msg.pushName || null, text, media, timestamp: msg.messageTimestamp, id: msg.key.id, isGroup, fromMe: msg.key.fromMe || false, }); // Taiat, dar marcat: 80 de caractere fix aratau ca un mesaj intreg, iar // logul e primul loc in care te uiti cand un raspuns iese prost. Pe // 2026-09-01 asta a dus la concluzia gresita ca gardul de „mesaj prea vag" // nu functioneaza, cand de fapt mesajul era mai lung decat parea in log. const scurt = text.length > 200 ? `${text.substring(0, 200)}… (+${text.length - 200})` : text; console.log(`[whatsapp] Message from ${msg.pushName || 'unknown'} in ${msg.key.remoteJid}: ${media ? '[imagine] ' : ''}${scurt}`); } }); } const app = express(); app.use(express.json({ limit: '50mb' })); app.get('/status', (_req, res) => { const p = activePairing(); res.json({ connected, phone: phoneNumber, selfChatOnly: TEST_MODE_SELF_CHAT_ONLY, allowSelfChat: ALLOW_SELF_CHAT, allowedGroups: [...ALLOWED_GROUP_JIDS], lid: ownLid, qr: connected ? null : currentQR, pairing: connected || !p ? null : { code: p.code, phone: p.phone, expires_in: Math.max(0, Math.round((PAIRING_CODE_TTL_MS - (Date.now() - p.at)) / 1000)), }, }); }); app.post('/pair', async (req, res) => { if (connected) { return res.status(409).json({ ok: false, error: 'deja conectat' }); } const { phone } = req.body || {}; const digits = String(phone || '').replace(/\D/g, ''); // Numarul trebuie dat in format international, fara `+` si fara `00`: 40723197939. if (digits.length < 8 || digits.length > 15) { return res.status(400).json({ ok: false, error: 'numar invalid — dă-l international, fara "+" (ex: 40723197939)' }); } if (!sock) { return res.status(503).json({ ok: false, error: 'puntea inca porneste, reincearca in cateva secunde' }); } if (sock.authState?.creds?.registered) { return res.status(409).json({ ok: false, error: 'sesiunea e deja inregistrata — sterge whatsapp-auth/ si reporneste puntea' }); } try { const code = await sock.requestPairingCode(digits); pairing = { code, phone: digits, at: Date.now() }; console.log(`[whatsapp] Cod de asociere pentru ${digits}: ${code}`); res.json({ ok: true, code, phone: digits, expires_in: PAIRING_CODE_TTL_MS / 1000 }); } catch (err) { console.error('[whatsapp] Cererea codului de asociere a esuat:', err.message); res.status(500).json({ ok: false, error: err.message }); } }); app.get('/pair-code', (_req, res) => { if (connected) return res.json({ error: 'already connected' }); const p = activePairing(); if (!p) return res.json({ error: 'niciun cod valabil — cere unul cu POST /pair' }); res.json({ code: p.code, phone: p.phone }); }); 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 = ` WhatsApp QR QR Code

Scan with WhatsApp → Linked Devices

`; 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 }); console.log(`[whatsapp] -> ${to}: ${text.substring(0, 120)}${text.length > 120 ? '…' : ''}`); 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 }); } }); // Retrimiterea unei capturi primite catre altcineva (escaladare la suport). // Accepta doar cai din MEDIA_DIR: puntea nu are motiv sa trimita orice fisier de // pe disc, iar cine ajunge la API-ul ei nu trebuie sa poata cere /etc/passwd. app.post('/send-image', async (req, res) => { const { to, path: filePath, caption } = req.body || {}; if (!to || !filePath) { return res.status(400).json({ ok: false, error: 'missing "to" or "path" in body' }); } if (!connected || !sock) { return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' }); } const resolved = path.resolve(filePath); if (resolved !== path.join(MEDIA_DIR, path.basename(resolved))) { return res.status(400).json({ ok: false, error: 'calea trebuie sa fie un fisier din MEDIA_DIR' }); } try { const buffer = fs.readFileSync(resolved); const result = await sock.sendMessage(to, { image: buffer, caption: caption || undefined }); console.log(`[whatsapp] -> ${to}: [imagine ${buffer.length} octeti] ${(caption || '').substring(0, 100)}`); res.json({ ok: true, id: result.key.id }); } catch (err) { console.error('[whatsapp] Send image 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 }); } }); // Grupurile din care face parte contul, cu JID-ul lor. WhatsApp nu arata nicaieri // JID-ul unui grup ("120363...@g.us"), iar el e singurul mod de a-l scrie in // SUPPORT_JID: de aici se citeste, o data, cand se leaga escaladarile de un grup. app.get('/groups', async (_req, res) => { if (!connected || !sock) { return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' }); } try { const all = await sock.groupFetchAllParticipating(); const groups = Object.values(all) .map((g) => ({ jid: g.id, subject: g.subject, participants: g.participants?.length || 0 })) .sort((a, b) => (a.subject || '').localeCompare(b.subject || '')); res.json({ ok: true, groups }); } catch (err) { console.error('[whatsapp] Group fetch 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}`); pruneMediaDir(); 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'));