Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
"""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/<scope>`` 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"]
|
||||
Reference in New Issue
Block a user