Spaces:
Running
Running
Download lumen_state_storage.py from SilverElixir/Lumen: direct link, hf CLI and curl.
- Browser
- Download file 11.7 kB
-
https://huggingface.co/spaces/SilverElixir/Lumen/resolve/main/lumen_state_storage.py
- Command line
-
hf download hf://spaces/SilverElixir/Lumen/lumen_state_storage.py
-
curl -L -o lumen_state_storage.py https://huggingface.co/spaces/SilverElixir/Lumen/resolve/main/lumen_state_storage.py
11.7 kB
| """ | |
| lumen_state_storage.py — механика персистентности: клиент Upstash Redis REST API, | |
| выбор backend (Upstash/файл), сериализация одного чата в JSON-снимок. | |
| Оркестрация записи ("сериализовать + записать + поймать") живёт в bot.py: тесты | |
| патчат `bot._storage_write_text` по имени, переезд сюда разорвал бы подмену. | |
| Конфиг backend передаётся параметром, а не читается из состояния модуля — | |
| иначе тесты с подменой кред "на лету" перестали бы работать. | |
| """ | |
| from __future__ import annotations | |
| import contextlib | |
| import json | |
| import logging | |
| import urllib.parse | |
| import urllib.request as _urllib_request | |
| from dataclasses import dataclass | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| log = logging.getLogger("bot") | |
| CHAT_STATE_SCHEMA_VERSION = 1 | |
| class StorageConfig: | |
| """Снимок конфигурации backend'а хранилища на момент ОДНОГО вызова — собирается | |
| заново вызывающим кодом в bot.py на каждый вызов (см. докстринг модуля), а не | |
| кэшируется здесь, чтобы подмена `bot.UPSTASH_REDIS_REST_URL`/`bot._CHATS_DIR` и | |
| т.п. в тестах (или, в перспективе, смена конфигурации без рестарта) применялась | |
| сразу же, без риска словить устаревшее закешированное значение.""" | |
| use_upstash: bool | |
| upstash_url: str | |
| upstash_token: str | |
| chats_dir: Path | |
| # ─────────────────── клиент Upstash Redis REST API ─────────────────── | |
| def _upstash_request(url: str, token: str, command_path: str, *, method: str = "GET", body: bytes | None = None) -> Any: | |
| """Синхронный запрос к Upstash Redis REST API. Намеренно на urllib.request из | |
| стандартной библиотеки, а не на aiohttp/отдельном SDK — не хотим тянуть новую | |
| pip-зависимость ради одной интеграции. Вызывается только из save_*/load_* в | |
| bot.py: load_* — один раз на старте до приёма трафика, save_* — уже вынесены в | |
| отдельный поток через asyncio.to_thread (см. _flush_dirty_state в bot.py), так | |
| что блокирующий вызов здесь не блокирует event loop.""" | |
| full_url = f"{url}/{command_path}" | |
| req = _urllib_request.Request(full_url, data=body, method=method) | |
| req.add_header("Authorization", f"Bearer {token}") | |
| if body is not None: | |
| req.add_header("Content-Type", "text/plain; charset=utf-8") | |
| with _urllib_request.urlopen(req, timeout=10) as resp: | |
| return json.loads(resp.read().decode("utf-8")) | |
| def _upstash_set(url: str, token: str, key: str, value: str) -> None: | |
| _upstash_request(url, token, f"set/{urllib.parse.quote(key, safe='')}", method="POST", body=value.encode("utf-8")) | |
| def _upstash_get(url: str, token: str, key: str) -> str | None: | |
| result = _upstash_request(url, token, f"get/{urllib.parse.quote(key, safe='')}", method="GET") | |
| return result.get("result") if isinstance(result, dict) else None | |
| def _upstash_delete(url: str, token: str, key: str) -> None: | |
| _upstash_request(url, token, f"del/{urllib.parse.quote(key, safe='')}", method="POST") | |
| # ─────────────────── единая точка ветвления backend'а ─────────────────── | |
| def _storage_write_text(cfg: StorageConfig, key: str, path: Path, text: str) -> None: | |
| """Единая точка ветвления backend'а: Upstash, если настроен, иначе локальный | |
| файл (атомарно — через .tmp + replace, как и раньше).""" | |
| if cfg.use_upstash: | |
| _upstash_set(cfg.upstash_url, cfg.upstash_token, key, text) | |
| return | |
| # Каталог chats/ мог снести внешний процесс — создаём заново, иначе запись падает. | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| temp_path = path.with_suffix(".tmp") | |
| with open(temp_path, "w", encoding="utf-8") as f: | |
| f.write(text) | |
| temp_path.replace(path) | |
| def _storage_read_text(cfg: StorageConfig, key: str, path: Path) -> str | None: | |
| if cfg.use_upstash: | |
| return _upstash_get(cfg.upstash_url, cfg.upstash_token, key) | |
| if not path.exists(): | |
| return None | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read() | |
| def _storage_delete_text(cfg: StorageConfig, key: str, path: Path) -> None: | |
| """Удаляет запись из хранилища — нужно per-chat формату: когда чат вытесняется | |
| _prune_old_chats() в bot.py, его собственный ключ/файл должен реально исчезать, | |
| а не висеть бесхозно (иначе Upstash/диск постепенно накапливали бы мусор от | |
| давно удалённых чатов).""" | |
| if cfg.use_upstash: | |
| _upstash_delete(cfg.upstash_url, cfg.upstash_token, key) | |
| return | |
| with contextlib.suppress(FileNotFoundError): | |
| path.unlink() | |
| # ─────────────────── per-chat ключи/пути и сериализация одного чата ─────────────────── | |
| def _chat_storage_key(chat_id: int) -> str: | |
| return f"lumen:chat:{chat_id}" | |
| def _chat_storage_path(cfg: StorageConfig, chat_id: int) -> Path: | |
| return cfg.chats_dir / f"{chat_id}.json" | |
| def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]: | |
| """Собирает JSON-сериализуемый снимок ОДНОГО чата — общая логика между | |
| сохранением и ручным экспортом (см. /export_state в bot.py). | |
| Начиная с введения автоматического роутера моделей "gemini_model"/ | |
| "openrouter_text_model"/"chat_provider" здесь БОЛЬШЕ НЕ хранятся — раньше это | |
| был явный выбор пользователя через /model и /provider, теперь провайдер и | |
| модель подбираются заново на каждое сообщение, хранить их per-chat незачем. | |
| "image_model" по той же причине убран отсюда 19 августа 2026 — генерация | |
| изображений (см. README, "Автоматический выбор модели") тоже перешла на | |
| подбор модели заново на каждый вызов (`_pick_image_model` в lumen_images.py) | |
| вместо персистентного выбора через удалённую команду /imgmodel. Старые | |
| персистентные записи, где эти поля ещё есть (созданные до соответствующих | |
| изменений), просто тихо игнорируются при чтении — см. _restore_single_chat в | |
| bot.py, там нет ни одной попытки их прочитать. | |
| "quota" убран отсюда 26 августа 2026 (аудит техдолга) — поле было мёртвым: | |
| записывалось в ChatState и сериализовалось сюда, но нигде не читалось — | |
| реальный учёт квоты целиком живёт в модульном GLOBAL_QUOTA (bot.py), per-chat | |
| квота никогда фактически не использовалась. Старые записи с этим полем в | |
| хранилище просто тихо игнорируются при чтении, как и остальные удалённые поля | |
| выше.""" | |
| # Ленивый импорт: lumen_chat_state импортирует этот модуль, топ-уровень закольцует. | |
| from lumen_chat_state import MAX_CHAT_HISTORY_LEN | |
| return { | |
| "schema_version": CHAT_STATE_SCHEMA_VERSION, | |
| "history": list(state.get("history", [])), | |
| "recent_media_ids": { | |
| uid: list(dq) for uid, dq in state.get("recent_media_ids", {}).items() | |
| }, | |
| # Ники переживают рестарт: без них после деплоя бот снова не знал бы авторов. | |
| "user_names": dict(state.get("user_names", {}) or {}), | |
| # Фон тоже переживает рестарт: иначе единые 100 сообщений сбрасывались деплоем. | |
| "ctx": [line for line in (state.get("ctx") or []) if isinstance(line, str)][:MAX_CHAT_HISTORY_LEN], | |
| # Настенные часы для подсчёта активных в /stats: monotonic сбрасывается | |
| # рестартом и делал все чаты "активными", time.time() переживает его. | |
| "last_activity": state.get("last_activity", 0.0), | |
| # Язык системных сообщений чата (см. lumen_lang.py, /lang в bot.py). | |
| # Старым снимкам без поля соответствует "en" — см. _chat_lang в bot.py. | |
| "lang": state.get("lang", "en"), | |
| } | |
| # ─────────────────── дата для сброса дневной квоты ─────────────────── | |
| def _quota_day_reset_in_sec() -> float: | |
| """Секунд до полуночи America/Los_Angeles — через столько обнулятся | |
| суточные счётчики. Та же зона, что у _current_quota_day выше.""" | |
| from datetime import timedelta | |
| try: | |
| from zoneinfo import ZoneInfo | |
| now = datetime.now(ZoneInfo("America/Los_Angeles")) | |
| except Exception: | |
| now = datetime.now(timezone.utc) | |
| nxt = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) | |
| return max(0.0, (nxt - now).total_seconds()) | |
| def _current_quota_day() -> str: | |
| """Дата (ISO, YYYY-MM-DD) для определения "новых суток" в целях сброса квоты. | |
| Google обнуляет дневные RPD-лимиты по полуночи Pacific Time — используем ту | |
| же зону, чтобы /stats не "сбрасывался" на 7-8 часов раньше или позже реального | |
| обнуления лимита на стороне Google. Если данные таймзоны недоступны в окружении | |
| (маловероятно, но встречается в урезанных Docker-образах) — тихо откатываемся | |
| на UTC: чуть менее точно по времени суток, но не ломает сам факт ежедневного | |
| сброса.""" | |
| try: | |
| from zoneinfo import ZoneInfo | |
| return datetime.now(ZoneInfo("America/Los_Angeles")).date().isoformat() | |
| except Exception: | |
| return datetime.now(timezone.utc).date().isoformat() | |