From eb2f7d003261cf19ccaf146d5714a6bbbaebb465 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 21:04:01 +0300 Subject: [PATCH 01/13] refactor: format code for consistency and clarity across multiple files --- bot/filters/__init__.py | 4 +- bot/filters/language.py | 9 +-- bot/filters/not_subbed.py | 23 +++--- bot/handlers/__init__.py | 12 +-- bot/handlers/faq.py | 7 +- bot/handlers/get_track.py | 22 +++--- bot/handlers/language.py | 17 +++-- bot/handlers/menu.py | 10 ++- bot/handlers/pages.py | 14 ++-- bot/handlers/search.py | 31 ++++---- bot/handlers/subscribe.py | 13 +++- bot/keyboards/command.py | 6 +- bot/keyboards/inline.py | 119 +++++++++++++++-------------- bot/middlewares/__init__.py | 2 + bot/middlewares/auth_middleware.py | 29 ++++--- bot/middlewares/i18n_middleware.py | 4 +- bot/utils.py | 1 + configs.py | 17 +++-- database/crud.py | 26 +++---- database/engine.py | 6 +- database/models/__init__.py | 7 +- database/models/required_subs.py | 4 +- database/models/search_history.py | 14 +++- database/models/user.py | 7 +- locales/__init__.py | 4 +- locales/_support_languages.py | 8 +- main.py | 35 ++++----- service/__init__.py | 3 +- service/core.py | 30 ++++---- service/data.py | 7 +- service/exceptions.py | 2 +- 31 files changed, 265 insertions(+), 228 deletions(-) diff --git a/bot/filters/__init__.py b/bot/filters/__init__.py index 9a7b82c..a07722b 100644 --- a/bot/filters/__init__.py +++ b/bot/filters/__init__.py @@ -1,6 +1,6 @@ """Filters for the bot.""" + from .language import LanguageFilter from .not_subbed import NotSubbedFilter - -__all__ = ['LanguageFilter', 'NotSubbedFilter'] +__all__ = ["LanguageFilter", "NotSubbedFilter"] diff --git a/bot/filters/language.py b/bot/filters/language.py index 373a20a..bbab96b 100644 --- a/bot/filters/language.py +++ b/bot/filters/language.py @@ -2,19 +2,18 @@ from aiogram import types from aiogram.filters import Filter + from database.models import User from locales import support_languages class LanguageFilter(Filter): - """ - A filter that checks if a user's language matches the specified language. + """A filter that checks if a user's language matches the specified language. """ async def __call__( - self, update: types.Message | types.CallbackQuery, user: User + self, update: types.Message | types.CallbackQuery, user: User, ) -> bool: - """ - Check if the user's language matches the specified language. + """Check if the user's language matches the specified language. """ return not support_languages.is_supported(user.language_code) diff --git a/bot/filters/not_subbed.py b/bot/filters/not_subbed.py index bd9d70d..06fc7e7 100644 --- a/bot/filters/not_subbed.py +++ b/bot/filters/not_subbed.py @@ -1,18 +1,19 @@ """Check if user is subbed to the channel.""" + import logging -from aiogram import types -from aiogram.filters import Filter -from aiogram import Bot + +from aiogram import Bot, types from aiogram.enums import ChatMemberStatus -from database.models import RequiredSubscriptions, User +from aiogram.filters import Filter + from database.crud import CRUD +from database.models import RequiredSubscriptions, User logger = logging.getLogger(__name__) class NotSubbedFilter(Filter): - """ - A filter that checks if a user is subbed to the channel. + """A filter that checks if a user is subbed to the channel. """ ALLOWED_STATUSES = { @@ -22,10 +23,9 @@ class NotSubbedFilter(Filter): } async def __call__( - self, update: types.Message | types.CallbackQuery, user: User, bot: Bot + self, update: types.Message | types.CallbackQuery, user: User, bot: Bot, ) -> bool: - """ - Check if the user is not subscribed to any required channels. + """Check if the user is not subscribed to any required channels. Returns True if user is NOT subscribed to ANY required channel. Returns False if user is subscribed to ALL required channels. """ @@ -40,10 +40,9 @@ async def __call__( return False async def _not_subscribe( - self, sub: RequiredSubscriptions, user: User, bot: Bot + self, sub: RequiredSubscriptions, user: User, bot: Bot, ) -> bool: - """ - Check if the user is subscribed to the channel. + """Check if the user is subscribed to the channel. Returns True if user is NOT subscribed. Returns False if user is subscribed or if channel is not accessible. """ diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index 82d0393..8ecd93b 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -1,14 +1,8 @@ """Setup router for the bot.""" + from aiogram import Dispatcher, Router -from . import ( - get_track, - language, - menu, - faq, - search, - pages, - subscribe -) + +from . import faq, get_track, language, menu, pages, search, subscribe def setup(dp: Dispatcher) -> None: diff --git a/bot/handlers/faq.py b/bot/handlers/faq.py index 3baa00e..2a04750 100644 --- a/bot/handlers/faq.py +++ b/bot/handlers/faq.py @@ -1,7 +1,10 @@ """FAQ handler for the bot.""" + import logging -from aiogram import types, Router, F + +from aiogram import F, Router, types from aiogram.utils.i18n import gettext + from bot.keyboards import inline logger = logging.getLogger(__name__) @@ -12,7 +15,7 @@ async def faq_handler(callback: types.CallbackQuery) -> None: try: await callback.message.edit_text( gettext("faq"), - reply_markup=inline.get_back_keyboard(gettext, "menu") + reply_markup=inline.get_back_keyboard(gettext, "menu"), ) except Exception as e: logger.error("Failed to send message: %s", e) diff --git a/bot/handlers/get_track.py b/bot/handlers/get_track.py index b70b7cd..960c8bf 100644 --- a/bot/handlers/get_track.py +++ b/bot/handlers/get_track.py @@ -1,19 +1,19 @@ """Get track handler for the bot.""" + import logging -from aiogram import types, Router, F, Bot + +from aiogram import Bot, F, Router, types from aiogram.types import BufferedInputFile from aiogram.utils.i18n import gettext -from service import Music, Track -from bot.utils import load_tracks_from_db +from bot.utils import load_tracks_from_db +from service import Music, Track logger = logging.getLogger(__name__) async def send_track( - callback: types.CallbackQuery, - bot: Bot, - track: Track + callback: types.CallbackQuery, bot: Bot, track: Track, ) -> None: """Send track.""" try: @@ -25,7 +25,7 @@ async def send_track( audio_file = BufferedInputFile(audio_bytes, filename=track.name) thumbnail_file = BufferedInputFile( - thumbnail_bytes, filename=track.name + thumbnail_bytes, filename=track.name, ) await callback.message.answer_audio( @@ -54,13 +54,13 @@ async def get_track_handler(callback: types.CallbackQuery, bot: Bot) -> None: async def get_all_from_page_handler( - callback: types.CallbackQuery, bot: Bot + callback: types.CallbackQuery, bot: Bot, ) -> None: """Get all tracks from page handler.""" try: _, _, search_id, start_indx, end_indx = callback.data.split(":") all_tracks: list[Track] = await load_tracks_from_db(search_id) - page_tracks = all_tracks[int(start_indx):int(end_indx)] + page_tracks = all_tracks[int(start_indx) : int(end_indx)] await callback.answer(gettext("track_sending")) for track in page_tracks: await send_track(callback, bot, track) @@ -72,8 +72,8 @@ async def get_all_from_page_handler( def register(router: Router) -> None: """Registers get track handler with the router.""" router.callback_query.register( - get_track_handler, F.data.startswith("track:get:") + get_track_handler, F.data.startswith("track:get:"), ) router.callback_query.register( - get_all_from_page_handler, F.data.startswith("track:all:") + get_all_from_page_handler, F.data.startswith("track:all:"), ) diff --git a/bot/handlers/language.py b/bot/handlers/language.py index fe9522e..9f2fa0a 100644 --- a/bot/handlers/language.py +++ b/bot/handlers/language.py @@ -1,17 +1,20 @@ """Language handler for the bot.""" + import logging from typing import Union -from aiogram import types, Router, Bot, F + +from aiogram import Bot, F, Router, types +from aiogram.filters import Command from aiogram.utils import i18n from aiogram.utils.i18n import gettext -from aiogram.filters import Command + from bot.filters import LanguageFilter -from bot.keyboards import inline, command +from bot.keyboards import command, inline from database.crud import CRUD from database.models import User from locales import support_languages -from .menu import menu_handler +from .menu import menu_handler logger = logging.getLogger(__name__) @@ -38,9 +41,7 @@ async def language_handler( async def language_set_handler( - callback: types.CallbackQuery, - user: User, - bot: Bot + callback: types.CallbackQuery, user: User, bot: Bot, ) -> None: """Language set handler.""" try: @@ -62,5 +63,5 @@ def register(router: Router) -> None: router.message.register(language_handler, Command("language")) router.callback_query.register(language_handler, F.data == "language") router.callback_query.register( - language_set_handler, F.data.startswith("language:set:") + language_set_handler, F.data.startswith("language:set:"), ) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 0992752..6617f7b 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -1,17 +1,19 @@ """Menu handler for the bot.""" + import logging from typing import Union -from aiogram import types, Router, F -from aiogram.filters import CommandStart, Command + +from aiogram import F, Router, types +from aiogram.filters import Command, CommandStart from aiogram.utils.i18n import gettext -from bot.keyboards import inline +from bot.keyboards import inline logger = logging.getLogger(__name__) async def menu_handler( - event: Union[types.Message, types.CallbackQuery] + event: Union[types.Message, types.CallbackQuery], ) -> None: """Menu handler.""" try: diff --git a/bot/handlers/pages.py b/bot/handlers/pages.py index 31d6c4c..74baa3f 100644 --- a/bot/handlers/pages.py +++ b/bot/handlers/pages.py @@ -1,10 +1,12 @@ """Pages handler for the bot.""" + import logging -from aiogram import types, Router, F -from service.data import Track + +from aiogram import F, Router, types + from bot.keyboards import inline from bot.utils import load_tracks_from_db - +from service.data import Track logger = logging.getLogger(__name__) @@ -17,8 +19,8 @@ async def pages_handler(callback: types.CallbackQuery) -> None: await callback.message.edit_reply_markup( reply_markup=inline.get_keyboard_of_tracks( - tracks, search_id, int(page) - ) + tracks, search_id, int(page), + ), ) except Exception as e: logger.error("Failed to send message: %s", e) @@ -27,5 +29,5 @@ async def pages_handler(callback: types.CallbackQuery) -> None: def register(router: Router) -> None: """Registers pages handler with the router.""" router.callback_query.register( - pages_handler, F.data.startswith("track:page") + pages_handler, F.data.startswith("track:page"), ) diff --git a/bot/handlers/search.py b/bot/handlers/search.py index e6fa900..c7b182e 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -1,18 +1,20 @@ """Search handler for the bot.""" + import logging -from aiogram import types, Router, F + +from aiogram import F, Router, types from aiogram.utils.i18n import gettext -from service import Music, Track + +from bot.keyboards import inline from database.crud import CRUD from database.models import SearchHistory, User -from bot.keyboards import inline - +from service import Music, Track logger = logging.getLogger(__name__) async def update_search( - user: User, keyword: str, tracks: list[Track] + user: User, keyword: str, tracks: list[Track], ) -> SearchHistory: """Updates the user in the database.""" user_crud = CRUD(User) @@ -22,7 +24,7 @@ async def update_search( search = await search_history_crud.create( user_id=user.id, keyword=keyword, - tracks=[track.__dict__ for track in tracks] + tracks=[track.__dict__ for track in tracks], ) return search @@ -34,9 +36,9 @@ async def search_handler(message: types.Message, user: User) -> None: if not keyword or len(keyword) > 100: await message.answer(gettext("search_query_error")) return - + search_message = await message.answer( - gettext("searching").format(keyword=keyword) + gettext("searching").format(keyword=keyword), ) async with Music() as service: tracks = await service.search(keyword) @@ -44,7 +46,7 @@ async def search_handler(message: types.Message, user: User) -> None: search = await update_search(user, keyword, tracks) await search_message.edit_text( gettext("search_result").format(keyword=keyword), - reply_markup=inline.get_keyboard_of_tracks(tracks, search.id) + reply_markup=inline.get_keyboard_of_tracks(tracks, search.id), ) except Exception as e: logger.error("Failed to send message: %s", e) @@ -55,14 +57,13 @@ async def get_track_list(list_type: str) -> list[Track]: async with Music() as service: map_list_type = { "top_hits": service.get_top_hits, - "new_hits": service.get_new_hits + "new_hits": service.get_new_hits, } return await map_list_type[list_type]() async def track_lists_handler( - callback: types.CallbackQuery, - user: User + callback: types.CallbackQuery, user: User, ) -> None: """Handles the track lists.""" _, _, list_type = callback.data.split(":") @@ -70,9 +71,7 @@ async def track_lists_handler( search = await update_search(user, list_type, tracks) await callback.message.answer( gettext(list_type), - reply_markup=inline.get_keyboard_of_tracks( - tracks, search.id - ) + reply_markup=inline.get_keyboard_of_tracks(tracks, search.id), ) @@ -80,5 +79,5 @@ def register(router: Router) -> None: """Registers search handler with the router.""" router.message.register(search_handler) router.callback_query.register( - track_lists_handler, F.data.startswith("track:list:") + track_lists_handler, F.data.startswith("track:list:"), ) diff --git a/bot/handlers/subscribe.py b/bot/handlers/subscribe.py index 1e00c78..5922484 100644 --- a/bot/handlers/subscribe.py +++ b/bot/handlers/subscribe.py @@ -1,11 +1,14 @@ """Subscription required handler for the bot.""" + import logging -from aiogram import types, Router, F, Bot + +from aiogram import Bot, F, Router, types from aiogram.utils.i18n import gettext -from bot.keyboards import inline + from bot.filters import NotSubbedFilter -from database.models import RequiredSubscriptions, User +from bot.keyboards import inline from database.crud import CRUD +from database.models import RequiredSubscriptions, User logger = logging.getLogger(__name__) @@ -28,7 +31,9 @@ async def sub_required_handler( async def sub_check_handler( - callback: types.CallbackQuery, user: User, bot: Bot, + callback: types.CallbackQuery, + user: User, + bot: Bot, ) -> None: """Subscription check handler.""" sub_check = NotSubbedFilter() diff --git a/bot/keyboards/command.py b/bot/keyboards/command.py index 19b22ab..888b01c 100644 --- a/bot/keyboards/command.py +++ b/bot/keyboards/command.py @@ -1,5 +1,7 @@ """Command keyboard for the bot.""" + from typing import Callable + from aiogram.types import BotCommand @@ -7,5 +9,7 @@ def get_commands(gettext: Callable[[str], str]) -> list[BotCommand]: """Get commands for the bot.""" return [ BotCommand(command="menu", description=gettext("menu_command")), - BotCommand(command="language", description=gettext("language_command")) + BotCommand( + command="language", description=gettext("language_command"), + ), ] diff --git a/bot/keyboards/inline.py b/bot/keyboards/inline.py index fbc05e7..b6d8bb4 100644 --- a/bot/keyboards/inline.py +++ b/bot/keyboards/inline.py @@ -1,16 +1,16 @@ """Inline keyboard templates.""" + from typing import Callable -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from service.data import Track -from locales import support_languages +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + from database.models import RequiredSubscriptions +from locales import support_languages +from service.data import Track def get_keyboard_of_tracks( - tracks: list[Track], - search_id: int, - page: int = 0 + tracks: list[Track], search_id: int, page: int = 0, ) -> InlineKeyboardMarkup: """Create paginated inline keyboard for track selection.""" TRACKS_PER_PAGE = 10 @@ -26,42 +26,52 @@ def get_keyboard_of_tracks( [ InlineKeyboardButton( text=f"{track.performer} - {track.title}", - callback_data=f"track:get:{search_id}:{track.index}" - ) - ] for track in current_page + callback_data=f"track:get:{search_id}:{track.index}", + ), + ] + for track in current_page ] if total_pages > 0: + def create_navigation_button(is_next: bool) -> InlineKeyboardButton: """Create navigation button (prev/next) based on current page.""" is_available = page < total_pages if is_next else page > 0 return InlineKeyboardButton( text="▶️" - if is_next and is_available else "⏺️" - if is_next and not is_available else "◀️" - if not is_next and is_available else "⏺️", + if is_next and is_available + else "⏺️" + if is_next and not is_available + else "◀️" + if not is_next and is_available + else "⏺️", callback_data=( f"track:page:{search_id}:" f"{page + 1 if is_next else page - 1}" - if is_available else "track:noop" - ) + if is_available + else "track:noop" + ), ) - keyboard.append([ - create_navigation_button(is_next=False), - InlineKeyboardButton( - text=f"{page + 1}/{total_pages + 1}", - callback_data="track:noop" - ), - create_navigation_button(is_next=True), - ]) - keyboard.append([ - InlineKeyboardButton( - text="🔽", - callback_data=f"track:all:{search_id}:{start_indx}:{end_indx}" - ) - ]) + keyboard.append( + [ + create_navigation_button(is_next=False), + InlineKeyboardButton( + text=f"{page + 1}/{total_pages + 1}", + callback_data="track:noop", + ), + create_navigation_button(is_next=True), + ], + ) + keyboard.append( + [ + InlineKeyboardButton( + text="🔽", + callback_data=f"track:all:{search_id}:{start_indx}:{end_indx}", + ), + ], + ) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -70,10 +80,11 @@ def create_navigation_button(is_next: bool) -> InlineKeyboardButton: [ InlineKeyboardButton( text=language.name, - callback_data=f"language:set:{language.code}" - ) for language in support_languages.languages - ] - ] + callback_data=f"language:set:{language.code}", + ) + for language in support_languages.languages + ], + ], ) @@ -84,22 +95,22 @@ def get_menu_keyboard(gettext: Callable[[str], str]) -> InlineKeyboardMarkup: [ InlineKeyboardButton( text=gettext("top_hits_button"), - callback_data="track:list:top_hits" + callback_data="track:list:top_hits", ), InlineKeyboardButton( text=gettext("new_hits_button"), - callback_data="track:list:new_hits" - ) + callback_data="track:list:new_hits", + ), ], [ InlineKeyboardButton( - text=gettext("faq_button"), callback_data="faq" + text=gettext("faq_button"), callback_data="faq", ), InlineKeyboardButton( - text=gettext("language_button"), callback_data="language" - ) + text=gettext("language_button"), callback_data="language", + ), ], - ] + ], ) @@ -109,33 +120,29 @@ def get_subscribe_keyboard( ) -> InlineKeyboardMarkup: """Get subscribe keyboard.""" chats = [ - [ - InlineKeyboardButton( - text=f"➕ {sub.chat_title}", url=sub.chat_link - ) - ] + [InlineKeyboardButton(text=f"➕ {sub.chat_title}", url=sub.chat_link)] for sub in sub_required ] - chats.append([ - InlineKeyboardButton( - text=gettext("sub_check_button"), callback_data="sub_check" - ) - ]) - return InlineKeyboardMarkup( - inline_keyboard=chats + chats.append( + [ + InlineKeyboardButton( + text=gettext("sub_check_button"), callback_data="sub_check", + ), + ], ) + return InlineKeyboardMarkup(inline_keyboard=chats) def get_back_keyboard( - gettext: Callable[[str], str], callback_data: str + gettext: Callable[[str], str], callback_data: str, ) -> InlineKeyboardMarkup: """Get back keyboard.""" return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=gettext("back_button"), callback_data=callback_data - ) - ] - ] + text=gettext("back_button"), callback_data=callback_data, + ), + ], + ], ) diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py index bc2ee51..31ad444 100644 --- a/bot/middlewares/__init__.py +++ b/bot/middlewares/__init__.py @@ -1,6 +1,8 @@ """Middlewares for the bot.""" + from aiogram import Dispatcher from aiogram.utils.i18n import I18n + from .auth_middleware import AuthMiddleware from .i18n_middleware import I18nMiddleware diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index d271cfb..8bc176e 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -1,6 +1,8 @@ """Auth middleware module for the bot.""" + import logging -from typing import Any, Awaitable, Callable, Dict +from collections.abc import Awaitable +from typing import Any, Callable, Dict from aiogram import BaseMiddleware from aiogram.types import Update @@ -9,7 +11,6 @@ from database.crud import CRUD from database.models import User - logger = logging.getLogger(__name__) @@ -20,10 +21,10 @@ async def __call__( self, handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], event: Update, - data: Dict[str, Any] + data: Dict[str, Any], ) -> Any: """Intercepts incoming updates, processes them and calls the next.""" - data['user'] = await self.ensure_user_in_db(data['event_from_user']) + data["user"] = await self.ensure_user_in_db(data["event_from_user"]) return await handler(event, data) async def ensure_user_in_db(self, user: User) -> User: @@ -32,9 +33,7 @@ async def ensure_user_in_db(self, user: User) -> User: user_data = self._prepare_user_data(user) try: - return await self._get_or_create_user( - user, user_crud, user_data - ) + return await self._get_or_create_user(user, user_crud, user_data) except Exception as e: logger.error("Failed to process user %s: %s", user.id, str(e)) @@ -48,28 +47,26 @@ def _get_user_crud(self) -> CRUD: def _prepare_user_data(user: User) -> Dict[str, Any]: """Prepares user data for database operations.""" return { - 'id': user.id, - 'username': user.username, - 'first_name': user.first_name, - 'last_name': user.last_name, + "id": user.id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, } @staticmethod async def _get_or_create_user( - user: User, - user_crud: CRUD, - user_data: Dict[str, Any] + user: User, user_crud: CRUD, user_data: Dict[str, Any], ) -> User: """Gets existing user or creates new one.""" db_user = await user_crud.get(id=user.id) if not db_user: - user_data['language_code'] = user.language_code + user_data["language_code"] = user.language_code db_user = await user_crud.create(**user_data) logger.info("User %s registered in the database.", user.id) else: - user_data['updated_at'] = func.now() + user_data["updated_at"] = func.now() db_user = await user_crud.update(db_user, **user_data) logger.info("User %s updated in the database.", user.id) diff --git a/bot/middlewares/i18n_middleware.py b/bot/middlewares/i18n_middleware.py index 41e6b8a..946854e 100644 --- a/bot/middlewares/i18n_middleware.py +++ b/bot/middlewares/i18n_middleware.py @@ -1,6 +1,8 @@ """Custom i18n middleware (language selection).""" + from aiogram.types import Message from aiogram.utils.i18n.middleware import I18nMiddleware as BaseI18nMiddleware + from database.models import User @@ -9,5 +11,5 @@ class I18nMiddleware(BaseI18nMiddleware): async def get_locale(self, event: Message, data: dict) -> str: """Get user locale.""" - user: User = data['user'] + user: User = data["user"] return user.language_code diff --git a/bot/utils.py b/bot/utils.py index e636bb2..9abd1c5 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -1,4 +1,5 @@ """Utils for the bot.""" + from database.crud import CRUD from database.models import SearchHistory from service.data import Track diff --git a/configs.py b/configs.py index 78ae17c..3422125 100644 --- a/configs.py +++ b/configs.py @@ -1,26 +1,29 @@ """Configurations for the app.""" + import os import time from dataclasses import dataclass -os.environ['TZ'] = os.getenv('TIMEZONE', 'UTC') +os.environ["TZ"] = os.getenv("TIMEZONE", "UTC") time.tzset() @dataclass class BotConfig: """Configuration class for the bot.""" - token: str = os.getenv('BOT_TOKEN') + + token: str = os.getenv("BOT_TOKEN") @dataclass class DBConfig: """Configuration class for the database.""" - host: str = os.getenv('POSTGRES_HOST', 'db') - port: str = os.getenv('POSTGRES_PORT', '5432') - user: str = os.getenv('POSTGRES_USER') - password: str = os.getenv('POSTGRES_PASSWORD') - db: str = os.getenv('POSTGRES_DB') + + host: str = os.getenv("POSTGRES_HOST", "db") + port: str = os.getenv("POSTGRES_PORT", "5432") + user: str = os.getenv("POSTGRES_USER") + password: str = os.getenv("POSTGRES_PASSWORD") + db: str = os.getenv("POSTGRES_DB") @property def url(self) -> str: diff --git a/database/crud.py b/database/crud.py index e01eb4b..256acd1 100644 --- a/database/crud.py +++ b/database/crud.py @@ -1,16 +1,18 @@ """CRUD operations.""" + import logging -from typing import AsyncGenerator, Type, TypeVar +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from typing import Type, TypeVar +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker -from sqlalchemy import select -from .engine import async_session_factory +from .engine import async_session_factory -T = TypeVar('T') +T = TypeVar("T") logger = logging.getLogger(__name__) @@ -21,7 +23,7 @@ class CRUD: def __init__( self, model: Type[T], - session_factory: sessionmaker = async_session_factory + session_factory: sessionmaker = async_session_factory, ) -> None: """Initialize the CRUD class.""" self.model = model @@ -51,16 +53,14 @@ async def create(self, **kwargs) -> T: return instance except SQLAlchemyError as e: await session.rollback() - logger.error( - f"Failed to create {self.model.__name__}: {e}" - ) + logger.error(f"Failed to create {self.model.__name__}: {e}") raise async def get(self, **kwargs) -> T: """Retrieve a record by any field.""" async with self.get_session() as session: query = await session.execute( - select(self.model).filter_by(**kwargs) + select(self.model).filter_by(**kwargs), ) instance = query.scalar_one_or_none() return instance @@ -83,9 +83,7 @@ async def update(self, instance: T, **kwargs) -> T: return instance except SQLAlchemyError as e: await session.rollback() - logger.error( - f"Failed to update {self.model.__name__}: {e}" - ) + logger.error(f"Failed to update {self.model.__name__}: {e}") raise async def delete(self, instance: T) -> bool: @@ -98,7 +96,5 @@ async def delete(self, instance: T) -> bool: return True except SQLAlchemyError as e: await session.rollback() - logger.error( - f"Failed to delete {self.model.__name__}: {e}" - ) + logger.error(f"Failed to delete {self.model.__name__}: {e}") raise diff --git a/database/engine.py b/database/engine.py index ef63bcd..2c89528 100644 --- a/database/engine.py +++ b/database/engine.py @@ -1,10 +1,12 @@ """Database engine.""" + import logging + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker -from configs import db_config +from configs import db_config logger = logging.getLogger(__name__) diff --git a/database/models/__init__.py b/database/models/__init__.py index 6e9562f..c8a6984 100644 --- a/database/models/__init__.py +++ b/database/models/__init__.py @@ -1,6 +1,7 @@ """Database models.""" -from .user import User -from .search_history import SearchHistory + from .required_subs import RequiredSubscriptions +from .search_history import SearchHistory +from .user import User -__all__ = ['User', 'SearchHistory', 'RequiredSubscriptions'] +__all__ = ["RequiredSubscriptions", "SearchHistory", "User"] diff --git a/database/models/required_subs.py b/database/models/required_subs.py index 483b371..11bbfa0 100644 --- a/database/models/required_subs.py +++ b/database/models/required_subs.py @@ -1,5 +1,7 @@ """Required subscriptions database model.""" -from sqlalchemy import Column, String, DateTime, Integer, BigInteger, func + +from sqlalchemy import BigInteger, Column, DateTime, Integer, String, func + from ..engine import Base diff --git a/database/models/search_history.py b/database/models/search_history.py index f96b2fa..0d1fa55 100644 --- a/database/models/search_history.py +++ b/database/models/search_history.py @@ -1,8 +1,16 @@ """Search history database model.""" -from sqlalchemy.dialects.postgresql import JSONB + from sqlalchemy import ( - Column, String, DateTime, Integer, BigInteger, ForeignKey, func + BigInteger, + Column, + DateTime, + ForeignKey, + Integer, + String, + func, ) +from sqlalchemy.dialects.postgresql import JSONB + from ..engine import Base @@ -12,7 +20,7 @@ class SearchHistory(Base): __tablename__ = "search_history" id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(BigInteger, ForeignKey('users.id')) + user_id = Column(BigInteger, ForeignKey("users.id")) keyword = Column(String, default=None) tracks = Column(JSONB, default=[]) created_at = Column(DateTime, default=func.now()) diff --git a/database/models/user.py b/database/models/user.py index dd860c7..a4d6db4 100644 --- a/database/models/user.py +++ b/database/models/user.py @@ -1,11 +1,14 @@ """User database model.""" -from sqlalchemy import Column, String, BigInteger, DateTime, Integer, func + +from sqlalchemy import BigInteger, Column, DateTime, Integer, String, func + from ..engine import Base class User(Base): """User model.""" - __tablename__ = 'users' + + __tablename__ = "users" id = Column(BigInteger, primary_key=True, autoincrement=True) username = Column(String, default=None) diff --git a/locales/__init__.py b/locales/__init__.py index 0d7a1dd..71face3 100644 --- a/locales/__init__.py +++ b/locales/__init__.py @@ -1,5 +1,5 @@ """Locales module.""" -from ._support_languages import support_languages +from ._support_languages import support_languages -__all__ = ['support_languages'] +__all__ = ["support_languages"] diff --git a/locales/_support_languages.py b/locales/_support_languages.py index 2f32bcc..6f6dfe9 100644 --- a/locales/_support_languages.py +++ b/locales/_support_languages.py @@ -1,10 +1,12 @@ """Supported languages.""" + from dataclasses import dataclass @dataclass class Language: """Supported language.""" + code: str name: str @@ -12,6 +14,7 @@ class Language: @dataclass class LanguageList: """Supported languages.""" + languages: list[Language] def is_supported(self, code: str) -> bool: @@ -20,8 +23,5 @@ def is_supported(self, code: str) -> bool: support_languages: LanguageList = LanguageList( - languages=[ - Language('en', '🇬🇧 English'), - Language('ru', '🇷🇺 Русский') - ] + languages=[Language("en", "🇬🇧 English"), Language("ru", "🇷🇺 Русский")], ) diff --git a/main.py b/main.py index f71e921..da684ed 100644 --- a/main.py +++ b/main.py @@ -1,39 +1,39 @@ """Entry point of the bot application.""" -import logging + import asyncio +import logging from aiogram import Bot, Dispatcher from aiogram.client.bot import DefaultBotProperties -from aiogram.utils.token import TokenValidationError -from aiogram.utils.i18n import I18n from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.utils.i18n import I18n +from aiogram.utils.token import TokenValidationError from bot import handlers, middlewares -from database.engine import init_db from configs import bot_config +from database.engine import init_db logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s:%(name)s - %(message)s' + format="%(asctime)s - %(levelname)s:%(name)s - %(message)s", ) logger = logging.getLogger(__name__) async def create_bot() -> Bot: - """ - Create and return a Bot instance.""" + """Create and return a Bot instance.""" try: bot = Bot( token=bot_config.token, - default=DefaultBotProperties(parse_mode='HTML') + default=DefaultBotProperties(parse_mode="HTML"), ) - logger.info('Successfully created bot instance.') + logger.info("Successfully created bot instance.") return bot except TokenValidationError as e: - logger.error('Invalid token provided: %s', bot_config.token) + logger.error("Invalid token provided: %s", bot_config.token) raise e except Exception as e: - logger.error('Failed to create bot instance: %s', str(e)) + logger.error("Failed to create bot instance: %s", str(e)) raise e @@ -43,18 +43,19 @@ async def main() -> None: storage = MemoryStorage() dp = Dispatcher(storage=storage) - i18n = I18n(path='locales', domain='messages') - logger.info('Successfully created dispatcher, i18n and storage instance.') + i18n = I18n(path="locales", domain="messages") + logger.info("Successfully created dispatcher, i18n and storage instance.") middlewares.setup(dp, i18n) - logger.info('Successfully set up middleware.') + logger.info("Successfully set up middleware.") handlers.setup(dp) - logger.info('Successfully set up handlers.') + logger.info("Successfully set up handlers.") await init_db() await dp.start_polling(bot) -if __name__ == '__main__': - logger.info('Starting application...') + +if __name__ == "__main__": + logger.info("Starting application...") asyncio.run(main()) diff --git a/service/__init__.py b/service/__init__.py index f629b2a..d45606f 100644 --- a/service/__init__.py +++ b/service/__init__.py @@ -1,5 +1,6 @@ """Service module.""" + from .core import Music from .data import Track -__all__ = ['Music', 'Track'] +__all__ = ["Music", "Track"] diff --git a/service/core.py b/service/core.py index ece45ae..99598f2 100644 --- a/service/core.py +++ b/service/core.py @@ -1,4 +1,5 @@ """Music service core module for downloading and searching music.""" + import logging import urllib.parse from typing import Optional @@ -6,7 +7,7 @@ import aiohttp from bs4 import BeautifulSoup -from .data import Track, ServiceConfig +from .data import ServiceConfig, Track from .exceptions import MusicServiceError logger = logging.getLogger(__name__) @@ -14,6 +15,7 @@ class Music: """Service for searching and downloading music.""" + BASE_URL = "https://mp3wr.com" TRACK_DOWNLOAD_URL = "https://cdn.mp3wr.com" @@ -22,7 +24,7 @@ def __init__(self, config: Optional[ServiceConfig] = None) -> None: self._config = config or ServiceConfig() self._session: Optional[aiohttp.ClientSession] = None - async def __aenter__(self) -> 'Music': + async def __aenter__(self) -> "Music": """Context manager entry point.""" await self.connect() return self @@ -34,9 +36,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: async def connect(self) -> None: """Initialize HTTP session.""" if self._session is None: - self._session = aiohttp.ClientSession( - headers=self._config.headers - ) + self._session = aiohttp.ClientSession(headers=self._config.headers) async def disconnect(self) -> None: """Close HTTP session.""" @@ -65,20 +65,21 @@ async def get_new_hits(self) -> list[Track]: return await self._parse_tracks(url) async def _parse_tracks( - self, url: str, is_search: bool = False + self, url: str, is_search: bool = False, ) -> list[Track]: """Parse tracks from the given URL.""" try: async with self._session.get( - url, timeout=self._config.timeout + url, timeout=self._config.timeout, ) as response: response.raise_for_status() soup = BeautifulSoup(await response.text(), "html.parser") tracks = [ Track.from_element(track_data, index, is_search) for index, track_data in enumerate( - soup.find_all("item") if is_search - else soup.find_all("li", class_="sarki-liste") + soup.find_all("item") + if is_search + else soup.find_all("li", class_="sarki-liste"), ) ] @@ -86,10 +87,10 @@ async def _parse_tracks( return tracks except (aiohttp.ClientError, TimeoutError) as e: - raise MusicServiceError(f"Failed to search music: {str(e)}") from e + raise MusicServiceError(f"Failed to search music: {e!s}") from e async def _download_data( - self, url: str, resource_type: str, track_name: str + self, url: str, resource_type: str, track_name: str, ) -> bytes: """Generic method for downloading data.""" MAX_SIZE = 50 * 1024 * 1024 # 50MB @@ -101,21 +102,20 @@ async def _download_data( try: async with self._session.get( - url, - timeout=self._config.timeout + url, timeout=self._config.timeout, ) as response: response.raise_for_status() content_length = response.content_length if content_length and content_length > MAX_SIZE: raise MusicServiceError( - f"File too large: {content_length} bytes" + f"File too large: {content_length} bytes", ) return await response.read() except Exception as e: raise MusicServiceError( - f"Failed to download {resource_type}: {str(e)}" + f"Failed to download {resource_type}: {e!s}", ) from e async def get_audio_bytes(self, track: Track) -> bytes: diff --git a/service/data.py b/service/data.py index 63a603c..9c9342e 100644 --- a/service/data.py +++ b/service/data.py @@ -1,7 +1,9 @@ """Data classes""" + import json import os from dataclasses import dataclass, field + from bs4 import BeautifulSoup headers_path = os.path.join(os.path.dirname(__file__), "headers.json") @@ -10,9 +12,10 @@ @dataclass class ServiceConfig: """Configuration for music service.""" + timeout: int = 30 headers: dict = field( - default_factory=lambda: json.load(open(headers_path)) + default_factory=lambda: json.load(open(headers_path)), ) @@ -39,7 +42,7 @@ def from_element( performer, title = full_name.split(" - ", 1) audio_url = element.find(class_="right").get("data-id") thumbnail_element = element.find( - class_="little_thumb" if is_search else "resim_thumb" + class_="little_thumb" if is_search else "resim_thumb", ) thumbnail_url = thumbnail_element.find("img").get("data-src") return cls( diff --git a/service/exceptions.py b/service/exceptions.py index 6b1414d..0b98bb9 100644 --- a/service/exceptions.py +++ b/service/exceptions.py @@ -3,4 +3,4 @@ class MusicServiceError(Exception): """Base exception for music service errors.""" - pass + From 0974a537e01bc90048f84d9fe148e477aa5ebbbe Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 21:05:37 +0300 Subject: [PATCH 02/13] refactor: enhance error logging and code consistency across multiple files --- bot/filters/language.py | 16 ++++++++++------ bot/filters/not_subbed.py | 11 +++++++---- bot/handlers/faq.py | 2 +- bot/handlers/get_track.py | 6 +++--- bot/handlers/language.py | 8 ++++---- bot/handlers/menu.py | 6 +++--- bot/handlers/pages.py | 7 +++++-- bot/handlers/search.py | 5 ++--- bot/handlers/subscribe.py | 3 ++- bot/middlewares/__init__.py | 2 +- bot/middlewares/auth_middleware.py | 12 ++++++------ bot/middlewares/i18n_middleware.py | 5 ++++- database/crud.py | 15 +++++++-------- database/models/required_subs.py | 2 +- database/models/search_history.py | 2 +- database/models/user.py | 2 +- main.py | 10 +++++----- service/core.py | 18 +++++++++++------- service/data.py | 6 +++--- service/exceptions.py | 2 +- 20 files changed, 78 insertions(+), 62 deletions(-) diff --git a/bot/filters/language.py b/bot/filters/language.py index bbab96b..b3d52ba 100644 --- a/bot/filters/language.py +++ b/bot/filters/language.py @@ -1,19 +1,23 @@ """Check language filter.""" +from __future__ import annotations + +from typing import TYPE_CHECKING -from aiogram import types from aiogram.filters import Filter -from database.models import User from locales import support_languages +if TYPE_CHECKING: + from aiogram import types + + from database.models import User + class LanguageFilter(Filter): - """A filter that checks if a user's language matches the specified language. - """ + """A filter that checks if a user's language matches the specified language.""" async def __call__( self, update: types.Message | types.CallbackQuery, user: User, ) -> bool: - """Check if the user's language matches the specified language. - """ + """Check if the user's language matches the specified language.""" return not support_languages.is_supported(user.language_code) diff --git a/bot/filters/not_subbed.py b/bot/filters/not_subbed.py index 06fc7e7..eef8f1d 100644 --- a/bot/filters/not_subbed.py +++ b/bot/filters/not_subbed.py @@ -1,20 +1,23 @@ """Check if user is subbed to the channel.""" +from __future__ import annotations import logging +from typing import TYPE_CHECKING -from aiogram import Bot, types from aiogram.enums import ChatMemberStatus from aiogram.filters import Filter from database.crud import CRUD from database.models import RequiredSubscriptions, User +if TYPE_CHECKING: + from aiogram import Bot, types + logger = logging.getLogger(__name__) class NotSubbedFilter(Filter): - """A filter that checks if a user is subbed to the channel. - """ + """A filter that checks if a user is subbed to the channel.""" ALLOWED_STATUSES = { ChatMemberStatus.MEMBER, @@ -49,7 +52,7 @@ async def _not_subscribe( try: chat = await bot.get_chat(sub.chat_id) except Exception as e: - logger.error(f"Failed to get chat {sub.chat_id}: {e}") + logger.exception(f"Failed to get chat {sub.chat_id}: {e}") return False try: diff --git a/bot/handlers/faq.py b/bot/handlers/faq.py index 2a04750..2fd8a84 100644 --- a/bot/handlers/faq.py +++ b/bot/handlers/faq.py @@ -18,7 +18,7 @@ async def faq_handler(callback: types.CallbackQuery) -> None: reply_markup=inline.get_back_keyboard(gettext, "menu"), ) except Exception as e: - logger.error("Failed to send message: %s", e) + logger.exception("Failed to send message: %s", e) def register(router: Router) -> None: diff --git a/bot/handlers/get_track.py b/bot/handlers/get_track.py index 960c8bf..049ad08 100644 --- a/bot/handlers/get_track.py +++ b/bot/handlers/get_track.py @@ -37,7 +37,7 @@ async def send_track( ) except Exception as e: await callback.message.answer(gettext("send_track_error")) - logger.error("Failed to send track: %s", e) + logger.exception("Failed to send track: %s", e) async def get_track_handler(callback: types.CallbackQuery, bot: Bot) -> None: @@ -50,7 +50,7 @@ async def get_track_handler(callback: types.CallbackQuery, bot: Bot) -> None: await send_track(callback, bot, track) except Exception as e: - logger.error("Failed get track handler: %s", e) + logger.exception("Failed get track handler: %s", e) async def get_all_from_page_handler( @@ -66,7 +66,7 @@ async def get_all_from_page_handler( await send_track(callback, bot, track) except Exception as e: - logger.error("Failed get all from page handler: %s", e) + logger.exception("Failed get all from page handler: %s", e) def register(router: Router) -> None: diff --git a/bot/handlers/language.py b/bot/handlers/language.py index 9f2fa0a..41a2851 100644 --- a/bot/handlers/language.py +++ b/bot/handlers/language.py @@ -1,7 +1,7 @@ """Language handler for the bot.""" +from __future__ import annotations import logging -from typing import Union from aiogram import Bot, F, Router, types from aiogram.filters import Command @@ -20,7 +20,7 @@ async def language_handler( - event: Union[types.Message, types.CallbackQuery], + event: types.Message | types.CallbackQuery, user: User, ) -> None: """Language handler.""" @@ -37,7 +37,7 @@ async def language_handler( else: await event.answer(text, reply_markup=keyboard) except Exception as e: - logger.error("Failed to send message: %s", e) + logger.exception("Failed to send message: %s", e) async def language_set_handler( @@ -54,7 +54,7 @@ async def language_set_handler( await bot.set_my_commands(command.get_commands(gettext)) await menu_handler(callback) except Exception as e: - logger.error("Failed to set language: %s", e) + logger.exception("Failed to set language: %s", e) def register(router: Router) -> None: diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 6617f7b..9e9a4b9 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -1,7 +1,7 @@ """Menu handler for the bot.""" +from __future__ import annotations import logging -from typing import Union from aiogram import F, Router, types from aiogram.filters import Command, CommandStart @@ -13,7 +13,7 @@ async def menu_handler( - event: Union[types.Message, types.CallbackQuery], + event: types.Message | types.CallbackQuery, ) -> None: """Menu handler.""" try: @@ -25,7 +25,7 @@ async def menu_handler( else: await event.answer(text, reply_markup=keyboard) except Exception as e: - logger.error("Failed to handle menu event: %s", e) + logger.exception("Failed to handle menu event: %s", e) def register(router: Router) -> None: diff --git a/bot/handlers/pages.py b/bot/handlers/pages.py index 74baa3f..67fae85 100644 --- a/bot/handlers/pages.py +++ b/bot/handlers/pages.py @@ -1,12 +1,15 @@ """Pages handler for the bot.""" import logging +from typing import TYPE_CHECKING from aiogram import F, Router, types from bot.keyboards import inline from bot.utils import load_tracks_from_db -from service.data import Track + +if TYPE_CHECKING: + from service.data import Track logger = logging.getLogger(__name__) @@ -23,7 +26,7 @@ async def pages_handler(callback: types.CallbackQuery) -> None: ), ) except Exception as e: - logger.error("Failed to send message: %s", e) + logger.exception("Failed to send message: %s", e) def register(router: Router) -> None: diff --git a/bot/handlers/search.py b/bot/handlers/search.py index c7b182e..cecb8a8 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -21,12 +21,11 @@ async def update_search( await user_crud.update(user, search_queries=user.search_queries + 1) search_history_crud = CRUD(SearchHistory) - search = await search_history_crud.create( + return await search_history_crud.create( user_id=user.id, keyword=keyword, tracks=[track.__dict__ for track in tracks], ) - return search async def search_handler(message: types.Message, user: User) -> None: @@ -49,7 +48,7 @@ async def search_handler(message: types.Message, user: User) -> None: reply_markup=inline.get_keyboard_of_tracks(tracks, search.id), ) except Exception as e: - logger.error("Failed to send message: %s", e) + logger.exception("Failed to send message: %s", e) async def get_track_list(list_type: str) -> list[Track]: diff --git a/bot/handlers/subscribe.py b/bot/handlers/subscribe.py index 5922484..d7ec9de 100644 --- a/bot/handlers/subscribe.py +++ b/bot/handlers/subscribe.py @@ -1,4 +1,5 @@ """Subscription required handler for the bot.""" +from __future__ import annotations import logging @@ -27,7 +28,7 @@ async def sub_required_handler( else: await event.message.answer(text, reply_markup=keyboard) except Exception as e: - logger.error("Failed to send message: %s", e) + logger.exception("Failed to send message: %s", e) async def sub_check_handler( diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py index 31ad444..da81f07 100644 --- a/bot/middlewares/__init__.py +++ b/bot/middlewares/__init__.py @@ -8,6 +8,6 @@ def setup(dp: Dispatcher, i18n: I18n) -> None: - """Setup middleware""" + """Setup middleware.""" dp.update.outer_middleware(AuthMiddleware()) dp.update.outer_middleware(I18nMiddleware(i18n)) diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index 8bc176e..169f4da 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -2,7 +2,7 @@ import logging from collections.abc import Awaitable -from typing import Any, Callable, Dict +from typing import Any, Callable from aiogram import BaseMiddleware from aiogram.types import Update @@ -19,9 +19,9 @@ class AuthMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[Update, dict[str, Any]], Awaitable[Any]], event: Update, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: """Intercepts incoming updates, processes them and calls the next.""" data["user"] = await self.ensure_user_in_db(data["event_from_user"]) @@ -36,7 +36,7 @@ async def ensure_user_in_db(self, user: User) -> User: return await self._get_or_create_user(user, user_crud, user_data) except Exception as e: - logger.error("Failed to process user %s: %s", user.id, str(e)) + logger.exception("Failed to process user %s: %s", user.id, str(e)) raise def _get_user_crud(self) -> CRUD: @@ -44,7 +44,7 @@ def _get_user_crud(self) -> CRUD: return CRUD(User) @staticmethod - def _prepare_user_data(user: User) -> Dict[str, Any]: + def _prepare_user_data(user: User) -> dict[str, Any]: """Prepares user data for database operations.""" return { "id": user.id, @@ -55,7 +55,7 @@ def _prepare_user_data(user: User) -> Dict[str, Any]: @staticmethod async def _get_or_create_user( - user: User, user_crud: CRUD, user_data: Dict[str, Any], + user: User, user_crud: CRUD, user_data: dict[str, Any], ) -> User: """Gets existing user or creates new one.""" db_user = await user_crud.get(id=user.id) diff --git a/bot/middlewares/i18n_middleware.py b/bot/middlewares/i18n_middleware.py index 946854e..7474b93 100644 --- a/bot/middlewares/i18n_middleware.py +++ b/bot/middlewares/i18n_middleware.py @@ -1,9 +1,12 @@ """Custom i18n middleware (language selection).""" +from typing import TYPE_CHECKING + from aiogram.types import Message from aiogram.utils.i18n.middleware import I18nMiddleware as BaseI18nMiddleware -from database.models import User +if TYPE_CHECKING: + from database.models import User class I18nMiddleware(BaseI18nMiddleware): diff --git a/database/crud.py b/database/crud.py index 256acd1..f6d76fc 100644 --- a/database/crud.py +++ b/database/crud.py @@ -3,7 +3,7 @@ import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import Type, TypeVar +from typing import TypeVar from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError @@ -22,7 +22,7 @@ class CRUD: def __init__( self, - model: Type[T], + model: type[T], session_factory: sessionmaker = async_session_factory, ) -> None: """Initialize the CRUD class.""" @@ -37,7 +37,7 @@ async def get_session(self) -> AsyncGenerator[AsyncSession, None]: yield session except Exception as e: await session.rollback() - logger.error("Failed to get session: %s", e) + logger.exception("Failed to get session: %s", e) raise finally: await session.close() @@ -53,7 +53,7 @@ async def create(self, **kwargs) -> T: return instance except SQLAlchemyError as e: await session.rollback() - logger.error(f"Failed to create {self.model.__name__}: {e}") + logger.exception(f"Failed to create {self.model.__name__}: {e}") raise async def get(self, **kwargs) -> T: @@ -62,8 +62,7 @@ async def get(self, **kwargs) -> T: query = await session.execute( select(self.model).filter_by(**kwargs), ) - instance = query.scalar_one_or_none() - return instance + return query.scalar_one_or_none() async def get_all(self) -> list[T]: """Get all records.""" @@ -83,7 +82,7 @@ async def update(self, instance: T, **kwargs) -> T: return instance except SQLAlchemyError as e: await session.rollback() - logger.error(f"Failed to update {self.model.__name__}: {e}") + logger.exception(f"Failed to update {self.model.__name__}: {e}") raise async def delete(self, instance: T) -> bool: @@ -96,5 +95,5 @@ async def delete(self, instance: T) -> bool: return True except SQLAlchemyError as e: await session.rollback() - logger.error(f"Failed to delete {self.model.__name__}: {e}") + logger.exception(f"Failed to delete {self.model.__name__}: {e}") raise diff --git a/database/models/required_subs.py b/database/models/required_subs.py index 11bbfa0..f2e2c14 100644 --- a/database/models/required_subs.py +++ b/database/models/required_subs.py @@ -2,7 +2,7 @@ from sqlalchemy import BigInteger, Column, DateTime, Integer, String, func -from ..engine import Base +from database.engine import Base class RequiredSubscriptions(Base): diff --git a/database/models/search_history.py b/database/models/search_history.py index 0d1fa55..8ee3ed0 100644 --- a/database/models/search_history.py +++ b/database/models/search_history.py @@ -11,7 +11,7 @@ ) from sqlalchemy.dialects.postgresql import JSONB -from ..engine import Base +from database.engine import Base class SearchHistory(Base): diff --git a/database/models/user.py b/database/models/user.py index a4d6db4..30fcd49 100644 --- a/database/models/user.py +++ b/database/models/user.py @@ -2,7 +2,7 @@ from sqlalchemy import BigInteger, Column, DateTime, Integer, String, func -from ..engine import Base +from database.engine import Base class User(Base): diff --git a/main.py b/main.py index da684ed..aeb0291 100644 --- a/main.py +++ b/main.py @@ -29,12 +29,12 @@ async def create_bot() -> Bot: ) logger.info("Successfully created bot instance.") return bot - except TokenValidationError as e: - logger.error("Invalid token provided: %s", bot_config.token) - raise e + except TokenValidationError: + logger.exception("Invalid token provided: %s", bot_config.token) + raise except Exception as e: - logger.error("Failed to create bot instance: %s", str(e)) - raise e + logger.exception("Failed to create bot instance: %s", str(e)) + raise async def main() -> None: diff --git a/service/core.py b/service/core.py index 99598f2..041a2f7 100644 --- a/service/core.py +++ b/service/core.py @@ -1,11 +1,12 @@ """Music service core module for downloading and searching music.""" +from __future__ import annotations import logging import urllib.parse -from typing import Optional import aiohttp from bs4 import BeautifulSoup +from typing_extensions import Self from .data import ServiceConfig, Track from .exceptions import MusicServiceError @@ -19,12 +20,12 @@ class Music: BASE_URL = "https://mp3wr.com" TRACK_DOWNLOAD_URL = "https://cdn.mp3wr.com" - def __init__(self, config: Optional[ServiceConfig] = None) -> None: + def __init__(self, config: ServiceConfig | None = None) -> None: """Initialize music service with optional configuration.""" self._config = config or ServiceConfig() - self._session: Optional[aiohttp.ClientSession] = None + self._session: aiohttp.ClientSession | None = None - async def __aenter__(self) -> "Music": + async def __aenter__(self) -> Self: """Context manager entry point.""" await self.connect() return self @@ -87,7 +88,8 @@ async def _parse_tracks( return tracks except (aiohttp.ClientError, TimeoutError) as e: - raise MusicServiceError(f"Failed to search music: {e!s}") from e + msg = f"Failed to search music: {e!s}" + raise MusicServiceError(msg) from e async def _download_data( self, url: str, resource_type: str, track_name: str, @@ -108,14 +110,16 @@ async def _download_data( content_length = response.content_length if content_length and content_length > MAX_SIZE: + msg = f"File too large: {content_length} bytes" raise MusicServiceError( - f"File too large: {content_length} bytes", + msg, ) return await response.read() except Exception as e: + msg = f"Failed to download {resource_type}: {e!s}" raise MusicServiceError( - f"Failed to download {resource_type}: {e!s}", + msg, ) from e async def get_audio_bytes(self, track: Track) -> bytes: diff --git a/service/data.py b/service/data.py index 9c9342e..4b07a2b 100644 --- a/service/data.py +++ b/service/data.py @@ -1,4 +1,4 @@ -"""Data classes""" +"""Data classes.""" import json import os @@ -21,7 +21,7 @@ class ServiceConfig: @dataclass class Track: - """Track data class""" + """Track data class.""" index: int name: str @@ -37,7 +37,7 @@ def from_element( index: int, is_search: bool = False, ) -> "Track": - """Create Track from BeautifulSoup element""" + """Create Track from BeautifulSoup element.""" full_name = element.find(class_="artist_name").text.strip() performer, title = full_name.split(" - ", 1) audio_url = element.find(class_="right").get("data-id") diff --git a/service/exceptions.py b/service/exceptions.py index 0b98bb9..3ed92c8 100644 --- a/service/exceptions.py +++ b/service/exceptions.py @@ -1,4 +1,4 @@ -"""Exceptions for music service""" +"""Exceptions for music service.""" class MusicServiceError(Exception): From ece3837d24450fb7fa681dd2408e5fe34d9fba99 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 21:37:29 +0300 Subject: [PATCH 03/13] refactor: improve error logging and code consistency across multiple files --- bot/__init__.py | 1 + bot/filters/language.py | 7 +++-- bot/filters/not_subbed.py | 46 +++++++++++++++++++++------- bot/handlers/__init__.py | 4 +-- bot/handlers/faq.py | 6 ++-- bot/handlers/get_track.py | 34 +++++++++++++-------- bot/handlers/language.py | 18 ++++++----- bot/handlers/menu.py | 7 +++-- bot/handlers/pages.py | 15 ++++++---- bot/handlers/search.py | 24 ++++++++------- bot/handlers/subscribe.py | 7 +++-- bot/keyboards/__init__.py | 1 + bot/keyboards/command.py | 3 +- bot/keyboards/inline.py | 27 ++++++++++------- bot/middlewares/__init__.py | 2 +- bot/middlewares/auth_middleware.py | 16 +++++----- database/__init__.py | 1 + database/crud.py | 12 ++++++-- main.py | 4 +-- service/core.py | 48 ++++++++++++++++++++---------- service/data.py | 7 +++-- service/exceptions.py | 1 - 22 files changed, 188 insertions(+), 103 deletions(-) diff --git a/bot/__init__.py b/bot/__init__.py index e69de29..7c3397f 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -0,0 +1 @@ +"""Bot package.""" diff --git a/bot/filters/language.py b/bot/filters/language.py index b3d52ba..970826e 100644 --- a/bot/filters/language.py +++ b/bot/filters/language.py @@ -1,4 +1,5 @@ """Check language filter.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -14,10 +15,12 @@ class LanguageFilter(Filter): - """A filter that checks if a user's language matches the specified language.""" + """checks if user's language matches the specified language.""" async def __call__( - self, update: types.Message | types.CallbackQuery, user: User, + self, + update: types.Message | types.CallbackQuery, # noqa: ARG002 + user: User, ) -> bool: """Check if the user's language matches the specified language.""" return not support_languages.is_supported(user.language_code) diff --git a/bot/filters/not_subbed.py b/bot/filters/not_subbed.py index eef8f1d..77e720e 100644 --- a/bot/filters/not_subbed.py +++ b/bot/filters/not_subbed.py @@ -1,8 +1,9 @@ """Check if user is subbed to the channel.""" + from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from aiogram.enums import ChatMemberStatus from aiogram.filters import Filter @@ -19,18 +20,29 @@ class NotSubbedFilter(Filter): """A filter that checks if a user is subbed to the channel.""" - ALLOWED_STATUSES = { + ALLOWED_STATUSES: ClassVar[set[ChatMemberStatus]] = { ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR, } async def __call__( - self, update: types.Message | types.CallbackQuery, user: User, bot: Bot, + self, + update: types.Message | types.CallbackQuery, # noqa: ARG002 + user: User, + bot: Bot, ) -> bool: """Check if the user is not subscribed to any required channels. - Returns True if user is NOT subscribed to ANY required channel. - Returns False if user is subscribed to ALL required channels. + + Args: + update: Update object. + user: User object. + bot: Bot object. + + Returns: + True if user is NOT subscribed to ANY required channel. + False if user is subscribed to ALL required channels. + """ chats = await CRUD(RequiredSubscriptions).get_all() @@ -43,20 +55,32 @@ async def __call__( return False async def _not_subscribe( - self, sub: RequiredSubscriptions, user: User, bot: Bot, + self, + sub: RequiredSubscriptions, + user: User, + bot: Bot, ) -> bool: """Check if the user is subscribed to the channel. - Returns True if user is NOT subscribed. - Returns False if user is subscribed or if channel is not accessible. + + Args: + sub: RequiredSubscriptions object. + user: User object. + bot: Bot object. + + Returns: + True if user is NOT subscribed. + False if user is subscribed or if channel is not accessible. + """ try: chat = await bot.get_chat(sub.chat_id) - except Exception as e: - logger.exception(f"Failed to get chat {sub.chat_id}: {e}") + except Exception: + logger.exception("Failed to get chat %s", sub.chat_id) return False try: member = await chat.get_member(user.id) - return member.status not in self.ALLOWED_STATUSES except Exception: return True + else: + return member.status not in self.ALLOWED_STATUSES diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index 8ecd93b..88c0e3c 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -1,4 +1,4 @@ -"""Setup router for the bot.""" +"""Set up handlers for the bot.""" from aiogram import Dispatcher, Router @@ -6,7 +6,7 @@ def setup(dp: Dispatcher) -> None: - """Setup handlers for the bot.""" + """Set up handlers for the bot.""" router = Router() # Register routers diff --git a/bot/handlers/faq.py b/bot/handlers/faq.py index 2fd8a84..cde7b12 100644 --- a/bot/handlers/faq.py +++ b/bot/handlers/faq.py @@ -17,10 +17,10 @@ async def faq_handler(callback: types.CallbackQuery) -> None: gettext("faq"), reply_markup=inline.get_back_keyboard(gettext, "menu"), ) - except Exception as e: - logger.exception("Failed to send message: %s", e) + except Exception: + logger.exception("Failed to send message") def register(router: Router) -> None: - """Registers FAQ handler with the router.""" + """Register FAQ handler with the router.""" router.callback_query.register(faq_handler, F.data == "faq") diff --git a/bot/handlers/get_track.py b/bot/handlers/get_track.py index 049ad08..02bb8d1 100644 --- a/bot/handlers/get_track.py +++ b/bot/handlers/get_track.py @@ -13,7 +13,9 @@ async def send_track( - callback: types.CallbackQuery, bot: Bot, track: Track, + callback: types.CallbackQuery, + bot: Bot, + track: Track, ) -> None: """Send track.""" try: @@ -25,19 +27,22 @@ async def send_track( audio_file = BufferedInputFile(audio_bytes, filename=track.name) thumbnail_file = BufferedInputFile( - thumbnail_bytes, filename=track.name, + thumbnail_bytes, + filename=track.name, ) + me = await bot.get_me() + await callback.message.answer_audio( audio_file, title=track.title, performer=track.performer, - caption=gettext("promo_caption").format(username=bot._me.username), + caption=gettext("promo_caption").format(username=me.username), thumbnail=thumbnail_file, ) - except Exception as e: + except Exception: await callback.message.answer(gettext("send_track_error")) - logger.exception("Failed to send track: %s", e) + logger.exception("Failed to send track") async def get_track_handler(callback: types.CallbackQuery, bot: Bot) -> None: @@ -49,12 +54,13 @@ async def get_track_handler(callback: types.CallbackQuery, bot: Bot) -> None: await callback.answer(gettext("track_sending")) await send_track(callback, bot, track) - except Exception as e: - logger.exception("Failed get track handler: %s", e) + except Exception: + logger.exception("Failed get track handler") async def get_all_from_page_handler( - callback: types.CallbackQuery, bot: Bot, + callback: types.CallbackQuery, + bot: Bot, ) -> None: """Get all tracks from page handler.""" try: @@ -65,15 +71,17 @@ async def get_all_from_page_handler( for track in page_tracks: await send_track(callback, bot, track) - except Exception as e: - logger.exception("Failed get all from page handler: %s", e) + except Exception: + logger.exception("Failed get all from page handler") def register(router: Router) -> None: - """Registers get track handler with the router.""" + """Register get track handler with the router.""" router.callback_query.register( - get_track_handler, F.data.startswith("track:get:"), + get_track_handler, + F.data.startswith("track:get:"), ) router.callback_query.register( - get_all_from_page_handler, F.data.startswith("track:all:"), + get_all_from_page_handler, + F.data.startswith("track:all:"), ) diff --git a/bot/handlers/language.py b/bot/handlers/language.py index 41a2851..324ba20 100644 --- a/bot/handlers/language.py +++ b/bot/handlers/language.py @@ -1,4 +1,5 @@ """Language handler for the bot.""" + from __future__ import annotations import logging @@ -36,12 +37,14 @@ async def language_handler( await event.message.edit_text(text, reply_markup=keyboard) else: await event.answer(text, reply_markup=keyboard) - except Exception as e: - logger.exception("Failed to send message: %s", e) + except Exception: + logger.exception("Failed to send message") async def language_set_handler( - callback: types.CallbackQuery, user: User, bot: Bot, + callback: types.CallbackQuery, + user: User, + bot: Bot, ) -> None: """Language set handler.""" try: @@ -53,15 +56,16 @@ async def language_set_handler( await bot.set_my_commands(command.get_commands(gettext)) await menu_handler(callback) - except Exception as e: - logger.exception("Failed to set language: %s", e) + except Exception: + logger.exception("Failed to set language") def register(router: Router) -> None: - """Registers language handler with the router.""" + """Register language handler with the router.""" router.message.register(language_handler, LanguageFilter()) router.message.register(language_handler, Command("language")) router.callback_query.register(language_handler, F.data == "language") router.callback_query.register( - language_set_handler, F.data.startswith("language:set:"), + language_set_handler, + F.data.startswith("language:set:"), ) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 9e9a4b9..7d48038 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -1,4 +1,5 @@ """Menu handler for the bot.""" + from __future__ import annotations import logging @@ -24,12 +25,12 @@ async def menu_handler( await event.message.edit_text(text, reply_markup=keyboard) else: await event.answer(text, reply_markup=keyboard) - except Exception as e: - logger.exception("Failed to handle menu event: %s", e) + except Exception: + logger.exception("Failed to handle menu event") def register(router: Router) -> None: - """Registers start handler with the router.""" + """Register start handler with the router.""" router.message.register(menu_handler, CommandStart()) router.message.register(menu_handler, Command("menu")) router.callback_query.register(menu_handler, F.data == "menu") diff --git a/bot/handlers/pages.py b/bot/handlers/pages.py index 67fae85..3082bab 100644 --- a/bot/handlers/pages.py +++ b/bot/handlers/pages.py @@ -15,22 +15,25 @@ async def pages_handler(callback: types.CallbackQuery) -> None: - """Handles the pages navigation.""" + """Handle the pages navigation.""" try: _, _, search_id, page = callback.data.split(":") tracks: list[Track] = await load_tracks_from_db(search_id) await callback.message.edit_reply_markup( reply_markup=inline.get_keyboard_of_tracks( - tracks, search_id, int(page), + tracks, + search_id, + int(page), ), ) - except Exception as e: - logger.exception("Failed to send message: %s", e) + except Exception: + logger.exception("Failed to send message") def register(router: Router) -> None: - """Registers pages handler with the router.""" + """Register pages handler with the router.""" router.callback_query.register( - pages_handler, F.data.startswith("track:page"), + pages_handler, + F.data.startswith("track:page"), ) diff --git a/bot/handlers/search.py b/bot/handlers/search.py index cecb8a8..8e0f025 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -14,9 +14,11 @@ async def update_search( - user: User, keyword: str, tracks: list[Track], + user: User, + keyword: str, + tracks: list[Track], ) -> SearchHistory: - """Updates the user in the database.""" + """Update the user in the database.""" user_crud = CRUD(User) await user_crud.update(user, search_queries=user.search_queries + 1) @@ -29,7 +31,7 @@ async def update_search( async def search_handler(message: types.Message, user: User) -> None: - """Handles the search.""" + """Handle the search.""" try: keyword = message.text.strip() if not keyword or len(keyword) > 100: @@ -47,12 +49,12 @@ async def search_handler(message: types.Message, user: User) -> None: gettext("search_result").format(keyword=keyword), reply_markup=inline.get_keyboard_of_tracks(tracks, search.id), ) - except Exception as e: - logger.exception("Failed to send message: %s", e) + except Exception: + logger.exception("Failed to send message") async def get_track_list(list_type: str) -> list[Track]: - """Gets the track list.""" + """Get the track list.""" async with Music() as service: map_list_type = { "top_hits": service.get_top_hits, @@ -62,9 +64,10 @@ async def get_track_list(list_type: str) -> list[Track]: async def track_lists_handler( - callback: types.CallbackQuery, user: User, + callback: types.CallbackQuery, + user: User, ) -> None: - """Handles the track lists.""" + """Handle the track lists.""" _, _, list_type = callback.data.split(":") tracks = await get_track_list(list_type) search = await update_search(user, list_type, tracks) @@ -75,8 +78,9 @@ async def track_lists_handler( def register(router: Router) -> None: - """Registers search handler with the router.""" + """Register search handler with the router.""" router.message.register(search_handler) router.callback_query.register( - track_lists_handler, F.data.startswith("track:list:"), + track_lists_handler, + F.data.startswith("track:list:"), ) diff --git a/bot/handlers/subscribe.py b/bot/handlers/subscribe.py index d7ec9de..4a32ddb 100644 --- a/bot/handlers/subscribe.py +++ b/bot/handlers/subscribe.py @@ -1,4 +1,5 @@ """Subscription required handler for the bot.""" + from __future__ import annotations import logging @@ -27,8 +28,8 @@ async def sub_required_handler( await event.answer(text, reply_markup=keyboard) else: await event.message.answer(text, reply_markup=keyboard) - except Exception as e: - logger.exception("Failed to send message: %s", e) + except Exception: + logger.exception("Failed to send message") async def sub_check_handler( @@ -46,7 +47,7 @@ async def sub_check_handler( def register(router: Router) -> None: - """Registers FAQ handler with the router.""" + """Register FAQ handler with the router.""" router.callback_query.register(sub_check_handler, F.data == "sub_check") router.callback_query.register(sub_required_handler, NotSubbedFilter()) router.message.register(sub_required_handler, NotSubbedFilter()) diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py index e69de29..a5cbae6 100644 --- a/bot/keyboards/__init__.py +++ b/bot/keyboards/__init__.py @@ -0,0 +1 @@ +"""Keyboards package.""" diff --git a/bot/keyboards/command.py b/bot/keyboards/command.py index 888b01c..12a4c20 100644 --- a/bot/keyboards/command.py +++ b/bot/keyboards/command.py @@ -10,6 +10,7 @@ def get_commands(gettext: Callable[[str], str]) -> list[BotCommand]: return [ BotCommand(command="menu", description=gettext("menu_command")), BotCommand( - command="language", description=gettext("language_command"), + command="language", + description=gettext("language_command"), ), ] diff --git a/bot/keyboards/inline.py b/bot/keyboards/inline.py index b6d8bb4..5477c35 100644 --- a/bot/keyboards/inline.py +++ b/bot/keyboards/inline.py @@ -10,16 +10,18 @@ def get_keyboard_of_tracks( - tracks: list[Track], search_id: int, page: int = 0, + tracks: list[Track], + search_id: int, + page: int = 0, ) -> InlineKeyboardMarkup: """Create paginated inline keyboard for track selection.""" - TRACKS_PER_PAGE = 10 + tracks_per_page = 10 - total_pages = max((len(tracks) - 1) // TRACKS_PER_PAGE, 0) + total_pages = max((len(tracks) - 1) // tracks_per_page, 0) page = min(max(0, page), total_pages) - start_indx = page * TRACKS_PER_PAGE - end_indx = (page + 1) * TRACKS_PER_PAGE + start_indx = page * tracks_per_page + end_indx = (page + 1) * tracks_per_page current_page = tracks[start_indx:end_indx] keyboard = [ @@ -104,10 +106,12 @@ def get_menu_keyboard(gettext: Callable[[str], str]) -> InlineKeyboardMarkup: ], [ InlineKeyboardButton( - text=gettext("faq_button"), callback_data="faq", + text=gettext("faq_button"), + callback_data="faq", ), InlineKeyboardButton( - text=gettext("language_button"), callback_data="language", + text=gettext("language_button"), + callback_data="language", ), ], ], @@ -126,7 +130,8 @@ def get_subscribe_keyboard( chats.append( [ InlineKeyboardButton( - text=gettext("sub_check_button"), callback_data="sub_check", + text=gettext("sub_check_button"), + callback_data="sub_check", ), ], ) @@ -134,14 +139,16 @@ def get_subscribe_keyboard( def get_back_keyboard( - gettext: Callable[[str], str], callback_data: str, + gettext: Callable[[str], str], + callback_data: str, ) -> InlineKeyboardMarkup: """Get back keyboard.""" return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=gettext("back_button"), callback_data=callback_data, + text=gettext("back_button"), + callback_data=callback_data, ), ], ], diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py index da81f07..e0c007f 100644 --- a/bot/middlewares/__init__.py +++ b/bot/middlewares/__init__.py @@ -8,6 +8,6 @@ def setup(dp: Dispatcher, i18n: I18n) -> None: - """Setup middleware.""" + """Set up middleware.""" dp.update.outer_middleware(AuthMiddleware()) dp.update.outer_middleware(I18nMiddleware(i18n)) diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index 169f4da..be10287 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -23,24 +23,24 @@ async def __call__( event: Update, data: dict[str, Any], ) -> Any: - """Intercepts incoming updates, processes them and calls the next.""" + """Intercept incoming updates, process them and call the next.""" data["user"] = await self.ensure_user_in_db(data["event_from_user"]) return await handler(event, data) async def ensure_user_in_db(self, user: User) -> User: - """Ensures that the user is registered in the database.""" + """Ensure that the user is registered in the database.""" user_crud = self._get_user_crud() user_data = self._prepare_user_data(user) try: return await self._get_or_create_user(user, user_crud, user_data) - except Exception as e: - logger.exception("Failed to process user %s: %s", user.id, str(e)) + except Exception: + logger.exception("Failed to process user %s", user.id) raise def _get_user_crud(self) -> CRUD: - """Creates CRUD instance for User model.""" + """Create CRUD instance for User model.""" return CRUD(User) @staticmethod @@ -55,9 +55,11 @@ def _prepare_user_data(user: User) -> dict[str, Any]: @staticmethod async def _get_or_create_user( - user: User, user_crud: CRUD, user_data: dict[str, Any], + user: User, + user_crud: CRUD, + user_data: dict[str, Any], ) -> User: - """Gets existing user or creates new one.""" + """Get existing user or create new one.""" db_user = await user_crud.get(id=user.id) if not db_user: diff --git a/database/__init__.py b/database/__init__.py index e69de29..cdce083 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -0,0 +1 @@ +"""Database package.""" diff --git a/database/crud.py b/database/crud.py index f6d76fc..54aa378 100644 --- a/database/crud.py +++ b/database/crud.py @@ -53,7 +53,9 @@ async def create(self, **kwargs) -> T: return instance except SQLAlchemyError as e: await session.rollback() - logger.exception(f"Failed to create {self.model.__name__}: {e}") + logger.exception( + f"Failed to create {self.model.__name__}: {e}", + ) raise async def get(self, **kwargs) -> T: @@ -82,7 +84,9 @@ async def update(self, instance: T, **kwargs) -> T: return instance except SQLAlchemyError as e: await session.rollback() - logger.exception(f"Failed to update {self.model.__name__}: {e}") + logger.exception( + f"Failed to update {self.model.__name__}: {e}", + ) raise async def delete(self, instance: T) -> bool: @@ -95,5 +99,7 @@ async def delete(self, instance: T) -> bool: return True except SQLAlchemyError as e: await session.rollback() - logger.exception(f"Failed to delete {self.model.__name__}: {e}") + logger.exception( + f"Failed to delete {self.model.__name__}: {e}", + ) raise diff --git a/main.py b/main.py index aeb0291..ceca49d 100644 --- a/main.py +++ b/main.py @@ -32,8 +32,8 @@ async def create_bot() -> Bot: except TokenValidationError: logger.exception("Invalid token provided: %s", bot_config.token) raise - except Exception as e: - logger.exception("Failed to create bot instance: %s", str(e)) + except Exception: + logger.exception("Failed to create bot instance") raise diff --git a/service/core.py b/service/core.py index 041a2f7..a5093c2 100644 --- a/service/core.py +++ b/service/core.py @@ -1,8 +1,10 @@ """Music service core module for downloading and searching music.""" + from __future__ import annotations import logging import urllib.parse +from typing import TYPE_CHECKING import aiohttp from bs4 import BeautifulSoup @@ -11,6 +13,9 @@ from .data import ServiceConfig, Track from .exceptions import MusicServiceError +if TYPE_CHECKING: + from types import TracebackType + logger = logging.getLogger(__name__) @@ -30,7 +35,12 @@ async def __aenter__(self) -> Self: await self.connect() return self - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: """Context manager exit point.""" await self.disconnect() @@ -66,17 +76,20 @@ async def get_new_hits(self) -> list[Track]: return await self._parse_tracks(url) async def _parse_tracks( - self, url: str, is_search: bool = False, + self, + url: str, + is_search: bool = False, ) -> list[Track]: """Parse tracks from the given URL.""" try: async with self._session.get( - url, timeout=self._config.timeout, + url, + timeout=self._config.timeout, ) as response: response.raise_for_status() soup = BeautifulSoup(await response.text(), "html.parser") tracks = [ - Track.from_element(track_data, index, is_search) + Track.from_element(track_data, index, is_search=is_search) for index, track_data in enumerate( soup.find_all("item") if is_search @@ -85,17 +98,22 @@ async def _parse_tracks( ] logger.info("Found %d tracks", len(tracks)) - return tracks except (aiohttp.ClientError, TimeoutError) as e: msg = f"Failed to search music: {e!s}" raise MusicServiceError(msg) from e + else: + return tracks + async def _download_data( - self, url: str, resource_type: str, track_name: str, + self, + url: str, + resource_type: str, + track_name: str, ) -> bytes: - """Generic method for downloading data.""" - MAX_SIZE = 50 * 1024 * 1024 # 50MB + """Download data.""" + max_size = 50 * 1024 * 1024 # 50MB if not self._session: await self.connect() @@ -104,23 +122,23 @@ async def _download_data( try: async with self._session.get( - url, timeout=self._config.timeout, + url, + timeout=self._config.timeout, ) as response: response.raise_for_status() content_length = response.content_length - if content_length and content_length > MAX_SIZE: + if content_length and content_length > max_size: msg = f"File too large: {content_length} bytes" raise MusicServiceError( msg, ) return await response.read() - except Exception as e: - msg = f"Failed to download {resource_type}: {e!s}" - raise MusicServiceError( - msg, - ) from e + + except Exception: + msg = f"Failed to download {resource_type}" + raise MusicServiceError(msg) from None async def get_audio_bytes(self, track: Track) -> bytes: """Download music file.""" diff --git a/service/data.py b/service/data.py index 4b07a2b..3118038 100644 --- a/service/data.py +++ b/service/data.py @@ -1,12 +1,12 @@ """Data classes.""" import json -import os from dataclasses import dataclass, field +from pathlib import Path from bs4 import BeautifulSoup -headers_path = os.path.join(os.path.dirname(__file__), "headers.json") +headers_path = Path(__file__).parent / "headers.json" @dataclass @@ -15,7 +15,7 @@ class ServiceConfig: timeout: int = 30 headers: dict = field( - default_factory=lambda: json.load(open(headers_path)), + default_factory=lambda: json.load(Path.open(headers_path)), ) @@ -35,6 +35,7 @@ def from_element( cls, element: BeautifulSoup, index: int, + *, is_search: bool = False, ) -> "Track": """Create Track from BeautifulSoup element.""" diff --git a/service/exceptions.py b/service/exceptions.py index 3ed92c8..f693db9 100644 --- a/service/exceptions.py +++ b/service/exceptions.py @@ -3,4 +3,3 @@ class MusicServiceError(Exception): """Base exception for music service errors.""" - From 4a371b546cc43156e568eab360888259f33543d3 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 21:53:37 +0300 Subject: [PATCH 04/13] refactor: improve code consistency and error handling across multiple files --- bot/filters/not_subbed.py | 2 +- bot/handlers/search.py | 5 ++- bot/keyboards/inline.py | 30 +++++++++++++++--- bot/middlewares/auth_middleware.py | 4 +-- bot/middlewares/i18n_middleware.py | 2 +- database/crud.py | 49 ++++++++++++++++-------------- main.py | 12 +++----- service/core.py | 34 ++++++++++++++------- 8 files changed, 88 insertions(+), 50 deletions(-) diff --git a/bot/filters/not_subbed.py b/bot/filters/not_subbed.py index 77e720e..a9390ab 100644 --- a/bot/filters/not_subbed.py +++ b/bot/filters/not_subbed.py @@ -80,7 +80,7 @@ async def _not_subscribe( try: member = await chat.get_member(user.id) - except Exception: + except Exception: # noqa: BLE001 return True else: return member.status not in self.ALLOWED_STATUSES diff --git a/bot/handlers/search.py b/bot/handlers/search.py index 8e0f025..64391b3 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -10,6 +10,9 @@ from database.models import SearchHistory, User from service import Music, Track +# Constants +MAX_KEYWORD_LENGTH = 100 + logger = logging.getLogger(__name__) @@ -34,7 +37,7 @@ async def search_handler(message: types.Message, user: User) -> None: """Handle the search.""" try: keyword = message.text.strip() - if not keyword or len(keyword) > 100: + if not keyword or len(keyword) > MAX_KEYWORD_LENGTH: await message.answer(gettext("search_query_error")) return diff --git a/bot/keyboards/inline.py b/bot/keyboards/inline.py index 5477c35..e781a6e 100644 --- a/bot/keyboards/inline.py +++ b/bot/keyboards/inline.py @@ -1,5 +1,6 @@ """Inline keyboard templates.""" +from enum import Enum, auto from typing import Callable from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -9,6 +10,13 @@ from service.data import Track +class NavigationDirection(Enum): + """Navigation direction for pagination.""" + + PREVIOUS = auto() + NEXT = auto() + + def get_keyboard_of_tracks( tracks: list[Track], search_id: int, @@ -36,8 +44,11 @@ def get_keyboard_of_tracks( if total_pages > 0: - def create_navigation_button(is_next: bool) -> InlineKeyboardButton: + def create_navigation_button( + direction: NavigationDirection, + ) -> InlineKeyboardButton: """Create navigation button (prev/next) based on current page.""" + is_next = direction == NavigationDirection.NEXT is_available = page < total_pages if is_next else page > 0 return InlineKeyboardButton( @@ -58,19 +69,23 @@ def create_navigation_button(is_next: bool) -> InlineKeyboardButton: keyboard.append( [ - create_navigation_button(is_next=False), + create_navigation_button( + direction=NavigationDirection.PREVIOUS, + ), InlineKeyboardButton( text=f"{page + 1}/{total_pages + 1}", callback_data="track:noop", ), - create_navigation_button(is_next=True), + create_navigation_button(direction=NavigationDirection.NEXT), ], ) keyboard.append( [ InlineKeyboardButton( text="🔽", - callback_data=f"track:all:{search_id}:{start_indx}:{end_indx}", + callback_data=( + f"track:all:{search_id}:{start_indx}:{end_indx}" + ), ), ], ) @@ -124,7 +139,12 @@ def get_subscribe_keyboard( ) -> InlineKeyboardMarkup: """Get subscribe keyboard.""" chats = [ - [InlineKeyboardButton(text=f"➕ {sub.chat_title}", url=sub.chat_link)] + [ + InlineKeyboardButton( + text=f"➕ {sub.chat_title}", # noqa: RUF001 + url=sub.chat_link, + ), + ] for sub in sub_required ] chats.append( diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index be10287..28864d0 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -22,7 +22,7 @@ async def __call__( handler: Callable[[Update, dict[str, Any]], Awaitable[Any]], event: Update, data: dict[str, Any], - ) -> Any: + ) -> Awaitable[Any]: """Intercept incoming updates, process them and call the next.""" data["user"] = await self.ensure_user_in_db(data["event_from_user"]) return await handler(event, data) @@ -45,7 +45,7 @@ def _get_user_crud(self) -> CRUD: @staticmethod def _prepare_user_data(user: User) -> dict[str, Any]: - """Prepares user data for database operations.""" + """Prepare user data for database operations.""" return { "id": user.id, "username": user.username, diff --git a/bot/middlewares/i18n_middleware.py b/bot/middlewares/i18n_middleware.py index 7474b93..a7cce12 100644 --- a/bot/middlewares/i18n_middleware.py +++ b/bot/middlewares/i18n_middleware.py @@ -12,7 +12,7 @@ class I18nMiddleware(BaseI18nMiddleware): """Custom i18n middleware for the bot.""" - async def get_locale(self, event: Message, data: dict) -> str: + async def get_locale(self, _: Message, data: dict) -> str: """Get user locale.""" user: User = data["user"] return user.language_code diff --git a/database/crud.py b/database/crud.py index 54aa378..8f6c0ae 100644 --- a/database/crud.py +++ b/database/crud.py @@ -3,7 +3,7 @@ import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import TypeVar +from typing import Any, TypeVar from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError @@ -35,30 +35,32 @@ async def get_session(self) -> AsyncGenerator[AsyncSession, None]: session = self.session_factory() try: yield session - except Exception as e: + except Exception: await session.rollback() - logger.exception("Failed to get session: %s", e) + logger.exception("Failed to get session") raise finally: await session.close() - async def create(self, **kwargs) -> T: + async def create(self, **kwargs: Any) -> T: # noqa: ANN401 """Create a new record in the database.""" async with self.get_session() as session: - instance = self.model(**kwargs) - session.add(instance) try: + instance = self.model(**kwargs) + session.add(instance) await session.commit() await session.refresh(instance) - return instance - except SQLAlchemyError as e: + except SQLAlchemyError: await session.rollback() logger.exception( - f"Failed to create {self.model.__name__}: {e}", + "Failed to create %s", + self.model.__name__, ) raise + else: + return instance - async def get(self, **kwargs) -> T: + async def get(self, **kwargs: Any) -> T: # noqa: ANN401 """Retrieve a record by any field.""" async with self.get_session() as session: query = await session.execute( @@ -72,34 +74,37 @@ async def get_all(self) -> list[T]: query = await session.execute(select(self.model)) return query.scalars().all() - async def update(self, instance: T, **kwargs) -> T: + async def update(self, instance: T, **kwargs: Any) -> T: # noqa: ANN401 """Update a record's information.""" async with self.get_session() as session: - instance = await session.merge(instance) - for key, value in kwargs.items(): - setattr(instance, key, value) try: + for key, value in kwargs.items(): + setattr(instance, key, value) + session.add(instance) await session.commit() await session.refresh(instance) - return instance - except SQLAlchemyError as e: + except SQLAlchemyError: await session.rollback() logger.exception( - f"Failed to update {self.model.__name__}: {e}", + "Failed to update %s", + self.model.__name__, ) raise + else: + return instance async def delete(self, instance: T) -> bool: - """Delete a record.""" + """Delete a record from the database.""" async with self.get_session() as session: - instance = await session.merge(instance) try: await session.delete(instance) await session.commit() - return True - except SQLAlchemyError as e: + except SQLAlchemyError: await session.rollback() logger.exception( - f"Failed to delete {self.model.__name__}: {e}", + "Failed to delete %s", + self.model.__name__, ) raise + else: + return True diff --git a/main.py b/main.py index ceca49d..3a0f924 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ import logging from aiogram import Bot, Dispatcher -from aiogram.client.bot import DefaultBotProperties +from aiogram.enums import ParseMode from aiogram.fsm.storage.memory import MemoryStorage from aiogram.utils.i18n import I18n from aiogram.utils.token import TokenValidationError @@ -25,20 +25,18 @@ async def create_bot() -> Bot: try: bot = Bot( token=bot_config.token, - default=DefaultBotProperties(parse_mode="HTML"), + default_parse_mode=ParseMode.HTML, ) logger.info("Successfully created bot instance.") - return bot except TokenValidationError: logger.exception("Invalid token provided: %s", bot_config.token) raise - except Exception: - logger.exception("Failed to create bot instance") - raise + else: + return bot async def main() -> None: - """The entry point of the bot application.""" + """Start the bot application.""" bot = await create_bot() storage = MemoryStorage() diff --git a/service/core.py b/service/core.py index a5093c2..f7ce62f 100644 --- a/service/core.py +++ b/service/core.py @@ -4,7 +4,7 @@ import logging import urllib.parse -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import aiohttp from bs4 import BeautifulSoup @@ -63,22 +63,30 @@ async def search(self, keyword: str) -> list[Track]: url = urllib.parse.urljoin(self.BASE_URL, f"search/{keyword}") logger.info("Searching music with keyword: %s", keyword) - return await self._parse_tracks(url, is_search=True) + return await self._parse_search_tracks(url) async def get_top_hits(self) -> list[Track]: """Get top tracks.""" url = urllib.parse.urljoin(self.BASE_URL, "besthit") - return await self._parse_tracks(url) + return await self._parse_regular_tracks(url) async def get_new_hits(self) -> list[Track]: """Get new hits.""" url = urllib.parse.urljoin(self.BASE_URL, "newhit") - return await self._parse_tracks(url) + return await self._parse_regular_tracks(url) + + async def _parse_search_tracks(self, url: str) -> list[Track]: + """Parse tracks from search results.""" + return await self._parse_tracks(url, mode="search") + + async def _parse_regular_tracks(self, url: str) -> list[Track]: + """Parse tracks from regular pages.""" + return await self._parse_tracks(url, mode="regular") async def _parse_tracks( self, url: str, - is_search: bool = False, + mode: Literal["search", "regular"], ) -> list[Track]: """Parse tracks from the given URL.""" try: @@ -88,6 +96,8 @@ async def _parse_tracks( ) as response: response.raise_for_status() soup = BeautifulSoup(await response.text(), "html.parser") + + is_search = mode == "search" tracks = [ Track.from_element(track_data, index, is_search=is_search) for index, track_data in enumerate( @@ -106,6 +116,11 @@ async def _parse_tracks( else: return tracks + def _raise_file_too_large_error(self, content_length: int) -> None: + """Raise an error for files that are too large.""" + msg = f"File too large: {content_length} bytes" + raise MusicServiceError(msg) + async def _download_data( self, url: str, @@ -129,16 +144,13 @@ async def _download_data( content_length = response.content_length if content_length and content_length > max_size: - msg = f"File too large: {content_length} bytes" - raise MusicServiceError( - msg, - ) + self._raise_file_too_large_error(content_length) return await response.read() - except Exception: + except (aiohttp.ClientError, TimeoutError) as e: msg = f"Failed to download {resource_type}" - raise MusicServiceError(msg) from None + raise MusicServiceError(msg) from e async def get_audio_bytes(self, track: Track) -> bytes: """Download music file.""" From 5aeabae22f8a5477385e930960122d0364231e34 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 22:44:14 +0300 Subject: [PATCH 05/13] refactor: enhance configuration validation and improve error handling across multiple files --- bot/filters/not_subbed.py | 30 ++++------------------------- bot/handlers/language.py | 14 ++++++++------ bot/middlewares/auth_middleware.py | 5 +++-- configs.py | 22 +++++++++++++++++---- database/crud.py | 10 ++++------ database/models/required_subs.py | 19 ++++++++++++------ database/models/search_history.py | 21 +++++++++++++------- database/models/user.py | 31 ++++++++++++++++++++---------- main.py | 15 ++++++++------- service/core.py | 6 ++++-- 10 files changed, 97 insertions(+), 76 deletions(-) diff --git a/bot/filters/not_subbed.py b/bot/filters/not_subbed.py index a9390ab..a019e52 100644 --- a/bot/filters/not_subbed.py +++ b/bot/filters/not_subbed.py @@ -32,18 +32,7 @@ async def __call__( user: User, bot: Bot, ) -> bool: - """Check if the user is not subscribed to any required channels. - - Args: - update: Update object. - user: User object. - bot: Bot object. - - Returns: - True if user is NOT subscribed to ANY required channel. - False if user is subscribed to ALL required channels. - - """ + """Check if the user is not subscribed to any required channels.""" chats = await CRUD(RequiredSubscriptions).get_all() if not chats: @@ -60,18 +49,7 @@ async def _not_subscribe( user: User, bot: Bot, ) -> bool: - """Check if the user is subscribed to the channel. - - Args: - sub: RequiredSubscriptions object. - user: User object. - bot: Bot object. - - Returns: - True if user is NOT subscribed. - False if user is subscribed or if channel is not accessible. - - """ + """Check if the user is subscribed to the channel.""" try: chat = await bot.get_chat(sub.chat_id) except Exception: @@ -82,5 +60,5 @@ async def _not_subscribe( member = await chat.get_member(user.id) except Exception: # noqa: BLE001 return True - else: - return member.status not in self.ALLOWED_STATUSES + + return member.status not in self.ALLOWED_STATUSES diff --git a/bot/handlers/language.py b/bot/handlers/language.py index 324ba20..e7b23f7 100644 --- a/bot/handlers/language.py +++ b/bot/handlers/language.py @@ -48,14 +48,16 @@ async def language_set_handler( ) -> None: """Language set handler.""" try: - language_code = callback.data.split(":")[-1] + if callback.data: + language_code = callback.data.split(":")[-1] - user_crud = CRUD(User) - await user_crud.update(user, language_code=language_code) - i18n.get_i18n().ctx_locale.set(language_code) + user_crud = CRUD(User) + await user_crud.update(user, language_code=language_code) + i18n.get_i18n().ctx_locale.set(language_code) + + await bot.set_my_commands(command.get_commands(gettext)) + await menu_handler(callback) - await bot.set_my_commands(command.get_commands(gettext)) - await menu_handler(callback) except Exception: logger.exception("Failed to set language") diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index 28864d0..f362dcb 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -1,12 +1,13 @@ """Auth middleware module for the bot.""" import logging +import time from collections.abc import Awaitable +from datetime import datetime from typing import Any, Callable from aiogram import BaseMiddleware from aiogram.types import Update -from sqlalchemy import func from database.crud import CRUD from database.models import User @@ -68,7 +69,7 @@ async def _get_or_create_user( logger.info("User %s registered in the database.", user.id) else: - user_data["updated_at"] = func.now() + user_data["updated_at"] = datetime.now(time.tzname) db_user = await user_crud.update(db_user, **user_data) logger.info("User %s updated in the database.", user.id) diff --git a/configs.py b/configs.py index 3422125..f85c0fe 100644 --- a/configs.py +++ b/configs.py @@ -1,5 +1,7 @@ """Configurations for the app.""" +from __future__ import annotations + import os import time from dataclasses import dataclass @@ -12,7 +14,13 @@ class BotConfig: """Configuration class for the bot.""" - token: str = os.getenv("BOT_TOKEN") + token: str | None = os.getenv("BOT_TOKEN") + + def __post_init__(self) -> None: + """Post-init method for the bot configuration.""" + if not self.token: + msg = "Bot token is not set" + raise ValueError(msg) @dataclass @@ -21,9 +29,15 @@ class DBConfig: host: str = os.getenv("POSTGRES_HOST", "db") port: str = os.getenv("POSTGRES_PORT", "5432") - user: str = os.getenv("POSTGRES_USER") - password: str = os.getenv("POSTGRES_PASSWORD") - db: str = os.getenv("POSTGRES_DB") + user: str | None = os.getenv("POSTGRES_USER") + password: str | None = os.getenv("POSTGRES_PASSWORD") + db: str | None = os.getenv("POSTGRES_DB") + + def __post_init__(self) -> None: + """Post-init method for the database configuration.""" + if not self.user or not self.password or not self.db: + msg = "Database configuration is incomplete" + raise ValueError(msg) @property def url(self) -> str: diff --git a/database/crud.py b/database/crud.py index 8f6c0ae..ee85839 100644 --- a/database/crud.py +++ b/database/crud.py @@ -57,8 +57,7 @@ async def create(self, **kwargs: Any) -> T: # noqa: ANN401 self.model.__name__, ) raise - else: - return instance + return instance async def get(self, **kwargs: Any) -> T: # noqa: ANN401 """Retrieve a record by any field.""" @@ -90,8 +89,8 @@ async def update(self, instance: T, **kwargs: Any) -> T: # noqa: ANN401 self.model.__name__, ) raise - else: - return instance + + return instance async def delete(self, instance: T) -> bool: """Delete a record from the database.""" @@ -106,5 +105,4 @@ async def delete(self, instance: T) -> bool: self.model.__name__, ) raise - else: - return True + return True diff --git a/database/models/required_subs.py b/database/models/required_subs.py index f2e2c14..e52c101 100644 --- a/database/models/required_subs.py +++ b/database/models/required_subs.py @@ -1,6 +1,9 @@ """Required subscriptions database model.""" -from sqlalchemy import BigInteger, Column, DateTime, Integer, String, func +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column from database.engine import Base @@ -10,8 +13,12 @@ class RequiredSubscriptions(Base): __tablename__ = "required_subscriptions" - id = Column(Integer, primary_key=True, autoincrement=True) - chat_id = Column(BigInteger) - chat_title = Column(String) - chat_link = Column(String) - created_at = Column(DateTime, default=func.now()) + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + chat_id: Mapped[int] = mapped_column(BigInteger) + chat_title: Mapped[str] = mapped_column(String) + chat_link: Mapped[str] = mapped_column(String) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now + ) diff --git a/database/models/search_history.py b/database/models/search_history.py index 8ee3ed0..5708672 100644 --- a/database/models/search_history.py +++ b/database/models/search_history.py @@ -1,15 +1,18 @@ """Search history database model.""" +from __future__ import annotations + +from datetime import datetime + from sqlalchemy import ( BigInteger, - Column, DateTime, ForeignKey, Integer, String, - func, ) from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column from database.engine import Base @@ -19,8 +22,12 @@ class SearchHistory(Base): __tablename__ = "search_history" - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(BigInteger, ForeignKey("users.id")) - keyword = Column(String, default=None) - tracks = Column(JSONB, default=[]) - created_at = Column(DateTime, default=func.now()) + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id")) + keyword: Mapped[str | None] = mapped_column(String, default=None) + tracks: Mapped[list[dict[str, str]]] = mapped_column(JSONB, default=[]) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now + ) diff --git a/database/models/user.py b/database/models/user.py index 30fcd49..30238e8 100644 --- a/database/models/user.py +++ b/database/models/user.py @@ -1,6 +1,11 @@ """User database model.""" -from sqlalchemy import BigInteger, Column, DateTime, Integer, String, func +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column from database.engine import Base @@ -10,12 +15,18 @@ class User(Base): __tablename__ = "users" - id = Column(BigInteger, primary_key=True, autoincrement=True) - username = Column(String, default=None) - first_name = Column(String, default=None) - last_name = Column(String, default=None) - language_code = Column(String, default=None) - state = Column(String, default=None) - search_queries = Column(Integer, default=0) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now()) + id: Mapped[int] = mapped_column( + BigInteger, primary_key=True, autoincrement=True + ) + username: Mapped[str | None] = mapped_column(String, default=None) + first_name: Mapped[str | None] = mapped_column(String, default=None) + last_name: Mapped[str | None] = mapped_column(String, default=None) + language_code: Mapped[str | None] = mapped_column(String, default=None) + state: Mapped[str | None] = mapped_column(String, default=None) + search_queries: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now + ) diff --git a/main.py b/main.py index 3a0f924..f7897c5 100644 --- a/main.py +++ b/main.py @@ -23,16 +23,17 @@ async def create_bot() -> Bot: """Create and return a Bot instance.""" try: - bot = Bot( - token=bot_config.token, - default_parse_mode=ParseMode.HTML, - ) - logger.info("Successfully created bot instance.") + if bot_config.token: + bot = Bot( + token=bot_config.token, + default_parse_mode=ParseMode.HTML, + ) + logger.info("Successfully created bot instance.") except TokenValidationError: logger.exception("Invalid token provided: %s", bot_config.token) raise - else: - return bot + + return bot async def main() -> None: diff --git a/service/core.py b/service/core.py index f7ce62f..3e9c3de 100644 --- a/service/core.py +++ b/service/core.py @@ -90,6 +90,9 @@ async def _parse_tracks( ) -> list[Track]: """Parse tracks from the given URL.""" try: + if not self._session: + await self.connect() + async with self._session.get( url, timeout=self._config.timeout, @@ -113,8 +116,7 @@ async def _parse_tracks( msg = f"Failed to search music: {e!s}" raise MusicServiceError(msg) from e - else: - return tracks + return tracks def _raise_file_too_large_error(self, content_length: int) -> None: """Raise an error for files that are too large.""" From 4c92d8a0880c1d58fa4507d8286b46d0ceb202cf Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 23:09:19 +0300 Subject: [PATCH 06/13] refactor: enhance error handling and code consistency across multiple files --- bot/filters/language.py | 5 ++- bot/filters/not_subbed.py | 4 +- bot/handlers/faq.py | 4 ++ bot/handlers/get_track.py | 27 +++++++++++-- bot/handlers/language.py | 8 +++- bot/handlers/menu.py | 4 ++ bot/handlers/pages.py | 25 ++++++++++-- bot/handlers/search.py | 13 +++++++ bot/handlers/subscribe.py | 16 +++++++- bot/middlewares/auth_middleware.py | 11 +++--- bot/middlewares/i18n_middleware.py | 6 +-- bot/utils.py | 6 ++- database/crud.py | 22 +++++++---- database/engine.py | 11 ++++-- database/models/required_subs.py | 7 +++- database/models/search_history.py | 7 +++- database/models/user.py | 10 +++-- locales/_support_languages.py | 6 ++- main.py | 9 +++-- service/core.py | 11 +++++- service/data.py | 61 +++++++++++++++++++++++++----- 21 files changed, 217 insertions(+), 56 deletions(-) diff --git a/bot/filters/language.py b/bot/filters/language.py index 970826e..2e0298c 100644 --- a/bot/filters/language.py +++ b/bot/filters/language.py @@ -23,4 +23,7 @@ async def __call__( user: User, ) -> bool: """Check if the user's language matches the specified language.""" - return not support_languages.is_supported(user.language_code) + language_code = user.language_code + if language_code is None: + return True + return not support_languages.is_supported(language_code) diff --git a/bot/filters/not_subbed.py b/bot/filters/not_subbed.py index a019e52..d67a516 100644 --- a/bot/filters/not_subbed.py +++ b/bot/filters/not_subbed.py @@ -33,7 +33,9 @@ async def __call__( bot: Bot, ) -> bool: """Check if the user is not subscribed to any required channels.""" - chats = await CRUD(RequiredSubscriptions).get_all() + chats: list[RequiredSubscriptions] = await CRUD( + RequiredSubscriptions, + ).get_all() if not chats: return False diff --git a/bot/handlers/faq.py b/bot/handlers/faq.py index cde7b12..e92f61d 100644 --- a/bot/handlers/faq.py +++ b/bot/handlers/faq.py @@ -13,6 +13,10 @@ async def faq_handler(callback: types.CallbackQuery) -> None: """FAQ handler.""" try: + if callback.message is None: + await callback.answer("Cannot edit message") + return + await callback.message.edit_text( gettext("faq"), reply_markup=inline.get_back_keyboard(gettext, "menu"), diff --git a/bot/handlers/get_track.py b/bot/handlers/get_track.py index 02bb8d1..d9bb0fd 100644 --- a/bot/handlers/get_track.py +++ b/bot/handlers/get_track.py @@ -19,6 +19,10 @@ async def send_track( ) -> None: """Send track.""" try: + if callback.message is None or callback.message.chat is None: + await callback.answer("Cannot access chat") + return + await bot.send_chat_action(callback.message.chat.id, "upload_document") async with Music() as service: @@ -33,6 +37,10 @@ async def send_track( me = await bot.get_me() + if callback.message is None: + await callback.answer("Cannot send message") + return + await callback.message.answer_audio( audio_file, title=track.title, @@ -41,14 +49,22 @@ async def send_track( thumbnail=thumbnail_file, ) except Exception: - await callback.message.answer(gettext("send_track_error")) + if callback.message is not None: + await callback.message.answer(gettext("send_track_error")) + else: + await callback.answer(gettext("send_track_error")) logger.exception("Failed to send track") async def get_track_handler(callback: types.CallbackQuery, bot: Bot) -> None: """Get track handler.""" try: - _, _, search_id, index = callback.data.split(":") + if callback.data is None: + await callback.answer("Invalid data") + return + + _, _, search_id_str, index = callback.data.split(":") + search_id = int(search_id_str) tracks: list[Track] = await load_tracks_from_db(search_id) track: Track = tracks[int(index)] await callback.answer(gettext("track_sending")) @@ -64,7 +80,12 @@ async def get_all_from_page_handler( ) -> None: """Get all tracks from page handler.""" try: - _, _, search_id, start_indx, end_indx = callback.data.split(":") + if callback.data is None: + await callback.answer("Invalid data") + return + + _, _, search_id_str, start_indx, end_indx = callback.data.split(":") + search_id = int(search_id_str) all_tracks: list[Track] = await load_tracks_from_db(search_id) page_tracks = all_tracks[int(start_indx) : int(end_indx)] await callback.answer(gettext("track_sending")) diff --git a/bot/handlers/language.py b/bot/handlers/language.py index e7b23f7..6537522 100644 --- a/bot/handlers/language.py +++ b/bot/handlers/language.py @@ -26,14 +26,20 @@ async def language_handler( ) -> None: """Language handler.""" try: + language_code = user.language_code text = ( gettext("language_choose") - if support_languages.is_supported(user.language_code) + if language_code is not None + and support_languages.is_supported(language_code) else "🌎 Choose language:" ) keyboard = inline.language_keyboard if isinstance(event, types.CallbackQuery): + if event.message is None: + await event.answer("Cannot edit message") + return + await event.message.edit_text(text, reply_markup=keyboard) else: await event.answer(text, reply_markup=keyboard) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 7d48038..21e2bbb 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -22,6 +22,10 @@ async def menu_handler( keyboard = inline.get_menu_keyboard(gettext) if isinstance(event, types.CallbackQuery): + if event.message is None: + await event.answer("Cannot edit message") + return + await event.message.edit_text(text, reply_markup=keyboard) else: await event.answer(text, reply_markup=keyboard) diff --git a/bot/handlers/pages.py b/bot/handlers/pages.py index 3082bab..1fa1fc3 100644 --- a/bot/handlers/pages.py +++ b/bot/handlers/pages.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from aiogram import F, Router, types +from aiogram.exceptions import TelegramBadRequest from bot.keyboards import inline from bot.utils import load_tracks_from_db @@ -17,18 +18,34 @@ async def pages_handler(callback: types.CallbackQuery) -> None: """Handle the pages navigation.""" try: - _, _, search_id, page = callback.data.split(":") - tracks: list[Track] = await load_tracks_from_db(search_id) + if not callback.data: + await callback.answer("Invalid data") + return + + data_parts = callback.data.split(":") + if len(data_parts) < 4: + await callback.answer("Invalid data format") + return + + _, _, search_id, page = data_parts + tracks: list[Track] = await load_tracks_from_db(int(search_id)) + + if callback.message is None: + await callback.answer("Cannot edit message") + return await callback.message.edit_reply_markup( reply_markup=inline.get_keyboard_of_tracks( tracks, - search_id, + int(search_id), int(page), ), ) + except TelegramBadRequest: + await callback.answer("Cannot edit message, please retry") except Exception: - logger.exception("Failed to send message") + logger.exception("Failed to handle pages navigation") + await callback.answer("Error occurred") def register(router: Router) -> None: diff --git a/bot/handlers/search.py b/bot/handlers/search.py index 64391b3..0e26248 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -36,6 +36,10 @@ async def update_search( async def search_handler(message: types.Message, user: User) -> None: """Handle the search.""" try: + if message.text is None: + await message.answer(gettext("search_query_error")) + return + keyword = message.text.strip() if not keyword or len(keyword) > MAX_KEYWORD_LENGTH: await message.answer(gettext("search_query_error")) @@ -71,9 +75,18 @@ async def track_lists_handler( user: User, ) -> None: """Handle the track lists.""" + if callback.data is None: + await callback.answer("Invalid data") + return + _, _, list_type = callback.data.split(":") tracks = await get_track_list(list_type) search = await update_search(user, list_type, tracks) + + if callback.message is None: + await callback.answer("Cannot send message") + return + await callback.message.answer( gettext(list_type), reply_markup=inline.get_keyboard_of_tracks(tracks, search.id), diff --git a/bot/handlers/subscribe.py b/bot/handlers/subscribe.py index 4a32ddb..594235f 100644 --- a/bot/handlers/subscribe.py +++ b/bot/handlers/subscribe.py @@ -20,13 +20,19 @@ async def sub_required_handler( ) -> None: """Subscription required handler.""" try: - required_chats = await CRUD(RequiredSubscriptions).get_all() + required_chats: list[RequiredSubscriptions] = await CRUD( + RequiredSubscriptions, + ).get_all() text = gettext("not_subscribed") keyboard = inline.get_subscribe_keyboard(gettext, required_chats) if isinstance(event, types.Message): await event.answer(text, reply_markup=keyboard) else: + if event.message is None: + await event.answer("Cannot send message") + return + await event.message.answer(text, reply_markup=keyboard) except Exception: logger.exception("Failed to send message") @@ -41,8 +47,16 @@ async def sub_check_handler( sub_check = NotSubbedFilter() if await sub_check(callback, user, bot): + if callback.message is None: + await callback.answer("Cannot send message") + return + await callback.message.answer(gettext("not_subscribed")) else: + if callback.message is None: + await callback.answer("Cannot delete message") + return + await callback.message.delete() diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index f362dcb..b62000e 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -1,13 +1,12 @@ """Auth middleware module for the bot.""" import logging -import time from collections.abc import Awaitable from datetime import datetime from typing import Any, Callable from aiogram import BaseMiddleware -from aiogram.types import Update +from aiogram.types import TelegramObject from database.crud import CRUD from database.models import User @@ -20,8 +19,8 @@ class AuthMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[Update, dict[str, Any]], Awaitable[Any]], - event: Update, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, data: dict[str, Any], ) -> Awaitable[Any]: """Intercept incoming updates, process them and call the next.""" @@ -61,7 +60,7 @@ async def _get_or_create_user( user_data: dict[str, Any], ) -> User: """Get existing user or create new one.""" - db_user = await user_crud.get(id=user.id) + db_user: User | None = await user_crud.get(id=user.id) if not db_user: user_data["language_code"] = user.language_code @@ -69,7 +68,7 @@ async def _get_or_create_user( logger.info("User %s registered in the database.", user.id) else: - user_data["updated_at"] = datetime.now(time.tzname) + user_data["updated_at"] = datetime.now().astimezone() db_user = await user_crud.update(db_user, **user_data) logger.info("User %s updated in the database.", user.id) diff --git a/bot/middlewares/i18n_middleware.py b/bot/middlewares/i18n_middleware.py index a7cce12..6992e56 100644 --- a/bot/middlewares/i18n_middleware.py +++ b/bot/middlewares/i18n_middleware.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from aiogram.types import Message +from aiogram.types import TelegramObject from aiogram.utils.i18n.middleware import I18nMiddleware as BaseI18nMiddleware if TYPE_CHECKING: @@ -12,7 +12,7 @@ class I18nMiddleware(BaseI18nMiddleware): """Custom i18n middleware for the bot.""" - async def get_locale(self, _: Message, data: dict) -> str: + async def get_locale(self, event: TelegramObject, data: dict) -> str: # noqa: ARG002 """Get user locale.""" user: User = data["user"] - return user.language_code + return user.language_code or "en" diff --git a/bot/utils.py b/bot/utils.py index 9abd1c5..808a439 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -9,4 +9,8 @@ async def load_tracks_from_db(search_id: int) -> list[Track]: """Get tracks from the database.""" search_history_crud = CRUD(SearchHistory) search: SearchHistory = await search_history_crud.get(id=int(search_id)) - return [Track(**track) for track in search.tracks] + + if search is None or not hasattr(search, "tracks"): + return [] + + return [Track.from_dict(track) for track in search.tracks] diff --git a/database/crud.py b/database/crud.py index ee85839..6ba2cdb 100644 --- a/database/crud.py +++ b/database/crud.py @@ -1,23 +1,29 @@ -"""CRUD operations.""" +"""Database CRUD operations.""" + +from __future__ import annotations import logging -from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import sessionmaker from .engine import async_session_factory +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.orm import sessionmaker + + T = TypeVar("T") logger = logging.getLogger(__name__) -class CRUD: +class CRUD(Generic[T]): """Generic class to handle CRUD operations for any model.""" def __init__( @@ -59,7 +65,7 @@ async def create(self, **kwargs: Any) -> T: # noqa: ANN401 raise return instance - async def get(self, **kwargs: Any) -> T: # noqa: ANN401 + async def get(self, **kwargs: Any) -> T | None: # noqa: ANN401 """Retrieve a record by any field.""" async with self.get_session() as session: query = await session.execute( @@ -71,7 +77,7 @@ async def get_all(self) -> list[T]: """Get all records.""" async with self.get_session() as session: query = await session.execute(select(self.model)) - return query.scalars().all() + return list(query.scalars().all()) async def update(self, instance: T, **kwargs: Any) -> T: # noqa: ANN401 """Update a record's information.""" diff --git a/database/engine.py b/database/engine.py index 2c89528..ccc4c93 100644 --- a/database/engine.py +++ b/database/engine.py @@ -2,9 +2,12 @@ import logging -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker from configs import db_config @@ -13,8 +16,8 @@ engine = create_async_engine(db_config.url, future=True) Base = declarative_base() -async_session_factory = sessionmaker( - bind=engine, +async_session_factory = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, diff --git a/database/models/required_subs.py b/database/models/required_subs.py index e52c101..897f8d0 100644 --- a/database/models/required_subs.py +++ b/database/models/required_subs.py @@ -14,11 +14,14 @@ class RequiredSubscriptions(Base): __tablename__ = "required_subscriptions" id: Mapped[int] = mapped_column( - Integer, primary_key=True, autoincrement=True + Integer, + primary_key=True, + autoincrement=True, ) chat_id: Mapped[int] = mapped_column(BigInteger) chat_title: Mapped[str] = mapped_column(String) chat_link: Mapped[str] = mapped_column(String) created_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.now + DateTime, + default=datetime.now, ) diff --git a/database/models/search_history.py b/database/models/search_history.py index 5708672..8b1a16f 100644 --- a/database/models/search_history.py +++ b/database/models/search_history.py @@ -23,11 +23,14 @@ class SearchHistory(Base): __tablename__ = "search_history" id: Mapped[int] = mapped_column( - Integer, primary_key=True, autoincrement=True + Integer, + primary_key=True, + autoincrement=True, ) user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id")) keyword: Mapped[str | None] = mapped_column(String, default=None) tracks: Mapped[list[dict[str, str]]] = mapped_column(JSONB, default=[]) created_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.now + DateTime, + default=datetime.now, ) diff --git a/database/models/user.py b/database/models/user.py index 30238e8..ba7367d 100644 --- a/database/models/user.py +++ b/database/models/user.py @@ -16,7 +16,9 @@ class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column( - BigInteger, primary_key=True, autoincrement=True + BigInteger, + primary_key=True, + autoincrement=True, ) username: Mapped[str | None] = mapped_column(String, default=None) first_name: Mapped[str | None] = mapped_column(String, default=None) @@ -25,8 +27,10 @@ class User(Base): state: Mapped[str | None] = mapped_column(String, default=None) search_queries: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.now + DateTime, + default=datetime.now, ) updated_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.now + DateTime, + default=datetime.now, ) diff --git a/locales/_support_languages.py b/locales/_support_languages.py index 6f6dfe9..9a25667 100644 --- a/locales/_support_languages.py +++ b/locales/_support_languages.py @@ -1,5 +1,7 @@ """Supported languages.""" +from __future__ import annotations + from dataclasses import dataclass @@ -17,8 +19,10 @@ class LanguageList: languages: list[Language] - def is_supported(self, code: str) -> bool: + def is_supported(self, code: str | None) -> bool: """Check if the language is supported.""" + if code is None: + return False return any(language.code == code for language in self.languages) diff --git a/main.py b/main.py index f7897c5..5939d15 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import logging from aiogram import Bot, Dispatcher +from aiogram.client.bot import DefaultBotProperties from aiogram.enums import ParseMode from aiogram.fsm.storage.memory import MemoryStorage from aiogram.utils.i18n import I18n @@ -26,15 +27,17 @@ async def create_bot() -> Bot: if bot_config.token: bot = Bot( token=bot_config.token, - default_parse_mode=ParseMode.HTML, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), ) logger.info("Successfully created bot instance.") + return bot + logger.error("No token provided") + msg = "No token provided" + raise ValueError(msg) except TokenValidationError: logger.exception("Invalid token provided: %s", bot_config.token) raise - return bot - async def main() -> None: """Start the bot application.""" diff --git a/service/core.py b/service/core.py index 3e9c3de..c24626d 100644 --- a/service/core.py +++ b/service/core.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal import aiohttp +from aiohttp import ClientTimeout from bs4 import BeautifulSoup from typing_extensions import Self @@ -92,10 +93,13 @@ async def _parse_tracks( try: if not self._session: await self.connect() + if not self._session: + msg = "Failed to initialize session" + raise MusicServiceError(msg) async with self._session.get( url, - timeout=self._config.timeout, + timeout=ClientTimeout(total=self._config.timeout), ) as response: response.raise_for_status() soup = BeautifulSoup(await response.text(), "html.parser") @@ -134,13 +138,16 @@ async def _download_data( if not self._session: await self.connect() + if not self._session: + msg = "Failed to initialize session" + raise MusicServiceError(msg) logger.info("Downloading %s for track: %s", resource_type, track_name) try: async with self._session.get( url, - timeout=self._config.timeout, + timeout=ClientTimeout(total=self._config.timeout), ) as response: response.raise_for_status() content_length = response.content_length diff --git a/service/data.py b/service/data.py index 3118038..26bf5f6 100644 --- a/service/data.py +++ b/service/data.py @@ -1,10 +1,13 @@ """Data classes.""" +from __future__ import annotations + import json from dataclasses import dataclass, field from pathlib import Path +from typing import Any -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, Tag headers_path = Path(__file__).parent / "headers.json" @@ -30,22 +33,60 @@ class Track: audio_url: str thumbnail_url: str + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Track: + """Create Track from a dictionary.""" + return cls( + index=int(data["index"]), + name=data["name"], + title=data["title"], + performer=data["performer"], + audio_url=data["audio_url"], + thumbnail_url=data["thumbnail_url"], + ) + @classmethod def from_element( cls, - element: BeautifulSoup, + element: BeautifulSoup | Tag, index: int, *, is_search: bool = False, - ) -> "Track": + ) -> Track: """Create Track from BeautifulSoup element.""" - full_name = element.find(class_="artist_name").text.strip() - performer, title = full_name.split(" - ", 1) - audio_url = element.find(class_="right").get("data-id") - thumbnail_element = element.find( - class_="little_thumb" if is_search else "resim_thumb", - ) - thumbnail_url = thumbnail_element.find("img").get("data-src") + artist_name_element = element.find(class_="artist_name") + if artist_name_element is None or not hasattr( + artist_name_element, + "text", + ): + msg = "Could not find artist name element" + raise TypeError(msg) + + full_name = artist_name_element.text.strip() + if " - " not in full_name: + performer, title = "Unknown Artist", full_name + else: + performer, title = full_name.split(" - ", 1) + + right_element = element.find(class_="right") + if not isinstance(right_element, Tag): + msg = "Could not find audio URL element" + raise TypeError(msg) + audio_url = right_element.get("data-id", "") + + class_name = "little_thumb" if is_search else "resim_thumb" + thumbnail_element = element.find(class_=class_name) + if not isinstance(thumbnail_element, Tag): + msg = "Could not find thumbnail element" + raise TypeError(msg) + + img_element = thumbnail_element.find("img") + if not isinstance(img_element, Tag): + msg = "Could not find image element" + raise TypeError(msg) + + thumbnail_url = img_element.get("data-src", "") + return cls( index=index, name=full_name, From a3bf5c47bd4386718435d3be017ce25b0c16343e Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 23:22:37 +0300 Subject: [PATCH 07/13] refactor: improve error handling and type annotations across multiple files --- bot/handlers/faq.py | 5 ++++- bot/handlers/language.py | 5 ++++- bot/handlers/menu.py | 5 ++++- bot/handlers/pages.py | 7 +++++-- bot/handlers/search.py | 5 ++++- bot/handlers/subscribe.py | 19 ++++++++++--------- bot/utils.py | 4 +++- database/crud.py | 7 ++++--- main.py | 1 + service/data.py | 4 ++-- 10 files changed, 41 insertions(+), 21 deletions(-) diff --git a/bot/handlers/faq.py b/bot/handlers/faq.py index e92f61d..d4f053b 100644 --- a/bot/handlers/faq.py +++ b/bot/handlers/faq.py @@ -13,7 +13,10 @@ async def faq_handler(callback: types.CallbackQuery) -> None: """FAQ handler.""" try: - if callback.message is None: + if callback.message is None or isinstance( + callback.message, + types.InaccessibleMessage, + ): await callback.answer("Cannot edit message") return diff --git a/bot/handlers/language.py b/bot/handlers/language.py index 6537522..b9c2806 100644 --- a/bot/handlers/language.py +++ b/bot/handlers/language.py @@ -36,7 +36,10 @@ async def language_handler( keyboard = inline.language_keyboard if isinstance(event, types.CallbackQuery): - if event.message is None: + if event.message is None or isinstance( + event.message, + types.InaccessibleMessage, + ): await event.answer("Cannot edit message") return diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 21e2bbb..3c9492b 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -22,7 +22,10 @@ async def menu_handler( keyboard = inline.get_menu_keyboard(gettext) if isinstance(event, types.CallbackQuery): - if event.message is None: + if event.message is None or isinstance( + event.message, + types.InaccessibleMessage, + ): await event.answer("Cannot edit message") return diff --git a/bot/handlers/pages.py b/bot/handlers/pages.py index 1fa1fc3..53e7030 100644 --- a/bot/handlers/pages.py +++ b/bot/handlers/pages.py @@ -23,14 +23,17 @@ async def pages_handler(callback: types.CallbackQuery) -> None: return data_parts = callback.data.split(":") - if len(data_parts) < 4: + if len(data_parts) < 4: # noqa: PLR2004 await callback.answer("Invalid data format") return _, _, search_id, page = data_parts tracks: list[Track] = await load_tracks_from_db(int(search_id)) - if callback.message is None: + if callback.message is None or isinstance( + callback.message, + types.InaccessibleMessage, + ): await callback.answer("Cannot edit message") return diff --git a/bot/handlers/search.py b/bot/handlers/search.py index 0e26248..650d3da 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -83,7 +83,10 @@ async def track_lists_handler( tracks = await get_track_list(list_type) search = await update_search(user, list_type, tracks) - if callback.message is None: + if callback.message is None or isinstance( + callback.message, + types.InaccessibleMessage, + ): await callback.answer("Cannot send message") return diff --git a/bot/handlers/subscribe.py b/bot/handlers/subscribe.py index 594235f..f873519 100644 --- a/bot/handlers/subscribe.py +++ b/bot/handlers/subscribe.py @@ -29,7 +29,10 @@ async def sub_required_handler( if isinstance(event, types.Message): await event.answer(text, reply_markup=keyboard) else: - if event.message is None: + if event.message is None or isinstance( + event.message, + types.InaccessibleMessage, + ): await event.answer("Cannot send message") return @@ -45,18 +48,16 @@ async def sub_check_handler( ) -> None: """Subscription check handler.""" sub_check = NotSubbedFilter() + if callback.message is None or isinstance( + callback.message, + types.InaccessibleMessage, + ): + await callback.answer("Cannot send message") + return if await sub_check(callback, user, bot): - if callback.message is None: - await callback.answer("Cannot send message") - return - await callback.message.answer(gettext("not_subscribed")) else: - if callback.message is None: - await callback.answer("Cannot delete message") - return - await callback.message.delete() diff --git a/bot/utils.py b/bot/utils.py index 808a439..cfda93d 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -8,7 +8,9 @@ async def load_tracks_from_db(search_id: int) -> list[Track]: """Get tracks from the database.""" search_history_crud = CRUD(SearchHistory) - search: SearchHistory = await search_history_crud.get(id=int(search_id)) + search: SearchHistory | None = await search_history_crud.get( + id=int(search_id), + ) if search is None or not hasattr(search, "tracks"): return [] diff --git a/database/crud.py b/database/crud.py index 6ba2cdb..c93b449 100644 --- a/database/crud.py +++ b/database/crud.py @@ -14,8 +14,7 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator - from sqlalchemy.ext.asyncio import AsyncSession - from sqlalchemy.orm import sessionmaker + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker T = TypeVar("T") @@ -29,7 +28,9 @@ class CRUD(Generic[T]): def __init__( self, model: type[T], - session_factory: sessionmaker = async_session_factory, + session_factory: async_sessionmaker[ + AsyncSession + ] = async_session_factory, ) -> None: """Initialize the CRUD class.""" self.model = model diff --git a/main.py b/main.py index 5939d15..5175b5c 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +# type: ignore[reportPrivateImportUsage] """Entry point of the bot application.""" import asyncio diff --git a/service/data.py b/service/data.py index 26bf5f6..da0f2fa 100644 --- a/service/data.py +++ b/service/data.py @@ -92,6 +92,6 @@ def from_element( name=full_name, title=title, performer=performer, - audio_url=audio_url, - thumbnail_url=thumbnail_url, + audio_url=str(audio_url), + thumbnail_url=str(thumbnail_url), ) From c8fa09e4029762fb601537710769d911b9d9c3e0 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 23:35:46 +0300 Subject: [PATCH 08/13] refactor: enhance error messaging and localization support across multiple handlers --- bot/handlers/faq.py | 2 +- bot/handlers/get_track.py | 6 +++--- bot/handlers/language.py | 2 +- bot/handlers/menu.py | 2 +- bot/handlers/pages.py | 11 ++++++----- bot/handlers/search.py | 4 ++-- bot/handlers/subscribe.py | 4 ++-- locales/en/LC_MESSAGES/messages.po | 15 +++++++++++++++ locales/ru/LC_MESSAGES/messages.po | 15 +++++++++++++++ 9 files changed, 46 insertions(+), 15 deletions(-) diff --git a/bot/handlers/faq.py b/bot/handlers/faq.py index d4f053b..069e3b6 100644 --- a/bot/handlers/faq.py +++ b/bot/handlers/faq.py @@ -17,7 +17,7 @@ async def faq_handler(callback: types.CallbackQuery) -> None: callback.message, types.InaccessibleMessage, ): - await callback.answer("Cannot edit message") + await callback.answer(gettext("cannot_edit_message")) return await callback.message.edit_text( diff --git a/bot/handlers/get_track.py b/bot/handlers/get_track.py index d9bb0fd..6054244 100644 --- a/bot/handlers/get_track.py +++ b/bot/handlers/get_track.py @@ -20,7 +20,7 @@ async def send_track( """Send track.""" try: if callback.message is None or callback.message.chat is None: - await callback.answer("Cannot access chat") + await callback.answer(gettext("cannot_access_chat")) return await bot.send_chat_action(callback.message.chat.id, "upload_document") @@ -60,7 +60,7 @@ async def get_track_handler(callback: types.CallbackQuery, bot: Bot) -> None: """Get track handler.""" try: if callback.data is None: - await callback.answer("Invalid data") + await callback.answer(gettext("invalid_data")) return _, _, search_id_str, index = callback.data.split(":") @@ -81,7 +81,7 @@ async def get_all_from_page_handler( """Get all tracks from page handler.""" try: if callback.data is None: - await callback.answer("Invalid data") + await callback.answer(gettext("invalid_data")) return _, _, search_id_str, start_indx, end_indx = callback.data.split(":") diff --git a/bot/handlers/language.py b/bot/handlers/language.py index b9c2806..00d4076 100644 --- a/bot/handlers/language.py +++ b/bot/handlers/language.py @@ -40,7 +40,7 @@ async def language_handler( event.message, types.InaccessibleMessage, ): - await event.answer("Cannot edit message") + await event.answer(gettext("cannot_edit_message")) return await event.message.edit_text(text, reply_markup=keyboard) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 3c9492b..2e83469 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -26,7 +26,7 @@ async def menu_handler( event.message, types.InaccessibleMessage, ): - await event.answer("Cannot edit message") + await event.answer(gettext("cannot_edit_message")) return await event.message.edit_text(text, reply_markup=keyboard) diff --git a/bot/handlers/pages.py b/bot/handlers/pages.py index 53e7030..997d35a 100644 --- a/bot/handlers/pages.py +++ b/bot/handlers/pages.py @@ -5,6 +5,7 @@ from aiogram import F, Router, types from aiogram.exceptions import TelegramBadRequest +from aiogram.utils.i18n import gettext from bot.keyboards import inline from bot.utils import load_tracks_from_db @@ -19,12 +20,12 @@ async def pages_handler(callback: types.CallbackQuery) -> None: """Handle the pages navigation.""" try: if not callback.data: - await callback.answer("Invalid data") + await callback.answer(gettext("invalid_data")) return data_parts = callback.data.split(":") if len(data_parts) < 4: # noqa: PLR2004 - await callback.answer("Invalid data format") + await callback.answer(gettext("invalid_data")) return _, _, search_id, page = data_parts @@ -34,7 +35,7 @@ async def pages_handler(callback: types.CallbackQuery) -> None: callback.message, types.InaccessibleMessage, ): - await callback.answer("Cannot edit message") + await callback.answer(gettext("cannot_edit_message")) return await callback.message.edit_reply_markup( @@ -45,10 +46,10 @@ async def pages_handler(callback: types.CallbackQuery) -> None: ), ) except TelegramBadRequest: - await callback.answer("Cannot edit message, please retry") + await callback.answer(gettext("cannot_edit_message")) except Exception: logger.exception("Failed to handle pages navigation") - await callback.answer("Error occurred") + await callback.answer(gettext("error_occurred")) def register(router: Router) -> None: diff --git a/bot/handlers/search.py b/bot/handlers/search.py index 650d3da..0036754 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -76,7 +76,7 @@ async def track_lists_handler( ) -> None: """Handle the track lists.""" if callback.data is None: - await callback.answer("Invalid data") + await callback.answer(gettext("invalid_data")) return _, _, list_type = callback.data.split(":") @@ -87,7 +87,7 @@ async def track_lists_handler( callback.message, types.InaccessibleMessage, ): - await callback.answer("Cannot send message") + await callback.answer(gettext("cannot_send_message")) return await callback.message.answer( diff --git a/bot/handlers/subscribe.py b/bot/handlers/subscribe.py index f873519..243fc43 100644 --- a/bot/handlers/subscribe.py +++ b/bot/handlers/subscribe.py @@ -33,7 +33,7 @@ async def sub_required_handler( event.message, types.InaccessibleMessage, ): - await event.answer("Cannot send message") + await event.answer(gettext("cannot_send_message")) return await event.message.answer(text, reply_markup=keyboard) @@ -52,7 +52,7 @@ async def sub_check_handler( callback.message, types.InaccessibleMessage, ): - await callback.answer("Cannot send message") + await callback.answer(gettext("cannot_send_message")) return if await sub_check(callback, user, bot): diff --git a/locales/en/LC_MESSAGES/messages.po b/locales/en/LC_MESSAGES/messages.po index 1b22ecf..4db685d 100644 --- a/locales/en/LC_MESSAGES/messages.po +++ b/locales/en/LC_MESSAGES/messages.po @@ -88,3 +88,18 @@ msgstr "❗️ To use the bot, you need to subscribe to the channels." msgid "sub_check_button" msgstr "✅ I'm subscribed" + +msgid "cannot_edit_message" +msgstr "❌ Failed to edit message." + +msgid "cannot_send_message" +msgstr "❌ Failed to send message." + +msgid "invalid_data" +msgstr "❌ Invalid data." + +msgid "cannot_access_chat" +msgstr "❌ Failed to access chat." + +msgid "error_occurred" +msgstr "❌ Error occurred." \ No newline at end of file diff --git a/locales/ru/LC_MESSAGES/messages.po b/locales/ru/LC_MESSAGES/messages.po index 8aa3ce2..675ee02 100644 --- a/locales/ru/LC_MESSAGES/messages.po +++ b/locales/ru/LC_MESSAGES/messages.po @@ -89,3 +89,18 @@ msgstr "❗️ Для использования бота необходимо msgid "sub_check_button" msgstr "✅ Я подписан" + +msgid "cannot_edit_message" +msgstr "❌ Не удалось отредактировать сообщение." + +msgid "cannot_send_message" +msgstr "❌ Не удалось отправить сообщение." + +msgid "invalid_data" +msgstr "❌ Некорректные данные." + +msgid "cannot_access_chat" +msgstr "❌ Не удалось получить доступ к чату." + +msgid "error_occurred" +msgstr "❌ Произошла ошибка." \ No newline at end of file From 2a0cbcb6ee31ee3b3ab1f1f1b491cfa199c6c785 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Sun, 3 Aug 2025 23:45:21 +0300 Subject: [PATCH 09/13] fix: update timestamp handling in AuthMiddleware to use local time --- bot/middlewares/auth_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index b62000e..349d3f1 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -68,7 +68,7 @@ async def _get_or_create_user( logger.info("User %s registered in the database.", user.id) else: - user_data["updated_at"] = datetime.now().astimezone() + user_data["updated_at"] = datetime.now() db_user = await user_crud.update(db_user, **user_data) logger.info("User %s updated in the database.", user.id) From 38413795751fd187723e22b42f3812c166f540f1 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Mon, 4 Aug 2025 00:01:18 +0300 Subject: [PATCH 10/13] refactor: streamline bot creation logic and enhance code readability across multiple files --- bot/filters/language.py | 1 + bot/middlewares/auth_middleware.py | 2 +- bot/middlewares/i18n_middleware.py | 6 +++++- bot/utils.py | 2 +- database/models/required_subs.py | 2 ++ database/models/search_history.py | 2 ++ database/models/user.py | 2 ++ locales/_support_languages.py | 5 ++++- main.py | 19 ++++++++----------- service/data.py | 4 +++- 10 files changed, 29 insertions(+), 16 deletions(-) diff --git a/bot/filters/language.py b/bot/filters/language.py index 2e0298c..042aa0d 100644 --- a/bot/filters/language.py +++ b/bot/filters/language.py @@ -26,4 +26,5 @@ async def __call__( language_code = user.language_code if language_code is None: return True + return not support_languages.is_supported(language_code) diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py index 349d3f1..303320d 100644 --- a/bot/middlewares/auth_middleware.py +++ b/bot/middlewares/auth_middleware.py @@ -68,7 +68,7 @@ async def _get_or_create_user( logger.info("User %s registered in the database.", user.id) else: - user_data["updated_at"] = datetime.now() + user_data["updated_at"] = datetime.now() # noqa: DTZ005 db_user = await user_crud.update(db_user, **user_data) logger.info("User %s updated in the database.", user.id) diff --git a/bot/middlewares/i18n_middleware.py b/bot/middlewares/i18n_middleware.py index 6992e56..0459112 100644 --- a/bot/middlewares/i18n_middleware.py +++ b/bot/middlewares/i18n_middleware.py @@ -12,7 +12,11 @@ class I18nMiddleware(BaseI18nMiddleware): """Custom i18n middleware for the bot.""" - async def get_locale(self, event: TelegramObject, data: dict) -> str: # noqa: ARG002 + async def get_locale( + self, + event: TelegramObject, # noqa: ARG002 + data: dict, + ) -> str: """Get user locale.""" user: User = data["user"] return user.language_code or "en" diff --git a/bot/utils.py b/bot/utils.py index cfda93d..a938477 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -12,7 +12,7 @@ async def load_tracks_from_db(search_id: int) -> list[Track]: id=int(search_id), ) - if search is None or not hasattr(search, "tracks"): + if not search: return [] return [Track.from_dict(track) for track in search.tracks] diff --git a/database/models/required_subs.py b/database/models/required_subs.py index 897f8d0..e8a6a78 100644 --- a/database/models/required_subs.py +++ b/database/models/required_subs.py @@ -18,9 +18,11 @@ class RequiredSubscriptions(Base): primary_key=True, autoincrement=True, ) + chat_id: Mapped[int] = mapped_column(BigInteger) chat_title: Mapped[str] = mapped_column(String) chat_link: Mapped[str] = mapped_column(String) + created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.now, diff --git a/database/models/search_history.py b/database/models/search_history.py index 8b1a16f..66575b5 100644 --- a/database/models/search_history.py +++ b/database/models/search_history.py @@ -27,9 +27,11 @@ class SearchHistory(Base): primary_key=True, autoincrement=True, ) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id")) keyword: Mapped[str | None] = mapped_column(String, default=None) tracks: Mapped[list[dict[str, str]]] = mapped_column(JSONB, default=[]) + created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.now, diff --git a/database/models/user.py b/database/models/user.py index ba7367d..bfb0899 100644 --- a/database/models/user.py +++ b/database/models/user.py @@ -25,7 +25,9 @@ class User(Base): last_name: Mapped[str | None] = mapped_column(String, default=None) language_code: Mapped[str | None] = mapped_column(String, default=None) state: Mapped[str | None] = mapped_column(String, default=None) + search_queries: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.now, diff --git a/locales/_support_languages.py b/locales/_support_languages.py index 9a25667..a0b5b2a 100644 --- a/locales/_support_languages.py +++ b/locales/_support_languages.py @@ -27,5 +27,8 @@ def is_supported(self, code: str | None) -> bool: support_languages: LanguageList = LanguageList( - languages=[Language("en", "🇬🇧 English"), Language("ru", "🇷🇺 Русский")], + languages=[ + Language("en", "🇬🇧 English"), + Language("ru", "🇷🇺 Русский"), + ], ) diff --git a/main.py b/main.py index 5175b5c..5d13a0c 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,3 @@ -# type: ignore[reportPrivateImportUsage] """Entry point of the bot application.""" import asyncio @@ -25,20 +24,18 @@ async def create_bot() -> Bot: """Create and return a Bot instance.""" try: - if bot_config.token: - bot = Bot( - token=bot_config.token, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) - logger.info("Successfully created bot instance.") - return bot - logger.error("No token provided") - msg = "No token provided" - raise ValueError(msg) + bot = Bot( + token=bot_config.token or "", + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + logger.info("Successfully created bot instance.") + except TokenValidationError: logger.exception("Invalid token provided: %s", bot_config.token) raise + return bot + async def main() -> None: """Start the bot application.""" diff --git a/service/data.py b/service/data.py index da0f2fa..2fa25ea 100644 --- a/service/data.py +++ b/service/data.py @@ -18,7 +18,9 @@ class ServiceConfig: timeout: int = 30 headers: dict = field( - default_factory=lambda: json.load(Path.open(headers_path)), + default_factory=lambda: json.load( + Path.open(headers_path, encoding="utf-8"), + ), ) From 065ccfeca72b8e5201c471f699263fc784582bf5 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Mon, 4 Aug 2025 00:07:17 +0300 Subject: [PATCH 11/13] refactor: improve error handling for session initialization in Music service --- service/core.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/service/core.py b/service/core.py index c24626d..257da31 100644 --- a/service/core.py +++ b/service/core.py @@ -59,7 +59,8 @@ async def disconnect(self) -> None: async def search(self, keyword: str) -> list[Track]: """Search for music by keyword.""" if not self._session: - await self.connect() + msg = "Failed to initialize session" + raise MusicServiceError(msg) url = urllib.parse.urljoin(self.BASE_URL, f"search/{keyword}") logger.info("Searching music with keyword: %s", keyword) @@ -92,10 +93,8 @@ async def _parse_tracks( """Parse tracks from the given URL.""" try: if not self._session: - await self.connect() - if not self._session: - msg = "Failed to initialize session" - raise MusicServiceError(msg) + msg = "Failed to initialize session" + raise MusicServiceError(msg) async with self._session.get( url, @@ -137,10 +136,8 @@ async def _download_data( max_size = 50 * 1024 * 1024 # 50MB if not self._session: - await self.connect() - if not self._session: - msg = "Failed to initialize session" - raise MusicServiceError(msg) + msg = "Failed to initialize session" + raise MusicServiceError(msg) logger.info("Downloading %s for track: %s", resource_type, track_name) From 0e3863f97cc37c983d3b5549c385d8e4a757869f Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Mon, 4 Aug 2025 01:15:23 +0300 Subject: [PATCH 12/13] feat: add IsPrivateChatFilter to handle private chat scenarios in the bot --- bot/filters/__init__.py | 3 ++- bot/filters/is_private_chat.py | 25 +++++++++++++++++++++++++ bot/handlers/__init__.py | 8 +++++++- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 bot/filters/is_private_chat.py diff --git a/bot/filters/__init__.py b/bot/filters/__init__.py index a07722b..6128a91 100644 --- a/bot/filters/__init__.py +++ b/bot/filters/__init__.py @@ -1,6 +1,7 @@ """Filters for the bot.""" +from .is_private_chat import IsPrivateChatFilter from .language import LanguageFilter from .not_subbed import NotSubbedFilter -__all__ = ["LanguageFilter", "NotSubbedFilter"] +__all__ = ["IsPrivateChatFilter", "LanguageFilter", "NotSubbedFilter"] diff --git a/bot/filters/is_private_chat.py b/bot/filters/is_private_chat.py new file mode 100644 index 0000000..3b2c406 --- /dev/null +++ b/bot/filters/is_private_chat.py @@ -0,0 +1,25 @@ +"""Filter for checking if the chat is private.""" + +from __future__ import annotations + +from aiogram.enums import ChatType +from aiogram.filters import Filter +from aiogram.types import CallbackQuery, Message + + +class IsPrivateChatFilter(Filter): + """Filter for checking if the chat is private.""" + + async def __call__( + self, + event: Message | CallbackQuery, + ) -> bool: + """Check if the chat is private.""" + if isinstance(event, CallbackQuery): + message = event.message + if not isinstance(message, Message): + return False + else: + message = event + + return message.chat.type == ChatType.PRIVATE diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index 88c0e3c..f875220 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -2,6 +2,8 @@ from aiogram import Dispatcher, Router +from bot.filters import IsPrivateChatFilter + from . import faq, get_track, language, menu, pages, search, subscribe @@ -9,13 +11,17 @@ def setup(dp: Dispatcher) -> None: """Set up handlers for the bot.""" router = Router() + # Add filters + router.message.filter(IsPrivateChatFilter()) + router.callback_query.filter(IsPrivateChatFilter()) + # Register routers language.register(router) menu.register(router) faq.register(router) - subscribe.register(router) search.register(router) pages.register(router) + subscribe.register(router) get_track.register(router) dp.include_router(router) From 40b1e4dfac962b142fbc51a0156b501bd9e26f87 Mon Sep 17 00:00:00 2001 From: goldpulpy Date: Mon, 4 Aug 2025 02:21:53 +0300 Subject: [PATCH 13/13] refactor: remove unused new hits feature and update music service URLs --- README.md | 1 - bot/handlers/get_track.py | 6 --- bot/handlers/search.py | 1 - bot/keyboards/inline.py | 4 -- locales/en/LC_MESSAGES/messages.po | 6 --- locales/ru/LC_MESSAGES/messages.po | 6 --- service/core.py | 68 +++++++++++------------------- service/data.py | 41 +++++------------- service/headers.json | 13 +++--- 9 files changed, 41 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 0175e02..4d467a8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ - 🎵 Поиск и прослушивание музыки - 🎧 Высокое качество звука -- 🌅 Аудио с обложкой - 📱 Интуитивный интерфейс - 🌍 Поддержка нескольких языков (🇬🇧 English, 🇷🇺 Русский) - 🌍 Автоопределение языка пользователя diff --git a/bot/handlers/get_track.py b/bot/handlers/get_track.py index 6054244..1743147 100644 --- a/bot/handlers/get_track.py +++ b/bot/handlers/get_track.py @@ -27,13 +27,8 @@ async def send_track( async with Music() as service: audio_bytes = await service.get_audio_bytes(track) - thumbnail_bytes = await service.get_thumbnail_bytes(track) audio_file = BufferedInputFile(audio_bytes, filename=track.name) - thumbnail_file = BufferedInputFile( - thumbnail_bytes, - filename=track.name, - ) me = await bot.get_me() @@ -46,7 +41,6 @@ async def send_track( title=track.title, performer=track.performer, caption=gettext("promo_caption").format(username=me.username), - thumbnail=thumbnail_file, ) except Exception: if callback.message is not None: diff --git a/bot/handlers/search.py b/bot/handlers/search.py index 0036754..88c7161 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -65,7 +65,6 @@ async def get_track_list(list_type: str) -> list[Track]: async with Music() as service: map_list_type = { "top_hits": service.get_top_hits, - "new_hits": service.get_new_hits, } return await map_list_type[list_type]() diff --git a/bot/keyboards/inline.py b/bot/keyboards/inline.py index e781a6e..1919e8c 100644 --- a/bot/keyboards/inline.py +++ b/bot/keyboards/inline.py @@ -114,10 +114,6 @@ def get_menu_keyboard(gettext: Callable[[str], str]) -> InlineKeyboardMarkup: text=gettext("top_hits_button"), callback_data="track:list:top_hits", ), - InlineKeyboardButton( - text=gettext("new_hits_button"), - callback_data="track:list:new_hits", - ), ], [ InlineKeyboardButton( diff --git a/locales/en/LC_MESSAGES/messages.po b/locales/en/LC_MESSAGES/messages.po index 4db685d..9b713b6 100644 --- a/locales/en/LC_MESSAGES/messages.po +++ b/locales/en/LC_MESSAGES/messages.po @@ -56,12 +56,6 @@ msgstr "🔥 Top" msgid "top_hits" msgstr "🔥 Popular music" -msgid "new_hits_button" -msgstr "🆕 New tracks" - -msgid "new_hits" -msgstr "🆕 New tracks" - msgid "searching" msgstr "🔍 Searching: {keyword}" diff --git a/locales/ru/LC_MESSAGES/messages.po b/locales/ru/LC_MESSAGES/messages.po index 675ee02..5bfebe0 100644 --- a/locales/ru/LC_MESSAGES/messages.po +++ b/locales/ru/LC_MESSAGES/messages.po @@ -57,12 +57,6 @@ msgstr "🔥 Топ песен" msgid "top_hits" msgstr "🔥 Популярная музыка" -msgid "new_hits_button" -msgstr "🆕 Новинки" - -msgid "new_hits" -msgstr "🆕 Новинки музыки" - msgid "searching" msgstr "🔍 Поиск: {keyword}" diff --git a/service/core.py b/service/core.py index 257da31..26cf15a 100644 --- a/service/core.py +++ b/service/core.py @@ -3,12 +3,11 @@ from __future__ import annotations import logging -import urllib.parse -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING import aiohttp from aiohttp import ClientTimeout -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, Tag from typing_extensions import Self from .data import ServiceConfig, Track @@ -23,8 +22,7 @@ class Music: """Service for searching and downloading music.""" - BASE_URL = "https://mp3wr.com" - TRACK_DOWNLOAD_URL = "https://cdn.mp3wr.com" + BASE_URL = "vuxo7.com" def __init__(self, config: ServiceConfig | None = None) -> None: """Initialize music service with optional configuration.""" @@ -62,34 +60,16 @@ async def search(self, keyword: str) -> list[Track]: msg = "Failed to initialize session" raise MusicServiceError(msg) - url = urllib.parse.urljoin(self.BASE_URL, f"search/{keyword}") + url = self.build_search_query(keyword) logger.info("Searching music with keyword: %s", keyword) - return await self._parse_search_tracks(url) + return await self._parse_tracks(url) async def get_top_hits(self) -> list[Track]: """Get top tracks.""" - url = urllib.parse.urljoin(self.BASE_URL, "besthit") - return await self._parse_regular_tracks(url) + return await self._parse_tracks(f"https://{self.BASE_URL}") - async def get_new_hits(self) -> list[Track]: - """Get new hits.""" - url = urllib.parse.urljoin(self.BASE_URL, "newhit") - return await self._parse_regular_tracks(url) - - async def _parse_search_tracks(self, url: str) -> list[Track]: - """Parse tracks from search results.""" - return await self._parse_tracks(url, mode="search") - - async def _parse_regular_tracks(self, url: str) -> list[Track]: - """Parse tracks from regular pages.""" - return await self._parse_tracks(url, mode="regular") - - async def _parse_tracks( - self, - url: str, - mode: Literal["search", "regular"], - ) -> list[Track]: + async def _parse_tracks(self, url: str) -> list[Track]: """Parse tracks from the given URL.""" try: if not self._session: @@ -102,14 +82,16 @@ async def _parse_tracks( ) as response: response.raise_for_status() soup = BeautifulSoup(await response.text(), "html.parser") + playlist = soup.find("ul", class_="playlist") + + if not isinstance(playlist, Tag): + msg = "Could not find playlist element" + raise TypeError(msg) - is_search = mode == "search" tracks = [ - Track.from_element(track_data, index, is_search=is_search) + Track.from_element(track_data, index) for index, track_data in enumerate( - soup.find_all("item") - if is_search - else soup.find_all("li", class_="sarki-liste"), + playlist.find_all("li"), ) ] @@ -160,14 +142,14 @@ async def _download_data( async def get_audio_bytes(self, track: Track) -> bytes: """Download music file.""" - url = track.audio_url - if url.startswith("/"): - url = urllib.parse.urljoin(self.BASE_URL, track.audio_url) - return await self._download_data(url, "audio", track.name) - - async def get_thumbnail_bytes(self, track: Track) -> bytes: - """Download thumbnail image.""" - url = track.thumbnail_url - if url.startswith("/"): - url = urllib.parse.urljoin(self.BASE_URL, track.thumbnail_url) - return await self._download_data(url, "thumbnail", track.name) + return await self._download_data(track.audio_url, "audio", track.name) + + def build_search_query(self, keyword: str) -> str: + """Build search query.""" + query = keyword.strip().lower().replace(" ", "-") + try: + subdomain = query.encode("idna").decode("ascii") + except UnicodeError: + subdomain = query + + return f"https://{subdomain}.{self.BASE_URL}" diff --git a/service/data.py b/service/data.py index 2fa25ea..7f2d4bc 100644 --- a/service/data.py +++ b/service/data.py @@ -33,7 +33,6 @@ class Track: title: str performer: str audio_url: str - thumbnail_url: str @classmethod def from_dict(cls, data: dict[str, Any]) -> Track: @@ -44,7 +43,6 @@ def from_dict(cls, data: dict[str, Any]) -> Track: title=data["title"], performer=data["performer"], audio_url=data["audio_url"], - thumbnail_url=data["thumbnail_url"], ) @classmethod @@ -52,42 +50,24 @@ def from_element( cls, element: BeautifulSoup | Tag, index: int, - *, - is_search: bool = False, ) -> Track: """Create Track from BeautifulSoup element.""" - artist_name_element = element.find(class_="artist_name") - if artist_name_element is None or not hasattr( - artist_name_element, - "text", - ): + artist_name_element = element.find(class_="playlist-name-artist") + track_name_element = element.find(class_="playlist-name-title") + if artist_name_element is None or track_name_element is None: msg = "Could not find artist name element" raise TypeError(msg) - full_name = artist_name_element.text.strip() - if " - " not in full_name: - performer, title = "Unknown Artist", full_name - else: - performer, title = full_name.split(" - ", 1) + performer = artist_name_element.text.strip() + title = track_name_element.text.strip() - right_element = element.find(class_="right") - if not isinstance(right_element, Tag): - msg = "Could not find audio URL element" - raise TypeError(msg) - audio_url = right_element.get("data-id", "") - - class_name = "little_thumb" if is_search else "resim_thumb" - thumbnail_element = element.find(class_=class_name) - if not isinstance(thumbnail_element, Tag): - msg = "Could not find thumbnail element" - raise TypeError(msg) + full_name = f"{performer} - {title}" - img_element = thumbnail_element.find("img") - if not isinstance(img_element, Tag): - msg = "Could not find image element" + audio_url = element.find(class_="playlist-play") + if not isinstance(audio_url, Tag): + msg = "Could not find audio URL element" raise TypeError(msg) - - thumbnail_url = img_element.get("data-src", "") + audio_url = audio_url.get("data-url", "") return cls( index=index, @@ -95,5 +75,4 @@ def from_element( title=title, performer=performer, audio_url=str(audio_url), - thumbnail_url=str(thumbnail_url), ) diff --git a/service/headers.json b/service/headers.json index 3271380..fe0cfe2 100644 --- a/service/headers.json +++ b/service/headers.json @@ -1,12 +1,11 @@ { - "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "referer": "https://mp3wr.com", - "sec-ch-ua": "Google Chrome;v=131, Chromium;v=131, Not_A Brand;v=24", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + "sec-ch-ua": "'Not)A;Brand';v='8', 'Chromium';v='138', 'Google Chrome';v='138'", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "Linux", - "sec-fetch-dest": "document", - "sec-fetch-mode": "navigate", - "sec-fetch-site": "same-site", - "sec-fetch-user": "?1", + "sec-fetch-dest": "audio", + "sec-fetch-mode": "no-cors", + "sec-fetch-site": "cross-site", + "sec-fetch-storage-access": "active", "upgrade-insecure-requests": "1" }