Spaces:
Running
Running
SilverElixir commited on
Commit ·
bae36ef
1
Parent(s): 8cc0538
TikTok hardening: DNS off-loop, manual redirects, JSON cap, N/A and WebM guards (A7-2..A7-4, A7-6..A7-9, A7-12)
Browse files- lumen_tiktok.py +97 -29
- lumen_tiktok_flow.py +18 -7
- tests/bot_test_helpers.py +39 -0
- tests/test_bot_tiktok.py +134 -0
lumen_tiktok.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
| 9 |
import asyncio
|
| 10 |
import contextlib
|
| 11 |
import ipaddress
|
|
|
|
| 12 |
import logging
|
| 13 |
import os
|
| 14 |
import re
|
|
@@ -93,8 +94,8 @@ def _chunk_tiktok_media_items(items: list, chunk_size: int = TELEGRAM_MEDIA_GROU
|
|
| 93 |
|
| 94 |
|
| 95 |
def _looks_like_video_bytes(data: bytes) -> bool:
|
| 96 |
-
"""Видео или картинка — по "ftyp" на смещении 4: Content-Type от TikWM недостоверен, поле в JSON не помечено."""
|
| 97 |
-
return len(data) >= 12 and data[4:8] == b"ftyp"
|
| 98 |
|
| 99 |
|
| 100 |
def _slideshow_slide_urls(media_data: dict, images_to_fetch: list[str]) -> list[str]:
|
|
@@ -120,7 +121,10 @@ def _tiktok_video_candidates(media_data: dict) -> list[dict[str, Any]]:
|
|
| 120 |
raw_url = media_data.get(url_key)
|
| 121 |
if not raw_url:
|
| 122 |
continue
|
| 123 |
-
if
|
|
|
|
|
|
|
|
|
|
| 124 |
raw_url = "https://www.tikwm.com" + raw_url
|
| 125 |
try:
|
| 126 |
size_bytes = int(media_data.get(size_key) or 0)
|
|
@@ -153,6 +157,28 @@ async def _tikwm_throttle() -> None:
|
|
| 153 |
_tikwm_last_request_ts = now
|
| 154 |
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
async def _fetch_tikwm_media_data(
|
| 157 |
session: aiohttp.ClientSession, resolved_url: str, headers: dict, *, hd: bool = True,
|
| 158 |
proxy_base_url: str = "",
|
|
@@ -185,7 +211,14 @@ async def _fetch_tikwm_media_data(
|
|
| 185 |
async with session.get(api_url, timeout=12, headers=api_headers) as r:
|
| 186 |
if r.status == 200:
|
| 187 |
all_attempts_403_empty = False
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
if res.get("code") == 0 and isinstance(res.get("data"), dict):
|
| 190 |
log.info("[tikwm] Successfully fetched media data from %s", api_url)
|
| 191 |
return res.get("data")
|
|
@@ -254,7 +287,9 @@ def _write_mp3_tags(path: str, title: str, artist: str, cover: bytes | None) ->
|
|
| 254 |
def _env_number_or_default(name: str, default: int) -> int:
|
| 255 |
try:
|
| 256 |
import bot
|
| 257 |
-
|
|
|
|
|
|
|
| 258 |
except Exception:
|
| 259 |
return default
|
| 260 |
TIKTOK_DOWNLOAD_MAX_BYTES = _env_number_or_default("TIKTOK_DOWNLOAD_MAX_BYTES", 75 * 1024 * 1024)
|
|
@@ -319,7 +354,7 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
|
|
| 319 |
if scheme not in ("http", "https"):
|
| 320 |
log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
|
| 321 |
return None
|
| 322 |
-
if not
|
| 323 |
log.warning("[download] Refusing non-public host for URL %r.", url)
|
| 324 |
return None
|
| 325 |
if headers is None:
|
|
@@ -333,7 +368,8 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
|
|
| 333 |
for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
|
| 334 |
async with session.get(current_url, headers=headers, timeout=60, allow_redirects=False) as resp:
|
| 335 |
if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
|
| 336 |
-
|
|
|
|
| 337 |
if next_url is None:
|
| 338 |
return None
|
| 339 |
current_url = next_url
|
|
@@ -345,7 +381,7 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
|
|
| 345 |
# проверен выше. Проверку оставляем как последний рубеж.
|
| 346 |
final_url = getattr(resp, "url", None)
|
| 347 |
final_host = urllib.parse.urlsplit(str(final_url)).hostname if final_url is not None else None
|
| 348 |
-
if final_host is not None and not
|
| 349 |
log.warning("[download] Refusing redirect to non-public host %r.", final_host)
|
| 350 |
return None
|
| 351 |
# Content-Length — быстрый отказ ДО скачивания (сервер может соврать/не прислать — ниже та же проверка потоково по факту).
|
|
@@ -436,9 +472,14 @@ async def _probe_video_dimensions(path: str) -> tuple[int, int, int]:
|
|
| 436 |
for line in stdout.decode(errors="replace").splitlines():
|
| 437 |
line = line.strip()
|
| 438 |
if line.startswith("width="):
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
| 440 |
elif line.startswith("height="):
|
| 441 |
-
|
|
|
|
|
|
|
| 442 |
elif line.startswith("duration="):
|
| 443 |
raw = line.split("=", 1)[1]
|
| 444 |
if raw and raw != "N/A":
|
|
@@ -482,13 +523,18 @@ async def _probe_and_thumbnail_from_path(path: str) -> tuple[int, int, int, byte
|
|
| 482 |
return duration, width, height, thumb_bytes
|
| 483 |
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
async def _probe_and_thumbnail_from_bytes(video_bytes: bytes) -> tuple[int, int, int, bytes | None]:
|
| 486 |
"""То же для байтов видео в памяти (видео-слайды): ffprobe/ffmpeg умеют только файлы — пишем во временный файл."""
|
| 487 |
try:
|
| 488 |
with tempfile.TemporaryDirectory() as tdir:
|
| 489 |
raw_path = os.path.join(tdir, "slide.mp4")
|
| 490 |
-
|
| 491 |
-
|
| 492 |
return await _probe_and_thumbnail_from_path(raw_path)
|
| 493 |
except Exception as probe_exc:
|
| 494 |
log.warning("[tiktok] Video-slide metadata probe failed, sending without: %s", probe_exc)
|
|
@@ -511,25 +557,47 @@ async def _resolve_tiktok_short(session: aiohttp.ClientSession, url: str) -> str
|
|
| 511 |
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
| 512 |
"Upgrade-Insecure-Requests": "1"
|
| 513 |
}
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
|
|
|
|
| 523 |
try:
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
except Exception as e:
|
| 533 |
-
|
| 534 |
|
| 535 |
return url
|
|
|
|
| 9 |
import asyncio
|
| 10 |
import contextlib
|
| 11 |
import ipaddress
|
| 12 |
+
import json
|
| 13 |
import logging
|
| 14 |
import os
|
| 15 |
import re
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def _looks_like_video_bytes(data: bytes) -> bool:
|
| 97 |
+
"""Видео или картинка — по "ftyp" на смещении 4 (MP4) или сигнатуре EBML в начале (WebM): Content-Type от TikWM недостоверен, поле в JSON не помечено."""
|
| 98 |
+
return len(data) >= 12 and (data[4:8] == b"ftyp" or data[:4] == b"\x1a\x45\xdf\xa3")
|
| 99 |
|
| 100 |
|
| 101 |
def _slideshow_slide_urls(media_data: dict, images_to_fetch: list[str]) -> list[str]:
|
|
|
|
| 121 |
raw_url = media_data.get(url_key)
|
| 122 |
if not raw_url:
|
| 123 |
continue
|
| 124 |
+
if raw_url.startswith("//"):
|
| 125 |
+
# Протокол-относительный URL чиним как https, иначе ниже склеилось бы в битый "https://www.tikwm.com//host/path".
|
| 126 |
+
raw_url = "https:" + raw_url
|
| 127 |
+
elif not raw_url.startswith("http"):
|
| 128 |
raw_url = "https://www.tikwm.com" + raw_url
|
| 129 |
try:
|
| 130 |
size_bytes = int(media_data.get(size_key) or 0)
|
|
|
|
| 157 |
_tikwm_last_request_ts = now
|
| 158 |
|
| 159 |
|
| 160 |
+
async def _read_capped_body(resp: aiohttp.ClientResponse, url: str) -> bytes | None:
|
| 161 |
+
# JSON TikWM обязан быть маленьким: читаем с тем же капом, что бинарные
|
| 162 |
+
# скачивания, иначе скомпрометированный прокси льёт в память без края.
|
| 163 |
+
content_length = resp.headers.get("Content-Length")
|
| 164 |
+
if content_length is not None:
|
| 165 |
+
try:
|
| 166 |
+
if int(content_length) > TIKTOK_DOWNLOAD_MAX_BYTES:
|
| 167 |
+
log.warning("[tikwm] Refusing %s: Content-Length %s exceeds the %d byte cap.", url, content_length, TIKTOK_DOWNLOAD_MAX_BYTES)
|
| 168 |
+
return None
|
| 169 |
+
except ValueError:
|
| 170 |
+
pass
|
| 171 |
+
chunks: list[bytes] = []
|
| 172 |
+
total = 0
|
| 173 |
+
async for chunk in resp.content.iter_chunked(65536):
|
| 174 |
+
total += len(chunk)
|
| 175 |
+
if total > TIKTOK_DOWNLOAD_MAX_BYTES:
|
| 176 |
+
log.warning("[tikwm] Aborting %s: exceeded the %d byte cap mid-stream.", url, TIKTOK_DOWNLOAD_MAX_BYTES)
|
| 177 |
+
return None
|
| 178 |
+
chunks.append(chunk)
|
| 179 |
+
return b"".join(chunks)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
async def _fetch_tikwm_media_data(
|
| 183 |
session: aiohttp.ClientSession, resolved_url: str, headers: dict, *, hd: bool = True,
|
| 184 |
proxy_base_url: str = "",
|
|
|
|
| 211 |
async with session.get(api_url, timeout=12, headers=api_headers) as r:
|
| 212 |
if r.status == 200:
|
| 213 |
all_attempts_403_empty = False
|
| 214 |
+
raw_body = await _read_capped_body(r, api_url)
|
| 215 |
+
if raw_body is None:
|
| 216 |
+
continue
|
| 217 |
+
try:
|
| 218 |
+
res = json.loads(raw_body.decode("utf-8"))
|
| 219 |
+
except Exception as e:
|
| 220 |
+
log.warning("[tikwm] Endpoint %s returned undecodable JSON: %s", api_url, e)
|
| 221 |
+
continue
|
| 222 |
if res.get("code") == 0 and isinstance(res.get("data"), dict):
|
| 223 |
log.info("[tikwm] Successfully fetched media data from %s", api_url)
|
| 224 |
return res.get("data")
|
|
|
|
| 287 |
def _env_number_or_default(name: str, default: int) -> int:
|
| 288 |
try:
|
| 289 |
import bot
|
| 290 |
+
# bot._env_number уже кастует и режет мусор дефолтом; здесь только
|
| 291 |
+
# отложенный импорт (bot.py импортирует этот модуль) и страховка.
|
| 292 |
+
return bot._env_number(name, default, cast=int, min_value=1024)
|
| 293 |
except Exception:
|
| 294 |
return default
|
| 295 |
TIKTOK_DOWNLOAD_MAX_BYTES = _env_number_or_default("TIKTOK_DOWNLOAD_MAX_BYTES", 75 * 1024 * 1024)
|
|
|
|
| 354 |
if scheme not in ("http", "https"):
|
| 355 |
log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
|
| 356 |
return None
|
| 357 |
+
if not await asyncio.to_thread(_host_resolves_to_public, urllib.parse.urlsplit(url).hostname):
|
| 358 |
log.warning("[download] Refusing non-public host for URL %r.", url)
|
| 359 |
return None
|
| 360 |
if headers is None:
|
|
|
|
| 368 |
for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
|
| 369 |
async with session.get(current_url, headers=headers, timeout=60, allow_redirects=False) as resp:
|
| 370 |
if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
|
| 371 |
+
# Резолв хопа тоже в потоке: внутри getaddrinfo, loop не стопорим.
|
| 372 |
+
next_url = await asyncio.to_thread(_checked_redirect_url, current_url, resp.headers.get("Location"))
|
| 373 |
if next_url is None:
|
| 374 |
return None
|
| 375 |
current_url = next_url
|
|
|
|
| 381 |
# проверен выше. Проверку оставляем как последний рубеж.
|
| 382 |
final_url = getattr(resp, "url", None)
|
| 383 |
final_host = urllib.parse.urlsplit(str(final_url)).hostname if final_url is not None else None
|
| 384 |
+
if final_host is not None and not await asyncio.to_thread(_host_resolves_to_public, final_host):
|
| 385 |
log.warning("[download] Refusing redirect to non-public host %r.", final_host)
|
| 386 |
return None
|
| 387 |
# Content-Length — быстрый отказ ДО скачивания (сервер может соврать/не прислать — ниже та же проверка потоково по факту).
|
|
|
|
| 472 |
for line in stdout.decode(errors="replace").splitlines():
|
| 473 |
line = line.strip()
|
| 474 |
if line.startswith("width="):
|
| 475 |
+
raw_w = line.split("=", 1)[1]
|
| 476 |
+
# "N/A" раньше ронял float() и вместе с ним валидный duration всего пробинга.
|
| 477 |
+
if raw_w and raw_w != "N/A":
|
| 478 |
+
width = int(float(raw_w))
|
| 479 |
elif line.startswith("height="):
|
| 480 |
+
raw_h = line.split("=", 1)[1]
|
| 481 |
+
if raw_h and raw_h != "N/A":
|
| 482 |
+
height = int(float(raw_h))
|
| 483 |
elif line.startswith("duration="):
|
| 484 |
raw = line.split("=", 1)[1]
|
| 485 |
if raw and raw != "N/A":
|
|
|
|
| 523 |
return duration, width, height, thumb_bytes
|
| 524 |
|
| 525 |
|
| 526 |
+
def _write_file_sync(path: str, data: bytes) -> None:
|
| 527 |
+
with open(path, "wb") as f:
|
| 528 |
+
f.write(data)
|
| 529 |
+
|
| 530 |
+
|
| 531 |
async def _probe_and_thumbnail_from_bytes(video_bytes: bytes) -> tuple[int, int, int, bytes | None]:
|
| 532 |
"""То же для байтов видео в памяти (видео-слайды): ffprobe/ffmpeg умеют только файлы — пишем во временный файл."""
|
| 533 |
try:
|
| 534 |
with tempfile.TemporaryDirectory() as tdir:
|
| 535 |
raw_path = os.path.join(tdir, "slide.mp4")
|
| 536 |
+
# Запись до 50МБ в потоке: в loop стопорила всех.
|
| 537 |
+
await asyncio.to_thread(_write_file_sync, raw_path, video_bytes)
|
| 538 |
return await _probe_and_thumbnail_from_path(raw_path)
|
| 539 |
except Exception as probe_exc:
|
| 540 |
log.warning("[tiktok] Video-slide metadata probe failed, sending without: %s", probe_exc)
|
|
|
|
| 557 |
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
| 558 |
"Upgrade-Insecure-Requests": "1"
|
| 559 |
}
|
| 560 |
+
# Редиректы разбираем вручную через _checked_redirect_url: aiohttp
|
| 561 |
+
# с allow_redirects=True сходил бы на следующий хоп до проверки хоста.
|
| 562 |
+
current_url = url
|
| 563 |
+
for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
|
| 564 |
+
try:
|
| 565 |
+
async with session.head(current_url, allow_redirects=False, timeout=8, headers=headers) as resp:
|
| 566 |
+
if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
|
| 567 |
+
next_url = await asyncio.to_thread(_checked_redirect_url, current_url, resp.headers.get("Location"))
|
| 568 |
+
if next_url is None:
|
| 569 |
+
break
|
| 570 |
+
current_url = next_url
|
| 571 |
+
continue
|
| 572 |
+
if resp.status < 400:
|
| 573 |
+
resolved = str(resp.url)
|
| 574 |
+
if _looks_like_resolved_tiktok_url(resolved):
|
| 575 |
+
return resolved
|
| 576 |
+
break
|
| 577 |
+
except Exception as e:
|
| 578 |
+
log.warning("[tiktok] HEAD resolution failed: %s", e)
|
| 579 |
+
break
|
| 580 |
|
| 581 |
+
current_url = url
|
| 582 |
try:
|
| 583 |
+
for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
|
| 584 |
+
async with session.get(current_url, allow_redirects=False, timeout=10, headers=headers) as resp:
|
| 585 |
+
if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
|
| 586 |
+
next_url = await asyncio.to_thread(_checked_redirect_url, current_url, resp.headers.get("Location"))
|
| 587 |
+
if next_url is None:
|
| 588 |
+
break
|
| 589 |
+
current_url = next_url
|
| 590 |
+
continue
|
| 591 |
+
# Тело осознанно не читаем: нужен только итоговый URL, соединение
|
| 592 |
+
# просто закроется — капа не требуется, байты не качаем.
|
| 593 |
+
resolved = str(resp.url)
|
| 594 |
+
if not _looks_like_resolved_tiktok_url(resolved):
|
| 595 |
+
# Битый URL после GET тоже логируем — иначе остаётся только малопонятный 403 уже на стороне TikWM.
|
| 596 |
+
log.warning(
|
| 597 |
+
"[tiktok] Resolving short link %s didn't yield anything that looks like a post URL via either HEAD or GET (result: %s) — passing it through as-is, TikWM may reject it.", url, resolved,
|
| 598 |
+
)
|
| 599 |
+
return resolved
|
| 600 |
except Exception as e:
|
| 601 |
+
log.warning("[tiktok] GET resolution failed: %s", e)
|
| 602 |
|
| 603 |
return url
|
lumen_tiktok_flow.py
CHANGED
|
@@ -26,12 +26,25 @@ from lumen_tiktok import (
|
|
| 26 |
_looks_like_video_bytes,
|
| 27 |
_slideshow_slide_urls,
|
| 28 |
_tiktok_video_candidates,
|
|
|
|
| 29 |
TikTokUserFacingError,
|
| 30 |
_tiktok_music_page_id,
|
| 31 |
)
|
| 32 |
|
| 33 |
log = logging.getLogger("bot")
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
async def _send_tiktok_music(session, media_data: dict, message: Message, author: str, headers: dict) -> None:
|
| 36 |
import bot
|
| 37 |
music_url = media_data.get("music")
|
|
@@ -125,12 +138,10 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
|
|
| 125 |
try:
|
| 126 |
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 127 |
tmp_mp3_path = os.path.join(tmp_dir, "music.mp3")
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
bot._write_mp3_tags(tmp_mp3_path, cleaned_title, performer_name, cover_bytes)
|
| 131 |
if os.path.exists(tmp_mp3_path) and os.path.getsize(tmp_mp3_path) > 0:
|
| 132 |
-
|
| 133 |
-
tagged_music_bytes = f.read()
|
| 134 |
# Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
|
| 135 |
music_duration = await bot._probe_audio_duration(tmp_mp3_path)
|
| 136 |
except Exception as tag_err:
|
|
@@ -320,8 +331,8 @@ async def _send_tiktok_single_video(
|
|
| 320 |
try:
|
| 321 |
with tempfile.TemporaryDirectory() as tdir:
|
| 322 |
raw_path = os.path.join(tdir, "raw_tiktok.mp4")
|
| 323 |
-
|
| 324 |
-
|
| 325 |
# Длительность/размеры — явно: TikTok-контейнер Telegram сам не всегда разбирает (иначе "файл" 0:00).
|
| 326 |
duration, width, height = await bot._probe_video_dimensions(raw_path)
|
| 327 |
thumb_bytes = await bot._generate_video_thumbnail(raw_path, duration)
|
|
|
|
| 26 |
_looks_like_video_bytes,
|
| 27 |
_slideshow_slide_urls,
|
| 28 |
_tiktok_video_candidates,
|
| 29 |
+
_write_file_sync,
|
| 30 |
TikTokUserFacingError,
|
| 31 |
_tiktok_music_page_id,
|
| 32 |
)
|
| 33 |
|
| 34 |
log = logging.getLogger("bot")
|
| 35 |
|
| 36 |
+
|
| 37 |
+
def _write_and_tag_mp3_sync(path: str, music_bytes: bytes, title: str, artist: str, cover: bytes | None) -> None:
|
| 38 |
+
import bot
|
| 39 |
+
with open(path, "wb") as f:
|
| 40 |
+
f.write(music_bytes)
|
| 41 |
+
bot._write_mp3_tags(path, title, artist, cover)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _read_file_sync(path: str) -> bytes:
|
| 45 |
+
with open(path, "rb") as f:
|
| 46 |
+
return f.read()
|
| 47 |
+
|
| 48 |
async def _send_tiktok_music(session, media_data: dict, message: Message, author: str, headers: dict) -> None:
|
| 49 |
import bot
|
| 50 |
music_url = media_data.get("music")
|
|
|
|
| 138 |
try:
|
| 139 |
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 140 |
tmp_mp3_path = os.path.join(tmp_dir, "music.mp3")
|
| 141 |
+
# Запись и теги — синхронные и тяжёлые (до 50МБ): в поток, loop не стопорим.
|
| 142 |
+
await asyncio.to_thread(_write_and_tag_mp3_sync, tmp_mp3_path, music_bytes, cleaned_title, performer_name, cover_bytes)
|
|
|
|
| 143 |
if os.path.exists(tmp_mp3_path) and os.path.getsize(tmp_mp3_path) > 0:
|
| 144 |
+
tagged_music_bytes = await asyncio.to_thread(_read_file_sync, tmp_mp3_path)
|
|
|
|
| 145 |
# Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
|
| 146 |
music_duration = await bot._probe_audio_duration(tmp_mp3_path)
|
| 147 |
except Exception as tag_err:
|
|
|
|
| 331 |
try:
|
| 332 |
with tempfile.TemporaryDirectory() as tdir:
|
| 333 |
raw_path = os.path.join(tdir, "raw_tiktok.mp4")
|
| 334 |
+
# Запись до 50МБ в потоке: в loop стопорила всех.
|
| 335 |
+
await asyncio.to_thread(_write_file_sync, raw_path, video_bytes)
|
| 336 |
# Длительность/размеры — явно: TikTok-контейнер Telegram сам не всегда разбирает (иначе "файл" 0:00).
|
| 337 |
duration, width, height = await bot._probe_video_dimensions(raw_path)
|
| 338 |
thumb_bytes = await bot._generate_video_thumbnail(raw_path, duration)
|
tests/bot_test_helpers.py
CHANGED
|
@@ -34,6 +34,34 @@ class _FakeResolveSession:
|
|
| 34 |
return self._get_response
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
class _FakeExc(Exception):
|
| 38 |
def __init__(self, msg, status_code=None):
|
| 39 |
super().__init__(msg)
|
|
@@ -276,8 +304,13 @@ class _FakeTikTokBot:
|
|
| 276 |
|
| 277 |
class _FakeTikTokResponse:
|
| 278 |
def __init__(self, status=200, json_body=None):
|
|
|
|
| 279 |
self.status = status
|
| 280 |
self._json_body = json_body or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
async def __aenter__(self):
|
| 283 |
return self
|
|
@@ -304,9 +337,15 @@ class _FakeTikTokSession:
|
|
| 304 |
|
| 305 |
class _FakeTikwmApiResponse:
|
| 306 |
def __init__(self, status=200, json_body=None, body_bytes=b""):
|
|
|
|
| 307 |
self.status = status
|
| 308 |
self._json_body = json_body or {}
|
| 309 |
self._body_bytes = body_bytes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
async def __aenter__(self):
|
| 312 |
return self
|
|
|
|
| 34 |
return self._get_response
|
| 35 |
|
| 36 |
|
| 37 |
+
class _FakeChainResolveResponse:
|
| 38 |
+
def __init__(self, status: int, url: str = "", headers=None):
|
| 39 |
+
self.status = status
|
| 40 |
+
self.url = url
|
| 41 |
+
self.headers = headers or {}
|
| 42 |
+
|
| 43 |
+
async def __aenter__(self):
|
| 44 |
+
return self
|
| 45 |
+
|
| 46 |
+
async def __aexit__(self, *args):
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class _FakeChainResolveSession:
|
| 51 |
+
"""head()/get() по карте URL с записью kwargs — для проверки ручной цепочки редиректов."""
|
| 52 |
+
def __init__(self, mapping: dict):
|
| 53 |
+
self._mapping = mapping
|
| 54 |
+
self.calls = []
|
| 55 |
+
|
| 56 |
+
def head(self, url, *args, **kwargs):
|
| 57 |
+
self.calls.append(("head", url, dict(kwargs)))
|
| 58 |
+
return self._mapping[url][0]
|
| 59 |
+
|
| 60 |
+
def get(self, url, *args, **kwargs):
|
| 61 |
+
self.calls.append(("get", url, dict(kwargs)))
|
| 62 |
+
return self._mapping[url][1]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
class _FakeExc(Exception):
|
| 66 |
def __init__(self, msg, status_code=None):
|
| 67 |
super().__init__(msg)
|
|
|
|
| 304 |
|
| 305 |
class _FakeTikTokResponse:
|
| 306 |
def __init__(self, status=200, json_body=None):
|
| 307 |
+
import json as _json
|
| 308 |
self.status = status
|
| 309 |
self._json_body = json_body or {}
|
| 310 |
+
# Честный фейк: настоящий aiohttp парсит .json() из тела — контент
|
| 311 |
+
# выводим из того же json, чтобы код шёл боевым путём чтения с капом.
|
| 312 |
+
self.headers = {}
|
| 313 |
+
self.content = _FakeDownloadContent([_json.dumps(self._json_body).encode("utf-8")])
|
| 314 |
|
| 315 |
async def __aenter__(self):
|
| 316 |
return self
|
|
|
|
| 337 |
|
| 338 |
class _FakeTikwmApiResponse:
|
| 339 |
def __init__(self, status=200, json_body=None, body_bytes=b""):
|
| 340 |
+
import json as _json
|
| 341 |
self.status = status
|
| 342 |
self._json_body = json_body or {}
|
| 343 |
self._body_bytes = body_bytes
|
| 344 |
+
# Честный фейк (см. _FakeTikTokResponse): контент из json, если байты не заданы.
|
| 345 |
+
self.headers = {}
|
| 346 |
+
self.content = _FakeDownloadContent(
|
| 347 |
+
[body_bytes] if body_bytes else [_json.dumps(self._json_body).encode("utf-8")]
|
| 348 |
+
)
|
| 349 |
|
| 350 |
async def __aenter__(self):
|
| 351 |
return self
|
tests/test_bot_tiktok.py
CHANGED
|
@@ -6,11 +6,16 @@ test_bot_tiktok.py — TikTok: резолв, слайдшоу, видео, му
|
|
| 6 |
from types import SimpleNamespace
|
| 7 |
import asyncio
|
| 8 |
import bot
|
|
|
|
| 9 |
import lumen_tiktok
|
| 10 |
import pytest
|
|
|
|
|
|
|
| 11 |
import time
|
| 12 |
from tests.bot_test_helpers import (
|
| 13 |
_FakeAudioBot,
|
|
|
|
|
|
|
| 14 |
_FakeDownloadResponse,
|
| 15 |
_FakeDownloadSession,
|
| 16 |
_FakeIncomingMessage,
|
|
@@ -1485,3 +1490,132 @@ def test_single_video_status_deleted_after_music():
|
|
| 1485 |
bot._delete_message_quietly = original_delete
|
| 1486 |
lumen_tiktok_flow._send_tiktok_music = original_music
|
| 1487 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from types import SimpleNamespace
|
| 7 |
import asyncio
|
| 8 |
import bot
|
| 9 |
+
import functools
|
| 10 |
import lumen_tiktok
|
| 11 |
import pytest
|
| 12 |
+
import socket
|
| 13 |
+
import threading
|
| 14 |
import time
|
| 15 |
from tests.bot_test_helpers import (
|
| 16 |
_FakeAudioBot,
|
| 17 |
+
_FakeChainResolveResponse,
|
| 18 |
+
_FakeChainResolveSession,
|
| 19 |
_FakeDownloadResponse,
|
| 20 |
_FakeDownloadSession,
|
| 21 |
_FakeIncomingMessage,
|
|
|
|
| 1490 |
bot._delete_message_quietly = original_delete
|
| 1491 |
lumen_tiktok_flow._send_tiktok_music = original_music
|
| 1492 |
|
| 1493 |
+
|
| 1494 |
+
# ─────────────── S9: DNS в потоке, ручные редиректы, капы, N/A, WebM ───────────────
|
| 1495 |
+
|
| 1496 |
+
def _fake_public_dns(host, port, *args, **kwargs):
|
| 1497 |
+
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))]
|
| 1498 |
+
|
| 1499 |
+
|
| 1500 |
+
def test_download_url_bin_resolves_dns_off_loop(monkeypatch):
|
| 1501 |
+
# A7-2: блокирующий getaddrinfo раньше стопорил loop на время резолва.
|
| 1502 |
+
seen = {}
|
| 1503 |
+
|
| 1504 |
+
def _recording_dns(host, port, *args, **kwargs):
|
| 1505 |
+
seen["thread"] = threading.current_thread()
|
| 1506 |
+
return _fake_public_dns(host, port)
|
| 1507 |
+
|
| 1508 |
+
monkeypatch.setattr(socket, "getaddrinfo", _recording_dns)
|
| 1509 |
+
result = asyncio.run(lumen_tiktok._download_url_bin(
|
| 1510 |
+
_FakeDownloadSession(_FakeDownloadResponse([b"ok"])), "https://example.com/f.mp4"))
|
| 1511 |
+
assert result == b"ok"
|
| 1512 |
+
assert seen["thread"] is not threading.main_thread()
|
| 1513 |
+
|
| 1514 |
+
|
| 1515 |
+
def test_resolve_tiktok_short_disables_auto_redirect(monkeypatch):
|
| 1516 |
+
# A7-3: allow_redirects=True ходил на следующий хоп до проверки хоста.
|
| 1517 |
+
monkeypatch.setattr(socket, "getaddrinfo", _fake_public_dns)
|
| 1518 |
+
canonical = "https://www.tiktok.com/@u/video/123"
|
| 1519 |
+
session = _FakeChainResolveSession({
|
| 1520 |
+
"https://vt.tiktok.com/x/": (
|
| 1521 |
+
_FakeChainResolveResponse(301, headers={"Location": canonical}),
|
| 1522 |
+
_FakeChainResolveResponse(200, url="https://vt.tiktok.com/x/"),
|
| 1523 |
+
),
|
| 1524 |
+
canonical: (
|
| 1525 |
+
_FakeChainResolveResponse(200, url=canonical),
|
| 1526 |
+
_FakeChainResolveResponse(200, url=canonical),
|
| 1527 |
+
),
|
| 1528 |
+
})
|
| 1529 |
+
result = asyncio.run(bot._resolve_tiktok_short(session, "https://vt.tiktok.com/x/"))
|
| 1530 |
+
assert result == canonical
|
| 1531 |
+
assert session.calls
|
| 1532 |
+
assert all(call[2].get("allow_redirects") is False for call in session.calls)
|
| 1533 |
+
|
| 1534 |
+
|
| 1535 |
+
def test_resolve_tiktok_short_refuses_non_public_hop():
|
| 1536 |
+
# A7-3: внутренний адрес в Location останавливает цепочку до запроса к нему.
|
| 1537 |
+
session = _FakeChainResolveSession({
|
| 1538 |
+
"https://vt.tiktok.com/x/": (
|
| 1539 |
+
_FakeChainResolveResponse(301, headers={"Location": "http://169.254.169.254/evil"}),
|
| 1540 |
+
_FakeChainResolveResponse(404, url="https://vt.tiktok.com/x/"),
|
| 1541 |
+
),
|
| 1542 |
+
})
|
| 1543 |
+
result = asyncio.run(bot._resolve_tiktok_short(session, "https://vt.tiktok.com/x/"))
|
| 1544 |
+
assert "169.254" not in result
|
| 1545 |
+
assert all("169.254" not in url for _, url, _ in session.calls)
|
| 1546 |
+
|
| 1547 |
+
|
| 1548 |
+
def test_fetch_tikwm_media_data_skips_huge_content_length():
|
| 1549 |
+
# A7-4: тело JSON раньше читалось без капа — заявленные гигабайты
|
| 1550 |
+
# пропускаем до скачивания.
|
| 1551 |
+
from tests.bot_test_helpers import _FakeTikwmApiResponse, _FakeTikwmApiSession
|
| 1552 |
+
resp = _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {}})
|
| 1553 |
+
resp.headers = {"Content-Length": str(10 ** 12)}
|
| 1554 |
+
result = asyncio.run(bot._fetch_tikwm_media_data(
|
| 1555 |
+
_FakeTikwmApiSession([resp]), "https://www.tiktok.com/@u/video/1", {}))
|
| 1556 |
+
assert result is None
|
| 1557 |
+
|
| 1558 |
+
|
| 1559 |
+
def test_probe_video_dimensions_survives_na_width(monkeypatch):
|
| 1560 |
+
# A7-6: width=N/A раньше ронял float() и вместе с ним валидный duration.
|
| 1561 |
+
class _FakeFFprobe:
|
| 1562 |
+
returncode = 0
|
| 1563 |
+
|
| 1564 |
+
def __init__(self, out):
|
| 1565 |
+
self._out = out
|
| 1566 |
+
|
| 1567 |
+
async def communicate(self):
|
| 1568 |
+
return (self._out, b"")
|
| 1569 |
+
|
| 1570 |
+
async def wait(self):
|
| 1571 |
+
return 0
|
| 1572 |
+
|
| 1573 |
+
def kill(self):
|
| 1574 |
+
pass
|
| 1575 |
+
|
| 1576 |
+
async def _fake_exec(*args, **kwargs):
|
| 1577 |
+
return _FakeFFprobe(b"width=N/A\nheight=720\nduration=10.5\n")
|
| 1578 |
+
|
| 1579 |
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
|
| 1580 |
+
assert asyncio.run(lumen_tiktok._probe_video_dimensions("/tmp/x.mp4")) == (10, 0, 720)
|
| 1581 |
+
|
| 1582 |
+
|
| 1583 |
+
def test_probe_thumbnail_from_bytes_writes_off_loop(monkeypatch):
|
| 1584 |
+
# A7-7: запись файла раньше шла в loop.
|
| 1585 |
+
seen = {}
|
| 1586 |
+
real_to_thread = asyncio.to_thread
|
| 1587 |
+
|
| 1588 |
+
def _wrap(func):
|
| 1589 |
+
@functools.wraps(func)
|
| 1590 |
+
def _inner(*args, **kwargs):
|
| 1591 |
+
seen.setdefault("threads", []).append(threading.current_thread())
|
| 1592 |
+
return func(*args, **kwargs)
|
| 1593 |
+
return _inner
|
| 1594 |
+
|
| 1595 |
+
async def _recorder(func, /, *args, **kwargs):
|
| 1596 |
+
return await real_to_thread(_wrap(func), *args, **kwargs)
|
| 1597 |
+
|
| 1598 |
+
monkeypatch.setattr(asyncio, "to_thread", _recorder)
|
| 1599 |
+
assert asyncio.run(lumen_tiktok._probe_and_thumbnail_from_bytes(b"\x00" * 16)) == (0, 0, 0, None)
|
| 1600 |
+
assert seen["threads"] and all(t is not threading.main_thread() for t in seen["threads"])
|
| 1601 |
+
|
| 1602 |
+
|
| 1603 |
+
def test_write_and_tag_mp3_sync_roundtrip(tmp_path):
|
| 1604 |
+
# A7-7: синхронный кусок MP3-пути вынесен в хелпер (мутаген битый вход терпит).
|
| 1605 |
+
from lumen_tiktok_flow import _write_and_tag_mp3_sync
|
| 1606 |
+
path = str(tmp_path / "m.mp3")
|
| 1607 |
+
_write_and_tag_mp3_sync(path, b"not a real mp3", "t", "a", None)
|
| 1608 |
+
import os
|
| 1609 |
+
assert os.path.exists(path) and os.path.getsize(path) > 0
|
| 1610 |
+
|
| 1611 |
+
|
| 1612 |
+
def test_tiktok_video_candidates_protocol_relative_url():
|
| 1613 |
+
# A7-8: "//host/path" раньше склеивался в битый tikwm-URL.
|
| 1614 |
+
candidates = bot._tiktok_video_candidates({"play": "//cdn.tikwm.com/v.mp4", "size": 1})
|
| 1615 |
+
assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
|
| 1616 |
+
|
| 1617 |
+
|
| 1618 |
+
def test_looks_like_video_bytes_true_for_webm_ebml():
|
| 1619 |
+
# A7-9: WebM-слайд раньше принимался за фото.
|
| 1620 |
+
assert bot._looks_like_video_bytes(b"\x1a\x45\xdf\xa3" + b"\x00" * 16) is True
|
| 1621 |
+
|