436 lines
13 KiB
Python
436 lines
13 KiB
Python
import asyncio
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import aiohttp
|
|
import structlog
|
|
from aiogram.exceptions import TelegramBadRequest
|
|
from aiogram.types import (
|
|
FSInputFile,
|
|
InputMediaDocument,
|
|
InputMediaPhoto,
|
|
InputMediaVideo,
|
|
ReactionTypeEmoji,
|
|
)
|
|
|
|
from app.config import Settings
|
|
from app.media_transfer import (
|
|
cleanup_paths,
|
|
download_max_image_url,
|
|
download_media_item,
|
|
resolve_file_name,
|
|
tmp_dir,
|
|
)
|
|
from app.max_layer.client_holder import MaxClientHolder
|
|
from app.max_layer.formatter import format_max_text
|
|
from app.models.domain import ChatMapping, MaxIncomingMessage
|
|
from app.models.tasks import (
|
|
Max2TgTask,
|
|
MediaItem,
|
|
NotifyFallbackTask,
|
|
SetReactionTask,
|
|
)
|
|
from app.queue.protocols import QueuePort
|
|
from app.storage.protocols import StoragePort
|
|
from app.telegram_layer.bot_holder import BotHolder
|
|
from app.telegram_layer.formatter import (
|
|
format_topic_pin_text,
|
|
split_text_for_media_caption,
|
|
)
|
|
from app.topic_locks import TopicLockRegistry
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
def _is_thread_not_found(exc: TelegramBadRequest) -> bool:
|
|
return "message thread not found" in exc.message.lower()
|
|
|
|
|
|
class TelegramWorker:
|
|
def __init__(
|
|
self,
|
|
settings: Settings,
|
|
bot_holder: BotHolder,
|
|
max_holder: MaxClientHolder,
|
|
queue: QueuePort,
|
|
storage: StoragePort,
|
|
topic_locks: TopicLockRegistry,
|
|
) -> None:
|
|
self._settings = settings
|
|
self._bot_holder = bot_holder
|
|
self._max_holder = max_holder
|
|
self._queue = queue
|
|
self._storage = storage
|
|
self._topic_locks = topic_locks
|
|
self._running = True
|
|
self._tmp_dir = tmp_dir(self._settings.data_dir)
|
|
|
|
async def run(self) -> None:
|
|
logger.info("tg_worker_started")
|
|
while self._running:
|
|
task = await self._queue.dequeue_max2tg(timeout=5)
|
|
if task is None:
|
|
continue
|
|
logger.info("tg_worker_task_received", task_kind=task.kind)
|
|
try:
|
|
if isinstance(task, Max2TgTask):
|
|
await self._send_to_tg(task)
|
|
elif isinstance(task, SetReactionTask):
|
|
await self._set_reaction(task)
|
|
elif isinstance(task, NotifyFallbackTask):
|
|
await self._notify_fallback(task)
|
|
except Exception as exc:
|
|
logger.exception("tg_worker_failed", task_kind=task.kind)
|
|
if isinstance(task, Max2TgTask):
|
|
await self._queue.enqueue_max2tg(
|
|
NotifyFallbackTask(
|
|
title="Ошибка отправки в Telegram",
|
|
details=f"max_message_id={task.max_message_id}: {exc}",
|
|
)
|
|
)
|
|
await asyncio.sleep(self._settings.tg_rate_limit_delay_sec)
|
|
|
|
async def _create_and_save_topic(
|
|
self,
|
|
bot,
|
|
task: Max2TgTask,
|
|
*,
|
|
reason: str,
|
|
old_thread_id: int | None = None,
|
|
) -> ChatMapping:
|
|
topic = await bot.create_forum_topic(
|
|
chat_id=self._settings.tg_forum_channel_id,
|
|
name=task.chat_title[:128],
|
|
)
|
|
mapping = ChatMapping(
|
|
max_chat_id=task.max_chat_id,
|
|
tg_chat_id=self._settings.tg_forum_channel_id,
|
|
tg_thread_id=topic.message_thread_id,
|
|
display_name=task.chat_title,
|
|
is_dm=task.is_dm,
|
|
)
|
|
await self._storage.save_mapping(mapping)
|
|
await self._send_and_pin_topic_info(bot, topic.message_thread_id, task)
|
|
logger.info(
|
|
"tg_topic_created",
|
|
max_chat_id=task.max_chat_id,
|
|
thread_id=topic.message_thread_id,
|
|
title=task.chat_title,
|
|
reason=reason,
|
|
old_thread_id=old_thread_id,
|
|
)
|
|
return mapping
|
|
|
|
async def _send_and_pin_topic_info(
|
|
self,
|
|
bot,
|
|
thread_id: int,
|
|
task: Max2TgTask,
|
|
) -> None:
|
|
chat_name = task.chat_name or task.chat_title
|
|
caption = format_topic_pin_text(
|
|
chat_name=chat_name,
|
|
is_dm=task.is_dm,
|
|
max_chat_id=task.max_chat_id,
|
|
participants_count=task.participants_count,
|
|
max_chat_link=task.max_chat_link,
|
|
)
|
|
kwargs: dict = {
|
|
"chat_id": self._settings.tg_forum_channel_id,
|
|
"message_thread_id": thread_id,
|
|
}
|
|
|
|
downloaded: Path | None = None
|
|
icon_path: Path | None = None
|
|
try:
|
|
icon_path = await self._resolve_topic_icon_path(task)
|
|
if icon_path is not None:
|
|
sent = await bot.send_photo(
|
|
photo=FSInputFile(icon_path),
|
|
caption=caption,
|
|
**kwargs,
|
|
)
|
|
if (
|
|
not task.chat_icon_local_path
|
|
or icon_path.resolve() != Path(task.chat_icon_local_path).resolve()
|
|
):
|
|
downloaded = icon_path
|
|
else:
|
|
sent = await bot.send_message(text=caption, **kwargs)
|
|
|
|
await bot.pin_chat_message(
|
|
chat_id=self._settings.tg_forum_channel_id,
|
|
message_id=sent.message_id,
|
|
disable_notification=True,
|
|
)
|
|
logger.info(
|
|
"tg_topic_info_pinned",
|
|
max_chat_id=task.max_chat_id,
|
|
thread_id=thread_id,
|
|
message_id=sent.message_id,
|
|
has_icon=icon_path is not None,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"tg_topic_info_pin_failed",
|
|
max_chat_id=task.max_chat_id,
|
|
thread_id=thread_id,
|
|
)
|
|
finally:
|
|
if downloaded is not None:
|
|
cleanup_paths([downloaded])
|
|
|
|
async def _resolve_topic_icon_path(self, task: Max2TgTask) -> Path | None:
|
|
if task.chat_icon_local_path:
|
|
local = Path(task.chat_icon_local_path)
|
|
if local.is_file() and local.stat().st_size > 0:
|
|
return local
|
|
|
|
if not task.chat_icon_url:
|
|
return None
|
|
|
|
dest = self._tmp_dir / f"topic_icon_{task.max_chat_id}_{uuid4().hex}.jpg"
|
|
async with aiohttp.ClientSession() as session:
|
|
if await download_max_image_url(session, task.chat_icon_url, dest):
|
|
return dest
|
|
logger.warning(
|
|
"tg_topic_icon_download_failed",
|
|
max_chat_id=task.max_chat_id,
|
|
url=task.chat_icon_url,
|
|
)
|
|
dest.unlink(missing_ok=True)
|
|
return None
|
|
|
|
async def _send_to_tg(self, task: Max2TgTask) -> None:
|
|
bot = await self._bot_holder.wait_bot()
|
|
logger.debug(
|
|
"tg_worker_send_start",
|
|
max_chat_id=task.max_chat_id,
|
|
max_message_id=task.max_message_id,
|
|
needs_new_topic=task.needs_new_topic,
|
|
media_count=len(task.media),
|
|
)
|
|
|
|
async with self._topic_locks.lock(task.max_chat_id):
|
|
mapping = await self._storage.get_mapping_by_max_chat(task.max_chat_id)
|
|
if mapping is None:
|
|
logger.info(
|
|
"tg_topic_creating",
|
|
max_chat_id=task.max_chat_id,
|
|
title=task.chat_title,
|
|
reason="no_mapping_in_storage_at_send_time",
|
|
needs_new_topic_from_router=task.needs_new_topic,
|
|
sqlite_path=self._settings.sqlite_path,
|
|
hint="mapping_missing_in_sqlite_will_call_create_forum_topic",
|
|
)
|
|
mapping = await self._create_and_save_topic(
|
|
bot, task, reason="no_mapping_in_storage"
|
|
)
|
|
elif task.needs_new_topic:
|
|
logger.info(
|
|
"tg_topic_reused",
|
|
max_chat_id=task.max_chat_id,
|
|
thread_id=mapping.tg_thread_id,
|
|
reason="mapping_found_despite_needs_new_topic_flag",
|
|
hint="mapping_appeared_between_router_enqueue_and_worker_send",
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"tg_topic_reused",
|
|
max_chat_id=task.max_chat_id,
|
|
thread_id=mapping.tg_thread_id,
|
|
)
|
|
|
|
assert mapping is not None
|
|
|
|
formatted = format_max_text(
|
|
MaxIncomingMessage(
|
|
max_chat_id=task.max_chat_id,
|
|
max_message_id=task.max_message_id,
|
|
text=task.text,
|
|
sender_id=None,
|
|
sender_name=task.sender_name,
|
|
is_dm=task.is_dm,
|
|
chat_title=task.chat_title,
|
|
chat_name=task.chat_name,
|
|
chat_icon_url=task.chat_icon_url,
|
|
chat_icon_local_path=task.chat_icon_local_path,
|
|
participants_count=task.participants_count,
|
|
max_chat_link=task.max_chat_link,
|
|
reply_to_max_message_id=None,
|
|
media=[],
|
|
)
|
|
)
|
|
|
|
reply_to = task.reply_to_tg_message_id
|
|
try:
|
|
sent = await self._dispatch_content(
|
|
bot, mapping.tg_thread_id, formatted, task.media, reply_to
|
|
)
|
|
except TelegramBadRequest as exc:
|
|
if not _is_thread_not_found(exc):
|
|
raise
|
|
old_thread_id = mapping.tg_thread_id
|
|
logger.info(
|
|
"tg_topic_stale",
|
|
max_chat_id=task.max_chat_id,
|
|
old_thread_id=old_thread_id,
|
|
reason="telegram_message_thread_not_found",
|
|
action="recreate_topic_and_update_binding",
|
|
)
|
|
async with self._topic_locks.lock(task.max_chat_id):
|
|
mapping = await self._create_and_save_topic(
|
|
bot,
|
|
task,
|
|
reason="telegram_thread_recreated",
|
|
old_thread_id=old_thread_id,
|
|
)
|
|
sent = await self._dispatch_content(
|
|
bot, mapping.tg_thread_id, formatted, task.media, None
|
|
)
|
|
if sent is None:
|
|
raise RuntimeError("Telegram API returned no message")
|
|
|
|
await self._storage.save_message_link(
|
|
task.max_chat_id,
|
|
task.max_message_id,
|
|
mapping.tg_chat_id,
|
|
mapping.tg_thread_id,
|
|
sent.message_id,
|
|
)
|
|
logger.info(
|
|
"tg_message_sent",
|
|
max_message_id=task.max_message_id,
|
|
tg_message_id=sent.message_id,
|
|
thread_id=mapping.tg_thread_id,
|
|
)
|
|
|
|
async def _send_overflow_text(
|
|
self,
|
|
bot,
|
|
*,
|
|
thread_id: int,
|
|
text: str,
|
|
reply_to_message_id: int,
|
|
) -> None:
|
|
await bot.send_message(
|
|
chat_id=self._settings.tg_forum_channel_id,
|
|
message_thread_id=thread_id,
|
|
text=text,
|
|
reply_to_message_id=reply_to_message_id,
|
|
)
|
|
logger.info(
|
|
"tg_caption_overflow_sent",
|
|
thread_id=thread_id,
|
|
text_len=len(text),
|
|
)
|
|
|
|
async def _dispatch_content(
|
|
self,
|
|
bot,
|
|
thread_id: int,
|
|
text: str,
|
|
media: list[MediaItem],
|
|
reply_to: int | None,
|
|
):
|
|
chat_id = self._settings.tg_forum_channel_id
|
|
kwargs: dict = {
|
|
"chat_id": chat_id,
|
|
"message_thread_id": thread_id,
|
|
}
|
|
if reply_to is not None:
|
|
kwargs["reply_to_message_id"] = reply_to
|
|
|
|
if not media:
|
|
return await bot.send_message(text=text or " ", **kwargs)
|
|
|
|
caption, overflow = split_text_for_media_caption(text)
|
|
|
|
downloaded: list = []
|
|
try:
|
|
max_client = self._max_holder.client
|
|
async with aiohttp.ClientSession() as session:
|
|
uploads: list[tuple[MediaItem, FSInputFile]] = []
|
|
for item in media:
|
|
path = await download_media_item(
|
|
bot,
|
|
session,
|
|
item,
|
|
self._tmp_dir,
|
|
max_client=max_client,
|
|
)
|
|
if path is None:
|
|
logger.warning(
|
|
"tg_worker_media_skipped",
|
|
kind=item.kind,
|
|
file_id=item.file_id,
|
|
url=item.url,
|
|
)
|
|
continue
|
|
downloaded.append(path)
|
|
uploads.append(
|
|
(item, FSInputFile(path, filename=resolve_file_name(item)))
|
|
)
|
|
|
|
if not uploads:
|
|
fallback = text or "[Telegram files]"
|
|
return await bot.send_message(text=fallback, **kwargs)
|
|
|
|
if len(uploads) == 1:
|
|
item, upload = uploads[0]
|
|
if item.kind == "photo":
|
|
sent = await bot.send_photo(
|
|
photo=upload, caption=caption, **kwargs
|
|
)
|
|
elif item.kind == "video":
|
|
sent = await bot.send_video(
|
|
video=upload, caption=caption, **kwargs
|
|
)
|
|
else:
|
|
sent = await bot.send_document(
|
|
document=upload, caption=caption, **kwargs
|
|
)
|
|
else:
|
|
group = []
|
|
for idx, (item, upload) in enumerate(uploads):
|
|
item_caption = caption if idx == 0 else None
|
|
if item.kind == "photo":
|
|
group.append(InputMediaPhoto(media=upload, caption=item_caption))
|
|
elif item.kind == "video":
|
|
group.append(InputMediaVideo(media=upload, caption=item_caption))
|
|
else:
|
|
group.append(InputMediaDocument(media=upload, caption=item_caption))
|
|
|
|
messages = await bot.send_media_group(
|
|
chat_id=chat_id,
|
|
message_thread_id=thread_id,
|
|
media=group,
|
|
)
|
|
sent = messages[0]
|
|
|
|
if overflow:
|
|
await self._send_overflow_text(
|
|
bot,
|
|
thread_id=thread_id,
|
|
text=overflow,
|
|
reply_to_message_id=sent.message_id,
|
|
)
|
|
return sent
|
|
finally:
|
|
cleanup_paths(downloaded)
|
|
|
|
async def _set_reaction(self, task: SetReactionTask) -> None:
|
|
bot = await self._bot_holder.wait_bot()
|
|
await bot.set_message_reaction(
|
|
chat_id=task.tg_chat_id,
|
|
message_id=task.tg_message_id,
|
|
reaction=[ReactionTypeEmoji(emoji=task.emoji)],
|
|
)
|
|
logger.info("tg_reaction_set", message_id=task.tg_message_id)
|
|
|
|
async def _notify_fallback(self, task: NotifyFallbackTask) -> None:
|
|
bot = await self._bot_holder.wait_bot()
|
|
text = f"⚠️ {task.title}\n\n{task.details}"
|
|
await bot.send_message(chat_id=self._settings.fallback_user_id, text=text)
|
|
logger.warning("fallback_notified", title=task.title)
|