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ă…
+
@@ -351,6 +364,31 @@ function mMetricsHtml(info) { }).join(''); } +function mShowPairCode(code, expiresIn) { + var pretty = code.length === 8 ? code.slice(0, 4) + '-' + code.slice(4) : code; + var out = document.getElementById('mPairOut'); + out.dataset.code = code; + out.innerHTML = + '
' + + esc(pretty) + '
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); });