pinned channel info

This commit is contained in:
kislovdm
2026-06-12 01:31:01 +03:00
parent aecfe4dedf
commit 2b5260049b
8 changed files with 277 additions and 18 deletions
+65 -7
View File
@@ -3,7 +3,7 @@ from typing import Any
import structlog import structlog
from pymax.types.domain.message import Message as MaxMessage 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__) logger = structlog.get_logger(__name__)
@@ -211,14 +211,72 @@ async def resolve_media(client, message: MaxMessage) -> list[dict]:
return items 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") is_dm = bool(getattr(chat, "is_dialog", False) or chat.type == "DIALOG")
if is_dm: if is_dm:
title = chat.title or "Контакт" for user_id in chat.participants or {}:
if not title.startswith(ls_prefix.strip()): if my_user_id and user_id == my_user_id:
title = f"{ls_prefix}{title}" continue
return title, True try:
return chat.title or f"Чат {chat.id}", False 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: def resolve_sender_name(user) -> str:
+35 -10
View File
@@ -1,16 +1,18 @@
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from uuid import uuid4
import aiohttp
import structlog import structlog
from pymax import ExtraConfig, Message, WebClient from pymax import ExtraConfig, Message, WebClient
from pymax.types.domain.enums import ChatType
from app.config import Settings 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.client_holder import MaxClientHolder
from app.max_layer.formatter import ( from app.max_layer.formatter import (
build_chat_title, extract_chat_meta,
extract_forwarded_content, extract_forwarded_content,
resolve_chat_icon_url,
format_forwarded_text, format_forwarded_text,
get_reply_target, get_reply_target,
resolve_media, resolve_media,
@@ -86,6 +88,21 @@ class MaxListener:
message_id=message.id, 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: async def run(self) -> None:
self._client = self.build_client() self._client = self.build_client()
await self._client.start() await self._client.start()
@@ -169,11 +186,14 @@ class MaxListener:
logger.exception("max_chat_fetch_failed", chat_id=message.chat_id) logger.exception("max_chat_fetch_failed", chat_id=message.chat_id)
return return
is_dm = chat.type == ChatType.DIALOG or getattr(chat, "is_dialog", False) chat_meta = extract_chat_meta(chat, self._settings.ls_topic_prefix)
chat_title, _ = build_chat_title(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 sender_name: str | None = None
if not is_dm and message.sender: if not chat_meta.is_dm and message.sender:
try: try:
user = await client.get_user(message.sender) user = await client.get_user(message.sender)
sender_name = resolve_sender_name(user) sender_name = resolve_sender_name(user)
@@ -207,7 +227,7 @@ class MaxListener:
is_forward=forwarded is not None, is_forward=forwarded is not None,
text_len=len(effective_text), text_len=len(effective_text),
media_count=len(media), media_count=len(media),
is_dm=is_dm, is_dm=chat_meta.is_dm,
reply_to=reply_to, reply_to=reply_to,
) )
@@ -217,8 +237,13 @@ class MaxListener:
text=effective_text, text=effective_text,
sender_id=message.sender, sender_id=message.sender,
sender_name=sender_name, sender_name=sender_name,
is_dm=is_dm, is_dm=chat_meta.is_dm,
chat_title=chat_title, 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, reply_to_max_message_id=reply_to,
media=media, media=media,
) )
+37
View File
@@ -44,6 +44,43 @@ def resolve_file_name(item: MediaItem | dict) -> str:
return _default_file_name(kind) 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( async def _download_url(
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
url: str, url: str,
+15
View File
@@ -10,6 +10,16 @@ class ChatMapping:
is_dm: bool 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) @dataclass(slots=True)
class MaxIncomingMessage: class MaxIncomingMessage:
max_chat_id: int max_chat_id: int
@@ -19,6 +29,11 @@ class MaxIncomingMessage:
sender_name: str | None sender_name: str | None
is_dm: bool is_dm: bool
chat_title: str 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 reply_to_max_message_id: int | None
media: list[dict] media: list[dict]
+5
View File
@@ -33,6 +33,11 @@ class Max2TgTask(BaseModel):
sender_name: str | None = None sender_name: str | None = None
is_dm: bool = False is_dm: bool = False
chat_title: str 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 needs_new_topic: bool = False
reply_to_tg_message_id: int | None = None reply_to_tg_message_id: int | None = None
media: list[MediaItem] = Field(default_factory=list) media: list[MediaItem] = Field(default_factory=list)
+5
View File
@@ -94,6 +94,11 @@ class MessageRouter:
sender_name=message.sender_name, sender_name=message.sender_name,
is_dm=message.is_dm, is_dm=message.is_dm,
chat_title=message.chat_title, 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, needs_new_topic=needs_new_topic,
reply_to_tg_message_id=reply_to_tg, reply_to_tg_message_id=reply_to_tg,
media=media, media=media,
+19
View File
@@ -12,6 +12,25 @@ def format_tg_author(message: Message) -> tuple[str, str | None]:
return name, user.username 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: def format_tg_to_max_text(author_name: str, username: str | None, text: str) -> str:
handle = f" (@{username})" if username else "" handle = f" (@{username})" if username else ""
return f"{author_name}{handle}:\n{text}" return f"{author_name}{handle}:\n{text}"
+96 -1
View File
@@ -1,4 +1,6 @@
import asyncio import asyncio
from pathlib import Path
from uuid import uuid4
import aiohttp import aiohttp
import structlog import structlog
@@ -12,7 +14,13 @@ from aiogram.types import (
) )
from app.config import Settings 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.client_holder import MaxClientHolder
from app.max_layer.formatter import format_max_text from app.max_layer.formatter import format_max_text
from app.models.domain import ChatMapping, MaxIncomingMessage from app.models.domain import ChatMapping, MaxIncomingMessage
@@ -25,6 +33,7 @@ from app.models.tasks import (
from app.queue.protocols import QueuePort from app.queue.protocols import QueuePort
from app.storage.protocols import StoragePort from app.storage.protocols import StoragePort
from app.telegram_layer.bot_holder import BotHolder from app.telegram_layer.bot_holder import BotHolder
from app.telegram_layer.formatter import format_topic_pin_text
from app.topic_locks import TopicLockRegistry from app.topic_locks import TopicLockRegistry
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -98,6 +107,7 @@ class TelegramWorker:
is_dm=task.is_dm, is_dm=task.is_dm,
) )
await self._storage.save_mapping(mapping) await self._storage.save_mapping(mapping)
await self._send_and_pin_topic_info(bot, topic.message_thread_id, task)
logger.info( logger.info(
"tg_topic_created", "tg_topic_created",
max_chat_id=task.max_chat_id, max_chat_id=task.max_chat_id,
@@ -108,6 +118,86 @@ class TelegramWorker:
) )
return mapping 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: async def _send_to_tg(self, task: Max2TgTask) -> None:
bot = await self._bot_holder.wait_bot() bot = await self._bot_holder.wait_bot()
logger.debug( logger.debug(
@@ -159,6 +249,11 @@ class TelegramWorker:
sender_name=task.sender_name, sender_name=task.sender_name,
is_dm=task.is_dm, is_dm=task.is_dm,
chat_title=task.chat_title, 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, reply_to_max_message_id=None,
media=[], media=[],
) )