8 Commits
Author SHA1 Message Date
kislovdm 1ef5cf4e38 ИСправление списка каналов
Docker Hub / build-and-push (push) Failing after 14s
2026-04-15 14:30:45 +03:00
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 282 additions and 38 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
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 .
RUN pip install --no-cache-dir -r ./requirements.txt
+59 -9
View File
@@ -1,4 +1,5 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from max_parser import parse_message
@@ -6,7 +7,7 @@ from models import ParsedMessage
from pymax import MaxClient
from pymax.types import PhotoAttach, VideoAttach
from storage import BridgeStorage
from telegram_api import TelegramClient
from telegram_api import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
@@ -80,12 +81,17 @@ class MaxToTelegramBridge:
total_media = len(parsed.image_urls) + len(parsed.video_urls)
if total_media > 1:
# Отправляем одним альбомом в Telegram (единое сообщение).
sent_messages = await self._telegram.send_media_group(
chat_id=target_chat_id,
target_chat_id, sent_messages = await self._send_with_migration_retry(
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,
video_urls=parsed.video_urls,
caption=text,
reply_to_message_id=reply_telegram_mid,
),
)
for sent in sent_messages:
mid = sent.get("message_id")
@@ -109,8 +115,13 @@ class MaxToTelegramBridge:
sent_any = False
if parsed.text.strip() and total_media == 0:
sent = await self._telegram.send_text(
target_chat_id, text, reply_to_message_id=reply_telegram_mid
target_chat_id, sent = await self._send_with_migration_retry(
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
if mid is not None:
@@ -124,11 +135,16 @@ class MaxToTelegramBridge:
for index, image_url in enumerate(parsed.image_urls):
caption = text if not sent_any and index == 0 else None
sent = await self._telegram.send_photo(
target_chat_id,
target_chat_id, sent = await self._send_with_migration_retry(
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,
caption=caption,
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
if mid is not None:
@@ -142,11 +158,16 @@ class MaxToTelegramBridge:
for index, video_url in enumerate(parsed.video_urls):
caption = text if not sent_any and index == 0 else None
sent = await self._telegram.send_video(
target_chat_id,
target_chat_id, sent = await self._send_with_migration_retry(
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,
caption=caption,
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
if mid is not None:
@@ -167,6 +188,35 @@ class MaxToTelegramBridge:
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(
self, *, telegram_chat_id: str, max_chat_id: str, parsed: ParsedMessage
) -> int | None:
+42 -9
View File
@@ -48,6 +48,25 @@ def _format_forward_text(*, sender: dict[str, Any] | None, text: str) -> str:
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
@@ -118,7 +137,7 @@ class TelegramToMaxBridge:
max_update_id = upd_id if max_update_id is None else max(max_update_id, upd_id)
message = None
for container in ("message", "edited_message", "channel_post", "edited_channel_post"):
for container in ("message", "channel_post"):
candidate = upd.get(container)
if isinstance(candidate, dict):
message = candidate
@@ -126,6 +145,9 @@ class TelegramToMaxBridge:
if not message:
continue
if not _is_supported_telegram_message(message):
continue
if self._is_own_telegram_message(message):
continue
@@ -151,6 +173,14 @@ class TelegramToMaxBridge:
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:
@@ -167,13 +197,6 @@ class TelegramToMaxBridge:
logger.exception("Cannot send Telegram reply for control command")
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)
normalized = _normalize_title(chat_title)
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:
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()
@@ -222,9 +248,11 @@ class TelegramToMaxBridge:
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 = str(chat.get("id"))
telegram_chat_id = chat_id
telegram_title = _telegram_chat_title(chat)
self._storage.set_chat_route(
max_chat_title_norm=norm,
@@ -237,6 +265,11 @@ class TelegramToMaxBridge:
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()
+55 -6
View File
@@ -1,11 +1,34 @@
import asyncio
import json
from typing import Any
import requests
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:
@@ -132,8 +155,8 @@ class TelegramClient:
payload: dict[str, Any] = {
"timeout": timeout,
"limit": limit,
# чтобы получать посты из каналов (channel_post) и обычные сообщения
"allowed_updates": ["message", "edited_message", "channel_post", "edited_channel_post"],
# Реагируем только на новые сообщения/посты (текст, фото, видео).
"allowed_updates": ["message", "channel_post"],
}
if offset is not None:
payload["offset"] = offset
@@ -145,7 +168,7 @@ class TelegramClient:
# Важно: не делаем getUpdates нигде больше (иначе 409 Conflict).
# Наполняем кэш чатов только из этого потока.
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)
if not isinstance(msg, dict):
continue
@@ -209,10 +232,36 @@ class TelegramClient:
response = await asyncio.to_thread(_do_request)
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(
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()
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
+35 -3
View File
@@ -90,6 +90,37 @@ def _normalize(value: str) -> str:
return str(value or "").strip().casefold()
def _deduplicate_chats(chats: list[Any]) -> list[Any]:
"""
Возвращает уникальные чаты с сохранением исходного порядка.
Сначала пытаемся уникализировать по chat.id, затем по нормализованному title.
"""
unique: list[Any] = []
seen_ids: set[str] = set()
seen_titles: set[str] = set()
for chat in chats:
chat_id = getattr(chat, "id", None)
if chat_id is not None:
key_id = str(chat_id).strip()
if key_id in seen_ids:
continue
seen_ids.add(key_id)
unique.append(chat)
continue
key_title = _normalize(_max_chat_title(chat))
if not key_title:
unique.append(chat)
continue
if key_title in seen_titles:
continue
seen_titles.add(key_title)
unique.append(chat)
return unique
async def _refresh_chats_best_effort(max_client: MaxClient) -> None:
# group.py: fetch_chats(marker=None) заполняет max_client.chats
try:
@@ -104,7 +135,7 @@ def _find_chat_by_title(max_client: MaxClient, title: str) -> Any | None:
wanted = _normalize(title)
if not wanted:
return None
chats = list(getattr(max_client, "chats", []) or [])
chats = _deduplicate_chats(list(getattr(max_client, "chats", []) or []))
for c in chats:
if _normalize(_max_chat_title(c)) == wanted:
return c
@@ -159,13 +190,14 @@ async def handle_control_command(
"/list — список активных чатов MAX\n"
"/join <LINK> — присоединиться к группе/каналу по ссылке\n"
"/leave <НАЗВАНИЕ> — покинуть указанный канал\n"
"/last_messages <НАЗВАНИЕ> — последние 10 сообщений из канала"
"/last_messages <НАЗВАНИЕ> — последние 10 сообщений из канала\n"
"/bind_max <НАЗВАНИЕ> — привязать текущий Telegram-чат к чату MAX"
)
if cmd == "/list":
await _refresh_chats_best_effort(max_client)
chats = list(getattr(max_client, "chats", []) or [])
chats = _deduplicate_chats(list(getattr(max_client, "chats", []) or []))
if not chats:
return "Список чатов пуст (или клиент MAX ещё не успел их загрузить)."