From 54cb857f583bffe3e4a26c2786f94763994ed7ce Mon Sep 17 00:00:00 2001 From: kislovdm Date: Thu, 9 Apr 2026 23:15:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=82=D1=80=D0=B5=D0=B4=D1=8B=20=D0=BC=D0=B0?= =?UTF-8?q?=D0=BA=D1=81=D0=B0=20=D0=B2=20=D1=82=D0=B5=D0=BB=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BC=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bridge.py | 54 ++++++++++++++++++++++++++++++++++++++--- src/max_parser.py | 20 +++++++++++++++ src/models.py | 3 +++ src/storage.py | 19 +++++++++++++++ src/telegram_api.py | 59 +++++++++++++++++++++++++++++++++------------ 5 files changed, 137 insertions(+), 18 deletions(-) diff --git a/src/bridge.py b/src/bridge.py index eb8237f..fa359ee 100644 --- a/src/bridge.py +++ b/src/bridge.py @@ -63,6 +63,15 @@ class MaxToTelegramBridge: text=parsed.text, include_chat_name=is_fallback, ) + + reply_telegram_mid = self._resolve_telegram_reply_to( + telegram_chat_id=str(target_chat_id), + max_chat_id=str(parsed.chat_id), + parsed=parsed, + ) + if parsed.reply_to_max_message_id and reply_telegram_mid is None: + text = self._prepend_max_reply_context(parsed, text) + has_any_payload = bool(text.strip()) or bool(parsed.image_urls) or bool(parsed.video_urls) if not has_any_payload: logger.debug("Skip empty message %s/%s", parsed.chat_id, parsed.message_id) @@ -76,6 +85,7 @@ class MaxToTelegramBridge: image_urls=parsed.image_urls, video_urls=parsed.video_urls, caption=text, + reply_to_message_id=reply_telegram_mid, ) for sent in sent_messages: mid = sent.get("message_id") @@ -99,7 +109,9 @@ class MaxToTelegramBridge: sent_any = False if parsed.text.strip() and total_media == 0: - sent = await self._telegram.send_text(target_chat_id, text) + sent = await self._telegram.send_text( + target_chat_id, text, reply_to_message_id=reply_telegram_mid + ) mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None if mid is not None: self._storage.save_mapping( @@ -112,7 +124,12 @@ class MaxToTelegramBridge: for index, image_url in enumerate(parsed.image_urls): caption = text if not sent_any and index == 0 else None - sent = await self._telegram.send_photo(target_chat_id, image_url, caption=caption) + sent = await self._telegram.send_photo( + target_chat_id, + image_url, + caption=caption, + reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None, + ) mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None if mid is not None: self._storage.save_mapping( @@ -125,7 +142,12 @@ class MaxToTelegramBridge: for index, video_url in enumerate(parsed.video_urls): caption = text if not sent_any and index == 0 else None - sent = await self._telegram.send_video(target_chat_id, video_url, caption=caption) + sent = await self._telegram.send_video( + target_chat_id, + video_url, + caption=caption, + reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None, + ) mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None if mid is not None: self._storage.save_mapping( @@ -145,6 +167,32 @@ class MaxToTelegramBridge: len(parsed.video_urls), ) + def _resolve_telegram_reply_to( + self, *, telegram_chat_id: str, max_chat_id: str, parsed: ParsedMessage + ) -> int | None: + if not parsed.reply_to_max_message_id: + return None + raw = self._storage.get_telegram_message_id_for_max( + telegram_chat_id=telegram_chat_id, + max_chat_id=max_chat_id, + max_message_id=str(parsed.reply_to_max_message_id), + ) + if not raw: + return None + try: + return int(raw) + except ValueError: + return None + + @staticmethod + def _prepend_max_reply_context(parsed: ParsedMessage, body: str) -> str: + """Если в Telegram нет исходного сообщения — сохраняем контекст ответа текстом.""" + prev = (parsed.reply_preview_text or "").strip() + if prev: + quoted = "\n".join(f"> {line}" for line in prev.splitlines()[:25]) + return f"↪ ответ в MAX:\n{quoted}\n\n{body}" + return f"↪ ответ в MAX (сообщение id={parsed.reply_to_max_message_id})\n\n{body}" + @staticmethod def _format_caption(*, sender_name: str, chat_name: str, text: str, include_chat_name: bool) -> str: sender_name = (sender_name or "").strip() or "unknown" diff --git a/src/max_parser.py b/src/max_parser.py index fb4b220..0711c79 100644 --- a/src/max_parser.py +++ b/src/max_parser.py @@ -77,6 +77,23 @@ def _extract_media_urls(message: Any) -> tuple[list[str], list[str]]: return image_urls, video_urls +def _extract_max_reply(message: Any) -> tuple[str | None, str | None]: + """Извлекает id сообщения MAX, на которое ответили, и короткий превью-текст (если есть).""" + link = _get_attr(message, ["link"], default=None) + if link is None: + return None, None + inner = _get_attr(link, ["message"], default=None) + if inner is None: + return None, None + mid = _stringify(_get_attr(inner, ["id", "message_id", "mid"])) + if not mid: + return None, None + preview = _stringify(_get_attr(inner, ["text", "message", "body"])) + if preview and len(preview) > 500: + preview = preview[:497] + "..." + return mid, preview or None + + def parse_message(message: Any) -> ParsedMessage: sender = _get_attr(message, ["sender", "sender_name", "author"], default="unknown") sender_data = _as_dict(sender) @@ -99,6 +116,7 @@ def parse_message(message: Any) -> ParsedMessage: text = _stringify(_get_attr(message, ["text", "message", "body"])) image_urls, video_urls = _extract_media_urls(message) + reply_mid, reply_preview = _extract_max_reply(message) return ParsedMessage( message_id=message_id, chat_id=chat_id, @@ -107,4 +125,6 @@ def parse_message(message: Any) -> ParsedMessage: text=text, image_urls=image_urls, video_urls=video_urls, + reply_to_max_message_id=reply_mid, + reply_preview_text=reply_preview, ) diff --git a/src/models.py b/src/models.py index 53a8be5..f2234e8 100644 --- a/src/models.py +++ b/src/models.py @@ -10,3 +10,6 @@ class ParsedMessage: text: str image_urls: list[str] = field(default_factory=list) video_urls: list[str] = field(default_factory=list) + # Ответ в MAX: Message.link указывает на исходное сообщение (тред). + reply_to_max_message_id: str | None = None + reply_preview_text: str | None = None diff --git a/src/storage.py b/src/storage.py index 8bb85e3..3d9f439 100644 --- a/src/storage.py +++ b/src/storage.py @@ -105,6 +105,25 @@ class BridgeStorage: return None return str(row[0]) + def get_telegram_message_id_for_max( + self, *, telegram_chat_id: str, max_chat_id: str, max_message_id: str + ) -> str | None: + """Обратный поиск: какое сообщение в Telegram соответствует паре MAX chat/message.""" + with closing(self._connect()) as conn: + row = conn.execute( + """ + SELECT telegram_message_id + FROM message_mapping + WHERE telegram_chat_id = ? AND max_chat_id = ? AND max_message_id = ? + ORDER BY telegram_message_id + LIMIT 1 + """, + (telegram_chat_id, max_chat_id, max_message_id), + ).fetchone() + if not row: + return None + return str(row[0]) + def set_chat_route( self, *, diff --git a/src/telegram_api.py b/src/telegram_api.py index f86e7ab..33c7c92 100644 --- a/src/telegram_api.py +++ b/src/telegram_api.py @@ -27,26 +27,48 @@ class TelegramClient: return chat_id, True return self._fallback_user_id, False - async def send_text(self, chat_id: str, text: str) -> dict[str, Any]: - return await self._request( - "sendMessage", - { - "chat_id": chat_id, - "text": text, - "disable_web_page_preview": True, - }, - ) + async def send_text( + self, + chat_id: str, + text: str, + *, + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "chat_id": chat_id, + "text": text, + "disable_web_page_preview": True, + } + if reply_to_message_id is not None: + payload["reply_to_message_id"] = reply_to_message_id + return await self._request("sendMessage", payload) - async def send_photo(self, chat_id: str, photo_url: str, caption: str | None = None) -> dict[str, Any]: + async def send_photo( + self, + chat_id: str, + photo_url: str, + caption: str | None = None, + *, + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: payload: dict[str, Any] = { "chat_id": chat_id, "photo": photo_url, } if caption: payload["caption"] = caption + if reply_to_message_id is not None: + payload["reply_to_message_id"] = reply_to_message_id return await self._request("sendPhoto", payload) - async def send_video(self, chat_id: str, video_url: str, caption: str | None = None) -> dict[str, Any]: + async def send_video( + self, + chat_id: str, + video_url: str, + caption: str | None = None, + *, + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: payload: dict[str, Any] = { "chat_id": chat_id, "video": video_url, @@ -54,6 +76,8 @@ class TelegramClient: } if caption: payload["caption"] = caption + if reply_to_message_id is not None: + payload["reply_to_message_id"] = reply_to_message_id return await self._request("sendVideo", payload) async def send_media_group( @@ -62,6 +86,8 @@ class TelegramClient: image_urls: list[str], video_urls: list[str], caption: str | None = None, + *, + reply_to_message_id: int | None = None, ) -> list[dict[str, Any]]: media: list[dict[str, Any]] = [] @@ -77,12 +103,15 @@ class TelegramClient: if caption: media[0]["caption"] = caption + mg_payload: dict[str, Any] = { + "chat_id": chat_id, + "media": media, + } + if reply_to_message_id is not None: + mg_payload["reply_to_message_id"] = reply_to_message_id data = await self._request( "sendMediaGroup", - { - "chat_id": chat_id, - "media": media, - }, + mg_payload, ) result = data.get("result", []) if not isinstance(result, list):