Add Maria WhatsApp+RAG bridge as a service (LXC 171)

Move the /tmp prototype (Baileys bridge + RAG consumer) into git as a
proper sibling project to discord-bridge/: own systemd --user units
(whatsapp bridge, rag consumer, dashboard, periodic Drive sync timer),
a filesystem document store with a stdlib control dashboard (start/
stop/restart, document CRUD, reindex, Google Drive sync via rclone),
and an idempotent ops/install.sh following the same conventions.

Co-Authored-By: Claude Agent <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-08-31 13:06:15 +00:00
parent b29b9f2548
commit dd5553e327
20 changed files with 1921 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
node_modules/
.pytest_cache/

View File

@@ -0,0 +1,140 @@
# Punte WhatsApp + RAG pentru Maria (LXC 171)
Bot de suport ROA pe WhatsApp: puntea WhatsApp (Baileys) primeste mesajele,
consumer-ul RAG raspunde folosind DOAR informatiile dintr-un depozit de
documente indexat cu embeddings (Ollama), iar un dashboard web controleaza
totul (start/stop/restart, documente, reindexare, sincronizare Google Drive).
Continua prototipul descris in
`claude-agent/docs/maria-whatsapp-rag-prototype.md` (construit initial in
`/tmp/maria-bridge/`) — aici e mutat in git, ca serviciu persistent.
Nu confunda cu:
- **Maria pe Flowise** (`vfp_roaauto/COMUN/utile/chatbot/`) — chatbot web separat.
- **Echo / `echo-whatsapp-bridge.service`** (LXC 110 moltbot) — alt bot, alta punte.
- **Punte Discord -> Claude Code** (`discord-bridge/`, acelasi container) — alt
proiect, alt scop (comanda Claude Code de pe Discord, nu suport RAG).
## Arhitectura
```
WhatsApp (self-chat, sau numarul legat)
|
v
whatsapp/index.js (Baileys) -- API HTTP :8099 (/status /send /messages /react /qr)
|
v
rag/consumer.py -- polling la /messages, RAG stateless (FARA memorie intre mesaje)
| vezi docs/maria-whatsapp-rag-prototype.md pentru motiv
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
dashboard/api.py -- panou web (stdlib, fara dependinte), :18792
controleaza maria-whatsapp.service + maria-rag.service,
gestioneaza documentele si declanseaza sincronizare/reindexare
```
Servicii `systemctl --user` (vezi `ops/`):
| Unitate | Ce face |
|---|---|
| `maria-whatsapp.service` | Puntea Baileys (Node), port 8099 |
| `maria-rag.service` | Consumer RAG (Python), polling + raspunsuri |
| `maria-sync.service` + `.timer` | Sincronizare Drive + reindexare, la 10 min |
| `maria-dashboard.service` | Panou de control, port 18792 |
## Instalare
```bash
cd maria-whatsapp-bridge
./ops/install.sh # creeaza ~/.maria-bridge/, venv, npm install, symlink-uri unit
```
Completeaza manual `~/.maria-bridge/env` (copiat din `ops/env.example` la prima
rulare): cel putin `LLM_URL` (backend-ul de chat) si, daca vrei sincronizare
automata cu Drive, `DRIVE_REMOTE`.
Bridge-ul WhatsApp NU porneste automat la instalare — cere scanarea unui cod QR
(actiune manuala, o singura data):
```bash
./ops/install.sh --start
# apoi deschide dashboard-ul (tunel SSH catre 127.0.0.1:18792) si scaneaza
# codul QR din cardul "Conectare WhatsApp"
```
## Dashboard
```bash
ssh -L 18792:127.0.0.1:18792 -N claude@10.0.20.171 &
# apoi: http://localhost:18792
```
Autentificare cu `DASHBOARD_TOKEN` din `~/.maria-bridge/env` (generat automat
de `install.sh`). `DASHBOARD_AUTH=off` in env dezactiveaza login-ul — foloseste
DOAR daca panoul ramane strict pe 127.0.0.1/tunel SSH.
Ce poti face din panou:
- start/stop/restart pentru puntea WhatsApp si consumer-ul RAG
- vezi starea conexiunii WhatsApp si codul QR de asociere (cand nu e conectat)
- listezi, adaugi si stergi documente din depozitul RAG
- reconstruiesti indexul manual, sau declansezi o sincronizare Drive imediata
- citesti ultimele linii din logurile fiecarui serviciu
## Depozitul de documente
Fisiere `.txt`/`.md` in `~/.maria-bridge/documents/`. Se pot administra:
1. **manual din dashboard** (adaugare/stergere text, reindexare automata la salvare);
2. **prin sincronizare Google Drive** — vezi mai jos.
## Sincronizare cu Google Drive
Sursa: dosarul `D:\GoogleDrive\romfast\document_store` de pe Windows (Google
Drive Desktop). Containerul e headless, deci sincronizarea foloseste
**rclone cu un cont de serviciu** — nu OAuth interactiv in browser.
Pasi (o singura data):
1. **Instaleaza rclone** pe container: `sudo apt-get install -y rclone`.
2. **Creeaza un cont de serviciu Google** cu acces la Drive API (Google Cloud
Console -> IAM -> Service Accounts -> Create -> descarca cheia JSON).
3. **Partajeaza folderul** `document_store` din Google Drive cu adresa de email
a contului de serviciu (click dreapta pe folder -> Share), exact cum ai
partaja cu o persoana. Fara acest pas, contul de serviciu nu vede nimic.
4. Pune cheia JSON pe container, ex. `~/.maria-bridge/gdrive-service-account.json`
(0600).
5. Configureaza remote-ul rclone (`rclone config`, fara sesiune interactiva de
browser cu tip `service_account_file`):
```
rclone config create gdrive drive \
scope=drive.readonly \
service_account_file=/home/claude/.maria-bridge/gdrive-service-account.json
```
6. Gaseste ID-ul folderului `document_store` (din URL-ul Drive) si testeaza:
```
rclone lsf gdrive: --drive-root-folder-id=<FOLDER_ID>
```
Sau, mai simplu, foloseste calea prin nume daca folderul e in "My Drive" al
contului care a facut share (rclone urmareste shared-with-me cu
`--drive-shared-with-me` daca e nevoie).
7. Pune tinta gasita in `~/.maria-bridge/env`:
```
DRIVE_REMOTE=gdrive:romfast/document_store
```
8. Testeaza manual: `systemctl --user start maria-sync.service` apoi
`journalctl --user -u maria-sync -n 50`, sau butonul „sincronizeaza din Drive
acum" din dashboard.
Dupa configurare, `maria-sync.timer` trage la fiecare 10 minute; reindexarea
ruleaza DOAR daca s-a schimbat efectiv ceva in depozit (amprenta pe nume +
mtime + marime, vezi `rag/sync.py`), ca sa nu reface embeddings degeaba.
## Context conversational
`rag/consumer.py` nu retine memorie intre mesaje — fiecare intrebare e o
interogare RAG independenta (system prompt + top-K chunk-uri + intrebare).
Vezi `claude-agent/docs/maria-whatsapp-rag-prototype.md` pentru motiv si
comparatie cu celelalte punti (Discord: context nelimitat + `/new`; Maria pe
Flowise: fereastra fixa de 5 schimburi).

