SilverElixir commited on
Commit
45dac5f
·
1 Parent(s): 93783a6

P1: drop docs/README duplicate and dead dict, EN requirements, fish warn-once, SSRF scheme guard, proxy rotation lock (AUD-N-001, AUD-B-002, AUD-H-002, AUD-F-001, AUD-D-003, AUD-E-004)

Browse files
bot.py CHANGED
@@ -255,6 +255,9 @@ _TELEGRAM_PROXY_FALLBACKS = [
255
  ]
256
  _TELEGRAM_PROXY_CANDIDATES: list[str] = [TELEGRAM_API_BASE_URL] + [u for u in _TELEGRAM_PROXY_FALLBACKS if u != TELEGRAM_API_BASE_URL]
257
  _telegram_proxy_idx = 0 # индекс текущего активного прокси в _TELEGRAM_PROXY_CANDIDATES
 
 
 
258
 
259
  # НАЙДЕНО ПРИ ОТЛАДКЕ (11-12 августа 2026, реальный инцидент): TikWM стабильно
260
  # отвечает HTTP 403 с ПУСТЫМ телом на запросы с IP HF Spaces (см. историю правок
@@ -581,12 +584,13 @@ async def _rotate_telegram_proxy() -> bool:
581
  целевой сервер (TelegramAPIServer) "запечён" в сессию при её создании, поэтому
582
  здесь она пересоздаётся заново, указывая на новый кандидат."""
583
  global TELEGRAM_API_BASE_URL, _telegram_proxy_idx
584
- if len(_TELEGRAM_PROXY_CANDIDATES) < 2:
585
- return False
586
- _telegram_proxy_idx = (_telegram_proxy_idx + 1) % len(_TELEGRAM_PROXY_CANDIDATES)
587
- new_url = _TELEGRAM_PROXY_CANDIDATES[_telegram_proxy_idx]
588
- old_url = TELEGRAM_API_BASE_URL
589
- TELEGRAM_API_BASE_URL = new_url
 
590
  log.warning('[telegram] Switching to fallback proxy: %s -> %s', old_url, new_url)
591
  if bot is not None:
592
  old_session = bot.session
@@ -1003,15 +1007,8 @@ def _classify_model_error(status: int | None, text: str) -> str:
1003
  # в system_prompt.py) — тексты НЕ должны упоминать "модели" во множественном
1004
  # числе или "подбор другой модели": для пользователя есть только Lumen, а
1005
  # переключения внутри — деталь реализации.
1006
- # Сами тексты живут в lumen_lang.py (ключи model_err_*), здесь — совместимые
1007
- # алиасы и тонкая обёртка с языком (lang="en" — дефолт для вызовов без чата,
1008
- # в тестах в том числе).
1009
- _MODEL_ERROR_MESSAGES: dict[str, str] = {
1010
- "rate_limit": _lang_t(DEFAULT_LANG, "model_err_rate_limit"),
1011
- "paid": _lang_t(DEFAULT_LANG, "model_err_paid"),
1012
- "forbidden": _lang_t(DEFAULT_LANG, "model_err_forbidden"),
1013
- "unavailable": _lang_t(DEFAULT_LANG, "model_err_unavailable"),
1014
- }
1015
  _MODEL_ERROR_FALLBACK_MSG = _lang_t(DEFAULT_LANG, "model_err_fallback")
1016
 
1017
  def _model_error_text(kind: str, lang: str = DEFAULT_LANG) -> str:
@@ -4952,6 +4949,9 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
4952
  needs_youtube = True
4953
  youtube_url_to_analyze = url
4954
  else:
 
 
 
4955
  needs_website = True
4956
 
4957
  clean_prompt = clean_mention(t).strip()
 
255
  ]
256
  _TELEGRAM_PROXY_CANDIDATES: list[str] = [TELEGRAM_API_BASE_URL] + [u for u in _TELEGRAM_PROXY_FALLBACKS if u != TELEGRAM_API_BASE_URL]
257
  _telegram_proxy_idx = 0 # индекс текущего активного прокси в _TELEGRAM_PROXY_CANDIDATES
258
+ # Ротация мутирует два глобала сразу — под локом, иначе два concurrent-сбоя
259
+ # уводят индекс на два шага и пропускают кандидата (AUD-E-004).
260
+ _proxy_rotation_lock = asyncio.Lock()
261
 
262
  # НАЙДЕНО ПРИ ОТЛАДКЕ (11-12 августа 2026, реальный инцидент): TikWM стабильно
263
  # отвечает HTTP 403 с ПУСТЫМ телом на запросы с IP HF Spaces (см. историю правок
 
584
  целевой сервер (TelegramAPIServer) "запечён" в сессию при её создании, поэтому
585
  здесь она пересоздаётся заново, указывая на новый кандидат."""
