# backend/file_chat.py — historique de discussion par fichier (#169) """Per-file chat history persisted under ``data/chats/``. One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so the filename never carries user-controlled path separators. Writes are atomic (tmp + move) and the message list is capped at :data:`MAX_MESSAGES` to bound growth. """ from __future__ import annotations import hashlib import json import logging import shutil import time import uuid from pathlib import Path from typing import Any logger = logging.getLogger("obsigate.file_chat") CHAT_DIR = Path("data/chats") MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first) MAX_TEXT = 4000 # characters per message def _chat_file(vault: str, path: str) -> Path: """Return the chat file for *(vault, path)* (hashed, traversal-proof).""" CHAT_DIR.mkdir(parents=True, exist_ok=True) key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32] return CHAT_DIR / f"{key}.json" def _read(vault: str, path: str) -> dict[str, Any]: """Load the raw chat document (empty structure when missing/corrupt).""" file = _chat_file(vault, path) if not file.exists(): return {"vault": vault, "path": path, "messages": []} try: doc = json.loads(file.read_text(encoding="utf-8")) if not isinstance(doc.get("messages"), list): raise TypeError("messages is not a list") # caught by the handler below return doc except Exception as e: logger.error("Failed to read chat for %s/%s: %s", vault, path, e) return {"vault": vault, "path": path, "messages": []} def _write(file: Path, doc: dict[str, Any]) -> None: """Atomically persist *doc* (tmp file + rename).""" try: tmp = file.with_suffix(".tmp") tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8") shutil.move(str(tmp), str(file)) except Exception as e: logger.error("Failed to write chat %s: %s", file.name, e) def get_messages(vault: str, path: str) -> list[dict[str, Any]]: """Return the chat history for *(vault, path)* (chronological).""" return list(_read(vault, path).get("messages", [])) def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]: """Append a message and persist it. Returns the stored message. The list is capped at :data:`MAX_MESSAGES` (oldest dropped first). """ text = (text or "").strip()[:MAX_TEXT] msg = { "id": uuid.uuid4().hex[:12], "user": user or "anonyme", "text": text, "ts": time.time(), } doc = _read(vault, path) messages = list(doc.get("messages", [])) messages.append(msg) if len(messages) > MAX_MESSAGES: messages = messages[-MAX_MESSAGES:] doc["messages"] = messages _write(_chat_file(vault, path), doc) return msg