diff --git a/docs/chatboti-si-punti.md b/docs/chatboti-si-punti.md
index c683f08..e88dfda 100644
--- a/docs/chatboti-si-punti.md
+++ b/docs/chatboti-si-punti.md
@@ -141,10 +141,33 @@ tail -20 ~/.maria-bridge/logs/rag.log
curl -s http://10.0.20.161:8091/v1/models
```
-**Regula:** WhatsApp-ul lui Echo (LXC 110) și cel al Mariei (LXC 104) sunt legate la
+**Regula:** WhatsApp-ul lui Echo (LXC 110) și cel al Mariei (LXC 171) sunt legate la
**același număr**, `+40723197939`, ca dispozitive diferite ale aceluiași cont. Un mesaj
poate ajunge la ambele. Dacă primești două răspunsuri, două punți sunt active simultan.
+### Chatul „Eu" (self-chat) le vede amândouă
+
+Ambele punți sunt *dispozitive conectate* ale aceluiași cont, deci **amândouă primesc și
+mesajele din chatul cu tine însuți**. Nu există o setare care să dea self-chat-ul doar
+uneia — separarea trebuie făcută oprind una din punți.
+
+Starea la 2026-08-31, verificată: puntea lui Echo era asociată ca dispozitiv `:11` al
+numărului (`auth/creds.json` → `me.id = 40723197939:11`) și a răspuns în „Eu" până
+dimineața, când sesiunea i-a căzut (`registered: false`, `connected: false`). Serviciul
+rulează în continuare și `echo-core` îi cere mesajele la câteva minute — dacă cineva îl
+reasociază, Echo revine în „Eu" lângă Maria, fără niciun avertisment.
+
+Pentru a-l scoate definitiv din WhatsApp (Telegram și Discord rămân neatinse):
+```bash
+ssh root@10.0.20.200 "pct exec 110 -- sudo -u moltbot XDG_RUNTIME_DIR=/run/user/1000 \
+ systemctl --user disable --now echo-whatsapp-bridge"
+```
+
+**Atenție la scope-ul systemd pe LXC 110:** serviciile lui Echo sunt unități de
+**utilizator** (`moltbot`, uid 1000). Un `systemctl is-active echo-core` rulat ca root
+răspunde `inactive` deși botul rulează — exact genul de răspuns care trimite diagnosticul
+pe pistă falsă. Folosește forma de mai sus, cu `--user` și `XDG_RUNTIME_DIR`.
+
---
## Capcane
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
index ddea518..617e6f3 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
+++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
@@ -98,6 +98,29 @@ def maria_whatsapp_status() -> dict:
return {"connected": False, "phone": None, "qr": None, "reachable": False}
+def maria_request_pairing_code(phone: str) -> tuple[dict, int]:
+ """Cere puntii un cod de asociere de 8 caractere pentru `phone`.
+
+ Dashboard-ul nu decide nimic despre numar — puntea valideaza si raspunde; noi
+ doar transmitem, ca sa nu existe doua reguli de validare care se pot desincroniza.
+ """
+ body = json.dumps({"phone": phone}).encode("utf-8")
+ req = urllib.request.Request(
+ f"{maria_bridge_url()}/pair", data=body,
+ headers={"Content-Type": "application/json"}, method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return json.loads(resp.read().decode("utf-8")), resp.status
+ except urllib.error.HTTPError as exc:
+ try:
+ return json.loads(exc.read().decode("utf-8")), exc.code
+ except ValueError:
+ return {"ok": False, "error": f"puntea a raspuns {exc.code}"}, exc.code
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
+ return {"ok": False, "error": f"puntea WhatsApp nu raspunde: {exc}"}, 503
+
+
def maria_index_info() -> dict:
try:
entries = json.loads(MARIA_INDEX_FILE.read_text(encoding="utf-8"))
@@ -710,6 +733,8 @@ class Handler(SimpleHTTPRequestHandler):
return self.handle_decide()
if path == "/api/maria/service":
return self.handle_maria_service()
+ if path == "/api/maria/pair":
+ return self.handle_maria_pair()
if path == "/api/maria/documents":
return self.handle_maria_document_write()
if path == "/api/maria/documents/delete":
@@ -803,6 +828,16 @@ class Handler(SimpleHTTPRequestHandler):
time.sleep(1.0)
return self.send_json({"ok": True, "action": action, "service": unit_info(unit)})
+ def handle_maria_pair(self):
+ """Cod de asociere WhatsApp — alternativa la QR, cand nu poti scana ecranul."""
+ data = self.read_json()
+ phone = str(data.get("phone") or "")
+ who = tailnet_user(self.headers) or "local"
+ print(f"[actiune] cod de asociere WhatsApp pentru {phone}, cerut de {who}",
+ file=sys.stderr, flush=True)
+ out, status = maria_request_pairing_code(phone)
+ return self.send_json(out, status)
+
def handle_maria_document_write(self):
data = self.read_json()
name = str(data.get("name") or "")
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
index 2b68387..c5165d0 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
+++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
@@ -99,6 +99,19 @@
Conectare WhatsApp
se încarcă…
+
+
+ Nu poți scana codul QR? Cere un cod de asociere și
+ introdu-l pe telefon în WhatsApp → Dispozitive conectate → Conectează un
+ dispozitiv → Conectează cu număr de telefon.
+
tastează-l pe telefon'
+ + (expiresIn ? ' — expiră în ~' + Math.round(expiresIn / 60) + ' min' : '')
+ + '. Dacă expiră, cere altul.';
+}
+
+async function mPair() {
+ var phone = document.getElementById('mPairPhone').value.trim();
+ var out = document.getElementById('mPairOut');
+ out.textContent = 'cer codul…';
+ var res = await post('api/maria/pair', { phone: phone });
+ if (res.ok && res.data && res.data.code) {
+ mShowPairCode(res.data.code, res.data.expires_in);
+ } else {
+ out.innerHTML = 'eroare '
+ + esc((res.data && res.data.error) || 'nu am putut cere codul');
+ }
+ mRefresh();
+}
+
async function mRefresh() {
var res = await api('api/maria/status');
if (!res.ok) return;
@@ -364,9 +402,21 @@ async function mRefresh() {
var wa = s.whatsapp || {};
var waStatus = document.getElementById('mWaStatus');
var waQr = document.getElementById('mWaQr');
+ // Cardul de asociere are rost doar cât timp puntea rulează și nu e conectată.
+ document.getElementById('mWaPair').hidden = !!wa.connected || wa.reachable === false;
+ var pairOut = document.getElementById('mPairOut');
+ if (wa.pairing && wa.pairing.code) {
+ mShowPairCode(wa.pairing.code, wa.pairing.expires_in);
+ } else if (!wa.connected && pairOut.dataset.code) {
+ // Codul afisat a expirat la punte. Lasat pe ecran, ar trimite omul sa
+ // tasteze un cod pe care WhatsApp il refuza fara sa spuna de ce.
+ pairOut.innerHTML = 'codul a expirat — cere altul';
+ delete pairOut.dataset.code;
+ }
if (wa.connected) {
waStatus.innerHTML = 'conectat ca ' + esc(wa.phone || '?');
waQr.innerHTML = '';
+ document.getElementById('mPairOut').innerHTML = '';
} else if (wa.qr) {
waStatus.innerHTML = 'neconectat — scanează codul QR din WhatsApp → Dispozitive conectate';
waQr.innerHTML = '';
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
index 0d25704..ff1a956 100644
--- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
@@ -60,12 +60,16 @@ embeddings.
WhatsApp (self-chat, sau numarul legat)
|
v
-whatsapp/index.js (Baileys) -- API HTTP :8099 (/status /send /messages /react /qr)
+whatsapp/index.js (Baileys) -- API HTTP :8099 (/status /send /messages /react /qr /pair)
+ | descarca imaginile primite in ~/.maria-bridge/media/
|
v
rag/consumer.py -- polling la /messages, RAG stateless (FARA memorie intre mesaje)
| vezi docs/maria-whatsapp-rag-prototype.md pentru motiv
v
+rag/ocr.py -- capturile de ecran -> text (tesseract), inainte de RAG
+ |
+ v
rag/store.py -- depozit documente (.txt/.md) in ~/.maria-bridge/documents/
rag/indexer.py -- chunking + embeddings Ollama -> rag_index.json
rag/sync.py -- rclone pull din Google Drive + reindexare conditionata
@@ -104,15 +108,77 @@ curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text # ~274 MB, CPU-only pe acest container
```
-Bridge-ul WhatsApp NU porneste automat la instalare — cere scanarea unui cod QR
+Pentru citirea capturilor de ecran primite (vezi „Imagini cu erori" mai jos):
+```bash
+sudo apt-get install -y tesseract-ocr tesseract-ocr-ron
+```
+
+Bridge-ul WhatsApp NU porneste automat la instalare — cere asocierea cu telefonul
(actiune manuala, o singura data):
```bash
./ops/install.sh --start
-# apoi deschide dashboard-ul puntii Discord si scaneaza codul QR din
-# sectiunea "Maria — WhatsApp + RAG" -> cardul "Conectare WhatsApp"
+# apoi deschide dashboard-ul puntii Discord, sectiunea "Maria — WhatsApp + RAG"
+# -> cardul "Conectare WhatsApp", si alege una din cele doua metode (mai jos)
```
+## Asocierea cu telefonul: QR sau cod
+
+Ambele duc la acelasi rezultat — puntea devine un *dispozitiv conectat* al contului.
+Alegerea e practica, nu tehnica:
+
+| | Cand se foloseste |
+|---|---|
+| **Cod QR** | Ai dashboard-ul deschis pe un ecran pe care telefonul il poate fotografia. |
+| **Cod de asociere** (8 caractere) | Esti pe telefon, sau ecranul cu QR-ul e la distanta: scrii numarul in dashboard, primesti un cod si il tastezi pe telefon. |
+
+Pentru codul de asociere, in dashboard: scrie numarul **in format international,
+fara `+` si fara `00`** (ex. `40723197939`), apasa „Cere cod de asociere", apoi pe
+telefon: WhatsApp -> Dispozitive conectate -> Conecteaza un dispozitiv ->
+**Conecteaza cu numar de telefon** -> tastezi codul.
+
+Codul e valabil **~3 minute**; dupa ce expira, dashboard-ul il marcheaza ca expirat
+si trebuie cerut altul. Dupa introducerea corecta, WhatsApp inchide conexiunea cu
+codul `restartRequired` (515) — puntea se reconecteaza singura, imediat; nu e o
+eroare si nu consuma din bugetul de reincercari.
+
+Din linia de comanda, aceleasi lucruri:
+```bash
+curl -s -X POST -H 'Content-Type: application/json' \
+ -d '{"phone":"40723197939"}' http://127.0.0.1:8099/pair
+curl -s http://127.0.0.1:8099/status | python3 -m json.tool # cod + secunde ramase
+```
+
+Codul se poate cere doar cat timp sesiunea NU e inregistrata. Daca puntea raspunde
+„sesiunea e deja inregistrata", opreste-o, sterge `~/.maria-bridge/whatsapp-auth/`
+si porneste-o din nou — dar atentie, asta desface asocierea existenta.
+
+## Imagini cu erori
+
+Utilizatorii trimit aproape intotdeauna o captura cu fereastra de eroare, nu textul
+ei. Puntea descarca imaginea in `~/.maria-bridge/media/`, iar consumer-ul o trece
+prin **tesseract** (`ron+eng`) inainte de RAG. Modelul de raspuns (Qwen3.5-2B pe
+LXC 104) e strict text, deci OCR-ul e singura cale — nu e o optiune de calitate.
+
+Doua detalii care nu se vad din cod la prima citire:
+
+- **Cautarea in index nu foloseste toata captura.** Un ecran intreg de meniuri,
+ coloane si totaluri dilueaza embedding-ul si scoate chunk-uri fara legatura. Se
+ cauta doar dupa liniile care arata a eroare (`ORA-…`, „eroare", „nu exista", …);
+ modelul primeste totusi fereastra intreaga, marcata explicit ca text OCR, ca sa nu
+ trateze greselile de recunoastere ca date exacte. Vezi `rag/ocr.py`.
+- **Capturile se sterg imediat dupa citire.** Pot contine date de client si nu exista
+ niciun motiv sa ramana pe disc. Ce ramane dupa un restart in mijlocul procesarii se
+ curata la pornirea puntii (dupa 24h).
+
+Legenda imaginii, daca exista, conteaza: intra si in intrebare si in cautare. Daca
+OCR-ul nu gaseste nimic lizibil si nu exista legenda, Maria cere textul erorii in
+loc sa inventeze un raspuns.
+
+Limite: `MAX_MEDIA_MB` (implicit 8) pentru imaginea bruta, `OCR_MAX_CHARS`
+(implicit 1500) pentru textul trimis modelului, `OCR_TIMEOUT_S` (60).
+Videoclipurile, audio si documentele non-imagine sunt in continuare ignorate.
+
## Dashboard (comun cu puntea Discord)
Nu exista un dashboard separat pentru Maria. Controlul se face din dashboard-ul
@@ -120,7 +186,7 @@ puntii Discord — vezi `../discord-bridge/README.md` pentru URL si autentificar
(`DASHBOARD_TOKEN` din `~/.claude-discord/env`, tunel SSH sau Tailscale la
`/claude`). Acolo, sectiunea "Maria — WhatsApp + RAG":
- start/stop/restart pentru puntea WhatsApp si consumer-ul RAG
-- starea conexiunii WhatsApp si codul QR de asociere (cand nu e conectat)
+- starea conexiunii WhatsApp si asocierea (cod QR sau cod de 8 caractere), cand nu e conectat
- listare, adaugare si stergere documente din depozitul RAG
- reconstruire index manual, sau sincronizare Drive imediata
- ultimele linii din logurile fiecarui serviciu Maria (`whatsapp.log`/`rag.log`)
@@ -207,7 +273,8 @@ mtime + marime, vezi `rag/sync.py`), ca sa nu reface embeddings degeaba.
```bash
cd /workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge
-python3 -m pytest # preferinta de format + taierea XML, fara retea si fara Ollama
+python3 -m pytest # preferinta de format, taierea XML, OCR si mesajele cu imagine
+ # — fara retea, fara Ollama si fara tesseract
```
## Context conversational
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example
index ca305f7..fc79915 100644
--- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example
@@ -10,6 +10,16 @@ BRIDGE_PORT=8099
# dupa ce esti multumit de calitatea raspunsurilor — altfel Maria raspunde la
# oricine iti scrie pe numarul legat.
TEST_MODE_SELF_CHAT_ONLY=true
+# Imaginile primite (capturi cu erori) peste aceasta marime sunt refuzate, fara
+# descarcare. O captura de ecran normala are sub 1 MB.
+MAX_MEDIA_MB=8
+
+# --- OCR pentru capturile de ecran ------------------------------------------
+# Modelul de raspuns e strict text, deci capturile trec prin tesseract inainte de
+# RAG (vezi rag/ocr.py). Cere: apt-get install tesseract-ocr tesseract-ocr-ron
+OCR_LANGS=ron+eng
+OCR_MAX_CHARS=1500
+OCR_TIMEOUT_S=60
# --- Backend LLM + embeddings -----------------------------------------------
# Modelul de chat folosit pentru raspunsuri (format compatibil OpenAI
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/install.sh b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/install.sh
index 22acb8f..4dd1be0 100755
--- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/install.sh
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/install.sh
@@ -122,6 +122,21 @@ else
warn "instaleaza cu: sudo apt-get install -y rclone (apoi vezi README.md)"
fi
+# --- 7b. tesseract (OCR pentru capturile de ecran primite) -------------------
+# Fara el, Maria raspunde in continuare la text, dar la o captura cu eroare
+# raspunde ca nu poate citi imaginea. Vezi rag/ocr.py.
+if command -v tesseract >/dev/null 2>&1; then
+ if tesseract --list-langs 2>/dev/null | grep -qx ron; then
+ info "tesseract gasit, cu limba romana"
+ else
+ warn "tesseract nu are pachetul de limba romana (ron) — OCR-ul va fi mai slab"
+ warn "instaleaza cu: sudo apt-get install -y tesseract-ocr-ron"
+ fi
+else
+ warn "tesseract nu e instalat — capturile de ecran primite pe WhatsApp nu pot fi citite"
+ warn "instaleaza cu: sudo apt-get install -y tesseract-ocr tesseract-ocr-ron"
+fi
+
# --- 8. enable / start -------------------------------------------------------
for u in maria-whatsapp.service maria-rag.service; do
systemctl --user enable "$u" >/dev/null 2>&1 \
@@ -136,13 +151,14 @@ if [ "$DO_START" -eq 1 ]; then
systemctl --user restart maria-whatsapp.service && info "punte WhatsApp pornita"
echo
echo " Deschide dashboard-ul puntii Discord (sectiunea 'Maria — WhatsApp + RAG')"
- echo " si scaneaza codul QR din cardul 'Conectare WhatsApp' inainte sa pornesti maria-rag."
+ echo " si asociaza WhatsApp din cardul 'Conectare WhatsApp' inainte sa pornesti"
+ echo " maria-rag: fie scanezi codul QR, fie ceri un cod de asociere pentru numar."
systemctl --user restart maria-rag.service && info "consumer RAG pornit"
systemctl --user --no-pager status maria-whatsapp.service | sed 's/^/ /'
else
echo
echo " Serviciile bridge/rag NU au fost pornite (intentionat: bridge-ul cere"
- echo " scanarea unui cod QR de asociere WhatsApp, actiune manuala)."
+ echo " asocierea WhatsApp — cod QR sau cod de asociere — actiune manuala)."
echo " Dupa ce completezi $STATE_DIR/env: $0 --start"
fi
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py
index bfea5a9..dadab7a 100644
--- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py
@@ -37,6 +37,10 @@ DEFAULTS: dict[str, str] = {
"MAX_TOKENS": "250",
"POLL_INTERVAL_S": "2",
"TEST_MODE_SELF_CHAT_ONLY": "true",
+ # OCR pentru capturile de ecran primite pe WhatsApp (vezi rag/ocr.py).
+ "OCR_LANGS": "ron+eng",
+ "OCR_MAX_CHARS": "1500",
+ "OCR_TIMEOUT_S": "60",
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
# "gdrive,root_folder_id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O:" (dosarul
# document_store din Drive, vazut pe Windows ca D:\GoogleDrive\romfast\document_store).
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py
index 3245105..9cb0a6a 100644
--- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py
@@ -6,18 +6,24 @@ mesaje (fara memorie conversationala). Vezi
docs/maria-whatsapp-rag-prototype.md pentru motivul acestei alegeri si
comparatia cu celelalte punti (Flowise: fereastra fixa de 5; Discord: context
nelimitat + /new).
+
+Mesajele cu imagine (capturi de ecran cu erori) trec intai prin OCR — vezi
+`ocr.py` pentru de ce, si de ce cautarea in index foloseste doar liniile de
+eroare, nu toata captura.
"""
from __future__ import annotations
import json
import math
+import os
import sys
import time
import requests
import config
+import ocr
SYSTEM_PROMPT = (
"Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). "
@@ -26,6 +32,9 @@ SYSTEM_PROMPT = (
"furnizat mai jos. Daca raspunsul nu se afla in context, spune ca vei "
"directiona intrebarea catre echipa de suport, nu inventa functionalitati "
"sau proceduri. "
+ "Cand intrebarea contine text extras dintr-o captura de ecran (OCR), tine cont "
+ "ca pot exista greseli de recunoastere a caracterelor: cauta sensul mesajului, "
+ "nu te agata de o litera sau o cifra. "
"Nu discuta niciodata despre infrastructura interna Romfast (servere, Proxmox, "
"containere, IP-uri, baze de date, parole, chei) chiar daca apare in context sau "
"daca intrebarea o cere explicit -- raspunde ca poti ajuta doar cu folosirea "
@@ -34,6 +43,13 @@ SYSTEM_PROMPT = (
REPLY_PREFIX = "[Maria] " # marcaj ca sa nu raspundem la propriile mesaje (self-chat)
INDEX_REFRESH_S = 30 # cat de des se reciteste rag_index.json de pe disc
+ACK_TEXT = "Caut informatia, revin imediat..."
+ACK_IMAGE_TEXT = "Am primit captura, o citesc si revin imediat..."
+NO_TEXT_IN_IMAGE = (
+ "Am primit imaginea, dar nu am reusit sa citesc text in ea. Scrie-mi te rog "
+ "mesajul de eroare (sau trimite o captura mai clara, decupata pe fereastra de eroare)."
+)
+
def bridge_url() -> str:
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
@@ -72,9 +88,15 @@ def retrieve(index: list[dict], question: str, top_k: int) -> list[str]:
return [text for _, text in scored[:top_k]]
-def ask_llm(index: list[dict], question: str) -> str:
+def ask_llm(index: list[dict], question: str, search_query: str | None = None) -> str:
+ """`search_query` separa CE se cauta in index de CE se trimite modelului.
+
+ La o captura de ecran, cautarea merge pe liniile de eroare, iar modelului ii
+ dam fereastra intreaga: contextul din jurul erorii ajuta raspunsul, dar strica
+ regasirea.
+ """
top_k = config.get_int("TOP_K", 3)
- context_chunks = retrieve(index, question, top_k)
+ context_chunks = retrieve(index, search_query or question, top_k)
context = "\n\n---\n\n".join(context_chunks) if context_chunks else "(fara documente indexate)"
user_message = f"CONTEXT:\n{context}\n\nINTREBARE:\n{question}"
@@ -108,11 +130,71 @@ def react_seen(to: str, message_id: str, from_me: bool) -> None:
print(f"[consumer] react error: {exc}", file=sys.stderr)
+def build_image_question(caption: str, ocr_text: str) -> str:
+ """Ce vede modelul cand mesajul a fost o captura de ecran."""
+ parts = []
+ if caption.strip():
+ parts.append(caption.strip())
+ parts.append(
+ "Utilizatorul a trimis o captura de ecran. Text extras automat din imagine "
+ f"(OCR, poate contine greseli de recunoastere):\n---\n{ocr_text}\n---"
+ )
+ if not caption.strip():
+ parts.append("Explica-i ce inseamna eroarea si cum o rezolva.")
+ return "\n\n".join(parts)
+
+
+def prepare_query(msg: dict) -> tuple[str | None, str | None, str | None]:
+ """(intrebare pentru model, interogare pentru index, raspuns imediat de trimis).
+
+ Al treilea element e diferit de None cand nu se poate raspunde deloc (imaginea
+ nu s-a descarcat, OCR indisponibil, nimic lizibil in captura) — atunci textul
+ lui pleaca asa cum e, fara sa mai deranjam modelul.
+ """
+ text = (msg.get("text") or "").strip()
+ media = msg.get("media")
+ if not media:
+ return text, text, None
+
+ if media.get("error"):
+ print(f"[consumer] imagine nedescarcata: {media['error']}", file=sys.stderr)
+ if text:
+ return text, text, None
+ return None, None, (
+ "Nu am reusit sa descarc imaginea. Mai incearca o data, sau scrie-mi "
+ "mesajul de eroare ca text."
+ )
+
+ path = media.get("path")
+ try:
+ ocr_text = ocr.run(path)
+ except Exception as exc: # noqa: BLE001
+ print(f"[consumer] OCR esuat pe {path}: {exc}", file=sys.stderr)
+ ocr_text = ""
+ finally:
+ # Capturile sunt de unica folosinta: pot contine date de client si nu
+ # avem niciun motiv sa le pastram pe disc dupa ce am citit textul.
+ try:
+ if path:
+ os.unlink(path)
+ except OSError:
+ pass
+
+ if not ocr_text:
+ if text:
+ return text, text, None
+ return None, None, NO_TEXT_IN_IMAGE
+
+ print(f"[consumer] OCR: {len(ocr_text)} caractere din captura", file=sys.stderr)
+ return build_image_question(text, ocr_text), ocr.retrieval_query(text, ocr_text), None
+
+
def main() -> None:
poll_s = config.get_int("POLL_INTERVAL_S", 2)
print(
f"[consumer] polling {bridge_url()}/messages la {poll_s}s, "
- f"LLM={config.get('LLM_URL')}, RAG top-{config.get('TOP_K')}",
+ f"LLM={config.get('LLM_URL')}, RAG top-{config.get('TOP_K')}, "
+ f"OCR={'da' if ocr.available() else 'INDISPONIBIL (tesseract lipseste)'}",
file=sys.stderr,
)
index = load_index()
@@ -128,15 +210,26 @@ def main() -> None:
for msg in messages:
if msg.get("isGroup"):
continue
- text = msg.get("text", "")
+ text = msg.get("text", "") or ""
if text.startswith(REPLY_PREFIX):
continue # ecoul propriului raspuns in self-chat, ignorat
sender = msg.get("from")
- print(f"[consumer] {sender}: {text[:80]}", file=sys.stderr)
+ has_image = bool(msg.get("media"))
+ print(
+ f"[consumer] {sender}: {'[imagine] ' if has_image else ''}{text[:80]}",
+ file=sys.stderr,
+ )
react_seen(sender, msg.get("id"), msg.get("fromMe", False))
- send_reply(sender, "Caut informatia, revin imediat...")
+ send_reply(sender, ACK_IMAGE_TEXT if has_image else ACK_TEXT)
+
+ question, search_query, immediate = prepare_query(msg)
+ if immediate is not None:
+ send_reply(sender, immediate)
+ continue
+ if not question:
+ continue
try:
- reply = ask_llm(index, text)
+ reply = ask_llm(index, question, search_query)
except Exception as exc: # noqa: BLE001
print(f"[consumer] LLM error: {exc}", file=sys.stderr)
reply = "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta."
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py
new file mode 100644
index 0000000..5b3f727
--- /dev/null
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""Citirea textului din capturile de ecran trimise pe WhatsApp (OCR cu tesseract).
+
+De ce OCR si nu un model vizual: modelul de raspuns al Mariei (Qwen3.5-2B pe
+llama.cpp, LXC 104) e strict text — nu accepta imagini. Utilizatorii insa trimit
+aproape intotdeauna o captura cu fereastra de eroare, nu textul ei. Tesseract
+(CPU, ~1-2 s pe o captura de ecran) transforma captura in text, iar restul
+lantului ramane neschimbat: acelasi RAG, acelasi prompt.
+
+Textul recunoscut e ZGOMOTOS. Doua consecinte tratate aici:
+ - pentru CAUTAREA in index folosim doar liniile care arata a eroare
+ (`retrieval_query`), nu toata fereastra: un ecran intreg de meniuri si
+ coloane diluează embedding-ul si scoate chunk-uri fara legatura;
+ - pentru PROMPT trimitem textul intreg, curatat si plafonat, marcat explicit ca
+ provenit din OCR, ca modelul sa nu trateze greselile de recunoastere ca date
+ exacte.
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+
+import config
+
+DEFAULT_LANGS = "ron+eng"
+DEFAULT_MAX_CHARS = 1500
+DEFAULT_TIMEOUT_S = 60
+
+# Semne ca o linie e chiar mesajul de eroare, nu decor de interfata. Ordinea nu
+# conteaza; o singura potrivire e de ajuns.
+_ERROR_HINTS = re.compile(
+ r"(ORA-\d+|PLS-\d+|TNS-\d+|SP2-\d+|IMP-\d+|EXP-\d+"
+ r"|\b(?:eroare|erori|eronat)\b|\berror(?:s|ul)?\b|\bexception\b|\besuat\b|\bfailed\b|\binvalid\b"
+ r"|\bnu se poate\b|\bnu exista\b|\bnu a fost\b|\bcod\s*[:=]?\s*-?\d+"
+ r"|\bE\d{3,}\b|\bwarning\b|\batentie\b)",
+ re.IGNORECASE,
+)
+
+# Linii care nu aduc nimic: prea scurte, sau numai simboluri/rame de fereastra.
+_JUNK_LINE = re.compile(r"^[\W_]*$")
+
+
+def available() -> bool:
+ return shutil.which("tesseract") is not None
+
+
+def clean(raw: str, max_chars: int | None = None) -> str:
+ """Normalizeaza iesirea tesseract: fara linii goale, fara zgomot, plafonat."""
+ if max_chars is None:
+ max_chars = config.get_int("OCR_MAX_CHARS", DEFAULT_MAX_CHARS)
+ lines: list[str] = []
+ for raw_line in raw.splitlines():
+ line = re.sub(r"[ \t ]+", " ", raw_line).strip()
+ if not line or _JUNK_LINE.match(line):
+ continue
+ if len(line) < 2:
+ continue
+ if lines and lines[-1] == line: # tesseract repeta uneori aceeasi linie
+ continue
+ lines.append(line)
+ text = "\n".join(lines)
+ if len(text) > max_chars:
+ text = text[:max_chars].rstrip() + "\n[…text trunchiat]"
+ return text
+
+
+def error_lines(text: str, limit: int = 6) -> list[str]:
+ """Liniile care arata a mesaj de eroare, in ordinea din imagine."""
+ out: list[str] = []
+ for line in text.splitlines():
+ if _ERROR_HINTS.search(line) and line not in out:
+ out.append(line)
+ if len(out) >= limit:
+ break
+ return out
+
+
+def retrieval_query(caption: str, ocr_text: str) -> str:
+ """Ce se trimite la cautarea in index: legenda + liniile de eroare, sau tot.
+
+ Cand nu se distinge nicio linie de eroare, cade inapoi pe primele linii ale
+ capturii — mai bine o cautare aproximativa decat niciuna.
+ """
+ parts: list[str] = []
+ if caption.strip():
+ parts.append(caption.strip())
+ errs = error_lines(ocr_text)
+ if errs:
+ parts.extend(errs)
+ else:
+ parts.extend(ocr_text.splitlines()[:8])
+ return "\n".join(p for p in parts if p.strip()).strip()
+
+
+def run(image_path: str, langs: str | None = None) -> str:
+ """Textul curatat dintr-o imagine. Ridica RuntimeError daca tesseract lipseste/cade."""
+ if not available():
+ raise RuntimeError("tesseract nu e instalat (apt-get install tesseract-ocr tesseract-ocr-ron)")
+ langs = langs or config.get("OCR_LANGS", DEFAULT_LANGS)
+ timeout = config.get_int("OCR_TIMEOUT_S", DEFAULT_TIMEOUT_S)
+ try:
+ proc = subprocess.run(
+ ["tesseract", image_path, "stdout", "-l", langs],
+ capture_output=True, text=True, timeout=timeout, check=False,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise RuntimeError(f"OCR a depasit {timeout}s") from exc
+ if proc.returncode != 0:
+ raise RuntimeError((proc.stderr or "tesseract a esuat").strip().splitlines()[-1])
+ return clean(proc.stdout)
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_mesaje_cu_imagine.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_mesaje_cu_imagine.py
new file mode 100644
index 0000000..1fd971d
--- /dev/null
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_mesaje_cu_imagine.py
@@ -0,0 +1,90 @@
+"""`consumer.prepare_query`: ce ajunge la model cand mesajul e o captura de ecran.
+
+Nu porneste nici puntea, nici LLM-ul — se testeaza doar decizia dintre „raspund
+pe baza imaginii", „raspund pe baza legendei" si „cer omului sa scrie textul".
+"""
+
+from __future__ import annotations
+
+import pytest
+
+import consumer
+
+
+@pytest.fixture
+def imagine(tmp_path, monkeypatch):
+ """Un mesaj cu imagine, cu OCR-ul inlocuit de un text dat."""
+ def _face(ocr_text: str, caption: str = "", *, esueaza: bool = False):
+ fisier = tmp_path / "captura.png"
+ fisier.write_bytes(b"nu conteaza continutul, ocr e inlocuit")
+
+ def fals_run(path, langs=None):
+ if esueaza:
+ raise RuntimeError("tesseract lipseste")
+ return ocr_text
+
+ monkeypatch.setattr(consumer.ocr, "run", fals_run)
+ return {"text": caption, "media": {"path": str(fisier), "mimetype": "image/png"}}, fisier
+ return _face
+
+
+def test_mesaj_text_simplu_trece_neatins():
+ intrebare, cautare, imediat = consumer.prepare_query({"text": "cum emit o factura?"})
+ assert intrebare == "cum emit o factura?"
+ assert cautare == "cum emit o factura?"
+ assert imediat is None
+
+
+def test_captura_cu_eroare_cauta_doar_linia_de_eroare(imagine):
+ msg, _ = imagine("Meniu Fisier Editare\nORA-01722: invalid number\nRand 4 din 9")
+ intrebare, cautare, imediat = consumer.prepare_query(msg)
+
+ assert imediat is None
+ # cautarea in index merge pe eroare, nu pe toata fereastra
+ assert cautare == "ORA-01722: invalid number"
+ # modelului ii dam captura intreaga, marcata ca OCR
+ assert "OCR" in intrebare
+ assert "Meniu Fisier Editare" in intrebare
+
+
+def test_legenda_intra_si_in_intrebare_si_in_cautare(imagine):
+ msg, _ = imagine("ORA-00942: table or view does not exist", caption="nu merge raportul")
+ intrebare, cautare, _ = consumer.prepare_query(msg)
+ assert intrebare.startswith("nu merge raportul")
+ assert cautare.splitlines()[0] == "nu merge raportul"
+
+
+def test_captura_se_sterge_dupa_citire(imagine):
+ msg, fisier = imagine("ORA-01722: invalid number")
+ consumer.prepare_query(msg)
+ # capturile pot contine date de client — nu raman pe disc dupa OCR
+ assert not fisier.exists()
+
+
+def test_captura_ilizibila_cere_textul_erorii(imagine):
+ msg, fisier = imagine("")
+ intrebare, _, imediat = consumer.prepare_query(msg)
+ assert intrebare is None
+ assert imediat == consumer.NO_TEXT_IN_IMAGE
+ assert not fisier.exists()
+
+
+def test_ocr_esuat_dar_cu_legenda_raspunde_pe_legenda(imagine):
+ msg, _ = imagine("", caption="imi da eroare la salvare", esueaza=True)
+ intrebare, cautare, imediat = consumer.prepare_query(msg)
+ assert imediat is None
+ assert intrebare == cautare == "imi da eroare la salvare"
+
+
+def test_imagine_nedescarcata_fara_legenda_cere_reincercare():
+ msg = {"text": "", "media": {"error": "imagine prea mare (12.0 MB > 8 MB)"}}
+ intrebare, _, imediat = consumer.prepare_query(msg)
+ assert intrebare is None
+ assert "scrie-mi" in imediat.lower()
+
+
+def test_imagine_nedescarcata_cu_legenda_raspunde_pe_legenda():
+ msg = {"text": "eroare la export SAF-T", "media": {"error": "download failed"}}
+ intrebare, cautare, imediat = consumer.prepare_query(msg)
+ assert imediat is None
+ assert intrebare == cautare == "eroare la export SAF-T"
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_ocr.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_ocr.py
new file mode 100644
index 0000000..9344d2b
--- /dev/null
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_ocr.py
@@ -0,0 +1,62 @@
+"""Curatarea textului OCR si alegerea interogarii de cautare.
+
+Fara tesseract si fara retea: `ocr.run` e singurul care atinge procese externe si
+nu e testat aici — restul sunt functii pure peste text.
+"""
+
+from __future__ import annotations
+
+import ocr
+
+
+def test_clean_scoate_liniile_goale_si_ramele():
+ raw = "Eroare la salvare\n\n \n|||||\n----\nORA-01722: invalid number\n"
+ assert ocr.clean(raw) == "Eroare la salvare\nORA-01722: invalid number"
+
+
+def test_clean_normalizeaza_spatiile_si_taie_duplicatele():
+ raw = "ORA-00001: unique constraint\nORA-00001: unique constraint\n"
+ # a doua linie e identica dupa normalizare — tesseract repeta uneori randuri
+ assert ocr.clean(raw) == "ORA-00001: unique constraint"
+
+
+def test_clean_plafoneaza_si_marcheaza_trunchierea():
+ out = ocr.clean("\n".join(f"linia numarul {i}" for i in range(500)), max_chars=100)
+ assert len(out) < 200
+ assert out.endswith("[…text trunchiat]")
+
+
+def test_error_lines_gaseste_codurile_oracle_si_mesajele_romanesti():
+ text = (
+ "Fisier Editare Ajutor\n"
+ "Nr. crt. Denumire Cantitate\n"
+ "ORA-12154: TNS:could not resolve\n"
+ "Documentul nu exista in baza de date\n"
+ "Total general 1.234,00\n"
+ )
+ assert ocr.error_lines(text) == [
+ "ORA-12154: TNS:could not resolve",
+ "Documentul nu exista in baza de date",
+ ]
+
+
+def test_error_lines_limiteaza_numarul():
+ text = "\n".join(f"eroare {i}" for i in range(20))
+ assert len(ocr.error_lines(text, limit=3)) == 3
+
+
+def test_retrieval_query_pune_legenda_prima_apoi_erorile():
+ q = ocr.retrieval_query("nu pot factura", "Meniu principal\nORA-00942: table or view does not exist")
+ assert q == "nu pot factura\nORA-00942: table or view does not exist"
+
+
+def test_retrieval_query_fara_linii_de_eroare_cade_pe_primele_linii():
+ # O captura fara niciun cuvant-cheie tot trebuie cautata cumva; mai bine
+ # aproximativ decat deloc.
+ text = "\n".join(f"linia {i}" for i in range(20))
+ q = ocr.retrieval_query("", text)
+ assert q.splitlines() == [f"linia {i}" for i in range(8)]
+
+
+def test_retrieval_query_doar_legenda_cand_ocr_e_gol():
+ assert ocr.retrieval_query("cum sterg o factura?", "") == "cum sterg o factura?"
diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js
index 692eb92..c4b3fb3 100644
--- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js
+++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js
@@ -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);
});