SilverElixir commited on
Commit
2939738
·
1 Parent(s): 3438464

Fix adversarial review findings across splitter quotas and backup

Browse files
.github/workflows/state-backup.yml CHANGED
@@ -59,7 +59,9 @@ jobs:
59
  cd backup-repo
60
  git add export_state.json
61
  git diff --cached --quiet || git commit -m "Backup $(date -u +%Y-%m-%d)"
62
- git push origin HEAD:main
 
 
63
 
64
  - name: Report backup failure
65
  # Канала уведомлений нет (нужен отдельный секрет/чат — решение владельца):
 
59
  cd backup-repo
60
  git add export_state.json
61
  git diff --cached --quiet || git commit -m "Backup $(date -u +%Y-%m-%d)"
62
+ # extraheader только на эту команду: в конфиге клона секрета нет.
63
+ git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "${STATE_BACKUP_TOKEN}" | base64 -w0)" \
64
+ push origin HEAD:main
65
 
66
  - name: Report backup failure
67
  # Канала уведомлений нет (нужен отдельный секрет/чат — решение владельца):
lumen_commands.py CHANGED
@@ -341,9 +341,16 @@ async def inline_tts(message: Message, text: str) -> None:
341
  return
342
  # Дневные лимиты пользователя: общий и отдельный на озвучку (он же тратит квоту Gemini TTS).
343
  # Проба без создания: отказ не заводит запись и не раздувает day_users.
 
 
344
  uid = bot._user_key_for_message(message)
345
  _tts_entry = bot._user_daily_peek(uid)
346
- if bot._user_daily_total_exhausted(uid, _tts_entry):
 
 
 
 
 
347
  hours, mins = bot._user_daily_reset_in()
348
  # Суточный счётчик /stats: отказ по лимиту, озвучки не будет.
349
  bot._record_stats_event("daily_limit_denials")
@@ -353,7 +360,7 @@ async def inline_tts(message: Message, text: str) -> None:
353
  hours=hours, mins=mins,
354
  ))
355
  return
356
- if bot._user_daily_tts_exhausted(uid, _tts_entry):
357
  bot._record_stats_event("daily_limit_denials")
358
  await bot._safe_reply(message, bot._t(
359
  message.chat.id, "user_daily_tts",
 
341
  return
342
  # Дневные лимиты пользователя: общий и отдельный на озвучку (он же тратит квоту Gemini TTS).
343
  # Проба без создания: отказ не заводит запись и не раздувает day_users.
344
+ # Считаем сразу все чанки: каждый — отдельный синтез, иначе длинный текст
345
+ # уводил бы счётчик за лимит.
346
  uid = bot._user_key_for_message(message)
347
  _tts_entry = bot._user_daily_peek(uid)
348
+ try:
349
+ _tts_over_total = int((_tts_entry or {}).get("total") or 0) + len(chunks) > bot._user_daily_limit(uid, "total", _tts_entry)
350
+ _tts_over_tts = int((_tts_entry or {}).get("tts") or 0) + len(chunks) > bot._user_daily_limit(uid, "tts", _tts_entry)
351
+ except (TypeError, ValueError):
352
+ _tts_over_total = _tts_over_tts = False
353
+ if bot._user_daily_total_exhausted(uid, _tts_entry) or _tts_over_total:
354
  hours, mins = bot._user_daily_reset_in()
355
  # Суточный счётчик /stats: отказ по лимиту, озвучки не будет.
356
  bot._record_stats_event("daily_limit_denials")
 
360
  hours=hours, mins=mins,
361
  ))
362
  return
363
+ if bot._user_daily_tts_exhausted(uid, _tts_entry) or _tts_over_tts:
364
  bot._record_stats_event("daily_limit_denials")
