SilverElixir commited on
Commit
c262572
·
1 Parent(s): 134fae6

Slideshow: wait for post budget instead of dropping slides; log effective TikTok limits

Browse files
Files changed (4) hide show
  1. bot.py +10 -0
  2. lumen_tiktok.py +4 -0
  3. lumen_tiktok_flow.py +39 -17
  4. 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
- # неатомарны при параллельных загрузках и кап пробивается (аудит H4, 10.2026).
251
- _post_budget_lock = asyncio.Lock()
252
-
253
- async def _download_slide_bounded(slide_url: str) -> bytes | None:
254
- async with bot._tiktok_slide_download_semaphore:
255
- async with _post_budget_lock:
 
 
 
 
 
 
 
 
 
 
256
  if post_budget[0] <= 0:
257
  return None
258
- # Резерв до скачивания: кап слайда — остаток бюджета.
259
- reservation = min(TIKTOK_DOWNLOAD_MAX_BYTES, post_budget[0])
260
- post_budget[0] -= reservation
261
- data = await bot._download_url_bin(
262
- session, slide_url, headers=headers,
263
- cap_bytes=reservation,
264
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  # Неиспользованный резерв возвращаем: реальный расход — только len(data).
266
- async with _post_budget_lock:
267
- post_budget[0] += reservation - len(data or b"")
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 оставляет только запись в фон.