From dcf44413400c5c7dafb31822d5ea6da9052c253a Mon Sep 17 00:00:00 2001 From: kislovdm Date: Mon, 20 Apr 2026 18:55:01 +0300 Subject: [PATCH] work with files --- src/bridge.py | 138 +++++++++++++++++++++++++++++++++++++++--- src/main.py | 3 +- src/max_parser.py | 14 ++++- src/models.py | 2 + src/reverse_bridge.py | 42 +++++++++++-- src/telegram_api.py | 18 ++++++ 6 files changed, 199 insertions(+), 18 deletions(-) diff --git a/src/bridge.py b/src/bridge.py index e2f4595..77213e4 100644 --- a/src/bridge.py +++ b/src/bridge.py @@ -34,11 +34,6 @@ class MaxToTelegramBridge: # - если найден целевой Telegram-чат (не fallback): "Ирина:\n<текст>" # - если fallback: "Ирина / Свободный микрофон:\n<текст>" # Решение о том, включать ли название чата, принимаем после определения маршрута. - has_any_payload = bool((parsed.text or "").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) - return - normalized = parsed.chat_name.strip().casefold() routed = self._storage.get_chat_route(max_chat_title_norm=normalized) if routed: @@ -64,6 +59,7 @@ class MaxToTelegramBridge: text=parsed.text, include_chat_name=is_fallback, ) + text = self._append_unknown_attachment_notice(parsed=parsed, text=text) reply_telegram_mid = self._resolve_telegram_reply_to( telegram_chat_id=str(target_chat_id), @@ -73,12 +69,12 @@ class MaxToTelegramBridge: 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) + has_any_payload = bool(text.strip()) or bool(parsed.image_urls) or bool(parsed.video_urls) or bool(parsed.file_urls) if not has_any_payload: - logger.debug("Skip empty message %s/%s", parsed.chat_id, parsed.message_id) - return + text = self._build_fallback_unknown_notice(parsed) total_media = len(parsed.image_urls) + len(parsed.video_urls) + sent_any = False if total_media > 1: # Отправляем одним альбомом в Telegram (единое сообщение). target_chat_id, sent_messages = await self._send_with_migration_retry( @@ -103,6 +99,7 @@ class MaxToTelegramBridge: max_chat_id=str(parsed.chat_id), max_message_id=str(parsed.message_id), ) + sent_any = True self._storage.mark_forwarded(parsed.message_id, parsed.chat_id) logger.info( "Forwarded media group %s/%s (images=%s, videos=%s)", @@ -113,7 +110,6 @@ class MaxToTelegramBridge: ) return - sent_any = False if parsed.text.strip() and total_media == 0: target_chat_id, sent = await self._send_with_migration_retry( target_chat_id=target_chat_id, @@ -179,15 +175,80 @@ class MaxToTelegramBridge: ) sent_any = True + for index, file_url in enumerate(parsed.file_urls): + caption = text if not sent_any and index == 0 else None + target_chat_id, sent = await self._send_with_migration_retry( + target_chat_id=target_chat_id, + max_chat_title_norm=normalized, + max_chat_title=parsed.chat_name, + send_action=lambda chat_id: self._telegram.send_document( + chat_id, + file_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( + telegram_chat_id=str(target_chat_id), + telegram_message_id=str(mid), + max_chat_id=str(parsed.chat_id), + max_message_id=str(parsed.message_id), + ) + sent_any = True + + if not sent_any: + # Последняя страховка: гарантируем уведомление в Telegram даже для пустых/неизвестных payload. + target_chat_id, sent = await self._send_with_migration_retry( + target_chat_id=target_chat_id, + max_chat_title_norm=normalized, + max_chat_title=parsed.chat_name, + send_action=lambda chat_id: self._telegram.send_text( + chat_id, self._build_fallback_unknown_notice(parsed), 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( + telegram_chat_id=str(target_chat_id), + telegram_message_id=str(mid), + max_chat_id=str(parsed.chat_id), + max_message_id=str(parsed.message_id), + ) + sent_any = True + self._storage.mark_forwarded(parsed.message_id, parsed.chat_id) logger.info( - "Forwarded message %s/%s (images=%s, videos=%s)", + "Forwarded message %s/%s (images=%s, videos=%s, files=%s, unknown=%s)", parsed.chat_id, parsed.message_id, len(parsed.image_urls), len(parsed.video_urls), + len(parsed.file_urls), + len(parsed.unknown_attachments), ) + async def notify_delivery_failure(self, max_message: Any, error: Exception) -> None: + """Best-effort аварийное уведомление, если основной форвардинг упал.""" + try: + parsed = parse_message(max_message) + parsed = await self._enrich_from_max(max_message, parsed) + body = self._build_fallback_unknown_notice(parsed) + body = f"{body}\n\n[bridge-error] {type(error).__name__}: {error}" + except Exception: + body = f"[!] Сообщение из MAX не доставлено в Telegram из-за ошибки bridge: {type(error).__name__}: {error}" + + fallback_chat_id = self._telegram.fallback_user_id + if not fallback_chat_id: + logger.error("Cannot send emergency notice: Telegram fallback user id is empty") + return + try: + await self._telegram.send_text(chat_id=fallback_chat_id, text=body) + logger.warning("Sent emergency notice to Telegram fallback chat %s", fallback_chat_id) + except Exception: + logger.exception("Cannot send emergency notice to Telegram") + async def _send_with_migration_retry( self, *, @@ -293,10 +354,18 @@ class MaxToTelegramBridge: parsed.video_urls.append(str(video_url)) except Exception: logger.exception("Cannot resolve video URL from Max") + else: + urls = self._extract_any_urls(attach) + if urls: + parsed.file_urls.extend(urls) + else: + parsed.unknown_attachments.append(type(attach).__name__) # Убираем дубли URL, если парсер и enrich нашли одинаковые вложения. parsed.image_urls = list(dict.fromkeys(parsed.image_urls)) parsed.video_urls = list(dict.fromkeys(parsed.video_urls)) + parsed.file_urls = list(dict.fromkeys(parsed.file_urls)) + parsed.unknown_attachments = list(dict.fromkeys(parsed.unknown_attachments)) return parsed def _is_self_message(self, max_message: Any) -> bool: @@ -345,3 +414,52 @@ class MaxToTelegramBridge: walk(attach) return list(dict.fromkeys(urls)) + + def _extract_any_urls(self, node: Any) -> list[str]: + urls: list[str] = [] + seen_ids: set[int] = set() + + def walk(value: Any) -> None: + if value is None: + return + obj_id = id(value) + if obj_id in seen_ids: + return + seen_ids.add(obj_id) + + if isinstance(value, str): + if value.startswith("http://") or value.startswith("https://"): + urls.append(value) + return + if isinstance(value, (list, tuple, set)): + for item in value: + walk(item) + return + if isinstance(value, dict): + for nested in value.values(): + walk(nested) + return + if hasattr(value, "__dict__"): + walk(vars(value)) + + walk(node) + return list(dict.fromkeys(urls)) + + @staticmethod + def _append_unknown_attachment_notice(*, parsed: ParsedMessage, text: str) -> str: + if not parsed.unknown_attachments: + return text + unknown_preview = ", ".join(parsed.unknown_attachments[:5]) + suffix = f"\n\n[!] Неизвестный тип вложения из MAX: {unknown_preview}" + return f"{text}{suffix}" if text else suffix.strip() + + @staticmethod + def _build_fallback_unknown_notice(parsed: ParsedMessage) -> str: + base = MaxToTelegramBridge._format_caption( + sender_name=parsed.sender_name, + chat_name=parsed.chat_name, + text=parsed.text, + include_chat_name=True, + ) + unknown = ", ".join(parsed.unknown_attachments[:5]) if parsed.unknown_attachments else "unknown" + return f"{base}\n\n[!] Неизвестный или пустой тип сообщения из MAX (attachments={unknown})." diff --git a/src/main.py b/src/main.py index 4520071..e8ef232 100644 --- a/src/main.py +++ b/src/main.py @@ -72,8 +72,9 @@ def main() -> None: health.mark_max_event() try: await bridge.forward_message(message) - except Exception: + except Exception as exc: logger.exception("Failed to forward Max message") + await bridge.notify_delivery_failure(message, exc) asyncio.run(max_client.start()) diff --git a/src/max_parser.py b/src/max_parser.py index 0711c79..0b9f5c5 100644 --- a/src/max_parser.py +++ b/src/max_parser.py @@ -36,9 +36,11 @@ def _is_video(media_type: str) -> bool: return "video" in value or value in {"mp4", "mov", "mkv", "avi"} -def _extract_media_urls(message: Any) -> tuple[list[str], list[str]]: +def _extract_media_urls(message: Any) -> tuple[list[str], list[str], list[str], list[str]]: image_urls: list[str] = [] video_urls: list[str] = [] + file_urls: list[str] = [] + unknown_attachments: list[str] = [] # В PyMax рабочее поле для вложений обычно называется attaches. raw_attachments = _get_attr(message, ["attaches", "attachments", "media", "files"], default=[]) or [] @@ -67,14 +69,18 @@ def _extract_media_urls(message: Any) -> tuple[list[str], list[str]]: media_type = _stringify(nested_data.get("type") or nested_data.get("media_type")) if not url: + kind = media_type or _stringify(type(item).__name__) or "unknown" + unknown_attachments.append(kind) continue if _is_image(media_type): image_urls.append(url) elif _is_video(media_type): video_urls.append(url) + else: + file_urls.append(url) - return image_urls, video_urls + return image_urls, video_urls, file_urls, unknown_attachments def _extract_max_reply(message: Any) -> tuple[str | None, str | None]: @@ -115,7 +121,7 @@ def parse_message(message: Any) -> ParsedMessage: chat_id = _stringify(_get_attr(message, ["chat_id", "dialog_id", "peer_id"])) or "unknown-chat" text = _stringify(_get_attr(message, ["text", "message", "body"])) - image_urls, video_urls = _extract_media_urls(message) + image_urls, video_urls, file_urls, unknown_attachments = _extract_media_urls(message) reply_mid, reply_preview = _extract_max_reply(message) return ParsedMessage( message_id=message_id, @@ -125,6 +131,8 @@ def parse_message(message: Any) -> ParsedMessage: text=text, image_urls=image_urls, video_urls=video_urls, + file_urls=file_urls, + unknown_attachments=unknown_attachments, reply_to_max_message_id=reply_mid, reply_preview_text=reply_preview, ) diff --git a/src/models.py b/src/models.py index f2234e8..3dd08ca 100644 --- a/src/models.py +++ b/src/models.py @@ -10,6 +10,8 @@ class ParsedMessage: text: str image_urls: list[str] = field(default_factory=list) video_urls: list[str] = field(default_factory=list) + file_urls: list[str] = field(default_factory=list) + unknown_attachments: 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/reverse_bridge.py b/src/reverse_bridge.py index 76ce5b6..7d64cba 100644 --- a/src/reverse_bridge.py +++ b/src/reverse_bridge.py @@ -64,6 +64,11 @@ def _is_supported_telegram_message(message: dict[str, Any]) -> bool: if has_video: return True + for key in ("document", "audio", "voice", "animation", "sticker", "video_note"): + value = message.get(key) + if isinstance(value, dict) and value.get("file_id"): + return True + return False @@ -314,8 +319,13 @@ class TelegramToMaxBridge: reply_to = self._resolve_reply_to_max_id(max_chat_id=max_chat_id, message=messages[0]) attachments: list[Any] = [] + extra_links: list[str] = [] for m in messages: - attachments.extend(await self._extract_attachments(m)) + extracted, links = await self._extract_attachments(m) + attachments.extend(extracted) + extra_links.extend(links) + + text = self._append_file_links(text=text, links=extra_links) if not text.strip() and not attachments: return @@ -375,7 +385,8 @@ class TelegramToMaxBridge: ) -> None: raw_text = str(message.get("text") or message.get("caption") or "").strip() text = _format_forward_text(sender=message.get("from"), text=raw_text) - attachments = await self._extract_attachments(message) + attachments, extra_links = await self._extract_attachments(message) + text = self._append_file_links(text=text, links=extra_links) if not text.strip() and not attachments: return @@ -430,8 +441,9 @@ class TelegramToMaxBridge: # reply_to в MAX — это id сообщения; если не нашли, просто отправляем без reply return mapped - async def _extract_attachments(self, message: dict[str, Any]) -> list[Any]: + async def _extract_attachments(self, message: dict[str, Any]) -> tuple[list[Any], list[str]]: attachments: list[Any] = [] + file_links: list[str] = [] # photo: массив размеров, берём последний (самый большой) photos = message.get("photo") @@ -459,7 +471,29 @@ class TelegramToMaxBridge: except Exception: logger.exception("Cannot fetch Telegram video URL") - return attachments + for key in ("document", "audio", "voice", "animation", "sticker", "video_note"): + value = message.get(key) + if not isinstance(value, dict): + continue + file_id = str(value.get("file_id") or "") + if not file_id: + continue + try: + url = await self._telegram.get_file_url(file_id) + file_links.append(url) + except Exception: + logger.exception("Cannot fetch Telegram %s URL", key) + + return attachments, file_links + + @staticmethod + def _append_file_links(*, text: str, links: list[str]) -> str: + uniq_links = list(dict.fromkeys([str(link).strip() for link in links if str(link).strip()])) + if not uniq_links: + return text + links_block = "\n".join(f"- {url}" for url in uniq_links) + suffix = f"\n\n[Telegram files]\n{links_block}" + return f"{text}{suffix}" if text else suffix.strip() def _refresh_max_chat_cache(self) -> None: title_to_id: dict[str, int] = {} diff --git a/src/telegram_api.py b/src/telegram_api.py index ba249a6..5129b65 100644 --- a/src/telegram_api.py +++ b/src/telegram_api.py @@ -103,6 +103,24 @@ class TelegramClient: payload["reply_to_message_id"] = reply_to_message_id return await self._request("sendVideo", payload) + async def send_document( + self, + chat_id: str, + document_url: str, + caption: str | None = None, + *, + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "chat_id": chat_id, + "document": document_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("sendDocument", payload) + async def send_media_group( self, chat_id: str,