""" 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стикер\w*)|" r"(?P