Spaces:
Running
Running
Merge pull request #11 from S1lverElixir/agent/audit-behavior
Browse files- bot.py +71 -19
- docs/ARCHITECTURE.md +2 -2
- docs/ENVIRONMENT.md +3 -0
- lumen_commands.py +20 -1
- lumen_lang.py +54 -0
- lumen_media_flow.py +39 -1
- lumen_message_core.py +51 -10
- lumen_router_config.py +9 -3
- lumen_tiktok.py +8 -6
- lumen_tiktok_flow.py +19 -2
- tests/test_bot_admin.py +49 -0
- tests/test_bot_state.py +153 -4
- tests/test_bot_tiktok.py +52 -4
- tests/test_bot_tts.py +64 -0
- tests/test_lumen_router_config.py +7 -1
bot.py
CHANGED
|
@@ -303,6 +303,9 @@ for env_name in ("OWNER_ID", "BOT_OWNER_ID", "ADMIN_ID", "TELEGRAM_OWNER_ID"):
|
|
| 303 |
TELEGRAM_REQUEST_TIMEOUT = _env_number("TELEGRAM_REQUEST_TIMEOUT", 45, min_value=1)
|
| 304 |
TELEGRAM_AI_TIMEOUT = _env_number("TELEGRAM_AI_TIMEOUT", 45, min_value=1)
|
| 305 |
TELEGRAM_MEDIA_TIMEOUT = _env_number("TELEGRAM_MEDIA_TIMEOUT", 25, min_value=1)
|
|
|
|
|
|
|
|
|
|
| 306 |
# Раньше было захардкожено как 15.0 прямо внутри _download_telegram_file_bytes —
|
| 307 |
# несогласованно с остальными таймаутами, которые все конфигурируются через env.
|
| 308 |
TELEGRAM_GET_FILE_TIMEOUT = _env_number("TELEGRAM_GET_FILE_TIMEOUT", 15, min_value=1)
|
|
@@ -338,6 +341,10 @@ TTS_MAX_CHARS = _env_number("TTS_MAX_CHARS", 800, cast=int, min_value=1)
|
|
| 338 |
# уходил в поток без таймаута и без http_options у клиента, и зависший Google
|
| 339 |
# держал лок чата бесконечно (аудит 26.09.2026).
|
| 340 |
TTS_SYNTH_TIMEOUT_SEC = _env_number("TTS_SYNTH_TIMEOUT_SEC", 60, min_value=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
_PROCESS_START_MONOTONIC = time.monotonic()
|
| 342 |
# ── Тайминги автоматического маршрутизатора моделей (см. секцию "автоматический
|
| 343 |
# выбор модели" ниже) ──
|
|
@@ -378,6 +385,9 @@ _tiktok_probe_semaphore = asyncio.Semaphore(TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY
|
|
| 378 |
# Лимит скачивания слайдов 8: 35 слайдов иначе занимают весь пул сессии (limit=40) и стопорят другие чаты (аудит 04.09.2026).
|
| 379 |
TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY = _env_number("TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY", 8, cast=int, min_value=1)
|
| 380 |
_tiktok_slide_download_semaphore = asyncio.Semaphore(TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY)
|
|
|
|
|
|
|
|
|
|
| 381 |
|
| 382 |
# Состояние/квоты/локи живут в lumen_chat_state.py (P2): здесь только реэкспорт
|
| 383 |
# имён (те же объекты — тесты мутируют bot.chat_state/bot.GLOBAL_QUOTA как раньше).
|
|
@@ -522,6 +532,7 @@ from lumen_rich import (
|
|
| 522 |
_edit_message_quietly,
|
| 523 |
)
|
| 524 |
from lumen_media_flow import (
|
|
|
|
| 525 |
_download_telegram_file_bytes,
|
| 526 |
_save_media_to_history,
|
| 527 |
_download_message_attachment_to_tmp,
|
|
@@ -839,6 +850,7 @@ __all__ = [
|
|
| 839 |
"_delete_message_quietly",
|
| 840 |
"_edit_message_quietly",
|
| 841 |
"_download_telegram_file_bytes",
|
|
|
|
| 842 |
"_save_media_to_history",
|
| 843 |
"_download_message_attachment_to_tmp",
|
| 844 |
"_fetch_media",
|
|
@@ -1207,6 +1219,10 @@ from lumen_message_core import (
|
|
| 1207 |
_process_raw_update,
|
| 1208 |
)
|
| 1209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1210 |
async def _webhook_startup() -> None:
|
| 1211 |
# Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
|
| 1212 |
await asyncio.to_thread(load_state_from_disk)
|
|
@@ -1281,26 +1297,45 @@ async def _webhook_startup() -> None:
|
|
| 1281 |
except Exception as exc:
|
| 1282 |
log.warning("[webhook] deleteWebhook failed (will need manual setup): %s", exc)
|
| 1283 |
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
"
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1297 |
log.info("[webhook] Webhook registered successfully: %s", webhook_url)
|
| 1298 |
-
|
| 1299 |
# Секрет в лог не подставляем — готовая ссылка живёт в /webhook_url (Bearer ADMIN_PANEL_KEY, не query).
|
| 1300 |
-
log.
|
| 1301 |
-
'[webhook] setWebhook failed — register it manually via: curl -H "Authorization: Bearer <ADMIN_PANEL_KEY>" https://.../webhook_url (get ADMIN_PANEL_KEY via curl -H "Authorization: Bearer <BOT_TOKEN>" .../admin_keys if you don\'t have it handy)
|
| 1302 |
-
|
| 1303 |
)
|
|
|
|
| 1304 |
|
| 1305 |
cmd_payloads = [
|
| 1306 |
{"commands": [{"command": c.command, "description": c.description} for c in commands]}
|
|
@@ -1326,8 +1361,11 @@ async def _webhook_startup() -> None:
|
|
| 1326 |
log.info("[webhook] Bot commands set successfully.")
|
| 1327 |
else:
|
| 1328 |
log.warning("[webhook] Bot commands were not set for any language.")
|
|
|
|
| 1329 |
|
| 1330 |
-
await try_setup()
|
|
|
|
|
|
|
| 1331 |
|
| 1332 |
log.info("[webhook] Bot is running in webhook mode. Updates arrive via POST /webhook")
|
| 1333 |
# Суточные перепроверки моделей в часовом цикле: истёкшее промо иначе видно
|
|
@@ -1402,7 +1440,21 @@ async def main() -> None:
|
|
| 1402 |
)
|
| 1403 |
server = uvicorn.Server(srv_config)
|
| 1404 |
try:
|
| 1405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1406 |
finally:
|
| 1407 |
startup_task.cancel()
|
| 1408 |
flush_task.cancel()
|
|
|
|
| 303 |
TELEGRAM_REQUEST_TIMEOUT = _env_number("TELEGRAM_REQUEST_TIMEOUT", 45, min_value=1)
|
| 304 |
TELEGRAM_AI_TIMEOUT = _env_number("TELEGRAM_AI_TIMEOUT", 45, min_value=1)
|
| 305 |
TELEGRAM_MEDIA_TIMEOUT = _env_number("TELEGRAM_MEDIA_TIMEOUT", 25, min_value=1)
|
| 306 |
+
# Потолок скачивания из Telegram: Bot API не отдаёт файлы больше 20МБ через
|
| 307 |
+
# getFile/путь (аудит D1, 30.09.2026) — большее отклоняем до скачивания.
|
| 308 |
+
TELEGRAM_DOWNLOAD_MAX_BYTES = _env_number("TELEGRAM_DOWNLOAD_MAX_BYTES", 20 * 1024 * 1024, cast=int, min_value=1024 * 1024)
|
| 309 |
# Раньше было захардкожено как 15.0 прямо внутри _download_telegram_file_bytes —
|
| 310 |
# несогласованно с остальными таймаутами, которые все конфигурируются через env.
|
| 311 |
TELEGRAM_GET_FILE_TIMEOUT = _env_number("TELEGRAM_GET_FILE_TIMEOUT", 15, min_value=1)
|
|
|
|
| 341 |
# уходил в поток без таймаута и без http_options у клиента, и зависший Google
|
| 342 |
# держал лок чата бесконечно (аудит 26.09.2026).
|
| 343 |
TTS_SYNTH_TIMEOUT_SEC = _env_number("TTS_SYNTH_TIMEOUT_SEC", 60, min_value=1)
|
| 344 |
+
# Общий дедлайн на все чанки мультичанкового TTS: зависший синтез иначе держал
|
| 345 |
+
# per-chat lock без края (аудит D2, 30.09.2026). Худший чанк ~100с (60 синтез +
|
| 346 |
+
# 30 ffmpeg + 10 probe), типичные 5 чанков укладываются с запасом.
|
| 347 |
+
TTS_TOTAL_BUDGET_SEC = _env_number("TTS_TOTAL_BUDGET_SEC", 240, min_value=30)
|
| 348 |
_PROCESS_START_MONOTONIC = time.monotonic()
|
| 349 |
# ── Тайминги автоматического маршрутизатора моделей (см. секцию "автоматический
|
| 350 |
# выбор модели" ниже) ──
|
|
|
|
| 385 |
# Лимит скачивания слайдов 8: 35 слайдов иначе занимают весь пул сессии (limit=40) и стопорят другие чаты (аудит 04.09.2026).
|
| 386 |
TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY = _env_number("TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY", 8, cast=int, min_value=1)
|
| 387 |
_tiktok_slide_download_semaphore = asyncio.Semaphore(TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY)
|
| 388 |
+
# Общий кап RAM на один пост: 35 слайдов по 75МБ в памяти (до ~2.6ГБ) роняли
|
| 389 |
+
# контейнер для всех чатов (аудит D1, 30.09.2026). Обычный пост весит до 50МБ.
|
| 390 |
+
TIKTOK_SLIDESHOW_MAX_BYTES = _env_number("TIKTOK_SLIDESHOW_MAX_BYTES", 200 * 1024 * 1024, cast=int, min_value=1024 * 1024)
|
| 391 |
|
| 392 |
# Состояние/квоты/локи живут в lumen_chat_state.py (P2): здесь только реэкспорт
|
| 393 |
# имён (те же объекты — тесты мутируют bot.chat_state/bot.GLOBAL_QUOTA как раньше).
|
|
|
|
| 532 |
_edit_message_quietly,
|
| 533 |
)
|
| 534 |
from lumen_media_flow import (
|
| 535 |
+
_MediaTooLargeError,
|
| 536 |
_download_telegram_file_bytes,
|
| 537 |
_save_media_to_history,
|
| 538 |
_download_message_attachment_to_tmp,
|
|
|
|
| 850 |
"_delete_message_quietly",
|
| 851 |
"_edit_message_quietly",
|
| 852 |
"_download_telegram_file_bytes",
|
| 853 |
+
"_MediaTooLargeError",
|
| 854 |
"_save_media_to_history",
|
| 855 |
"_download_message_attachment_to_tmp",
|
| 856 |
"_fetch_media",
|
|
|
|
| 1219 |
_process_raw_update,
|
| 1220 |
)
|
| 1221 |
|
| 1222 |
+
# setWebhook: попыток с экспоненциальным бэкоффом от базы (аудит D4, 30.09.2026).
|
| 1223 |
+
SETWEBHOOK_MAX_ATTEMPTS = 5
|
| 1224 |
+
SETWEBHOOK_BACKOFF_SEC = 5.0
|
| 1225 |
+
|
| 1226 |
async def _webhook_startup() -> None:
|
| 1227 |
# Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
|
| 1228 |
await asyncio.to_thread(load_state_from_disk)
|
|
|
|
| 1297 |
except Exception as exc:
|
| 1298 |
log.warning("[webhook] deleteWebhook failed (will need manual setup): %s", exc)
|
| 1299 |
|
| 1300 |
+
# setWebhook с ретраями и экспоненциальным бэкоффом: провал без ретрая
|
| 1301 |
+
# оставлял тихий мёртвый бот (аудит D4, 30.09.2026). После исчерпания —
|
| 1302 |
+
# RuntimeError наверх: main останавливает сервер и выходит кодом 1,
|
| 1303 |
+
# чтобы платформа перезапустила контейнер, а не служила пустоту.
|
| 1304 |
+
webhook_ok = False
|
| 1305 |
+
backoff = SETWEBHOOK_BACKOFF_SEC
|
| 1306 |
+
for attempt in range(1, SETWEBHOOK_MAX_ATTEMPTS + 1):
|
| 1307 |
+
try:
|
| 1308 |
+
await asyncio.wait_for(
|
| 1309 |
+
telegram_api_call("setWebhook", {
|
| 1310 |
+
"url": webhook_url,
|
| 1311 |
+
"secret_token": WEBHOOK_SECRET,
|
| 1312 |
+
# drop_pending_updates=True — решение владельца (27.09.2026): после
|
| 1313 |
+
# простоя не отвечать пачкой на старые сообщения. Цена — сообщения,
|
| 1314 |
+
# пришедшие пока бот лежал, теряются.
|
| 1315 |
+
"drop_pending_updates": True,
|
| 1316 |
+
"allowed_updates": ALLOWED_UPDATES,
|
| 1317 |
+
}, request_timeout=15.0),
|
| 1318 |
+
timeout=18.0
|
| 1319 |
+
)
|
| 1320 |
+
webhook_ok = True
|
| 1321 |
+
break
|
| 1322 |
+
except Exception as exc:
|
| 1323 |
+
log.warning(
|
| 1324 |
+
"[webhook] setWebhook attempt %d/%d failed: %s",
|
| 1325 |
+
attempt, SETWEBHOOK_MAX_ATTEMPTS, exc,
|
| 1326 |
+
)
|
| 1327 |
+
if attempt < SETWEBHOOK_MAX_ATTEMPTS:
|
| 1328 |
+
await asyncio.sleep(backoff)
|
| 1329 |
+
backoff *= 2
|
| 1330 |
+
if webhook_ok:
|
| 1331 |
log.info("[webhook] Webhook registered successfully: %s", webhook_url)
|
| 1332 |
+
else:
|
| 1333 |
# Секрет в лог не подставляем — готовая ссылка живёт в /webhook_url (Bearer ADMIN_PANEL_KEY, не query).
|
| 1334 |
+
log.error(
|
| 1335 |
+
'[webhook] setWebhook failed after %d attempts — register it manually via: curl -H "Authorization: Bearer <ADMIN_PANEL_KEY>" https://.../webhook_url (get ADMIN_PANEL_KEY via curl -H "Authorization: Bearer <BOT_TOKEN>" .../admin_keys if you don\'t have it handy)',
|
| 1336 |
+
SETWEBHOOK_MAX_ATTEMPTS,
|
| 1337 |
)
|
| 1338 |
+
return False
|
| 1339 |
|
| 1340 |
cmd_payloads = [
|
| 1341 |
{"commands": [{"command": c.command, "description": c.description} for c in commands]}
|
|
|
|
| 1361 |
log.info("[webhook] Bot commands set successfully.")
|
| 1362 |
else:
|
| 1363 |
log.warning("[webhook] Bot commands were not set for any language.")
|
| 1364 |
+
return webhook_ok
|
| 1365 |
|
| 1366 |
+
setup_ok = await try_setup()
|
| 1367 |
+
if not setup_ok:
|
| 1368 |
+
raise RuntimeError("setWebhook failed after retries — refusing to serve a dead webhook")
|
| 1369 |
|
| 1370 |
log.info("[webhook] Bot is running in webhook mode. Updates arrive via POST /webhook")
|
| 1371 |
# Суточные перепроверки моделей в часовом цикле: истёкшее промо иначе видно
|
|
|
|
| 1440 |
)
|
| 1441 |
server = uvicorn.Server(srv_config)
|
| 1442 |
try:
|
| 1443 |
+
serve_task = asyncio.create_task(server.serve(), name="uvicorn_serve")
|
| 1444 |
+
done, _pending = await asyncio.wait(
|
| 1445 |
+
{startup_task, serve_task}, return_when=asyncio.FIRST_COMPLETED,
|
| 1446 |
+
)
|
| 1447 |
+
# Стартап бесконечный (часовой цикл в хвосте): завершился — значит упал.
|
| 1448 |
+
# Раньше падение было тихим (await только в finally) — процесс служил
|
| 1449 |
+
# /webhook, который Telegram не вызывал (аудит D4, 30.09.2026).
|
| 1450 |
+
if startup_task in done and not startup_task.cancelled():
|
| 1451 |
+
exc = startup_task.exception()
|
| 1452 |
+
if exc is not None:
|
| 1453 |
+
log.error("[webhook] Startup failed, stopping the server: %s", exc)
|
| 1454 |
+
server.should_exit = True
|
| 1455 |
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
| 1456 |
+
await serve_task
|
| 1457 |
+
raise SystemExit(1) from exc
|
| 1458 |
finally:
|
| 1459 |
startup_task.cancel()
|
| 1460 |
flush_task.cancel()
|
docs/ARCHITECTURE.md
CHANGED
|
@@ -16,9 +16,9 @@ Routing is fully automatic: the router builds a fresh candidate list for every m
|
|
| 16 |
|
| 17 |
- **A YouTube or website link** → Gemini only. It's the only provider that can read page content (`url_context`) or analyze a video by URL.
|
| 18 |
- **A video or audio attachment** → video goes to Gemini only (OpenRouter's multimodal models only accept images as base64). Voice/audio is first transcribed cheaply (Groq Whisper) and the text joins the normal routing below; if transcription fails or is unavailable, the audio goes to Gemini as before.
|
| 19 |
-
- **An image attachment, no live-info need** → free OpenRouter vision models first, Gemini as a reserve.
|
| 20 |
- **Needs current information** (a lightweight keyword heuristic: "now," "today," "price," "who is currently...") → Gemini, prioritizing the models with a real search-grounding quota, then OpenRouter as a reserve, Groq last (knowledge-only answer if both are down).
|
| 21 |
-
- **Plain text, no attachments, no freshness need** (the most common case) → Groq first (1000 free requests/day: Qwen, then gpt-oss), then OpenRouter. Heavier requests (code, multi-step reasoning, caught by another lightweight heuristic)
|
| 22 |
|
| 23 |
The reasoning: Gemini's free quota (roughly 20 requests per day for the flagship model) is the scarcest resource in the system, so it's only spent where a capability unique to Gemini is actually needed. Everything else, the bulk of ordinary messages, runs on Groq (1000/day) and OpenRouter's free tiers.
|
| 24 |
|
|
|
|
| 16 |
|
| 17 |
- **A YouTube or website link** → Gemini only. It's the only provider that can read page content (`url_context`) or analyze a video by URL.
|
| 18 |
- **A video or audio attachment** → video goes to Gemini only (OpenRouter's multimodal models only accept images as base64). Voice/audio is first transcribed cheaply (Groq Whisper) and the text joins the normal routing below; if transcription fails or is unavailable, the audio goes to Gemini as before.
|
| 19 |
+
- **An image attachment, no live-info need** → free OpenRouter vision models first, Gemini as a reserve. With a freshness need, Gemini search models go first and OpenRouter vision stays as a fallback.
|
| 20 |
- **Needs current information** (a lightweight keyword heuristic: "now," "today," "price," "who is currently...") → Gemini, prioritizing the models with a real search-grounding quota, then OpenRouter as a reserve, Groq last (knowledge-only answer if both are down).
|
| 21 |
+
- **Plain text, no attachments, no freshness need** (the most common case) → Groq first (1000 free requests/day: Qwen, then gpt-oss), then OpenRouter. Heavier requests (code, multi-step reasoning, caught by another lightweight heuristic) also start at Groq to spare the scarcer OpenRouter/Gemini quotas, then go to the stronger free OpenRouter models, Gemini as reserve.
|
| 22 |
|
| 23 |
The reasoning: Gemini's free quota (roughly 20 requests per day for the flagship model) is the scarcest resource in the system, so it's only spent where a capability unique to Gemini is actually needed. Everything else, the bulk of ordinary messages, runs on Groq (1000/day) and OpenRouter's free tiers.
|
| 24 |
|
docs/ENVIRONMENT.md
CHANGED
|
@@ -61,9 +61,11 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
|
|
| 61 |
| `TELEGRAM_REQUEST_TIMEOUT` | `45s` | Timeout for HTTP calls to the Telegram API. |
|
| 62 |
| `TELEGRAM_AI_TIMEOUT` | `45s` | Budget for the Gemini retry after `MALFORMED_FUNCTION_CALL` inside the main chat route (capped by the remaining `ROUTE_TOTAL_BUDGET_SEC`). Not used by `/tts` — that lives on `TTS_SYNTH_TIMEOUT_SEC`. |
|
| 63 |
| `TELEGRAM_MEDIA_TIMEOUT` | `25s` | Timeout for downloading media files from Telegram. |
|
|
|
|
| 64 |
| `TELEGRAM_GET_FILE_TIMEOUT` | `15s` | Timeout for the `getFile` metadata call before a download. |
|
| 65 |
| `TTS_MAX_CHARS` | `800` | Size of one `/tts` synthesis chunk; up to `TTS_MAX_PARTS` (5) chunks are synthesized, so the accepted maximum is `TTS_MAX_CHARS × TTS_MAX_PARTS` (4000 by default). |
|
| 66 |
| `TTS_SYNTH_TIMEOUT_SEC` | `60s` | Timeout for a single TTS synthesis call. Also passed into the SDK as `http_options.timeout`, so a hung provider cannot hold the chat lock. |
|
|
|
|
| 67 |
| `RATE_LIMIT_MAX_REQUESTS` | `5` | Max requests per user within `RATE_LIMIT_WINDOW_SEC`. |
|
| 68 |
| `RATE_LIMIT_WINDOW_SEC` | `30s` | Sliding window width for rate limiting. |
|
| 69 |
| `ROUTE_MODEL_TIMEOUT_SEC` | `22s` | Timeout for a single attempt at a single model. No retries: any failure moves straight to the next model. |
|
|
@@ -88,6 +90,7 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
|
|
| 88 |
| Variable | Default | Purpose |
|
| 89 |
|---|---|---|
|
| 90 |
| `TIKTOK_DOWNLOAD_MAX_BYTES` | `75 MB` | Hard cap on any single downloaded TikTok file (video, slide, cover or music), aborted mid-stream if exceeded. |
|
|
|
|
| 91 |
| `TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY` | `8` | Max slideshow slides downloaded in parallel; keeps one large post from hogging the shared HTTP connection pool. |
|
| 92 |
| `TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY` | `4` | Max concurrent `ffprobe`/`ffmpeg` processes when probing "live" video slides in a slideshow. |
|
| 93 |
|
|
|
|
| 61 |
| `TELEGRAM_REQUEST_TIMEOUT` | `45s` | Timeout for HTTP calls to the Telegram API. |
|
| 62 |
| `TELEGRAM_AI_TIMEOUT` | `45s` | Budget for the Gemini retry after `MALFORMED_FUNCTION_CALL` inside the main chat route (capped by the remaining `ROUTE_TOTAL_BUDGET_SEC`). Not used by `/tts` — that lives on `TTS_SYNTH_TIMEOUT_SEC`. |
|
| 63 |
| `TELEGRAM_MEDIA_TIMEOUT` | `25s` | Timeout for downloading media files from Telegram. |
|
| 64 |
+
| `TELEGRAM_DOWNLOAD_MAX_BYTES` | `20971520` (20 MB) | Cap on a single Telegram download (Bot API doesn't serve bigger files via `getFile`); bigger files are refused before downloading with an honest user-facing message. |
|
| 65 |
| `TELEGRAM_GET_FILE_TIMEOUT` | `15s` | Timeout for the `getFile` metadata call before a download. |
|
| 66 |
| `TTS_MAX_CHARS` | `800` | Size of one `/tts` synthesis chunk; up to `TTS_MAX_PARTS` (5) chunks are synthesized, so the accepted maximum is `TTS_MAX_CHARS × TTS_MAX_PARTS` (4000 by default). |
|
| 67 |
| `TTS_SYNTH_TIMEOUT_SEC` | `60s` | Timeout for a single TTS synthesis call. Also passed into the SDK as `http_options.timeout`, so a hung provider cannot hold the chat lock. |
|
| 68 |
+
| `TTS_TOTAL_BUDGET_SEC` | `240s` | Total deadline for all chunks of one multi-chunk TTS request; past it the text is cut and the user is told only the beginning was voiced. |
|
| 69 |
| `RATE_LIMIT_MAX_REQUESTS` | `5` | Max requests per user within `RATE_LIMIT_WINDOW_SEC`. |
|
| 70 |
| `RATE_LIMIT_WINDOW_SEC` | `30s` | Sliding window width for rate limiting. |
|
| 71 |
| `ROUTE_MODEL_TIMEOUT_SEC` | `22s` | Timeout for a single attempt at a single model. No retries: any failure moves straight to the next model. |
|
|
|
|
| 90 |
| Variable | Default | Purpose |
|
| 91 |
|---|---|---|
|
| 92 |
| `TIKTOK_DOWNLOAD_MAX_BYTES` | `75 MB` | Hard cap on any single downloaded TikTok file (video, slide, cover or music), aborted mid-stream if exceeded. |
|
| 93 |
+
| `TIKTOK_SLIDESHOW_MAX_BYTES` | `200 MB` | Total RAM cap for one slideshow post; slides past it are skipped (a typical post is under 50 MB). |
|
| 94 |
| `TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY` | `8` | Max slideshow slides downloaded in parallel; keeps one large post from hogging the shared HTTP connection pool. |
|
| 95 |
| `TIKTOK_VIDEO_SLIDE_PROBE_CONCURRENCY` | `4` | Max concurrent `ffprobe`/`ffmpeg` processes when probing "live" video slides in a slideshow. |
|
| 96 |
|
lumen_commands.py
CHANGED
|
@@ -313,9 +313,25 @@ async def inline_tts(message: Message, text: str) -> None:
|
|
| 313 |
)
|
| 314 |
return
|
| 315 |
status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_voicing"))
|
|
|
|
|
|
|
|
|
|
| 316 |
try:
|
| 317 |
# Весь синтез ДО отправки: упавший кусок — одна ошибка вместо рваного "пол-ответа + ошибка".
|
| 318 |
-
voices = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
except Exception as exc:
|
| 320 |
log.exception("TTS synthesis failed:")
|
| 321 |
# Сырой текст ошибки содержит ID моделей — показываем классифицированный текст.
|
|
@@ -344,6 +360,9 @@ async def inline_tts(message: Message, text: str) -> None:
|
|
| 344 |
)
|
| 345 |
if sent is None:
|
| 346 |
raise RuntimeError("Telegram send_voice failed: connection timeout or proxy unavailable")
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
async def cmd_tts(message: Message) -> None:
|
| 349 |
import bot
|
|
|
|
| 313 |
)
|
| 314 |
return
|
| 315 |
status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_voicing"))
|
| 316 |
+
# Общий дедлайн на все чанки: зависший синтез иначе держал per-chat lock без края.
|
| 317 |
+
deadline = time.monotonic() + bot.TTS_TOTAL_BUDGET_SEC
|
| 318 |
+
shortened = False
|
| 319 |
try:
|
| 320 |
# Весь синтез ДО отправки: упавший кусок — одна ошибка вместо рваного "пол-ответа + ошибка".
|
| 321 |
+
voices = []
|
| 322 |
+
for chunk in chunks:
|
| 323 |
+
remaining = deadline - time.monotonic()
|
| 324 |
+
if remaining <= 0:
|
| 325 |
+
shortened = True
|
| 326 |
+
break
|
| 327 |
+
try:
|
| 328 |
+
voices.append(await asyncio.wait_for(_synthesize_tts_voice(chunk), timeout=remaining))
|
| 329 |
+
except asyncio.TimeoutError:
|
| 330 |
+
shortened = True
|
| 331 |
+
break
|
| 332 |
+
if not voices:
|
| 333 |
+
# Даже первый кусок не уложился — честная ошибка, а не пустота.
|
| 334 |
+
raise RuntimeError("TTS total budget exceeded before the first chunk")
|
| 335 |
except Exception as exc:
|
| 336 |
log.exception("TTS synthesis failed:")
|
| 337 |
# Сырой текст ошибки содержит ID моделей — показываем классифицированный текст.
|
|
|
|
| 360 |
)
|
| 361 |
if sent is None:
|
| 362 |
raise RuntimeError("Telegram send_voice failed: connection timeout or proxy unavailable")
|
| 363 |
+
if shortened:
|
| 364 |
+
# Обрезка по общему дедлайну — говорим прямо, что озвучено начало.
|
| 365 |
+
await bot._safe_reply(message, bot._t(message.chat.id, "tts_shortened"))
|
| 366 |
|
| 367 |
async def cmd_tts(message: Message) -> None:
|
| 368 |
import bot
|
lumen_lang.py
CHANGED
|
@@ -339,6 +339,22 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
| 339 |
"ru": "Временная ошибка сервиса. Попробуй чуть позже.",
|
| 340 |
"uk": "Тимчасова помилка сервісу. Спробуй трохи пізніше.",
|
| 341 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
"err_quota_exhausted": {
|
| 343 |
"be": "Бясплатны ліміт запытаў вычарпаны — гэта рэальны сутачны ліміт сэрвісу, а не памылка. Паспрабуй пазней.",
|
| 344 |
"en": "The free request limit is exhausted — that's the service's real daily limit, not an error. Try again later.",
|
|
@@ -751,6 +767,8 @@ PACK_PT: dict[str, str] = {
|
|
| 751 |
"tiktok_dl_as_is": "Não encontrei versão sem marcas d'água — baixando como está",
|
| 752 |
"tiktok_dl_plain": "Baixando vídeo sem marcas d'água",
|
| 753 |
"tiktok_too_big": "Este vídeo do TikTok é grande demais para enviar — a API de bots do Telegram limita o envio de arquivos a 50 MB. Tente baixar este vídeo de outro jeito.",
|
|
|
|
|
|
|
| 754 |
"tiktok_no_media": "Link reconhecido, mas o TikTok não devolveu nem vídeo nem fotos — talvez o conteúdo tenha sido excluído ou esteja indisponível.",
|
| 755 |
"tiktok_processing": "Processando o link do TikTok",
|
| 756 |
"tiktok_fetch_fail": "Não consegui obter o vídeo deste link — talvez seja privado, foi excluído, bloqueado por região ou o link está quebrado.",
|
|
@@ -833,6 +851,8 @@ PACK_AR: dict[str, str] = {
|
|
| 833 |
"tiktok_dl_as_is": "لم توجد نسخة دون علامات مائية — جارٍ التنزيل كما هو",
|
| 834 |
"tiktok_dl_plain": "جارٍ تنزيل الفيديو دون علامات مائية",
|
| 835 |
"tiktok_too_big": "هذا الفيديو من TikTok كبير جدًا للإرسال — واجهة Telegram Bot API تحدّ تحميل الملفات بـ 50 م.ب. حاول تنزيل الفيديو بطريقة أخرى.",
|
|
|
|
|
|
|
| 836 |
"tiktok_no_media": "تم التعرف على الرابط لكن TikTok لم يعطِ فيديو ولا صور — ربما المحتوى محذوف أو غير متاح.",
|
| 837 |
"tiktok_processing": "جارٍ معالجة رابط TikTok",
|
| 838 |
"tiktok_fetch_fail": "تعذر جلب الفيديو لهذا الرابط ��� ربما خاص أو محذوف أو محظور إقليميًا أو الرابط تالف.",
|
|
@@ -915,6 +935,8 @@ PACK_TR: dict[str, str] = {
|
|
| 915 |
"tiktok_dl_as_is": "Filigransız sürüm bulunamadı — olduğu gibi indiriliyor",
|
| 916 |
"tiktok_dl_plain": "Filigransız video indiriliyor",
|
| 917 |
"tiktok_too_big": "Bu TikTok videosu göndermek için çok büyük — Telegram Bot API dosya yüklemeyi 50 MB ile sınırlıyor. Videoyu başka yolla indirmeyi dene.",
|
|
|
|
|
|
|
| 918 |
"tiktok_no_media": "Bağlantı tanındı ama TikTok ne video ne fotoğraf verdi — içerik silinmiş veya kullanılamıyor olabilir.",
|
| 919 |
"tiktok_processing": "TikTok bağlantısı işleniyor",
|
| 920 |
"tiktok_fetch_fail": "Bu bağlantıdan video alınamadı — özel, silinmiş, bölge engelli olabilir veya bağlantı bozuktur.",
|
|
@@ -997,6 +1019,8 @@ PACK_DE: dict[str, str] = {
|
|
| 997 |
"tiktok_dl_as_is": "Keine Version ohne Wasserzeichen gefunden — lade wie es ist",
|
| 998 |
"tiktok_dl_plain": "Video ohne Wasserzeichen wird geladen",
|
| 999 |
"tiktok_too_big": "Dieses TikTok-Video ist zu groß zum Senden — die Telegram Bot API begrenzt Datei-Uploads auf 50 MB. Video auf anderem Weg laden.",
|
|
|
|
|
|
|
| 1000 |
"tiktok_no_media": "Link erkannt, aber TikTok gab weder Video noch Fotos dazu — Inhalt vielleicht gelöscht oder nicht verfügbar.",
|
| 1001 |
"tiktok_processing": "TikTok-Link wird verarbeitet",
|
| 1002 |
"tiktok_fetch_fail": "Video zu diesem Link konnte nicht geholt werden — vielleicht privat, gelöscht, regionsgesperrt oder Link defekt.",
|
|
@@ -1079,6 +1103,8 @@ PACK_FR: dict[str, str] = {
|
|
| 1079 |
"tiktok_dl_as_is": "Aucune version sans filigrane trouvée — téléchargement tel quel",
|
| 1080 |
"tiktok_dl_plain": "Téléchargement de la vidéo sans filigrane",
|
| 1081 |
"tiktok_too_big": "Cette vidéo TikTok est trop grosse pour être envoyée — l'API Bot de Telegram limite l'envoi de fichiers à 50 Mo. Essaie de télécharger cette vidéo autrement.",
|
|
|
|
|
|
|
| 1082 |
"tiktok_no_media": "Lien reconnu, mais TikTok n'a donné ni vidéo ni photos — le contenu est peut-être supprimé ou indisponible.",
|
| 1083 |
"tiktok_processing": "Traitement du lien TikTok",
|
| 1084 |
"tiktok_fetch_fail": "Impossible de récupérer la vidéo de ce lien — peut-être privée, supprimée, bloquée par région ou lien cassé.",
|
|
@@ -1161,6 +1187,8 @@ PACK_IT: dict[str, str] = {
|
|
| 1161 |
"tiktok_dl_as_is": "Nessuna versione senza watermark trovata — scarico com'è",
|
| 1162 |
"tiktok_dl_plain": "Download del video senza watermark",
|
| 1163 |
"tiktok_too_big": "Questo video TikTok è troppo grande per essere inviato — le Bot API di Telegram limitano l'invio di file a 50 MB. Prova a scaricare questo video in un altro modo.",
|
|
|
|
|
|
|
| 1164 |
"tiktok_no_media": "Link riconosciuto, ma TikTok non ha dato né video né foto — forse il contenuto è stato eliminato o non è disponibile.",
|
| 1165 |
"tiktok_processing": "Elaborazione del link TikTok",
|
| 1166 |
"tiktok_fetch_fail": "Impossibile ottenere il video da questo link — forse privato, eliminato, bloccato per regione o link rotto.",
|
|
@@ -1243,6 +1271,8 @@ PACK_HI: dict[str, str] = {
|
|
| 1243 |
"tiktok_dl_as_is": "बिना वॉटरमार्क वाला संस्करण नहीं मिला — जैसा है वैसा डाउनलोड हो रहा है",
|
| 1244 |
"tiktok_dl_plain": "बिना वॉटरमार्क वीडियो डाउनलोड हो रहा है",
|
| 1245 |
"tiktok_too_big": "यह TikTok वीडियो भेजने के लिए बहुत बड़ा है — Telegram Bot API फ़ाइल अपलोड को 50 MB तक सीमित करता है। इस वीडियो को किसी और तरीके से डाउनलोड करने की कोशिश करो।",
|
|
|
|
|
|
|
| 1246 |
"tiktok_no_media": "लिंक पहचाना गया, लेकिन TikTok ने न वीडियो दिया न फ़ोटो — शायद कंटेंट हटाया गया है या उपलब्ध नहीं है।",
|
| 1247 |
"tiktok_processing": "TikTok लिंक संसाधित हो रहा है",
|
| 1248 |
"tiktok_fetch_fail": "इस लिंक से वीडियो लाया नहीं जा सका — शायद निजी है, हटाया गया है, क्षेत्र-प्रतिबंधित है या लिंक टूटा है।",
|
|
@@ -1325,6 +1355,8 @@ PACK_ID: dict[str, str] = {
|
|
| 1325 |
"tiktok_dl_as_is": "Tidak ada versi tanpa watermark — mengunduh apa adanya",
|
| 1326 |
"tiktok_dl_plain": "Mengunduh video tanpa watermark",
|
| 1327 |
"tiktok_too_big": "Video TikTok ini terlalu besar untuk dikirim — Bot API Telegram membatasi unggahan file hingga 50 MB. Coba unduh video ini dengan cara lain.",
|
|
|
|
|
|
|
| 1328 |
"tiktok_no_media": "Tautan dikenali, tetapi TikTok tidak memberi video maupun foto — kontennya mungkin dihapus atau tidak tersedia.",
|
| 1329 |
"tiktok_processing": "Memproses tautan TikTok",
|
| 1330 |
"tiktok_fetch_fail": "Tidak bisa mengambil video dari tautan ini — mungkin privat, dihapus, dibatasi wilayah, atau tautannya rusak.",
|
|
@@ -1407,6 +1439,8 @@ PACK_MS: dict[str, str] = {
|
|
| 1407 |
"tiktok_dl_as_is": "Tiada versi tanpa tera air — memuat turun seadanya",
|
| 1408 |
"tiktok_dl_plain": "Memuat turun video tanpa tera air",
|
| 1409 |
"tiktok_too_big": "Video TikTok ini terlalu besar untuk dihantar — Bot API Telegram mengehadkan muat naik fail hingga 50 MB. Cuba muat turun video ini dengan cara lain.",
|
|
|
|
|
|
|
| 1410 |
"tiktok_no_media": "Pautan dikenal pasti, tetapi TikTok tidak memberi video mahupun foto — kandungannya mungkin dipadam atau tidak tersedia.",
|
| 1411 |
"tiktok_processing": "Memproses pautan TikTok",
|
| 1412 |
"tiktok_fetch_fail": "Tidak dapat mengambil video daripada pautan ini — mungkin peribadi, dipadam, disekat wilayah atau pautannya rosak.",
|
|
@@ -1489,6 +1523,8 @@ PACK_VI: dict[str, str] = {
|
|
| 1489 |
"tiktok_dl_as_is": "Không thấy bản không watermark — tải nguyên như vậy",
|
| 1490 |
"tiktok_dl_plain": "Đang tải video không watermark",
|
| 1491 |
"tiktok_too_big": "Video TikTok này quá lớn để gửi — Bot API Telegram giới hạn tải file lên 50 MB. Thử tải video này bằng cách khác.",
|
|
|
|
|
|
|
| 1492 |
"tiktok_no_media": "Đã nhận ra liên kết, nhưng TikTok không đưa video lẫn ảnh — nội dung có thể đã xóa hoặc không khả dụng.",
|
| 1493 |
"tiktok_processing": "Đang xử lý liên kết TikTok",
|
| 1494 |
"tiktok_fetch_fail": "Không lấy được video từ liên kết này — có thể riêng tư, đã xóa, chặn vùng hoặc link hỏng.",
|
|
@@ -1571,6 +1607,8 @@ PACK_TH: dict[str, str] = {
|
|
| 1571 |
"tiktok_dl_as_is": "ไม่พบเวอร์ชันไม่มีลายน้ำ — ดาวน์โหลดตามสภาพ",
|
| 1572 |
"tiktok_dl_plain": "กำลังดาวน์โหลดวิดีโอแบบไม่มีลายน้ำ",
|
| 1573 |
"tiktok_too_big": "วิดีโอ TikTok นี้ใหญ่เกินกว่าจะส่ง — Bot API ของ Telegram จำกัดอัปโหลดไฟล์ที่ 50 MB ลองดาวน์โหลดวิดีโอนี้ด้วยวิธีอื่น",
|
|
|
|
|
|
|
| 1574 |
"tiktok_no_media": "รู้จักลิงก์แล้ว แต่ TikTok ไม่ให้ทั้งวิดีโอและรูปภาพ — เนื้อหาอาจถูกลบหรือใช้ไม่ได้",
|
| 1575 |
"tiktok_processing": "กำลังประมวลผลลิงก์ TikTok",
|
| 1576 |
"tiktok_fetch_fail": "ดึงวิดีโอจากลิงก์นี้ไม่ได้ — อาจเป็นส่วนตัว ถูกลบ ถูกบล็อกตามภูมิภาค หรือลิงก์เสีย",
|
|
@@ -1653,6 +1691,8 @@ PACK_FA: dict[str, str] = {
|
|
| 1653 |
"tiktok_dl_as_is": "نسخه بدون واترمارک پیدا نشد — همانطور که هست دانلود میشود",
|
| 1654 |
"tiktok_dl_plain": "در حال دانلود ویدیوی بدون واترمارک",
|
| 1655 |
"tiktok_too_big": "این ویدیوی TikTok برای ارسال خیلی بزرگ است — Bot API تلگرام آپلود فایل را به ۵۰ مگابایت محدود میکند. ویدیو را به روش دیگری دانلود کن.",
|
|
|
|
|
|
|
| 1656 |
"tiktok_no_media": "لینک شناخته شد، اما TikTok نه ویدیو داد نه عکس — شاید محتوا حذف شده یا در دسترس نیست.",
|
| 1657 |
"tiktok_processing": "در حال پردازش لینک TikTok",
|
| 1658 |
"tiktok_fetch_fail": "نتوانستم ویدیوی این لینک را بگیرم — شاید خصوصی، حذفشده، محدودشده منطقهای است یا لینک خراب است.",
|
|
@@ -1735,6 +1775,8 @@ PACK_UR: dict[str, str] = {
|
|
| 1735 |
"tiktok_dl_as_is": "بغیر واٹرمارک والا نسخہ نہیں ملا — جیسی ہے ویسی ڈاؤن لوڈ ہو رہی ہے",
|
| 1736 |
"tiktok_dl_plain": "بغیر واٹرمارک ویڈیو ڈاؤن لوڈ ہو رہی ہے",
|
| 1737 |
"tiktok_too_big": "یہ TikTok ویڈیو بھیجنے کے لیے بہت بڑی ہے — Telegram Bot API فائل اپلوڈ کو 50 MB تک محدود کرتا ہے۔ اس ویڈیو کو کسی اور طریقے سے ڈاؤن لوڈ کرنے کی کوشش کرو۔",
|
|
|
|
|
|
|
| 1738 |
"tiktok_no_media": "لنک پہچانا گیا، لیکن TikTok نے نہ ویڈیو دی نہ تصاویر — شاید مواد ہٹایا گیا ہے یا دستیاب نہیں ہے۔",
|
| 1739 |
"tiktok_processing": "TikTok لنک زیرِ عمل ہے",
|
| 1740 |
"tiktok_fetch_fail": "اس لنک سے ویڈیو لائی نہ جا سکی — شاید نجی ہے، ہٹائی گئی ہے، علاقائی پابندی ہے یا لنک ٹوٹا ہے۔",
|
|
@@ -1817,6 +1859,8 @@ PACK_UZ: dict[str, str] = {
|
|
| 1817 |
"tiktok_dl_as_is": "Suv belgisiz nusxa topilmadi — boricha yuklanmoqda",
|
| 1818 |
"tiktok_dl_plain": "Suv belgisiz video yuklanmoqda",
|
| 1819 |
"tiktok_too_big": "Bu TikTok videosi yuborish uchun juda katta — Telegram Bot API fayl yuklashni 50 MB bilan cheklaydi. Videoni boshqa yo'l bilan yuklashga urin.",
|
|
|
|
|
|
|
| 1820 |
"tiktok_no_media": "Havola tanildi, lekin TikTok na video berdi na foto — kontent o'chirilgan yoki mavjud bo'lmasligi mumkin.",
|
| 1821 |
"tiktok_processing": "TikTok havolasi qayta ishlanmoqda",
|
| 1822 |
"tiktok_fetch_fail": "Bu havoladan videoni olib bo'lmadi — shaxsiy, o'chirilgan, hudud cheklovli bo'lishi yoki havola buzilgan bo'lishi mumkin.",
|
|
@@ -1899,6 +1943,8 @@ PACK_BN: dict[str, str] = {
|
|
| 1899 |
"tiktok_dl_as_is": "ওয়াটারমার্ক ছাড়া সংস্করণ পাওয়া যায়নি — যেমন আছে তেমন ডাউনলোড হচ্ছে",
|
| 1900 |
"tiktok_dl_plain": "ওয়াটারমার্ক ছাড়া ভিডিও ডাউনলোড হচ্ছে",
|
| 1901 |
"tiktok_too_big": "এই TikTok ভিডিও প��ঠানোর জন্য অনেক বড় — Telegram Bot API ফাইল আপলোড 50 MB-এ সীমাবদ্ধ। ভিডিওটি অন্য উপায়ে ডাউনলোড করার চেষ্টা করো।",
|
|
|
|
|
|
|
| 1902 |
"tiktok_no_media": "লিংক চেনা গেছে, কিন্তু TikTok ভিডিও বা ছবি কিছুই দেয়নি — হয়তো কন্টেন্ট মোছা বা অনুপলব্ধ।",
|
| 1903 |
"tiktok_processing": "TikTok লিংক প্রক্রিয়াধীন",
|
| 1904 |
"tiktok_fetch_fail": "এই লিংক থেকে ভিডিও আনা যায়নি — হয়তো ব্যক্তিগত, মোছা, অঞ্চল-নিষিদ্ধ বা লিংক নষ্ট।",
|
|
@@ -1981,6 +2027,8 @@ PACK_FIL: dict[str, str] = {
|
|
| 1981 |
"tiktok_dl_as_is": "Walang nahanap na bersyong walang watermark — dina-download kung ano",
|
| 1982 |
"tiktok_dl_plain": "Dina-download ang video nang walang watermark",
|
| 1983 |
"tiktok_too_big": "Masyadong malaki ang TikTok video na ito para i-send — nililimitahan ng Telegram Bot API ang upload ng file sa 50 MB. Subukang i-download ang video sa ibang paraan.",
|
|
|
|
|
|
|
| 1984 |
"tiktok_no_media": "Nakilala ang link, pero walang binigay ang TikTok na video o photo — baka binura o hindi available ang content.",
|
| 1985 |
"tiktok_processing": "Pinoproseso ang TikTok link",
|
| 1986 |
"tiktok_fetch_fail": "Hindi makuha ang video mula sa link na ito — baka private, binura, region-blocked, o sira ang link.",
|
|
@@ -2063,6 +2111,8 @@ PACK_PL: dict[str, str] = {
|
|
| 2063 |
"tiktok_dl_as_is": "Nie znalazłem wersji bez znaków wodnych — pobieram jak jest",
|
| 2064 |
"tiktok_dl_plain": "Pobieram film bez znaków wodnych",
|
| 2065 |
"tiktok_too_big": "Ten film z TikToka jest za duży do wysłania — Bot API Telegram ogranicza wysyłanie plików do 50 MB. Spróbuj pobrać ten film inaczej.",
|
|
|
|
|
|
|
| 2066 |
"tiktok_no_media": "Link rozpoznany, ale TikTok nie oddał ani filmu, ani zdjęć — treść może usunięta albo niedostępna.",
|
| 2067 |
"tiktok_processing": "Przetwarzam link TikToka",
|
| 2068 |
"tiktok_fetch_fail": "Nie udało się pobrać filmu z tego linku — może prywatny, usunięty, zablokowany regionalnie albo link uszkodzony.",
|
|
@@ -2145,6 +2195,8 @@ PACK_NL: dict[str, str] = {
|
|
| 2145 |
"tiktok_dl_as_is": "Geen versie zonder watermerk gevonden — downloaden zoals het is",
|
| 2146 |
"tiktok_dl_plain": "Video zonder watermerk downloaden",
|
| 2147 |
"tiktok_too_big": "Deze TikTok-video is te groot om te verzenden — de Telegram Bot API limiteert bestandsuploads tot 50 MB. Probeer deze video anders te downloaden.",
|
|
|
|
|
|
|
| 2148 |
"tiktok_no_media": "Link herkend, maar TikTok gaf video noch foto's — inhoud misschien verwijderd of niet beschikbaar.",
|
| 2149 |
"tiktok_processing": "TikTok-link verwerken",
|
| 2150 |
"tiktok_fetch_fail": "Video van deze link kon niet worden opgehaald — misschien privé, verwijderd, regiogeblokkeerd of link stuk.",
|
|
@@ -2227,6 +2279,8 @@ PACK_ZU: dict[str, str] = {
|
|
| 2227 |
"tiktok_dl_as_is": "Ayitholakalanga inguqulo engena-watermark — kulanda njengoba injalo",
|
| 2228 |
"tiktok_dl_plain": "Kulanda ividiyo ngaphandle kwe-watermark",
|
| 2229 |
"tiktok_too_big": "Le vidiyo ye-TikTok inkulu kakhulu ukuthunyelwa — i-Telegram Bot API ikhawulela ukulayishwa kwefayela ku-50 MB. Zama ukulanda le vidiyo ngenye indlela.",
|
|
|
|
|
|
|
| 2230 |
"tiktok_no_media": "Isixhumanisi saziwe, kodwa i-TikTok ayinikanga vidiyo noma zithombe — okuqukethwe kungasuswa noma kungatholakali.",
|
| 2231 |
"tiktok_processing": "Kucutshungulwa isixhumanisi se-TikTok",
|
| 2232 |
"tiktok_fetch_fail": "Ayikwazanga ukuthathwa ividiyo kulesi sixhumanisi — mhlawumbe eyimfihlo, isusiwe, ivinjelwe isifunda, noma isixhumanisi sonakele.",
|
|
|
|
| 339 |
"ru": "Временная ошибка сервиса. Попробуй чуть позже.",
|
| 340 |
"uk": "Тимчасова помилка сервісу. Спробуй трохи пізніше.",
|
| 341 |
},
|
| 342 |
+
"tts_shortened": {
|
| 343 |
+
"be": "Агучыла толькі пачатак — увесь тэкст не ўмясціўся ў ліміт часу, астатняе скарочана.",
|
| 344 |
+
"en": "Only managed to voice the beginning — the full text didn't fit the time limit, the rest was cut.",
|
| 345 |
+
"es": "Solo alcancé a ponerle voz al principio — el texto completo no cupo en el límite de tiempo, el resto se ha recortado.",
|
| 346 |
+
"kk": "Басын ғана дыбыстай алдым — толық мәтін уақыт лимитіне сыймады, қалғаны қысқартылды.",
|
| 347 |
+
"ru": "Успела озвучить только начало — весь текст не уложился в лимит времени, остальное сокращено.",
|
| 348 |
+
"uk": "Встигла озвучити лише початок — увесь текст не вмістився в ліміт часу, решту скорочено.",
|
| 349 |
+
},
|
| 350 |
+
"media_too_big": {
|
| 351 |
+
"be": "Гэты файл занадта вялікі для аналізу (ліміт {limit_mb} МБ). Дашлі меншы або апішы словамі — тады змагу дапамагчы.",
|
| 352 |
+
"en": "This file is too big to analyse (limit {limit_mb} MB). Send a smaller one or describe it in words — then I can help.",
|
| 353 |
+
"es": "Este archivo es demasiado grande para analizarlo (límite {limit_mb} MB). Envía uno más pequeño o descríbelo con palabras — entonces podré ayudar.",
|
| 354 |
+
"kk": "Бұл файл талдау үшін тым үлкен (лимит {limit_mb} МБ). Кішірек файл жібер немесе сөзбен сипатта — сонда көмектесемін.",
|
| 355 |
+
"ru": "Этот файл слишком большой для анализа (лимит {limit_mb} МБ). Пришли файл поменьше или опиши словами — тогда смогу помочь.",
|
| 356 |
+
"uk": "Цей файл завеликий для аналізу (ліміт {limit_mb} МБ). Надішли менший або опиши словами — тоді зможу допомогти.",
|
| 357 |
+
},
|
| 358 |
"err_quota_exhausted": {
|
| 359 |
"be": "Бясплатны ліміт запытаў вычарпаны — гэта рэальны сутачны ліміт сэрвісу, а не памылка. Паспрабуй пазней.",
|
| 360 |
"en": "The free request limit is exhausted — that's the service's real daily limit, not an error. Try again later.",
|
|
|
|
| 767 |
"tiktok_dl_as_is": "Não encontrei versão sem marcas d'água — baixando como está",
|
| 768 |
"tiktok_dl_plain": "Baixando vídeo sem marcas d'água",
|
| 769 |
"tiktok_too_big": "Este vídeo do TikTok é grande demais para enviar — a API de bots do Telegram limita o envio de arquivos a 50 MB. Tente baixar este vídeo de outro jeito.",
|
| 770 |
+
"tts_shortened": "Só consegui dar voz ao começo — o texto completo não coube no limite de tempo, o resto foi cortado.",
|
| 771 |
+
"media_too_big": "Este arquivo é grande demais para analisar (limite {limit_mb} MB). Envie um menor ou descreva com palavras — então poderei ajudar.",
|
| 772 |
"tiktok_no_media": "Link reconhecido, mas o TikTok não devolveu nem vídeo nem fotos — talvez o conteúdo tenha sido excluído ou esteja indisponível.",
|
| 773 |
"tiktok_processing": "Processando o link do TikTok",
|
| 774 |
"tiktok_fetch_fail": "Não consegui obter o vídeo deste link — talvez seja privado, foi excluído, bloqueado por região ou o link está quebrado.",
|
|
|
|
| 851 |
"tiktok_dl_as_is": "لم توجد نسخة دون علامات مائية — جارٍ التنزيل كما هو",
|
| 852 |
"tiktok_dl_plain": "جارٍ تنزيل الفيديو دون علامات مائية",
|
| 853 |
"tiktok_too_big": "هذا الفيديو من TikTok كبير جدًا للإرسال — واجهة Telegram Bot API تحدّ تحميل الملفات بـ 50 م.ب. حاول تنزيل الفيديو بطريقة أخرى.",
|
| 854 |
+
"tts_shortened": "تمكنت من التعليق الصوتي على البداية فقط — النص الكامل لم يتسع للمهلة، وتم اختصار الباقي.",
|
| 855 |
+
"media_too_big": "هذا الملف كبير جدًا للتحليل (الحد {limit_mb} م.ب). أرسل ملفًا أصغر أو صِفه بالكلمات — وسأساعدك.",
|
| 856 |
"tiktok_no_media": "تم التعرف على الرابط لكن TikTok لم يعطِ فيديو ولا صور — ربما المحتوى محذوف أو غير متاح.",
|
| 857 |
"tiktok_processing": "جارٍ معالجة رابط TikTok",
|
| 858 |
"tiktok_fetch_fail": "تعذر جلب الفيديو لهذا الرابط ��� ربما خاص أو محذوف أو محظور إقليميًا أو الرابط تالف.",
|
|
|
|
| 935 |
"tiktok_dl_as_is": "Filigransız sürüm bulunamadı — olduğu gibi indiriliyor",
|
| 936 |
"tiktok_dl_plain": "Filigransız video indiriliyor",
|
| 937 |
"tiktok_too_big": "Bu TikTok videosu göndermek için çok büyük — Telegram Bot API dosya yüklemeyi 50 MB ile sınırlıyor. Videoyu başka yolla indirmeyi dene.",
|
| 938 |
+
"tts_shortened": "Sadece başlangıcı seslendirebildim — metnin tamamı süre limitine sığmadı, kalanı kısaltıldı.",
|
| 939 |
+
"media_too_big": "Bu dosya analiz için çok büyük (limit {limit_mb} MB). Daha küçüğünü gönder ya da kelimelerle anlat — o zaman yardım edebilirim.",
|
| 940 |
"tiktok_no_media": "Bağlantı tanındı ama TikTok ne video ne fotoğraf verdi — içerik silinmiş veya kullanılamıyor olabilir.",
|
| 941 |
"tiktok_processing": "TikTok bağlantısı işleniyor",
|
| 942 |
"tiktok_fetch_fail": "Bu bağlantıdan video alınamadı — özel, silinmiş, bölge engelli olabilir veya bağlantı bozuktur.",
|
|
|
|
| 1019 |
"tiktok_dl_as_is": "Keine Version ohne Wasserzeichen gefunden — lade wie es ist",
|
| 1020 |
"tiktok_dl_plain": "Video ohne Wasserzeichen wird geladen",
|
| 1021 |
"tiktok_too_big": "Dieses TikTok-Video ist zu groß zum Senden — die Telegram Bot API begrenzt Datei-Uploads auf 50 MB. Video auf anderem Weg laden.",
|
| 1022 |
+
"tts_shortened": "Ich habe nur den Anfang vertont — der ganze Text hat das Zeitlimit gesprengt, der Rest wurde gekürzt.",
|
| 1023 |
+
"media_too_big": "Diese Datei ist zu groß für die Analyse (Limit {limit_mb} MB). Schick eine kleinere oder beschreib sie mit Worten — dann kann ich helfen.",
|
| 1024 |
"tiktok_no_media": "Link erkannt, aber TikTok gab weder Video noch Fotos dazu — Inhalt vielleicht gelöscht oder nicht verfügbar.",
|
| 1025 |
"tiktok_processing": "TikTok-Link wird verarbeitet",
|
| 1026 |
"tiktok_fetch_fail": "Video zu diesem Link konnte nicht geholt werden — vielleicht privat, gelöscht, regionsgesperrt oder Link defekt.",
|
|
|
|
| 1103 |
"tiktok_dl_as_is": "Aucune version sans filigrane trouvée — téléchargement tel quel",
|
| 1104 |
"tiktok_dl_plain": "Téléchargement de la vidéo sans filigrane",
|
| 1105 |
"tiktok_too_big": "Cette vidéo TikTok est trop grosse pour être envoyée — l'API Bot de Telegram limite l'envoi de fichiers à 50 Mo. Essaie de télécharger cette vidéo autrement.",
|
| 1106 |
+
"tts_shortened": "Je n'ai pu mettre en voix que le début — le texte entier n'a pas tenu dans la limite de temps, la suite a été coupée.",
|
| 1107 |
+
"media_too_big": "Ce fichier est trop gros pour être analysé (limite {limit_mb} Mo). Envoie-en un plus petit ou décris-le avec des mots — je pourrai aider.",
|
| 1108 |
"tiktok_no_media": "Lien reconnu, mais TikTok n'a donné ni vidéo ni photos — le contenu est peut-être supprimé ou indisponible.",
|
| 1109 |
"tiktok_processing": "Traitement du lien TikTok",
|
| 1110 |
"tiktok_fetch_fail": "Impossible de récupérer la vidéo de ce lien — peut-être privée, supprimée, bloquée par région ou lien cassé.",
|
|
|
|
| 1187 |
"tiktok_dl_as_is": "Nessuna versione senza watermark trovata — scarico com'è",
|
| 1188 |
"tiktok_dl_plain": "Download del video senza watermark",
|
| 1189 |
"tiktok_too_big": "Questo video TikTok è troppo grande per essere inviato — le Bot API di Telegram limitano l'invio di file a 50 MB. Prova a scaricare questo video in un altro modo.",
|
| 1190 |
+
"tts_shortened": "Sono riuscita a dare voce solo all'inizio — l'intero testo non è entrato nel limite di tempo, il resto è stato tagliato.",
|
| 1191 |
+
"media_too_big": "Questo file è troppo grande per essere analizzato (limite {limit_mb} MB). Inviane uno più piccolo o descrivelo a parole — così potrò aiutare.",
|
| 1192 |
"tiktok_no_media": "Link riconosciuto, ma TikTok non ha dato né video né foto — forse il contenuto è stato eliminato o non è disponibile.",
|
| 1193 |
"tiktok_processing": "Elaborazione del link TikTok",
|
| 1194 |
"tiktok_fetch_fail": "Impossibile ottenere il video da questo link — forse privato, eliminato, bloccato per regione o link rotto.",
|
|
|
|
| 1271 |
"tiktok_dl_as_is": "बिना वॉटरमार्क वाला संस्करण नहीं मिला — जैसा है वैसा डाउनलोड हो रहा है",
|
| 1272 |
"tiktok_dl_plain": "बिना वॉटरमार्क वीडियो डाउनलोड हो रहा है",
|
| 1273 |
"tiktok_too_big": "यह TikTok वीडियो भेजने के लिए बहुत बड़ा है — Telegram Bot API फ़ाइल अपलोड को 50 MB तक सीमित करता है। इस वीडियो को किसी और तरीके से डाउनलोड करने की कोशिश करो।",
|
| 1274 |
+
"tts_shortened": "सिर्फ शुरुआत को आवाज़ दे पाई — पूरा पाठ समय सीमा में नहीं समाया, बाकी काट दिया।",
|
| 1275 |
+
"media_too_big": "यह फ़ाइल विश्लेषण के लिए बहुत बड़ी है (सीमा {limit_mb} MB)। छोटी फ़ाइल भेजो या शब्दों में बताओ — तब मदद कर पाऊंगा।",
|
| 1276 |
"tiktok_no_media": "लिंक पहचाना गया, लेकिन TikTok ने न वीडियो दिया न फ़ोटो — शायद कंटेंट हटाया गया है या उपलब्ध नहीं है।",
|
| 1277 |
"tiktok_processing": "TikTok लिंक संसाधित हो रहा है",
|
| 1278 |
"tiktok_fetch_fail": "इस लिंक से वीडियो लाया नहीं जा सका — शायद निजी है, हटाया गया है, क्षेत्र-प्रतिबंधित है या लिंक टूटा है।",
|
|
|
|
| 1355 |
"tiktok_dl_as_is": "Tidak ada versi tanpa watermark — mengunduh apa adanya",
|
| 1356 |
"tiktok_dl_plain": "Mengunduh video tanpa watermark",
|
| 1357 |
"tiktok_too_big": "Video TikTok ini terlalu besar untuk dikirim — Bot API Telegram membatasi unggahan file hingga 50 MB. Coba unduh video ini dengan cara lain.",
|
| 1358 |
+
"tts_shortened": "Hanya sempat menyuarakan bagian awal — teks lengkap tidak muat dalam batas waktu, sisanya dipotong.",
|
| 1359 |
+
"media_too_big": "File ini terlalu besar untuk dianalisis (batas {limit_mb} MB). Kirim yang lebih kecil atau jelaskan dengan kata-kata — maka saya bisa membantu.",
|
| 1360 |
"tiktok_no_media": "Tautan dikenali, tetapi TikTok tidak memberi video maupun foto — kontennya mungkin dihapus atau tidak tersedia.",
|
| 1361 |
"tiktok_processing": "Memproses tautan TikTok",
|
| 1362 |
"tiktok_fetch_fail": "Tidak bisa mengambil video dari tautan ini — mungkin privat, dihapus, dibatasi wilayah, atau tautannya rusak.",
|
|
|
|
| 1439 |
"tiktok_dl_as_is": "Tiada versi tanpa tera air — memuat turun seadanya",
|
| 1440 |
"tiktok_dl_plain": "Memuat turun video tanpa tera air",
|
| 1441 |
"tiktok_too_big": "Video TikTok ini terlalu besar untuk dihantar — Bot API Telegram mengehadkan muat naik fail hingga 50 MB. Cuba muat turun video ini dengan cara lain.",
|
| 1442 |
+
"tts_shortened": "Hanya sempat menyuarakan bahagian awal — teks penuh tidak muat dalam had masa, selebihnya dipotong.",
|
| 1443 |
+
"media_too_big": "Fail ini terlalu besar untuk dianalisis (had {limit_mb} MB). Hantar yang lebih kecil atau terangkan dengan kata-kata — maka saya boleh membantu.",
|
| 1444 |
"tiktok_no_media": "Pautan dikenal pasti, tetapi TikTok tidak memberi video mahupun foto — kandungannya mungkin dipadam atau tidak tersedia.",
|
| 1445 |
"tiktok_processing": "Memproses pautan TikTok",
|
| 1446 |
"tiktok_fetch_fail": "Tidak dapat mengambil video daripada pautan ini — mungkin peribadi, dipadam, disekat wilayah atau pautannya rosak.",
|
|
|
|
| 1523 |
"tiktok_dl_as_is": "Không thấy bản không watermark — tải nguyên như vậy",
|
| 1524 |
"tiktok_dl_plain": "Đang tải video không watermark",
|
| 1525 |
"tiktok_too_big": "Video TikTok này quá lớn để gửi — Bot API Telegram giới hạn tải file lên 50 MB. Thử tải video này bằng cách khác.",
|
| 1526 |
+
"tts_shortened": "Tôi chỉ kịp lồng tiếng phần đầu — toàn văn không vừa giới hạn thời gian, phần còn lại đã bị cắt.",
|
| 1527 |
+
"media_too_big": "Tệp này quá lớn để phân tích (giới hạn {limit_mb} MB). Hãy gửi tệp nhỏ hơn hoặc mô tả bằng lời — tôi sẽ giúp được.",
|
| 1528 |
"tiktok_no_media": "Đã nhận ra liên kết, nhưng TikTok không đưa video lẫn ảnh — nội dung có thể đã xóa hoặc không khả dụng.",
|
| 1529 |
"tiktok_processing": "Đang xử lý liên kết TikTok",
|
| 1530 |
"tiktok_fetch_fail": "Không lấy được video từ liên kết này — có thể riêng tư, đã xóa, chặn vùng hoặc link hỏng.",
|
|
|
|
| 1607 |
"tiktok_dl_as_is": "ไม่พบเวอร์ชันไม่มีลายน้ำ — ดาวน์โหลดตามสภาพ",
|
| 1608 |
"tiktok_dl_plain": "กำลังดาวน์โหลดวิดีโอแบบไม่มีลายน้ำ",
|
| 1609 |
"tiktok_too_big": "วิดีโอ TikTok นี้ใหญ่เกินกว่าจะส่ง — Bot API ของ Telegram จำกัดอัปโหลดไฟล์ที่ 50 MB ลองดาวน์โหลดวิดีโอนี้ด้วยวิธีอื่น",
|
| 1610 |
+
"tts_shortened": "พากย์เสียงได้แค่ตอนต้น — ข้อความทั้งหมดเกินขีดจำกัดเวลา ส่วนที่เหลือถูกตัดออก",
|
| 1611 |
+
"media_too_big": "ไฟล์นี้ใหญ่เกินกว่าจะวิเคราะห์ (จำกัด {limit_mb} MB) ส่งไฟล์ที่เล็กกว่ามา หรืออธิบายเป็นคำพูด แล้วจะช่วยได้",
|
| 1612 |
"tiktok_no_media": "รู้จักลิงก์แล้ว แต่ TikTok ไม่ให้ทั้งวิดีโอและรูปภาพ — เนื้อหาอาจถูกลบหรือใช้ไม่ได้",
|
| 1613 |
"tiktok_processing": "กำลังประมวลผลลิงก์ TikTok",
|
| 1614 |
"tiktok_fetch_fail": "ดึงวิดีโอจากลิงก์นี้ไม่ได้ — อาจเป็นส่วนตัว ถูกลบ ถูกบล็อกตามภูมิภาค หรือลิงก์เสีย",
|
|
|
|
| 1691 |
"tiktok_dl_as_is": "نسخه بدون واترمارک پیدا نشد — همانطور که هست دانلود میشود",
|
| 1692 |
"tiktok_dl_plain": "در حال دانلود ویدیوی بدون واترمارک",
|
| 1693 |
"tiktok_too_big": "این ویدیوی TikTok برای ارسال خیلی بزرگ است — Bot API تلگرام آپلود فایل را به ۵۰ مگابایت محدود میکند. ویدیو را به روش دیگری دانلود کن.",
|
| 1694 |
+
"tts_shortened": "فقط توانستم ابتدای متن را بخوانم — کل متن در محدودیت زمانی جا نشد، بقیه کوتاه شد.",
|
| 1695 |
+
"media_too_big": "این فایل برای تحلیل خیلی بزرگ است (سقف {limit_mb} مگابایت). فایل کوچکتری بفرست یا با کلمات توضیح بده — آنوقت کمک میکنم.",
|
| 1696 |
"tiktok_no_media": "لینک شناخته شد، اما TikTok نه ویدیو داد نه عکس — شاید محتوا حذف شده یا در دسترس نیست.",
|
| 1697 |
"tiktok_processing": "در حال پردازش لینک TikTok",
|
| 1698 |
"tiktok_fetch_fail": "نتوانستم ویدیوی این لینک را بگیرم — شاید خصوصی، حذفشده، محدودشده منطقهای است یا لینک خراب است.",
|
|
|
|
| 1775 |
"tiktok_dl_as_is": "بغیر واٹرمارک والا نسخہ نہیں ملا — جیسی ہے ویسی ڈاؤن لوڈ ہو رہی ہے",
|
| 1776 |
"tiktok_dl_plain": "بغیر واٹرمارک ویڈیو ڈاؤن لوڈ ہو رہی ہے",
|
| 1777 |
"tiktok_too_big": "یہ TikTok ویڈیو بھیجنے کے لیے بہت بڑی ہے — Telegram Bot API فائل اپلوڈ کو 50 MB تک محدود کرتا ہے۔ اس ویڈیو کو کسی اور طریقے سے ڈاؤن لوڈ کرنے کی کوشش کرو۔",
|
| 1778 |
+
"tts_shortened": "صرف شروع کو آواز دے سکی — پورا متن وقت کی حد میں نہیں سمایا، باقی مختصر کر دیا۔",
|
| 1779 |
+
"media_too_big": "یہ فائل تجزیے کے لیے بہت بڑی ہے (حد {limit_mb} MB)۔ چھوٹی فائل بھیجو یا لفظوں میں بتاؤ — پھر مدد کر سکوں گا۔",
|
| 1780 |
"tiktok_no_media": "لنک پہچانا گیا، لیکن TikTok نے نہ ویڈیو دی نہ تصاویر — شاید مواد ہٹایا گیا ہے یا دستیاب نہیں ہے۔",
|
| 1781 |
"tiktok_processing": "TikTok لنک زیرِ عمل ہے",
|
| 1782 |
"tiktok_fetch_fail": "اس لنک سے ویڈیو لائی نہ جا سکی — شاید نجی ہے، ہٹائی گئی ہے، علاقائی پابندی ہے یا لنک ٹوٹا ہے۔",
|
|
|
|
| 1859 |
"tiktok_dl_as_is": "Suv belgisiz nusxa topilmadi — boricha yuklanmoqda",
|
| 1860 |
"tiktok_dl_plain": "Suv belgisiz video yuklanmoqda",
|
| 1861 |
"tiktok_too_big": "Bu TikTok videosi yuborish uchun juda katta — Telegram Bot API fayl yuklashni 50 MB bilan cheklaydi. Videoni boshqa yo'l bilan yuklashga urin.",
|
| 1862 |
+
"tts_shortened": "Faqat boshini ovozlay oldim — to'liq matn vaqt limitiga sig'madi, qolgani qisqartirildi.",
|
| 1863 |
+
"media_too_big": "Bu fayl tahlil uchun juda katta (limit {limit_mb} MB). Kichikroq fayl yubor yoki so'z bilan tasvirla — shunda yordam bera olaman.",
|
| 1864 |
"tiktok_no_media": "Havola tanildi, lekin TikTok na video berdi na foto — kontent o'chirilgan yoki mavjud bo'lmasligi mumkin.",
|
| 1865 |
"tiktok_processing": "TikTok havolasi qayta ishlanmoqda",
|
| 1866 |
"tiktok_fetch_fail": "Bu havoladan videoni olib bo'lmadi — shaxsiy, o'chirilgan, hudud cheklovli bo'lishi yoki havola buzilgan bo'lishi mumkin.",
|
|
|
|
| 1943 |
"tiktok_dl_as_is": "ওয়াটারমার্ক ছাড়া সংস্করণ পাওয়া যায়নি — যেমন আছে তেমন ডাউনলোড হচ্ছে",
|
| 1944 |
"tiktok_dl_plain": "ওয়াটারমার্ক ছাড়া ভিডিও ডাউনলোড হচ্ছে",
|
| 1945 |
"tiktok_too_big": "এই TikTok ভিডিও প��ঠানোর জন্য অনেক বড় — Telegram Bot API ফাইল আপলোড 50 MB-এ সীমাবদ্ধ। ভিডিওটি অন্য উপায়ে ডাউনলোড করার চেষ্টা করো।",
|
| 1946 |
+
"tts_shortened": "শুধু শুরুটা কণ্ঠ দিতে পেরেছি — পুরো লেখা সময়সীমায় ধরেনি, বাকিটা ছাঁটা হয়েছে।",
|
| 1947 |
+
"media_too_big": "এই ফাইল বিশ্লেষণের জন্য অনেক বড় (সীমা {limit_mb} MB)। ছোট ফাইল পাঠাও বা কথায় বর্ণনা করো — তাহলে সাহায্য করতে পারব।",
|
| 1948 |
"tiktok_no_media": "লিংক চেনা গেছে, কিন্তু TikTok ভিডিও বা ছবি কিছুই দেয়নি — হয়তো কন্টেন্ট মোছা বা অনুপলব্ধ।",
|
| 1949 |
"tiktok_processing": "TikTok লিংক প্রক্রিয়াধীন",
|
| 1950 |
"tiktok_fetch_fail": "এই লিংক থেকে ভিডিও আনা যায়নি — হয়তো ব্যক্তিগত, মোছা, অঞ্চল-নিষিদ্ধ বা লিংক নষ্ট।",
|
|
|
|
| 2027 |
"tiktok_dl_as_is": "Walang nahanap na bersyong walang watermark — dina-download kung ano",
|
| 2028 |
"tiktok_dl_plain": "Dina-download ang video nang walang watermark",
|
| 2029 |
"tiktok_too_big": "Masyadong malaki ang TikTok video na ito para i-send — nililimitahan ng Telegram Bot API ang upload ng file sa 50 MB. Subukang i-download ang video sa ibang paraan.",
|
| 2030 |
+
"tts_shortened": "Nabigyan ko lang ng boses ang simula — hindi kasya ang buong teksto sa limitasyon ng oras, pinutol ang natira.",
|
| 2031 |
+
"media_too_big": "Masyadong malaki ang file na ito para suriin (limit {limit_mb} MB). Magpadala ng mas maliit o ilarawan gamit ang mga salita — makakatulong ako.",
|
| 2032 |
"tiktok_no_media": "Nakilala ang link, pero walang binigay ang TikTok na video o photo — baka binura o hindi available ang content.",
|
| 2033 |
"tiktok_processing": "Pinoproseso ang TikTok link",
|
| 2034 |
"tiktok_fetch_fail": "Hindi makuha ang video mula sa link na ito — baka private, binura, region-blocked, o sira ang link.",
|
|
|
|
| 2111 |
"tiktok_dl_as_is": "Nie znalazłem wersji bez znaków wodnych — pobieram jak jest",
|
| 2112 |
"tiktok_dl_plain": "Pobieram film bez znaków wodnych",
|
| 2113 |
"tiktok_too_big": "Ten film z TikToka jest za duży do wysłania — Bot API Telegram ogranicza wysyłanie plików do 50 MB. Spróbuj pobrać ten film inaczej.",
|
| 2114 |
+
"tts_shortened": "Udało mi się udźwiękowić tylko początek — cały tekst nie zmieścił się w limicie czasu, reszta została skrócona.",
|
| 2115 |
+
"media_too_big": "Ten plik jest za duży do analizy (limit {limit_mb} MB). Wyślij mniejszy albo opisz słowami — wtedy będę mógł pomóc.",
|
| 2116 |
"tiktok_no_media": "Link rozpoznany, ale TikTok nie oddał ani filmu, ani zdjęć — treść może usunięta albo niedostępna.",
|
| 2117 |
"tiktok_processing": "Przetwarzam link TikToka",
|
| 2118 |
"tiktok_fetch_fail": "Nie udało się pobrać filmu z tego linku — może prywatny, usunięty, zablokowany regionalnie albo link uszkodzony.",
|
|
|
|
| 2195 |
"tiktok_dl_as_is": "Geen versie zonder watermerk gevonden — downloaden zoals het is",
|
| 2196 |
"tiktok_dl_plain": "Video zonder watermerk downloaden",
|
| 2197 |
"tiktok_too_big": "Deze TikTok-video is te groot om te verzenden — de Telegram Bot API limiteert bestandsuploads tot 50 MB. Probeer deze video anders te downloaden.",
|
| 2198 |
+
"tts_shortened": "Ik heb alleen het begin ingesproken — de volledige tekst paste niet in de tijdslimiet, de rest is ingekort.",
|
| 2199 |
+
"media_too_big": "Dit bestand is te groot om te analyseren (limiet {limit_mb} MB). Stuur een kleiner of beschrijf het in woorden — dan kan ik helpen.",
|
| 2200 |
"tiktok_no_media": "Link herkend, maar TikTok gaf video noch foto's — inhoud misschien verwijderd of niet beschikbaar.",
|
| 2201 |
"tiktok_processing": "TikTok-link verwerken",
|
| 2202 |
"tiktok_fetch_fail": "Video van deze link kon niet worden opgehaald — misschien privé, verwijderd, regiogeblokkeerd of link stuk.",
|
|
|
|
| 2279 |
"tiktok_dl_as_is": "Ayitholakalanga inguqulo engena-watermark — kulanda njengoba injalo",
|
| 2280 |
"tiktok_dl_plain": "Kulanda ividiyo ngaphandle kwe-watermark",
|
| 2281 |
"tiktok_too_big": "Le vidiyo ye-TikTok inkulu kakhulu ukuthunyelwa — i-Telegram Bot API ikhawulela ukulayishwa kwefayela ku-50 MB. Zama ukulanda le vidiyo ngenye indlela.",
|
| 2282 |
+
"tts_shortened": "Ngikwazile ukuphimisela isiqalo kuphela — umbiko wonke awungenanga kumkhawulo wesikhathi, okusele kuncishisiwe.",
|
| 2283 |
+
"media_too_big": "Leli fayela likhulu kakhulu ukuhlaziywa (umkhawulo {limit_mb} MB). Thumela elincane noma ulichaze ngamazwi — ngizokwazi ukusiza.",
|
| 2284 |
"tiktok_no_media": "Isixhumanisi saziwe, kodwa i-TikTok ayinikanga vidiyo noma zithombe — okuqukethwe kungasuswa noma kungatholakali.",
|
| 2285 |
"tiktok_processing": "Kucutshungulwa isixhumanisi se-TikTok",
|
| 2286 |
"tiktok_fetch_fail": "Ayikwazanga ukuthathwa ividiyo kulesi sixhumanisi — mhlawumbe eyimfihlo, isusiwe, ivinjelwe isifunda, noma isixhumanisi sonakele.",
|
lumen_media_flow.py
CHANGED
|
@@ -21,6 +21,14 @@ from lumen_media import (
|
|
| 21 |
|
| 22 |
log = logging.getLogger("bot")
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
async def _download_telegram_file_bytes(file_id: str, *, timeout: float | None = None, retries: int = 1) -> tuple[bytes, str]:
|
| 25 |
# Один ретрай getFile/скачивания: единичный сбой прокси иначе слепил бота ("NOT_FOUND" в логах).
|
| 26 |
import bot
|
|
@@ -31,14 +39,21 @@ async def _download_telegram_file_bytes(file_id: str, *, timeout: float | None =
|
|
| 31 |
file_path = getattr(file, "file_path", None) or getattr(file, "path", None)
|
| 32 |
if not file_path:
|
| 33 |
raise RuntimeError("File path is empty")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
session = await bot._get_telegram_session()
|
| 35 |
url = f"{bot.TELEGRAM_API_BASE_URL}/file/bot{bot.BOT_TOKEN}/{file_path}"
|
| 36 |
async with session.get(url, timeout=timeout or bot.TELEGRAM_MEDIA_TIMEOUT) as resp:
|
| 37 |
resp.raise_for_status()
|
| 38 |
-
data = await
|
| 39 |
mime = resp.headers.get("Content-Type", "application/octet-stream").split(";", 1)[0].strip()
|
| 40 |
real_mime = _sanitize_mime_type(file_path, mime)
|
| 41 |
return data, real_mime
|
|
|
|
|
|
|
|
|
|
| 42 |
except Exception as exc:
|
| 43 |
last_exc = exc
|
| 44 |
if attempt < retries:
|
|
@@ -49,6 +64,26 @@ async def _download_telegram_file_bytes(file_id: str, *, timeout: float | None =
|
|
| 49 |
exc_str = exc_str.replace(bot.BOT_TOKEN, "<TOKEN>")
|
| 50 |
raise RuntimeError(f"Network error in download_telegram_file_bytes: {exc_str}") from None
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def _save_media_to_history(source: Any, state: dict[str, Any], user_id: int | None) -> None:
|
| 53 |
file_id, mime, _ = _media_file_id_and_mime(source)
|
| 54 |
if not file_id or user_id is None:
|
|
@@ -103,6 +138,9 @@ async def _fetch_media(file_id: str, mime: str) -> tuple[bytes, str] | None:
|
|
| 103 |
if final_mime == "application/octet-stream":
|
| 104 |
final_mime = _sanitize_mime_type(None, real_mime)
|
| 105 |
return data, final_mime
|
|
|
|
|
|
|
|
|
|
| 106 |
except Exception as exc:
|
| 107 |
log.warning("[media] Download media failed for file_id %s: %s", file_id, exc)
|
| 108 |
return None
|
|
|
|
| 21 |
|
| 22 |
log = logging.getLogger("bot")
|
| 23 |
|
| 24 |
+
class _MediaTooLargeError(RuntimeError):
|
| 25 |
+
"""Файл больше капа скачивания: вызывающий код отвечает пользователю
|
| 26 |
+
понятным текстом вместо слепого анализа (аудит D1, 30.09.2026)."""
|
| 27 |
+
def __init__(self, size_bytes: int, cap_bytes: int):
|
| 28 |
+
self.size_bytes = size_bytes
|
| 29 |
+
self.cap_bytes = cap_bytes
|
| 30 |
+
super().__init__(f"file size {size_bytes} exceeds the {cap_bytes} byte download cap")
|
| 31 |
+
|
| 32 |
async def _download_telegram_file_bytes(file_id: str, *, timeout: float | None = None, retries: int = 1) -> tuple[bytes, str]:
|
| 33 |
# Один ретрай getFile/скачивания: единичный сбой прокси иначе слепил бота ("NOT_FOUND" в логах).
|
| 34 |
import bot
|
|
|
|
| 39 |
file_path = getattr(file, "file_path", None) or getattr(file, "path", None)
|
| 40 |
if not file_path:
|
| 41 |
raise RuntimeError("File path is empty")
|
| 42 |
+
declared_size = getattr(file, "file_size", None)
|
| 43 |
+
if isinstance(declared_size, int) and declared_size > bot.TELEGRAM_DOWNLOAD_MAX_BYTES:
|
| 44 |
+
# Заранее большой размер — без скачивания, с честным отказом выше.
|
| 45 |
+
raise _MediaTooLargeError(declared_size, bot.TELEGRAM_DOWNLOAD_MAX_BYTES)
|
| 46 |
session = await bot._get_telegram_session()
|
| 47 |
url = f"{bot.TELEGRAM_API_BASE_URL}/file/bot{bot.BOT_TOKEN}/{file_path}"
|
| 48 |
async with session.get(url, timeout=timeout or bot.TELEGRAM_MEDIA_TIMEOUT) as resp:
|
| 49 |
resp.raise_for_status()
|
| 50 |
+
data = await _read_capped_telegram_body(resp, url)
|
| 51 |
mime = resp.headers.get("Content-Type", "application/octet-stream").split(";", 1)[0].strip()
|
| 52 |
real_mime = _sanitize_mime_type(file_path, mime)
|
| 53 |
return data, real_mime
|
| 54 |
+
except _MediaTooLargeError:
|
| 55 |
+
# Кап — не сеть: ретрай бессмыслен, пробрасываем сразу.
|
| 56 |
+
raise
|
| 57 |
except Exception as exc:
|
| 58 |
last_exc = exc
|
| 59 |
if attempt < retries:
|
|
|
|
| 64 |
exc_str = exc_str.replace(bot.BOT_TOKEN, "<TOKEN>")
|
| 65 |
raise RuntimeError(f"Network error in download_telegram_file_bytes: {exc_str}") from None
|
| 66 |
|
| 67 |
+
async def _read_capped_telegram_body(resp: Any, url: str) -> bytes:
|
| 68 |
+
"""Тело ответа Telegram с капом: Content-Length врёт/отсутствует —
|
| 69 |
+
та же проверка потоково по факту (см. кап TikWM-скачиваний)."""
|
| 70 |
+
import bot
|
| 71 |
+
content_length = resp.headers.get("Content-Length")
|
| 72 |
+
if content_length is not None:
|
| 73 |
+
try:
|
| 74 |
+
if int(content_length) > bot.TELEGRAM_DOWNLOAD_MAX_BYTES:
|
| 75 |
+
raise _MediaTooLargeError(int(content_length), bot.TELEGRAM_DOWNLOAD_MAX_BYTES)
|
| 76 |
+
except ValueError:
|
| 77 |
+
pass
|
| 78 |
+
chunks: list[bytes] = []
|
| 79 |
+
total = 0
|
| 80 |
+
async for chunk in resp.content.iter_chunked(65536):
|
| 81 |
+
total += len(chunk)
|
| 82 |
+
if total > bot.TELEGRAM_DOWNLOAD_MAX_BYTES:
|
| 83 |
+
raise _MediaTooLargeError(total, bot.TELEGRAM_DOWNLOAD_MAX_BYTES)
|
| 84 |
+
chunks.append(chunk)
|
| 85 |
+
return b"".join(chunks)
|
| 86 |
+
|
| 87 |
def _save_media_to_history(source: Any, state: dict[str, Any], user_id: int | None) -> None:
|
| 88 |
file_id, mime, _ = _media_file_id_and_mime(source)
|
| 89 |
if not file_id or user_id is None:
|
|
|
|
| 138 |
if final_mime == "application/octet-stream":
|
| 139 |
final_mime = _sanitize_mime_type(None, real_mime)
|
| 140 |
return data, final_mime
|
| 141 |
+
except _MediaTooLargeError:
|
| 142 |
+
# Честный отказ выше по стеку, а не тихое None.
|
| 143 |
+
raise
|
| 144 |
except Exception as exc:
|
| 145 |
log.warning("[media] Download media failed for file_id %s: %s", file_id, exc)
|
| 146 |
return None
|
lumen_message_core.py
CHANGED
|
@@ -43,6 +43,12 @@ from lumen_security import _looks_like_injection_probe
|
|
| 43 |
|
| 44 |
log = logging.getLogger("bot")
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
async def _process_media_group_buffers(mgid: str) -> None:
|
| 47 |
import bot
|
| 48 |
messages: list = []
|
|
@@ -116,18 +122,44 @@ async def _process_media_group_buffers_locked(messages: list) -> None:
|
|
| 116 |
continue
|
| 117 |
targets.append((fid, mime, src))
|
| 118 |
# Качаем параллельно, а не по очереди: альбом из 9 фото иначе ждал бы до ~10-20с последовательных скачиваний.
|
| 119 |
-
fetched_list =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
skipped = 0
|
|
|
|
|
|
|
| 121 |
for (fid, mime, src), fetched in zip(targets, fetched_list):
|
| 122 |
-
if fetched:
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
else:
|
| 127 |
skipped += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
if skipped:
|
| 129 |
# Упавшие слайды молча выпадали и анализ шёл по части файлов.
|
| 130 |
log.warning("[album] Skipped %d of %d files: download failed, analysing the rest.", skipped, len(targets))
|
|
|
|
|
|
|
|
|
|
| 131 |
await bot._handle_message_core(main_msg, extra_media=extra_media or None)
|
| 132 |
|
| 133 |
def _record_passive_group_context(message: Message, state: dict[str, Any], t: str) -> None:
|
|
@@ -356,9 +388,15 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 356 |
await bot._send_pick_question(message, pick_scenario, clean_prompt)
|
| 357 |
return
|
| 358 |
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
# Голос/аудио: сначала дешёвая транскрибация — дальше текст идёт общим роутингом по
|
| 364 |
# сценарию (тяжесть/свежесть определяются по сказанному). Не вышло — падает в прежний
|
|
@@ -386,7 +424,10 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 386 |
src = _msg_media_source(message)
|
| 387 |
if src is not None and type(src).__name__ != "Sticker":
|
| 388 |
# Вложение было, но скачать/распознать не вышло — честно говорим, а не "Слушаю" в пустоту.
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
| 390 |
return
|
| 391 |
if mentioned:
|
| 392 |
await bot._tg_call(message.reply, bot._t(message.chat.id, "status_listening"))
|
|
|
|
| 43 |
|
| 44 |
log = logging.getLogger("bot")
|
| 45 |
|
| 46 |
+
# Суммарный кап extra-файлов альбома: 9 файлов по 20МБ + base64 давали ~250МБ
|
| 47 |
+
# в RAM (аудит D1, 30.09.2026). Обычный альбом — десятки МБ.
|
| 48 |
+
_ALBUM_EXTRA_MAX_BYTES = 100 * 1024 * 1024
|
| 49 |
+
# Параллельных скачиваний альбома: ограничивает in-flight память (3×20МБ).
|
| 50 |
+
_ALBUM_FETCH_CONCURRENCY = 3
|
| 51 |
+
|
| 52 |
async def _process_media_group_buffers(mgid: str) -> None:
|
| 53 |
import bot
|
| 54 |
messages: list = []
|
|
|
|
| 122 |
continue
|
| 123 |
targets.append((fid, mime, src))
|
| 124 |
# Качаем параллельно, а не по очереди: альбом из 9 фото иначе ждал бы до ~10-20с последовательных скачиваний.
|
| 125 |
+
fetched_list: list = [None] * len(targets)
|
| 126 |
+
# Семафор режет in-flight память, суммарный кап — накопленную (аудит D1, 30.09.2026).
|
| 127 |
+
_album_fetch_semaphore = asyncio.Semaphore(_ALBUM_FETCH_CONCURRENCY)
|
| 128 |
+
|
| 129 |
+
async def _fetch_one_bounded(fid: str, mime: str) -> tuple[bytes, str] | None:
|
| 130 |
+
async with _album_fetch_semaphore:
|
| 131 |
+
return await bot._fetch_media(fid, mime)
|
| 132 |
+
|
| 133 |
+
try:
|
| 134 |
+
fetched_list = list(await asyncio.gather(
|
| 135 |
+
*(_fetch_one_bounded(fid, mime) for fid, mime, _ in targets),
|
| 136 |
+
return_exceptions=True,
|
| 137 |
+
))
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
skipped = 0
|
| 141 |
+
too_big = 0
|
| 142 |
+
extra_bytes = 0
|
| 143 |
for (fid, mime, src), fetched in zip(targets, fetched_list):
|
| 144 |
+
if isinstance(fetched, bot._MediaTooLargeError):
|
| 145 |
+
too_big += 1
|
| 146 |
+
continue
|
| 147 |
+
if not fetched or isinstance(fetched, BaseException):
|
|
|
|
| 148 |
skipped += 1
|
| 149 |
+
continue
|
| 150 |
+
if extra_bytes + len(fetched[0]) > _ALBUM_EXTRA_MAX_BYTES:
|
| 151 |
+
skipped += 1
|
| 152 |
+
continue
|
| 153 |
+
extra_bytes += len(fetched[0])
|
| 154 |
+
extra_media.append(fetched)
|
| 155 |
+
# Файлы альбома — в recent_media_ids, иначе последующие вопросы их не найдут.
|
| 156 |
+
bot._save_media_to_history(src, album_state, album_user_id)
|
| 157 |
if skipped:
|
| 158 |
# Упавшие слайды молча выпадали и анализ шёл по части файлов.
|
| 159 |
log.warning("[album] Skipped %d of %d files: download failed, analysing the rest.", skipped, len(targets))
|
| 160 |
+
if too_big:
|
| 161 |
+
# Большие отклонены капом: честный отказ пользователю — из _handle_message_core.
|
| 162 |
+
log.warning("[album] Skipped %d of %d files: over the download size cap.", too_big, len(targets))
|
| 163 |
await bot._handle_message_core(main_msg, extra_media=extra_media or None)
|
| 164 |
|
| 165 |
def _record_passive_group_context(message: Message, state: dict[str, Any], t: str) -> None:
|
|
|
|
| 388 |
await bot._send_pick_question(message, pick_scenario, clean_prompt)
|
| 389 |
return
|
| 390 |
|
| 391 |
+
media_too_big_mb = 0
|
| 392 |
+
try:
|
| 393 |
+
med_path, med_mime, med_name, media_tuple = await bot._resolve_incoming_media(
|
| 394 |
+
message, state, clean_prompt, is_private=is_private,
|
| 395 |
+
)
|
| 396 |
+
except bot._MediaTooLargeError as exc:
|
| 397 |
+
# Файл больше капа скачивания: честный отказ ниже вместо слепого ответа.
|
| 398 |
+
med_path, med_name, media_tuple = None, "", None
|
| 399 |
+
media_too_big_mb = max(1, round(exc.cap_bytes / (1024 * 1024)))
|
| 400 |
|
| 401 |
# Голос/аудио: сначала дешёвая транскрибация — дальше текст идёт общим роутингом по
|
| 402 |
# сценарию (тяжесть/свежесть определяются по сказанному). Не вышло — падает в прежний
|
|
|
|
| 424 |
src = _msg_media_source(message)
|
| 425 |
if src is not None and type(src).__name__ != "Sticker":
|
| 426 |
# Вложение было, но скачать/распознать не вышло — честно говорим, а не "Слушаю" в пустоту.
|
| 427 |
+
if media_too_big_mb:
|
| 428 |
+
await bot._safe_reply(message, bot._t(message.chat.id, "media_too_big", limit_mb=media_too_big_mb))
|
| 429 |
+
else:
|
| 430 |
+
await bot._safe_reply(message, bot._model_error_text("fallback", bot._chat_lang(message.chat.id)))
|
| 431 |
return
|
| 432 |
if mentioned:
|
| 433 |
await bot._tg_call(message.reply, bot._t(message.chat.id, "status_listening"))
|
lumen_router_config.py
CHANGED
|
@@ -441,10 +441,14 @@ def _build_route(
|
|
| 441 |
return _gemini_route(chain)
|
| 442 |
|
| 443 |
if media_mime:
|
| 444 |
-
if
|
| 445 |
-
# Видео/аудио
|
| 446 |
chain = GEMINI_SEARCH_CHAIN if needs_freshness else GEMINI_HEAVY_CHAIN
|
| 447 |
return _gemini_route(chain)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
# Картинка без поиска — сначала vision OpenRouter, Gemini резервом.
|
| 449 |
return _or_route(_OR_VISION_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
|
| 450 |
|
|
@@ -453,6 +457,8 @@ def _build_route(
|
|
| 453 |
return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_HEAVY_ORDER if is_heavy else _OR_LIGHT_ORDER) + _groq_route(_GROQ_LIGHT_ORDER)
|
| 454 |
|
| 455 |
# Обычный текст — сначала Groq (1000/день против 50 у OpenRouter), дальше OpenRouter, Gemini резервом.
|
|
|
|
|
|
|
| 456 |
if is_heavy:
|
| 457 |
-
return _or_route(_OR_HEAVY_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
|
| 458 |
return _groq_route(_GROQ_LIGHT_ORDER) + _or_route(_OR_LIGHT_ORDER) + _gemini_route(GEMINI_SEARCH_CHAIN)
|
|
|
|
| 441 |
return _gemini_route(chain)
|
| 442 |
|
| 443 |
if media_mime:
|
| 444 |
+
if is_video_or_audio_media:
|
| 445 |
+
# Видео/аудио — только Gemini (OpenRouter не примет не-изображение).
|
| 446 |
chain = GEMINI_SEARCH_CHAIN if needs_freshness else GEMINI_HEAVY_CHAIN
|
| 447 |
return _gemini_route(chain)
|
| 448 |
+
if needs_freshness:
|
| 449 |
+
# Картинка+свежесть: Gemini с поиском головой, дальше vision
|
| 450 |
+
# OpenRouter без поиска (аудит D3, 30.09.2026: раньше резерва не было).
|
| 451 |
+
return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_VISION_ORDER)
|
| 452 |
# Картинка без поиска — сначала vision OpenRouter, Gemini резервом.
|
| 453 |
return _or_route(_OR_VISION_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
|
| 454 |
|
|
|
|
| 457 |
return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_HEAVY_ORDER if is_heavy else _OR_LIGHT_ORDER) + _groq_route(_GROQ_LIGHT_ORDER)
|
| 458 |
|
| 459 |
# Обычный текст — сначала Groq (1000/день против 50 у OpenRouter), дальше OpenRouter, Gemini резервом.
|
| 460 |
+
# Тяжёлый — тоже с Groq первой: свободная квота Groq-Tier экономит scarce-квоты
|
| 461 |
+
# OR/Gemini (аудит D3, 30.09.2026); не потянет — цепочка уйдёт дальше сама.
|
| 462 |
if is_heavy:
|
| 463 |
+
return _groq_route(_GROQ_LIGHT_ORDER) + _or_route(_OR_HEAVY_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
|
| 464 |
return _groq_route(_GROQ_LIGHT_ORDER) + _or_route(_OR_LIGHT_ORDER) + _gemini_route(GEMINI_SEARCH_CHAIN)
|
lumen_tiktok.py
CHANGED
|
@@ -347,9 +347,11 @@ def _checked_redirect_url(current_url: str, location: str | None) -> str | None:
|
|
| 347 |
return next_url
|
| 348 |
|
| 349 |
|
| 350 |
-
async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: dict | None = None) -> bytes | None:
|
| 351 |
# URL — из JSON чужого сервиса (TikWM): качаем только http(s) (AUD-D-003)
|
| 352 |
-
# и только с публичных адресов (SSRF-гард ниже).
|
|
|
|
|
|
|
| 353 |
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
| 354 |
if scheme not in ("http", "https"):
|
| 355 |
log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
|
|
@@ -388,8 +390,8 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
|
|
| 388 |
content_length = resp.headers.get("Content-Length")
|
| 389 |
if content_length is not None:
|
| 390 |
try:
|
| 391 |
-
if int(content_length) >
|
| 392 |
-
log.warning("[download] Refusing to download %s: Content-Length %s exceeds the %d byte cap.", current_url, content_length,
|
| 393 |
return None
|
| 394 |
except ValueError:
|
| 395 |
pass
|
|
@@ -397,8 +399,8 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
|
|
| 397 |
total = 0
|
| 398 |
async for chunk in resp.content.iter_chunked(65536):
|
| 399 |
total += len(chunk)
|
| 400 |
-
if total >
|
| 401 |
-
log.warning("[download] Aborting download of %s: exceeded the %d byte cap mid-stream.", current_url,
|
| 402 |
return None
|
| 403 |
chunks.append(chunk)
|
| 404 |
return b"".join(chunks)
|
|
|
|
| 347 |
return next_url
|
| 348 |
|
| 349 |
|
| 350 |
+
async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: dict | None = None, *, cap_bytes: int | None = None) -> bytes | None:
|
| 351 |
# URL — из JSON чужого сервиса (TikWM): качаем только http(s) (AUD-D-003)
|
| 352 |
+
# и только с публичных адресов (SSRF-гард ниже). cap_bytes — отдельный кап
|
| 353 |
+
# вызывающего (видео режем лимитом отправки Telegram, а не общим 75МБ).
|
| 354 |
+
cap = cap_bytes if cap_bytes is not None else TIKTOK_DOWNLOAD_MAX_BYTES
|
| 355 |
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
| 356 |
if scheme not in ("http", "https"):
|
| 357 |
log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
|
|
|
|
| 390 |
content_length = resp.headers.get("Content-Length")
|
| 391 |
if content_length is not None:
|
| 392 |
try:
|
| 393 |
+
if int(content_length) > cap:
|
| 394 |
+
log.warning("[download] Refusing to download %s: Content-Length %s exceeds the %d byte cap.", current_url, content_length, cap)
|
| 395 |
return None
|
| 396 |
except ValueError:
|
| 397 |
pass
|
|
|
|
| 399 |
total = 0
|
| 400 |
async for chunk in resp.content.iter_chunked(65536):
|
| 401 |
total += len(chunk)
|
| 402 |
+
if total > cap:
|
| 403 |
+
log.warning("[download] Aborting download of %s: exceeded the %d byte cap mid-stream.", current_url, cap)
|
| 404 |
return None
|
| 405 |
chunks.append(chunk)
|
| 406 |
return b"".join(chunks)
|
lumen_tiktok_flow.py
CHANGED
|
@@ -223,14 +223,26 @@ async def _try_send_tiktok_slideshow(
|
|
| 223 |
))
|
| 224 |
# Качаем слайды параллельно gather; конкурентность режем семафором, чтобы не занимать весь пул сессии.
|
| 225 |
fetch_urls = _slideshow_slide_urls(media_data, images_to_fetch)
|
|
|
|
|
|
|
| 226 |
|
| 227 |
async def _download_slide_bounded(slide_url: str) -> bytes | None:
|
| 228 |
async with bot._tiktok_slide_download_semaphore:
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
downloaded = list(await asyncio.gather(
|
| 232 |
*(_download_slide_bounded(u) for u in fetch_urls)
|
| 233 |
))
|
|
|
|
|
|
|
|
|
|
| 234 |
video_indices = [idx for idx, b in enumerate(downloaded) if b and _looks_like_video_bytes(b)]
|
| 235 |
if video_indices:
|
| 236 |
log.info('[tiktok] In the slideshow, %d of %d slides were recognized as video (live_images/magic bytes).', len(video_indices), len(downloaded))
|
|
@@ -322,7 +334,12 @@ async def _send_tiktok_single_video(
|
|
| 322 |
status_msg = bot._t(message.chat.id, "tiktok_dl_plain")
|
| 323 |
await bot._edit_message_quietly(status, status_msg)
|
| 324 |
|
| 325 |
-
video_bytes = await bot._download_url_bin(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
if not video_bytes:
|
| 327 |
continue
|
| 328 |
|
|
|
|
| 223 |
))
|
| 224 |
# Качаем слайды параллельно gather; конкурентность режем семафором, чтобы не занимать весь пул сессии.
|
| 225 |
fetch_urls = _slideshow_slide_urls(media_data, images_to_fetch)
|
| 226 |
+
# Общий кап RAM на пост: больше не качаем и остаток пропускаем.
|
| 227 |
+
post_budget = [bot.TIKTOK_SLIDESHOW_MAX_BYTES]
|
| 228 |
|
| 229 |
async def _download_slide_bounded(slide_url: str) -> bytes | None:
|
| 230 |
async with bot._tiktok_slide_download_semaphore:
|
| 231 |
+
if post_budget[0] <= 0:
|
| 232 |
+
return None
|
| 233 |
+
data = await bot._download_url_bin(session, slide_url, headers=headers)
|
| 234 |
+
if data:
|
| 235 |
+
post_budget[0] -= len(data)
|
| 236 |
+
if post_budget[0] < 0:
|
| 237 |
+
return None
|
| 238 |
+
return data
|
| 239 |
|
| 240 |
downloaded = list(await asyncio.gather(
|
| 241 |
*(_download_slide_bounded(u) for u in fetch_urls)
|
| 242 |
))
|
| 243 |
+
skipped_slides = sum(1 for b in downloaded if not b)
|
| 244 |
+
if skipped_slides:
|
| 245 |
+
log.warning("[tiktok] Slideshow: %d of %d slides skipped (failed or over the post size cap).", skipped_slides, len(downloaded))
|
| 246 |
video_indices = [idx for idx, b in enumerate(downloaded) if b and _looks_like_video_bytes(b)]
|
| 247 |
if video_indices:
|
| 248 |
log.info('[tiktok] In the slideshow, %d of %d slides were recognized as video (live_images/magic bytes).', len(video_indices), len(downloaded))
|
|
|
|
| 334 |
status_msg = bot._t(message.chat.id, "tiktok_dl_plain")
|
| 335 |
await bot._edit_message_quietly(status, status_msg)
|
| 336 |
|
| 337 |
+
video_bytes = await bot._download_url_bin(
|
| 338 |
+
session, candidate["url"], headers=headers,
|
| 339 |
+
# Видео больше лимита отправки всё равно не уйдёт — режем капом
|
| 340 |
+
# отправки, а не общим 75МБ (аудит A7-5: 50-75МБ качались зря).
|
| 341 |
+
cap_bytes=bot.TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES,
|
| 342 |
+
)
|
| 343 |
if not video_bytes:
|
| 344 |
continue
|
| 345 |
|
tests/test_bot_admin.py
CHANGED
|
@@ -465,3 +465,52 @@ def test_message_mentions_bot_without_chat_returns_false():
|
|
| 465 |
|
| 466 |
assert bot.message_mentions_bot(_NoChatMessage()) is False
|
| 467 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
|
| 466 |
assert bot.message_mentions_bot(_NoChatMessage()) is False
|
| 467 |
|
| 468 |
+
|
| 469 |
+
def test_webhook_setup_retries_then_raises(monkeypatch):
|
| 470 |
+
# Аудит D4: провал setWebhook без ретрая давал тихий мёртвый бот — теперь
|
| 471 |
+
# ретраи с бэкоффом и явный отказ вместо молчания.
|
| 472 |
+
sleeps = []
|
| 473 |
+
|
| 474 |
+
async def fast_sleep(delay):
|
| 475 |
+
sleeps.append(delay)
|
| 476 |
+
|
| 477 |
+
async def always_fail(method, *args, **kwargs):
|
| 478 |
+
if method == "getMe":
|
| 479 |
+
return {"username": "testbot"}
|
| 480 |
+
raise RuntimeError("boom")
|
| 481 |
+
|
| 482 |
+
monkeypatch.setattr(bot.asyncio, "sleep", fast_sleep)
|
| 483 |
+
monkeypatch.setattr(bot, "SETWEBHOOK_MAX_ATTEMPTS", 3)
|
| 484 |
+
_patch_startup(monkeypatch, always_fail)
|
| 485 |
+
with pytest.raises(RuntimeError, match="setWebhook"):
|
| 486 |
+
asyncio.run(bot._webhook_startup())
|
| 487 |
+
assert sleeps == [1.5, 5.0, 10.0]
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def test_webhook_setup_succeeds_after_retries(monkeypatch):
|
| 491 |
+
# Сторож: два провала подряд — третий регистрирует, стартап живёт дальше.
|
| 492 |
+
calls = {"hook": 0}
|
| 493 |
+
sleeps = []
|
| 494 |
+
|
| 495 |
+
async def fast_sleep(delay):
|
| 496 |
+
sleeps.append(delay)
|
| 497 |
+
if len(sleeps) > 3:
|
| 498 |
+
raise asyncio.CancelledError
|
| 499 |
+
|
| 500 |
+
async def flaky(method, *args, **kwargs):
|
| 501 |
+
if method == "getMe":
|
| 502 |
+
return {"username": "testbot"}
|
| 503 |
+
if method == "setWebhook":
|
| 504 |
+
calls["hook"] += 1
|
| 505 |
+
if calls["hook"] < 3:
|
| 506 |
+
raise RuntimeError("boom")
|
| 507 |
+
return True
|
| 508 |
+
return {}
|
| 509 |
+
|
| 510 |
+
monkeypatch.setattr(bot.asyncio, "sleep", fast_sleep)
|
| 511 |
+
_patch_startup(monkeypatch, flaky)
|
| 512 |
+
with pytest.raises(asyncio.CancelledError):
|
| 513 |
+
asyncio.run(bot._webhook_startup())
|
| 514 |
+
assert calls["hook"] == 3
|
| 515 |
+
assert sleeps == [1.5, 5.0, 10.0, 3600]
|
| 516 |
+
|
tests/test_bot_state.py
CHANGED
|
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock
|
|
| 9 |
from unittest.mock import patch
|
| 10 |
import asyncio
|
| 11 |
import bot
|
|
|
|
| 12 |
import contextlib
|
| 13 |
import json
|
| 14 |
import logging
|
|
@@ -1147,8 +1148,8 @@ def test_process_media_group_buffers_cleanup_on_cancel():
|
|
| 1147 |
def test_process_media_group_buffers_fetches_extras_in_parallel_keeping_order(monkeypatch):
|
| 1148 |
# Скачивание осталось параллельным (после проверки лимита, до обработки).
|
| 1149 |
monkeypatch.setattr(bot, "_reject_rate_limited_message", AsyncMock(return_value=False))
|
| 1150 |
-
# Альбом качается параллельно
|
| 1151 |
-
# а порядок вложений обязан совпасть с порядком сообщений.
|
| 1152 |
chat_id = 999961
|
| 1153 |
user = SimpleNamespace(id=778)
|
| 1154 |
|
|
@@ -1185,8 +1186,8 @@ def test_process_media_group_buffers_fetches_extras_in_parallel_keeping_order(mo
|
|
| 1185 |
bot._mg_buffers["mg2"] = msgs
|
| 1186 |
try:
|
| 1187 |
asyncio.run(bot._process_media_group_buffers("mg2"))
|
| 1188 |
-
#
|
| 1189 |
-
assert max_concurrent ==
|
| 1190 |
assert [b for b, _ in captured["extra"]] == [b"file_1", b"file_2", b"file_3", b"file_4"]
|
| 1191 |
finally:
|
| 1192 |
bot._fetch_media = original_fetch
|
|
@@ -2442,3 +2443,151 @@ def test_acquire_chat_lock_retakes_after_registry_swap(monkeypatch):
|
|
| 2442 |
lock.release()
|
| 2443 |
finally:
|
| 2444 |
bot._chat_locks.pop(chat_id, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from unittest.mock import patch
|
| 10 |
import asyncio
|
| 11 |
import bot
|
| 12 |
+
import pytest
|
| 13 |
import contextlib
|
| 14 |
import json
|
| 15 |
import logging
|
|
|
|
| 1148 |
def test_process_media_group_buffers_fetches_extras_in_parallel_keeping_order(monkeypatch):
|
| 1149 |
# Скачивание осталось параллельным (после проверки лимита, до обработки).
|
| 1150 |
monkeypatch.setattr(bot, "_reject_rate_limited_message", AsyncMock(return_value=False))
|
| 1151 |
+
# Альбом качается параллельно, но bounded семафором (аудит D1): in-flight
|
| 1152 |
+
# память ограничена, а порядок вложений обязан совпасть с порядком сообщений.
|
| 1153 |
chat_id = 999961
|
| 1154 |
user = SimpleNamespace(id=778)
|
| 1155 |
|
|
|
|
| 1186 |
bot._mg_buffers["mg2"] = msgs
|
| 1187 |
try:
|
| 1188 |
asyncio.run(bot._process_media_group_buffers("mg2"))
|
| 1189 |
+
# Три доп. фото in-flight одновременно (семафор), четвёртое ждёт слота.
|
| 1190 |
+
assert max_concurrent == 3
|
| 1191 |
assert [b for b, _ in captured["extra"]] == [b"file_1", b"file_2", b"file_3", b"file_4"]
|
| 1192 |
finally:
|
| 1193 |
bot._fetch_media = original_fetch
|
|
|
|
| 2443 |
lock.release()
|
| 2444 |
finally:
|
| 2445 |
bot._chat_locks.pop(chat_id, None)
|
| 2446 |
+
|
| 2447 |
+
|
| 2448 |
+
# ─────────────── B1: капы размеров (D1) ───────────────
|
| 2449 |
+
|
| 2450 |
+
def test_download_refuses_oversize_by_declared_size(monkeypatch):
|
| 2451 |
+
# Аудит D1/A6-1: заявленный размер больше капа — отказ до скачивания.
|
| 2452 |
+
import lumen_media_flow
|
| 2453 |
+
|
| 2454 |
+
async def fake_get_file(file_id):
|
| 2455 |
+
return SimpleNamespace(file_path="photos/x.jpg", file_size=10 ** 12)
|
| 2456 |
+
|
| 2457 |
+
def must_not_run(*args, **kwargs):
|
| 2458 |
+
raise AssertionError("no network for oversize file")
|
| 2459 |
+
|
| 2460 |
+
monkeypatch.setattr(bot, "bot", SimpleNamespace(get_file=fake_get_file))
|
| 2461 |
+
monkeypatch.setattr(bot, "_get_telegram_session", must_not_run)
|
| 2462 |
+
with pytest.raises(lumen_media_flow._MediaTooLargeError):
|
| 2463 |
+
asyncio.run(bot._download_telegram_file_bytes("fid"))
|
| 2464 |
+
|
| 2465 |
+
|
| 2466 |
+
def test_download_aborts_mid_stream_over_cap(monkeypatch):
|
| 2467 |
+
# Аудит D1/A6-1: враньё в Content-Length — потоковый кап по факту.
|
| 2468 |
+
import lumen_media_flow
|
| 2469 |
+
from tests.bot_test_helpers import _FakeDownloadContent
|
| 2470 |
+
|
| 2471 |
+
async def fake_get_file(file_id):
|
| 2472 |
+
return SimpleNamespace(file_path="photos/x.jpg", file_size=None)
|
| 2473 |
+
|
| 2474 |
+
class _Resp:
|
| 2475 |
+
status = 200
|
| 2476 |
+
headers = {}
|
| 2477 |
+
|
| 2478 |
+
async def __aenter__(self):
|
| 2479 |
+
return self
|
| 2480 |
+
|
| 2481 |
+
async def __aexit__(self, *args):
|
| 2482 |
+
return False
|
| 2483 |
+
|
| 2484 |
+
def raise_for_status(self):
|
| 2485 |
+
pass
|
| 2486 |
+
|
| 2487 |
+
_Resp.content = _FakeDownloadContent([b"y" * (2 * 1024 * 1024)] * 3)
|
| 2488 |
+
|
| 2489 |
+
class _Session:
|
| 2490 |
+
def get(self, *args, **kwargs):
|
| 2491 |
+
return _Resp()
|
| 2492 |
+
|
| 2493 |
+
async def fake_session():
|
| 2494 |
+
return _Session()
|
| 2495 |
+
|
| 2496 |
+
monkeypatch.setattr(bot, "bot", SimpleNamespace(get_file=fake_get_file))
|
| 2497 |
+
monkeypatch.setattr(bot, "_get_telegram_session", fake_session)
|
| 2498 |
+
monkeypatch.setattr(bot, "TELEGRAM_DOWNLOAD_MAX_BYTES", 1024 * 1024)
|
| 2499 |
+
with pytest.raises(lumen_media_flow._MediaTooLargeError):
|
| 2500 |
+
asyncio.run(bot._download_telegram_file_bytes("fid"))
|
| 2501 |
+
|
| 2502 |
+
|
| 2503 |
+
def test_oversize_attachment_gets_honest_refusal(monkeypatch):
|
| 2504 |
+
# Аудит D1: большой файл — понятный текст, а не слепой ответ.
|
| 2505 |
+
chat_id = 999982
|
| 2506 |
+
sent = {}
|
| 2507 |
+
|
| 2508 |
+
async def fake_to_tmp(source):
|
| 2509 |
+
raise bot._MediaTooLargeError(10 ** 12, 20 * 1024 * 1024)
|
| 2510 |
+
|
| 2511 |
+
async def fake_reply(message, text, **kwargs):
|
| 2512 |
+
sent["text"] = text
|
| 2513 |
+
|
| 2514 |
+
monkeypatch.setattr(bot, "_download_message_attachment_to_tmp", fake_to_tmp)
|
| 2515 |
+
monkeypatch.setattr(bot, "_safe_reply", fake_reply)
|
| 2516 |
+
bot.get_state(chat_id)["lang"] = "ru"
|
| 2517 |
+
msg = _FakeIncomingMessage(chat_id)
|
| 2518 |
+
msg.text = None
|
| 2519 |
+
msg.caption = None
|
| 2520 |
+
msg.photo = [SimpleNamespace(file_id="big", mime_type="video/mp4")]
|
| 2521 |
+
msg.from_user = SimpleNamespace(id=555)
|
| 2522 |
+
try:
|
| 2523 |
+
asyncio.run(bot._handle_message_core(msg))
|
| 2524 |
+
assert "20" in sent.get("text", "") and "большой" in sent["text"]
|
| 2525 |
+
finally:
|
| 2526 |
+
bot.chat_state.pop(chat_id, None)
|
| 2527 |
+
|
| 2528 |
+
|
| 2529 |
+
def test_album_skips_files_over_running_total(monkeypatch):
|
| 2530 |
+
# Аудит D1/A4-04: суммарный кап extra-файлов альбома.
|
| 2531 |
+
import lumen_message_core
|
| 2532 |
+
monkeypatch.setattr(lumen_message_core, "_ALBUM_EXTRA_MAX_BYTES", 100)
|
| 2533 |
+
|
| 2534 |
+
async def big_fetch(fid, mime):
|
| 2535 |
+
return (b"z" * 60, mime)
|
| 2536 |
+
|
| 2537 |
+
async def allow_all(message):
|
| 2538 |
+
return False
|
| 2539 |
+
|
| 2540 |
+
seen = {}
|
| 2541 |
+
|
| 2542 |
+
async def fake_core(message, extra_media=None):
|
| 2543 |
+
seen["extra"] = extra_media
|
| 2544 |
+
|
| 2545 |
+
monkeypatch.setattr(bot, "_fetch_media", big_fetch)
|
| 2546 |
+
monkeypatch.setattr(bot, "_reject_rate_limited_message", allow_all)
|
| 2547 |
+
monkeypatch.setattr(bot, "_handle_message_core", fake_core)
|
| 2548 |
+
chat_id = 999983
|
| 2549 |
+
try:
|
| 2550 |
+
asyncio.run(lumen_message_core._process_media_group_buffers_locked(
|
| 2551 |
+
[_album_message(chat_id, caption="опиши")] +
|
| 2552 |
+
[_album_message(chat_id, file_id=f"f{i}") for i in range(4)]
|
| 2553 |
+
))
|
| 2554 |
+
assert seen["extra"] is not None and len(seen["extra"]) == 1
|
| 2555 |
+
finally:
|
| 2556 |
+
bot.chat_state.pop(chat_id, None)
|
| 2557 |
+
|
| 2558 |
+
|
| 2559 |
+
def test_album_downloads_bounded_concurrency(monkeypatch):
|
| 2560 |
+
# Аудит D1/A6-2: in-flight скачивания альбома ограничены семафором.
|
| 2561 |
+
import lumen_message_core
|
| 2562 |
+
live = {"cur": 0, "max": 0}
|
| 2563 |
+
|
| 2564 |
+
async def slow_fetch(fid, mime):
|
| 2565 |
+
live["cur"] += 1
|
| 2566 |
+
live["max"] = max(live["max"], live["cur"])
|
| 2567 |
+
try:
|
| 2568 |
+
await asyncio.sleep(0.02)
|
| 2569 |
+
return (b"z", mime)
|
| 2570 |
+
finally:
|
| 2571 |
+
live["cur"] -= 1
|
| 2572 |
+
|
| 2573 |
+
async def allow_all(message):
|
| 2574 |
+
return False
|
| 2575 |
+
|
| 2576 |
+
seen = {}
|
| 2577 |
+
|
| 2578 |
+
async def fake_core(message, extra_media=None):
|
| 2579 |
+
seen["extra"] = extra_media
|
| 2580 |
+
|
| 2581 |
+
monkeypatch.setattr(bot, "_fetch_media", slow_fetch)
|
| 2582 |
+
monkeypatch.setattr(bot, "_reject_rate_limited_message", allow_all)
|
| 2583 |
+
monkeypatch.setattr(bot, "_handle_message_core", fake_core)
|
| 2584 |
+
chat_id = 999984
|
| 2585 |
+
try:
|
| 2586 |
+
asyncio.run(lumen_message_core._process_media_group_buffers_locked(
|
| 2587 |
+
[_album_message(chat_id, caption="опиши")] +
|
| 2588 |
+
[_album_message(chat_id, file_id=f"f{i}") for i in range(9)]
|
| 2589 |
+
))
|
| 2590 |
+
assert live["max"] <= 3
|
| 2591 |
+
assert seen["extra"] is not None and len(seen["extra"]) == 9
|
| 2592 |
+
finally:
|
| 2593 |
+
bot.chat_state.pop(chat_id, None)
|
tests/test_bot_tiktok.py
CHANGED
|
@@ -523,7 +523,7 @@ def test_handle_tiktok_single_video_happy_path():
|
|
| 523 |
async def fake_resolve(session, url):
|
| 524 |
return url # ссылка уже "разрешена", не короткая
|
| 525 |
|
| 526 |
-
async def fake_download_url_bin(session, url, headers=None):
|
| 527 |
return b"\x00" * 100 # не видео-байты (не ftyp) — не важно для этого теста
|
| 528 |
|
| 529 |
async def fake_probe_dims(path):
|
|
@@ -589,7 +589,7 @@ def test_handle_tiktok_slideshow_video_probing_respects_concurrency_cap():
|
|
| 589 |
async def fake_resolve(session, url):
|
| 590 |
return url
|
| 591 |
|
| 592 |
-
async def fake_download_url_bin(session, url, headers=None):
|
| 593 |
return video_bytes
|
| 594 |
|
| 595 |
async def fake_probe_and_thumbnail(item_bytes):
|
|
@@ -663,7 +663,7 @@ def test_handle_tiktok_slideshow_download_respects_concurrency_cap():
|
|
| 663 |
async def fake_resolve(session, url):
|
| 664 |
return url
|
| 665 |
|
| 666 |
-
async def fake_download_url_bin(session, url, headers=None):
|
| 667 |
nonlocal current_concurrent, max_concurrent_seen
|
| 668 |
async with lock:
|
| 669 |
current_concurrent += 1
|
|
@@ -1449,7 +1449,7 @@ def test_single_video_status_deleted_after_music():
|
|
| 1449 |
async def fake_resolve(session, url):
|
| 1450 |
return url
|
| 1451 |
|
| 1452 |
-
async def fake_download_url_bin(session, url, headers=None):
|
| 1453 |
return b"\x00" * 100
|
| 1454 |
|
| 1455 |
async def fake_probe_dims(path):
|
|
@@ -1625,3 +1625,51 @@ def test_looks_like_video_bytes_true_for_webm_ebml():
|
|
| 1625 |
# A7-9: WebM-слайд раньше принимался за фото.
|
| 1626 |
assert bot._looks_like_video_bytes(b"\x1a\x45\xdf\xa3" + b"\x00" * 16) is True
|
| 1627 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
async def fake_resolve(session, url):
|
| 524 |
return url # ссылка уже "разрешена", не короткая
|
| 525 |
|
| 526 |
+
async def fake_download_url_bin(session, url, headers=None, cap_bytes=None):
|
| 527 |
return b"\x00" * 100 # не видео-байты (не ftyp) — не важно для этого теста
|
| 528 |
|
| 529 |
async def fake_probe_dims(path):
|
|
|
|
| 589 |
async def fake_resolve(session, url):
|
| 590 |
return url
|
| 591 |
|
| 592 |
+
async def fake_download_url_bin(session, url, headers=None, cap_bytes=None):
|
| 593 |
return video_bytes
|
| 594 |
|
| 595 |
async def fake_probe_and_thumbnail(item_bytes):
|
|
|
|
| 663 |
async def fake_resolve(session, url):
|
| 664 |
return url
|
| 665 |
|
| 666 |
+
async def fake_download_url_bin(session, url, headers=None, cap_bytes=None):
|
| 667 |
nonlocal current_concurrent, max_concurrent_seen
|
| 668 |
async with lock:
|
| 669 |
current_concurrent += 1
|
|
|
|
| 1449 |
async def fake_resolve(session, url):
|
| 1450 |
return url
|
| 1451 |
|
| 1452 |
+
async def fake_download_url_bin(session, url, headers=None, cap_bytes=None):
|
| 1453 |
return b"\x00" * 100
|
| 1454 |
|
| 1455 |
async def fake_probe_dims(path):
|
|
|
|
| 1625 |
# A7-9: WebM-слайд раньше принимался за фото.
|
| 1626 |
assert bot._looks_like_video_bytes(b"\x1a\x45\xdf\xa3" + b"\x00" * 16) is True
|
| 1627 |
|
| 1628 |
+
|
| 1629 |
+
def test_download_url_bin_honors_caller_cap():
|
| 1630 |
+
# Аудит A7-5: видео режем лимитом отправки (50МБ), а не общим 75МБ.
|
| 1631 |
+
mapping = {"http://8.8.8.8/v.mp4": _FakeDownloadResponse([b"123456789012345"])}
|
| 1632 |
+
session = _RedirectDownloadSession(mapping)
|
| 1633 |
+
result = asyncio.run(lumen_tiktok._download_url_bin(
|
| 1634 |
+
session, "http://8.8.8.8/v.mp4", cap_bytes=10))
|
| 1635 |
+
assert result is None
|
| 1636 |
+
|
| 1637 |
+
|
| 1638 |
+
def test_slideshow_stops_at_post_budget(monkeypatch):
|
| 1639 |
+
# Аудит D1/A7-1: общий кап RAM на пост — остаток слайдов пропускается.
|
| 1640 |
+
calls = {}
|
| 1641 |
+
|
| 1642 |
+
async def fake_download(session, url, headers=None):
|
| 1643 |
+
return b"k" * 1024
|
| 1644 |
+
|
| 1645 |
+
class _FakeTgBot:
|
| 1646 |
+
async def send_photo(self, **kwargs):
|
| 1647 |
+
calls["photo"] = calls.get("photo", 0) + 1
|
| 1648 |
+
|
| 1649 |
+
async def send_video(self, **kwargs):
|
| 1650 |
+
calls["video"] = calls.get("video", 0) + 1
|
| 1651 |
+
|
| 1652 |
+
async def send_media_group(self, **kwargs):
|
| 1653 |
+
calls["group"] = calls.get("group", 0) + 1
|
| 1654 |
+
|
| 1655 |
+
async def fake_edit(*args, **kwargs):
|
| 1656 |
+
return True
|
| 1657 |
+
|
| 1658 |
+
monkeypatch.setattr(bot, "TIKTOK_SLIDESHOW_MAX_BYTES", 1500)
|
| 1659 |
+
monkeypatch.setattr(bot, "_download_url_bin", fake_download)
|
| 1660 |
+
monkeypatch.setattr(bot, "_edit_message_quietly", fake_edit)
|
| 1661 |
+
monkeypatch.setattr(bot, "_delete_message_quietly", fake_edit)
|
| 1662 |
+
monkeypatch.setattr(bot, "bot", _FakeTgBot())
|
| 1663 |
+
incoming = _FakeIncomingMessage(999985)
|
| 1664 |
+
incoming.message_id = 7
|
| 1665 |
+
media_data = {"images": ["http://x/1.jpg", "http://x/2.jpg", "http://x/3.jpg"]}
|
| 1666 |
+
try:
|
| 1667 |
+
result = asyncio.run(bot._try_send_tiktok_slideshow(
|
| 1668 |
+
None, media_data, incoming, None, "author", {},
|
| 1669 |
+
))
|
| 1670 |
+
assert result is True
|
| 1671 |
+
assert calls.get("photo") == 1
|
| 1672 |
+
assert "group" not in calls
|
| 1673 |
+
finally:
|
| 1674 |
+
bot.chat_state.pop(999985, None)
|
| 1675 |
+
|
tests/test_bot_tts.py
CHANGED
|
@@ -413,3 +413,67 @@ def test_pcm_to_wav_rejects_non_wav_riff():
|
|
| 413 |
assert out.startswith(b"RIFF") and out[8:12] == b"WAVE"
|
| 414 |
assert lumen_tts.pcm_to_wav(b"RIFFxxxxWAVE....") == b"RIFFxxxxWAVE...."
|
| 415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
assert out.startswith(b"RIFF") and out[8:12] == b"WAVE"
|
| 414 |
assert lumen_tts.pcm_to_wav(b"RIFFxxxxWAVE....") == b"RIFFxxxxWAVE...."
|
| 415 |
|
| 416 |
+
|
| 417 |
+
def test_inline_tts_zero_budget_never_synthesizes(monkeypatch):
|
| 418 |
+
# Аудит D2: нулевой общий бюджет — синтез не стартует вообще, сразу честная ошибка.
|
| 419 |
+
import lumen_commands
|
| 420 |
+
|
| 421 |
+
async def must_not_synthesize(text):
|
| 422 |
+
raise AssertionError("synthesis must not start on zero budget")
|
| 423 |
+
|
| 424 |
+
monkeypatch.setattr(bot, "TTS_TOTAL_BUDGET_SEC", 0)
|
| 425 |
+
monkeypatch.setattr(lumen_commands, "_synthesize_tts_voice", must_not_synthesize)
|
| 426 |
+
incoming = _FakeIncomingMessage(999986)
|
| 427 |
+
incoming.message_id = 12350
|
| 428 |
+
original_bot = bot.bot
|
| 429 |
+
bot.bot = _FakeVoiceBot()
|
| 430 |
+
try:
|
| 431 |
+
asyncio.run(bot.inline_tts(incoming, "Привет, мир"))
|
| 432 |
+
shown = [text for msg in incoming.sent for text, _ in msg.edits]
|
| 433 |
+
assert shown, "пользователь должен увидеть ошибку, а не тишину"
|
| 434 |
+
finally:
|
| 435 |
+
bot.bot = original_bot
|
| 436 |
+
bot.chat_state.pop(999986, None)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def test_inline_tts_sends_partial_with_shortened_note(monkeypatch):
|
| 440 |
+
# Аудит D2: второй кусок завис — первый уходит, пользователь узнаёт об обрезке.
|
| 441 |
+
import lumen_commands
|
| 442 |
+
|
| 443 |
+
class _CountingVoiceBot(_FakeVoiceBot):
|
| 444 |
+
def __init__(self):
|
| 445 |
+
super().__init__()
|
| 446 |
+
self.voice_count = 0
|
| 447 |
+
|
| 448 |
+
async def send_voice(self, **kwargs):
|
| 449 |
+
self.voice_count += 1
|
| 450 |
+
return await super().send_voice(**kwargs)
|
| 451 |
+
|
| 452 |
+
calls = {"n": 0}
|
| 453 |
+
|
| 454 |
+
async def flaky_synth(text):
|
| 455 |
+
calls["n"] += 1
|
| 456 |
+
if calls["n"] > 1:
|
| 457 |
+
await asyncio.sleep(30)
|
| 458 |
+
return (b"audio1", "speech.ogg", 5)
|
| 459 |
+
|
| 460 |
+
sent = {}
|
| 461 |
+
|
| 462 |
+
async def fake_reply(message, text, **kwargs):
|
| 463 |
+
sent["text"] = text
|
| 464 |
+
|
| 465 |
+
monkeypatch.setattr(bot, "TTS_TOTAL_BUDGET_SEC", 0.3)
|
| 466 |
+
monkeypatch.setattr(lumen_commands, "_synthesize_tts_voice", flaky_synth)
|
| 467 |
+
monkeypatch.setattr(bot, "_safe_reply", fake_reply)
|
| 468 |
+
voice_bot = _CountingVoiceBot()
|
| 469 |
+
monkeypatch.setattr(bot, "bot", voice_bot)
|
| 470 |
+
incoming = _FakeIncomingMessage(999987)
|
| 471 |
+
incoming.message_id = 12351
|
| 472 |
+
bot.get_state(999987)["lang"] = "ru"
|
| 473 |
+
try:
|
| 474 |
+
asyncio.run(bot.inline_tts(incoming, "x" * 900))
|
| 475 |
+
assert voice_bot.voice_count == 1
|
| 476 |
+
assert "сокращ" in sent.get("text", "")
|
| 477 |
+
finally:
|
| 478 |
+
bot.chat_state.pop(999987, None)
|
| 479 |
+
|
tests/test_lumen_router_config.py
CHANGED
|
@@ -161,8 +161,11 @@ def test_build_route_plain_text_prefers_groq_to_save_quotas():
|
|
| 161 |
|
| 162 |
|
| 163 |
def test_build_route_heavy_plain_text_uses_strong_openrouter_models_first():
|
|
|
|
|
|
|
| 164 |
route = lumen_router_config._build_route(needs_youtube=False, needs_website=False, media_mime=None, is_heavy=True, needs_freshness=False)
|
| 165 |
-
assert route[0] == ("
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
def test_build_route_image_without_freshness_prefers_openrouter_vision():
|
|
@@ -186,6 +189,9 @@ def test_build_route_image_with_freshness_forces_gemini_search_chain():
|
|
| 186 |
# См. обновлённый GEMINI_SEARCH_CHAIN (24.07.2026) — реальная квота на search
|
| 187 |
# grounding подтверждена только у Gemini 2.5, не у 3.x lite-моделей.
|
| 188 |
assert route[0][1] == "gemini-2.5-flash"
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
|
| 191 |
def test_or_route_excludes_uncensored_and_dead_models():
|
|
|
|
| 161 |
|
| 162 |
|
| 163 |
def test_build_route_heavy_plain_text_uses_strong_openrouter_models_first():
|
| 164 |
+
# ОБНОВЛЕНО (аудит D3, 30.09.2026): тяжёлый текст идёт через Groq головой
|
| 165 |
+
# (свободные 1000/день экономят scarce-квоты OR/Gemini), дальше сильные OR.
|
| 166 |
route = lumen_router_config._build_route(needs_youtube=False, needs_website=False, media_mime=None, is_heavy=True, needs_freshness=False)
|
| 167 |
+
assert route[0] == ("groq", "qwen/qwen3.8-27b")
|
| 168 |
+
assert route[2] == ("openrouter", "nvidia/nemotron-3-ultra-550b-a55b:free")
|
| 169 |
|
| 170 |
|
| 171 |
def test_build_route_image_without_freshness_prefers_openrouter_vision():
|
|
|
|
| 189 |
# См. обновлённый GEMINI_SEARCH_CHAIN (24.07.2026) — реальная квота на search
|
| 190 |
# grounding подтверждена только у Gemini 2.5, не у 3.x lite-моделей.
|
| 191 |
assert route[0][1] == "gemini-2.5-flash"
|
| 192 |
+
# ОБНОВЛЕНО (аудит D3, 30.09.2026): дальше vision OpenRouter резервом без
|
| 193 |
+
# поиска — раньше при исчерпании Gemini пользователь получал ошибку квоты.
|
| 194 |
+
assert any(p == "openrouter" for p, _ in route)
|
| 195 |
|
| 196 |
|
| 197 |
def test_or_route_excludes_uncensored_and_dead_models():
|