SilverElixir commited on
Commit
419a40a
·
1 Parent(s): 42c54b2

P2: extract lumen_message_core.py (message handling core) with bot.X re-exports (AUD-A-001)

Browse files
Files changed (2) hide show
  1. bot.py +45 -358
  2. lumen_message_core.py +412 -0
bot.py CHANGED
@@ -34,7 +34,6 @@ from aiogram.filters import Command
34
  from aiogram.types import (
35
  BotCommand,
36
  Message,
37
- Update,
38
  )
39
  from google import genai
40
  from google.genai import types # нужен тестам как bot.types (сборка Content/Part)
@@ -1012,6 +1011,40 @@ __all__ = [
1012
  "_fetch_media",
1013
  "_sanitize_mime_type",
1014
  "_mime_suffix",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1015
  # Имена транспортных вызовов и _notify_owner — только для `bot.X` в тестах.
1016
  "_get_telegram_session",
1017
  "_rotate_telegram_proxy",
@@ -1348,342 +1381,7 @@ PICK_BUTTONS_ENABLED = os.getenv("PICK_BUTTONS_ENABLED", "1") == "1"
1348
 
1349
  # обработка сообщений
1350
 
1351
- async def _process_media_group_buffers(mgid: str) -> None:
1352
- await asyncio.sleep(0.8)
1353
- messages = _mg_buffers.pop(mgid, [])
1354
- _mg_tasks.pop(mgid, None)
1355
- if not messages:
1356
- return
1357
- # Первое сообщение альбома обычно несёт подпись (caption) — используем его
1358
- # как основное. Остальные фото/видео из альбома собираем как доп. вложения,
1359
- # чтобы модель реально видела все присланные медиафайлы, а не только первый.
1360
- main_msg = messages[0]
1361
- extra_media: list[tuple[bytes, str]] = []
1362
- MAX_ALBUM_EXTRA = 9 # первое уходит как основное, до +9 дополнительных (итого 10 — как лимит TikTok-слайдшоу)
1363
- album_state = get_state(main_msg.chat.id)
1364
- album_user_id = main_msg.from_user.id if main_msg.from_user else None
1365
- for m in messages[1:1 + MAX_ALBUM_EXTRA]:
1366
- src = _msg_media_source(m)
1367
- if not src:
1368
- continue
1369
- fid, mime, _ = _media_file_id_and_mime(src)
1370
- if not fid:
1371
- continue
1372
- fetched = await _fetch_media(fid, mime)
1373
- if fetched:
1374
- extra_media.append(fetched)
1375
- # РЕГРЕССИЯ: раньше эти файлы качались ТОЛЬКО для текущего ответа
1376
- # (extra_media ниже) и никогда не попадали в recent_media_ids —
1377
- # "а что было на втором фото" не находило файл, хотя бот его видел.
1378
- _save_media_to_history(src, album_state, album_user_id)
1379
- await _handle_message_core(main_msg, extra_media=extra_media or None)
1380
-
1381
- def _record_passive_group_context(message: Message, state: dict[str, Any], t: str) -> None:
1382
- """Пассивная запись сообщения группы в фон чата (бот не упомянут) — только
1383
- логирование контекста и запоминание последнего медиа отправителя, без
1384
- какого-либо ответа. Вынесено из _handle_message_core (см. аудит техдолга,
1385
- разбиение самой длинной функции проекта на именованные шаги) — чистый
1386
- побочный эффект над state, поведение не изменилось."""
1387
- if t.strip():
1388
- username = message.from_user.username or message.from_user.first_name or "User"
1389
- state["ctx"].append(f"@{username}: {t.strip()}")
1390
- _save_media_to_history(_msg_media_source(message), state, message.from_user.id if message.from_user else None)
1391
- mark_state_dirty(message.chat.id)
1392
-
1393
-
1394
- def _should_only_record_passively(message: Message, t: str, *, is_private: bool, is_guest: bool, mentioned: bool) -> bool:
1395
- """True, если сообщение — это фон группового чата без обращения к боту (см.
1396
- _record_passive_group_context выше) и активная обработка не нужна вообще.
1397
- Единственное исключение — ссылка на TikTok обрабатывается ВСЕГДА, даже без
1398
- упоминания бота (исторически так и задумано, см. комментарий в исходной
1399
- _handle_message_core)."""
1400
- if is_private or is_guest or mentioned:
1401
- return False
1402
- url = extract_url(t)
1403
- return not url or not is_tiktok(url)
1404
-
1405
-
1406
- def _rate_limit_key_for_message(message: Message) -> int:
1407
- """Возвращает идентификатор для скользящего окна rate limit по сообщению.
1408
-
1409
- НАЙДЕНО ПРИ СЕКЬЮРИТИ-РЕВЬЮ: Telegram не всегда прикладывает from_user к сообщению —
1410
- например, сообщение отправлено "от имени канала" в привязанной группе (Telegram
1411
- официально это разрешает любому админу канала), либо иной сценарий без обычного
1412
- пользователя. Раньше _handle_message_core в этом случае передавал в
1413
- _check_and_register_rate_limit буквальный None, а тот безусловно возвращает False
1414
- ("не лимитирован") для любого falsy ключа — то есть отправитель без from_user мог
1415
- слать запросы к платным AI-провайдерам вообще без ограничения скорости (реальный,
1416
- а не гипотетический обход rate limit). Используем sender_chat.id (если есть) или
1417
- сам chat.id как запасной идентификатор — тогда такой отправитель (или весь чат)
1418
- всё равно попадает под скользящее окно, а не обходит его полностью."""
1419
- if message.from_user:
1420
- return message.from_user.id
1421
- sender_chat = getattr(message, "sender_chat", None)
1422
- return sender_chat.id if sender_chat else message.chat.id
1423
-
1424
-
1425
- async def _reject_rate_limited_message(message: Message) -> bool:
1426
- if not _check_and_register_rate_limit(_rate_limit_key_for_message(message)):
1427
- return False
1428
- await _tg_call(message.reply, _t(message.chat.id, "rate_limited"))
1429
- return True
1430
-
1431
-
1432
- async def _resolve_incoming_media(
1433
- message: Message, state: dict[str, Any], clean_prompt: str, *, is_private: bool,
1434
- ) -> tuple[str | None, str, str, tuple[bytes, str] | None]:
1435
- """Три приоритета определения "о каком медиа речь" для текущего сообщения —
1436
- вынесено из _handle_message_core (аудит техдолга, разбиение самой длинной
1437
- функции проекта) как один логический шаг, дальше используется как есть.
1438
-
1439
- 1. Прямое вложение в САМОМ сообщении (фото/видео/аудио/документ и т.п.).
1440
- 2. Явный реплай на сообщение с медиа — пользователь прямо указал файл.
1441
- 3. Словесная отсылка ("что на фото") без реплая — берётся последнее медиа
1442
- ИМЕННО этого пользователя (не всего чата, см. комментарий ниже).
1443
-
1444
- Возвращает (med_path, med_mime, med_name, media_tuple) — та же четвёрка,
1445
- что раньше собиралась инлайн; med_path непустой ТОЛЬКО для приоритета №1
1446
- (файл реально лежит на диске и должен быть удалён вызывающим кодом в
1447
- finally), приоритеты №2/№3 работают через _fetch_media (в память, без
1448
- временного файла)."""
1449
- asking_user_id = message.from_user.id if message.from_user else None
1450
- media_src = _msg_media_source(message)
1451
- med_path, med_mime, med_name = None, "", ""
1452
- media_tuple = None
1453
-
1454
- if media_src:
1455
- res = await _download_message_attachment_to_tmp(media_src)
1456
- if res:
1457
- med_path, med_mime, med_name = res
1458
- _save_media_to_history(media_src, state, asking_user_id)
1459
- with open(med_path, "rb") as f:
1460
- media_tuple = (f.read(), med_mime)
1461
-
1462
- # Приоритет №2: явный реплай на сообщение с медиа — самый надёжный сигнал,
1463
- # пользователь прямо указал, о каком файле речь. Работает без триггер-слов.
1464
- if media_tuple is None and message.reply_to_message is not None:
1465
- reply_src = _msg_media_source(message.reply_to_message)
1466
- if reply_src:
1467
- reply_fid, reply_mime, _ = _media_file_id_and_mime(reply_src)
1468
- if reply_fid:
1469
- fetched = await _fetch_media(reply_fid, reply_mime)
1470
- if fetched:
1471
- media_tuple = fetched
1472
-
1473
- # Приоритет ��3: словесная отсылка к "тому самому" файлу без реплая.
1474
- # Ищем СНАЧАЛА среди недавних медиа именно этого пользователя (не всего чата —
1475
- # в группе разные люди шлют разные файлы, и общий "последний в чате" элемент
1476
- # почти всегда окажется чужим и не тем, о чём спрашивают). НАЙДЕНО ПРИ КАЛИБРОВКЕ
1477
- # (18 августа 2026): раньше здесь брался last элемент БЕЗ проверки типа — "покажи
1478
- # стикер", когда стикер не отправлялся вообще, доставал последнее фото/скриншот
1479
- # и подробно ЕГО описывал. Теперь ищем именно элемент запрошенной категории
1480
- # (см. _find_recent_media_by_category) — если такого нет, честно None, а не
1481
- # ближайший неподходящий по типу файл.
1482
- if media_tuple is None and state.get("recent_media_ids"):
1483
- category = _media_reference_category(clean_prompt)
1484
- if category:
1485
- buckets: dict[str, Any] = state["recent_media_ids"]
1486
- own_bucket = buckets.get(str(asking_user_id)) if asking_user_id is not None else None
1487
- fid_mime = None
1488
- if own_bucket:
1489
- fid_mime = _find_recent_media_by_category(own_bucket, category)
1490
- elif is_private:
1491
- # В личке с ботом собеседник ровно один — не так критично,
1492
- # можно поискать по всем известным бакетам чата вообще.
1493
- for bucket in buckets.values():
1494
- fid_mime = _find_recent_media_by_category(bucket, category)
1495
- if fid_mime:
1496
- break
1497
- if fid_mime:
1498
- fid, mime = fid_mime
1499
- fetched = await _fetch_media(fid, mime)
1500
- if fetched:
1501
- media_tuple = fetched
1502
-
1503
- return med_path, med_mime, med_name, media_tuple
1504
-
1505
-
1506
- async def _handle_message_core(message: Message, extra_media: list[tuple[bytes, str]] | None = None) -> None:
1507
- state = get_state(message.chat.id)
1508
- t = message.text or message.caption or ""
1509
- is_private = message.chat.type == ChatType.PRIVATE
1510
- is_guest = is_guest_message(message)
1511
- mentioned = message_mentions_bot(message)
1512
-
1513
- if _should_only_record_passively(message, t, is_private=is_private, is_guest=is_guest, mentioned=mentioned):
1514
- _record_passive_group_context(message, state, t)
1515
- return
1516
-
1517
- # Начинаем обработку активного запроса с проверкой rate limit
1518
- if await _reject_rate_limited_message(message):
1519
- return
1520
-
1521
- # Проверка на ссылки загрузки (TikTok — сразу всегда, даже в группах без упоминания)
1522
-
1523
- url = extract_url(t)
1524
- needs_youtube = False
1525
- needs_website = False
1526
- youtube_url_to_analyze: str | None = None
1527
- if url:
1528
- if is_tiktok(url):
1529
- await handle_tiktok(message, url)
1530
- return
1531
- # Gemini (теперь доступный автоматически на любое сообщение, а не по
1532
- # ручному выбору провайдера) реально умеет анализировать YouTube-видео
1533
- # по ссылке (file_uri) и читать содержимое обычных сайтов через
1534
- # url_context — модель сама решает, вызывать ли второе, если видит
1535
- # ссылку в тексте. Роутер (см. _build_route) направит такое сообщение
1536
- # именно в Gemini; если конкретный вызов не сработает (приватное видео,
1537
- # сайт недоступен и т.п.) — код ниже поймает исключение и даст честный
1538
- # ответ "не смог открыть", а не будет молчать или врать.
1539
- if is_youtube(url):
1540
- needs_youtube = True
1541
- youtube_url_to_analyze = url
1542
- else:
1543
- # needs_website читает ссылку сама модель через url_context на
1544
- # стороне Google — наш сервер произвольные URL не скачивает
1545
- # (свои загрузки — только через _download_url_bin с гардом схемы).
1546
- needs_website = True
1547
-
1548
- clean_prompt = clean_mention(t).strip()
1549
-
1550
- if clean_prompt and _looks_like_injection_probe(clean_prompt):
1551
- # Явная попытка промт-инъекции — отвечаем з��готовленной фразой БЕЗ обращения
1552
- # к LLM вообще (см. _INJECTION_PROBE_RE выше). Логируем для последующего
1553
- # разбора через /logs, чтобы со временем пополнять список паттернов реальными
1554
- # случаями, а не только теми, что придуманы заранее.
1555
- log.warning('[injection-probe] Blocked a prompt-injection attempt in chat %s: %r', message.chat.id, clean_prompt[:300])
1556
- await _safe_reply(message, _t(message.chat.id, "injection_probe_reply"))
1557
- return
1558
-
1559
- # ищем триггерные фразы (без учёта эмодзи)
1560
- lower_prompt = clean_prompt.lower().strip()
1561
-
1562
- matched_draw_trigger = _match_trigger_prefix(lower_prompt, DRAW_TRIGGER_PREFIXES)
1563
- matched_tts_trigger = _match_trigger_prefix(lower_prompt, TTS_TRIGGER_PREFIXES)
1564
-
1565
- if matched_draw_trigger:
1566
- prompt_content = clean_prompt[len(matched_draw_trigger):].strip()
1567
- prompt_content = re.sub(r'^[:\s\-\,]+', '', prompt_content).strip()
1568
- # Триггер сказан без содержания ("нарисуй" / "нарисуй это" в ответ на
1569
- # сообщение с описанием) — берём текст из reply вместо того, чтобы
1570
- # просто промолчать/уйти в обычный диалог.
1571
- prompt_content = _strip_reply_marker(prompt_content)
1572
- if not prompt_content and message.reply_to_message is not None:
1573
- reply_text = (message.reply_to_message.text or message.reply_to_message.caption or "").strip()
1574
- if reply_text:
1575
- prompt_content = reply_text
1576
- if prompt_content:
1577
- await inline_draw(message, prompt_content)
1578
- return
1579
-
1580
- if matched_tts_trigger:
1581
- tts_content = clean_prompt[len(matched_tts_trigger):].strip()
1582
- tts_content = re.sub(r'^[:\s\-\,]+', '', tts_content).strip()
1583
- # То же самое для озвучки — реплай "озвучь"/"озвучь это" без текста
1584
- # означает "озвучь ТО сообщение, на которое я отвечаю".
1585
- tts_content = _strip_reply_marker(tts_content)
1586
- if not tts_content and message.reply_to_message is not None:
1587
- reply_text = (message.reply_to_message.text or message.reply_to_message.caption or "").strip()
1588
- if reply_text:
1589
- tts_content = reply_text
1590
- if tts_content:
1591
- await inline_tts(message, tts_content)
1592
- return
1593
-
1594
- # Кнопки-уточнения для вкусовых запросов без деталей ("посоветуй фильм"):
1595
- # вместо гадания модели — вопрос с вариантами. _pick_resolved ставят только
1596
- # колбэки (см. handle_pick_callback): дополненный текст всё ещё матчится
1597
- # детектором, без флага ушёл бы в кнопки по кругу.
1598
- if PICK_BUTTONS_ENABLED and not getattr(message, "_pick_resolved", False):
1599
- pick_scenario = match_pick_request(lower_prompt)
1600
- if pick_scenario:
1601
- await _send_pick_question(message, pick_scenario, clean_prompt)
1602
- return
1603
-
1604
- med_path, med_mime, med_name, media_tuple = await _resolve_incoming_media(
1605
- message, state, clean_prompt, is_private=is_private,
1606
- )
1607
-
1608
- if media_tuple and not clean_prompt:
1609
- clean_prompt = _ensure_prompt_text(None, media_tuple[1])
1610
- if youtube_url_to_analyze and not clean_prompt:
1611
- clean_prompt = "Подробно перескажи и опиши содержание этого YouTube-видео."
1612
-
1613
- if not clean_prompt and not media_tuple and not youtube_url_to_analyze:
1614
- if mentioned:
1615
- await _tg_call(message.reply, _t(message.chat.id, "status_listening"))
1616
- return
1617
-
1618
- # Отправка typing экшена
1619
- try:
1620
- if not is_guest:
1621
- await bot.send_chat_action(chat_id=message.chat.id, action="typing")
1622
- except Exception:
1623
- pass
1624
-
1625
- media_mime = media_tuple[1] if media_tuple else None
1626
- is_heavy = _looks_like_heavy_query(clean_prompt)
1627
- needs_freshness = _looks_like_freshness_query(clean_prompt)
1628
- route = _build_route(
1629
- needs_youtube=needs_youtube, needs_website=needs_website,
1630
- media_mime=media_mime, is_heavy=is_heavy, needs_freshness=needs_freshness,
1631
- )
1632
- log.info(
1633
- '[router] chat=%s heavy=%s freshness=%s youtube=%s website=%s media=%s route=%s',
1634
- message.chat.id, is_heavy, needs_freshness, needs_youtube, needs_website, media_mime,
1635
- [f"{p}:{m}" for p, m in route],
1636
- )
1637
-
1638
- ai_prompt = clean_prompt
1639
- if (
1640
- media_tuple is None
1641
- and not extra_media
1642
- and youtube_url_to_analyze is None
1643
- and _media_reference_category(clean_prompt) is not None
1644
- ):
1645
- # Прод-кейс 17.09.2026: "что на фото?" без файла — модель выдумала
1646
- # подробное описание несуществующего скриншота, хотя промпт требует
1647
- # обратного. Разрешение резолвинга уже позади (приоритеты 1–3 ничего не
1648
- # нашли), поэтому этот факт сообщаем модели явно: слабым моделям одного
1649
- # раздела промпта не хватает, а с пометкой перед глазами сочинять
1650
- # сложнее. Ветка истории ("то фото" с текстовым описанием выше)
1651
- # сознательно НЕ отрезается — пометка велит проверить её сначала.
1652
- ai_prompt += _NO_MEDIA_NOTE
1653
- gemini_media_list = ([media_tuple] if media_tuple else []) + list(extra_media or [])
1654
- gemini_media_list = gemini_media_list or None
1655
- # Стриминг ("живой" эффект печати) имеет смысл только для простого
1656
- # текстового обмена без вложений/YouTube — см. _run_route.
1657
- allow_stream = not gemini_media_list and not youtube_url_to_analyze
1658
- try:
1659
- ans, reply_already_sent = await _run_route(
1660
- message.chat.id, ai_prompt, route, message,
1661
- media=gemini_media_list, media_filename=med_name,
1662
- youtube_url=youtube_url_to_analyze, allow_stream=allow_stream,
1663
- )
1664
- if not reply_already_sent:
1665
- await _safe_reply(message, ans)
1666
- mark_state_dirty(message.chat.id)
1667
- except Exception as exc:
1668
- if isinstance(exc, (GeminiAllModelsExhaustedError, RouteBudgetExceededError)):
1669
- # Известный, уже обрабатываемый исход (реальный суточный лимит квоты /
1670
- # общий бюджет времени маршрута исчерпан) — не баг, а штатная деградация,
1671
- # для которой пользователь и так получает понятный текст ниже, а владелец
1672
- # (в случае квоты) отдельно уведомляется через _maybe_alert_gemini_exhausted.
1673
- # log.exception на КАЖДЫЙ такой случай безусловно заводил issue в Sentry —
1674
- # см. LUMEN-3 (аудит логирования): 8 событий за месяц оказались этим
1675
- # классом, маскируя реальные новые баги среди ожидаемого шума.
1676
- log.warning("Chat AI processing hit a known, already-handled outcome: %s", exc)
1677
- else:
1678
- log.exception("Chat AI processing failed:")
1679
- head_model = route[0][1] if route else DEFAULT_GEMINI_MODEL
1680
- if isinstance(exc, GeminiAllModelsExhaustedError):
1681
- await _maybe_alert_gemini_exhausted()
1682
- await _safe_reply(message, _route_error_reply_text(exc, head_model, youtube_url_to_analyze=youtube_url_to_analyze, lang=_chat_lang(message.chat.id)))
1683
- finally:
1684
- if med_path and os.path.exists(med_path):
1685
- with contextlib.suppress(Exception):
1686
- os.unlink(med_path)
1687
 
1688
  @dp.errors()
1689
  async def global_error_handler(event: Any) -> bool:
@@ -1725,27 +1423,16 @@ async def handle_message(message: Message) -> None:
1725
  lock.release()
1726
 
1727
  # вебхук и запуск
1728
-
1729
- async def _process_raw_update(raw_update: dict) -> None:
1730
- if not isinstance(raw_update, dict):
1731
- return
1732
- try:
1733
- guest = raw_update.get("guest_message")
1734
- if isinstance(guest, dict):
1735
- try:
1736
- msg_obj = Message.model_validate(guest, context={"bot": bot})
1737
- gq_id = guest.get("guest_query_id")
1738
- if gq_id is not None and not getattr(msg_obj, "guest_query_id", None):
1739
- with contextlib.suppress(Exception):
1740
- object.__setattr__(msg_obj, "guest_query_id", gq_id)
1741
- await _handle_message_core(msg_obj)
1742
- except Exception as exc:
1743
- log.warning("[guest] Guest processing failed: %s", exc)
1744
- return
1745
- upd = Update.model_validate(raw_update, context={"bot": bot})
1746
- await dp.feed_update(bot, upd)
1747
- except Exception as exc:
1748
- log.warning("[update] Raw update processing failed: %s", exc)
1749
 
1750
  async def _webhook_startup() -> None:
1751
  load_state_from_disk()
 
34
  from aiogram.types import (
35
  BotCommand,
36
  Message,
 
37
  )
38
  from google import genai
39
  from google.genai import types # нужен тестам как bot.types (сборка Content/Part)
 
1011
  "_fetch_media",
1012
  "_sanitize_mime_type",
1013
  "_mime_suffix",
1014
+ "_msg_media_source",
1015
+ "_media_file_id_and_mime",
1016
+ "_ensure_prompt_text",
1017
+ "_looks_like_injection_probe",
1018
+ "DRAW_TRIGGER_PREFIXES",
1019
+ "TTS_TRIGGER_PREFIXES",
1020
+ "_NO_MEDIA_NOTE",
1021
+ "_match_trigger_prefix",
1022
+ "_media_reference_category",
1023
+ "_find_recent_media_by_category",
1024
+ "_strip_reply_marker",
1025
+ "extract_url",
1026
+ "is_tiktok",
1027
+ "is_youtube",
1028
+ "match_pick_request",
1029
+ "clean_mention",
1030
+ "inline_draw",
1031
+ "inline_tts",
1032
+ "_send_pick_question",
1033
+ "DEFAULT_GEMINI_MODEL",
1034
+ "_looks_like_heavy_query",
1035
+ "_looks_like_freshness_query",
1036
+ "_build_route",
1037
+ "_maybe_alert_gemini_exhausted",
1038
+ "mark_state_dirty",
1039
+ "get_state",
1040
+ "_chat_lang",
1041
+ "_check_and_register_rate_limit",
1042
+ "_record_passive_group_context",
1043
+ "_should_only_record_passively",
1044
+ "_rate_limit_key_for_message",
1045
+ "_reject_rate_limited_message",
1046
+ "_resolve_incoming_media",
1047
+ "_process_raw_update",
1048
  # Имена транспортных вызовов и _notify_owner — только для `bot.X` в тестах.
1049
  "_get_telegram_session",
1050
  "_rotate_telegram_proxy",
 
1381
 
1382
  # обработка сообщений
1383
 
1384
+ # Ядро обработки сообщений живёт в lumen_message_core.py (P2).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
 
1386
  @dp.errors()
1387
  async def global_error_handler(event: Any) -> bool:
 
1423
  lock.release()
1424
 
1425
  # вебхук и запуск
1426
+ from lumen_message_core import (
1427
+ _process_media_group_buffers,
1428
+ _record_passive_group_context,
1429
+ _should_only_record_passively,
1430
+ _rate_limit_key_for_message,
1431
+ _reject_rate_limited_message,
1432
+ _resolve_incoming_media,
1433
+ _handle_message_core,
1434
+ _process_raw_update,
1435
+ )
 
 
 
 
 
 
 
 
 
 
 
1436
 
1437
  async def _webhook_startup() -> None:
1438
  load_state_from_disk()
lumen_message_core.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ lumen_message_core.py — ядро обработки входящих сообщений (вынесено из bot.py,
3
+ P2 аудита): альбомы, пассивный фон групп, rate limit, разбор вложений,
4
+ _handle_message_core, handle_message, _process_raw_update.
5
+
6
+ Буферы альбомов (_mg_buffers/_mg_tasks) живут в bot.py — здесь только чтение/
7
+ мутация тех же объектов через `bot.`. Остальные связи с рантаймом — тоже через
8
+ отложенный `import bot` внутри функций. bot.py реэкспортирует имена.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import logging
15
+ import os
16
+ import re
17
+ from typing import Any
18
+
19
+ from aiogram.enums import ChatType
20
+ from aiogram.types import Message, Update
21
+
22
+ from lumen_message_parse import (
23
+ DRAW_TRIGGER_PREFIXES,
24
+ TTS_TRIGGER_PREFIXES,
25
+ _NO_MEDIA_NOTE,
26
+ _find_recent_media_by_category,
27
+ _match_trigger_prefix,
28
+ _media_reference_category,
29
+ _strip_reply_marker,
30
+ extract_url,
31
+ is_tiktok,
32
+ is_youtube,
33
+ match_pick_request,
34
+ )
35
+ from lumen_media import (
36
+ _ensure_prompt_text,
37
+ _media_file_id_and_mime,
38
+ _msg_media_source,
39
+ )
40
+ from lumen_router_config import (
41
+ _build_route,
42
+ _looks_like_freshness_query,
43
+ _looks_like_heavy_query,
44
+ )
45
+ from lumen_security import _looks_like_injection_probe
46
+
47
+ log = logging.getLogger("bot")
48
+
49
+ async def _process_media_group_buffers(mgid: str) -> None:
50
+ import bot
51
+ await asyncio.sleep(0.8)
52
+ messages = bot._mg_buffers.pop(mgid, [])
53
+ bot._mg_tasks.pop(mgid, None)
54
+ if not messages:
55
+ return
56
+ # Первое сообщение альбома обычно несёт подпись (caption) — используем его
57
+ # как основное. Остальные фото/видео из альбома собираем как доп. вложения,
58
+ # чтобы модель реально видела все присланные медиафайлы, а не только первый.
59
+ main_msg = messages[0]
60
+ extra_media: list[tuple[bytes, str]] = []
61
+ MAX_ALBUM_EXTRA = 9 # первое уходит как основное, до +9 дополнительных (итого 10 — как лимит TikTok-слайдшоу)
62
+ album_state = bot.get_state(main_msg.chat.id)
63
+ album_user_id = main_msg.from_user.id if main_msg.from_user else None
64
+ for m in messages[1:1 + MAX_ALBUM_EXTRA]:
65
+ src = _msg_media_source(m)
66
+ if not src:
67
+ continue
68
+ fid, mime, _ = _media_file_id_and_mime(src)
69
+ if not fid:
70
+ continue
71
+ fetched = await bot._fetch_media(fid, mime)
72
+ if fetched:
73
+ extra_media.append(fetched)
74
+ # РЕГРЕССИЯ: раньше эти файлы качались ТОЛЬКО для текущего ответа
75
+ # (extra_media ниже) и никогда не попадали в recent_media_ids —
76
+ # "а что было на втором фото" не находило файл, хотя бот его видел.
77
+ bot._save_media_to_history(src, album_state, album_user_id)
78
+ await bot._handle_message_core(main_msg, extra_media=extra_media or None)
79
+
80
+ def _record_passive_group_context(message: Message, state: dict[str, Any], t: str) -> None:
81
+ """Пассивная запись сообщения группы в фон чата (бот не упомянут) — только
82
+ логирование контекста и запоминание последнего медиа отправителя, без
83
+ какого-либо ответа. Вынесено из _handle_message_core (см. аудит техдолга,
84
+ разбиение самой длинной функции проекта на именованные шаги) — чистый
85
+ побочный эффект над state, поведение не изменилось."""
86
+ import bot
87
+ if t.strip():
88
+ username = message.from_user.username or message.from_user.first_name or "User"
89
+ state["ctx"].append(f"@{username}: {t.strip()}")
90
+ bot._save_media_to_history(_msg_media_source(message), state, message.from_user.id if message.from_user else None)
91
+ bot.mark_state_dirty(message.chat.id)
92
+
93
+
94
+ def _should_only_record_passively(message: Message, t: str, *, is_private: bool, is_guest: bool, mentioned: bool) -> bool:
95
+ """True, если сообщение — это фон группового чата без обращения к боту (см.
96
+ _record_passive_group_context выше) и активная обработка не нужна вообще.
97
+ Единственное исключение — ссылка на TikTok обрабатывается ВСЕГДА, даже без
98
+ упоминания бота (исторически так и задумано, см. комментарий в исходной
99
+ _handle_message_core)."""
100
+ if is_private or is_guest or mentioned:
101
+ return False
102
+ url = extract_url(t)
103
+ return not url or not is_tiktok(url)
104
+
105
+
106
+ def _rate_limit_key_for_message(message: Message) -> int:
107
+ """Возвращает идентификатор для скользящего окна rate limit по сообщению.
108
+
109
+ НАЙДЕНО ПРИ СЕКЬЮРИТИ-РЕВЬЮ: Telegram не всегда прикладывает from_user к сообщению —
110
+ например, сообщение отправлено "от имени канала" в привязанной группе (Telegram
111
+ официально это разрешает любому админу канала), либо иной сценарий без обычного
112
+ пользователя. Раньше _handle_message_core в этом случае передавал в
113
+ _check_and_register_rate_limit буквальный None, а тот безусловно возвращает False
114
+ ("не лимитирован") для любого falsy ключа — то есть отправитель без from_user мог
115
+ слать запросы к платным AI-провайдерам вообще без ограничения скорости (реальный,
116
+ а не гипотетический обход rate limit). Используем sender_chat.id (если есть) или
117
+ сам chat.id как запасной идентификатор — тогда такой отправитель (или весь чат)
118
+ всё равно попадает под скользящее окно, а не обходит его полностью."""
119
+ if message.from_user:
120
+ return message.from_user.id
121
+ sender_chat = getattr(message, "sender_chat", None)
122
+ return sender_chat.id if sender_chat else message.chat.id
123
+
124
+
125
+ async def _reject_rate_limited_message(message: Message) -> bool:
126
+ import bot
127
+ if not bot._check_and_register_rate_limit(bot._rate_limit_key_for_message(message)):
128
+ return False
129
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "rate_limited"))
130
+ return True
131
+
132
+
133
+ async def _resolve_incoming_media(
134
+ message: Message, state: dict[str, Any], clean_prompt: str, *, is_private: bool,
135
+ ) -> tuple[str | None, str, str, tuple[bytes, str] | None]:
136
+ """Три приоритета определения "о каком медиа речь" для текущего сообщения —
137
+ вынесено из _handle_message_core (аудит техдолга, разбиение самой длинной
138
+ функции проекта) как один логический шаг, дальше используется как есть.
139
+
140
+ 1. Прямое вложение в САМОМ сообщении (фото/видео/аудио/документ и т.п.).
141
+ 2. Явный реплай на сообщение с медиа — пользователь прямо указал файл.
142
+ 3. Словесная отсылка ("что на фото") без реплая — берётся последнее медиа
143
+ ИМЕННО этого пользователя (не всего чата, см. комментарий ниже).
144
+
145
+ Возвращает (med_path, med_mime, med_name, media_tuple) — та же четвёрка,
146
+ что раньше собиралась инлайн; med_path непустой ТОЛЬКО для приоритета №1
147
+ (файл реально лежит на диске и должен быть удалён вызывающим кодом в
148
+ finally), приоритеты №2/№3 работают через _fetch_media (в память, без
149
+ временного файла)."""
150
+ import bot
151
+ asking_user_id = message.from_user.id if message.from_user else None
152
+ media_src = _msg_media_source(message)
153
+ med_path, med_mime, med_name = None, "", ""
154
+ media_tuple = None
155
+
156
+ if media_src:
157
+ res = await bot._download_message_attachment_to_tmp(media_src)
158
+ if res:
159
+ med_path, med_mime, med_name = res
160
+ bot._save_media_to_history(media_src, state, asking_user_id)
161
+ with open(med_path, "rb") as f:
162
+ media_tuple = (f.read(), med_mime)
163
+
164
+ # Приоритет №2: явный реплай на сообщение с медиа — самый надёжный сигнал,
165
+ # пользователь прямо указал, о каком файле речь. Работает без триггер-слов.
166
+ if media_tuple is None and message.reply_to_message is not None:
167
+ reply_src = _msg_media_source(message.reply_to_message)
168
+ if reply_src:
169
+ reply_fid, reply_mime, _ = _media_file_id_and_mime(reply_src)
170
+ if reply_fid:
171
+ fetched = await bot._fetch_media(reply_fid, reply_mime)
172
+ if fetched:
173
+ media_tuple = fetched
174
+
175
+ # При��ритет №3: словесная отсылка к "тому самому" файлу без реплая.
176
+ # Ищем СНАЧАЛА среди недавних медиа именно этого пользователя (не всего чата —
177
+ # в группе разные люди шлют разные файлы, и общий "последний в чате" элемент
178
+ # почти всегда окажется чужим и не тем, о чём спрашивают). НАЙДЕНО ПРИ КАЛИБРОВКЕ
179
+ # (18 августа 2026): раньше здесь брался last элемент БЕЗ проверки типа — "покажи
180
+ # стикер", когда стикер не отправлялся вообще, доставал последнее фото/скриншот
181
+ # и подробно ЕГО описывал. Теперь ищем именно элемент запрошенной категории
182
+ # (см. _find_recent_media_by_category) — если такого нет, честно None, а не
183
+ # ближайший неподходящий по типу файл.
184
+ if media_tuple is None and state.get("recent_media_ids"):
185
+ category = _media_reference_category(clean_prompt)
186
+ if category:
187
+ buckets: dict[str, Any] = state["recent_media_ids"]
188
+ own_bucket = buckets.get(str(asking_user_id)) if asking_user_id is not None else None
189
+ fid_mime = None
190
+ if own_bucket:
191
+ fid_mime = _find_recent_media_by_category(own_bucket, category)
192
+ elif is_private:
193
+ # В личке с ботом собеседник ровно один — не так критично,
194
+ # можно поискать по всем известным бакетам чата вообще.
195
+ for bucket in buckets.values():
196
+ fid_mime = _find_recent_media_by_category(bucket, category)
197
+ if fid_mime:
198
+ break
199
+ if fid_mime:
200
+ fid, mime = fid_mime
201
+ fetched = await bot._fetch_media(fid, mime)
202
+ if fetched:
203
+ media_tuple = fetched
204
+
205
+ return med_path, med_mime, med_name, media_tuple
206
+
207
+
208
+ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes, str]] | None = None) -> None:
209
+ import bot
210
+ state = bot.get_state(message.chat.id)
211
+ t = message.text or message.caption or ""
212
+ is_private = message.chat.type == ChatType.PRIVATE
213
+ is_guest = bot.is_guest_message(message)
214
+ mentioned = bot.message_mentions_bot(message)
215
+
216
+ if bot._should_only_record_passively(message, t, is_private=is_private, is_guest=is_guest, mentioned=mentioned):
217
+ bot._record_passive_group_context(message, state, t)
218
+ return
219
+
220
+ # Начинаем обработку активного запроса с проверкой rate limit
221
+ if await bot._reject_rate_limited_message(message):
222
+ return
223
+
224
+ # Проверка на ссылки загрузки (TikTok — сразу всегда, даже в группах без упоминания)
225
+
226
+ url = extract_url(t)
227
+ needs_youtube = False
228
+ needs_website = False
229
+ youtube_url_to_analyze: str | None = None
230
+ if url:
231
+ if is_tiktok(url):
232
+ await bot.handle_tiktok(message, url)
233
+ return
234
+ # Gemini (теперь доступный автоматически на любое сообщение, а не по
235
+ # ручному выбору провайдера) реально умеет анализировать YouTube-видео
236
+ # по ссылке (file_uri) и читать содержимое обычных сайтов через
237
+ # url_context — модель сама решает, вызывать ли второе, если видит
238
+ # ссылку в тексте. Роутер (см. _build_route) направит такое сообщение
239
+ # именно в Gemini; если конкретный вызов не сработает (приватное видео,
240
+ # сайт недоступен и т.п.) — код ниже поймает исключение и даст честный
241
+ # ответ "не смог открыть", а не будет молчать или врать.
242
+ if is_youtube(url):
243
+ needs_youtube = True
244
+ youtube_url_to_analyze = url
245
+ else:
246
+ # needs_website читает ссылку сама модель через url_context на
247
+ # стороне Google — наш сервер произвольные URL не скачивает
248
+ # (свои загрузки — только через _download_url_bin с гардом схемы).
249
+ needs_website = True
250
+
251
+ clean_prompt = bot.clean_mention(t).strip()
252
+
253
+ if clean_prompt and _looks_like_injection_probe(clean_prompt):
254
+ # Явная ��опытка промт-инъекции — отвечаем заготовленной фразой БЕЗ обращения
255
+ # к LLM вообще (см. _INJECTION_PROBE_RE выше). Логируем для последующего
256
+ # разбора через /logs, чтобы со временем пополнять список паттернов реальными
257
+ # случаями, а не только теми, что придуманы заранее.
258
+ log.warning('[injection-probe] Blocked a prompt-injection attempt in chat %s: %r', message.chat.id, clean_prompt[:300])
259
+ await bot._safe_reply(message, bot._t(message.chat.id, "injection_probe_reply"))
260
+ return
261
+
262
+ # ищем триггерные фразы (без учёта эмодзи)
263
+ lower_prompt = clean_prompt.lower().strip()
264
+
265
+ matched_draw_trigger = _match_trigger_prefix(lower_prompt, DRAW_TRIGGER_PREFIXES)
266
+ matched_tts_trigger = _match_trigger_prefix(lower_prompt, TTS_TRIGGER_PREFIXES)
267
+
268
+ if matched_draw_trigger:
269
+ prompt_content = clean_prompt[len(matched_draw_trigger):].strip()
270
+ prompt_content = re.sub(r'^[:\s\-\,]+', '', prompt_content).strip()
271
+ # Триггер сказан без содержания ("нарисуй" / "нарисуй это" в ответ на
272
+ # сообщение с описанием) — берём текст из reply вместо того, чтобы
273
+ # просто промолчать/уйти в обычный диалог.
274
+ prompt_content = _strip_reply_marker(prompt_content)
275
+ if not prompt_content and message.reply_to_message is not None:
276
+ reply_text = (message.reply_to_message.text or message.reply_to_message.caption or "").strip()
277
+ if reply_text:
278
+ prompt_content = reply_text
279
+ if prompt_content:
280
+ await bot.inline_draw(message, prompt_content)
281
+ return
282
+
283
+ if matched_tts_trigger:
284
+ tts_content = clean_prompt[len(matched_tts_trigger):].strip()
285
+ tts_content = re.sub(r'^[:\s\-\,]+', '', tts_content).strip()
286
+ # То же самое для озвучки — реплай "озвучь"/"озвучь это" без текста
287
+ # означает "озвучь ТО сообщение, на которое я отвечаю".
288
+ tts_content = _strip_reply_marker(tts_content)
289
+ if not tts_content and message.reply_to_message is not None:
290
+ reply_text = (message.reply_to_message.text or message.reply_to_message.caption or "").strip()
291
+ if reply_text:
292
+ tts_content = reply_text
293
+ if tts_content:
294
+ await bot.inline_tts(message, tts_content)
295
+ return
296
+
297
+ # Кнопки-уточнения для вкусовых запросов без деталей ("посоветуй фильм"):
298
+ # вместо гадания модели — вопрос с вариантами. _pick_resolved ставят только
299
+ # колбэки (см. handle_pick_callback): дополненный текст всё ещё матчится
300
+ # детектором, без флага ушёл бы в кнопки по кругу.
301
+ if bot.PICK_BUTTONS_ENABLED and not getattr(message, "_pick_resolved", False):
302
+ pick_scenario = match_pick_request(lower_prompt)
303
+ if pick_scenario:
304
+ await bot._send_pick_question(message, pick_scenario, clean_prompt)
305
+ return
306
+
307
+ med_path, med_mime, med_name, media_tuple = await bot._resolve_incoming_media(
308
+ message, state, clean_prompt, is_private=is_private,
309
+ )
310
+
311
+ if media_tuple and not clean_prompt:
312
+ clean_prompt = _ensure_prompt_text(None, media_tuple[1])
313
+ if youtube_url_to_analyze and not clean_prompt:
314
+ clean_prompt = "Подробно перескажи и опиши содержание этого YouTube-видео."
315
+
316
+ if not clean_prompt and not media_tuple and not youtube_url_to_analyze:
317
+ if mentioned:
318
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "status_listening"))
319
+ return
320
+
321
+ # Отправка typing экшена
322
+ try:
323
+ if not is_guest:
324
+ await bot.send_chat_action(chat_id=message.chat.id, action="typing")
325
+ except Exception:
326
+ pass
327
+
328
+ media_mime = media_tuple[1] if media_tuple else None
329
+ is_heavy = _looks_like_heavy_query(clean_prompt)
330
+ needs_freshness = _looks_like_freshness_query(clean_prompt)
331
+ route = _build_route(
332
+ needs_youtube=needs_youtube, needs_website=needs_website,
333
+ media_mime=media_mime, is_heavy=is_heavy, needs_freshness=needs_freshness,
334
+ )
335
+ log.info(
336
+ '[router] chat=%s heavy=%s freshness=%s youtube=%s website=%s media=%s route=%s',
337
+ message.chat.id, is_heavy, needs_freshness, needs_youtube, needs_website, media_mime,
338
+ [f"{p}:{m}" for p, m in route],
339
+ )
340
+
341
+ ai_prompt = clean_prompt
342
+ if (
343
+ media_tuple is None
344
+ and not extra_media
345
+ and youtube_url_to_analyze is None
346
+ and _media_reference_category(clean_prompt) is not None
347
+ ):
348
+ # Прод-кейс 17.09.2026: "что на фото?" без файла — модель выдумала
349
+ # подробное описание несуществующего скриншота, хотя промпт требует
350
+ # обратного. Разрешение резолвинга уже позади (приоритеты 1–3 ничего не
351
+ # нашли), поэтому этот факт сообщаем модели явно: слабым моделям одного
352
+ # раздела промпта не хватает, а с пометкой перед глазами сочинять
353
+ # сложнее. Ветка истории ("то фото" с текстовым описанием выше)
354
+ # сознательно НЕ отрезается — пометка велит проверить её сначала.
355
+ ai_prompt += _NO_MEDIA_NOTE
356
+ gemini_media_list = ([media_tuple] if media_tuple else []) + list(extra_media or [])
357
+ gemini_media_list = gemini_media_list or None
358
+ # Стриминг ("живой" эффект печати) имеет смысл только для простого
359
+ # текстового обмена без вложений/YouTube — см. _run_route.
360
+ allow_stream = not gemini_media_list and not youtube_url_to_analyze
361
+ try:
362
+ ans, reply_already_sent = await bot._run_route(
363
+ message.chat.id, ai_prompt, route, message,
364
+ media=gemini_media_list, media_filename=med_name,
365
+ youtube_url=youtube_url_to_analyze, allow_stream=allow_stream,
366
+ )
367
+ if not reply_already_sent:
368
+ await bot._safe_reply(message, ans)
369
+ bot.mark_state_dirty(message.chat.id)
370
+ except Exception as exc:
371
+ if isinstance(exc, (bot.GeminiAllModelsExhaustedError, bot.RouteBudgetExceededError)):
372
+ # Известный, уже обрабатываемый исход (реальный суточный лимит квоты /
373
+ # общий бюджет времени маршрута исчерпан) — не баг, а штатная деградация,
374
+ # для которой пользователь и так получает понятный текст ниже, а владелец
375
+ # (в случае квоты) отдельно уведомляется через _maybe_alert_gemini_exhausted.
376
+ # log.exception на КАЖДЫЙ такой случай безусловно заводил issue в Sentry —
377
+ # см. LUMEN-3 (аудит логирования): 8 событий за месяц оказались этим
378
+ # классом, маскируя реальные новые баги среди ожидаемого шума.
379
+ log.warning("Chat AI processing hit a known, already-handled outcome: %s", exc)
380
+ else:
381
+ log.exception("Chat AI processing failed:")
382
+ head_model = route[0][1] if route else bot.DEFAULT_GEMINI_MODEL
383
+ if isinstance(exc, bot.GeminiAllModelsExhaustedError):
384
+ await bot._maybe_alert_gemini_exhausted()
385
+ 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)))
386
+ finally:
387
+ if med_path and os.path.exists(med_path):
388
+ with contextlib.suppress(Exception):
389
+ os.unlink(med_path)
390
+
391
+
392
+ async def _process_raw_update(raw_update: dict) -> None:
393
+ import bot
394
+ if not isinstance(raw_update, dict):
395
+ return
396
+ try:
397
+ guest = raw_update.get("guest_message")
398
+ if isinstance(guest, dict):
399
+ try:
400
+ msg_obj = Message.model_validate(guest, context={"bot": bot})
401
+ gq_id = guest.get("guest_query_id")
402
+ if gq_id is not None and not getattr(msg_obj, "guest_query_id", None):
403
+ with contextlib.suppress(Exception):
404
+ object.__setattr__(msg_obj, "guest_query_id", gq_id)
405
+ await bot._handle_message_core(msg_obj)
406
+ except Exception as exc:
407
+ log.warning("[guest] Guest processing failed: %s", exc)
408
+ return
409
+ upd = Update.model_validate(raw_update, context={"bot": bot})
410
+ await bot.dp.feed_update(bot.bot, upd)
411
+ except Exception as exc:
412
+ log.warning("[update] Raw update processing failed: %s", exc)