feat(maria): asociere prin cod de telefon si citirea capturilor cu erori

Doua lucruri cerute de utilizator, ambele pe acelasi drum: sa poti lega
puntea fara sa ai un ecran de scanat, si sa poti trimite Mariei o poza cu
eroarea in loc sa transcrii mesajul.

Asociere prin cod de 8 caractere (alternativa la QR):
- endpoint-ul /pair exista, dar nu era folosibil: fara `browser` explicit
  WhatsApp refuza codul, iar dupa introducerea lui corecta serverul cere un
  restart (515) care consuma din bugetul de reincercari si putea opri puntea.
  Acum descriptorul e Browsers.ubuntu('Chrome') si restartRequired reconecteaza
  imediat, fara sa numere.
- codul are TTL de 3 minute, iar /status il da doar cat timp e valabil —
  un cod expirat afisat in dashboard trimite omul sa tasteze degeaba.
- dashboard: camp pentru numar + buton, in acelasi card cu QR-ul.

Imagini cu erori (capturi de ecran):
- puntea descarca imaginile in ~/.maria-bridge/media/ (imageMessage sau
  document cu mimetype image/*, si prin ambalajele efemer/"vezi o data" —
  fara despachetare pareau mesaje fara continut si se aruncau tacut).
- rag/ocr.py: tesseract ron+eng. Modelul de raspuns e strict text, deci OCR
  nu e o optiune de calitate, e singura cale.
- cautarea in index merge DOAR pe liniile care arata a eroare; o fereastra
  intreaga de meniuri si totaluri dilueaza embedding-ul si scoate chunk-uri
  fara legatura. Modelul primeste captura intreaga, marcata ca text OCR.
- capturile se sterg imediat dupa citire (pot contine date de client).
- cand nu se citeste nimic si nu exista legenda, Maria cere textul erorii
  in loc sa raspunda in gol.

16 teste noi (42 in total). install.sh verifica tesseract; README documenteaza
ambele metode de asociere si drumul unei capturi.

Separat, in docs/chatboti-si-punti.md: chatul "Eu" e vazut de AMBELE punti de pe
numar. Puntea lui Echo (LXC 110) e asociata ca dispozitiv :11 al aceluiasi cont,
momentan nelegata dar pornita — daca se reasociaza, raspunde in "Eu" langa Maria.
Notat si ca serviciile lui Echo sunt unitati de UTILIZATOR: `systemctl is-active`
ca root raspunde "inactive" desi botul ruleaza.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
This commit is contained in:
Claude Agent
2026-08-31 21:22:28 +00:00
parent f5c8df7adf
commit 7a1d2a2073
12 changed files with 733 additions and 36 deletions

View File

@@ -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

View File

@@ -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 "")

View File

@@ -99,6 +99,19 @@
<h2>Conectare WhatsApp</h2>
<div id="mWaStatus" class="empty">se încarcă…</div>
<div id="mWaQr"></div>
<div id="mWaPair" hidden>
<div class="detail" style="margin-top:var(--space-5)">
Nu poți scana codul QR? Cere un <strong>cod de asociere</strong> și
introdu-l pe telefon în WhatsApp → Dispozitive conectate → Conectează un
dispozitiv → <em>Conectează cu număr de telefon</em>.
</div>
<div class="actions" style="margin-top:var(--space-3)">
<input type="text" id="mPairPhone" placeholder="40723197939 (internațional, fără +)"
style="flex:1;min-width:220px">
<button onclick="mPair()">Cere cod de asociere</button>
</div>
<div id="mPairOut" class="detail" style="margin-top:var(--space-3)"></div>
</div>
</section>
<section class="card">
@@ -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 =
'<div style="font-family:monospace;font-size:1.9rem;letter-spacing:.18em;margin:var(--space-3) 0">'
+ esc(pretty) + '</div><span class="muted">tastează-l pe telefon'
+ (expiresIn ? ' — expiră în ~' + Math.round(expiresIn / 60) + ' min' : '')
+ '. Dacă expiră, cere altul.</span>';
}
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 = '<span class="pill bad">eroare</span> '
+ 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 = '<span class="muted">codul a expirat — cere altul</span>';
delete pairOut.dataset.code;
}
if (wa.connected) {
waStatus.innerHTML = '<span class="pill ok">conectat</span> ca ' + esc(wa.phone || '?');
waQr.innerHTML = '';
document.getElementById('mPairOut').innerHTML = '';
} else if (wa.qr) {
waStatus.innerHTML = '<span class="pill bad">neconectat</span> — scanează codul QR din WhatsApp → Dispozitive conectate';
waQr.innerHTML = '<img src="' + wa.qr + '" style="width:220px;height:220px;border-radius:8px;background:#fff;padding:8px;margin-top:var(--space-4)">';

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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).

View File

@@ -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."

View File

@@ -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)

View File

@@ -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"

View File

@@ -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?"

View File

@@ -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);
});