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
Files changed (3) hide show
  1. bot.py +107 -616
  2. lumen_chat_state.py +693 -0
  3. 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, TypedDict
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
- MAX_CHAT_LIMIT = 5000
510
- PRUNED_CHAT_TARGET = 4500
511
- MAX_CHAT_HISTORY_LEN = 100
512
- MAX_MEDIA_RECENT_IDS = 8 # хранится ОТДЕЛЬНО на каждого пользователя чата (см. recent_media_ids: dict[user_id, deque])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if _dirty_chat_ids or _index_dirty or _pending_chat_deletions:
 
 
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 = bot._CHATS_DIR
259
- bot._CHATS_DIR = tmp_path
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
- bot._CHATS_DIR = original_chats_dir
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 = bot._CHATS_DIR
276
- bot._CHATS_DIR = tmp_path
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
- bot._CHATS_DIR = original_chats_dir
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
- bot._index_dirty = False
304
- bot._quota_dirty = False
 
 
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
- bot._index_dirty = False
330
- bot._quota_dirty = False
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
- # bot._last_quota_check_monotonic сбрасывается явно: в проде троттлинг (см.
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 = bot._last_quota_check_monotonic
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
- bot._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0
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
- bot._last_quota_check_monotonic = original_throttle
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 = bot._last_quota_check_monotonic
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
- bot._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0
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
- bot._last_quota_check_monotonic = original_throttle
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 = bot._last_quota_check_monotonic
670
  calls = []
671
  original_fn = bot._current_quota_day
672
  try:
673
- bot._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0
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
- bot._last_quota_check_monotonic = original_throttle
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 = bot._last_gemini_exhausted_alert_monotonic
995
  original_owner, original_bot = bot.OWNER_ID, bot.bot
996
  bot.OWNER_ID = 12345
997
  fake = _FakeOwnerBot()
998
  bot.bot = fake
999
- bot._last_gemini_exhausted_alert_monotonic = time.monotonic() - bot.GEMINI_EXHAUSTED_ALERT_COOLDOWN_SEC - 10.0
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
- bot._last_gemini_exhausted_alert_monotonic = original_last
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