Spaces:
Running
Running
SilverElixir commited on
Commit ·
c262572
1
Parent(s): 134fae6
Slideshow: wait for post budget instead of dropping slides; log effective TikTok limits
Browse files- bot.py +10 -0
- lumen_tiktok.py +4 -0
- lumen_tiktok_flow.py +39 -17
- tests/test_bot_tiktok.py +54 -0
bot.py
CHANGED
|
@@ -1110,6 +1110,7 @@ from lumen_tiktok import (
|
|
| 1110 |
_tiktok_music_page_id,
|
| 1111 |
_write_mp3_tags,
|
| 1112 |
_download_url_bin,
|
|
|
|
| 1113 |
_probe_audio_duration,
|
| 1114 |
_probe_video_dimensions,
|
| 1115 |
_generate_video_thumbnail,
|
|
@@ -1355,6 +1356,15 @@ async def _webhook_startup() -> None:
|
|
| 1355 |
# Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
|
| 1356 |
await asyncio.to_thread(load_state_from_disk)
|
| 1357 |
log.info("Bot startup: webhook mode.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1358 |
_check_temporary_free_models_expiry()
|
| 1359 |
_check_unconfirmed_model_quotas()
|
| 1360 |
_check_scheduled_removals_due()
|
|
|
|
| 1110 |
_tiktok_music_page_id,
|
| 1111 |
_write_mp3_tags,
|
| 1112 |
_download_url_bin,
|
| 1113 |
+
TIKTOK_DOWNLOAD_MAX_BYTES,
|
| 1114 |
_probe_audio_duration,
|
| 1115 |
_probe_video_dimensions,
|
| 1116 |
_generate_video_thumbnail,
|
|
|
|
| 1356 |
# Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
|
| 1357 |
await asyncio.to_thread(load_state_from_disk)
|
| 1358 |
log.info("Bot startup: webhook mode.")
|
| 1359 |
+
# На Space кап можно переопределить env: без этой строки в логах не видно,
|
| 1360 |
+
# какой лимит реально работает (разбор 26.10.2026).
|
| 1361 |
+
log.info(
|
| 1362 |
+
"[setup] TikTok limits: post=%.0fMB, slide=%.0fMB, slide concurrency=%d, Telegram upload=%.0fMB",
|
| 1363 |
+
TIKTOK_SLIDESHOW_MAX_BYTES / (1024 * 1024),
|
| 1364 |
+
TIKTOK_DOWNLOAD_MAX_BYTES / (1024 * 1024),
|
| 1365 |
+
TIKTOK_SLIDE_DOWNLOAD_CONCURRENCY,
|
| 1366 |
+
TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES / (1024 * 1024),
|
| 1367 |
+
)
|
| 1368 |
_check_temporary_free_models_expiry()
|
| 1369 |
_check_unconfirmed_model_quotas()
|
| 1370 |
_check_scheduled_removals_due()
|
lumen_tiktok.py
CHANGED
|
@@ -401,6 +401,10 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
|
|
| 401 |
# URL — из JSON чужого сервиса (TikWM): качаем только http(s) (AUD-D-003)
|
| 402 |
# и только с публичных адресов (SSRF-гард ниже). cap_bytes — отдельный кап
|
| 403 |
# вызывающего (видео режем лимитом отправки Telegram, а не общим 75МБ).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
cap = cap_bytes if cap_bytes is not None else TIKTOK_DOWNLOAD_MAX_BYTES
|
| 405 |
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
| 406 |
if scheme not in ("http", "https"):
|
|
|
|
| 401 |
# URL — из JSON чужого сервиса (TikWM): качаем только http(s) (AUD-D-003)
|
| 402 |
# и только с публичных адресов (SSRF-гард ниже). cap_bytes — отдельный кап
|
| 403 |
# вызывающего (видео режем лимитом отправки Telegram, а не общим 75МБ).
|
| 404 |
+
# Не-строка вместо ссылки раньше роняла urlsplit и весь пост.
|
| 405 |
+
if not isinstance(url, str):
|
| 406 |
+
log.warning("[download] Refusing non-string URL %r.", url)
|
| 407 |
+
return None
|
| 408 |
cap = cap_bytes if cap_bytes is not None else TIKTOK_DOWNLOAD_MAX_BYTES
|
| 409 |
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
| 410 |
if scheme not in ("http", "https"):
|
lumen_tiktok_flow.py
CHANGED
|
@@ -246,26 +246,48 @@ async def _try_send_tiktok_slideshow(
|
|
| 246 |
fetch_urls = _slideshow_slide_urls(media_data, images_to_fetch)
|
| 247 |
# Общий кап RAM на пост: больше не качаем и остаток пропускаем.
|
| 248 |
post_budget = [bot.TIKTOK_SLIDESHOW_MAX_BYTES]
|
| 249 |
-
# Резерв
|
| 250 |
-
#
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
if post_budget[0] <= 0:
|
| 257 |
return None
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
# Неиспользованный резерв возвращаем: реальный расход — только len(data).
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
return data or None
|
| 269 |
|
| 270 |
downloaded = list(await asyncio.gather(
|
| 271 |
*(_download_slide_bounded(u) for u in fetch_urls)
|
|
|
|
| 246 |
fetch_urls = _slideshow_slide_urls(media_data, images_to_fetch)
|
| 247 |
# Общий кап RAM на пост: больше не качаем и остаток пропускаем.
|
| 248 |
post_budget = [bot.TIKTOK_SLIDESHOW_MAX_BYTES]
|
| 249 |
+
# Резерв — под локом ДО скачивания: иначе параллельные слайды пробивают
|
| 250 |
+
# кап (аудит H4, 10.2026). Если бюджет занят резервами, ждём освобождения,
|
| 251 |
+
# а не пропускаем слайд: резерв 75 МБ на 200 МБ поста выбирали первые три
|
| 252 |
+
# слайда, остальные терялись (прод 08-26.10.2026).
|
| 253 |
+
_budget_cond = asyncio.Condition()
|
| 254 |
+
_reserved = [0]
|
| 255 |
+
|
| 256 |
+
async def _reserve_budget() -> int | None:
|
| 257 |
+
async with _budget_cond:
|
| 258 |
+
while True:
|
| 259 |
+
# Свободно = остаток минус уже обещанное другим слайдам.
|
| 260 |
+
free = post_budget[0] - _reserved[0]
|
| 261 |
+
if free > 0:
|
| 262 |
+
reservation = min(TIKTOK_DOWNLOAD_MAX_BYTES, free)
|
| 263 |
+
_reserved[0] += reservation
|
| 264 |
+
return reservation
|
| 265 |
+
# Бюджет выбран реальными байтами — остаток слайдов не влезет.
|
| 266 |
if post_budget[0] <= 0:
|
| 267 |
return None
|
| 268 |
+
await _budget_cond.wait()
|
| 269 |
+
|
| 270 |
+
async def _release_budget(reservation: int, data: bytes | None) -> None:
|
| 271 |
+
async with _budget_cond:
|
| 272 |
+
_reserved[0] -= reservation
|
| 273 |
+
post_budget[0] -= len(data or b"")
|
| 274 |
+
_budget_cond.notify_all()
|
| 275 |
+
|
| 276 |
+
async def _download_slide_bounded(slide_url: str) -> bytes | None:
|
| 277 |
+
reservation = await _reserve_budget()
|
| 278 |
+
if reservation is None:
|
| 279 |
+
return None
|
| 280 |
+
data = None
|
| 281 |
+
try:
|
| 282 |
+
async with bot._tiktok_slide_download_semaphore:
|
| 283 |
+
data = await bot._download_url_bin(
|
| 284 |
+
session, slide_url, headers=headers,
|
| 285 |
+
cap_bytes=reservation,
|
| 286 |
+
)
|
| 287 |
+
finally:
|
| 288 |
# Неиспользованный резерв возвращаем: реальный расход — только len(data).
|
| 289 |
+
await _release_budget(reservation, data)
|
| 290 |
+
return data or None
|
|
|
|
| 291 |
|
| 292 |
downloaded = list(await asyncio.gather(
|
| 293 |
*(_download_slide_bounded(u) for u in fetch_urls)
|
tests/test_bot_tiktok.py
CHANGED
|
@@ -1848,6 +1848,11 @@ def test_download_url_bin_honors_caller_cap():
|
|
| 1848 |
assert result is None
|
| 1849 |
|
| 1850 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1851 |
def test_slideshow_stops_at_post_budget(monkeypatch):
|
| 1852 |
# Аудит D1/A7-1: общий кап RAM на пост — остаток слайдов пропускается.
|
| 1853 |
calls = {}
|
|
@@ -1936,6 +1941,55 @@ def test_slideshow_slide_download_reserves_post_budget(monkeypatch):
|
|
| 1936 |
bot.chat_state.pop(999984, None)
|
| 1937 |
|
| 1938 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1939 |
def test_group_tiktok_link_passive_only_when_mention_required(monkeypatch):
|
| 1940 |
# Аудит H2: групповой TikTok без обращения качается по умолчанию;
|
| 1941 |
# TIKTOK_GROUP_REQUIRE_MENTION=1 оставляет только запись в фон.
|
|
|
|
| 1848 |
assert result is None
|
| 1849 |
|
| 1850 |
|
| 1851 |
+
def test_download_url_bin_rejects_non_string_url():
|
| 1852 |
+
# TikWM — чужой JSON: число или объект вместо ссылки раньше роняли urlsplit.
|
| 1853 |
+
assert asyncio.run(lumen_tiktok._download_url_bin(None, 123)) is None
|
| 1854 |
+
|
| 1855 |
+
|
| 1856 |
def test_slideshow_stops_at_post_budget(monkeypatch):
|
| 1857 |
# Аудит D1/A7-1: общий кап RAM на пост — остаток слайдов пропускается.
|
| 1858 |
calls = {}
|
|
|
|
| 1941 |
bot.chat_state.pop(999984, None)
|
| 1942 |
|
| 1943 |
|
| 1944 |
+
def test_slideshow_waits_for_post_budget_instead_of_dropping_slides(monkeypatch):
|
| 1945 |
+
# 08-26.10.2026: резерв 75МБ на слайд при 200МБ на пост выбирали весь бюджет
|
| 1946 |
+
# первыми тремя слайдами, остальные молча пропускались.
|
| 1947 |
+
sent_media = []
|
| 1948 |
+
|
| 1949 |
+
async def fake_download(session, url, headers=None, cap_bytes=None):
|
| 1950 |
+
# Задержка нужна, чтобы слайды реально шли параллельно: без неё первый
|
| 1951 |
+
# успевает вернуть резерв до старта второго и старый баг не виден.
|
| 1952 |
+
await asyncio.sleep(0.01)
|
| 1953 |
+
# Честный фейк: сверх капа не отдаёт, как настоящий _download_url_bin.
|
| 1954 |
+
payload = b"k" * 1024
|
| 1955 |
+
if cap_bytes is not None and cap_bytes < len(payload):
|
| 1956 |
+
return None
|
| 1957 |
+
return payload
|
| 1958 |
+
|
| 1959 |
+
class _FakeTgBot:
|
| 1960 |
+
async def send_photo(self, **kwargs):
|
| 1961 |
+
sent_media.append(kwargs["photo"])
|
| 1962 |
+
return SimpleNamespace()
|
| 1963 |
+
|
| 1964 |
+
async def send_video(self, **kwargs):
|
| 1965 |
+
sent_media.append(kwargs["video"])
|
| 1966 |
+
return SimpleNamespace()
|
| 1967 |
+
|
| 1968 |
+
async def send_media_group(self, **kwargs):
|
| 1969 |
+
sent_media.extend(kwargs["media"])
|
| 1970 |
+
return SimpleNamespace()
|
| 1971 |
+
|
| 1972 |
+
async def fake_edit(*args, **kwargs):
|
| 1973 |
+
return True
|
| 1974 |
+
|
| 1975 |
+
monkeypatch.setattr(bot, "TIKTOK_SLIDESHOW_MAX_BYTES", 10 * 1024)
|
| 1976 |
+
monkeypatch.setattr(bot, "_download_url_bin", fake_download)
|
| 1977 |
+
monkeypatch.setattr(bot, "_edit_message_quietly", fake_edit)
|
| 1978 |
+
monkeypatch.setattr(bot, "_delete_message_quietly", fake_edit)
|
| 1979 |
+
monkeypatch.setattr(bot, "bot", _FakeTgBot())
|
| 1980 |
+
incoming = _FakeIncomingMessage(999986)
|
| 1981 |
+
incoming.message_id = 7
|
| 1982 |
+
media_data = {"images": [f"http://x/{i}.jpg" for i in range(5)]}
|
| 1983 |
+
try:
|
| 1984 |
+
result = asyncio.run(bot._try_send_tiktok_slideshow(
|
| 1985 |
+
None, media_data, incoming, None, "author", {},
|
| 1986 |
+
))
|
| 1987 |
+
assert result is True
|
| 1988 |
+
assert len(sent_media) == 5
|
| 1989 |
+
finally:
|
| 1990 |
+
bot.chat_state.pop(999986, None)
|
| 1991 |
+
|
| 1992 |
+
|
| 1993 |
def test_group_tiktok_link_passive_only_when_mention_required(monkeypatch):
|
| 1994 |
# Аудит H2: групповой TikTok без обращения качается по умолчанию;
|
| 1995 |
# TIKTOK_GROUP_REQUIRE_MENTION=1 оставляет только запись в фон.
|