3 Commits
Author SHA1 Message Date
kislovdm 2d32bf4807 ++
Docker Hub / build-and-push (push) Failing after 13s
2026-04-20 19:19:45 +03:00
kislovdm d019e884b6 обработка forward 2026-04-20 19:13:08 +03:00
kislovdm e40297787e temporary skip max
Docker Hub / build-and-push (push) Failing after 13s
2026-04-20 19:03:44 +03:00
2 changed files with 242 additions and 28 deletions
+165 -27
View File
@@ -5,7 +5,7 @@ from typing import Any
from max_parser import parse_message from max_parser import parse_message
from models import ParsedMessage from models import ParsedMessage
from pymax import MaxClient from pymax import MaxClient
from pymax.types import PhotoAttach, VideoAttach from pymax.types import AudioAttach, FileAttach, Message, PhotoAttach, StickerAttach, VideoAttach
from storage import BridgeStorage from storage import BridgeStorage
from telegram_api import TelegramApiError, TelegramClient from telegram_api import TelegramApiError, TelegramClient
@@ -19,9 +19,9 @@ class MaxToTelegramBridge:
self._storage = storage self._storage = storage
async def forward_message(self, max_message: Any) -> None: async def forward_message(self, max_message: Any) -> None:
if self._is_self_message(max_message): #if self._is_self_message(max_message):
logger.debug("Skip self message %s/%s", getattr(max_message, "chat_id", "?"), getattr(max_message, "id", "?")) # logger.debug("Skip self message %s/%s", getattr(max_message, "chat_id", "?"), getattr(max_message, "id", "?"))
return # return
parsed = parse_message(max_message) parsed = parse_message(max_message)
parsed = await self._enrich_from_max(max_message, parsed) parsed = await self._enrich_from_max(max_message, parsed)
@@ -110,7 +110,8 @@ class MaxToTelegramBridge:
) )
return return
if parsed.text.strip() and total_media == 0: should_send_plain_text = total_media == 0 and not parsed.file_urls and bool(text.strip())
if should_send_plain_text:
target_chat_id, sent = await self._send_with_migration_retry( target_chat_id, sent = await self._send_with_migration_retry(
target_chat_id=target_chat_id, target_chat_id=target_chat_id,
max_chat_title_norm=normalized, max_chat_title_norm=normalized,
@@ -200,12 +201,13 @@ class MaxToTelegramBridge:
if not sent_any: if not sent_any:
# Последняя страховка: гарантируем уведомление в Telegram даже для пустых/неизвестных payload. # Последняя страховка: гарантируем уведомление в Telegram даже для пустых/неизвестных payload.
fallback_text = text.strip() or self._build_fallback_unknown_notice(parsed)
target_chat_id, sent = await self._send_with_migration_retry( target_chat_id, sent = await self._send_with_migration_retry(
target_chat_id=target_chat_id, target_chat_id=target_chat_id,
max_chat_title_norm=normalized, max_chat_title_norm=normalized,
max_chat_title=parsed.chat_name, max_chat_title=parsed.chat_name,
send_action=lambda chat_id: self._telegram.send_text( send_action=lambda chat_id: self._telegram.send_text(
chat_id, self._build_fallback_unknown_notice(parsed), reply_to_message_id=reply_telegram_mid chat_id, fallback_text, reply_to_message_id=reply_telegram_mid
), ),
) )
mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None
@@ -338,28 +340,24 @@ class MaxToTelegramBridge:
except Exception: except Exception:
logger.debug("Cannot resolve chat title", exc_info=True) logger.debug("Cannot resolve chat title", exc_info=True)
attaches = getattr(max_message, "attaches", None) or [] await self._collect_message_attachments(
for attach in attaches: message=max_message,
if isinstance(attach, PhotoAttach): parsed=parsed,
parsed.image_urls.extend(self._extract_photo_urls(attach)) source_tag="root",
elif isinstance(attach, VideoAttach): )
try:
video = await self._max_client.get_video_by_id( link = getattr(max_message, "link", None)
chat_id=max_message.chat_id, linked_message = getattr(link, "message", None)
message_id=max_message.id, if linked_message is not None:
video_id=attach.video_id, if not (parsed.text or "").strip():
linked_text = str(getattr(linked_message, "text", "") or "").strip()
if linked_text:
parsed.text = linked_text
await self._collect_message_attachments(
message=linked_message,
parsed=parsed,
source_tag="forward",
) )
video_url = getattr(video, "url", None)
if video_url:
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 нашли одинаковые вложения. # Убираем дубли URL, если парсер и enrich нашли одинаковые вложения.
parsed.image_urls = list(dict.fromkeys(parsed.image_urls)) parsed.image_urls = list(dict.fromkeys(parsed.image_urls))
@@ -368,6 +366,127 @@ class MaxToTelegramBridge:
parsed.unknown_attachments = list(dict.fromkeys(parsed.unknown_attachments)) parsed.unknown_attachments = list(dict.fromkeys(parsed.unknown_attachments))
return parsed return parsed
async def _collect_message_attachments(
self,
*,
message: Any,
parsed: ParsedMessage,
source_tag: str,
) -> None:
attaches = getattr(message, "attaches", None) or []
message_chat_id = getattr(message, "chat_id", None)
message_id = getattr(message, "id", None)
if message_chat_id is None:
message_chat_id = parsed.chat_id
for attach in attaches:
if isinstance(attach, PhotoAttach):
parsed.image_urls.extend(self._extract_photo_urls(attach))
continue
if isinstance(attach, VideoAttach):
try:
video = await self._max_client.get_video_by_id(
chat_id=message_chat_id,
message_id=message_id,
video_id=attach.video_id,
)
video_url = getattr(video, "url", None)
if video_url:
parsed.video_urls.append(str(video_url))
except Exception:
logger.exception("Cannot resolve video URL from Max (%s)", source_tag)
continue
if isinstance(attach, FileAttach):
resolved = await self._resolve_file_attach_url(
message_chat_id=message_chat_id,
message_id=message_id,
attach=attach,
)
if resolved:
parsed.file_urls.append(resolved)
else:
fallback = str(getattr(attach, "name", "") or "").strip()
if fallback:
parsed.text = self._append_missing_file_note(parsed.text, fallback)
else:
parsed.unknown_attachments.append(type(attach).__name__)
continue
if isinstance(attach, AudioAttach):
audio_url = str(getattr(attach, "url", "") or "").strip()
if audio_url:
parsed.file_urls.append(audio_url)
else:
parsed.unknown_attachments.append(type(attach).__name__)
continue
if isinstance(attach, StickerAttach):
sticker_url = str(getattr(attach, "url", "") or "").strip()
if sticker_url:
parsed.image_urls.append(sticker_url)
else:
parsed.unknown_attachments.append(type(attach).__name__)
continue
# Fallback на случай сырого Attach/нестандартного типа:
if await self._resolve_generic_file_attach(
message_chat_id=message_chat_id,
message_id=message_id,
attach=attach,
parsed=parsed,
):
continue
urls = self._extract_any_urls(attach)
if urls:
parsed.file_urls.extend(urls)
continue
parsed.unknown_attachments.append(type(attach).__name__)
async def _resolve_file_attach_url(self, *, message_chat_id: Any, message_id: Any, attach: FileAttach) -> str | None:
file_id = getattr(attach, "file_id", None)
if file_id is None or message_id is None:
return None
try:
file_info = await self._max_client.get_file_by_id(
chat_id=message_chat_id,
message_id=message_id,
file_id=file_id,
)
url = getattr(file_info, "url", None)
return str(url) if url else None
except Exception:
logger.exception("Cannot resolve file URL from Max (file_id=%s)", file_id)
return None
async def _resolve_generic_file_attach(
self,
*,
message_chat_id: Any,
message_id: Any,
attach: Any,
parsed: ParsedMessage,
) -> bool:
file_id = getattr(attach, "file_id", None)
if file_id is None or message_id is None:
return False
try:
file_info = await self._max_client.get_file_by_id(
chat_id=message_chat_id,
message_id=message_id,
file_id=file_id,
)
url = getattr(file_info, "url", None)
if url:
parsed.file_urls.append(str(url))
return True
except Exception:
logger.debug("Cannot resolve generic file attach from Max", exc_info=True)
return False
def _is_self_message(self, max_message: Any) -> bool: def _is_self_message(self, max_message: Any) -> bool:
sender = getattr(max_message, "sender", None) sender = getattr(max_message, "sender", None)
me = getattr(self._max_client, "me", None) me = getattr(self._max_client, "me", None)
@@ -445,6 +564,25 @@ class MaxToTelegramBridge:
walk(node) walk(node)
return list(dict.fromkeys(urls)) return list(dict.fromkeys(urls))
@staticmethod
def _is_forward_attach_like(attach: Any) -> bool:
name = type(attach).__name__.lower()
if "forward" in name or "share" in name or "quote" in name:
return True
if hasattr(attach, "__dict__"):
keys = {str(k).lower() for k in vars(attach).keys()}
if {"forward", "forwarded", "forwards", "link", "message", "messages", "origin", "payload"} & keys:
return True
return False
@staticmethod
def _append_missing_file_note(current_text: str, file_name: str) -> str:
text = (current_text or "").strip()
note = f"[MAX forwarded file without direct URL] {file_name}"
if not text:
return note
return f"{text}\n{note}"
@staticmethod @staticmethod
def _append_unknown_attachment_notice(*, parsed: ParsedMessage, text: str) -> str: def _append_unknown_attachment_notice(*, parsed: ParsedMessage, text: str) -> str:
if not parsed.unknown_attachments: if not parsed.unknown_attachments:
+76
View File
@@ -36,6 +36,64 @@ def _is_video(media_type: str) -> bool:
return "video" in value or value in {"mp4", "mov", "mkv", "avi"} return "video" in value or value in {"mp4", "mov", "mkv", "avi"}
def _is_forward_like(data: dict[str, Any]) -> bool:
media_type = _stringify(data.get("type") or data.get("media_type") or data.get("kind")).lower()
if "forward" in media_type or "share" in media_type or "quote" in media_type:
return True
forward_keys = {
"forward",
"forwarded",
"forwards",
"link",
"message",
"messages",
"payload",
"quote",
"origin",
}
return any(key in data for key in forward_keys)
def _classify_url(url: str, media_type: str) -> str:
lowered = url.lower()
if _is_image(media_type) or lowered.endswith((".jpg", ".jpeg", ".png", ".webp", ".gif")):
return "image"
if _is_video(media_type) or lowered.endswith((".mp4", ".mov", ".mkv", ".avi", ".webm")):
return "video"
return "file"
def _collect_urls(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.strip())
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))
def _extract_media_urls(message: Any) -> tuple[list[str], list[str], list[str], list[str]]: def _extract_media_urls(message: Any) -> tuple[list[str], list[str], list[str], list[str]]:
image_urls: list[str] = [] image_urls: list[str] = []
video_urls: list[str] = [] video_urls: list[str] = []
@@ -47,6 +105,7 @@ def _extract_media_urls(message: Any) -> tuple[list[str], list[str], list[str],
for item in raw_attachments: for item in raw_attachments:
data = _as_dict(item) data = _as_dict(item)
media_type = _stringify(data.get("type") or data.get("media_type") or data.get("kind")) media_type = _stringify(data.get("type") or data.get("media_type") or data.get("kind"))
is_forward_like = _is_forward_like(data)
url = _stringify( url = _stringify(
data.get("base_url") data.get("base_url")
or data.get("url") or data.get("url")
@@ -69,6 +128,23 @@ def _extract_media_urls(message: Any) -> tuple[list[str], list[str], list[str],
media_type = _stringify(nested_data.get("type") or nested_data.get("media_type")) media_type = _stringify(nested_data.get("type") or nested_data.get("media_type"))
if not url: if not url:
nested_urls = _collect_urls(item)
for nested_url in nested_urls:
kind = _classify_url(nested_url, media_type)
if kind == "image":
image_urls.append(nested_url)
elif kind == "video":
video_urls.append(nested_url)
else:
file_urls.append(nested_url)
if nested_urls:
continue
if not url:
if is_forward_like:
# Forward-пакет может не содержать прямого URL в верхнем уровне;
# текст/медиа достанем рекурсивно в других этапах.
continue
kind = media_type or _stringify(type(item).__name__) or "unknown" kind = media_type or _stringify(type(item).__name__) or "unknown"
unknown_attachments.append(kind) unknown_attachments.append(kind)
continue continue