Lumen / lumen_message_parse.py
SilverElixir
Media lows: safe mime suffix, exact game forms, ext map, WAV check, album skip log, mg cap (A6-5..A6-11, A1-5)
e8e379a
Raw History Blame Contribute Delete
15.1 kB
"""
lumen_message_parse.py — разбор входящих сообщений: ссылки, триггерные фразы
/draw и /tts без слэша, маркеры реплая, отсылки к медиа, пометка "файла нет".
Чистые функции и конфиги, без зависимости от рантайма бота.
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import urlparse
_TIKTOK_RE = re.compile(r"(?:^|\.)tiktok\.com$")
_YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be"}
def extract_url(text: str) -> str | None:
"""Первая ссылка в тексте. Вторая и далее игнорируются: TikTok/YouTube-пути
берут одну ссылку (см. match-обработку в message_core)."""
m = re.search(r"https?://[^\s]+", text)
if not m:
return None
return m.group(0).rstrip(".,!?;:)>]'\"")
def is_tiktok(url: str) -> bool:
try:
host = urlparse(url).hostname or ""
return bool(_TIKTOK_RE.search(host.lower()))
except Exception:
return False
def is_youtube(url: str) -> bool:
try:
host = (urlparse(url).hostname or "").lower()
return host in _YOUTUBE_HOSTS
except Exception:
return False
# Триггеры только startswith: иначе "как нарисовать..." ложно запустит генерацию.
DRAW_TRIGGER_PREFIXES = [
"сгенерируй картинку", "сгенерируй мне картинку", "сгенерируй изображение",
"сгенерируй мне изображение", "создай картинку", "создай мне картинку",
"создай изображение", "создай мне изображение", "нарисуй картинку", "нарисуй изображение",
"нарисуй мне", "нарисуй", "нарисуйте", "изобрази картинку", "изобрази", "нарисуй-ка",
"сгенери картинку", "сгенери изображение", "можешь нарисовать", "можешь нарисовать мне",
# EN: /start обещает "draw a cat" — чиним (найдено внешним аудитом: был только русский).
"generate an image", "generate me an image", "create an image", "draw a picture",
"draw me", "draw an", "draw a", "paint me", "paint", "sketch",
]
# "хочу картинку" не триггер: путается с показом/редактурой. Длинные фразы выше коротких, иначе вернётся первое совпадение.
TTS_TRIGGER_PREFIXES = [
"озвучьте", "озвучь текст", "озвучь мне", "озвуч текст", "озвучь", "озвуч",
"переведи текст в голос", "переведи слова в голос", "переведи в голос",
"переведи в аудио", "произнеси текст", "произнеси", "проговори",
"скажи голосом", "переведи текст в аудио", "переведи слова в аудио",
"преврати в аудио", "преврати текст в аудио", "преврати это в аудио",
"конвертируй в аудио", "переведи в звук", "начитай текст", "начитай",
"зачитай текст", "зачитай", "зачитайте текст", "зачитайте",
"прочитай вслух", "прочти вслух", "прочтите вслух", "прочтите", "прочти",
"сделай аудио", "сделай голосовое", "запиши голосовое",
# EN: /start обещает "read this out loud" (найдено внешним аудитом).
"read this out loud", "read it out loud", "read out loud", "read aloud",
"voice this", "voice it", "speak this", "say this aloud", "narrate this",
]
def _match_trigger_prefix(text_lower: str, prefixes: list[str]) -> str | None:
"""Первый триггер в начале строки. Нужна граница слова, иначе "прочтите"/"нарисуйка" дадут битую генерацию."""
for prefix in prefixes:
if not text_lower.startswith(prefix):
continue
rest = text_lower[len(prefix):]
if rest and rest[0].isalpha():
continue
return prefix
return None
# Пустышки "это/этот текст" значат реплай. Найдено 09.2026: "нарисуй это" рисовало слово "это".
_REPLY_MARKER_RE = re.compile(
r"^(это|этот|эта|эту|эти|этого|этому|этим|этих)"
r"(\s+(текст|сообщение|пост|файл|картинк\w*|изображени\w*|видео|фото))?$",
re.IGNORECASE,
)
def _strip_reply_marker(content: str) -> str:
"""Убирает указательную пустышку из остатка после триггера: если кроме неё
ничего нет — имелся в виду реплай (возвращает ""). Иначе возвращает остаток
как есть для буквального использования. Хвостовая пунктуация ("это.",
"это!") перед проверкой срезается — иначе "озвучь это." чинился бы только
без точки (найдено код-ревью)."""
if _REPLY_MARKER_RE.match(content.strip().rstrip(".,!?:;—-").strip()):
return ""
return content
# Пометка "файла нет" на один ход модели, в историю не пишем: иначе старые пометки всплывут в следующих ходах.
_NO_MEDIA_NOTE = (
"\n\n[Служебная пометка — это не слова пользователя: к сообщению не "
"прикреплён файл, и среди недавних файлов чата подходящего нет. Если "
"в истории выше нет твоего собственного описания именно этого медиа — "
"честно скажи, что не видишь файла, и попроси прислать его. "
"Ничего не выдумывай.]"
)
def _history_user_text(user_text: str) -> str:
"""Текст user-реплики для записи в историю: срезает одноразовую служебную
пометку (см. _NO_MEDIA_NOTE), если она есть в хвосте. Самому текущему
запросу к модели пометка уже ушла — здесь остаётся чистый текст."""
if user_text.endswith(_NO_MEDIA_NOTE):
return user_text[:-len(_NO_MEDIA_NOTE)]
return user_text
# Только явные существительные медиа: общие "это/покажи" тянули чужое фото и давали галлюцинации.
# Именованные группы — чтобы понять КАКОЙ тип спросили (регрессия 18.08.2026: "покажи стикер" описывал чужое фото).
_MEDIA_REFERENCE_RE = re.compile(
r"\b(?:"
r"(?P<sticker>стикер\w*)|"
r"(?P<video>видео\w*|видос\w*|ролик\w*|клип\w*|кружок\w*|gif\w*|гиф\w*)|"
r"(?P<audio>аудио\w*|голосов\w*|войс\w*)|"
r"(?P<photo>фото\w*|снимок\w*|изображени\w*|скрин\w*|скриншот\w*|картинк\w*)|"
r"(?P<document>документ\w*|pdf|пдф|файл\w*)"
r")\b",
re.IGNORECASE,
)
def _media_reference_category(text: str) -> str | None:
"""Какой ТИП медиа упомянут в тексте ("фото"/"видео"/"аудио"/"стикер"/
"документ"), если вообще упомянут — None, если явного упоминания нет
(см. _MEDIA_REFERENCE_RE).
Используется в _resolve_incoming_media (приоритет №3), чтобы искать в
recent_media_ids медиа ИМЕННО запрошенного типа, а не слепо последний файл
независимо от того, что реально спросили."""
if not text:
return None
m = _MEDIA_REFERENCE_RE.search(text)
if not m:
return None
return next(name for name, val in m.groupdict().items() if val is not None)
def _looks_like_media_reference(text: str) -> bool:
"""Требует явного упоминания конкретного типа медиа (фото/видео/аудио/стикер и
т.п.) — иначе см. регрессию выше. Тестируется отдельно от _handle_message_core."""
return _media_reference_category(text) is not None
def _mime_matches_media_category(mime: str, category: str) -> bool:
"""Mime подходит категории: стикер только image/webp, фото пережимается в JPEG, этого хватает без объекта Sticker."""
low = (mime or "").lower()
if category == "sticker":
return low == "image/webp"
if category == "photo":
return low.startswith("image/") and low != "image/webp"
if category in ("video", "audio"):
return low.startswith(f"{category}/")
if category == "document":
# PDF/текстовые/офисные файлы + octet-stream (неопознанный бинарник —
# честная ошибка пользователю всё равно придёт позже из ask_gemini через
# _is_gemini_supported_mime, а не молчаливое "не нашёл файл").
return low.startswith(("application/", "text/")) or low == "application/octet-stream"
return False
# ── Кнопки-уточнения (pick-сценарии) ──
# Pick вместо гадания модели: глагол вкуса + тема + коротко, длинные с деталями идут в модель.
_PICK_VERBS = (
"посоветуй", "порекомендуй", "подскажи", "накидай", "придумай",
"recommend", "suggest", "advise",
)
_PICK_TOPICS: dict[str, str] = {
"фильм": "film", "фильмы": "film", "фильмов": "film",
"сериал": "series", "сериалы": "series",
"музык": "music", "песн": "music", "трек": "music",
"книг": "books", "книж": "books", "книжку": "books",
"игрушку": "games",
"movie": "film", "movies": "film", "film": "film", "films": "film",
"series": "series", "show": "series", "shows": "series",
"music": "music", "song": "music", "songs": "music",
"track": "music", "tracks": "music",
"book": "books", "books": "books",
"game": "games", "games": "games",
}
# Игровые формы — только целиком с двух сторон: префикс "игр" ловил глаголы
# ("кто играет сегодня" — не запрос игры). Инфинитивы оставляем: "во что поиграть" — игры.
_PICK_EXACT_TOPICS: dict[str, str] = {
"игра": "games", "игры": "games", "игру": "games", "игрой": "games",
"игре": "games", "игр": "games", "играть": "games", "поиграть": "games",
}
_PICK_MAX_LEN = 60
PICK_QUESTIONS: dict[str, str] = {
"film": "Что сегодня хочется?",
"series": "Что сегодня хочется?",
"music": "Какое настроение?",
"books": "Что сегодня хочется?",
"games": "Во что хочется?",
}
PICK_OPTIONS: dict[str, list[str]] = {
"film": ["Лёгкое и весёлое", "Драма", "Триллер", "Фантастика"],
"series": ["Лёгкое и весёлое", "Драма", "Детектив", "Фантастика"],
"music": ["Энергичное", "Спокойное", "Грустное", "Весёлое"],
"books": ["Фантастика", "Детектив", "Нон-фикшн", "Классика"],
"games": ["Экшен", "Стратегия", "RPG", "Головоломка"],
}
# Как выбор дописывается к исходному запросу перед обычным маршрутом.
PICK_CHOICE_TEMPLATES: dict[str, str] = {
"film": "{original} (жанр: {choice})",
"series": "{original} (жанр: {choice})",
"music": "{original} (настроение: {choice})",
"books": "{original} (жанр: {choice})",
"games": "{original} (жанр: {choice})",
}
def match_pick_request(text_lower: str) -> str | None:
"""Возвращает id pick-сценария (film/music/...), если текст — вкусовой
запрос без деталей, иначе None. Проверяется в _handle_message_core до
маршрута к ИИ: совпавшим запросам показываются кнопки вместо гадания."""
text = text_lower.strip()
if not text or len(text) > _PICK_MAX_LEN:
return None
# Детали (год, перечисления через запятую) — обычным путём в модель:
# у такого запроса уже есть вкус, кнопки не нужны.
if re.search(r"\b(19|20)\d{2}\b", text) or text.count(",") >= 2:
return None
if not any(v in text for v in _PICK_VERBS):
return None
for topic, scenario in _PICK_EXACT_TOPICS.items():
if re.search(r"(?<!\w)" + re.escape(topic) + r"(?!\w)", text):
return scenario
for topic, scenario in _PICK_TOPICS.items():
# Латиница строго \b, кириллица префиксом (AUD-E-006): "тигр" не игры.
if topic.isascii():
if re.search(r"\b" + re.escape(topic) + r"\b", text):
return scenario
elif re.search(r"(?<!\w)" + re.escape(topic), text):
return scenario
return None
def _find_recent_media_by_category(bucket: Any, category: str) -> tuple[str, str] | None:
"""Последний элемент бакета ПОДХОДЯЩЕГО типа (не просто последний): "покажи стикер" при [фото] даёт None."""
if not bucket:
return None
for fid, mime in reversed(bucket):
if _mime_matches_media_category(mime, category):
return fid, mime
return None