@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user