SilverElixir commited on
Commit
e6073ca
·
1 Parent(s): e980e7c

Harden identity/output filters, quotas, history and deploy guards after the security audit

Browse files
.github/workflows/deploy-proxy.yml CHANGED
@@ -31,9 +31,10 @@ concurrency:
31
  jobs:
32
  deploy:
33
  # Авто: только пуши в ЭТОТ репозиторий с зелёным CI (та же защита от форков, что в sync-to-hf.yml).
34
- # Вручную: всегда (для ручного запуска и нужен отдельный пункт).
 
35
  if: >-
36
- github.event_name == 'workflow_dispatch' ||
37
  (github.event.workflow_run.conclusion == 'success'
38
  && github.event.workflow_run.event == 'push'
39
  && github.event.workflow_run.head_repository.full_name == github.repository)
 
31
  jobs:
32
  deploy:
33
  # Авто: только пуши в ЭТОТ репозиторий с зелёным CI (та же защита от форков, что в sync-to-hf.yml).
34
+ # Вручную: только main — иначе ветка с изменённым proxy.ts получала бы
35
+ # production OIDC и могла читать токен бота (аудит 28.10.2026).
36
  if: >-
37
+ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
38
  (github.event.workflow_run.conclusion == 'success'
39
  && github.event.workflow_run.event == 'push'
40
  && github.event.workflow_run.head_repository.full_name == github.repository)
.github/workflows/persona-eval.yml CHANGED
@@ -38,6 +38,9 @@ concurrency:
38
 
39
  jobs:
40
  eval:
 
 
 
41
  runs-on: ubuntu-latest
42
  timeout-minutes: 120
43
  steps:
 
38
 
39
  jobs:
40
  eval:
41
+ # Секреты провайдеров не должны достаться ветке с чужим eval-скриптом:
42
+ # ручной запуск разрешён только с main (аудит 28.10.2026).
43
+ if: github.ref == 'refs/heads/main'
44
  runs-on: ubuntu-latest
45
  timeout-minutes: 120
46
  steps:
.github/workflows/sync-to-hf.yml CHANGED
@@ -47,7 +47,16 @@ jobs:
47
  run: |
48
  set -euo pipefail
49
  git remote add hf "https://user:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE_REPO}"
50
- # Lease вместо голого force: хотфикс, залитый напрямую на Space,
51
- # честно уронит push вместо молчаливого отката.
52
  git fetch hf main || true
53
- git push hf HEAD:main --force-with-lease
 
 
 
 
 
 
 
 
 
 
 
 
47
  run: |
48
  set -euo pipefail
49
  git remote add hf "https://user:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE_REPO}"
 
 
50
  git fetch hf main || true
51
+ # Хотфикс, залитый напрямую на Space, нельзя молча перезаписать:
52
+ # сначала проверяем, что удалённая ветка — предок деплоимого коммита,
53
+ # и только потом пушим с lease на конкретный SHA (аудит 28.10.2026).
54
+ if git rev-parse --verify -q hf/main >/dev/null; then
55
+ if ! git merge-base --is-ancestor hf/main HEAD; then
56
+ echo "HF Space has commits not in this deploy branch (hotfix?) — refusing to overwrite." >&2
57
+ exit 1
58
+ fi
59
+ git push hf HEAD:main --force-with-lease=main:"$(git rev-parse hf/main)"
60
+ else
61
+ git push hf HEAD:main
62
+ fi
bot.py CHANGED
@@ -322,6 +322,11 @@ if SENTRY_DSN:
322
  # Только трекинг ошибок (трейсинг выключен — бережём квоту Sentry 5000 событий/мес).
323
  traces_sample_rate=0.0,
324
  send_default_pii=False,
 
 
 
 
 
325
  )
326
  log.info('[setup] Sentry error tracking enabled.')
327
 
@@ -441,6 +446,11 @@ DRAW_TOTAL_BUDGET_SEC = _env_number("DRAW_TOTAL_BUDGET_SEC", 120, min_value=1)
441
  # озвучка — до конца синтеза. Это осознанно (один чат — одно дело за раз), но
442
  # второй вопрос во время длинной отрисовки честно получит «занято» через 45с.
443
  CHAT_LOCK_TIMEOUT_SEC = _env_number("CHAT_LOCK_TIMEOUT_SEC", 45, min_value=1)
 
 
 
 
 
444
  # INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC — сколько main() при остановке ждёт штатного
445
  # завершения fire-and-forget задач перед отменой остатка (Sentry LUMEN-2: event loop убивал их посреди сетевых вызовов при редеплое).
446
  INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC = _env_number("INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC", 10, min_value=0)
