SilverElixir commited on
Commit
b059157
·
unverified ·
1 Parent(s): 9a08963

Add files via upload

Browse files
Files changed (6) hide show
  1. README.md +19 -2
  2. bot.py +22 -11
  3. lumen_tiktok.py +35 -22
  4. proxy.ts +145 -0
  5. proxy_test.ts +187 -0
  6. test_bot.py +59 -0
README.md CHANGED
@@ -22,8 +22,9 @@ AI-ассистент в Telegram: автоматический выбор ме
22
  ### Опциональные (есть разумные значения по умолчанию)
23
  | Переменная | По умолчанию | Назначение |
24
  |---|---|---|
25
- | `TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Базовый URL Telegram Bot API. В проде указывает на прокси `https://tg-proxy.silverelixir.deno.net`, т.к. HF Spaces не имеет прямого исходящего доступа к Telegram. |
26
  | `TELEGRAM_API_BASE_URL_FALLBACKS` | — | Список резервных прокси через запятую. При срабатывании circuit breaker (см. `_rotate_telegram_proxy`) бот переключается на следующий адрес по кругу вместо того, чтобы просто ждать паузу на единственном известном прокси. Не задано — поведение как раньше, один прокси. |
 
27
  | `TG_PROXY_COOLDOWN_SEC` | `20` (сек) | Пауза после срабатывания circuit breaker (когда прокси перед Telegram признан недоступным). |
28
  | `TG_PROXY_TRIP_THRESHOLD` | `3` | Сколько сбоев подряд (без единого успеха между ними) нужно, чтобы circuit breaker сработал — защита от того, чтобы одна разовая заминка на одной ноде anycast-CDN глушила ответы бота всем чатам. |
29
  | `ADMIN_SECRET_SEED` | значение `BOT_TOKEN` | Отдельная соль для вывода `WEBHOOK_SECRET`/`ADMIN_PANEL_KEY` — если задана, оба секрета можно ротировать независимо от `BOT_TOKEN`, не трогая сам токен бота у @BotFather. |
@@ -52,6 +53,22 @@ AI-ассистент в Telegram: автоматический выбор ме
52
  | `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` | — | Если заданы ОБА — состояние (история чатов, квоты) пишется в Upstash Redis вместо эфемерного диска контейнера и переживает редеплой. См. раздел "Персистентное хранилище" ниже. |
53
  | `SENTRY_DSN` | — | Если задан — включает персистентный трекинг ошибок через Sentry (переживает редеплой, в отличие от `bot.log` на эфемерном диске). См. раздел "Трекинг ошибок (Sentry, бесплатно)" ниже. |
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  ## Персистентное хранилище (Upstash Redis, бесплатно)
56
 
57
  По умолчанию `chat_state.json`/`global_quota.json` живут на диске контейнера HF Spaces — он эфемерный, при каждом редеплое (новый пуш кода) всё обнуляется. Чтобы это исправить бесплатно и без риска случайного списания денег:
@@ -209,7 +226,7 @@ AI-ассистент в Telegram: автоматический выбор ме
209
  - **Скачивание с YouTube не поддерживается** (датацентровые IP HF Spaces блокируются на уровне TLS-handshake) — доступен только просмотр/анализ по ссылке через встроенную возможность Gemini, не скачивание файла.
210
  - **Автоматический мониторинг падений — только опционально.** Без настроенного `SENTRY_DSN` (см. раздел "Трекинг ошибок" выше) узнать, что бот не отвечает, можно только по логам или жалобам пользователей — `bot.log` при этом живёт на эфемерном диске и не переживает редеплой. `_notify_owner` отдельно шлёт ЛС владельцу на два конкретных сценария (срабатывание circuit breaker прокси, полное исчерпание квоты Gemini), но это точечные алерты, а не общая история ошибок. `/diag` — ручная проверка по запросу.
211
  - **Стриминг не тестировался против реальных API** (см. выше) — только через мокнутые asyncio-клиент (Gemini) и aiohttp-сессию (OpenRouter, SSE). Логика проверена, но стоит последить за логами `[stream]`/`[identity-leak]`/`[injection-echo]` первые несколько дней после деплоя — особенно для OpenRouter-стриминга, добавленного позже Gemini-версии.
212
- - **Скачивание TikTok зависит от неофициального стороннего API (TikWM)**, а не от официального API TikTok (которого для скачивания попросту не существует публично) — при изменении TikWM формата ответа или его временной недоступности скачивание может сломаться до соответствующей правки кода. Диагностический лог `[tikwm][diag]` (см. раздел выше) — первое место, куда стоит смотреть при таких сбоях.
213
  - **Разрешение фото в TikTok-слайдшоу не проверено попиксельно** — сопоставление с оригиналом в приложении TikTok руками не делалось; по виду URL (`~tplv-photomode-image.jpeg`, без явных суффиксов вида `zoomcover:WxH`, которые обычно означают уменьшенную обложку/превью) похоже на полноразмерный кадр, но это косвенный, а не стопроцентно подтверждённый признак.
214
 
215
  ## Тесты
 
22
  ### Опциональные (есть разумные значения по умолчанию)
23
  | Переменная | По умолчанию | Назначение |
24
  |---|---|---|
