# backend/file_chat.py — historique de discussion (#169, #190) """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. #190 adds the **general chat**: the same store addressed with the reserved sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second implementation. Messages may carry an ``attachment`` (image/video/url) uploaded under ``data/chat_uploads/``. """ from __future__ import annotations import hashlib import html import json import logging import re import shutil import time import uuid from pathlib import Path from typing import Any import httpx 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 # #190 — general (non file-bound) conversation, stored like any other one. GLOBAL_VAULT = "__global__" GLOBAL_PATH = "general" # #191 — private (2 users) conversations reuse the same store: the vault is # the reserved sentinel and the path is the sorted username pair, so the # storage key never depends on who asks. DM_VAULT = "__dm__" # #190 — attachments (image/video) live outside the vaults. UPLOAD_DIR = Path("data/chat_uploads") MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment ALLOWED_ATTACH_EXT = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm", ".ogg", ".mov", ".m4v", } # #191 — link preview fetch budget PREVIEW_TIMEOUT = 5.0 # seconds PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed 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, attachment: dict[str, Any] | None = None, preview: dict[str, Any] | None = None, ) -> dict[str, Any]: """Append a message and persist it. Returns the stored message. The list is capped at :data:`MAX_MESSAGES` (oldest dropped first). *attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url; *preview* (#191) is the OpenGraph card of the first URL in *text*. """ text = (text or "").strip()[:MAX_TEXT] msg: dict[str, Any] = { "id": uuid.uuid4().hex[:12], "user": user or "anonyme", "text": text, "ts": time.time(), } if attachment: msg["attachment"] = attachment if preview: msg["preview"] = preview doc = _read(vault, path) return _append(vault, path, doc, msg) def _append( vault: str, path: str, doc: dict[str, Any], msg: dict[str, Any], ) -> dict[str, Any]: """Cap, persist and return *msg* (shared by file and global chats).""" 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 # --- #191 : messages privés (2 utilisateurs) ------------------------------- def dm_path(user_a: str, user_b: str) -> str: """Storage path for the private conversation between two users. The pair is sorted so both participants address the same document. """ return "|".join(sorted([user_a, user_b])) def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]: """Return the private history between two users (chronological).""" return get_messages(DM_VAULT, dm_path(user_a, user_b)) def add_dm_message( user_a: str, user_b: str, author: str, text: str, attachment: dict[str, Any] | None = None, preview: dict[str, Any] | None = None, ) -> dict[str, Any]: """Append a private message. Returns the stored message.""" return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview) # --- #191 : suppression ----------------------------------------------------- def delete_message(vault: str, path: str, message_id: str) -> bool: """Remove one message from a conversation. True when it existed.""" doc = _read(vault, path) messages = list(doc.get("messages", [])) kept = [m for m in messages if m.get("id") != message_id] if len(kept) == len(messages): return False doc["messages"] = kept _write(_chat_file(vault, path), doc) return True # --- #190 : chat général (conversation centrale, hors fichier) ------------- def get_global_messages() -> list[dict[str, Any]]: """Return the general-chat history (chronological).""" return get_messages(GLOBAL_VAULT, GLOBAL_PATH) def add_global_message( user: str, text: str, attachment: dict[str, Any] | None = None, preview: dict[str, Any] | None = None, ) -> dict[str, Any]: """Append a message to the general chat. Returns the stored message.""" return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview) def save_attachment(filename: str, data: bytes) -> dict[str, Any]: """Persist an uploaded attachment under :data:`UPLOAD_DIR`. Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID (never the client name), the extension must be in :data:`ALLOWED_ATTACH_EXT` and the size is capped at :data:`MAX_UPLOAD_BYTES`. Raises: ValueError: extension refused, empty file or size exceeded. """ ext = Path(filename or "").suffix.lower() if ext not in ALLOWED_ATTACH_EXT: raise ValueError(f"extension refusée : {ext or '(aucune)'}") if not data: raise ValueError("fichier vide") if len(data) > MAX_UPLOAD_BYTES: raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)") UPLOAD_DIR.mkdir(parents=True, exist_ok=True) name = f"{uuid.uuid4().hex}{ext}" (UPLOAD_DIR / name).write_bytes(data) kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image" return { "name": name, "url": f"/api/chat/attachment/{name}", "mime": _MIME_BY_EXT.get(ext, "application/octet-stream"), "kind": kind, } # --- #191 : link preview ---------------------------------------------------- _URL_RE = re.compile(r"https?://[^\s<>\"']+") _PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {} PREVIEW_CACHE_MAX = 200 def _og(content: str, prop: str) -> str: """Extract one OpenGraph/``