@@ -544,6 +554,8 @@ from lumen_chat_state import (
544
  _user_daily_gemini_exhausted,
545
  _user_daily_tts_exhausted,
546
  _record_user_daily,
 
 
547
  _user_key_for_message,
548
  _user_daily_reset_in,
549
  _trim_history,
@@ -924,6 +936,8 @@ __all__ = [
924
  "_user_daily_gemini_exhausted",
925
  "_user_daily_tts_exhausted",
926
  "_record_user_daily",
 
 
927
  "_user_key_for_message",
928
  "_user_daily_reset_in",
929
  # Имена остальных вынесенных модулей — только для `bot.X` в тестах.
@@ -1364,15 +1378,29 @@ async def _handle_message(message: Message) -> None:
1364
 
1365
  remaining = _msg_remaining_sec()
1366
  lock_timeout = CHAT_LOCK_TIMEOUT_SEC if remaining is None else max(1.0, min(CHAT_LOCK_TIMEOUT_SEC, remaining))
1367
- try:
1368
- lock = await acquire_chat_lock(chat_id, lock_timeout)
1369
- except asyncio.TimeoutError:
1370
- log.warning("[lock] Timeout waiting for lock on chat %s", chat_id)
1371
  with contextlib.suppress(Exception):
1372
- # Ответ без создания чата: обычный _t через get_state завёл бы запись
1373
- # даже тому, кого отшили «занято» (та же причина, что у лимита).
1374
- await _tg_call(message.reply, _t_no_create(chat_id, "lock_busy"))
1375
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1376
 
1377
  try:
1378
  await _handle_message_core(message)
 
322
  # Только трекинг ошибок (трейсинг выключен — бережём квоту Sentry 5000 событий/мес).
323
  traces_sample_rate=0.0,
324
  send_default_pii=False,
325
+ # Локальные переменные и тела запросов содержат промпты и историю:
326
+ # before_send чистит только точные секреты, поэтому не отправляем их вовсе
327
+ # (аудит 28.10.2026).
328
+ include_local_variables=False,
329
+ max_request_body_size="never",
330
  )
331
  log.info('[setup] Sentry error tracking enabled.')
332
 
 
446
  # озвучка — до конца синтеза. Это осознанно (один чат — одно дело за раз), но
447
  # второй вопрос во время длинной отрисовки честно получит «занято» через 45с.
448
  CHAT_LOCK_TIMEOUT_SEC = _env_number("CHAT_LOCK_TIMEOUT_SEC", 45, min_value=1)
449
+ # Сколько сообщений одного чата могут ЖДАТЬ лок одновременно. Раньше сотня
450
+ # сообщений занимала все 100 in-flight слотов, и другие чаты получали 503
451
+ # (аудит 28.10.2026). Четвёртый и следующие сразу получают «занято».
452
+ _MAX_CHAT_LOCK_WAITERS = 3
453
+ _chat_lock_waiters: dict[int, int] = {}
454
  # INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC — сколько main() при остановке ждёт штатного
455
  # завершения fire-and-forget задач перед отменой остатка (Sentry LUMEN-2: event loop убивал их посреди сетевых вызовов при редеплое).
456
  INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC = _env_number("INFLIGHT_TASKS_SHUTDOWN_TIMEOUT_SEC", 10, min_value=0)
 
554
  _user_daily_gemini_exhausted,
555
  _user_daily_tts_exhausted,
556
  _record_user_daily,
557
+ _reserve_user_daily,
558
+ _refund_user_daily,
559
  _user_key_for_message,
560
  _user_daily_reset_in,
561
  _trim_history,
 
936
  "_user_daily_gemini_exhausted",
937
  "_user_daily_tts_exhausted",
938
  "_record_user_daily",
939
+ "_reserve_user_daily",
940
+ "_refund_user_daily",
941
  "_user_key_for_message",
942
  "_user_daily_reset_in",
943
  # Имена остальных вынесенных модулей — только для `bot.X` в тестах.
 
1378
 
1379
  remaining = _msg_remaining_sec()
1380
  lock_timeout = CHAT_LOCK_TIMEOUT_SEC if remaining is None else max(1.0, min(CHAT_LOCK_TIMEOUT_SEC, remaining))
1381
+ waiters = _chat_lock_waiters.get(chat_id, 0)
1382
+ if waiters >= _MAX_CHAT_LOCK_WAITERS:
1383
+ log.warning("[lock] Too many waiters for chat %s", chat_id)
 
1384
  with contextlib.suppress(Exception):
1385
+ await _tg_call(message.reply, _t_no_create(chat_id, "lock_busy"))
 
 
1386
  return
1387
+ _chat_lock_waiters[chat_id] = waiters + 1
1388
+ try:
1389
+ try:
1390
+ lock = await acquire_chat_lock(chat_id, lock_timeout)
1391
+ except asyncio.TimeoutError:
1392
+ log.warning("[lock] Timeout waiting for lock on chat %s", chat_id)
1393
+ with contextlib.suppress(Exception):
1394
+ # Ответ без создания чата: обычный _t через get_state завёл бы запись
1395
+ # даже тому, кого отшили «занято» (та же причина, что у лимита).
1396
+ await _tg_call(message.reply, _t_no_create(chat_id, "lock_busy"))
1397
+ return
1398
+ finally:
1399
+ left = _chat_lock_waiters.get(chat_id, 1) - 1
1400
+ if left > 0:
1401
+ _chat_lock_waiters[chat_id] = left
1402
+ else:
1403
+ _chat_lock_waiters.pop(chat_id, None)
1404
 
1405
  try:
1406
  await _handle_message_core(message)
lumen_chat_state.py CHANGED
@@ -1278,6 +1278,41 @@ def _record_user_daily(user_id: int | None, *, gemini: bool = False, tts: bool =
1278
  bot.mark_quota_dirty()
1279
 
1280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1281
  def _user_key_for_message(message: Any) -> int | None:
1282
  """user_id автора или ближайший устойчивый идентификатор (как у rate limit)."""
1283
  if message is None:
 
1278
  bot.mark_quota_dirty()
1279
 
1280
 
1281
+ def _reserve_user_daily(user_id: int | None, *, total: int = 0, tts: int = 0) -> bool:
1282
+ """Атомарно резервирует счётчики ДО первого await: параллельные /tts иначе
1283
+ читали 0 и обходили дневной лимит (аудит 28.10.2026). Возвращает False,
1284
+ если резерв не влезает; счётчики в этом случае не меняются."""
1285
+ import bot
1286
+ if user_id is None or bot._is_owner(user_id):
1287
+ return True
1288
+ entry = _user_daily_entry(user_id)
1289
+ used_total = int(entry.get("total") or 0)
1290
+ used_tts = int(entry.get("tts") or 0)
1291
+ if used_total + total > _user_daily_limit(user_id, "total", entry):
1292
+ return False
1293
+ if used_tts + tts > _user_daily_limit(user_id, "tts", entry):
1294
+ return False
1295
+ entry["total"] = used_total + total
1296
+ if tts:
1297
+ entry["tts"] = used_tts + tts
1298
+ bot.mark_quota_dirty()
1299
+ return True
1300
+
1301
+
1302
+ def _refund_user_daily(user_id: int | None, *, total: int = 0, tts: int = 0) -> None:
1303
+ """Возвращает неиспользованный резерв (синтез/отправка не удались)."""
1304
+ import bot
1305
+ if user_id is None or bot._is_owner(user_id):
1306
+ return
1307
+ if not total and not tts:
1308
+ return
1309
+ entry = _user_daily_entry(user_id)
1310
+ entry["total"] = max(0, int(entry.get("total") or 0) - total)
1311
+ if tts:
1312
+ entry["tts"] = max(0, int(entry.get("tts") or 0) - tts)
1313
+ bot.mark_quota_dirty()
1314
+
1315
+
1316
  def _user_key_for_message(message: Any) -> int | None:
1317
  """user_id автора или ближайший устойчивый идентификатор (как у rate limit)."""
1318
  if message is None:
lumen_commands.py CHANGED
@@ -348,28 +348,33 @@ async def inline_tts(message: Message, text: str) -> None:
348
  # Считаем сразу все чанки: каждый — отдельный синтез, иначе длинный текст
349
  # уводил бы счётчик за лимит.
350
  uid = bot._user_key_for_message(message)
351
- _tts_entry = bot._user_daily_peek(uid)
352
- try:
353
- _tts_over_total = int((_tts_entry or {}).get("total") or 0) + len(chunks) > bot._user_daily_limit(uid, "total", _tts_entry)
354
- _tts_over_tts = int((_tts_entry or {}).get("tts") or 0) + len(chunks) > bot._user_daily_limit(uid, "tts", _tts_entry)
355
- except (TypeError, ValueError):
356
- _tts_over_total = _tts_over_tts = False
357
- if bot._user_daily_total_exhausted(uid, _tts_entry) or _tts_over_total:
358
- hours, mins = bot._user_daily_reset_in()
359
- # Суточный счётчик /stats: отказ по лимиту, озвучки не будет.
360
- bot._record_stats_event("daily_limit_denials")
361
- await bot._safe_reply(message, bot._t(
362
- message.chat.id, "user_daily_total",
363
- used=(_tts_entry or {}).get("total", 0), limit=bot._user_daily_limit(uid, "total", _tts_entry),
364
- hours=hours, mins=mins,
365
- ))
366
- return
367
- if bot._user_daily_tts_exhausted(uid, _tts_entry) or _tts_over_tts:
368
- bot._record_stats_event("daily_limit_denials")
369
- await bot._safe_reply(message, bot._t(
370
- message.chat.id, "user_daily_tts",
371
- used=(_tts_entry or {}).get("tts", 0), limit=bot._user_daily_limit(uid, "tts", _tts_entry),
372
- ))
 
 
 
 
 
373
  return
374
  status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_voicing"))
375
  # Общий дедлайн на все чанки: зависший синтез иначе держал per-chat lock без края.
@@ -404,6 +409,7 @@ async def inline_tts(message: Message, text: str) -> None:
404
  # Статус уже снесён выше (перед send_voice): если правка не прошла — дублируем реплаем.
405
  if not await bot._edit_message_quietly(status, user_err):
406
  await bot._safe_reply(message, user_err)
 
407
  return
408
  await bot._delete_message_quietly(status)
409
  for i, (final_audio, final_filename, voice_duration) in enumerate(voices):
@@ -418,9 +424,13 @@ async def inline_tts(message: Message, text: str) -> None:
418
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
419
  )
420
  if sent is None:
 
 
421
  raise RuntimeError("Telegram send_voice failed: connection timeout or proxy unavailable")
422
- # Каждый чанк — отдельный синтез за квоту: списываем поштучно.
423
- bot._record_user_daily(uid, tts=True)
 
 
424
  bot._record_stats_event("answers_sent")
425
  if shortened:
426
  # Обрезка по общему дедлайну — говорим прямо, что озвучено начало.
@@ -454,6 +464,10 @@ async def cmd_reset(message: Message) -> None:
454
  state = bot.get_state(message.chat.id)
455
  state["history"] = []
456
  state["ctx"].clear()
 
 
 
 
457
  bot.mark_state_dirty(message.chat.id)
458
  await bot._tg_call(
459
  message.reply,
 
348
  # Считаем сразу все чанки: каждый — отдельный синтез, иначе длинный текст
349
  # уводил бы счётчик за лимит.
350
  uid = bot._user_key_for_message(message)
351
+ # Атомарный резерв ДО первого await: параллельные /tts раньше читали 0
352
+ # и обходили дневной лимит (аудит 28.10.2026). Резерв списывает сразу все
353
+ # чанки, потому что каждый — отдельный синтез за квоту.
354
+ if not bot._reserve_user_daily(uid, total=len(chunks), tts=len(chunks)):
355
+ _tts_entry = bot._user_daily_peek(uid)
356
+ try:
357
+ _tts_over_total = int((_tts_entry or {}).get("total") or 0) + len(chunks) > bot._user_daily_limit(uid, "total", _tts_entry)
358
+ _tts_over_tts = int((_tts_entry or {}).get("tts") or 0) + len(chunks) > bot._user_daily_limit(uid, "tts", _tts_entry)
359
+ except (TypeError, ValueError):
360
+ _tts_over_total = _tts_over_tts = False
361
+ if bot._user_daily_total_exhausted(uid, _tts_entry) or _tts_over_total:
362
+ hours, mins = bot._user_daily_reset_in()
363
+ # Суточный счётчик /stats: отказ по лимиту, озвучки не будет.
364
+ bot._record_stats_event("daily_limit_denials")
365
+ await bot._safe_reply(message, bot._t(
366
+ message.chat.id, "user_daily_total",
367
+ used=(_tts_entry or {}).get("total", 0), limit=bot._user_daily_limit(uid, "total", _tts_entry),
368
+ hours=hours, mins=mins,
369
+ ))
370
+ return
371
+ if bot._user_daily_tts_exhausted(uid, _tts_entry) or _tts_over_tts:
372
+ bot._record_stats_event("daily_limit_denials")
373
+ await bot._safe_reply(message, bot._t(
374
+ message.chat.id, "user_daily_tts",
375
+ used=(_tts_entry or {}).get("tts", 0), limit=bot._user_daily_limit(uid, "tts", _tts_entry),
376
+ ))
377
+ return
378
  return
379
  status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_voicing"))
380
  # Общий дедлайн на все чанки: зависший синтез иначе держал per-chat lock без края.
 
409
  # Статус уже снесён выше (перед send_voice): если правка не прошла — дублируем реплаем.
410
  if not await bot._edit_message_quietly(status, user_err):
411
  await bot._safe_reply(message, user_err)
412
+ bot._refund_user_daily(uid, total=len(chunks), tts=len(chunks))
413
  return
414
  await bot._delete_message_quietly(status)
415
  for i, (final_audio, final_filename, voice_duration) in enumerate(voices):
 
424
  call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT,
425
  )
426
  if sent is None:
427
+ # Неотправленные чанки возвращаем в лимит: резерв списал их заранее.
428
+ bot._refund_user_daily(uid, total=len(chunks) - i, tts=len(chunks) - i)
429
  raise RuntimeError("Telegram send_voice failed: connection timeout or proxy unavailable")
430
+ unsent = len(chunks) - len(voices)
431
+ if unsent > 0:
432
+ # Обрезка по общему дедлайну: за несозданные чанки не списываем.
433
+ bot._refund_user_daily(uid, total=unsent, tts=unsent)
434
  bot._record_stats_event("answers_sent")
435
  if shortened:
436
  # Обрезка по общему дедлайну — говорим прямо, что озвучено начало.
 
464
  state = bot.get_state(message.chat.id)
465
  state["history"] = []
466
  state["ctx"].clear()
467
+ # Сброс должен забывать и ссылки на медиа/имена: иначе старые файлы
468
+ # всплывали после /reset (аудит 28.10.2026).
469
+ state.get("recent_media_ids", {}).clear()
470
+ state.get("user_names", {}).clear()
471
  bot.mark_state_dirty(message.chat.id)
472
  await bot._tg_call(
473
  message.reply,
lumen_formatting.py CHANGED
@@ -6,6 +6,24 @@ lumen_formatting.py — markdown Lumen в Telegram HTML.
6
  from __future__ import annotations
7
 
8
  import re
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  _TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
11
  # Регекс квадратичен на длинных пробельных хвостах: строку режем до match,
@@ -414,6 +432,7 @@ def _md_to_html(text: str) -> str:
414
  continue
415
  m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL)
416
  label, url = m.group(1), m.group(2)
 
417
  label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
418
  url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
419
  text = text.replace(key, f'<a href="{url}">{label}</a>')
@@ -597,6 +616,7 @@ def _md_to_rich_html(text: str) -> str:
597
  for key, orig in _links.items():
598
  m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL)
599
  label, url = m.group(1), m.group(2)
 
600
  label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
601
  url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
602
  text = text.replace(key, f'<a href="{url}">{label}</a>')
 
6
  from __future__ import annotations
7
 
8
  import re
9
+ from urllib.parse import urlparse
10
+
11
+ _URL_LABEL_RE = re.compile(r"^(?:https?://|www\.)", re.IGNORECASE)
12
+
13
+
14
+ def _safe_link_label(label: str, url: str) -> str:
15
+ """Метка-URL, ведущая на другой хост, — готовый фишинг: показываем реальный
16
+ адрес. Обычные текстовые метки ("Reuters") не трогаем (аудит 28.10.2026)."""
17
+ if not _URL_LABEL_RE.match(label.strip()):
18
+ return label
19
+ try:
20
+ label_host = urlparse(label if "://" in label else "https://" + label).netloc.lower()
21
+ url_host = urlparse(url).netloc.lower()
22
+ except ValueError:
23
+ return label
24
+ if label_host and url_host and label_host != url_host:
25
+ return url
26
+ return label
27
 
28
  _TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
29
  # Регекс квадратичен на длинных пробельных хвостах: строку режем до match,
 
432
  continue
433
  m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL)
434
  label, url = m.group(1), m.group(2)
435
+ label = _safe_link_label(label, url)
436
  label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
437
  url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
438
  text = text.replace(key, f'<a href="{url}">{label}</a>')
 
616
  for key, orig in _links.items():
617
  m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL)
618
  label, url = m.group(1), m.group(2)
619
+ label = _safe_link_label(label, url)
620
  label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
621
  url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
622
  text = text.replace(key, f'<a href="{url}">{label}</a>')
lumen_message_core.py CHANGED
@@ -228,6 +228,10 @@ def _album_extra_sources(messages: list, main_msg: Any) -> list[tuple[str, str,
228
  return sources
229
 
230
 
 
 
 
 
231
  def _record_passive_group_context(message: Message, state: dict[str, Any], t: str) -> None:
232
  """Фон группы без упоминания: только контекст и медиа в state, без ответа."""
233
  import bot
@@ -239,9 +243,13 @@ def _record_passive_group_context(message: Message, state: dict[str, Any], t: st
239
  getattr(sender, "username", None) if sender is not None else None,
240
  getattr(sender, "first_name", None) if sender is not None else None,
241
  )
242
- state["ctx"].append(f"{display}: {t.strip()}")
243
  # Общий лимит 100 сообщений на чат, старые фоновые уходят первыми.
244
  bot._trim_combined(state)
 
 
 
 
245
  bot._save_media_to_history(_msg_media_source(message), state, message.from_user.id if message.from_user else None)
246
  bot.mark_state_dirty(message.chat.id)
247
 
@@ -831,6 +839,7 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
831
  media=gemini_media_list, media_filename=med_name,
832
  youtube_url=youtube_url_to_analyze, allow_stream=allow_stream,
833
  deadline=route_deadline, provenance=provenance, status=status,
 
834
  )
835
  answered_grounded = answered_with_live_search(provenance)
836
  if needs_freshness and not snippets_injected and not answered_grounded and not reply_already_sent and not no_search_note:
 
228
  return sources
229
 
230
 
231
+ _PASSIVE_LINE_MAX_CHARS = 500
232
+ _PASSIVE_CTX_MAX_CHARS = 12000
233
+
234
+
235
  def _record_passive_group_context(message: Message, state: dict[str, Any], t: str) -> None:
236
  """Фон группы без упоминания: только контекст и медиа в state, без ответа."""
237
  import bot
 
243
  getattr(sender, "username", None) if sender is not None else None,
244
  getattr(sender, "first_name", None) if sender is not None else None,
245
  )
246
+ state["ctx"].append(f"{display}: {t.strip()[:_PASSIVE_LINE_MAX_CHARS]}")
247
  # Общий лимит 100 сообщений на чат, старые фоновые уходят первыми.
248
  bot._trim_combined(state)
249
+ # Кап по символам: 100 сообщений по 4096 символов раздували промпт
250
+ # и позволяли одному участнику забить чужой контекст (аудит 28.10.2026).
251
+ while len(state["ctx"]) > 1 and sum(len(line) for line in state["ctx"]) > _PASSIVE_CTX_MAX_CHARS:
252
+ state["ctx"].popleft()
253
  bot._save_media_to_history(_msg_media_source(message), state, message.from_user.id if message.from_user else None)
