502 lines
19 KiB
Python
502 lines
19 KiB
Python
"""Service classes for private handlers"""
|
||
|
||
# Standard library imports
|
||
import asyncio
|
||
import html
|
||
import random
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, Protocol, Union
|
||
|
||
# Third-party imports
|
||
from aiogram import types
|
||
from aiogram.types import FSInputFile
|
||
|
||
from database.models import TelegramPost, User
|
||
from helper_bot.keyboards import get_reply_keyboard_for_post
|
||
|
||
# Local imports - utilities
|
||
from helper_bot.utils.helper_func import (
|
||
add_in_db_media,
|
||
check_username_and_full_name,
|
||
determine_anonymity,
|
||
get_first_name,
|
||
get_text_message,
|
||
prepare_media_group_from_middlewares,
|
||
send_audio_message,
|
||
send_media_group_message_to_private_chat,
|
||
send_photo_message,
|
||
send_text_message,
|
||
send_video_message,
|
||
send_video_note_message,
|
||
send_voice_message,
|
||
)
|
||
|
||
# Local imports - metrics
|
||
from helper_bot.utils.metrics import (
|
||
db_query_time,
|
||
track_errors,
|
||
track_file_operations,
|
||
track_media_processing,
|
||
track_time,
|
||
)
|
||
from logs.custom_logger import logger
|
||
|
||
|
||
class DatabaseProtocol(Protocol):
|
||
"""Protocol for database operations"""
|
||
|
||
async def user_exists(self, user_id: int) -> bool: ...
|
||
async def add_user(self, user: User) -> None: ...
|
||
async def update_user_info(
|
||
self, user_id: int, username: str = None, full_name: str = None
|
||
) -> None: ...
|
||
async def update_user_date(self, user_id: int) -> None: ...
|
||
async def add_post(self, post: TelegramPost) -> None: ...
|
||
async def update_stickers_info(self, user_id: int) -> None: ...
|
||
async def add_message(
|
||
self, message_text: str, user_id: int, message_id: int, date: int = None
|
||
) -> None: ...
|
||
async def update_helper_message(
|
||
self, message_id: int, helper_message_id: int
|
||
) -> None: ...
|
||
|
||
|
||
@dataclass
|
||
class BotSettings:
|
||
"""Bot configuration settings"""
|
||
|
||
group_for_posts: str
|
||
group_for_message: str
|
||
main_public: str
|
||
group_for_logs: str
|
||
important_logs: str
|
||
preview_link: str
|
||
logs: str
|
||
test: str
|
||
|
||
|
||
class UserService:
|
||
"""Service for user-related operations"""
|
||
|
||
def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None:
|
||
self.db = db
|
||
self.settings = settings
|
||
|
||
@track_time("update_user_activity", "user_service")
|
||
@track_errors("user_service", "update_user_activity")
|
||
@db_query_time("update_user_activity", "users", "update")
|
||
async def update_user_activity(self, user_id: int) -> None:
|
||
"""Update user's last activity timestamp with metrics tracking"""
|
||
await self.db.update_user_date(user_id)
|
||
|
||
@track_time("ensure_user_exists", "user_service")
|
||
@track_errors("user_service", "ensure_user_exists")
|
||
@db_query_time("ensure_user_exists", "users", "insert")
|
||
async def ensure_user_exists(self, message: types.Message) -> None:
|
||
"""Ensure user exists in database, create if needed with metrics tracking"""
|
||
user_id = message.from_user.id
|
||
full_name = message.from_user.full_name
|
||
username = message.from_user.username or "private_username"
|
||
first_name = get_first_name(message)
|
||
is_bot = message.from_user.is_bot
|
||
language_code = message.from_user.language_code
|
||
|
||
# Create User object with current timestamp
|
||
current_timestamp = int(datetime.now().timestamp())
|
||
user = User(
|
||
user_id=user_id,
|
||
first_name=first_name,
|
||
full_name=full_name,
|
||
username=username,
|
||
is_bot=is_bot,
|
||
language_code=language_code,
|
||
emoji="",
|
||
has_stickers=False,
|
||
date_added=current_timestamp,
|
||
date_changed=current_timestamp,
|
||
voice_bot_welcome_received=False,
|
||
)
|
||
|
||
# Пытаемся создать пользователя (если уже существует - игнорируем)
|
||
# Это устраняет race condition и упрощает логику
|
||
await self.db.add_user(user)
|
||
|
||
# Проверяем, нужно ли обновить информацию о существующем пользователе
|
||
is_need_update = await check_username_and_full_name(
|
||
user_id, username, full_name, self.db
|
||
)
|
||
if is_need_update:
|
||
await self.db.update_user_info(user_id, username, full_name)
|
||
safe_full_name = (
|
||
html.escape(full_name) if full_name else "Неизвестный пользователь"
|
||
)
|
||
safe_username = html.escape(username) if username else "Без никнейма"
|
||
|
||
await message.answer(
|
||
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {safe_full_name} и ник @{safe_username}"
|
||
)
|
||
await message.bot.send_message(
|
||
chat_id=self.settings.group_for_logs,
|
||
text=f"Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}",
|
||
)
|
||
|
||
await self.db.update_user_date(user_id)
|
||
|
||
async def log_user_message(self, message: types.Message) -> None:
|
||
"""Forward user message to logs group with metrics tracking"""
|
||
await message.forward(chat_id=self.settings.group_for_logs)
|
||
|
||
def get_safe_user_info(self, message: types.Message) -> tuple[str, str]:
|
||
"""Get safely escaped user information for logging"""
|
||
full_name = message.from_user.full_name or "Неизвестный пользователь"
|
||
username = message.from_user.username or "Без никнейма"
|
||
return html.escape(full_name), html.escape(username)
|
||
|
||
|
||
class PostService:
|
||
"""Service for post-related operations"""
|
||
|
||
def __init__(
|
||
self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None
|
||
) -> None:
|
||
self.db = db
|
||
self.settings = settings
|
||
self.s3_storage = s3_storage
|
||
|
||
async def _save_media_background(
|
||
self, sent_message: types.Message, bot_db: Any, s3_storage
|
||
) -> None:
|
||
"""Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю"""
|
||
try:
|
||
success = await add_in_db_media(sent_message, bot_db, s3_storage)
|
||
if not success:
|
||
logger.warning(
|
||
f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}"
|
||
)
|
||
|
||
@track_time("handle_text_post", "post_service")
|
||
@track_errors("post_service", "handle_text_post")
|
||
@db_query_time("handle_text_post", "posts", "insert")
|
||
async def handle_text_post(self, message: types.Message, first_name: str) -> None:
|
||
"""Handle text post submission"""
|
||
post_text = get_text_message(
|
||
message.text.lower(), first_name, message.from_user.username
|
||
)
|
||
markup = get_reply_keyboard_for_post()
|
||
|
||
sent_message = await send_text_message(
|
||
self.settings.group_for_posts, message, post_text, markup
|
||
)
|
||
|
||
# Сохраняем сырой текст и определяем анонимность
|
||
raw_text = message.text or ""
|
||
is_anonymous = determine_anonymity(raw_text)
|
||
|
||
post = TelegramPost(
|
||
message_id=sent_message.message_id,
|
||
text=raw_text,
|
||
author_id=message.from_user.id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
is_anonymous=is_anonymous,
|
||
)
|
||
await self.db.add_post(post)
|
||
|
||
@track_time("handle_photo_post", "post_service")
|
||
@track_errors("post_service", "handle_photo_post")
|
||
@db_query_time("handle_photo_post", "posts", "insert")
|
||
async def handle_photo_post(self, message: types.Message, first_name: str) -> None:
|
||
"""Handle photo post submission"""
|
||
post_caption = ""
|
||
if message.caption:
|
||
post_caption = get_text_message(
|
||
message.caption.lower(), first_name, message.from_user.username
|
||
)
|
||
|
||
markup = get_reply_keyboard_for_post()
|
||
sent_message = await send_photo_message(
|
||
self.settings.group_for_posts,
|
||
message,
|
||
message.photo[-1].file_id,
|
||
post_caption,
|
||
markup,
|
||
)
|
||
|
||
# Сохраняем сырой caption и определяем анонимность
|
||
raw_caption = message.caption or ""
|
||
is_anonymous = determine_anonymity(raw_caption)
|
||
|
||
post = TelegramPost(
|
||
message_id=sent_message.message_id,
|
||
text=raw_caption,
|
||
author_id=message.from_user.id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
is_anonymous=is_anonymous,
|
||
)
|
||
await self.db.add_post(post)
|
||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||
asyncio.create_task(
|
||
self._save_media_background(sent_message, self.db, self.s3_storage)
|
||
)
|
||
|
||
@track_time("handle_video_post", "post_service")
|
||
@track_errors("post_service", "handle_video_post")
|
||
@db_query_time("handle_video_post", "posts", "insert")
|
||
async def handle_video_post(self, message: types.Message, first_name: str) -> None:
|
||
"""Handle video post submission"""
|
||
post_caption = ""
|
||
if message.caption:
|
||
post_caption = get_text_message(
|
||
message.caption.lower(), first_name, message.from_user.username
|
||
)
|
||
|
||
markup = get_reply_keyboard_for_post()
|
||
sent_message = await send_video_message(
|
||
self.settings.group_for_posts,
|
||
message,
|
||
message.video.file_id,
|
||
post_caption,
|
||
markup,
|
||
)
|
||
|
||
# Сохраняем сырой caption и определяем анонимность
|
||
raw_caption = message.caption or ""
|
||
is_anonymous = determine_anonymity(raw_caption)
|
||
|
||
post = TelegramPost(
|
||
message_id=sent_message.message_id,
|
||
text=raw_caption,
|
||
author_id=message.from_user.id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
is_anonymous=is_anonymous,
|
||
)
|
||
await self.db.add_post(post)
|
||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||
asyncio.create_task(
|
||
self._save_media_background(sent_message, self.db, self.s3_storage)
|
||
)
|
||
|
||
@track_time("handle_video_note_post", "post_service")
|
||
@track_errors("post_service", "handle_video_note_post")
|
||
@db_query_time("handle_video_note_post", "posts", "insert")
|
||
async def handle_video_note_post(self, message: types.Message) -> None:
|
||
"""Handle video note post submission"""
|
||
markup = get_reply_keyboard_for_post()
|
||
sent_message = await send_video_note_message(
|
||
self.settings.group_for_posts, message, message.video_note.file_id, markup
|
||
)
|
||
|
||
# Сохраняем пустую строку, так как video_note не имеет caption
|
||
raw_caption = ""
|
||
is_anonymous = determine_anonymity(raw_caption)
|
||
|
||
post = TelegramPost(
|
||
message_id=sent_message.message_id,
|
||
text=raw_caption,
|
||
author_id=message.from_user.id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
is_anonymous=is_anonymous,
|
||
)
|
||
await self.db.add_post(post)
|
||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||
asyncio.create_task(
|
||
self._save_media_background(sent_message, self.db, self.s3_storage)
|
||
)
|
||
|
||
@track_time("handle_audio_post", "post_service")
|
||
@track_errors("post_service", "handle_audio_post")
|
||
@db_query_time("handle_audio_post", "posts", "insert")
|
||
async def handle_audio_post(self, message: types.Message, first_name: str) -> None:
|
||
"""Handle audio post submission"""
|
||
post_caption = ""
|
||
if message.caption:
|
||
post_caption = get_text_message(
|
||
message.caption.lower(), first_name, message.from_user.username
|
||
)
|
||
|
||
markup = get_reply_keyboard_for_post()
|
||
sent_message = await send_audio_message(
|
||
self.settings.group_for_posts,
|
||
message,
|
||
message.audio.file_id,
|
||
post_caption,
|
||
markup,
|
||
)
|
||
|
||
# Сохраняем сырой caption и определяем анонимность
|
||
raw_caption = message.caption or ""
|
||
is_anonymous = determine_anonymity(raw_caption)
|
||
|
||
post = TelegramPost(
|
||
message_id=sent_message.message_id,
|
||
text=raw_caption,
|
||
author_id=message.from_user.id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
is_anonymous=is_anonymous,
|
||
)
|
||
await self.db.add_post(post)
|
||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||
asyncio.create_task(
|
||
self._save_media_background(sent_message, self.db, self.s3_storage)
|
||
)
|
||
|
||
@track_time("handle_voice_post", "post_service")
|
||
@track_errors("post_service", "handle_voice_post")
|
||
@db_query_time("handle_voice_post", "posts", "insert")
|
||
async def handle_voice_post(self, message: types.Message) -> None:
|
||
"""Handle voice post submission"""
|
||
markup = get_reply_keyboard_for_post()
|
||
sent_message = await send_voice_message(
|
||
self.settings.group_for_posts, message, message.voice.file_id, markup
|
||
)
|
||
|
||
# Сохраняем пустую строку, так как voice не имеет caption
|
||
raw_caption = ""
|
||
is_anonymous = determine_anonymity(raw_caption)
|
||
|
||
post = TelegramPost(
|
||
message_id=sent_message.message_id,
|
||
text=raw_caption,
|
||
author_id=message.from_user.id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
is_anonymous=is_anonymous,
|
||
)
|
||
await self.db.add_post(post)
|
||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||
asyncio.create_task(
|
||
self._save_media_background(sent_message, self.db, self.s3_storage)
|
||
)
|
||
|
||
@track_time("handle_media_group_post", "post_service")
|
||
@track_errors("post_service", "handle_media_group_post")
|
||
@db_query_time("handle_media_group_post", "posts", "insert")
|
||
@track_media_processing("media_group")
|
||
async def handle_media_group_post(
|
||
self, message: types.Message, album: list, first_name: str
|
||
) -> None:
|
||
"""Handle media group post submission"""
|
||
post_caption = " "
|
||
raw_caption = ""
|
||
|
||
if album and album[0].caption:
|
||
raw_caption = album[0].caption or ""
|
||
post_caption = get_text_message(
|
||
album[0].caption.lower(), first_name, message.from_user.username
|
||
)
|
||
|
||
is_anonymous = determine_anonymity(raw_caption)
|
||
media_group = await prepare_media_group_from_middlewares(album, post_caption)
|
||
|
||
media_group_message_ids = await send_media_group_message_to_private_chat(
|
||
self.settings.group_for_posts,
|
||
message,
|
||
media_group,
|
||
self.db,
|
||
None,
|
||
self.s3_storage,
|
||
)
|
||
|
||
main_post_id = media_group_message_ids[-1]
|
||
|
||
main_post = TelegramPost(
|
||
message_id=main_post_id,
|
||
text=raw_caption,
|
||
author_id=message.from_user.id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
is_anonymous=is_anonymous,
|
||
)
|
||
await self.db.add_post(main_post)
|
||
|
||
for msg_id in media_group_message_ids:
|
||
await self.db.add_message_link(main_post_id, msg_id)
|
||
|
||
await asyncio.sleep(0.2)
|
||
|
||
markup = get_reply_keyboard_for_post()
|
||
helper_message = await send_text_message(
|
||
self.settings.group_for_posts, message, "^", markup
|
||
)
|
||
helper_message_id = helper_message.message_id
|
||
|
||
helper_post = TelegramPost(
|
||
message_id=helper_message_id,
|
||
text="^",
|
||
author_id=message.from_user.id,
|
||
helper_text_message_id=main_post_id,
|
||
created_at=int(datetime.now().timestamp()),
|
||
)
|
||
await self.db.add_post(helper_post)
|
||
|
||
await self.db.update_helper_message(
|
||
message_id=main_post_id, helper_message_id=helper_message_id
|
||
)
|
||
|
||
@track_time("process_post", "post_service")
|
||
@track_errors("post_service", "process_post")
|
||
@track_media_processing("media_group")
|
||
async def process_post(
|
||
self, message: types.Message, album: Union[list, None] = None
|
||
) -> None:
|
||
"""Process post based on content type"""
|
||
first_name = get_first_name(message)
|
||
|
||
if message.media_group_id is not None:
|
||
await self.handle_media_group_post(message, album, first_name)
|
||
return
|
||
|
||
content_handlers: Dict[str, Callable] = {
|
||
"text": lambda: self.handle_text_post(message, first_name),
|
||
"photo": lambda: self.handle_photo_post(message, first_name),
|
||
"video": lambda: self.handle_video_post(message, first_name),
|
||
"video_note": lambda: self.handle_video_note_post(message),
|
||
"audio": lambda: self.handle_audio_post(message, first_name),
|
||
"voice": lambda: self.handle_voice_post(message),
|
||
}
|
||
|
||
handler = content_handlers.get(message.content_type)
|
||
if handler:
|
||
await handler()
|
||
else:
|
||
from .constants import ERROR_MESSAGES
|
||
|
||
await message.bot.send_message(
|
||
message.chat.id, ERROR_MESSAGES["UNSUPPORTED_CONTENT"]
|
||
)
|
||
|
||
|
||
class StickerService:
|
||
"""Service for sticker-related operations"""
|
||
|
||
def __init__(self, settings: BotSettings) -> None:
|
||
self.settings = settings
|
||
|
||
@track_time("send_random_hello_sticker", "sticker_service")
|
||
@track_errors("sticker_service", "send_random_hello_sticker")
|
||
@track_file_operations("sticker")
|
||
async def send_random_hello_sticker(self, message: types.Message) -> None:
|
||
"""Send random hello sticker with metrics tracking"""
|
||
name_stick_hello = list(Path("Stick").rglob("Hello_*"))
|
||
if not name_stick_hello:
|
||
return
|
||
random_stick_hello = random.choice(name_stick_hello)
|
||
random_stick_hello = FSInputFile(path=random_stick_hello)
|
||
await message.answer_sticker(random_stick_hello)
|
||
await asyncio.sleep(0.3)
|
||
|
||
@track_time("send_random_goodbye_sticker", "sticker_service")
|
||
@track_errors("sticker_service", "send_random_goodbye_sticker")
|
||
@track_file_operations("sticker")
|
||
async def send_random_goodbye_sticker(self, message: types.Message) -> None:
|
||
"""Send random goodbye sticker with metrics tracking"""
|
||
name_stick_bye = list(Path("Stick").rglob("Universal_*"))
|
||
if not name_stick_bye:
|
||
return
|
||
random_stick_bye = random.choice(name_stick_bye)
|
||
random_stick_bye = FSInputFile(path=random_stick_bye)
|
||
await message.answer_sticker(random_stick_bye)
|