feat: initial commit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 4m50s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 4m50s
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.github
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.sqlite3
|
||||
*.db
|
||||
max_session
|
||||
forwarded_messages.db
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
dist
|
||||
build
|
||||
45
.github/workflows/docker-publish.yml
vendored
Normal file
45
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.n08i40k.ru
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata for image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: registry.n08i40k.ru/n08i40k/max-messages-forwarder
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Deploy
|
||||
run: curl ${{ secrets.DEPLOY_URL }}
|
||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.github
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.sqlite3
|
||||
*.db
|
||||
max_session
|
||||
forwarded_messages.db
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
dist
|
||||
build
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
VIRTUAL_ENV=/opt/venv
|
||||
|
||||
RUN python -m venv "$VIRTUAL_ENV"
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY bridge.py ./
|
||||
COPY bridge_app ./bridge_app
|
||||
|
||||
VOLUME ["/app/max_session"]
|
||||
|
||||
ENTRYPOINT ["python", "bridge.py"]
|
||||
29
bridge.py
Normal file
29
bridge.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bridge messages from a Max group chat to a Telegram group using bot tokens."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from bridge_app import parse_args, run_bridge
|
||||
|
||||
|
||||
def configure_logging(debug: bool) -> None:
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config, list_chats, debug = parse_args()
|
||||
configure_logging(debug)
|
||||
try:
|
||||
asyncio.run(run_bridge(config, list_chats))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
bridge_app/__init__.py
Normal file
12
bridge_app/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Reusable components for the Max ↔ Telegram bridge CLI."""
|
||||
from .config import Config, parse_args
|
||||
from .max_bridge import list_chats_and_exit, run_bridge
|
||||
from .storage import ForwardedMessageStore
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"parse_args",
|
||||
"list_chats_and_exit",
|
||||
"run_bridge",
|
||||
"ForwardedMessageStore",
|
||||
]
|
||||
159
bridge_app/config.py
Normal file
159
bridge_app/config.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Configuration models and CLI argument parsing for the Max ↔ Telegram bridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "y", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "n", "off"}:
|
||||
return False
|
||||
raise ValueError(value)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Config:
|
||||
max_phone: str
|
||||
max_chat_id: int | None
|
||||
telegram_bot_token: str | None
|
||||
telegram_chat_id: int | None
|
||||
telegram_test_dc: bool
|
||||
max_token: str | None = None
|
||||
max_ws_uri: str | None = None
|
||||
work_dir: str = "./max_session"
|
||||
disable_fake_telemetry: bool = False
|
||||
db_path: str = ""
|
||||
|
||||
|
||||
def parse_args() -> tuple[Config, bool, bool]:
|
||||
"""Parse CLI arguments and environment variables into a Config."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Forward messages from a Max group chat to a Telegram chat. "
|
||||
"Configuration can come from CLI flags or environment variables."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-phone",
|
||||
default=os.getenv("MAX_PHONE"),
|
||||
help="Phone number used to authenticate in Max (env: MAX_PHONE)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-token",
|
||||
default=os.getenv("MAX_TOKEN"),
|
||||
help="Optional auth token for Max session (env: MAX_TOKEN)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-chat-id",
|
||||
type=int,
|
||||
default=int(os.getenv("MAX_CHAT_ID")) if os.getenv("MAX_CHAT_ID") else None,
|
||||
help="ID of the Max group chat to mirror (env: MAX_CHAT_ID)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-ws-uri",
|
||||
default=os.getenv("MAX_WS_URI"),
|
||||
help="Override the default Max WebSocket URI (env: MAX_WS_URI)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--work-dir",
|
||||
default=os.getenv("MAX_WORKDIR", "./max_session"),
|
||||
help="Directory to store Max session database (env: MAX_WORKDIR)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--telegram-bot-token",
|
||||
default=os.getenv("TELEGRAM_BOT_TOKEN"),
|
||||
help="Telegram bot token (env: TELEGRAM_BOT_TOKEN)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--telegram-chat-id",
|
||||
type=int,
|
||||
default=(
|
||||
int(os.getenv("TELEGRAM_CHAT_ID"))
|
||||
if os.getenv("TELEGRAM_CHAT_ID")
|
||||
else None
|
||||
),
|
||||
help="Telegram chat ID that should receive forwarded messages (env: TELEGRAM_CHAT_ID)",
|
||||
)
|
||||
telegram_test_dc_default = False
|
||||
telegram_test_dc_env = os.getenv("TELEGRAM_TEST_DC")
|
||||
if telegram_test_dc_env is not None:
|
||||
try:
|
||||
telegram_test_dc_default = _parse_bool(telegram_test_dc_env)
|
||||
except ValueError:
|
||||
parser.error(
|
||||
"Invalid boolean value for TELEGRAM_TEST_DC: "
|
||||
f"{telegram_test_dc_env}"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--telegram-test-dc",
|
||||
dest="telegram_test_dc",
|
||||
action="store_true",
|
||||
help="Use Telegram test data-center for bot (env: TELEGRAM_TEST_DC)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-telegram-test-dc",
|
||||
dest="telegram_test_dc",
|
||||
action="store_false",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.set_defaults(telegram_test_dc=telegram_test_dc_default)
|
||||
parser.add_argument(
|
||||
"--db-path",
|
||||
default=os.getenv("MAX_BRIDGE_DB_PATH"),
|
||||
help=(
|
||||
"Path to SQLite database storing forwarded message mapping "
|
||||
"(env: MAX_BRIDGE_DB_PATH). Defaults to <work-dir>/forwarded_messages.db"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable-fake-telemetry",
|
||||
action="store_true",
|
||||
help="Disable sending fake telemetry pings required by the Max client",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-chats",
|
||||
action="store_true",
|
||||
help="List available Max chats and exit without forwarding",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.max_phone:
|
||||
parser.error("--max-phone or MAX_PHONE environment variable is required")
|
||||
|
||||
if not args.list_chats:
|
||||
missing: list[str] = []
|
||||
if args.max_chat_id is None:
|
||||
missing.append("--max-chat-id / MAX_CHAT_ID")
|
||||
if not args.telegram_bot_token:
|
||||
missing.append("--telegram-bot-token / TELEGRAM_BOT_TOKEN")
|
||||
if args.telegram_chat_id is None:
|
||||
missing.append("--telegram-chat-id / TELEGRAM_CHAT_ID")
|
||||
if missing:
|
||||
parser.error("Missing required options: " + ", ".join(missing))
|
||||
|
||||
db_path = args.db_path or str(Path(args.work_dir) / "forwarded_messages.db")
|
||||
|
||||
config = Config(
|
||||
max_phone=args.max_phone,
|
||||
max_chat_id=args.max_chat_id,
|
||||
telegram_bot_token=args.telegram_bot_token,
|
||||
telegram_chat_id=args.telegram_chat_id,
|
||||
telegram_test_dc=args.telegram_test_dc,
|
||||
max_token=args.max_token,
|
||||
max_ws_uri=args.max_ws_uri,
|
||||
work_dir=args.work_dir,
|
||||
disable_fake_telemetry=args.disable_fake_telemetry,
|
||||
db_path=db_path,
|
||||
)
|
||||
return config, bool(args.list_chats), bool(args.debug)
|
||||
226
bridge_app/forwarding.py
Normal file
226
bridge_app/forwarding.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Tools for deduplicating and forwarding Max messages to Telegram."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Literal, Optional, Sequence
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import PRODUCTION, TEST, TelegramAPIServer
|
||||
from aiogram.types import BufferedInputFile, InputMediaPhoto, InputMediaVideo
|
||||
|
||||
from pymax.static import AttachType
|
||||
from pymax.types import Message, User
|
||||
|
||||
|
||||
class MessageDeduplicator:
|
||||
"""Keep a bounded set of processed message IDs."""
|
||||
|
||||
def __init__(self, limit: int = 1024) -> None:
|
||||
self._limit = limit
|
||||
self._order: deque[int] = deque()
|
||||
self._seen: set[int] = set()
|
||||
|
||||
def add(self, message_id: int) -> bool:
|
||||
if message_id in self._seen:
|
||||
return False
|
||||
self._seen.add(message_id)
|
||||
self._order.append(message_id)
|
||||
if len(self._order) > self._limit:
|
||||
old = self._order.popleft()
|
||||
self._seen.discard(old)
|
||||
return True
|
||||
|
||||
|
||||
class TelegramForwarder:
|
||||
def __init__(self, bot_token: str, chat_id: int, test_dc: bool) -> None:
|
||||
self._bot_token = bot_token
|
||||
self._chat_id = chat_id
|
||||
self._test_dc = test_dc
|
||||
self._bot: Bot | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._bot is not None:
|
||||
return
|
||||
server: TelegramAPIServer = TEST if self._test_dc else PRODUCTION
|
||||
self._bot = Bot(self._bot_token, session=AiohttpSession(api=server))
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._bot is not None:
|
||||
await self._bot.session.close()
|
||||
self._bot = None
|
||||
|
||||
async def send_message(
|
||||
self, text: str, *, reply_to_message_id: int | None = None
|
||||
) -> int:
|
||||
if self._bot is None:
|
||||
raise RuntimeError("TelegramForwarder is not started")
|
||||
message = await self._bot.send_message(
|
||||
chat_id=self._chat_id,
|
||||
text=text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
allow_sending_without_reply=reply_to_message_id is not None,
|
||||
parse_mode="MARKDOWN",
|
||||
)
|
||||
return int(message.message_id)
|
||||
|
||||
async def forward(
|
||||
self,
|
||||
text: str,
|
||||
attachments: Sequence["ForwardAttachment"],
|
||||
*,
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int:
|
||||
"""Send text and optional media, returning the primary Telegram message id."""
|
||||
|
||||
if not attachments:
|
||||
return await self.send_message(
|
||||
text, reply_to_message_id=reply_to_message_id
|
||||
)
|
||||
|
||||
caption = text or None
|
||||
if caption and len(caption) > 1024:
|
||||
primary_id = await self.send_message(
|
||||
text, reply_to_message_id=reply_to_message_id
|
||||
)
|
||||
await self.send_attachments(
|
||||
attachments,
|
||||
reply_to_message_id=primary_id,
|
||||
)
|
||||
return primary_id
|
||||
|
||||
return await self.send_attachments(
|
||||
attachments,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
caption=caption,
|
||||
)
|
||||
|
||||
async def send_attachments(
|
||||
self,
|
||||
attachments: Sequence["ForwardAttachment"],
|
||||
*,
|
||||
reply_to_message_id: int | None = None,
|
||||
caption: str | None = None,
|
||||
) -> int:
|
||||
"""Send one or more media attachments, returning the lead message id."""
|
||||
|
||||
if self._bot is None:
|
||||
raise RuntimeError("TelegramForwarder is not started")
|
||||
if not attachments:
|
||||
raise ValueError("attachments must not be empty")
|
||||
|
||||
allow_without_reply = reply_to_message_id is not None
|
||||
|
||||
if len(attachments) == 1:
|
||||
attachment = attachments[0]
|
||||
if attachment.kind == "photo":
|
||||
message = await self._bot.send_photo(
|
||||
chat_id=self._chat_id,
|
||||
photo=attachment.media,
|
||||
caption=caption,
|
||||
parse_mode="MARKDOWN" if caption else None,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
allow_sending_without_reply=allow_without_reply,
|
||||
)
|
||||
elif attachment.kind == "video":
|
||||
message = await self._bot.send_video(
|
||||
chat_id=self._chat_id,
|
||||
video=attachment.media,
|
||||
caption=caption,
|
||||
parse_mode="MARKDOWN" if caption else None,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
allow_sending_without_reply=allow_without_reply,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported attachment kind: {attachment.kind}")
|
||||
return int(message.message_id)
|
||||
|
||||
media_group = []
|
||||
for index, attachment in enumerate(attachments):
|
||||
item_caption = caption if index == 0 else None
|
||||
if attachment.kind == "photo":
|
||||
media_group.append(
|
||||
InputMediaPhoto(
|
||||
media=attachment.media,
|
||||
caption=item_caption,
|
||||
parse_mode="MARKDOWN" if item_caption else None,
|
||||
)
|
||||
)
|
||||
elif attachment.kind == "video":
|
||||
media_group.append(
|
||||
InputMediaVideo(
|
||||
media=attachment.media,
|
||||
caption=item_caption,
|
||||
parse_mode="MARKDOWN" if item_caption else None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported attachment kind: {attachment.kind}")
|
||||
|
||||
messages = await self._bot.send_media_group(
|
||||
chat_id=self._chat_id,
|
||||
media=media_group,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
allow_sending_without_reply=allow_without_reply,
|
||||
)
|
||||
return int(messages[0].message_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ForwardAttachment:
|
||||
kind: Literal["photo", "video"]
|
||||
media: str | BufferedInputFile
|
||||
|
||||
|
||||
def format_sender(user: Optional[User], fallback_sender_id: Optional[int]) -> str:
|
||||
if user and user.names:
|
||||
return user.names[0].name
|
||||
if fallback_sender_id is not None:
|
||||
return f"user:{fallback_sender_id}"
|
||||
return "system"
|
||||
|
||||
|
||||
def iter_attachment_descriptions(message: Message) -> Iterable[str]:
|
||||
if not message.attaches:
|
||||
return []
|
||||
descriptions: list[str] = []
|
||||
for attach in message.attaches:
|
||||
attach_type = getattr(attach, "type", None)
|
||||
if isinstance(attach_type, AttachType):
|
||||
type_name = attach_type.value.lower()
|
||||
else:
|
||||
type_name = str(attach_type)
|
||||
|
||||
parts = [type_name]
|
||||
base_url = getattr(attach, "base_url", None)
|
||||
if base_url:
|
||||
parts.append(base_url)
|
||||
token = getattr(attach, "photo_token", None) or getattr(attach, "token", None)
|
||||
if token:
|
||||
parts.append(f"token={token}")
|
||||
descriptions.append(" ".join(parts))
|
||||
return descriptions
|
||||
|
||||
|
||||
def build_forward_text(
|
||||
message: Message,
|
||||
sender: Optional[User],
|
||||
*,
|
||||
include_attachment_descriptions: bool = True,
|
||||
) -> str:
|
||||
sender_name = format_sender(sender, message.sender)
|
||||
|
||||
parts: list[str] = [f"От {sender_name}:"]
|
||||
text = (message.text or "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
attachments: list[str] = []
|
||||
if include_attachment_descriptions:
|
||||
attachments = list(iter_attachment_descriptions(message))
|
||||
if attachments:
|
||||
parts.append("Attachments:")
|
||||
parts.extend(f"- {item}" for item in attachments)
|
||||
if not text and not attachments:
|
||||
parts.append("(empty message)")
|
||||
return "\n".join(parts)
|
||||
284
bridge_app/max_bridge.py
Normal file
284
bridge_app/max_bridge.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Core bridge runtime for syncing messages from Max to Telegram."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import signal
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
from pymax import MaxClient
|
||||
from pymax.types import Message, PhotoAttach, User, VideoAttach
|
||||
|
||||
from .config import Config
|
||||
from .forwarding import (
|
||||
ForwardAttachment,
|
||||
MessageDeduplicator,
|
||||
TelegramForwarder,
|
||||
build_forward_text,
|
||||
)
|
||||
from .storage import ForwardedMessageStore
|
||||
|
||||
|
||||
def _decode_preview_image(preview_data: str | None) -> bytes | None:
|
||||
if not preview_data:
|
||||
return None
|
||||
payload = preview_data
|
||||
if preview_data.startswith("data:"):
|
||||
_, _, payload = preview_data.partition(",")
|
||||
try:
|
||||
return base64.b64decode(payload)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_photo_url(attach: PhotoAttach) -> str | None:
|
||||
base_url = getattr(attach, "base_url", None)
|
||||
token = getattr(attach, "photo_token", None)
|
||||
if not base_url or not token:
|
||||
return None
|
||||
|
||||
if token in base_url:
|
||||
return base_url
|
||||
if "{token}" in base_url:
|
||||
return base_url.replace("{token}", token)
|
||||
if "{photoToken}" in base_url:
|
||||
return base_url.replace("{photoToken}", token)
|
||||
|
||||
encoded_token = quote(token, safe="")
|
||||
if base_url.endswith(("/", "=", "?", ":")):
|
||||
return base_url + encoded_token
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.query:
|
||||
if parsed.query.endswith("="):
|
||||
return base_url + encoded_token
|
||||
query_params = parse_qsl(parsed.query, keep_blank_values=True)
|
||||
if not any(key == "token" for key, _ in query_params):
|
||||
query_params.append(("token", token))
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
return base_url
|
||||
|
||||
return f"{base_url.rstrip('/')}/{encoded_token}"
|
||||
|
||||
|
||||
async def _collect_forward_attachments(
|
||||
client: MaxClient,
|
||||
message: Message,
|
||||
logger: logging.Logger,
|
||||
) -> list[ForwardAttachment]:
|
||||
attachments: list[ForwardAttachment] = []
|
||||
for index, attach in enumerate(message.attaches or []):
|
||||
if isinstance(attach, PhotoAttach):
|
||||
url = _build_photo_url(attach)
|
||||
if url:
|
||||
attachments.append(ForwardAttachment(kind="photo", media=url))
|
||||
continue
|
||||
|
||||
preview_bytes = _decode_preview_image(getattr(attach, "preview_data", None))
|
||||
if preview_bytes:
|
||||
filename = f"max_photo_{attach.photo_id or message.id}_{index}.jpg"
|
||||
attachments.append(
|
||||
ForwardAttachment(
|
||||
kind="photo",
|
||||
media=BufferedInputFile(preview_bytes, filename=filename),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Skipping photo attachment without usable data: %s", attach)
|
||||
continue
|
||||
|
||||
if isinstance(attach, VideoAttach):
|
||||
video_id = getattr(attach, "video_id", None)
|
||||
if video_id is None:
|
||||
logger.debug("Video attachment missing video_id: %s", attach)
|
||||
continue
|
||||
try:
|
||||
video_request = await client.get_video_by_id(
|
||||
message.chat_id, message.id, video_id
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to resolve video attachment %s for message %s",
|
||||
video_id,
|
||||
message.id,
|
||||
)
|
||||
continue
|
||||
if video_request and getattr(video_request, "url", None):
|
||||
attachments.append(
|
||||
ForwardAttachment(kind="video", media=video_request.url)
|
||||
)
|
||||
else:
|
||||
logger.debug("Skipping video attachment without URL: %s", attach)
|
||||
continue
|
||||
|
||||
logger.debug("Unsupported attachment type: %s", getattr(attach, "type", None))
|
||||
|
||||
return attachments
|
||||
|
||||
|
||||
async def list_chats_and_exit(client: MaxClient) -> None:
|
||||
if not client.chats and not client.dialogs and not client.channels:
|
||||
logging.info("No chats available yet; waiting for initial sync")
|
||||
|
||||
for chat in client.chats:
|
||||
logging.info("Group chat %s | id=%s", chat.title or "<no title>", chat.id)
|
||||
for chat in client.channels:
|
||||
logging.info("Channel %s | id=%s", chat.title or "<no title>", chat.id)
|
||||
for dialog in client.dialogs:
|
||||
if dialog.contact:
|
||||
logging.info(
|
||||
"Dialog with %s | id=%s",
|
||||
dialog.contact.names[0].name if dialog.contact.names else dialog.id,
|
||||
dialog.id,
|
||||
)
|
||||
else:
|
||||
logging.info("Dialog id=%s", dialog.id)
|
||||
|
||||
|
||||
async def run_bridge(config: Config, list_chats: bool) -> None:
|
||||
logger = logging.getLogger("bridge")
|
||||
|
||||
client_kwargs: dict[str, object] = {
|
||||
"phone": config.max_phone,
|
||||
"work_dir": config.work_dir,
|
||||
"send_fake_telemetry": not config.disable_fake_telemetry,
|
||||
}
|
||||
if config.max_token:
|
||||
client_kwargs["token"] = config.max_token
|
||||
if config.max_ws_uri:
|
||||
client_kwargs["uri"] = config.max_ws_uri
|
||||
|
||||
client = MaxClient(**client_kwargs) # type: ignore[arg-type]
|
||||
deduplicator = MessageDeduplicator()
|
||||
process_lock = asyncio.Lock()
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
async def shutdown() -> None:
|
||||
logger.info("Shutting down")
|
||||
await client.close()
|
||||
stop_event.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, lambda: asyncio.create_task(shutdown()))
|
||||
except NotImplementedError:
|
||||
# add_signal_handler is not available on Windows event loop policy
|
||||
signal.signal(sig, lambda _sig, _frame: asyncio.create_task(shutdown()))
|
||||
|
||||
forwarder: TelegramForwarder | None = None
|
||||
store: ForwardedMessageStore | None = None
|
||||
if not list_chats:
|
||||
assert config.telegram_bot_token is not None
|
||||
assert config.telegram_chat_id is not None
|
||||
forwarder = TelegramForwarder(
|
||||
config.telegram_bot_token, config.telegram_chat_id, config.telegram_test_dc
|
||||
)
|
||||
await forwarder.start()
|
||||
store = ForwardedMessageStore(config.db_path)
|
||||
try:
|
||||
await store.initialize()
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize forwarded message store")
|
||||
await forwarder.close()
|
||||
raise
|
||||
|
||||
@client.on_start
|
||||
async def _on_start() -> None:
|
||||
logger.info(
|
||||
"Connected to Max as %s. Loaded %d chats, %d dialogs, %d channels.",
|
||||
config.max_phone,
|
||||
len(client.chats),
|
||||
len(client.dialogs),
|
||||
len(client.channels),
|
||||
)
|
||||
if list_chats:
|
||||
await list_chats_and_exit(client)
|
||||
await shutdown()
|
||||
|
||||
@client.on_message()
|
||||
async def _on_message(message: Message) -> None:
|
||||
if list_chats:
|
||||
return
|
||||
async with process_lock:
|
||||
if message.chat_id is None:
|
||||
logger.debug("Skipping message without chat_id: %s", message)
|
||||
return
|
||||
target_chat_id = config.max_chat_id
|
||||
if target_chat_id is None:
|
||||
logger.debug("No target chat configured; skipping message %s", message.id)
|
||||
return
|
||||
if message.chat_id != target_chat_id:
|
||||
return
|
||||
if not deduplicator.add(message.id):
|
||||
logger.debug("Skipping duplicate message id=%s", message.id)
|
||||
return
|
||||
|
||||
sender_user: User | None = None
|
||||
if message.sender is not None:
|
||||
try:
|
||||
sender_user = await client.get_user(message.sender)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve sender %s", message.sender)
|
||||
|
||||
attachments_to_forward = await _collect_forward_attachments(
|
||||
client, message, logger
|
||||
)
|
||||
text = build_forward_text(
|
||||
message,
|
||||
sender_user,
|
||||
include_attachment_descriptions=not attachments_to_forward,
|
||||
)
|
||||
|
||||
reply_to_message_id: int | None = None
|
||||
if (
|
||||
store is not None
|
||||
and message.link is not None
|
||||
and getattr(message.link, "message", None) is not None
|
||||
):
|
||||
linked_message = message.link.message
|
||||
if isinstance(linked_message, Message):
|
||||
reply_to_message_id = await store.lookup_telegram_message_id(
|
||||
target_chat_id, linked_message.id
|
||||
)
|
||||
if reply_to_message_id is None:
|
||||
logger.debug(
|
||||
"No Telegram mapping found for reply target %s", linked_message.id
|
||||
)
|
||||
|
||||
assert forwarder is not None
|
||||
try:
|
||||
telegram_message_id = await forwarder.forward(
|
||||
text,
|
||||
attachments_to_forward,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to forward message %s", message.id)
|
||||
return
|
||||
|
||||
if store is not None:
|
||||
try:
|
||||
await store.record_forwarded(
|
||||
target_chat_id, message.id, telegram_message_id
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to record forwarded message mapping for %s", message.id
|
||||
)
|
||||
|
||||
try:
|
||||
await client.start()
|
||||
except Exception:
|
||||
logger.exception("Max client stopped unexpectedly")
|
||||
finally:
|
||||
if forwarder is not None:
|
||||
await forwarder.close()
|
||||
stop_event.set()
|
||||
|
||||
await stop_event.wait()
|
||||
105
bridge_app/storage.py
Normal file
105
bridge_app/storage.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""SQLite-backed storage for forwarded message metadata."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ForwardedMessageStore:
|
||||
"""Persist mappings of Max message IDs to Telegram message IDs."""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._path = Path(db_path)
|
||||
self._initialized = False
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Ensure the database and table exist."""
|
||||
if self._initialized:
|
||||
return
|
||||
async with self._init_lock:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
def setup() -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(self._path)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS forwarded_messages (
|
||||
max_chat_id INTEGER NOT NULL,
|
||||
max_message_id INTEGER NOT NULL,
|
||||
telegram_message_id INTEGER NOT NULL,
|
||||
forwarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (max_chat_id, max_message_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_forwarded_telegram
|
||||
ON forwarded_messages (telegram_message_id)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
await asyncio.to_thread(setup)
|
||||
self._initialized = True
|
||||
|
||||
async def record_forwarded(
|
||||
self, max_chat_id: int, max_message_id: int, telegram_message_id: int
|
||||
) -> None:
|
||||
"""Store a mapping for a successfully forwarded message."""
|
||||
await self.initialize()
|
||||
|
||||
def insert() -> None:
|
||||
conn = sqlite3.connect(self._path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO forwarded_messages (
|
||||
max_chat_id,
|
||||
max_message_id,
|
||||
telegram_message_id
|
||||
) VALUES (?, ?, ?)
|
||||
""",
|
||||
(max_chat_id, max_message_id, telegram_message_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
await asyncio.to_thread(insert)
|
||||
|
||||
async def lookup_telegram_message_id(
|
||||
self, max_chat_id: int, max_message_id: int
|
||||
) -> Optional[int]:
|
||||
"""Return the Telegram message ID for a given Max message, if known."""
|
||||
await self.initialize()
|
||||
|
||||
def query() -> Optional[int]:
|
||||
conn = sqlite3.connect(self._path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT telegram_message_id
|
||||
FROM forwarded_messages
|
||||
WHERE max_chat_id = ? AND max_message_id = ?
|
||||
""",
|
||||
(max_chat_id, max_message_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return await asyncio.to_thread(query)
|
||||
|
||||
|
||||
__all__ = ["ForwardedMessageStore"]
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
aiogram==3.22.0
|
||||
maxapi-python==1.1.13
|
||||
Reference in New Issue
Block a user