254
  bot.mark_state_dirty(message.chat.id)
255
 
 
839
  media=gemini_media_list, media_filename=med_name,
840
  youtube_url=youtube_url_to_analyze, allow_stream=allow_stream,
841
  deadline=route_deadline, provenance=provenance, status=status,
842
+ history_text=clean_prompt,
843
  )
844
  answered_grounded = answered_with_live_search(provenance)
845
  if needs_freshness and not snippets_injected and not answered_grounded and not reply_already_sent and not no_search_note:
lumen_routes.py CHANGED
@@ -348,7 +348,7 @@ async def _or_chat_completion_with_fallback(
348
  stop_on=_account_wide, log_label="or", chat_id=chat_id, user_text=user_text,
349
  )
350
 
351
- async def ask_openrouter_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None) -> str:
352
  import bot
353
  state = bot.get_state(chat_id)
354
  history = state.setdefault("history", [])
@@ -361,7 +361,7 @@ async def ask_openrouter_text(chat_id: int, user_text: str, model_chain: list[st
361
  answer, model_trial = await bot._or_chat_completion_with_fallback(messages, trial_models, primary_model_id, deadline=deadline, chat_id=chat_id, user_text=user_text)
362
 
363
  # В историю пишем текст с автором — модель видит кто спрашивал.
364
- history.append({"role": "user", "content": _history_user_text(user_text)})
365
  history.append({"role": "assistant", "content": answer})
366
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
367
  await bot._trim_history(history)
@@ -403,7 +403,7 @@ def _trim_messages_for_groq(messages: list[dict]) -> list[dict]:
403
  kept.reverse()
404
  return [head] + kept + ([tail] if len(messages) > 1 else [])
405
 
406
- async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None) -> str:
407
  """Обычный текстовый запрос через прямой Groq — по образцу ask_openrouter_text: одна попытка на модель, скраб утечек, расход в квоту "groq". Сообщения строит общий _build_openrouter_turn_messages (тот же OpenAI-формат). Историю под запрос режет _trim_messages_for_groq (иначе см. его комментарий)."""
408
  import bot
409
  from lumen_router_config import _GROQ_LIGHT_ORDER
@@ -427,7 +427,7 @@ async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *,
427
  )
428
 
429
  # В историю пишем текст пользователя с автором, как у остальных провайдеров.
430
- history.append({"role": "user", "content": _history_user_text(user_text)})
431
  history.append({"role": "assistant", "content": answer})
432
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
433
  await bot._trim_history(history)