586
  global TELEGRAM_API_BASE_URL, _telegram_proxy_idx
587
+ async with _proxy_rotation_lock:
588
+ if len(_TELEGRAM_PROXY_CANDIDATES) < 2:
589
+ return False
590
+ _telegram_proxy_idx = (_telegram_proxy_idx + 1) % len(_TELEGRAM_PROXY_CANDIDATES)
591
+ new_url = _TELEGRAM_PROXY_CANDIDATES[_telegram_proxy_idx]
592
+ old_url = TELEGRAM_API_BASE_URL
593
+ TELEGRAM_API_BASE_URL = new_url
594
  log.warning('[telegram] Switching to fallback proxy: %s -> %s', old_url, new_url)
595
  if bot is not None:
596
  old_session = bot.session
 
1007
  # в system_prompt.py) — тексты НЕ должны упоминать "модели" во множественном
1008
  # числе или "подбор другой модели": для пользователя есть только Lumen, а
1009
  # переключения внутри — деталь реализации.
1010
+ # Сами тексты живут в lumen_lang.py (ключи model_err_*), здесь — тонкая
1011
+ # обёртка с языком (lang="en" — дефолт для вызовов без чата, в тестах в том числе).
 
 
 
 
 
 
 
1012
  _MODEL_ERROR_FALLBACK_MSG = _lang_t(DEFAULT_LANG, "model_err_fallback")
1013
 
1014
  def _model_error_text(kind: str, lang: str = DEFAULT_LANG) -> str:
 
4949
  needs_youtube = True
4950
  youtube_url_to_analyze = url
4951
  else:
4952
+ # needs_website читает ссылку сама модель через url_context на
4953
+ # стороне Google — наш сервер произвольные URL не скачивает
4954
+ # (свои загрузки — только через _download_url_bin с гардом схемы).
4955
  needs_website = True
4956
 
4957
  clean_prompt = clean_mention(t).strip()
