Spaces:
Running
Running
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 +3 -1
- lumen_commands.py +9 -2
- lumen_formatting.py +19 -1
- lumen_routes.py +3 -2
- lumen_tiktok.py +6 -4
- lumen_tiktok_flow.py +19 -16
- tests/test_bot_tiktok.py +37 -0
- tests/test_bot_tts.py +34 -0
- tests/test_lumen_formatting.py +7 -0
.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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
| 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 |
-
#
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# Реальный размер уже скачан: лимит
|
| 405 |
-
# а
|
| 406 |
-
|
| 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)")
|