Spaces:
Running
Running
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- bot.py +45 -358
- 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 |
-
|
| 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 |
-
|
| 1730 |
-
|
| 1731 |
-
|
| 1732 |
-
|
| 1733 |
-
|
| 1734 |
-
|
| 1735 |
-
|
| 1736 |
-
|
| 1737 |
-
|
| 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)
|