@@ -562,7 +562,7 @@ async def _transcribe_audio(audio_bytes: bytes, mime: str, chat_id: int, deadlin
562
 
563
  async def ask_openrouter_multimodal(
564
  chat_id: int, user_text: str, media_tuple: tuple[bytes, str] | list[tuple[bytes, str]], media_filename: str,
565
- model_chain: list[str], *, deadline: float | None = None,
566
  ) -> str:
567
  """Альбом для OpenRouter: все картинки одним сообщением (content-блоки image_url подряд) —
568
  раньше уходила только первая, остальные 2–9 молча терялись (найдено внешним аудитом)."""
@@ -604,7 +604,7 @@ async def ask_openrouter_multimodal(
604
 
605
  answer, model_trial = await bot._or_chat_completion_with_fallback(messages, trial_models, primary_model_id, deadline=deadline, chat_id=chat_id, user_text=user_text)
606
 
607
- history.append({"role": "user", "content": _history_user_text(user_text)})
608
  history.append({"role": "assistant", "content": answer})
609
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
610
  await bot._trim_history(history)
@@ -800,6 +800,7 @@ async def ask_gemini(
800
  chat_id: int, user_text: str, media: list[tuple[bytes, str]] | None = None,
801
  youtube_url: str | None = None, model_chain: list[str] | None = None,
802
  deadline: float | None = None, provenance: dict | None = None,
 
803
  ) -> str:
804
  import bot
805
  state = bot.get_state(chat_id)
@@ -926,7 +927,7 @@ async def ask_gemini(
926
  bot._record_model_outcome("gemini", curr_model_id,
927
  bad=_detect_garbled_mix(ans) and not _is_garbled_echo(ans, user_text))
928
 
929
- hist.append({"role": "user", "content": _history_user_text(user_text)})
930
  hist.append({"role": "assistant", "content": ans})
931
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
932
  await bot._trim_history(hist)
@@ -1000,13 +1001,16 @@ async def _run_route(
1000
  media: list[tuple[bytes, str]] | None = None, media_filename: str = "",
1001
  youtube_url: str | None = None, allow_stream: bool = False,
1002
  deadline: float | None = None, provenance: dict | None = None,
1003
- status: Any | None = None,
1004
  ) -> tuple[str, bool]:
1005
  """Идёт по маршруту _build_route: внутри провайдера — по одной попытке на модель, при отказе всего провайдера — резервный. Возвращает (ответ, reply_already_sent)."""
1006
  import bot
1007
  if not route:
1008
  raise RuntimeError("Empty route — no model to choose from.")
1009
  route_started = time.monotonic()
 
 
 
1010
  # Порядок внутри провайдера — по измеренным задержкам; сами блоки и защита квоты Gemini не трогаем.
1011
  route = _reorder_route_by_speed(route)
1012
  if deadline is None:
@@ -1046,7 +1050,7 @@ async def _run_route(
1046
  head_model = route[0][1]
1047
  if first_provider == "gemini" and GEMINI_MODELS.get(head_model, {}).get("stream", True):
1048
  streamed, placeholder = await bot._try_gemini_streaming(
1049
- chat_id, ai_prompt, message, head_model, deadline=deadline, status=status)
1050
  if streamed is not None:
1051
  log.info('[router] chat=%s response received via streaming (gemini:%s)', chat_id, head_model)
1052
  _count_user_answer("gemini", head_model)
@@ -1056,7 +1060,7 @@ async def _run_route(
1056
  reusable_placeholder = placeholder
1057
  elif first_provider == "openrouter":
1058
  streamed, placeholder = await bot._try_openrouter_streaming(
1059
- chat_id, ai_prompt, message, head_model, deadline=deadline, status=status)
1060
  if streamed is not None:
1061
  log.info('[router] chat=%s response received via streaming (openrouter:%s)', chat_id, head_model)
1062
  _count_user_answer("openrouter")
@@ -1066,7 +1070,7 @@ async def _run_route(
1066
  reusable_placeholder = placeholder
1067
  elif first_provider == "groq":
1068
  streamed, placeholder = await bot._try_groq_streaming(
1069
- chat_id, ai_prompt, message, head_model, deadline=deadline, status=status)
1070
  if streamed is not None:
1071
  log.info('[router] chat=%s response received via streaming (groq:%s)', chat_id, head_model)
1072
  _count_user_answer("groq")
@@ -1092,15 +1096,15 @@ async def _run_route(
1092
  break
1093
  try:
1094
  if provider == "gemini":
1095
- ans = await bot.ask_gemini(chat_id, ai_prompt, media=media, youtube_url=youtube_url, model_chain=ids, deadline=deadline, provenance=provenance)
1096
  elif provider == "groq":
1097
- ans = await bot.ask_groq_text(chat_id, ai_prompt, model_chain=ids, deadline=deadline)
1098
  _note_answered("groq", None)
1099
  elif media:
1100
- ans = await bot.ask_openrouter_multimodal(chat_id, ai_prompt, media, media_filename, model_chain=ids, deadline=deadline)
1101
  _note_answered("openrouter", None)
1102
  else:
1103
- ans = await bot.ask_openrouter_text(chat_id, ai_prompt, model_chain=ids, deadline=deadline)
1104
  _note_answered("openrouter", None)
1105
 
1106
  if status is not None:
 
348
  stop_on=_account_wide, log_label="or", chat_id=chat_id, user_text=user_text,
349
  )
350
 
351
+ async def ask_openrouter_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None, history_text: str | None = None) -> str:
352
  import bot
353
  state = bot.get_state(chat_id)
354
  history = state.setdefault("history", [])
 
361
  answer, model_trial = await bot._or_chat_completion_with_fallback(messages, trial_models, primary_model_id, deadline=deadline, chat_id=chat_id, user_text=user_text)
362
 
363
  # В историю пишем текст с автором — модель видит кто спрашивал.
364
+ history.append({"role": "user", "content": _history_user_text(history_text or user_text)})
365
  history.append({"role": "assistant", "content": answer})
366
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
367
  await bot._trim_history(history)
 
403
  kept.reverse()
404
  return [head] + kept + ([tail] if len(messages) > 1 else [])
405
 
406
+ async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None, history_text: str | None = None) -> str:
407
  """Обычный текстовый запрос через прямой Groq — по образцу ask_openrouter_text: одна попытка на модель, скраб утечек, расход в квоту "groq". Сообщения строит общий _build_openrouter_turn_messages (тот же OpenAI-формат). Историю под запрос режет _trim_messages_for_groq (иначе см. его комментарий)."""
408
  import bot
409
  from lumen_router_config import _GROQ_LIGHT_ORDER
 
427
  )
428
 
429
  # В историю пишем текст пользователя с автором, как у остальных провайдеров.
430
+ history.append({"role": "user", "content": _history_user_text(history_text or user_text)})
431
  history.append({"role": "assistant", "content": answer})
432
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
433
  await bot._trim_history(history)
 
562
 
563
  async def ask_openrouter_multimodal(
564
  chat_id: int, user_text: str, media_tuple: tuple[bytes, str] | list[tuple[bytes, str]], media_filename: str,
565
+ model_chain: list[str], *, deadline: float | None = None, history_text: str | None = None,
566
  ) -> str:
567
  """Альбом для OpenRouter: все картинки одним сообщением (content-блоки image_url подряд) —
568
  раньше уходила только первая, остальные 2–9 молча терялись (найдено внешним аудитом)."""
 
604
 
605
  answer, model_trial = await bot._or_chat_completion_with_fallback(messages, trial_models, primary_model_id, deadline=deadline, chat_id=chat_id, user_text=user_text)
606
 
607
+ history.append({"role": "user", "content": _history_user_text(history_text or user_text)})
608
  history.append({"role": "assistant", "content": answer})
609
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
610
  await bot._trim_history(history)
 
800
  chat_id: int, user_text: str, media: list[tuple[bytes, str]] | None = None,
801
  youtube_url: str | None = None, model_chain: list[str] | None = None,
802
  deadline: float | None = None, provenance: dict | None = None,
803
+ history_text: str | None = None,
804
  ) -> str:
805
  import bot
806
  state = bot.get_state(chat_id)
 
927
  bot._record_model_outcome("gemini", curr_model_id,
928
  bad=_detect_garbled_mix(ans) and not _is_garbled_echo(ans, user_text))
929
 
930
+ hist.append({"role": "user", "content": _history_user_text(history_text or user_text)})
931
  hist.append({"role": "assistant", "content": ans})
932
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
933
  await bot._trim_history(hist)
 
1001
  media: list[tuple[bytes, str]] | None = None, media_filename: str = "",
1002
  youtube_url: str | None = None, allow_stream: bool = False,
1003
  deadline: float | None = None, provenance: dict | None = None,
1004
+ status: Any | None = None, history_text: str | None = None,
1005
  ) -> tuple[str, bool]:
1006
  """Идёт по маршруту _build_route: внутри провайдера — по одной попытке на модель, при отказе всего провайдера — резервный. Возвращает (ответ, reply_already_sent)."""
1007
  import bot
1008
  if not route:
1009
  raise RuntimeError("Empty route — no model to choose from.")
1010
  route_started = time.monotonic()
1011
+ # В историю пишем чистый запрос пользователя, а не ai_prompt со сниппетами
1012
+ # и датой: чужой текст не должен переживать ход (аудит 28.10.2026).
1013
+ history_text = history_text or ai_prompt
1014
  # Порядок внутри провайдера — по измеренным задержкам; сами блоки и защита квоты Gemini не трогаем.
1015
  route = _reorder_route_by_speed(route)
1016
  if deadline is None:
 
1050
  head_model = route[0][1]
1051
  if first_provider == "gemini" and GEMINI_MODELS.get(head_model, {}).get("stream", True):
1052
  streamed, placeholder = await bot._try_gemini_streaming(
1053
+ chat_id, ai_prompt, message, head_model, deadline=deadline, status=status, history_text=history_text)
1054
  if streamed is not None:
1055
  log.info('[router] chat=%s response received via streaming (gemini:%s)', chat_id, head_model)
1056
  _count_user_answer("gemini", head_model)
 
1060
  reusable_placeholder = placeholder
1061
  elif first_provider == "openrouter":
1062
  streamed, placeholder = await bot._try_openrouter_streaming(
1063
+ chat_id, ai_prompt, message, head_model, deadline=deadline, status=status, history_text=history_text)
1064
  if streamed is not None:
1065
  log.info('[router] chat=%s response received via streaming (openrouter:%s)', chat_id, head_model)
1066
  _count_user_answer("openrouter")
 
1070
  reusable_placeholder = placeholder
1071
  elif first_provider == "groq":
1072
  streamed, placeholder = await bot._try_groq_streaming(
1073
+ chat_id, ai_prompt, message, head_model, deadline=deadline, status=status, history_text=history_text)
1074
  if streamed is not None:
1075
  log.info('[router] chat=%s response received via streaming (groq:%s)', chat_id, head_model)
1076
  _count_user_answer("groq")
 
1096
  break
1097
  try:
1098
  if provider == "gemini":
1099
+ ans = await bot.ask_gemini(chat_id, ai_prompt, media=media, youtube_url=youtube_url, model_chain=ids, deadline=deadline, provenance=provenance, history_text=history_text)
1100
  elif provider == "groq":
1101
+ ans = await bot.ask_groq_text(chat_id, ai_prompt, model_chain=ids, deadline=deadline, history_text=history_text)
1102
  _note_answered("groq", None)
1103
  elif media:
1104
+ ans = await bot.ask_openrouter_multimodal(chat_id, ai_prompt, media, media_filename, model_chain=ids, deadline=deadline, history_text=history_text)
1105
  _note_answered("openrouter", None)
1106
  else:
1107
+ ans = await bot.ask_openrouter_text(chat_id, ai_prompt, model_chain=ids, deadline=deadline, history_text=history_text)
1108
  _note_answered("openrouter", None)
1109
 
1110
  if status is not None:
lumen_security.py CHANGED
@@ -44,6 +44,14 @@ def _leak_scan_window(full_text: str, latest_piece: str) -> str:
44
  window_size = max(_LEAK_SCAN_TAIL_CHARS, len(latest_piece) + 100)
45
  return full_text[-window_size:]
46
 
 
 
 
 
 
 
 
 
47
 
48
  # Слой Б — только точные фразы "я — бренд". Широкое окно рядом с "я" ложно ловило
49
  # рассказы про бренды: "я" — частый токен, а хеджирование вроде "я не могу
@@ -51,7 +59,7 @@ def _leak_scan_window(full_text: str, latest_piece: str) -> str:
51
  _LEAK_BRAND_TOKENS = (
52
  r"(gemini|gemma|gpt[\s\-]?(?:oss|\d)[\w\-]*|chatgpt|openai|claude|anthropic|deepmind|openrouter|"
53
  r"nemotron|qwen|llama|glm[\s\-]?4|hermes|mistral|dolphin[\s\-]?mistral|venice|laguna|"
54
- r"deepseek|grok|meta(?:\s*ai)?|google|"
55
  r"lfm[\s\-]?2\.5|нейросет\w*\s+google|модел\w*\s+google|google\s*ai|google\s+gemini)"
56
  )
57
  _IDENTITY_LEAK_RE = re.compile(
@@ -65,17 +73,26 @@ _IDENTITY_LEAK_RE = re.compile(
65
  # Google research" честные рассказы, не самоопределение (см. ниже проверку
66
  # по предложениям с _FIRST_PERSON_RE).
67
  rf"|\bэт[оауи]\s*(?:модел\w*|нейросет\w*)\s*(?:—|-|:)?\s*{_LEAK_BRAND_TOKENS}\b"
68
- rf"|\bi\s*(?:am|'m)\s+{_LEAK_BRAND_TOKENS}\b"
 
 
 
69
  # "built on / powered by / based on бренд" тоже только от первого лица,
70
  # иначе честный рассказ о чужом стеке блокировался целиком.
71
- rf"|\bi\s+(?:was\s+)?(?:created|made)\s+by\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
72
- rf"|\bi\s*(?:am|'m)\s+from\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
 
 
 
 
73
  rf"|\bmy\s+creators?\s+(?:is|are)\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
74
  # "я от Google" только как короткое самоопределение (дальше пунктуация/конец):
75
  # "я от Google узнал..." — честная фраза, а не утечка.
76
  rf"|\bя\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}(?=\s*[.!?…;:,]|$)"
77
  rf"|\bмо[йи]\s+создател\w*\s*(?:—|-|:)?\s*(?:компани\w*\s+)?{_LEAK_BRAND_TOKENS}\b"
78
  rf"|\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?(?:модел\w*|нейросет\w*)\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}\b"
 
 
79
  rf"|{_LEAK_BRAND_TOKENS}\s*,?\s*а\s+не\s+lumen\b",
80
  re.IGNORECASE,
81
  )
@@ -131,12 +148,15 @@ _INJECTED_PAYLOAD_ECHO_FALLBACK = (
131
  )
132
 
133
  def _detect_injected_payload_echo(text: str) -> bool:
134
- return bool(text) and bool(_INJECTED_PAYLOAD_ECHO_RE.search(text))
 
 
135
 
136
  def _detect_identity_leak(text: str) -> bool:
137
  """Чистая проверка без лога: дёшево вызывать на каждый кусок стрима, лог только в _scrub_identity_leak."""
138
  if not text:
139
  return False
 
140
  if _LEAK_LITERAL_RE is not None and _LEAK_LITERAL_RE.search(text):
141
  return True
142
  if _IDENTITY_LEAK_RE.search(text):
 
44
  window_size = max(_LEAK_SCAN_TAIL_CHARS, len(latest_piece) + 100)
45
  return full_text[-window_size:]
46
 
47
+ _INVISIBLE_RE = re.compile(r"[\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u206f]")
48
+
49
+ def _normalize_leak_text(text: str) -> str:
50
+ """NFKC + удаление невидимых символов: fullwidth и zero-width обходили
51
+ выходной фильтр (аудит 28.10.2026). Гомоглифы (кириллическая «а» в Gemini)
52
+ этим не покрываются — остаточный риск отмечен в отчёте."""
53
+ return _INVISIBLE_RE.sub("", unicodedata.normalize("NFKC", text))
54
+
55
 
56
  # Слой Б — только точные фразы "я — бренд". Широкое окно рядом с "я" ложно ловило
57
  # рассказы про бренды: "я" — частый токен, а хеджирование вроде "я не могу
 
59
  _LEAK_BRAND_TOKENS = (
60
  r"(gemini|gemma|gpt[\s\-]?(?:oss|\d)[\w\-]*|chatgpt|openai|claude|anthropic|deepmind|openrouter|"
61
  r"nemotron|qwen|llama|glm[\s\-]?4|hermes|mistral|dolphin[\s\-]?mistral|venice|laguna|"
62
+ r"deepseek|grok|meta(?:\s*ai)?|google|groq|hugging\s*face|huggingface|"
63
  r"lfm[\s\-]?2\.5|нейросет\w*\s+google|модел\w*\s+google|google\s*ai|google\s+gemini)"
64
  )
65
  _IDENTITY_LEAK_RE = re.compile(
 
73
  # Google research" честные рассказы, не самоопределение (см. ниже проверку
74
  # по предложениям с _FIRST_PERSON_RE).
75
  rf"|\bэт[оауи]\s*(?:модел\w*|нейросет\w*)\s*(?:—|-|:)?\s*{_LEAK_BRAND_TOKENS}\b"
76
+ # "I am a Gemini model": артикль перед брендом — обычная формулировка,
77
+ # которую старый узкий шаблон пропускал (аудит 28.10.2026). Слова между
78
+ # артиклем и брендом не разрешаем: "I am reading about Gemini" — не утечка.
79
+ rf"|\bi\s*(?:am|'m)\s+(?:an?\s+)?{_LEAK_BRAND_TOKENS}\b"
80
  # "built on / powered by / based on бренд" тоже только от первого лица,
81
  # иначе честный рассказ о чужом стеке блокировался целиком.
82
+ rf"|\bi\s*(?:am|'m|was|have\s+been|'ve\s+been)\s+(?:created|made|built|developed|trained|powered)\s+by\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
83
+ # То же с названием роли перед глаголом: "I am an AI model made by Google".
84
+ rf"|\bi\s*(?:am|'m)\s+(?:an?\s+)?(?:ai|language|large\s+language|llm|text|chat)\s+(?:model|assistant|chatbot|ai)\s+(?:created|made|built|developed|trained|powered)\s+by\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
85
+ # "I am a language model from Google" — роль обязательна, иначе ловился бы
86
+ # рассказ "I am reading a book by Google" (аудит 28.10.2026).
87
+ rf"|\bi\s*(?:am|'m)\s+(?:an?\s+)?(?:ai|language|large\s+language|llm|text|chat)\s+(?:model|assistant|chatbot|ai)\s+(?:from|by)\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
88
  rf"|\bmy\s+creators?\s+(?:is|are)\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
89
  # "я от Google" только как короткое самоопределение (дальше пунктуация/конец):
90
  # "я от Google узнал..." — честная фраза, а не утечка.
91
  rf"|\bя\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}(?=\s*[.!?…;:,]|$)"
92
  rf"|\bмо[йи]\s+создател\w*\s*(?:—|-|:)?\s*(?:компани\w*\s+)?{_LEAK_BRAND_TOKENS}\b"
93
  rf"|\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?(?:модел\w*|нейросет\w*)\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}\b"
94
+ # "Я — языковая модель Google": модель/ассистент + бренд без "от".
95
+ rf"|\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?(?:языков\w*\s+|ии[\s\-]?)?(?:модел\w*|нейросет\w*|ассистент\w*|бот\w*)\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}\b"
96
  rf"|{_LEAK_BRAND_TOKENS}\s*,?\s*а\s+не\s+lumen\b",
97
  re.IGNORECASE,
98
  )
 
148
  )
149
 
150
  def _detect_injected_payload_echo(text: str) -> bool:
151
+ if not text:
152
+ return False
153
+ return bool(_INJECTED_PAYLOAD_ECHO_RE.search(_normalize_leak_text(text)))
154
 
155
  def _detect_identity_leak(text: str) -> bool:
156
  """Чистая проверка без лога: дёшево вызывать на каждый кусок стрима, лог только в _scrub_identity_leak."""
157
  if not text:
158
  return False
159
+ text = _normalize_leak_text(text)
160
  if _LEAK_LITERAL_RE is not None and _LEAK_LITERAL_RE.search(text):
161
  return True
162
  if _IDENTITY_LEAK_RE.search(text):
lumen_streaming.py CHANGED
@@ -237,7 +237,7 @@ async def _groq_stream_pieces(model_id: str, messages: list[dict], *, deadline:
237
 
238
  async def _run_streaming_reply(
239
  chat_id: int, user_text: str, message: Message, *, provider: str, model_id: str, piece_agen,
240
- deadline: float | None = None, status=None,
241
  ) -> tuple[str | None, Message | None]:
242
  """Стриминг: генератор кусков + плейсхолдер, чанкинг, троттлинг, обрыв при утечке/инъекции, HTML-финал, запись в историю. Возвращает (ответ, плейсхолдер): успех — (текст, None); сбой до показа — (None, плейсхолдер для следующей модели); сбой после — (показанное + пометка, None)."""
243
  import bot
@@ -330,7 +330,7 @@ async def _run_streaming_reply(
330
  await aclose()
331
  final_answer = _IDENTITY_LEAK_FALLBACK if leak_kind == "identity" else _INJECTED_PAYLOAD_ECHO_FALLBACK
332
  await bot._tg_call(sent_messages[-1].edit_text, final_answer, parse_mode=None, call_timeout=15.0)
333
- hist.append({"role": "user", "content": _history_user_text(user_text)})
334
  hist.append({"role": "assistant", "content": final_answer})
335
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
336
  await bot._trim_history(hist)
@@ -353,7 +353,7 @@ async def _run_streaming_reply(
353
  with contextlib.suppress(Exception):
354
  await bot._tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]) + note, parse_mode=ParseMode.HTML, call_timeout=15.0)
355
  final_answer = full_text.strip()
356
- hist.append({"role": "user", "content": _history_user_text(user_text)})
357
  hist.append({"role": "assistant", "content": final_answer})
358
  # Обрезка с саммари (см. _trim_history): молчаливый срез оставлял
359
  # саммаризацию только нестриминговым путям (аудит 26.09.2026).
@@ -397,6 +397,9 @@ async def _run_streaming_reply(
397
  ttf_sec=(first_piece_ts - overall_start_ts) if first_piece_ts is not None else None,
398
  )
399
 
 
 
 
400
  # "Довывод" остатка: бэкенды одним куском в конце показывали бы "…" до самого финала. Ограничено catchup-лимитом сверху.
401
  final_chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
402
  target_full = final_chunks[-1]
@@ -513,10 +516,11 @@ async def _run_streaming_reply(
513
  await aclose()
514
 
515
  # Пустой full_text сюда не доходит — ветки "Empty response" нет.
516
- final_answer = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
 
517
  bot._record_model_outcome(provider, model_id,
518
  bad=_detect_garbled_mix(full_text.strip()) and not _is_garbled_echo(full_text.strip(), user_text))
519
- hist.append({"role": "user", "content": _history_user_text(user_text)})
520
  hist.append({"role": "assistant", "content": final_answer})
521
  # Тот же _trim_history, что в нестриминговых путях (см. комментарий выше).
522
  await bot._trim_history(hist)
@@ -524,7 +528,7 @@ async def _run_streaming_reply(
524
  bot._record_quota_usage(provider, model_id)
525
  return final_answer, None
526
 
527
- async def _try_gemini_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None, status=None) -> tuple[str | None, Message | None]:
528
  """Обёртка _run_streaming_reply для Gemini (строит contents/config)."""
529
  import bot
530
  conf = GEMINI_MODELS.get(model_id, {})
@@ -533,21 +537,21 @@ async def _try_gemini_streaming(chat_id: int, user_text: str, message: Message,
533
  contents = await bot._build_gemini_turn_contents(chat_id, user_text)
534
  call_contents, gconfig = bot._build_gemini_call_config(model_id, contents)
535
  piece_agen = _gemini_stream_pieces(model_id, call_contents, gconfig, deadline=deadline)
536
- return await _run_streaming_reply(chat_id, user_text, message, provider="gemini", model_id=model_id, piece_agen=piece_agen, deadline=deadline, status=status)
537
 
538
- async def _try_openrouter_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None, status=None) -> tuple[str | None, Message | None]:
539
  """Обёртка _run_streaming_reply для OpenRouter (SSE через chat/completions)."""
540
  import bot
541
  messages = bot._build_openrouter_turn_messages(chat_id, user_text, model_id)
542
  # Генератор — через bot.: тесты подменяют bot._openrouter_stream_pieces фейком.
543
  piece_agen = bot._openrouter_stream_pieces(model_id, messages, deadline=deadline)
544
- return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen, deadline=deadline, status=status)
545
 
546
- async def _try_groq_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None, status=None) -> tuple[str | None, Message | None]:
547
  """Обёртка _run_streaming_reply для Groq (SSE через chat/completions). Историю режем тем же _trim_messages_for_groq, что обычный путь, — иначе стрим-проба падает с 413 ещё до цепочки."""
