Spaces:
Running
Running
SilverElixir commited on
Commit ·
7786593
1
Parent(s): 83fef4a
Medium wave 4: validated env numbers, Upstash backend and shutdown flush tests, deno check/lint in CI, reproducible pip step, docs for missing variables
Browse files- .github/workflows/ci.yml +7 -0
- Dockerfile +6 -4
- bot.py +45 -22
- docs/ENVIRONMENT.md +6 -1
- lumen_limits.py +16 -7
- lumen_tiktok.py +9 -1
- tests/test_bot_state.py +101 -0
.github/workflows/ci.yml
CHANGED
|
@@ -44,6 +44,13 @@ jobs:
|
|
| 44 |
|
| 45 |
- run: deno test proxy/proxy_test.ts
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
# проверка requirements.txt на известные CVE в зафиксированных версиях —
|
| 48 |
# requirements-dev.txt (pytest/pyflakes/pip-audit) не сканируем: это
|
| 49 |
# dev-инструменты, не попадающие в рантайм-образ (см. Dockerfile/.dockerignore)
|
|
|
|
| 44 |
|
| 45 |
- run: deno test proxy/proxy_test.ts
|
| 46 |
|
| 47 |
+
# Типы и линт прокси: до этого в CI не проверялось ничего, кроме тестов,
|
| 48 |
+
# поэтому @ts-ignore на duplex:"half" и опечатки в proxy.ts проходили молча
|
| 49 |
+
# (аудит 26.09.2026). deno check ловит и типы, и неиспользуемые импорты.
|
| 50 |
+
- run: deno check proxy/proxy.ts
|
| 51 |
+
|
| 52 |
+
- run: deno lint proxy/proxy.ts
|
| 53 |
+
|
| 54 |
# проверка requirements.txt на известные CVE в зафиксированных версиях —
|
| 55 |
# requirements-dev.txt (pytest/pyflakes/pip-audit) не сканируем: это
|
| 56 |
# dev-инструменты, не попадающие в рантайм-образ (см. Dockerfile/.dockerignore)
|
Dockerfile
CHANGED
|
@@ -7,8 +7,8 @@ FROM mwader/static-ffmpeg:9.0 AS ffmpeg
|
|
| 7 |
|
| 8 |
# ОБНОВЛЕНО (аудит зависимостей, 16 августа 2026): 3.10 -> 3.13 — requirements.txt
|
| 9 |
# и так уже требовали только "Python 3.10+" (не конкретно 3.10), все закреплённые
|
| 10 |
-
# версии зависимостей (aiogram 3.
|
| 11 |
-
# 0.
|
| 12 |
# под новой версией прошёл без единого изменения кода.
|
| 13 |
FROM python:3.13-slim
|
| 14 |
|
|
@@ -37,9 +37,11 @@ RUN apt-get update \
|
|
| 37 |
COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
|
| 38 |
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
COPY requirements.txt /app/requirements.txt
|
| 41 |
-
RUN pip install -
|
| 42 |
-
&& pip install -r /app/requirements.txt
|
| 43 |
|
| 44 |
COPY . /app
|
| 45 |
|
|
|
|
| 7 |
|
| 8 |
# ОБНОВЛЕНО (аудит зависимостей, 16 августа 2026): 3.10 -> 3.13 — requirements.txt
|
| 9 |
# и так уже требовали только "Python 3.10+" (не конкретно 3.10), все закреплённые
|
| 10 |
+
# версии зависимостей (aiogram 3.31, google-genai 2.24, fastapi 0.141, uvicorn
|
| 11 |
+
# 0.53 и т.д.) заявляют поддержку вплоть до 3.14 — полный прогон тестового сьюта
|
| 12 |
# под новой версией прошёл без единого изменения кода.
|
| 13 |
FROM python:3.13-slim
|
| 14 |
|
|
|
|
| 37 |
COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
|
| 38 |
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
|
| 39 |
|
| 40 |
+
# Без `pip install --upgrade pip`: версии инструмента не зафиксированы, а образ
|
| 41 |
+
# пересобирается с нуля на каждом деплое — такой шаг делает сборку невоспроизводимой
|
| 42 |
+
# ровно в том смысле, ради которого закреплён requirements.txt (аудит 26.09.2026).
|
| 43 |
COPY requirements.txt /app/requirements.txt
|
| 44 |
+
RUN pip install -r /app/requirements.txt
|
|
|
|
| 45 |
|
| 46 |
COPY . /app
|
| 47 |
|
bot.py
CHANGED
|
@@ -235,6 +235,29 @@ def _tikwm_proxy_candidates() -> list[str]:
|
|
| 235 |
return candidates
|
| 236 |
|
| 237 |
BOT_USERNAME = os.getenv("BOT_USERNAME", "LumenAI_bot").strip().lstrip("@")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
|
| 239 |
OPENROUTER_API_KEY = (os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_KEY") or "").strip()
|
| 240 |
# Referer пересчитываем в try_setup, если владелец не задал его явно: иначе t.me остался бы со заглушкой.
|
|
@@ -246,7 +269,7 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
| 246 |
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
|
| 247 |
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
| 248 |
# Голосовые длиннее не транскрибируем (Whisper API всё равно режет 25 МБ) — такие идут прежним путём через Gemini-аудио.
|
| 249 |
-
VOICE_TRANSCRIBE_MAX_BYTES =
|
| 250 |
# Раньше у OpenRouter был свой отдельный лимит истории (30), меньший, чем у Gemini
|
| 251 |
# (100) — при переключении провайдера (/provider или /model) ощущалось резкое
|
| 252 |
# "обнуление" контекста разговора. Теперь история ОБЩАЯ (см. state["history"] в
|
|
@@ -284,16 +307,16 @@ for env_name in ("OWNER_ID", "BOT_OWNER_ID", "ADMIN_ID", "TELEGRAM_OWNER_ID"):
|
|
| 284 |
OWNER_ID = int(val)
|
| 285 |
break
|
| 286 |
|
| 287 |
-
TELEGRAM_REQUEST_TIMEOUT =
|
| 288 |
-
TELEGRAM_AI_TIMEOUT =
|
| 289 |
-
TELEGRAM_MEDIA_TIMEOUT =
|
| 290 |
# Раньше было захардкожено как 15.0 прямо внутри _download_telegram_file_bytes —
|
| 291 |
# несогласованно с остальными таймаутами, которые все конфигурируются через env.
|
| 292 |
-
TELEGRAM_GET_FILE_TIMEOUT =
|
| 293 |
# Cooldown после HTML-мусора от прокси вместо JSON: без него десятки вызовов/сек валят лавину WARNING (см. _tg_call).
|
| 294 |
-
TELEGRAM_PROXY_COOLDOWN_SEC =
|
| 295 |
# Лимит ожидания следующего куска для любого провайдера: зависший стрим иначе держит лок чата бесконечно.
|
| 296 |
-
STREAM_CHUNK_TIMEOUT_SEC =
|
| 297 |
# ── Паттерн "живой печати" при стриминге (см. lumen_typing_pace.py и
|
| 298 |
# _run_streaming_reply ниже) ── Раньше во время стрима сообщение показывало РОВНО
|
| 299 |
# то, что успело накопиться с последнего edit_text — если бэкенд (особенно у
|
|
@@ -310,9 +333,9 @@ STREAM_CHUNK_TIMEOUT_SEC = float(os.getenv("STREAM_CHUNK_TIMEOUT_SEC", "30"))
|
|
| 310 |
# показан ещё не весь (см. catchup_reveal_steps) — произведение двух этих чисел
|
| 311 |
# ограничивает МАКСИМАЛЬНУЮ добавленную задержку сверху реальной скорости ответа,
|
| 312 |
# независимо от длины текста и точности оценки скорости.
|
| 313 |
-
STREAM_EDIT_MIN_INTERVAL_SEC =
|
| 314 |
-
STREAM_TYPING_TICK_SEC =
|
| 315 |
-
STREAM_TYPING_MAX_CATCHUP_TICKS =
|
| 316 |
# FIRST_CHUNK_TIMEOUT_SEC — пол ожидания первого куска стрима (см.
|
| 317 |
# lumen_model_speed.first_chunk_limit_sec): обычно-быстрая модель, зависшая
|
| 318 |
# разово, бросается рано (12–25с вместо полных 30). Честная оговорка: предел
|
|
@@ -320,7 +343,7 @@ STREAM_TYPING_MAX_CATCHUP_TICKS = int(os.getenv("STREAM_TYPING_MAX_CATCHUP_TICKS
|
|
| 320 |
# стоит STREAM_CHUNK_TIMEOUT_SEC на каждый кусок, включая первый, и для
|
| 321 |
# обычно-медленной модели первым сработает именно он. Итоговый предел первого
|
| 322 |
# куска — всегда минимум из двух.
|
| 323 |
-
FIRST_CHUNK_TIMEOUT_SEC =
|
| 324 |
# Анимация ожидания ("бегущие точки") в плейсхолдере, пока не пришёл первый
|
| 325 |
# кусок стрима: первые полсекунды висит статичное "…" (дешевле, чем дёргать
|
| 326 |
# API ради мгновенных ответов — их анимация вообще не касается), дальше —
|
|
@@ -338,16 +361,16 @@ _DOTS_FRAMES = (".", "..", "…")
|
|
| 338 |
RICH_MESSAGES_ENABLED = os.getenv("RICH_MESSAGES_ENABLED", "1") == "1"
|
| 339 |
# Лимит длины текста для /tts — без него пользователь мог отправить огромный
|
| 340 |
# текст, что вызывало бы очень долгий прогон Gemini TTS + ffmpeg на один запрос.
|
| 341 |
-
TTS_MAX_CHARS =
|
| 342 |
# Потолок одного синтеза TTS. Раньше его не было вообще: синхронный вызов Gemini
|
| 343 |
# уходил в поток без таймаута и без http_options у клиента, и зависший Google
|
| 344 |
# держал лок чата бесконечно (аудит 26.09.2026).
|
| 345 |
-
TTS_SYNTH_TIMEOUT_SEC =
|
| 346 |
_PROCESS_START_MONOTONIC = time.monotonic()
|
| 347 |
# ── Тайминги автоматического маршрутизатора моделей (см. секцию "автоматический
|
| 348 |
# выбор модели" ниже) ──
|
| 349 |
# Без ретраев одной модели: любая ошибка — сразу следующая в маршруте; общий бюджет ROUTE_TOTAL_BUDGET_SEC держит лок чата от минутного зависания.
|
| 350 |
-
ROUTE_MODEL_TIMEOUT_SEC =
|
| 351 |
# ROUTE_TOTAL_BUDGET_SEC — общий бюджет времени на ВЕСЬ маршрут одного сообщения,
|
| 352 |
# включая ОБА провайдера (Gemini и OpenRouter), если маршрут предполагает
|
| 353 |
# резервный переход между ними. Без этого потолка каскадный сбой сразу у многих
|
|
@@ -355,22 +378,22 @@ ROUTE_MODEL_TIMEOUT_SEC = float(os.getenv("ROUTE_MODEL_TIMEOUT_SEC", "22"))
|
|
| 355 |
# время удерживая лок чата (_chat_locks). При превышении бюджета дальнейшие
|
| 356 |
# попытки прекращаются и пользователь получает честное "сейчас всё перегружено"
|
| 357 |
# вместо тихого зависания.
|
| 358 |
-
ROUTE_TOTAL_BUDGET_SEC =
|
| 359 |
# HISTORY_SUMMARY_BUDGET_SEC — потолок саммаризации истории: без него два висящих
|
| 360 |
# backend-вызова подряд (Groq + OpenRouter по ROUTE_MODEL_TIMEOUT_SEC каждый) держали бы
|
| 361 |
# lock чата десятки секунд ПОСЛЕ готового ответа (найдено внешним аудитом).
|
| 362 |
-
HISTORY_SUMMARY_BUDGET_SEC =
|
| 363 |
# Общий бюджет /draw 120с: иначе 5 моделей × 90с давали до 7.5 мин висящего "Генерирую" (ревью 28.08.2026).
|
| 364 |
-
DRAW_TOTAL_BUDGET_SEC =
|
| 365 |
# CHAT_LOCK_TIMEOUT_SEC — сколько ждём лок чата, прежде чем ответить «занято».
|
| 366 |
# Обязан быть ВЫШЕ самой долгой защищаемой работы: /draw держит лок до
|
| 367 |
# DRAW_TOTAL_BUDGET_SEC, поэтому хардкод 45с отдавал «занято» посреди ещё идущей
|
| 368 |
# отрисовки, а в pick-кнопках стояло ещё и третье значение — 10с (аудит 26.09.2026).
|
| 369 |
# Одно имя на все три места, чтобы значения снова не разъехались.
|
| 370 |
-
CHAT_LOCK_TIMEOUT_SEC =
|
| 371 |
# INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC — сколько main() при остановке ждёт штатного
|
| 372 |
# завершения fire-and-forget задач перед отменой остатка (Sentry LUMEN-2: event loop убивал их посреди сетевых вызовов при редеплое).
|
| 373 |
-
INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC =
|
| 374 |
TG_MAX_LEN = 4096
|
| 375 |
# Upload-ботов Telegram режет 50 МБ — слишком большие варианты качества пропускаем до скачивания.
|
| 376 |
TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024
|
|
@@ -378,10 +401,10 @@ TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024
|
|
| 378 |
TIKTOK_SLIDESHOW_MAX_ITEMS = 35
|
| 379 |
TELEGRAM_MEDIA_GROUP_CHUNK = 10
|
| 380 |
# Лимит ffprobe/ffmpeg-процессов: без него слайдшоу кладёт CPU контейнера (ревью 28.08.2026).
|
| 381 |
-
TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY =
|
| 382 |
_tiktok_probe_semaphore = asyncio.Semaphore(TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY)
|
| 383 |
# Лимит скачивания слайдов 8: 35 слайдов иначе занимают весь пул сессии (limit=40) и стопорят другие чаты (аудит 04.09.2026).
|
| 384 |
-
TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY =
|
| 385 |
_tiktok_slide_download_semaphore = asyncio.Semaphore(TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY)
|
| 386 |
|
| 387 |
# Состояние/квоты/локи живут в lumen_chat_state.py (P2): здесь только реэкспорт
|
|
@@ -481,7 +504,7 @@ from lumen_telegram_transport import (
|
|
| 481 |
IPv4AiohttpSession,
|
| 482 |
)
|
| 483 |
|
| 484 |
-
TELEGRAM_PROXY_TRIP_THRESHOLD =
|
| 485 |
_tg_proxy_breaker = _TelegramProxyCircuitBreaker(cooldown_sec=TELEGRAM_PROXY_COOLDOWN_SEC, trip_threshold=TELEGRAM_PROXY_TRIP_THRESHOLD)
|
| 486 |
|
| 487 |
# конвертация markdown в html, утилиты json
|
|
|
|
| 235 |
return candidates
|
| 236 |
|
| 237 |
BOT_USERNAME = os.getenv("BOT_USERNAME", "LumenAI_bot").strip().lstrip("@")
|
| 238 |
+
|
| 239 |
+
def _env_number(name: str, default: float | int, *, cast: type = float, min_value: float | None = None) -> float | int:
|
| 240 |
+
"""Число из переменной окружения с внятной диагностикой.
|
| 241 |
+
|
| 242 |
+
Раньше все таймауты и лимиты читались голым int()/float(): опечатка в HF
|
| 243 |
+
Variable (например ROUTE_MODEL_TIMEOUT_SEC=22s или пустое значение) давала
|
| 244 |
+
голый ValueError на старте — то есть crashloop с невнятным логом вместо
|
| 245 |
+
понятного сообщения. Теперь мусор и выход за нижнюю границу заменяются
|
| 246 |
+
дефолтом с WARNING (бот продолжает работать), а не падают (аудит 26.09.2026).
|
| 247 |
+
"""
|
| 248 |
+
raw = os.getenv(name)
|
| 249 |
+
if raw is None or not str(raw).strip():
|
| 250 |
+
return cast(default)
|
| 251 |
+
try:
|
| 252 |
+
value = cast(str(raw).strip())
|
| 253 |
+
except (TypeError, ValueError):
|
| 254 |
+
log.warning("[setup] %s=%r is not a number — using default %s", name, raw, default)
|
| 255 |
+
return cast(default)
|
| 256 |
+
if min_value is not None and value < min_value:
|
| 257 |
+
log.warning("[setup] %s=%s is below the allowed minimum %s — using default %s", name, value, min_value, default)
|
| 258 |
+
return cast(default)
|
| 259 |
+
return value
|
| 260 |
+
|
| 261 |
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
|
| 262 |
OPENROUTER_API_KEY = (os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_KEY") or "").strip()
|
| 263 |
# Referer пересчитываем в try_setup, если владелец не задал его явно: иначе t.me остался бы со заглушкой.
|
|
|
|
| 269 |
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
|
| 270 |
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
| 271 |
# Голосовые длиннее не транскрибируем (Whisper API всё равно режет 25 МБ) — такие идут прежним путём через Gemini-аудио.
|
| 272 |
+
VOICE_TRANSCRIBE_MAX_BYTES = _env_number("VOICE_TRANSCRIBE_MAX_BYTES", 10 * 1024 * 1024, cast=int, min_value=1024)
|
| 273 |
# Раньше у OpenRouter был свой отдельный лимит истории (30), меньший, чем у Gemini
|
| 274 |
# (100) — при переключении провайдера (/provider или /model) ощущалось резкое
|
| 275 |
# "обнуление" контекста разговора. Теперь история ОБЩАЯ (см. state["history"] в
|
|
|
|
| 307 |
OWNER_ID = int(val)
|
| 308 |
break
|
| 309 |
|
| 310 |
+
TELEGRAM_REQUEST_TIMEOUT = _env_number("TELEGRAM_REQUEST_TIMEOUT", 45, min_value=1)
|
| 311 |
+
TELEGRAM_AI_TIMEOUT = _env_number("TELEGRAM_AI_TIMEOUT", 45, min_value=1)
|
| 312 |
+
TELEGRAM_MEDIA_TIMEOUT = _env_number("TELEGRAM_MEDIA_TIMEOUT", 25, min_value=1)
|
| 313 |
# Раньше было захардкожено как 15.0 прямо внутри _download_telegram_file_bytes —
|
| 314 |
# несогласованно с остальными таймаутами, которые все конфигурируются через env.
|
| 315 |
+
TELEGRAM_GET_FILE_TIMEOUT = _env_number("TELEGRAM_GET_FILE_TIMEOUT", 15, min_value=1)
|
| 316 |
# Cooldown после HTML-мусора от прокси вместо JSON: без него десятки вызовов/сек валят лавину WARNING (см. _tg_call).
|
| 317 |
+
TELEGRAM_PROXY_COOLDOWN_SEC = _env_number("TELEGRAM_PROXY_COOLDOWN_SEC", 20, min_value=0)
|
| 318 |
# Лимит ожидания следующего куска для любого провайдера: зависший стрим иначе держит лок чата бесконечно.
|
| 319 |
+
STREAM_CHUNK_TIMEOUT_SEC = _env_number("STREAM_CHUNK_TIMEOUT_SEC", 30, min_value=1)
|
| 320 |
# ── Паттерн "живой печати" при стриминге (см. lumen_typing_pace.py и
|
| 321 |
# _run_streaming_reply ниже) ── Раньше во время стрима сообщение показывало РОВНО
|
| 322 |
# то, что успело накопиться с последнего edit_text — если бэкенд (особенно у
|
|
|
|
| 333 |
# показан ещё не весь (см. catchup_reveal_steps) — произведение двух этих чисел
|
| 334 |
# ограничивает МАКСИМАЛЬНУЮ добавленную задержку сверху реальной скорости ответа,
|
| 335 |
# независимо от длины текста и точности оценки скорости.
|
| 336 |
+
STREAM_EDIT_MIN_INTERVAL_SEC = _env_number("STREAM_EDIT_MIN_INTERVAL_SEC", 1.2, min_value=0)
|
| 337 |
+
STREAM_TYPING_TICK_SEC = _env_number("STREAM_TYPING_TICK_SEC", 0.5, min_value=0)
|
| 338 |
+
STREAM_TYPING_MAX_CATCHUP_TICKS = _env_number("STREAM_TYPING_MAX_CATCHUP_TICKS", 6, cast=int, min_value=0)
|
| 339 |
# FIRST_CHUNK_TIMEOUT_SEC — пол ожидания первого куска стрима (см.
|
| 340 |
# lumen_model_speed.first_chunk_limit_sec): обычно-быстрая модель, зависшая
|
| 341 |
# разово, бросается рано (12–25с вместо полных 30). Честная оговорка: предел
|
|
|
|
| 343 |
# стоит STREAM_CHUNK_TIMEOUT_SEC на каждый кусок, включая первый, и для
|
| 344 |
# обычно-медленной модели первым сработает именно он. Итоговый предел первого
|
| 345 |
# куска — всегда минимум из двух.
|
| 346 |
+
FIRST_CHUNK_TIMEOUT_SEC = _env_number("FIRST_CHUNK_TIMEOUT_SEC", 12, min_value=1)
|
| 347 |
# Анимация ожидания ("бегущие точки") в плейсхолдере, пока не пришёл первый
|
| 348 |
# кусок стрима: первые полсекунды висит статичное "…" (дешевле, чем дёргать
|
| 349 |
# API ради мгновенных ответов — их анимация вообще не касается), дальше —
|
|
|
|
| 361 |
RICH_MESSAGES_ENABLED = os.getenv("RICH_MESSAGES_ENABLED", "1") == "1"
|
| 362 |
# Лимит длины текста для /tts — без него пользователь мог отправить огромный
|
| 363 |
# текст, что вызывало бы очень долгий прогон Gemini TTS + ffmpeg на один запрос.
|
| 364 |
+
TTS_MAX_CHARS = _env_number("TTS_MAX_CHARS", 800, cast=int, min_value=1)
|
| 365 |
# Потолок одного синтеза TTS. Раньше его не было вообще: синхронный вызов Gemini
|
| 366 |
# уходил в поток без таймаута и без http_options у клиента, и зависший Google
|
| 367 |
# держал лок чата бесконечно (аудит 26.09.2026).
|
| 368 |
+
TTS_SYNTH_TIMEOUT_SEC = _env_number("TTS_SYNTH_TIMEOUT_SEC", 60, min_value=1)
|
| 369 |
_PROCESS_START_MONOTONIC = time.monotonic()
|
| 370 |
# ── Тайминги автоматического маршрутизатора моделей (см. секцию "автоматический
|
| 371 |
# выбор модели" ниже) ──
|
| 372 |
# Без ретраев одной модели: любая ошибка — сразу следующая в маршруте; общий бюджет ROUTE_TOTAL_BUDGET_SEC держит лок чата от минутного зависания.
|
| 373 |
+
ROUTE_MODEL_TIMEOUT_SEC = _env_number("ROUTE_MODEL_TIMEOUT_SEC", 22, min_value=1)
|
| 374 |
# ROUTE_TOTAL_BUDGET_SEC — общий бюджет времени на ВЕСЬ маршрут одного сообщения,
|
| 375 |
# включая ОБА провайдера (Gemini и OpenRouter), если маршрут предполагает
|
| 376 |
# резервный переход между ними. Без этого потолка каскадный сбой сразу у многих
|
|
|
|
| 378 |
# время удерживая лок чата (_chat_locks). При превышении бюджета дальнейшие
|
| 379 |
# попытки прекращаются и пользователь получает честное "сейчас всё перегружено"
|
| 380 |
# вместо тихого зависания.
|
| 381 |
+
ROUTE_TOTAL_BUDGET_SEC = _env_number("ROUTE_TOTAL_BUDGET_SEC", 40, min_value=1)
|
| 382 |
# HISTORY_SUMMARY_BUDGET_SEC — потолок саммаризации истории: без него два висящих
|
| 383 |
# backend-вызова подряд (Groq + OpenRouter по ROUTE_MODEL_TIMEOUT_SEC каждый) держали бы
|
| 384 |
# lock чата десятки секунд ПОСЛЕ готового ответа (найдено внешним аудитом).
|
| 385 |
+
HISTORY_SUMMARY_BUDGET_SEC = _env_number("HISTORY_SUMMARY_BUDGET_SEC", 30, min_value=1)
|
| 386 |
# Общий бюджет /draw 120с: иначе 5 моделей × 90с давали до 7.5 мин висящего "Генерирую" (ревью 28.08.2026).
|
| 387 |
+
DRAW_TOTAL_BUDGET_SEC = _env_number("DRAW_TOTAL_BUDGET_SEC", 120, min_value=1)
|
| 388 |
# CHAT_LOCK_TIMEOUT_SEC — сколько ждём лок чата, прежде чем ответить «занято».
|
| 389 |
# Обязан быть ВЫШЕ самой долгой защищаемой работы: /draw держит лок до
|
| 390 |
# DRAW_TOTAL_BUDGET_SEC, поэтому хардкод 45с отдавал «занято» посреди ещё идущей
|
| 391 |
# отрисовки, а в pick-кнопках стояло ещё и третье значение — 10с (аудит 26.09.2026).
|
| 392 |
# Одно имя на все три места, чтобы значения снова не разъехались.
|
| 393 |
+
CHAT_LOCK_TIMEOUT_SEC = _env_number("CHAT_LOCK_TIMEOUT_SEC", 150, min_value=1)
|
| 394 |
# INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC — сколько main() при остановке ждёт штатного
|
| 395 |
# завершения fire-and-forget задач перед отменой остатка (Sentry LUMEN-2: event loop убивал их посреди сетевых вызовов при редеплое).
|
| 396 |
+
INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC = _env_number("INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC", 10, min_value=0)
|
| 397 |
TG_MAX_LEN = 4096
|
| 398 |
# Upload-ботов Telegram режет 50 МБ — слишком большие варианты качества пропускаем до скачивания.
|
| 399 |
TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024
|
|
|
|
| 401 |
TIKTOK_SLIDESHOW_MAX_ITEMS = 35
|
| 402 |
TELEGRAM_MEDIA_GROUP_CHUNK = 10
|
| 403 |
# Лимит ffprobe/ffmpeg-процессов: без него слайдшоу кладёт CPU контейнера (ревью 28.08.2026).
|
| 404 |
+
TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY = _env_number("TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY", 4, cast=int, min_value=1)
|
| 405 |
_tiktok_probe_semaphore = asyncio.Semaphore(TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY)
|
| 406 |
# Лимит скачивания слайдов 8: 35 слайдов иначе занимают весь пул сессии (limit=40) и стопорят другие чаты (аудит 04.09.2026).
|
| 407 |
+
TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY = _env_number("TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY", 8, cast=int, min_value=1)
|
| 408 |
_tiktok_slide_download_semaphore = asyncio.Semaphore(TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY)
|
| 409 |
|
| 410 |
# Состояние/квоты/локи живут в lumen_chat_state.py (P2): здесь только реэкспорт
|
|
|
|
| 504 |
IPv4AiohttpSession,
|
| 505 |
)
|
| 506 |
|
| 507 |
+
TELEGRAM_PROXY_TRIP_THRESHOLD = _env_number("TELEGRAM_PROXY_TRIP_THRESHOLD", 3, cast=int, min_value=1)
|
| 508 |
_tg_proxy_breaker = _TelegramProxyCircuitBreaker(cooldown_sec=TELEGRAM_PROXY_COOLDOWN_SEC, trip_threshold=TELEGRAM_PROXY_TRIP_THRESHOLD)
|
| 509 |
|
| 510 |
# конвертация markdown в html, утилиты json
|
docs/ENVIRONMENT.md
CHANGED
|
@@ -78,7 +78,10 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
|
|
| 78 |
| `HISTORY_SUMMARY_BUDGET_SEC` | `30s` | Cap for history summarization on overflow; beyond it the history is cut plainly instead of holding the chat lock. |
|
| 79 |
| `RICH_MESSAGES_ENABLED` | `1` | Send final answers via `sendRichMessage` (Bot API 10.1+: real tables, headings, math). Any failure falls back to plain HTML automatically; streaming edits always use HTML. Set to `0` (plus restart) to force legacy HTML if rendering breaks on old clients. |
|
| 80 |
| `PICK_BUTTONS_ENABLED` | `1` | Show clarifying buttons for short taste requests without details ("посоветуй фильм"). Set to `0` (plus restart) to answer such requests as plain text instead. |
|
| 81 |
-
| `PICK_TTL_SEC` | `300s` | How long pick buttons stay valid. Expired
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
## TikTok downloader
|
| 84 |
|
|
@@ -106,6 +109,8 @@ Setup: create a free database at [upstash.com](https://upstash.com), grab the RE
|
|
| 106 |
| `SENTRY_DSN` | — | Enables Sentry error tracking if set. Every event is scrubbed of known secrets (`BOT_TOKEN`, API keys, `WEBHOOK_SECRET`, etc.) before it's sent. |
|
| 107 |
| `BOT_LOG_PATH` | `/app/bot.log` | Log file path. Mainly relevant for tests; leave it alone in production. |
|
| 108 |
| `LOG_LEVEL` | `INFO` | Standard logging level (`DEBUG`/`INFO`/`WARNING`/...). |
|
|
|
|
|
|
|
| 109 |
|
| 110 |
Sentry setup: create a free Python project at [sentry.io](https://sentry.io) (Developer tier: 5,000 events/month), copy the DSN from the project settings, and add it as a Space secret.
|
| 111 |
|
|
|
|
| 78 |
| `HISTORY_SUMMARY_BUDGET_SEC` | `30s` | Cap for history summarization on overflow; beyond it the history is cut plainly instead of holding the chat lock. |
|
| 79 |
| `RICH_MESSAGES_ENABLED` | `1` | Send final answers via `sendRichMessage` (Bot API 10.1+: real tables, headings, math). Any failure falls back to plain HTML automatically; streaming edits always use HTML. Set to `0` (plus restart) to force legacy HTML if rendering breaks on old clients. |
|
| 80 |
| `PICK_BUTTONS_ENABLED` | `1` | Show clarifying buttons for short taste requests without details ("посоветуй фильм"). Set to `0` (plus restart) to answer such requests as plain text instead. |
|
| 81 |
+
| `PICK_TTL_SEC` | `300s` | How long pick buttons stay valid. Expired taps on a still-known request reissue the buttons once; anything older answers with a "buttons expired, write in text" notice. |
|
| 82 |
+
| `MAX_PENDING_PICKS` | `500` | Cap on simultaneously stored pick-button questions; the oldest are dropped past it (memory only, they do not survive a restart). |
|
| 83 |
+
| `MAX_RATE_LIMIT_KEYS` | `20000` | Cap on tracked per-user rate-limit keys; prevents unbounded growth of the in-memory tracker. |
|
| 84 |
+
| `INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC` | `10s` | How long shutdown waits for in-flight update tasks before cancelling the rest. |
|
| 85 |
|
| 86 |
## TikTok downloader
|
| 87 |
|
|
|
|
| 109 |
| `SENTRY_DSN` | — | Enables Sentry error tracking if set. Every event is scrubbed of known secrets (`BOT_TOKEN`, API keys, `WEBHOOK_SECRET`, etc.) before it's sent. |
|
| 110 |
| `BOT_LOG_PATH` | `/app/bot.log` | Log file path. Mainly relevant for tests; leave it alone in production. |
|
| 111 |
| `LOG_LEVEL` | `INFO` | Standard logging level (`DEBUG`/`INFO`/`WARNING`/...). |
|
| 112 |
+
| `DIAG_TOTAL_BUDGET_SEC` | `25s` | Total budget for the `/diag` network check across all probed hosts, so a hanging host cannot hang the diagnostic. |
|
| 113 |
+
| `ADMIN_SECRET_SEED` | — | Secret seed for deriving `WEBHOOK_SECRET` and `ADMIN_PANEL_KEY`. **Set it.** Without it both keys are derived from `BOT_TOKEN`, so a leaked token also exposes `/export_state` and `/webhook` (the bot logs a warning at startup). Any change here rotates both keys; `WEBHOOK_SECRET` re-registers itself, `ADMIN_PANEL_KEY` is retrieved with `GET /admin_keys` (Bearer `BOT_TOKEN`). |
|
| 114 |
|
| 115 |
Sentry setup: create a free Python project at [sentry.io](https://sentry.io) (Developer tier: 5,000 events/month), copy the DSN from the project settings, and add it as a Space secret.
|
| 116 |
|
lumen_limits.py
CHANGED
|
@@ -7,14 +7,23 @@ lumen_limits.py — скользящее окно rate limit и очередь
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
import os
|
| 11 |
import time
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
-
# Простой трекер для rate limiting
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
user_rate_limits: dict[int, list[float]] = {}
|
| 19 |
|
| 20 |
|
|
@@ -53,8 +62,8 @@ def _check_and_register_rate_limit(user_id: int | None) -> bool:
|
|
| 53 |
return False
|
| 54 |
|
| 55 |
|
| 56 |
-
PICK_TTL_SEC =
|
| 57 |
-
MAX_PENDING_PICKS =
|
| 58 |
# Только в памяти: после рестарта кнопки честно протухают, это штатно.
|
| 59 |
_pending_picks: dict[str, dict[str, Any]] = {}
|
| 60 |
|
|
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import time
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
+
# Простой трекер для rate limiting.
|
| 14 |
+
# Числа читаем через bot._env_number (импорт отложенный: bot импортирует этот
|
| 15 |
+
# модуль) — опечатка в переменной окружения не должна ронять бот на старте
|
| 16 |
+
# (аудит 26.09.2026).
|
| 17 |
+
def _env_number(name: str, default: float | int, *, cast: type = float, min_value: float | None = None) -> float | int:
|
| 18 |
+
try:
|
| 19 |
+
import bot
|
| 20 |
+
return bot._env_number(name, default, cast=cast, min_value=min_value)
|
| 21 |
+
except Exception:
|
| 22 |
+
return cast(default)
|
| 23 |
+
|
| 24 |
+
RATE_LIMIT_MAX_REQUESTS = _env_number("RATE_LIMIT_MAX_REQUESTS", 5, cast=int, min_value=1)
|
| 25 |
+
RATE_LIMIT_WINDOW_SEC = _env_number("RATE_LIMIT_WINDOW_SEC", 30, min_value=1)
|
| 26 |
+
MAX_RATE_LIMIT_KEYS = _env_number("MAX_RATE_LIMIT_KEYS", 20000, cast=int, min_value=100)
|
| 27 |
user_rate_limits: dict[int, list[float]] = {}
|
| 28 |
|
| 29 |
|
|
|
|
| 62 |
return False
|
| 63 |
|
| 64 |
|
| 65 |
+
PICK_TTL_SEC = _env_number("PICK_TTL_SEC", 300, min_value=1)
|
| 66 |
+
MAX_PENDING_PICKS = _env_number("MAX_PENDING_PICKS", 500, cast=int, min_value=1)
|
| 67 |
# Только в памяти: после рестарта кнопки честно протухают, это штатно.
|
| 68 |
_pending_picks: dict[str, dict[str, Any]] = {}
|
| 69 |
|
lumen_tiktok.py
CHANGED
|
@@ -249,7 +249,15 @@ def _write_mp3_tags(path: str, title: str, artist: str, cover: bytes | None) ->
|
|
| 249 |
|
| 250 |
# ── ffmpeg и скачивание бинарных URL ──
|
| 251 |
# Лимит размера (аудит 04.09.2026): слайды качаются до 35 параллельно — читаем потоково с капом.рвём свыше лимита.
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
|
| 255 |
# ── SSRF-гард для _download_url_bin ──
|
|
|
|
| 249 |
|
| 250 |
# ── ffmpeg и скачивание бинарных URL ──
|
| 251 |
# Лимит размера (аудит 04.09.2026): слайды качаются до 35 параллельно — читаем потоково с капом.рвём свыше лимита.
|
| 252 |
+
# Число из окружения — через bot._env_number (мусор в переменной не должен ронять
|
| 253 |
+
# старт; отложенный импорт, потому что bot.py импортирует этот модуль).
|
| 254 |
+
def _env_number_or_default(name: str, default: int) -> int:
|
| 255 |
+
try:
|
| 256 |
+
import bot
|
| 257 |
+
return int(bot._env_number(name, default, cast=int, min_value=1024))
|
| 258 |
+
except Exception:
|
| 259 |
+
return default
|
| 260 |
+
TIKTOK_DOWNLOAD_MAX_BYTES = _env_number_or_default("TIKTOK_DOWNLOAD_MAX_BYTES", 75 * 1024 * 1024)
|
| 261 |
|
| 262 |
|
| 263 |
# ── SSRF-гард для _download_url_bin ──
|
tests/test_bot_state.py
CHANGED
|
@@ -264,6 +264,47 @@ def test_upstash_get_parses_result_field():
|
|
| 264 |
bot.UPSTASH_REDIS_REST_TOKEN = ""
|
| 265 |
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
| 268 |
# Регрессия на найденный при код-ревью баг (см. test_flush_dirty_state_once_*
|
| 269 |
# ниже): функция теперь ДОЛЖНА сигнализировать успех/неудачу вызывающему коду,
|
|
@@ -631,6 +672,66 @@ def test_process_media_group_buffers_holds_chat_lock():
|
|
| 631 |
bot.chat_state.pop(chat_id, None)
|
| 632 |
|
| 633 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
def test_prune_old_chats_never_drops_a_chat_with_held_lock():
|
| 635 |
# Регрессия (аудит 26.09.2026): чат с ЗАНЯТЫМ локом вытеснялся из памяти
|
| 636 |
# вместе с локом — идущий маршрут писал в отвязанный state, а следующее
|
|
|
|
| 264 |
bot.UPSTASH_REDIS_REST_TOKEN = ""
|
| 265 |
|
| 266 |
|
| 267 |
+
def test_upstash_delete_hits_del_endpoint():
|
| 268 |
+
# Ветка use_upstash=True (_storage_delete_text) и _upstash_delete не были
|
| 269 |
+
# проверены ни одним тестом, хотя это весь прод-бэкенд (аудит 26.09.2026).
|
| 270 |
+
import lumen_state_storage as lss
|
| 271 |
+
calls = []
|
| 272 |
+
with patch.object(lss, "_upstash_request", side_effect=lambda url, token, path, **kw: calls.append((path, kw))):
|
| 273 |
+
lss._upstash_delete("https://fake", "tok", "lumen:chat:42")
|
| 274 |
+
assert calls == [("del/lumen%3Achat%3A42", {"method": "POST"})]
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def test_storage_write_read_delete_use_upstash_branch(tmp_path):
|
| 278 |
+
# Тот же пробел: ветки use_upstash=True в _storage_write_text/_storage_read_text/
|
| 279 |
+
# _storage_delete_text шли мимо тестов. Проверяем, что они реально дергают
|
| 280 |
+
# Upstash и НЕ трогают локальные файлы.
|
| 281 |
+
import lumen_state_storage as lss
|
| 282 |
+
cfg = lss.StorageConfig(
|
| 283 |
+
use_upstash=True, upstash_url="https://fake", upstash_token="tok", chats_dir=tmp_path,
|
| 284 |
+
)
|
| 285 |
+
seen = {}
|
| 286 |
+
|
| 287 |
+
def fake_set(url, token, key, value):
|
| 288 |
+
seen["set"] = (url, token, key, value)
|
| 289 |
+
|
| 290 |
+
def fake_get(url, token, key):
|
| 291 |
+
seen["get"] = (url, token, key)
|
| 292 |
+
return '{"history": []}'
|
| 293 |
+
|
| 294 |
+
def fake_delete(url, token, key):
|
| 295 |
+
seen["delete"] = (url, token, key)
|
| 296 |
+
|
| 297 |
+
path = tmp_path / "1.json"
|
| 298 |
+
with patch.object(lss, "_upstash_set", fake_set), patch.object(lss, "_upstash_get", fake_get), patch.object(lss, "_upstash_delete", fake_delete):
|
| 299 |
+
lss._storage_write_text(cfg, "lumen:chat:1", path, '{"a": 1}')
|
| 300 |
+
assert lss._storage_read_text(cfg, "lumen:chat:1", path) == '{"history": []}'
|
| 301 |
+
lss._storage_delete_text(cfg, "lumen:chat:1", path)
|
| 302 |
+
assert seen["set"] == ("https://fake", "tok", "lumen:chat:1", '{"a": 1}')
|
| 303 |
+
assert seen["get"] == ("https://fake", "tok", "lumen:chat:1")
|
| 304 |
+
assert seen["delete"] == ("https://fake", "tok", "lumen:chat:1")
|
| 305 |
+
assert not path.exists(), "Upstash-ветка не должна создавать локальные файлы"
|
| 306 |
+
|
| 307 |
+
|
| 308 |
def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
| 309 |
# Регрессия на найденный при код-ревью баг (см. test_flush_dirty_state_once_*
|
| 310 |
# ниже): функция теперь ДОЛЖНА сигнализировать успех/неудачу вызывающему коду,
|
|
|
|
| 672 |
bot.chat_state.pop(chat_id, None)
|
| 673 |
|
| 674 |
|
| 675 |
+
def test_env_number_falls_back_on_garbage_and_keeps_valid(monkeypatch, caplog):
|
| 676 |
+
# Регрессия (аудит 26.09.2026): опечатка в HF Variable (пустое значение или
|
| 677 |
+
# "22s") роняла бот на старте голым ValueError. Теперь — дефолт + WARNING.
|
| 678 |
+
import logging
|
| 679 |
+
monkeypatch.setenv("LUMEN_PROBE_NUM", "22s")
|
| 680 |
+
with caplog.at_level(logging.WARNING, logger="bot"):
|
| 681 |
+
assert bot._env_number("LUMEN_PROBE_NUM", 22, min_value=1) == 22
|
| 682 |
+
assert any("LUMEN_PROBE_NUM" in r.getMessage() for r in caplog.records)
|
| 683 |
+
|
| 684 |
+
monkeypatch.setenv("LUMEN_PROBE_NUM", "5")
|
| 685 |
+
assert bot._env_number("LUMEN_PROBE_NUM", 22, min_value=1) == 5
|
| 686 |
+
|
| 687 |
+
monkeypatch.setenv("LUMEN_PROBE_NUM", " ")
|
| 688 |
+
assert bot._env_number("LUMEN_PROBE_NUM", 22, min_value=1) == 22
|
| 689 |
+
|
| 690 |
+
monkeypatch.setenv("LUMEN_PROBE_NUM", "-3")
|
| 691 |
+
assert bot._env_number("LUMEN_PROBE_NUM", 22, min_value=1) == 22
|
| 692 |
+
|
| 693 |
+
monkeypatch.delenv("LUMEN_PROBE_NUM", raising=False)
|
| 694 |
+
assert bot._env_number("LUMEN_PROBE_NUM", 22, min_value=1) == 22
|
| 695 |
+
assert bot._env_number("LUMEN_PROBE_NUM", 7, cast=int, min_value=1) == 7
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def test_flush_state_now_writes_everything_on_shutdown(tmp_path):
|
| 699 |
+
# _flush_state_now (финальный синхронный сброс при остановке) не был покрыт
|
| 700 |
+
# ни одним тестом: именно он спасает несохранённые изменения между последним
|
| 701 |
+
# тиком и остановкой контейнера (аудит 26.09.2026).
|
| 702 |
+
import lumen_chat_state as lcs
|
| 703 |
+
chat_id = 999701
|
| 704 |
+
original_chats_dir = lcs._CHATS_DIR
|
| 705 |
+
lcs._CHATS_DIR = tmp_path
|
| 706 |
+
written = []
|
| 707 |
+
original_write = bot._storage_write_text
|
| 708 |
+
original_save = bot._save_chat_to_storage
|
| 709 |
+
original_delete = bot._delete_chat_storage
|
| 710 |
+
bot._storage_write_text = lambda key, path, text: written.append((key, text)) or True
|
| 711 |
+
bot._save_chat_to_storage = lambda cid, state: written.append((f"chat:{cid}", "")) or True
|
| 712 |
+
bot._delete_chat_storage = lambda cid: written.append((f"delete:{cid}", "")) or True
|
| 713 |
+
lcs.chat_state[chat_id] = {"history": [{"role": "user", "content": "hi"}], "last_activity": 0.0}
|
| 714 |
+
lcs._dirty_chat_ids.add(chat_id)
|
| 715 |
+
lcs._pending_chat_deletions.add(999999)
|
| 716 |
+
lcs._index_dirty = True
|
| 717 |
+
try:
|
| 718 |
+
bot._flush_state_now()
|
| 719 |
+
keys = [k for k, _ in written]
|
| 720 |
+
assert f"chat:{chat_id}" in keys, "грязный чат должен сохраниться при остановке"
|
| 721 |
+
assert "delete:999999" in keys, "очередь удалений должна быть применена"
|
| 722 |
+
assert "lumen:chat_index" in keys or any("chat_index" in k for k in keys), "индекс должен сохраниться"
|
| 723 |
+
assert 999999 not in lcs._pending_chat_deletions
|
| 724 |
+
assert chat_id not in lcs._dirty_chat_ids
|
| 725 |
+
finally:
|
| 726 |
+
bot._storage_write_text = original_write
|
| 727 |
+
bot._save_chat_to_storage = original_save
|
| 728 |
+
bot._delete_chat_storage = original_delete
|
| 729 |
+
lcs._CHATS_DIR = original_chats_dir
|
| 730 |
+
lcs.chat_state.pop(chat_id, None)
|
| 731 |
+
lcs._pending_chat_deletions.discard(999999)
|
| 732 |
+
lcs._index_dirty = False
|
| 733 |
+
|
| 734 |
+
|
| 735 |
def test_prune_old_chats_never_drops_a_chat_with_held_lock():
|
| 736 |
# Регрессия (аудит 26.09.2026): чат с ЗАНЯТЫМ локом вытеснялся из памяти
|
| 737 |
# вместе с локом — идущий маршрут писал в отвязанный state, а следующее
|