Spaces:
Running
Running
SilverElixir commited on
Commit ·
f40e9a4
1
Parent(s): e9b6d6a
Finish audit leftovers: speed_key dedup, truncate in formatting, honest mime suffixes, filename sanitize, tiger boundary, dead image data, deterministic timing tests, HEALTHCHECK, dependabot (AUD-B-003, AUD-E-005, AUD-E-006, AUD-K-001, AUD-M-001, AUD-M-004)
Browse files- .dockerignore +1 -0
- .github/dependabot.yml +13 -0
- Dockerfile +5 -0
- bot.py +6 -20
- lumen_formatting.py +19 -0
- lumen_images.py +5 -25
- lumen_media.py +13 -2
- lumen_message_parse.py +4 -3
- lumen_model_speed.py +4 -6
- test_bot.py +53 -14
- test_lumen_formatting.py +14 -0
.dockerignore
CHANGED
|
@@ -31,5 +31,6 @@ chat_state.json
|
|
| 31 |
chat_state.tmp
|
| 32 |
global_quota.json
|
| 33 |
global_quota.tmp
|
|
|
|
| 34 |
|
| 35 |
.DS_Store
|
|
|
|
| 31 |
chat_state.tmp
|
| 32 |
global_quota.json
|
| 33 |
global_quota.tmp
|
| 34 |
+
export_state.json
|
| 35 |
|
| 36 |
.DS_Store
|
.github/dependabot.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
updates:
|
| 3 |
+
# CI-гейт автодеплоит в прод ("зелёный CI = прод"), а экшены закреплены
|
| 4 |
+
# мажор-тегами (v6/v7) — Dependabot открывает PR на новые релизы экшенов,
|
| 5 |
+
# чтобы дрейф версий был видимым решением, а не тихим (AUD-M-001).
|
| 6 |
+
- package-ecosystem: "github-actions"
|
| 7 |
+
directory: "/"
|
| 8 |
+
schedule:
|
| 9 |
+
interval: "weekly"
|
| 10 |
+
- package-ecosystem: "pip"
|
| 11 |
+
directory: "/"
|
| 12 |
+
schedule:
|
| 13 |
+
interval: "weekly"
|
Dockerfile
CHANGED
|
@@ -45,4 +45,9 @@ COPY . /app
|
|
| 45 |
|
| 46 |
EXPOSE 7860
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
CMD ["python", "-u", "bot.py"]
|
|
|
|
| 45 |
|
| 46 |
EXPOSE 7860
|
| 47 |
|
| 48 |
+
# Дешёвая проба живости — in-memory "/" без сетевых вызовов (см. healthcheck в
|
| 49 |
+
# bot.py). Отдельного curl в образе нет — хватает stdlib-urllib.
|
| 50 |
+
HEALTHCHECK --interval=60s --timeout=5s --start-period=60s --retries=3 \
|
| 51 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/', timeout=4)"
|
| 52 |
+
|
| 53 |
CMD ["python", "-u", "bot.py"]
|
bot.py
CHANGED
|
@@ -648,7 +648,7 @@ async def _close_sessions() -> None:
|
|
| 648 |
# именно ради старых тестов на `bot.X`, но с переездом тестов на прямой импорт
|
| 649 |
# модуля этот ре-экспорт стал мёртвым (см. аудит техдолга, 26 августа 2026) и
|
| 650 |
# убран вместе с соответствующим `__all__`.
|
| 651 |
-
from lumen_formatting import _md_to_html, _md_to_rich_html, _split_text_chunks, _strip_markdown
|
| 652 |
|
| 653 |
_PRUNE_SENTINEL = object()
|
| 654 |
|
|
@@ -789,23 +789,6 @@ def is_guest_message(message: Message | dict) -> bool:
|
|
| 789 |
return bool(message.get("guest_query_id"))
|
| 790 |
return bool(getattr(message, "guest_query_id", None))
|
| 791 |
|
| 792 |
-
def _truncate_html_to_fit(md_text: str, limit: int) -> str:
|
| 793 |
-
"""HTML по границе исходника, а не по границе тегов: резать готовый HTML
|
| 794 |
-
по codepoint можно угодить в середину <b>/ссылки — Telegram ответит
|
| 795 |
-
"can't parse entities" (AUD-J-002). Бинарным поиском ищем самый длинный
|
| 796 |
-
префикс исходника, чей HTML влезает в лимит."""
|
| 797 |
-
full = _md_to_html(md_text)
|
| 798 |
-
if len(full) <= limit:
|
| 799 |
-
return full
|
| 800 |
-
lo, hi = 0, len(md_text)
|
| 801 |
-
while lo < hi:
|
| 802 |
-
mid = (lo + hi + 1) // 2
|
| 803 |
-
if len(_md_to_html(md_text[:mid])) <= limit - 1:
|
| 804 |
-
lo = mid
|
| 805 |
-
else:
|
| 806 |
-
hi = mid - 1
|
| 807 |
-
return _md_to_html(md_text[:lo])[:limit - 1] + "…"
|
| 808 |
-
|
| 809 |
async def _answer_guest_text(message: Message, text: str) -> None:
|
| 810 |
qid = getattr(message, "guest_query_id", None)
|
| 811 |
if not qid:
|
|
@@ -2633,9 +2616,12 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
|
|
| 2633 |
except Exception as tag_err:
|
| 2634 |
log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err)
|
| 2635 |
|
|
|
|
|
|
|
|
|
|
| 2636 |
await bot.send_audio(
|
| 2637 |
-
|
| 2638 |
-
|
| 2639 |
title=cleaned_title,
|
| 2640 |
performer=performer_name,
|
| 2641 |
thumbnail=thumbnail_file,
|
|
|
|
| 648 |
# именно ради старых тестов на `bot.X`, но с переездом тестов на прямой импорт
|
| 649 |
# модуля этот ре-экспорт стал мёртвым (см. аудит техдолга, 26 августа 2026) и
|
| 650 |
# убран вместе с соответствующим `__all__`.
|
| 651 |
+
from lumen_formatting import _md_to_html, _md_to_rich_html, _split_text_chunks, _strip_markdown, _truncate_html_to_fit
|
| 652 |
|
| 653 |
_PRUNE_SENTINEL = object()
|
| 654 |
|
|
|
|
| 789 |
return bool(message.get("guest_query_id"))
|
| 790 |
return bool(getattr(message, "guest_query_id", None))
|
| 791 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
async def _answer_guest_text(message: Message, text: str) -> None:
|
| 793 |
qid = getattr(message, "guest_query_id", None)
|
| 794 |
if not qid:
|
|
|
|
| 2616 |
except Exception as tag_err:
|
| 2617 |
log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err)
|
| 2618 |
|
| 2619 |
+
# Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
|
| 2620 |
+
# иначе multipart-имя файла битое.
|
| 2621 |
+
safe_title = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", cleaned_title[:60]).strip() or "track"
|
| 2622 |
await bot.send_audio(
|
| 2623 |
+
chat_id=message.chat.id,
|
| 2624 |
+
audio=BufferedInputFile(tagged_music_bytes, filename=f"{safe_title}.mp3"),
|
| 2625 |
title=cleaned_title,
|
| 2626 |
performer=performer_name,
|
| 2627 |
thumbnail=thumbnail_file,
|
lumen_formatting.py
CHANGED
|
@@ -609,3 +609,22 @@ def _split_text_chunks(text: str, max_len: int = 4096) -> list[str]:
|
|
| 609 |
chunks.append(remaining)
|
| 610 |
return chunks
|
| 611 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
chunks.append(remaining)
|
| 610 |
return chunks
|
| 611 |
|
| 612 |
+
|
| 613 |
+
def _truncate_html_to_fit(md_text: str, limit: int) -> str:
|
| 614 |
+
"""HTML по границе исходника, а не по границе тегов: резать готовый HTML
|
| 615 |
+
по codepoint можно угодить в середину <b>/ссылки — Telegram ответит
|
| 616 |
+
"can't parse entities" (AUD-J-002). Бинарным поиском ищем самый длинный
|
| 617 |
+
префикс исходника, чей HTML влезает в лимит. Вынесено из bot.py (гостевые
|
| 618 |
+
ответы — одиночный InlineQueryResultArticle без фолбэка на plain-текст)."""
|
| 619 |
+
full = _md_to_html(md_text)
|
| 620 |
+
if len(full) <= limit:
|
| 621 |
+
return full
|
| 622 |
+
lo, hi = 0, len(md_text)
|
| 623 |
+
while lo < hi:
|
| 624 |
+
mid = (lo + hi + 1) // 2
|
| 625 |
+
if len(_md_to_html(md_text[:mid])) <= limit - 1:
|
| 626 |
+
lo = mid
|
| 627 |
+
else:
|
| 628 |
+
hi = mid - 1
|
| 629 |
+
return _md_to_html(md_text[:lo])[:limit - 1] + "…"
|
| 630 |
+
|
lumen_images.py
CHANGED
|
@@ -40,26 +40,11 @@ import aiohttp
|
|
| 40 |
DEFAULT_POLLINATIONS_IMAGE_MODEL = os.getenv("POLLINATIONS_IMAGE_MODEL", "flux").strip()
|
| 41 |
|
| 42 |
POLLINATIONS_IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
| 43 |
-
"flux": {
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
},
|
| 47 |
-
"
|
| 48 |
-
"name": "FLUX Realism",
|
| 49 |
-
"desc": "FLUX с акцентом на гиперреализм — детализированные текстуры, естественное освещение, кинематографичность.",
|
| 50 |
-
},
|
| 51 |
-
"flux-anime": {
|
| 52 |
-
"name": "FLUX Anime",
|
| 53 |
-
"desc": "FLUX для аниме и иллюстраций — характерные пропорции, яркие цвета, стилизация под японскую графику.",
|
| 54 |
-
},
|
| 55 |
-
"turbo": {
|
| 56 |
-
"name": "Turbo",
|
| 57 |
-
"desc": "Быстрая дистиллированная модель. Результат за несколько секунд — для черновиков и быстрых итераций.",
|
| 58 |
-
},
|
| 59 |
-
"dreamshaper": {
|
| 60 |
-
"name": "DreamShaper",
|
| 61 |
-
"desc": "Художественная модель для фэнтези, концепт-арта и стилизованных иллюстраций.",
|
| 62 |
-
},
|
| 63 |
}
|
| 64 |
|
| 65 |
# ── Автоматический выбор модели генерации по содержимому промпта ──
|
|
@@ -135,8 +120,3 @@ async def _pollinations_text_to_image(session: aiohttp.ClientSession, model_id:
|
|
| 135 |
if model_id not in POLLINATIONS_IMAGE_MODELS:
|
| 136 |
raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
|
| 137 |
return await _pollinations_generate(session, model_id, prompt)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def _image_model_label(model_id: str) -> str:
|
| 141 |
-
meta = POLLINATIONS_IMAGE_MODELS.get(model_id, {})
|
| 142 |
-
return meta.get("name", model_id)
|
|
|
|
| 40 |
DEFAULT_POLLINATIONS_IMAGE_MODEL = os.getenv("POLLINATIONS_IMAGE_MODEL", "flux").strip()
|
| 41 |
|
| 42 |
POLLINATIONS_IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
| 43 |
+
"flux": {"name": "FLUX Pro"},
|
| 44 |
+
"flux-realism": {"name": "FLUX Realism"},
|
| 45 |
+
"flux-anime": {"name": "FLUX Anime"},
|
| 46 |
+
"turbo": {"name": "Turbo"},
|
| 47 |
+
"dreamshaper": {"name": "DreamShaper"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
# ── Автоматический выбор модели генерации по содержимому промпта ──
|
|
|
|
| 120 |
if model_id not in POLLINATIONS_IMAGE_MODELS:
|
| 121 |
raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
|
| 122 |
return await _pollinations_generate(session, model_id, prompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lumen_media.py
CHANGED
|
@@ -139,10 +139,21 @@ def _mime_suffix(mime: str, filename: str = "") -> str:
|
|
| 139 |
if m.startswith("image/"):
|
| 140 |
sub = m.split("/", 1)[1]
|
| 141 |
return {"jpeg": ".jpg", "jpg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"}.get(sub, f".{sub}")
|
|
|
|
|
|
|
| 142 |
if m.startswith("audio/"):
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
if m.startswith("video/"):
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return ".bin"
|
| 147 |
|
| 148 |
def _msg_media_source(message: Any) -> Any | None:
|
|
|
|
| 139 |
if m.startswith("image/"):
|
| 140 |
sub = m.split("/", 1)[1]
|
| 141 |
return {"jpeg": ".jpg", "jpg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"}.get(sub, f".{sub}")
|
| 142 |
+
# Честная карта вместо ".mp3 для любого audio" (AUD-E-005): расширение врёт
|
| 143 |
+
# редко, но метка временному файлу должна соответствовать содержимому.
|
| 144 |
if m.startswith("audio/"):
|
| 145 |
+
sub = m.split("/", 1)[1].split(";")[0].strip()
|
| 146 |
+
return {
|
| 147 |
+
"mpeg": ".mp3", "mp3": ".mp3", "ogg": ".ogg", "wav": ".wav",
|
| 148 |
+
"x-wav": ".wav", "webm": ".webm", "mp4": ".m4a", "x-m4a": ".m4a",
|
| 149 |
+
"aac": ".aac", "flac": ".flac", "opus": ".opus",
|
| 150 |
+
}.get(sub, f".{sub}")
|
| 151 |
if m.startswith("video/"):
|
| 152 |
+
sub = m.split("/", 1)[1].split(";")[0].strip()
|
| 153 |
+
return {
|
| 154 |
+
"mp4": ".mp4", "quicktime": ".mov", "x-msvideo": ".avi",
|
| 155 |
+
"x-matroska": ".mkv", "webm": ".webm", "mpeg": ".mpg",
|
| 156 |
+
}.get(sub, f".{sub}")
|
| 157 |
return ".bin"
|
| 158 |
|
| 159 |
def _msg_media_source(message: Any) -> Any | None:
|
lumen_message_parse.py
CHANGED
|
@@ -274,12 +274,13 @@ def match_pick_request(text_lower: str) -> str | None:
|
|
| 274 |
return None
|
| 275 |
for topic, scenario in _PICK_TOPICS.items():
|
| 276 |
# Латиницу — строго по границам слов ("notebook" — не книги,
|
| 277 |
-
# "shower" — не сериалы); кириллицу — по
|
| 278 |
-
#
|
|
|
|
| 279 |
if topic.isascii():
|
| 280 |
if re.search(r"\b" + re.escape(topic) + r"\b", text):
|
| 281 |
return scenario
|
| 282 |
-
elif
|
| 283 |
return scenario
|
| 284 |
return None
|
| 285 |
|
|
|
|
| 274 |
return None
|
| 275 |
for topic, scenario in _PICK_TOPICS.items():
|
| 276 |
# Латиницу — строго по границам слов ("notebook" — не книги,
|
| 277 |
+
# "shower" — не сериалы); кириллицу — по началу слова: корни вида
|
| 278 |
+
# "музык"/"книг" так и задуманы префиксными, но "тигр" — не игры
|
| 279 |
+
# (AUD-E-006). \w в Python юникодный, кириллицу покрывает.
|
| 280 |
if topic.isascii():
|
| 281 |
if re.search(r"\b" + re.escape(topic) + r"\b", text):
|
| 282 |
return scenario
|
| 283 |
+
elif re.search(r"(?<!\w)" + re.escape(topic), text):
|
| 284 |
return scenario
|
| 285 |
return None
|
| 286 |
|
lumen_model_speed.py
CHANGED
|
@@ -42,6 +42,10 @@ lumen_model_speed.py — самокалибрующаяся оценка зад
|
|
| 42 |
|
| 43 |
from __future__ import annotations
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
# Насколько сильно один новый замер сдвигает EMA — тот же компромисс
|
| 46 |
# "стабильность против скорости подстройки", что и в lumen_typing_pace.py.
|
| 47 |
_EMA_ALPHA = 0.3
|
|
@@ -66,12 +70,6 @@ _TTF_ABANDON_MULT = 2.5
|
|
| 66 |
_latency_ema: dict[str, tuple[float, float]] = {}
|
| 67 |
|
| 68 |
|
| 69 |
-
def speed_key(provider: str, model_id: str) -> str:
|
| 70 |
-
"""Единый ключ — тот же формат пары (provider, model_id), что в
|
| 71 |
-
lumen_typing_pace.speed_key и GLOBAL_QUOTA."""
|
| 72 |
-
return f"{provider}:{model_id}"
|
| 73 |
-
|
| 74 |
-
|
| 75 |
def expected_ttf_sec(key: str) -> float:
|
| 76 |
"""Ожидаемое время до первого куска — EMA или приор для новой модели."""
|
| 77 |
ttf = _latency_ema.get(key, (_DEFAULT_TTF_SEC, _DEFAULT_TOTAL_SEC))[0]
|
|
|
|
| 42 |
|
| 43 |
from __future__ import annotations
|
| 44 |
|
| 45 |
+
# Единый ключ provider:model_id живёт в lumen_typing_pace (там каноническое
|
| 46 |
+
# определение) — здесь реэкспорт, чтобы не плодить два одинаковых форматтера.
|
| 47 |
+
from lumen_typing_pace import speed_key
|
| 48 |
+
|
| 49 |
# Насколько сильно один новый замер сдвигает EMA — тот же компромисс
|
| 50 |
# "стабильность против скорости подстройки", что и в lumen_typing_pace.py.
|
| 51 |
_EMA_ALPHA = 0.3
|
|
|
|
| 70 |
_latency_ema: dict[str, tuple[float, float]] = {}
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def expected_ttf_sec(key: str) -> float:
|
| 74 |
"""Ожидаемое время до первого куска — EMA или приор для новой модели."""
|
| 75 |
ttf = _latency_ema.get(key, (_DEFAULT_TTF_SEC, _DEFAULT_TOTAL_SEC))[0]
|
test_bot.py
CHANGED
|
@@ -137,6 +137,16 @@ def test_sanitize_mime_type_audio_ogg_passthrough():
|
|
| 137 |
assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg"
|
| 138 |
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
def test_ensure_prompt_text_gif_gets_animation_prompt_not_generic_image():
|
| 141 |
assert "анимации" in bot._ensure_prompt_text(None, "image/gif")
|
| 142 |
assert "анимации" in bot._ensure_prompt_text(" ", "image/GIF")
|
|
@@ -1900,6 +1910,34 @@ def _run_send_tiktok_music(media_data: dict, language_code: str | None = "ru"):
|
|
| 1900 |
return captured.get("title"), captured.get("artist")
|
| 1901 |
|
| 1902 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1903 |
def test_send_tiktok_music_truly_generic_original_sound_uses_localized_label():
|
| 1904 |
# raw_music_title буквально "original sound" без остатка (TikTok в этом случае
|
| 1905 |
# обычно добавляет юзернейм автора видео тем же куском — здесь его просто нет
|
|
@@ -3213,7 +3251,7 @@ def test_ask_gemini_falls_back_to_next_model_on_timeout():
|
|
| 3213 |
def fake_generate_content(*, model, contents, config=None):
|
| 3214 |
calls.append(model)
|
| 3215 |
if model == "gemini-3.6-flash":
|
| 3216 |
-
time.sleep(0.
|
| 3217 |
return _FakeGeminiResponse(text="Ответ от второй модели")
|
| 3218 |
|
| 3219 |
fake_client = MagicMock()
|
|
@@ -3949,17 +3987,6 @@ def test_allowed_updates_contains_only_real_telegram_types():
|
|
| 3949 |
assert "message" in bot.ALLOWED_UPDATES
|
| 3950 |
|
| 3951 |
|
| 3952 |
-
def test_truncate_html_to_fit_keeps_short_text_and_cuts_at_source_boundary():
|
| 3953 |
-
# Регрессия AUD-J-002: рез готового HTML рвал теги ("can't parse entities").
|
| 3954 |
-
short = bot._truncate_html_to_fit("привет", bot.TG_MAX_LEN)
|
| 3955 |
-
assert short == bot._md_to_html("привет")
|
| 3956 |
-
long_md = "**" + "x" * 5000 + "**"
|
| 3957 |
-
cut = bot._truncate_html_to_fit(long_md, bot.TG_MAX_LEN)
|
| 3958 |
-
assert len(cut) <= bot.TG_MAX_LEN
|
| 3959 |
-
assert cut.endswith("…")
|
| 3960 |
-
assert cut.count("<b>") == cut.count("</b>")
|
| 3961 |
-
|
| 3962 |
-
|
| 3963 |
# ─────────────────── ADMIN_SECRET_SEED — независимая ротация секретов ───────────────────
|
| 3964 |
|
| 3965 |
def test_admin_secrets_are_independent_of_bot_token_when_seed_set():
|
|
@@ -4384,6 +4411,7 @@ def test_handle_tiktok_slideshow_video_probing_respects_concurrency_cap():
|
|
| 4384 |
current_concurrent = 0
|
| 4385 |
max_concurrent_seen = 0
|
| 4386 |
lock = asyncio.Lock()
|
|
|
|
| 4387 |
|
| 4388 |
async def fake_get_http_session():
|
| 4389 |
return _FakeTikTokSession(tikwm_json)
|
|
@@ -4399,7 +4427,11 @@ def test_handle_tiktok_slideshow_video_probing_respects_concurrency_cap():
|
|
| 4399 |
async with lock:
|
| 4400 |
current_concurrent += 1
|
| 4401 |
max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
|
| 4402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4403 |
async with lock:
|
| 4404 |
current_concurrent -= 1
|
| 4405 |
return 0, 0, 0, None
|
|
@@ -4453,6 +4485,7 @@ def test_handle_tiktok_slideshow_download_respects_concurrency_cap():
|
|
| 4453 |
current_concurrent = 0
|
| 4454 |
max_concurrent_seen = 0
|
| 4455 |
lock = asyncio.Lock()
|
|
|
|
| 4456 |
|
| 4457 |
async def fake_get_http_session():
|
| 4458 |
return _FakeTikTokSession(tikwm_json)
|
|
@@ -4465,7 +4498,10 @@ def test_handle_tiktok_slideshow_download_respects_concurrency_cap():
|
|
| 4465 |
async with lock:
|
| 4466 |
current_concurrent += 1
|
| 4467 |
max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
|
| 4468 |
-
|
|
|
|
|
|
|
|
|
|
| 4469 |
async with lock:
|
| 4470 |
current_concurrent -= 1
|
| 4471 |
return b"\xff\xd8\xff\xe0fake jpeg bytes" # не ftyp -> обычное фото, не видео-слайд
|
|
@@ -5320,6 +5356,9 @@ def test_match_pick_request_rejects_detailed_or_unrelated():
|
|
| 5320 |
assert bot.match_pick_request("") is None
|
| 5321 |
# Латиница — только по границам слов: "notebook" — не книги.
|
| 5322 |
assert bot.match_pick_request("recommend a notebook") is None
|
|
|
|
|
|
|
|
|
|
| 5323 |
|
| 5324 |
|
| 5325 |
def test_pick_question_sent_instead_of_ai_route(rate_guard_setup, monkeypatch):
|
|
|
|
| 137 |
assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg"
|
| 138 |
|
| 139 |
|
| 140 |
+
def test_mime_suffix_maps_audio_video_subtypes_honestly():
|
| 141 |
+
# Регрессия AUD-E-005: было ".mp3 для любого audio" — расширение врало.
|
| 142 |
+
assert bot._mime_suffix("audio/ogg", "") == ".ogg"
|
| 143 |
+
assert bot._mime_suffix("audio/mpeg", "") == ".mp3"
|
| 144 |
+
assert bot._mime_suffix("audio/wav", "") == ".wav"
|
| 145 |
+
assert bot._mime_suffix("video/quicktime", "") == ".mov"
|
| 146 |
+
assert bot._mime_suffix("video/mp4", "") == ".mp4"
|
| 147 |
+
assert bot._mime_suffix("image/jpeg", "") == ".jpg"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
def test_ensure_prompt_text_gif_gets_animation_prompt_not_generic_image():
|
| 151 |
assert "анимации" in bot._ensure_prompt_text(None, "image/gif")
|
| 152 |
assert "анимации" in bot._ensure_prompt_text(" ", "image/GIF")
|
|
|
|
| 1910 |
return captured.get("title"), captured.get("artist")
|
| 1911 |
|
| 1912 |
|
| 1913 |
+
def test_send_tiktok_music_filename_sanitizes_slashes_and_control_chars():
|
| 1914 |
+
# Регрессия AUD-E-005: слэш/контролы из чужого названия в multipart-имени.
|
| 1915 |
+
incoming = _FakeIncomingMessage(999602)
|
| 1916 |
+
incoming.message_id = 12345
|
| 1917 |
+
incoming.from_user = SimpleNamespace(language_code="ru")
|
| 1918 |
+
media_data = {
|
| 1919 |
+
"music": "https://example.com/sound.mp3",
|
| 1920 |
+
"music_info": {"title": "a/b\x01c", "author": "X", "cover": ""},
|
| 1921 |
+
"author": {"nickname": "N", "unique_id": "n"},
|
| 1922 |
+
}
|
| 1923 |
+
|
| 1924 |
+
async def fake_download(session, url, headers=None):
|
| 1925 |
+
return b"fake-bytes"
|
| 1926 |
+
|
| 1927 |
+
fake_bot = _FakeAudioBot()
|
| 1928 |
+
original_download, original_bot = bot._download_url_bin, bot.bot
|
| 1929 |
+
bot._download_url_bin = fake_download
|
| 1930 |
+
bot.bot = fake_bot
|
| 1931 |
+
try:
|
| 1932 |
+
asyncio.run(bot._send_tiktok_music(None, media_data, incoming, "N", {}))
|
| 1933 |
+
finally:
|
| 1934 |
+
bot._download_url_bin = original_download
|
| 1935 |
+
bot.bot = original_bot
|
| 1936 |
+
filename = fake_bot.sent_audio["audio"].filename
|
| 1937 |
+
assert "/" not in filename and "\\" not in filename and "\x01" not in filename
|
| 1938 |
+
assert filename.endswith(".mp3")
|
| 1939 |
+
|
| 1940 |
+
|
| 1941 |
def test_send_tiktok_music_truly_generic_original_sound_uses_localized_label():
|
| 1942 |
# raw_music_title буквально "original sound" без остатка (TikTok в этом случае
|
| 1943 |
# обычно добавляет юзернейм автора видео тем же куском — здесь его просто нет
|
|
|
|
| 3251 |
def fake_generate_content(*, model, contents, config=None):
|
| 3252 |
calls.append(model)
|
| 3253 |
if model == "gemini-3.6-flash":
|
| 3254 |
+
time.sleep(0.5) # 10-кратный запас над ROUTE_MODEL_TIMEOUT_SEC ниже
|
| 3255 |
return _FakeGeminiResponse(text="Ответ от второй модели")
|
| 3256 |
|
| 3257 |
fake_client = MagicMock()
|
|
|
|
| 3987 |
assert "message" in bot.ALLOWED_UPDATES
|
| 3988 |
|
| 3989 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3990 |
# ─────────────────── ADMIN_SECRET_SEED — независимая ротация секретов ───────────────────
|
| 3991 |
|
| 3992 |
def test_admin_secrets_are_independent_of_bot_token_when_seed_set():
|
|
|
|
| 4411 |
current_concurrent = 0
|
| 4412 |
max_concurrent_seen = 0
|
| 4413 |
lock = asyncio.Lock()
|
| 4414 |
+
overlapped = asyncio.Event()
|
| 4415 |
|
| 4416 |
async def fake_get_http_session():
|
| 4417 |
return _FakeTikTokSession(tikwm_json)
|
|
|
|
| 4427 |
async with lock:
|
| 4428 |
current_concurrent += 1
|
| 4429 |
max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
|
| 4430 |
+
if current_concurrent >= 2:
|
| 4431 |
+
overlapped.set()
|
| 4432 |
+
# Детерминированный барьер вместо sleep: дальше идёт только тот, кто
|
| 4433 |
+
# реально пересёкся с соседом; последовательный регресс упрётся в таймаут.
|
| 4434 |
+
await asyncio.wait_for(overlapped.wait(), timeout=2)
|
| 4435 |
async with lock:
|
| 4436 |
current_concurrent -= 1
|
| 4437 |
return 0, 0, 0, None
|
|
|
|
| 4485 |
current_concurrent = 0
|
| 4486 |
max_concurrent_seen = 0
|
| 4487 |
lock = asyncio.Lock()
|
| 4488 |
+
overlapped = asyncio.Event()
|
| 4489 |
|
| 4490 |
async def fake_get_http_session():
|
| 4491 |
return _FakeTikTokSession(tikwm_json)
|
|
|
|
| 4498 |
async with lock:
|
| 4499 |
current_concurrent += 1
|
| 4500 |
max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
|
| 4501 |
+
if current_concurrent >= 2:
|
| 4502 |
+
overlapped.set()
|
| 4503 |
+
# Детерминированный барьер вместо sleep (см. комментарий в probe-тесте выше).
|
| 4504 |
+
await asyncio.wait_for(overlapped.wait(), timeout=2)
|
| 4505 |
async with lock:
|
| 4506 |
current_concurrent -= 1
|
| 4507 |
return b"\xff\xd8\xff\xe0fake jpeg bytes" # не ftyp -> обычное фото, не видео-слайд
|
|
|
|
| 5356 |
assert bot.match_pick_request("") is None
|
| 5357 |
# Латиница — только по границам слов: "notebook" — не книги.
|
| 5358 |
assert bot.match_pick_request("recommend a notebook") is None
|
| 5359 |
+
# Кириллица — по началу слова: "тигр" — не игры (AUD-E-006), "игру" — игры.
|
| 5360 |
+
assert bot.match_pick_request("посоветуй тигра") is None
|
| 5361 |
+
assert bot.match_pick_request("придумай игру для компании") == "games"
|
| 5362 |
|
| 5363 |
|
| 5364 |
def test_pick_question_sent_instead_of_ai_route(rate_guard_setup, monkeypatch):
|
test_lumen_formatting.py
CHANGED
|
@@ -18,6 +18,20 @@ import lumen_formatting
|
|
| 18 |
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
# ─────────────────────────── _md_to_html ───────────────────────────
|
| 22 |
|
| 23 |
def test_md_to_html_empty_string():
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
+
# ─────────────────────────── _truncate_html_to_fit ───────────────────────────
|
| 22 |
+
|
| 23 |
+
def test_truncate_html_to_fit_keeps_short_text_and_cuts_at_source_boundary():
|
| 24 |
+
# Регрессия AUD-J-002: рез готового HTML рвал теги ("can't parse entities").
|
| 25 |
+
# Переехала из test_bot.py вслед за функцией (TG_MAX_LEN=4096 — лимит Telegram).
|
| 26 |
+
short = lumen_formatting._truncate_html_to_fit("привет", 4096)
|
| 27 |
+
assert short == lumen_formatting._md_to_html("привет")
|
| 28 |
+
long_md = "**" + "x" * 5000 + "**"
|
| 29 |
+
cut = lumen_formatting._truncate_html_to_fit(long_md, 4096)
|
| 30 |
+
assert len(cut) <= 4096
|
| 31 |
+
assert cut.endswith("…")
|
| 32 |
+
assert cut.count("<b>") == cut.count("</b>")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
# ─────────────────────────── _md_to_html ───────────────────────────
|
| 36 |
|
| 37 |
def test_md_to_html_empty_string():
|