Spaces:
Running
Running
SilverElixir commited on
Commit ·
de60829
1
Parent(s): 6309600
Route TikTok media sends through tg_call
Browse files- lumen_tiktok_flow.py +58 -25
- tests/test_bot_tiktok.py +43 -0
lumen_tiktok_flow.py
CHANGED
|
@@ -150,30 +150,40 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
|
|
| 150 |
# Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
|
| 151 |
# иначе multipart-имя файла битое.
|
| 152 |
safe_title = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", cleaned_title[:60]).strip() or "track"
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
if thumbnail_file is None:
|
| 165 |
-
raise
|
| 166 |
# Обложка с TikWM бывает жирной — Telegram режет отправку целиком, и трек не приходит
|
| 167 |
# вообще молча. Повторяем без обложки: музыка важнее картинки.
|
| 168 |
-
log.warning("[tiktok] send_audio with thumbnail failed, retrying without
|
| 169 |
-
await bot.
|
|
|
|
| 170 |
chat_id=message.chat.id,
|
| 171 |
audio=BufferedInputFile(tagged_music_bytes, filename=f"{safe_title}.mp3"),
|
| 172 |
title=cleaned_title,
|
| 173 |
performer=performer_name,
|
| 174 |
duration=music_duration if music_duration > 0 else None,
|
| 175 |
-
reply_to_message_id=message.message_id
|
|
|
|
|
|
|
| 176 |
)
|
|
|
|
|
|
|
| 177 |
except Exception as e:
|
| 178 |
log.warning("[tiktok] failed to send music: %s", e)
|
| 179 |
|
|
@@ -282,23 +292,33 @@ async def _try_send_tiktok_slideshow(
|
|
| 282 |
# Один уцелевший слайд — обычным send_photo/send_video: media group требует минимум 2.
|
| 283 |
only_item = media_items[0]
|
| 284 |
if isinstance(only_item, InputMediaVideo):
|
| 285 |
-
await bot.
|
|
|
|
| 286 |
chat_id=message.chat.id, video=only_item.media,
|
| 287 |
supports_streaming=True, reply_to_message_id=message.message_id,
|
|
|
|
| 288 |
)
|
| 289 |
else:
|
| 290 |
-
await bot.
|
|
|
|
| 291 |
chat_id=message.chat.id, photo=only_item.media,
|
| 292 |
reply_to_message_id=message.message_id,
|
|
|
|
| 293 |
)
|
|
|
|
|
|
|
| 294 |
else:
|
| 295 |
# Группы по 10 без хвоста из 1 (жёсткое требование Telegram 2–10); первая — ответом на ссылку.
|
| 296 |
chunks = _chunk_tiktok_media_items(media_items)
|
| 297 |
for chunk_idx, chunk in enumerate(chunks):
|
| 298 |
-
await bot.
|
|
|
|
| 299 |
chat_id=message.chat.id, media=chunk,
|
| 300 |
reply_to_message_id=message.message_id if chunk_idx == 0 else None,
|
|
|
|
| 301 |
)
|
|
|
|
|
|
|
| 302 |
if chunk_idx + 1 < len(chunks):
|
| 303 |
# Пауза между группами — против анти-флуда.
|
| 304 |
await asyncio.sleep(0.3)
|
|
@@ -311,12 +331,13 @@ async def _send_tiktok_single_video(
|
|
| 311 |
session: aiohttp.ClientSession, media_data: dict, message: Message, status: Message | None,
|
| 312 |
author: str, headers: dict,
|
| 313 |
) -> None:
|
| 314 |
-
"""Пробуем качества HD → обычное → с водяным;
|
| 315 |
import bot
|
| 316 |
# Качества HD → обычное → с водяным (раньше без HD); EntityTooLarge — следующий вариант.дальше.
|
| 317 |
video_candidates = _tiktok_video_candidates(media_data)
|
| 318 |
if video_candidates:
|
| 319 |
hit_size_limit = False
|
|
|
|
| 320 |
for candidate in video_candidates:
|
| 321 |
if candidate["size"] and candidate["size"] > bot.TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES:
|
| 322 |
# Заранее большой размер пропускаем без скачивания и помечаем hit_size_limit, чтобы дать честное "слишком большое".
|
|
@@ -370,16 +391,24 @@ async def _send_tiktok_single_video(
|
|
| 370 |
if thumb_bytes:
|
| 371 |
send_kwargs["thumbnail"] = BufferedInputFile(thumb_bytes, filename="thumb.jpg")
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
# Реальный размер больше лимита, хотя заявленный был неточен — пробуем следующий, более лёгкий вариант.
|
| 377 |
hit_size_limit = True
|
| 378 |
-
log.
|
| 379 |
-
'[tiktok]
|
| 380 |
candidate["key"], candidate["label"], len(video_bytes),
|
| 381 |
)
|
| 382 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
await _send_tiktok_music(session, media_data, message, author, headers)
|
| 385 |
await bot._delete_message_quietly(status)
|
|
@@ -390,6 +419,10 @@ async def _send_tiktok_single_video(
|
|
| 390 |
raise TikTokUserFacingError(
|
| 391 |
bot._t(message.chat.id, "tiktok_too_big")
|
| 392 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
raise TikTokUserFacingError(bot._t(message.chat.id, "tiktok_no_media"))
|
| 395 |
|
|
|
|
| 150 |
# Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
|
| 151 |
# иначе multipart-имя файла битое.
|
| 152 |
safe_title = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", cleaned_title[:60]).strip() or "track"
|
| 153 |
+
# Через _tg_call: breaker-гейт, таймаут и RetryAfter вместо прямого send_audio.
|
| 154 |
+
# None вместо исключения — дальше та же логика, что раньше в except.
|
| 155 |
+
sent_audio = await bot._tg_call(
|
| 156 |
+
bot.bot.send_audio,
|
| 157 |
+
chat_id=message.chat.id,
|
| 158 |
+
audio=BufferedInputFile(tagged_music_bytes, filename=f"{safe_title}.mp3"),
|
| 159 |
+
title=cleaned_title,
|
| 160 |
+
performer=performer_name,
|
| 161 |
+
duration=music_duration if music_duration > 0 else None,
|
| 162 |
+
thumbnail=thumbnail_file,
|
| 163 |
+
reply_to_message_id=message.message_id,
|
| 164 |
+
call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
|
| 165 |
+
# Без вслепую ретрая: повтор без обложки ниже и есть стратегия повтора.
|
| 166 |
+
retries=0,
|
| 167 |
+
)
|
| 168 |
+
if sent_audio is None:
|
| 169 |
if thumbnail_file is None:
|
| 170 |
+
raise RuntimeError("Telegram send_audio failed: connection timeout or proxy unavailable")
|
| 171 |
# Обложка с TikWM бывает жирной — Telegram режет отправку целиком, и трек не приходит
|
| 172 |
# вообще молча. Повторяем без обложки: музыка важнее картинки.
|
| 173 |
+
log.warning("[tiktok] send_audio with thumbnail failed, retrying without")
|
| 174 |
+
sent_audio = await bot._tg_call(
|
| 175 |
+
bot.bot.send_audio,
|
| 176 |
chat_id=message.chat.id,
|
| 177 |
audio=BufferedInputFile(tagged_music_bytes, filename=f"{safe_title}.mp3"),
|
| 178 |
title=cleaned_title,
|
| 179 |
performer=performer_name,
|
| 180 |
duration=music_duration if music_duration > 0 else None,
|
| 181 |
+
reply_to_message_id=message.message_id,
|
| 182 |
+
call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
|
| 183 |
+
retries=0,
|
| 184 |
)
|
| 185 |
+
if sent_audio is None:
|
| 186 |
+
raise RuntimeError("Telegram send_audio failed: connection timeout or proxy unavailable")
|
| 187 |
except Exception as e:
|
| 188 |
log.warning("[tiktok] failed to send music: %s", e)
|
| 189 |
|
|
|
|
| 292 |
# Один уцелевший слайд — обычным send_photo/send_video: media group требует минимум 2.
|
| 293 |
only_item = media_items[0]
|
| 294 |
if isinstance(only_item, InputMediaVideo):
|
| 295 |
+
sent_slide = await bot._tg_call(
|
| 296 |
+
bot.bot.send_video,
|
| 297 |
chat_id=message.chat.id, video=only_item.media,
|
| 298 |
supports_streaming=True, reply_to_message_id=message.message_id,
|
| 299 |
+
call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
|
| 300 |
)
|
| 301 |
else:
|
| 302 |
+
sent_slide = await bot._tg_call(
|
| 303 |
+
bot.bot.send_photo,
|
| 304 |
chat_id=message.chat.id, photo=only_item.media,
|
| 305 |
reply_to_message_id=message.message_id,
|
| 306 |
+
call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
|
| 307 |
)
|
| 308 |
+
if sent_slide is None:
|
| 309 |
+
raise RuntimeError("Telegram slideshow slide send failed: connection timeout or proxy unavailable")
|
| 310 |
else:
|
| 311 |
# Группы по 10 без хвоста из 1 (жёсткое требование Telegram 2–10); первая — ответом на ссылку.
|
| 312 |
chunks = _chunk_tiktok_media_items(media_items)
|
| 313 |
for chunk_idx, chunk in enumerate(chunks):
|
| 314 |
+
sent_chunk = await bot._tg_call(
|
| 315 |
+
bot.bot.send_media_group,
|
| 316 |
chat_id=message.chat.id, media=chunk,
|
| 317 |
reply_to_message_id=message.message_id if chunk_idx == 0 else None,
|
| 318 |
+
call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
|
| 319 |
)
|
| 320 |
+
if sent_chunk is None:
|
| 321 |
+
raise RuntimeError("Telegram send_media_group failed: connection timeout or proxy unavailable")
|
| 322 |
if chunk_idx + 1 < len(chunks):
|
| 323 |
# Пауза между группами — против анти-флуда.
|
| 324 |
await asyncio.sleep(0.3)
|
|
|
|
| 331 |
session: aiohttp.ClientSession, media_data: dict, message: Message, status: Message | None,
|
| 332 |
author: str, headers: dict,
|
| 333 |
) -> None:
|
| 334 |
+
"""Пробуем качества HD → обычное → с водяным; переполнение лимита — следующий вариант, не сдаёмся."""
|
| 335 |
import bot
|
| 336 |
# Качества HD → обычное → с водяным (раньше без HD); EntityTooLarge — следующий вариант.дальше.
|
| 337 |
video_candidates = _tiktok_video_candidates(media_data)
|
| 338 |
if video_candidates:
|
| 339 |
hit_size_limit = False
|
| 340 |
+
send_failed = False
|
| 341 |
for candidate in video_candidates:
|
| 342 |
if candidate["size"] and candidate["size"] > bot.TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES:
|
| 343 |
# Заранее большой размер пропускаем без скачивания и помечаем hit_size_limit, чтобы дать честное "слишком большое".
|
|
|
|
| 391 |
if thumb_bytes:
|
| 392 |
send_kwargs["thumbnail"] = BufferedInputFile(thumb_bytes, filename="thumb.jpg")
|
| 393 |
|
| 394 |
+
# Реальный размер уже скачан: лимит Telegram проверяем до отправки,
|
| 395 |
+
# а не по 413 от API (_tg_call клиентские ошибки не пробрасывает).
|
| 396 |
+
if len(video_bytes) > bot.TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES:
|
|
|
|
| 397 |
hit_size_limit = True
|
| 398 |
+
log.info(
|
| 399 |
+
'[tiktok] Skipping variant %s (%s, %d bytes) — real size exceeds the Telegram Bot API limit.',
|
| 400 |
candidate["key"], candidate["label"], len(video_bytes),
|
| 401 |
)
|
| 402 |
continue
|
| 403 |
+
# Через _tg_call: breaker-гейт, таймаут и RetryAfter. None — сбой
|
| 404 |
+
# отправки: пробуем следующий вариант, как раньше при исключении.
|
| 405 |
+
sent_video = await bot._tg_call(
|
| 406 |
+
bot.bot.send_video, **send_kwargs,
|
| 407 |
+
call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
|
| 408 |
+
)
|
| 409 |
+
if sent_video is None:
|
| 410 |
+
send_failed = True
|
| 411 |
+
continue
|
| 412 |
|
| 413 |
await _send_tiktok_music(session, media_data, message, author, headers)
|
| 414 |
await bot._delete_message_quietly(status)
|
|
|
|
| 419 |
raise TikTokUserFacingError(
|
| 420 |
bot._t(message.chat.id, "tiktok_too_big")
|
| 421 |
)
|
| 422 |
+
if send_failed:
|
| 423 |
+
# Варианты скачались, но ни один не ушёл в Telegram — честная
|
| 424 |
+
# ошибка отправки, а не "контент удалён".
|
| 425 |
+
raise RuntimeError("Telegram send_video failed: connection timeout or proxy unavailable")
|
| 426 |
|
| 427 |
raise TikTokUserFacingError(bot._t(message.chat.id, "tiktok_no_media"))
|
| 428 |
|
tests/test_bot_tiktok.py
CHANGED
|
@@ -1409,6 +1409,46 @@ def test_single_video_status_deleted_after_music():
|
|
| 1409 |
lumen_tiktok_flow._send_tiktok_music = original_music
|
| 1410 |
|
| 1411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1412 |
# ─────────────── S9: DNS в потоке, ручные редиректы, капы, N/A, WebM ───────────────
|
| 1413 |
|
| 1414 |
def _fake_public_dns(host, port, *args, **kwargs):
|
|
@@ -1642,12 +1682,15 @@ def test_slideshow_stops_at_post_budget(monkeypatch):
|
|
| 1642 |
class _FakeTgBot:
|
| 1643 |
async def send_photo(self, **kwargs):
|
| 1644 |
calls["photo"] = calls.get("photo", 0) + 1
|
|
|
|
| 1645 |
|
| 1646 |
async def send_video(self, **kwargs):
|
| 1647 |
calls["video"] = calls.get("video", 0) + 1
|
|
|
|
| 1648 |
|
| 1649 |
async def send_media_group(self, **kwargs):
|
| 1650 |
calls["group"] = calls.get("group", 0) + 1
|
|
|
|
| 1651 |
|
| 1652 |
async def fake_edit(*args, **kwargs):
|
| 1653 |
return True
|
|
|
|
| 1409 |
lumen_tiktok_flow._send_tiktok_music = original_music
|
| 1410 |
|
| 1411 |
|
| 1412 |
+
def test_single_video_send_goes_through_tg_call(monkeypatch):
|
| 1413 |
+
# Отправки идут через _tg_call (breaker/таймаут/RetryAfter), а не напрямую:
|
| 1414 |
+
# при недоступности Telegram прямой send_video не дёргается вообще.
|
| 1415 |
+
import lumen_tiktok_flow
|
| 1416 |
+
direct_calls = []
|
| 1417 |
+
|
| 1418 |
+
class _DirectBot:
|
| 1419 |
+
async def send_video(self, **kwargs):
|
| 1420 |
+
direct_calls.append(kwargs)
|
| 1421 |
+
return SimpleNamespace()
|
| 1422 |
+
|
| 1423 |
+
async def fake_download(session, url, headers=None, cap_bytes=None):
|
| 1424 |
+
return b"\x00" * 100
|
| 1425 |
+
|
| 1426 |
+
async def fake_probe_dims(path):
|
| 1427 |
+
return 0, 0, 0
|
| 1428 |
+
|
| 1429 |
+
async def fake_thumb(path, duration):
|
| 1430 |
+
return None
|
| 1431 |
+
|
| 1432 |
+
async def fake_tg_call(method, *args, **kwargs):
|
| 1433 |
+
return None
|
| 1434 |
+
|
| 1435 |
+
monkeypatch.setattr(bot, "_download_url_bin", fake_download)
|
| 1436 |
+
monkeypatch.setattr(bot, "_probe_video_dimensions", fake_probe_dims)
|
| 1437 |
+
monkeypatch.setattr(bot, "_generate_video_thumbnail", fake_thumb)
|
| 1438 |
+
monkeypatch.setattr(bot, "_tg_call", fake_tg_call)
|
| 1439 |
+
monkeypatch.setattr(bot, "bot", _DirectBot())
|
| 1440 |
+
incoming = _FakeIncomingMessage(999456)
|
| 1441 |
+
incoming.message_id = 1
|
| 1442 |
+
media_data = {"play": "https://tikwm.com/v.mp4", "size": 100, "author": {"nickname": "Nick"}}
|
| 1443 |
+
try:
|
| 1444 |
+
with pytest.raises(RuntimeError):
|
| 1445 |
+
asyncio.run(lumen_tiktok_flow._send_tiktok_single_video(
|
| 1446 |
+
None, media_data, incoming, None, "Nick", {}))
|
| 1447 |
+
assert direct_calls == []
|
| 1448 |
+
finally:
|
| 1449 |
+
bot.chat_state.pop(999456, None)
|
| 1450 |
+
|
| 1451 |
+
|
| 1452 |
# ─────────────── S9: DNS в потоке, ручные редиректы, капы, N/A, WebM ───────────────
|
| 1453 |
|
| 1454 |
def _fake_public_dns(host, port, *args, **kwargs):
|
|
|
|
| 1682 |
class _FakeTgBot:
|
| 1683 |
async def send_photo(self, **kwargs):
|
| 1684 |
calls["photo"] = calls.get("photo", 0) + 1
|
| 1685 |
+
return SimpleNamespace()
|
| 1686 |
|
| 1687 |
async def send_video(self, **kwargs):
|
| 1688 |
calls["video"] = calls.get("video", 0) + 1
|
| 1689 |
+
return SimpleNamespace()
|
| 1690 |
|
| 1691 |
async def send_media_group(self, **kwargs):
|
| 1692 |
calls["group"] = calls.get("group", 0) + 1
|
| 1693 |
+
return SimpleNamespace()
|
| 1694 |
|
| 1695 |
async def fake_edit(*args, **kwargs):
|
| 1696 |
return True
|