pinned channel info
This commit is contained in:
@@ -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:
|
||||
|
||||
+35
-10
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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=[],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user