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
+126
View File
@@ -0,0 +1,126 @@
from aiogram import Dispatcher, F
from aiogram.enums import ChatType
from aiogram.filters import Command, CommandObject
from aiogram.types import Message
from pymax.types.domain.enums import ChatType as MaxChatType
from app.config import Settings
from app.max_layer.client_holder import MaxClientHolder
from app.max_layer.formatter import resolve_sender_name
from app.storage.protocols import StoragePort
def register_admin_handlers(
dp: Dispatcher,
settings: Settings,
storage: StoragePort,
max_holder: MaxClientHolder,
) -> None:
fallback = (
F.chat.type == ChatType.PRIVATE,
F.from_user.id == settings.fallback_user_id,
)
@dp.message(Command("start"), *fallback)
async def cmd_start(message: Message) -> None:
connected = max_holder.ready.is_set()
status = "подключён" if connected else "ожидание подключения"
await message.answer(
"max2telegram bridge\n\n"
f"Статус MAX: {status}\n\n"
"Команды: /help, /list, /join, /leave, /last_messages"
)
@dp.message(Command("help"), *fallback)
async def cmd_help(message: Message) -> None:
await message.answer(
"/start — статус\n"
"/help — справка\n"
"/list — активные маппинги\n"
"/join <ссылка> — вступить в MAX-группу\n"
"/leave <id или название> — выйти из MAX-чата\n"
"/last_messages <id или название> — последние 10 сообщений"
)
@dp.message(Command("list"), *fallback)
async def cmd_list(message: Message) -> None:
mappings = await storage.list_mappings()
if not mappings:
await message.answer("Маппинги не найдены.")
return
lines = [
f"• {m.display_name}\n MAX: {m.max_chat_id} → TG thread: {m.tg_thread_id}"
for m in mappings
]
await message.answer("Активные маппинги:\n\n" + "\n".join(lines))
@dp.message(Command("join"), *fallback)
async def cmd_join(message: Message, command: CommandObject) -> None:
if not command.args:
await message.answer("Использование: /join <ссылка>")
return
client = await max_holder.wait_client()
try:
chat = await client.join_group(command.args.strip())
await message.answer(f"Вступили в «{chat.title}» (id={chat.id})")
except Exception as exc:
await message.answer(f"Ошибка: {exc}")
@dp.message(Command("leave"), *fallback)
async def cmd_leave(message: Message, command: CommandObject) -> None:
if not command.args:
await message.answer("Использование: /leave <id или название>")
return
client = await max_holder.wait_client()
target = command.args.strip()
chat = await _resolve_chat(client, target)
if chat is None:
await message.answer("Чат не найден.")
return
if chat.type == MaxChatType.CHANNEL:
await client.leave_channel(chat.id)
else:
await client.leave_group(chat.id)
await message.answer(f"Вышли из «{chat.title}» (id={chat.id})")
@dp.message(Command("last_messages"), *fallback)
async def cmd_last_messages(message: Message, command: CommandObject) -> None:
if not command.args:
await message.answer("Использование: /last_messages <id или название>")
return
client = await max_holder.wait_client()
target = command.args.strip()
chat = await _resolve_chat(client, target)
if chat is None:
await message.answer("Чат не найден.")
return
history = await client.fetch_history(chat_id=chat.id, backward=10)
if not history:
await message.answer("Сообщений нет.")
return
lines = []
for msg in reversed(history):
sender = f"User {msg.sender}" if msg.sender else "?"
if msg.sender:
try:
user = await client.get_user(msg.sender)
sender = resolve_sender_name(user)
except Exception:
pass
text = (msg.text or "")[:200]
lines.append(f"[{msg.id}] {sender}: {text}")
await message.answer("\n".join(lines) or "Пусто.")
async def _resolve_chat(client, target: str):
if target.isdigit():
try:
return await client.get_chat(int(target))
except Exception:
return None
if client.chats:
for chat in client.chats:
if chat.title and target.lower() in chat.title.lower():
return chat
return None
+20
View File
@@ -0,0 +1,20 @@
import asyncio
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from aiogram import Bot
class BotHolder:
def __init__(self) -> None:
self.bot: Bot | None = None
self.ready = asyncio.Event()
def set_bot(self, bot: "Bot") -> None:
self.bot = bot
self.ready.set()
async def wait_bot(self) -> "Bot":
await self.ready.wait()
assert self.bot is not None
return self.bot
+73
View File
@@ -0,0 +1,73 @@
from aiogram.types import Message
from app.models.domain import TgIncomingMessage
def format_tg_author(message: Message) -> tuple[str, str | None]:
user = message.from_user
if user is None:
return "Unknown", None
parts = [user.first_name or "", user.last_name or ""]
name = " ".join(p for p in parts if p).strip() or (user.username or "User")
return name, user.username
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}"
def extract_tg_media(message: Message) -> list[dict]:
items: list[dict] = []
if message.photo:
photo = message.photo[-1]
items.append({"kind": "photo", "file_id": photo.file_id})
elif message.video:
items.append({"kind": "video", "file_id": message.video.file_id})
elif message.document:
items.append(
{
"kind": "document",
"file_id": message.document.file_id,
"file_name": message.document.file_name,
"mime_type": message.document.mime_type,
}
)
elif message.audio:
items.append({"kind": "audio", "file_id": message.audio.file_id})
elif message.voice:
items.append({"kind": "voice", "file_id": message.voice.file_id})
elif message.sticker or message.animation or message.video_note:
items.append({"kind": "unsupported", "file_id": None})
return items
def build_tg_incoming(message: Message, forum_channel_id: int) -> TgIncomingMessage | None:
if message.chat.id != forum_channel_id:
return None
if message.is_topic_message is False and message.message_thread_id is None:
return None
author_name, username = format_tg_author(message)
text = message.text or message.caption or ""
media = extract_tg_media(message)
if not text and not media:
return None
if media and not text and any(m.get("kind") == "unsupported" for m in media):
text = "[Telegram files]"
return TgIncomingMessage(
tg_chat_id=message.chat.id,
tg_message_id=message.message_id,
tg_thread_id=message.message_thread_id,
text=text,
author_name=author_name,
author_username=username,
is_bot=bool(message.from_user and message.from_user.is_bot),
reply_to_tg_message_id=message.reply_to_message.message_id
if message.reply_to_message
else None,
media=media,
)
+76
View File
@@ -0,0 +1,76 @@
import structlog
from aiogram import Bot, Dispatcher, F
from aiogram.enums import ChatType
from aiogram.types import Message
from app.config import Settings
from app.router.router import MessageRouter
from app.storage.protocols import StoragePort
from app.telegram_layer.admin import register_admin_handlers
from app.telegram_layer.bot_holder import BotHolder
from app.telegram_layer.formatter import build_tg_incoming, format_tg_to_max_text
logger = structlog.get_logger(__name__)
class TelegramListener:
def __init__(
self,
settings: Settings,
bot_holder: BotHolder,
router: MessageRouter,
storage: StoragePort,
max_holder,
) -> None:
self._settings = settings
self._bot_holder = bot_holder
self._router = router
self._storage = storage
self._max_holder = max_holder
self._bot = Bot(token=settings.tg_bot_token)
self._dp = Dispatcher()
async def run(self) -> None:
self._bot_holder.set_bot(self._bot)
register_admin_handlers(
self._dp,
self._settings,
self._storage,
self._max_holder,
)
self._dp.message.register(
self._on_forum_message,
F.chat.id == self._settings.tg_forum_channel_id,
F.chat.type == ChatType.SUPERGROUP,
)
logger.info("telegram_listener_started")
try:
await self._dp.start_polling(self._bot)
except Exception:
logger.exception("telegram_polling_failed")
raise
async def _on_forum_message(self, message: Message) -> None:
logger.debug(
"tg_forum_message_received",
message_id=message.message_id,
thread_id=message.message_thread_id,
has_text=bool(message.text or message.caption),
)
incoming = build_tg_incoming(message, self._settings.tg_forum_channel_id)
if incoming is None:
logger.debug("tg_forum_message_skipped", message_id=message.message_id)
return
incoming.text = format_tg_to_max_text(
incoming.author_name,
incoming.author_username,
incoming.text,
)
try:
await self._router.handle_tg_message(incoming)
except Exception:
logger.exception("tg_route_failed", message_id=message.message_id)
await self._router.notify_error(
"Ошибка маршрутизации TG → MAX",
f"message_id={message.message_id}",
)
+245
View File
@@ -0,0 +1,245 @@
import asyncio
import aiohttp
import structlog
from aiogram.types import (
FSInputFile,
InputMediaDocument,
InputMediaPhoto,
InputMediaVideo,
ReactionTypeEmoji,
)
from app.config import Settings
from app.media_transfer import cleanup_paths, 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
from app.models.tasks import (
Max2TgTask,
MediaItem,
NotifyFallbackTask,
SetReactionTask,
)
from app.queue.protocols import QueuePort
from app.storage.protocols import StoragePort
from app.telegram_layer.bot_holder import BotHolder
from app.topic_locks import TopicLockRegistry
logger = structlog.get_logger(__name__)
class TelegramWorker:
def __init__(
self,
settings: Settings,
bot_holder: BotHolder,
max_holder: MaxClientHolder,
queue: QueuePort,
storage: StoragePort,
topic_locks: TopicLockRegistry,
) -> None:
self._settings = settings
self._bot_holder = bot_holder
self._max_holder = max_holder
self._queue = queue
self._storage = storage
self._topic_locks = topic_locks
self._running = True
self._tmp_dir = tmp_dir(self._settings.data_dir)
async def run(self) -> None:
logger.info("tg_worker_started")
while self._running:
task = await self._queue.dequeue_max2tg(timeout=5)
if task is None:
continue
logger.info("tg_worker_task_received", task_kind=task.kind)
try:
if isinstance(task, Max2TgTask):
await self._send_to_tg(task)
elif isinstance(task, SetReactionTask):
await self._set_reaction(task)
elif isinstance(task, NotifyFallbackTask):
await self._notify_fallback(task)
except Exception as exc:
logger.exception("tg_worker_failed", task_kind=task.kind)
if isinstance(task, Max2TgTask):
await self._queue.enqueue_max2tg(
NotifyFallbackTask(
title="Ошибка отправки в Telegram",
details=f"max_message_id={task.max_message_id}: {exc}",
)
)
await asyncio.sleep(self._settings.tg_rate_limit_delay_sec)
async def _send_to_tg(self, task: Max2TgTask) -> None:
bot = await self._bot_holder.wait_bot()
logger.debug(
"tg_worker_send_start",
max_chat_id=task.max_chat_id,
max_message_id=task.max_message_id,
needs_new_topic=task.needs_new_topic,
media_count=len(task.media),
)
async with self._topic_locks.lock(task.max_chat_id):
mapping = await self._storage.get_mapping_by_max_chat(task.max_chat_id)
if mapping is None:
topic = await bot.create_forum_topic(
chat_id=self._settings.tg_forum_channel_id,
name=task.chat_title[:128],
)
mapping = ChatMapping(
max_chat_id=task.max_chat_id,
tg_chat_id=self._settings.tg_forum_channel_id,
tg_thread_id=topic.message_thread_id,
display_name=task.chat_title,
is_dm=task.is_dm,
)
await self._storage.save_mapping(mapping)
logger.info(
"tg_topic_created",
max_chat_id=task.max_chat_id,
thread_id=topic.message_thread_id,
title=task.chat_title,
)
else:
logger.debug(
"tg_topic_reused",
max_chat_id=task.max_chat_id,
thread_id=mapping.tg_thread_id,
)
assert mapping is not None
thread_id = mapping.tg_thread_id
formatted = format_max_text(
MaxIncomingMessage(
max_chat_id=task.max_chat_id,
max_message_id=task.max_message_id,
text=task.text,
sender_id=None,
sender_name=task.sender_name,
is_dm=task.is_dm,
chat_title=task.chat_title,
reply_to_max_message_id=None,
media=[],
)
)
sent = await self._dispatch_content(
bot, thread_id, formatted, task.media, task.reply_to_tg_message_id
)
if sent is None:
raise RuntimeError("Telegram API returned no message")
await self._storage.save_message_link(
task.max_chat_id,
task.max_message_id,
mapping.tg_chat_id,
mapping.tg_thread_id,
sent.message_id,
)
logger.info(
"tg_message_sent",
max_message_id=task.max_message_id,
tg_message_id=sent.message_id,
thread_id=thread_id,
)
async def _dispatch_content(
self,
bot,
thread_id: int,
text: str,
media: list[MediaItem],
reply_to: int | None,
):
chat_id = self._settings.tg_forum_channel_id
kwargs = {
"chat_id": chat_id,
"message_thread_id": thread_id,
"reply_to_message_id": reply_to,
}
if not media:
return await bot.send_message(text=text or " ", **kwargs)
downloaded: list = []
try:
max_client = self._max_holder.client
async with aiohttp.ClientSession() as session:
uploads: list[tuple[MediaItem, FSInputFile]] = []
for item in media:
path = await download_media_item(
bot,
session,
item,
self._tmp_dir,
max_client=max_client,
)
if path is None:
logger.warning(
"tg_worker_media_skipped",
kind=item.kind,
file_id=item.file_id,
url=item.url,
)
continue
downloaded.append(path)
uploads.append(
(item, FSInputFile(path, filename=resolve_file_name(item)))
)
if not uploads:
fallback = text or "[Telegram files]"
return await bot.send_message(text=fallback, **kwargs)
if len(uploads) == 1:
item, upload = uploads[0]
if item.kind == "photo":
return await bot.send_photo(
photo=upload, caption=text or None, **kwargs
)
if item.kind == "video":
return await bot.send_video(
video=upload, caption=text or None, **kwargs
)
return await bot.send_document(
document=upload, caption=text or None, **kwargs
)
group = []
for idx, (item, upload) in enumerate(uploads):
caption = text if idx == 0 else None
if item.kind == "photo":
group.append(InputMediaPhoto(media=upload, caption=caption))
elif item.kind == "video":
group.append(InputMediaVideo(media=upload, caption=caption))
else:
group.append(InputMediaDocument(media=upload, caption=caption))
messages = await bot.send_media_group(
chat_id=chat_id,
message_thread_id=thread_id,
media=group,
)
return messages[0]
finally:
cleanup_paths(downloaded)
async def _set_reaction(self, task: SetReactionTask) -> None:
bot = await self._bot_holder.wait_bot()
await bot.set_message_reaction(
chat_id=task.tg_chat_id,
message_id=task.tg_message_id,
reaction=[ReactionTypeEmoji(emoji=task.emoji)],
)
logger.info("tg_reaction_set", message_id=task.tg_message_id)
async def _notify_fallback(self, task: NotifyFallbackTask) -> None:
bot = await self._bot_holder.wait_bot()
text = f"⚠️ {task.title}\n\n{task.details}"
await bot.send_message(chat_id=self._settings.fallback_user_id, text=text)
logger.warning("fallback_notified", title=task.title)