@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
max_token: str
|
||||
max_device_id: str
|
||||
tg_bot_token: str
|
||||
tg_forum_channel_id: int
|
||||
fallback_user_id: int
|
||||
database_url: str = "sqlite+aiosqlite:///app/data/bridge.db"
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
tg_rate_limit_delay_sec: float = 3.5
|
||||
max_rate_limit_delay_sec: float = 1.0
|
||||
ls_topic_prefix: str = "👤 "
|
||||
max_reconnect_fetch_limit: int = 50
|
||||
log_level: str = "INFO"
|
||||
data_dir: str = ""
|
||||
max_session_name: str = "max_session.db"
|
||||
|
||||
@property
|
||||
def sqlite_path(self) -> str:
|
||||
if self.database_url.startswith("sqlite+aiosqlite:///"):
|
||||
return self.database_url.removeprefix("sqlite+aiosqlite:///")
|
||||
return "app/data/bridge.db"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _default_data_dir(self) -> "Settings":
|
||||
if not self.data_dir:
|
||||
object.__setattr__(self, "data_dir", str(Path(self.sqlite_path).parent))
|
||||
return self
|
||||
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def setup_logging(level: str) -> None:
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
)
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
getattr(logging, level.upper(), logging.INFO)
|
||||
),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import Settings
|
||||
from app.logging_setup import setup_logging
|
||||
from app.max_layer.client_holder import MaxClientHolder
|
||||
from app.max_layer.listener import MaxListener
|
||||
from app.max_layer.worker import MaxWorker
|
||||
from app.queue.redis_queue import RedisQueue
|
||||
from app.router.router import MessageRouter
|
||||
from app.storage.database import create_engine, create_session_factory
|
||||
from app.storage.repository import SqliteStorage
|
||||
from app.telegram_layer.bot_holder import BotHolder
|
||||
from app.telegram_layer.listener import TelegramListener
|
||||
from app.telegram_layer.worker import TelegramWorker
|
||||
from app.topic_locks import TopicLockRegistry
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
SERVICE_RESTART_DELAY_SEC = 3.0
|
||||
|
||||
|
||||
async def run_service(
|
||||
name: str,
|
||||
runner: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
await runner()
|
||||
logger.warning("service_stopped", service=name)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("service_cancelled", service=name)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("service_failed", service=name)
|
||||
await asyncio.sleep(SERVICE_RESTART_DELAY_SEC)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
settings = Settings()
|
||||
setup_logging(settings.log_level)
|
||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.data_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_engine(settings.database_url)
|
||||
session_factory = create_session_factory(engine)
|
||||
storage = SqliteStorage(session_factory)
|
||||
await storage.init()
|
||||
mappings = await storage.list_mappings()
|
||||
logger.info(
|
||||
"storage_ready",
|
||||
sqlite_path=settings.sqlite_path,
|
||||
mapping_count=len(mappings),
|
||||
)
|
||||
|
||||
queue = RedisQueue(settings.redis_url)
|
||||
await queue.connect()
|
||||
|
||||
topic_locks = TopicLockRegistry()
|
||||
router = MessageRouter(
|
||||
storage=storage,
|
||||
queue=queue,
|
||||
forum_channel_id=settings.tg_forum_channel_id,
|
||||
topic_locks=topic_locks,
|
||||
)
|
||||
|
||||
max_holder = MaxClientHolder()
|
||||
bot_holder = BotHolder()
|
||||
|
||||
max_listener = MaxListener(settings, max_holder, router, storage)
|
||||
tg_listener = TelegramListener(
|
||||
settings, bot_holder, router, storage, max_holder
|
||||
)
|
||||
tg_worker = TelegramWorker(
|
||||
settings, bot_holder, max_holder, queue, storage, topic_locks
|
||||
)
|
||||
max_worker = MaxWorker(settings, max_holder, bot_holder, queue, router, storage)
|
||||
|
||||
logger.info("max2telegram_starting")
|
||||
|
||||
await asyncio.gather(
|
||||
run_service("max_listener", max_listener.run),
|
||||
run_service("tg_listener", tg_listener.run),
|
||||
run_service("tg_worker", tg_worker.run),
|
||||
run_service("max_worker", max_worker.run),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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
|
||||
@@ -0,0 +1,171 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.models.tasks import MediaItem
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_MAX_CDN_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def tmp_dir(data_dir: str) -> Path:
|
||||
path = Path(data_dir) / "tmp"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _item_field(item: MediaItem | dict, name: str, default=None):
|
||||
if isinstance(item, MediaItem):
|
||||
return getattr(item, name, default)
|
||||
return item.get(name, default)
|
||||
|
||||
|
||||
def _default_file_name(kind: str) -> str:
|
||||
if "photo" in kind:
|
||||
return f"{uuid4().hex}.jpg"
|
||||
if "video" in kind:
|
||||
return f"{uuid4().hex}.mp4"
|
||||
return f"{uuid4().hex}.bin"
|
||||
|
||||
|
||||
def resolve_file_name(item: MediaItem | dict) -> str:
|
||||
kind = _item_field(item, "kind", "") or ""
|
||||
file_name = _item_field(item, "file_name")
|
||||
if file_name:
|
||||
return file_name
|
||||
return _default_file_name(kind)
|
||||
|
||||
|
||||
async def _download_url(
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
dest: Path,
|
||||
) -> bool:
|
||||
try:
|
||||
async with session.get(url, headers=_MAX_CDN_HEADERS) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(
|
||||
"media_download_http_error",
|
||||
url=url,
|
||||
status=resp.status,
|
||||
)
|
||||
return False
|
||||
dest.write_bytes(await resp.read())
|
||||
return True
|
||||
except aiohttp.ClientError as exc:
|
||||
logger.warning("media_download_failed", url=url, error=str(exc))
|
||||
return False
|
||||
|
||||
|
||||
async def _resolve_max_url(client, item: MediaItem | dict) -> str | None:
|
||||
chat_id = _item_field(item, "max_chat_id")
|
||||
message_id = _item_field(item, "max_message_id")
|
||||
if chat_id is None or message_id is None:
|
||||
return _item_field(item, "url")
|
||||
|
||||
file_id = _item_field(item, "max_file_id")
|
||||
if file_id is not None:
|
||||
file_req = await client.get_file_by_id(chat_id, message_id, file_id)
|
||||
return file_req.url if file_req else None
|
||||
|
||||
video_id = _item_field(item, "max_video_id")
|
||||
if video_id is not None:
|
||||
video = await client.get_video_by_id(chat_id, message_id, video_id)
|
||||
return video.url if video else None
|
||||
|
||||
return _item_field(item, "url")
|
||||
|
||||
|
||||
async def download_max_media(
|
||||
client,
|
||||
items: list[dict],
|
||||
dest_dir: Path,
|
||||
) -> list[dict]:
|
||||
if not items:
|
||||
return []
|
||||
|
||||
downloaded: list[dict] = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for item in items:
|
||||
url = await _resolve_max_url(client, item)
|
||||
if not url:
|
||||
logger.warning(
|
||||
"max_media_url_unresolved",
|
||||
kind=item.get("kind"),
|
||||
max_file_id=item.get("max_file_id"),
|
||||
max_video_id=item.get("max_video_id"),
|
||||
)
|
||||
continue
|
||||
|
||||
file_name = resolve_file_name(item)
|
||||
dest = dest_dir / f"{uuid4().hex}_{file_name}"
|
||||
if not await _download_url(session, url, dest):
|
||||
logger.warning(
|
||||
"max_media_download_skipped",
|
||||
kind=item.get("kind"),
|
||||
max_file_id=item.get("max_file_id"),
|
||||
max_video_id=item.get("max_video_id"),
|
||||
)
|
||||
continue
|
||||
|
||||
stored = dict(item)
|
||||
stored["local_path"] = str(dest)
|
||||
stored.pop("url", None)
|
||||
downloaded.append(stored)
|
||||
|
||||
return downloaded
|
||||
|
||||
|
||||
async def download_media_item(
|
||||
bot,
|
||||
session: aiohttp.ClientSession,
|
||||
item: MediaItem | dict,
|
||||
dest_dir: Path,
|
||||
*,
|
||||
max_client=None,
|
||||
) -> Path | None:
|
||||
local_path = _item_field(item, "local_path")
|
||||
if local_path:
|
||||
path = Path(local_path)
|
||||
if path.is_file():
|
||||
return path
|
||||
logger.warning("media_local_path_missing", path=local_path)
|
||||
|
||||
file_id = _item_field(item, "file_id")
|
||||
file_name = resolve_file_name(item)
|
||||
|
||||
if file_id:
|
||||
tg_file = await bot.get_file(file_id)
|
||||
if tg_file.file_path is None:
|
||||
return None
|
||||
dest = dest_dir / file_name
|
||||
await bot.download_file(tg_file.file_path, destination=dest)
|
||||
return dest
|
||||
|
||||
url = _item_field(item, "url")
|
||||
if url is None and max_client is not None:
|
||||
url = await _resolve_max_url(max_client, item)
|
||||
|
||||
if url:
|
||||
dest = dest_dir / file_name
|
||||
if await _download_url(session, url, dest):
|
||||
return dest
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_paths(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("media_temp_cleanup_failed", path=str(path))
|
||||
@@ -0,0 +1,36 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChatMapping:
|
||||
max_chat_id: int
|
||||
tg_chat_id: int
|
||||
tg_thread_id: int
|
||||
display_name: str
|
||||
is_dm: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MaxIncomingMessage:
|
||||
max_chat_id: int
|
||||
max_message_id: int
|
||||
text: str
|
||||
sender_id: int | None
|
||||
sender_name: str | None
|
||||
is_dm: bool
|
||||
chat_title: str
|
||||
reply_to_max_message_id: int | None
|
||||
media: list[dict]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TgIncomingMessage:
|
||||
tg_chat_id: int
|
||||
tg_message_id: int
|
||||
tg_thread_id: int | None
|
||||
text: str
|
||||
author_name: str
|
||||
author_username: str | None
|
||||
is_bot: bool
|
||||
reply_to_tg_message_id: int | None
|
||||
media: list[dict]
|
||||
@@ -0,0 +1,77 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskKind(str, Enum):
|
||||
SEND_TO_TG = "send_to_tg"
|
||||
SET_REACTION = "set_reaction"
|
||||
NOTIFY_FALLBACK = "notify_fallback"
|
||||
SEND_TO_MAX = "send_to_max"
|
||||
|
||||
|
||||
class MediaItem(BaseModel):
|
||||
kind: str
|
||||
url: str | None = None
|
||||
file_id: str | None = None
|
||||
file_name: str | None = None
|
||||
mime_type: str | None = None
|
||||
caption: str | None = None
|
||||
local_path: str | None = None
|
||||
max_chat_id: int | None = None
|
||||
max_message_id: int | None = None
|
||||
max_file_id: int | None = None
|
||||
max_video_id: int | None = None
|
||||
|
||||
|
||||
class Max2TgTask(BaseModel):
|
||||
kind: TaskKind = TaskKind.SEND_TO_TG
|
||||
max_chat_id: int
|
||||
max_message_id: int
|
||||
text: str
|
||||
sender_name: str | None = None
|
||||
is_dm: bool = False
|
||||
chat_title: str
|
||||
needs_new_topic: bool = False
|
||||
reply_to_tg_message_id: int | None = None
|
||||
media: list[MediaItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SetReactionTask(BaseModel):
|
||||
kind: TaskKind = TaskKind.SET_REACTION
|
||||
tg_chat_id: int
|
||||
tg_message_id: int
|
||||
tg_thread_id: int | None = None
|
||||
emoji: str = "🦄"
|
||||
|
||||
|
||||
class NotifyFallbackTask(BaseModel):
|
||||
kind: TaskKind = TaskKind.NOTIFY_FALLBACK
|
||||
title: str
|
||||
details: str
|
||||
|
||||
|
||||
class Tg2MaxTask(BaseModel):
|
||||
kind: TaskKind = TaskKind.SEND_TO_MAX
|
||||
max_chat_id: int
|
||||
text: str
|
||||
tg_chat_id: int
|
||||
tg_message_id: int
|
||||
tg_thread_id: int | None = None
|
||||
reply_to_max_message_id: int | None = None
|
||||
media: list[MediaItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
def parse_queue_payload(data: str) -> Max2TgTask | SetReactionTask | NotifyFallbackTask | Tg2MaxTask:
|
||||
raw: dict[str, Any] = __import__("json").loads(data)
|
||||
kind = TaskKind(raw.get("kind", TaskKind.SEND_TO_TG))
|
||||
if kind == TaskKind.SEND_TO_TG:
|
||||
return Max2TgTask.model_validate(raw)
|
||||
if kind == TaskKind.SET_REACTION:
|
||||
return SetReactionTask.model_validate(raw)
|
||||
if kind == TaskKind.NOTIFY_FALLBACK:
|
||||
return NotifyFallbackTask.model_validate(raw)
|
||||
if kind == TaskKind.SEND_TO_MAX:
|
||||
return Tg2MaxTask.model_validate(raw)
|
||||
raise ValueError(f"Unknown task kind: {kind}")
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import Protocol
|
||||
|
||||
from app.models.tasks import Max2TgTask, NotifyFallbackTask, SetReactionTask, Tg2MaxTask
|
||||
|
||||
|
||||
class QueuePort(Protocol):
|
||||
async def connect(self) -> None: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
async def enqueue_max2tg(self, task: Max2TgTask | SetReactionTask | NotifyFallbackTask) -> None: ...
|
||||
|
||||
async def dequeue_max2tg(self, timeout: int = 0) -> Max2TgTask | SetReactionTask | NotifyFallbackTask | None: ...
|
||||
|
||||
async def enqueue_tg2max(self, task: Tg2MaxTask) -> None: ...
|
||||
|
||||
async def dequeue_tg2max(self, timeout: int = 0) -> Tg2MaxTask | None: ...
|
||||
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from redis.exceptions import TimeoutError as RedisTimeoutError
|
||||
|
||||
from app.models.tasks import (
|
||||
Max2TgTask,
|
||||
NotifyFallbackTask,
|
||||
SetReactionTask,
|
||||
Tg2MaxTask,
|
||||
parse_queue_payload,
|
||||
)
|
||||
from app.queue.protocols import QueuePort
|
||||
|
||||
MAX2TG_QUEUE = "max2tg_queue"
|
||||
TG2MAX_QUEUE = "tg2max_queue"
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class RedisQueue(QueuePort):
|
||||
def __init__(self, redis_url: str) -> None:
|
||||
self._redis_url = redis_url
|
||||
self._client: redis.Redis | None = None
|
||||
|
||||
async def _blpop(self, queue: str, timeout: int) -> tuple[str, str] | None:
|
||||
assert self._client is not None
|
||||
try:
|
||||
return await self._client.blpop(queue, timeout=timeout or None)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except RedisTimeoutError as exc:
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling():
|
||||
raise asyncio.CancelledError() from exc
|
||||
return None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._client = redis.from_url(self._redis_url, decode_responses=True)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def enqueue_max2tg(
|
||||
self, task: Max2TgTask | SetReactionTask | NotifyFallbackTask
|
||||
) -> None:
|
||||
assert self._client is not None
|
||||
await self._client.rpush(MAX2TG_QUEUE, task.model_dump_json())
|
||||
logger.debug("queue_enqueued", queue=MAX2TG_QUEUE, kind=task.kind)
|
||||
|
||||
async def dequeue_max2tg(
|
||||
self, timeout: int = 0
|
||||
) -> Max2TgTask | SetReactionTask | NotifyFallbackTask | None:
|
||||
assert self._client is not None
|
||||
result = await self._blpop(MAX2TG_QUEUE, timeout=timeout)
|
||||
if result is None:
|
||||
return None
|
||||
_, payload = result
|
||||
task = parse_queue_payload(payload)
|
||||
logger.debug("queue_dequeued", queue=MAX2TG_QUEUE, kind=task.kind)
|
||||
return task
|
||||
|
||||
async def enqueue_tg2max(self, task: Tg2MaxTask) -> None:
|
||||
assert self._client is not None
|
||||
await self._client.rpush(TG2MAX_QUEUE, task.model_dump_json())
|
||||
logger.debug(
|
||||
"queue_enqueued",
|
||||
queue=TG2MAX_QUEUE,
|
||||
kind=task.kind,
|
||||
tg_message_id=task.tg_message_id,
|
||||
)
|
||||
|
||||
async def dequeue_tg2max(self, timeout: int = 0) -> Tg2MaxTask | None:
|
||||
assert self._client is not None
|
||||
result = await self._blpop(TG2MAX_QUEUE, timeout=timeout)
|
||||
if result is None:
|
||||
return None
|
||||
_, payload = result
|
||||
data = json.loads(payload)
|
||||
task = Tg2MaxTask.model_validate(data)
|
||||
logger.debug(
|
||||
"queue_dequeued",
|
||||
queue=TG2MAX_QUEUE,
|
||||
tg_message_id=task.tg_message_id,
|
||||
)
|
||||
return task
|
||||
@@ -0,0 +1,161 @@
|
||||
import structlog
|
||||
|
||||
from app.models.domain import MaxIncomingMessage, TgIncomingMessage
|
||||
from app.models.tasks import Max2TgTask, MediaItem, NotifyFallbackTask, Tg2MaxTask
|
||||
from app.queue.protocols import QueuePort
|
||||
from app.storage.protocols import StoragePort
|
||||
from app.topic_locks import TopicLockRegistry
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MessageRouter:
|
||||
"""Маршрутизация сообщений между MAX и Telegram через очереди и хранилище."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: StoragePort,
|
||||
queue: QueuePort,
|
||||
forum_channel_id: int,
|
||||
topic_locks: TopicLockRegistry,
|
||||
) -> None:
|
||||
self._storage = storage
|
||||
self._queue = queue
|
||||
self._forum_channel_id = forum_channel_id
|
||||
self._topic_locks = topic_locks
|
||||
|
||||
async def handle_max_message(self, message: MaxIncomingMessage) -> bool:
|
||||
logger.debug(
|
||||
"router_max_message_received",
|
||||
max_chat_id=message.max_chat_id,
|
||||
message_id=message.max_message_id,
|
||||
text_len=len(message.text),
|
||||
media_count=len(message.media),
|
||||
)
|
||||
if message.max_chat_id is None or message.max_message_id is None:
|
||||
logger.warning(
|
||||
"router_max_message_rejected",
|
||||
reason="missing_ids",
|
||||
max_chat_id=message.max_chat_id,
|
||||
message_id=message.max_message_id,
|
||||
)
|
||||
return False
|
||||
|
||||
async with self._topic_locks.lock(message.max_chat_id):
|
||||
mapping = await self._storage.get_mapping_by_max_chat(message.max_chat_id)
|
||||
needs_new_topic = mapping is None
|
||||
if mapping is not None:
|
||||
logger.debug(
|
||||
"router_mapping_found",
|
||||
max_chat_id=message.max_chat_id,
|
||||
thread_id=mapping.tg_thread_id,
|
||||
)
|
||||
|
||||
reply_to_tg: int | None = None
|
||||
if message.reply_to_max_message_id is not None:
|
||||
link = await self._storage.get_tg_message_by_max(
|
||||
message.max_chat_id, message.reply_to_max_message_id
|
||||
)
|
||||
if link:
|
||||
reply_to_tg = link[2]
|
||||
logger.debug(
|
||||
"router_reply_mapped",
|
||||
max_reply_to=message.reply_to_max_message_id,
|
||||
tg_reply_to=reply_to_tg,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"router_reply_not_found",
|
||||
max_chat_id=message.max_chat_id,
|
||||
max_reply_to=message.reply_to_max_message_id,
|
||||
)
|
||||
|
||||
media = [MediaItem.model_validate(item) for item in message.media]
|
||||
|
||||
task = Max2TgTask(
|
||||
max_chat_id=message.max_chat_id,
|
||||
max_message_id=message.max_message_id,
|
||||
text=message.text,
|
||||
sender_name=message.sender_name,
|
||||
is_dm=message.is_dm,
|
||||
chat_title=message.chat_title,
|
||||
needs_new_topic=needs_new_topic,
|
||||
reply_to_tg_message_id=reply_to_tg,
|
||||
media=media,
|
||||
)
|
||||
await self._queue.enqueue_max2tg(task)
|
||||
logger.info(
|
||||
"max_message_enqueued",
|
||||
max_chat_id=message.max_chat_id,
|
||||
message_id=message.max_message_id,
|
||||
needs_new_topic=needs_new_topic,
|
||||
)
|
||||
return True
|
||||
|
||||
async def handle_tg_message(self, message: TgIncomingMessage) -> bool:
|
||||
logger.debug(
|
||||
"router_tg_message_received",
|
||||
tg_chat_id=message.tg_chat_id,
|
||||
tg_message_id=message.tg_message_id,
|
||||
thread_id=message.tg_thread_id,
|
||||
text_len=len(message.text),
|
||||
)
|
||||
if message.is_bot:
|
||||
logger.debug("router_tg_message_skipped", reason="is_bot")
|
||||
return False
|
||||
|
||||
if message.tg_chat_id != self._forum_channel_id:
|
||||
logger.debug(
|
||||
"router_tg_message_skipped",
|
||||
reason="wrong_chat",
|
||||
tg_chat_id=message.tg_chat_id,
|
||||
)
|
||||
return False
|
||||
|
||||
if message.tg_thread_id is None:
|
||||
logger.debug("router_tg_message_skipped", reason="no_thread_id")
|
||||
return False
|
||||
|
||||
mapping = await self._storage.get_mapping_by_tg_thread(
|
||||
message.tg_chat_id, message.tg_thread_id
|
||||
)
|
||||
if mapping is None:
|
||||
logger.debug(
|
||||
"tg_message_no_mapping",
|
||||
tg_chat_id=message.tg_chat_id,
|
||||
thread_id=message.tg_thread_id,
|
||||
)
|
||||
return False
|
||||
|
||||
reply_to_max: int | None = None
|
||||
if message.reply_to_tg_message_id is not None:
|
||||
link = await self._storage.get_max_message_by_tg(
|
||||
message.tg_chat_id, message.reply_to_tg_message_id
|
||||
)
|
||||
if link:
|
||||
reply_to_max = link[1]
|
||||
|
||||
media = [MediaItem.model_validate(item) for item in message.media]
|
||||
|
||||
task = Tg2MaxTask(
|
||||
max_chat_id=mapping.max_chat_id,
|
||||
text=message.text,
|
||||
tg_chat_id=message.tg_chat_id,
|
||||
tg_message_id=message.tg_message_id,
|
||||
tg_thread_id=message.tg_thread_id,
|
||||
reply_to_max_message_id=reply_to_max,
|
||||
media=media,
|
||||
)
|
||||
await self._queue.enqueue_tg2max(task)
|
||||
logger.info(
|
||||
"tg_message_enqueued",
|
||||
tg_message_id=message.tg_message_id,
|
||||
max_chat_id=mapping.max_chat_id,
|
||||
)
|
||||
return True
|
||||
|
||||
async def notify_error(self, title: str, details: str) -> None:
|
||||
logger.error("router_notify_error", title=title, details=details)
|
||||
await self._queue.enqueue_max2tg(
|
||||
NotifyFallbackTask(title=title, details=details)
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
def create_engine(database_url: str) -> AsyncEngine:
|
||||
engine = create_async_engine(database_url, echo=False)
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_conn, _record) -> None:
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker:
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
@@ -0,0 +1,38 @@
|
||||
from typing import Protocol
|
||||
|
||||
from app.models.domain import ChatMapping
|
||||
|
||||
|
||||
class StoragePort(Protocol):
|
||||
async def init(self) -> None: ...
|
||||
|
||||
async def get_mapping_by_max_chat(self, max_chat_id: int) -> ChatMapping | None: ...
|
||||
|
||||
async def get_mapping_by_tg_thread(
|
||||
self, tg_chat_id: int, tg_thread_id: int
|
||||
) -> ChatMapping | None: ...
|
||||
|
||||
async def save_mapping(self, mapping: ChatMapping) -> None: ...
|
||||
|
||||
async def list_mappings(self) -> list[ChatMapping]: ...
|
||||
|
||||
async def get_sync_marker(self, max_chat_id: int) -> int | None: ...
|
||||
|
||||
async def update_sync_marker(self, max_chat_id: int, message_id: int) -> None: ...
|
||||
|
||||
async def save_message_link(
|
||||
self,
|
||||
max_chat_id: int,
|
||||
max_message_id: int,
|
||||
tg_chat_id: int,
|
||||
tg_thread_id: int,
|
||||
tg_message_id: int,
|
||||
) -> None: ...
|
||||
|
||||
async def get_tg_message_by_max(
|
||||
self, max_chat_id: int, max_message_id: int
|
||||
) -> tuple[int, int, int] | None: ...
|
||||
|
||||
async def get_max_message_by_tg(
|
||||
self, tg_chat_id: int, tg_message_id: int
|
||||
) -> tuple[int, int] | None: ...
|
||||
@@ -0,0 +1,157 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.models.domain import ChatMapping
|
||||
from app.storage.protocols import StoragePort
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class ChatMappingRow(Base):
|
||||
__tablename__ = "chat_mappings"
|
||||
|
||||
max_chat_id = Column(Integer, primary_key=True)
|
||||
tg_chat_id = Column(Integer, nullable=False)
|
||||
tg_thread_id = Column(Integer, nullable=False)
|
||||
display_name = Column(String, nullable=False)
|
||||
is_dm = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class SyncMarkerRow(Base):
|
||||
__tablename__ = "sync_markers"
|
||||
|
||||
max_chat_id = Column(Integer, primary_key=True)
|
||||
last_processed_message_id = Column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class MessageLinkRow(Base):
|
||||
__tablename__ = "message_links"
|
||||
|
||||
max_chat_id = Column(Integer, primary_key=True)
|
||||
max_message_id = Column(Integer, primary_key=True)
|
||||
tg_chat_id = Column(Integer, nullable=False)
|
||||
tg_thread_id = Column(Integer, nullable=False)
|
||||
tg_message_id = Column(Integer, nullable=False)
|
||||
|
||||
|
||||
class SqliteStorage(StoragePort):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def init(self) -> None:
|
||||
async with self._session_factory() as session:
|
||||
async with session.begin():
|
||||
conn = await session.connection()
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def get_mapping_by_max_chat(self, max_chat_id: int) -> ChatMapping | None:
|
||||
async with self._session_factory() as session:
|
||||
row = await session.get(ChatMappingRow, max_chat_id)
|
||||
if row is None:
|
||||
return None
|
||||
return self._to_mapping(row)
|
||||
|
||||
async def get_mapping_by_tg_thread(
|
||||
self, tg_chat_id: int, tg_thread_id: int
|
||||
) -> ChatMapping | None:
|
||||
async with self._session_factory() as session:
|
||||
stmt = select(ChatMappingRow).where(
|
||||
ChatMappingRow.tg_chat_id == tg_chat_id,
|
||||
ChatMappingRow.tg_thread_id == tg_thread_id,
|
||||
)
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return self._to_mapping(row)
|
||||
|
||||
async def save_mapping(self, mapping: ChatMapping) -> None:
|
||||
async with self._session_factory() as session:
|
||||
async with session.begin():
|
||||
row = await session.get(ChatMappingRow, mapping.max_chat_id)
|
||||
if row is None:
|
||||
session.add(
|
||||
ChatMappingRow(
|
||||
max_chat_id=mapping.max_chat_id,
|
||||
tg_chat_id=mapping.tg_chat_id,
|
||||
tg_thread_id=mapping.tg_thread_id,
|
||||
display_name=mapping.display_name,
|
||||
is_dm=mapping.is_dm,
|
||||
)
|
||||
)
|
||||
else:
|
||||
row.tg_chat_id = mapping.tg_chat_id
|
||||
row.tg_thread_id = mapping.tg_thread_id
|
||||
row.display_name = mapping.display_name
|
||||
row.is_dm = mapping.is_dm
|
||||
|
||||
async def list_mappings(self) -> list[ChatMapping]:
|
||||
async with self._session_factory() as session:
|
||||
rows = (await session.execute(select(ChatMappingRow))).scalars().all()
|
||||
return [self._to_mapping(row) for row in rows]
|
||||
|
||||
async def get_sync_marker(self, max_chat_id: int) -> int | None:
|
||||
async with self._session_factory() as session:
|
||||
row = await session.get(SyncMarkerRow, max_chat_id)
|
||||
return row.last_processed_message_id if row else None
|
||||
|
||||
async def update_sync_marker(self, max_chat_id: int, message_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
async with session.begin():
|
||||
await session.merge(
|
||||
SyncMarkerRow(max_chat_id=max_chat_id, last_processed_message_id=message_id)
|
||||
)
|
||||
|
||||
async def save_message_link(
|
||||
self,
|
||||
max_chat_id: int,
|
||||
max_message_id: int,
|
||||
tg_chat_id: int,
|
||||
tg_thread_id: int,
|
||||
tg_message_id: int,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
async with session.begin():
|
||||
await session.merge(
|
||||
MessageLinkRow(
|
||||
max_chat_id=max_chat_id,
|
||||
max_message_id=max_message_id,
|
||||
tg_chat_id=tg_chat_id,
|
||||
tg_thread_id=tg_thread_id,
|
||||
tg_message_id=tg_message_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_tg_message_by_max(
|
||||
self, max_chat_id: int, max_message_id: int
|
||||
) -> tuple[int, int, int] | None:
|
||||
async with self._session_factory() as session:
|
||||
row = await session.get(MessageLinkRow, (max_chat_id, max_message_id))
|
||||
if row is None:
|
||||
return None
|
||||
return row.tg_chat_id, row.tg_thread_id, row.tg_message_id
|
||||
|
||||
async def get_max_message_by_tg(
|
||||
self, tg_chat_id: int, tg_message_id: int
|
||||
) -> tuple[int, int] | None:
|
||||
async with self._session_factory() as session:
|
||||
stmt = select(MessageLinkRow).where(
|
||||
MessageLinkRow.tg_chat_id == tg_chat_id,
|
||||
MessageLinkRow.tg_message_id == tg_message_id,
|
||||
)
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return row.max_chat_id, row.max_message_id
|
||||
|
||||
@staticmethod
|
||||
def _to_mapping(row: ChatMappingRow) -> ChatMapping:
|
||||
return ChatMapping(
|
||||
max_chat_id=row.max_chat_id,
|
||||
tg_chat_id=row.tg_chat_id,
|
||||
tg_thread_id=row.tg_thread_id,
|
||||
display_name=row.display_name,
|
||||
is_dm=row.is_dm,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,13 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
class TopicLockRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[int, asyncio.Lock] = {}
|
||||
|
||||
def lock(self, max_chat_id: int) -> asyncio.Lock:
|
||||
lock = self._locks.get(max_chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[max_chat_id] = lock
|
||||
return lock
|
||||
Reference in New Issue
Block a user