docs/README.md DELETED
@@ -1,118 +0,0 @@
1
- ---
2
- title: Lumen
3
- colorFrom: yellow
4
- colorTo: gray
5
- sdk: docker
6
- app_port: 7860
7
- pinned: false
8
- ---
9
-
10
- <p align="center">
11
- <img src="assets/lumen-banner.svg" alt="Lumen: Intelligence. Clarity." width="480">
12
- </p>
13
-
14
- A Telegram bot styled after Claude's tone and personality (direct, warm, light on hedging), running on Google Gemini and free OpenRouter models with automatic per-message routing between them. Lumen also generates images, downloads TikTok videos without watermarks, and reads text back as speech. It runs as a webhook service on Hugging Face Spaces (Docker).
15
-
16
- **Stack:** Python 3.13 · aiogram · FastAPI · google-genai · Docker
17
-
18
- > The YAML block above is Hugging Face Space metadata, not a mistake: this same `README.md` doubles as the Space card. Everything below it is normal GitHub documentation.
19
-
20
- ## Features
21
-
22
- - **Automatic model routing.** Every message goes to whichever provider actually has what it needs: web search and link reading go to Gemini, everything else defaults to free OpenRouter models, protecting Gemini's tight daily quota.
23
- - **Streaming replies** with a self-calibrating typing-speed pacer, so answers type themselves in instead of landing in a few large chunks.
24
- - **Defenses against prompt injection and identity leaks.** A deterministic input filter plus output scrubbers keep the bot from revealing which model or provider actually answered.
25
- - **TikTok downloads** without watermarks: video, slideshows (including "live" photo slides), and original sound.
26
- - **Image generation** through Pollinations.ai. The model is picked from the prompt itself (anime, fantasy, realism, quick sketch, or a general default).
27
- - **Text-to-speech**, trying Fish Audio first and falling back to Gemini TTS.
28
- - `/draw` and `/tts` also work as plain phrases at the start of a message ("draw a cat," "read this out loud"), no slash required.
29
- - **Persistent state.** An optional Upstash Redis backend keeps chat history and quota counters alive across redeploys.
30
- - **Error tracking** through an optional Sentry integration that scrubs secrets before sending anything.
31
-
32
- ## How it works
33
-
34
- Lumen is a single FastAPI + aiogram service. Telegram delivers updates to a webhook. Each message gets routed through a chain of candidate models (Gemini and/or OpenRouter), built on the fly from the message's content: attachments, links, and a couple of lightweight heuristics for "does this need current information" and "is this a heavy request." The first provider that answers wins; the other is tried as a fallback if its whole chain fails.
35
-
36
- The codebase is a modular monolith. `bot.py` is the orchestrator; the rest is split into focused modules: `lumen_router_config.py` (model routing), `lumen_formatting.py` (markdown to Telegram HTML), `lumen_security.py` (injection and leak defenses), `lumen_images.py`, `lumen_tts.py`, `lumen_tiktok.py`, `lumen_telegram_transport.py`, `lumen_state_storage.py`, and `lumen_typing_pace.py`.
37
-
38
- See **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** for a deeper look at routing, streaming, the security layers, and the TikTok downloader.
39
-
40
- ## Deploying on Hugging Face Spaces
41
-
42
- 1. Push this repository to a Space (`sdk: docker` is already set in the frontmatter above).
43
- 2. Set the two required secrets: `BOT_TOKEN` and `GEMINI_API_KEY`.
44
- 3. Hugging Face builds the image from `Dockerfile` and runs `python -u bot.py`.
45
- 4. On startup the bot registers its own webhook. If that fails, check the logs for `[webhook] setWebhook failed` and register it manually:
46
- ```bash
47
- curl -H "Authorization: Bearer <ADMIN_PANEL_KEY>" https://<space-host>/webhook_url
48
- ```
49
- then open the `register_link` it returns.
50
- 5. `WEBHOOK_SECRET` and `ADMIN_PANEL_KEY` are derived deterministically from `BOT_TOKEN` (or from `ADMIN_SECRET_SEED`, if set). Fetch them with:
51
- ```bash
52
- curl -H "Authorization: Bearer <BOT_TOKEN>" https://<space-host>/admin_keys
53
- ```
54
-
55
- Hugging Face Spaces' outbound IPs are blocked by Telegram's API at the network level, so a small proxy is required. See [Proxy setup](#proxy-setup) below.
56
-
57
- ## Configuration
58
-
59
- Only `BOT_TOKEN` and `GEMINI_API_KEY` are required. Everything else has a sane default; the full reference (proxy addresses, timeouts, storage, rate limiting, TikTok tuning) lives in **[docs/ENVIRONMENT.md](docs/ENVIRONMENT.md)**.
60
-
61
- ## Proxy setup
62
-
63
- Hugging Face Spaces' outbound IPs are blocked by Telegram's Bot API entirely, and TikWM (the TikTok API this bot uses) returns `403` to the same IPs. Both are fixed by one small pass-through proxy, deployed separately on Deno Deploy (source: `proxy.ts`):
64
-
65
- ```bash
66
- TELEGRAM_API_BASE_URL=https://<proxy-domain>/fetch/api.telegram.org
67
- TIKWM_API_BASE_URL=https://<proxy-domain>/fetch/www.tikwm.com
68
- ```
69
-
70
- The proxy only forwards to an explicit host allowlist (`api.telegram.org`, `www.tikwm.com`, `tikwm.com`). See `proxy.ts` for the implementation and `proxy_test.ts` for its tests.
71
-
72
- ## Diagnostics
73
-
74
- All three endpoints below require `Authorization: Bearer <ADMIN_PANEL_KEY>` (`/admin_keys`, covered in the deploy steps above, uses `BOT_TOKEN` instead):
75
-
76
- | Endpoint | Purpose |
77
- |---|---|
78
- | `GET /diag` | Checks outbound network reachability (Telegram, Gemini, OpenRouter, TikWM, Pollinations, etc.) from inside the container. |
79
- | `GET /webhook_url` | Shows the computed webhook URL and a ready-to-open registration link. |
80
- | `GET /export_state` | Dumps all chat histories and quota counters as JSON, for manual backup. |
81
-
82
- `/logs` (a Telegram command, owner-only) sends the current `bot.log` with secrets redacted.
83
-
84
- ## Commands
85
-
86
- `/start`, `/reset`, `/draw`, and `/tts` show up in Telegram's command menu:
87
-
88
- | Command | Access | Purpose |
89
- |---|---|---|
90
- | `/start` | anyone | Shows a short intro and the command list. |
91
- | `/reset` | anyone in DMs; group admins/owner in groups | Clears the chat's conversation history. |
92
- | `/draw [description]` | anyone | Generates an image (see [Features](#features)). |
93
- | `/tts [text]` | anyone | Reads text out loud. |
94
-
95
- Two more owner-only commands stay out of the menu on purpose, since they surface internal details that shouldn't be visible in a group chat:
96
-
97
- | Command | Access | Purpose |
98
- |---|---|---|
99
- | `/stats` | bot owner, DMs only | Active chat count, process uptime, per-model quota usage. |
100
- | `/logs` | bot owner, DMs only | Sends `bot.log` with secrets redacted. |
101
-
102
- ## Testing
103
-
104
- ```bash
105
- pip install -r requirements.txt -r requirements-dev.txt
106
- pytest -v
107
- ```
108
-
109
- Test files mirror the module split: `test_lumen_formatting.py`, `test_lumen_security.py`, `test_lumen_router_config.py`, and `test_lumen_typing_pace.py` each test their matching module directly, while `test_bot.py` covers everything defined in `bot.py` itself. `conftest.py` stubs `BOT_TOKEN`/`GEMINI_API_KEY`/`BOT_LOG_PATH` so the suite needs no real secrets.
110
-
111
- CI (`.github/workflows/ci.yml`) runs `pyflakes` + `pytest` + `pip-audit` on every push and pull request; a successful run triggers `sync-to-hf.yml`, which mirrors the commit to the Hugging Face Space.
112
-
113
- ## Known limitations
114
-
115
- - Chat history and quota counters survive redeploys only if Upstash is configured (see [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md)); otherwise they live on the container's ephemeral disk and reset on every rebuild.
116
- - YouTube downloading isn't supported, because Hugging Face Spaces' outbound IPs are blocked at the TLS handshake level. Viewing and analyzing a video by link still works.
117
- - TikTok downloading depends on the unofficial TikWM API, not an official TikTok endpoint, and needs the proxy above to work from Hugging Face Spaces at all.
118
- - Streaming has only been exercised against mocked clients, not live Gemini/OpenRouter SSE traffic. See the manual smoke-test checklist in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) before touching that code path.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lumen_lang.py CHANGED
@@ -3,7 +3,7 @@ lumen_lang.py — язык системных сообщений бота (не
3
 
4
  Что это: все ФИКСИРОВАННЫЕ строки, которые бот отправляет пользователю сам
5
  (/start, подсказки, ошибки, статусы, кнопки-уточнения и их служебные реплики),
6
- на 6 языках. Ответы ИИ не трогаем — модель отвечает на языке собеседника
7
  (см. RESPONSE LANGUAGE в system_prompt.py).
8
 
9
  Языки (по алфавиту кода — так пользователю проще искать в меню), 25 штук:
 
3
 
4
  Что это: все ФИКСИРОВАННЫЕ строки, которые бот отправляет пользователю сам
5
  (/start, подсказки, ошибки, статусы, кнопки-уточнения и их служебные реплики),
6
+ на 25 языках. Ответы ИИ не трогаем — модель отвечает на языке собеседника
7
  (см. RESPONSE LANGUAGE в system_prompt.py).
8
 
9
  Языки (по алфавиту кода — так пользователю проще искать в меню), 25 штук:
lumen_router_config.py CHANGED
@@ -179,9 +179,16 @@ FISH_AUDIO_FREE_TIER_EXPIRY = date(2026, 8, 31)
179
  # если Fish снова откроют free-доступ — достаточно вернуть True одной строкой.
180
  FISH_AUDIO_ENABLED = False
181
 
 
 
182
  def _check_fish_audio_tts_expiry() -> None:
 
 
 
 
183
  today = date.today()
184
  if today > FISH_AUDIO_FREE_TIER_EXPIRY:
 
185
  log.warning(
186
  '[tts] The advertised free-tier access to %s expired on %s (today is %s) — check fish.audio/blog/s2-1-pro-free-api in case it was extended again, and update FISH_AUDIO_FREE_TIER_EXPIRY. If access is really gone, _fish_audio_tts_bytes in bot.py already falls back to Gemini TTS silently on any failure — nothing breaks functionally, but the wasted failing requests are worth removing.',
187
  FISH_AUDIO_TTS_MODEL, FISH_AUDIO_FREE_TIER_EXPIRY.isoformat(), today.isoformat(),
 
179
  # если Fish снова откроют free-доступ — достаточно вернуть True одной строкой.
180
  FISH_AUDIO_ENABLED = False
181
 
182
+ _FISH_EXPIRY_WARNED = False
183
+
184
  def _check_fish_audio_tts_expiry() -> None:
185
+ # Warn once per process: after 31.08.2026 the condition is true forever.
186
+ global _FISH_EXPIRY_WARNED
187
+ if _FISH_EXPIRY_WARNED:
188
+ return
189
  today = date.today()
190
  if today > FISH_AUDIO_FREE_TIER_EXPIRY:
191
+ _FISH_EXPIRY_WARNED = True
192
  log.warning(
193
  '[tts] The advertised free-tier access to %s expired on %s (today is %s) — check fish.audio/blog/s2-1-pro-free-api in case it was extended again, and update FISH_AUDIO_FREE_TIER_EXPIRY. If access is really gone, _fish_audio_tts_bytes in bot.py already falls back to Gemini TTS silently on any failure — nothing breaks functionally, but the wasted failing requests are worth removing.',
194
  FISH_AUDIO_TTS_MODEL, FISH_AUDIO_FREE_TIER_EXPIRY.isoformat(), today.isoformat(),
lumen_tiktok.py CHANGED
@@ -501,6 +501,12 @@ TIKTOK_DOWNLOAD_MAX_BYTES = int(os.getenv("TIKTOK_DOWNLOAD_MAX_BYTES", str(75 *
501
 
502
 
503
  async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: dict | None = None) -> bytes | None:
 
 
 
 
 
 
504
  if headers is None:
505
  headers = {
506
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
 
501
 
502
 
503
  async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: dict | None = None) -> bytes | None:
504
+ # URL приходят из JSON постороннего сервиса (TikWM) — качаем только
505
+ # http(s), остальное (file://, ftp:// и т.п.) отбрасываем до запроса (AUD-D-003).
506
+ scheme = urllib.parse.urlsplit(url).scheme.lower()
507
+ if scheme not in ("http", "https"):
508
+ log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
509
+ return None
510
  if headers is None:
511
  headers = {
512
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
requirements.txt CHANGED
@@ -1,46 +1,43 @@
1
  # requirements.txt — Lumen Bot
2
  # Python 3.10+
3
  #
4
- # ВАЖНО: версии зафиксированы через == (а не >=), т.к. HF Spaces пересобирает
5
- # Docker-образ с нуля при каждом деплое, и без верхней границы `pip install`
6
- # мог бы молча подтянуть новую мажорную версию с ломающими изменениями API —
7
- # особенно критично для google-genai, чей API (types.GoogleSearch,
8
- # types.GoogleMaps, types.UrlContext и т.п.) активно используется в bot.py
9
- # и менялся между мажорными версиями. Обновлять версии здесь — осознанное
10
- # решение с последующей проверкой /diag и ручным тестом основных команд,
11
- # а не побочный эффект случайного редеплоя.
12
  #
13
- # ОБНОВЛЕНО (аудит зависимостей, 16 августа 2026) — все пакеты подняты до
14
- # актуальных на этот момент версий PyPI, changelog каждого сверен на предмет
15
- # breaking changes относительно реально используемого в bot.py API (см. историю
16
- # коммита). aiohttp/mutagen/pytest/pyflakes уже были на последней версии —
17
- # оставлены без изменений.
18
- # ОБНОВЛЕНО ПОВТОРНО (аудит техдолга, 7 сентября 2026) — aiogram/google-genai/
19
- # sentry-sdk/uvicorn отстали на несколько минорных релизов (без известных CVE,
20
- # см. pip-audit); подняты до актуальных на этот момент стабильных версий.
21
- # fastapi/aiohttp/mutagen/tzdata уже были на последней версии — не тронуты.
22
- # Полный прогон тестового сьюта (390 тестов) под новыми версиями прошёл без
23
- # единого изменения кода — реального взаимодействия с живым API (Gemini/
24
- # Telegram) это не проверяет (см. README про сетевые ограничения песочницы),
25
- # перед деплоем всё равно стоит ручной smoke-тест /diag и основных команд.
26
  aiohttp==3.14.3
27
  aiogram==3.31.0
28
  fastapi==0.141.1
29
  google-genai==2.22.0
30
  mutagen==1.48.1
31
  uvicorn==0.52.4
32
- # tzdata — python:3.13-slim (см. Dockerfile) не ставит системную базу часовых
33
- # поясов через apt, а stdlib-модуль zoneinfo (используется в _current_quota_day
34
- # для дневного сброса счётчиков квоты по America/Los_Angeles — там у Google
35
- # полночь для обнуления RPD-лимитов) без нeё падает ZoneInfoNotFoundError. Код
36
- # и так безопасно откатывается на UTC при отсутствии данных (см. try/except в
37
- # _current_quota_day), но тогда сброс квоты сдвигается на 7-8 часов от реального
38
- # обнуления лимита у Google — с этим пакетом сброс происходит в верное время.
39
- # Чистый pip-пакет с данными IANA tzdb, без компиляции — не раздувает образ.
40
  tzdata==2026.3
41
- # sentry-sdk — опциональный (см. SENTRY_DSN в bot.py) персистентный трекинг ошибок:
42
- # bot.log живёт на эфемерном диске HF Spaces и теряется при каждом редеплое (см.
43
- # README, "Известные ограничения" — раньше это было единственным незакрытым
44
- # пунктом там). Без SENTRY_DSN sentry_sdk.init() не вызывается вообще — пакет
45
- # просто лежит неиспользуемым, поведение не меняется для тех, кто его не настроил.
46
  sentry-sdk==2.68.1
 
1
  # requirements.txt — Lumen Bot
2
  # Python 3.10+
3
  #
4
+ # NOTE: versions are pinned with == (not >=) because HF Spaces rebuilds the
5
+ # Docker image from scratch on every deploy — without an upper bound a fresh
6
+ # `pip install` could silently pull a new major with breaking API changes.
7
+ # This matters most for google-genai, whose API (types.GoogleSearch,
8
+ # types.GoogleMaps, types.UrlContext, etc.) is used heavily in bot.py and
9
+ # has changed between majors. Bumping a version here is a deliberate decision
10
+ # followed by a /diag check plus a manual smoke test of the main commands,
11
+ # never a side effect of a random redeploy.
12
  #
13
+ # UPDATED (dependency audit, 2026-08-16) — all packages raised to the PyPI
14
+ # versions current at the time, each changelog checked for breaking changes
15
+ # against the API actually used in bot.py (see commit history).
16
+ # aiohttp/mutagen/pytest/pyflakes were already latest — left untouched.
17
+ # UPDATED AGAIN (tech-debt audit, 2026-09-07) — aiogram/google-genai/
18
+ # sentry-sdk/uvicorn had lagged a few minor releases (no known CVEs per
19
+ # pip-audit); raised to the stable versions current at the time.
20
+ # fastapi/aiohttp/mutagen/tzdata were already latest — untouched.
21
+ # Full suite (390 tests) passed under the new versions with zero code changes —
22
+ # that does not cover live API behavior (Gemini/Telegram), so a manual
23
+ # /diag + main-commands smoke test is still due before deploy.
 
 
24
  aiohttp==3.14.3
25
  aiogram==3.31.0
26
  fastapi==0.141.1
27
  google-genai==2.22.0
28
  mutagen==1.48.1
29
  uvicorn==0.52.4
30
+ # tzdata — python:3.13-slim (see Dockerfile) ships no system timezone database
31
+ # via apt, while stdlib zoneinfo (used in _current_quota_day for the daily
32
+ # quota reset on America/Los_Angeles — Google's midnight for RPD limits)
33
+ # raises ZoneInfoNotFoundError without it. The code already falls back to UTC
34
+ # safely (see try/except in _current_quota_day), but then the quota reset
35
+ # drifts 7-8 hours off Google's real reset — with this package it lands right.
36
+ # Pure-pip IANA tzdb data, no compilation — does not bloat the image.
 
37
  tzdata==2026.3
38
+ # sentry-sdk — optional (see SENTRY_DSN in bot.py) persistent error tracking:
39
+ # bot.log lives on the ephemeral HF Spaces disk and is lost on every redeploy
40
+ # (see README, "Known limitations" — that used to be the only open item there).
41
+ # Without SENTRY_DSN, sentry_sdk.init() is never called — the package just
42
+ # sits unused and changes nothing for whoever did not configure it.
43
  sentry-sdk==2.68.1
test_bot.py CHANGED
@@ -4035,6 +4035,32 @@ def test_rotate_telegram_proxy_reports_lap_done_after_full_cycle():
4035
  bot.bot = original_bot
4036
 
4037
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4038
  class _FakeOwnerBot:
4039
  def __init__(self):
4040
  self.sent: list[dict] = []
@@ -4978,6 +5004,14 @@ def test_download_url_bin_ignores_malformed_content_length_header():
4978
  assert result == b"ok"
4979
 
4980
 
 
 
 
 
 
 
 
 
4981
  @pytest.fixture
4982
  def rate_guard_setup(monkeypatch):
4983
  def _setup():
 
4035
  bot.bot = original_bot
4036
 
4037
 
4038
+ def test_rotate_telegram_proxy_concurrent_rotations_advance_one_step_each():
4039
+ # Контракт AUD-E-004: индекс и URL мутируют под локом — две concurrent-
4040
+ # ротации дают два шага (0→1→2), а не два прыжка в одну точку.
4041
+ original_candidates = bot._TELEGRAM_PROXY_CANDIDATES
4042
+ original_idx = bot._telegram_proxy_idx
4043
+ original_base_url = bot.TELEGRAM_API_BASE_URL
4044
+ original_bot = bot.bot
4045
+ bot._TELEGRAM_PROXY_CANDIDATES = ["https://one.example.com", "https://two.example.com", "https://three.example.com"]
4046
+ bot._telegram_proxy_idx = 0
4047
+ bot.TELEGRAM_API_BASE_URL = "https://one.example.com"
4048
+ bot.bot = None
4049
+ try:
4050
+ async def _two_rotations():
4051
+ return await asyncio.gather(bot._rotate_telegram_proxy(), bot._rotate_telegram_proxy())
4052
+
4053
+ switched = asyncio.run(_two_rotations())
4054
+ assert switched == [True, True]
4055
+ assert bot._telegram_proxy_idx == 2
4056
+ assert bot.TELEGRAM_API_BASE_URL == "https://three.example.com"
4057
+ finally:
4058
+ bot._TELEGRAM_PROXY_CANDIDATES = original_candidates
4059
+ bot._telegram_proxy_idx = original_idx
4060
+ bot.TELEGRAM_API_BASE_URL = original_base_url
4061
+ bot.bot = original_bot
4062
+
4063
+
4064
  class _FakeOwnerBot:
4065
  def __init__(self):
4066
  self.sent: list[dict] = []
 
5004
  assert result == b"ok"
5005
 
5006
 
5007
+ def test_download_url_bin_refuses_non_http_scheme_without_fetching():
5008
+ # Регрессия AUD-D-003: URL приходят из JSON постороннего сервиса — не-HTTP
5009
+ # отбрасываем до запроса (фейк вернул бы байты на что угодно, None доказывает гард).
5010
+ session = _FakeDownloadSession(_FakeDownloadResponse([b"should never be fetched"]))
5011
+ assert asyncio.run(lumen_tiktok._download_url_bin(session, "file:///etc/passwd")) is None
5012
+ assert asyncio.run(lumen_tiktok._download_url_bin(session, "ftp://evil.example/x.mp4")) is None
5013
+
5014
+
5015
  @pytest.fixture
5016
  def rate_guard_setup(monkeypatch):
5017
  def _setup():
test_lumen_router_config.py CHANGED
@@ -541,7 +541,9 @@ def test_check_fish_audio_tts_expiry_warns_after_expiry_date(caplog):
541
  import logging
542
  from datetime import date, timedelta
543
  original_expiry = lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY
 
544
  lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY = date.today() - timedelta(days=1)
 
545
  try:
546
  with caplog.at_level(logging.WARNING, logger="bot"):
547
  lumen_router_config._check_fish_audio_tts_expiry()
@@ -549,6 +551,25 @@ def test_check_fish_audio_tts_expiry_warns_after_expiry_date(caplog):
549
  assert lumen_router_config.FISH_AUDIO_TTS_MODEL in messages
550
  finally:
551
  lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY = original_expiry
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
 
554
  def test_check_fish_audio_tts_expiry_silent_before_expiry_date(caplog):
 
541
  import logging
542
  from datetime import date, timedelta
543
  original_expiry = lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY
544
+ original_warned = lumen_router_config._FISH_EXPIRY_WARNED
545
  lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY = date.today() - timedelta(days=1)
546
+ lumen_router_config._FISH_EXPIRY_WARNED = False
547
  try:
548
  with caplog.at_level(logging.WARNING, logger="bot"):
549
  lumen_router_config._check_fish_audio_tts_expiry()
 
551
  assert lumen_router_config.FISH_AUDIO_TTS_MODEL in messages
552
  finally:
553
  lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY = original_expiry
554
+ lumen_router_config._FISH_EXPIRY_WARNED = original_warned
555
+
556
+
557
+ def test_check_fish_audio_tts_expiry_warns_only_once(caplog):
558
+ import logging
559
+ from datetime import date, timedelta
560
+ original_expiry = lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY
561
+ original_warned = lumen_router_config._FISH_EXPIRY_WARNED
562
+ lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY = date.today() - timedelta(days=1)
563
+ lumen_router_config._FISH_EXPIRY_WARNED = False
564
+ try:
565
+ with caplog.at_level(logging.WARNING, logger="bot"):
566
+ lumen_router_config._check_fish_audio_tts_expiry()
567
+ caplog.clear()
568
+ lumen_router_config._check_fish_audio_tts_expiry()
569
+ assert caplog.records == []
570
+ finally:
571
+ lumen_router_config.FISH_AUDIO_FREE_TIER_EXPIRY = original_expiry
572
+ lumen_router_config._FISH_EXPIRY_WARNED = original_warned
573
 
574
 
575
  def test_check_fish_audio_tts_expiry_silent_before_expiry_date(caplog):