Files
max2telegram/src/reverse_bridge.py
T
2026-04-10 19:09:15 +03:00

481 lines
19 KiB
Python

import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any
from pymax import MaxClient
from pymax.files import Photo, Video
from storage import BridgeStorage
from telegram_api import TelegramClient
from health import HealthState
from telegram_max_commands import handle_control_command
logger = logging.getLogger(__name__)
def _normalize_title(value: str) -> str:
return str(value or "").strip().casefold()
def _telegram_chat_title(chat: dict[str, Any]) -> str:
# для каналов обычно есть title; для групп тоже; в крайнем случае — username
return str(chat.get("title") or chat.get("username") or "").strip()
def _format_sender_line(sender: dict[str, Any] | None) -> str:
if not isinstance(sender, dict):
return "Unknown:"
first = str(sender.get("first_name") or "").strip()
last = str(sender.get("last_name") or "").strip()
username = str(sender.get("username") or "").strip()
full_name = " ".join([p for p in (first, last) if p])
if not full_name:
full_name = "Unknown"
if username:
return f"{full_name} (@{username}):"
return f"{full_name}:"
def _format_forward_text(*, sender: dict[str, Any] | None, text: str) -> str:
header = _format_sender_line(sender)
body = str(text or "").strip()
if body:
return f"{header}\n{body}"
return header
def _is_supported_telegram_message(message: dict[str, Any]) -> bool:
# Текстовые сообщения и команды.
text = message.get("text")
if isinstance(text, str) and text.strip():
return True
photos = message.get("photo")
has_photo = isinstance(photos, list) and any(isinstance(p, dict) and p.get("file_id") for p in photos)
if has_photo:
return True
video = message.get("video")
has_video = isinstance(video, dict) and video.get("file_id")
if has_video:
return True
return False
@dataclass
class _MediaGroupBuffer:
first_seen_monotonic: float
updates: list[dict[str, Any]] = field(default_factory=list)
class TelegramToMaxBridge:
def __init__(
self,
*,
max_client: MaxClient,
telegram: TelegramClient,
storage: BridgeStorage,
health: "HealthState | None" = None,
) -> None:
self._max_client = max_client
self._telegram = telegram
self._storage = storage
self._health = health
self._max_title_to_id: dict[str, int] = {}
self._bot_id: str | None = None
self._offset: int | None = None
self._media_groups: dict[tuple[str, str], _MediaGroupBuffer] = {}
self._media_group_grace_sec = 1.2
self._start_lock = asyncio.Lock()
self._is_running = False
async def start(self) -> None:
async with self._start_lock:
if self._is_running:
logger.warning("Telegram->Max bridge start skipped: poller is already running")
return
self._is_running = True
try:
me = await self._telegram.get_me()
self._bot_id = str(me.get("id") or "")
if not self._bot_id:
raise RuntimeError("Cannot resolve Telegram bot id (getMe)")
self._refresh_max_chat_cache()
logger.info("Telegram->Max bridge started (bot_id=%s)", self._bot_id)
while True:
try:
updates = await self._telegram.get_updates(offset=self._offset, timeout=25, limit=100)
if self._health:
self._health.mark_telegram_ok()
await self._handle_updates(updates)
except Exception:
if self._health:
self._health.mark_telegram_error()
logger.exception("Telegram polling loop error")
# 409 Conflict: где-то еще идет getUpdates (другой инстанс или webhook/второй poller).
# Делаем backoff, чтобы не долбить API.
await asyncio.sleep(10)
finally:
async with self._start_lock:
self._is_running = False
async def _handle_updates(self, updates: list[dict[str, Any]]) -> None:
max_update_id = None
for upd in updates:
upd_id = upd.get("update_id")
if isinstance(upd_id, int):
max_update_id = upd_id if max_update_id is None else max(max_update_id, upd_id)
message = None
for container in ("message", "channel_post"):
candidate = upd.get(container)
if isinstance(candidate, dict):
message = candidate
break
if not message:
continue
if not _is_supported_telegram_message(message):
continue
if self._is_own_telegram_message(message):
continue
await self._handle_message(message)
if max_update_id is not None:
self._offset = max_update_id + 1
await self._flush_ready_media_groups()
def _is_own_telegram_message(self, message: dict[str, Any]) -> bool:
sender = message.get("from")
if isinstance(sender, dict):
if sender.get("is_bot") is True:
# важно: не уйти в цикл на собственных постах бота
return True
if self._bot_id and str(sender.get("id") or "") == self._bot_id:
return True
return False
async def _handle_message(self, message: dict[str, Any]) -> None:
chat = message.get("chat")
if not isinstance(chat, dict):
return
# Команда привязки чата Telegram к названию чата в MAX (для Max->Telegram маршрутизации).
# Обрабатываем раньше control-команд, чтобы /bind_max не попадала как "неизвестная".
text = str(message.get("text") or "").strip()
cmd = text.split(maxsplit=1)[0].split("@", 1)[0].strip().casefold() if text else ""
if cmd == "/bind_max":
await self._handle_bind_max_command(message, chat)
return
# Управление MAX через Telegram: только личка боту и только от fallback_user_id.
# В этом случае команду не пересылаем в MAX.
try:
reply = await handle_control_command(message, max_client=self._max_client, telegram=self._telegram)
except Exception:
logger.exception("Telegram MAX control command handler failed")
reply = None
if reply is not None:
chat_id = str(chat.get("id") or "")
if chat_id:
try:
await self._telegram.send_text(chat_id=chat_id, text=reply)
except Exception:
logger.exception("Cannot send Telegram reply for control command")
return
chat_title = _telegram_chat_title(chat)
normalized = _normalize_title(chat_title)
if not normalized:
logger.error("Telegram chat without title/username, skip (chat=%s)", chat)
return
max_chat_id = self._resolve_max_chat_id_by_title(normalized)
if max_chat_id is None:
# требование: если в MAX нет канала/группы — ошибка и не пересылать
logger.error("MAX чат с названием '%s' не найден — сообщение не пересылаю", chat_title)
return
telegram_chat_id = str(chat.get("id"))
telegram_message_id = str(message.get("message_id"))
media_group_id = message.get("media_group_id")
if media_group_id is not None:
key = (telegram_chat_id, str(media_group_id))
buf = self._media_groups.get(key)
if buf is None:
buf = _MediaGroupBuffer(first_seen_monotonic=time.monotonic())
self._media_groups[key] = buf
buf.updates.append(message)
return
await self._forward_single_message(
max_chat_id=max_chat_id,
telegram_chat_id=telegram_chat_id,
telegram_message_id=telegram_message_id,
message=message,
media_group_id=None,
)
async def _handle_bind_max_command(self, message: dict[str, Any], chat: dict[str, Any]) -> None:
raw = str(message.get("text") or "").strip()
chat_id = str(chat.get("id") or "")
# формат: /bind_max <точное название чата в MAX>
parts = raw.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
logger.error("bind_max: missing MAX chat title. Use: /bind_max <MAX chat title>")
if chat_id:
await self._telegram.send_text(chat_id=chat_id, text="Использование: /bind_max <точное название чата в MAX>")
return
max_title = parts[1].strip()
norm = _normalize_title(max_title)
# проверяем, что чат существует в MAX
max_chat_id = self._resolve_max_chat_id_by_title(norm)
if max_chat_id is None:
logger.error("bind_max: MAX чат '%s' не найден — привязку не сохраняю", max_title)
if chat_id:
await self._telegram.send_text(chat_id=chat_id, text=f"MAX чат '{max_title}' не найден. Привязка не сохранена.")
return
telegram_chat_id = chat_id
telegram_title = _telegram_chat_title(chat)
self._storage.set_chat_route(
max_chat_title_norm=norm,
telegram_chat_id=telegram_chat_id,
telegram_chat_title=telegram_title,
)
logger.info(
"bind_max: bound MAX '%s' -> Telegram '%s' (%s)",
max_title,
telegram_title,
telegram_chat_id,
)
if chat_id:
await self._telegram.send_text(
chat_id=chat_id,
text=f"Канал успешно привязан: Telegram '{telegram_title or telegram_chat_id}' -> MAX '{max_title}'.",
)
async def _flush_ready_media_groups(self) -> None:
now = time.monotonic()
ready: list[tuple[tuple[str, str], _MediaGroupBuffer]] = []
for key, buf in self._media_groups.items():
if (now - buf.first_seen_monotonic) >= self._media_group_grace_sec:
ready.append((key, buf))
for key, buf in ready:
self._media_groups.pop(key, None)
telegram_chat_id, media_group_id = key
# сообщения альбома приходят отдельно; отправляем в MAX одним сообщением с несколькими attachments
await self._forward_media_group(
telegram_chat_id=telegram_chat_id,
media_group_id=media_group_id,
messages=buf.updates,
)
async def _forward_media_group(self, *, telegram_chat_id: str, media_group_id: str, messages: list[dict[str, Any]]) -> None:
if not messages:
return
chat = messages[0].get("chat")
if not isinstance(chat, dict):
return
chat_title = _telegram_chat_title(chat)
normalized = _normalize_title(chat_title)
max_chat_id = self._resolve_max_chat_id_by_title(normalized)
if max_chat_id is None:
logger.error("MAX чат с названием '%s' не найден — media group не пересылаю", chat_title)
return
# Telegram может прислать несколько элементов с caption только на первом. Берём text/caption с первого, где он есть.
raw_text = ""
for m in messages:
cand = str(m.get("text") or m.get("caption") or "").strip()
if cand:
raw_text = cand
break
text = _format_forward_text(sender=messages[0].get("from"), text=raw_text)
reply_to = self._resolve_reply_to_max_id(max_chat_id=max_chat_id, message=messages[0])
attachments: list[Any] = []
for m in messages:
attachments.extend(await self._extract_attachments(m))
if not text.strip() and not attachments:
return
sent = await self._max_client.send_message(
chat_id=max_chat_id,
text=text,
attachments=attachments if attachments else None,
reply_to=reply_to,
)
if not sent:
logger.error("MAX send_message returned empty for media group (chat_id=%s)", max_chat_id)
return
max_message_id = str(getattr(sent, "id", "") or "")
if not max_message_id:
logger.error("Cannot resolve MAX message id after sending media group (chat_id=%s)", max_chat_id)
return
# Реакция на каждое сообщение альбома в Telegram — best effort.
for m in messages:
mid = m.get("message_id")
if mid is None:
continue
try:
await self._telegram.add_reaction(chat_id=telegram_chat_id, message_id=str(mid), emoji="🦄")
except Exception:
logger.debug("Cannot add Telegram reaction", exc_info=True)
for m in messages:
tid = str(m.get("message_id"))
if tid:
self._storage.save_mapping(
telegram_chat_id=telegram_chat_id,
telegram_message_id=tid,
max_chat_id=str(max_chat_id),
max_message_id=max_message_id,
media_group_id=media_group_id,
)
logger.info(
"Forwarded Telegram media group %s (count=%s) -> MAX %s/%s",
media_group_id,
len(messages),
max_chat_id,
max_message_id,
)
async def _forward_single_message(
self,
*,
max_chat_id: int,
telegram_chat_id: str,
telegram_message_id: str,
message: dict[str, Any],
media_group_id: str | None,
) -> None:
raw_text = str(message.get("text") or message.get("caption") or "").strip()
text = _format_forward_text(sender=message.get("from"), text=raw_text)
attachments = await self._extract_attachments(message)
if not text.strip() and not attachments:
return
reply_to = self._resolve_reply_to_max_id(max_chat_id=max_chat_id, message=message)
sent = await self._max_client.send_message(
chat_id=max_chat_id,
text=text,
attachments=attachments if attachments else None,
reply_to=reply_to,
)
if not sent:
logger.error("MAX send_message returned empty (chat_id=%s)", max_chat_id)
return
max_message_id = str(getattr(sent, "id", "") or "")
if not max_message_id:
logger.error("Cannot resolve MAX message id after sending (chat_id=%s)", max_chat_id)
return
# Реакция в Telegram: best effort.
try:
await self._telegram.add_reaction(chat_id=telegram_chat_id, message_id=telegram_message_id, emoji="🦄")
except Exception:
logger.debug("Cannot add Telegram reaction", exc_info=True)
self._storage.save_mapping(
telegram_chat_id=telegram_chat_id,
telegram_message_id=telegram_message_id,
max_chat_id=str(max_chat_id),
max_message_id=max_message_id,
media_group_id=media_group_id,
)
logger.info("Forwarded Telegram %s/%s -> MAX %s/%s", telegram_chat_id, telegram_message_id, max_chat_id, max_message_id)
def _resolve_reply_to_max_id(self, *, max_chat_id: int, message: dict[str, Any]) -> str | None:
reply = message.get("reply_to_message")
if not isinstance(reply, dict):
return None
reply_mid = reply.get("message_id")
if reply_mid is None:
return None
chat = message.get("chat")
if not isinstance(chat, dict):
return None
telegram_chat_id = str(chat.get("id"))
mapped = self._storage.get_max_message_id_for_telegram(
telegram_chat_id=telegram_chat_id,
telegram_message_id=str(reply_mid),
)
# reply_to в MAX — это id сообщения; если не нашли, просто отправляем без reply
return mapped
async def _extract_attachments(self, message: dict[str, Any]) -> list[Any]:
attachments: list[Any] = []
# photo: массив размеров, берём последний (самый большой)
photos = message.get("photo")
if isinstance(photos, list) and photos:
best = None
for p in photos:
if isinstance(p, dict) and p.get("file_id"):
best = p
if best and isinstance(best, dict):
file_id = str(best.get("file_id"))
if file_id:
try:
url = await self._telegram.get_file_url(file_id)
attachments.append(Photo(url=url))
except Exception:
logger.exception("Cannot fetch Telegram photo URL")
video = message.get("video")
if isinstance(video, dict) and video.get("file_id"):
file_id = str(video.get("file_id"))
if file_id:
try:
url = await self._telegram.get_file_url(file_id)
attachments.append(Video(url=url))
except Exception:
logger.exception("Cannot fetch Telegram video URL")
return attachments
def _refresh_max_chat_cache(self) -> None:
title_to_id: dict[str, int] = {}
for chat in getattr(self._max_client, "chats", []) or []:
title = getattr(chat, "title", None)
chat_id = getattr(chat, "id", None)
if title and chat_id is not None:
title_to_id[_normalize_title(str(title))] = int(chat_id)
self._max_title_to_id = title_to_id
def _resolve_max_chat_id_by_title(self, normalized_title: str) -> int | None:
chat_id = self._max_title_to_id.get(normalized_title)
if chat_id is not None:
return chat_id
# на всякий случай обновим кэш (например, если добавили чат во время работы)
self._refresh_max_chat_cache()
return self._max_title_to_id.get(normalized_title)