7 Commits
Author SHA1 Message Date
kislovdm 1fd5e4ea39 после миграции группы в супергруппу необходимо обновить её id
Docker Hub / build-and-push (push) Failing after 14s
2026-04-14 11:13:21 +03:00
kislovdm bcf9366cf6 remove sha builds 2026-04-11 11:00:32 +03:00
kislovdm 102009369a clear caches in image 2026-04-11 10:58:08 +03:00
kislovdm 7a3c7317ea fix actions 2026-04-11 10:55:07 +03:00
kislovdm 7d522e5f73 build actions
Docker Hub / build-and-push (push) Failing after 14s
2026-04-11 10:39:02 +03:00
kislovdm 70f1cea78e к предыдущему 2026-04-10 19:09:15 +03:00
kislovdm 763ca45ab2 Только нормальные команды 2026-04-10 19:00:31 +03:00
6 changed files with 249 additions and 36 deletions
+78
View File
@@ -0,0 +1,78 @@
# Сборка образа и публикация на Docker Hub (hub.docker.com).
#
# Настройка в GitHub → Settings → Secrets and variables → Actions:
# DOCKERHUB_USERNAME — логин Docker Hub
# DOCKERHUB_TOKEN — Access Token (рекомендуется), см. https://hub.docker.com/settings/security
#
# Опционально: Variables → DOCKERHUB_IMAGE (например org/max2telegram), если имя образа
# отличается от <DOCKERHUB_USERNAME>/max2telegram.
#
# Теги образа:
# push в master → dev (+ sha)
# push тега v* → {{version}}, {{major}}.{{minor}}, latest (+ sha)
name: Docker Hub
on:
push:
branches:
- main
- master
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: docker-hub-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
actions: write # кэш слоёв Buildx (type=gha)
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image name
id: image
run: |
if [ -n "${{ vars.DOCKERHUB_IMAGE }}" ]; then
echo "name=${{ vars.DOCKERHUB_IMAGE }}" >> "$GITHUB_OUTPUT"
else
echo "name=${{ secrets.DOCKERHUB_USERNAME }}/max2telegram" >> "$GITHUB_OUTPUT"
fi
- name: Docker Hub login
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata (теги)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3 -1
View File
@@ -2,7 +2,9 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update; apt-get install -y git RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
COPY ./src/requirements.txt . COPY ./src/requirements.txt .
RUN pip install --no-cache-dir -r ./requirements.txt RUN pip install --no-cache-dir -r ./requirements.txt
+59 -9
View File
@@ -1,4 +1,5 @@
import logging import logging
from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from max_parser import parse_message from max_parser import parse_message
@@ -6,7 +7,7 @@ from models import ParsedMessage
from pymax import MaxClient from pymax import MaxClient
from pymax.types import PhotoAttach, VideoAttach from pymax.types import PhotoAttach, VideoAttach
from storage import BridgeStorage from storage import BridgeStorage
from telegram_api import TelegramClient from telegram_api import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -80,12 +81,17 @@ class MaxToTelegramBridge:
total_media = len(parsed.image_urls) + len(parsed.video_urls) total_media = len(parsed.image_urls) + len(parsed.video_urls)
if total_media > 1: if total_media > 1:
# Отправляем одним альбомом в Telegram (единое сообщение). # Отправляем одним альбомом в Telegram (единое сообщение).
sent_messages = await self._telegram.send_media_group( target_chat_id, sent_messages = await self._send_with_migration_retry(
chat_id=target_chat_id, target_chat_id=target_chat_id,
max_chat_title_norm=normalized,
max_chat_title=parsed.chat_name,
send_action=lambda chat_id: self._telegram.send_media_group(
chat_id=chat_id,
image_urls=parsed.image_urls, image_urls=parsed.image_urls,
video_urls=parsed.video_urls, video_urls=parsed.video_urls,
caption=text, caption=text,
reply_to_message_id=reply_telegram_mid, reply_to_message_id=reply_telegram_mid,
),
) )
for sent in sent_messages: for sent in sent_messages:
mid = sent.get("message_id") mid = sent.get("message_id")
@@ -109,8 +115,13 @@ class MaxToTelegramBridge:
sent_any = False sent_any = False
if parsed.text.strip() and total_media == 0: if parsed.text.strip() and total_media == 0:
sent = await self._telegram.send_text( target_chat_id, sent = await self._send_with_migration_retry(
target_chat_id, text, reply_to_message_id=reply_telegram_mid target_chat_id=target_chat_id,
max_chat_title_norm=normalized,
max_chat_title=parsed.chat_name,
send_action=lambda chat_id: self._telegram.send_text(
chat_id, text, reply_to_message_id=reply_telegram_mid
),
) )
mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None
if mid is not None: if mid is not None:
@@ -124,11 +135,16 @@ class MaxToTelegramBridge:
for index, image_url in enumerate(parsed.image_urls): for index, image_url in enumerate(parsed.image_urls):
caption = text if not sent_any and index == 0 else None caption = text if not sent_any and index == 0 else None
sent = await self._telegram.send_photo( target_chat_id, sent = await self._send_with_migration_retry(
target_chat_id, target_chat_id=target_chat_id,
max_chat_title_norm=normalized,
max_chat_title=parsed.chat_name,
send_action=lambda chat_id: self._telegram.send_photo(
chat_id,
image_url, image_url,
caption=caption, caption=caption,
reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None, reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None,
),
) )
mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None
if mid is not None: if mid is not None:
@@ -142,11 +158,16 @@ class MaxToTelegramBridge:
for index, video_url in enumerate(parsed.video_urls): for index, video_url in enumerate(parsed.video_urls):
caption = text if not sent_any and index == 0 else None caption = text if not sent_any and index == 0 else None
sent = await self._telegram.send_video( target_chat_id, sent = await self._send_with_migration_retry(
target_chat_id, target_chat_id=target_chat_id,
max_chat_title_norm=normalized,
max_chat_title=parsed.chat_name,
send_action=lambda chat_id: self._telegram.send_video(
chat_id,
video_url, video_url,
caption=caption, caption=caption,
reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None, reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None,
),
) )
mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None
if mid is not None: if mid is not None:
@@ -167,6 +188,35 @@ class MaxToTelegramBridge:
len(parsed.video_urls), len(parsed.video_urls),
) )
async def _send_with_migration_retry(
self,
*,
target_chat_id: str,
max_chat_title_norm: str,
max_chat_title: str,
send_action: Callable[[str], Awaitable[Any]],
) -> tuple[str, Any]:
try:
sent = await send_action(target_chat_id)
return target_chat_id, sent
except TelegramApiError as exc:
migrated_chat_id = exc.migrate_to_chat_id
if not migrated_chat_id or migrated_chat_id == str(target_chat_id):
raise
logger.warning(
"Telegram chat %s upgraded to %s for MAX chat '%s'; update route and retry",
target_chat_id,
migrated_chat_id,
max_chat_title,
)
self._storage.set_chat_route(
max_chat_title_norm=max_chat_title_norm,
telegram_chat_id=migrated_chat_id,
telegram_chat_title=max_chat_title,
)
sent = await send_action(migrated_chat_id)
return migrated_chat_id, sent
def _resolve_telegram_reply_to( def _resolve_telegram_reply_to(
self, *, telegram_chat_id: str, max_chat_id: str, parsed: ParsedMessage self, *, telegram_chat_id: str, max_chat_id: str, parsed: ParsedMessage
) -> int | None: ) -> int | None:
+42 -9
View File
@@ -48,6 +48,25 @@ def _format_forward_text(*, sender: dict[str, Any] | None, text: str) -> str:
return header 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 @dataclass
class _MediaGroupBuffer: class _MediaGroupBuffer:
first_seen_monotonic: float first_seen_monotonic: float
@@ -118,7 +137,7 @@ class TelegramToMaxBridge:
max_update_id = upd_id if max_update_id is None else max(max_update_id, upd_id) max_update_id = upd_id if max_update_id is None else max(max_update_id, upd_id)
message = None message = None
for container in ("message", "edited_message", "channel_post", "edited_channel_post"): for container in ("message", "channel_post"):
candidate = upd.get(container) candidate = upd.get(container)
if isinstance(candidate, dict): if isinstance(candidate, dict):
message = candidate message = candidate
@@ -126,6 +145,9 @@ class TelegramToMaxBridge:
if not message: if not message:
continue continue
if not _is_supported_telegram_message(message):
continue
if self._is_own_telegram_message(message): if self._is_own_telegram_message(message):
continue continue
@@ -151,6 +173,14 @@ class TelegramToMaxBridge:
if not isinstance(chat, dict): if not isinstance(chat, dict):
return 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 через Telegram: только личка боту и только от fallback_user_id.
# В этом случае команду не пересылаем в MAX. # В этом случае команду не пересылаем в MAX.
try: try:
@@ -167,13 +197,6 @@ class TelegramToMaxBridge:
logger.exception("Cannot send Telegram reply for control command") logger.exception("Cannot send Telegram reply for control command")
return return
# Команда привязки чата Telegram к названию чата в MAX (для Max->Telegram маршрутизации).
# Работает даже при privacy mode, т.к. команды приходят боту.
text = str(message.get("text") or "").strip()
if text.startswith("/bind_max"):
await self._handle_bind_max_command(message, chat)
return
chat_title = _telegram_chat_title(chat) chat_title = _telegram_chat_title(chat)
normalized = _normalize_title(chat_title) normalized = _normalize_title(chat_title)
if not normalized: if not normalized:
@@ -209,10 +232,13 @@ class TelegramToMaxBridge:
async def _handle_bind_max_command(self, message: dict[str, Any], chat: dict[str, Any]) -> None: async def _handle_bind_max_command(self, message: dict[str, Any], chat: dict[str, Any]) -> None:
raw = str(message.get("text") or "").strip() raw = str(message.get("text") or "").strip()
chat_id = str(chat.get("id") or "")
# формат: /bind_max <точное название чата в MAX> # формат: /bind_max <точное название чата в MAX>
parts = raw.split(maxsplit=1) parts = raw.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip(): if len(parts) < 2 or not parts[1].strip():
logger.error("bind_max: missing MAX chat title. Use: /bind_max <MAX chat title>") 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 return
max_title = parts[1].strip() max_title = parts[1].strip()
@@ -222,9 +248,11 @@ class TelegramToMaxBridge:
max_chat_id = self._resolve_max_chat_id_by_title(norm) max_chat_id = self._resolve_max_chat_id_by_title(norm)
if max_chat_id is None: if max_chat_id is None:
logger.error("bind_max: MAX чат '%s' не найден — привязку не сохраняю", max_title) 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 return
telegram_chat_id = str(chat.get("id")) telegram_chat_id = chat_id
telegram_title = _telegram_chat_title(chat) telegram_title = _telegram_chat_title(chat)
self._storage.set_chat_route( self._storage.set_chat_route(
max_chat_title_norm=norm, max_chat_title_norm=norm,
@@ -237,6 +265,11 @@ class TelegramToMaxBridge:
telegram_title, telegram_title,
telegram_chat_id, 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: async def _flush_ready_media_groups(self) -> None:
now = time.monotonic() now = time.monotonic()
+55 -6
View File
@@ -1,11 +1,34 @@
import asyncio import asyncio
import json
from typing import Any from typing import Any
import requests import requests
class TelegramApiError(RuntimeError): class TelegramApiError(RuntimeError):
pass def __init__(
self,
message: str,
*,
method: str | None = None,
status_code: int | None = None,
error_code: int | None = None,
description: str | None = None,
parameters: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.method = method
self.status_code = status_code
self.error_code = error_code
self.description = description
self.parameters = parameters or {}
@property
def migrate_to_chat_id(self) -> str | None:
value = self.parameters.get("migrate_to_chat_id")
if value is None:
return None
return str(value)
class TelegramClient: class TelegramClient:
@@ -132,8 +155,8 @@ class TelegramClient:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"timeout": timeout, "timeout": timeout,
"limit": limit, "limit": limit,
# чтобы получать посты из каналов (channel_post) и обычные сообщения # Реагируем только на новые сообщения/посты (текст, фото, видео).
"allowed_updates": ["message", "edited_message", "channel_post", "edited_channel_post"], "allowed_updates": ["message", "channel_post"],
} }
if offset is not None: if offset is not None:
payload["offset"] = offset payload["offset"] = offset
@@ -145,7 +168,7 @@ class TelegramClient:
# Важно: не делаем getUpdates нигде больше (иначе 409 Conflict). # Важно: не делаем getUpdates нигде больше (иначе 409 Conflict).
# Наполняем кэш чатов только из этого потока. # Наполняем кэш чатов только из этого потока.
for upd in updates: for upd in updates:
for container in ("message", "edited_message", "channel_post", "edited_channel_post"): for container in ("message", "channel_post"):
msg = upd.get(container) msg = upd.get(container)
if not isinstance(msg, dict): if not isinstance(msg, dict):
continue continue
@@ -209,10 +232,36 @@ class TelegramClient:
response = await asyncio.to_thread(_do_request) response = await asyncio.to_thread(_do_request)
if response.status_code >= 400: if response.status_code >= 400:
error_code: int | None = None
description: str | None = None
parameters: dict[str, Any] = {}
try:
payload_data = response.json()
if isinstance(payload_data, dict):
if isinstance(payload_data.get("error_code"), int):
error_code = payload_data.get("error_code")
if isinstance(payload_data.get("description"), str):
description = payload_data.get("description")
if isinstance(payload_data.get("parameters"), dict):
parameters = payload_data.get("parameters", {})
except (json.JSONDecodeError, ValueError):
payload_data = None
raise TelegramApiError( raise TelegramApiError(
f"Telegram HTTP error on {method}: {response.status_code} {response.text}" f"Telegram HTTP error on {method}: {response.status_code} {response.text}",
method=method,
status_code=response.status_code,
error_code=error_code,
description=description,
parameters=parameters,
) )
data = response.json() data = response.json()
if not data.get("ok"): if not data.get("ok"):
raise TelegramApiError(f"Telegram API error on {method}: {data}") raise TelegramApiError(
f"Telegram API error on {method}: {data}",
method=method,
error_code=data.get("error_code") if isinstance(data.get("error_code"), int) else None,
description=data.get("description") if isinstance(data.get("description"), str) else None,
parameters=data.get("parameters") if isinstance(data.get("parameters"), dict) else None,
)
return data return data
+2 -1
View File
@@ -159,7 +159,8 @@ async def handle_control_command(
"/list — список активных чатов MAX\n" "/list — список активных чатов MAX\n"
"/join <LINK> — присоединиться к группе/каналу по ссылке\n" "/join <LINK> — присоединиться к группе/каналу по ссылке\n"
"/leave <НАЗВАНИЕ> — покинуть указанный канал\n" "/leave <НАЗВАНИЕ> — покинуть указанный канал\n"
"/last_messages <НАЗВАНИЕ> — последние 10 сообщений из канала" "/last_messages <НАЗВАНИЕ> — последние 10 сообщений из канала\n"
"/bind_max <НАЗВАНИЕ> — привязать текущий Telegram-чат к чату MAX"
) )
if cmd == "/list": if cmd == "/list":