Spaces:
Running
Running
SilverElixir commited on
Commit ·
2dcccd0
1
Parent(s): 168044d
Harden TikTok downloads with caps and URL checks
Browse files- lumen_tiktok.py +22 -8
- lumen_tiktok_flow.py +12 -2
- tests/bot_test_helpers.py +5 -6
- tests/test_bot_tiktok.py +119 -1
lumen_tiktok.py
CHANGED
|
@@ -121,11 +121,19 @@ def _tiktok_video_candidates(media_data: dict) -> list[dict[str, Any]]:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
try:
|
| 130 |
size_bytes = int(media_data.get(size_key) or 0)
|
| 131 |
except (TypeError, ValueError):
|
|
@@ -157,14 +165,19 @@ async def _tikwm_throttle() -> None:
|
|
| 157 |
_tikwm_last_request_ts = now
|
| 158 |
|
| 159 |
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) >
|
| 167 |
-
log.warning("[tikwm] Refusing %s: Content-Length %s exceeds the %d byte cap.", url, content_length,
|
| 168 |
return None
|
| 169 |
except ValueError:
|
| 170 |
pass
|
|
@@ -172,8 +185,8 @@ async def _read_capped_body(resp: aiohttp.ClientResponse, url: str) -> bytes | N
|
|
| 172 |
total = 0
|
| 173 |
async for chunk in resp.content.iter_chunked(65536):
|
| 174 |
total += len(chunk)
|
| 175 |
-
if total >
|
| 176 |
-
log.warning("[tikwm] Aborting %s: exceeded the %d byte cap mid-stream.", url,
|
| 177 |
return None
|
| 178 |
chunks.append(chunk)
|
| 179 |
return b"".join(chunks)
|
|
@@ -211,7 +224,7 @@ async def _fetch_tikwm_media_data(
|
|
| 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:
|
|
@@ -224,7 +237,8 @@ async def _fetch_tikwm_media_data(
|
|
| 224 |
return res.get("data")
|
| 225 |
log.warning("[tikwm] Endpoint %s returned code %s: %s", api_url, res.get("code"), res.get("msg"))
|
| 226 |
else:
|
| 227 |
-
|
|
|
|
| 228 |
if r.status == 403:
|
| 229 |
saw_403 = True
|
| 230 |
if r.status != 403 or body:
|
|
|
|
| 121 |
raw_url = media_data.get(url_key)
|
| 122 |
if not raw_url:
|
| 123 |
continue
|
| 124 |
+
if not isinstance(raw_url, str):
|
| 125 |
+
log.warning("[tiktok] Skipping non-string URL for %s: %r.", url_key, raw_url)
|
| 126 |
+
continue
|
| 127 |
if raw_url.startswith("//"):
|
| 128 |
# Протокол-относительный URL чиним как https, иначе ниже склеилось бы в битый "https://www.tikwm.com//host/path".
|
| 129 |
raw_url = "https:" + raw_url
|
| 130 |
elif not raw_url.startswith("http"):
|
| 131 |
+
# Склейка давала userinfo-путаницу ("@evil.com/x" ехал хостом):
|
| 132 |
+
# только относительный путь к базовому хосту.
|
| 133 |
+
raw_url = urllib.parse.urljoin("https://www.tikwm.com/", raw_url)
|
| 134 |
+
if urllib.parse.urlsplit(raw_url).scheme.lower() not in ("http", "https"):
|
| 135 |
+
log.warning("[tiktok] Skipping non-HTTP(S) URL for %s: %r.", url_key, raw_url)
|
| 136 |
+
continue
|
| 137 |
try:
|
| 138 |
size_bytes = int(media_data.get(size_key) or 0)
|
| 139 |
except (TypeError, ValueError):
|
|
|
|
| 165 |
_tikwm_last_request_ts = now
|
| 166 |
|
| 167 |
|
| 168 |
+
# JSON TikWM обязан быть маленьким: свой кап, а не общий видео-лимит.
|
| 169 |
+
TIKWM_JSON_MAX_BYTES = 1 * 1024 * 1024
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def _read_capped_body(resp: aiohttp.ClientResponse, url: str, *, cap_bytes: int | None = None) -> bytes | None:
|
| 173 |
# JSON TikWM обязан быть маленьким: читаем с тем же капом, что бинарные
|
| 174 |
# скачивания, иначе скомпрометированный прокси льёт в память без края.
|
| 175 |
+
cap = cap_bytes if cap_bytes is not None else TIKTOK_DOWNLOAD_MAX_BYTES
|
| 176 |
content_length = resp.headers.get("Content-Length")
|
| 177 |
if content_length is not None:
|
| 178 |
try:
|
| 179 |
+
if int(content_length) > cap:
|
| 180 |
+
log.warning("[tikwm] Refusing %s: Content-Length %s exceeds the %d byte cap.", url, content_length, cap)
|
| 181 |
return None
|
| 182 |
except ValueError:
|
| 183 |
pass
|
|
|
|
| 185 |
total = 0
|
| 186 |
async for chunk in resp.content.iter_chunked(65536):
|
| 187 |
total += len(chunk)
|
| 188 |
+
if total > cap:
|
| 189 |
+
log.warning("[tikwm] Aborting %s: exceeded the %d byte cap mid-stream.", url, cap)
|
| 190 |
return None
|
| 191 |
chunks.append(chunk)
|
| 192 |
return b"".join(chunks)
|
|
|
|
| 224 |
async with session.get(api_url, timeout=12, headers=api_headers) as r:
|
| 225 |
if r.status == 200:
|
| 226 |
all_attempts_403_empty = False
|
| 227 |
+
raw_body = await _read_capped_body(r, api_url, cap_bytes=TIKWM_JSON_MAX_BYTES)
|
| 228 |
if raw_body is None:
|
| 229 |
continue
|
| 230 |
try:
|
|
|
|
| 237 |
return res.get("data")
|
| 238 |
log.warning("[tikwm] Endpoint %s returned code %s: %s", api_url, res.get("code"), res.get("msg"))
|
| 239 |
else:
|
| 240 |
+
raw_error = await _read_capped_body(r, api_url, cap_bytes=TIKWM_JSON_MAX_BYTES)
|
| 241 |
+
body = raw_error if raw_error is not None else b""
|
| 242 |
if r.status == 403:
|
| 243 |
saw_403 = True
|
| 244 |
if r.status != 403 or body:
|
lumen_tiktok_flow.py
CHANGED
|
@@ -20,6 +20,7 @@ from aiogram.types import (
|
|
| 20 |
)
|
| 21 |
|
| 22 |
from lumen_tiktok import (
|
|
|
|
| 23 |
_original_sound_label,
|
| 24 |
_GENERIC_ORIGINAL_SOUND_PHRASES,
|
| 25 |
_chunk_tiktok_media_items,
|
|
@@ -210,7 +211,12 @@ async def _fetch_tikwm_media_data_with_proxy_fallback(session: aiohttp.ClientSes
|
|
| 210 |
import bot
|
| 211 |
result = None
|
| 212 |
for candidate in bot._tikwm_proxy_candidates():
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
if result is not None:
|
| 215 |
return result
|
| 216 |
return result
|
|
@@ -240,7 +246,11 @@ async def _try_send_tiktok_slideshow(
|
|
| 240 |
async with bot._tiktok_slide_download_semaphore:
|
| 241 |
if post_budget[0] <= 0:
|
| 242 |
return None
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
if data:
|
| 245 |
post_budget[0] -= len(data)
|
| 246 |
if post_budget[0] < 0:
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
from lumen_tiktok import (
|
| 23 |
+
TIKTOK_DOWNLOAD_MAX_BYTES,
|
| 24 |
_original_sound_label,
|
| 25 |
_GENERIC_ORIGINAL_SOUND_PHRASES,
|
| 26 |
_chunk_tiktok_media_items,
|
|
|
|
| 211 |
import bot
|
| 212 |
result = None
|
| 213 |
for candidate in bot._tikwm_proxy_candidates():
|
| 214 |
+
try:
|
| 215 |
+
result = await bot._fetch_tikwm_media_data(session, resolved_url, headers, proxy_base_url=candidate)
|
| 216 |
+
except Exception as exc:
|
| 217 |
+
# Упавший кандидат не отменяет остальных: пробуем дальше.
|
| 218 |
+
log.warning("[tiktok] TikWM proxy candidate %r failed: %s", candidate, exc)
|
| 219 |
+
continue
|
| 220 |
if result is not None:
|
| 221 |
return result
|
| 222 |
return result
|
|
|
|
| 246 |
async with bot._tiktok_slide_download_semaphore:
|
| 247 |
if post_budget[0] <= 0:
|
| 248 |
return None
|
| 249 |
+
# Резерв до скачивания: кап слайда — остаток бюджета.
|
| 250 |
+
data = await bot._download_url_bin(
|
| 251 |
+
session, slide_url, headers=headers,
|
| 252 |
+
cap_bytes=min(TIKTOK_DOWNLOAD_MAX_BYTES, post_budget[0]),
|
| 253 |
+
)
|
| 254 |
if data:
|
| 255 |
post_budget[0] -= len(data)
|
| 256 |
if post_budget[0] < 0:
|
tests/bot_test_helpers.py
CHANGED
|
@@ -345,16 +345,15 @@ class _FakeTikTokSession:
|
|
| 345 |
|
| 346 |
|
| 347 |
class _FakeTikwmApiResponse:
|
| 348 |
-
def __init__(self, status=200, json_body=None, body_bytes=
|
| 349 |
import json as _json
|
| 350 |
self.status = status
|
| 351 |
self._json_body = json_body or {}
|
| 352 |
-
|
| 353 |
-
#
|
|
|
|
| 354 |
self.headers = {}
|
| 355 |
-
self.content = _FakeDownloadContent(
|
| 356 |
-
[body_bytes] if body_bytes else [_json.dumps(self._json_body).encode("utf-8")]
|
| 357 |
-
)
|
| 358 |
|
| 359 |
async def __aenter__(self):
|
| 360 |
return self
|
|
|
|
| 345 |
|
| 346 |
|
| 347 |
class _FakeTikwmApiResponse:
|
| 348 |
+
def __init__(self, status=200, json_body=None, body_bytes: bytes | None = None):
|
| 349 |
import json as _json
|
| 350 |
self.status = status
|
| 351 |
self._json_body = json_body or {}
|
| 352 |
+
# Явно заданные байты (включая пустые) едут и в read(), и в стрим,
|
| 353 |
+
# как у настоящего ответа.
|
| 354 |
+
self._body_bytes = body_bytes if body_bytes is not None else _json.dumps(self._json_body).encode("utf-8")
|
| 355 |
self.headers = {}
|
| 356 |
+
self.content = _FakeDownloadContent([self._body_bytes])
|
|
|
|
|
|
|
| 357 |
|
| 358 |
async def __aenter__(self):
|
| 359 |
return self
|
tests/test_bot_tiktok.py
CHANGED
|
@@ -1667,6 +1667,71 @@ def test_fetch_tikwm_media_data_skips_huge_content_length():
|
|
| 1667 |
assert result is None
|
| 1668 |
|
| 1669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1670 |
def test_probe_video_dimensions_survives_na_width(monkeypatch):
|
| 1671 |
# A7-6: width=N/A раньше ронял float() и вместе с ним валидный duration.
|
| 1672 |
class _FakeFFprobe:
|
|
@@ -1726,6 +1791,17 @@ def test_tiktok_video_candidates_protocol_relative_url():
|
|
| 1726 |
assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
|
| 1727 |
|
| 1728 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1729 |
def test_download_url_bin_honors_caller_cap():
|
| 1730 |
# Аудит A7-5: видео режем лимитом отправки (50МБ), а не общим 75МБ.
|
| 1731 |
mapping = {"http://8.8.8.8/v.mp4": _FakeDownloadResponse([b"123456789012345"])}
|
|
@@ -1739,7 +1815,7 @@ def test_slideshow_stops_at_post_budget(monkeypatch):
|
|
| 1739 |
# Аудит D1/A7-1: общий кап RAM на пост — остаток слайдов пропускается.
|
| 1740 |
calls = {}
|
| 1741 |
|
| 1742 |
-
async def fake_download(session, url, headers=None):
|
| 1743 |
return b"k" * 1024
|
| 1744 |
|
| 1745 |
class _FakeTgBot:
|
|
@@ -1776,3 +1852,45 @@ def test_slideshow_stops_at_post_budget(monkeypatch):
|
|
| 1776 |
finally:
|
| 1777 |
bot.chat_state.pop(999985, None)
|
| 1778 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1667 |
assert result is None
|
| 1668 |
|
| 1669 |
|
| 1670 |
+
def test_fetch_tikwm_media_data_json_capped_below_video_limit():
|
| 1671 |
+
# JSON капнут своим мегабайтом, а не общим видео-лимитом 75МБ.
|
| 1672 |
+
import lumen_tiktok
|
| 1673 |
+
from tests.bot_test_helpers import _FakeTikwmApiResponse, _FakeTikwmApiSession
|
| 1674 |
+
assert lumen_tiktok.TIKWM_JSON_MAX_BYTES <= 2 * 1024 * 1024
|
| 1675 |
+
big = b'{"code": 0, "data": {}, "pad": "' + b"x" * (lumen_tiktok.TIKWM_JSON_MAX_BYTES + 100) + b'"}'
|
| 1676 |
+
resp = _FakeTikwmApiResponse(status=200, body_bytes=big)
|
| 1677 |
+
result = asyncio.run(bot._fetch_tikwm_media_data(
|
| 1678 |
+
_FakeTikwmApiSession([resp]), "https://www.tiktok.com/@u/video/1", {}))
|
| 1679 |
+
assert result is None
|
| 1680 |
+
|
| 1681 |
+
|
| 1682 |
+
def test_fetch_tikwm_media_data_error_branch_reads_capped():
|
| 1683 |
+
# Ветка ошибки тоже читает с капом, а не голым read().
|
| 1684 |
+
from tests.bot_test_helpers import _FakeDownloadContent, _FakeTikwmApiSession
|
| 1685 |
+
calls = []
|
| 1686 |
+
|
| 1687 |
+
class _NoUncappedReadResponse:
|
| 1688 |
+
status = 500
|
| 1689 |
+
headers = {"Content-Length": str(10 ** 12)}
|
| 1690 |
+
|
| 1691 |
+
def __init__(self):
|
| 1692 |
+
self.content = _FakeDownloadContent([b"e" * 100])
|
| 1693 |
+
|
| 1694 |
+
async def __aenter__(self):
|
| 1695 |
+
return self
|
| 1696 |
+
|
| 1697 |
+
async def __aexit__(self, *args):
|
| 1698 |
+
return False
|
| 1699 |
+
|
| 1700 |
+
async def read(self):
|
| 1701 |
+
calls.append("read")
|
| 1702 |
+
return b"e" * 100
|
| 1703 |
+
|
| 1704 |
+
result = asyncio.run(bot._fetch_tikwm_media_data(
|
| 1705 |
+
_FakeTikwmApiSession([_NoUncappedReadResponse()]), "https://www.tiktok.com/@u/video/1", {}))
|
| 1706 |
+
assert result is None
|
| 1707 |
+
assert calls == []
|
| 1708 |
+
|
| 1709 |
+
|
| 1710 |
+
def test_tikwm_proxy_fallback_tries_next_candidate_on_exception(monkeypatch):
|
| 1711 |
+
# Исключение первого кандидата не отменяет остальных.
|
| 1712 |
+
import lumen_tiktok_flow
|
| 1713 |
+
calls = []
|
| 1714 |
+
|
| 1715 |
+
async def flaky_fetch(session, resolved_url, headers, proxy_base_url=""):
|
| 1716 |
+
calls.append(proxy_base_url)
|
| 1717 |
+
if len(calls) == 1:
|
| 1718 |
+
raise RuntimeError("proxy down")
|
| 1719 |
+
return {"play": "https://tikwm.com/v.mp4"}
|
| 1720 |
+
|
| 1721 |
+
async def always_boom(session, resolved_url, headers, proxy_base_url=""):
|
| 1722 |
+
raise RuntimeError("all down")
|
| 1723 |
+
|
| 1724 |
+
monkeypatch.setattr(bot, "_tikwm_proxy_candidates", lambda: ["http://p1", "http://p2"])
|
| 1725 |
+
monkeypatch.setattr(bot, "_fetch_tikwm_media_data", flaky_fetch)
|
| 1726 |
+
result = asyncio.run(lumen_tiktok_flow._fetch_tikwm_media_data_with_proxy_fallback(
|
| 1727 |
+
None, "https://www.tiktok.com/@u/video/1", {}))
|
| 1728 |
+
assert result == {"play": "https://tikwm.com/v.mp4"}
|
| 1729 |
+
assert calls == ["http://p1", "http://p2"]
|
| 1730 |
+
monkeypatch.setattr(bot, "_fetch_tikwm_media_data", always_boom)
|
| 1731 |
+
assert asyncio.run(lumen_tiktok_flow._fetch_tikwm_media_data_with_proxy_fallback(
|
| 1732 |
+
None, "https://www.tiktok.com/@u/video/1", {})) is None
|
| 1733 |
+
|
| 1734 |
+
|
| 1735 |
def test_probe_video_dimensions_survives_na_width(monkeypatch):
|
| 1736 |
# A7-6: width=N/A раньше ронял float() и вместе с ним валидный duration.
|
| 1737 |
class _FakeFFprobe:
|
|
|
|
| 1791 |
assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
|
| 1792 |
|
| 1793 |
|
| 1794 |
+
def test_tiktok_video_candidates_rejects_userinfo_and_non_string():
|
| 1795 |
+
# Склейка давала userinfo-путаницу: "@evil.com/x" ехал хостом.
|
| 1796 |
+
import urllib.parse
|
| 1797 |
+
candidates = bot._tiktok_video_candidates({"play": "@evil.com/x", "size": 1})
|
| 1798 |
+
assert candidates[0]["url"] == "https://www.tikwm.com/@evil.com/x"
|
| 1799 |
+
assert urllib.parse.urlsplit(candidates[0]["url"]).hostname == "www.tikwm.com"
|
| 1800 |
+
assert bot._tiktok_video_candidates({"play": "javascript:alert(1)", "size": 1}) == []
|
| 1801 |
+
assert bot._tiktok_video_candidates({"play": 12345, "size": 1}) == []
|
| 1802 |
+
assert bot._tiktok_video_candidates({"play": {"u": 1}, "size": 1}) == []
|
| 1803 |
+
|
| 1804 |
+
|
| 1805 |
def test_download_url_bin_honors_caller_cap():
|
| 1806 |
# Аудит A7-5: видео режем лимитом отправки (50МБ), а не общим 75МБ.
|
| 1807 |
mapping = {"http://8.8.8.8/v.mp4": _FakeDownloadResponse([b"123456789012345"])}
|
|
|
|
| 1815 |
# Аудит D1/A7-1: общий кап RAM на пост — остаток слайдов пропускается.
|
| 1816 |
calls = {}
|
| 1817 |
|
| 1818 |
+
async def fake_download(session, url, headers=None, cap_bytes=None):
|
| 1819 |
return b"k" * 1024
|
| 1820 |
|
| 1821 |
class _FakeTgBot:
|
|
|
|
| 1852 |
finally:
|
| 1853 |
bot.chat_state.pop(999985, None)
|
| 1854 |
|
| 1855 |
+
|
| 1856 |
+
class _SlideshowCapBot:
|
| 1857 |
+
async def send_photo(self, **kwargs):
|
| 1858 |
+
return SimpleNamespace()
|
| 1859 |
+
|
| 1860 |
+
async def send_video(self, **kwargs):
|
| 1861 |
+
return SimpleNamespace()
|
| 1862 |
+
|
| 1863 |
+
async def send_media_group(self, **kwargs):
|
| 1864 |
+
return SimpleNamespace()
|
| 1865 |
+
|
| 1866 |
+
|
| 1867 |
+
def test_slideshow_slide_download_reserves_post_budget(monkeypatch):
|
| 1868 |
+
# Кап слайда — остаток общего бюджета до скачивания, а не после.
|
| 1869 |
+
import lumen_tiktok
|
| 1870 |
+
seen_caps = {}
|
| 1871 |
+
|
| 1872 |
+
async def fake_download(session, url, headers=None, cap_bytes=None):
|
| 1873 |
+
seen_caps[url] = cap_bytes
|
| 1874 |
+
return b"k" * 1024
|
| 1875 |
+
|
| 1876 |
+
async def fake_edit(*args, **kwargs):
|
| 1877 |
+
return True
|
| 1878 |
+
|
| 1879 |
+
monkeypatch.setattr(bot, "TIKTOK_SLIDESHOW_MAX_BYTES", 1500)
|
| 1880 |
+
monkeypatch.setattr(bot, "_download_url_bin", fake_download)
|
| 1881 |
+
monkeypatch.setattr(bot, "_edit_message_quietly", fake_edit)
|
| 1882 |
+
monkeypatch.setattr(bot, "_delete_message_quietly", fake_edit)
|
| 1883 |
+
monkeypatch.setattr(bot, "bot", _SlideshowCapBot())
|
| 1884 |
+
incoming = _FakeIncomingMessage(999984)
|
| 1885 |
+
incoming.message_id = 7
|
| 1886 |
+
media_data = {"images": ["http://x/1.jpg", "http://x/2.jpg", "http://x/3.jpg"]}
|
| 1887 |
+
try:
|
| 1888 |
+
asyncio.run(bot._try_send_tiktok_slideshow(
|
| 1889 |
+
None, media_data, incoming, None, "author", {},
|
| 1890 |
+
))
|
| 1891 |
+
assert seen_caps
|
| 1892 |
+
assert all(cap is not None and cap <= 1500 for cap in seen_caps.values())
|
| 1893 |
+
assert all(cap <= lumen_tiktok.TIKTOK_DOWNLOAD_MAX_BYTES for cap in seen_caps.values())
|
| 1894 |
+
finally:
|
| 1895 |
+
bot.chat_state.pop(999984, None)
|
| 1896 |
+
|