- **A — Suivi du document** : l'en-tête du panneau chat affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document. - **B — Chat général** : onglet « Chat » (dernier) dans la sidebar, conversation centrale stockée dans le store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi. - **C — Ordre des onglets** : la barre de filtre passe sous les onglets et sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est actif (`switchSidebarTab` + `routeFilter`). - Transport : broadcast SSE `chat_message` réutilisé (`vault __global__` route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket. - i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur. - Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5 nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules. - Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
192 lines
6.3 KiB
Python
192 lines
6.3 KiB
Python
# 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 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
|
|
|
|
# #190 — general (non file-bound) conversation, stored like any other one.
|
|
GLOBAL_VAULT = "__global__"
|
|
GLOBAL_PATH = "general"
|
|
|
|
# #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",
|
|
}
|
|
|
|
|
|
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,
|
|
) -> 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.
|
|
"""
|
|
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
|
|
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
|
|
|
|
|
|
# --- #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,
|
|
) -> dict[str, Any]:
|
|
"""Append a message to the general chat. Returns the stored message."""
|
|
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment)
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
def attachment_path(name: str) -> Path | None:
|
|
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
|
|
p = Path(name)
|
|
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
|
|
return None
|
|
file = UPLOAD_DIR / p.name
|
|
return file if file.exists() else None
|
|
|
|
|
|
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
|
|
_MIME_BY_EXT = {
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".svg": "image/svg+xml",
|
|
".mp4": "video/mp4",
|
|
".webm": "video/webm",
|
|
".ogg": "video/ogg",
|
|
".mov": "video/quicktime",
|
|
".m4v": "video/x-m4v",
|
|
}
|