SilverElixir commited on
Commit
c8fab35
·
unverified ·
2 Parent(s): f75e0d92d97356

Merge pull request #11 from S1lverElixir/agent/audit-behavior

Browse files
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
- try:
1285
- await asyncio.wait_for(
1286
- telegram_api_call("setWebhook", {
1287
- "url": webhook_url,
1288
- "secret_token": WEBHOOK_SECRET,
1289
- # drop_pending_updates=True — решение владельца (27.09.2026): после
1290
- # простоя не отвечать пачкой на старые сообщения. Цена — сообщения,
1291
- # пришедшие пока бот лежал, теряются.
1292
- "drop_pending_updates": True,
1293
- "allowed_updates": ALLOWED_UPDATES,
1294
- }, request_timeout=15.0),
1295
- timeout=18.0
1296
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1297
  log.info("[webhook] Webhook registered successfully: %s", webhook_url)
1298
- except Exception as exc:
1299
  # Секрет в лог не подставляем — готовая ссылка живёт в /webhook_url (Bearer ADMIN_PANEL_KEY, не query).
1300
- log.warning(
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): %s',
1302
- exc,
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
- await server.serve()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) skip Groq entirely and start at 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
 
 
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 = [await _synthesize_tts_voice(chunk) for chunk in chunks]
 
 
 
 
 
 
 
 
 
 
 
 
 
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 resp.read()
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 = await asyncio.gather(*(bot._fetch_media(fid, mime) for fid, mime, _ in targets))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  skipped = 0
 
 
121
  for (fid, mime, src), fetched in zip(targets, fetched_list):
122
- if fetched:
123
- extra_media.append(fetched)
124
- # Регрессия: файлы альбома пишем в recent_media_ids, иначе "что на втором фото" не найдёт их.
125
- bot._save_media_to_history(src, album_state, album_user_id)
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
- med_path, med_mime, med_name, media_tuple = await bot._resolve_incoming_media(
360
- message, state, clean_prompt, is_private=is_private,
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
- await bot._safe_reply(message, bot._model_error_text("fallback", bot._chat_lang(message.chat.id)))
 
 
 
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 needs_freshness or 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
  # Картинка без поиска — сначала 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) > TIKTOK_DOWNLOAD_MAX_BYTES:
392
- log.warning("[download] Refusing to download %s: Content-Length %s exceeds the %d byte cap.", current_url, content_length, TIKTOK_DOWNLOAD_MAX_BYTES)
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 > TIKTOK_DOWNLOAD_MAX_BYTES:
401
- log.warning("[download] Aborting download of %s: exceeded the %d byte cap mid-stream.", current_url, TIKTOK_DOWNLOAD_MAX_BYTES)
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
- return await bot._download_url_bin(session, slide_url, headers=headers)
 
 
 
 
 
 
 
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(session, candidate["url"], headers=headers)
 
 
 
 
 
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
- # Все 4 доп. фото качались одновременно, а не по очереди.
1189
- assert max_concurrent == 4
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] == ("openrouter", "nvidia/nemotron-3-ultra-550b-a55b:free")
 
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():