diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index 61756e4..ef158b2 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -202,6 +202,12 @@ https://claude-agent.tailf7372d.ts.net/claude # din tailnet (ca /echo la molt Procesul e legat de `127.0.0.1:18790`; in tailnet il publica `tailscale serve`. Fara Tailscale: `ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171`. +Acelasi panou include si sectiunea **"Maria — WhatsApp + RAG"**, care controleaza +proiectul sibling `../maria-whatsapp-bridge/` (punte WhatsApp Baileys + consumer +RAG): start/stop/restart, cod QR de asociere, depozit de documente, reindexare +si sincronizare Google Drive. Decizie deliberata: un singur dashboard comun +pentru ambele punti de pe acest container, nu un panou separat per serviciu. + | Fisier | Ce e | |--------|------| | `~/.claude-discord/env` | token + allowlist + limite (0600) | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py index 05b9b26..5cd2a29 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -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" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html index ff00701..a47e67a 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -71,6 +71,70 @@
se încarcă…+ +
| Nume | Mărime |
|---|
se încarcă…+