Spaces:
Running
Running
SilverElixir commited on
Commit ·
22d7bdd
1
Parent(s): 87259c9
Refuse gemini-only media early, cache build version
Browse files- lumen_commands.py +17 -3
- lumen_message_core.py +36 -0
- tests/test_bot_commands.py +25 -2
- tests/test_bot_state.py +33 -0
lumen_commands.py
CHANGED
|
@@ -552,6 +552,10 @@ async def cmd_stats(message: Message) -> None:
|
|
| 552 |
# Счётчики — по датам Google (сброс в _reset_quota_if_new_day проверяем перед отрисовкой: фоновый тик мог не успеть, а used копились через рестарты).
|
| 553 |
bot._reset_quota_if_new_day()
|
| 554 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
total_chats = len(bot.chat_state)
|
| 556 |
# "Активные" — с живой активностью за сутки по настенным часам (monotonic
|
| 557 |
# сбрасывался рестартом и считал активными всех). Без метки — неактивен.
|
|
@@ -608,7 +612,7 @@ async def cmd_stats(message: Message) -> None:
|
|
| 608 |
proxy_line = bot._tg_proxy_breaker.status_text()
|
| 609 |
|
| 610 |
quota_day = bot.GLOBAL_QUOTA.get("quota_day") or "—"
|
| 611 |
-
webhook_text = await
|
| 612 |
|
| 613 |
text = (
|
| 614 |
f"<b>Статистика Lumen</b>\n"
|
|
@@ -644,12 +648,20 @@ async def cmd_stats(message: Message) -> None:
|
|
| 644 |
await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML)
|
| 645 |
|
| 646 |
|
|
|
|
|
|
|
|
|
|
| 647 |
def _build_version() -> str:
|
| 648 |
"""Короткий хеш сборки для /stats: env сборки, иначе git, иначе unknown."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
for env_name in ("LUMEN_BUILD_VERSION", "BUILD_VERSION", "GIT_COMMIT", "COMMIT_SHA"):
|
| 650 |
raw = os.getenv(env_name, "").strip()
|
| 651 |
if raw:
|
| 652 |
-
|
|
|
|
| 653 |
try:
|
| 654 |
proc = subprocess.run(
|
| 655 |
["git", "rev-parse", "--short", "HEAD"],
|
|
@@ -657,9 +669,11 @@ def _build_version() -> str:
|
|
| 657 |
)
|
| 658 |
sha = (proc.stdout or "").strip()
|
| 659 |
if proc.returncode == 0 and sha:
|
| 660 |
-
|
|
|
|
| 661 |
except Exception:
|
| 662 |
pass
|
|
|
|
| 663 |
return "unknown"
|
| 664 |
|
| 665 |
|
|
|
|
| 552 |
# Счётчики — по датам Google (сброс в _reset_quota_if_new_day проверяем перед отрисовкой: фоновый тик мог не успеть, а used копились через рестарты).
|
| 553 |
bot._reset_quota_if_new_day()
|
| 554 |
|
| 555 |
+
# Вебхук опрашиваем параллельно со сборкой текста: иначе висящий API
|
| 556 |
+
# держал бы всю команду до 10с уже после готовой статистики.
|
| 557 |
+
_webhook_task = asyncio.ensure_future(_webhook_info_text())
|
| 558 |
+
|
| 559 |
total_chats = len(bot.chat_state)
|
| 560 |
# "Активные" — с живой активностью за сутки по настенным часам (monotonic
|
| 561 |
# сбрасывался рестартом и считал активными всех). Без метки — неактивен.
|
|
|
|
| 612 |
proxy_line = bot._tg_proxy_breaker.status_text()
|
| 613 |
|
| 614 |
quota_day = bot.GLOBAL_QUOTA.get("quota_day") or "—"
|
| 615 |
+
webhook_text = await _webhook_task
|
| 616 |
|
| 617 |
text = (
|
| 618 |
f"<b>Статистика Lumen</b>\n"
|
|
|
|
| 648 |
await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML)
|
| 649 |
|
| 650 |
|
| 651 |
+
_BUILD_VERSION_CACHED: str | None = None
|
| 652 |
+
|
| 653 |
+
|
| 654 |
def _build_version() -> str:
|
| 655 |
"""Короткий хеш сборки для /stats: env сборки, иначе git, иначе unknown."""
|
| 656 |
+
global _BUILD_VERSION_CACHED
|
| 657 |
+
# git-вызов блокирует loop до 5с — считаем один раз за жизнь процесса.
|
| 658 |
+
if _BUILD_VERSION_CACHED is not None:
|
| 659 |
+
return _BUILD_VERSION_CACHED
|
| 660 |
for env_name in ("LUMEN_BUILD_VERSION", "BUILD_VERSION", "GIT_COMMIT", "COMMIT_SHA"):
|
| 661 |
raw = os.getenv(env_name, "").strip()
|
| 662 |
if raw:
|
| 663 |
+
_BUILD_VERSION_CACHED = raw[:12]
|
| 664 |
+
return _BUILD_VERSION_CACHED
|
| 665 |
try:
|
| 666 |
proc = subprocess.run(
|
| 667 |
["git", "rev-parse", "--short", "HEAD"],
|
|
|
|
| 669 |
)
|
| 670 |
sha = (proc.stdout or "").strip()
|
| 671 |
if proc.returncode == 0 and sha:
|
| 672 |
+
_BUILD_VERSION_CACHED = sha[:12]
|
| 673 |
+
return _BUILD_VERSION_CACHED
|
| 674 |
except Exception:
|
| 675 |
pass
|
| 676 |
+
_BUILD_VERSION_CACHED = "unknown"
|
| 677 |
return "unknown"
|
| 678 |
|
| 679 |
|
lumen_message_core.py
CHANGED
|
@@ -304,6 +304,31 @@ def _strip_trigger_content(clean_prompt: str, trigger: str, message: Message) ->
|
|
| 304 |
return content
|
| 305 |
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
async def _handle_message_core(message: Message, extra_media: list[tuple[bytes, str]] | None = None) -> None:
|
| 308 |
import bot
|
| 309 |
t = message.text or message.caption or ""
|
|
@@ -336,6 +361,17 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 336 |
))
|
| 337 |
return
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
state = bot.get_state(message.chat.id)
|
| 340 |
|
| 341 |
# Проверка на ссылки загрузки (TikTok — сразу всегда, даже в группах без упоминания)
|
|
|
|
| 304 |
return content
|
| 305 |
|
| 306 |
|
| 307 |
+
def _direct_attachment_needs_gemini(message: Message) -> bool:
|
| 308 |
+
"""Видео/аудио/документ (не картинка) во вложении или реплае — такое поздно
|
| 309 |
+
отдавать только Gemini, скачивание заранее бесполезно при исчерпанном лимите."""
|
| 310 |
+
for msg in (message, getattr(message, "reply_to_message", None)):
|
| 311 |
+
if msg is None:
|
| 312 |
+
continue
|
| 313 |
+
# Имя атрибута уже говорит о категории без скачивания (и без опоры на
|
| 314 |
+
# имя класса — в тестах лежат даблы, у aiogram свои типы).
|
| 315 |
+
for attr in ("video", "video_note", "animation", "voice", "audio"):
|
| 316 |
+
if getattr(msg, attr, None):
|
| 317 |
+
return True
|
| 318 |
+
src = _msg_media_source(msg)
|
| 319 |
+
if src is None:
|
| 320 |
+
continue
|
| 321 |
+
if type(src).__name__ in ("Video", "VideoNote", "Animation", "Voice", "Audio"):
|
| 322 |
+
return True
|
| 323 |
+
_, mime, _ = _media_file_id_and_mime(src)
|
| 324 |
+
m = (mime or "").lower()
|
| 325 |
+
if m.startswith(("video/", "audio/")):
|
| 326 |
+
return True
|
| 327 |
+
if type(src).__name__ == "Document" and not m.startswith("image/"):
|
| 328 |
+
return True
|
| 329 |
+
return False
|
| 330 |
+
|
| 331 |
+
|
| 332 |
async def _handle_message_core(message: Message, extra_media: list[tuple[bytes, str]] | None = None) -> None:
|
| 333 |
import bot
|
| 334 |
t = message.text or message.caption or ""
|
|
|
|
| 361 |
))
|
| 362 |
return
|
| 363 |
|
| 364 |
+
# Видео/аудио при исчерпанном Gemini отказываем до скачивания: поздняя
|
| 365 |
+
# проверка после маршрута уже скачала бы файл и пожгла транскрибацию.
|
| 366 |
+
# Картинки пропускаем — их отдаст Groq/OpenRouter через поздний фолбэк.
|
| 367 |
+
if bot._user_daily_gemini_exhausted(uid_early, _early_entry) and _direct_attachment_needs_gemini(message):
|
| 368 |
+
bot._record_stats_event("daily_limit_denials")
|
| 369 |
+
await bot._safe_reply(message, bot._t(
|
| 370 |
+
message.chat.id, "user_daily_gemini",
|
| 371 |
+
used=(_early_entry or {}).get("gemini", 0), limit=bot._user_daily_limit(uid_early, "gemini", _early_entry),
|
| 372 |
+
))
|
| 373 |
+
return
|
| 374 |
+
|
| 375 |
state = bot.get_state(message.chat.id)
|
| 376 |
|
| 377 |
# Проверка на ссылки загрузки (TikTok — сразу всегда, даже в группах без упоминания)
|
tests/test_bot_commands.py
CHANGED
|
@@ -286,8 +286,31 @@ def test_cmd_stats_truncation_keeps_html_valid(monkeypatch):
|
|
| 286 |
lcs.chat_state.pop(chat_id, None)
|
| 287 |
|
| 288 |
|
| 289 |
-
def
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
|
| 293 |
def test_match_trigger_prefix_finds_tts_trigger():
|
|
|
|
| 286 |
lcs.chat_state.pop(chat_id, None)
|
| 287 |
|
| 288 |
|
| 289 |
+
def test_build_version_caches_git_call(monkeypatch):
|
| 290 |
+
# Защищает кеш версии: git-вызов блокирует loop до 5с, без кеша каждый
|
| 291 |
+
# /stats стопает бота. Регрессия — прямой subprocess при каждом вызове.
|
| 292 |
+
import lumen_commands as lc
|
| 293 |
+
monkeypatch.setattr(lc, "_BUILD_VERSION_CACHED", None)
|
| 294 |
+
calls = []
|
| 295 |
+
|
| 296 |
+
class _Proc:
|
| 297 |
+
returncode = 0
|
| 298 |
+
stdout = "abc1234\n"
|
| 299 |
+
|
| 300 |
+
def fake_run(*args, **kwargs):
|
| 301 |
+
calls.append(1)
|
| 302 |
+
return _Proc()
|
| 303 |
+
|
| 304 |
+
monkeypatch.setattr(lc.subprocess, "run", fake_run)
|
| 305 |
+
try:
|
| 306 |
+
assert lc._build_version() == "abc1234"
|
| 307 |
+
assert lc._build_version() == "abc1234"
|
| 308 |
+
assert len(calls) == 1
|
| 309 |
+
finally:
|
| 310 |
+
monkeypatch.setattr(lc, "_BUILD_VERSION_CACHED", None)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def test_match_trigger_prefix_finds_draw_trigger(): assert bot._match_trigger_prefix("нарисуй кота на пляже", bot.DRAW_TRIGGER_PREFIXES) == "нарисуй"
|
| 314 |
|
| 315 |
|
| 316 |
def test_match_trigger_prefix_finds_tts_trigger():
|
tests/test_bot_state.py
CHANGED
|
@@ -2874,3 +2874,36 @@ def test_core_refuses_youtube_when_gemini_exhausted(rate_guard_setup, monkeypatc
|
|
| 2874 |
finally:
|
| 2875 |
bot.GLOBAL_QUOTA.get("user_daily", {}).pop("456", None)
|
| 2876 |
bot.chat_state.pop(123, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2874 |
finally:
|
| 2875 |
bot.GLOBAL_QUOTA.get("user_daily", {}).pop("456", None)
|
| 2876 |
bot.chat_state.pop(123, None)
|
| 2877 |
+
|
| 2878 |
+
|
| 2879 |
+
def test_core_refuses_video_before_download_when_gemini_exhausted(rate_guard_setup, monkeypatch):
|
| 2880 |
+
# Защищает ранний отказ для видео/аудио: поздняя проверка скачала бы файл
|
| 2881 |
+
# и пожгла транскрибацию перед отказом. Регрессия — возврат проверки под
|
| 2882 |
+
# скачивание. Старые тесты гоняют только текстовые/YouTube-отказы.
|
| 2883 |
+
from types import SimpleNamespace as _NS
|
| 2884 |
+
message = rate_guard_setup()
|
| 2885 |
+
message.text = "смотри"
|
| 2886 |
+
message.video = _NS(file_id="vid123")
|
| 2887 |
+
monkeypatch.setattr(bot, "DAILY_USER_GEMINI_LIMIT", 1)
|
| 2888 |
+
bot._record_user_daily(456, gemini=True)
|
| 2889 |
+
replies = []
|
| 2890 |
+
|
| 2891 |
+
async def fake_safe_reply(msg, text, **kwargs):
|
| 2892 |
+
replies.append(text)
|
| 2893 |
+
|
| 2894 |
+
async def fail_download(*args, **kwargs):
|
| 2895 |
+
raise AssertionError("exhausted user media must not be downloaded")
|
| 2896 |
+
|
| 2897 |
+
async def fail_route(*args, **kwargs):
|
| 2898 |
+
raise AssertionError("gemini-only request must not reach models")
|
| 2899 |
+
|
| 2900 |
+
monkeypatch.setattr(bot, "_safe_reply", fake_safe_reply)
|
| 2901 |
+
monkeypatch.setattr(bot, "_run_route", fail_route)
|
| 2902 |
+
monkeypatch.setattr(bot, "_download_message_attachment_to_tmp", fail_download)
|
| 2903 |
+
monkeypatch.setattr(bot, "_fetch_media", fail_download)
|
| 2904 |
+
try:
|
| 2905 |
+
asyncio.run(bot._handle_message_core(message))
|
| 2906 |
+
assert len(replies) == 1 and "1/1" in replies[0]
|
| 2907 |
+
finally:
|
| 2908 |
+
bot.GLOBAL_QUOTA.get("user_daily", {}).pop("456", None)
|
| 2909 |
+
bot.chat_state.pop(123, None)
|