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:
@@ -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
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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)">';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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."
|
||||
|
||||
112
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py
Normal file
112
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py
Normal 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)
|
||||
@@ -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"
|
||||
@@ -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?"
|
||||
@@ -1,21 +1,40 @@
|
||||
// Punte WhatsApp (Baileys) pentru Maria — implicit doar self-chat (vezi
|
||||
// TEST_MODE_SELF_CHAT_ONLY). Sesiunea WhatsApp (auth/) NU intra in git —
|
||||
// contine chei de sesiune, e in ~/.maria-bridge/whatsapp-auth/ (vezi config.py).
|
||||
//
|
||||
// Asocierea se poate face in doua feluri, ambele expuse in dashboard:
|
||||
// - cod QR (GET /status -> qr, sau pagina GET /qr)
|
||||
// - cod de 8 caractere legat de numar (POST /pair {phone}), pentru cand
|
||||
// telefonul e in mana si camera nu poate scana ecranul serverului.
|
||||
// Imaginile primite (capturi cu erori) se descarca in ~/.maria-bridge/media/ si
|
||||
// se pun in coada cu `media`; consumer-ul le trece prin OCR inainte de RAG.
|
||||
|
||||
const express = require('express');
|
||||
const pino = require('pino');
|
||||
const QRCode = require('qrcode');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion;
|
||||
let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion,
|
||||
downloadMediaMessage, Browsers;
|
||||
|
||||
const PORT = parseInt(process.env.BRIDGE_PORT || '8099', 10);
|
||||
const HOST = process.env.BRIDGE_HOST || '127.0.0.1';
|
||||
const AUTH_DIR = process.env.MARIA_BRIDGE_DIR
|
||||
? path.join(process.env.MARIA_BRIDGE_DIR, 'whatsapp-auth')
|
||||
: path.join(__dirname, 'auth');
|
||||
const MEDIA_DIR = process.env.MARIA_BRIDGE_DIR
|
||||
? path.join(process.env.MARIA_BRIDGE_DIR, 'media')
|
||||
: path.join(__dirname, 'media');
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const TEST_MODE_SELF_CHAT_ONLY = (process.env.TEST_MODE_SELF_CHAT_ONLY || 'true') !== 'false';
|
||||
// WhatsApp invalideaza codul de asociere in cateva minute. Il tinem afisat doar
|
||||
// atat: un cod expirat afisat in dashboard trimite omul sa tasteze degeaba.
|
||||
const PAIRING_CODE_TTL_MS = 3 * 60 * 1000;
|
||||
const MAX_MEDIA_MB = parseFloat(process.env.MAX_MEDIA_MB || '8');
|
||||
// Fisierele descarcate sunt temporare: consumer-ul le sterge dupa OCR. Curatarea
|
||||
// de la pornire prinde ce a ramas dupa un restart in mijlocul procesarii.
|
||||
const MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const logger = pino({ level: 'warn' });
|
||||
|
||||
@@ -25,11 +44,82 @@ let phoneNumber = null;
|
||||
let ownJid = null;
|
||||
let ownLid = null; // acelasi cont, adresat in formatul nou LID
|
||||
let currentQR = null;
|
||||
let currentPairingCode = null;
|
||||
let pairing = null; // { code, phone, at } — vezi PAIRING_CODE_TTL_MS
|
||||
let reconnectAttempts = 0;
|
||||
let messageQueue = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
function activePairing() {
|
||||
if (!pairing) return null;
|
||||
if (Date.now() - pairing.at > PAIRING_CODE_TTL_MS) return null;
|
||||
return pairing;
|
||||
}
|
||||
|
||||
function pruneMediaDir() {
|
||||
try {
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
const now = Date.now();
|
||||
for (const name of fs.readdirSync(MEDIA_DIR)) {
|
||||
const p = path.join(MEDIA_DIR, name);
|
||||
try {
|
||||
if (now - fs.statSync(p).mtimeMs > MEDIA_TTL_MS) fs.unlinkSync(p);
|
||||
} catch (_) { /* fisier disparut intre listare si stat */ }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Nu pot curata directorul media:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Mesajele reale sosesc uneori impachetate (mesaje efemere, "vezi o data").
|
||||
// Fara despachetare, o captura de ecran trimisa cu dispariție automata pare
|
||||
// un mesaj fara continut si se arunca tacut.
|
||||
function unwrap(message) {
|
||||
let m = message;
|
||||
for (let i = 0; i < 5 && m; i++) {
|
||||
const inner = m.ephemeralMessage?.message
|
||||
|| m.viewOnceMessage?.message
|
||||
|| m.viewOnceMessageV2?.message
|
||||
|| m.viewOnceMessageV2Extension?.message
|
||||
|| m.documentWithCaptionMessage?.message;
|
||||
if (!inner) break;
|
||||
m = inner;
|
||||
}
|
||||
return m || {};
|
||||
}
|
||||
|
||||
function extForMime(mimetype) {
|
||||
const map = {
|
||||
'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png',
|
||||
'image/webp': '.webp', 'image/gif': '.gif', 'image/bmp': '.bmp',
|
||||
};
|
||||
return map[(mimetype || '').split(';')[0].toLowerCase()] || '.bin';
|
||||
}
|
||||
|
||||
/** Nodul de imagine al unui mesaj, fie el `imageMessage` sau un document cu mimetype de imagine. */
|
||||
function imageNode(message) {
|
||||
const m = unwrap(message);
|
||||
if (m.imageMessage) return { node: m.imageMessage, kind: 'image' };
|
||||
const doc = m.documentMessage;
|
||||
if (doc && (doc.mimetype || '').startsWith('image/')) return { node: doc, kind: 'document' };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function saveImage(msg, node) {
|
||||
const bytes = Number(node.fileLength || 0);
|
||||
if (bytes > MAX_MEDIA_MB * 1024 * 1024) {
|
||||
throw new Error(`imagine prea mare (${(bytes / 1048576).toFixed(1)} MB > ${MAX_MEDIA_MB} MB)`);
|
||||
}
|
||||
const buffer = await downloadMediaMessage(
|
||||
msg, 'buffer', {},
|
||||
{ logger, reuploadRequest: sock.updateMediaMessage },
|
||||
);
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
const safeId = String(msg.key.id || Date.now()).replace(/[^A-Za-z0-9_-]/g, '');
|
||||
const file = path.join(MEDIA_DIR, `${Date.now()}_${safeId}${extForMime(node.mimetype)}`);
|
||||
fs.writeFileSync(file, buffer, { mode: 0o600 });
|
||||
return { path: file, mimetype: node.mimetype || null, bytes: buffer.length };
|
||||
}
|
||||
|
||||
async function startConnection() {
|
||||
if (!makeWASocket) {
|
||||
const baileys = await import('@whiskeysockets/baileys');
|
||||
@@ -37,6 +127,8 @@ async function startConnection() {
|
||||
useMultiFileAuthState = baileys.useMultiFileAuthState;
|
||||
DisconnectReason = baileys.DisconnectReason;
|
||||
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
|
||||
downloadMediaMessage = baileys.downloadMediaMessage;
|
||||
Browsers = baileys.Browsers;
|
||||
}
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
@@ -47,6 +139,10 @@ async function startConnection() {
|
||||
auth: state,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
// Asocierea prin cod de 8 caractere e refuzata de WhatsApp daca descriptorul
|
||||
// de browser nu e unul cunoscut; `Browsers.ubuntu('Chrome')` e cel folosit de
|
||||
// exemplele Baileys. Nu afecteaza asocierea prin QR.
|
||||
browser: Browsers.ubuntu('Chrome'),
|
||||
defaultQueryTimeoutMs: 60000,
|
||||
});
|
||||
|
||||
@@ -67,6 +163,7 @@ async function startConnection() {
|
||||
if (connection === 'open') {
|
||||
connected = true;
|
||||
currentQR = null;
|
||||
pairing = null;
|
||||
reconnectAttempts = 0;
|
||||
phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id?.split('@')[0] || null;
|
||||
ownJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null;
|
||||
@@ -89,7 +186,14 @@ async function startConnection() {
|
||||
console.log(`[whatsapp] Disconnected (status: ${statusCode})`);
|
||||
|
||||
if (shouldReconnect && !shuttingDown) {
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
// Dupa introducerea codului de asociere WhatsApp cere explicit un restart
|
||||
// (515). Nu e o eroare si nu trebuie sa consume din bugetul de reincercari,
|
||||
// altfel o asociere reusita poate opri puntea la a cincea incercare.
|
||||
const isRestartRequired = statusCode === DisconnectReason.restartRequired;
|
||||
if (isRestartRequired) {
|
||||
console.log('[whatsapp] Restart cerut de server (asociere finalizata?) — reconectare imediata');
|
||||
setTimeout(startConnection, 500);
|
||||
} else if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
console.log(`[whatsapp] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||
@@ -103,7 +207,7 @@ async function startConnection() {
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
||||
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
@@ -121,9 +225,26 @@ async function startConnection() {
|
||||
console.log(`[whatsapp] ignorat (fromMe, nu e self-chat): remoteJid=${msg.key.remoteJid}`);
|
||||
continue;
|
||||
}
|
||||
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
|
||||
if (!text) {
|
||||
console.log(`[whatsapp] ignorat (fara text): remoteJid=${msg.key.remoteJid} tipuri=${Object.keys(msg.message || {}).join(',')}`);
|
||||
|
||||
const inner = unwrap(msg.message);
|
||||
const img = imageNode(msg.message);
|
||||
let text = inner.conversation || inner.extendedTextMessage?.text || '';
|
||||
let media = null;
|
||||
|
||||
if (img) {
|
||||
// Legenda imaginii e adesea tot ce spune omul („nu merge asta").
|
||||
text = img.node.caption || text || '';
|
||||
try {
|
||||
media = await saveImage(msg, img.node);
|
||||
console.log(`[whatsapp] Imagine primita de la ${msg.key.remoteJid}: ${media.path} (${media.bytes} octeti, ${media.mimetype})`);
|
||||
} catch (err) {
|
||||
console.error(`[whatsapp] Descarcarea imaginii a esuat (${msg.key.remoteJid}): ${err.message}`);
|
||||
media = { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
if (!text && !media) {
|
||||
console.log(`[whatsapp] ignorat (fara text): remoteJid=${msg.key.remoteJid} tipuri=${Object.keys(inner || {}).join(',')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -132,13 +253,14 @@ async function startConnection() {
|
||||
participant: msg.key.participant || null,
|
||||
pushName: msg.pushName || null,
|
||||
text,
|
||||
media,
|
||||
timestamp: msg.messageTimestamp,
|
||||
id: msg.key.id,
|
||||
isGroup,
|
||||
fromMe: msg.key.fromMe || false,
|
||||
});
|
||||
|
||||
console.log(`[whatsapp] Message from ${msg.pushName || 'unknown'} in ${msg.key.remoteJid}: ${text.substring(0, 80)}`);
|
||||
console.log(`[whatsapp] Message from ${msg.pushName || 'unknown'} in ${msg.key.remoteJid}: ${media ? '[imagine] ' : ''}${text.substring(0, 80)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -147,41 +269,53 @@ const app = express();
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
app.get('/status', (_req, res) => {
|
||||
const p = activePairing();
|
||||
res.json({
|
||||
connected,
|
||||
phone: phoneNumber,
|
||||
selfChatOnly: TEST_MODE_SELF_CHAT_ONLY,
|
||||
lid: ownLid,
|
||||
qr: connected ? null : currentQR,
|
||||
pairing: connected || !p ? null : {
|
||||
code: p.code,
|
||||
phone: p.phone,
|
||||
expires_in: Math.max(0, Math.round((PAIRING_CODE_TTL_MS - (Date.now() - p.at)) / 1000)),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/pair', async (req, res) => {
|
||||
if (connected) {
|
||||
return res.json({ error: 'already connected' });
|
||||
return res.status(409).json({ ok: false, error: 'deja conectat' });
|
||||
}
|
||||
const { phone } = req.body || {};
|
||||
if (!phone) {
|
||||
return res.status(400).json({ error: 'missing "phone" in body' });
|
||||
const digits = String(phone || '').replace(/\D/g, '');
|
||||
// Numarul trebuie dat in format international, fara `+` si fara `00`: 40723197939.
|
||||
if (digits.length < 8 || digits.length > 15) {
|
||||
return res.status(400).json({ ok: false, error: 'numar invalid — dă-l international, fara "+" (ex: 40723197939)' });
|
||||
}
|
||||
if (!sock) {
|
||||
return res.status(503).json({ error: 'socket not ready yet, try again in a few seconds' });
|
||||
return res.status(503).json({ ok: false, error: 'puntea inca porneste, reincearca in cateva secunde' });
|
||||
}
|
||||
if (sock.authState?.creds?.registered) {
|
||||
return res.status(409).json({ ok: false, error: 'sesiunea e deja inregistrata — sterge whatsapp-auth/ si reporneste puntea' });
|
||||
}
|
||||
try {
|
||||
const code = await sock.requestPairingCode(phone.replace(/\D/g, ''));
|
||||
currentPairingCode = code;
|
||||
console.log(`[whatsapp] Pairing code for ${phone}: ${code}`);
|
||||
res.json({ ok: true, code });
|
||||
const code = await sock.requestPairingCode(digits);
|
||||
pairing = { code, phone: digits, at: Date.now() };
|
||||
console.log(`[whatsapp] Cod de asociere pentru ${digits}: ${code}`);
|
||||
res.json({ ok: true, code, phone: digits, expires_in: PAIRING_CODE_TTL_MS / 1000 });
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Pairing code error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
console.error('[whatsapp] Cererea codului de asociere a esuat:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/pair-code', (_req, res) => {
|
||||
if (connected) return res.json({ error: 'already connected' });
|
||||
if (!currentPairingCode) return res.json({ error: 'no pairing code yet — POST /pair first' });
|
||||
res.json({ code: currentPairingCode });
|
||||
const p = activePairing();
|
||||
if (!p) return res.json({ error: 'niciun cod valabil — cere unul cu POST /pair' });
|
||||
res.json({ code: p.code, phone: p.phone });
|
||||
});
|
||||
|
||||
app.get('/qr', (_req, res) => {
|
||||
@@ -247,6 +381,7 @@ app.get('/messages', (_req, res) => {
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
console.log(`[whatsapp] Bridge API listening on http://${HOST}:${PORT}`);
|
||||
pruneMediaDir();
|
||||
startConnection().catch((err) => {
|
||||
console.error('[whatsapp] Failed to start connection:', err.message);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user