SilverElixir commited on
Commit
de60829
·
1 Parent(s): 6309600

Route TikTok media sends through tg_call

Browse files
Files changed (2) hide show
  1. lumen_tiktok_flow.py +58 -25
  2. 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
- try:
154
- await bot.bot.send_audio(
155
- chat_id=message.chat.id,
156
- audio=BufferedInputFile(tagged_music_bytes, filename=f"{safe_title}.mp3"),
157
- title=cleaned_title,
158
- performer=performer_name,
159
- duration=music_duration if music_duration > 0 else None,
160
- thumbnail=thumbnail_file,
161
- reply_to_message_id=message.message_id
162
- )
163
- except Exception as send_exc:
 
 
 
 
 
164
  if thumbnail_file is None:
165
- raise
166
  # Обложка с TikWM бывает жирной — Telegram режет отправку целиком, и трек не приходит
167
  # вообще молча. Повторяем без обложки: музыка важнее картинки.
168
- log.warning("[tiktok] send_audio with thumbnail failed, retrying without: %s", send_exc)
169
- await bot.bot.send_audio(
 
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.bot.send_video(
 
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.bot.send_photo(
 
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.bot.send_media_group(
 
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 → обычное → с водяным; EntityTooLarge — следующий вариант, не сдаёмся."""
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
- try:
374
- await bot.bot.send_video(**send_kwargs)
375
- except TelegramEntityTooLarge:
376
- # Реальный размер больше лимита, хотя заявленный был неточен — пробуем следующий, более лёгкий вариант.
377
  hit_size_limit = True
378
- log.warning(
379
- '[tiktok] Variant %s (%s, %d bytes) exceeded the Telegram limit when sending — trying the next quality option.',
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