from __future__ import annotations
from loguru import logger
import asyncio
import os
import re
from pathlib import Path
from typing import Any, Optional
import aiohttp
from aiogram import Bot
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.exceptions import TelegramRetryAfter
from aiogram.types import FSInputFile, InputMediaPhoto, InputMediaVideo
try:
from .config import settings
from .media_processor import ProcessedMedia
from .text_formatter import format_post_text, split_message_chunks
except (ImportError, ValueError):
from config import settings
from media_processor import ProcessedMedia
from text_formatter import format_post_text, split_message_chunks
MAX_MEDIA_GROUP = 10
MAX_RICH_MEDIA = 50
MAX_RICH_TEXT = 32768
def parse_topic(value: str) -> tuple[int, Optional[int]]:
raw = str(value or "").strip()
if not raw:
raise ValueError("Telegram chat ID is empty")
if ":" in raw:
chat_id, thread_id = raw.split(":", 1)
return int(chat_id), int(thread_id)
return int(raw), None
def tg_message_url(chat_id: int, message_id: int) -> str:
chat = str(abs(int(chat_id)))
if chat.startswith("100"):
chat = chat[3:]
return f"https://t.me/c/{chat}/{message_id}"
class RichMessageUnavailable(RuntimeError):
pass
class TelegramPoster:
def __init__(self) -> None:
self.bot: Optional[Bot] = None
self.chat_id: int = 0
self.thread_id: Optional[int] = None
self.storage_chat_id: Optional[int] = None
self.storage_thread_id: Optional[int] = None
self.is_local_api: bool = False
self.caption_limit: int = 1024
self.message_limit: int = 4096
async def init(self) -> None:
if not settings.tg_bot_token:
raise ValueError("TG_BOT_TOKEN is not configured")
if not settings.tg_chat_id:
raise ValueError("TG_CHAT_ID is not configured")
self.chat_id, self.thread_id = parse_topic(settings.tg_chat_id)
if settings.tg_media_channel_id:
try:
self.storage_chat_id, self.storage_thread_id = parse_topic(settings.tg_media_channel_id)
except Exception as exc:
logger.warning("Could not parse TG_MEDIA_CHANNEL_ID: {}", exc)
local_url = (settings.local_bot_api_url or "").strip().rstrip("/")
if local_url:
try:
session = AiohttpSession(
api=TelegramAPIServer.from_base(local_url, is_local=True)
)
test_bot = Bot(token=settings.tg_bot_token, session=session)
me = await test_bot.get_me()
self.bot = test_bot
self.is_local_api = True
logger.info("Connected to local Telegram Bot API at {}: @{}", local_url, me.username)
except Exception as exc:
logger.warning(
"Local Telegram Bot API unavailable at {}: {}. Falling back to standard API.",
local_url,
exc,
)
self.bot = Bot(token=settings.tg_bot_token)
self.is_local_api = False
else:
self.bot = Bot(token=settings.tg_bot_token)
self.is_local_api = False
async def close(self) -> None:
if self.bot and self.bot.session:
await self.bot.session.close()
def chat_kwargs(self, is_storage: bool = False) -> dict[str, Any]:
c_id = self.storage_chat_id if is_storage and self.storage_chat_id else self.chat_id
t_id = self.storage_thread_id if is_storage and self.storage_chat_id else self.thread_id
kwargs: dict[str, Any] = {"chat_id": int(c_id)}
if t_id:
kwargs["message_thread_id"] = int(t_id)
return kwargs
async def tg_retry(self, fn):
for attempt in range(1, 4):
try:
return await fn()
except TelegramRetryAfter as exc:
delay = float(exc.retry_after) + 0.5
logger.warning("Telegram flood control: retry after {}s", delay)
await asyncio.sleep(delay)
except Exception as exc:
if attempt >= 3:
raise
logger.warning("Telegram request error attempt {}: {}", attempt, exc)
await asyncio.sleep(2 ** attempt)
raise RuntimeError("Telegram retries exhausted")
async def upload_media_for_file_ids(
self, media_items: list[ProcessedMedia]
) -> dict[str, str]:
"""Uploads files to destination/storage to obtain tg_file_ids"""
file_ids: dict[str, str] = {}
valid_items = [m for m in media_items if m.local_path and m.local_path.exists()]
if not valid_items or not self.bot:
return file_ids
# If storage channel is configured, we send there; otherwise we send directly
use_storage = bool(self.storage_chat_id)
group = []
for item in valid_items[:MAX_MEDIA_GROUP]:
fs = FSInputFile(str(item.local_path))
if item.media_type == "photo":
group.append(InputMediaPhoto(media=fs))
else:
group.append(InputMediaVideo(media=fs))
if not group:
return file_ids
try:
if len(group) == 1:
item = valid_items[0]
fs = FSInputFile(str(item.local_path))
if item.media_type == "photo":
msg = await self.tg_retry(
lambda: self.bot.send_photo(photo=fs, **self.chat_kwargs(use_storage))
)
if msg.photo:
file_ids[item.attachment_id] = msg.photo[-1].file_id
else:
msg = await self.tg_retry(
lambda: self.bot.send_video(video=fs, **self.chat_kwargs(use_storage))
)
if msg.video:
file_ids[item.attachment_id] = msg.video.file_id
else:
msgs = await self.tg_retry(
lambda: self.bot.send_media_group(media=group, **self.chat_kwargs(use_storage))
)
for item, msg in zip(valid_items, msgs):
if item.media_type == "photo" and msg.photo:
file_ids[item.attachment_id] = msg.photo[-1].file_id
elif item.media_type == "video" and msg.video:
file_ids[item.attachment_id] = msg.video.file_id
except Exception as exc:
logger.warning("Error uploading media items for file_ids: {}", exc)
return file_ids
def build_rich_text_html(self, text: str) -> str:
paragraphs = []
for p in text.strip().split("\n\n"):
body = "
".join(line for line in p.splitlines() if line.strip())
if body:
paragraphs.append(f"
{body}
") return "\n".join(paragraphs) def build_rich_message( self, text: str, media_items: list[ProcessedMedia], file_ids: dict[str, str] ) -> Optional[dict[str, Any]]: rich_media = [] media_tags = [] valid_items = [ m for m in media_items if m.attachment_id in file_ids ][:MAX_RICH_MEDIA] if not valid_items: return None for idx, item in enumerate(valid_items): f_id = file_ids[item.attachment_id] media_id = f"m{idx}" m_type = "photo" if item.media_type == "photo" else "video" rich_media.append({"id": media_id, "media": {"type": m_type, "media": f_id}}) if item.media_type == "photo": media_tags.append(f'