25
+ | `TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Базовый URL Telegram Bot API. В проде указывает на прокси (см. раздел "Прокси" ниже), т.к. HF Spaces не имеет прямого исходящего доступа к Telegram — датацентровые IP блокируются на уровне TLS-handshake. |
26
  | `TELEGRAM_API_BASE_URL_FALLBACKS` | — | Список резервных прокси через запятую. При срабатывании circuit breaker (см. `_rotate_telegram_proxy`) бот переключается на следующий адрес по кругу вместо того, чтобы просто ждать паузу на единственном известном прокси. Не задано — поведение как раньше, один прокси. |
27
+ | `TIKWM_API_BASE_URL` | — (пусто — прямые запросы к TikWM) | Базовый URL прокси для запросов к TikWM (см. раздел "Прокси" ниже). НАЙДЕНО ПРИ ОТЛАДКЕ (август 2026, подтверждено вручную): TikWM стабильно отвечает HTTP 403 с пустым телом на запросы с датацентровых IP HF Spaces — тот же класс блокировки, что уже задокументирован ниже для YouTube. Троттлинг, ретраи, валидация URL и заголовки Referer/Origin (см. `_fetch_tikwm_media_data` в `lumen_tiktok.py`) не помогают — единственное подтверждённое рабочее решение — прокси с другого IP. Пусто по умолчанию — старое поведение (прямые запросы к обоим зеркалам tikwm.com), обратная совместимость сохранена. |
28
  | `TG_PROXY_COOLDOWN_SEC` | `20` (сек) | Пауза после срабатывания circuit breaker (когда прокси перед Telegram признан недоступным). |
29
  | `TG_PROXY_TRIP_THRESHOLD` | `3` | Сколько сбоев подряд (без единого успеха между ними) нужно, чтобы circuit breaker сработал — защита от того, чтобы одна разовая заминка на одной ноде anycast-CDN глушила ответы бота всем чатам. |
30
  | `ADMIN_SECRET_SEED` | значение `BOT_TOKEN` | Отдельная соль для вывода `WEBHOOK_SECRET`/`ADMIN_PANEL_KEY` — если задана, оба секрета можно ротировать независимо от `BOT_TOKEN`, не трогая сам токен бота у @BotFather. |
 
53
  | `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` | — | Если заданы ОБА — состояние (история чатов, квоты) пишется в Upstash Redis вместо эфемерного диска контейнера и переживает редеплой. См. раздел "Персистентное хранилище" ниже. |
54
  | `SENTRY_DSN` | — | Если задан — включает персистентный трекинг ошибок через Sentry (переживает редеплой, в отличие от `bot.log` на эфемерном диске). См. раздел "Трекинг ошибок (Sentry, бесплатно)" ниже. |
55
 
56
+ ## Прокси (Telegram + TikWM)
57
+
58
+ HF Spaces не имеет прямого исходящего доступа к части внешних сервисов — датацентровые IP блокируются на уровне сети: Telegram Bot API блокируется полностью (на уровне TLS-handshake — без прокси бот вообще не может ходить в Telegram), TikWM отвечает HTTP 403 с пустым телом на все запросы (найдено при отладке, август 2026 — подтверждено вручную: та же ссылка с не-датацентрового IP, например с телефона на мобильной сети, скачивается через TikWM без проблем; троттлинг, ретраи, валидация URL и заголовки Referer/Origin, добавленные в `_fetch_tikwm_media_data`, не помогли — реальная причина именно блокировка IP, а не что-либо на стороне кода бота).
59
+
60
+ Оба случая закрывает **один и тот же** прокси — небольшой self-hosted скрипт на Deno Deploy. Бесплатный тариф Deno Deploy: 1 млн запросов/мес, 20 ГБ исходящего трафика/мес, лимиты общие на весь аккаунт (не на отдельный проект) — поэтому смысла заводить два раздельных Deno-приложения (одно под Telegram, другое под TikWM) нет, они бы всё равно делили одну и ту же квоту.
61
+
62
+ Прокси — прозрачный allowlisted pass-through: `https://<домен>/fetch/<host>/<путь>` пробрасывается в `https://<host>/<путь>` как есть (метод/заголовки/тело запроса и статус/заголовки/тело ответа — без изменений, тело стримится, а не буферизуется целиком в памяти — важно для загрузки видео в Telegram через `sendVideo`/`sendMediaGroup`). Разрешённые хосты — явный список внутри самого скрипта прокси (`api.telegram.org`, `www.tikwm.com`, `tikwm.com`) — без него прокси был бы открытым анонимным релеем на произвольный адрес, что тратило бы общую квоту трафика аккаунта и могло бы привлечь к аккаунту внимание как к источнику абьюза.
63
+
64
+ Настройка:
65
+ 1. Задеплоить скрипт прокси (исходник — `proxy.ts`, живёт отдельно от этого Python-репозитория) как приложение на Deno Deploy.
66
+ 2. В HF Spaces secrets/variables:
67
+ - `TELEGRAM_API_BASE_URL = https://<домен>/fetch/api.telegram.org`
68
+ - `TIKWM_API_BASE_URL = https://<домен>/fetch/www.tikwm.com`
69
+
70
+ `bot.py` дальше сам достраивает нужные пути (`/bot<token>/<method>`, `/file/bot<token>/<path>` для Telegram; `/api/?url=...` для TikWM) поверх этой базы — менять код при смене адреса прокси не нужно, только значения переменных окружения.
71
+
72
  ## Персистентное хранилище (Upstash Redis, бесплатно)
73
 
74
  По умолчанию `chat_state.json`/`global_quota.json` живут на диске контейнера HF Spaces — он эфемерный, при каждом редеплое (новый пуш кода) всё обнуляется. Чтобы это исправить бесплатно и без риска случайного списания денег:
 
226
  - **Скачивание с YouTube не поддерживается** (датацентровые IP HF Spaces блокируются на уровне TLS-handshake) — доступен только просмотр/анализ по ссылке через встроенную возможность Gemini, не скачивание файла.
227
  - **Автоматический мониторинг падений — только опционально.** Без настроенного `SENTRY_DSN` (см. раздел "Трекинг ошибок" выше) узнать, что бот не отвечает, можно только по логам или жалобам пользователей — `bot.log` при этом живёт на эфемерном диске и не переживает редеплой. `_notify_owner` отдельно шлёт ЛС владельцу на два конкретных сценария (срабатывание circuit breaker прокси, полное исчерпание квоты Gemini), но это точечные алерты, а не общая история ошибок. `/diag` — ручная проверка по запросу.
228
  - **Стриминг не тестировался против реальных API** (см. выше) — только через мокнутые asyncio-клиент (Gemini) и aiohttp-сессию (OpenRouter, SSE). Логика проверена, но стоит последить за логами `[stream]`/`[identity-leak]`/`[injection-echo]` первые несколько дней после деплоя — особенно для OpenRouter-стриминга, добавленного позже Gemini-версии.
229
+ - **Скачивание TikTok зависит от неофициального стороннего API (TikWM)**, а не от официального API TikTok (которого для скачивания попросту не существует публично) — при изменении TikWM формата ответа или его временной недоступности скачивание может сломаться до соответствующей правки кода. Диагностический лог `[tikwm][diag]` (см. раздел выше) — первое место, куда стоит смотреть при таких сбоях. TikWM также блокирует датацентровые IP HF Spaces (см. раздел "Прокси" выше, `TIKWM_API_BASE_URL`) — без настроенного прокси скачивание TikTok стабильно не работает вообще.
230
  - **Разрешение фото в TikTok-слайдшоу не проверено попиксельно** — сопоставление с оригиналом в приложении TikTok руками не делалось; по виду URL (`~tplv-photomode-image.jpeg`, без явных суффиксов вида `zoomcover:WxH`, которые обычно означают уменьшенную обложку/превью) похоже на полноразмерный кадр, но это косвенный, а не стопроцентно подтверждённый признак.
