SilverElixir commited on
Commit
22d7bdd
·
1 Parent(s): 87259c9

Refuse gemini-only media early, cache build version

Browse files
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 _webhook_info_text()
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
- return raw[:12]
 
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
- return sha[:12]
 
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 test_match_trigger_prefix_finds_draw_trigger():
290
- assert bot._match_trigger_prefix("нарисуй кота на пляже", bot.DRAW_TRIGGER_PREFIXES) == "нарисуй"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)