Spaces:
Running
Running
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 +15 -15
- docs/README.md +0 -118
- lumen_lang.py +1 -1
- lumen_router_config.py +7 -0
- lumen_tiktok.py +6 -0
- requirements.txt +31 -34
- test_bot.py +34 -0
- test_lumen_router_config.py +21 -0
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 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
|
|
|
| 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 |
-
#
|
| 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 |
-
на
|
| 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 |
-
#
|
| 5 |
-
# Docker
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
-
# types.GoogleMaps, types.UrlContext
|
| 9 |
-
#
|
| 10 |
-
#
|
| 11 |
-
#
|
| 12 |
#
|
| 13 |
-
#
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
#
|
| 17 |
-
#
|
| 18 |
-
#
|
| 19 |
-
#
|
| 20 |
-
#
|
| 21 |
-
#
|
| 22 |
-
#
|
| 23 |
-
#
|
| 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 (
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
-
#
|
| 36 |
-
#
|
| 37 |
-
#
|
| 38 |
-
#
|
| 39 |
-
# Чистый pip-пакет с данными IANA tzdb, без компиляции — не раздувает образ.
|
| 40 |
tzdata==2026.3
|
| 41 |
-
# sentry-sdk —
|
| 42 |
-
# bot.log
|
| 43 |
-
# README, "
|
| 44 |
-
#
|
| 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):
|