Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js
Claude Agent f46410eb58 feat(maria): ALLOW_SELF_CHAT — Maria raspunde doar in grupul de test, nu si in self-chat
`TEST_MODE_SELF_CHAT_ONLY` insemna „self-chat SI grupurile permise"; nu exista
niciun fel de a spune „doar grupurile". `ALLOW_SELF_CHAT=false` (nou, implicit
`true`) scoate chatul cu sine insusi din filtrul puntii.

De ce: `SUPPORT_JID` e tot numarul propriu, deci escaladarile, reamintirile si
raspunsurile suportului cadeau in acelasi chat cu intrebarile — Maria isi citea
propriul canal de suport ca pe o discutie cu un client.

Pus pe `false` in `~/.maria-bridge/env`, puntea repornita: raspunde acum doar in
`120363409761730101@g.us` („Maria Test"). Se vede in `/status` (`allowSelfChat`)
si in linia de conectare din log.

Trimiterea catre suport nu e afectata — filtrul e doar pe mesajele primite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-09-01 21:12:25 +00:00

492 lines
20 KiB
JavaScript

// 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 `<numar>@s.whatsapp.net` si pe
// `<lid>@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>@lid`, deci comparatia doar cu
// `<numar>@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 = `<!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 &rarr; 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 });
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'));