Spaces:
Running
Running
Add files via upload
Browse files- README.md +19 -2
- bot.py +22 -11
- lumen_tiktok.py +35 -22
- proxy.ts +145 -0
- proxy_test.ts +187 -0
- 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. В проде указывает на прокси
|
| 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-
|
| 2500 |
-
# стабильно отвечали HTTP 403 почти на любую ссылку):
|
| 2501 |
-
#
|
| 2502 |
-
# 403 у
|
| 2503 |
-
#
|
| 2504 |
-
# р
|
| 2505 |
-
#
|
| 2506 |
-
#
|
| 2507 |
-
|
| 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(
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 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 (та приходит как
|
| 311 |
-
{"code":..., "msg":...}, см. ветку ниже) — пустое тело на
|
| 312 |
-
похоже на блокировку на уровне
|
| 313 |
-
проблемы, что уже задокументирован в
|
| 314 |
-
д
|
| 315 |
-
|
| 316 |
-
с
|
| 317 |
-
|
| 318 |
-
|
| 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 |
-
|
| 328 |
-
f"
|
| 329 |
-
|
| 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 с пустым телом —
|