@@ -0,0 +1,22 @@
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pymax import WebClient
|
||||
|
||||
|
||||
class MaxClientHolder:
|
||||
def __init__(self) -> None:
|
||||
self.client: WebClient | None = None
|
||||
self.ready = asyncio.Event()
|
||||
self.my_user_id: int | None = None
|
||||
|
||||
def set_client(self, client: "WebClient", my_user_id: int | None) -> None:
|
||||
self.client = client
|
||||
self.my_user_id = my_user_id
|
||||
self.ready.set()
|
||||
|
||||
async def wait_client(self) -> "WebClient":
|
||||
await self.ready.wait()
|
||||
assert self.client is not None
|
||||
return self.client
|
||||
@@ -0,0 +1,223 @@
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from pymax.types.domain.message import Message as MaxMessage
|
||||
|
||||
from app.models.domain import MaxIncomingMessage
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
def get_forward_link(message: MaxMessage) -> dict[str, Any] | None:
|
||||
link = getattr(message, "link", None)
|
||||
if not isinstance(link, dict):
|
||||
return None
|
||||
if str(link.get("type", "")).upper() != "FORWARD":
|
||||
return None
|
||||
return link
|
||||
|
||||
|
||||
def extract_forwarded_content(
|
||||
message: MaxMessage,
|
||||
) -> tuple[str, list[dict[str, Any]]] | None:
|
||||
link = get_forward_link(message)
|
||||
if link is None:
|
||||
return None
|
||||
|
||||
nested = link.get("message")
|
||||
if not isinstance(nested, dict):
|
||||
logger.warning(
|
||||
"max_forward_missing_nested_message",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
return None
|
||||
|
||||
text = nested.get("text") or ""
|
||||
attaches = nested.get("attaches") or []
|
||||
if not isinstance(attaches, list):
|
||||
attaches = []
|
||||
|
||||
logger.info(
|
||||
"max_forward_extracted",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
nested_message_id=nested.get("id"),
|
||||
nested_sender=nested.get("sender"),
|
||||
text_len=len(text),
|
||||
attach_count=len(attaches),
|
||||
)
|
||||
return text, attaches
|
||||
|
||||
|
||||
def format_forwarded_text(
|
||||
original_text: str,
|
||||
original_sender_name: str | None = None,
|
||||
) -> str:
|
||||
header = "↪️ Переслано"
|
||||
if original_sender_name:
|
||||
header += f" от {original_sender_name}"
|
||||
if original_text:
|
||||
return f"{header}:\n{original_text}"
|
||||
return header
|
||||
|
||||
|
||||
def _attach_type(attach: dict[str, Any]) -> str:
|
||||
return str(attach.get("_type") or attach.get("type") or "").upper()
|
||||
|
||||
|
||||
async def resolve_raw_attaches(
|
||||
client,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
attaches: list[dict[str, Any]],
|
||||
) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for attach in attaches:
|
||||
if not isinstance(attach, dict):
|
||||
continue
|
||||
|
||||
attach_type = _attach_type(attach)
|
||||
if attach_type == "PHOTO":
|
||||
url = attach.get("baseUrl") or attach.get("base_url")
|
||||
if url:
|
||||
items.append(
|
||||
{
|
||||
"kind": "photo",
|
||||
"url": url,
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if attach_type == "VIDEO":
|
||||
video_id = attach.get("videoId") or attach.get("video_id")
|
||||
if video_id is not None:
|
||||
items.append(
|
||||
{
|
||||
"kind": "video",
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message_id,
|
||||
"max_video_id": video_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if attach_type == "FILE":
|
||||
file_id = attach.get("fileId") or attach.get("file_id")
|
||||
if file_id is not None:
|
||||
items.append(
|
||||
{
|
||||
"kind": "document",
|
||||
"file_name": attach.get("name"),
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message_id,
|
||||
"max_file_id": file_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
url = attach.get("baseUrl") or attach.get("base_url") or attach.get("url")
|
||||
if url:
|
||||
items.append(
|
||||
{
|
||||
"kind": "document",
|
||||
"url": url,
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message_id,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"max_raw_attaches_resolved",
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
input_count=len(attaches),
|
||||
resolved_count=len(items),
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
async def resolve_media(client, message: MaxMessage) -> list[dict]:
|
||||
from pymax.types.domain.attachments.file import FileAttachment
|
||||
from pymax.types.domain.attachments.photo import PhotoAttachment
|
||||
from pymax.types.domain.attachments.video import VideoAttachment
|
||||
|
||||
items: list[dict] = []
|
||||
chat_id = message.chat_id
|
||||
if chat_id is None:
|
||||
return items
|
||||
|
||||
for attach in message.attaches:
|
||||
if isinstance(attach, dict):
|
||||
items.extend(
|
||||
await resolve_raw_attaches(client, chat_id, message.id, [attach])
|
||||
)
|
||||
continue
|
||||
if isinstance(attach, PhotoAttachment):
|
||||
items.append(
|
||||
{
|
||||
"kind": "photo",
|
||||
"url": attach.base_url,
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message.id,
|
||||
}
|
||||
)
|
||||
elif isinstance(attach, VideoAttachment):
|
||||
items.append(
|
||||
{
|
||||
"kind": "video",
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message.id,
|
||||
"max_video_id": attach.video_id,
|
||||
}
|
||||
)
|
||||
elif isinstance(attach, FileAttachment):
|
||||
items.append(
|
||||
{
|
||||
"kind": "document",
|
||||
"file_name": attach.name,
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message.id,
|
||||
"max_file_id": attach.file_id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
url = getattr(attach, "base_url", None) or getattr(attach, "url", None)
|
||||
if url:
|
||||
items.append(
|
||||
{
|
||||
"kind": "document",
|
||||
"url": url,
|
||||
"max_chat_id": chat_id,
|
||||
"max_message_id": message.id,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def build_chat_title(chat, ls_prefix: str) -> tuple[str, bool]:
|
||||
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
|
||||
|
||||
|
||||
def resolve_sender_name(user) -> str:
|
||||
if user is None:
|
||||
return "Неизвестный"
|
||||
if user.names:
|
||||
name = user.names[0]
|
||||
parts = [name.first_name, name.last_name]
|
||||
return " ".join(p for p in parts if p) or f"User {user.id}"
|
||||
return f"User {user.id}"
|
||||
|
||||
|
||||
def format_max_text(message: MaxIncomingMessage) -> str:
|
||||
if message.is_dm:
|
||||
return message.text
|
||||
sender = message.sender_name or "Неизвестный"
|
||||
return f"{sender}:\n{message.text}"
|
||||
@@ -0,0 +1,241 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
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.max_layer.client_holder import MaxClientHolder
|
||||
from app.max_layer.formatter import (
|
||||
build_chat_title,
|
||||
extract_forwarded_content,
|
||||
format_forwarded_text,
|
||||
resolve_media,
|
||||
resolve_raw_attaches,
|
||||
resolve_sender_name,
|
||||
)
|
||||
from app.models.domain import MaxIncomingMessage
|
||||
from app.router.router import MessageRouter
|
||||
from app.storage.protocols import StoragePort
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MaxListener:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
holder: MaxClientHolder,
|
||||
router: MessageRouter,
|
||||
storage: StoragePort,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._holder = holder
|
||||
self._router = router
|
||||
self._storage = storage
|
||||
self._client: WebClient | None = None
|
||||
self._tmp_dir = tmp_dir(self._settings.data_dir)
|
||||
|
||||
def build_client(self) -> WebClient:
|
||||
work_dir = str(Path(self._settings.data_dir))
|
||||
extra = ExtraConfig(
|
||||
token=self._settings.max_token,
|
||||
device_id=self._settings.max_device_id,
|
||||
log_level=self._settings.log_level,
|
||||
reconnect=True,
|
||||
reconnect_delay=3.0,
|
||||
telemetry=False,
|
||||
)
|
||||
client = WebClient(
|
||||
session_name=self._settings.max_session_name,
|
||||
work_dir=work_dir,
|
||||
extra_config=extra,
|
||||
)
|
||||
self._register_handlers(client)
|
||||
return client
|
||||
|
||||
def _register_handlers(self, client: WebClient) -> None:
|
||||
@client.on_start()
|
||||
async def on_start(c: WebClient) -> None:
|
||||
my_id = c.me.contact.id if c.me else None
|
||||
self._holder.set_client(c, my_id)
|
||||
logger.info("max_client_started", user_id=my_id)
|
||||
await self._catch_up_history(c)
|
||||
|
||||
@client.on_message()
|
||||
async def on_message(message: Message, c: WebClient) -> None:
|
||||
logger.debug(
|
||||
"max_message_event",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
sender=message.sender,
|
||||
msg_type=message.type,
|
||||
has_text=bool(message.text),
|
||||
attach_count=len(message.attaches),
|
||||
is_forward=bool(getattr(message, "link", None)),
|
||||
)
|
||||
try:
|
||||
await self._process_message(c, message)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"max_message_handler_failed",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
self._client = self.build_client()
|
||||
await self._client.start()
|
||||
|
||||
async def _catch_up_history(self, client: WebClient) -> None:
|
||||
if not client.chats:
|
||||
logger.info("max_history_catch_up_skipped", reason="no_chats")
|
||||
return
|
||||
limit = self._settings.max_reconnect_fetch_limit
|
||||
logger.info("max_history_catch_up_started", chat_count=len(client.chats), limit=limit)
|
||||
for chat in client.chats:
|
||||
try:
|
||||
messages = await client.fetch_history(chat_id=chat.id, backward=limit)
|
||||
if not messages:
|
||||
logger.debug("max_history_empty", chat_id=chat.id)
|
||||
continue
|
||||
logger.info("max_history_fetched", chat_id=chat.id, count=len(messages))
|
||||
for msg in sorted(messages, key=lambda m: m.id):
|
||||
await self._process_message(client, msg)
|
||||
except Exception:
|
||||
logger.exception("max_history_fetch_failed", chat_id=chat.id)
|
||||
|
||||
async def _process_message(self, client: WebClient, message: Message) -> None:
|
||||
if message.chat_id is None:
|
||||
logger.debug("max_message_skipped", reason="no_chat_id", message_id=message.id)
|
||||
return
|
||||
if self._holder.my_user_id and message.sender == self._holder.my_user_id:
|
||||
link = await self._storage.get_tg_message_by_max(
|
||||
message.chat_id, message.id
|
||||
)
|
||||
if link is not None:
|
||||
logger.debug(
|
||||
"max_message_skipped",
|
||||
reason="own_echo",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
return
|
||||
|
||||
forwarded = extract_forwarded_content(message)
|
||||
effective_text = message.text or ""
|
||||
forwarded_attaches: list | None = None
|
||||
if forwarded is not None:
|
||||
nested_text, nested_attaches = forwarded
|
||||
original_sender_name: str | None = None
|
||||
nested = getattr(message, "link", {}).get("message", {})
|
||||
nested_sender = nested.get("sender") if isinstance(nested, dict) else None
|
||||
if nested_sender:
|
||||
try:
|
||||
user = await client.get_user(nested_sender)
|
||||
original_sender_name = resolve_sender_name(user)
|
||||
except Exception:
|
||||
original_sender_name = f"User {nested_sender}"
|
||||
logger.warning(
|
||||
"max_forward_sender_lookup_failed",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
nested_sender=nested_sender,
|
||||
)
|
||||
effective_text = format_forwarded_text(nested_text, original_sender_name)
|
||||
forwarded_attaches = nested_attaches
|
||||
|
||||
has_content = bool(
|
||||
effective_text.strip()
|
||||
or message.attaches
|
||||
or forwarded_attaches
|
||||
)
|
||||
if not has_content:
|
||||
logger.debug(
|
||||
"max_message_skipped",
|
||||
reason="empty",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
is_forward=forwarded is not None,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
chat = await client.get_chat(message.chat_id)
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
sender_name: str | None = None
|
||||
if not is_dm and message.sender:
|
||||
try:
|
||||
user = await client.get_user(message.sender)
|
||||
sender_name = resolve_sender_name(user)
|
||||
except Exception:
|
||||
sender_name = f"User {message.sender}"
|
||||
|
||||
reply_to: int | None = None
|
||||
if message.options and isinstance(message.options, dict):
|
||||
reply_to = message.options.get("replyTo")
|
||||
if reply_to is None and message.prev_message_id:
|
||||
try:
|
||||
reply_to = int(message.prev_message_id)
|
||||
except (TypeError, ValueError):
|
||||
reply_to = None
|
||||
|
||||
try:
|
||||
if forwarded_attaches is not None:
|
||||
# File API requires the forward wrapper message id in this chat,
|
||||
# not the nested original message id (error.user.file.access).
|
||||
media = await resolve_raw_attaches(
|
||||
client, message.chat_id, message.id, forwarded_attaches
|
||||
)
|
||||
else:
|
||||
media = await resolve_media(client, message)
|
||||
media = await download_max_media(client, media, self._tmp_dir)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"max_media_resolve_failed",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
media = []
|
||||
|
||||
logger.info(
|
||||
"max_message_processing",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
is_forward=forwarded is not None,
|
||||
text_len=len(effective_text),
|
||||
media_count=len(media),
|
||||
is_dm=is_dm,
|
||||
)
|
||||
|
||||
incoming = MaxIncomingMessage(
|
||||
max_chat_id=message.chat_id,
|
||||
max_message_id=message.id,
|
||||
text=effective_text,
|
||||
sender_id=message.sender,
|
||||
sender_name=sender_name,
|
||||
is_dm=is_dm,
|
||||
chat_title=chat_title,
|
||||
reply_to_max_message_id=reply_to,
|
||||
media=media,
|
||||
)
|
||||
try:
|
||||
await self._router.handle_max_message(incoming)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"max_route_failed",
|
||||
chat_id=message.chat_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
await self._router.notify_error(
|
||||
"Ошибка маршрутизации MAX → TG",
|
||||
f"chat_id={message.chat_id} message_id={message.id}",
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
import asyncio
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
from pymax import File, Photo, Video
|
||||
|
||||
from app.config import Settings
|
||||
from app.media_transfer import download_media_item, tmp_dir
|
||||
from app.max_layer.client_holder import MaxClientHolder
|
||||
from app.models.tasks import SetReactionTask, Tg2MaxTask
|
||||
from app.queue.protocols import QueuePort
|
||||
from app.router.router import MessageRouter
|
||||
from app.storage.protocols import StoragePort
|
||||
from app.telegram_layer.bot_holder import BotHolder
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MaxWorker:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
holder: MaxClientHolder,
|
||||
bot_holder: BotHolder,
|
||||
queue: QueuePort,
|
||||
router: MessageRouter,
|
||||
storage: StoragePort,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._holder = holder
|
||||
self._bot_holder = bot_holder
|
||||
self._queue = queue
|
||||
self._router = router
|
||||
self._storage = storage
|
||||
self._running = True
|
||||
self._tmp_dir = tmp_dir(self._settings.data_dir)
|
||||
|
||||
async def run(self) -> None:
|
||||
logger.info("max_worker_started")
|
||||
while self._running:
|
||||
task = await self._queue.dequeue_tg2max(timeout=5)
|
||||
if task is None:
|
||||
continue
|
||||
logger.info(
|
||||
"max_worker_task_received",
|
||||
tg_message_id=task.tg_message_id,
|
||||
max_chat_id=task.max_chat_id,
|
||||
media_count=len(task.media),
|
||||
)
|
||||
try:
|
||||
await self._process(task)
|
||||
except Exception as exc:
|
||||
logger.exception("max_worker_failed", tg_message_id=task.tg_message_id)
|
||||
await self._router.notify_error(
|
||||
"Ошибка отправки в MAX",
|
||||
f"tg_message_id={task.tg_message_id}: {exc}",
|
||||
)
|
||||
await asyncio.sleep(self._settings.max_rate_limit_delay_sec)
|
||||
|
||||
async def _process(self, task: Tg2MaxTask) -> None:
|
||||
client = await self._holder.wait_client()
|
||||
attachments = await self._build_attachments(task.media)
|
||||
logger.debug(
|
||||
"max_worker_sending",
|
||||
max_chat_id=task.max_chat_id,
|
||||
attachment_count=len(attachments),
|
||||
text_len=len(task.text),
|
||||
reply_to=task.reply_to_max_message_id,
|
||||
)
|
||||
|
||||
sent = await client.send_message(
|
||||
chat_id=task.max_chat_id,
|
||||
text=task.text,
|
||||
reply_to=task.reply_to_max_message_id,
|
||||
attachments=attachments or None,
|
||||
)
|
||||
if sent is None:
|
||||
raise RuntimeError("MAX API returned no message")
|
||||
|
||||
await self._storage.save_message_link(
|
||||
task.max_chat_id,
|
||||
sent.id,
|
||||
task.tg_chat_id,
|
||||
task.tg_thread_id,
|
||||
task.tg_message_id,
|
||||
)
|
||||
await self._storage.update_sync_marker(task.max_chat_id, sent.id)
|
||||
|
||||
await self._queue.enqueue_max2tg(
|
||||
SetReactionTask(
|
||||
tg_chat_id=task.tg_chat_id,
|
||||
tg_message_id=task.tg_message_id,
|
||||
tg_thread_id=task.tg_thread_id,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"max_message_sent",
|
||||
max_chat_id=task.max_chat_id,
|
||||
max_message_id=sent.id,
|
||||
tg_message_id=task.tg_message_id,
|
||||
)
|
||||
|
||||
async def _build_attachments(self, media: list) -> list:
|
||||
if not media:
|
||||
return []
|
||||
bot = await self._bot_holder.wait_bot()
|
||||
result = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for item in media:
|
||||
kind = item.kind if hasattr(item, "kind") else item.get("kind", "")
|
||||
if kind == "unsupported":
|
||||
continue
|
||||
path = await download_media_item(bot, session, item, self._tmp_dir)
|
||||
if path is None:
|
||||
logger.warning(
|
||||
"max_worker_media_skipped",
|
||||
kind=kind,
|
||||
file_id=getattr(item, "file_id", None) or item.get("file_id"),
|
||||
url=getattr(item, "url", None) or item.get("url"),
|
||||
)
|
||||
continue
|
||||
if "photo" in kind:
|
||||
result.append(Photo(path=str(path)))
|
||||
elif "video" in kind:
|
||||
result.append(Video(path=str(path)))
|
||||
else:
|
||||
result.append(File(path=str(path)))
|
||||
return result
|
||||
Reference in New Issue
Block a user