"""Thread-safe, scope-isolated local sticker storage. The module deliberately performs no network access. ``source['url']`` may be a local filename or a ``file://`` URI when a preview should be copied; remote URLs are retained as metadata but are not downloaded. """ from __future__ import annotations import copy from datetime import datetime, timezone import json import os from pathlib import Path import random import re import secrets import shutil import tempfile import threading from typing import Any from urllib.parse import unquote, urlparse SCENES: dict[str, str] = { "daily_cute": "日常卖萌、轻松可爱的互动", "happy": "开心、愉快或表达喜悦", "sad": "难过、失落或情绪低落", "very_sad": "非常难过、哭泣或极度沮丧", "comfort": "安慰、鼓励或陪伴他人", "awkward": "尴尬、无语或不知所措", "angry": "生气、不满或抗议", "surprised": "惊讶、意外或震惊", "thinking": "思考、疑惑或认真琢磨", "greeting": "打招呼、问候或道别", "thanks": "感谢、感激或礼貌回应", "tease": "调侃、逗弄或开玩笑", "celebrate": "庆祝、祝贺或欢呼", "other": "不属于以上场景的其他情绪或用途", } ALLOWED_SUFFIXES = frozenset({".webp", ".tgs", ".webm", ".png", ".jpg"}) def scene_prompt() -> str: """Return model-readable instructions for scene classification.""" lines = [ "请为贴纸选择 1 至 3 个最匹配的场景;只能使用以下场景标识:" ] lines.extend(f"- {scene}:{description}" for scene, description in SCENES.items()) return "\n".join(lines) def _copy_atomic(source: Path, destination: Path) -> None: """Copy a file without exposing a partially written destination.""" fd, temporary_name = tempfile.mkstemp( prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent ) os.close(fd) temporary = Path(temporary_name) try: shutil.copy2(source, temporary) os.replace(temporary, destination) finally: temporary.unlink(missing_ok=True) def _local_preview(value: Any) -> Path | None: """Resolve a local preview source without ever performing network I/O.""" if isinstance(value, os.PathLike): return Path(value) if not isinstance(value, str) or not value: return None parsed = urlparse(value) if parsed.scheme == "file": if parsed.netloc not in {"", "localhost"}: return None return Path(unquote(parsed.path)) if parsed.scheme: return None return Path(value) class StickerStore: """Persist stickers below ``root/`` with atomic index updates.""" def __init__(self, root: str | os.PathLike[str]) -> None: self.root = Path(root).expanduser().resolve() self.root.mkdir(parents=True, exist_ok=True) self._lock = threading.RLock() @staticmethod def _scope_name(scope: str | int) -> str: if isinstance(scope, bool): raise ValueError("scope must be a UID or group ID") name = str(scope) if ( not name or name in {".", ".."} or "/" in name or "\\" in name or "\x00" in name or Path(name).is_absolute() ): raise ValueError("scope must be one safe path component") return name @staticmethod def _require_within(path: Path, parent: Path) -> None: try: path.resolve().relative_to(parent.resolve()) except ValueError as exc: raise ValueError("sticker storage path escapes its scope") from exc def _layout(self, scope: str | int, *, create: bool) -> tuple[Path, Path, Path]: name = self._scope_name(scope) scope_dir = self.root / name objects = scope_dir / "objects" previews = scope_dir / "previews" if create: scope_dir.mkdir(parents=True, exist_ok=True) objects.mkdir(exist_ok=True) previews.mkdir(exist_ok=True) self._require_within(scope_dir, self.root) self._require_within(objects, scope_dir) self._require_within(previews, scope_dir) return scope_dir, objects, previews @staticmethod def _empty_index() -> dict[str, Any]: return {"version": 1, "stickers": {}} @staticmethod def _load_index(scope_dir: Path) -> dict[str, Any]: index_path = scope_dir / "index.json" if not index_path.exists(): return StickerStore._empty_index() with index_path.open("r", encoding="utf-8") as handle: index = json.load(handle) if not isinstance(index, dict) or not isinstance(index.get("stickers"), dict): raise ValueError(f"invalid sticker index: {index_path}") return index @staticmethod def _write_index(scope_dir: Path, index: dict[str, Any]) -> None: index_path = scope_dir / "index.json" fd, temporary_name = tempfile.mkstemp( prefix=".index.", suffix=".tmp", dir=scope_dir ) temporary = Path(temporary_name) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(index, handle, ensure_ascii=False, indent=2, sort_keys=True) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(temporary, index_path) finally: temporary.unlink(missing_ok=True) @staticmethod def _valid_scenes(classification: dict[str, Any]) -> list[str] | None: if not isinstance(classification, dict) or classification.get("accept") is False: return None raw_scenes = classification.get("scenes") if not isinstance(raw_scenes, (list, tuple)) or not 1 <= len(raw_scenes) <= 3: return None if any(not isinstance(scene, str) or scene not in SCENES for scene in raw_scenes): return None # Preserve classifier order while avoiding duplicate entries. return list(dict.fromkeys(raw_scenes)) def learn( self, scope: str | int, source: dict[str, Any], classification: dict[str, Any], ) -> dict[str, Any] | None: """Learn a sticker, or merge an existing ``file_unique_id``. Returns a copy of the stored metadata. Invalid classifications, missing source data, unsupported formats, and missing new source files return ``None``. Duplicate learning updates the index only and never copies either the object or its preview again. """ scenes = self._valid_scenes(classification) if scenes is None or not isinstance(source, dict): return None original_value = source.get("original_path") unique_value = source.get("file_unique_id") if original_value is None or unique_value is None: return None try: original = Path(original_value).expanduser() except TypeError: return None file_unique_id = str(unique_value).strip() if not file_unique_id: return None suffix = original.suffix.lower() if suffix not in ALLOWED_SUFFIXES: return None description_value = classification.get("description", "") description = "" if description_value is None else str(description_value) with self._lock: scope_dir, objects, previews = self._layout(scope, create=True) index = self._load_index(scope_dir) stickers: dict[str, dict[str, Any]] = index["stickers"] for existing in stickers.values(): if existing.get("file_unique_id") != file_unique_id: continue merged = list(dict.fromkeys([*existing.get("scenes", []), *scenes])) existing["scenes"] = merged[:3] if "description" in classification: existing["description"] = description self._write_index(scope_dir, index) return copy.deepcopy(existing) if not original.is_file(): return None sticker_id = secrets.token_hex(16) while sticker_id in stickers: sticker_id = secrets.token_hex(16) object_path = objects / f"{sticker_id}{suffix}" preview_source = _local_preview(source.get("url")) preview_path: Path | None = None if preview_source is not None and preview_source.is_file(): preview_suffix = preview_source.suffix.lower() if not re.fullmatch(r"\.[a-z0-9]{1,10}", preview_suffix): preview_suffix = suffix preview_path = previews / f"{sticker_id}{preview_suffix}" created_paths: list[Path] = [] try: _copy_atomic(original, object_path) created_paths.append(object_path) if preview_path is not None: _copy_atomic(preview_source, preview_path) created_paths.append(preview_path) record = { "id": sticker_id, "scenes": scenes, "description": description, "emoji": source.get("emoji"), "set_name": source.get("set_name"), "source_uid": source.get("source_uid"), "file_unique_id": file_unique_id, "format": suffix.removeprefix("."), "created_at": datetime.now(timezone.utc).isoformat(), "path": str(object_path.resolve()), "preview_path": ( str(preview_path.resolve()) if preview_path is not None else None ), "url": source.get("url"), } stickers[sticker_id] = record self._write_index(scope_dir, index) except Exception: for created_path in created_paths: created_path.unlink(missing_ok=True) raise return copy.deepcopy(record) def random_for_scene(self, scope: str | int, scene: str) -> dict[str, Any] | None: """Return a uniformly selected random sticker belonging to ``scene``.""" with self._lock: scope_dir, _, _ = self._layout(scope, create=False) index = self._load_index(scope_dir) candidates = [ record for record in index["stickers"].values() if scene in record.get("scenes", []) ] return copy.deepcopy(random.choice(candidates)) if candidates else None def get(self, scope: str | int, sticker_id: str) -> dict[str, Any] | None: """Return one metadata record, or ``None`` when it does not exist.""" with self._lock: scope_dir, _, _ = self._layout(scope, create=False) record = self._load_index(scope_dir)["stickers"].get(sticker_id) return copy.deepcopy(record) if record is not None else None def migrate_all_to_global(self, global_scope: str = "global") -> int: """Merge legacy per-chat libraries into one shared library and archive them. Existing IDs may change, which is safe because models select scenes rather than file IDs. ``file_unique_id`` deduplication makes retries idempotent. """ migrated = 0 with self._lock: archive_root = self.root / ".migrated" for scope_dir in sorted(self.root.iterdir()): if (not scope_dir.is_dir() or scope_dir.name.startswith(".") or scope_dir.name == global_scope): continue index = self._load_index(scope_dir) complete = True for record in list(index["stickers"].values()): source = { "original_path": record.get("path"), "url": record.get("preview_path"), "file_unique_id": record.get("file_unique_id"), "emoji": record.get("emoji"), "set_name": record.get("set_name"), "source_uid": record.get("source_uid"), } classification = { "accept": True, "scenes": record.get("scenes") or ["other"], "description": record.get("description", ""), } if self.learn(global_scope, source, classification) is not None: migrated += 1 else: complete = False if not complete: # Keep the legacy scope available for repair/retry. Any # records already copied are deduplicated by file_unique_id. continue archive_root.mkdir(exist_ok=True) destination = archive_root / scope_dir.name if not destination.exists(): os.replace(scope_dir, destination) return migrated def prompt_summary(self, scope: str | int) -> str: """Summarize only scene names, counts, and descriptions for a model.""" with self._lock: scope_dir, _, _ = self._layout(scope, create=False) records = list(self._load_index(scope_dir)["stickers"].values()) if not records: return "" lines = ["贴纸库存:"] for scene, scene_description in SCENES.items(): matching = [record for record in records if scene in record.get("scenes", [])] if not matching: continue descriptions = list( dict.fromkeys( str(record.get("description", "")).strip() for record in matching if str(record.get("description", "")).strip() ) ) brief = ";".join(descriptions) if descriptions else "暂无" lines.append( f"- {scene}({scene_description}):{len(matching)} 个;简述:{brief}" ) return "\n".join(lines) __all__ = ["ALLOWED_SUFFIXES", "SCENES", "StickerStore", "scene_prompt"]