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
+166 -28
View File
@@ -5,7 +5,7 @@ from typing import Any
from max_parser import parse_message
from models import ParsedMessage
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 telegram_api import TelegramApiError, TelegramClient
@@ -19,9 +19,9 @@ class MaxToTelegramBridge:
self._storage = storage
async def forward_message(self, max_message: Any) -> None:
if self._is_self_message(max_message):
logger.debug("Skip self message %s/%s", getattr(max_message, "chat_id", "?"), getattr(max_message, "id", "?"))
return
#if self._is_self_message(max_message):
# logger.debug("Skip self message %s/%s", getattr(max_message, "chat_id", "?"), getattr(max_message, "id", "?"))
# return
parsed = parse_message(max_message)
parsed = await self._enrich_from_max(max_message, parsed)
@@ -110,7 +110,8 @@ class MaxToTelegramBridge:
)
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=target_chat_id,
max_chat_title_norm=normalized,
@@ -200,12 +201,13 @@ class MaxToTelegramBridge:
if not sent_any:
# Последняя страховка: гарантируем уведомление в 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=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
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
@@ -338,28 +340,24 @@ class MaxToTelegramBridge:
except Exception:
logger.debug("Cannot resolve chat title", exc_info=True)
attaches = getattr(max_message, "attaches", None) or []
for attach in attaches:
if isinstance(attach, PhotoAttach):
parsed.image_urls.extend(self._extract_photo_urls(attach))
elif isinstance(attach, VideoAttach):
try:
video = await self._max_client.get_video_by_id(
chat_id=max_message.chat_id,
message_id=max_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")
else:
urls = self._extract_any_urls(attach)
if urls:
parsed.file_urls.extend(urls)
else:
parsed.unknown_attachments.append(type(attach).__name__)
await self._collect_message_attachments(
message=max_message,
parsed=parsed,
source_tag="root",
)
link = getattr(max_message, "link", None)
linked_message = getattr(link, "message", None)
if linked_message is not None:
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",
)
# Убираем дубли URL, если парсер и enrich нашли одинаковые вложения.
parsed.image_urls = list(dict.fromkeys(parsed.image_urls))
@@ -368,6 +366,127 @@ class MaxToTelegramBridge:
parsed.unknown_attachments = list(dict.fromkeys(parsed.unknown_attachments))
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:
sender = getattr(max_message, "sender", None)
me = getattr(self._max_client, "me", None)
@@ -445,6 +564,25 @@ class MaxToTelegramBridge:
walk(node)
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
def _append_unknown_attachment_notice(*, parsed: ParsedMessage, text: str) -> str:
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"}
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]]:
image_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:
data = _as_dict(item)
media_type = _stringify(data.get("type") or data.get("media_type") or data.get("kind"))
is_forward_like = _is_forward_like(data)
url = _stringify(
data.get("base_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"))
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"
unknown_attachments.append(kind)
continue