diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py index 74584f7..f85acee 100644 --- a/MusicBot/cogs/general.py +++ b/MusicBot/cogs/general.py @@ -9,8 +9,8 @@ from yandex_music.exceptions import UnauthorizedError from yandex_music import ClientAsync as YMClient from MusicBot.ui import ListenView -from MusicBot.database import BaseUsersDatabase, BaseGuildsDatabase -from MusicBot.cogs.utils import generate_item_embed +from MusicBot.database import BaseUsersDatabase +from MusicBot.cogs.utils import BaseBot, generate_item_embed users_db = BaseUsersDatabase() @@ -22,8 +22,7 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]: return [] uid = ctx.interaction.user.id - token = await users_db.get_ym_token(uid) - if not token: + if not (token := await users_db.get_ym_token(uid)): logging.info(f"[GENERAL] User {uid} has no token") return [] @@ -33,15 +32,13 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]: logging.info(f"[GENERAL] User {uid} provided invalid token") return [] - content_type = ctx.options['тип'] - search = await client.search(ctx.value) - if not search: + if not (search := await client.search(ctx.value)): logging.warning(f"[GENERAL] Failed to search for '{ctx.value}' for user {uid}") return [] logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}") - if content_type not in ('Трек', 'Альбом', 'Артист', 'Плейлист'): + if (content_type := ctx.options['тип']) not in ('Трек', 'Альбом', 'Артист', 'Плейлист'): logging.error(f"[GENERAL] Invalid content type '{content_type}' for user {uid}") return [] @@ -64,8 +61,7 @@ async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> li return [] uid = ctx.interaction.user.id - token = await users_db.get_ym_token(uid) - if not token: + if not (token := await users_db.get_ym_token(uid)): logging.info(f"[GENERAL] User {uid} has no token") return [] @@ -84,12 +80,10 @@ async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> li return [playlist.title for playlist in playlists_list if playlist.title and ctx.value in playlist.title][:100] -class General(Cog): +class General(Cog, BaseBot): def __init__(self, bot: discord.Bot): - self.bot = bot - self.db = BaseGuildsDatabase() - self.users_db = users_db + BaseBot.__init__(self, bot) account = discord.SlashCommandGroup("account", "Команды, связанные с аккаунтом.") @@ -174,9 +168,10 @@ class General(Cog): await ctx.respond(embed=embed, ephemeral=True) @account.command(description="Ввести токен Яндекс Музыки.") - @discord.option("token", type=discord.SlashCommandOptionType.string, description="Токен.") + @discord.option("token", type=discord.SlashCommandOptionType.string, description="Токен для доступа к API Яндекс Музыки.") async def login(self, ctx: discord.ApplicationContext, token: str) -> None: logging.info(f"[GENERAL] Login command invoked by user {ctx.author.id} in guild {ctx.guild_id}") + try: client = await YMClient(token).init() except UnauthorizedError: @@ -192,35 +187,31 @@ class General(Cog): await self.users_db.update(ctx.author.id, {'ym_token': token}) await ctx.respond(f'✅ Привет, {client.me.account.first_name}!', delete_after=15, ephemeral=True) + self._ym_clients[token] = client logging.info(f"[GENERAL] User {ctx.author.id} logged in successfully") @account.command(description="Удалить токен из базы данных бота.") async def remove(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[GENERAL] Remove command invoked by user {ctx.author.id} in guild {ctx.guild_id}") - if not await self.users_db.get_ym_token(ctx.user.id): + + if not (token := await self.users_db.get_ym_token(ctx.user.id)): logging.info(f"[GENERAL] No token found for user {ctx.author.id}") await ctx.respond('❌ Токен не указан.', delete_after=15, ephemeral=True) return + if token in self._ym_clients: + del self._ym_clients[token] + await self.users_db.update(ctx.user.id, {'ym_token': None}) - await ctx.respond(f'✅ Токен был удалён.', delete_after=15, ephemeral=True) logging.info(f"[GENERAL] Token removed for user {ctx.author.id}") + await ctx.respond(f'✅ Токен был удалён.', delete_after=15, ephemeral=True) + @account.command(description="Получить плейлист «Мне нравится»") async def likes(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[GENERAL] Likes command invoked by user {ctx.author.id} in guild {ctx.guild_id}") - token = await self.users_db.get_ym_token(ctx.user.id) - if not token: - logging.info(f"[GENERAL] No token found for user {ctx.user.id}") - await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) - return - - try: - client = await YMClient(token).init() - except UnauthorizedError: - logging.info(f"[GENERAL] Invalid token for user {ctx.user.id}") - await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True) + if not (client := await self.init_ym_client(ctx)): return try: @@ -262,16 +253,7 @@ class General(Cog): # NOTE: Recommendations can be accessed by using /find, but it's more convenient to have it in separate command. logging.debug(f"[GENERAL] Recommendations command invoked by user {ctx.user.id} in guild {ctx.guild_id} for type '{content_type}'") - token = await self.users_db.get_ym_token(ctx.user.id) - if not token: - await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) - return - - try: - client = await YMClient(token).init() - except UnauthorizedError: - logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token") - await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True) + if not (client := await self.init_ym_client(ctx)): return search = await client.search(content_type, type_='playlist') @@ -280,13 +262,11 @@ class General(Cog): await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) return - playlist = search.playlists.results[0] - if playlist is None: + if (playlist := search.playlists.results[0]) is None: logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}") await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) - tracks = await playlist.fetch_tracks_async() - if not tracks: + if not await playlist.fetch_tracks_async(): logging.info(f"[GENERAL] User {ctx.user.id} search for '{content_type}' returned no tracks") await ctx.respond("❌ Пустой плейлист.", delete_after=15, ephemeral=True) return @@ -304,17 +284,7 @@ class General(Cog): async def playlist(self, ctx: discord.ApplicationContext, name: str) -> None: logging.info(f"[GENERAL] Playlist command invoked by user {ctx.user.id} in guild {ctx.guild_id}") - token = await self.users_db.get_ym_token(ctx.user.id) - if not token: - logging.info(f"[GENERAL] No token found for user {ctx.user.id}") - await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) - return - - try: - client = await YMClient(token).init() - except UnauthorizedError: - logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token") - await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True) + if not (client := await self.init_ym_client(ctx)): return try: @@ -324,14 +294,12 @@ class General(Cog): await ctx.respond("❌ Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True) return - playlist = next((playlist for playlist in playlists if playlist.title == name), None) - if not playlist: + if not (playlist := next((playlist for playlist in playlists if playlist.title == name), None)): logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' not found") await ctx.respond("❌ Плейлист не найден.", delete_after=15, ephemeral=True) return - tracks = await playlist.fetch_tracks_async() - if not tracks: + if not await playlist.fetch_tracks_async(): logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' is empty") await ctx.respond("❌ Плейлист пуст.", delete_after=15, ephemeral=True) return @@ -361,21 +329,10 @@ class General(Cog): ) -> None: logging.info(f"[GENERAL] Find command invoked by user {ctx.user.id} in guild {ctx.guild_id} for '{content_type}' with name '{name}'") - token = await self.users_db.get_ym_token(ctx.user.id) - if not token: - logging.info(f"[GENERAL] No token found for user {ctx.user.id}") - await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) + if not (client := await self.init_ym_client(ctx)): return - try: - client = await YMClient(token).init() - except UnauthorizedError: - logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token") - await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True) - return - - search_result = await client.search(name, nocorrect=True) - if not search_result: + if not (search_result := await client.search(name, nocorrect=True)): logging.warning(f"Failed to search for '{name}' for user {ctx.user.id}") await ctx.respond("❌ Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True) return diff --git a/MusicBot/cogs/utils/__init__.py b/MusicBot/cogs/utils/__init__.py index 59224b3..5d21eee 100644 --- a/MusicBot/cogs/utils/__init__.py +++ b/MusicBot/cogs/utils/__init__.py @@ -1,8 +1,9 @@ from .embeds import generate_item_embed -from .voice_extension import VoiceExtension, menu_views +from .voice_extension import VoiceExtension +from .base_bot import BaseBot __all__ = [ "generate_item_embed", "VoiceExtension", - "menu_views" + "BaseBot" ] \ No newline at end of file diff --git a/MusicBot/cogs/utils/base_bot.py b/MusicBot/cogs/utils/base_bot.py new file mode 100644 index 0000000..be8c344 --- /dev/null +++ b/MusicBot/cogs/utils/base_bot.py @@ -0,0 +1,177 @@ +import asyncio +import logging +from typing import Any, Literal, cast + +import yandex_music.exceptions +from yandex_music import ClientAsync as YMClient + +import discord +from discord.ui import View +from discord import Interaction, ApplicationContext, RawReactionActionEvent + +from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase + +class BaseBot: + + menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks. + _ym_clients: dict[str, YMClient] = {} # Store YM clients to prevent creating new ones for each command. + + def __init__(self, bot: discord.Bot | None) -> None: + self.bot = bot + self.db = VoiceGuildsDatabase() + self.users_db = BaseUsersDatabase() + + async def init_ym_client( + self, + ctx: ApplicationContext | Interaction | RawReactionActionEvent, + token: str | None = None + ) -> YMClient | None: + """Initialize Yandex Music client. Return client on success. Return None if no token found and respond to the context. + + Args: + ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. + token (str | None, optional): Token. Fetched from database if not provided. Defaults to None. + + Returns: + (YMClient | None): Client or None. + """ + logging.debug("[VC_EXT] Initializing Yandex Music client") + + if not token: + uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None + token = await self.users_db.get_ym_token(uid) if uid else None + + if not token: + logging.debug("[VC_EXT] No token found") + await self.send_response_message(ctx, "❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) + return None + + try: + if token in self._ym_clients: + client = self._ym_clients[token] + + await client.account_status() + return client + + client = await YMClient(token).init() + except yandex_music.exceptions.UnauthorizedError: + del self._ym_clients[token] + await self.send_response_message(ctx, "❌ Недействительный токен. Обновите его с помощью /account login.", ephemeral=True, delete_after=15) + return None + + self._ym_clients[token] = client + return client + + async def send_response_message( + self, + ctx: ApplicationContext | Interaction | RawReactionActionEvent, + content: str | None = None, + *, + delete_after: float | None = None, + ephemeral: bool = False, + view: discord.ui.View | None = None, + embed: discord.Embed | None = None + ) -> discord.Interaction | discord.WebhookMessage | discord.Message | None: + """Send response message based on context type. self.bot must be set in order to use RawReactionActionEvent context type. + RawReactionActionEvent can't be ephemeral. + + Args: + ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. + content (str): Message content to send. + delete_after (float | None, optional): Time after which the message will be deleted. Defaults to None. + ephemeral (bool, optional): Whether the message is ephemeral. Defaults to False. + view (discord.ui.View | None, optional): Discord view. Defaults to None. + embed (discord.Embed | None, optional): Discord embed. Defaults to None. + + Returns: + (discord.InteractionMessage | discord.WebhookMessage | discord.Message | None): Message or None. Type depends on the context type. + """ + if not isinstance(ctx, RawReactionActionEvent): + return await ctx.respond(content, delete_after=delete_after, ephemeral=ephemeral, view=view, embed=embed) + elif self.bot: + channel = self.bot.get_channel(ctx.channel_id) + if isinstance(channel, (discord.abc.Messageable)): + return await channel.send(content, delete_after=delete_after, view=view, embed=embed) # type: ignore + + return None + + async def get_message_by_id( + self, + ctx: ApplicationContext | Interaction | RawReactionActionEvent, + message_id: int + ) -> discord.Message | None: + """Get message by id based on context type. self.bot must be set in order to use RawReactionActionEvent context type. + + Args: + ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. + message_id (int): Message id. + + Returns: + (discord.Message | None): Message or None. + + Raises: + ValueError: Bot instance is not set. + discord.DiscordException: Failed to get message. + """ + try: + if isinstance(ctx, ApplicationContext): + return await ctx.fetch_message(message_id) + elif isinstance(ctx, Interaction): + return ctx.client.get_message(message_id) + elif not self.bot: + raise ValueError("Bot instance is not set.") + else: + return self.bot.get_message(message_id) + except discord.DiscordException as e: + logging.debug(f"[BASE_BOT] Failed to get message: {e}") + raise + + async def update_menu_views_dict( + self, + ctx: ApplicationContext | Interaction | RawReactionActionEvent, + *, + disable: bool = False + ) -> None: + """Genereate a new menu view and update the `menu_views` dict. This prevents creating multiple menu views for the same guild. + Use guild id as a key to access menu view. + + Args: + ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context + guild (ExplicitGuild): Guild. + disable (bool, optional): Disable menu. Defaults to False. + """ + logging.debug(f"[VC_EXT] Updating menu views dict for guild {ctx.guild_id}") + from MusicBot.ui import MenuView + + if not ctx.guild_id: + logging.warning("[VC_EXT] Guild not found") + return + + if ctx.guild_id in self.menu_views: + self.menu_views[ctx.guild_id].stop() + + self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable) + + def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop: + """Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance. + + Args: + ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. + + Raises: + TypeError: If the context is not a RawReactionActionEvent, ApplicationContext or Interaction. + ValueError: If the context is a RawReactionActionEvent and the bot is not set. + + Returns: + asyncio.AbstractEventLoop: Current event loop. + """ + if isinstance(ctx, Interaction): + return ctx.client.loop + elif isinstance(ctx, ApplicationContext): + return ctx.bot.loop + elif isinstance(ctx, RawReactionActionEvent): + if not self.bot: + raise ValueError("Bot is not set.") + return self.bot.loop + else: + raise TypeError(f"Invalid context type: '{type(ctx).__name__}'.") \ No newline at end of file diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index ce2023b..ce9c9e6 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -8,20 +8,16 @@ import yandex_music.exceptions from yandex_music import Track, TrackShort, ClientAsync as YMClient import discord -from discord.ui import View from discord import Interaction, ApplicationContext, RawReactionActionEvent, VoiceChannel +from MusicBot.cogs.utils.base_bot import BaseBot from MusicBot.cogs.utils import generate_item_embed -from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase, ExplicitGuild, MessageVotes +from MusicBot.database import ExplicitGuild, MessageVotes -menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks. - -class VoiceExtension: +class VoiceExtension(BaseBot): def __init__(self, bot: discord.Bot | None) -> None: - self.bot = bot - self.db = VoiceGuildsDatabase() - self.users_db = BaseUsersDatabase() + super().__init__(bot) async def send_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *, disable: bool = False) -> bool: """Send menu message to the channel and delete old one if exists. Return True if sent. @@ -30,16 +26,16 @@ class VoiceExtension: ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. disable (bool, optional): Disable menu message buttons. Defaults to False. - Raises: - ValueError: If bot instance is not set and ctx is RawReactionActionEvent. - Returns: bool: True if sent, False if not. + + Raises: + ValueError: If bot instance is not set and ctx is RawReactionActionEvent. """ logging.info(f"[VC_EXT] Sending menu message to channel {ctx.channel_id} in guild {ctx.guild_id}") if not ctx.guild_id: - logging.warning("[VC_EXT] Guild id not found in context inside 'create_menu'") + logging.warning("[VC_EXT] Guild id not found in context") return False guild = await self.db.get_guild(ctx.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vibing': 1}) @@ -65,28 +61,17 @@ class VoiceExtension: if (message := await self.get_menu_message(ctx, guild['current_menu'])): await message.delete() - await self._update_menu_views_dict(ctx, disable=disable) - - if isinstance(ctx, (ApplicationContext, Interaction)): - interaction = await ctx.respond(view=menu_views[ctx.guild_id], embed=embed) - elif not self.bot: - raise ValueError("Bot instance is not set.") - elif not (channel := self.bot.get_channel(ctx.channel_id)): - logging.warning(f"[VC_EXT] Channel {ctx.channel_id} not found in guild {ctx.guild_id}") - return False - elif isinstance(channel, discord.VoiceChannel): - interaction = await channel.send( - view=menu_views[ctx.guild_id], - embed=embed # type: ignore # Wrong typehints. - ) - else: - logging.warning(f"[VC_EXT] Channel {ctx.channel_id} is not a voice channel in guild {ctx.guild_id}") - return False + await self.update_menu_views_dict(ctx, disable=disable) + interaction = await self.send_response_message(ctx, embed=embed, view=self.menu_views[ctx.guild_id]) response = await interaction.original_response() if isinstance(interaction, discord.Interaction) else interaction - await self.db.update(ctx.guild_id, {'current_menu': response.id}) - logging.info(f"[VC_EXT] New menu message {response.id} created in guild {ctx.guild_id}") + if response: + await self.db.update(ctx.guild_id, {'current_menu': response.id}) + logging.info(f"[VC_EXT] New menu message {response.id} created in guild {ctx.guild_id}") + else: + logging.warning(f"[VC_EXT] Failed to save menu message id. Invalid response.") + return True async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, menu_mid: int) -> discord.Message | None: @@ -107,18 +92,9 @@ class VoiceExtension: return None try: - if isinstance(ctx, ApplicationContext): - menu = await ctx.fetch_message(menu_mid) - elif isinstance(ctx, Interaction): - menu = ctx.client.get_message(menu_mid) - elif not self.bot: - raise ValueError("Bot instance is not set.") - else: - menu = self.bot.get_message(menu_mid) - except discord.DiscordException as e: - logging.debug(f"[VC_EXT] Failed to get menu message: {e}") - await self.db.update(ctx.guild_id, {'current_menu': None}) - return None + menu = await self.get_message_by_id(ctx, menu_mid) + except discord.DiscordException: + menu = None if not menu: logging.debug(f"[VC_EXT] Menu message {menu_mid} not found in guild {ctx.guild_id}") @@ -128,7 +104,7 @@ class VoiceExtension: logging.debug(f"[VC_EXT] Menu message {menu_mid} successfully fetched") return menu - async def update_menu_full( + async def update_menu_embed_and_view( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *, @@ -151,17 +127,15 @@ class VoiceExtension: "interaction context" if isinstance(ctx, Interaction) else "application context" if isinstance(ctx, ApplicationContext) else "raw reaction context" - ) - ) + )) - gid = ctx.guild_id uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - if not gid or not uid: + if not ctx.guild_id or not uid: logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'update_menu_embed'") return False - guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1}) + guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1}) if not guild['current_menu']: logging.debug("[VC_EXT] No current menu found") return False @@ -180,8 +154,7 @@ class VoiceExtension: )) embed = await generate_item_embed(track, guild['vibing']) - vc = await self.get_voice_client(ctx) - if not vc: + if not (vc := await self.get_voice_client(ctx)): logging.warning("[VC_EXT] Voice client not found") return False @@ -190,16 +163,16 @@ class VoiceExtension: else: embed.remove_footer() - await self._update_menu_views_dict(ctx) + await self.update_menu_views_dict(ctx) try: if isinstance(ctx, Interaction) and button_callback: # If interaction from menu buttons - await ctx.edit(embed=embed, view=menu_views[gid]) + await ctx.edit(embed=embed, view=self.menu_views[ctx.guild_id]) else: # If interaction from other buttons or commands. They should have their own response. - await menu_message.edit(embed=embed, view=menu_views[gid]) - except discord.NotFound: - logging.warning("[VC_EXT] Menu message not found") + await menu_message.edit(embed=embed, view=self.menu_views[ctx.guild_id]) + except discord.DiscordException as e: + logging.warning(f"[VC_EXT] Error while updating menu message: {e}") return False logging.debug("[VC_EXT] Menu embed updated successfully") @@ -231,24 +204,26 @@ class VoiceExtension: logging.warning("[VC_EXT] Guild ID not found in context inside 'update_menu_view'") return False - guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1}) - if not guild['current_menu']: - return False + if not menu_message: + guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1}) + if not guild['current_menu']: + return False + + menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message - menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message if not menu_message: return False - await self._update_menu_views_dict(ctx, disable=disable) + await self.update_menu_views_dict(ctx, disable=disable) try: if isinstance(ctx, Interaction) and button_callback: # If interaction from menu buttons - await ctx.edit(view=menu_views[ctx.guild_id]) + await ctx.edit(view=self.menu_views[ctx.guild_id]) else: # If interaction from other buttons or commands. They should have their own response. - await menu_message.edit(view=menu_views[ctx.guild_id]) - except discord.NotFound: - logging.warning("[VC_EXT] Menu message not found") + await menu_message.edit(view=self.menu_views[ctx.guild_id]) + except discord.DiscordException as e: + logging.warning(f"[VC_EXT] Error while updating menu view: {e}") return False logging.debug("[VC_EXT] Menu view updated successfully") @@ -257,8 +232,8 @@ class VoiceExtension: async def update_vibe( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, - type: str, - id: str | int, + vibe_type: str, + item_id: str | int, *, viber_id: int | None = None, update_settings: bool = False @@ -268,28 +243,26 @@ class VoiceExtension: Args: ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. - type (str): Type of the item. - id (str | int): ID of the item. + vibe_type (str): Type of the item. + item_id (str | int): ID of the item. viber_id (int | None, optional): ID of the user who started vibe. If None, uses user id in context. Defaults to None. update_settings (bool, optional): Update vibe settings by sending feedack usind data from database. Defaults to False. Returns: bool: True if vibe was updated successfully. False otherwise. """ - logging.info(f"[VC_EXT] Updating vibe for guild {ctx.guild_id} with type '{type}' and id '{id}'") + logging.info(f"[VC_EXT] Updating vibe for guild {ctx.guild_id} with type '{vibe_type}' and id '{item_id}'") - gid = ctx.guild_id uid = viber_id if viber_id else ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - if not uid or not gid: + if not uid or not ctx.guild_id: logging.warning("[VC_EXT] Guild ID or User ID not found in context") return False user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_settings': 1}) - guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_track': 1}) - client = await self.init_ym_client(ctx, user['ym_token']) + guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_track': 1}) - if not client: + if not (client := await self.init_ym_client(ctx, user['ym_token'])): return False if update_settings: @@ -297,7 +270,7 @@ class VoiceExtension: settings = user['vibe_settings'] await client.rotor_station_settings2( - f"{type}:{id}", + f"{vibe_type}:{item_id}", mood_energy=settings['mood'], diversity=settings['diversity'], language=settings['lang'] @@ -306,7 +279,7 @@ class VoiceExtension: if not guild['vibing']: try: feedback = await client.rotor_station_feedback_radio_started( - f"{type}:{id}", + f"{vibe_type}:{item_id}", f"desktop-user-{client.me.account.uid}", # type: ignore # That's made up, but it doesn't do much anyway. ) except yandex_music.exceptions.BadRequestError as e: @@ -314,11 +287,11 @@ class VoiceExtension: return False if not feedback: - logging.warning(f"[VIBE] Failed to start radio '{type}:{id}'") + logging.warning(f"[VIBE] Failed to start radio '{vibe_type}:{item_id}'") return False tracks = await client.rotor_station_tracks( - f"{type}:{id}", + f"{vibe_type}:{item_id}", queue=guild['current_track']['id'] if guild['current_track'] else None # type: ignore ) @@ -330,11 +303,11 @@ class VoiceExtension: logging.debug(f"[VIBE] Got next vibe tracks: {[track.title for track in next_tracks]}") await self.users_db.update(uid, { - 'vibe_type': type, - 'vibe_id': id, + 'vibe_type': vibe_type, + 'vibe_id': item_id, 'vibe_batch_id': tracks.batch_id }) - await self.db.update(gid, { + await self.db.update(ctx.guild_id, { 'next_tracks': [track.to_dict() for track in next_tracks], 'current_viber_id': uid, 'vibing': True @@ -352,9 +325,14 @@ class VoiceExtension: Returns: bool: Check result. """ - if not ctx.user or not ctx.guild_id: - logging.warning("[VC_EXT] User or guild id not found in context inside 'voice_check'") - await ctx.respond("❌ Что-то пошло не так. Попробуйте еще раз.", delete_after=15, ephemeral=True) + if not ctx.user: + logging.info("[VC_EXT] User not found in context inside 'voice_check'") + await ctx.respond("❌ Пользователь не найден.", delete_after=15, ephemeral=True) + return False + + if not ctx.guild_id: + logging.info("[VC_EXT] Guild id not found in context inside 'voice_check'") + await ctx.respond("❌ Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True) return False if not await self.users_db.get_ym_token(ctx.user.id): @@ -400,20 +378,16 @@ class VoiceExtension: if isinstance(ctx, (Interaction, ApplicationContext)): voice_clients = ctx.client.voice_clients if isinstance(ctx, Interaction) else ctx.bot.voice_clients guild = ctx.guild - elif isinstance(ctx, RawReactionActionEvent): - if not self.bot: - raise ValueError("Bot instance is not set.") - if not ctx.guild_id: - logging.warning("[VC_EXT] Guild ID not found in context inside 'get_voice_client'") - return None + elif not self.bot: + raise ValueError("Bot instance is not set.") + elif not ctx.guild_id: + logging.warning("[VC_EXT] Guild ID not found in context") + return None + else: voice_clients = self.bot.voice_clients guild = await self.bot.fetch_guild(ctx.guild_id) - else: - raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") - voice_client = discord.utils.get(voice_clients, guild=guild) - - if voice_client: + if (voice_client := discord.utils.get(voice_clients, guild=guild)): logging.debug("[VC_EXT] Voice client found") else: logging.debug("[VC_EXT] Voice client not found") @@ -484,34 +458,32 @@ class VoiceExtension: """ logging.debug("[VC_EXT] Stopping playback") - gid = ctx.guild_id - uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - - if not gid or not uid: + if not ctx.guild_id: logging.warning("[VC_EXT] Guild ID not found in context") return False - guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'current_track': 1, 'vibing': 1}) vc = await self.get_voice_client(ctx) if not vc else vc - if not vc: return False - await self.db.update(gid, {'current_track': None, 'is_stopped': True}) + await self.db.update(ctx.guild_id, {'current_track': None, 'is_stopped': True}) vc.stop() if full: + guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1, 'current_track': 1, 'vibing': 1}) if guild['vibing'] and guild['current_track']: await self.send_vibe_feedback(ctx, 'trackFinished', guild['current_track']) + + await self.db.update(ctx.guild_id, { + 'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'vibing': False + }) - if not guild['current_menu']: - return True - - return await self._full_stop(ctx, guild['current_menu'], gid) + if guild['current_menu']: + return await self._delete_menu_message(ctx, guild['current_menu'], ctx.guild_id) return True - async def next_track( + async def play_next_track( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, vc: discord.VoiceClient | None = None, @@ -537,44 +509,38 @@ class VoiceExtension: """ logging.debug("[VC_EXT] Switching to next track") - gid = ctx.guild_id uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - if not gid or not uid: + if not ctx.guild_id or not uid: logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'") return None - guild = await self.db.get_guild(gid, projection={'shuffle': 1, 'repeat': 1, 'is_stopped': 1, 'current_menu': 1, 'vibing': 1, 'current_track': 1}) + guild = await self.db.get_guild(ctx.guild_id, projection={'shuffle': 1, 'repeat': 1, 'is_stopped': 1, 'current_menu': 1, 'vibing': 1, 'current_track': 1}) user = await self.users_db.get_user(uid) if guild['is_stopped'] and after: logging.debug("[VC_EXT] Playback is stopped, skipping after callback.") return None - if guild['current_track'] and guild['current_menu'] and not guild['repeat']: + if guild['current_track'] and not guild['repeat']: logging.debug("[VC_EXT] Adding current track to history") - await self.db.modify_track(gid, guild['current_track'], 'previous', 'insert') + await self.db.modify_track(ctx.guild_id, guild['current_track'], 'previous', 'insert') - if after and guild['current_menu']: - await self.update_menu_view(ctx, menu_message=menu_message, disable=True) + if after and not await self.update_menu_view(ctx, menu_message=menu_message, disable=True): + await self.send_response_message(ctx, "❌ Не удалось обновить меню.", ephemeral=True, delete_after=15) if guild['vibing'] and guild['current_track']: - if not await self.send_vibe_feedback(ctx, 'trackFinished' if after else 'skip', guild['current_track']): - if not isinstance(ctx, RawReactionActionEvent): - await ctx.respond("❌ Не удалось отправить отчёт об оконачнии Моей Волны.", ephemeral=True, delete_after=15) - elif self.bot: - channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) - await channel.send("❌ Не удалось отправить отчёт об оконачнии Моей Волны.", delete_after=15) + await self.send_vibe_feedback(ctx, 'trackFinished' if after else 'skip', guild['current_track']) if guild['repeat'] and after: logging.debug("[VC_EXT] Repeating current track") next_track = guild['current_track'] elif guild['shuffle']: logging.debug("[VC_EXT] Getting random track from queue") - next_track = await self.db.pop_random_track(gid, 'next') + next_track = await self.db.pop_random_track(ctx.guild_id, 'next') else: logging.debug("[VC_EXT] Getting next track from queue") - next_track = await self.db.get_track(gid, 'next') + next_track = await self.db.get_track(ctx.guild_id, 'next') if not next_track and guild['vibing']: logging.debug("[VC_EXT] No next track found, generating new vibe") @@ -583,7 +549,7 @@ class VoiceExtension: return None await self.update_vibe(ctx, user['vibe_type'], user['vibe_id']) - next_track = await self.db.get_track(gid, 'next') + next_track = await self.db.get_track(ctx.guild_id, 'next') if next_track: title = await self.play_track(ctx, next_track, client=client, vc=vc, button_callback=button_callback) @@ -602,11 +568,11 @@ class VoiceExtension: logging.info("[VC_EXT] No next track found") if after: - await self.db.update(gid, {'is_stopped': True, 'current_track': None}) + await self.db.update(ctx.guild_id, {'is_stopped': True, 'current_track': None}) return None - async def previous_track(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, button_callback: bool = False) -> str | None: + async def play_previous_track(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, button_callback: bool = False) -> str | None: """Switch to the previous track in the queue. Repeat current track if no previous one found. Return track title on success. Should be called only if there's already track playing. @@ -619,15 +585,14 @@ class VoiceExtension: """ logging.debug("[VC_EXT] Switching to previous track") - gid = ctx.guild_id uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - if not gid or not uid: + if not ctx.guild_id or not uid: logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'") return None - current_track = await self.db.get_track(gid, 'current') - prev_track = await self.db.get_track(gid, 'previous') + current_track = await self.db.get_track(ctx.guild_id, 'current') + prev_track = await self.db.get_track(ctx.guild_id, 'previous') if prev_track: logging.debug("[VC_EXT] Previous track found") @@ -644,34 +609,32 @@ class VoiceExtension: return None - async def get_likes(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort] | None: - """Get liked tracks. Return list of tracks on success. Return None if no token found. + async def get_liked_tracks(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort]: + """Get liked tracks from Yandex Music. Return list of tracks on success. + Return empty list if no likes found or error occurred. Args: ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. Returns: - (list[Track] | None): List of tracks or None. + list[Track]: List of tracks. """ logging.info("[VC_EXT] Getting liked tracks") if not ctx.guild_id: - logging.warning("Guild ID not found in context inside 'get_likes'") - return None - - client = await self.init_ym_client(ctx) + logging.warning("Guild ID not found in context") + return [] if not await self.db.get_track(ctx.guild_id, 'current'): - logging.debug("[VC_EXT] Current track not found in 'get_likes'") - return None + logging.debug("[VC_EXT] Current track not found. Likes can't be fetched") + return [] - if not client: - return None + if not (client := await self.init_ym_client(ctx)): + return [] - likes = await client.users_likes_tracks() - if not likes: + if not (likes := await client.users_likes_tracks()): logging.info("[VC_EXT] No likes found") - return None + return [] return likes.tracks @@ -724,48 +687,6 @@ class VoiceExtension: logging.debug(f"[VC_EXT] Track found in {action}s. Removing...") await remove_func(current_track['id']) return (True, 'removed') - - async def init_ym_client(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, token: str | None = None) -> YMClient | None: - """Initialize Yandex Music client. Return client on success. Return None if no token found and respond to the context. - - Args: - ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. - token (str | None, optional): Token. Fetched from database if not provided. Defaults to None. - - Returns: - (YMClient | None): Client or None. - """ - logging.debug("[VC_EXT] Initializing Yandex Music client") - - if not token: - uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - token = await self.users_db.get_ym_token(uid) if uid else None - - if not token: - logging.debug("No token found in 'init_ym_client'") - if not isinstance(ctx, discord.RawReactionActionEvent): - await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) - return None - - if not hasattr(self, '_ym_clients'): - self._ym_clients: dict[str, YMClient] = {} - - if token in self._ym_clients: - client = self._ym_clients[token] - try: - await client.account_status() - return client - except yandex_music.exceptions.UnauthorizedError: - del self._ym_clients[token] - return None - try: - client = await YMClient(token).init() - except yandex_music.exceptions.UnauthorizedError: - logging.debug("UnauthorizedError in 'init_ym_client'") - return None - - self._ym_clients[token] = client - return client async def proccess_vote(self, ctx: RawReactionActionEvent, guild: ExplicitGuild, channel: VoiceChannel, vote_data: MessageVotes) -> bool: """Proccess vote and perform action from `vote_data` and respond. Return True on success. @@ -787,14 +708,16 @@ class VoiceExtension: if not guild['current_menu'] and not await self.send_menu_message(ctx): await channel.send(content=f"❌ Не удалось отправить меню! Попробуйте ещё раз.", delete_after=15) + return False if vote_data['action'] in ('next', 'previous'): if not guild.get(f'{vote_data['action']}_tracks'): logging.info(f"[VOICE] No {vote_data['action']} tracks found for message {ctx.message_id}") await channel.send(content=f"❌ Очередь пуста!", delete_after=15) - elif not (await self.next_track(ctx) if vote_data['action'] == 'next' else await self.previous_track(ctx)): + elif not (await self.play_next_track(ctx) if vote_data['action'] == 'next' else await self.play_previous_track(ctx)): await channel.send(content=f"❌ Ошибка при смене трека! Попробуйте ещё раз.", delete_after=15) + return False elif vote_data['action'] == 'add_track': if not vote_data['vote_content']: @@ -805,9 +728,9 @@ class VoiceExtension: if guild['current_track']: await channel.send(content=f"✅ Трек был добавлен в очередь!", delete_after=15) - else: - if not await self.next_track(ctx): - await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) + elif not await self.play_next_track(ctx): + await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) + return False elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'): if not vote_data['vote_content']: @@ -819,9 +742,9 @@ class VoiceExtension: if guild['current_track']: await channel.send(content=f"✅ Контент был добавлен в очередь!", delete_after=15) - else: - if not await self.next_track(ctx): - await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) + elif not await self.play_next_track(ctx): + await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) + return False elif vote_data['action'] == 'play/pause': if not (vc := await self.get_voice_client(ctx)): @@ -833,7 +756,7 @@ class VoiceExtension: else: vc.resume() - await self.update_menu_full(ctx) + await self.update_menu_embed_and_view(ctx) elif vote_data['action'] in ('repeat', 'shuffle'): await self.db.update(guild['_id'], {vote_data['action']: not guild[vote_data['action']]}) @@ -844,26 +767,25 @@ class VoiceExtension: await channel.send("✅ Очередь и история сброшены.", delete_after=15) elif vote_data['action'] == 'stop': - res = await self.stop_playing(ctx, full=True) - if res: + if await self.stop_playing(ctx, full=True): await channel.send("✅ Воспроизведение остановлено.", delete_after=15) else: await channel.send("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15) + return False elif vote_data['action'] == 'vibe_station': - _type, _id, viber_id = vote_data['vote_content'] if isinstance(vote_data['vote_content'], list) else (None, None, None) + vibe_type, vibe_id, viber_id = vote_data['vote_content'] if isinstance(vote_data['vote_content'], list) else (None, None, None) - if not _type or not _id or not viber_id: + if not vibe_type or not vibe_id or not viber_id: logging.warning(f"[VOICE] Recieved empty vote context for message {ctx.message_id}") await channel.send("❌ Произошла ошибка при обновлении станции.", delete_after=15) return False - if not await self.update_vibe(ctx, _type, _id, viber_id=viber_id): + if not await self.update_vibe(ctx, vibe_type, vibe_id, viber_id=viber_id): await channel.send("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15) return False - next_track = await self.db.get_track(ctx.guild_id, 'next') - if next_track: + if (next_track := await self.db.get_track(ctx.guild_id, 'next')): await self.play_track(ctx, next_track) else: await channel.send("❌ Не удалось воспроизвести трек.", delete_after=15) @@ -908,14 +830,14 @@ class VoiceExtension: client = await self.init_ym_client(ctx, user['ym_token']) if not client: logging.info(f"[VC_EXT] Failed to init YM client for user {user['_id']}") - if not isinstance(ctx, RawReactionActionEvent): - await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) - elif self.bot: - channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) - await channel.send("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15) + await self.send_response_message(ctx, "❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) return False - - total_play_seconds = track['duration_ms'] // 1000 if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms'] else None + + if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms']: + total_play_seconds = track['duration_ms'] // 1000 + else: + total_play_seconds = None + try: feedback = await client.rotor_station_feedback( f'{user['vibe_type']}:{user['vibe_id']}', @@ -930,32 +852,6 @@ class VoiceExtension: logging.info(f"[VC_EXT] Sent vibe feedback type '{feedback_type}' with result: {feedback}") return feedback - - async def _update_menu_views_dict( - self, - ctx: ApplicationContext | Interaction | RawReactionActionEvent, - *, - disable: bool = False - ) -> None: - """Genereate a new menu view and update the `menu_views` dict. This prevents creating multiple menu views for the same guild. - Use guild id as a key to access menu view. - - Args: - ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context - guild (ExplicitGuild): Guild. - disable (bool, optional): Disable menu. Defaults to False. - """ - logging.debug(f"[VC_EXT] Updating menu views dict for guild {ctx.guild_id}") - from MusicBot.ui import MenuView - - if not ctx.guild_id: - logging.warning("[VC_EXT] Guild not found") - return - - if ctx.guild_id in menu_views: - menu_views[ctx.guild_id].stop() - - menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable) async def _download_track(self, gid: int, track: Track) -> None: """Download track to local storage. Return True on success. @@ -970,8 +866,8 @@ class VoiceExtension: logging.warning(f"[VC_EXT] Timed out while downloading track '{track.title}'") raise - async def _full_stop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, current_menu: int, gid: int) -> Literal[True]: - """Stop all actions and delete menu. Return True on success. + async def _delete_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, current_menu: int, gid: int) -> Literal[True]: + """Delete current menu message and stop menu view. Return True on success. Args: ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. @@ -982,16 +878,13 @@ class VoiceExtension: """ logging.debug("[VC_EXT] Performing full stop") - if gid in menu_views: - menu_views[gid].stop() - del menu_views[gid] + if gid in self.menu_views: + self.menu_views[gid].stop() + del self.menu_views[gid] if (menu := await self.get_menu_message(ctx, current_menu)): await menu.delete() - await self.db.update(gid, { - 'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'vibing': False - }) return True async def _play_track( @@ -1052,7 +945,7 @@ class VoiceExtension: if menu_message or guild['current_menu']: # Updating menu message before playing to prevent delay and avoid FFMPEG lags. - await self.update_menu_full(ctx, menu_message=menu_message, button_callback=button_callback) + await self.update_menu_embed_and_view(ctx, menu_message=menu_message, button_callback=button_callback) if not guild['vibing']: # Giving FFMPEG enough time to process the audio file @@ -1060,22 +953,14 @@ class VoiceExtension: loop = self._get_current_event_loop(ctx) try: - vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop)) + vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.play_next_track(ctx, after=True), loop)) except discord.errors.ClientException as e: logging.error(f"[VC_EXT] Error while playing track '{track.title}': {e}") - if not isinstance(ctx, RawReactionActionEvent): - await ctx.respond(f"❌ Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True) - elif self.bot: - channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) - await channel.send(f"❌ Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15) + await self.send_response_message(ctx, f"❌ Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True) return None except yandex_music.exceptions.InvalidBitrateError: logging.error(f"[VC_EXT] Invalid bitrate while playing track '{track.title}'") - if not isinstance(ctx, RawReactionActionEvent): - await ctx.respond(f"❌ У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15, ephemeral=True) - elif self.bot: - channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) - await channel.send(f"❌ У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15) + await self.send_response_message(ctx, f"❌ У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15, ephemeral=True) return None logging.info(f"[VC_EXT] Playing track '{track.title}'") @@ -1085,27 +970,3 @@ class VoiceExtension: await self.send_vibe_feedback(ctx, 'trackStarted', track) return track.title - - def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop: - """Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance. - - Args: - ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. - - Raises: - TypeError: If the context is not a RawReactionActionEvent, ApplicationContext or Interaction. - ValueError: If the context is a RawReactionActionEvent and the bot is not set. - - Returns: - asyncio.AbstractEventLoop: Current event loop. - """ - if isinstance(ctx, Interaction): - return ctx.client.loop - elif isinstance(ctx, ApplicationContext): - return ctx.bot.loop - elif isinstance(ctx, RawReactionActionEvent): - if not self.bot: - raise ValueError("Bot is not set.") - return self.bot.loop - else: - raise TypeError(f"Invalid context type: '{type(ctx).__name__}'.") diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index c4e1c35..0aca49f 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -7,8 +7,8 @@ from discord.ext.commands import Cog from yandex_music import ClientAsync as YMClient from yandex_music.exceptions import UnauthorizedError +from MusicBot.cogs.utils import VoiceExtension from MusicBot.database import BaseUsersDatabase -from MusicBot.cogs.utils import VoiceExtension, menu_views from MusicBot.ui import QueueView, generate_queue_embed def setup(bot: discord.Bot): @@ -20,8 +20,7 @@ async def get_vibe_stations_suggestions(ctx: discord.AutocompleteContext) -> lis if not ctx.interaction.user or not ctx.value or len(ctx.value) < 2: return [] - token = await users_db.get_ym_token(ctx.interaction.user.id) - if not token: + if not (token := await users_db.get_ym_token(ctx.interaction.user.id)): logging.info(f"[GENERAL] User {ctx.interaction.user.id} has no token") return [] @@ -46,63 +45,71 @@ class Voice(Cog, VoiceExtension): @Cog.listener() async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None: - gid = member.guild.id - guild = await self.db.get_guild(gid, projection={'current_menu': 1}) + guild = await self.db.get_guild(member.guild.id, projection={'current_menu': 1}) - channel = after.channel or before.channel - if not channel: + if not after.channel or not before.channel: logging.warning(f"[VOICE] No channel found for member {member.id}") return - vc = cast(discord.VoiceClient | None, discord.utils.get(self.typed_bot.voice_clients, guild=await self.typed_bot.fetch_guild(gid))) + vc = cast( + discord.VoiceClient | None, + discord.utils.get( + self.typed_bot.voice_clients, + guild=await self.typed_bot.fetch_guild(member.guild.id) + ) + ) - for member in channel.members: + if not vc: + logging.info(f"[VOICE] No voice client found for guild {member.guild.id}") + return + + for member in set(before.channel.members + after.channel.members): if member.id == self.typed_bot.user.id: # type: ignore # should be logged in logging.info(f"[VOICE] Voice state update for member {member.id} in guild {member.guild.id}") break else: - logging.debug(f"[VOICE] Bot is not in the channel {channel.id}") + logging.debug(f"[VOICE] Bot is not in the channel {after.channel.id}") return - if not vc: - logging.info(f"[VOICE] No voice client found for guild {gid}") - return + if len(after.channel.members) == 1: + logging.info(f"[VOICE] Clearing history and stopping playback for guild {member.guild.id}") - if len(channel.members) == 1: - logging.info(f"[VOICE] Clearing history and stopping playback for guild {gid}") - - if member.guild.id in menu_views: - menu_views[member.guild.id].stop() - del menu_views[member.guild.id] + if member.guild.id in self.menu_views: + self.menu_views[member.guild.id].stop() + del self.menu_views[member.guild.id] if guild['current_menu']: - message = self.typed_bot.get_message(guild['current_menu']) - if message: + if (message := self.typed_bot.get_message(guild['current_menu'])): await message.delete() - await self.db.update(gid, { + await self.db.update(member.guild.id, { 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'current_track': None, 'current_menu': None, 'vibing': False, 'repeat': False, 'shuffle': False, 'is_stopped': True }) vc.stop() - if member.guild.id in menu_views: - menu_views[member.guild.id].stop() - del menu_views[member.guild.id] + if member.guild.id in self.menu_views: + self.menu_views[member.guild.id].stop() + del self.menu_views[member.guild.id] @Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: logging.debug(f"[VOICE] Reaction added by user {payload.user_id} in channel {payload.channel_id}") + if not self.typed_bot.user or not payload.member: return - bot_id = self.typed_bot.user.id - if payload.user_id == bot_id: + if not payload.guild_id: + logging.info(f"[VOICE] No guild id in reaction payload") return - channel = cast(discord.VoiceChannel, self.typed_bot.get_channel(payload.channel_id)) - if not channel: + if payload.user_id == self.typed_bot.user.id: + return + + channel = self.typed_bot.get_channel(payload.channel_id) + if not isinstance(channel, discord.VoiceChannel): + logging.info(f"[VOICE] Channel {payload.channel_id} is not a voice channel") return try: @@ -114,19 +121,16 @@ class Voice(Cog, VoiceExtension): logging.info(f"[VOICE] Message {payload.message_id} not found in channel {payload.channel_id}") return - if not message or message.author.id != bot_id: + if not message or message.author.id != self.typed_bot.user.id: + logging.info(f"[VOICE] Message {payload.message_id} is not a bot message") return if not await self.users_db.get_ym_token(payload.user_id): await message.remove_reaction(payload.emoji, payload.member) - await channel.send("Для участия в голосовании необходимо авторизоваться через /account login.", delete_after=15) + await channel.send("❌ Для участия в голосовании необходимо авторизоваться через /account login.", delete_after=15) return - guild_id = payload.guild_id - if not guild_id: - return - - guild = await self.db.get_guild(guild_id) + guild = await self.db.get_guild(payload.guild_id) votes = guild['votes'] if str(payload.message_id) not in votes: @@ -156,29 +160,30 @@ class Voice(Cog, VoiceExtension): await message.edit(content='Запрос был отклонён.', delete_after=15) del votes[str(payload.message_id)] - await self.db.update(guild_id, {'votes': votes}) + await self.db.update(payload.guild_id, {'votes': votes}) @Cog.listener() async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent) -> None: logging.debug(f"[VOICE] Reaction removed by user {payload.user_id} in channel {payload.channel_id}") - if not self.typed_bot.user: + + if not self.typed_bot.user or not payload.member: return - guild_id = payload.guild_id - if not guild_id: + if not payload.guild_id: return - guild = await self.db.get_guild(guild_id, projection={'votes': 1}) + channel = self.typed_bot.get_channel(payload.channel_id) + if not isinstance(channel, discord.VoiceChannel): + logging.info(f"[VOICE] Channel {payload.channel_id} is not a voice channel") + return + + guild = await self.db.get_guild(payload.guild_id, projection={'votes': 1}) votes = guild['votes'] if str(payload.message_id) not in votes: logging.info(f"[VOICE] Message {payload.message_id} not found in votes") return - channel = cast(discord.VoiceChannel, self.typed_bot.get_channel(payload.channel_id)) - if not channel: - return - try: message = await channel.fetch_message(payload.message_id) except discord.Forbidden: @@ -199,8 +204,8 @@ class Voice(Cog, VoiceExtension): logging.info(f"[VOICE] User {payload.user_id} removed negative vote for message {payload.message_id}") del vote_data['negative_votes'][payload.user_id] - await self.db.update(guild_id, {'votes': votes}) - + await self.db.update(payload.guild_id, {'votes': votes}) + @voice.command(name="menu", description="Создать или обновить меню проигрывателя.") async def menu(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[VOICE] Menu command invoked by user {ctx.author.id} in guild {ctx.guild_id}") @@ -240,9 +245,9 @@ class Voice(Cog, VoiceExtension): @voice.command(description="Заставить бота покинуть голосовой канал.") async def leave(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[VOICE] Leave command invoked by user {ctx.author.id} in guild {ctx.guild_id}") - + if not ctx.guild_id: - logging.warning("[VOICE] Leave command invoked without guild_id") + logging.info("[VOICE] Leave command invoked without guild_id") await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) return @@ -253,27 +258,26 @@ class Voice(Cog, VoiceExtension): logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild_id}") await ctx.respond("❌ У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) return + + if not await self.voice_check(ctx): + return - if (vc := await self.get_voice_client(ctx)) and await self.voice_check(ctx) and vc.is_connected: - res = await self.stop_playing(ctx, vc=vc, full=True) - if not res: - await ctx.respond("❌ Не удалось отключиться.", delete_after=15, ephemeral=True) - return - - await vc.disconnect(force=True) - await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True) - logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild_id}") - else: + if not (vc := await self.get_voice_client(ctx)) or not vc.is_connected: + logging.info(f"[VOICE] Voice client is not connected in guild {ctx.guild_id}") await ctx.respond("❌ Бот не подключен к голосовому каналу.", delete_after=15, ephemeral=True) + return + + if not await self.stop_playing(ctx, vc=vc, full=True): + await ctx.respond("❌ Не удалось отключиться.", delete_after=15, ephemeral=True) + return + + await vc.disconnect(force=True) + await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True) + logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild_id}") @queue.command(description="Очистить очередь треков и историю прослушивания.") async def clear(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[VOICE] Clear queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}") - - if not ctx.guild_id: - logging.warning("[VOICE] Clear command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) - return if not await self.voice_check(ctx): return @@ -283,14 +287,14 @@ class Voice(Cog, VoiceExtension): if len(channel.members) > 2 and not member.guild_permissions.manage_channels: logging.info(f"Starting vote for clearing queue in guild {ctx.guild_id}") - + response_message = f"{member.mention} хочет очистить историю прослушивания и очередь треков.\n\n Выполнить действие?." message = cast(discord.Interaction, await ctx.respond(response_message, delete_after=60)) response = await message.original_response() await response.add_reaction('✅') await response.add_reaction('❌') - + await self.db.update_vote( ctx.guild_id, response.id, @@ -311,11 +315,6 @@ class Voice(Cog, VoiceExtension): @queue.command(description="Получить очередь треков.") async def get(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[VOICE] Get queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}") - - if not ctx.guild_id: - logging.warning("[VOICE] Get command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) - return if not await self.voice_check(ctx): return @@ -334,11 +333,6 @@ class Voice(Cog, VoiceExtension): @voice.command(description="Прервать проигрывание, удалить историю, очередь и текущий плеер.") async def stop(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[VOICE] Stop command invoked by user {ctx.author.id} in guild {ctx.guild_id}") - - if not ctx.guild_id: - logging.warning("[VOICE] Stop command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) - return if not await self.voice_check(ctx): return @@ -387,13 +381,9 @@ class Voice(Cog, VoiceExtension): ) async def vibe(self, ctx: discord.ApplicationContext, name: str | None = None) -> None: logging.info(f"[VOICE] Vibe (user) command invoked by user {ctx.user.id} in guild {ctx.guild_id}") + if not await self.voice_check(ctx): return - - if not ctx.guild_id: - logging.warning("[VOICE] Vibe command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) - return guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1, 'vibing': 1}) @@ -404,19 +394,11 @@ class Voice(Cog, VoiceExtension): await ctx.defer(invisible=False) if name: - token = await users_db.get_ym_token(ctx.user.id) - if not token: - logging.info(f"[GENERAL] User {ctx.user.id} has no token") + + if not (client := await self.init_ym_client(ctx)): return - try: - client = await YMClient(token).init() - except UnauthorizedError: - logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token") - return - - stations = await client.rotor_stations_list() - for content in stations: + for content in (await client.rotor_stations_list()): if content.station and content.station.name == name and content.ad_params: break else: @@ -427,23 +409,23 @@ class Voice(Cog, VoiceExtension): await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True) return - _type, _id = content.ad_params.other_params.split(':') if content.ad_params else (None, None) + vibe_type, vibe_id = content.ad_params.other_params.split(':') if content.ad_params else (None, None) - if not _type or not _id: + if not vibe_type or not vibe_id: logging.debug(f"[VOICE] Station {name} has no ad params") await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True) return else: - _type, _id = 'user', 'onyourwave' + vibe_type, vibe_id = 'user', 'onyourwave' content = None - + member = cast(discord.Member, ctx.author) channel = cast(discord.VoiceChannel, ctx.channel) - + if len(channel.members) > 2 and not member.guild_permissions.manage_channels: logging.info(f"Starting vote for starting vibe in guild {ctx.guild_id}") - if _type == 'user' and _id == 'onyourwave': + if vibe_type == 'user' and vibe_id == 'onyourwave': station = "Моя Волна" elif content and content.station: station = content.station.name @@ -457,7 +439,7 @@ class Voice(Cog, VoiceExtension): await message.add_reaction('✅') await message.add_reaction('❌') - + await self.db.update_vote( ctx.guild_id, message.id, @@ -466,12 +448,12 @@ class Voice(Cog, VoiceExtension): 'negative_votes': list(), 'total_members': len(channel.members), 'action': 'vibe_station', - 'vote_content': [_type, _id, ctx.user.id] + 'vote_content': [vibe_type, vibe_id, ctx.user.id] } ) return - if not await self.update_vibe(ctx, _type, _id): + if not await self.update_vibe(ctx, vibe_type, vibe_id): await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True) return @@ -480,6 +462,5 @@ class Voice(Cog, VoiceExtension): elif not await self.send_menu_message(ctx, disable=True): await ctx.respond("❌ Не удалось отправить меню. Попробуйте позже.", delete_after=15, ephemeral=True) - next_track = await self.db.get_track(ctx.guild_id, 'next') - if next_track: + if (next_track := await self.db.get_track(ctx.guild_id, 'next')): await self.play_track(ctx, next_track) diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index 5447c18..194ddec 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -9,7 +9,8 @@ from discord import ( import yandex_music.exceptions from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient -from MusicBot.cogs.utils.voice_extension import VoiceExtension, menu_views + +from MusicBot.cogs.utils import VoiceExtension class ToggleButton(Button, VoiceExtension): def __init__(self, *args, **kwargs): @@ -142,9 +143,9 @@ class SwitchTrackButton(Button, VoiceExtension): return tracks_type = callback_type + '_tracks' - guild = await self.db.get_guild(gid, projection={tracks_type: 1, 'vote_switch_track': 1}) + guild = await self.db.get_guild(gid, projection={tracks_type: 1, 'vote_switch_track': 1, 'vibing': 1}) - if not guild[tracks_type]: + if not guild[tracks_type] and not guild['vibing']: logging.info(f"[MENU] No tracks in '{tracks_type}' list in guild {gid}") await interaction.respond(f"❌ Нет треков в {'очереди' if callback_type == 'next' else 'истории'}.", delete_after=15, ephemeral=True) return @@ -176,9 +177,9 @@ class SwitchTrackButton(Button, VoiceExtension): return if callback_type == 'next': - title = await self.next_track(interaction, button_callback=True) + title = await self.play_next_track(interaction, button_callback=True) else: - title = await self.previous_track(interaction, button_callback=True) + title = await self.play_previous_track(interaction, button_callback=True) if not title: await interaction.respond(f"❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) @@ -205,8 +206,8 @@ class ReactionButton(Button, VoiceExtension): res = await self.react_track(interaction, callback_type) if callback_type == 'like' and res[0]: - await self._update_menu_views_dict(interaction) - await interaction.edit(view=menu_views[gid]) + await self.update_menu_views_dict(interaction) + await interaction.edit(view=self.menu_views[gid]) await interaction.respond( f"✅ Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}", delete_after=15, ephemeral=True @@ -214,11 +215,11 @@ class ReactionButton(Button, VoiceExtension): elif callback_type == 'dislike' and res[0]: - if len(channel.members) == 2 and not await self.next_track(interaction, vc=vc, button_callback=True): + if len(channel.members) == 2 and not await self.play_next_track(interaction, vc=vc, button_callback=True): await interaction.respond("✅ Воспроизведение приостановлено. Нет треков в очереди.", delete_after=15) - await self._update_menu_views_dict(interaction) - await interaction.edit(view=menu_views[gid]) + await self.update_menu_views_dict(interaction) + await interaction.edit(view=self.menu_views[gid]) await interaction.respond( f"✅ Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}", delete_after=15, ephemeral=True @@ -465,7 +466,7 @@ class MyVibeSettingsButton(Button, VoiceExtension): if not await self.voice_check(interaction, check_vibe_privilage=True): return - await interaction.respond('Настройки "Моей Волны"', view=await MyVibeSettingsView(interaction).init(), ephemeral=True) + await interaction.respond('Настройки **Волны**', view=await MyVibeSettingsView(interaction).init(), ephemeral=True) class AddToPlaylistSelect(Select, VoiceExtension): def __init__(self, ym_client: YMClient, *args, **kwargs): @@ -601,7 +602,7 @@ class MenuView(View, VoiceExtension): self.shuffle_button.style = ButtonStyle.success current_track = self.guild['current_track'] - likes = await self.get_likes(self.ctx) + likes = await self.get_liked_tracks(self.ctx) self.add_item(self.repeat_button) self.add_item(self.prev_button) @@ -610,7 +611,7 @@ class MenuView(View, VoiceExtension): self.add_item(self.shuffle_button) if not isinstance(self.ctx, RawReactionActionEvent) and len(cast(VoiceChannel, self.ctx.channel).members) == 2: - if likes and current_track and str(current_track['id']) in [str(like.id) for like in likes]: + if current_track and str(current_track['id']) in [str(like.id) for like in likes]: self.like_button.style = ButtonStyle.success if not current_track: @@ -645,8 +646,7 @@ class MenuView(View, VoiceExtension): await self.stop_playing(self.ctx) await self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': [], 'vibing': False}) - message = await self.get_menu_message(self.ctx, self.guild['current_menu']) - if message: + if (message := await self.get_menu_message(self.ctx, self.guild['current_menu'])): await message.delete() logging.debug('[MENU] Successfully deleted menu message') else: