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 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.30, google-genai 2.18, fastapi 0.141, uvicorn
11
- # 0.52 и т.д.) заявляют поддержку вплоть до 3.14 — полный прогон тестового сьюта
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 --upgrade pip \
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 = int(os.getenv("VOICE_TRANSCRIBE_MAX_BYTES", str(10 * 1024 * 1024)))
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 = float(os.getenv("TELEGRAM_REQUEST_TIMEOUT", "45"))
288
- TELEGRAM_AI_TIMEOUT = float(os.getenv("TELEGRAM_AI_TIMEOUT", "45"))
289
- TELEGRAM_MEDIA_TIMEOUT = float(os.getenv("TELEGRAM_MEDIA_TIMEOUT", "25"))
290
  # Раньше было захардкожено как 15.0 прямо внутри _download_telegram_file_bytes —
291
  # несогласованно с остальными таймаутами, которые все конфигурируются через env.
292
- TELEGRAM_GET_FILE_TIMEOUT = float(os.getenv("TELEGRAM_GET_FILE_TIMEOUT", "15"))
293
  # Cooldown после HTML-мусора от прокси вместо JSON: без него десятки вызовов/сек валят лавину WARNING (см. _tg_call).
294
- TELEGRAM_PROXY_COOLDOWN_SEC = float(os.getenv("TELEGRAM_PROXY_COOLDOWN_SEC", "20"))
295
  # Лимит ожидания следующего куска для любого провайдера: зависший стрим иначе держит лок чата бесконечно.
296
- STREAM_CHUNK_TIMEOUT_SEC = float(os.getenv("STREAM_CHUNK_TIMEOUT_SEC", "30"))
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 = float(os.getenv("STREAM_EDIT_MIN_INTERVAL_SEC", "1.2"))
314
- STREAM_TYPING_TICK_SEC = float(os.getenv("STREAM_TYPING_TICK_SEC", "0.5"))
315
- STREAM_TYPING_MAX_CATCHUP_TICKS = int(os.getenv("STREAM_TYPING_MAX_CATCHUP_TICKS", "6"))
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 = float(os.getenv("FIRST_CHUNK_TIMEOUT_SEC", "12"))
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 = int(os.getenv("TTS_MAX_CHARS", "800"))
342
  # Потолок одного синтеза TTS. Раньше его не было вообще: синхронный вызов Gemini
343
  # уходил в поток без таймаута и без http_options у клиента, и зависший Google
344
  # держал лок чата бесконечно (аудит 26.09.2026).
345
- TTS_SYNTH_TIMEOUT_SEC = float(os.getenv("TTS_SYNTH_TIMEOUT_SEC", "60"))
346
  _PROCESS_START_MONOTONIC = time.monotonic()
347
  # ── Тайминги автоматического маршрутизатора моделей (см. секцию "автоматический
348
  # выбор модели" ниже) ──
349
  # Без ретраев одной модели: любая ошибка — сразу следующая в маршруте; общий бюджет ROUTE_TOTAL_BUDGET_SEC держит лок чата от минутного зависания.
350
- ROUTE_MODEL_TIMEOUT_SEC = float(os.getenv("ROUTE_MODEL_TIMEOUT_SEC", "22"))
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 = float(os.getenv("ROUTE_TOTAL_BUDGET_SEC", "40"))
359
  # HISTORY_SUMMARY_BUDGET_SEC — потолок саммаризации истории: без него два висящих
360
  # backend-вызова подряд (Groq + OpenRouter по ROUTE_MODEL_TIMEOUT_SEC каждый) держали бы
361
  # lock чата десятки секунд ПОСЛЕ готового ответа (найдено внешним аудитом).
362
- HISTORY_SUMMARY_BUDGET_SEC = float(os.getenv("HISTORY_SUMMARY_BUDGET_SEC", "30"))
363
  # Общий бюджет /draw 120с: иначе 5 моделей × 90с давали до 7.5 мин висящего "Генерирую" (ревью 28.08.2026).
364
- DRAW_TOTAL_BUDGET_SEC = float(os.getenv("DRAW_TOTAL_BUDGET_SEC", "120"))
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 = float(os.getenv("CHAT_LOCK_TIMEOUT_SEC", "150"))
371
  # INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC — сколько main() при остановке ждёт штатного
372
  # завершения fire-and-forget задач перед отменой остатка (Sentry LUMEN-2: event loop убивал их посреди сетевых вызовов при редеплое).
373
- INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC = float(os.getenv("INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC", "10"))
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 = int(os.getenv("TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY", "4"))
382
  _tiktok_probe_semaphore = asyncio.Semaphore(TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY)
383
  # Лимит скачивания слайдов 8: 35 слайдов иначе занимают весь пул сессии (limit=40) и стопорят другие чаты (аудит 04.09.2026).
384
- TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY = int(os.getenv("TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY", "8"))
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 = int(os.getenv("TELEGRAM_PROXY_TRIP_THRESHOLD", "3"))
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 or post-restart taps get a "buttons expired, write in text" notice instead of an answer. |
 
 
 
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
- RATE_LIMIT_MAX_REQUESTS = int(os.getenv("RATE_LIMIT_MAX_REQUESTS", "5"))
16
- RATE_LIMIT_WINDOW_SEC = float(os.getenv("RATE_LIMIT_WINDOW_SEC", "30"))
17
- MAX_RATE_LIMIT_KEYS = int(os.getenv("MAX_RATE_LIMIT_KEYS", "20000"))
 
 
 
 
 
 
 
 
 
 
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 = float(os.getenv("PICK_TTL_SEC", "300"))
57
- MAX_PENDING_PICKS = int(os.getenv("MAX_PENDING_PICKS", "500"))
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
- TIKTOK_DOWNLOAD_MAX_BYTES = int(os.getenv("TIKTOK_DOWNLOAD_MAX_BYTES", str(75 * 1024 * 1024)))
 
 
 
 
 
 
 
 
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, а следующее