View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Dashboard de control pentru puntea WhatsApp+RAG a lui Maria (LXC 171).
Model: `discord-bridge/dashboard/api.py` de pe acelasi container. Diferente
deliberate fata de acela:
- controleaza DOUA unitati fixe (`maria-whatsapp.service`, `maria-rag.service`),
nu una singura — dar tot dintr-o lista fixa, nu dintr-un nume primit din
request (acelasi motiv: altfel dashboard-ul devine un `systemctl` remote
fara parola).
- expune si un mic depozit de documente (rag/store.py) — listare, adaugare,
stergere — plus un buton de reindexare (ruleaza rag/indexer.py) si starea
de conectare WhatsApp (proxy catre `/status` al puntii, inclusiv QR-ul de
asociere cand nu e inca legata).
- bind implicit pe 127.0.0.1, acelasi model de autentificare prin token/cookie
ca discord-bridge (vezi acolo comentariul complet despre motiv).
Fara dependinte in afara stdlib, ca sa ruleze fara venv-ul consumer-ului.
"""
from __future__ import annotations
import json
import secrets
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
# Radacina proiectului (parintele lui dashboard/) si rag/ trebuie sa fie
# importabile: de acolo vin config.py si store.py.
_DASH = Path(__file__).resolve().parent
_ROOT = _DASH.parent
_RAG = _ROOT / "rag"
for _p in (str(_ROOT), str(_RAG), str(_DASH)):
if _p not in sys.path:
sys.path.insert(0, _p)
import config # noqa: E402
import store # noqa: E402
import sync as sync_mod # noqa: E402
# ── constante ───────────────────────────────────────────────────────────
UNITS = {
"bridge": "maria-whatsapp.service",
"rag": "maria-rag.service",
}
SELF_SERVICE = "maria-dashboard.service"
COOKIE_NAME = "maria-dashboard"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30
def log_file(which: str) -> Path:
name = {"bridge": "whatsapp.log", "rag": "rag.log", "dashboard": "dashboard.log"}.get(which, "dashboard.log")
return config.LOG_DIR / name
def bridge_base_url() -> str:
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
_TOKEN: str | None = None
def reset_token_cache() -> None:
global _TOKEN
_TOKEN = None
def auth_disabled() -> bool:
return (config.get("DASHBOARD_AUTH") or "").strip().lower() in ("off", "none", "0", "false")
def dashboard_token() -> str:
global _TOKEN
if _TOKEN is None:
tok = (config.get("DASHBOARD_TOKEN") or "").strip()
if not tok:
tok = secrets.token_urlsafe(32)
print(
f"[auth] DASHBOARD_TOKEN nesetat in {config.ENV_FILE} — token efemer "
f"pentru acest proces: {tok}",
file=sys.stderr, flush=True,
)
_TOKEN = tok
return _TOKEN
# ── systemd ─────────────────────────────────────────────────────────────
def _sysctl(*args: str, timeout: float = 30.0) -> subprocess.CompletedProcess:
return subprocess.run(
["systemctl", "--user", *args],
capture_output=True, text=True, timeout=timeout,
)
def _show(unit: str, prop: str) -> str:
try:
return _sysctl("show", "-p", prop, "--value", unit, timeout=5).stdout.strip()
except Exception:
return ""
def _uptime_s(unit: str) -> int | None:
mono = _show(unit, "ActiveEnterTimestampMonotonic")
if mono.isdigit() and int(mono) > 0:
return max(0, int(time.monotonic() - int(mono) / 1_000_000))
ts = _show(unit, "ActiveEnterTimestamp")
if not ts:
return None
try:
parts = ts.split()
stamp = datetime.strptime(f"{parts[1]} {parts[2]}", "%Y-%m-%d %H:%M:%S")
except (ValueError, IndexError):
return None
return max(0, int(time.time() - stamp.timestamp()))
def unit_info(unit: str) -> dict:
active = _show(unit, "ActiveState")
pid = _show(unit, "MainPID")
mem = _show(unit, "MemoryCurrent")
restarts = _show(unit, "NRestarts")
return {
"unit": unit,
"active": active == "active",
"state": active or "unknown",
"sub": _show(unit, "SubState"),
"enabled": _show(unit, "UnitFileState"),
"pid": int(pid) if pid.isdigit() and pid != "0" else None,
"memory_bytes": int(mem) if mem.isdigit() else None,
"restarts": int(restarts) if restarts.isdigit() else 0,
"uptime_s": _uptime_s(unit) if active == "active" else None,
}
# ── puntea WhatsApp (proxy HTTP catre whatsapp/index.js) ──────────────────
def whatsapp_status() -> dict:
"""Interogheaza /status al puntii Node. Esec (proces oprit) = raspuns neutru."""
try:
req = urllib.request.Request(f"{bridge_base_url()}/status")
with urllib.request.urlopen(req, timeout=3) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return {"connected": False, "phone": None, "qr": None, "reachable": False}
# ── depozitul de documente + index ─────────────────────────────────────────
def index_info() -> dict:
try:
raw = config.INDEX_FILE.read_text(encoding="utf-8")
entries = json.loads(raw)
sources = sorted({e.get("source") for e in entries if isinstance(e, dict) and e.get("source")})
return {
"chunks": len(entries),
"documents_indexed": len(sources),
"mtime": config.INDEX_FILE.stat().st_mtime,
}
except (OSError, ValueError):
return {"chunks": 0, "documents_indexed": 0, "mtime": None}
def _venv_python() -> str:
py = config.STATE_DIR / "venv" / "bin" / "python"
return str(py) if py.exists() else sys.executable
def run_reindex() -> dict:
"""Ruleaza rag/indexer.py cu python-ul din venv (daca exista) si intoarce output-ul."""
try:
r = subprocess.run(
[_venv_python(), str(_RAG / "indexer.py")],
capture_output=True, text=True, timeout=600,
cwd=str(_RAG),
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "indexarea a depasit timpul (600s)"}
ok = r.returncode == 0
return {"ok": ok, "stdout": r.stdout[-4000:], "stderr": r.stderr[-4000:]}
def run_sync(force: bool = False) -> dict:
"""Ruleaza rag/sync.py (rclone pull + reindexare conditionata) intr-un proces
separat — acelasi motiv ca la reindex: nu blocam thread-ul dashboard-ului cu
embeddings, iar rclone poate lua cateva secunde bune pe retea."""
args = [_venv_python(), str(_RAG / "sync.py")]
if force:
args.append("--force")
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=600, cwd=str(_RAG))
except subprocess.TimeoutExpired:
return {"ok": False, "error": "sincronizarea a depasit timpul (600s)"}
ok = r.returncode == 0
out = {"ok": ok, "stdout": r.stdout[-4000:], "stderr": r.stderr[-4000:]}
out["state"] = sync_mod.read_sync_state()
return out
# ── HTTP ────────────────────────────────────────────────────────────────
def _parse_cookies(raw: str) -> dict[str, str]:
out: dict[str, str] = {}
for chunk in (raw or "").split(";"):
chunk = chunk.strip()
if "=" in chunk:
k, v = chunk.split("=", 1)
out[k.strip()] = v.strip()
return out
class Handler(SimpleHTTPRequestHandler):
server_version = "maria-dashboard"
protocol_version = "HTTP/1.1"
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(_DASH), **kw)
def log_message(self, format, *args): # noqa: A002
sys.stderr.write("%s %s\n" % (self.address_string(), format % args))
def send_json(self, payload, status: int = 200, extra_headers: dict | None = None):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for k, v in (extra_headers or {}).items():
self.send_header(k, v)
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def read_json(self) -> dict:
try:
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n).decode("utf-8") if n > 0 else ""
data = json.loads(raw) if raw else {}
return data if isinstance(data, dict) else {}
except (ValueError, OSError, UnicodeDecodeError):
return {}
def authed(self) -> bool:
if auth_disabled():
return True
got = _parse_cookies(self.headers.get("Cookie", "")).get(COOKIE_NAME, "")
return bool(got) and secrets.compare_digest(got, dashboard_token())
def deny(self):
self.send_json({"error": "neautentificat"}, 401)
# --- GET -----------------------------------------------------------
def do_GET(self):
path = urlparse(self.path).path
if path.startswith("/api/"):
if not self.authed():
return self.deny()
return self.route_get(path)
if path in ("/", "/index.html") and not self.authed():
self.send_response(302)
self.send_header("Location", "/login.html")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path == "/login.html" and auth_disabled():
self.send_response(302)
self.send_header("Location", "/")
self.send_header("Content-Length", "0")
self.end_headers()
return
return super().do_GET()
def route_get(self, path: str):
qs = parse_qs(urlparse(self.path).query)
if path == "/api/status":
return self.send_json({
"bridge": unit_info(UNITS["bridge"]),
"rag": unit_info(UNITS["rag"]),
"dashboard": unit_info(SELF_SERVICE),
"whatsapp": whatsapp_status(),
"index": index_info(),
"documents": len(store.list_documents()),
"sync": {
**sync_mod.read_sync_state(),
"drive_remote": config.get("DRIVE_REMOTE") or None,
},
"auth": not auth_disabled(),
"now": time.time(),
})
if path == "/api/documents":
return self.send_json({"documents": store.list_documents()})
if path == "/api/documents/read":
name = qs.get("name", [""])[0]
try:
return self.send_json({"name": name, "content": store.read_document(name)})
except (ValueError, OSError) as exc:
return self.send_json({"error": str(exc)}, 400)
if path == "/api/logs":
try:
n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000)
except ValueError:
n = 200
which = qs.get("service", ["bridge"])[0]
target = log_file(which)
if not target.exists():
return self.send_json({"lines": [f"({target} nu exista)"]})
r = subprocess.run(["tail", "-n", str(n), str(target)],
capture_output=True, text=True, timeout=15)
return self.send_json({"file": target.name, "lines": r.stdout.splitlines()})
return self.send_json({"error": "ruta necunoscuta"}, 404)
# --- POST ----------------------------------------------------------
def do_POST(self):
path = urlparse(self.path).path
if path == "/api/auth/login":
return self.handle_login()
if path == "/api/auth/logout":
return self.send_json(
{"ok": True},
extra_headers={"Set-Cookie": f"{COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"},
)
if not self.authed():
return self.deny()
if path == "/api/service":
return self.handle_service()
if path == "/api/documents":
return self.handle_document_write()
if path == "/api/documents/delete":
return self.handle_document_delete()
if path == "/api/reindex":
return self.send_json(run_reindex())
if path == "/api/sync":
data = self.read_json()
return self.send_json(run_sync(force=bool(data.get("force"))))
if path == "/api/restart-self":
self.send_json({"ok": True, "message": "dashboard-ul reporneste in 1s"})
threading.Thread(target=lambda: (time.sleep(1), __import__("os")._exit(0)), daemon=True).start()
return None
return self.send_json({"error": "ruta necunoscuta"}, 404)
def handle_login(self):
data = self.read_json()
provided = (data.get("token") or "").strip()
if not provided or not secrets.compare_digest(provided, dashboard_token()):
time.sleep(0.5)
return self.send_json({"error": "token invalid"}, 401)
cookie = (f"{COOKIE_NAME}={dashboard_token()}; HttpOnly; SameSite=Strict; "
f"Path=/; Max-Age={COOKIE_MAX_AGE}")
return self.send_json({"ok": True}, extra_headers={"Set-Cookie": cookie})
def handle_service(self):
"""start / stop / restart pe una din UNITATILE FIXE. Numele vine dintr-o
cheie (bridge/rag), niciodata dintr-un nume de unit arbitrar."""
data = self.read_json()
which = str(data.get("service") or "")
action = str(data.get("action") or "")
unit = UNITS.get(which)
if not unit:
return self.send_json({"ok": False, "error": f"serviciu necunoscut: {which}"}, 400)
if action not in ("start", "stop", "restart"):
return self.send_json({"ok": False, "error": f"actiune necunoscuta: {action}"}, 400)
print(f"[actiune] {action} pe {unit}", file=sys.stderr, flush=True)
try:
r = _sysctl(action, unit)
except subprocess.TimeoutExpired:
return self.send_json({"ok": False, "error": "systemctl a depasit timpul"}, 504)
if r.returncode != 0:
return self.send_json({"ok": False, "error": (r.stderr or r.stdout).strip()}, 500)
time.sleep(1.0)
return self.send_json({"ok": True, "action": action, "service": unit_info(unit)})
def handle_document_write(self):
data = self.read_json()
name = str(data.get("name") or "")
content = data.get("content")
if content is None:
return self.send_json({"ok": False, "error": "lipseste 'content'"}, 400)
try:
store.write_document(name, str(content))
except (ValueError, OSError) as exc:
return self.send_json({"ok": False, "error": str(exc)}, 400)
return self.send_json({"ok": True, "name": name})
def handle_document_delete(self):
data = self.read_json()
name = str(data.get("name") or "")
try:
ok = store.delete_document(name)
except (ValueError, OSError) as exc:
return self.send_json({"ok": False, "error": str(exc)}, 400)
return self.send_json({"ok": ok, "name": name}, 200 if ok else 404)
def main() -> None:
bind = config.get("DASHBOARD_BIND") or "127.0.0.1"
port = config.get_int("DASHBOARD_PORT", 18792)
dashboard_token()
srv = ThreadingHTTPServer((bind, port), Handler)
srv.daemon_threads = True
print(f"dashboard Maria pe http://{bind}:{port} (unitati: {list(UNITS.values())})",
file=sys.stderr, flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maria — WhatsApp + RAG</title>
<style>
:root{color-scheme:dark}
body{font-family:system-ui,sans-serif;background:#111;color:#eee;margin:0;padding:24px;
max-width:920px;margin-inline:auto}
h1{font-size:20px}
h2{font-size:15px;color:#aaa;margin-top:28px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:12px}
.card{background:#1b1b1b;border-radius:10px;padding:16px}
.row{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:14px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.dot.on{background:#4caf50} .dot.off{background:#e57373}
button{padding:6px 12px;border-radius:6px;border:1px solid #333;background:#232323;
color:#eee;cursor:pointer;font-size:13px;margin-right:6px}
button:hover{background:#2e2e2e}
button.danger:hover{background:#5c2020}
table{width:100%;border-collapse:collapse;font-size:13px;margin-top:8px}
td,th{padding:6px 4px;border-bottom:1px solid #262626;text-align:left}
textarea{width:100%;min-height:120px;background:#0d0d0d;color:#eee;border:1px solid #333;
border-radius:6px;padding:8px;box-sizing:border-box;font-family:monospace}
input[type=text]{padding:6px;border-radius:6px;border:1px solid #333;background:#0d0d0d;color:#eee}
pre{background:#0d0d0d;padding:10px;border-radius:6px;max-height:280px;overflow:auto;font-size:12px}
img.qr{width:220px;height:220px;border-radius:8px;background:#fff;padding:8px}
.muted{color:#888;font-size:12px}
a.logout{color:#888;font-size:12px;float:right;cursor:pointer}
</style>
</head>
<body>
<h1>Maria — punte WhatsApp + RAG <a class="logout" id="logout">deconectare</a></h1>
<div class="grid">
<div class="card">
<div class="row"><b>Punte WhatsApp</b><span id="bridge-state"></span></div>
<div class="row muted" id="bridge-detail"></div>
<button onclick="svc('bridge','start')">start</button>
<button onclick="svc('bridge','stop')">stop</button>
<button onclick="svc('bridge','restart')">restart</button>
</div>
<div class="card">
<div class="row"><b>Consumer RAG</b><span id="rag-state"></span></div>
<div class="row muted" id="rag-detail"></div>
<button onclick="svc('rag','start')">start</button>
<button onclick="svc('rag','stop')">stop</button>
<button onclick="svc('rag','restart')">restart</button>
</div>
</div>
<h2>Conectare WhatsApp</h2>
<div class="card" id="wa-card">
<div class="row" id="wa-status">se incarca...</div>
<div id="wa-qr"></div>
</div>
<h2>Documente indexate (depozit RAG)</h2>
<div class="card">
<div class="row muted" id="index-detail"></div>
<div class="row muted" id="sync-detail"></div>
<button onclick="reindex()">reconstruieste indexul</button>
<button onclick="syncNow()">sincronizeaza din Drive acum</button>
<table id="docs-table"><thead><tr><th>nume</th><th>marime</th><th></th></tr></thead><tbody></tbody></table>
<h2 style="margin-top:16px">Adauga document (.txt / .md)</h2>
<input type="text" id="doc-name" placeholder="ex: politica-retururi.md" style="width:260px">
<br><br>
<textarea id="doc-content" placeholder="continutul documentului..."></textarea>
<br><br>
<button onclick="addDoc()">salveaza si reindexeaza</button>
<div class="muted" id="doc-msg"></div>
</div>
<h2>Loguri</h2>
<div class="card">
<select id="log-service" onchange="loadLogs()">
<option value="bridge">whatsapp</option>
<option value="rag">rag</option>
<option value="dashboard">dashboard</option>
</select>
<button onclick="loadLogs()">reincarca</button>
<pre id="logs"></pre>
</div>
<script>
async function api(path, opts) {
const r = await fetch(path, opts);
if (r.status === 401) { location.href = '/login.html'; throw new Error('neautentificat'); }
return r.json();
}
function fmtUptime(s) {
if (s == null) return '—';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60);
return `${h}h${m}m`;
}
function renderUnit(prefix, info) {
document.getElementById(prefix+'-state').innerHTML =
`<span class="dot ${info.active?'on':'off'}"></span>${info.state}/${info.sub}`;
document.getElementById(prefix+'-detail').textContent =
info.active ? `pid ${info.pid ?? '?'} · sus de ${fmtUptime(info.uptime_s)} · ${info.restarts} restarturi`
: `${info.restarts} restarturi`;
}
async function refresh() {
const s = await api('/api/status');
renderUnit('bridge', s.bridge);
renderUnit('rag', s.rag);
const wa = s.whatsapp || {};
const waStatus = document.getElementById('wa-status');
const waQr = document.getElementById('wa-qr');
if (wa.connected) {
waStatus.innerHTML = `<span class="dot on"></span>conectat ca ${wa.phone || '?'}`;
waQr.innerHTML = '';
} else if (wa.qr) {
waStatus.innerHTML = `<span class="dot off"></span>neconectat — scaneaza codul QR din WhatsApp &rarr; Dispozitive conectate`;
waQr.innerHTML = `<img class="qr" src="${wa.qr}">`;
} else {
waStatus.innerHTML = `<span class="dot off"></span>${wa.reachable === false ? 'puntea nu ruleaza' : 'astept codul QR...'}`;
waQr.innerHTML = '';
}
document.getElementById('index-detail').textContent =
`${s.index.chunks} chunk-uri din ${s.index.documents_indexed} documente indexate · ${s.documents} documente in depozit` +
(s.index.mtime ? ` · indexat ${new Date(s.index.mtime*1000).toLocaleString('ro-RO')}` : '');
const sync = s.sync || {};
document.getElementById('sync-detail').textContent = sync.drive_remote
? `Drive: ${sync.drive_remote}` + (sync.synced_at ? ` · ultima sincronizare ${new Date(sync.synced_at*1000).toLocaleString('ro-RO')}` : ' · inca nesincronizat')
: 'Sincronizare Drive dezactivata (DRIVE_REMOTE nesetat in env) — doar upload manual';
const docs = await api('/api/documents');
const tbody = document.querySelector('#docs-table tbody');
tbody.innerHTML = '';
for (const d of docs.documents) {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${d.name}</td><td>${d.size} B</td><td><button class="danger" onclick="delDoc('${d.name}')">sterge</button></td>`;
tbody.appendChild(tr);
}
}
async function svc(which, action) {
const r = await api('/api/service', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({service: which, action}),
});
if (!r.ok) alert(r.error || 'a esuat');
refresh();
}
async function addDoc() {
const name = document.getElementById('doc-name').value.trim();
const content = document.getElementById('doc-content').value;
const msg = document.getElementById('doc-msg');
if (!name || !content) { msg.textContent = 'completeaza numele si continutul'; return; }
const r = await api('/api/documents', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name, content}),
});
if (!r.ok) { msg.textContent = r.error || 'a esuat'; return; }
msg.textContent = 'salvat, reindexez...';
const ri = await api('/api/reindex', {method: 'POST'});
msg.textContent = ri.ok ? 'salvat si reindexat' : ('salvat, reindexarea a esuat: ' + (ri.error || ri.stderr || ''));
document.getElementById('doc-content').value = '';
refresh();
}
async function delDoc(name) {
if (!confirm(`Stergi ${name}?`)) return;
await api('/api/documents/delete', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name}),
});
await reindex();
}
async function reindex() {
const r = await api('/api/reindex', {method: 'POST'});
if (!r.ok) alert('reindexare esuata: ' + (r.error || r.stderr || ''));
refresh();
}
async function syncNow() {
const r = await api('/api/sync', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({force: true}),
});
if (!r.ok) alert('sincronizare esuata: ' + (r.error || r.stderr || ''));
refresh();
}
async function loadLogs() {
const which = document.getElementById('log-service').value;
const r = await api(`/api/logs?service=${which}&lines=200`);
document.getElementById('logs').textContent = (r.lines || []).join('\n');
}
document.getElementById('logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', {method: 'POST'});
location.href = '/login.html';
});
refresh();
loadLogs();
setInterval(refresh, 4000);
</script>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maria — autentificare</title>
<style>
body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;
align-items:center;justify-content:center;min-height:100vh;margin:0}
form{background:#1b1b1b;padding:32px;border-radius:12px;width:320px}
h1{font-size:18px;margin:0 0 16px}
input{width:100%;padding:10px;border-radius:6px;border:1px solid #333;
background:#0d0d0d;color:#eee;box-sizing:border-box;margin-bottom:12px}
button{width:100%;padding:10px;border-radius:6px;border:none;background:#2e7d32;
color:#fff;font-weight:600;cursor:pointer}
button:hover{background:#256428}
.err{color:#e57373;font-size:13px;min-height:18px}
</style>
</head>
<body>
<form id="f">
<h1>Maria — dashboard WhatsApp+RAG</h1>
<input type="password" id="token" placeholder="DASHBOARD_TOKEN" autofocus>
<button type="submit">Intra</button>
<div class="err" id="err"></div>
</form>
<script>
document.getElementById('f').addEventListener('submit', async (e) => {
e.preventDefault();
const token = document.getElementById('token').value;
const err = document.getElementById('err');
err.textContent = '';
try {
const r = await fetch('/api/auth/login', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token}),
});
if (r.ok) { location.href = '/'; return; }
const d = await r.json().catch(() => ({}));
err.textContent = d.error || 'token invalid';
} catch (ex) {
err.textContent = 'eroare de retea';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
# Configurarea puntii WhatsApp+RAG a lui Maria.
#
# install.sh copiaza acest fisier in ~/.maria-bridge/env cu drepturi 0600.
# Format KEY=value, fara `export`, fara expandare de variabile.
# --- Puntea WhatsApp (whatsapp/index.js) -----------------------------------
BRIDGE_HOST=127.0.0.1
BRIDGE_PORT=8099
# true = raspunde DOAR in chatul cu tine insuti (self-chat). Pune "false" abia
# dupa ce esti multumit de calitatea raspunsurilor — altfel Maria raspunde la
# oricine iti scrie pe numarul legat.
TEST_MODE_SELF_CHAT_ONLY=true
# --- Backend LLM + embeddings -----------------------------------------------
# Modelul de chat folosit pentru raspunsuri (format compatibil OpenAI
# /v1/chat/completions). Implicit presupune un tunel/proxy local catre acelasi
# backend folosit de Echo (LXC 110) — vezi docs/maria-whatsapp-rag-prototype.md.
LLM_URL=http://127.0.0.1:8091
# Ollama, pentru embeddings (nomic-embed-text).
OLLAMA_URL=http://127.0.0.1:11434
EMBED_MODEL=nomic-embed-text
TOP_K=3
MAX_TOKENS=250
POLL_INTERVAL_S=2
# --- Depozit de documente + sincronizare Google Drive -----------------------
# Tinta rclone pentru dosarul de documente (partajat de pe Windows,
# D:\GoogleDrive\romfast\document_store), format `<remote>:<cale>`.
# Gol = sincronizare dezactivata; documentele se administreaza doar manual din
# dashboard. Vezi README.md, "Sincronizare cu Google Drive", pentru configurarea
# remote-ului `rclone` (cont de serviciu, fara OAuth interactiv pe un container
# headless).
DRIVE_REMOTE=
# --- dashboard de control (dashboard/api.py) --------------------------------
# Token de acces. Generat automat de ops/install.sh daca lipseste.
DASHBOARD_TOKEN=
# `off` scoate complet login-ul. De pus DOAR daca panoul ramane strict pe
# 127.0.0.1 / tailnet (acelasi motiv ca la discord-bridge).
DASHBOARD_AUTH=
DASHBOARD_BIND=127.0.0.1
DASHBOARD_PORT=18792

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# install.sh — instaleaza puntea WhatsApp+RAG a lui Maria pe LXC 171 (claude-agent).
#
# IDEMPOTENT: se poate rula de cate ori vrei. Nu suprascrie niciodata
# ~/.maria-bridge/env (acolo sta configuratia completata de om).
#
# ./ops/install.sh # instaleaza / actualizeaza, NU porneste serviciile
# ./ops/install.sh --start # in plus porneste bridge+rag (bridge cere QR scanat)
#
# Ce face:
# 1. ~/.maria-bridge/ cu 0700, subdirectoare (logs/, documents/, whatsapp-auth/)
# 2. env 0600 din ops/env.example (doar daca lipseste)
# 3. npm install pentru whatsapp/ (Baileys)
# 4. venv + pip install pentru rag/requirements.txt
# 5. loginctl enable-linger (serviciile de utilizator supravietuiesc logout-ului)
# 6. symlink-uri unit -> ~/.config/systemd/user/ (bridge, rag, dashboard, timer sync)
# 7. systemd-analyze verify + enable
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$(dirname "$HERE")" # .../maria-whatsapp-bridge
STATE_DIR="$HOME/.maria-bridge"
UNIT_DIR="$HOME/.config/systemd/user"
UNITS=(maria-whatsapp.service maria-rag.service maria-sync.service maria-sync.timer)
DASH_UNIT="maria-dashboard.service"
DO_START=0
[ "${1:-}" = "--start" ] && DO_START=1
info() { printf ' \033[32m*\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf ' \033[31mX\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] && die "NU rula ca root. Serviciile Maria sunt servicii de utilizator (claude)."
echo "== Punte WhatsApp+RAG Maria: instalare =="
echo " sursa: $SRC"
# --- 1. director de stare --------------------------------------------------
mkdir -p "$STATE_DIR/logs" "$STATE_DIR/documents" "$STATE_DIR/whatsapp-auth"
chmod 0700 "$STATE_DIR"
info "director de stare: $STATE_DIR (0700)"
# --- 2. env ----------------------------------------------------------------
if [ -f "$STATE_DIR/env" ]; then
chmod 0600 "$STATE_DIR/env"
info "env exista deja, nu se atinge"
else
cp "$HERE/env.example" "$STATE_DIR/env"
chmod 0600 "$STATE_DIR/env"
info "env creat din env.example (0600) — completeaza LLM_URL/DRIVE_REMOTE dupa caz"
fi
# --- 3. dependinte Node (whatsapp/) -----------------------------------------
if command -v npm >/dev/null 2>&1; then
if [ -d "$SRC/whatsapp/node_modules" ]; then
info "node_modules exista deja in whatsapp/"
else
(cd "$SRC/whatsapp" && npm install --omit=dev) \
&& info "npm install: gata (Baileys si dependinte)" \
|| warn "npm install a esuat (retea?) — reia cu: cd $SRC/whatsapp && npm install"
fi
else
warn "npm lipseste din PATH — instaleaza Node (nvm) inainte de a porni puntea"
fi
# --- 4. venv + dependinte Python (rag/) -------------------------------------
if [ ! -x "$STATE_DIR/venv/bin/python" ]; then
python3 -m venv "$STATE_DIR/venv" || die "crearea venv a esuat"
info "venv creat: $STATE_DIR/venv"
else
info "venv exista deja"
fi
"$STATE_DIR/venv/bin/python" -m pip install --quiet --upgrade pip >/dev/null 2>&1
if "$STATE_DIR/venv/bin/python" -m pip install --quiet -r "$SRC/rag/requirements.txt"; then
info "dependinte Python instalate din rag/requirements.txt"
else
warn "instalarea dependintelor Python a esuat (retea?) — reia cu: $STATE_DIR/venv/bin/pip install -r $SRC/rag/requirements.txt"
fi
# --- 5. linger ---------------------------------------------------------------
if [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" = "yes" ]; then
info "linger deja activ pentru $USER"
else
if loginctl enable-linger "$USER" 2>/dev/null; then
info "linger activat pentru $USER"
elif sudo -n loginctl enable-linger "$USER" 2>/dev/null; then
info "linger activat pentru $USER (prin sudo)"
else
warn "nu am putut activa linger; ruleaza manual: sudo loginctl enable-linger $USER"
fi
fi
# --- 6. unit-uri -------------------------------------------------------------
mkdir -p "$UNIT_DIR"
for u in "${UNITS[@]}"; do
ln -sfn "$HERE/$u" "$UNIT_DIR/$u"
info "unit legat: $UNIT_DIR/$u -> $HERE/$u"
done
ln -sfn "$SRC/dashboard/$DASH_UNIT" "$UNIT_DIR/$DASH_UNIT"
info "unit legat: $UNIT_DIR/$DASH_UNIT -> $SRC/dashboard/$DASH_UNIT"
if grep -qE '^DASHBOARD_TOKEN=.+' "$STATE_DIR/env" 2>/dev/null; then
info "DASHBOARD_TOKEN exista deja in env"
else
printf 'DASHBOARD_TOKEN=%s\n' "$(python3 -c 'import secrets;print(secrets.token_urlsafe(24))')" \
>> "$STATE_DIR/env"
info "DASHBOARD_TOKEN generat si adaugat in $STATE_DIR/env"
fi
systemctl --user daemon-reload 2>/dev/null || warn "daemon-reload a esuat (sesiune fara systemd de utilizator?)"
if systemd-analyze verify "$UNIT_DIR/maria-whatsapp.service" 2>&1 | grep -vE 'Unknown key|^$' | grep -q .; then
systemd-analyze verify "$UNIT_DIR/maria-whatsapp.service" 2>&1 | sed 's/^/ /'
warn "systemd-analyze verify a raportat probleme (vezi mai sus)"
else
info "systemd-analyze verify: curat"
fi
# --- 7. rclone (optional, pentru sincronizare Drive) ------------------------
if command -v rclone >/dev/null 2>&1; then
info "rclone gasit: $(command -v rclone)"
if ! grep -qE '^DRIVE_REMOTE=.+' "$STATE_DIR/env" 2>/dev/null; then
warn "DRIVE_REMOTE necompletat — sincronizarea cu Google Drive ramane dezactivata"
warn "vezi README.md, sectiunea 'Sincronizare cu Google Drive'"
fi
else
warn "rclone nu e instalat — sincronizarea automata cu Google Drive nu va functiona"
warn "instaleaza cu: sudo apt-get install -y rclone (apoi vezi README.md)"
fi
# --- 8. enable / start -------------------------------------------------------
for u in maria-whatsapp.service maria-rag.service; do
systemctl --user enable "$u" >/dev/null 2>&1 \
&& info "$u enabled (porneste la boot)" \
|| warn "enable pentru $u a esuat"
done
systemctl --user enable --now "$DASH_UNIT" >/dev/null 2>&1 \
&& info "dashboard enabled + pornit pe 127.0.0.1:$(grep -E '^DASHBOARD_PORT=' "$STATE_DIR/env" | cut -d= -f2- || echo 18792)" \
|| warn "enable pentru $DASH_UNIT a esuat"
systemctl --user enable --now maria-sync.timer >/dev/null 2>&1 \
&& info "timer de sincronizare Drive activat (la fiecare 10 min)" \
|| warn "enable pentru maria-sync.timer a esuat"
if [ "$DO_START" -eq 1 ]; then
systemctl --user restart maria-whatsapp.service && info "punte WhatsApp pornita"
echo
echo " Deschide dashboard-ul (127.0.0.1:\$DASHBOARD_PORT, tunel SSH) si scaneaza"
echo " codul QR din cardul 'Conectare WhatsApp' inainte sa pornesti maria-rag."
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 " Dupa ce completezi $STATE_DIR/env: $0 --start"
fi
echo "== gata =="

View File

@@ -0,0 +1,38 @@
# Unit systemd de UTILIZATOR pentru dashboard-ul de control al puntii Maria.
#
# Se instaleaza in ~/.config/systemd/user/maria-dashboard.service (vezi install.sh).
# Unit SEPARAT de bridge/rag, ca o repornire a lor sa nu ia si panoul din care
# ai apasat butonul (acelasi motiv ca la claude-discord-dashboard.service).
#
# systemctl --user daemon-reload
# systemctl --user enable --now maria-dashboard
# journalctl --user -u maria-dashboard -f
[Unit]
Description=Dashboard de control pentru puntea WhatsApp+RAG a lui Maria (LXC 171)
Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
After=network.target
[Service]
Type=simple
WorkingDirectory=%h/.maria-bridge
EnvironmentFile=%h/.maria-bridge/env
Environment=MARIA_BRIDGE_DIR=%h/.maria-bridge
Environment=PYTHONUNBUFFERED=1
# Stdlib only, dar are nevoie sa dea `systemctl --user` — acelasi utilizator ca
# bridge/rag, deci merge fara sudo.
ExecStart=/usr/bin/python3 /workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/dashboard/api.py
Restart=always
RestartSec=2
MemoryHigh=192M
MemoryMax=384M
StandardOutput=append:%h/.maria-bridge/logs/dashboard.log
StandardError=append:%h/.maria-bridge/logs/dashboard.log
SyslogIdentifier=maria-dashboard
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,48 @@
# Unit systemd de UTILIZATOR pentru consumer-ul RAG al lui Maria (rag/consumer.py).
#
# Se instaleaza in ~/.config/systemd/user/maria-rag.service (vezi install.sh).
#
# systemctl --user daemon-reload
# systemctl --user enable --now maria-rag
# journalctl --user -u maria-rag -f
[Unit]
Description=Consumer RAG pentru Maria — raspunde la mesajele din puntea WhatsApp
Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
After=network-online.target maria-whatsapp.service
Wants=network-online.target
# Nu e o dependinta stricta (poate porni si daca puntea WhatsApp inca nu e sus —
# consumer.py doar face polling si tolereaza erori de retea), dar ordinea are sens.
Wants=maria-whatsapp.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
WorkingDirectory=/workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag
EnvironmentFile=%h/.maria-bridge/env
Environment=MARIA_BRIDGE_DIR=%h/.maria-bridge
Environment=PYTHONUNBUFFERED=1
ExecStartPre=/bin/mkdir -p %h/.maria-bridge/logs %h/.maria-bridge/documents
ExecStart=%h/.maria-bridge/venv/bin/python consumer.py
KillMode=control-group
KillSignal=SIGTERM
TimeoutStopSec=15
Restart=on-failure
RestartSec=5
RestartSteps=5
RestartMaxDelaySec=120
MemoryHigh=256M
MemoryMax=512M
StandardOutput=append:%h/.maria-bridge/logs/rag.log
StandardError=append:%h/.maria-bridge/logs/rag.log
SyslogIdentifier=maria-rag
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,21 @@
# Unit systemd de UTILIZATOR: o singura trecere de sincronizare Drive + reindexare
# conditionata (rag/sync.py). Pornit periodic de maria-sync.timer, sau manual:
#
# systemctl --user start maria-sync.service
# journalctl --user -u maria-sync -f
[Unit]
Description=Sincronizare depozit documente Maria (Drive) + reindexare RAG
Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
[Service]
Type=oneshot
WorkingDirectory=/workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag
EnvironmentFile=%h/.maria-bridge/env
Environment=MARIA_BRIDGE_DIR=%h/.maria-bridge
Environment=PYTHONUNBUFFERED=1
ExecStart=%h/.maria-bridge/venv/bin/python sync.py
StandardOutput=append:%h/.maria-bridge/logs/sync.log
StandardError=append:%h/.maria-bridge/logs/sync.log
SyslogIdentifier=maria-sync

View File

@@ -0,0 +1,20 @@
# Timer de UTILIZATOR: declanseaza maria-sync.service la fiecare 10 minute.
#
# Interval fix aici (nu citit din env): un timer systemd nu poate parametriza
# OnUnitActiveSec dintr-un fisier extern usor — de editat direct daca 10 min nu
# se potriveste.
#
# systemctl --user daemon-reload
# systemctl --user enable --now maria-sync.timer
# systemctl --user list-timers maria-sync.timer
[Unit]
Description=Sincronizare periodica a depozitului de documente Maria (Drive)
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,47 @@
# Unit systemd de UTILIZATOR pentru puntea WhatsApp (Baileys) a lui Maria.
#
# Se instaleaza in ~/.config/systemd/user/maria-whatsapp.service (vezi install.sh).
#
# systemctl --user daemon-reload
# systemctl --user enable --now maria-whatsapp
# journalctl --user -u maria-whatsapp -f
[Unit]
Description=Punte WhatsApp (Baileys) pentru Maria (LXC 171 claude-agent)
Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
WorkingDirectory=/workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp
EnvironmentFile=%h/.maria-bridge/env
Environment=MARIA_BRIDGE_DIR=%h/.maria-bridge
Environment=NODE_ENV=production
# nvm — acelasi PATH ca discord-bridge, pe acelasi container.
Environment=PATH=%h/bin:%h/.nvm/versions/node/v20.19.6/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStartPre=/bin/mkdir -p %h/.maria-bridge/logs %h/.maria-bridge/whatsapp-auth
ExecStart=%h/.nvm/versions/node/v20.19.6/bin/node index.js
KillMode=control-group
KillSignal=SIGTERM
TimeoutStopSec=15
Restart=on-failure
RestartSec=5
RestartSteps=5
RestartMaxDelaySec=120
MemoryHigh=384M
MemoryMax=768M
StandardOutput=append:%h/.maria-bridge/logs/whatsapp.log
StandardError=append:%h/.maria-bridge/logs/whatsapp.log
SyslogIdentifier=maria-whatsapp
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,106 @@
"""Configuratie comuna a puntii WhatsApp+RAG pentru Maria (LXC 171 claude-agent).
Citeste ~/.maria-bridge/env (KEY=value, tolerant la comentarii si ghilimele).
Mirrors deliberat conventiile din discord-bridge/config.py, ca sa fie un singur
model de citit pentru cine intretine ambele punti pe acest container.
"""
from __future__ import annotations
import os
import pathlib
# Directorul de baza poate fi mutat in teste prin MARIA_BRIDGE_DIR.
_DEFAULT_DIR = pathlib.Path.home() / ".maria-bridge"
STATE_DIR: pathlib.Path = pathlib.Path(os.environ.get("MARIA_BRIDGE_DIR") or _DEFAULT_DIR)
DOCS_DIR: pathlib.Path = STATE_DIR / "documents"
INDEX_FILE: pathlib.Path = STATE_DIR / "rag_index.json"
LOG_DIR: pathlib.Path = STATE_DIR / "logs"
ENV_FILE: pathlib.Path = STATE_DIR / "env"
AUTH_DIR: pathlib.Path = STATE_DIR / "whatsapp-auth"
_env: dict[str, str] = {}
# Valori implicite. LLM_URL/OLLAMA_URL presupun tunel/proxy local catre backend-ul
# real (vezi docs/maria-whatsapp-rag-prototype.md) — de completat in env dupa caz.
DEFAULTS: dict[str, str] = {
"BRIDGE_HOST": "127.0.0.1",
"BRIDGE_PORT": "8099",
"LLM_URL": "http://127.0.0.1:8091",
"OLLAMA_URL": "http://127.0.0.1:11434",
"EMBED_MODEL": "nomic-embed-text",
"TOP_K": "3",
"MAX_TOKENS": "250",
"POLL_INTERVAL_S": "2",
"TEST_MODE_SELF_CHAT_ONLY": "true",
"DASHBOARD_BIND": "127.0.0.1",
"DASHBOARD_PORT": "18792",
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
# "gdrive:romfast/document_store" (dosarul D:\GoogleDrive\romfast\document_store
# de pe Windows, vazut prin Google Drive API). Gol = sincronizare dezactivata,
# doar upload manual din dashboard.
"DRIVE_REMOTE": "",
}
def parse_env(text: str) -> dict[str, str]:
"""Parseaza un fisier de tip KEY=value. Nu arunca niciodata."""
out: dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):].strip()
if "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
if not key:
continue
val = val.strip()
if val[:1] not in ("'", '"'):
cut = val.find(" #")
if cut >= 0:
val = val[:cut].rstrip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
val = val[1:-1]
out[key] = val
return out
def reload(base_dir: str | os.PathLike | None = None) -> dict[str, str]:
"""Recalculeaza caile si reciteste env-ul. Returneaza dictionarul incarcat."""
global STATE_DIR, DOCS_DIR, INDEX_FILE, LOG_DIR, ENV_FILE, AUTH_DIR, _env
if base_dir is None:
base_dir = os.environ.get("MARIA_BRIDGE_DIR") or _DEFAULT_DIR
STATE_DIR = pathlib.Path(base_dir)
DOCS_DIR = STATE_DIR / "documents"
INDEX_FILE = STATE_DIR / "rag_index.json"
LOG_DIR = STATE_DIR / "logs"
ENV_FILE = STATE_DIR / "env"
AUTH_DIR = STATE_DIR / "whatsapp-auth"
try:
_env = parse_env(ENV_FILE.read_text(encoding="utf-8"))
except OSError:
_env = {}
return _env
def get(key: str, default: str | None = None) -> str | None:
if key in _env:
return _env[key]
if key in DEFAULTS:
return DEFAULTS[key]
return default
def get_int(key: str, default: int) -> int:
try:
return int(get(key, str(default)) or default)
except (TypeError, ValueError):
return default
reload()

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Consuma mesajele din puntea WhatsApp (whatsapp/index.js), raspunde via RAG.
Fiecare mesaj e o interogare RAG INDEPENDENTA: nu exista chat_history intre
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).
"""
from __future__ import annotations
import json
import math
import sys
import time
import requests
import config
SYSTEM_PROMPT = (
"Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). "
"Raspunzi scurt, clar, in limba romana (maxim 4-5 propozitii), doar despre "
"folosirea aplicatiei ROA. Foloseste EXCLUSIV informatiile din contextul "
"furnizat mai jos. Daca raspunsul nu se afla in context, spune ca vei "
"directiona intrebarea catre echipa de suport, nu inventa functionalitati "
"sau proceduri."
)
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
def bridge_url() -> str:
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
def load_index() -> list[dict]:
try:
return json.loads(config.INDEX_FILE.read_text(encoding="utf-8"))
except OSError:
return []
def embed(text: str) -> list[float]:
resp = requests.post(
f"{config.get('OLLAMA_URL')}/api/embeddings",
json={"model": config.get("EMBED_MODEL"), "prompt": text},
timeout=30,
)
resp.raise_for_status()
return resp.json()["embedding"]
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def retrieve(index: list[dict], question: str, top_k: int) -> list[str]:
if not index:
return []
q_vec = embed(question)
scored = [(cosine(q_vec, e["embedding"]), e["text"]) for e in index]
scored.sort(key=lambda pair: pair[0], reverse=True)
return [text for _, text in scored[:top_k]]
def ask_llm(index: list[dict], question: str) -> str:
top_k = config.get_int("TOP_K", 3)
context_chunks = retrieve(index, 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}"
resp = requests.post(
f"{config.get('LLM_URL')}/v1/chat/completions",
json={
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
],
"max_tokens": config.get_int("MAX_TOKENS", 250),
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def send_reply(to: str, text: str) -> None:
requests.post(f"{bridge_url()}/send", json={"to": to, "text": REPLY_PREFIX + text}, timeout=15)
def react_seen(to: str, message_id: str, from_me: bool) -> None:
try:
requests.post(
f"{bridge_url()}/react",
json={"to": to, "id": message_id, "emoji": "\U0001F440", "fromMe": from_me},
timeout=10,
)
except Exception as exc: # noqa: BLE001
print(f"[consumer] react error: {exc}", file=sys.stderr)
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')}",
file=sys.stderr,
)
index = load_index()
last_index_check = time.time()
while True:
try:
if time.time() - last_index_check > INDEX_REFRESH_S:
index = load_index()
last_index_check = time.time()
resp = requests.get(f"{bridge_url()}/messages", timeout=10)
resp.raise_for_status()
messages = resp.json().get("messages", [])
for msg in messages:
if msg.get("isGroup"):
continue
text = msg.get("text", "")
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)
react_seen(sender, msg.get("id"), msg.get("fromMe", False))
send_reply(sender, "Caut informatia, revin imediat...")
try:
reply = ask_llm(index, text)
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."
send_reply(sender, reply)
print(f"[consumer] -> raspuns catre {sender}", file=sys.stderr)
except Exception as exc: # noqa: BLE001
print(f"[consumer] poll error: {exc}", file=sys.stderr)
time.sleep(poll_s)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""(Re)construieste rag_index.json din toate documentele din depozit (store.py),
cu embeddings Ollama. Rulat manual sau declansat din dashboard (`/api/reindex`)."""
from __future__ import annotations
import json
import re
import sys
import requests
import config
import store
def chunk_text(text: str) -> list[str]:
# imparte pe linii goale in paragrafe, uneste bucatile mici cu urmatoarea
raw_parts = re.split(r"\n\s*\n", text.strip())
chunks: list[str] = []
buffer = ""
for part in raw_parts:
part = part.strip()
if not part:
continue
buffer = f"{buffer}\n\n{part}" if buffer else part
if len(buffer) >= 200:
chunks.append(buffer)
buffer = ""
if buffer:
chunks.append(buffer)
return chunks
def embed(text: str) -> list[float]:
resp = requests.post(
f"{config.get('OLLAMA_URL')}/api/embeddings",
json={"model": config.get("EMBED_MODEL"), "prompt": text},
timeout=60,
)
resp.raise_for_status()
return resp.json()["embedding"]
def build() -> dict:
entries = []
docs = store.list_documents()
for doc in docs:
text = store.read_document(doc["name"])
for i, chunk in enumerate(chunk_text(text)):
vec = embed(chunk)
entries.append({"source": doc["name"], "chunk": i, "text": chunk, "embedding": vec})
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
config.INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8")
return {"documents": len(docs), "chunks": len(entries)}
if __name__ == "__main__":
result = build()
print(
f"[indexer] {result['documents']} documente, {result['chunks']} chunk-uri -> {config.INDEX_FILE}",
file=sys.stderr,
)

View File

@@ -0,0 +1,2 @@
# Consumer + indexer RAG pentru Maria. Restul e stdlib.
requests==2.32.3

View File

@@ -0,0 +1,53 @@
"""Depozitul de documente pentru indexarea RAG a lui Maria.
Fisiere text (.txt/.md) sub STATE_DIR/documents/, un singur nivel (fara
subdirectoare), ca numele afisat in dashboard sa fie neambiguu si sa poata servi
direct ca parametru de request fara riscuri de traversare de cale.
"""
from __future__ import annotations
import re
import config
_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,200}$")
def validate_name(name: str) -> str:
if not name or not _SAFE_NAME.match(name) or ".." in name or "/" in name:
raise ValueError(f"nume de document invalid: {name!r}")
if not name.endswith((".txt", ".md")):
raise ValueError("doar fisiere .txt sau .md")
return name
def list_documents() -> list[dict]:
config.DOCS_DIR.mkdir(parents=True, exist_ok=True)
out = []
for f in sorted(config.DOCS_DIR.glob("*")):
if not f.is_file() or f.suffix not in (".txt", ".md"):
continue
st = f.stat()
out.append({"name": f.name, "size": st.st_size, "mtime": st.st_mtime})
return out
def read_document(name: str) -> str:
name = validate_name(name)
return (config.DOCS_DIR / name).read_text(encoding="utf-8")
def write_document(name: str, content: str) -> None:
name = validate_name(name)
config.DOCS_DIR.mkdir(parents=True, exist_ok=True)
(config.DOCS_DIR / name).write_text(content, encoding="utf-8")
def delete_document(name: str) -> bool:
name = validate_name(name)
path = config.DOCS_DIR / name
if not path.exists():
return False
path.unlink()
return True

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Sincronizeaza depozitul de documente cu un remote rclone (Google Drive) si
reconstruieste indexul RAG DOAR daca s-a schimbat efectiv ceva pe disc.
De ce prin rclone si nu direct cu Google Drive API: containerul e headless
(fara browser pentru OAuth interactiv) — rclone se configureaza o data cu un
cont de serviciu (`rclone config`, tip `drive`, `service_account_file=...`),
vezi README.md, sectiunea "Sincronizare cu Google Drive".
Config (`~/.maria-bridge/env`, vezi ops/env.example):
DRIVE_REMOTE - tinta rclone, ex: gdrive:romfast/document_store
(gol = sincronizare dezactivata, doar upload manual din dashboard)
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
import time
import config
import indexer
STATE_FILE_NAME = ".sync_state.json"
def _fingerprint() -> str:
"""Amprenta continutului depozitului (nume+mtime+marime), ca sa reindexam
doar cand s-a schimbat efectiv ceva, nu la fiecare tur de sincronizare."""
config.DOCS_DIR.mkdir(parents=True, exist_ok=True)
h = hashlib.sha256()
for f in sorted(config.DOCS_DIR.glob("*")):
if f.is_file() and f.suffix in (".txt", ".md"):
st = f.stat()
h.update(f"{f.name}:{st.st_mtime_ns}:{st.st_size}\n".encode())
return h.hexdigest()
def _sync_state_path():
return config.STATE_DIR / STATE_FILE_NAME
def read_sync_state() -> dict:
try:
return json.loads(_sync_state_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _write_state(fingerprint: str, extra: dict | None = None) -> None:
data = {"fingerprint": fingerprint, "synced_at": time.time()}
if extra:
data.update(extra)
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
_sync_state_path().write_text(json.dumps(data), encoding="utf-8")
def pull_from_drive() -> dict:
"""`rclone sync <DRIVE_REMOTE> -> DOCS_DIR`. Fara remote configurat, e no-op."""
remote = config.get("DRIVE_REMOTE")
if not remote:
return {"ok": True, "skipped": "DRIVE_REMOTE nesetat in env"}
config.DOCS_DIR.mkdir(parents=True, exist_ok=True)
try:
r = subprocess.run(
["rclone", "sync", remote, str(config.DOCS_DIR),
"--include", "*.txt", "--include", "*.md"],
capture_output=True, text=True, timeout=300,
)
except FileNotFoundError:
return {"ok": False, "error": "rclone nu e instalat — vezi README.md"}
except subprocess.TimeoutExpired:
return {"ok": False, "error": "rclone a depasit timpul (300s)"}
return {"ok": r.returncode == 0, "stdout": r.stdout[-2000:], "stderr": r.stderr[-2000:]}
def sync_and_reindex(force: bool = False) -> dict:
pulled = pull_from_drive()
fp = _fingerprint()
last = read_sync_state().get("fingerprint")
if not force and fp == last:
return {"pulled": pulled, "reindexed": False, "reason": "fara schimbari"}
result = indexer.build()
_write_state(fp, {"last_build": result})
return {"pulled": pulled, "reindexed": True, "build": result}
if __name__ == "__main__":
out = sync_and_reindex(force="--force" in sys.argv[1:])
print(json.dumps(out, ensure_ascii=False), file=sys.stderr)

View File

@@ -0,0 +1,248 @@
// 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).
const express = require('express');
const pino = require('pino');
const QRCode = require('qrcode');
const path = require('path');
let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion;
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 MAX_RECONNECT_ATTEMPTS = 5;
const TEST_MODE_SELF_CHAT_ONLY = (process.env.TEST_MODE_SELF_CHAT_ONLY || 'true') !== 'false';
const logger = pino({ level: 'warn' });
let sock = null;
let connected = false;
let phoneNumber = null;
let ownJid = null;
let currentQR = null;
let currentPairingCode = null;
let reconnectAttempts = 0;
let messageQueue = [];
let shuttingDown = false;
async function startConnection() {
if (!makeWASocket) {
const baileys = await import('@whiskeysockets/baileys');
makeWASocket = baileys.default;
useMultiFileAuthState = baileys.useMultiFileAuthState;
DisconnectReason = baileys.DisconnectReason;
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
}
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();
sock = makeWASocket({
version,
auth: state,
logger,
printQRInTerminal: false,
defaultQueryTimeoutMs: 60000,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
try {
currentQR = await QRCode.toDataURL(qr);
console.log('[whatsapp] QR code generated — scan with WhatsApp to link');
} catch (err) {
console.error('[whatsapp] Failed to generate QR code:', err.message);
}
}
if (connection === 'open') {
connected = true;
currentQR = null;
reconnectAttempts = 0;
phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id?.split('@')[0] || null;
ownJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null;
console.log(`[whatsapp] Connected as ${phoneNumber} (self-chat-only mode: ${TEST_MODE_SELF_CHAT_ONLY})`);
}
if (connection === 'close') {
connected = false;
phoneNumber = null;
const statusCode = lastDisconnect?.error?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`[whatsapp] Disconnected (status: ${statusCode})`);
if (shouldReconnect && !shuttingDown) {
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})`);
setTimeout(startConnection, delay);
} else {
console.error(`[whatsapp] Max reconnect attempts reached (${MAX_RECONNECT_ATTEMPTS})`);
}
} else if (statusCode === DisconnectReason.loggedOut) {
console.log('[whatsapp] Logged out — delete auth dir and restart to re-link');
}
}
});
sock.ev.on('messages.upsert', ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.remoteJid === 'status@broadcast') continue;
const isGroup = msg.key.remoteJid.endsWith('@g.us');
const isSelfChat = ownJid && msg.key.remoteJid === ownJid;
if (TEST_MODE_SELF_CHAT_ONLY && !isSelfChat) continue;
if (msg.key.fromMe && !isGroup && !isSelfChat) continue;
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
if (!text) continue;
messageQueue.push({
from: msg.key.remoteJid,
participant: msg.key.participant || null,
pushName: msg.pushName || null,
text,
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)}`);
}
});
}
const app = express();
app.use(express.json({ limit: '50mb' }));
app.get('/status', (_req, res) => {
res.json({
connected,
phone: phoneNumber,
selfChatOnly: TEST_MODE_SELF_CHAT_ONLY,
qr: connected ? null : currentQR,
});
});
app.post('/pair', async (req, res) => {
if (connected) {
return res.json({ error: 'already connected' });
}
const { phone } = req.body || {};
if (!phone) {
return res.status(400).json({ error: 'missing "phone" in body' });
}
if (!sock) {
return res.status(503).json({ error: 'socket not ready yet, try again in a few seconds' });
}
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 });
} catch (err) {
console.error('[whatsapp] Pairing code error:', err.message);
res.status(500).json({ 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 });
});
app.get('/qr', (_req, res) => {
if (connected) {
return res.json({ error: 'already connected' });
}
if (!currentQR) {
return res.json({ error: 'no QR code available yet' });
}
const html = `<!DOCTYPE html>
<html><head><title>WhatsApp QR</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#111;flex-direction:column;font-family:sans-serif;color:#fff}
img{width:400px;height:400px;border-radius:12px}p{margin-top:16px;opacity:.6}</style></head>
<body><img src="${currentQR}" alt="QR Code"/><p>Scan with WhatsApp &rarr; Linked Devices</p></body></html>`;
res.type('html').send(html);
});
app.post('/send', async (req, res) => {
const { to, text } = req.body || {};
if (!to || !text) {
return res.status(400).json({ ok: false, error: 'missing "to" or "text" in body' });
}
if (!connected || !sock) {
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
}
try {
const result = await sock.sendMessage(to, { text });
res.json({ ok: true, id: result.key.id });
} catch (err) {
console.error('[whatsapp] Send failed:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/react', async (req, res) => {
const { to, id, emoji, fromMe, participant } = req.body || {};
if (!to || !id || emoji == null) {
return res.status(400).json({ ok: false, error: 'missing "to", "id", or "emoji" in body' });
}
if (!connected || !sock) {
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
}
try {
const key = { remoteJid: to, id, fromMe: fromMe || false };
if (participant) key.participant = participant;
await sock.sendMessage(to, { react: { text: emoji, key } });
res.json({ ok: true });
} catch (err) {
console.error('[whatsapp] React failed:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
app.get('/messages', (_req, res) => {
const messages = messageQueue.splice(0);
res.json({ messages });
});
const server = app.listen(PORT, HOST, () => {
console.log(`[whatsapp] Bridge API listening on http://${HOST}:${PORT}`);
startConnection().catch((err) => {
console.error('[whatsapp] Failed to start connection:', err.message);
});
});
function shutdown(signal) {
console.log(`[whatsapp] Received ${signal}, shutting down...`);
shuttingDown = true;
if (sock) {
sock.end(undefined);
}
server.close(() => {
console.log('[whatsapp] HTTP server closed');
process.exit(0);
});
setTimeout(() => process.exit(1), 5000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

View File

@@ -0,0 +1,15 @@
{
"name": "maria-whatsapp-bridge",
"version": "1.0.0",
"description": "WhatsApp bridge for Maria RAG support bot, using Baileys",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "^6.7.16",
"express": "^4.21.0",
"pino": "^9.6.0",
"qrcode": "^1.5.4"
}
}