diff --git a/app/max_layer/formatter.py b/app/max_layer/formatter.py index bdf57a9..fdb14ab 100644 --- a/app/max_layer/formatter.py +++ b/app/max_layer/formatter.py @@ -3,7 +3,7 @@ from typing import Any import structlog from pymax.types.domain.message import Message as MaxMessage -from app.models.domain import MaxIncomingMessage +from app.models.domain import MaxChatMeta, MaxIncomingMessage logger = structlog.get_logger(__name__) @@ -211,14 +211,72 @@ async def resolve_media(client, message: MaxMessage) -> list[dict]: return items -def build_chat_title(chat, ls_prefix: str) -> tuple[str, bool]: +def _pick_image_url(*values: str | None) -> str | None: + for value in values: + if value and value.strip(): + return value.strip() + return None + + +def _chat_icon_url(chat) -> str | None: + return _pick_image_url( + getattr(chat, "base_icon_url", None), + getattr(chat, "base_raw_icon_url", None), + ) + + +def _user_icon_url(user) -> str | None: + return _pick_image_url( + getattr(user, "base_url", None), + getattr(user, "base_raw_url", None), + ) + + +async def resolve_chat_icon_url( + client, + chat, + my_user_id: int | None, +) -> str | None: is_dm = bool(getattr(chat, "is_dialog", False) or chat.type == "DIALOG") if is_dm: - title = chat.title or "Контакт" - if not title.startswith(ls_prefix.strip()): - title = f"{ls_prefix}{title}" - return title, True - return chat.title or f"Чат {chat.id}", False + for user_id in chat.participants or {}: + if my_user_id and user_id == my_user_id: + continue + try: + user = await client.get_user(user_id) + icon_url = _user_icon_url(user) + if icon_url: + return icon_url + except Exception: + logger.debug( + "max_dm_icon_lookup_failed", + chat_id=chat.id, + user_id=user_id, + ) + return None + + return _chat_icon_url(chat) + + +def extract_chat_meta(chat, ls_prefix: str) -> MaxChatMeta: + is_dm = bool(getattr(chat, "is_dialog", False) or chat.type == "DIALOG") + chat_name = chat.title or ("Контакт" if is_dm else f"Чат {chat.id}") + topic_title = chat_name + if is_dm and not topic_title.startswith(ls_prefix.strip()): + topic_title = f"{ls_prefix}{topic_title}" + return MaxChatMeta( + topic_title=topic_title, + chat_name=chat_name, + is_dm=is_dm, + icon_url=_chat_icon_url(chat), + participants_count=getattr(chat, "participants_count", 0) or 0, + link=getattr(chat, "link", None), + ) + + +def build_chat_title(chat, ls_prefix: str) -> tuple[str, bool]: + meta = extract_chat_meta(chat, ls_prefix) + return meta.topic_title, meta.is_dm def resolve_sender_name(user) -> str: diff --git a/app/max_layer/listener.py b/app/max_layer/listener.py index c2969d1..ddcb641 100644 --- a/app/max_layer/listener.py +++ b/app/max_layer/listener.py @@ -1,16 +1,18 @@ import asyncio from pathlib import Path +from uuid import uuid4 + +import aiohttp import structlog from pymax import ExtraConfig, Message, WebClient -from pymax.types.domain.enums import ChatType - from app.config import Settings -from app.media_transfer import download_max_media, tmp_dir +from app.media_transfer import download_max_image_url, download_max_media, tmp_dir from app.max_layer.client_holder import MaxClientHolder from app.max_layer.formatter import ( - build_chat_title, + extract_chat_meta, extract_forwarded_content, + resolve_chat_icon_url, format_forwarded_text, get_reply_target, resolve_media, @@ -86,6 +88,21 @@ class MaxListener: message_id=message.id, ) + async def _download_chat_icon(self, icon_url: str | None) -> str | None: + if not icon_url: + return None + dest = self._tmp_dir / f"chat_icon_{uuid4().hex}.jpg" + try: + async with aiohttp.ClientSession() as session: + if not await download_max_image_url(session, icon_url, dest): + logger.warning("max_chat_icon_download_failed", url=icon_url) + return None + return str(dest) + except Exception: + logger.exception("max_chat_icon_download_failed", url=icon_url) + dest.unlink(missing_ok=True) + return None + async def run(self) -> None: self._client = self.build_client() await self._client.start() @@ -169,11 +186,14 @@ class MaxListener: logger.exception("max_chat_fetch_failed", chat_id=message.chat_id) return - is_dm = chat.type == ChatType.DIALOG or getattr(chat, "is_dialog", False) - chat_title, _ = build_chat_title(chat, self._settings.ls_topic_prefix) + chat_meta = extract_chat_meta(chat, self._settings.ls_topic_prefix) + chat_icon_url = await resolve_chat_icon_url( + client, chat, self._holder.my_user_id + ) + chat_icon_local_path = await self._download_chat_icon(chat_icon_url) sender_name: str | None = None - if not is_dm and message.sender: + if not chat_meta.is_dm and message.sender: try: user = await client.get_user(message.sender) sender_name = resolve_sender_name(user) @@ -207,7 +227,7 @@ class MaxListener: is_forward=forwarded is not None, text_len=len(effective_text), media_count=len(media), - is_dm=is_dm, + is_dm=chat_meta.is_dm, reply_to=reply_to, ) @@ -217,8 +237,13 @@ class MaxListener: text=effective_text, sender_id=message.sender, sender_name=sender_name, - is_dm=is_dm, - chat_title=chat_title, + is_dm=chat_meta.is_dm, + chat_title=chat_meta.topic_title, + chat_name=chat_meta.chat_name, + chat_icon_url=chat_icon_url, + chat_icon_local_path=chat_icon_local_path, + participants_count=chat_meta.participants_count, + max_chat_link=chat_meta.link, reply_to_max_message_id=reply_to, media=media, ) diff --git a/app/media_transfer.py b/app/media_transfer.py index ce4bacc..e5dd330 100644 --- a/app/media_transfer.py +++ b/app/media_transfer.py @@ -44,6 +44,43 @@ def resolve_file_name(item: MediaItem | dict) -> str: return _default_file_name(kind) +def max_image_url_candidates(url: str) -> list[str]: + normalized = url.strip() + if not normalized: + return [] + if normalized.startswith("//"): + normalized = f"https:{normalized}" + candidates = [normalized] + if "i.oneme.ru" in normalized and "size=" not in normalized: + sep = "&" if "?" in normalized else "?" + for size in (512, 256, 128): + sized = f"{normalized}{sep}size={size}" + if sized not in candidates: + candidates.append(sized) + return candidates + + +async def download_url_to_file( + session: aiohttp.ClientSession, + url: str, + dest: Path, +) -> bool: + return await _download_url(session, url, dest) + + +async def download_max_image_url( + session: aiohttp.ClientSession, + url: str, + dest: Path, +) -> bool: + for candidate in max_image_url_candidates(url): + if await _download_url(session, candidate, dest): + if dest.stat().st_size > 0: + return True + dest.unlink(missing_ok=True) + return False + + async def _download_url( session: aiohttp.ClientSession, url: str, diff --git a/app/models/domain.py b/app/models/domain.py index 4cc466d..bff2a69 100644 --- a/app/models/domain.py +++ b/app/models/domain.py @@ -10,6 +10,16 @@ class ChatMapping: is_dm: bool +@dataclass(slots=True) +class MaxChatMeta: + topic_title: str + chat_name: str + is_dm: bool + icon_url: str | None + participants_count: int + link: str | None + + @dataclass(slots=True) class MaxIncomingMessage: max_chat_id: int @@ -19,6 +29,11 @@ class MaxIncomingMessage: sender_name: str | None is_dm: bool chat_title: str + chat_name: str + chat_icon_url: str | None + chat_icon_local_path: str | None + participants_count: int + max_chat_link: str | None reply_to_max_message_id: int | None media: list[dict] diff --git a/app/models/tasks.py b/app/models/tasks.py index 2572521..2c84ec7 100644 --- a/app/models/tasks.py +++ b/app/models/tasks.py @@ -33,6 +33,11 @@ class Max2TgTask(BaseModel): sender_name: str | None = None is_dm: bool = False chat_title: str + chat_name: str = "" + chat_icon_url: str | None = None + chat_icon_local_path: str | None = None + participants_count: int = 0 + max_chat_link: str | None = None needs_new_topic: bool = False reply_to_tg_message_id: int | None = None media: list[MediaItem] = Field(default_factory=list) diff --git a/app/router/router.py b/app/router/router.py index fa0740e..e3637ad 100644 --- a/app/router/router.py +++ b/app/router/router.py @@ -94,6 +94,11 @@ class MessageRouter: sender_name=message.sender_name, is_dm=message.is_dm, chat_title=message.chat_title, + chat_name=message.chat_name, + chat_icon_url=message.chat_icon_url, + chat_icon_local_path=message.chat_icon_local_path, + participants_count=message.participants_count, + max_chat_link=message.max_chat_link, needs_new_topic=needs_new_topic, reply_to_tg_message_id=reply_to_tg, media=media, diff --git a/app/telegram_layer/formatter.py b/app/telegram_layer/formatter.py index 4675fdf..c4f3f95 100644 --- a/app/telegram_layer/formatter.py +++ b/app/telegram_layer/formatter.py @@ -12,6 +12,25 @@ def format_tg_author(message: Message) -> tuple[str, str | None]: return name, user.username +def format_topic_pin_text( + *, + chat_name: str, + is_dm: bool, + max_chat_id: int, + participants_count: int, + max_chat_link: str | None, +) -> str: + chat_type = "PRIVATE" if is_dm else "CHAT" + lines = [ + f"{chat_name} · Тип: {chat_type}", + f"id: {max_chat_id}", + f"Участников: {participants_count}", + ] + if max_chat_link: + lines.extend(["", f"🔗 {max_chat_link}"]) + return "\n".join(lines) + + def format_tg_to_max_text(author_name: str, username: str | None, text: str) -> str: handle = f" (@{username})" if username else "" return f"{author_name}{handle}:\n{text}" diff --git a/app/telegram_layer/worker.py b/app/telegram_layer/worker.py index 4df85e2..a11246f 100644 --- a/app/telegram_layer/worker.py +++ b/app/telegram_layer/worker.py @@ -1,4 +1,6 @@ import asyncio +from pathlib import Path +from uuid import uuid4 import aiohttp import structlog @@ -12,7 +14,13 @@ from aiogram.types import ( ) from app.config import Settings -from app.media_transfer import cleanup_paths, download_media_item, resolve_file_name, tmp_dir +from app.media_transfer import ( + cleanup_paths, + download_max_image_url, + download_media_item, + resolve_file_name, + tmp_dir, +) from app.max_layer.client_holder import MaxClientHolder from app.max_layer.formatter import format_max_text from app.models.domain import ChatMapping, MaxIncomingMessage @@ -25,6 +33,7 @@ from app.models.tasks import ( from app.queue.protocols import QueuePort from app.storage.protocols import StoragePort from app.telegram_layer.bot_holder import BotHolder +from app.telegram_layer.formatter import format_topic_pin_text from app.topic_locks import TopicLockRegistry logger = structlog.get_logger(__name__) @@ -98,6 +107,7 @@ class TelegramWorker: is_dm=task.is_dm, ) await self._storage.save_mapping(mapping) + await self._send_and_pin_topic_info(bot, topic.message_thread_id, task) logger.info( "tg_topic_created", max_chat_id=task.max_chat_id, @@ -108,6 +118,86 @@ class TelegramWorker: ) return mapping + async def _send_and_pin_topic_info( + self, + bot, + thread_id: int, + task: Max2TgTask, + ) -> None: + chat_name = task.chat_name or task.chat_title + caption = format_topic_pin_text( + chat_name=chat_name, + is_dm=task.is_dm, + max_chat_id=task.max_chat_id, + participants_count=task.participants_count, + max_chat_link=task.max_chat_link, + ) + kwargs: dict = { + "chat_id": self._settings.tg_forum_channel_id, + "message_thread_id": thread_id, + } + + downloaded: Path | None = None + icon_path: Path | None = None + try: + icon_path = await self._resolve_topic_icon_path(task) + if icon_path is not None: + sent = await bot.send_photo( + photo=FSInputFile(icon_path), + caption=caption, + **kwargs, + ) + if ( + not task.chat_icon_local_path + or icon_path.resolve() != Path(task.chat_icon_local_path).resolve() + ): + downloaded = icon_path + else: + sent = await bot.send_message(text=caption, **kwargs) + + await bot.pin_chat_message( + chat_id=self._settings.tg_forum_channel_id, + message_id=sent.message_id, + disable_notification=True, + ) + logger.info( + "tg_topic_info_pinned", + max_chat_id=task.max_chat_id, + thread_id=thread_id, + message_id=sent.message_id, + has_icon=icon_path is not None, + ) + except Exception: + logger.exception( + "tg_topic_info_pin_failed", + max_chat_id=task.max_chat_id, + thread_id=thread_id, + ) + finally: + if downloaded is not None: + cleanup_paths([downloaded]) + + async def _resolve_topic_icon_path(self, task: Max2TgTask) -> Path | None: + if task.chat_icon_local_path: + local = Path(task.chat_icon_local_path) + if local.is_file() and local.stat().st_size > 0: + return local + + if not task.chat_icon_url: + return None + + dest = self._tmp_dir / f"topic_icon_{task.max_chat_id}_{uuid4().hex}.jpg" + async with aiohttp.ClientSession() as session: + if await download_max_image_url(session, task.chat_icon_url, dest): + return dest + logger.warning( + "tg_topic_icon_download_failed", + max_chat_id=task.max_chat_id, + url=task.chat_icon_url, + ) + dest.unlink(missing_ok=True) + return None + async def _send_to_tg(self, task: Max2TgTask) -> None: bot = await self._bot_holder.wait_bot() logger.debug( @@ -159,6 +249,11 @@ class TelegramWorker: sender_name=task.sender_name, is_dm=task.is_dm, chat_title=task.chat_title, + chat_name=task.chat_name, + chat_icon_url=task.chat_icon_url, + chat_icon_local_path=task.chat_icon_local_path, + participants_count=task.participants_count, + max_chat_link=task.max_chat_link, reply_to_max_message_id=None, media=[], )