Spaces:
Running
Running
File size: 46,258 Bytes
419a40a 5f80ef1 419a40a 49f4622 419a40a 0e888a7 8eec4ca 0e888a7 419a40a 8eec4ca 419a40a e7c2546 419a40a c67c969 419a40a 5f80ef1 dd4c939 6472592 a7a4333 6472592 dd4c939 6472592 dd4c939 0b2d333 62c1832 0b2d333 62c1832 5f80ef1 5a68db4 419a40a 87dd95b 419a40a 87dd95b e7c2546 e8e379a e7c2546 87dd95b e7c2546 e8e379a e7c2546 e8e379a e7c2546 419a40a 0b65b92 419a40a 8365632 419a40a 8365632 6e8e909 8365632 419a40a 27a3176 419a40a 0b65b92 419a40a f7686fb 419a40a 5bd5d41 419a40a 0b2d333 419a40a 0b65b92 419a40a 0b2d333 419a40a 0b2d333 419a40a 0b65b92 419a40a 0b2d333 22d7bdd 419a40a 8eec4ca 419a40a 5a68db4 8eec4ca 419a40a 5a68db4 419a40a 87259c9 22d7bdd 5a68db4 419a40a 87259c9 0b65b92 419a40a 27a3176 8eec4ca 419a40a f7686fb 419a40a 0b2d333 419a40a 0b2d333 419a40a 5f80ef1 f7686fb 419a40a e7c2546 419a40a 9bccd1d 49f4622 9bccd1d 49f4622 9bccd1d 217e497 9bccd1d f4c0cd7 27a3176 8eec4ca f4c0cd7 9bccd1d 419a40a 959974b e7c2546 959974b 419a40a 0b65b92 419a40a 0b65b92 419a40a f95285c 419a40a 0e888a7 419a40a f47e28b 87259c9 f47e28b 87259c9 f47e28b 5e12873 f47e28b 87259c9 f47e28b 87259c9 f47e28b 5e12873 f47e28b 87259c9 f47e28b 87259c9 f47e28b 8365632 73ec2bd 0e888a7 73ec2bd 0e888a7 73ec2bd 8eec4ca 73ec2bd 0e888a7 8eec4ca 0e888a7 73ec2bd 419a40a 0b65b92 419a40a f47e28b 8eec4ca 419a40a 8eec4ca 419a40a 8eec4ca 419a40a 8eec4ca f47e28b 0e888a7 419a40a f7686fb 419a40a 8eec4ca 419a40a 8eec4ca 419a40a 0b65b92 419a40a 0803c88 419a40a 49f4622 a7a4333 49f4622 419a40a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 | """
lumen_message_core.py — ядро обработки входящих сообщений: альбомы, пассивный
фон групп, rate limit, разбор вложений, _handle_message_core. Буферы альбомов
живут в bot.py, связь с ним — отложенным импортом внутри функций.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import re
import time
from typing import Any
from aiogram.enums import ChatType
from aiogram.types import Message, Update
from lumen_message_parse import (
DRAW_TRIGGER_PREFIXES,
TTS_TRIGGER_PREFIXES,
_NO_MEDIA_NOTE,
_find_recent_media_by_category,
_match_trigger_prefix,
_media_reference_category,
_strip_reply_marker,
extract_url,
is_tiktok,
is_youtube,
match_pick_request,
)
from lumen_media import (
_ensure_prompt_text,
_media_file_id_and_mime,
_msg_media_source,
)
from lumen_router_config import (
_build_route,
_looks_like_freshness_query,
_looks_like_heavy_query,
_looks_like_search_request,
)
from lumen_search import (
answered_with_live_search,
fetch_web_snippets,
format_snippets_for_prompt,
route_has_live_search,
web_search_is_enabled,
web_search_max_results,
web_search_timeout_sec,
)
from lumen_security import _looks_like_injection_probe
import lumen_devtrace
log = logging.getLogger("bot")
# Суммарный кап extra-файлов альбома: 9 файлов по 20МБ + base64 давали ~250МБ
# в RAM (аудит D1, 30.09.2026). Обычный альбом — десятки МБ.
_ALBUM_EXTRA_MAX_BYTES = 100 * 1024 * 1024
# Параллельных скачиваний альбома: ограничивает in-flight память (3×20МБ).
_ALBUM_FETCH_CONCURRENCY = 3
async def _process_media_group_buffers(mgid: str) -> None:
import bot
messages: list = []
try:
await asyncio.sleep(0.8)
finally:
# Отмена во сне — запись обязана уйти, иначе висит вечно. Чужую (новую)
# задачу под тем же mgid не трогаем — сверяемся, что это мы.
messages = bot._mg_buffers.pop(mgid, []) or []
if bot._mg_tasks.get(mgid) is asyncio.current_task():
bot._mg_tasks.pop(mgid, None)
if not messages:
return
# Альбом — под тем же per-chat lock: иначе фоновый таск и свежий вопрос
# гоняются за history/ctx (аудит). Ожидание ограничено: вечное висело в фоне
# дольше любого бюджета (аудит 26.09.2026).
main_msg = messages[0]
try:
lock = await bot.acquire_chat_lock(main_msg.chat.id if main_msg.chat else 0, bot.CHAT_LOCK_TIMEOUT_SEC)
except asyncio.TimeoutError:
log.warning("[album] Timeout waiting for lock on chat %s", main_msg.chat.id if main_msg.chat else None)
with contextlib.suppress(Exception):
await bot._safe_reply(main_msg, bot._t(main_msg.chat.id if main_msg.chat else None, "lock_busy"))
return
try:
await _process_media_group_buffers_locked(messages)
finally:
with contextlib.suppress(Exception):
lock.release()
async def _process_media_group_buffers_locked(messages: list) -> None:
"""Тело обработки альбома под per-chat lock (см. выше)."""
import bot
# Основное — сообщение с подписью (caption бывает не на первом фото); иначе промт терялся.
main_msg = next((m for m in messages if m.text or m.caption), messages[0])
# Альбом в группе без упоминания бота — такой же пассивный фон, как обычное
# сообщение: раньше он всё равно тратил слот лимита и качал файлы (враждебное
# ревью 27.09.2026). В личке и при прямом обращении альбом обрабатывается как раньше.
if main_msg.chat and main_msg.chat.type != ChatType.PRIVATE:
t_text = main_msg.text or main_msg.caption or ""
if bot._should_only_record_passively(
main_msg, t_text,
is_private=False,
is_guest=bot.is_guest_message(main_msg),
mentioned=bot.message_mentions_bot(main_msg),
):
# Файлы 2..10 пассивного альбома тоже в recent_media_ids: иначе
# "что на втором фото" их не найдёт (порядок — как в альбоме).
_passive_state = bot.get_state(main_msg.chat.id)
bot._record_passive_group_context(main_msg, _passive_state, t_text)
_passive_uid = main_msg.from_user.id if main_msg.from_user else None
for _m in messages[1:]:
bot._save_media_to_history(_msg_media_source(_m), _passive_state, _passive_uid)
return
# Лимит — ДО скачивания: раньше альбом грузился целиком даже для отклонённого
# автора (аудит 26.09.2026). Слот ровно один: handle_message буферизует альбом
# целиком и в _handle_message_core не заходит.
if await bot._reject_rate_limited_message(main_msg):
return
extra_media: list[tuple[bytes, str]] = []
MAX_ALBUM_EXTRA = 9 # первое уходит как основное, до +9 дополнительных (итого 10 — как лимит TikTok-слайдшоу)
album_state = bot.get_state(main_msg.chat.id)
album_user_id = main_msg.from_user.id if main_msg.from_user else None
targets: list[tuple[str, str, Any]] = []
for m in messages[1:1 + MAX_ALBUM_EXTRA]:
src = _msg_media_source(m)
if not src:
continue
fid, mime, _ = _media_file_id_and_mime(src)
if not fid:
continue
targets.append((fid, mime, src))
# Качаем параллельно, а не по очереди: альбом из 9 фото иначе ждал бы до ~10-20с последовательных скачиваний.
fetched_list: list = [None] * len(targets)
# Семафор режет in-flight память, суммарный кап — накопленную (аудит D1, 30.09.2026).
_album_fetch_semaphore = asyncio.Semaphore(_ALBUM_FETCH_CONCURRENCY)
async def _fetch_one_bounded(fid: str, mime: str) -> tuple[bytes, str] | None:
async with _album_fetch_semaphore:
return await bot._fetch_media(fid, mime)
try:
fetched_list = list(await asyncio.gather(
*(_fetch_one_bounded(fid, mime) for fid, mime, _ in targets),
return_exceptions=True,
))
except Exception:
pass
skipped = 0
too_big = 0
extra_bytes = 0
for (fid, mime, src), fetched in zip(targets, fetched_list):
if isinstance(fetched, bot._MediaTooLargeError):
too_big += 1
continue
if not fetched or isinstance(fetched, BaseException):
skipped += 1
continue
if extra_bytes + len(fetched[0]) > _ALBUM_EXTRA_MAX_BYTES:
skipped += 1
continue
extra_bytes += len(fetched[0])
extra_media.append(fetched)
# Файлы альбома — в recent_media_ids, иначе последующие вопросы их не найдут.
bot._save_media_to_history(src, album_state, album_user_id)
if skipped:
# Упавшие слайды молча выпадали и анализ шёл по части файлов.
log.warning("[album] Skipped %d of %d files: download failed, analysing the rest.", skipped, len(targets))
if too_big:
# Большие отклонены капом: честный отказ пользователю — из _handle_message_core.
log.warning("[album] Skipped %d of %d files: over the download size cap.", too_big, len(targets))
await bot._handle_message_core(main_msg, extra_media=extra_media or None)
def _record_passive_group_context(message: Message, state: dict[str, Any], t: str) -> None:
"""Фон группы без упоминания: только контекст и медиа в state, без ответа."""
import bot
if t.strip():
sender = message.from_user
display = bot._note_sender(
state,
sender.id if sender is not None else None,
getattr(sender, "username", None) if sender is not None else None,
getattr(sender, "first_name", None) if sender is not None else None,
)
state["ctx"].append(f"{display}: {t.strip()}")
# Общий лимит 100 сообщений на чат, старые фоновые уходят первыми.
bot._trim_combined(state)
bot._save_media_to_history(_msg_media_source(message), state, message.from_user.id if message.from_user else None)
bot.mark_state_dirty(message.chat.id)
# Сколько текста реплая несём модели: хватает на цитату, чат не раздувает.
_REPLY_TARGET_MAX_CHARS = 1000
def _reply_target_block(message: Message, state: dict[str, Any]) -> str:
"""Цитата сообщения-реплая: реплай на бота отвечает по нему, а не по последнему."""
import bot
reply = getattr(message, "reply_to_message", None)
if reply is None:
return ""
text = (getattr(reply, "text", None) or getattr(reply, "caption", None) or "").strip()
if not text:
return ""
if len(text) > _REPLY_TARGET_MAX_CHARS:
text = text[:_REPLY_TARGET_MAX_CHARS].rstrip() + "…"
ruser = getattr(reply, "from_user", None)
try:
bot_username = (bot.BOT_USERNAME or "").lower()
except Exception:
bot_username = ""
r_username = getattr(ruser, "username", None) if ruser is not None else None
if r_username and bot_username and str(r_username).lower() == bot_username:
return f"[Отвечаешь именно на это сообщение (реплай) — твой прошлый ответ: {text}]\n"
if ruser is not None:
display = bot._note_sender(
state, getattr(ruser, "id", None),
getattr(ruser, "username", None), getattr(ruser, "first_name", None),
)
else:
display = "User"
return f"[Отвечаешь именно на это сообщение (реплай) от {display}: {text}]\n"
def _tagged_prompt(state: dict[str, Any], message: Message, clean_prompt: str) -> str:
"""Вопрос с автором и реплаем: модель знает кто спрашивает и на что отвечает."""
import bot
sender = message.from_user
display = bot._note_sender(
state,
sender.id if sender is not None else None,
getattr(sender, "username", None) if sender is not None else None,
getattr(sender, "first_name", None) if sender is not None else None,
)
tagged = f"[{display}] {clean_prompt}" if (clean_prompt or "").strip() else f"[{display}]"
return _reply_target_block(message, state) + tagged + _nick_hints(state, clean_prompt)
# Сколько подсказок по прозвищам несём модели: хватает на участников, шум не раздувает.
_NICK_HINT_MAX = 3
# Короткие слова не резолвим: двухбуквенные совпадения шумят на каждом сообщении.
_NICK_HINT_MIN_LEN = 3
def _nick_hints(state: dict[str, Any], clean_prompt: str) -> str:
"""Связывает прозвища из вопроса с авторами («блинчик» — это @user (Имя))."""
import bot
try:
words = re.findall(r"[\w@]+", clean_prompt or "", re.UNICODE)
except Exception:
return ""
seen: set[str] = set()
hints: list[str] = []
for word in words:
surface = word.strip().lstrip("@")
if len(surface) < _NICK_HINT_MIN_LEN or surface.lower() in seen:
continue
seen.add(surface.lower())
resolved = bot._resolve_nick(state, surface)
if resolved and resolved not in hints:
hints.append(f"[Подсказка: «{surface}» — это {resolved}.]")
if len(hints) >= _NICK_HINT_MAX:
break
return ("\n" + "\n".join(hints)) if hints else ""
def _should_only_record_passively(message: Message, t: str, *, is_private: bool, is_guest: bool, mentioned: bool) -> bool:
"""True, если сообщение — это фон группового чата без обращения к боту (см.
_record_passive_group_context выше) и активная обработка не нужна вообще.
Единственное исключение — ссылка на TikTok обрабатывается ВСЕГДА, даже без
упоминания бота (исторически так и задумано, см. комментарий в исходной
_handle_message_core)."""
if is_private or is_guest or mentioned:
return False
url = extract_url(t)
if url and is_tiktok(url):
import bot
# Групповой TikTok без обращения по умолчанию качается (удобство шаринга);
# TIKTOK_GROUP_REQUIRE_MENTION=1 оставляет только запись в фон (аудит H2, 10.2026).
return bool(bot.TIKTOK_GROUP_REQUIRE_MENTION)
return not url or not is_tiktok(url)
def _rate_limit_key_for_message(message: Message) -> int:
"""Ключ rate limit: без from_user (пост от канала) берём sender_chat/chat.id, иначе обход лимита к платным провайдерам."""
if message.from_user:
return message.from_user.id
sender_chat = getattr(message, "sender_chat", None)
return sender_chat.id if sender_chat else message.chat.id
_CONTINUE_RE = re.compile(
r"^(продолжи(ть)?|продолжай|дальше|договори|continue)\s*,?\s*(пожалуйста)?\s*[.!…]*$",
re.IGNORECASE,
)
_CONTINUE_INSTRUCTION = (
"\n\n[Продолжи оборванный ответ с места обрыва, не повторяя уже сказанное. / "
"Continue the interrupted answer from the cut point without repeating what was already said.]"
)
def _continue_after_interrupt(state: dict, clean_prompt: str) -> str | None:
"""«продолжи» после оборванного стрима: исходный вопрос + пометка продолжить
с места обрыва. Частичный ответ уже лежит в истории — модель его видит,
дублировать его в промт не нужно. Без флага обрыва или без пары
вопрос-ответ в хвосте истории — None (обычный путь)."""
if not _CONTINUE_RE.match((clean_prompt or "").strip()):
return None
hist = state.get("history") or []
if (
state.get("interrupted")
and len(hist) >= 2
and isinstance(hist[-1], dict) and hist[-1].get("role") == "assistant" and (hist[-1].get("content") or "").strip()
and isinstance(hist[-2], dict) and hist[-2].get("role") == "user" and (hist[-2].get("content") or "").strip()
):
return hist[-2]["content"].strip() + _CONTINUE_INSTRUCTION
return None
async def _reject_rate_limited_message(message: Message) -> bool:
import bot
if not bot._check_and_register_rate_limit(bot._rate_limit_key_for_message(message)):
return False
# Ответ без создания чата: обычный _t через _chat_lang заводил бы запись даже
# отклонённому сообщению. Язык уже существующего чата подхватывается так же.
await bot._tg_call(message.reply, bot._t_no_create(message.chat.id, "rate_limited"))
return True
def _read_attachment_sync(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
async def _resolve_incoming_media(
message: Message, state: dict[str, Any], clean_prompt: str, *, is_private: bool,
) -> tuple[str | None, str, str, tuple[bytes, str] | None]:
"""Чьё медиа: 1) вложение, 2) реплай, 3) слова. Файл на диске только в №1, остальное через _fetch_media в памяти."""
import bot
asking_user_id = message.from_user.id if message.from_user else None
media_src = _msg_media_source(message)
med_path, med_mime, med_name = None, "", ""
media_tuple = None
if media_src:
res = await bot._download_message_attachment_to_tmp(media_src)
if res:
med_path, med_mime, med_name = res
bot._save_media_to_history(media_src, state, asking_user_id)
# Чтение вложения в потоке: файлы до десятков МБ стопорили loop.
media_tuple = (await asyncio.to_thread(_read_attachment_sync, med_path), med_mime)
# Приоритет №2: явный реплай на сообщение с медиа — самый надёжный сигнал,
# пользователь прямо указал, о каком файле речь. Работает без триггер-слов.
if media_tuple is None and message.reply_to_message is not None:
reply_src = _msg_media_source(message.reply_to_message)
if reply_src:
reply_fid, reply_mime, _ = _media_file_id_and_mime(reply_src)
if reply_fid:
fetched = await bot._fetch_media(reply_fid, reply_mime)
if fetched:
# Медиа из реплая тоже в историю: ссылка словами должна находить файл.
bot._save_media_to_history(reply_src, state, asking_user_id)
media_tuple = fetched
# №3 ищем у автора, не последнее в чате: калибровка 18.08.2026 — "покажи стикер" описывал чужое фото.
if media_tuple is None and state.get("recent_media_ids"):
category = _media_reference_category(clean_prompt)
if category:
buckets: dict[str, Any] = state["recent_media_ids"]
own_bucket = buckets.get(str(asking_user_id)) if asking_user_id is not None else None
fid_mime = None
if own_bucket:
fid_mime = _find_recent_media_by_category(own_bucket, category)
elif is_private:
# В личке с ботом собеседник ровно один — не так критично,
# можно поискать по всем известным бакетам чата вообще.
for bucket in buckets.values():
fid_mime = _find_recent_media_by_category(bucket, category)
if fid_mime:
break
if fid_mime:
fid, mime = fid_mime
fetched = await bot._fetch_media(fid, mime)
if fetched:
media_tuple = fetched
return med_path, med_mime, med_name, media_tuple
def _strip_trigger_content(clean_prompt: str, trigger: str, message: Message) -> str:
"""Текст после draw/tts-триггера: чистка префикса + фолбэк на текст реплая
("нарисуй это" в ответ на сообщение с описанием)."""
content = re.sub(r'^[:\s\-\,]+', '', clean_prompt[len(trigger):].strip()).strip()
content = _strip_reply_marker(content)
if not content and message.reply_to_message is not None:
reply_text = (message.reply_to_message.text or message.reply_to_message.caption or "").strip()
if reply_text:
content = reply_text
return content
def _direct_attachment_needs_gemini(message: Message) -> bool:
"""Видео/аудио/документ (не картинка) во вложении или реплае — такое поздно
отдавать только Gemini, скачивание заранее бесполезно при исчерпанном лимите."""
for msg in (message, getattr(message, "reply_to_message", None)):
if msg is None:
continue
# Имя атрибута уже говорит о категории без скачивания (и без опоры на
# имя класса — в тестах лежат даблы, у aiogram свои типы).
for attr in ("video", "video_note", "animation", "voice", "audio"):
if getattr(msg, attr, None):
return True
src = _msg_media_source(msg)
if src is None:
continue
if type(src).__name__ in ("Video", "VideoNote", "Animation", "Voice", "Audio"):
return True
_, mime, _ = _media_file_id_and_mime(src)
m = (mime or "").lower()
if m.startswith(("video/", "audio/")):
return True
if type(src).__name__ == "Document" and not m.startswith("image/"):
return True
return False
async def _handle_message_core(message: Message, extra_media: list[tuple[bytes, str]] | None = None) -> None:
import bot
t = message.text or message.caption or ""
is_private = message.chat.type == ChatType.PRIVATE
is_guest = bot.is_guest_message(message)
mentioned = bot.message_mentions_bot(message)
# Старт для замера задержки ответа в след.
_trace_start = time.monotonic()
if bot._should_only_record_passively(message, t, is_private=is_private, is_guest=is_guest, mentioned=mentioned):
# Пассивный фон — единственное место, где состояние нужно ДО лимита.
bot._record_passive_group_context(message, bot.get_state(message.chat.id), t)
lumen_devtrace.record_passive(message.chat.id, t, is_private=is_private, mentioned=mentioned)
return
# Лимит проверяем ДО get_state: раньше отклонённое по лимиту сообщение всё равно
# заводило запись чата (и переписывало индекс), раздувая состояние и вытесняя
# реальные чаты (аудит 26.09.2026).
if await bot._reject_rate_limited_message(message):
return
# Общий дневной лимит — до любой обработки и до get_state: отказ не создаёт
# ни запись чата, ни запись пользователя, ни messages_received.
uid_early = bot._user_key_for_message(message)
_early_entry = bot._user_daily_peek(uid_early)
if bot._user_daily_total_exhausted(uid_early, _early_entry):
hours, mins = bot._user_daily_reset_in()
bot._record_stats_event("daily_limit_denials")
await bot._safe_reply(message, bot._t(
message.chat.id, "user_daily_total",
used=(_early_entry or {}).get("total", 0), limit=bot._user_daily_limit(uid_early, "total", _early_entry),
hours=hours, mins=mins,
))
return
# Видео/аудио при исчерпанном Gemini отказываем до скачивания: поздняя
# проверка после маршрута уже скачала бы файл и пожгла транскрибацию.
# Картинки пропускаем — их отдаст Groq/OpenRouter через поздний фолбэк.
if bot._user_daily_gemini_exhausted(uid_early, _early_entry) and _direct_attachment_needs_gemini(message):
bot._record_stats_event("daily_limit_denials")
await bot._safe_reply(message, bot._t(
message.chat.id, "user_daily_gemini",
used=(_early_entry or {}).get("gemini", 0), limit=bot._user_daily_limit(uid_early, "gemini", _early_entry),
))
return
state = bot.get_state(message.chat.id)
# Проверка на ссылки загрузки (TikTok — сразу всегда, даже в группах без упоминания)
url = extract_url(t)
needs_youtube = False
needs_website = False
youtube_url_to_analyze: str | None = None
if url:
if is_tiktok(url):
# Считаем только дошедшее до обработки: фон и отказы выше не в счёт.
bot._record_stats_event("messages_received")
await bot.handle_tiktok(message, url)
return
# YouTube/сайт читает сама модель (file_uri/url_context), сервер чужое не качает.
# Не открылось — скажем честно.
if is_youtube(url):
needs_youtube = True
youtube_url_to_analyze = url
else:
# needs_website читает ссылку сама модель через url_context на
# стороне Google — наш сервер произвольные URL не скачивает
# (свои загрузки — только через _download_url_bin с гардом схемы).
needs_website = True
clean_prompt = bot.clean_mention(t).strip()
if clean_prompt and _looks_like_injection_probe(clean_prompt):
# Инъекция: отвечаем без LLM. Текст в лог не пишем, только длину (аудит M5, 10.2026).
log.warning('[injection-probe] Blocked a prompt-injection attempt in chat %s (prompt_len=%d)', message.chat.id, len(clean_prompt))
lumen_devtrace.record_blocked(message.chat.id, reason="injection-probe", prompt_len=len(clean_prompt))
await bot._safe_reply(message, bot._t(message.chat.id, "injection_probe_reply"))
return
lower_prompt = clean_prompt.lower().strip()
# «продолжи» после оборванного стрима: подменяем промт исходным вопросом с
# пометкой (частичный ответ уже в истории). Кнопки-уточнения при добивке не
# показываем — вопрос уже задан, гадать нечего.
continued_prompt = _continue_after_interrupt(state, clean_prompt)
is_continuation = continued_prompt is not None
if is_continuation:
clean_prompt = continued_prompt
lower_prompt = clean_prompt.lower().strip()
matched_draw_trigger = _match_trigger_prefix(lower_prompt, DRAW_TRIGGER_PREFIXES)
matched_tts_trigger = _match_trigger_prefix(lower_prompt, TTS_TRIGGER_PREFIXES)
if matched_draw_trigger:
prompt_content = _strip_trigger_content(clean_prompt, matched_draw_trigger, message)
if prompt_content:
await bot.inline_draw(message, prompt_content)
return
if matched_tts_trigger:
tts_content = _strip_trigger_content(clean_prompt, matched_tts_trigger, message)
if tts_content:
await bot.inline_tts(message, tts_content)
return
# Кнопки-уточнения для вкусовых запросов без деталей — вместо гадания модели.
# _pick_resolved ставят только колбэки: без флага дополненный текст ушёл бы
# в кнопки по кругу.
if bot.PICK_BUTTONS_ENABLED and not is_continuation and not getattr(message, "_pick_resolved", False):
pick_scenario = match_pick_request(lower_prompt)
if pick_scenario:
await bot._send_pick_question(message, pick_scenario, clean_prompt)
return
media_too_big_mb = 0
try:
med_path, med_mime, med_name, media_tuple = await bot._resolve_incoming_media(
message, state, clean_prompt, is_private=is_private,
)
except bot._MediaTooLargeError as exc:
# Файл больше капа скачивания: честный отказ ниже вместо слепого ответа.
med_path, med_name, media_tuple = None, "", None
media_too_big_mb = max(1, round(exc.cap_bytes / (1024 * 1024)))
# Голос/аудио: сначала дешёвая транскрибация — дальше текст идёт общим роутингом по
# сценарию (тяжесть/свежесть определяются по сказанному). Не вышло — падает в прежний
# путь: аудио напрямую в Gemini (см. is_video_or_audio_media в роутере).
# Один бюджет на транскрибацию и маршрут: иначе войс держал лок сверх бюджета.
route_deadline: float | None = None
if media_tuple and media_tuple[1].startswith("audio/"):
route_deadline = time.monotonic() + bot.ROUTE_TOTAL_BUDGET_SEC
transcript = await bot._transcribe_audio(media_tuple[0], media_tuple[1], message.chat.id, deadline=route_deadline)
if transcript:
clean_prompt = (clean_prompt + "\n" + transcript).strip() if clean_prompt else transcript
media_tuple = None
if _looks_like_injection_probe(clean_prompt):
# Голосовой транскрипт дописывается после первого префильтра (аудит A3-1).
log.warning('[injection-probe] Blocked a prompt-injection attempt in chat %s (prompt_len=%d)', message.chat.id, len(clean_prompt))
lumen_devtrace.record_blocked(message.chat.id, reason="injection-probe", prompt_len=len(clean_prompt))
await bot._safe_reply(message, bot._t(message.chat.id, "injection_probe_reply"))
return
if media_tuple and not clean_prompt:
clean_prompt = _ensure_prompt_text(None, media_tuple[1])
if youtube_url_to_analyze and not clean_prompt:
clean_prompt = "Подробно перескажи и опиши содержание этого YouTube-видео."
if not clean_prompt and not media_tuple and not youtube_url_to_analyze:
src = _msg_media_source(message)
if src is not None and type(src).__name__ != "Sticker":
# Вложение было, но скачать/распознать не вышло — честно говорим, а не "Слушаю" в пустоту.
if media_too_big_mb:
await bot._safe_reply(message, bot._t(message.chat.id, "media_too_big", limit_mb=media_too_big_mb))
else:
await bot._safe_reply(message, bot._model_error_text("fallback", bot._chat_lang(message.chat.id)))
return
if mentioned:
await bot._tg_call(message.reply, bot._t(message.chat.id, "status_listening"))
return
# Отправка typing экшена (bot.bot — инстанс aiogram Bot, не модуль).
try:
if not is_guest:
await bot.bot.send_chat_action(chat_id=message.chat.id, action="typing")
except Exception:
pass
media_mime = media_tuple[1] if media_tuple else None
# Смешанный альбом (фото + видео-слайды): достаточно одного видео — весь набор едет
# в Gemini-ветку, иначе видео-слайды молча терялись (ревью ветки: смотрели только media[0]).
if media_mime and media_mime.startswith("image/") and extra_media:
for _, extra_mime in extra_media:
if not (extra_mime or "").startswith("image/"):
media_mime = extra_mime
break
is_heavy = _looks_like_heavy_query(clean_prompt)
# Явная просьба искать тоже ведёт на свежий маршрут: иначе "погугли" без слов-маркеров уходило на модели без поиска (прод 11.10.2026).
needs_freshness = _looks_like_freshness_query(clean_prompt) or _looks_like_search_request(clean_prompt)
route = _build_route(
needs_youtube=needs_youtube, needs_website=needs_website,
media_mime=media_mime, is_heavy=is_heavy, needs_freshness=needs_freshness,
)
log.info(
'[router] chat=%s heavy=%s freshness=%s youtube=%s website=%s media=%s route=%s',
message.chat.id, is_heavy, needs_freshness, needs_youtube, needs_website, media_mime,
[f"{p}:{m}" for p, m in route],
)
# Дневные лимиты на пользователя — до скачивания/маршрута уже поздно, но до
# вызова моделей: отказы и неудачи не считаются, только успешные ответы.
# Проба без создания: иначе отказ заводил бы запись и раздувал day_users.
uid = bot._user_key_for_message(message)
_late_entry = bot._user_daily_peek(uid)
if bot._user_daily_total_exhausted(uid, _late_entry):
hours, mins = bot._user_daily_reset_in()
# Суточный счётчик /stats: отказ по лимиту, ответа модели не будет.
bot._record_stats_event("daily_limit_denials")
await bot._safe_reply(message, bot._t(
message.chat.id, "user_daily_total",
used=(_late_entry or {}).get("total", 0), limit=bot._user_daily_limit(uid, "total", _late_entry),
hours=hours, mins=mins,
))
return
no_search_note = False
if bot._user_daily_gemini_exhausted(uid, _late_entry):
non_gemini = [(p, m) for p, m in route if p != "gemini"]
if not non_gemini:
# Ссылки, YouTube, видео/аудио и документы читает только Gemini.
bot._record_stats_event("daily_limit_denials")
await bot._safe_reply(message, bot._t(
message.chat.id, "user_daily_gemini",
used=(_late_entry or {}).get("gemini", 0), limit=bot._user_daily_limit(uid, "gemini", _late_entry),
))
return
route = non_gemini
# Свежесть без Gemini — честная пометка, что ответ без поиска.
no_search_note = needs_freshness
# Дошло до обработки: фон, инъекции и отказы выше уже отсеяны.
bot._record_stats_event("messages_received")
# Автор и реплай в промпте: модель знает кто спрашивает и на что отвечает.
ai_prompt = bot._tagged_prompt(state, message, clean_prompt)
# Внешний поиск нужен свежему запросу без живого поиска в маршруте (ссылки/YouTube
# читает только Gemini — им сниппеты не замена). Дневной лимит пользователя — не сюда:
# его квота потрачена, ответ идёт с честной пометкой без чужого поиска.
# Выключен свитчом или пуст — тоже отвечаем с пометкой.
need_external_search = (
needs_freshness
and not needs_youtube
and not needs_website
and not no_search_note
and not route_has_live_search(route)
)
web_search_failed = False
snippets_injected = False
if need_external_search and web_search_is_enabled():
# Сниппеты DuckDuckGo едут любому провайдеру, иначе ответ был бы по памяти (прод 11.10.2026).
# Поиск идёт до старта бюджета маршрута: худшее +8с к 40с лока (параллельный вопрос получит "занято").
search_budget = web_search_timeout_sec()
if route_deadline is not None:
search_budget = min(search_budget, max(0.0, route_deadline - time.monotonic()))
snippets: list = []
if search_budget > 0:
try:
snippets = await fetch_web_snippets(
clean_prompt, timeout_sec=search_budget, max_results=web_search_max_results(),
)
except Exception as exc:
log.warning("[web-search] Snippet fetch failed, answering with a no-search note: %s", exc.__class__.__name__)
if snippets:
ai_prompt += format_snippets_for_prompt(snippets)
snippets_injected = True
else:
web_search_failed = True
elif need_external_search:
web_search_failed = True
if (
media_tuple is None
and not extra_media
and youtube_url_to_analyze is None
and _media_reference_category(clean_prompt) is not None
):
# Прод-кейс 17.09.2026: без файла модель выдумывала фото. Дописываем _NO_MEDIA_NOTE, историю не режем.
ai_prompt += _NO_MEDIA_NOTE
gemini_media_list = ([media_tuple] if media_tuple else []) + list(extra_media or [])
gemini_media_list = gemini_media_list or None
# Стриминг ("живой" эффект печати) имеет смысл только для простого
# текстового обмена без вложений/YouTube — см. _run_route. При пометке
# "без поиска" стрим гасим: она дописывается к готовому ответу.
# Стрим головы без живого поиска при свежем запросе гасим: иначе ответ уйдёт
# без поиска до пост-проверки и ретрай уже невозможен (прод 12.10.2026).
head_has_live_search = (not route) or route_has_live_search(route[:1])
allow_stream = (
not gemini_media_list
and not youtube_url_to_analyze
and not no_search_note
and (not needs_freshness or head_has_live_search)
)
try:
provenance: dict = {}
ans, reply_already_sent = await bot._run_route(
message.chat.id, ai_prompt, route, message,
media=gemini_media_list, media_filename=med_name,
youtube_url=youtube_url_to_analyze, allow_stream=allow_stream,
deadline=route_deadline, provenance=provenance,
)
answered_grounded = answered_with_live_search(provenance)
if needs_freshness and not snippets_injected and not answered_grounded and not reply_already_sent and not no_search_note:
# План маршрута содержал живой поиск, но ответила модель без него
# (граундинг-модели умерли на квоте по ходу). Догоняем DuckDuckGo
# и один ретрай со сниппетами; не вышло — честная пометка.
retry_snippets: list = []
try:
retry_budget = web_search_timeout_sec()
if route_deadline is not None:
retry_budget = min(retry_budget, max(0.0, route_deadline - time.monotonic()))
if retry_budget > 0:
retry_snippets = await fetch_web_snippets(
clean_prompt, timeout_sec=retry_budget, max_results=web_search_max_results(),
)
except Exception as exc:
log.warning("[web-search] Post-hoc snippet fetch failed: %s", exc.__class__.__name__)
if retry_snippets:
log.info("[web-search] Retrying route once with fresh snippets (first answer had no live search).")
ai_prompt += format_snippets_for_prompt(retry_snippets)
snippets_injected = True
try:
ans, reply_already_sent = await bot._run_route(
message.chat.id, ai_prompt, route, message,
media=gemini_media_list, media_filename=med_name,
youtube_url=youtube_url_to_analyze, allow_stream=False,
deadline=route_deadline, provenance=provenance,
)
except Exception as exc:
log.warning("[web-search] Retry with snippets failed, keeping first answer with a no-search note: %s", exc.__class__.__name__)
web_search_failed = True
else:
web_search_failed = True
elif needs_freshness and not snippets_injected and not answered_grounded:
log.warning("[web-search] Answer already streamed without live search; cannot retry cleanly.")
if no_search_note:
ans = bot._t(message.chat.id, "fallback_no_search") + "\n\n" + ans
if web_search_failed:
ans = bot._t(message.chat.id, "fallback_no_live_search") + "\n\n" + ans
if not reply_already_sent:
await bot._safe_reply(message, ans)
# Успешный ответ закрывает флаг обрыва (добивка тоже считается успехом).
state.pop("interrupted", None)
bot.mark_state_dirty(message.chat.id)
lumen_devtrace.record_answer(
message.chat.id, user_text=clean_prompt, route=route,
provider=provenance.get("provider", ""), model=provenance.get("model") or "",
latency_ms=int((time.monotonic() - _trace_start) * 1000), reply=ans,
ctx_tail=list(state.get("ctx") or [])[-3:],
flags={"fresh": bool(needs_freshness), "snippets": bool(snippets_injected),
"grounded": bool(answered_grounded), "streamed": bool(reply_already_sent)},
)
except Exception as exc:
lumen_devtrace.record_failure(
message.chat.id, user_text=clean_prompt, route=route,
error=f"{type(exc).__name__}: {exc}",
latency_ms=int((time.monotonic() - _trace_start) * 1000),
)
if isinstance(exc, (bot.GeminiAllModelsExhaustedError, bot.RouteBudgetExceededError)):
# Квота/бюджет маршрута штатно, не баг: только warning, иначе Sentry тонет (LUMEN-3: 8 шумов за месяц).
log.warning("Chat AI processing hit a known, already-handled outcome: %s", exc)
else:
log.exception("Chat AI processing failed:")
head_model = route[0][1] if route else bot.DEFAULT_GEMINI_MODEL
if isinstance(exc, bot.GeminiAllModelsExhaustedError):
await bot._maybe_alert_gemini_exhausted()
await bot._safe_reply(message, bot._route_error_reply_text(exc, head_model, youtube_url_to_analyze=youtube_url_to_analyze, lang=bot._chat_lang(message.chat.id)))
finally:
if med_path and os.path.exists(med_path):
with contextlib.suppress(Exception):
os.unlink(med_path)
async def _process_raw_update(raw_update: dict) -> None:
import bot
if not isinstance(raw_update, dict):
return
try:
guest = raw_update.get("guest_message")
if isinstance(guest, dict):
try:
msg_obj = Message.model_validate(guest, context={"bot": bot})
guest_user = getattr(msg_obj, "from_user", None)
if guest_user is not None and bot._is_banned(guest_user.id):
# Гость в бане — тот же игнор, что у обычных сообщений.
return
gq_id = guest.get("guest_query_id")
if gq_id is not None and not getattr(msg_obj, "guest_query_id", None):
with contextlib.suppress(Exception):
object.__setattr__(msg_obj, "guest_query_id", gq_id)
# Тот же per-chat lock, что у обычного пути: параллельные апдейты иначе гоняются за history/ctx.
guest_chat_id = msg_obj.chat.id if msg_obj.chat else 0
try:
guest_lock = await bot.acquire_chat_lock(guest_chat_id, bot.CHAT_LOCK_TIMEOUT_SEC)
except asyncio.TimeoutError:
log.warning("[guest] Timeout waiting for lock on chat %s", guest_chat_id)
with contextlib.suppress(Exception):
await bot._tg_call(msg_obj.reply, bot._t_no_create(guest_chat_id, "lock_busy"))
return
try:
await bot._handle_message_core(msg_obj)
finally:
with contextlib.suppress(Exception):
guest_lock.release()
except Exception as exc:
log.warning("[guest] Guest processing failed: %s", exc)
return
upd = Update.model_validate(raw_update, context={"bot": bot})
await bot.dp.feed_update(bot.bot, upd)
except Exception as exc:
log.warning("[update] Raw update processing failed: %s", exc)
|