init
Docker Hub / build-and-push (push) Failing after 13s

This commit is contained in:
kislovdm
2026-06-12 00:42:55 +03:00
parent 64fe9c466e
commit e7c32da1f9
51 changed files with 2454 additions and 3248 deletions
View File
+22
View File
@@ -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
+223
View File
@@ -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}"
+241
View File
@@ -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}",
)
+128
View File
@@ -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