Consolidate Maria control into the shared Discord-bridge dashboard

Drop the standalone maria-dashboard.service — one common panel now
controls both bridges, at the operator's request. discord-bridge's
dashboard/api.py gains a Maria section (fixed-unit start/stop/restart
for maria-whatsapp/maria-rag, WhatsApp status+QR proxy, document CRUD,
reindex, Drive sync trigger, log tail) reached via subprocess/HTTP —
no cross-module imports, to avoid colliding with discord-bridge's own
`config` module name. index.html gets a matching "Maria — WhatsApp +
RAG" section. maria-whatsapp-bridge loses its dashboard/ folder and
DASHBOARD_* env keys; install.sh/README point at the shared panel.

Co-Authored-By: Claude Agent <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-08-31 13:28:55 +00:00
parent cfe01135d1
commit 18977cd029
12 changed files with 3019 additions and 766 deletions

View File

@@ -21,12 +21,15 @@ from __future__ import annotations
import json
import os
import re
import secrets
import shutil
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
@@ -47,6 +50,120 @@ from limits import parse_cap # noqa: E402
SERVICE = "claude-discord.service"
SELF_SERVICE = "claude-discord-dashboard.service"
# ── Maria (WhatsApp+RAG, proiect sibling in acelasi repo) ────────────────
# Panou unic la cererea operatorului: acest dashboard controleaza si serviciile
# Mariei, nu doar puntea Discord. NU importam rag/config.py de acolo (ar coliza
# pe numele de modul "config" cu discord-bridge/config.py, deja importat mai
# sus) — citim direct caile si folosim subprocess/HTTP, exact ca la SERVICE.
MARIA_UNITS = {"bridge": "maria-whatsapp.service", "rag": "maria-rag.service"}
MARIA_STATE_DIR = Path(os.environ.get("MARIA_BRIDGE_DIR") or (Path.home() / ".maria-bridge"))
MARIA_DOCS_DIR = MARIA_STATE_DIR / "documents"
MARIA_INDEX_FILE = MARIA_STATE_DIR / "rag_index.json"
MARIA_ENV_FILE = MARIA_STATE_DIR / "env"
MARIA_LOG_DIR = MARIA_STATE_DIR / "logs"
MARIA_VENV_PY = MARIA_STATE_DIR / "venv" / "bin" / "python"
MARIA_RAG_DIR = _BRIDGE.parent / "maria-whatsapp-bridge" / "rag"
_MARIA_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,200}$")
def maria_env() -> dict:
try:
return config.parse_env(MARIA_ENV_FILE.read_text(encoding="utf-8"))
except OSError:
return {}
def maria_get(key: str, default: str = "") -> str:
return maria_env().get(key, default)
def maria_bridge_url() -> str:
return f"http://{maria_get('BRIDGE_HOST', '127.0.0.1')}:{maria_get('BRIDGE_PORT', '8099')}"
def maria_log(which: str) -> Path:
name = {"bridge": "whatsapp.log", "rag": "rag.log"}.get(which, "rag.log")
return MARIA_LOG_DIR / name
def maria_whatsapp_status() -> dict:
try:
req = urllib.request.Request(f"{maria_bridge_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}
def maria_index_info() -> dict:
try:
entries = json.loads(MARIA_INDEX_FILE.read_text(encoding="utf-8"))
sources = {e.get("source") for e in entries if isinstance(e, dict) and e.get("source")}
return {"chunks": len(entries), "documents_indexed": len(sources),
"mtime": MARIA_INDEX_FILE.stat().st_mtime}
except (OSError, ValueError):
return {"chunks": 0, "documents_indexed": 0, "mtime": None}
def maria_sync_state() -> dict:
try:
return json.loads((MARIA_STATE_DIR / ".sync_state.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def maria_documents() -> list[dict]:
MARIA_DOCS_DIR.mkdir(parents=True, exist_ok=True)
out = []
for f in sorted(MARIA_DOCS_DIR.glob("*")):
if f.is_file() and f.suffix in (".txt", ".md"):
st = f.stat()
out.append({"name": f.name, "size": st.st_size, "mtime": st.st_mtime})
return out
def _maria_validate_name(name: str) -> str:
if not name or not _MARIA_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 maria_write_document(name: str, content: str) -> None:
name = _maria_validate_name(name)
MARIA_DOCS_DIR.mkdir(parents=True, exist_ok=True)
(MARIA_DOCS_DIR / name).write_text(content, encoding="utf-8")
def maria_delete_document(name: str) -> bool:
name = _maria_validate_name(name)
path = MARIA_DOCS_DIR / name
if not path.exists():
return False
path.unlink()
return True
def _maria_run(script: str, timeout: float = 600.0, extra_args: list[str] | None = None) -> dict:
interpreter = str(MARIA_VENV_PY) if MARIA_VENV_PY.exists() else sys.executable
args = [interpreter, str(MARIA_RAG_DIR / script), *(extra_args or [])]
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, cwd=str(MARIA_RAG_DIR))
except subprocess.TimeoutExpired:
return {"ok": False, "error": f"{script} a depasit timpul ({timeout:.0f}s)"}
return {"ok": r.returncode == 0, "stdout": r.stdout[-4000:], "stderr": r.stderr[-4000:]}
def maria_reindex() -> dict:
return _maria_run("indexer.py")
def maria_sync(force: bool = False) -> dict:
out = _maria_run("sync.py", extra_args=(["--force"] if force else []))
out["state"] = maria_sync_state()
return out
# Caile se recalculeaza la fiecare apel, nu se ingheata la import: `config.reload()`
# muta STATE_DIR (testele o folosesc ca sa scoata totul din ~/.claude-discord).
@@ -526,6 +643,28 @@ class Handler(SimpleHTTPRequestHandler):
return self.send_json({"checks": doctor()})
if path == "/api/cleanup":
return self.send_json(orphans_report(dry_run=True))
if path == "/api/maria/status":
return self.send_json({
"bridge": unit_info(MARIA_UNITS["bridge"]),
"rag": unit_info(MARIA_UNITS["rag"]),
"whatsapp": maria_whatsapp_status(),
"index": maria_index_info(),
"documents": len(maria_documents()),
"sync": {**maria_sync_state(), "drive_remote": maria_get("DRIVE_REMOTE") or None},
})
if path == "/api/maria/documents":
return self.send_json({"documents": maria_documents()})
if path == "/api/maria/logs":
try:
n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000)
except ValueError:
n = 200
target = maria_log(qs.get("service", ["bridge"])[0])
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 ----------------------------------------------------------
@@ -547,6 +686,17 @@ class Handler(SimpleHTTPRequestHandler):
return self.send_json(orphans_report(dry_run=bool(data.get("dry_run", True))))
if path == "/api/approvals/decide":
return self.handle_decide()
if path == "/api/maria/service":
return self.handle_maria_service()
if path == "/api/maria/documents":
return self.handle_maria_document_write()
if path == "/api/maria/documents/delete":
return self.handle_maria_document_delete()
if path == "/api/maria/reindex":
return self.send_json(maria_reindex())
if path == "/api/maria/sync":
data = self.read_json()
return self.send_json(maria_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), os._exit(0)), daemon=True).start()
@@ -608,6 +758,50 @@ class Handler(SimpleHTTPRequestHandler):
return self.send_json({"ok": bool(ok), "request_id": rid, "decision": decision},
200 if ok else 404)
def handle_maria_service(self):
"""start / stop / restart pe una din UNITATILE FIXE ale Mariei — acelasi
principiu ca handle_service: numele vine dintr-o cheie (bridge/rag), nu
dintr-un nume de unit arbitrar din request."""
data = self.read_json()
which = str(data.get("service") or "")
action = str(data.get("action") or "")
unit = MARIA_UNITS.get(which)
if not unit:
return self.send_json({"ok": False, "error": f"serviciu Maria necunoscut: {which}"}, 400)
if action not in ("start", "stop", "restart"):
return self.send_json({"ok": False, "error": f"actiune necunoscuta: {action}"}, 400)
who = tailnet_user(self.headers) or "local"
print(f"[actiune] {action} pe {unit}, cerut de {who}", 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_maria_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:
maria_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_maria_document_delete(self):
data = self.read_json()
name = str(data.get("name") or "")
try:
ok = maria_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"