SilverElixir commited on
Commit
da6a2d2
·
1 Parent(s): 2447fb0

Isolate tests from live provider keys, requeue cancelled state flushes and trim redundant latency in search, draw, rich conversion and TikTok metadata

Browse files
lumen_chat_state.py CHANGED
@@ -663,10 +663,16 @@ async def _flush_dirty_state_once() -> None:
663
  if _pending_chat_deletions:
664
  to_delete = list(_pending_chat_deletions)
665
  _pending_chat_deletions.clear()
666
- del_results = await asyncio.gather(
667
- *(bot._delete_chat_storage_limited(cid) for cid in to_delete),
668
- return_exceptions=True,
669
- )
 
 
 
 
 
 
670
  # Неудавшиеся id возвращаются в очередь для повтора, а не теряются при 5xx/429 Upstash.
671
  failed_deletes = {cid for cid, res in zip(to_delete, del_results) if res is not True}
672
  if failed_deletes:
@@ -686,10 +692,16 @@ async def _flush_dirty_state_once() -> None:
686
  # Каждый чат — свой независимый write; если один упадёт, это не
687
  # заденет сохранение остальных чатов из этой же пачки.
688
  attempted_ids = [cid for cid in to_save if cid in chat_state]
689
- save_results = await asyncio.gather(
690
- *(bot._save_chat_to_storage_limited(cid, chat_state[cid]) for cid in attempted_ids),
691
- return_exceptions=True,
692
- )
 
 
 
 
 
 
693
  # Неудавшиеся id возвращаются в грязные для повтора, а не теряются при 5xx/429 Upstash.
694
  failed_ids = {cid for cid, res in zip(attempted_ids, save_results) if res is not True}
695
  if failed_ids:
 
663
  if _pending_chat_deletions:
664
  to_delete = list(_pending_chat_deletions)
665
  _pending_chat_deletions.clear()
666
+ try:
667
+ del_results = await asyncio.gather(
668
+ *(bot._delete_chat_storage_limited(cid) for cid in to_delete),
669
+ return_exceptions=True,
670
+ )
671
+ except asyncio.CancelledError:
672
+ # Отмена флаша не должна терять очередь удалений: id уже сняты
673
+ # с набора, вернуть их до выхода (аудит 29.10.2026).
674
+ _pending_chat_deletions.update(to_delete)
675
+ raise
676
  # Неудавшиеся id возвращаются в очередь для повтора, а не теряются при 5xx/429 Upstash.
677
  failed_deletes = {cid for cid, res in zip(to_delete, del_results) if res is not True}
678
  if failed_deletes:
 
692
  # Каждый чат — свой независимый write; если один упадёт, это не
693
  # заденет сохранение остальных чатов из этой же пачки.
694
  attempted_ids = [cid for cid in to_save if cid in chat_state]
695
+ try:
696
+ save_results = await asyncio.gather(
697
+ *(bot._save_chat_to_storage_limited(cid, chat_state[cid]) for cid in attempted_ids),
698
+ return_exceptions=True,
699
+ )
700
+ except asyncio.CancelledError:
701
+ # Отмена (shutdown/рестарт) не должна терять грязные чаты:
702
+ # id уже сняты с набора — возвращаем до выхода (аудит 29.10.2026).
703
+ _dirty_chat_ids.update(attempted_ids)
704
+ raise
705
  # Неудавшиеся id возвращаются в грязные для повтора, а не теряются при 5xx/429 Upstash.
706
  failed_ids = {cid for cid, res in zip(attempted_ids, save_results) if res is not True}
707
  if failed_ids:
lumen_commands.py CHANGED
@@ -112,9 +112,10 @@ async def inline_draw(message: Message, prompt: str) -> None:
112
  await status_obj.set_action_key("status_taking_longer")
113
  else:
114
  await bot._edit_message_quietly(status, bot._t(message.chat.id, "status_taking_longer"))
115
- # Таймаут попытки — остаток общего бюджета: зависшая модель не
116
- # переживает дедлайн цепочки.
117
- attempt_timeout = max(1.0, deadline - time.monotonic())
 
118
  image_bytes = await bot._pollinations_text_to_image(session, attempt_model, prompt, timeout_sec=attempt_timeout)
119
  break
120
  except Exception as exc:
 
112
  await status_obj.set_action_key("status_taking_longer")
113
  else:
114
  await bot._edit_message_quietly(status, bot._t(message.chat.id, "status_taking_longer"))
