Spaces:
Running
Running
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 +3 -2
- .github/workflows/persona-eval.yml +3 -0
- .github/workflows/sync-to-hf.yml +12 -3
- bot.py +35 -7
- lumen_chat_state.py +35 -0
- lumen_commands.py +38 -24
- lumen_formatting.py +20 -0
- lumen_message_core.py +10 -1
- lumen_routes.py +19 -15
- lumen_security.py +25 -5
- lumen_streaming.py +15 -11
- lumen_tiktok_flow.py +9 -0
- tests/test_bot_commands.py +28 -0
- tests/test_bot_routes.py +41 -41
- tests/test_bot_state.py +39 -0
- tests/test_bot_streaming.py +23 -0
- tests/test_lumen_formatting.py +9 -0
- tests/test_lumen_security.py +29 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
log.warning("[lock] Timeout waiting for lock on chat %s", chat_id)
|
| 1371 |
with contextlib.suppress(Exception):
|
| 1372 |
-
|
| 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 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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("&", "&").replace("<", "<").replace(">", ">")
|
| 418 |
url = url.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 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("&", "&").replace("<", "<").replace(">", ">")
|
| 601 |
url = url.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 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("&", "&").replace("<", "<").replace(">", ">")
|
| 437 |
url = url.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 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("&", "&").replace("<", "<").replace(">", ">")
|
| 621 |
url = url.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
# "built on / powered by / based on бренд" тоже только от первого лица,
|
| 70 |
# иначе честный рассказ о чужом стеке блокировался целиком.
|
| 71 |
-
rf"|\bi\s
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|