Spaces:
Running
Running
SilverElixir commited on
Commit ·
d124960
1
Parent(s): 9983566
P2: extract lumen_chat_state.py (chat state, quotas, locks, owner utils) with bot.X re-exports (AUD-A-001)
Browse files- bot.py +107 -616
- lumen_chat_state.py +693 -0
- test_bot_state.py +26 -23
bot.py
CHANGED
|
@@ -24,7 +24,7 @@ import time
|
|
| 24 |
from pathlib import Path
|
| 25 |
from collections import deque
|
| 26 |
from datetime import date, datetime
|
| 27 |
-
from typing import Any
|
| 28 |
from types import SimpleNamespace
|
| 29 |
import urllib.request as _urllib_request
|
| 30 |
|
|
@@ -506,10 +506,68 @@ _tiktok_probe_semaphore = asyncio.Semaphore(TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY
|
|
| 506 |
TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY = int(os.getenv("TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY", "8"))
|
| 507 |
_tiktok_slide_download_semaphore = asyncio.Semaphore(TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY)
|
| 508 |
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
|
| 514 |
# Простой трекер для rate limiting и очередь кнопок-уточнений живут в
|
| 515 |
# lumen_limits.py (P2): здесь только реэкспорт имён, чтобы `bot.X` в тестах
|
|
@@ -1104,14 +1162,6 @@ def get_system_prompt(model_id: str | None = None) -> str:
|
|
| 1104 |
# типовая аннотация (TypedDict), НЕ меняет поведение в рантайме: chat_state[cid]
|
| 1105 |
# остаётся обычным dict, никакой валидации здесь не добавляется — это только
|
| 1106 |
# документация формы для статических проверок типов и читаемости.
|
| 1107 |
-
class ChatState(TypedDict, total=False):
|
| 1108 |
-
history: list[dict[str, Any]]
|
| 1109 |
-
ctx: "deque[str]"
|
| 1110 |
-
recent_media_ids: dict[str, "deque[tuple[str, str]]"]
|
| 1111 |
-
last_activity: float
|
| 1112 |
-
lang: str
|
| 1113 |
-
|
| 1114 |
-
chat_state: dict[int, ChatState] = {}
|
| 1115 |
|
| 1116 |
# Буферы альбомов/медиа-групп
|
| 1117 |
_mg_buffers: dict[str, list[Message]] = {}
|
|
@@ -1182,25 +1232,6 @@ ALLOWED_UPDATES = ["message", "edited_message", "callback_query"]
|
|
| 1182 |
|
| 1183 |
# хранение состояния и квот
|
| 1184 |
|
| 1185 |
-
_STATE_DIR = Path(os.getenv("STATE_DIR", "/app")).resolve()
|
| 1186 |
-
try:
|
| 1187 |
-
_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
| 1188 |
-
except Exception as _state_dir_exc:
|
| 1189 |
-
log.warning('[setup] STATE_DIR %s is not writable (%s), using a temp directory instead.', _STATE_DIR, _state_dir_exc)
|
| 1190 |
-
_STATE_DIR = Path(tempfile.gettempdir())
|
| 1191 |
-
STATE_FILE_PATH = _STATE_DIR / "chat_state.json"
|
| 1192 |
-
GLOBAL_QUOTA_FILE = _STATE_DIR / "global_quota.json"
|
| 1193 |
-
# Per-chat хранилище (см. код-ревью suggestion #7): раньше ВЕСЬ chat_state (до 5000
|
| 1194 |
-
# чатов) сериализовался и писался ОДНИМ блоком при каждом флаше — с Upstash это один
|
| 1195 |
-
# большой REST-запрос; один неудачный/слишком большой write рисковал потерять сразу
|
| 1196 |
-
# всё разом, а не одну запись. Теперь у каждого чата свой собственный ключ/файл, а
|
| 1197 |
-
# CHAT_INDEX_KEY/CHAT_INDEX_FILE хранит только список ID чатов — так при рестарте
|
| 1198 |
-
# известно, какие per-chat ключи вообще нужно прочитать.
|
| 1199 |
-
_CHATS_DIR = _STATE_DIR / "chats"
|
| 1200 |
-
with contextlib.suppress(Exception):
|
| 1201 |
-
_CHATS_DIR.mkdir(parents=True, exist_ok=True)
|
| 1202 |
-
CHAT_INDEX_KEY = "lumen:chat_index"
|
| 1203 |
-
CHAT_INDEX_FILE = _STATE_DIR / "chat_index.json"
|
| 1204 |
|
| 1205 |
# Опциональное персистентное хранилище (Upstash Redis, бесплатный тир — см. README).
|
| 1206 |
# Если оба значения заданы, состояние пишется туда вместо эфемерного диска контейнера.
|
|
@@ -1234,25 +1265,10 @@ log.info(
|
|
| 1234 |
# публичный интерфейс и поведение не изменились.
|
| 1235 |
from lumen_state_storage import (
|
| 1236 |
CHAT_STATE_SCHEMA_VERSION,
|
| 1237 |
-
StorageConfig,
|
| 1238 |
-
_chat_storage_key,
|
| 1239 |
_serialize_chat_state,
|
| 1240 |
_current_quota_day,
|
| 1241 |
-
_upstash_request as _lumen_upstash_request,
|
| 1242 |
-
_upstash_set as _lumen_upstash_set,
|
| 1243 |
-
_upstash_get as _lumen_upstash_get,
|
| 1244 |
-
_upstash_delete as _lumen_upstash_delete,
|
| 1245 |
-
_storage_write_text as _lumen_storage_write_text,
|
| 1246 |
-
_storage_read_text as _lumen_storage_read_text,
|
| 1247 |
-
_storage_delete_text as _lumen_storage_delete_text,
|
| 1248 |
-
_chat_storage_path as _lumen_chat_storage_path,
|
| 1249 |
)
|
| 1250 |
|
| 1251 |
-
def _storage_config() -> StorageConfig:
|
| 1252 |
-
return StorageConfig(
|
| 1253 |
-
use_upstash=USE_UPSTASH, upstash_url=UPSTASH_REDIS_REST_URL,
|
| 1254 |
-
upstash_token=UPSTASH_REDIS_REST_TOKEN, chats_dir=_CHATS_DIR,
|
| 1255 |
-
)
|
| 1256 |
|
| 1257 |
# `_urllib_request` (импортирован в самом начале файла) больше не используется
|
| 1258 |
# напрямую нигде в коде bot.py — реальный клиент Upstash REST API (единственный
|
|
@@ -1352,561 +1368,53 @@ __all__ = [
|
|
| 1352 |
"_history_user_text",
|
| 1353 |
# Пространство имён genai-типов для тестов (bot.types.Content/...).
|
| 1354 |
"types",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1355 |
]
|
| 1356 |
|
| 1357 |
-
def _upstash_request(command_path: str, *, method: str = "GET", body: bytes | None = None) -> Any:
|
| 1358 |
-
return _lumen_upstash_request(UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, command_path, method=method, body=body)
|
| 1359 |
-
|
| 1360 |
-
def _upstash_set(key: str, value: str) -> None:
|
| 1361 |
-
_lumen_upstash_set(UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, key, value)
|
| 1362 |
-
|
| 1363 |
-
def _upstash_get(key: str) -> str | None:
|
| 1364 |
-
return _lumen_upstash_get(UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, key)
|
| 1365 |
-
|
| 1366 |
-
def _upstash_delete(key: str) -> None:
|
| 1367 |
-
_lumen_upstash_delete(UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, key)
|
| 1368 |
-
|
| 1369 |
-
def _storage_write_text(key: str, path: Path, text: str) -> None:
|
| 1370 |
-
_lumen_storage_write_text(_storage_config(), key, path, text)
|
| 1371 |
-
|
| 1372 |
-
def _storage_read_text(key: str, path: Path) -> str | None:
|
| 1373 |
-
return _lumen_storage_read_text(_storage_config(), key, path)
|
| 1374 |
-
|
| 1375 |
-
def _storage_delete_text(key: str, path: Path) -> None:
|
| 1376 |
-
_lumen_storage_delete_text(_storage_config(), key, path)
|
| 1377 |
-
|
| 1378 |
-
def _chat_storage_path(chat_id: int) -> Path:
|
| 1379 |
-
return _lumen_chat_storage_path(_storage_config(), chat_id)
|
| 1380 |
-
|
| 1381 |
-
# _save_chat_to_storage/_delete_chat_storage НАМЕРЕННО остаются здесь как реальные
|
| 1382 |
-
# (не тонкие обёрточные) реализации, а не делегируют в lumen_state_storage.py, как
|
| 1383 |
-
# остальные функции этой секции: они вызывают _storage_write_text/_storage_delete_text
|
| 1384 |
-
# ПО ИМЕНИ, разрешаемому в собственном пространстве имён bot.py на момент вызова —
|
| 1385 |
-
# это ЕДИНСТВЕННЫЙ способ, которым существующие тесты (патчащие именно
|
| 1386 |
-
# `bot._storage_write_text`/`bot._storage_delete_text` через unittest.mock.patch)
|
| 1387 |
-
# продолжают перехватывать вызов. Если бы эти две функции были обёртками вокруг
|
| 1388 |
-
# lumen_state_storage._save_chat_to_storage (как остальные выше), тот вызывал бы
|
| 1389 |
-
# СВОЮ собственную, непропатченную копию _storage_write_text внутри своего модуля —
|
| 1390 |
-
# патч bot._storage_write_text никак её не затронул бы (patch мутирует атрибут
|
| 1391 |
-
# только на объекте bot, а не на объекте lumen_state_storage).
|
| 1392 |
-
def _save_chat_to_storage(chat_id: int, state: dict[str, Any]) -> bool:
|
| 1393 |
-
"""Возвращает True при успехе, False при сбое. НАЙДЕНО ПРИ КОД-РЕВЬЮ: раньше эта
|
| 1394 |
-
функция ничего не возвращала — вызывающий код (_flush_dirty_state) уже успевал
|
| 1395 |
-
убрать chat_id из "грязного" набора ДО того, как запись реально прошла, и при
|
| 1396 |
-
сбое (например, временный 5xx/сетевой сбой Upstash) исключение здесь просто
|
| 1397 |
-
логировалось и терялось — состояние чата (вся история диалога) молча пропадало
|
| 1398 |
-
до следующей независимой мутации этого же чата. Если это было последнее
|
| 1399 |
-
сообщение перед долгим затишьем — при рестарте контейнера данные терялись
|
| 1400 |
-
безвозвратно, ровно то, что персистентность через Upstash должна была
|
| 1401 |
-
предотвращать. Теперь вызывающий код (_flush_dirty_state) возвращает неудавшиеся
|
| 1402 |
-
chat_id обратно в _dirty_chat_ids для повтора на следующем цикле."""
|
| 1403 |
-
try:
|
| 1404 |
-
payload = json.dumps(_serialize_chat_state(state), ensure_ascii=False)
|
| 1405 |
-
_storage_write_text(_chat_storage_key(chat_id), _chat_storage_path(chat_id), payload)
|
| 1406 |
-
return True
|
| 1407 |
-
except Exception as exc:
|
| 1408 |
-
log.warning("[state] Saving chat %s failed: %s", chat_id, exc)
|
| 1409 |
-
return False
|
| 1410 |
-
|
| 1411 |
-
def _delete_chat_storage(chat_id: int) -> bool:
|
| 1412 |
-
"""Возвращает True при успехе, False при сбое — тот же принцип, что и у
|
| 1413 |
-
_save_chat_to_storage выше (см. докстринг там): неудавшееся удаление теперь
|
| 1414 |
-
тоже возвращается в очередь на повтор, а не молча забывается (иначе вытесненный
|
| 1415 |
-
чат мог бы бесхозно остаться в Upstash/на диске навсегда при транзиентном сбое)."""
|
| 1416 |
-
try:
|
| 1417 |
-
_storage_delete_text(_chat_storage_key(chat_id), _chat_storage_path(chat_id))
|
| 1418 |
-
return True
|
| 1419 |
-
except Exception as exc:
|
| 1420 |
-
log.warning("[state] Deleting chat %s failed: %s", chat_id, exc)
|
| 1421 |
-
return False
|
| 1422 |
-
|
| 1423 |
-
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одной записи GLOBAL_QUOTA[provider][model_id]
|
| 1424 |
-
# (см. _quota_entry/_record_quota_usage/_mark_quota_exhausted ниже) была разбросанным
|
| 1425 |
-
# по коду соглашением, а не задокументированной структурой. Как и ChatState выше —
|
| 1426 |
-
# чисто типовая аннотация, ничего не меняет в рантайме (GLOBAL_QUOTA остаётся
|
| 1427 |
-
# обычным dict из dict'ов).
|
| 1428 |
-
class QuotaEntry(TypedDict):
|
| 1429 |
-
used: int
|
| 1430 |
-
exhausted_at: float | None
|
| 1431 |
-
|
| 1432 |
-
GLOBAL_QUOTA: dict[str, Any] = {
|
| 1433 |
-
"gemini": {},
|
| 1434 |
-
"openrouter": {},
|
| 1435 |
-
# НАЙДЕНО ПРИ КАЛИБРОВКЕ (25 июля 2026): /stats показывал сотни запросов
|
| 1436 |
-
# по моделям при аптайме процесса всего 12 минут — счётчик "used" копится
|
| 1437 |
-
# НАВСЕГДА (переживает рестарты через Upstash, см. _save_chat_to_storage/
|
| 1438 |
-
# load_global_quota), а реальные суточные лимиты Google/OpenRouter обнуляются
|
| 1439 |
-
# каждые сутки. Бот об этом не знал вообще — "used"/"exhausted_at" не
|
| 1440 |
-
# сбрасывались никогда, поэтому /stats после нескольких дней работы показывал
|
| 1441 |
-
# бы бессмысленно огромные числа, а модель, один раз поймавшая 429 в первый
|
| 1442 |
-
# день, так и висела бы с пометкой "(лимит исчерпан)" даже после того, как
|
| 1443 |
-
# реальный лимит давно обновился. quota_day хранит дату (ISO, по America/
|
| 1444 |
-
# Los_Angeles — именно там у Google полночь, когда реально обнуляется RPD-
|
| 1445 |
-
# лимит) последнего сброса счётчиков — см. _reset_quota_if_new_day ниже.
|
| 1446 |
-
"quota_day": None,
|
| 1447 |
-
}
|
| 1448 |
-
|
| 1449 |
-
# НАЙДЕНО ПРИ CODE-REVIEW (перф): _quota_entry вызывает _reset_quota_if_new_day
|
| 1450 |
-
# на КАЖДОЕ обращение к квоте (а таких обращений — по несколько на каждый успешный/
|
| 1451 |
-
# неудачный вызов любой модели, т.е. потенциально десятки в секунду при активном
|
| 1452 |
-
# трафике). Без троттлинга это означало бы конструирование ZoneInfo("America/
|
| 1453 |
-
# Los_Angeles") и datetime.now(...) на каждый такой вызов — сама по себе дата не
|
| 1454 |
-
# меняется чаще раза в сутки, минутная неточность здесь совершенно не важна.
|
| 1455 |
-
# _QUOTA_CHECK_THROTTLE_SEC ограничивает, как часто мы вообще пересчитываем
|
| 1456 |
-
# текущую дату; между пересчётами просто ничего не делаем.
|
| 1457 |
-
_QUOTA_CHECK_THROTTLE_SEC = 60.0
|
| 1458 |
-
_last_quota_check_monotonic: float = 0.0
|
| 1459 |
-
|
| 1460 |
# Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
|
| 1461 |
# _handle_message_core) — без него один и тот же алерт улетал бы на КАЖДОЕ
|
| 1462 |
# сообщение, требующее Gemini, пока квота не восстановится (может быть весь день).
|
| 1463 |
GEMINI_EXHAUSTED_ALERT_COOLDOWN_SEC = 3600.0
|
| 1464 |
-
_last_gemini_exhausted_alert_monotonic: float = 0.0
|
| 1465 |
|
| 1466 |
-
async def _maybe_alert_gemini_exhausted() -> None:
|
| 1467 |
-
global _last_gemini_exhausted_alert_monotonic
|
| 1468 |
-
now = time.monotonic()
|
| 1469 |
-
if now - _last_gemini_exhausted_alert_monotonic < GEMINI_EXHAUSTED_ALERT_COOLDOWN_SEC:
|
| 1470 |
-
return
|
| 1471 |
-
_last_gemini_exhausted_alert_monotonic = now
|
| 1472 |
-
await _notify_owner(_t(OWNER_ID, "owner_quota_notice"))
|
| 1473 |
-
|
| 1474 |
-
def _reset_quota_if_new_day() -> None:
|
| 1475 |
-
"""Сбрасывает used/exhausted_at у ВСЕХ моделей (Gemini и OpenRouter), если с
|
| 1476 |
-
последнего сброса наступили новые сутки (по America/Los_Angeles). Вызывается и лениво (см.
|
| 1477 |
-
_quota_entry — на любое обращение к квоте), и явно раз в час из фонового цикла
|
| 1478 |
-
в _webhook_startup, чтобы сброс не зависел от того, придёт ли вообще новое
|
| 1479 |
-
сообщение сразу после полуночи."""
|
| 1480 |
-
global _last_quota_check_monotonic
|
| 1481 |
-
now_mono = time.monotonic()
|
| 1482 |
-
if now_mono - _last_quota_check_monotonic < _QUOTA_CHECK_THROTTLE_SEC:
|
| 1483 |
-
return
|
| 1484 |
-
_last_quota_check_monotonic = now_mono
|
| 1485 |
-
today = _current_quota_day()
|
| 1486 |
-
if GLOBAL_QUOTA.get("quota_day") == today:
|
| 1487 |
-
return
|
| 1488 |
-
had_previous = GLOBAL_QUOTA.get("quota_day") is not None
|
| 1489 |
-
for provider in ("gemini", "openrouter"):
|
| 1490 |
-
for entry in GLOBAL_QUOTA.get(provider, {}).values():
|
| 1491 |
-
if isinstance(entry, dict):
|
| 1492 |
-
entry["used"] = 0
|
| 1493 |
-
entry["exhausted_at"] = None
|
| 1494 |
-
GLOBAL_QUOTA["quota_day"] = today
|
| 1495 |
-
if had_previous:
|
| 1496 |
-
log.info('[quota] New day started (%s) — used/exhausted_at counters reset for all models.', today)
|
| 1497 |
-
mark_quota_dirty()
|
| 1498 |
-
|
| 1499 |
-
def load_global_quota() -> None:
|
| 1500 |
-
try:
|
| 1501 |
-
raw = _storage_read_text("lumen:global_quota", GLOBAL_QUOTA_FILE)
|
| 1502 |
-
if not raw:
|
| 1503 |
-
return
|
| 1504 |
-
loaded = json.loads(raw)
|
| 1505 |
-
if isinstance(loaded, dict):
|
| 1506 |
-
if "gemini" in loaded:
|
| 1507 |
-
GLOBAL_QUOTA["gemini"] = loaded["gemini"]
|
| 1508 |
-
if "openrouter" in loaded:
|
| 1509 |
-
GLOBAL_QUOTA["openrouter"] = loaded["openrouter"]
|
| 1510 |
-
if "quota_day" in loaded:
|
| 1511 |
-
GLOBAL_QUOTA["quota_day"] = loaded["quota_day"]
|
| 1512 |
-
except Exception as exc:
|
| 1513 |
-
log.warning("[quota] Failed to load global quota: %s", exc)
|
| 1514 |
-
# Проверяем сразу после загрузки — если бот был перезапущен уже на следующие
|
| 1515 |
-
# сутки (обычное дело при редеплое), счётчики должны обнулиться сразу на
|
| 1516 |
-
# старте, а не ждать первого сообщения после полуночи или ближайшего часового тика.
|
| 1517 |
-
_reset_quota_if_new_day()
|
| 1518 |
-
|
| 1519 |
-
def save_global_quota() -> None:
|
| 1520 |
-
try:
|
| 1521 |
-
_storage_write_text("lumen:global_quota", GLOBAL_QUOTA_FILE, json.dumps(GLOBAL_QUOTA, ensure_ascii=False))
|
| 1522 |
-
except Exception as exc:
|
| 1523 |
-
log.warning("[quota] Failed to save global quota: %s", exc)
|
| 1524 |
-
|
| 1525 |
-
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: до сих пор каждый формат-дрейф персистентного
|
| 1526 |
-
# снимка чата (слияние gemini_history/or_history в единую history) обнаруживался
|
| 1527 |
-
# в _restore_single_chat ad hoc проверками "есть ли такой-то ключ в JSON" —
|
| 1528 |
-
# рабочий, но накопительный подход: с каждым новым изменением формата туда
|
| 1529 |
-
# добавлялась ещё ��дна ветка "если ключа нет — значит старая запись".
|
| 1530 |
-
# CHAT_STATE_SCHEMA_VERSION (импортирован из lumen_state_storage.py вместе с
|
| 1531 |
-
# _serialize_chat_state — см. блок импорта в начале секции "хранение состояния и
|
| 1532 |
-
# квот") делает следующую подобную миграцию однозначной: новый код сможет
|
| 1533 |
-
# проверять `s.get("schema_version", 0)` одним явным числом вместо повторного
|
| 1534 |
-
# гадания по присутствию ключей. Существующие персистентные записи (сделанные до
|
| 1535 |
-
# введения этого поля) не имеют "schema_version" вообще — они естественно
|
| 1536 |
-
# трактуются как версия 0 и продолжают проходить через уже отлаженные эвристики
|
| 1537 |
-
# ниже без каких-либо изменений в их поведении (это поле — задел на будущее, а
|
| 1538 |
-
# не ретроактивная миграция уже написанной логики).
|
| 1539 |
-
|
| 1540 |
-
def _restore_single_chat(cid: int, s: dict[str, Any]) -> None:
|
| 1541 |
-
"""Разворачивает сериализованный снимок одного чата (см. _serialize_chat_state)
|
| 1542 |
-
обратно в chat_state[cid] — общая логика между новым per-chat форматом чтения
|
| 1543 |
-
и одноразовой миграцией из старого общего блоба (см. load_state_from_disk).
|
| 1544 |
-
|
| 1545 |
-
Поля "gemini_model"/"openrouter_text_model"/"chat_provider"/"image_model" из
|
| 1546 |
-
старых записей (созданных до перехода на автоматический роутер для текста и,
|
| 1547 |
-
позже, для генерации изображений — см. README, "Автоматический выбор модели")
|
| 1548 |
-
намеренно нигде ниже не читаются — они устарели и больше ни на что не влияют.
|
| 1549 |
-
|
| 1550 |
-
schema_version (см. CHAT_STATE_SCHEMA_VERSION выше) в самих записях, читаемых
|
| 1551 |
-
здесь, пока ни на что не влияет — существующая миграция (history/gemini_history)
|
| 1552 |
-
уже надёжно определяется по присутствию конкретных ключей, и это не нужно
|
| 1553 |
-
менять задним числом. Поле — задел на СЛЕДУЮЩИЙ формат-дрейф: тогда новую
|
| 1554 |
-
ветку можно будет добавить как `if s.get("schema_version", 0) < N`, а не
|
| 1555 |
-
подбирать очередную эвристику по ключам, как приходилось делать для миграции
|
| 1556 |
-
ниже."""
|
| 1557 |
-
schema_version = s.get("schema_version", 0)
|
| 1558 |
-
log.debug('[state] Restoring chat %s (schema_version=%s)', cid, schema_version)
|
| 1559 |
-
raw_media = s.get("recent_media_ids", {})
|
| 1560 |
-
if isinstance(raw_media, dict):
|
| 1561 |
-
media_buckets = {
|
| 1562 |
-
str(uid): deque(items, maxlen=MAX_MEDIA_RECENT_IDS)
|
| 1563 |
-
for uid, items in raw_media.items()
|
| 1564 |
-
}
|
| 1565 |
-
else:
|
| 1566 |
-
# Старый формат (плоский список на весь чат) — не мигрируем содержимое,
|
| 1567 |
-
# просто стартуем с чистого состояния, новые записи наполнят сами по себе.
|
| 1568 |
-
media_buckets = {}
|
| 1569 |
-
if "history" in s:
|
| 1570 |
-
history = list(s.get("history") or [])
|
| 1571 |
-
else:
|
| 1572 |
-
# Миграция со старого формата раздельной памяти Gemini/OpenRouter (до
|
| 1573 |
-
# объединения в общую историю) — просто конкатенируем обе, обрезая до
|
| 1574 |
-
# общего лимита. Порядок между двумя источниками восстановить точно
|
| 1575 |
-
# нельзя (нет временных меток), но сохранить сам факт истории важнее,
|
| 1576 |
-
# чем идеальная хронология при одноразовой миграции старых чатов.
|
| 1577 |
-
history = list(s.get("gemini_history") or []) + list(s.get("or_history") or [])
|
| 1578 |
-
if len(history) > SHARED_HISTORY_MAX_LEN:
|
| 1579 |
-
history = history[-SHARED_HISTORY_MAX_LEN:]
|
| 1580 |
-
chat_state[cid] = {
|
| 1581 |
-
"history": history,
|
| 1582 |
-
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 1583 |
-
"recent_media_ids": media_buckets,
|
| 1584 |
-
"last_activity": time.monotonic(),
|
| 1585 |
-
}
|
| 1586 |
-
|
| 1587 |
-
def _save_chat_index() -> None:
|
| 1588 |
-
try:
|
| 1589 |
-
ids = sorted(chat_state.keys())
|
| 1590 |
-
_storage_write_text(CHAT_INDEX_KEY, CHAT_INDEX_FILE, json.dumps(ids))
|
| 1591 |
-
except Exception as exc:
|
| 1592 |
-
log.warning("[state] Saving chat index failed: %s", exc)
|
| 1593 |
-
|
| 1594 |
-
# Раньше save_state_to_disk()/save_global_quota() вызывались синхронно почти на
|
| 1595 |
-
# каждое сообщение прямо внутри асинхронных обработчиков — блокирующий json.dump
|
| 1596 |
-
# на полном chat_state (до 5000 чатов) блокировал event loop для ВСЕХ чатов сразу,
|
| 1597 |
-
# и с ростом числа активных чатов это становится всё дороже на каждое сообщение
|
| 1598 |
-
# от любого одного пользователя. Теперь горячий путь только помечает КОНКРЕТНЫЙ
|
| 1599 |
-
# чат "грязным" (см. mark_state_dirty(chat_id)), а реальная запись идёт из
|
| 1600 |
-
# фоновой корутины _flush_dirty_state раз в FLUSH_INTERVAL_SEC через
|
| 1601 |
-
# asyncio.to_thread (не блокируя loop) — и только по изменившимся чатам, а не
|
| 1602 |
-
# по всем сразу (см. код-ревью suggestion #7 про размер payload и blast radius).
|
| 1603 |
-
_dirty_chat_ids: set[int] = set()
|
| 1604 |
-
_pending_chat_deletions: set[int] = set()
|
| 1605 |
-
_index_dirty = False
|
| 1606 |
-
_quota_dirty = False
|
| 1607 |
-
FLUSH_INTERVAL_SEC = 10.0
|
| 1608 |
-
# НАЙДЕНО ПРИ КОД-РЕВЬЮ (performance): раньше ничего не ограничивало число ОДНОВРЕМЕННЫХ
|
| 1609 |
-
# asyncio.to_thread-вызовов внутри одного цикла _flush_dirty_state — резкий всплеск
|
| 1610 |
-
# "грязных" чатов разом (например, после активности сразу в нескольких группах) мог бы
|
| 1611 |
-
# породить сотни параллельных блокирующих HTTP-запросов к Upstash одновременно. Не
|
| 1612 |
-
# критично при текущем масштабе бота, но дешёвая защита на будущее — ограничиваем
|
| 1613 |
-
# конкурентность семафором, а не оставляем неограниченной.
|
| 1614 |
-
STATE_FLUSH_CONCURRENCY = int(os.getenv("STATE_FLUSH_CONCURRENCY", "10"))
|
| 1615 |
-
_state_flush_semaphore = asyncio.Semaphore(STATE_FLUSH_CONCURRENCY)
|
| 1616 |
-
|
| 1617 |
-
async def _save_chat_to_storage_limited(chat_id: int, state: dict[str, Any]) -> bool:
|
| 1618 |
-
async with _state_flush_semaphore:
|
| 1619 |
-
return await asyncio.to_thread(_save_chat_to_storage, chat_id, state)
|
| 1620 |
-
|
| 1621 |
-
async def _delete_chat_storage_limited(chat_id: int) -> bool:
|
| 1622 |
-
async with _state_flush_semaphore:
|
| 1623 |
-
return await asyncio.to_thread(_delete_chat_storage, chat_id)
|
| 1624 |
-
|
| 1625 |
-
def mark_state_dirty(chat_id: int | None = None) -> None:
|
| 1626 |
-
"""Помечает состояние чата как требующее сохранения.
|
| 1627 |
-
Явный chat_id (предпочтительный путь для нового кода) — помечает "грязным"
|
| 1628 |
-
ТОЛЬКО этот чат, ничего больше. Вызов БЕЗ chat_id (для мест, которые меняют
|
| 1629 |
-
сразу много чатов разом — например _prune_old_chats при вытеснении старых
|
| 1630 |
-
чатов) помечает "грязными" вообще все текущие чаты и индекс целиком."""
|
| 1631 |
-
global _index_dirty
|
| 1632 |
-
if chat_id is not None:
|
| 1633 |
-
_dirty_chat_ids.add(chat_id)
|
| 1634 |
-
else:
|
| 1635 |
-
_dirty_chat_ids.update(chat_state.keys())
|
| 1636 |
-
_index_dirty = True
|
| 1637 |
-
|
| 1638 |
-
def _mark_new_chat_id(chat_id: int) -> None:
|
| 1639 |
-
"""Регистрирует НОВЫЙ chat_id, только что появившийся в chat_state (см.
|
| 1640 |
-
get_state). Помечает и сам чат, и индекс "грязными" — если пометить только
|
| 1641 |
-
чат без индекса, после рестарта его данные будут недостижимы: per-chat ключ
|
| 1642 |
-
существует, но индекс (единственный способ узнать список ID при чтении) о
|
| 1643 |
-
нём не знает."""
|
| 1644 |
-
global _index_dirty
|
| 1645 |
-
_dirty_chat_ids.add(chat_id)
|
| 1646 |
-
_index_dirty = True
|
| 1647 |
-
|
| 1648 |
-
def mark_quota_dirty() -> None:
|
| 1649 |
-
global _quota_dirty
|
| 1650 |
-
_quota_dirty = True
|
| 1651 |
-
|
| 1652 |
-
async def _flush_dirty_state_once() -> None:
|
| 1653 |
-
"""Тело ОДНОЙ итерации периодического сброса состояния — вынесено из
|
| 1654 |
-
_flush_dirty_state (которая теперь только спит и вызывает эту функцию в цикле)
|
| 1655 |
-
отдельной функцией, чтобы её можно было протестировать напрямую, не дожидаясь
|
| 1656 |
-
реального FLUSH_INTERVAL_SEC в тестах."""
|
| 1657 |
-
global _dirty_chat_ids, _index_dirty, _quota_dirty, _pending_chat_deletions
|
| 1658 |
-
try:
|
| 1659 |
-
if _pending_chat_deletions:
|
| 1660 |
-
to_delete = list(_pending_chat_deletions)
|
| 1661 |
-
_pending_chat_deletions = set()
|
| 1662 |
-
del_results = await asyncio.gather(
|
| 1663 |
-
*(_delete_chat_storage_limited(cid) for cid in to_delete),
|
| 1664 |
-
return_exceptions=True,
|
| 1665 |
-
)
|
| 1666 |
-
# ИСПРАВЛЕНО (код-ревью): раньше результат просто игнорировался — при
|
| 1667 |
-
# сбое удаление молча "терялось" (чат оставался бесхозно висеть в
|
| 1668 |
-
# хранилище навсегда, если это был единственный шанс его удалить).
|
| 1669 |
-
# Теперь неудавшиеся id возвращаются в очередь для повтора на
|
| 1670 |
-
# следующем цикле (return_exceptions=True защищает и от неожиданного
|
| 1671 |
-
# исключения, которое не было поймано внутри самой _delete_chat_storage).
|
| 1672 |
-
failed_deletes = {cid for cid, res in zip(to_delete, del_results) if res is not True}
|
| 1673 |
-
if failed_deletes:
|
| 1674 |
-
_pending_chat_deletions.update(failed_deletes)
|
| 1675 |
-
log.warning(
|
| 1676 |
-
'[state] %d chat deletion(s) failed, will retry next cycle: %s',
|
| 1677 |
-
len(failed_deletes), ", ".join(str(c) for c in sorted(failed_deletes)),
|
| 1678 |
-
)
|
| 1679 |
-
if _dirty_chat_ids:
|
| 1680 |
-
to_save = list(_dirty_chat_ids)
|
| 1681 |
-
_dirty_chat_ids = set()
|
| 1682 |
-
# Каждый чат — свой независимый write; если один упадёт, это не
|
| 1683 |
-
# заденет сохранение остальных чатов из этой же пачки.
|
| 1684 |
-
attempted_ids = [cid for cid in to_save if cid in chat_state]
|
| 1685 |
-
save_results = await asyncio.gather(
|
| 1686 |
-
*(_save_chat_to_storage_limited(cid, chat_state[cid]) for cid in attempted_ids),
|
| 1687 |
-
return_exceptions=True,
|
| 1688 |
-
)
|
| 1689 |
-
# ИСПРАВЛЕНО (найдено при код-ревью, КРИТИЧНО): раньше to_save
|
| 1690 |
-
# очищался ДО того, как запись реально прошла, а _save_chat_to_storage
|
| 1691 |
-
# сама ловила исключение и просто логировала его — наружу в gather
|
| 1692 |
-
# ничего не долетало. При транзиентном сбое Upstash (сетевой глюк,
|
| 1693 |
-
# 429 и т.п.) состояние чата (вся история диалога) молча терялось до
|
| 1694 |
-
# следующей независимой мутации этого же чата — а если это было
|
| 1695 |
-
# последнее сообщение перед долгим затишьем, данные пропадали
|
| 1696 |
-
# безвозвратно при следующем рестарте контейнера. Теперь неудавшиеся
|
| 1697 |
-
# id возвращаются обратно в _dirty_chat_ids для повтора на следующем
|
| 1698 |
-
# цикле (FLUSH_INTERVAL_SEC секунд спустя), а не теряются молча.
|
| 1699 |
-
failed_ids = {cid for cid, res in zip(attempted_ids, save_results) if res is not True}
|
| 1700 |
-
if failed_ids:
|
| 1701 |
-
_dirty_chat_ids.update(failed_ids)
|
| 1702 |
-
log.warning(
|
| 1703 |
-
'[state] %d chat(s) failed to save this cycle, will retry next: %s',
|
| 1704 |
-
len(failed_ids), ", ".join(str(c) for c in sorted(failed_ids)),
|
| 1705 |
-
)
|
| 1706 |
-
if _index_dirty:
|
| 1707 |
-
_index_dirty = False
|
| 1708 |
-
await asyncio.to_thread(_save_chat_index)
|
| 1709 |
-
if _quota_dirty:
|
| 1710 |
-
_quota_dirty = False
|
| 1711 |
-
await asyncio.to_thread(save_global_quota)
|
| 1712 |
-
except Exception as exc:
|
| 1713 |
-
log.warning('[state] Periodic state flush failed: %s', exc)
|
| 1714 |
-
|
| 1715 |
-
|
| 1716 |
-
async def _flush_dirty_state() -> None:
|
| 1717 |
-
while True:
|
| 1718 |
-
await asyncio.sleep(FLUSH_INTERVAL_SEC)
|
| 1719 |
-
await _flush_dirty_state_once()
|
| 1720 |
-
def _flush_state_now() -> None:
|
| 1721 |
-
"""Синхронный финальный сброс всего "грязного" состояния — используется только
|
| 1722 |
-
при остановке процесса (main(), finally): event loop всё равно останавливается,
|
| 1723 |
-
поэтому блокирующие вызовы здесь не проблема, а вот пропустить несохранённые
|
| 1724 |
-
изменения между последним тиком периодического флаша и остановкой контейнера —
|
| 1725 |
-
проблема."""
|
| 1726 |
-
global _dirty_chat_ids, _index_dirty, _pending_chat_deletions
|
| 1727 |
-
for cid in list(_pending_chat_deletions):
|
| 1728 |
-
_delete_chat_storage(cid)
|
| 1729 |
-
_pending_chat_deletions = set()
|
| 1730 |
-
for cid in list(_dirty_chat_ids):
|
| 1731 |
-
st = chat_state.get(cid)
|
| 1732 |
-
if st is not None:
|
| 1733 |
-
_save_chat_to_storage(cid, st)
|
| 1734 |
-
_dirty_chat_ids = set()
|
| 1735 |
-
if _index_dirty:
|
| 1736 |
-
_save_chat_index()
|
| 1737 |
-
_index_dirty = False
|
| 1738 |
-
|
| 1739 |
-
def load_state_from_disk() -> None:
|
| 1740 |
-
load_global_quota()
|
| 1741 |
-
|
| 1742 |
-
index_raw = None
|
| 1743 |
-
try:
|
| 1744 |
-
index_raw = _storage_read_text(CHAT_INDEX_KEY, CHAT_INDEX_FILE)
|
| 1745 |
-
except Exception as exc:
|
| 1746 |
-
log.warning("[state] Reading chat index failed, falling back to legacy combined blob: %s", exc)
|
| 1747 |
-
|
| 1748 |
-
if index_raw is not None:
|
| 1749 |
-
# Новый формат (per-chat ключи) — индекс уже существует, читаем каждый
|
| 1750 |
-
# чат отдельно по своему ключу.
|
| 1751 |
-
try:
|
| 1752 |
-
chat_ids = json.loads(index_raw)
|
| 1753 |
-
except Exception as exc:
|
| 1754 |
-
log.warning('[state] Failed to parse chat index: %s', exc)
|
| 1755 |
-
chat_ids = []
|
| 1756 |
-
loaded_count = 0
|
| 1757 |
-
for chat_id_raw in chat_ids:
|
| 1758 |
-
try:
|
| 1759 |
-
cid = int(chat_id_raw)
|
| 1760 |
-
except Exception:
|
| 1761 |
-
continue
|
| 1762 |
-
try:
|
| 1763 |
-
raw = _storage_read_text(_chat_storage_key(cid), _chat_storage_path(cid))
|
| 1764 |
-
except Exception as exc:
|
| 1765 |
-
log.warning('[state] Failed to read chat %s: %s', cid, exc)
|
| 1766 |
-
continue
|
| 1767 |
-
if not raw:
|
| 1768 |
-
continue
|
| 1769 |
-
try:
|
| 1770 |
-
s = json.loads(raw)
|
| 1771 |
-
except Exception as exc:
|
| 1772 |
-
log.warning('[state] Failed to parse chat state %s: %s', cid, exc)
|
| 1773 |
-
continue
|
| 1774 |
-
_restore_single_chat(cid, s)
|
| 1775 |
-
loaded_count += 1
|
| 1776 |
-
log.info("[state] Restored states for %d chats (per-chat storage).", loaded_count)
|
| 1777 |
-
return
|
| 1778 |
-
|
| 1779 |
-
# ── Legacy-формат (единый блоб на все чаты, старый ключ "lumen:chat_state") ──
|
| 1780 |
-
# Индекса ещё нет — значит бот ещё ни разу не сохранял состояние в новом
|
| 1781 |
-
# per-chat формате (первый запуск после этого обновления). Читаем как раньше,
|
| 1782 |
-
# но сразу помечаем ВСЕ восстановленные чаты и индекс "грязными" (mark_state_
|
| 1783 |
-
# dirty() без аргумента) — уже самый первый периодический флаш перепишет их
|
| 1784 |
-
# в новом per-chat формате; дальше старый общий ключ больше не читается.
|
| 1785 |
-
# Сам старый ключ/файл намеренно не удаляется автоматически — не хотим лишний
|
| 1786 |
-
# раз трогать чужие данные во время миграции, можно вычистить вручную позже.
|
| 1787 |
-
try:
|
| 1788 |
-
raw = _storage_read_text("lumen:chat_state", STATE_FILE_PATH)
|
| 1789 |
-
if not raw:
|
| 1790 |
-
return
|
| 1791 |
-
loaded = json.loads(raw)
|
| 1792 |
-
for chat_id_str, s in loaded.items():
|
| 1793 |
-
try:
|
| 1794 |
-
cid = int(chat_id_str)
|
| 1795 |
-
except Exception:
|
| 1796 |
-
continue
|
| 1797 |
-
_restore_single_chat(cid, s)
|
| 1798 |
-
log.info(
|
| 1799 |
-
'[state] Restored states for %d chats (migrated from the old shared storage format — will be rewritten in the new per-chat format on next flush).',
|
| 1800 |
-
len(chat_state),
|
| 1801 |
-
)
|
| 1802 |
-
mark_state_dirty()
|
| 1803 |
-
except Exception as exc:
|
| 1804 |
-
log.warning("[state] Restoring states failed: %s", exc)
|
| 1805 |
-
|
| 1806 |
-
def get_state(chat_id: int) -> dict[str, Any]:
|
| 1807 |
-
if chat_id not in chat_state:
|
| 1808 |
-
chat_state[chat_id] = {
|
| 1809 |
-
"history": [],
|
| 1810 |
-
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 1811 |
-
"recent_media_ids": {},
|
| 1812 |
-
"last_activity": time.monotonic(),
|
| 1813 |
-
}
|
| 1814 |
-
# Новый chat_id должен попасть в индекс (см. per-chat хранилище выше) —
|
| 1815 |
-
# иначе после рестарта его данные будут недостижимы: собственный ключ
|
| 1816 |
-
# существует, но индекс о нём не знает.
|
| 1817 |
-
_mark_new_chat_id(chat_id)
|
| 1818 |
-
else:
|
| 1819 |
-
chat_state[chat_id]["last_activity"] = time.monotonic()
|
| 1820 |
-
if len(chat_state) > MAX_CHAT_LIMIT:
|
| 1821 |
-
_prune_old_chats()
|
| 1822 |
-
return chat_state[chat_id]
|
| 1823 |
-
|
| 1824 |
-
def _chat_lang(chat_id: int | None) -> str:
|
| 1825 |
-
"""Язык системных сообщений для чата: поле "lang" состояния, иначе "en".
|
| 1826 |
-
Никогда не падает — при любом мусоре в поле отдаёт дефолт."""
|
| 1827 |
-
try:
|
| 1828 |
-
if chat_id is None:
|
| 1829 |
-
return DEFAULT_LANG
|
| 1830 |
-
return normalize_lang(get_state(chat_id).get("lang", DEFAULT_LANG))
|
| 1831 |
-
except Exception:
|
| 1832 |
-
return DEFAULT_LANG
|
| 1833 |
-
|
| 1834 |
-
def _t(chat_id: int | None, key: str, **kwargs: Any) -> str:
|
| 1835 |
-
"""Системная строка key на языке чата (см. lumen_lang.py)."""
|
| 1836 |
-
return _lang_t(_chat_lang(chat_id), key, **kwargs)
|
| 1837 |
-
|
| 1838 |
-
def _prune_old_chats() -> None:
|
| 1839 |
-
sorted_ids = sorted(chat_state.keys(), key=lambda cid: chat_state[cid].get("last_activity", 0))
|
| 1840 |
-
to_remove = len(chat_state) - PRUNED_CHAT_TARGET
|
| 1841 |
-
removed_ids = sorted_ids[:to_remove]
|
| 1842 |
-
for cid in removed_ids:
|
| 1843 |
-
chat_state.pop(cid, None)
|
| 1844 |
-
_chat_locks.pop(cid, None)
|
| 1845 |
-
# Вытесненные чаты должны реально исчезнуть из хранилища (иначе их собственные
|
| 1846 |
-
# ключи/файлы бесхозно копятся навсегда) — ставим в очередь на удаление,
|
| 1847 |
-
# обрабатывается в _flush_dirty_state вместе с обычным сбросом.
|
| 1848 |
-
_pending_chat_deletions.update(removed_ids)
|
| 1849 |
-
mark_state_dirty()
|
| 1850 |
-
|
| 1851 |
-
def get_chat_lock(chat_id: int) -> asyncio.Lock:
|
| 1852 |
-
lock = _chat_locks.get(chat_id)
|
| 1853 |
-
if lock is None:
|
| 1854 |
-
lock = asyncio.Lock()
|
| 1855 |
-
_chat_locks[chat_id] = lock
|
| 1856 |
-
return lock
|
| 1857 |
-
|
| 1858 |
-
def _evict_orphan_chat_locks() -> int:
|
| 1859 |
-
"""Сносит локи чатов, которых уже нет в chat_state и которые никто не
|
| 1860 |
-
держит, — иначе _chat_locks растёт навсегда (AUD-G-001). Занятые не трогаем."""
|
| 1861 |
-
orphan = [cid for cid, lock in _chat_locks.items() if cid not in chat_state and not lock.locked()]
|
| 1862 |
-
for cid in orphan:
|
| 1863 |
-
_chat_locks.pop(cid, None)
|
| 1864 |
-
return len(orphan)
|
| 1865 |
-
|
| 1866 |
-
def _is_owner(user_id: int | None) -> bool:
|
| 1867 |
-
"""Единая точка проверки "это владелец бота?" — используется в /logs, /stats
|
| 1868 |
-
и при гейтинге привилегированных действий в группах (см. _is_privileged_in_chat
|
| 1869 |
-
ниже). Модель/провайдер бот теперь выбирает сам (см. секцию "автоматический
|
| 1870 |
-
выбор модели"), поэтому проверка реальных названий моделей ("показывать ли
|
| 1871 |
-
Gemini/Gemma/OpenRouter владельцу") больше не нужна нигде — эти названия
|
| 1872 |
-
вообще никому не показываются, включая владельца."""
|
| 1873 |
-
return OWNER_ID is not None and user_id is not None and user_id == OWNER_ID
|
| 1874 |
-
|
| 1875 |
-
async def _notify_owner(text: str) -> None:
|
| 1876 |
-
"""Минимальная наблюдаемость (аудит техдолга, август 2026): раньше единственным
|
| 1877 |
-
способом узнать о проблеме было ручное открытие /stats или /logs владельцем.
|
| 1878 |
-
Отправляет короткое ЛС владельцу через уже существующего бота — без внешнего
|
| 1879 |
-
сервиса мониторинга. Вызывается только на редкие, действительно важные события
|
| 1880 |
-
(срабатывание circuit breaker прокси, полное исчерпание квоты Gemini — см. сайты
|
| 1881 |
-
вызова), с собственным троттлингом на стороне вызывающего кода, чтобы не спамить
|
| 1882 |
-
владельца на каждое повторяющееся сообщение. Никогда не поднимает исключение —
|
| 1883 |
-
сбой уведомления не должен ронять обработку сообщения, из-за которого его вызвали."""
|
| 1884 |
-
if OWNER_ID is None or bot is None:
|
| 1885 |
-
return
|
| 1886 |
-
with contextlib.suppress(Exception):
|
| 1887 |
-
await _tg_call(bot.send_message, chat_id=OWNER_ID, text=text, call_timeout=10.0)
|
| 1888 |
-
|
| 1889 |
-
async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | None) -> bool:
|
| 1890 |
-
"""Может ли этот пользователь менять ОБЩИЕ настройки данного чата (модель
|
| 1891 |
-
для генерации изображений, сброс истории — выбор модели/провайдера для
|
| 1892 |
-
текстового чата больше не настройка чата вообще, см. "автоматический выбор
|
| 1893 |
-
модели" ниже)? В личных сообщениях у чата всего один пользователь —
|
| 1894 |
-
разрешено всегда. Владелец бота (OWNER_ID) — разрешено всегда, в любом чате.
|
| 1895 |
-
В группах/супергруппах — только создатель или администратор ЭТОЙ группы
|
| 1896 |
-
(проверяется через getChatMember, не требует особых прав у бота помимо
|
| 1897 |
-
членства в чате)."""
|
| 1898 |
-
if chat_type == ChatType.PRIVATE:
|
| 1899 |
-
return True
|
| 1900 |
-
if _is_owner(user_id):
|
| 1901 |
-
return True
|
| 1902 |
-
if user_id is None or bot is None:
|
| 1903 |
-
return False
|
| 1904 |
-
try:
|
| 1905 |
-
member = await bot.get_chat_member(chat_id, user_id)
|
| 1906 |
-
return getattr(member, "status", None) in ("creator", "administrator")
|
| 1907 |
-
except Exception as exc:
|
| 1908 |
-
log.warning('[perm] Failed to check admin status in chat %s: %s', chat_id, exc)
|
| 1909 |
-
return False
|
| 1910 |
|
| 1911 |
|
| 1912 |
# генерация изображений (pollinations)
|
|
@@ -2035,25 +1543,6 @@ async def _fetch_media(file_id: str, mime: str) -> tuple[bytes, str] | None:
|
|
| 2035 |
|
| 2036 |
# учёт квот
|
| 2037 |
|
| 2038 |
-
def _quota_entry(provider: str, model_id: str) -> QuotaEntry:
|
| 2039 |
-
_reset_quota_if_new_day()
|
| 2040 |
-
sub = GLOBAL_QUOTA.setdefault(provider, {})
|
| 2041 |
-
return sub.setdefault(model_id, {"used": 0, "exhausted_at": None})
|
| 2042 |
-
|
| 2043 |
-
def _mark_quota_exhausted(provider: str, model_id: str) -> None:
|
| 2044 |
-
"""Записывает момент, когда API реально вернул 429/RESOURCE_EXHAUSTED для модели.
|
| 2045 |
-
Используется, потому что _record_quota_usage инкрементирует "used" только при
|
| 2046 |
-
успешном ответе — без этого счётчик мог годами показывать 0, даже если все
|
| 2047 |
-
запросы к модели упирались в реальный лимит на стороне Google/OpenRouter."""
|
| 2048 |
-
e = _quota_entry(provider, model_id)
|
| 2049 |
-
e["exhausted_at"] = time.time()
|
| 2050 |
-
mark_quota_dirty()
|
| 2051 |
-
|
| 2052 |
-
def _record_quota_usage(provider: str, model_id: str) -> None:
|
| 2053 |
-
e = _quota_entry(provider, model_id)
|
| 2054 |
-
e["used"] = int(e.get("used") or 0) + 1
|
| 2055 |
-
e["exhausted_at"] = None
|
| 2056 |
-
mark_quota_dirty()
|
| 2057 |
|
| 2058 |
# openrouter api
|
| 2059 |
#
|
|
@@ -3508,9 +2997,11 @@ async def main() -> None:
|
|
| 3508 |
# Финальный синхронный сброс — не ждём следующего тика периодического
|
| 3509 |
# флаша (раз в FLUSH_INTERVAL_SEC), иначе последние изменения между
|
| 3510 |
# последним тиком и остановкой процесса терялись бы при рестарте.
|
| 3511 |
-
|
|
|
|
|
|
|
| 3512 |
_flush_state_now()
|
| 3513 |
-
if _quota_dirty:
|
| 3514 |
save_global_quota()
|
| 3515 |
await _close_sessions()
|
| 3516 |
|
|
|
|
| 24 |
from pathlib import Path
|
| 25 |
from collections import deque
|
| 26 |
from datetime import date, datetime
|
| 27 |
+
from typing import Any
|
| 28 |
from types import SimpleNamespace
|
| 29 |
import urllib.request as _urllib_request
|
| 30 |
|
|
|
|
| 506 |
TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY = int(os.getenv("TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY", "8"))
|
| 507 |
_tiktok_slide_download_semaphore = asyncio.Semaphore(TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY)
|
| 508 |
|
| 509 |
+
# Состояние/квоты/локи живут в lumen_chat_state.py (P2): здесь только реэкспорт
|
| 510 |
+
# имён (те же объекты — тесты мутируют bot.chat_state/bot.GLOBAL_QUOTA как раньше).
|
| 511 |
+
import lumen_chat_state
|
| 512 |
+
from lumen_chat_state import (
|
| 513 |
+
ChatState,
|
| 514 |
+
chat_state,
|
| 515 |
+
MAX_CHAT_LIMIT,
|
| 516 |
+
PRUNED_CHAT_TARGET,
|
| 517 |
+
MAX_CHAT_HISTORY_LEN,
|
| 518 |
+
MAX_MEDIA_RECENT_IDS,
|
| 519 |
+
_STATE_DIR,
|
| 520 |
+
STATE_FILE_PATH,
|
| 521 |
+
GLOBAL_QUOTA_FILE,
|
| 522 |
+
_CHATS_DIR,
|
| 523 |
+
CHAT_INDEX_KEY,
|
| 524 |
+
CHAT_INDEX_FILE,
|
| 525 |
+
_storage_config,
|
| 526 |
+
_upstash_request,
|
| 527 |
+
_upstash_set,
|
| 528 |
+
_upstash_get,
|
| 529 |
+
_upstash_delete,
|
| 530 |
+
_storage_write_text,
|
| 531 |
+
_storage_read_text,
|
| 532 |
+
_storage_delete_text,
|
| 533 |
+
_chat_storage_path,
|
| 534 |
+
_save_chat_to_storage,
|
| 535 |
+
_delete_chat_storage,
|
| 536 |
+
QuotaEntry,
|
| 537 |
+
GLOBAL_QUOTA,
|
| 538 |
+
_QUOTA_CHECK_THROTTLE_SEC,
|
| 539 |
+
_maybe_alert_gemini_exhausted,
|
| 540 |
+
_reset_quota_if_new_day,
|
| 541 |
+
load_global_quota,
|
| 542 |
+
save_global_quota,
|
| 543 |
+
_restore_single_chat,
|
| 544 |
+
_save_chat_index,
|
| 545 |
+
_dirty_chat_ids,
|
| 546 |
+
_pending_chat_deletions,
|
| 547 |
+
_last_quota_check_monotonic,
|
| 548 |
+
_last_gemini_exhausted_alert_monotonic,
|
| 549 |
+
_save_chat_to_storage_limited,
|
| 550 |
+
_delete_chat_storage_limited,
|
| 551 |
+
mark_state_dirty,
|
| 552 |
+
_mark_new_chat_id,
|
| 553 |
+
mark_quota_dirty,
|
| 554 |
+
_flush_dirty_state_once,
|
| 555 |
+
_flush_dirty_state,
|
| 556 |
+
_flush_state_now,
|
| 557 |
+
load_state_from_disk,
|
| 558 |
+
get_state,
|
| 559 |
+
_chat_lang,
|
| 560 |
+
_t,
|
| 561 |
+
_prune_old_chats,
|
| 562 |
+
get_chat_lock,
|
| 563 |
+
_evict_orphan_chat_locks,
|
| 564 |
+
_is_owner,
|
| 565 |
+
_notify_owner,
|
| 566 |
+
_is_privileged_in_chat,
|
| 567 |
+
_quota_entry,
|
| 568 |
+
_mark_quota_exhausted,
|
| 569 |
+
_record_quota_usage,
|
| 570 |
+
)
|
| 571 |
|
| 572 |
# Простой трекер для rate limiting и очередь кнопок-уточнений живут в
|
| 573 |
# lumen_limits.py (P2): здесь только реэкспорт имён, чтобы `bot.X` в тестах
|
|
|
|
| 1162 |
# типовая аннотация (TypedDict), НЕ меняет поведение в рантайме: chat_state[cid]
|
| 1163 |
# остаётся обычным dict, никакой валидации здесь не добавляется — это только
|
| 1164 |
# документация формы для статических проверок типов и читаемости.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
|
| 1166 |
# Буферы альбомов/медиа-групп
|
| 1167 |
_mg_buffers: dict[str, list[Message]] = {}
|
|
|
|
| 1232 |
|
| 1233 |
# хранение состояния и квот
|
| 1234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1235 |
|
| 1236 |
# Опциональное персистентное хранилище (Upstash Redis, бесплатный тир — см. README).
|
| 1237 |
# Если оба значения заданы, состояние пишется туда вместо эфемерного диска контейнера.
|
|
|
|
| 1265 |
# публичный интерфейс и поведение не изменились.
|
| 1266 |
from lumen_state_storage import (
|
| 1267 |
CHAT_STATE_SCHEMA_VERSION,
|
|
|
|
|
|
|
| 1268 |
_serialize_chat_state,
|
| 1269 |
_current_quota_day,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1270 |
)
|
| 1271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1272 |
|
| 1273 |
# `_urllib_request` (импортирован в самом начале файла) больше не используется
|
| 1274 |
# напрямую нигде в коде bot.py — реальный клиент Upstash REST API (единственный
|
|
|
|
| 1368 |
"_history_user_text",
|
| 1369 |
# Пространство имён genai-типов для тестов (bot.types.Content/...).
|
| 1370 |
"types",
|
| 1371 |
+
# Имена из lumen_chat_state.py код bot.py сам не читает — они нужны как
|
| 1372 |
+
# `bot.X` существующим тестам.
|
| 1373 |
+
"ChatState",
|
| 1374 |
+
"MAX_CHAT_LIMIT",
|
| 1375 |
+
"PRUNED_CHAT_TARGET",
|
| 1376 |
+
"MAX_CHAT_HISTORY_LEN",
|
| 1377 |
+
"STATE_FILE_PATH",
|
| 1378 |
+
"GLOBAL_QUOTA_FILE",
|
| 1379 |
+
"_CHATS_DIR",
|
| 1380 |
+
"CHAT_INDEX_KEY",
|
| 1381 |
+
"CHAT_INDEX_FILE",
|
| 1382 |
+
"_storage_config",
|
| 1383 |
+
"_upstash_request",
|
| 1384 |
+
"_upstash_set",
|
| 1385 |
+
"_upstash_get",
|
| 1386 |
+
"_upstash_delete",
|
| 1387 |
+
"_storage_write_text",
|
| 1388 |
+
"_storage_read_text",
|
| 1389 |
+
"_storage_delete_text",
|
| 1390 |
+
"_chat_storage_path",
|
| 1391 |
+
"_save_chat_to_storage",
|
| 1392 |
+
"_delete_chat_storage",
|
| 1393 |
+
"QuotaEntry",
|
| 1394 |
+
"_QUOTA_CHECK_THROTTLE_SEC",
|
| 1395 |
+
"load_global_quota",
|
| 1396 |
+
"_restore_single_chat",
|
| 1397 |
+
"_save_chat_index",
|
| 1398 |
+
"_save_chat_to_storage_limited",
|
| 1399 |
+
"_delete_chat_storage_limited",
|
| 1400 |
+
"_mark_new_chat_id",
|
| 1401 |
+
"mark_quota_dirty",
|
| 1402 |
+
"_flush_dirty_state_once",
|
| 1403 |
+
"_prune_old_chats",
|
| 1404 |
+
"_quota_entry",
|
| 1405 |
+
# Прямые имена lumen_state_storage для тестов.
|
| 1406 |
+
"_serialize_chat_state",
|
| 1407 |
+
"_current_quota_day",
|
| 1408 |
+
# Монотонный маркер троттлинга проверки даты (читают тесты).
|
| 1409 |
+
"_last_quota_check_monotonic",
|
| 1410 |
+
"_last_gemini_exhausted_alert_monotonic",
|
| 1411 |
]
|
| 1412 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1413 |
# Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
|
| 1414 |
# _handle_message_core) — без него один и тот же алерт улетал бы на КАЖДОЕ
|
| 1415 |
# сообщение, требующее Gemini, пока квота не восстановится (может быть весь день).
|
| 1416 |
GEMINI_EXHAUSTED_ALERT_COOLDOWN_SEC = 3600.0
|
|
|
|
| 1417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1418 |
|
| 1419 |
|
| 1420 |
# генерация изображений (pollinations)
|
|
|
|
| 1543 |
|
| 1544 |
# учёт квот
|
| 1545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1546 |
|
| 1547 |
# openrouter api
|
| 1548 |
#
|
|
|
|
| 2997 |
# Финальный синхронный сброс — не ждём следующего тика периодического
|
| 2998 |
# флаша (раз в FLUSH_INTERVAL_SEC), иначе последние изменения между
|
| 2999 |
# последним тиком и остановкой процесса терялись бы при рестарте.
|
| 3000 |
+
# Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
|
| 3001 |
+
# bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
|
| 3002 |
+
if _dirty_chat_ids or lumen_chat_state._index_dirty or _pending_chat_deletions:
|
| 3003 |
_flush_state_now()
|
| 3004 |
+
if lumen_chat_state._quota_dirty:
|
| 3005 |
save_global_quota()
|
| 3006 |
await _close_sessions()
|
| 3007 |
|
lumen_chat_state.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
lumen_chat_state.py — состояние чатов, квоты и локи (вынесено из bot.py,
|
| 3 |
+
P2 аудита): ChatState/chat_state, персистентность (файл/Upstash), квоты,
|
| 4 |
+
флашинг грязного состояния, get_state/_t/_chat_lang, локи чатов, owner-утилиты.
|
| 5 |
+
|
| 6 |
+
Связи с рантаймом bot.py — только через отложенный `import bot` внутри функций
|
| 7 |
+
(модульного цикла нет). bot.py реэкспортирует имена — словари те же объекты,
|
| 8 |
+
тесты мутируют bot.chat_state/bot.GLOBAL_QUOTA как раньше.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import contextlib
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
import tempfile
|
| 18 |
+
import time
|
| 19 |
+
from collections import deque
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any, TypedDict
|
| 22 |
+
|
| 23 |
+
from aiogram.enums import ChatType
|
| 24 |
+
from lumen_lang import DEFAULT_LANG, normalize_lang, t as _lang_t
|
| 25 |
+
from lumen_state_storage import (
|
| 26 |
+
StorageConfig,
|
| 27 |
+
_chat_storage_key,
|
| 28 |
+
_serialize_chat_state,
|
| 29 |
+
_upstash_request as _lumen_upstash_request,
|
| 30 |
+
_upstash_set as _lumen_upstash_set,
|
| 31 |
+
_upstash_get as _lumen_upstash_get,
|
| 32 |
+
_upstash_delete as _lumen_upstash_delete,
|
| 33 |
+
_storage_write_text as _lumen_storage_write_text,
|
| 34 |
+
_storage_read_text as _lumen_storage_read_text,
|
| 35 |
+
_storage_delete_text as _lumen_storage_delete_text,
|
| 36 |
+
_chat_storage_path as _lumen_chat_storage_path,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
log = logging.getLogger("bot")
|
| 40 |
+
|
| 41 |
+
MAX_CHAT_LIMIT = 5000
|
| 42 |
+
PRUNED_CHAT_TARGET = 4500
|
| 43 |
+
MAX_CHAT_HISTORY_LEN = 100
|
| 44 |
+
MAX_MEDIA_RECENT_IDS = 8 # хранится ОТДЕЛЬНО на каждого пользователя чата (см. recent_media_ids: dict[user_id, deque])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class ChatState(TypedDict, total=False):
|
| 48 |
+
history: list[dict[str, Any]]
|
| 49 |
+
ctx: "deque[str]"
|
| 50 |
+
recent_media_ids: dict[str, "deque[tuple[str, str]]"]
|
| 51 |
+
last_activity: float
|
| 52 |
+
lang: str
|
| 53 |
+
|
| 54 |
+
chat_state: dict[int, ChatState] = {}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
_STATE_DIR = Path(os.getenv("STATE_DIR", "/app")).resolve()
|
| 58 |
+
try:
|
| 59 |
+
_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
except Exception as _state_dir_exc:
|
| 61 |
+
log.warning('[setup] STATE_DIR %s is not writable (%s), using a temp directory instead.', _STATE_DIR, _state_dir_exc)
|
| 62 |
+
_STATE_DIR = Path(tempfile.gettempdir())
|
| 63 |
+
STATE_FILE_PATH = _STATE_DIR / "chat_state.json"
|
| 64 |
+
GLOBAL_QUOTA_FILE = _STATE_DIR / "global_quota.json"
|
| 65 |
+
# Per-chat хранилище (см. код-ревью suggestion #7): раньше ВЕСЬ chat_state (до 5000
|
| 66 |
+
# чатов) сериализовался и писался ОДНИМ блоком при каждом флаше — с Upstash это один
|
| 67 |
+
# большой REST-запрос; один неудачный/слишком большой write рисковал потерять сразу
|
| 68 |
+
# всё разом, а не одну запись. Теперь у каждого чата свой собственный ключ/файл, а
|
| 69 |
+
# CHAT_INDEX_KEY/CHAT_INDEX_FILE хранит только список ID чатов — так при рестарте
|
| 70 |
+
# известно, какие per-chat ключи вообще нужно прочитать.
|
| 71 |
+
_CHATS_DIR = _STATE_DIR / "chats"
|
| 72 |
+
with contextlib.suppress(Exception):
|
| 73 |
+
_CHATS_DIR.mkdir(parents=True, exist_ok=True)
|
| 74 |
+
CHAT_INDEX_KEY = "lumen:chat_index"
|
| 75 |
+
CHAT_INDEX_FILE = _STATE_DIR / "chat_index.json"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _storage_config() -> StorageConfig:
|
| 79 |
+
import bot
|
| 80 |
+
return StorageConfig(
|
| 81 |
+
use_upstash=bot.USE_UPSTASH, upstash_url=bot.UPSTASH_REDIS_REST_URL,
|
| 82 |
+
upstash_token=bot.UPSTASH_REDIS_REST_TOKEN, chats_dir=_CHATS_DIR,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _upstash_request(command_path: str, *, method: str = "GET", body: bytes | None = None) -> Any:
|
| 87 |
+
import bot
|
| 88 |
+
return _lumen_upstash_request(bot.UPSTASH_REDIS_REST_URL, bot.UPSTASH_REDIS_REST_TOKEN, command_path, method=method, body=body)
|
| 89 |
+
|
| 90 |
+
def _upstash_set(key: str, value: str) -> None:
|
| 91 |
+
import bot
|
| 92 |
+
_lumen_upstash_set(bot.UPSTASH_REDIS_REST_URL, bot.UPSTASH_REDIS_REST_TOKEN, key, value)
|
| 93 |
+
|
| 94 |
+
def _upstash_get(key: str) -> str | None:
|
| 95 |
+
import bot
|
| 96 |
+
return _lumen_upstash_get(bot.UPSTASH_REDIS_REST_URL, bot.UPSTASH_REDIS_REST_TOKEN, key)
|
| 97 |
+
|
| 98 |
+
def _upstash_delete(key: str) -> None:
|
| 99 |
+
import bot
|
| 100 |
+
_lumen_upstash_delete(bot.UPSTASH_REDIS_REST_URL, bot.UPSTASH_REDIS_REST_TOKEN, key)
|
| 101 |
+
|
| 102 |
+
def _storage_write_text(key: str, path: Path, text: str) -> None:
|
| 103 |
+
import bot
|
| 104 |
+
_lumen_storage_write_text(bot._storage_config(), key, path, text)
|
| 105 |
+
|
| 106 |
+
def _storage_read_text(key: str, path: Path) -> str | None:
|
| 107 |
+
import bot
|
| 108 |
+
return _lumen_storage_read_text(bot._storage_config(), key, path)
|
| 109 |
+
|
| 110 |
+
def _storage_delete_text(key: str, path: Path) -> None:
|
| 111 |
+
import bot
|
| 112 |
+
_lumen_storage_delete_text(bot._storage_config(), key, path)
|
| 113 |
+
|
| 114 |
+
def _chat_storage_path(chat_id: int) -> Path:
|
| 115 |
+
import bot
|
| 116 |
+
return _lumen_chat_storage_path(bot._storage_config(), chat_id)
|
| 117 |
+
|
| 118 |
+
# _save_chat_to_storage/_delete_chat_storage НАМЕРЕННО ост��ются здесь как реальные
|
| 119 |
+
# (не тонкие обёрточные) реализации, а не делегируют в lumen_state_storage.py, как
|
| 120 |
+
# остальные функции этой секции: они вызывают _storage_write_text/_storage_delete_text
|
| 121 |
+
# ПО ИМЕНИ, разрешаемому в собственном пространстве имён bot.py на момент вызова —
|
| 122 |
+
# это ЕДИНСТВЕННЫЙ способ, которым существующие тесты (патчащие именно
|
| 123 |
+
# `bot._storage_write_text`/`bot._storage_delete_text` через unittest.mock.patch)
|
| 124 |
+
# продолжают перехватывать вызов. Если бы эти две функции были обёртками вокруг
|
| 125 |
+
# lumen_state_storage._save_chat_to_storage (как остальные выше), тот вызывал бы
|
| 126 |
+
# СВОЮ собственную, непропатченную копию _storage_write_text внутри своего модуля —
|
| 127 |
+
# патч bot._storage_write_text никак её не затронул бы (patch мутирует атрибут
|
| 128 |
+
# только на объекте bot, а не на объекте lumen_state_storage).
|
| 129 |
+
def _save_chat_to_storage(chat_id: int, state: dict[str, Any]) -> bool:
|
| 130 |
+
"""Возвращает True при успехе, False при сбое. НАЙДЕНО ПРИ КОД-РЕВЬЮ: раньше эта
|
| 131 |
+
функция ничего не возвращала — вызывающий код (_flush_dirty_state) уже успевал
|
| 132 |
+
убрать chat_id из "грязного" набора ДО того, как запись реально прошла, и при
|
| 133 |
+
сбое (например, временный 5xx/сетевой сбой Upstash) исключение здесь просто
|
| 134 |
+
логировалось и терялось — состояние чата (вся история диалога) молча пропадало
|
| 135 |
+
до следующей независимой мутации этого же чата. Если это было последнее
|
| 136 |
+
сообщение перед долгим затишьем — при рестарте контейнера данные терялись
|
| 137 |
+
безвозвратно, ровно то, что персистентность через Upstash должна была
|
| 138 |
+
предотвращать. Теперь вызывающий код (_flush_dirty_state) возвращает неудавшиеся
|
| 139 |
+
chat_id обратно в _dirty_chat_ids для повтора на следующем цикле."""
|
| 140 |
+
import bot
|
| 141 |
+
try:
|
| 142 |
+
payload = json.dumps(_serialize_chat_state(state), ensure_ascii=False)
|
| 143 |
+
bot._storage_write_text(_chat_storage_key(chat_id), bot._chat_storage_path(chat_id), payload)
|
| 144 |
+
return True
|
| 145 |
+
except Exception as exc:
|
| 146 |
+
log.warning("[state] Saving chat %s failed: %s", chat_id, exc)
|
| 147 |
+
return False
|
| 148 |
+
|
| 149 |
+
def _delete_chat_storage(chat_id: int) -> bool:
|
| 150 |
+
"""Возвращает True при успехе, False при сбое — тот же принцип, что и у
|
| 151 |
+
_save_chat_to_storage выше (см. докстринг там): неудавшееся удаление теперь
|
| 152 |
+
тоже возвращается в очередь на повтор, а не молча забывается (иначе вытесненный
|
| 153 |
+
чат мог бы бесхозно остаться в Upstash/на диске навсегда при транзиентном сбое)."""
|
| 154 |
+
import bot
|
| 155 |
+
try:
|
| 156 |
+
bot._storage_delete_text(_chat_storage_key(chat_id), bot._chat_storage_path(chat_id))
|
| 157 |
+
return True
|
| 158 |
+
except Exception as exc:
|
| 159 |
+
log.warning("[state] Deleting chat %s failed: %s", chat_id, exc)
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одной записи GLOBAL_QUOTA[provider][model_id]
|
| 163 |
+
# (см. _quota_entry/_record_quota_usage/_mark_quota_exhausted ниже) была разбросанным
|
| 164 |
+
# по коду соглашением, а не задокументированной структурой. Как и ChatState выше —
|
| 165 |
+
# чисто типовая аннотация, ничего не меняет в рантайме (GLOBAL_QUOTA остаётся
|
| 166 |
+
# обычным dict из dict'ов).
|
| 167 |
+
class QuotaEntry(TypedDict):
|
| 168 |
+
used: int
|
| 169 |
+
exhausted_at: float | None
|
| 170 |
+
|
| 171 |
+
GLOBAL_QUOTA: dict[str, Any] = {
|
| 172 |
+
"gemini": {},
|
| 173 |
+
"openrouter": {},
|
| 174 |
+
# НАЙДЕНО ПРИ КАЛИБРОВКЕ (25 июля 2026): /stats показывал сотни запросов
|
| 175 |
+
# по моделям при аптайме процесса всего 12 минут — счётчик "used" копится
|
| 176 |
+
# НАВСЕГДА (переживает рестарты через Upstash, см. _save_chat_to_storage/
|
| 177 |
+
# load_global_quota), а реальные суточные лимиты Google/OpenRouter обнуляются
|
| 178 |
+
# каждые ��утки. Бот об этом не знал вообще — "used"/"exhausted_at" не
|
| 179 |
+
# сбрасывались никогда, поэтому /stats после нескольких дней работы показывал
|
| 180 |
+
# бы бессмысленно огромные числа, а модель, один раз поймавшая 429 в первый
|
| 181 |
+
# день, так и висела бы с пометкой "(лимит исчерпан)" даже после того, как
|
| 182 |
+
# реальный лимит давно обновился. quota_day хранит дату (ISO, по America/
|
| 183 |
+
# Los_Angeles — именно там у Google полночь, когда реально обнуляется RPD-
|
| 184 |
+
# лимит) последнего сброса счётчиков — см. _reset_quota_if_new_day ниже.
|
| 185 |
+
"quota_day": None,
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
# НАЙДЕНО ПРИ CODE-REVIEW (перф): _quota_entry вызывает _reset_quota_if_new_day
|
| 189 |
+
# на КАЖДОЕ обращение к квоте (а таких обращений — по несколько на каждый успешный/
|
| 190 |
+
# неудачный вызов любой модели, т.е. потенциально десятки в секунду при активном
|
| 191 |
+
# трафике). Без троттлинга это означало бы конструирование ZoneInfo("America/
|
| 192 |
+
# Los_Angeles") и datetime.now(...) на каждый такой вызов — сама по себе дата не
|
| 193 |
+
# меняется чаще раза в сутки, минутная неточность здесь совершенно не важна.
|
| 194 |
+
# _QUOTA_CHECK_THROTTLE_SEC ограничивает, как часто мы вообще пересчитываем
|
| 195 |
+
# текущую дату; между пересчётами просто ничего не делаем.
|
| 196 |
+
_QUOTA_CHECK_THROTTLE_SEC = 60.0
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
_last_gemini_exhausted_alert_monotonic: float = 0.0
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
async def _maybe_alert_gemini_exhausted() -> None:
|
| 203 |
+
import bot
|
| 204 |
+
global _last_gemini_exhausted_alert_monotonic
|
| 205 |
+
now = time.monotonic()
|
| 206 |
+
if now - _last_gemini_exhausted_alert_monotonic < bot.GEMINI_EXHAUSTED_ALERT_COOLDOWN_SEC:
|
| 207 |
+
return
|
| 208 |
+
_last_gemini_exhausted_alert_monotonic = now
|
| 209 |
+
await bot._notify_owner(bot._t(bot.OWNER_ID, "owner_quota_notice"))
|
| 210 |
+
|
| 211 |
+
_last_quota_check_monotonic: float = 0.0
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _reset_quota_if_new_day() -> None:
|
| 215 |
+
"""Сбрасывает used/exhausted_at у ВСЕХ моделей (Gemini и OpenRouter), если с
|
| 216 |
+
последнего сброса наступили новые сутки (по America/Los_Angeles). Вызывается и лениво (см.
|
| 217 |
+
_quota_entry — на любое обращение к квоте), и явно раз в час из фонового цикла
|
| 218 |
+
в _webhook_startup, чтобы сброс не зависел от того, придёт ли вообще новое
|
| 219 |
+
сообщение сразу после полуночи."""
|
| 220 |
+
import bot
|
| 221 |
+
global _last_quota_check_monotonic
|
| 222 |
+
now_mono = time.monotonic()
|
| 223 |
+
if now_mono - _last_quota_check_monotonic < _QUOTA_CHECK_THROTTLE_SEC:
|
| 224 |
+
return
|
| 225 |
+
_last_quota_check_monotonic = now_mono
|
| 226 |
+
today = bot._current_quota_day()
|
| 227 |
+
if GLOBAL_QUOTA.get("quota_day") == today:
|
| 228 |
+
return
|
| 229 |
+
had_previous = GLOBAL_QUOTA.get("quota_day") is not None
|
| 230 |
+
for provider in ("gemini", "openrouter"):
|
| 231 |
+
for entry in GLOBAL_QUOTA.get(provider, {}).values():
|
| 232 |
+
if isinstance(entry, dict):
|
| 233 |
+
entry["used"] = 0
|
| 234 |
+
entry["exhausted_at"] = None
|
| 235 |
+
GLOBAL_QUOTA["quota_day"] = today
|
| 236 |
+
if had_previous:
|
| 237 |
+
log.info('[quota] New day started (%s) — used/exhausted_at counters reset for all models.', today)
|
| 238 |
+
bot.mark_quota_dirty()
|
| 239 |
+
|
| 240 |
+
def load_global_quota() -> None:
|
| 241 |
+
import bot
|
| 242 |
+
try:
|
| 243 |
+
raw = bot._storage_read_text("lumen:global_quota", GLOBAL_QUOTA_FILE)
|
| 244 |
+
if not raw:
|
| 245 |
+
return
|
| 246 |
+
loaded = json.loads(raw)
|
| 247 |
+
if isinstance(loaded, dict):
|
| 248 |
+
if "gemini" in loaded:
|
| 249 |
+
GLOBAL_QUOTA["gemini"] = loaded["gemini"]
|
| 250 |
+
if "openrouter" in loaded:
|
| 251 |
+
GLOBAL_QUOTA["openrouter"] = loaded["openrouter"]
|
| 252 |
+
if "quota_day" in loaded:
|
| 253 |
+
GLOBAL_QUOTA["quota_day"] = loaded["quota_day"]
|
| 254 |
+
except Exception as exc:
|
| 255 |
+
log.warning("[quota] Failed to load global quota: %s", exc)
|
| 256 |
+
# Проверяем сразу после загрузки — если бот был перезапущен уже на следующие
|
| 257 |
+
# сутки (обычное дело при редеплое), счётчики должны обнулиться сразу на
|
| 258 |
+
# старте, а не ждать первого сообщения после полуночи или ближайшего часового тика.
|
| 259 |
+
bot._reset_quota_if_new_day()
|
| 260 |
+
|
| 261 |
+
def save_global_quota() -> None:
|
| 262 |
+
import bot
|
| 263 |
+
try:
|
| 264 |
+
bot._storage_write_text("lumen:global_quota", GLOBAL_QUOTA_FILE, json.dumps(GLOBAL_QUOTA, ensure_ascii=False))
|
| 265 |
+
except Exception as exc:
|
| 266 |
+
log.warning("[quota] Failed to save global quota: %s", exc)
|
| 267 |
+
|
| 268 |
+
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: до сих пор каждый формат-дрейф персистентного
|
| 269 |
+
# снимка чата (слияние gemini_history/or_history в единую history) обнаруживался
|
| 270 |
+
# в _restore_single_chat ad hoc проверками "есть ли такой-то ключ в JSON" —
|
| 271 |
+
# рабочий, но накопительный подход: с каждым новым изменением формата туда
|
| 272 |
+
# добавлялась ещё одна ветка "если ключа нет — значит старая запись".
|
| 273 |
+
# CHAT_STATE_SCHEMA_VERSION (импортирован из lumen_state_storage.py вместе с
|
| 274 |
+
# _serialize_chat_state — см. блок импорта в начале секции "хранение состояния и
|
| 275 |
+
# квот") делает следующую подобную миграцию однозначной: новый код сможет
|
| 276 |
+
# проверять `s.get("schema_version", 0)` одним явным числом вместо повторного
|
| 277 |
+
# гадания по присутствию ключей. Существующие персистентные записи (сделанные до
|
| 278 |
+
# введения этого поля) не имеют "schema_version" вообще — они естественно
|
| 279 |
+
# трактуются как версия 0 и продолжают проходить через уже отлаженные эвристики
|
| 280 |
+
# ниже без каких-либо изменений в их поведении (это поле — задел на будущее, а
|
| 281 |
+
# не ретроактивная миграция уже написанной логики).
|
| 282 |
+
|
| 283 |
+
def _restore_single_chat(cid: int, s: dict[str, Any]) -> None:
|
| 284 |
+
"""Разворачивает сериализованный снимок одного чата (см. _serialize_chat_state)
|
| 285 |
+
обратно в chat_state[cid] — общая логика между новым per-chat форматом чтения
|
| 286 |
+
и одноразовой миграцией из старого общего блоба (см. load_state_from_disk).
|
| 287 |
+
|
| 288 |
+
Поля "gemini_model"/"openrouter_text_model"/"chat_provider"/"image_model" из
|
| 289 |
+
старых записей (созданных до перехода на автоматический роутер для текста и,
|
| 290 |
+
позже, для генерации изображений — см. README, "Автоматический выбор модели")
|
| 291 |
+
намеренно нигде ниже не читаются — они устарели и больше ни на что не влияют.
|
| 292 |
+
|
| 293 |
+
schema_version (см. CHAT_STATE_SCHEMA_VERSION выше) в самих записях, читаемых
|
| 294 |
+
здесь, пока ни на что не влияет — существующая миграция (history/gemini_history)
|
| 295 |
+
уже надёжно определяется по присутствию конкретных ключей, и это не нужно
|
| 296 |
+
менять задним числом. Поле — задел на СЛЕДУЮЩИЙ формат-дрейф: тогда новую
|
| 297 |
+
ветку можно будет добавить как `if s.get("schema_version", 0) < N`, а не
|
| 298 |
+
подбирать очередную эвристику по ключам, как приходилось делать для миграции
|
| 299 |
+
ниже."""
|
| 300 |
+
import bot
|
| 301 |
+
schema_version = s.get("schema_version", 0)
|
| 302 |
+
log.debug('[state] Restoring chat %s (schema_version=%s)', cid, schema_version)
|
| 303 |
+
raw_media = s.get("recent_media_ids", {})
|
| 304 |
+
if isinstance(raw_media, dict):
|
| 305 |
+
media_buckets = {
|
| 306 |
+
str(uid): deque(items, maxlen=MAX_MEDIA_RECENT_IDS)
|
| 307 |
+
for uid, items in raw_media.items()
|
| 308 |
+
}
|
| 309 |
+
else:
|
| 310 |
+
# Старый формат (плоский список на весь чат) — не мигрируем содержимое,
|
| 311 |
+
# просто стартуем с чистого состояния, новые записи наполнят сами по себе.
|
| 312 |
+
media_buckets = {}
|
| 313 |
+
if "history" in s:
|
| 314 |
+
history = list(s.get("history") or [])
|
| 315 |
+
else:
|
| 316 |
+
# Миграция со старого формата раздельной памяти Gemini/OpenRouter (до
|
| 317 |
+
# объединения в общую историю) — просто конкатенируем обе, обрезая до
|
| 318 |
+
# общего лимита. Порядок между двумя источниками восстановить точно
|
| 319 |
+
# нельзя (нет временных меток), но сохранить сам факт истории важнее,
|
| 320 |
+
# чем идеальная хронология при одноразовой миграции старых чатов.
|
| 321 |
+
history = list(s.get("gemini_history") or []) + list(s.get("or_history") or [])
|
| 322 |
+
if len(history) > bot.SHARED_HISTORY_MAX_LEN:
|
| 323 |
+
history = history[-bot.SHARED_HISTORY_MAX_LEN:]
|
| 324 |
+
chat_state[cid] = {
|
| 325 |
+
"history": history,
|
| 326 |
+
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 327 |
+
"recent_media_ids": media_buckets,
|
| 328 |
+
"last_activity": time.monotonic(),
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
def _save_chat_index() -> None:
|
| 332 |
+
import bot
|
| 333 |
+
try:
|
| 334 |
+
ids = sorted(chat_state.keys())
|
| 335 |
+
bot._storage_write_text(CHAT_INDEX_KEY, CHAT_INDEX_FILE, json.dumps(ids))
|
| 336 |
+
except Exception as exc:
|
| 337 |
+
log.warning("[state] Saving chat index failed: %s", exc)
|
| 338 |
+
|
| 339 |
+
# Раньше save_state_to_disk()/save_global_quota() вызывались синхронно почти на
|
| 340 |
+
# каждое сообщение прямо внутри асинхронных обработчиков — блокирующий json.dump
|
| 341 |
+
# на полном chat_state (до 5000 чатов) блокировал event loop для ВСЕХ чатов сразу,
|
| 342 |
+
# и с ростом числа активных чатов это становится всё дороже на каждое сообщение
|
| 343 |
+
# от любого одного пользователя. Теперь горячий путь только помечает КОНКРЕТНЫЙ
|
| 344 |
+
# чат "грязным" (см. mark_state_dirty(chat_id)), а реальная запись идёт из
|
| 345 |
+
# фоновой корутины _flush_dirty_state раз в FLUSH_INTERVAL_SEC через
|
| 346 |
+
# asyncio.to_thread (не блокируя loop) — и только по изменившимся чатам, а не
|
| 347 |
+
# по всем сразу (см. код-ревью suggestion #7 про размер payload и blast radius).
|
| 348 |
+
_dirty_chat_ids: set[int] = set()
|
| 349 |
+
_pending_chat_deletions: set[int] = set()
|
| 350 |
+
_index_dirty = False
|
| 351 |
+
_quota_dirty = False
|
| 352 |
+
FLUSH_INTERVAL_SEC = 10.0
|
| 353 |
+
# НАЙДЕНО ПРИ КОД-РЕВЬЮ (performance): раньше ничего не ограничивало число ОДНОВРЕМЕННЫХ
|
| 354 |
+
# asyncio.to_thread-вызовов внутри одного цикла _flush_dirty_state — резкий всплеск
|
| 355 |
+
# "грязных" чатов разом (например, после активности сразу в нескольких группах) мог бы
|
| 356 |
+
# породить сотни параллельных блокирующих HTTP-запросов к Upstash одновременно. Не
|
| 357 |
+
# критично при текущем масштабе бота, но дешёвая защита на будущее — ограничиваем
|
| 358 |
+
# конкурентность семафором, а не оставляем неограниченной.
|
| 359 |
+
STATE_FLUSH_CONCURRENCY = int(os.getenv("STATE_FLUSH_CONCURRENCY", "10"))
|
| 360 |
+
_state_flush_semaphore = asyncio.Semaphore(STATE_FLUSH_CONCURRENCY)
|
| 361 |
+
|
| 362 |
+
async def _save_chat_to_storage_limited(chat_id: int, state: dict[str, Any]) -> bool:
|
| 363 |
+
import bot
|
| 364 |
+
async with _state_flush_semaphore:
|
| 365 |
+
return await asyncio.to_thread(bot._save_chat_to_storage, chat_id, state)
|
| 366 |
+
|
| 367 |
+
async def _delete_chat_storage_limited(chat_id: int) -> bool:
|
| 368 |
+
import bot
|
| 369 |
+
async with _state_flush_semaphore:
|
| 370 |
+
return await asyncio.to_thread(bot._delete_chat_storage, chat_id)
|
| 371 |
+
|
| 372 |
+
def mark_state_dirty(chat_id: int | None = None) -> None:
|
| 373 |
+
"""Помечает состояние чата как требующее сохранения.
|
| 374 |
+
Явный chat_id (предпочтительный путь для нового кода) — помечает "грязным"
|
| 375 |
+
ТОЛЬКО этот чат, ничего больше. Вызов БЕЗ chat_id (для мест, которые меняют
|
| 376 |
+
сразу много чатов разом — например _prune_old_chats при вытеснении старых
|
| 377 |
+
чатов) помечает "грязными" вообще все текущие чаты и индекс целиком."""
|
| 378 |
+
global _index_dirty
|
| 379 |
+
if chat_id is not None:
|
| 380 |
+
_dirty_chat_ids.add(chat_id)
|
| 381 |
+
else:
|
| 382 |
+
_dirty_chat_ids.update(chat_state.keys())
|
| 383 |
+
_index_dirty = True
|
| 384 |
+
|
| 385 |
+
def _mark_new_chat_id(chat_id: int) -> None:
|
| 386 |
+
"""Регистрирует НОВЫЙ chat_id, только что появившийся в chat_state (см.
|
| 387 |
+
get_state). Помечает и сам чат, и индекс "грязными" — если пометить только
|
| 388 |
+
чат без индекса, после рестарта его данные будут недостижимы: per-chat ключ
|
| 389 |
+
существует, но индекс (единственный способ узнать список ID при чтении) о
|
| 390 |
+
нём не знает."""
|
| 391 |
+
global _index_dirty
|
| 392 |
+
_dirty_chat_ids.add(chat_id)
|
| 393 |
+
_index_dirty = True
|
| 394 |
+
|
| 395 |
+
def mark_quota_dirty() -> None:
|
| 396 |
+
global _quota_dirty
|
| 397 |
+
_quota_dirty = True
|
| 398 |
+
|
| 399 |
+
async def _flush_dirty_state_once() -> None:
|
| 400 |
+
"""Тело ОДНОЙ итераци�� периодического сброса состояния — вынесено из
|
| 401 |
+
_flush_dirty_state (которая теперь только спит и вызывает эту функцию в цикле)
|
| 402 |
+
отдельной функцией, чтобы её можно было протестировать напрямую, не дожидаясь
|
| 403 |
+
реального FLUSH_INTERVAL_SEC в тестах."""
|
| 404 |
+
import bot
|
| 405 |
+
global _index_dirty, _quota_dirty
|
| 406 |
+
try:
|
| 407 |
+
if _pending_chat_deletions:
|
| 408 |
+
to_delete = list(_pending_chat_deletions)
|
| 409 |
+
_pending_chat_deletions.clear()
|
| 410 |
+
del_results = await asyncio.gather(
|
| 411 |
+
*(bot._delete_chat_storage_limited(cid) for cid in to_delete),
|
| 412 |
+
return_exceptions=True,
|
| 413 |
+
)
|
| 414 |
+
# ИСПРАВЛЕНО (код-ревью): раньше результат просто игнорировался — при
|
| 415 |
+
# сбое удаление молча "терялось" (чат оставался бесхозно висеть в
|
| 416 |
+
# хранилище навсегда, если это был единственный шанс его удалить).
|
| 417 |
+
# Теперь неудавшиеся id возвращаются в очередь для повтора на
|
| 418 |
+
# следующем цикле (return_exceptions=True защищает и от неожиданного
|
| 419 |
+
# исключения, которое не было поймано внутри самой _delete_chat_storage).
|
| 420 |
+
failed_deletes = {cid for cid, res in zip(to_delete, del_results) if res is not True}
|
| 421 |
+
if failed_deletes:
|
| 422 |
+
_pending_chat_deletions.update(failed_deletes)
|
| 423 |
+
log.warning(
|
| 424 |
+
'[state] %d chat deletion(s) failed, will retry next cycle: %s',
|
| 425 |
+
len(failed_deletes), ", ".join(str(c) for c in sorted(failed_deletes)),
|
| 426 |
+
)
|
| 427 |
+
if _dirty_chat_ids:
|
| 428 |
+
to_save = list(_dirty_chat_ids)
|
| 429 |
+
_dirty_chat_ids.clear()
|
| 430 |
+
# Каждый чат — свой независимый write; если один упадёт, это не
|
| 431 |
+
# заденет сохранение остальных чатов из этой же пачки.
|
| 432 |
+
attempted_ids = [cid for cid in to_save if cid in chat_state]
|
| 433 |
+
save_results = await asyncio.gather(
|
| 434 |
+
*(bot._save_chat_to_storage_limited(cid, chat_state[cid]) for cid in attempted_ids),
|
| 435 |
+
return_exceptions=True,
|
| 436 |
+
)
|
| 437 |
+
# ИСПРАВЛЕНО (найдено при код-ревью, КРИТИЧНО): раньше to_save
|
| 438 |
+
# очищался ДО того, как запись реально прошла, а _save_chat_to_storage
|
| 439 |
+
# сама ловила исключение и просто логировала его — наружу в gather
|
| 440 |
+
# ничего не долетало. При транзиентном сбое Upstash (сетевой глюк,
|
| 441 |
+
# 429 и т.п.) состояние чата (вся история диалога) молча терялось до
|
| 442 |
+
# следующей независимой мутации этого же чата — а если это было
|
| 443 |
+
# последнее сообщение перед долгим затишьем, данные пропадали
|
| 444 |
+
# безвозвратно при следующем рестарте контейнера. Теперь неудавшиеся
|
| 445 |
+
# id возвращаются обратно в _dirty_chat_ids для повтора на следующем
|
| 446 |
+
# цикле (FLUSH_INTERVAL_SEC секунд спустя), а не теряются молча.
|
| 447 |
+
failed_ids = {cid for cid, res in zip(attempted_ids, save_results) if res is not True}
|
| 448 |
+
if failed_ids:
|
| 449 |
+
_dirty_chat_ids.update(failed_ids)
|
| 450 |
+
log.warning(
|
| 451 |
+
'[state] %d chat(s) failed to save this cycle, will retry next: %s',
|
| 452 |
+
len(failed_ids), ", ".join(str(c) for c in sorted(failed_ids)),
|
| 453 |
+
)
|
| 454 |
+
if _index_dirty:
|
| 455 |
+
_index_dirty = False
|
| 456 |
+
await asyncio.to_thread(bot._save_chat_index)
|
| 457 |
+
if _quota_dirty:
|
| 458 |
+
_quota_dirty = False
|
| 459 |
+
await asyncio.to_thread(bot.save_global_quota)
|
| 460 |
+
except Exception as exc:
|
| 461 |
+
log.warning('[state] Periodic state flush failed: %s', exc)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
async def _flush_dirty_state() -> None:
|
| 465 |
+
import bot
|
| 466 |
+
while True:
|
| 467 |
+
await asyncio.sleep(FLUSH_INTERVAL_SEC)
|
| 468 |
+
await bot._flush_dirty_state_once()
|
| 469 |
+
def _flush_state_now() -> None:
|
| 470 |
+
"""Синхронный финальный сброс всего "грязного" состояния — используется только
|
| 471 |
+
при остановке процесса (main(), finally): event loop всё равно останавливается,
|
| 472 |
+
поэтому блокирующие вызовы здесь не проблема, а вот пропустить несохранённые
|
| 473 |
+
изменения между последним тиком периодического флаша и остановкой контейнера —
|
| 474 |
+
проблема."""
|
| 475 |
+
import bot
|
| 476 |
+
global _index_dirty
|
| 477 |
+
for cid in list(_pending_chat_deletions):
|
| 478 |
+
bot._delete_chat_storage(cid)
|
| 479 |
+
_pending_chat_deletions.clear()
|
| 480 |
+
for cid in list(_dirty_chat_ids):
|
| 481 |
+
st = chat_state.get(cid)
|
| 482 |
+
if st is not None:
|
| 483 |
+
bot._save_chat_to_storage(cid, st)
|
| 484 |
+
_dirty_chat_ids.clear()
|
| 485 |
+
if _index_dirty:
|
| 486 |
+
bot._save_chat_index()
|
| 487 |
+
_index_dirty = False
|
| 488 |
+
|
| 489 |
+
def load_state_from_disk() -> None:
|
| 490 |
+
import bot
|
| 491 |
+
bot.load_global_quota()
|
| 492 |
+
|
| 493 |
+
index_raw = None
|
| 494 |
+
try:
|
| 495 |
+
index_raw = bot._storage_read_text(CHAT_INDEX_KEY, CHAT_INDEX_FILE)
|
| 496 |
+
except Exception as exc:
|
| 497 |
+
log.warning("[state] Reading chat index failed, falling back to legacy combined blob: %s", exc)
|
| 498 |
+
|
| 499 |
+
if index_raw is not None:
|
| 500 |
+
# Новый формат (per-chat ключи) — индекс уже существует, читаем каждый
|
| 501 |
+
# чат отдельно по своему ключу.
|
| 502 |
+
try:
|
| 503 |
+
chat_ids = json.loads(index_raw)
|
| 504 |
+
except Exception as exc:
|
| 505 |
+
log.warning('[state] Failed to parse chat index: %s', exc)
|
| 506 |
+
chat_ids = []
|
| 507 |
+
loaded_count = 0
|
| 508 |
+
for chat_id_raw in chat_ids:
|
| 509 |
+
try:
|
| 510 |
+
cid = int(chat_id_raw)
|
| 511 |
+
except Exception:
|
| 512 |
+
continue
|
| 513 |
+
try:
|
| 514 |
+
raw = bot._storage_read_text(_chat_storage_key(cid), bot._chat_storage_path(cid))
|
| 515 |
+
except Exception as exc:
|
| 516 |
+
log.warning('[state] Failed to read chat %s: %s', cid, exc)
|
| 517 |
+
continue
|
| 518 |
+
if not raw:
|
| 519 |
+
continue
|
| 520 |
+
try:
|
| 521 |
+
s = json.loads(raw)
|
| 522 |
+
except Exception as exc:
|
| 523 |
+
log.warning('[state] Failed to parse chat state %s: %s', cid, exc)
|
| 524 |
+
continue
|
| 525 |
+
bot._restore_single_chat(cid, s)
|
| 526 |
+
loaded_count += 1
|
| 527 |
+
log.info("[state] Restored states for %d chats (per-chat storage).", loaded_count)
|
| 528 |
+
return
|
| 529 |
+
|
| 530 |
+
# ── Legacy-формат (единый блоб на все чаты, старый ключ "lumen:chat_state") ──
|
| 531 |
+
# Индекса ещё нет — значит бот ещё ни разу не сохранял состояние в новом
|
| 532 |
+
# per-chat формате (первый запуск после этого обновления). Читаем как раньше,
|
| 533 |
+
# но сразу помечаем ВСЕ восстановленные чаты и индекс "грязными" (mark_state_
|
| 534 |
+
# dirty() без аргумента) — уже самый первый периодический флаш перепишет их
|
| 535 |
+
# в новом per-chat формате; дальше старый общий ключ больше не читается.
|
| 536 |
+
# Сам старый ключ/файл намеренно не удаляется автоматически — не хотим лишний
|
| 537 |
+
# раз трогать чужие данные во время миграции, можно вычистить вручную позже.
|
| 538 |
+
try:
|
| 539 |
+
raw = bot._storage_read_text("lumen:chat_state", STATE_FILE_PATH)
|
| 540 |
+
if not raw:
|
| 541 |
+
return
|
| 542 |
+
loaded = json.loads(raw)
|
| 543 |
+
for chat_id_str, s in loaded.items():
|
| 544 |
+
try:
|
| 545 |
+
cid = int(chat_id_str)
|
| 546 |
+
except Exception:
|
| 547 |
+
continue
|
| 548 |
+
bot._restore_single_chat(cid, s)
|
| 549 |
+
log.info(
|
| 550 |
+
'[state] Restored states for %d chats (migrated from the old shared storage format — will be rewritten in the new per-chat format on next flush).',
|
| 551 |
+
len(chat_state),
|
| 552 |
+
)
|
| 553 |
+
bot.mark_state_dirty()
|
| 554 |
+
except Exception as exc:
|
| 555 |
+
log.warning("[state] Restoring states failed: %s", exc)
|
| 556 |
+
|
| 557 |
+
def get_state(chat_id: int) -> dict[str, Any]:
|
| 558 |
+
import bot
|
| 559 |
+
if chat_id not in chat_state:
|
| 560 |
+
chat_state[chat_id] = {
|
| 561 |
+
"history": [],
|
| 562 |
+
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 563 |
+
"recent_media_ids": {},
|
| 564 |
+
"last_activity": time.monotonic(),
|
| 565 |
+
}
|
| 566 |
+
# Новый chat_id должен попасть в индекс (см. per-chat хранилище выше) —
|
| 567 |
+
# иначе после рестарта его данные будут недостижимы: собственный ключ
|
| 568 |
+
# существует, но индекс о нём не знает.
|
| 569 |
+
bot._mark_new_chat_id(chat_id)
|
| 570 |
+
else:
|
| 571 |
+
chat_state[chat_id]["last_activity"] = time.monotonic()
|
| 572 |
+
if len(chat_state) > MAX_CHAT_LIMIT:
|
| 573 |
+
bot._prune_old_chats()
|
| 574 |
+
return chat_state[chat_id]
|
| 575 |
+
|
| 576 |
+
def _chat_lang(chat_id: int | None) -> str:
|
| 577 |
+
"""Язык системных сообщений для чата: поле "lang" состояния, иначе "en".
|
| 578 |
+
Никогда не падает — при любом мусоре в поле отдаёт дефолт."""
|
| 579 |
+
import bot
|
| 580 |
+
try:
|
| 581 |
+
if chat_id is None:
|
| 582 |
+
return DEFAULT_LANG
|
| 583 |
+
return normalize_lang(bot.get_state(chat_id).get("lang", DEFAULT_LANG))
|
| 584 |
+
except Exception:
|
| 585 |
+
return DEFAULT_LANG
|
| 586 |
+
|
| 587 |
+
def _t(chat_id: int | None, key: str, **kwargs: Any) -> str:
|
| 588 |
+
"""Системная строка key на языке чата (см. lumen_lang.py)."""
|
| 589 |
+
import bot
|
| 590 |
+
return _lang_t(bot._chat_lang(chat_id), key, **kwargs)
|
| 591 |
+
|
| 592 |
+
def _prune_old_chats() -> None:
|
| 593 |
+
import bot
|
| 594 |
+
sorted_ids = sorted(chat_state.keys(), key=lambda cid: chat_state[cid].get("last_activity", 0))
|
| 595 |
+
to_remove = len(chat_state) - PRUNED_CHAT_TARGET
|
| 596 |
+
removed_ids = sorted_ids[:to_remove]
|
| 597 |
+
for cid in removed_ids:
|
| 598 |
+
chat_state.pop(cid, None)
|
| 599 |
+
bot._chat_locks.pop(cid, None)
|
| 600 |
+
# Вытесненные чаты должны реально исчезнуть из хранилища (иначе их собственные
|
| 601 |
+
# ключи/файлы бесхозно копятся навсегда) — ставим в очередь на удаление,
|
| 602 |
+
# обрабатывается в _flush_dirty_state вместе с обычным сбросом.
|
| 603 |
+
_pending_chat_deletions.update(removed_ids)
|
| 604 |
+
bot.mark_state_dirty()
|
| 605 |
+
|
| 606 |
+
def get_chat_lock(chat_id: int) -> asyncio.Lock:
|
| 607 |
+
import bot
|
| 608 |
+
lock = bot._chat_locks.get(chat_id)
|
| 609 |
+
if lock is None:
|
| 610 |
+
lock = asyncio.Lock()
|
| 611 |
+
bot._chat_locks[chat_id] = lock
|
| 612 |
+
return lock
|
| 613 |
+
|
| 614 |
+
def _evict_orphan_chat_locks() -> int:
|
| 615 |
+
"""Сносит локи чатов, которых уже нет в chat_state и которые никто не
|
| 616 |
+
держит, — иначе _chat_locks растёт навсегда (AUD-G-001). Занятые не трогаем."""
|
| 617 |
+
import bot
|
| 618 |
+
orphan = [cid for cid, lock in bot._chat_locks.items() if cid not in chat_state and not lock.locked()]
|
| 619 |
+
for cid in orphan:
|
| 620 |
+
bot._chat_locks.pop(cid, None)
|
| 621 |
+
return len(orphan)
|
| 622 |
+
|
| 623 |
+
def _is_owner(user_id: int | None) -> bool:
|
| 624 |
+
"""Единая точка проверки "это владелец бота?" — используется в /logs, /stats
|
| 625 |
+
и при гейтинге привилегированных действий в группах (см. _is_privileged_in_chat
|
| 626 |
+
ниже). Модель/провайдер бот теперь выбирает сам (см. секцию "автоматический
|
| 627 |
+
выбор модели"), поэтому проверка реальных названий моделей ("показывать ли
|
| 628 |
+
Gemini/Gemma/OpenRouter владельцу") больше не нужна нигде — эти названия
|
| 629 |
+
вообще никому не показываются, включая владельца."""
|
| 630 |
+
import bot
|
| 631 |
+
return bot.OWNER_ID is not None and user_id is not None and user_id == bot.OWNER_ID
|
| 632 |
+
|
| 633 |
+
async def _notify_owner(text: str) -> None:
|
| 634 |
+
"""Минимальная наблюдаемость (аудит техдолга, август 2026): раньше единственным
|
| 635 |
+
способом узнать о проблеме было ручное открытие /stats или /logs владельцем.
|
| 636 |
+
Отправляет короткое ЛС владельцу через уже существующего бота — без внешнего
|
| 637 |
+
сервиса мониторинга. Вызывается только на редкие, действительно важные события
|
| 638 |
+
(срабатывание circuit breaker прокси, полное исчерпание квоты Gemini — см. сайты
|
| 639 |
+
вызова), с собственным троттлингом на стороне вызывающего кода, чтобы не спамить
|
| 640 |
+
владельца на каждое повторяющееся сообщение. Никогда не поднимает исключение —
|
| 641 |
+
сбой уведомления не должен ронять обработку сообщения, из-за которого его вызвали."""
|
| 642 |
+
import bot
|
| 643 |
+
if bot.OWNER_ID is None or bot.bot is None:
|
| 644 |
+
return
|
| 645 |
+
with contextlib.suppress(Exception):
|
| 646 |
+
await bot._tg_call(bot.bot.send_message, chat_id=bot.OWNER_ID, text=text, call_timeout=10.0)
|
| 647 |
+
|
| 648 |
+
async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | None) -> bool:
|
| 649 |
+
"""Может ли этот пользователь менять ОБЩИЕ настройки данного чата (модель
|
| 650 |
+
для генерации изображений, сброс истории — выбор модели/провайдера для
|
| 651 |
+
текстового чата больше не настройка чата вообще, см. "автоматический выб��р
|
| 652 |
+
модели" ниже)? В личных сообщениях у чата всего один пользователь —
|
| 653 |
+
разрешено всегда. Владелец бота (OWNER_ID) — разрешено всегда, в любом чате.
|
| 654 |
+
В группах/супергруппах — только создатель или администратор ЭТОЙ группы
|
| 655 |
+
(проверяется через getChatMember, не требует особых прав у бота помимо
|
| 656 |
+
членства в чате)."""
|
| 657 |
+
import bot
|
| 658 |
+
if chat_type == ChatType.PRIVATE:
|
| 659 |
+
return True
|
| 660 |
+
if bot._is_owner(user_id):
|
| 661 |
+
return True
|
| 662 |
+
if user_id is None or bot.bot is None:
|
| 663 |
+
return False
|
| 664 |
+
try:
|
| 665 |
+
member = await bot.bot.get_chat_member(chat_id, user_id)
|
| 666 |
+
return getattr(member, "status", None) in ("creator", "administrator")
|
| 667 |
+
except Exception as exc:
|
| 668 |
+
log.warning('[perm] Failed to check admin status in chat %s: %s', chat_id, exc)
|
| 669 |
+
return False
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def _quota_entry(provider: str, model_id: str) -> QuotaEntry:
|
| 673 |
+
import bot
|
| 674 |
+
bot._reset_quota_if_new_day()
|
| 675 |
+
sub = GLOBAL_QUOTA.setdefault(provider, {})
|
| 676 |
+
return sub.setdefault(model_id, {"used": 0, "exhausted_at": None})
|
| 677 |
+
|
| 678 |
+
def _mark_quota_exhausted(provider: str, model_id: str) -> None:
|
| 679 |
+
"""Записывает момент, когда API реально вернул 429/RESOURCE_EXHAUSTED для модели.
|
| 680 |
+
Используется, потому что _record_quota_usage инкрементирует "used" только при
|
| 681 |
+
успешном ответе — без этого счётчик мог годами показывать 0, даже если все
|
| 682 |
+
запросы к модели упирались в реальный лимит на стороне Google/OpenRouter."""
|
| 683 |
+
import bot
|
| 684 |
+
e = bot._quota_entry(provider, model_id)
|
| 685 |
+
e["exhausted_at"] = time.time()
|
| 686 |
+
bot.mark_quota_dirty()
|
| 687 |
+
|
| 688 |
+
def _record_quota_usage(provider: str, model_id: str) -> None:
|
| 689 |
+
import bot
|
| 690 |
+
e = bot._quota_entry(provider, model_id)
|
| 691 |
+
e["used"] = int(e.get("used") or 0) + 1
|
| 692 |
+
e["exhausted_at"] = None
|
| 693 |
+
bot.mark_quota_dirty()
|
test_bot_state.py
CHANGED
|
@@ -10,6 +10,7 @@ import asyncio
|
|
| 10 |
import bot
|
| 11 |
import json
|
| 12 |
import logging
|
|
|
|
| 13 |
import lumen_limits
|
| 14 |
import sentry_sdk
|
| 15 |
import sys
|
|
@@ -255,13 +256,13 @@ def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
|
| 255 |
# а не просто логировать исключение и возвращать None в обоих случаях.
|
| 256 |
chat_id = 999601
|
| 257 |
state = {"history": [{"role": "user", "content": "привет"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 258 |
-
original_chats_dir =
|
| 259 |
-
|
| 260 |
try:
|
| 261 |
assert bot._save_chat_to_storage(chat_id, state) is True
|
| 262 |
assert (tmp_path / f"{chat_id}.json").exists()
|
| 263 |
finally:
|
| 264 |
-
|
| 265 |
|
| 266 |
|
| 267 |
def test_save_chat_to_storage_returns_false_on_failure():
|
|
@@ -272,14 +273,14 @@ def test_save_chat_to_storage_returns_false_on_failure():
|
|
| 272 |
|
| 273 |
def test_delete_chat_storage_returns_true_on_success(tmp_path):
|
| 274 |
chat_id = 999603
|
| 275 |
-
original_chats_dir =
|
| 276 |
-
|
| 277 |
try:
|
| 278 |
(tmp_path / f"{chat_id}.json").write_text("{}")
|
| 279 |
assert bot._delete_chat_storage(chat_id) is True
|
| 280 |
assert not (tmp_path / f"{chat_id}.json").exists()
|
| 281 |
finally:
|
| 282 |
-
|
| 283 |
|
| 284 |
|
| 285 |
def test_delete_chat_storage_returns_false_on_failure():
|
|
@@ -300,8 +301,10 @@ def test_flush_dirty_state_once_requeues_failed_saves():
|
|
| 300 |
bot.chat_state[fail_chat] = {"history": [{"role": "user", "content": "fail"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 301 |
bot._dirty_chat_ids.clear()
|
| 302 |
bot._dirty_chat_ids.update({ok_chat, fail_chat})
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
| 305 |
|
| 306 |
def fake_save(cid, state):
|
| 307 |
return cid != fail_chat # успех для ok_chat, неудача для fail_chat
|
|
@@ -326,8 +329,8 @@ def test_flush_dirty_state_once_requeues_failed_deletes():
|
|
| 326 |
bot._pending_chat_deletions.clear()
|
| 327 |
bot._pending_chat_deletions.update({ok_chat, fail_chat})
|
| 328 |
bot._dirty_chat_ids.clear()
|
| 329 |
-
|
| 330 |
-
|
| 331 |
|
| 332 |
def fake_delete(cid):
|
| 333 |
return cid != fail_chat
|
|
@@ -596,7 +599,7 @@ def test_find_recent_media_by_category_none_for_empty_bucket():
|
|
| 596 |
|
| 597 |
|
| 598 |
def test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover():
|
| 599 |
-
#
|
| 600 |
# _QUOTA_CHECK_THROTTLE_SEC) абсолютно безопасен, т.к. между реальными вызовами
|
| 601 |
# проходят настоящие секунды — но в тестах десятки вызовов _quota_entry (через
|
| 602 |
# ask_gemini/ask_openrouter_* в других тестах этого же файла) укладываются в
|
|
@@ -620,12 +623,12 @@ def test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover():
|
|
| 620 |
"openrouter": dict(bot.GLOBAL_QUOTA.get("openrouter", {})),
|
| 621 |
"quota_day": bot.GLOBAL_QUOTA.get("quota_day"),
|
| 622 |
}
|
| 623 |
-
original_throttle =
|
| 624 |
try:
|
| 625 |
bot.GLOBAL_QUOTA["gemini"] = {"gemini-2.5-flash": {"used": 106, "remaining": 0, "limit": 1500, "exhausted_at": 12345.0}}
|
| 626 |
bot.GLOBAL_QUOTA["openrouter"] = {"some-model:free": {"used": 50, "remaining": None, "limit": None, "exhausted_at": None}}
|
| 627 |
bot.GLOBAL_QUOTA["quota_day"] = "2020-01-01" # заведомо "вчерашний" день
|
| 628 |
-
|
| 629 |
|
| 630 |
bot._reset_quota_if_new_day()
|
| 631 |
|
|
@@ -637,24 +640,24 @@ def test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover():
|
|
| 637 |
bot.GLOBAL_QUOTA["gemini"] = original_quota["gemini"]
|
| 638 |
bot.GLOBAL_QUOTA["openrouter"] = original_quota["openrouter"]
|
| 639 |
bot.GLOBAL_QUOTA["quota_day"] = original_quota["quota_day"]
|
| 640 |
-
|
| 641 |
|
| 642 |
|
| 643 |
def test_reset_quota_if_new_day_is_noop_within_same_day():
|
| 644 |
original_quota_day = bot.GLOBAL_QUOTA.get("quota_day")
|
| 645 |
-
original_throttle =
|
| 646 |
try:
|
| 647 |
bot.GLOBAL_QUOTA["gemini"]["test-model-999"] = {"used": 7, "remaining": None, "limit": None, "exhausted_at": None}
|
| 648 |
bot.GLOBAL_QUOTA["quota_day"] = bot._current_quota_day()
|
| 649 |
# См. комментарий в test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover
|
| 650 |
# выше про то, почему буквальный 0.0 не годится как "точно давно".
|
| 651 |
-
|
| 652 |
bot._reset_quota_if_new_day()
|
| 653 |
assert bot.GLOBAL_QUOTA["gemini"]["test-model-999"]["used"] == 7
|
| 654 |
finally:
|
| 655 |
bot.GLOBAL_QUOTA["gemini"].pop("test-model-999", None)
|
| 656 |
bot.GLOBAL_QUOTA["quota_day"] = original_quota_day
|
| 657 |
-
|
| 658 |
|
| 659 |
|
| 660 |
def test_reset_quota_if_new_day_throttles_repeated_calls():
|
|
@@ -666,11 +669,11 @@ def test_reset_quota_if_new_day_throttles_repeated_calls():
|
|
| 666 |
#
|
| 667 |
# См. комментарий в test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover
|
| 668 |
# про то, почему сброс делается относительно time.monotonic(), а не в буквальный 0.0.
|
| 669 |
-
original_throttle =
|
| 670 |
calls = []
|
| 671 |
original_fn = bot._current_quota_day
|
| 672 |
try:
|
| 673 |
-
|
| 674 |
bot._current_quota_day = lambda: (calls.append(1), original_fn())[1]
|
| 675 |
bot._reset_quota_if_new_day()
|
| 676 |
bot._reset_quota_if_new_day()
|
|
@@ -678,7 +681,7 @@ def test_reset_quota_if_new_day_throttles_repeated_calls():
|
|
| 678 |
assert len(calls) == 1
|
| 679 |
finally:
|
| 680 |
bot._current_quota_day = original_fn
|
| 681 |
-
|
| 682 |
|
| 683 |
|
| 684 |
def test_serialize_chat_state_stamps_current_schema_version():
|
|
@@ -991,18 +994,18 @@ def test_maybe_alert_gemini_exhausted_throttled():
|
|
| 991 |
# on_day_rollover выше: time.monotonic() не гарантированно "далеко за" какой-то
|
| 992 |
# абсолютной точкой (в свежем процессе может быть близко к нулю) — "давно" нужно
|
| 993 |
# выражать относительно текущего time.monotonic(), а не буквальным 0.0.
|
| 994 |
-
original_last =
|
| 995 |
original_owner, original_bot = bot.OWNER_ID, bot.bot
|
| 996 |
bot.OWNER_ID = 12345
|
| 997 |
fake = _FakeOwnerBot()
|
| 998 |
bot.bot = fake
|
| 999 |
-
|
| 1000 |
try:
|
| 1001 |
asyncio.run(bot._maybe_alert_gemini_exhausted())
|
| 1002 |
asyncio.run(bot._maybe_alert_gemini_exhausted())
|
| 1003 |
assert len(fake.sent) == 1 # второй вызов сразу же — троттлинг не пускает повтор
|
| 1004 |
finally:
|
| 1005 |
-
|
| 1006 |
bot.OWNER_ID, bot.bot = original_owner, original_bot
|
| 1007 |
|
| 1008 |
|
|
|
|
| 10 |
import bot
|
| 11 |
import json
|
| 12 |
import logging
|
| 13 |
+
import lumen_chat_state
|
| 14 |
import lumen_limits
|
| 15 |
import sentry_sdk
|
| 16 |
import sys
|
|
|
|
| 256 |
# а не просто логировать исключение и возвращать None в обоих случаях.
|
| 257 |
chat_id = 999601
|
| 258 |
state = {"history": [{"role": "user", "content": "привет"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 259 |
+
original_chats_dir = lumen_chat_state._CHATS_DIR
|
| 260 |
+
lumen_chat_state._CHATS_DIR = tmp_path
|
| 261 |
try:
|
| 262 |
assert bot._save_chat_to_storage(chat_id, state) is True
|
| 263 |
assert (tmp_path / f"{chat_id}.json").exists()
|
| 264 |
finally:
|
| 265 |
+
lumen_chat_state._CHATS_DIR = original_chats_dir
|
| 266 |
|
| 267 |
|
| 268 |
def test_save_chat_to_storage_returns_false_on_failure():
|
|
|
|
| 273 |
|
| 274 |
def test_delete_chat_storage_returns_true_on_success(tmp_path):
|
| 275 |
chat_id = 999603
|
| 276 |
+
original_chats_dir = lumen_chat_state._CHATS_DIR
|
| 277 |
+
lumen_chat_state._CHATS_DIR = tmp_path
|
| 278 |
try:
|
| 279 |
(tmp_path / f"{chat_id}.json").write_text("{}")
|
| 280 |
assert bot._delete_chat_storage(chat_id) is True
|
| 281 |
assert not (tmp_path / f"{chat_id}.json").exists()
|
| 282 |
finally:
|
| 283 |
+
lumen_chat_state._CHATS_DIR = original_chats_dir
|
| 284 |
|
| 285 |
|
| 286 |
def test_delete_chat_storage_returns_false_on_failure():
|
|
|
|
| 301 |
bot.chat_state[fail_chat] = {"history": [{"role": "user", "content": "fail"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 302 |
bot._dirty_chat_ids.clear()
|
| 303 |
bot._dirty_chat_ids.update({ok_chat, fail_chat})
|
| 304 |
+
# Флаги живут в lumen_chat_state (P2) — ребиндим там же, где их читает
|
| 305 |
+
# _flush_dirty_state_once, иначе тест и код увидят разные значения.
|
| 306 |
+
lumen_chat_state._index_dirty = False
|
| 307 |
+
lumen_chat_state._quota_dirty = False
|
| 308 |
|
| 309 |
def fake_save(cid, state):
|
| 310 |
return cid != fail_chat # успех для ok_chat, неудача для fail_chat
|
|
|
|
| 329 |
bot._pending_chat_deletions.clear()
|
| 330 |
bot._pending_chat_deletions.update({ok_chat, fail_chat})
|
| 331 |
bot._dirty_chat_ids.clear()
|
| 332 |
+
lumen_chat_state._index_dirty = False
|
| 333 |
+
lumen_chat_state._quota_dirty = False
|
| 334 |
|
| 335 |
def fake_delete(cid):
|
| 336 |
return cid != fail_chat
|
|
|
|
| 599 |
|
| 600 |
|
| 601 |
def test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover():
|
| 602 |
+
# lumen_chat_state._last_quota_check_monotonic сбрасывается явно: в проде троттлинг (см.
|
| 603 |
# _QUOTA_CHECK_THROTTLE_SEC) абсолютно безопасен, т.к. между реальными вызовами
|
| 604 |
# проходят настоящие секунды — но в тестах десятки вызовов _quota_entry (через
|
| 605 |
# ask_gemini/ask_openrouter_* в других тестах этого же файла) укладываются в
|
|
|
|
| 623 |
"openrouter": dict(bot.GLOBAL_QUOTA.get("openrouter", {})),
|
| 624 |
"quota_day": bot.GLOBAL_QUOTA.get("quota_day"),
|
| 625 |
}
|
| 626 |
+
original_throttle = lumen_chat_state._last_quota_check_monotonic
|
| 627 |
try:
|
| 628 |
bot.GLOBAL_QUOTA["gemini"] = {"gemini-2.5-flash": {"used": 106, "remaining": 0, "limit": 1500, "exhausted_at": 12345.0}}
|
| 629 |
bot.GLOBAL_QUOTA["openrouter"] = {"some-model:free": {"used": 50, "remaining": None, "limit": None, "exhausted_at": None}}
|
| 630 |
bot.GLOBAL_QUOTA["quota_day"] = "2020-01-01" # заведомо "вчерашний" день
|
| 631 |
+
lumen_chat_state._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0
|
| 632 |
|
| 633 |
bot._reset_quota_if_new_day()
|
| 634 |
|
|
|
|
| 640 |
bot.GLOBAL_QUOTA["gemini"] = original_quota["gemini"]
|
| 641 |
bot.GLOBAL_QUOTA["openrouter"] = original_quota["openrouter"]
|
| 642 |
bot.GLOBAL_QUOTA["quota_day"] = original_quota["quota_day"]
|
| 643 |
+
lumen_chat_state._last_quota_check_monotonic = original_throttle
|
| 644 |
|
| 645 |
|
| 646 |
def test_reset_quota_if_new_day_is_noop_within_same_day():
|
| 647 |
original_quota_day = bot.GLOBAL_QUOTA.get("quota_day")
|
| 648 |
+
original_throttle = lumen_chat_state._last_quota_check_monotonic
|
| 649 |
try:
|
| 650 |
bot.GLOBAL_QUOTA["gemini"]["test-model-999"] = {"used": 7, "remaining": None, "limit": None, "exhausted_at": None}
|
| 651 |
bot.GLOBAL_QUOTA["quota_day"] = bot._current_quota_day()
|
| 652 |
# См. комментарий в test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover
|
| 653 |
# выше про то, почему буквальный 0.0 не годится как "точно давно".
|
| 654 |
+
lumen_chat_state._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0
|
| 655 |
bot._reset_quota_if_new_day()
|
| 656 |
assert bot.GLOBAL_QUOTA["gemini"]["test-model-999"]["used"] == 7
|
| 657 |
finally:
|
| 658 |
bot.GLOBAL_QUOTA["gemini"].pop("test-model-999", None)
|
| 659 |
bot.GLOBAL_QUOTA["quota_day"] = original_quota_day
|
| 660 |
+
lumen_chat_state._last_quota_check_monotonic = original_throttle
|
| 661 |
|
| 662 |
|
| 663 |
def test_reset_quota_if_new_day_throttles_repeated_calls():
|
|
|
|
| 669 |
#
|
| 670 |
# См. комментарий в test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover
|
| 671 |
# про то, почему сброс делается относительно time.monotonic(), а не в буквальный 0.0.
|
| 672 |
+
original_throttle = lumen_chat_state._last_quota_check_monotonic
|
| 673 |
calls = []
|
| 674 |
original_fn = bot._current_quota_day
|
| 675 |
try:
|
| 676 |
+
lumen_chat_state._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0
|
| 677 |
bot._current_quota_day = lambda: (calls.append(1), original_fn())[1]
|
| 678 |
bot._reset_quota_if_new_day()
|
| 679 |
bot._reset_quota_if_new_day()
|
|
|
|
| 681 |
assert len(calls) == 1
|
| 682 |
finally:
|
| 683 |
bot._current_quota_day = original_fn
|
| 684 |
+
lumen_chat_state._last_quota_check_monotonic = original_throttle
|
| 685 |
|
| 686 |
|
| 687 |
def test_serialize_chat_state_stamps_current_schema_version():
|
|
|
|
| 994 |
# on_day_rollover выше: time.monotonic() не гарантированно "далеко за" какой-то
|
| 995 |
# абсолютной точкой (в свежем процессе может быть близко к нулю) — "давно" нужно
|
| 996 |
# выражать относительно текущего time.monotonic(), а не буквальным 0.0.
|
| 997 |
+
original_last = lumen_chat_state._last_gemini_exhausted_alert_monotonic
|
| 998 |
original_owner, original_bot = bot.OWNER_ID, bot.bot
|
| 999 |
bot.OWNER_ID = 12345
|
| 1000 |
fake = _FakeOwnerBot()
|
| 1001 |
bot.bot = fake
|
| 1002 |
+
lumen_chat_state._last_gemini_exhausted_alert_monotonic = time.monotonic() - bot.GEMINI_EXHAUSTED_ALERT_COOLDOWN_SEC - 10.0
|
| 1003 |
try:
|
| 1004 |
asyncio.run(bot._maybe_alert_gemini_exhausted())
|
| 1005 |
asyncio.run(bot._maybe_alert_gemini_exhausted())
|
| 1006 |
assert len(fake.sent) == 1 # второй вызов сразу же — троттлинг не пускает повтор
|
| 1007 |
finally:
|
| 1008 |
+
lumen_chat_state._last_gemini_exhausted_alert_monotonic = original_last
|
| 1009 |
bot.OWNER_ID, bot.bot = original_owner, original_bot
|
| 1010 |
|
| 1011 |
|