115
+ # Таймаут попытки — не больше остатка бюджета и не больше 40 с:
116
+ # одна зависшая модель не должна съедать все 120 с и лишать
117
+ # шанса резервные (аудит 29.10.2026).
118
+ attempt_timeout = max(1.0, min(40.0, deadline - time.monotonic()))
119
  image_bytes = await bot._pollinations_text_to_image(session, attempt_model, prompt, timeout_sec=attempt_timeout)
120
  break
121
  except Exception as exc:
lumen_formatting.py CHANGED
@@ -33,6 +33,8 @@ _TABLE_SEP_MAX_LEN = 500
33
 
34
  def _is_table_separator(line: str) -> bool:
35
  """Строка-разделитель markdown-таблицы с bounded match."""
 
 
36
  return _TABLE_SEP_RE.match(line[:_TABLE_SEP_MAX_LEN]) is not None
37
 
38
  def _split_table_cells(line: str) -> list[str]:
 
33
 
34
  def _is_table_separator(line: str) -> bool:
35
  """Строка-разделитель markdown-таблицы с bounded match."""
36
+ if "-" not in line:
37
+ return False
38
  return _TABLE_SEP_RE.match(line[:_TABLE_SEP_MAX_LEN]) is not None
39
 
40
  def _split_table_cells(line: str) -> list[str]:
lumen_message_core.py CHANGED
@@ -901,7 +901,7 @@ async def _handle_message_core(
901
  retry_budget = web_search_timeout_sec()
902
  if route_deadline is not None:
903
  retry_budget = min(retry_budget, max(0.0, route_deadline - time.monotonic()))
904
- if retry_budget > 0:
905
  retry_snippets = await fetch_web_snippets(
906
  clean_prompt, timeout_sec=retry_budget, max_results=web_search_max_results(),
907
  )
 
901
  retry_budget = web_search_timeout_sec()
902
  if route_deadline is not None:
903
  retry_budget = min(retry_budget, max(0.0, route_deadline - time.monotonic()))
904
+ if retry_budget > 0 and web_search_is_enabled():
905
  retry_snippets = await fetch_web_snippets(
906
  clean_prompt, timeout_sec=retry_budget, max_results=web_search_max_results(),
907
  )
lumen_rich.py CHANGED
@@ -129,9 +129,10 @@ async def _send_text(message: Message, text: str, parse_html: bool = True, **kwa
129
  if i > 0:
130
  # Пауза между кусками — против флуд-контроля за серию подряд.
131
  await bot._typing_sleep(bot.STREAM_TYPING_TICK_SEC)
132
- if parse_html:
133
  # Сначала рич (таблицы/заголовки/математика), при любом неуспехе —
134
  # обычный HTML-путь ниже. Reply threading — только у первого чанка.
 
135
  rich_res = await bot._try_send_rich(message, _md_to_rich_html(chunk), is_first_chunk=(i == 0), **chunk_kwargs)
136
  if rich_res is not None:
137
  continue
@@ -182,7 +183,7 @@ async def _edit_message_quietly(msg: Message | None, text: str, **kwargs: Any) -
182
  return False
183
  try:
184
  # Порядок: рич → HTML → голый текст; таблицы видны только через рич.
185
- if await bot._try_edit_rich(msg, _md_to_rich_html(text), **kwargs):
186
  return True
187
  kwargs.setdefault("parse_mode", ParseMode.HTML)
188
  res = await bot._tg_call(msg.edit_text, _md_to_html(text), **kwargs)
 
129
  if i > 0:
130
  # Пауза между кусками — против флуд-контроля за серию подряд.
131
  await bot._typing_sleep(bot.STREAM_TYPING_TICK_SEC)
132
+ if parse_html and bot.RICH_MESSAGES_ENABLED:
133
  # Сначала рич (таблицы/заголовки/математика), при любом неуспехе —
134
  # обычный HTML-путь ниже. Reply threading — только у первого чанка.
135
+ # При выключенном риче не тратим время на конвертацию (0.3-0.7 мс/чанк).
136
  rich_res = await bot._try_send_rich(message, _md_to_rich_html(chunk), is_first_chunk=(i == 0), **chunk_kwargs)
137
  if rich_res is not None:
138
  continue
 
183
  return False
184
  try:
185
  # Порядок: рич → HTML → голый текст; таблицы видны только через рич.
186
+ if bot.RICH_MESSAGES_ENABLED and await bot._try_edit_rich(msg, _md_to_rich_html(text), **kwargs):
187
  return True
188
  kwargs.setdefault("parse_mode", ParseMode.HTML)
189
  res = await bot._tg_call(msg.edit_text, _md_to_html(text), **kwargs)
lumen_tiktok.py CHANGED
@@ -733,6 +733,7 @@ async def _tiktok_dsp_song_id(session: aiohttp.ClientSession, video_id: str, uni
733
  if not video_id or not unique_id or time.monotonic() < _DSP_DISABLED_UNTIL:
734
  return None
735
  url = f"https://www.tiktok.com/@{urllib.parse.quote(unique_id.lstrip('@'))}/video/{video_id}?lang=en"
 
736
  for ua in _DSP_PAGE_UAS:
737
  try:
738
  async with session.get(
@@ -743,11 +744,18 @@ async def _tiktok_dsp_song_id(session: aiohttp.ClientSession, video_id: str, uni
743
  if resp.status != 200:
744
  continue
745
  html = await resp.text(errors="ignore")
 
 
746
  song_id = _extract_tt2dsp_song_id(html)
747
  if song_id:
748
  return song_id
749
  except Exception as exc:
750
  log.info("[tiktok-music] DSP page fetch failed (%s), trying next UA/fallback: %s", type(exc).__name__, exc)
 
 
 
 
 
751
  # TikTok не отдал разметку (антибот/сеть): не долбим его на каждой загрузке.
752
  _DSP_DISABLED_UNTIL = time.monotonic() + 300.0
753
  log.warning("[tiktok-music] DSP metadata unavailable for 5 minutes (TikTok page unreachable), using TikWM fields.")
 
733
  if not video_id or not unique_id or time.monotonic() < _DSP_DISABLED_UNTIL:
734
  return None
735
  url = f"https://www.tiktok.com/@{urllib.parse.quote(unique_id.lstrip('@'))}/video/{video_id}?lang=en"
736
+ hydration_seen = False
737
  for ua in _DSP_PAGE_UAS:
738
  try:
739
  async with session.get(
 
744
  if resp.status != 200:
745
  continue
746
  html = await resp.text(errors="ignore")
747
+ if '<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"' in html:
748
+ hydration_seen = True
749
  song_id = _extract_tt2dsp_song_id(html)
750
  if song_id:
751
  return song_id
752
  except Exception as exc:
753
  log.info("[tiktok-music] DSP page fetch failed (%s), trying next UA/fallback: %s", type(exc).__name__, exc)
754
+ if hydration_seen:
755
+ # Страница TikTok валидна, но DSP-трека в ней нет: это оригинальный
756
+ # звук. Не выключаем DSP на 5 минут для следующих лицензированных
757
+ # треков (аудит 29.10.2026); этот sound_id уже закэширован как None.
758
+ return None
759
  # TikTok не отдал разметку (антибот/сеть): не долбим его на каждой загрузке.
760
  _DSP_DISABLED_UNTIL = time.monotonic() + 300.0
761
  log.warning("[tiktok-music] DSP metadata unavailable for 5 minutes (TikTok page unreachable), using TikWM fields.")
lumen_tiktok_flow.py CHANGED
@@ -4,6 +4,7 @@ lumen_tiktok_flow.py — оркестрация TikTok-загрузок (муз
4
  from __future__ import annotations
5
 
6
  import asyncio
 
7
  import logging
8
  import os
9
  import re
@@ -111,6 +112,9 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
111
  if not music_url:
112
  return
113
 
 
 
 
114
  try:
115
  async with bot._media_download_slot():
116
  music_bytes = await bot._download_url_bin(session, music_url, headers=headers)
@@ -191,7 +195,7 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
191
  # и аватарку вместо настоящего названия/исполнителя/обложки.
192
  dsp_track = None
193
  try:
194
- dsp_track = await _fetch_dsp_track_metadata(session, media_data)
195
  except Exception as dsp_exc:
196
  log.info("[tiktok-music] DSP metadata lookup skipped: %s", dsp_exc)
197
  if dsp_track:
@@ -271,6 +275,12 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
271
  raise RuntimeError("Telegram send_audio failed: connection timeout or proxy unavailable")
272
  except Exception as e:
273
  log.warning("[tiktok] failed to send music: %s", e)
 
 
 
 
 
 
274
 
275
  async def handle_tiktok_sound(message: Message, status: Message | None) -> None:
276
  """Ссылка на страницу звука TikTok (не видео) — см. комментарий выше по файлу:
 
4
  from __future__ import annotations
5
 
6
  import asyncio
7
+ import contextlib
8
  import logging
9
  import os
10
  import re
 
112
  if not music_url:
113
  return
114
 
115
+ # DSP-метаданные (TikTok → iTunes) не зависят от mp3: запускаем параллельно
116
+ # со скачиванием, чтобы не добавлять 1-6 с после него (аудит 29.10.2026).
117
+ dsp_task = asyncio.create_task(_fetch_dsp_track_metadata(session, media_data))
118
  try:
119
  async with bot._media_download_slot():
120
  music_bytes = await bot._download_url_bin(session, music_url, headers=headers)
 
195
  # и аватарку вместо настоящего названия/исполнителя/обложки.
196
  dsp_track = None
197
  try:
198
+ dsp_track = await dsp_task
199
  except Exception as dsp_exc:
200
  log.info("[tiktok-music] DSP metadata lookup skipped: %s", dsp_exc)
201
  if dsp_track:
 
275
  raise RuntimeError("Telegram send_audio failed: connection timeout or proxy unavailable")
276
  except Exception as e:
277
  log.warning("[tiktok] failed to send music: %s", e)
278
+ finally:
279
+ # Ранний выход/исключение не должны оставлять DSP-запрос в фоне.
280
+ with contextlib.suppress(asyncio.CancelledError, Exception):
281
+ if not dsp_task.done():
282
+ dsp_task.cancel()
283
+ await dsp_task
284
 
285
  async def handle_tiktok_sound(message: Message, status: Message | None) -> None:
286
  """Ссылка на страницу звука TikTok (не видео) — см. комментарий выше по файлу:
tests/conftest.py CHANGED
@@ -16,6 +16,10 @@ import tempfile
16
  os.environ.setdefault("BOT_LOG_PATH", os.path.join(tempfile.gettempdir(), "lumen_test_bot.log"))
17
  os.environ.setdefault("BOT_TOKEN", "test-token-not-real")
18
  os.environ.setdefault("GEMINI_API_KEY", "test-key-not-real")
 
 
 
 
19
 
20
  import copy
21
  import pytest
 
16
  os.environ.setdefault("BOT_LOG_PATH", os.path.join(tempfile.gettempdir(), "lumen_test_bot.log"))
17
  os.environ.setdefault("BOT_TOKEN", "test-token-not-real")
18
  os.environ.setdefault("GEMINI_API_KEY", "test-key-not-real")
19
+ # Провайдерские ключи — только пустые: иначе bot.py ниже подхватит .env
20
+ # разработчика и тест сходит в живой API (сеть, чужая квота, нестабильность).
21
+ for _provider_key in ("GROQ_API_KEY", "OPENROUTER_API_KEY", "OPENROUTER_KEY", "SENTRY_DSN"):
22
+ os.environ[_provider_key] = ""
23
 
24
  import copy
25
  import pytest
tests/test_bot_commands.py CHANGED
@@ -636,6 +636,32 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
636
  bot.chat_state.pop(chat_id, None)
637
 
638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  def test_inline_draw_refuses_when_user_total_limit_exhausted(monkeypatch):
640
  # /draw без дневного лимита обходился: исчерпанный total — отказ до генерации.
641
  from types import SimpleNamespace
 
636
  bot.chat_state.pop(chat_id, None)
637
 
638
 
639
+ def test_inline_draw_caps_single_attempt_timeout(monkeypatch):
640
+ # Аудит 29.10.2026: одна зависшая модель получала все 120 с бюджета и
641
+ # лишала шанса резервные. Таймаут одной попытки ограничен 40 с.
642
+ captured = {}
643
+
644
+ async def fake_image(session, model_id, prompt, timeout_sec=None):
645
+ captured["timeout"] = timeout_sec
646
+ return b"\x89PNG fake bytes"
647
+
648
+ class _FakePhotoBot:
649
+ async def send_photo(self, **kwargs):
650
+ return SimpleNamespace()
651
+
652
+ monkeypatch.setattr(bot, "_pollinations_text_to_image", fake_image)
653
+ monkeypatch.setattr(bot, "DRAW_TOTAL_BUDGET_SEC", 120)
654
+ monkeypatch.setattr(bot, "bot", _FakePhotoBot())
655
+ incoming = _FakeIncomingMessage(999431)
656
+ incoming.message_id = 1
657
+ try:
658
+ asyncio.run(bot.inline_draw(incoming, "нарисуй кота"))
659
+ assert captured["timeout"] is not None
660
+ assert captured["timeout"] <= 40
661
+ finally:
662
+ bot.chat_state.pop(999431, None)
663
+
664
+
665
  def test_inline_draw_refuses_when_user_total_limit_exhausted(monkeypatch):
666
  # /draw без дневного лимита обходился: исчерпанный total — отказ до генерации.
667
  from types import SimpleNamespace
tests/test_bot_state.py CHANGED
@@ -459,6 +459,39 @@ def test_flush_keeps_dirty_flag_when_mutated_during_write():
459
  _run_with_blocking_save("_quota_dirty", "_save_quota_payload", lumen_chat_state.mark_quota_dirty)
460
 
461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  def test_load_state_falls_back_to_legacy_blob_on_corrupt_index():
463
  # Внешний аудит: битый индекс обнулял восстановление, хотя per-chat файлы целы.
464
  import json
 
459
  _run_with_blocking_save("_quota_dirty", "_save_quota_payload", lumen_chat_state.mark_quota_dirty)
460
 
461
 
462
+ def test_flush_cancel_requeues_dirty_chats():
463
+ # Аудит 29.10.2026: флаш снимал чаты с очереди до await; отмена (shutdown)
464
+ # теряла их без записи. Теперь id возвращаются в очередь.
465
+ import lumen_chat_state as lcs
466
+ cid = 999712
467
+ bot._dirty_chat_ids.clear()
468
+ bot._pending_chat_deletions.clear()
469
+ lcs._state_load_failed = False
470
+ bot.get_state(cid)
471
+ lcs._dirty_chat_ids.add(cid)
472
+ gate = asyncio.Event()
473
+
474
+ async def hanging_save(_cid, _state):
475
+ await gate.wait()
476
+ return True
477
+
478
+ async def scenario():
479
+ task = asyncio.create_task(lcs._flush_dirty_state_once())
480
+ await asyncio.sleep(0.05)
481
+ task.cancel()
482
+ with pytest.raises(asyncio.CancelledError):
483
+ await task
484
+ assert cid in lcs._dirty_chat_ids
485
+
486
+ try:
487
+ with patch("bot._save_chat_to_storage_limited", hanging_save):
488
+ asyncio.run(scenario())
489
+ finally:
490
+ gate.set()
491
+ bot.chat_state.pop(cid, None)
492
+ lcs._dirty_chat_ids.discard(cid)
493
+
494
+
495
  def test_load_state_falls_back_to_legacy_blob_on_corrupt_index():
496
  # Внешний аудит: битый индекс обнулял восстановление, хотя per-chat файлы целы.
497
  import json
tests/test_bot_tiktok.py CHANGED
@@ -466,6 +466,50 @@ def test_send_tiktok_music_prefers_dsp_track_metadata(monkeypatch):
466
  bot.chat_state.pop(999601, None)
467
 
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  def test_send_tiktok_music_does_not_take_handle_tail_for_a_title():
470
  # Прод 28.10.2026: "original sound - hhh31721" + ник "Hhh" давали хвост
471
  # "31721" как название, а исполнителем — "Hhh". Полный хендл обязан
 
466
  bot.chat_state.pop(999601, None)
467
 
468
 
469
+ def test_tiktok_dsp_no_song_id_does_not_disable_future_lookups():
470
+ # Прод 29.10.2026: валидная страница без tt2dsp (оригинальный звук) ставила
471
+ # общий пятиминутный кулдаун и лишала следующие лицензированные треки
472
+ # метаданных. Кулдаун — только когда страницу вообще не отдали.
473
+ import lumen_tiktok as lt
474
+
475
+ class _Resp:
476
+ status = 200
477
+
478
+ def __init__(self, body):
479
+ self._body = body
480
+
481
+ async def __aenter__(self):
482
+ return self
483
+
484
+ async def __aexit__(self, *args):
485
+ return False
486
+
487
+ async def text(self, errors=None):
488
+ return self._body
489
+
490
+ class _Session:
491
+ def __init__(self, body):
492
+ self._body = body
493
+
494
+ def get(self, url, **kwargs):
495
+ return _Resp(self._body)
496
+
497
+ valid_no_song = (
498
+ '<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">'
499
+ '{"music":{}}</script>'
500
+ )
501
+ old_disabled = lt._DSP_DISABLED_UNTIL
502
+ lt._DSP_DISABLED_UNTIL = 0.0
503
+ try:
504
+ assert asyncio.run(lt._tiktok_dsp_song_id(_Session(valid_no_song), "1", "user")) is None
505
+ assert lt._DSP_DISABLED_UNTIL == 0.0
506
+ # Антибот-заглушка без hydration — вот это настоящий сбой, кулдаун уместен.
507
+ assert asyncio.run(lt._tiktok_dsp_song_id(_Session("<html>challenge</html>"), "2", "user")) is None
508
+ assert lt._DSP_DISABLED_UNTIL > 0.0
509
+ finally:
510
+ lt._DSP_DISABLED_UNTIL = old_disabled
511
+
512
+
513
  def test_send_tiktok_music_does_not_take_handle_tail_for_a_title():
514
  # Прод 28.10.2026: "original sound - hhh31721" + ник "Hhh" давали хвост
515
  # "31721" как название, а исполнителем — "Hhh". Полный хендл обязан