548
  import bot
549
  from lumen_routes import _trim_messages_for_groq
550
  messages = _trim_messages_for_groq(bot._build_openrouter_turn_messages(chat_id, user_text, model_id))
551
  # Генератор — через bot.: тесты подменяют bot._groq_stream_pieces фейком.
552
  piece_agen = bot._groq_stream_pieces(model_id, messages, deadline=deadline)
553
- return await _run_streaming_reply(chat_id, user_text, message, provider="groq", model_id=model_id, piece_agen=piece_agen, deadline=deadline, status=status)
 
237
 
238
  async def _run_streaming_reply(
239
  chat_id: int, user_text: str, message: Message, *, provider: str, model_id: str, piece_agen,
240
+ deadline: float | None = None, status=None, history_text=None,
241
  ) -> tuple[str | None, Message | None]:
242
  """Стриминг: генератор кусков + плейсхолдер, чанкинг, троттлинг, обрыв при утечке/инъекции, HTML-финал, запись в историю. Возвращает (ответ, плейсхолдер): успех — (текст, None); сбой до показа — (None, плейсхолдер для следующей модели); сбой после — (показанное + пометка, None)."""
243
  import bot
 
330
  await aclose()
331
  final_answer = _IDENTITY_LEAK_FALLBACK if leak_kind == "identity" else _INJECTED_PAYLOAD_ECHO_FALLBACK
332
  await bot._tg_call(sent_messages[-1].edit_text, final_answer, parse_mode=None, call_timeout=15.0)
333
+ hist.append({"role": "user", "content": _history_user_text(history_text or user_text)})
334
  hist.append({"role": "assistant", "content": final_answer})
335
  # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
336
  await bot._trim_history(hist)
 
353
  with contextlib.suppress(Exception):
354
  await bot._tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]) + note, parse_mode=ParseMode.HTML, call_timeout=15.0)
355
  final_answer = full_text.strip()
356
+ hist.append({"role": "user", "content": _history_user_text(history_text or user_text)})
357
  hist.append({"role": "assistant", "content": final_answer})
358
  # Обрезка с саммари (см. _trim_history): молчаливый срез оставлял
359
  # саммаризацию только нестриминговым путям (аудит 26.09.2026).
 
397
  ttf_sec=(first_piece_ts - overall_start_ts) if first_piece_ts is not None else None,
398
  )
399
 
400
+ # Финальный скраб ДО последней правки: иначе пользователь видел ответ,
401
+ # который в историю уже попал очищенным (аудит 28.10.2026).
402
+ full_text = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
403
  # "Довывод" остатка: бэкенды одним куском в конце показывали бы "…" до самого финала. Ограничено catchup-лимитом сверху.
404
  final_chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
405
  target_full = final_chunks[-1]
 
516
  await aclose()
517
 
518
  # Пустой full_text сюда не доходит — ветки "Empty response" нет.
519
+ # Скраб уже применён до финальной правки (см. выше).
520
+ final_answer = full_text
521
  bot._record_model_outcome(provider, model_id,
522
  bad=_detect_garbled_mix(full_text.strip()) and not _is_garbled_echo(full_text.strip(), user_text))
523
+ hist.append({"role": "user", "content": _history_user_text(history_text or user_text)})
524
  hist.append({"role": "assistant", "content": final_answer})
525
  # Тот же _trim_history, что в нестриминговых путях (см. комментарий выше).
526
  await bot._trim_history(hist)
 
528
  bot._record_quota_usage(provider, model_id)
529
  return final_answer, None
530
 
531
+ async def _try_gemini_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None, status=None, history_text=None) -> tuple[str | None, Message | None]:
532
  """Обёртка _run_streaming_reply для Gemini (строит contents/config)."""
533
  import bot
534
  conf = GEMINI_MODELS.get(model_id, {})
 
537
  contents = await bot._build_gemini_turn_contents(chat_id, user_text)
538
  call_contents, gconfig = bot._build_gemini_call_config(model_id, contents)
539
  piece_agen = _gemini_stream_pieces(model_id, call_contents, gconfig, deadline=deadline)
540
+ return await _run_streaming_reply(chat_id, user_text, message, provider="gemini", model_id=model_id, piece_agen=piece_agen, deadline=deadline, status=status, history_text=history_text)
541
 
542
+ async def _try_openrouter_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None, status=None, history_text=None) -> tuple[str | None, Message | None]:
543
  """Обёртка _run_streaming_reply для OpenRouter (SSE через chat/completions)."""
544
  import bot
545
  messages = bot._build_openrouter_turn_messages(chat_id, user_text, model_id)
546
  # Генератор — через bot.: тесты подменяют bot._openrouter_stream_pieces фейком.
547
  piece_agen = bot._openrouter_stream_pieces(model_id, messages, deadline=deadline)
548
+ return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen, deadline=deadline, status=status, history_text=history_text)
549
 
550
+ async def _try_groq_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None, status=None, history_text=None) -> tuple[str | None, Message | None]:
551
  """Обёртка _run_streaming_reply для Groq (SSE через chat/completions). Историю режем тем же _trim_messages_for_groq, что обычный путь, — иначе стрим-проба падает с 413 ещё до цепочки."""
552
  import bot
553
  from lumen_routes import _trim_messages_for_groq
554
  messages = _trim_messages_for_groq(bot._build_openrouter_turn_messages(chat_id, user_text, model_id))
555
  # Генератор — через bot.: тесты подменяют bot._groq_stream_pieces фейком.
556
  piece_agen = bot._groq_stream_pieces(model_id, messages, deadline=deadline)
557
+ return await _run_streaming_reply(chat_id, user_text, message, provider="groq", model_id=model_id, piece_agen=piece_agen, deadline=deadline, status=status, history_text=history_text)
lumen_tiktok_flow.py CHANGED
@@ -483,6 +483,13 @@ async def _send_tiktok_single_video(
483
 
484
  raise TikTokUserFacingError(bot._t(message.chat.id, "tiktok_no_media"))
485
 
 
 
 
 
 
 
 
486
  async def handle_tiktok(message: Message, url: str) -> None:
487
  import bot
488
  if bot.is_guest_message(message):
@@ -526,8 +533,10 @@ async def handle_tiktok(message: Message, url: str) -> None:
526
  author = (media_data.get("author") or {}).get("nickname") or bot._t(message.chat.id, "tiktok_author")
527
 
528
  if await _try_send_tiktok_slideshow(session, media_data, message, status, author, headers):
 
529
  return
530
  await _send_tiktok_single_video(session, media_data, message, status, author, headers)
 
531
  except Exception as exc:
532
  if isinstance(exc, (TelegramEntityTooLarge, TikTokUserFacingError)):
533
  # Известные исходы — warning без Sentry, неожиданные — exception с трейсбеком (LUMEN-1).
 
483
 
484
  raise TikTokUserFacingError(bot._t(message.chat.id, "tiktok_no_media"))
485
 
486
+ def _record_tiktok_quota(message: Message) -> None:
487
+ """Успешная выдача TikTok — один ответ в дневной счётчик пользователя.
488
+ Раньше ветка возвращалась до _run_route и вообще не тратила лимит (аудит 28.10.2026)."""
489
+ import bot
490
+ bot._record_user_daily(bot._user_key_for_message(message))
491
+
492
+
493
  async def handle_tiktok(message: Message, url: str) -> None:
494
  import bot
495
  if bot.is_guest_message(message):
 
533
  author = (media_data.get("author") or {}).get("nickname") or bot._t(message.chat.id, "tiktok_author")
534
 
535
  if await _try_send_tiktok_slideshow(session, media_data, message, status, author, headers):
536
+ _record_tiktok_quota(message)
537
  return
538
  await _send_tiktok_single_video(session, media_data, message, status, author, headers)
539
+ _record_tiktok_quota(message)
540
  except Exception as exc:
541
  if isinstance(exc, (TelegramEntityTooLarge, TikTokUserFacingError)):
542
  # Известные исходы — warning без Sentry, неожиданные — exception с трейсбеком (LUMEN-1).
tests/test_bot_commands.py CHANGED
@@ -1660,6 +1660,34 @@ def test_pick_callback_ignores_banned_user(monkeypatch):
1660
  assert answered == [True]
1661
 
1662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1663
  def test_banned_user_commands_send_nothing(monkeypatch):
1664
  # Обход бана через прямые команды: catch-all с _is_banned не ловит
1665
  # зарегистрированные раньше cmd_*, каждая проверяет первой строкой.
 
1660
  assert answered == [True]
1661
 
1662
 
1663
+ def test_cmd_reset_clears_media_refs_and_names(monkeypatch):
1664
+ # Аудит 28.10.2026: /reset чистил историю, но старые ссылки на медиа
1665
+ # и имена оставались в состоянии.
1666
+ from collections import deque
1667
+ from unittest.mock import AsyncMock
1668
+ chat_id = 999806
1669
+ bot.chat_state[chat_id] = {
1670
+ "history": [{"role": "user", "content": "hi"}],
1671
+ "ctx": deque(["фон"]),
1672
+ "recent_media_ids": {"1": deque(["file-1"])},
1673
+ "user_names": {"1": "User"},
1674
+ "lang": "en",
1675
+ }
1676
+ incoming = _FakeIncomingMessage(chat_id)
1677
+ incoming.from_user = SimpleNamespace(id=42)
1678
+ monkeypatch.setattr(bot, "_is_privileged_in_chat", AsyncMock(return_value=True))
1679
+ monkeypatch.setattr(bot, "_tg_call", AsyncMock())
1680
+ try:
1681
+ asyncio.run(bot.cmd_reset(incoming))
1682
+ state = bot.chat_state[chat_id]
1683
+ assert state["history"] == []
1684
+ assert len(state["ctx"]) == 0
1685
+ assert state["recent_media_ids"] == {}
1686
+ assert state["user_names"] == {}
1687
+ finally:
1688
+ bot.chat_state.pop(chat_id, None)
1689
+
1690
+
1691
  def test_banned_user_commands_send_nothing(monkeypatch):
1692
  # Обход бана через прямые команды: catch-all с _is_banned не ловит
1693
  # зарегистрированные раньше cmd_*, каждая проверяет первой строкой.
tests/test_bot_routes.py CHANGED
@@ -233,7 +233,7 @@ def test_run_route_falls_back_to_second_provider_when_first_fully_fails():
233
  async def failing_or_text(*args, **kwargs):
234
  raise bot.OpenRouterAPIError("всё сломано", status_code=500)
235
 
236
- async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None, deadline=None, provenance=None):
237
  return "Ответ от Gemini (резерв)"
