feat(maria): asociere prin cod de telefon si citirea capturilor cu erori
Doua lucruri cerute de utilizator, ambele pe acelasi drum: sa poti lega
puntea fara sa ai un ecran de scanat, si sa poti trimite Mariei o poza cu
eroarea in loc sa transcrii mesajul.
Asociere prin cod de 8 caractere (alternativa la QR):
- endpoint-ul /pair exista, dar nu era folosibil: fara `browser` explicit
WhatsApp refuza codul, iar dupa introducerea lui corecta serverul cere un
restart (515) care consuma din bugetul de reincercari si putea opri puntea.
Acum descriptorul e Browsers.ubuntu('Chrome') si restartRequired reconecteaza
imediat, fara sa numere.
- codul are TTL de 3 minute, iar /status il da doar cat timp e valabil —
un cod expirat afisat in dashboard trimite omul sa tasteze degeaba.
- dashboard: camp pentru numar + buton, in acelasi card cu QR-ul.
Imagini cu erori (capturi de ecran):
- puntea descarca imaginile in ~/.maria-bridge/media/ (imageMessage sau
document cu mimetype image/*, si prin ambalajele efemer/"vezi o data" —
fara despachetare pareau mesaje fara continut si se aruncau tacut).
- rag/ocr.py: tesseract ron+eng. Modelul de raspuns e strict text, deci OCR
nu e o optiune de calitate, e singura cale.
- cautarea in index merge DOAR pe liniile care arata a eroare; o fereastra
intreaga de meniuri si totaluri dilueaza embedding-ul si scoate chunk-uri
fara legatura. Modelul primeste captura intreaga, marcata ca text OCR.
- capturile se sterg imediat dupa citire (pot contine date de client).
- cand nu se citeste nimic si nu exista legenda, Maria cere textul erorii
in loc sa raspunda in gol.
16 teste noi (42 in total). install.sh verifica tesseract; README documenteaza
ambele metode de asociere si drumul unei capturi.
Separat, in docs/chatboti-si-punti.md: chatul "Eu" e vazut de AMBELE punti de pe
numar. Puntea lui Echo (LXC 110) e asociata ca dispozitiv :11 al aceluiasi cont,
momentan nelegata dar pornita — daca se reasociaza, raspunde in "Eu" langa Maria.
Notat si ca serviciile lui Echo sunt unitati de UTILIZATOR: `systemctl is-active`
ca root raspunde "inactive" desi botul ruleaza.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
This commit is contained in:
@@ -1,21 +1,40 @@
|
||||
// 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;
|
||||
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';
|
||||
// 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' });
|
||||
|
||||
@@ -25,11 +44,82 @@ let phoneNumber = null;
|
||||
let ownJid = null;
|
||||
let ownLid = null; // acelasi cont, adresat in formatul nou LID
|
||||
let currentQR = null;
|
||||
let currentPairingCode = null;
|
||||
let pairing = null; // { code, phone, at } — vezi PAIRING_CODE_TTL_MS
|
||||
let reconnectAttempts = 0;
|
||||
let messageQueue = [];
|
||||
let shuttingDown = 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');
|
||||
@@ -37,6 +127,8 @@ async function startConnection() {
|
||||
useMultiFileAuthState = baileys.useMultiFileAuthState;
|
||||
DisconnectReason = baileys.DisconnectReason;
|
||||
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
|
||||
downloadMediaMessage = baileys.downloadMediaMessage;
|
||||
Browsers = baileys.Browsers;
|
||||
}
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
@@ -47,6 +139,10 @@ async function startConnection() {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -67,6 +163,7 @@ async function startConnection() {
|
||||
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;
|
||||
@@ -89,7 +186,14 @@ async function startConnection() {
|
||||
console.log(`[whatsapp] Disconnected (status: ${statusCode})`);
|
||||
|
||||
if (shouldReconnect && !shuttingDown) {
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
// 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})`);
|
||||
@@ -103,7 +207,7 @@ async function startConnection() {
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
||||
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
@@ -121,9 +225,26 @@ async function startConnection() {
|
||||
console.log(`[whatsapp] ignorat (fromMe, nu e self-chat): remoteJid=${msg.key.remoteJid}`);
|
||||
continue;
|
||||
}
|
||||
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
|
||||
if (!text) {
|
||||
console.log(`[whatsapp] ignorat (fara text): remoteJid=${msg.key.remoteJid} tipuri=${Object.keys(msg.message || {}).join(',')}`);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -132,13 +253,14 @@ async function startConnection() {
|
||||
participant: msg.key.participant || null,
|
||||
pushName: msg.pushName || null,
|
||||
text,
|
||||
media,
|
||||
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)}`);
|
||||
console.log(`[whatsapp] Message from ${msg.pushName || 'unknown'} in ${msg.key.remoteJid}: ${media ? '[imagine] ' : ''}${text.substring(0, 80)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -147,41 +269,53 @@ 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,
|
||||
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.json({ error: 'already connected' });
|
||||
return res.status(409).json({ ok: false, error: 'deja conectat' });
|
||||
}
|
||||
const { phone } = req.body || {};
|
||||
if (!phone) {
|
||||
return res.status(400).json({ error: 'missing "phone" in 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({ error: 'socket not ready yet, try again in a few seconds' });
|
||||
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(phone.replace(/\D/g, ''));
|
||||
currentPairingCode = code;
|
||||
console.log(`[whatsapp] Pairing code for ${phone}: ${code}`);
|
||||
res.json({ ok: true, code });
|
||||
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] Pairing code error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
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' });
|
||||
if (!currentPairingCode) return res.json({ error: 'no pairing code yet — POST /pair first' });
|
||||
res.json({ code: currentPairingCode });
|
||||
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) => {
|
||||
@@ -247,6 +381,7 @@ app.get('/messages', (_req, res) => {
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user