6 Commits
Author SHA1 Message Date
kislovdm dcf4441340 work with files 2026-04-20 18:55:01 +03:00
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
9 changed files with 362 additions and 46 deletions
+6 -3
View File
@@ -6,6 +6,10 @@
# #
# Опционально: Variables → DOCKERHUB_IMAGE (например org/max2telegram), если имя образа # Опционально: Variables → DOCKERHUB_IMAGE (например org/max2telegram), если имя образа
# отличается от <DOCKERHUB_USERNAME>/max2telegram. # отличается от <DOCKERHUB_USERNAME>/max2telegram.
#
# Теги образа:
# push в master → dev (+ sha)
# push тега v* → {{version}}, {{major}}.{{minor}}, latest (+ sha)
name: Docker Hub name: Docker Hub
@@ -57,11 +61,10 @@ jobs:
with: with:
images: ${{ steps.image.outputs.name }} images: ${{ steps.image.outputs.name }}
tags: | tags: |
type=ref,event=branch type=raw,value=dev,enable=${{ github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=sha,prefix= type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
+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
+197 -29
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__)
@@ -33,11 +34,6 @@ class MaxToTelegramBridge:
# - если найден целевой Telegram-чат (не fallback): "Ирина:\n<текст>" # - если найден целевой Telegram-чат (не fallback): "Ирина:\n<текст>"
# - если fallback: "Ирина / Свободный микрофон:\n<текст>" # - если fallback: "Ирина / Свободный микрофон:\n<текст>"
# Решение о том, включать ли название чата, принимаем после определения маршрута. # Решение о том, включать ли название чата, принимаем после определения маршрута.
has_any_payload = bool((parsed.text or "").strip()) or bool(parsed.image_urls) or bool(parsed.video_urls)
if not has_any_payload:
logger.debug("Skip empty message %s/%s", parsed.chat_id, parsed.message_id)
return
normalized = parsed.chat_name.strip().casefold() normalized = parsed.chat_name.strip().casefold()
routed = self._storage.get_chat_route(max_chat_title_norm=normalized) routed = self._storage.get_chat_route(max_chat_title_norm=normalized)
if routed: if routed:
@@ -63,6 +59,7 @@ class MaxToTelegramBridge:
text=parsed.text, text=parsed.text,
include_chat_name=is_fallback, include_chat_name=is_fallback,
) )
text = self._append_unknown_attachment_notice(parsed=parsed, text=text)
reply_telegram_mid = self._resolve_telegram_reply_to( reply_telegram_mid = self._resolve_telegram_reply_to(
telegram_chat_id=str(target_chat_id), telegram_chat_id=str(target_chat_id),
@@ -72,20 +69,25 @@ class MaxToTelegramBridge:
if parsed.reply_to_max_message_id and reply_telegram_mid is None: if parsed.reply_to_max_message_id and reply_telegram_mid is None:
text = self._prepend_max_reply_context(parsed, text) text = self._prepend_max_reply_context(parsed, text)
has_any_payload = bool(text.strip()) or bool(parsed.image_urls) or bool(parsed.video_urls) has_any_payload = bool(text.strip()) or bool(parsed.image_urls) or bool(parsed.video_urls) or bool(parsed.file_urls)
if not has_any_payload: if not has_any_payload:
logger.debug("Skip empty message %s/%s", parsed.chat_id, parsed.message_id) text = self._build_fallback_unknown_notice(parsed)
return
total_media = len(parsed.image_urls) + len(parsed.video_urls) total_media = len(parsed.image_urls) + len(parsed.video_urls)
sent_any = False
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,
image_urls=parsed.image_urls, max_chat_title_norm=normalized,
video_urls=parsed.video_urls, max_chat_title=parsed.chat_name,
caption=text, send_action=lambda chat_id: self._telegram.send_media_group(
reply_to_message_id=reply_telegram_mid, 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: for sent in sent_messages:
mid = sent.get("message_id") mid = sent.get("message_id")
@@ -97,6 +99,7 @@ class MaxToTelegramBridge:
max_chat_id=str(parsed.chat_id), max_chat_id=str(parsed.chat_id),
max_message_id=str(parsed.message_id), max_message_id=str(parsed.message_id),
) )
sent_any = True
self._storage.mark_forwarded(parsed.message_id, parsed.chat_id) self._storage.mark_forwarded(parsed.message_id, parsed.chat_id)
logger.info( logger.info(
"Forwarded media group %s/%s (images=%s, videos=%s)", "Forwarded media group %s/%s (images=%s, videos=%s)",
@@ -107,10 +110,14 @@ class MaxToTelegramBridge:
) )
return return
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 +131,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,
image_url, max_chat_title_norm=normalized,
caption=caption, max_chat_title=parsed.chat_name,
reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None, 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 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 +154,59 @@ 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,
video_url, max_chat_title_norm=normalized,
caption=caption, max_chat_title=parsed.chat_name,
reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None, 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:
self._storage.save_mapping(
telegram_chat_id=str(target_chat_id),
telegram_message_id=str(mid),
max_chat_id=str(parsed.chat_id),
max_message_id=str(parsed.message_id),
)
sent_any = True
for index, file_url in enumerate(parsed.file_urls):
caption = text if not sent_any and index == 0 else None
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_document(
chat_id,
file_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:
self._storage.save_mapping(
telegram_chat_id=str(target_chat_id),
telegram_message_id=str(mid),
max_chat_id=str(parsed.chat_id),
max_message_id=str(parsed.message_id),
)
sent_any = True
if not sent_any:
# Последняя страховка: гарантируем уведомление в Telegram даже для пустых/неизвестных payload.
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, self._build_fallback_unknown_notice(parsed), 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:
@@ -160,13 +220,64 @@ class MaxToTelegramBridge:
self._storage.mark_forwarded(parsed.message_id, parsed.chat_id) self._storage.mark_forwarded(parsed.message_id, parsed.chat_id)
logger.info( logger.info(
"Forwarded message %s/%s (images=%s, videos=%s)", "Forwarded message %s/%s (images=%s, videos=%s, files=%s, unknown=%s)",
parsed.chat_id, parsed.chat_id,
parsed.message_id, parsed.message_id,
len(parsed.image_urls), len(parsed.image_urls),
len(parsed.video_urls), len(parsed.video_urls),
len(parsed.file_urls),
len(parsed.unknown_attachments),
) )
async def notify_delivery_failure(self, max_message: Any, error: Exception) -> None:
"""Best-effort аварийное уведомление, если основной форвардинг упал."""
try:
parsed = parse_message(max_message)
parsed = await self._enrich_from_max(max_message, parsed)
body = self._build_fallback_unknown_notice(parsed)
body = f"{body}\n\n[bridge-error] {type(error).__name__}: {error}"
except Exception:
body = f"[!] Сообщение из MAX не доставлено в Telegram из-за ошибки bridge: {type(error).__name__}: {error}"
fallback_chat_id = self._telegram.fallback_user_id
if not fallback_chat_id:
logger.error("Cannot send emergency notice: Telegram fallback user id is empty")
return
try:
await self._telegram.send_text(chat_id=fallback_chat_id, text=body)
logger.warning("Sent emergency notice to Telegram fallback chat %s", fallback_chat_id)
except Exception:
logger.exception("Cannot send emergency notice to Telegram")
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:
@@ -243,10 +354,18 @@ class MaxToTelegramBridge:
parsed.video_urls.append(str(video_url)) parsed.video_urls.append(str(video_url))
except Exception: except Exception:
logger.exception("Cannot resolve video URL from Max") logger.exception("Cannot resolve video URL from Max")
else:
urls = self._extract_any_urls(attach)
if urls:
parsed.file_urls.extend(urls)
else:
parsed.unknown_attachments.append(type(attach).__name__)
# Убираем дубли URL, если парсер и enrich нашли одинаковые вложения. # Убираем дубли URL, если парсер и enrich нашли одинаковые вложения.
parsed.image_urls = list(dict.fromkeys(parsed.image_urls)) parsed.image_urls = list(dict.fromkeys(parsed.image_urls))
parsed.video_urls = list(dict.fromkeys(parsed.video_urls)) parsed.video_urls = list(dict.fromkeys(parsed.video_urls))
parsed.file_urls = list(dict.fromkeys(parsed.file_urls))
parsed.unknown_attachments = list(dict.fromkeys(parsed.unknown_attachments))
return parsed return parsed
def _is_self_message(self, max_message: Any) -> bool: def _is_self_message(self, max_message: Any) -> bool:
@@ -295,3 +414,52 @@ class MaxToTelegramBridge:
walk(attach) walk(attach)
return list(dict.fromkeys(urls)) return list(dict.fromkeys(urls))
def _extract_any_urls(self, node: Any) -> list[str]:
urls: list[str] = []
seen_ids: set[int] = set()
def walk(value: Any) -> None:
if value is None:
return
obj_id = id(value)
if obj_id in seen_ids:
return
seen_ids.add(obj_id)
if isinstance(value, str):
if value.startswith("http://") or value.startswith("https://"):
urls.append(value)
return
if isinstance(value, (list, tuple, set)):
for item in value:
walk(item)
return
if isinstance(value, dict):
for nested in value.values():
walk(nested)
return
if hasattr(value, "__dict__"):
walk(vars(value))
walk(node)
return list(dict.fromkeys(urls))
@staticmethod
def _append_unknown_attachment_notice(*, parsed: ParsedMessage, text: str) -> str:
if not parsed.unknown_attachments:
return text
unknown_preview = ", ".join(parsed.unknown_attachments[:5])
suffix = f"\n\n[!] Неизвестный тип вложения из MAX: {unknown_preview}"
return f"{text}{suffix}" if text else suffix.strip()
@staticmethod
def _build_fallback_unknown_notice(parsed: ParsedMessage) -> str:
base = MaxToTelegramBridge._format_caption(
sender_name=parsed.sender_name,
chat_name=parsed.chat_name,
text=parsed.text,
include_chat_name=True,
)
unknown = ", ".join(parsed.unknown_attachments[:5]) if parsed.unknown_attachments else "unknown"
return f"{base}\n\n[!] Неизвестный или пустой тип сообщения из MAX (attachments={unknown})."
+2 -1
View File
@@ -72,8 +72,9 @@ def main() -> None:
health.mark_max_event() health.mark_max_event()
try: try:
await bridge.forward_message(message) await bridge.forward_message(message)
except Exception: except Exception as exc:
logger.exception("Failed to forward Max message") logger.exception("Failed to forward Max message")
await bridge.notify_delivery_failure(message, exc)
asyncio.run(max_client.start()) asyncio.run(max_client.start())
+11 -3
View File
@@ -36,9 +36,11 @@ def _is_video(media_type: str) -> bool:
return "video" in value or value in {"mp4", "mov", "mkv", "avi"} return "video" in value or value in {"mp4", "mov", "mkv", "avi"}
def _extract_media_urls(message: Any) -> tuple[list[str], list[str]]: def _extract_media_urls(message: Any) -> tuple[list[str], list[str], list[str], list[str]]:
image_urls: list[str] = [] image_urls: list[str] = []
video_urls: list[str] = [] video_urls: list[str] = []
file_urls: list[str] = []
unknown_attachments: list[str] = []
# В PyMax рабочее поле для вложений обычно называется attaches. # В PyMax рабочее поле для вложений обычно называется attaches.
raw_attachments = _get_attr(message, ["attaches", "attachments", "media", "files"], default=[]) or [] raw_attachments = _get_attr(message, ["attaches", "attachments", "media", "files"], default=[]) or []
@@ -67,14 +69,18 @@ def _extract_media_urls(message: Any) -> tuple[list[str], list[str]]:
media_type = _stringify(nested_data.get("type") or nested_data.get("media_type")) media_type = _stringify(nested_data.get("type") or nested_data.get("media_type"))
if not url: if not url:
kind = media_type or _stringify(type(item).__name__) or "unknown"
unknown_attachments.append(kind)
continue continue
if _is_image(media_type): if _is_image(media_type):
image_urls.append(url) image_urls.append(url)
elif _is_video(media_type): elif _is_video(media_type):
video_urls.append(url) video_urls.append(url)
else:
file_urls.append(url)
return image_urls, video_urls return image_urls, video_urls, file_urls, unknown_attachments
def _extract_max_reply(message: Any) -> tuple[str | None, str | None]: def _extract_max_reply(message: Any) -> tuple[str | None, str | None]:
@@ -115,7 +121,7 @@ def parse_message(message: Any) -> ParsedMessage:
chat_id = _stringify(_get_attr(message, ["chat_id", "dialog_id", "peer_id"])) or "unknown-chat" chat_id = _stringify(_get_attr(message, ["chat_id", "dialog_id", "peer_id"])) or "unknown-chat"
text = _stringify(_get_attr(message, ["text", "message", "body"])) text = _stringify(_get_attr(message, ["text", "message", "body"]))
image_urls, video_urls = _extract_media_urls(message) image_urls, video_urls, file_urls, unknown_attachments = _extract_media_urls(message)
reply_mid, reply_preview = _extract_max_reply(message) reply_mid, reply_preview = _extract_max_reply(message)
return ParsedMessage( return ParsedMessage(
message_id=message_id, message_id=message_id,
@@ -125,6 +131,8 @@ def parse_message(message: Any) -> ParsedMessage:
text=text, text=text,
image_urls=image_urls, image_urls=image_urls,
video_urls=video_urls, video_urls=video_urls,
file_urls=file_urls,
unknown_attachments=unknown_attachments,
reply_to_max_message_id=reply_mid, reply_to_max_message_id=reply_mid,
reply_preview_text=reply_preview, reply_preview_text=reply_preview,
) )
+2
View File
@@ -10,6 +10,8 @@ class ParsedMessage:
text: str text: str
image_urls: list[str] = field(default_factory=list) image_urls: list[str] = field(default_factory=list)
video_urls: list[str] = field(default_factory=list) video_urls: list[str] = field(default_factory=list)
file_urls: list[str] = field(default_factory=list)
unknown_attachments: list[str] = field(default_factory=list)
# Ответ в MAX: Message.link указывает на исходное сообщение (тред). # Ответ в MAX: Message.link указывает на исходное сообщение (тред).
reply_to_max_message_id: str | None = None reply_to_max_message_id: str | None = None
reply_preview_text: str | None = None reply_preview_text: str | None = None
+38 -4
View File
@@ -64,6 +64,11 @@ def _is_supported_telegram_message(message: dict[str, Any]) -> bool:
if has_video: if has_video:
return True return True
for key in ("document", "audio", "voice", "animation", "sticker", "video_note"):
value = message.get(key)
if isinstance(value, dict) and value.get("file_id"):
return True
return False return False
@@ -314,8 +319,13 @@ class TelegramToMaxBridge:
reply_to = self._resolve_reply_to_max_id(max_chat_id=max_chat_id, message=messages[0]) reply_to = self._resolve_reply_to_max_id(max_chat_id=max_chat_id, message=messages[0])
attachments: list[Any] = [] attachments: list[Any] = []
extra_links: list[str] = []
for m in messages: for m in messages:
attachments.extend(await self._extract_attachments(m)) extracted, links = await self._extract_attachments(m)
attachments.extend(extracted)
extra_links.extend(links)
text = self._append_file_links(text=text, links=extra_links)
if not text.strip() and not attachments: if not text.strip() and not attachments:
return return
@@ -375,7 +385,8 @@ class TelegramToMaxBridge:
) -> None: ) -> None:
raw_text = str(message.get("text") or message.get("caption") or "").strip() raw_text = str(message.get("text") or message.get("caption") or "").strip()
text = _format_forward_text(sender=message.get("from"), text=raw_text) text = _format_forward_text(sender=message.get("from"), text=raw_text)
attachments = await self._extract_attachments(message) attachments, extra_links = await self._extract_attachments(message)
text = self._append_file_links(text=text, links=extra_links)
if not text.strip() and not attachments: if not text.strip() and not attachments:
return return
@@ -430,8 +441,9 @@ class TelegramToMaxBridge:
# reply_to в MAX — это id сообщения; если не нашли, просто отправляем без reply # reply_to в MAX — это id сообщения; если не нашли, просто отправляем без reply
return mapped return mapped
async def _extract_attachments(self, message: dict[str, Any]) -> list[Any]: async def _extract_attachments(self, message: dict[str, Any]) -> tuple[list[Any], list[str]]:
attachments: list[Any] = [] attachments: list[Any] = []
file_links: list[str] = []
# photo: массив размеров, берём последний (самый большой) # photo: массив размеров, берём последний (самый большой)
photos = message.get("photo") photos = message.get("photo")
@@ -459,7 +471,29 @@ class TelegramToMaxBridge:
except Exception: except Exception:
logger.exception("Cannot fetch Telegram video URL") logger.exception("Cannot fetch Telegram video URL")
return attachments for key in ("document", "audio", "voice", "animation", "sticker", "video_note"):
value = message.get(key)
if not isinstance(value, dict):
continue
file_id = str(value.get("file_id") or "")
if not file_id:
continue
try:
url = await self._telegram.get_file_url(file_id)
file_links.append(url)
except Exception:
logger.exception("Cannot fetch Telegram %s URL", key)
return attachments, file_links
@staticmethod
def _append_file_links(*, text: str, links: list[str]) -> str:
uniq_links = list(dict.fromkeys([str(link).strip() for link in links if str(link).strip()]))
if not uniq_links:
return text
links_block = "\n".join(f"- {url}" for url in uniq_links)
suffix = f"\n\n[Telegram files]\n{links_block}"
return f"{text}{suffix}" if text else suffix.strip()
def _refresh_max_chat_cache(self) -> None: def _refresh_max_chat_cache(self) -> None:
title_to_id: dict[str, int] = {} title_to_id: dict[str, int] = {}
+70 -3
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:
@@ -80,6 +103,24 @@ class TelegramClient:
payload["reply_to_message_id"] = reply_to_message_id payload["reply_to_message_id"] = reply_to_message_id
return await self._request("sendVideo", payload) return await self._request("sendVideo", payload)
async def send_document(
self,
chat_id: str,
document_url: str,
caption: str | None = None,
*,
reply_to_message_id: int | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"chat_id": chat_id,
"document": document_url,
}
if caption:
payload["caption"] = caption
if reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
return await self._request("sendDocument", payload)
async def send_media_group( async def send_media_group(
self, self,
chat_id: str, chat_id: str,
@@ -209,10 +250,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
+33 -2
View File
@@ -90,6 +90,37 @@ def _normalize(value: str) -> str:
return str(value or "").strip().casefold() 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: async def _refresh_chats_best_effort(max_client: MaxClient) -> None:
# group.py: fetch_chats(marker=None) заполняет max_client.chats # group.py: fetch_chats(marker=None) заполняет max_client.chats
try: try:
@@ -104,7 +135,7 @@ def _find_chat_by_title(max_client: MaxClient, title: str) -> Any | None:
wanted = _normalize(title) wanted = _normalize(title)
if not wanted: if not wanted:
return None return None
chats = list(getattr(max_client, "chats", []) or []) chats = _deduplicate_chats(list(getattr(max_client, "chats", []) or []))
for c in chats: for c in chats:
if _normalize(_max_chat_title(c)) == wanted: if _normalize(_max_chat_title(c)) == wanted:
return c return c
@@ -166,7 +197,7 @@ async def handle_control_command(
if cmd == "/list": if cmd == "/list":
await _refresh_chats_best_effort(max_client) 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: if not chats:
return "Список чатов пуст (или клиент MAX ещё не успел их загрузить)." return "Список чатов пуст (или клиент MAX ещё не успел их загрузить)."