238
 
239
  original_or_text = bot.ask_openrouter_text
@@ -438,7 +438,7 @@ def test_run_route_reorders_slow_head_down(monkeypatch):
438
 
439
  seen = []
440
 
441
- async def fake_ask(chat_id, prompt, model_chain, *, deadline=None):
442
  seen.append(list(model_chain))
443
  return "ok"
444
 
@@ -462,7 +462,7 @@ def test_or_empty_response_falls_through_to_next_model(monkeypatch):
462
  # response" — пустой ответ обязан двигать цепочку дальше, а не идти в чат.
463
  calls = []
464
 
465
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
466
  model = json_body["model"]
467
  calls.append(model)
468
  if model == "m1:free":
@@ -482,7 +482,7 @@ def test_or_empty_response_falls_through_to_next_model(monkeypatch):
482
  def test_chain_empty_responses_feed_quarantine_counter(monkeypatch):
483
  # Три пустых подряд в одну голову — карантин (реальная регрессия: мёртвая голова
484
  # жевала бы каждую попытку до конца суток вместо объезда после третьей).
485
- async def fake_empty(path, method="GET", *, json_body=None, deadline=None):
486
  return {"choices": []}
487
 
488
  monkeypatch.setattr(bot, "_or_request", fake_empty)
@@ -502,7 +502,7 @@ def test_chain_garbled_response_delivered_but_feeds_quarantine(monkeypatch):
502
  mush = "результат: " + "dataданные fileфайл testтест " + "продолжение " + "я" * 80
503
  assert len(mush) >= 100
504
 
505
- async def fake_mush(path, method="GET", *, json_body=None, deadline=None):
506
  return {"choices": [{"message": {"content": mush}}]}
507
 
508
  monkeypatch.setattr(bot, "_or_request", fake_mush)
@@ -522,7 +522,7 @@ def test_chain_echoed_mix_does_not_feed_quarantine(monkeypatch):
522
  mush = "результат: " + "dataданные fileфайл testтест " + "продолжение " + "я" * 80
523
  user_text = "повтори за мной: dataданные fileфайл testтест и дальше своими словами"
524
 
525
- async def fake_mush(path, method="GET", *, json_body=None, deadline=None):
526
  return {"choices": [{"message": {"content": mush}}]}
527
 
528
  monkeypatch.setattr(bot, "_or_request", fake_mush)
@@ -597,7 +597,7 @@ def test_or_chain_stops_on_account_wide_limit(monkeypatch):
597
  # НЕ пробуются (иначе это минуты попыток впустую, как было в проде).
598
  calls = []
599
 
600
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
601
  model = json_body["model"]
602
  calls.append(model)
603
  raise bot.OpenRouterAPIError(
@@ -618,7 +618,7 @@ def test_gemini_empty_response_falls_through_to_next_model(monkeypatch):
618
  chat_id = 999304
619
  extracts = ["", "хороший ответ"]
620
 
621
- async def fake_extract(resp, *, model_id, call_contents, gconfig, deadline=None):
622
  return extracts.pop(0)
623
 
624
  fake_client = MagicMock()
@@ -637,7 +637,7 @@ def test_gemini_empty_response_falls_through_to_next_model(monkeypatch):
637
  def test_run_route_streams_openrouter_when_route_head_is_openrouter():
638
  chat_id = 999305
639
 
640
- async def fake_or_stream(cid, prompt, message, model_id, deadline=None, status=None):
641
  return "Стримленный ответ от OpenRouter", None
642
 
643
  async def must_not_be_called(*args, **kwargs):
@@ -660,12 +660,12 @@ def test_run_route_streams_openrouter_when_route_head_is_openrouter():
660
  def test_run_route_falls_back_from_failed_openrouter_stream_to_non_streaming():
661
  chat_id = 999306
662
 
663
- async def fake_or_stream_fail(cid, prompt, message, model_id, deadline=None, status=None):
664
  return None, None # ранний сбой без плейсхолдера (например, message.reply сам не удался)
665
 
666
  calls = []
667
 
668
- async def fake_or_text(cid, prompt, model_chain, deadline=None):
669
  calls.append(model_chain)
670
  return "Ответ без стрима"
671
 
@@ -690,12 +690,12 @@ def test_run_route_tries_groq_head_with_stream_fallback_to_text():
690
  # Groq-подключение 21.09.2026: голова-groq стримится через _try_groq_streaming, при раннем сбое — обычный ask_groq_text без пере-пробы упавшей модели.
691
  chat_id = 999703
692
 
693
- async def fake_groq_stream_fail(cid, prompt, message, model_id, deadline=None, status=None):
694
  return None, None
695
 
696
  calls = []
697
 
698
- async def fake_groq_text(cid, prompt, model_chain, deadline=None):
699
  calls.append(model_chain)
700
  return "Ответ Groq без стрима"
701
 
@@ -723,10 +723,10 @@ def test_run_route_reuses_stream_placeholder_when_fallback_succeeds():
723
  chat_id = 999307
724
  placeholder = _FakeSentMessage()
725
 
726
- async def fake_or_stream_fail_with_placeholder(cid, prompt, message, model_id, deadline=None, status=None):
727
  return None, placeholder
728
 
729
- async def fake_or_text(cid, prompt, model_chain, deadline=None):
730
  return "Ответ от резервной модели"
731
 
732
  original_stream = bot._try_openrouter_streaming
@@ -754,10 +754,10 @@ def test_run_route_reuse_adds_thought_block_in_private_chat(monkeypatch):
754
  placeholder = _FakeSentMessage()
755
  incoming = _FakeIncomingMessage(chat_id)
756
 
757
- async def fake_stream_fail(cid, prompt, message, model_id, deadline=None, status=None):
758
  return None, placeholder
759
 
760
- async def fake_text(cid, prompt, model_chain, deadline=None):
761
  return "Ответ от резервной модели"
762
 
763
  monkeypatch.setattr(bot, "_try_openrouter_streaming", fake_stream_fail)
@@ -778,7 +778,7 @@ def test_run_route_deletes_orphaned_placeholder_when_whole_route_fails():
778
  chat_id = 999308
779
  placeholder = _FakeSentMessage()
780
 
781
- async def fake_or_stream_fail_with_placeholder(cid, prompt, message, model_id, deadline=None, status=None):
782
  return None, placeholder
783
 
784
  async def failing_or_text(*args, **kwargs):
@@ -1152,7 +1152,7 @@ def test_ask_gemini_raises_when_route_budget_exceeded():
1152
  def test_or_chat_completion_with_fallback_switches_model(status_code, message):
1153
  # И минутный 429, и "постоянная" на вид 403 не обрывают переход к следующей
1154
  # модели: при attempts_per_model=1 переход происходит независимо от классификации.
1155
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1156
  model = json_body["model"]
1157
  if model == "model-a":
1158
  raise bot.OpenRouterAPIError(message, status_code=status_code)
@@ -1174,7 +1174,7 @@ def test_ask_openrouter_multimodal_sends_all_album_images_not_just_first():
1174
  chat_id = 999411
1175
  captured = {}
1176
 
1177
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1178
  captured["messages"] = json_body["messages"]
1179
  return {"choices": [{"message": {"content": "вижу три фото"}}]}
1180
 
@@ -1200,7 +1200,7 @@ def test_ask_openrouter_multimodal_skips_video_slides_for_gemini():
1200
  chat_id = 999412
1201
  captured = {}
1202
 
1203
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1204
  captured["messages"] = json_body["messages"]
1205
  return {"choices": [{"message": {"content": "вижу фото"}}]}
1206
 
@@ -1301,7 +1301,7 @@ def test_probe_or_model_liveness_warns_on_dead_model_pattern(caplog):
1301
  from datetime import date
1302
  expected_model = bot._OR_LIGHT_ORDER[date.today().timetuple().tm_yday % len(bot._OR_LIGHT_ORDER)]
1303
 
1304
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1305
  model = json_body["model"]
1306
  if model == expected_model:
1307
  raise bot.OpenRouterAPIError("This model is unavailable for free. use another slug", status_code=404)
@@ -1328,7 +1328,7 @@ def test_probe_or_model_liveness_rotates_by_day_of_year():
1328
  from datetime import date
1329
  calls = []
1330
 
1331
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1332
  calls.append(json_body["model"])
1333
  return {"choices": [{"message": {"content": "pong"}}]}
1334
 
@@ -1352,7 +1352,7 @@ def test_probe_or_model_liveness_rotates_by_day_of_year():
1352
  def test_probe_or_model_liveness_silent_when_all_alive(caplog):
1353
  import logging
1354
 
1355
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1356
  return {"choices": [{"message": {"content": "pong"}}]}
1357
 
1358
  original_request = bot._or_request
@@ -1421,7 +1421,7 @@ def test_ask_groq_text_success_records_groq_quota(monkeypatch):
1421
  chat_id = 999701
1422
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1423
 
1424
- async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1425
  assert json_body["model"] == "qwen/qwen3.8-27b"
1426
  return {"choices": [{"message": {"content": "Канберра."}}]}
1427
 
@@ -1441,7 +1441,7 @@ def test_ask_groq_text_falls_back_to_next_model(monkeypatch):
1441
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1442
  seen = []
1443
 
1444
- async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1445
  seen.append(json_body["model"])
1446
  if json_body["model"] == "qwen/qwen3.8-27b":
1447
  raise bot.GroqAPIError("overloaded", status_code=503)
@@ -1462,7 +1462,7 @@ def test_ask_groq_text_raises_last_error_when_whole_chain_fails(monkeypatch):
1462
  chat_id = 999703
1463
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1464
 
1465
- async def failing_groq_request(path, method="GET", *, json_body=None, deadline=None):
1466
  raise bot.GroqAPIError("overloaded", status_code=503)
1467
 
1468
  monkeypatch.setattr(bot, "_groq_request", failing_groq_request)
@@ -1485,7 +1485,7 @@ def test_ask_groq_text_trims_long_history_to_token_budget(monkeypatch):
1485
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": history, "ctx": deque()})
1486
  seen = {}
1487
 
1488
- async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1489
  # Первый запрос — сам ответ; второй (если будет) — саммаризатор истории
1490
  # _trim_history, его не трогаем.
1491
  seen.setdefault("messages", json_body["messages"])
@@ -1514,7 +1514,7 @@ def test_model_attempt_counters_track_chain_success_and_failure(monkeypatch):
1514
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1515
  calls = {"n": 0}
1516
 
1517
- async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1518
  calls["n"] += 1
1519
  if calls["n"] == 1:
1520
  raise bot.GroqAPIError("boom", status_code=500)
@@ -1750,7 +1750,7 @@ def test_text_provider_trims_history_with_summary_not_silent_cut(monkeypatch, as
1750
  async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
1751
  return answer, trial_models[0]
1752
 
1753
- async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1754
  return {"choices": [{"message": {"content": answer}}]}
1755
 
1756
  stubs = {"_or_chat_completion_with_fallback": fake_fallback, "_groq_request": fake_groq_request}
@@ -1785,7 +1785,7 @@ def test_text_providers_keep_media_note_out_of_history(monkeypatch):
1785
  async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
1786
  return "ответ", trial_models[0]
1787
 
1788
- async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1789
  return {"choices": [{"message": {"content": "ответ"}}]}
1790
 
1791
  monkeypatch.setattr(bot, "_or_chat_completion_with_fallback", fake_fallback)
@@ -1806,10 +1806,10 @@ def test_run_route_marks_unsupported_media_for_text_fallback(monkeypatch):
1806
  chat_id = 999815
1807
  captured = {}
1808
 
1809
- async def failing_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None, deadline=None, provenance=None):
1810
  raise ValueError("Тип вложения 'application/x-foo' не поддерживается для анализа.")
1811
 
1812
- async def fake_groq_text(cid, prompt, model_chain, deadline=None):
1813
  captured["prompt"] = prompt
1814
  return "не вижу файла, пришлите его"
1815
 
@@ -1860,7 +1860,7 @@ def test_run_route_records_user_daily_on_gemini_success():
1860
  incoming = _FakeIncomingMessage(chat_id)
1861
  incoming.from_user = SimpleNamespace(id=777011)
1862
 
1863
- async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None, deadline=None, provenance=None):
1864
  return "Ответ от Gemini"