231
 
232
  ## Тесты
bot.py CHANGED
@@ -180,6 +180,19 @@ _TELEGRAM_PROXY_FALLBACKS = [
180
  _TELEGRAM_PROXY_CANDIDATES: list[str] = [TELEGRAM_API_BASE_URL] + [u for u in _TELEGRAM_PROXY_FALLBACKS if u != TELEGRAM_API_BASE_URL]
181
  _telegram_proxy_idx = 0 # индекс текущего активного прокси в _TELEGRAM_PROXY_CANDIDATES
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  BOT_USERNAME = os.getenv("BOT_USERNAME", "LumenAI_bot").strip().lstrip("@")
184
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
185
  OPENROUTER_API_KEY = (os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_KEY") or "").strip()
@@ -2496,17 +2509,15 @@ async def handle_tiktok(message: Message, url: str) -> None:
2496
  # передавался, и бот молча всегда скачивал видео в обычном качестве, даже
2497
  # когда у TikWM реально была версия получше (см. _tiktok_video_candidates).
2498
  #
2499
- # ИСПРАВЛЕНО (отладка 11-12 августа 2026, реальный инцидент — оба зеркала TikWM
2500
- # стабильно отвечали HTTP 403 почти на любую ссылку): запрос к обоим зеркалам
2501
- # TikWM (www.tikwm.com/tikwm.com) с троттлингом и одной повторной попыткой при
2502
- # 403 у ОБОИХ зеркал вынесен в _fetch_tikwm_media_data (lumen_tiktok.py). ВАЖНО —
2503
- # честно зафиксировано по итогам повторной отладки: троттлинг и валидность
2504
- # резолвленного URL (см. _looks_like_resolved_tiktok_url) сами по себе инцидент
2505
- # НЕ закрыли — 403 с пустым телом продолжал приходить даже при полностью
2506
- # корректном URL и правильно разнесённых по времени запросах. См. подробный
2507
- # диагностический комментарий и лог "[tikwm][diag]" внутри _fetch_tikwm_media_data
2508
- # — похоже на блокировку исходящего IP этого сервера, а не на баг в нашем коде.
2509
- media_data = await _fetch_tikwm_media_data(session, resolved_url, headers)
2510
 
2511
  if not media_data:
2512
  raise TikTokUserFacingError("Не удалось полу��ить видео по этой ссылке — возможно, оно приватное, удалено, заблокировано по региону или ссылка битая.")
 
180
  _TELEGRAM_PROXY_CANDIDATES: list[str] = [TELEGRAM_API_BASE_URL] + [u for u in _TELEGRAM_PROXY_FALLBACKS if u != TELEGRAM_API_BASE_URL]
181
  _telegram_proxy_idx = 0 # индекс текущего активного прокси в _TELEGRAM_PROXY_CANDIDATES
182
 
183
+ # НАЙДЕНО ПРИ ОТЛАДКЕ (11-12 августа 2026, реальный инцидент): TikWM стабильно
184
+ # отвечает HTTP 403 с ПУСТЫМ телом на запросы с IP HF Spaces (см. историю правок
185
+ # в lumen_tiktok.py — троттлинг, ретраи и подмена заголовков не помогли, реальная
186
+ # причина — блокировка исходящего IP, а не что-либо, что чинится на нашей
187
+ # стороне). TIKWM_API_BASE_URL — опциональная база для прокси-запроса к TikWM
188
+ # (единый прокси с Telegram, см. README/proxy.ts — тот же принцип, что уже
189
+ # применяется для TELEGRAM_API_BASE_URL). Пусто по умолчанию — _fetch_tikwm_media_data
190
+ # в этом случае стучится в TikWM напрямую (два зеркала), как и раньше; если
191
+ # задано — идёт ОДНИМ запросом через прокси вместо прямого обращения к двум
192
+ # зеркалам напрямую (сам прокси уже решает, к какому реальному хосту TikWM
193
+ # стучаться — см. proxy.ts).
194
+ TIKWM_API_BASE_URL = os.getenv("TIKWM_API_BASE_URL", "").strip().rstrip("/")
195
+
196
  BOT_USERNAME = os.getenv("BOT_USERNAME", "LumenAI_bot").strip().lstrip("@")
197
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
198
  OPENROUTER_API_KEY = (os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_KEY") or "").strip()
 
2509
  # передавался, и бот молча всегда скачивал видео в обычном качестве, даже
2510
  # когда у TikWM реально была версия получше (см. _tiktok_video_candidates).
2511
  #
2512
+ # ИСПРАВЛЕНО (отладка 11-13 августа 2026, реальный инцидент — оба зеркала TikWM
2513
+ # стабильно отвечали HTTP 403 почти на любую ссылку): троттлинг, ретраи, валидация
2514
+ # резолвленного URL (см. _looks_like_resolved_tiktok_url) и заголовки Referer/Origin
2515
+ # — НЕ помогли, 403 с пустым телом продолжал приходить даже при полностью корректном
2516
+ # URL и правильно разнесённых по времени запросах. Причина подтверждена вручную —
2517
+ # блокировка исходящего IP HF Spaces (см. подробный диагностический комментарий в
2518
+ # _fetch_tikwm_media_data). TIKWM_API_BASE_URL — единственное реально работающее
2519
+ # решение: запрос уходит через выделенный прокси с другого IP (см. proxy.ts).
2520
+ media_data = await _fetch_tikwm_media_data(session, resolved_url, headers, proxy_base_url=TIKWM_API_BASE_URL)
 
 
2521
 
2522
  if not media_data:
2523
  raise TikTokUserFacingError("Не удалось полу��ить видео по этой ссылке — возможно, оно приватное, удалено, заблокировано по региону или ссылка битая.")
lumen_tiktok.py CHANGED
@@ -291,12 +291,13 @@ async def _tikwm_throttle() -> None:
291
  _tikwm_last_request_ts = now
292
 
293
 
294
- async def _fetch_tikwm_media_data(session: aiohttp.ClientSession, resolved_url: str, headers: dict, *, hd: bool = True) -> dict | None:
295
- """Запрашивает метаданные поста TikTok у публичного API TikWM, пробуя оба
296
- известных зеркала (www.tikwm.com и tikwm.com) — см. секцию выше про троттлинг
297
- и почему 403 от обоих зеркал заслуживает одной повторной попытки. Возвращает
298
- `data` из ответа при успехе (`code == 0`) или None, если ни одно зеркало не
299
- отдало рабочих данных даже после повторной попытки.
 
300
 
301
  Ответ с ЛЮБЫМ статусом, кроме 200, логируется вместе с телом ответа (а не
302
  только кодом статуса) — без текста самой ошибки TikWM невозможно отличить
@@ -304,19 +305,27 @@ async def _fetch_tikwm_media_data(session: aiohttp.ClientSession, resolved_url:
304
 
305
  НАЙДЕНО ПРИ ПОВТОРНОЙ ОТЛАДКЕ (12 августа 2026): даже полностью корректный,
306
  заново провалидированный URL (см. _looks_like_resolved_tiktok_url) и
307
- корректно разнесённые по времени запросы (throttle+retry выше — оба реально
308
  сработали в реальном инциденте, см. историю правок) всё равно стабильно
309
- получали HTTP 403 с ПОЛНОСТЬЮ ПУСТЫМ телом от ОБОИХ зеркал. Это не похоже
310
- на обычную ошибку самого приложения TikWM (та приходит как HTTP 200 с JSON
311
- {"code":..., "msg":...}, см. ветку ниже) — пустое тело на 403 гораздо больше
312
- похоже на блокировку на уровне прокси/WAF/файрвола ПЕРЕД TikWM (тот же класс
313
- проблемы, что уже задокументирован в README для YouTube — блокировка
314
- датацентровых IP HF Spaces), чем на что-либо, что чинится тайминг- или URL-
315
- правками на нашей стороне. `Referer`/`Origin`, имитирующие вызов со страницы
316
- самого tikwm.com — распространённая, но НЕ гарантированная техника обхода
317
- подобных анти-скрейпинг проверок; честно говоря, из песочницы разработки нет
318
- возможности проверить исходящую сеть до tikwm.com напрямую, поэтому эффект
319
- этого изменения можно подтвердить только по реальному продакшен-трафику."""
 
 
 
 
 
 
 
 
320
  api_headers = {
321
  **headers,
322
  "Referer": "https://www.tikwm.com/",
@@ -324,10 +333,14 @@ async def _fetch_tikwm_media_data(session: aiohttp.ClientSession, resolved_url:
324
  "Accept": "application/json, text/plain, */*",
325
  }
326
  quoted = urllib.parse.quote(resolved_url)
327
- endpoints = [
328
- f"https://www.tikwm.com/api/?url={quoted}&hd=1" if hd else f"https://www.tikwm.com/api/?url={quoted}",
329
- f"https://tikwm.com/api/?url={quoted}&hd=1" if hd else f"https://tikwm.com/api/?url={quoted}",
330
- ]
 
 
 
 
331
  # Остаётся True только если ВООБЩЕ каждая попытка (оба зеркала, оба раунда)
332
  # была именно "403 + пустое тело" — ни одной другой ошибки/статуса/исключения
333
  # среди них не было. Используется только для диагностического лога ниже —
 
291
  _tikwm_last_request_ts = now
292
 
293
 
294
+ async def _fetch_tikwm_media_data(
295
+ session: aiohttp.ClientSession, resolved_url: str, headers: dict, *, hd: bool = True,
296
+ proxy_base_url: str = "",
297
+ ) -> dict | None:
298
+ """Запрашивает метаданные поста TikTok у публичного API TikWM. Возвращает
299
+ `data` из ответа при успехе (`code == 0`) или None, если не отдало рабочих
300
+ данных даже после повторной попытки.
301
 
302
  Ответ с ЛЮБЫМ статусом, кроме 200, логируется вместе с телом ответа (а не
303
  только кодом статуса) — без текста самой ошибки TikWM невозможно отличить
 
305
 
306
  НАЙДЕНО ПРИ ПОВТОРНОЙ ОТЛАДКЕ (12 августа 2026): даже полностью корректный,
307
  заново провалидированный URL (см. _looks_like_resolved_tiktok_url) и
308
+ корректно разнесённые по времени запросы (throttle+retry ниже — оба реально
309
  сработали в реальном инциденте, см. историю правок) всё равно стабильно
310
+ получали HTTP 403 с ПОЛНОСТЬЮ ПУСТЫМ телом от ОБОИХ прямых зеркал TikWM.
311
+ Это не похоже на обычную ошибку самого приложения TikWM (та приходит как
312
+ HTTP 200 с JSON {"code":..., "msg":...}, см. ветку ниже) — пустое тело на
313
+ 403 гораздо больше похоже на блокировку исходящего IP HF Spaces на уровне
314
+ прокси/WAF перед TikWM (тот же класс проблемы, что уже задокументирован в
315
+ README для YouTube), чем на что-либо, что чинится тайминг- или URL-правками
316
+ на нашей стороне. Подтверждено вручную владельцем: то же самое видео через
317
+ собственный веб-интерфейс TikWM с телефона на мобильной сети (не датацентровый
318
+ IP) скачивается без проблем — единственное отличие от сервера бота именно
319
+ исходящий IP. `proxy_base_url` ниже — единственное реально рабочее решение из
320
+ всех попробованных (throttle/URL-валидация/заголовки Referer-Origin не помогли).
321
+
322
+ `proxy_base_url` (см. TIKWM_API_BASE_URL в bot.py, proxy.ts в отдельном
323
+ Deno-репозитории) — если задан, запрос идёт ЧЕРЕЗ выделенный прокси с
324
+ другого, незаблокированного IP, ОДНИМ эндпоинтом вместо двух прямых зеркал:
325
+ устойчивость к недоступности одного зеркала (изначальная причина пробовать
326
+ оба) прокси даёт даром просто тем, что выходит с другого IP — дублировать
327
+ зеркала друг для друга через прокси уже незачем. Если не задан (пусто по
328
+ умолчанию) — прежнее поведение, прямые запросы к обоим зеркалам напрямую."""
329
  api_headers = {
330
  **headers,
331
  "Referer": "https://www.tikwm.com/",
 
333
  "Accept": "application/json, text/plain, */*",
334
  }
335
  quoted = urllib.parse.quote(resolved_url)
336
+ if proxy_base_url:
337
+ suffix = f"/api/?url={quoted}&hd=1" if hd else f"/api/?url={quoted}"
338
+ endpoints = [f"{proxy_base_url}{suffix}"]
339
+ else:
340
+ endpoints = [
341
+ f"https://www.tikwm.com/api/?url={quoted}&hd=1" if hd else f"https://www.tikwm.com/api/?url={quoted}",
342
+ f"https://tikwm.com/api/?url={quoted}&hd=1" if hd else f"https://tikwm.com/api/?url={quoted}",
343
+ ]
344
  # Остаётся True только если ВООБЩЕ каждая попытка (оба зеркала, оба раунда)
345
  # была именно "403 + пустое тело" — ни одной другой ошибки/статуса/исключения
346
  # среди них не было. Используется только для диагностического лога ниже —
proxy.ts ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * proxy.ts — единая точка выхода для запросов, которые сервер бота (HF Spaces)
3
+ * не может сделать напрямую, потому что датацентровые IP HF Spaces блокируются
4
+ * некоторыми сервисами на уровне сети (см. README проекта Lumen):
5
+ * - Telegram Bot API (полностью заблокирован на уровне TLS-handshake — без
6
+ * этого прокси бот вообще не может ходить в Telegram).
7
+ * - TikWM (стабильно отвечает HTTP 403 с пустым телом на исходящие запросы
8
+ * с IP HF Spaces — см. историю отладки, /logs с тегом [tikwm][diag]).
9
+ *
10
+ * ЗАМЕНЯЕТ собой прежний tg-proxy (умел проксировать только Telegram Bot API
11
+ * по формату /bot<token>/<method>) — переименован и обобщён. Лимиты Deno
12
+ * Deploy (free-тариф: 1 млн запросов/мес, 20 ГБ исходящего трафика/мес, 15ч
13
+ * CPU/мес) общие на ВЕСЬ АККАУНТ, а не на отдельный проект — значит держать
14
+ * два раздельных Deno-приложения (одно под Telegram, другое под TikWM) не
15
+ * даёт вообще никакой отдельной квоты, только лишняя сущность для поддержки.
16
+ * Один универсальный прокси проще: один домен, один секрет, один деплой.
17
+ *
18
+ * ── Формат ──
19
+ * GET/POST/... https://<домен>/fetch/<host>/<путь...>?<query>
20
+ * -> https://<host>/<путь...>?<query>
21
+ * Метод, заголовки и тело запроса передаются как есть; статус, заголовки и
22
+ * тело ответа — тоже как есть (без буферизации целиком в память — тело
23
+ * стримится напрямую, это важно для больших file-загрузок в Telegram, см.
24
+ * sendVideo/sendPhoto/sendMediaGroup у бота).
25
+ *
26
+ * ── Почему нужен allowlist хостов ──
27
+ * Без ограничения на разрешённые хосты это был бы открытый анонимный релей на
28
+ * ЛЮБОЙ адрес в интернете — кто угодно, узнав домен, мог бы использовать его
29
+ * для проксирования куда захочет, тратя общую квоту трафика аккаунта (те же
30
+ * 20 ГБ/мес) и потенциально привлекая к аккаунту внимание как к источнику
31
+ * абьюза/скана. ALLOWED_HOSTS ниже — единственные хосты, которые реально
32
+ * нужны боту; расширять список нужно только по факту новой подтверждённой
33
+ * необходимости (см. тот же принцип "не добавляй заранее" в остальном проекте).
34
+ *
35
+ * ── Как настроить бота на использование этого прокси ──
36
+ * В HF Spaces secrets/variables:
37
+ * TELEGRAM_API_BASE_URL = https://<домен>/fetch/api.telegram.org
38
+ * TIKWM_API_BASE_URL = https://<домен>/fetch/www.tikwm.com
39
+ * bot.py дальше сам достраивает нужные пути (/bot<token>/<method>,
40
+ * /file/bot<token>/<path>, /api/?url=...) поверх этой базы — никаких других
41
+ * изменений в Python-коде для смены адреса прокси не требуется.
42
+ */
43
+
44
+ export const ALLOWED_HOSTS = new Set([
45
+ "api.telegram.org",
46
+ "www.tikwm.com",
47
+ "tikwm.com",
48
+ ]);
49
+
50
+ // Заголовки, которые нельзя слепо пробрасывать дальше как есть — Host/Connection
51
+ // в запросе относятся к соединению с ЭТИМ (Deno) сервером, а не с реальным
52
+ // апстримом, апстрим сам выставит правильные. Content-Encoding/Content-Length в
53
+ // ОТВЕТЕ — fetch() в Deno уже сам распаковывает gzip/br к моменту, когда тело
54
+ // становится нам доступно, поэтому исходный Content-Encoding больше не описывает
55
+ // реальное тело — клиент, попытавшийся распаковать уже распакованное, получил бы
56
+ // битые данные. Content-Length по той же причине может не совпадать с реальным
57
+ // размером — рантайм сам выставит корректный Transfer-Encoding для стрима.
58
+ const HOP_BY_HOP_REQUEST_HEADERS = ["host", "connection"];
59
+ const HOP_BY_HOP_RESPONSE_HEADERS = ["content-encoding", "content-length", "connection", "transfer-encoding"];
60
+
61
+ // Разложено на чистые, независимо тестируемые функции (resolveTarget/
62
+ // buildForwardHeaders/buildResponseHeaders) вместо одного большого обработчика —
63
+ // тот же принцип, что и в остальном проекте Lumen (см. lumen_tiktok.py и др.):
64
+ // маршрутизацию и фильтрацию заголовков можно проверить юнит-тестами без единого
65
+ // реального сетевого вызова, а сама сетевая часть (handleRequest) тестируется
66
+ // отдельно через подмену fetch.
67
+
68
+ export type TargetResolution =
69
+ | { ok: true; host: string; url: string }
70
+ | { ok: false; status: 404 | 403; message: string };
71
+
72
+ export function resolveTarget(pathname: string, search: string): TargetResolution {
73
+ // pathname всегда начинается с "/", поэтому после split("/") первый элемент —
74
+ // всегда пустая строка, а реальные сегменты — начиная с индекса 1. Намеренно
75
+ // НЕ фильтруем пустые сегменты через .filter(Boolean) (как было в первой
76
+ // версии) — НАЙДЕНО ПРИ ТЕСТИРОВАНИИ: filter(Boolean) съедал завершающий "/"
77
+ // у путей вида "/fetch/host/api/" (пустой хвостовой сегмент после join
78
+ // как раз и восстанавливает эту же завершающую "/"), из-за чего запрос
79
+ // TikWM вида ".../api/?url=..." ушёл бы к апстриму как ".../api?url=..."
80
+ // без слеша — ровно тот класс "почти правильного, но не совсем" URL, из-за
81
+ // которого уже был потрачен не один час отладки в этом проекте.
82
+ const parts = pathname.split("/");
83
+ if (parts.length < 3 || parts[1] !== "fetch" || parts[2] === "") {
84
+ return { ok: false, status: 404, message: "Not found — ожидаемый формат пути: /fetch/<host>/<путь>" };
85
+ }
86
+ const host = parts[2];
87
+ if (!ALLOWED_HOSTS.has(host)) {
88
+ return { ok: false, status: 403, message: "Host not allowed" };
89
+ }
90
+ const path = "/" + parts.slice(3).join("/");
91
+ return { ok: true, host, url: `https://${host}${path}${search}` };
92
+ }
93
+
94
+ export function buildForwardHeaders(reqHeaders: Headers): Headers {
95
+ const headers = new Headers(reqHeaders);
96
+ for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name);
97
+ return headers;
98
+ }
99
+
100
+ export function buildResponseHeaders(upstreamHeaders: Headers): Headers {
101
+ const headers = new Headers(upstreamHeaders);
102
+ for (const name of HOP_BY_HOP_RESPONSE_HEADERS) headers.delete(name);
103
+ return headers;
104
+ }
105
+
106
+ // fetchImpl — точка подмены для тестов (тот же приём, что bot._get_http_session
107
+ // и т.п. в Python-части проекта) — реальная сеть не нужна ни одному юнит-тесту.
108
+ export async function handleRequest(req: Request, fetchImpl: typeof fetch = fetch): Promise<Response> {
109
+ const url = new URL(req.url);
110
+ const target = resolveTarget(url.pathname, url.search);
111
+ if (!target.ok) {
112
+ return new Response(target.message, { status: target.status });
113
+ }
114
+
115
+ const forwardHeaders = buildForwardHeaders(req.headers);
116
+ let upstreamResp: Response;
117
+ try {
118
+ upstreamResp = await fetchImpl(target.url, {
119
+ method: req.method,
120
+ headers: forwardHeaders,
121
+ // GET/HEAD не могут иметь тело запроса (fetch бросит исключение, если
122
+ // передать body для них) — для остальных методов пробрасываем тело
123
+ // напрямую как поток, не буферизуя целиком в памяти (важно для
124
+ // multipart file-загрузок в Telegram, см. докстринг выше).
125
+ body: (req.method === "GET" || req.method === "HEAD") ? undefined : req.body,
126
+ // @ts-ignore — Deno требует duplex:"half" для потокового тела запроса
127
+ // (часть стандарта WHATWG fetch для body типа ReadableStream).
128
+ duplex: "half",
129
+ });
130
+ } catch (e) {
131
+ return new Response(`Upstream fetch failed: ${e}`, { status: 502 });
132
+ }
133
+
134
+ const respHeaders = buildResponseHeaders(upstreamResp.headers);
135
+ return new Response(upstreamResp.body, {
136
+ status: upstreamResp.status,
137
+ headers: respHeaders,
138
+ });
139
+ }
140
+
141
+ // Реальный сервер стартует только при прямом запуске файла (deno run/deploy),
142
+ // не при импорте из proxy_test.ts — и��аче тесты пытались бы забиндить порт.
143
+ if (import.meta.main) {
144
+ Deno.serve((req) => handleRequest(req));
145
+ }
proxy_test.ts ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // proxy_test.ts — юнит-тесты на proxy.ts. Намеренно БЕЗ внешних зависимостей
2
+ // (никаких jsr:/npm: импортов) — только простые ручные assert-функции ниже.
3
+ // Так `deno test` работает даже без доступа в интернет и не тянет lock-файл.
4
+ //
5
+ // Запуск:
6
+ // deno test proxy_test.ts
7
+
8
+ import {
9
+ ALLOWED_HOSTS,
10
+ buildForwardHeaders,
11
+ buildResponseHeaders,
12
+ handleRequest,
13
+ resolveTarget,
14
+ } from "./proxy.ts";
15
+
16
+ function assert(condition: boolean, message: string): void {
17
+ if (!condition) throw new Error(message);
18
+ }
19
+
20
+ function assertEquals<T>(actual: T, expected: T, message?: string): void {
21
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
22
+ if (!ok) {
23
+ throw new Error(
24
+ message ?? `Ожидалось ${JSON.stringify(expected)}, получено ${JSON.stringify(actual)}`,
25
+ );
26
+ }
27
+ }
28
+
29
+ // ─────────────────────────── resolveTarget ───────────────────────────
30
+
31
+ Deno.test("resolveTarget строит корректный URL для разрешённого хоста", () => {
32
+ const result = resolveTarget("/fetch/api.telegram.org/bot123/getMe", "");
33
+ assert(result.ok, "ожидался ok=true");
34
+ if (result.ok) {
35
+ assertEquals(result.host, "api.telegram.org");
36
+ assertEquals(result.url, "https://api.telegram.org/bot123/getMe");
37
+ }
38
+ });
39
+
40
+ Deno.test("resolveTarget сохраняет query-строку как есть", () => {
41
+ const result = resolveTarget("/fetch/www.tikwm.com/api/", "?url=https://x&hd=1");
42
+ assert(result.ok, "ожидался ok=true");
43
+ if (result.ok) {
44
+ assertEquals(result.url, "https://www.tikwm.com/api/?url=https://x&hd=1");
45
+ }
46
+ });
47
+
48
+ Deno.test("resolveTarget не теряет завершающий слеш в пути (регрессия)", () => {
49
+ // РЕГРЕССИЯ: первая версия использовала .filter(Boolean) при разборе пути,
50
+ // который съедал пустой хвостовой сегмент после последнего "/" — путь вида
51
+ // "/fetch/host/api/" превращался в "https://host/api" (без слеша). Именно
52
+ // такой формат URL реально нужен TikWM (см. bot.py: ".../api/?url=...").
53
+ const withSlash = resolveTarget("/fetch/www.tikwm.com/api/", "");
54
+ assert(withSlash.ok, "ожидался ok=true");
55
+ if (withSlash.ok) assertEquals(withSlash.url, "https://www.tikwm.com/api/");
56
+
57
+ const withoutSlash = resolveTarget("/fetch/www.tikwm.com/api", "");
58
+ assert(withoutSlash.ok, "ожидался ok=true");
59
+ if (withoutSlash.ok) assertEquals(withoutSlash.url, "https://www.tikwm.com/api");
60
+ });
61
+
62
+ Deno.test("resolveTarget корректно строит вложенный путь (Telegram file API)", () => {
63
+ // Регрессия на реальный формат вызова из bot.py (_download_telegram_file_bytes):
64
+ // {TELEGRAM_API_BASE_URL}/file/bot{TOKEN}/{file_path}
65
+ const result = resolveTarget("/fetch/api.telegram.org/file/bot123/photos/file_1.jpg", "");
66
+ assert(result.ok, "ожидался ok=true");
67
+ if (result.ok) {
68
+ assertEquals(result.url, "https://api.telegram.org/file/bot123/photos/file_1.jpg");
69
+ }
70
+ });
71
+
72
+ Deno.test("resolveTarget отклоняет неразрешённый хост с 403 и не строит URL на его основе", () => {
73
+ const result = resolveTarget("/fetch/evil.example.com/steal", "");
74
+ assert(!result.ok, "ожидался ok=false");
75
+ if (!result.ok) {
76
+ assertEquals(result.status, 403);
77
+ }
78
+ });
79
+
80
+ Deno.test("resolveTarget отклоняет некорректный формат пути с 404", () => {
81
+ assertEquals(resolveTarget("/", "").ok, false);
82
+ assertEquals(resolveTarget("/fetch", "").ok, false);
83
+ assertEquals(resolveTarget("/fetch/", "").ok, false);
84
+ assertEquals(resolveTarget("/wrong-prefix/api.telegram.org/getMe", "").ok, false);
85
+ });
86
+
87
+ Deno.test("ALLOWED_HOSTS содержит ровно те хосты, что реально нужны боту", () => {
88
+ assertEquals(ALLOWED_HOSTS.has("api.telegram.org"), true);
89
+ assertEquals(ALLOWED_HOSTS.has("www.tikwm.com"), true);
90
+ assertEquals(ALLOWED_HOSTS.has("tikwm.com"), true);
91
+ assertEquals(ALLOWED_HOSTS.size, 3);
92
+ });
93
+
94
+ // ─────────────────────────── buildForwardHeaders / buildResponseHeaders ───────────────────────────
95
+
96
+ Deno.test("buildForwardHeaders убирает hop-by-hop заголовки запроса, остальные сохраняет", () => {
97
+ const original = new Headers({
98
+ "Host": "proxy.example.com",
99
+ "Connection": "keep-alive",
100
+ "User-Agent": "test-ua",
101
+ });
102
+ const forwarded = buildForwardHeaders(original);
103
+ assertEquals(forwarded.has("host"), false);
104
+ assertEquals(forwarded.has("connection"), false);
105
+ assertEquals(forwarded.get("user-agent"), "test-ua");
106
+ });
107
+
108
+ Deno.test("buildResponseHeaders убирает Content-Encoding/Content-Length (см. докстринг про распаковку)", () => {
109
+ // РЕГРЕССИЯ: fetch() в Deno сам распаковывает gzip/br до того, как тело
110
+ // становится доступно — пробросить исходный Content-Encoding было бы багом
111
+ // (клиент попытался бы распаковать уже распакованные данные).
112
+ const original = new Headers({
113
+ "Content-Encoding": "gzip",
114
+ "Content-Length": "1234",
115
+ "Content-Type": "application/json",
116
+ });
117
+ const cleaned = buildResponseHeaders(original);
118
+ assertEquals(cleaned.has("content-encoding"), false);
119
+ assertEquals(cleaned.has("content-length"), false);
120
+ assertEquals(cleaned.get("content-type"), "application/json");
121
+ });
122
+
123
+ // ─────────────────────────── handleRequest (с подменённым fetch) ───────────────────────────
124
+
125
+ Deno.test("handleRequest пробрасывает GET без тела и возвращает статус/тело апстрима", async () => {
126
+ const fakeFetch: typeof fetch = (url, init) => {
127
+ assertEquals(String(url), "https://www.tikwm.com/api/?url=x");
128
+ assertEquals(init?.method, "GET");
129
+ assertEquals(init?.body, undefined);
130
+ return Promise.resolve(new Response(JSON.stringify({ code: 0 }), {
131
+ status: 200,
132
+ headers: { "Content-Type": "application/json" },
133
+ }));
134
+ };
135
+ const req = new Request("https://proxy.example/fetch/www.tikwm.com/api/?url=x", { method: "GET" });
136
+ const resp = await handleRequest(req, fakeFetch);
137
+ assertEquals(resp.status, 200);
138
+ const body = await resp.json();
139
+ assertEquals(body, { code: 0 });
140
+ });
141
+
142
+ Deno.test("handleRequest возвращает 403 для неразрешённого хоста, не вызывая fetch вообще", async () => {
143
+ let fetchCalled = false;
144
+ const fakeFetch: typeof fetch = () => {
145
+ fetchCalled = true;
146
+ return Promise.resolve(new Response("не должно случиться"));
147
+ };
148
+ const req = new Request("https://proxy.example/fetch/evil.example.com/x", { method: "GET" });
149
+ const resp = await handleRequest(req, fakeFetch);
150
+ assertEquals(resp.status, 403);
151
+ assertEquals(fetchCalled, false, "fetch не должен был вызываться для неразрешённого хоста");
152
+ });
153
+
154
+ Deno.test("handleRequest возвращает 404 для пути без /fetch/ префикса", async () => {
155
+ const req = new Request("https://proxy.example/something-else", { method: "GET" });
156
+ const unusedFetch: typeof fetch = () => Promise.resolve(new Response("unused"));
157
+ const resp = await handleRequest(req, unusedFetch);
158
+ assertEquals(resp.status, 404);
159
+ });
160
+
161
+ Deno.test("handleRequest пробрасывает метод и тело для POST (Telegram sendMessage)", async () => {
162
+ let capturedMethod: string | undefined;
163
+ let capturedBodyIsStream = false;
164
+ const fakeFetch: typeof fetch = (_url, init) => {
165
+ capturedMethod = init?.method;
166
+ capturedBodyIsStream = init?.body instanceof ReadableStream;
167
+ return Promise.resolve(new Response('{"ok":true}', { status: 200 }));
168
+ };
169
+ const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/sendMessage", {
170
+ method: "POST",
171
+ body: JSON.stringify({ chat_id: 1, text: "hi" }),
172
+ headers: { "Content-Type": "application/json" },
173
+ });
174
+ const resp = await handleRequest(req, fakeFetch);
175
+ assertEquals(resp.status, 200);
176
+ assertEquals(capturedMethod, "POST");
177
+ assert(capturedBodyIsStream, "тело POST-запроса должно передаваться апстриму как поток (без буферизации целиком)");
178
+ });
179
+
180
+ Deno.test("handleRequest возвращает 502, если апстрим-fetch упал с исключением", async () => {
181
+ const fakeFetch: typeof fetch = () => {
182
+ throw new Error("network unreachable");
183
+ };
184
+ const req = new Request("https://proxy.example/fetch/api.telegram.org/getMe", { method: "GET" });
185
+ const resp = await handleRequest(req, fakeFetch);
186
+ assertEquals(resp.status, 502);
187
+ });
test_bot.py CHANGED
@@ -3319,6 +3319,65 @@ def test_fetch_tikwm_media_data_sends_referer_and_origin_headers():
3319
  assert sent_headers["User-Agent"] == "test-ua"
3320
 
3321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3322
  def test_fetch_tikwm_media_data_logs_ip_block_hint_when_all_attempts_403_with_empty_body(caplog):
3323
  # Регрессия на реальный инцидент (12 августа 2026): корректный URL + корректный
3324
  # троттлинг + ВСЕ попытки (оба зеркала, оба раунда) вернули 403 с пустым телом —
 
3319
  assert sent_headers["User-Agent"] == "test-ua"
3320
 
3321
 
3322
+ # ─────────────────── _fetch_tikwm_media_data: запрос через выделенный прокси (proxy_base_url) ───────────────────
3323
+ # Регрессия на итог отладки блокировки IP (см. README/proxy.ts): throttle, ретраи,
3324
+ # валидация URL и Referer/Origin не помогли — единственное подтверждённое рабочее
3325
+ # решение — прокси с другого IP. Тесты ниже проверяют именно ветку `proxy_base_url`
3326
+ # в отрыве от прямых зеркал (та ветка уже покрыта тестами выше).
3327
+
3328
+ def test_fetch_tikwm_media_data_uses_single_proxy_endpoint_when_configured():
3329
+ resp = _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "via-proxy"}})
3330
+ session = _FakeTikwmApiSession([resp])
3331
+ result = asyncio.run(bot._fetch_tikwm_media_data(
3332
+ session, "https://www.tiktok.com/@u/video/1", {},
3333
+ proxy_base_url="https://proxy.example.com/fetch/www.tikwm.com",
3334
+ ))
3335
+ assert result == {"play": "via-proxy"}
3336
+ # Ровно ОДИН запрос (не два зеркала) — прокси сам решает, к какому реальному
3337
+ # хосту TikWM стучаться, дублировать зеркала через него уже незачем.
3338
+ assert session.requested_urls == [
3339
+ "https://proxy.example.com/fetch/www.tikwm.com/api/?url=https%3A//www.tiktok.com/%40u/video/1&hd=1"
3340
+ ]
3341
+
3342
+
3343
+ def test_fetch_tikwm_media_data_proxy_endpoint_respects_hd_flag():
3344
+ resp = _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "x"}})
3345
+ session = _FakeTikwmApiSession([resp])
3346
+ asyncio.run(bot._fetch_tikwm_media_data(
3347
+ session, "https://www.tiktok.com/@u/video/1", {}, hd=False,
3348
+ proxy_base_url="https://proxy.example.com/fetch/www.tikwm.com",
3349
+ ))
3350
+ assert "&hd=1" not in session.requested_urls[0]
3351
+
3352
+
3353
+ def test_fetch_tikwm_media_data_proxy_still_retries_once_on_403():
3354
+ # Ретрай-раунд при 403 (см. _TIKWM_RETRY_BACKOFF_SEC) не завязан на наличие
3355
+ # двух зеркал — с одним прокси-эндпоинтом повторная попытка тоже должна
3356
+ # сработать (мало ли транзиентная ошибка именно на стороне прокси).
3357
+ responses = [
3358
+ _FakeTikwmApiResponse(status=403, body_bytes=b""),
3359
+ _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "retried"}}),
3360
+ ]
3361
+ session = _FakeTikwmApiSession(responses)
3362
+ result = asyncio.run(bot._fetch_tikwm_media_data(
3363
+ session, "https://www.tiktok.com/@u/video/1", {},
3364
+ proxy_base_url="https://proxy.example.com/fetch/www.tikwm.com",
3365
+ ))
3366
+ assert result == {"play": "retried"}
3367
+ assert len(session.requested_urls) == 2
3368
+
3369
+
3370
+ def test_fetch_tikwm_media_data_without_proxy_still_uses_both_direct_mirrors():
3371
+ # Регрессия на обратную совместимость: proxy_base_url="" (значение по
3372
+ # умолчанию, как и раньше, когда TIKWM_API_BASE_URL не задан в env) должно
3373
+ # сохранять старое поведение — оба прямых зеркала, без единого изменения.
3374
+ resp = _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "direct"}})
3375
+ session = _FakeTikwmApiSession([resp])
3376
+ result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/1", {}))
3377
+ assert result == {"play": "direct"}
3378
+ assert session.requested_urls[0].startswith("https://www.tikwm.com/api/?url=")
3379
+
3380
+
3381
  def test_fetch_tikwm_media_data_logs_ip_block_hint_when_all_attempts_403_with_empty_body(caplog):
3382
  # Регрессия на реальный инцидент (12 августа 2026): корректный URL + корректный
3383
  # троттлинг + ВСЕ попытки (оба зеркала, оба раунда) вернули 403 с пустым телом —