365
  await bot._safe_reply(message, bot._t(
366
  message.chat.id, "user_daily_tts",
lumen_formatting.py CHANGED
@@ -727,6 +727,24 @@ def _split_to_fit_html(chunk_md: str, max_len: int) -> list[str]:
727
  return out
728
 
729
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  def _split_text_chunks(text: str, max_len: int = 4096) -> list[str]:
731
  """Разбивает длинный текст на части не длиннее max_len, стараясь резать по
732
  границам абзацев/строк/предложений, а не посреди слова. Раньше сообщения
@@ -765,7 +783,7 @@ def _split_text_chunks(text: str, max_len: int = 4096) -> list[str]:
765
  out: list[str] = []
766
  for chunk in md_chunks:
767
  out.extend(_split_to_fit_html(chunk, max_len))
768
- return out or [text]
769
 
770
 
771
  def _truncate_html_to_fit(md_text: str, limit: int) -> str:
 
727
  return out
728
 
729
 
730
+ def _merge_short_chunks(chunks: list[str], max_len: int) -> list[str]:
731
+ """Склейка соседних кусков обратно, пока влезают: сегментация по заборам
732
+ иначе дробила бы каждое короткое сообщение с кодом на три."""
733
+ merged: list[str] = []
734
+ acc = ""
735
+ for chunk in chunks:
736
+ candidate = chunk if not acc else acc + chunk
737
+ if _tg_len(_md_to_html(candidate)) <= max_len:
738
+ acc = candidate
739
+ else:
740
+ if acc:
741
+ merged.append(acc)
742
+ acc = chunk
743
+ if acc or not merged:
744
+ merged.append(acc)
745
+ return merged
746
+
747
+
748
  def _split_text_chunks(text: str, max_len: int = 4096) -> list[str]:
749
  """Разбивает длинный текст на части не длиннее max_len, стараясь резать по
750
  границам абзацев/строк/предложений, а не посреди слова. Раньше сообщения
 
783
  out: list[str] = []
784
  for chunk in md_chunks:
785
  out.extend(_split_to_fit_html(chunk, max_len))
786
+ return _merge_short_chunks(out, max_len) or [text]
787
 
788
 
789
  def _truncate_html_to_fit(md_text: str, limit: int) -> str:
lumen_routes.py CHANGED
@@ -280,8 +280,9 @@ async def _chat_completion_chain(
280
  label, model_trial,
281
  )
282
  raise
283
- # 429 — в карантин/остывку, как стриминг выше: иначе модель долбят
284
- # каждым сообщением, а _is_quota_exhausted её не видит.
 
285
  try:
286
  _txt = bot._error_text(exc).strip() or exc.__class__.__name__
287
  if bot._classify_model_error(bot._error_status(exc, _txt), _txt) == "rate_limit":
 
280
  label, model_trial,
281
  )
282
  raise
283
+ # 429 — в карантин/остывку, как стриминг выше. Account-wide сюда не
284
+ # доходит: stop_on выше уже оборвал цепочку без метки (иначе метка
285
+ # каскадом расползлась бы по всем моделям, хотя лимит общий).
286
  try:
287
  _txt = bot._error_text(exc).strip() or exc.__class__.__name__
288
  if bot._classify_model_error(bot._error_status(exc, _txt), _txt) == "rate_limit":
lumen_tiktok.py CHANGED
@@ -480,9 +480,8 @@ async def _communicate_process(proc: asyncio.subprocess.Process, *, timeout: flo
480
  with contextlib.suppress(ProcessLookupError):
481
  proc.kill()
482
  # Дренаж ограничен: communicate, переживший kill, не держит loop вечно.
483
- # Результат дренажа уже опоздал (таймаут был) — только подчищаем задачу,
484
- # наружу по-прежнему исходный TimeoutError. Внешняя отмена из дренажа
485
- # летит сама: CancelledError здесь не ловим.
486
  try:
487
  await asyncio.wait_for(asyncio.shield(completion), timeout=_COMMUNICATE_DRAIN_SEC)
488
  except asyncio.TimeoutError:
@@ -497,7 +496,10 @@ async def _communicate_process(proc: asyncio.subprocess.Process, *, timeout: flo
497
  if proc.returncode is None:
498
  with contextlib.suppress(ProcessLookupError):
499
  proc.kill()
500
- if not completion.done():
 
 
 
501
  completion.cancel()
502
  raise
503
 
 
480
  with contextlib.suppress(ProcessLookupError):
481
  proc.kill()
482
  # Дренаж ограничен: communicate, переживший kill, не держит loop вечно.
483
+ # Ошибка самого дренажа летит как есть (вызывающие ловят широкий
484
+ # Exception); исходный TimeoutError — когда дренаж просто не уложился.
 
485
  try:
486
  await asyncio.wait_for(asyncio.shield(completion), timeout=_COMMUNICATE_DRAIN_SEC)
487
  except asyncio.TimeoutError:
 
496
  if proc.returncode is None:
497
  with contextlib.suppress(ProcessLookupError):
498
  proc.kill()
499
+ if completion.done():
500
+ with contextlib.suppress(Exception, asyncio.CancelledError):
501
+ completion.result()
502
+ else:
503
  completion.cancel()
504
  raise
505
 
lumen_tiktok_flow.py CHANGED
@@ -163,7 +163,7 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
163
  thumbnail=thumbnail_file,
164
  reply_to_message_id=message.message_id,
165
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
166
- # Без вслепую ретрая: повтор без обложки ниже и есть стратегия повтора.
167
  retries=0,
168
  )
169
  if sent_audio is None:
@@ -214,8 +214,11 @@ async def _fetch_tikwm_media_data_with_proxy_fallback(session: aiohttp.ClientSes
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
@@ -307,6 +310,8 @@ async def _try_send_tiktok_slideshow(
307
  chat_id=message.chat.id, video=only_item.media,
308
  supports_streaming=True, reply_to_message_id=message.message_id,
309
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
 
 
310
  )
311
  else:
312
  sent_slide = await bot._tg_call(
@@ -314,6 +319,7 @@ async def _try_send_tiktok_slideshow(
314
  chat_id=message.chat.id, photo=only_item.media,
315
  reply_to_message_id=message.message_id,
316
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
 
317
  )
318
  if sent_slide is None:
319
  raise RuntimeError("Telegram slideshow slide send failed: connection timeout or proxy unavailable")
@@ -326,6 +332,7 @@ async def _try_send_tiktok_slideshow(
326
  chat_id=message.chat.id, media=chunk,
327
  reply_to_message_id=message.message_id if chunk_idx == 0 else None,
328
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
 
329
  )
330
  if sent_chunk is None:
331
  raise RuntimeError("Telegram send_media_group failed: connection timeout or proxy unavailable")
@@ -401,20 +408,16 @@ async def _send_tiktok_single_video(
401
  if thumb_bytes:
402
  send_kwargs["thumbnail"] = BufferedInputFile(thumb_bytes, filename="thumb.jpg")
403
 
404
- # Реальный размер уже скачан: лимит Telegram проверяем до отправки,
405
- # а не по 413 от API (_tg_call клиентские ошибки не пробрасывает).
406
- if len(video_bytes) > bot.TELEGRAM_BOT_API_UPLOAD_LIMIT_BYTES:
407
- hit_size_limit = True
408
- log.info(
409
- '[tiktok] Skipping variant %s (%s, %d bytes) — real size exceeds the Telegram Bot API limit.',
410
- candidate["key"], candidate["label"], len(video_bytes),
411
- )
412
- continue
413
  # Через _tg_call: breaker-гейт, таймаут и RetryAfter. None — сбой
414
  # отправки: пробуем следующий вариант, как раньше при исключении.
 
415
  sent_video = await bot._tg_call(
416
  bot.bot.send_video, **send_kwargs,
417
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
 
418
  )
419
  if sent_video is None:
420
  send_failed = True
@@ -424,15 +427,15 @@ async def _send_tiktok_single_video(
424
  await bot._delete_message_quietly(status)
425
  return
426
 
 
 
 
 
427
  if hit_size_limit:
428
  # Скачался, но ни один вариант не влез в лимит — отличаем от "TikTok ничего не отдал" (иначе вводящее "контент удалён").
429
  raise TikTokUserFacingError(
430
  bot._t(message.chat.id, "tiktok_too_big")
431
  )
432
- if send_failed:
433
- # Варианты скачались, но ни один не ушёл в Telegram — честная
434
- # ошибка отправки, а не "контент удалён".
435
- raise RuntimeError("Telegram send_video failed: connection timeout or proxy unavailable")
436
 
437
  raise TikTokUserFacingError(bot._t(message.chat.id, "tiktok_no_media"))
438
 
 
163
  thumbnail=thumbnail_file,
164
  reply_to_message_id=message.message_id,
165
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
166
+ # Без слепого ретрая: повтор без обложки ниже и есть стратегия повтора.
167
  retries=0,
168
  )
169
  if sent_audio is None:
 
214
  try:
215
  result = await bot._fetch_tikwm_media_data(session, resolved_url, headers, proxy_base_url=candidate)
216
  except Exception as exc:
217
+ # Рутина сети — warning без Sentry; остальное пахнет багом — error.
218
+ if isinstance(exc, (aiohttp.ClientError, asyncio.TimeoutError, OSError)):
219
+ log.warning("[tiktok] TikWM proxy candidate %r failed: %s", candidate, exc)
220
+ else:
221
+ log.exception("[tiktok] TikWM proxy candidate %r failed unexpectedly", candidate)
222
  continue
223
  if result is not None:
224
  return result
 
310
  chat_id=message.chat.id, video=only_item.media,
311
  supports_streaming=True, reply_to_message_id=message.message_id,
312
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
313
+ # Без слепого ретрая: повтор отправки после сбоя дал бы дубль.
314
+ retries=0,
315
  )
316
  else:
317
  sent_slide = await bot._tg_call(
 
319
  chat_id=message.chat.id, photo=only_item.media,
320
  reply_to_message_id=message.message_id,
321
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
322
+ retries=0,
323
  )
324
  if sent_slide is None:
325
  raise RuntimeError("Telegram slideshow slide send failed: connection timeout or proxy unavailable")
 
332
  chat_id=message.chat.id, media=chunk,
333
  reply_to_message_id=message.message_id if chunk_idx == 0 else None,
334
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
335
+ retries=0,
336
  )
337
  if sent_chunk is None:
338
  raise RuntimeError("Telegram send_media_group failed: connection timeout or proxy unavailable")
 
408
  if thumb_bytes:
409
  send_kwargs["thumbnail"] = BufferedInputFile(thumb_bytes, filename="thumb.jpg")
410
 
411
+ # Реальный размер уже скачан: больше лимита сюда не приезжает
412
+ # (кап скачивания выше), заявленный перебор отсеян до скачивания.
413
+ # Проверка лимита Telegram — парой выше, здесь только отправка.
 
 
 
 
 
 
414
  # Через _tg_call: breaker-гейт, таймаут и RetryAfter. None — сбой
415
  # отправки: пробуем следующий вариант, как раньше при исключении.
416
+ # Без слепого ретрая: повтор отправки после сбоя дал бы дубль.
417
  sent_video = await bot._tg_call(
418
  bot.bot.send_video, **send_kwargs,
419
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
420
+ retries=0,
421
  )
422
  if sent_video is None:
423
  send_failed = True
 
427
  await bot._delete_message_quietly(status)
428
  return
429
 
430
+ if send_failed:
431
+ # Варианты скачались, но ни один не ушёл в Telegram — честная
432
+ # ошибка отправки, а не "контент удалён".
433
+ raise RuntimeError("Telegram send_video failed: connection timeout or proxy unavailable")
434
  if hit_size_limit:
435
  # Скачался, но ни один вариант не влез в лимит — отличаем от "TikTok ничего не отдал" (иначе вводящее "контент удалён").
436
  raise TikTokUserFacingError(
437
  bot._t(message.chat.id, "tiktok_too_big")
438
  )
 
 
 
 
439
 
440
  raise TikTokUserFacingError(bot._t(message.chat.id, "tiktok_no_media"))
441
 
tests/test_bot_tiktok.py CHANGED
@@ -1512,6 +1512,43 @@ def test_single_video_send_goes_through_tg_call(monkeypatch):
1512
  bot.chat_state.pop(999456, None)
1513
 
1514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1515
  # ─────────────── S9: DNS в потоке, ручные редиректы, капы, N/A, WebM ───────────────
1516
 
1517
  def _fake_public_dns(host, port, *args, **kwargs):
 
1512
  bot.chat_state.pop(999456, None)
1513
 
1514
 
1515
+ def test_single_video_send_does_not_retry_transient_failure(monkeypatch):
1516
+ # Повтор отправки после сбоя дал бы дубль: одна попытка, ошибка наружу.
1517
+ import lumen_tiktok_flow
1518
+ attempts = []
1519
+
1520
+ class _FlakyBot:
1521
+ async def send_video(self, **kwargs):
1522
+ attempts.append(kwargs)
1523
+ if len(attempts) == 1:
1524
+ raise RuntimeError("connection reset by peer")
1525
+ return SimpleNamespace()
1526
+
1527
+ async def fake_download(session, url, headers=None, cap_bytes=None):
1528
+ return b"\x00" * 100
1529
+
1530
+ async def fake_probe_dims(path):
1531
+ return 0, 0, 0
1532
+
1533
+ async def fake_thumb(path, duration):
1534
+ return None
1535
+
1536
+ monkeypatch.setattr(bot, "_download_url_bin", fake_download)
1537
+ monkeypatch.setattr(bot, "_probe_video_dimensions", fake_probe_dims)
1538
+ monkeypatch.setattr(bot, "_generate_video_thumbnail", fake_thumb)
1539
+ monkeypatch.setattr(bot, "bot", _FlakyBot())
1540
+ incoming = _FakeIncomingMessage(999457)
1541
+ incoming.message_id = 1
1542
+ media_data = {"play": "https://tikwm.com/v.mp4", "size": 100, "author": {"nickname": "Nick"}}
1543
+ try:
1544
+ with pytest.raises(RuntimeError):
1545
+ asyncio.run(lumen_tiktok_flow._send_tiktok_single_video(
1546
+ None, media_data, incoming, None, "Nick", {}))
1547
+ assert len(attempts) == 1
1548
+ finally:
1549
+ bot.chat_state.pop(999457, None)
1550
+
1551
+
1552
  # ─────────────── S9: DNS в потоке, ручные редиректы, капы, N/A, WebM ───────────────
1553
 
1554
  def _fake_public_dns(host, port, *args, **kwargs):
tests/test_bot_tts.py CHANGED
@@ -562,3 +562,37 @@ def test_inline_tts_charges_each_synthesized_chunk():
562
  bot.GLOBAL_QUOTA.get("user_daily", {}).pop("777015", None)
563
  bot.chat_state.pop(999810, None)
564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  bot.GLOBAL_QUOTA.get("user_daily", {}).pop("777015", None)
563
  bot.chat_state.pop(999810, None)
564
 
565
+
566
+ def test_inline_tts_refuses_when_chunks_would_exceed_limit(monkeypatch):
567
+ # Проверка считает все чанки сразу: 4/5 + текст на 3 чанка — отказ до синтеза.
568
+ import lumen_commands
569
+ from types import SimpleNamespace
570
+ limit = bot.TTS_MAX_CHARS
571
+ part = "Предложение номер раз про интересные вещи. "
572
+ text = part * ((limit // len(part)) + 2)
573
+ assert len(text) > limit
574
+ incoming = _FakeIncomingMessage(999811)
575
+ incoming.message_id = 12361
576
+ incoming.from_user = SimpleNamespace(id=777016)
577
+ monkeypatch.setattr(bot, "DAILY_USER_TTS_LIMIT", 5)
578
+ for _ in range(4):
579
+ bot._record_user_daily(777016, tts=True)
580
+
581
+ async def fail_synth(chunk_text):
582
+ raise AssertionError("synthesis must not run when chunks exceed the limit")
583
+
584
+ monkeypatch.setattr(lumen_commands, "_synthesize_tts_voice", fail_synth)
585
+ replies = []
586
+
587
+ async def fake_safe_reply(msg, reply_text, **kwargs):
588
+ replies.append(reply_text)
589
+
590
+ monkeypatch.setattr(bot, "_safe_reply", fake_safe_reply)
591
+ try:
592
+ asyncio.run(bot.inline_tts(incoming, text))
593
+ assert len(replies) == 1
594
+ assert bot._user_daily_entry(777016)["tts"] == 4
595
+ finally:
596
+ bot.GLOBAL_QUOTA.get("user_daily", {}).pop("777016", None)
597
+ bot.chat_state.pop(999811, None)
598
+
tests/test_lumen_formatting.py CHANGED
@@ -120,6 +120,13 @@ def test_split_chunks_keep_fences_intact():
120
  assert "```" not in lumen_formatting._md_to_html(chunk)
121
 
122
 
 
 
 
 
 
 
 
123
  def test_md_to_html_link_with_inline_code_has_no_placeholder_leak():
124
  # Код внутри метки ссылки: плейсхолдер не протекает, оба тега на месте.
125
  html = lumen_formatting._md_to_html("[`code` текст](https://example.com)")
 
120
  assert "```" not in lumen_formatting._md_to_html(chunk)
121
 
122
 
123
+ def test_split_chunks_merge_short_fenced_message_back():
124
+ # Короткое сообщение с кодом едет одним куском, а не тремя.
125
+ text = "Hi\n```python\nx = 1\n```\nBye"
126
+ chunks = lumen_formatting._split_text_chunks(text, 4096)
127
+ assert chunks == [text]
128
+
129
+
130
  def test_md_to_html_link_with_inline_code_has_no_placeholder_leak():
131
  # Код внутри метки ссылки: плейсхолдер не протекает, оба тега на месте.
132
  html = lumen_formatting._md_to_html("[`code` текст](https://example.com)")