1865
 
1866
  original = bot.ask_gemini
@@ -1909,7 +1909,7 @@ def test_run_route_keeps_route_block_order(monkeypatch):
1909
  calls = []
1910
 
1911
  async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None,
1912
- deadline=None, provenance=None):
1913
  calls.append(("gemini", list(model_chain)))
1914
  if model_chain == ["gemini-3.8-flash"]:
1915
  if provenance is not None:
@@ -1917,11 +1917,11 @@ def test_run_route_keeps_route_block_order(monkeypatch):
1917
  return "резерв Gemini"
1918
  raise RuntimeError("gemma недоступна")
1919
 
1920
- async def failing_groq(cid, prompt, model_chain, deadline=None):
1921
  calls.append(("groq", list(model_chain)))
1922
  raise bot.GroqAPIError("overloaded", status_code=503)
1923
 
1924
- async def failing_or(cid, prompt, model_chain, deadline=None):
1925
  calls.append(("openrouter", list(model_chain)))
1926
  raise bot.OpenRouterAPIError("сломано", status_code=500)
1927
 
@@ -1954,7 +1954,7 @@ def test_run_route_gemma_answer_does_not_spend_gemini_user_limit():
1954
  incoming.from_user = SimpleNamespace(id=777013)
1955
 
1956
  async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None,
1957
- deadline=None, provenance=None):
1958
  if provenance is not None:
1959
  provenance.update(provider="gemini", model="gemma-4-26b-a4b-it")
1960
  return "Ответ Gemma"
@@ -1992,10 +1992,10 @@ def test_handled_message_grows_provider_quota_user_daily_and_stats(rate_guard_se
1992
  monkeypatch.setattr(bot, "_try_openrouter_streaming", AsyncMock(return_value=(None, None)))
1993
  monkeypatch.setattr(bot, "_try_groq_streaming", AsyncMock(return_value=(None, None)))
1994
 
1995
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1996
  return {"choices": [{"message": {"content": "Привет! Как сам?"}}]}
1997
 
1998
- async def failing_groq_request(path, method="GET", *, json_body=None, deadline=None):
1999
  raise bot.GroqAPIError("overloaded", status_code=503)
2000
 
2001
  monkeypatch.setattr(bot, "_or_request", fake_or_request)
@@ -2130,7 +2130,7 @@ def test_openrouter_success_logs_token_usage_when_api_returns_it(caplog, monkeyp
2130
  import logging
2131
  chat_id = 999903
2132
 
2133
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
2134
  return {"choices": [{"message": {"content": "ответ"}}],
2135
  "usage": {"prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33}}
2136
 
 
233
  async def failing_or_text(*args, **kwargs):
234
  raise bot.OpenRouterAPIError("всё сломано", status_code=500)
235
 
236
+ async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None, deadline=None, provenance=None, **kwargs):
237
  return "Ответ от Gemini (резерв)"
238
 
239
  original_or_text = bot.ask_openrouter_text
 
438
 
439
  seen = []
440
 
441
+ async def fake_ask(chat_id, prompt, model_chain, *, deadline=None, **kwargs):
442
  seen.append(list(model_chain))
443
  return "ok"
444
 
 
462
  # response" — пустой ответ обязан двигать цепочку дальше, а не идти в чат.
463
  calls = []
464
 
465
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
466
  model = json_body["model"]
467
  calls.append(model)
468
  if model == "m1:free":
 
482
  def test_chain_empty_responses_feed_quarantine_counter(monkeypatch):
483
  # Три пустых подряд в одну голову — карантин (реальная регрессия: мёртвая голова
484
  # жевала бы каждую попытку до конца суток вместо объезда после третьей).
485
+ async def fake_empty(path, method="GET", *, json_body=None, deadline=None, **kwargs):
486
  return {"choices": []}
487
 
488
  monkeypatch.setattr(bot, "_or_request", fake_empty)
 
502
  mush = "результат: " + "dataданные fileфайл testтест " + "продолжение " + "я" * 80
503
  assert len(mush) >= 100
504
 
505
+ async def fake_mush(path, method="GET", *, json_body=None, deadline=None, **kwargs):
506
  return {"choices": [{"message": {"content": mush}}]}
507
 
508
  monkeypatch.setattr(bot, "_or_request", fake_mush)
 
522
  mush = "результат: " + "dataданные fileфайл testтест " + "продолжение " + "я" * 80
523
  user_text = "повтори за мной: dataданные fileфайл testтест и дальше своими словами"
524
 
525
+ async def fake_mush(path, method="GET", *, json_body=None, deadline=None, **kwargs):
526
  return {"choices": [{"message": {"content": mush}}]}
527
 
528
  monkeypatch.setattr(bot, "_or_request", fake_mush)
 
597
  # НЕ пробуются (иначе это минуты попыток впустую, как было в проде).
598
  calls = []
599
 
600
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
601
  model = json_body["model"]
602
  calls.append(model)
603
  raise bot.OpenRouterAPIError(
 
618
  chat_id = 999304
619
  extracts = ["", "хороший ответ"]
620
 
621
+ async def fake_extract(resp, *, model_id, call_contents, gconfig, deadline=None, **kwargs):
622
  return extracts.pop(0)
623
 
624
  fake_client = MagicMock()
 
637
  def test_run_route_streams_openrouter_when_route_head_is_openrouter():
638
  chat_id = 999305
639
 
640
+ async def fake_or_stream(cid, prompt, message, model_id, deadline=None, status=None, **kwargs):
641
  return "Стримленный ответ от OpenRouter", None
642
 
643
  async def must_not_be_called(*args, **kwargs):
 
660
  def test_run_route_falls_back_from_failed_openrouter_stream_to_non_streaming():
661
  chat_id = 999306
662
 
663
+ async def fake_or_stream_fail(cid, prompt, message, model_id, deadline=None, status=None, **kwargs):
664
  return None, None # ранний сбой без плейсхолдера (например, message.reply сам не удался)
665
 
666
  calls = []
667
 
668
+ async def fake_or_text(cid, prompt, model_chain, deadline=None, **kwargs):
669
  calls.append(model_chain)
670
  return "Ответ без стрима"
671
 
 
690
  # Groq-подключение 21.09.2026: голова-groq стримится через _try_groq_streaming, при раннем сбое — обычный ask_groq_text без пере-пробы упавшей модели.
691
  chat_id = 999703
692
 
693
+ async def fake_groq_stream_fail(cid, prompt, message, model_id, deadline=None, status=None, **kwargs):
694
  return None, None
695
 
696
  calls = []
697
 
698
+ async def fake_groq_text(cid, prompt, model_chain, deadline=None, **kwargs):
699
  calls.append(model_chain)
700
  return "Ответ Groq без стрима"
701
 
 
723
  chat_id = 999307
724
  placeholder = _FakeSentMessage()
725
 
726
+ async def fake_or_stream_fail_with_placeholder(cid, prompt, message, model_id, deadline=None, status=None, **kwargs):
727
  return None, placeholder
728
 
729
+ async def fake_or_text(cid, prompt, model_chain, deadline=None, **kwargs):
730
  return "Ответ от резервной модели"
731
 
732
  original_stream = bot._try_openrouter_streaming
 
754
  placeholder = _FakeSentMessage()
755
  incoming = _FakeIncomingMessage(chat_id)
756
 
757
+ async def fake_stream_fail(cid, prompt, message, model_id, deadline=None, status=None, **kwargs):
758
  return None, placeholder
759
 
760
+ async def fake_text(cid, prompt, model_chain, deadline=None, **kwargs):
761
  return "Ответ от резервной модели"
762
 
763
  monkeypatch.setattr(bot, "_try_openrouter_streaming", fake_stream_fail)
 
778
  chat_id = 999308
779
  placeholder = _FakeSentMessage()
780
 
781
+ async def fake_or_stream_fail_with_placeholder(cid, prompt, message, model_id, deadline=None, status=None, **kwargs):
782
  return None, placeholder
783
 
784
  async def failing_or_text(*args, **kwargs):
 
1152
  def test_or_chat_completion_with_fallback_switches_model(status_code, message):
1153
  # И минутный 429, и "постоянная" на вид 403 не обрывают переход к следующей
1154
  # модели: при attempts_per_model=1 переход происходит независимо от классификации.
1155
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1156
  model = json_body["model"]
1157
  if model == "model-a":
1158
  raise bot.OpenRouterAPIError(message, status_code=status_code)
 
1174
  chat_id = 999411
1175
  captured = {}
1176
 
1177
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1178
  captured["messages"] = json_body["messages"]
1179
  return {"choices": [{"message": {"content": "вижу три фото"}}]}
1180
 
 
1200
  chat_id = 999412
1201
  captured = {}
1202
 
1203
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1204
  captured["messages"] = json_body["messages"]
1205
  return {"choices": [{"message": {"content": "вижу фото"}}]}
1206
 
 
1301
  from datetime import date
1302
  expected_model = bot._OR_LIGHT_ORDER[date.today().timetuple().tm_yday % len(bot._OR_LIGHT_ORDER)]
1303
 
1304
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1305
  model = json_body["model"]
1306
  if model == expected_model:
1307
  raise bot.OpenRouterAPIError("This model is unavailable for free. use another slug", status_code=404)
 
1328
  from datetime import date
1329
  calls = []
1330
 
1331
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1332
  calls.append(json_body["model"])
1333
  return {"choices": [{"message": {"content": "pong"}}]}
1334
 
 
1352
  def test_probe_or_model_liveness_silent_when_all_alive(caplog):
1353
  import logging
1354
 
1355
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1356
  return {"choices": [{"message": {"content": "pong"}}]}
1357
 
1358
  original_request = bot._or_request
 
1421
  chat_id = 999701
1422
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1423
 
1424
+ async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1425
  assert json_body["model"] == "qwen/qwen3.8-27b"
1426
  return {"choices": [{"message": {"content": "Канберра."}}]}
1427
 
 
1441
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1442
  seen = []
1443
 
1444
+ async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1445
  seen.append(json_body["model"])
1446
  if json_body["model"] == "qwen/qwen3.8-27b":
1447
  raise bot.GroqAPIError("overloaded", status_code=503)
 
1462
  chat_id = 999703
1463
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1464
 
1465
+ async def failing_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1466
  raise bot.GroqAPIError("overloaded", status_code=503)
1467
 
1468
  monkeypatch.setattr(bot, "_groq_request", failing_groq_request)
 
1485
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": history, "ctx": deque()})
1486
  seen = {}
1487
 
1488
+ async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1489
  # Первый запрос — сам ответ; второй (если будет) — саммаризатор истории
1490
  # _trim_history, его не трогаем.
1491
  seen.setdefault("messages", json_body["messages"])
 
1514
  monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
1515
  calls = {"n": 0}
1516
 
1517
+ async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1518
  calls["n"] += 1
1519
  if calls["n"] == 1:
1520
  raise bot.GroqAPIError("boom", status_code=500)
 
1750
  async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
1751
  return answer, trial_models[0]
1752
 
1753
+ async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1754
  return {"choices": [{"message": {"content": answer}}]}
1755
 
1756
  stubs = {"_or_chat_completion_with_fallback": fake_fallback, "_groq_request": fake_groq_request}
 
1785
  async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
1786
  return "ответ", trial_models[0]
1787
 
1788
+ async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1789
  return {"choices": [{"message": {"content": "ответ"}}]}
1790
 
1791
  monkeypatch.setattr(bot, "_or_chat_completion_with_fallback", fake_fallback)
 
1806
  chat_id = 999815
1807
  captured = {}
1808
 
1809
+ async def failing_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None, deadline=None, provenance=None, **kwargs):
1810
  raise ValueError("Тип вложения 'application/x-foo' не поддерживается для анализа.")
1811
 
1812
+ async def fake_groq_text(cid, prompt, model_chain, deadline=None, **kwargs):
1813
  captured["prompt"] = prompt
1814
  return "не вижу файла, пришлите его"
1815
 
 
1860
  incoming = _FakeIncomingMessage(chat_id)
1861
  incoming.from_user = SimpleNamespace(id=777011)
1862
 
1863
+ async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None, deadline=None, provenance=None, **kwargs):
1864
  return "Ответ от Gemini"
1865
 
1866
  original = bot.ask_gemini
 
1909
  calls = []
1910
 
1911
  async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None,
1912
+ deadline=None, provenance=None, **kwargs):
1913
  calls.append(("gemini", list(model_chain)))
1914
  if model_chain == ["gemini-3.8-flash"]:
1915
  if provenance is not None:
 
1917
  return "резерв Gemini"
1918
  raise RuntimeError("gemma недоступна")
1919
 
1920
+ async def failing_groq(cid, prompt, model_chain, deadline=None, **kwargs):
1921
  calls.append(("groq", list(model_chain)))
1922
  raise bot.GroqAPIError("overloaded", status_code=503)
1923
 
1924
+ async def failing_or(cid, prompt, model_chain, deadline=None, **kwargs):
1925
  calls.append(("openrouter", list(model_chain)))
1926
  raise bot.OpenRouterAPIError("сломано", status_code=500)
1927
 
 
1954
  incoming.from_user = SimpleNamespace(id=777013)
1955
 
1956
  async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None,
1957
+ deadline=None, provenance=None, **kwargs):
1958
  if provenance is not None:
1959
  provenance.update(provider="gemini", model="gemma-4-26b-a4b-it")
1960
  return "Ответ Gemma"
 
1992
  monkeypatch.setattr(bot, "_try_openrouter_streaming", AsyncMock(return_value=(None, None)))
1993
  monkeypatch.setattr(bot, "_try_groq_streaming", AsyncMock(return_value=(None, None)))
1994
 
1995
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1996
  return {"choices": [{"message": {"content": "Привет! Как сам?"}}]}
1997
 
1998
+ async def failing_groq_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
1999
  raise bot.GroqAPIError("overloaded", status_code=503)
2000
 
2001
  monkeypatch.setattr(bot, "_or_request", fake_or_request)
 
2130
  import logging
2131
  chat_id = 999903
2132
 
2133
+ async def fake_or_request(path, method="GET", *, json_body=None, deadline=None, **kwargs):
2134
  return {"choices": [{"message": {"content": "ответ"}}],
2135
  "usage": {"prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33}}
2136
 
tests/test_bot_state.py CHANGED
@@ -2925,6 +2925,23 @@ def test_user_daily_record_counts_by_provider(monkeypatch):
2925
  bot.GLOBAL_QUOTA.get("user_daily", {}).pop(str(uid), None)
2926
 
2927
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2928
  def test_user_daily_owner_and_unknown_are_unlimited():
2929
  # Новое: владелец не ограничен и не оставляет записей — регрессия
2930
  # "владелец заблокирован собственными лимитами".
@@ -3155,6 +3172,28 @@ def test_combined_memory_caps_background_at_100():
3155
  bot._dirty_chat_ids.discard(9001)
3156
 
3157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3158
  def test_sender_directory_resolves_nicknames():
3159
  # Прозвища резолвятся по username и first_name: иначе "блинчик" терялся.
3160
  state = {"history": [], "user_names": {}}
 
2925
  bot.GLOBAL_QUOTA.get("user_daily", {}).pop(str(uid), None)
2926
 
2927
 
2928
+ def test_user_daily_reserve_is_atomic_and_refundable(monkeypatch):
2929
+ # Аудит 28.10.2026: проверка-потом-списание позволяла параллельным /tts
2930
+ # обойти дневной лимит. Резерв обязан отказывать второму вызову сразу.
2931
+ monkeypatch.setattr(bot, "DAILY_USER_TTS_LIMIT", 5)
2932
+ monkeypatch.setattr(bot, "DAILY_USER_MESSAGE_LIMIT", 20)
2933
+ uid = 999888773
2934
+ try:
2935
+ assert bot._reserve_user_daily(uid, total=5, tts=5) is True
2936
+ assert bot._reserve_user_daily(uid, total=5, tts=5) is False
2937
+ assert bot.GLOBAL_QUOTA["user_daily"][str(uid)]["tts"] == 5
2938
+ bot._refund_user_daily(uid, total=5, tts=5)
2939
+ assert bot.GLOBAL_QUOTA["user_daily"][str(uid)]["tts"] == 0
2940
+ assert bot._reserve_user_daily(uid, total=5, tts=5) is True
2941
+ finally:
2942
+ bot.GLOBAL_QUOTA.get("user_daily", {}).pop(str(uid), None)
2943
+
2944
+
2945
  def test_user_daily_owner_and_unknown_are_unlimited():
2946
  # Новое: владелец не ограничен и не оставляет записей — регрессия
2947
  # "владелец заблокирован собственными лимитами".
 
3172
  bot._dirty_chat_ids.discard(9001)
3173
 
3174
 
3175
+ def test_passive_context_caps_line_and_total_chars():
3176
+ # Аудит 28.10.2026: 100 сообщений по 4096 символов раздували промпт и
3177
+ # позволяли одному участнику забить чужой контекст.
3178
+ from collections import deque
3179
+ from lumen_message_core import _PASSIVE_CTX_MAX_CHARS
3180
+ state = {"history": [], "ctx": deque(), "recent_media_ids": {}, "user_names": {}}
3181
+ try:
3182
+ def _msg():
3183
+ return SimpleNamespace(chat=SimpleNamespace(id=9002),
3184
+ from_user=SimpleNamespace(id=8, username="u", first_name="U"),
3185
+ text="x")
3186
+
3187
+ bot._record_passive_group_context(_msg(), state, "я" * 5000)
3188
+ assert len(state["ctx"]) == 1
3189
+ assert len(state["ctx"][0]) <= 600
3190
+ for _ in range(50):
3191
+ bot._record_passive_group_context(_msg(), state, "x" * 400)
3192
+ assert sum(len(line) for line in state["ctx"]) <= _PASSIVE_CTX_MAX_CHARS + 600
3193
+ finally:
3194
+ bot._dirty_chat_ids.discard(9002)
3195
+
3196
+
3197
  def test_sender_directory_resolves_nicknames():
3198
  # Прозвища резолвятся по username и first_name: иначе "блинчик" терялся.
3199
  state = {"history": [], "user_names": {}}
tests/test_bot_streaming.py CHANGED
@@ -764,6 +764,29 @@ def test_streaming_plain_text_adds_collapsed_thought_block():
764
  bot.chat_state.pop(chat_id, None)
765
 
766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
  def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
768
  # Живой статус уже в чате: стрим пишет ответ в него, второго «…» нет.
769
  chat_id = 999320
 
764
  bot.chat_state.pop(chat_id, None)
765
 
766
 
767
+ def test_streaming_stores_clean_history_text_not_full_prompt():
768
+ # Аудит 28.10.2026: в историю уходил ai_prompt со сниппетами и датой;
769
+ # сохраняем только чистый запрос пользователя.
770
+ chat_id = 999323
771
+
772
+ async def pieces():
773
+ yield "Ответ"
774
+
775
+ incoming = _FakeIncomingMessage(chat_id)
776
+ try:
777
+ answer, _ = asyncio.run(bot._run_streaming_reply(
778
+ chat_id, "полный промпт со сниппетами", incoming,
779
+ provider="openrouter", model_id="y:free", piece_agen=pieces(),
780
+ history_text="чистый вопрос",
781
+ ))
782
+ assert answer == "Ответ"
783
+ history = bot.chat_state[chat_id]["history"]
784
+ assert history[-2] == {"role": "user", "content": "чистый вопрос"}
785
+ assert history[-1] == {"role": "assistant", "content": "Ответ"}
786
+ finally:
787
+ bot.chat_state.pop(chat_id, None)
788
+
789
+
790
  def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
791
  # Живой статус уже в чате: стрим пишет ответ в него, второго «…» нет.
792
  chat_id = 999320
tests/test_lumen_formatting.py CHANGED
@@ -622,6 +622,15 @@ def test_md_to_html_converts_markdown_link():
622
  assert result == '<a href="https://example.com/page">почитать здесь</a>'
623
 
624
 
 
 
 
 
 
 
 
 
 
625
  def test_md_to_html_link_with_underscores_in_url_not_corrupted_by_italic():
626
  # Регрессия: URL с двумя "_" мог бы ошибочно засчитаться за пару italic-
627
  # маркеров, если бы конвертация ссылок шла раньше bold/italic в Phase 3.
 
622
  assert result == '<a href="https://example.com/page">почитать здесь</a>'
623
 
624
 
625
+ def test_md_to_html_replaces_mismatched_url_label():
626
+ # Аудит 28.10.2026: метка-URL на чужой хост — готовый фишинг.
627
+ html = lumen_formatting._md_to_html("[https://bank.example](https://attacker.example/x)")
628
+ assert ">https://attacker.example/x<" in html
629
+ assert "bank.example" not in html
630
+ # Обычная текстовая метка не трогается.
631
+ assert ">Reuters<" in lumen_formatting._md_to_html("[Reuters](https://reuters.com/x)")
632
+
633
+
634
  def test_md_to_html_link_with_underscores_in_url_not_corrupted_by_italic():
635
  # Регрессия: URL с двумя "_" мог бы ошибочно засчитаться за пару italic-
636
  # маркеров, если бы конвертация ссылок шла раньше bold/italic в Phase 3.
tests/test_lumen_security.py CHANGED
@@ -41,6 +41,35 @@ def test_detect_identity_leak_based_on_needs_first_person():
41
  assert lumen_security._detect_identity_leak("Меня создала Google") is True
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  def test_detect_identity_leak_literal_matches_whole_token_only():
45
  # Полный ID отдельным токеном утечка, слаг внутри длинного слова нет.
46
  assert lumen_security._detect_identity_leak("Использую модель gemini-3.5-flash для ответа") is True
 
41
  assert lumen_security._detect_identity_leak("Меня создала Google") is True
42
 
43
 
44
+ def test_detect_identity_leak_catches_articles_created_by_and_invisible_chars():
45
+ # Аудит 28.10.2026: узкий шаблон пропускал обычные формулировки с артиклем,
46
+ # built/trained by и fullwidth/zero-width символами.
47
+ for text in (
48
+ "I am a Gemini model.",
49
+ "I am created by Google.",
50
+ "I am built by Google.",
51
+ "I am an AI model made by Anthropic.",
52
+ "I am a language model trained by Google.",
53
+ "I am a language model from Google.",
54
+ "Я — языковая модель Google.",
55
+ "\uff29 am a Gemini model.", # fullwidth I нормализуется NFKC
56
+ "I am a Gemini\u200b model.", # zero-width внутри фразы
57
+ ):
58
+ assert lumen_security._detect_identity_leak(text) is True, ascii(text)
59
+
60
+
61
+ def test_detect_identity_leak_does_not_flag_third_person_or_reports():
62
+ # Расширение шаблонов не должно ловить честные рассказы о чужих моделях.
63
+ for text in (
64
+ "I am a big fan of Google.",
65
+ "I am reading about Gemini.",
66
+ "I am reading a book created by Google.",
67
+ "This model was created by Google.",
68
+ "Я — фанат Google.",
69
+ ):
70
+ assert lumen_security._detect_identity_leak(text) is False, ascii(text)
71
+
72
+
73
  def test_detect_identity_leak_literal_matches_whole_token_only():
74
  # Полный ID отдельным токеном утечка, слаг внутри длинного слова нет.
75
  assert lumen_security._detect_identity_leak("Использую модель gemini-3.5-flash для ответа") is True