Spaces:
Running
Running
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 +20 -8
- lumen_commands.py +4 -3
- lumen_formatting.py +2 -0
- lumen_message_core.py +1 -1
- lumen_rich.py +3 -2
- lumen_tiktok.py +8 -0
- lumen_tiktok_flow.py +11 -1
- tests/conftest.py +4 -0
- tests/test_bot_commands.py +26 -0
- tests/test_bot_state.py +33 -0
- tests/test_bot_tiktok.py +44 -0
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 |
-
|
| 667 |
-
|
| 668 |
-
|
| 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 |
-
|
| 690 |
-
|
| 691 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 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". Полный хендл обязан
|