diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py index a96f67c..fd2fbf9 100644 --- a/MusicBot/cogs/general.py +++ b/MusicBot/cogs/general.py @@ -162,7 +162,7 @@ class General(Cog, BaseBot): "Запустить станцию. Без уточнения станции, запускает Мою Волну.\n```/voice vibe <название станции>```" ) else: - await ctx.respond('❌ Неизвестная команда.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Неизвестная команда.", delete_after=15, ephemeral=True) return await ctx.respond(embed=embed, ephemeral=True) @@ -176,16 +176,16 @@ class General(Cog, BaseBot): client = await YMClient(token).init() except UnauthorizedError: logging.info(f"[GENERAL] Invalid token provided by user {ctx.author.id}") - await ctx.respond('❌ Недействительный токен.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Недействительный токен.", delete_after=15, ephemeral=True) return if not client.me or not client.me.account: logging.warning(f"[GENERAL] Failed to get user info for user {ctx.author.id}") - await ctx.respond('❌ Не удалось получить информацию о пользователе.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Не удалось получить информацию о пользователе.", delete_after=15, ephemeral=True) return 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) + await self.respond(ctx, "success", 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") @@ -196,7 +196,7 @@ class General(Cog, BaseBot): 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) + await self.respond(ctx, "error", "Токен не указан.", delete_after=15, ephemeral=True) return if token in self._ym_clients: @@ -205,7 +205,7 @@ class General(Cog, BaseBot): await self.users_db.update(ctx.user.id, {'ym_token': None}) logging.info(f"[GENERAL] Token removed for user {ctx.author.id}") - await ctx.respond(f'✅ Токен был удалён.', delete_after=15, ephemeral=True) + await self.respond(ctx, "success", "Токен был удалён.", delete_after=15, ephemeral=True) @account.command(description="Получить плейлист «Мне нравится»") async def likes(self, ctx: discord.ApplicationContext) -> None: @@ -213,7 +213,7 @@ class General(Cog, BaseBot): guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'}) if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']: - await ctx.respond('❌ Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Только владелец токена может делиться личными плейлистами.", delete_after=15, ephemeral=True) return if not (client := await self.init_ym_client(ctx)): @@ -223,16 +223,20 @@ class General(Cog, BaseBot): likes = await client.users_likes_tracks() except UnauthorizedError: logging.warning(f"[GENERAL] Unknown token error for user {ctx.user.id}") - await ctx.respond("❌ Произошла неизвестная ошибка при попытке получения лайков. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True) + await self.respond( + ctx, "error", + "Произошла неизвестная ошибка при попытке получения лайков. Пожалуйста, сообщите об этом разработчику.", + delete_after=15, ephemeral=True + ) return if likes is None: logging.info(f"[GENERAL] Failed to fetch likes for user {ctx.user.id}") - await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True) return elif not likes: logging.info(f"[GENERAL] Empty likes for user {ctx.user.id}") - await ctx.respond('❌ У вас нет треков в плейлисте «Мне нравится».', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "У вас нет треков в плейлисте «Мне нравится».", delete_after=15, ephemeral=True) return await ctx.defer() # Sometimes it takes a while to fetch all tracks, so we defer the response @@ -260,7 +264,7 @@ class General(Cog, BaseBot): guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'}) if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']: - await ctx.respond('❌ Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Только владелец токена может делиться личными плейлистами.", delete_after=15, ephemeral=True) return if not (client := await self.init_ym_client(ctx)): @@ -269,16 +273,16 @@ class General(Cog, BaseBot): search = await client.search(content_type, type_='playlist') if not search or not search.playlists: logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}") - await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True) return 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) + await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True) 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) + await self.respond(ctx, "error", "Пустой плейлист.", delete_after=15, ephemeral=True) return await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist)) @@ -296,7 +300,7 @@ class General(Cog, BaseBot): guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'}) if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']: - await ctx.respond('❌ Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "олько владелец токена может делиться личными плейлистами.", delete_after=15, ephemeral=True) return if not (client := await self.init_ym_client(ctx)): @@ -306,17 +310,17 @@ class General(Cog, BaseBot): playlists = await client.users_playlists_list() except UnauthorizedError: logging.warning(f"[GENERAL] Unknown token error for user {ctx.user.id}") - await ctx.respond("❌ Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True) return 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) + await self.respond(ctx, "error", "Плейлист не найден.", delete_after=15, ephemeral=True) return 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) + await self.respond(ctx, "error", "Плейлист пуст.", delete_after=15, ephemeral=True) return await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist)) @@ -349,7 +353,7 @@ class General(Cog, BaseBot): 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) + await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True) return if content_type == 'Трек': @@ -363,7 +367,7 @@ class General(Cog, BaseBot): if not content: logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no results") - await ctx.respond("❌ По запросу ничего не найдено.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "По запросу ничего не найдено.", delete_after=15, ephemeral=True) return result = content.results[0] diff --git a/MusicBot/cogs/settings.py b/MusicBot/cogs/settings.py index f5fef41..60d4015 100644 --- a/MusicBot/cogs/settings.py +++ b/MusicBot/cogs/settings.py @@ -5,11 +5,12 @@ import discord from discord.ext.commands import Cog from MusicBot.database import BaseUsersDatabase, BaseGuildsDatabase +from MusicBot.cogs.utils import BaseBot def setup(bot): bot.add_cog(Settings(bot)) -class Settings(Cog): +class Settings(Cog, BaseBot): settings = discord.SlashCommandGroup("settings", "Команды для изменения настроек бота.") @@ -22,7 +23,7 @@ class Settings(Cog): async def show(self, ctx: discord.ApplicationContext) -> None: if not ctx.guild_id: logging.info("[SETTINGS] Show command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) + await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", ephemeral=True) return guild = await self.db.get_guild(ctx.guild_id, projection={ @@ -37,6 +38,8 @@ class Settings(Cog): token = "🔐 - Используется токен пользователя, запустившего бота" if guild['use_single_token'] else "🔒 - Используется личный токен пользователя" embed = discord.Embed(title="Настройки бота", color=0xfed42b) + embed.set_author(name='YandexMusic', icon_url="https://github.com/Lemon4ksan/YandexMusicDiscordBot/blob/main/assets/Logo.png?raw=true") + embed.add_field(name="__Голосование__", value=vote, inline=False) embed.add_field(name="__Подключение/Отключение__", value=connect, inline=False) embed.add_field(name="__Токен__", value=token, inline=False) @@ -52,8 +55,8 @@ class Settings(Cog): choices=[ 'Переключение треков без голосования для всех', 'Добавление в очередь без голосования для всех', - 'Добавление/Отключение бота из канала для всех', - 'Использовать единый токен для прослушивания' + 'Добавление/Отключение бота от канала для всех', + 'Использовать токен запустившего пользователя для всех' ] ) async def toggle( @@ -62,18 +65,18 @@ class Settings(Cog): vote_type: Literal[ 'Переключение треков без голосования для всех', 'Добавление в очередь без голосования для всех', - 'Добавление/Отключение бота из канала для всех', - 'Использовать единый токен для прослушивания' + 'Добавление/Отключение бота от канала для всех', + 'Использовать токен запустившего пользователя для всех' ] ) -> None: if not ctx.guild_id: logging.info("[SETTINGS] Toggle command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True) return member = cast(discord.Member, ctx.user) if not member.guild_permissions.manage_channels: - await ctx.respond("❌ У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) return guild = await self.db.get_guild(ctx.guild_id, projection={ @@ -88,15 +91,15 @@ class Settings(Cog): await self.db.update(ctx.guild_id, {'vote_add': not guild['vote_add']}) response_message = "Голосование за добавление в очередь " + ("❌ выключено." if guild['vote_add'] else "✅ включено.") - elif vote_type == 'Добавление/Отключение бота из канала для всех': + elif vote_type == 'Добавление/Отключение бота от канала для всех': await self.db.update(ctx.guild_id, {'allow_change_connect': not guild['allow_change_connect']}) response_message = f"Добавление/Отключение бота от канала теперь {'✅ разрешено' if not guild['allow_change_connect'] else '❌ запрещено'} участникам без прав управления каналом." - elif vote_type == 'Использовать единый токен для прослушивания': + elif vote_type == 'Использовать токен запустившего пользователя для всех': await self.db.update(ctx.guild_id, {'use_single_token': not guild['use_single_token']}) response_message = f"Использование единого токена для прослушивания теперь {'✅ включено' if not guild['use_single_token'] else '❌ выключено'}." else: - response_message = "❌ Неизвестный тип голосования." + response_message = "Неизвестный тип настроек." - await ctx.respond(response_message, delete_after=15, ephemeral=True) + await self.respond(ctx, 'info', response_message, delete_after=30, ephemeral=True) diff --git a/MusicBot/cogs/utils/base_bot.py b/MusicBot/cogs/utils/base_bot.py index 762872f..deb3275 100644 --- a/MusicBot/cogs/utils/base_bot.py +++ b/MusicBot/cogs/utils/base_bot.py @@ -6,14 +6,13 @@ 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 discord import Interaction, ApplicationContext, RawReactionActionEvent, MISSING 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. + + menu_views: dict[int, Any] = {} # 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: @@ -39,7 +38,7 @@ class BaseBot: if not (token := await self.get_ym_token(ctx)): logging.debug("[BASE_BOT] No token found") - await self.send_response_message(ctx, "❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Укажите токен через /account login.", delete_after=15, ephemeral=True) return None try: @@ -52,7 +51,7 @@ class BaseBot: client = await YMClient(token).init() except yandex_music.exceptions.UnauthorizedError: del self._ym_clients[token] - await self.send_response_message(ctx, "❌ Недействительный токен Yandex Music.", ephemeral=True, delete_after=15) + await self.respond(ctx, "error", "Недействительный токен Yandex Music.", ephemeral=True, delete_after=15) return None self._ym_clients[token] = client @@ -74,30 +73,44 @@ class BaseBot: else: return await self.users_db.get_ym_token(uid) - async def send_response_message( + async def respond( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, + response_type: Literal['info', 'success', 'error'] | None = None, content: str | None = None, *, delete_after: float | None = None, ephemeral: bool = False, + embed: discord.Embed | None = None, view: discord.ui.View | None = None, - embed: discord.Embed | None = None + **kwargs: Any ) -> 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. + """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. + content (str): Message content to send. If embed is not set, used as description. + response_type (Literal['info', 'success', 'error'] | None, optional): Response type. Applies if embed is not specified. + delete_after (float, 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. + embed (discord.Embed, optional): Discord embed. Defaults to None. + view (discord.ui.View, optional): Discord view. Defaults to None. + kwargs: Additional arguments for embed generation. Applies if embed is not specified. Returns: (discord.InteractionMessage | discord.WebhookMessage | discord.Message | None): Message or None. Type depends on the context type. """ + + if not embed and response_type: + if content: + kwargs['description'] = content + embed = self.generate_response_embed(ctx, response_type, **kwargs) + content = None + + if not isinstance(ctx, RawReactionActionEvent) and not view and ctx.response.is_done(): + view = MISSING + if not isinstance(ctx, RawReactionActionEvent): return await ctx.respond(content, delete_after=delete_after, ephemeral=ephemeral, view=view, embed=embed) elif self.bot: @@ -106,7 +119,7 @@ class BaseBot: 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, @@ -162,33 +175,49 @@ class BaseBot: return guild['current_viber_id'] return ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - - async def update_menu_views_dict( + + async def init_menu_view(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, gid: int, *, disable: bool = False) -> None: + from MusicBot.ui import MenuView + self.menu_views[gid] = await MenuView(ctx).init(disable=disable) + + def generate_response_embed( 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"[BASE_BOT] Updating menu views dict for guild {ctx.guild_id}") - from MusicBot.ui import MenuView + embed_type: Literal['info', 'success', 'error'] = 'info', + **kwargs: Any + ) -> discord.Embed: - if not ctx.guild_id: - logging.warning("[BASE_BOT] Guild not found") - return + if isinstance(ctx, Interaction): + name = ctx.client.user.name if ctx.client.user else None + icon_url = ctx.client.user.avatar.url if ctx.client.user and ctx.client.user.avatar else None + elif isinstance(ctx, ApplicationContext): + name = ctx.bot.user.name if ctx.bot.user else None + icon_url = ctx.bot.user.avatar.url if ctx.bot.user and ctx.bot.user.avatar else None + elif self.bot: + name = self.bot.user.name if self.bot.user else None + icon_url = self.bot.user.avatar.url if self.bot.user and self.bot.user.avatar else None + else: + name = icon_url = None + + if not name: + name = 'YandexMusic' + if not icon_url: + icon_url="https://github.com/Lemon4ksan/YandexMusicDiscordBot/blob/main/assets/Logo.png?raw=true" - 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) + embed = discord.Embed(**kwargs) + embed.set_author(name=name, icon_url=icon_url) + if embed_type == 'info': + embed.color = 0xfed42b + elif embed_type == 'success': + embed.set_author(name = "✅ Успех") + embed.color = discord.Color.green() + else: + embed.set_author(name = "❌ Ошибка") + embed.color = discord.Color.red() + + return embed + 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. diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index 95b1495..8ad7399 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -8,7 +8,7 @@ import yandex_music.exceptions from yandex_music import Track, TrackShort, ClientAsync as YMClient import discord -from discord import Interaction, ApplicationContext, RawReactionActionEvent, VoiceChannel +from discord import Interaction, ApplicationContext, RawReactionActionEvent from MusicBot.cogs.utils.base_bot import BaseBot from MusicBot.cogs.utils import generate_item_embed @@ -63,12 +63,10 @@ class VoiceExtension(BaseBot): if guild['current_menu']: logging.info(f"[VC_EXT] Deleting old menu message {guild['current_menu']} in guild {ctx.guild_id}") - if (message := await self.get_menu_message(ctx, guild['current_menu'])): - await message.delete() + await self._delete_menu_message(ctx, guild['current_menu'], ctx.guild_id) - 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]) + await self.init_menu_view(ctx, ctx.guild_id, disable=disable) + interaction = await self.respond(ctx, embed=embed, view=self.menu_views[ctx.guild_id]) response = await interaction.original_response() if isinstance(interaction, discord.Interaction) else interaction if response: @@ -120,7 +118,6 @@ class VoiceExtension(BaseBot): Args: ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. - menu_mid (int): Id of the menu message to update. Defaults to None. menu_message (discord.Message | None): Message to update. If None, fetches menu from channel using `menu_mid`. Defaults to None. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False. @@ -174,7 +171,7 @@ class VoiceExtension(BaseBot): else: embed.remove_footer() - await self.update_menu_views_dict(ctx) + await self.menu_views[ctx.guild_id].update() try: if isinstance(ctx, Interaction) and button_callback: # If interaction from menu buttons @@ -193,7 +190,6 @@ class VoiceExtension(BaseBot): self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *, - menu_message: discord.Message | None = None, button_callback: bool = False, disable: bool = False ) -> bool: @@ -201,8 +197,6 @@ class VoiceExtension(BaseBot): Args: ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. - guild (ExplicitGuild): Guild data. - menu_message (discord.Message | None, optional): Menu message to update. Defaults to None. button_callback (bool, optional): If True, the interaction is from a button callback. Defaults to False. disable (bool, optional): Disable the view if True. Defaults to False. @@ -210,29 +204,33 @@ class VoiceExtension(BaseBot): bool: True if the view was updated, False otherwise. """ logging.debug("[VC_EXT] Updating menu view") - + if not ctx.guild_id: - logging.warning("[VC_EXT] Guild ID not found in context inside 'update_menu_view'") + logging.warning("[VC_EXT] Guild ID not found in context") 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 + guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1}) - menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message - - if not menu_message: + if not guild['current_menu']: + logging.warning("[VC_EXT] Current menu not found in guild data") return False - await self.update_menu_views_dict(ctx, disable=disable) + if ctx.guild_id not in self.menu_views: + logging.debug("[VC_EXT] Creating new menu view") + await self.init_menu_view(ctx, ctx.guild_id, disable=disable) + + view = self.menu_views[ctx.guild_id] + await view.update(disable=disable) + try: if isinstance(ctx, Interaction) and button_callback: # If interaction from menu buttons - await ctx.edit(view=self.menu_views[ctx.guild_id]) + await ctx.edit(view=view) else: # If interaction from other buttons or commands. They should have their own response. - await menu_message.edit(view=self.menu_views[ctx.guild_id]) + if (menu_message := await self.get_menu_message(ctx, guild['current_menu'])): + await menu_message.edit(view=view) + except discord.DiscordException as e: logging.warning(f"[VC_EXT] Error while updating menu view: {e}") return False @@ -338,40 +336,40 @@ class VoiceExtension(BaseBot): """ if not ctx.user: logging.info("[VC_EXT] User not found in context inside 'voice_check'") - await ctx.respond("❌ Пользователь не найден.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Пользователь не найден.", 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) + await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True) return False if not await self.get_ym_token(ctx): logging.debug(f"[VC_EXT] No token found for user {ctx.user.id}") - await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Укажите токен через /account login.", delete_after=15, ephemeral=True) return False if not isinstance(ctx.channel, discord.VoiceChannel): logging.debug("[VC_EXT] User is not in a voice channel") - await ctx.respond("❌ Вы должны отправить команду в чате голосового канала.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Вы должны отправить команду в чате голосового канала.", delete_after=15, ephemeral=True) return False if ctx.user.id not in ctx.channel.voice_states: logging.debug("[VC_EXT] User is not connected to the voice channel") - await ctx.respond("❌ Вы должны находиться в голосовом канале.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Вы должны находиться в голосовом канале.", delete_after=15, ephemeral=True) return False voice_clients = ctx.client.voice_clients if isinstance(ctx, Interaction) else ctx.bot.voice_clients if not discord.utils.get(voice_clients, guild=ctx.guild): logging.debug("[VC_EXT] Voice client not found") - await ctx.respond("❌ Добавьте бота в голосовой канал при помощи команды /voice join.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Добавьте бота в голосовой канал при помощи команды /voice join.", delete_after=15, ephemeral=True) return False if check_vibe_privilage: guild = await self.db.get_guild(ctx.guild_id, projection={'current_viber_id': 1, 'vibing': 1}) if guild['vibing'] and ctx.user.id != guild['current_viber_id']: logging.debug("[VIBE] Context user is not the current viber") - await ctx.respond("❌ Вы не можете изменять чужую волну!", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Вы не можете изменять чужую волну!", delete_after=15, ephemeral=True) return False logging.debug("[VC_EXT] Voice requirements met") @@ -411,7 +409,6 @@ class VoiceExtension(BaseBot): track: Track | dict[str, Any], *, vc: discord.VoiceClient | None = None, - menu_message: discord.Message | None = None, button_callback: bool = False, ) -> str | None: """Play `track` in the voice channel. Avoids additional vibe feedback used in `next_track` and `previous_track`. @@ -421,7 +418,6 @@ class VoiceExtension(BaseBot): ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. track (dict[str, Any]): Track to play. vc (discord.VoiceClient | None, optional): Voice client. Defaults to None. - menu_message (discord.Message | None, optional): Menu message to update. Defaults to None. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False. Returns: @@ -444,7 +440,6 @@ class VoiceExtension(BaseBot): ctx, track, vc=vc, - menu_message=menu_message, button_callback=button_callback ) @@ -501,7 +496,6 @@ class VoiceExtension(BaseBot): vc: discord.VoiceClient | None = None, *, after: bool = False, - menu_message: discord.Message | None = None, button_callback: bool = False ) -> str | None: """Switch to the next track in the queue. Return track title on success. @@ -524,9 +518,12 @@ class VoiceExtension(BaseBot): logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'") return None - 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}) + 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 + }) - if guild['is_stopped'] and after: + if after and guild['is_stopped']: logging.debug("[VC_EXT] Playback is stopped, skipping after callback.") return None @@ -534,8 +531,9 @@ class VoiceExtension(BaseBot): logging.debug("[VC_EXT] Adding current track to history") await self.db.modify_track(ctx.guild_id, guild['current_track'], 'previous', 'insert') - 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 after and guild['current_menu']: + if not await self.update_menu_view(ctx, button_callback=button_callback, disable=True): + await self.respond(ctx, "error", "Не удалось обновить меню.", ephemeral=True, delete_after=15) if guild['vibing'] and guild['current_track']: await self.send_vibe_feedback(ctx, 'trackFinished' if after else 'skip', guild['current_track']) @@ -570,10 +568,17 @@ class VoiceExtension(BaseBot): logging.info("[VC_EXT] No next track found") if after: await self.db.update(ctx.guild_id, {'is_stopped': True, 'current_track': None}) + + if guild['current_menu']: + await self.update_menu_view(ctx, button_callback=button_callback) return None - async def play_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. @@ -643,55 +648,12 @@ class VoiceExtension(BaseBot): return [] return collection.tracks - - async def react_track( - self, - ctx: ApplicationContext | Interaction, - action: Literal['like', 'dislike'] - ) -> tuple[bool, Literal['added', 'removed'] | None]: - """Like or dislike current track. Return track title on success. - - Args: - ctx (ApplicationContext | Interaction): Context. - action (Literal['like', 'dislike']): Action to perform. - - Returns: - (tuple[bool, Literal['added', 'removed'] | None]): Tuple with success status and action. - """ - if not (gid := ctx.guild_id) or not ctx.user: - logging.warning("[VC_EXT] Guild or User not found") - return (False, None) - - if not (current_track := await self.db.get_track(gid, 'current')): - logging.debug("[VC_EXT] Current track not found") - return (False, None) - - if not (client := await self.init_ym_client(ctx)): - return (False, None) - - if action == 'like': - tracks = await client.users_likes_tracks() - add_func = client.users_likes_tracks_add - remove_func = client.users_likes_tracks_remove - else: - tracks = await client.users_dislikes_tracks() - add_func = client.users_dislikes_tracks_add - remove_func = client.users_dislikes_tracks_remove - - if tracks is None: - logging.debug(f"[VC_EXT] No {action}s found") - return (False, None) - - if str(current_track['id']) not in [str(track.id) for track in tracks]: - logging.debug(f"[VC_EXT] Track not found in {action}s. Adding...") - await add_func(current_track['id']) - return (True, 'added') - else: - logging.debug(f"[VC_EXT] Track found in {action}s. Removing...") - await remove_func(current_track['id']) - return (True, 'removed') - async def proccess_vote(self, ctx: RawReactionActionEvent, guild: ExplicitGuild, channel: VoiceChannel, vote_data: MessageVotes) -> bool: + async def proccess_vote( + self, + ctx: RawReactionActionEvent, + guild: ExplicitGuild, + vote_data: MessageVotes) -> bool: """Proccess vote and perform action from `vote_data` and respond. Return True on success. Args: @@ -710,16 +672,16 @@ class VoiceExtension(BaseBot): return False if not guild['current_menu'] and not await self.send_menu_message(ctx): - await channel.send(content=f"❌ Не удалось отправить меню! Попробуйте ещё раз.", delete_after=15) + await self.respond(ctx, "error", "Не удалось отправить меню! Попробуйте ещё раз.", 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) + await self.respond(ctx, "error", "Очередь пуста!", delete_after=15) 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) + await self.respond(ctx, "error", "Ошибка при смене трека! Попробуйте ещё раз.", delete_after=15) return False elif vote_data['action'] == 'add_track': @@ -730,9 +692,9 @@ class VoiceExtension(BaseBot): await self.db.modify_track(guild['_id'], vote_data['vote_content'], 'next', 'append') if guild['current_track']: - await channel.send(content=f"✅ Трек был добавлен в очередь!", delete_after=15) + await self.respond(ctx, "success", "Трек был добавлен в очередь!", delete_after=15) elif not await self.play_next_track(ctx): - await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) + await self.respond(ctx, "error", "Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) return False elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'): @@ -744,14 +706,14 @@ class VoiceExtension(BaseBot): await self.db.modify_track(guild['_id'], vote_data['vote_content'], 'next', 'extend') if guild['current_track']: - await channel.send(content=f"✅ Контент был добавлен в очередь!", delete_after=15) + await self.respond(ctx, "success", "Контент был добавлен в очередь!", delete_after=15) elif not await self.play_next_track(ctx): - await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) + await self.respond(ctx, "error", "Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) return False elif vote_data['action'] == 'play/pause': if not (vc := await self.get_voice_client(ctx)): - await channel.send(content=f"❌ Ошибка при изменении воспроизведения! Попробуйте ещё раз.", delete_after=15) + await self.respond(ctx, "error", "Ошибка при изменении воспроизведения! Попробуйте ещё раз.", delete_after=15) return False if vc.is_playing(): @@ -767,31 +729,31 @@ class VoiceExtension(BaseBot): elif vote_data['action'] == 'clear_queue': await self.db.update(ctx.guild_id, {'previous_tracks': [], 'next_tracks': []}) - await channel.send("✅ Очередь и история сброшены.", delete_after=15) + await self.respond(ctx, "success", "Очередь и история сброшены.", delete_after=15) elif vote_data['action'] == 'stop': if await self.stop_playing(ctx, full=True): - await channel.send("✅ Воспроизведение остановлено.", delete_after=15) + await self.respond(ctx, "success", "Воспроизведение остановлено.", delete_after=15) else: - await channel.send("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15) + await self.respond(ctx, "error", "Произошла ошибка при остановке воспроизведения.", delete_after=15) return False - + elif vote_data['action'] == 'vibe_station': vibe_type, vibe_id, viber_id = vote_data['vote_content'] if isinstance(vote_data['vote_content'], list) else (None, None, None) 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) + await self.respond(ctx, "error", "Произошла ошибка при обновлении станции.", delete_after=15) return False if not await self.update_vibe(ctx, vibe_type, vibe_id, viber_id=viber_id): - await channel.send("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15) + await self.respond(ctx, "error", "Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15) return False 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) + await self.respond(ctx, "error", "Не удалось воспроизвести трек.", delete_after=15) return False else: @@ -825,8 +787,6 @@ class VoiceExtension(BaseBot): user = await self.users_db.get_user(uid, projection={'vibe_batch_id': 1, 'vibe_type': 1, 'vibe_id': 1}) if not (client := await self.init_ym_client(ctx)): - logging.info(f"[VC_EXT] Failed to init YM client for user {user['_id']}") - await self.send_response_message(ctx, "❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) return False if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms']: @@ -895,7 +855,6 @@ class VoiceExtension(BaseBot): track: Track, *, vc: discord.VoiceClient | None = None, - menu_message: discord.Message | None = None, button_callback: bool = False, retry: bool = False ) -> str | None: @@ -906,7 +865,6 @@ class VoiceExtension(BaseBot): ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. track (Track): Track to play. vc (discord.VoiceClient | None): Voice client. - menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None. button_callback (bool): Should be True if the function is being called from button callback. Defaults to False. retry (bool): Whether the function is called again. @@ -928,21 +886,25 @@ class VoiceExtension(BaseBot): await self._download_track(ctx.guild_id, track) except yandex_music.exceptions.TimedOutError: if not retry: - return await self._play_track(ctx, track, vc=vc, menu_message=menu_message, button_callback=button_callback, retry=True) + return await self._play_track(ctx, track, vc=vc, button_callback=button_callback, retry=True) - await self.send_response_message(ctx, f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) + await self.respond(ctx, "error", "Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) logging.error(f"[VC_EXT] Failed to download track '{track.title}'") return None + except yandex_music.exceptions.InvalidBitrateError: + logging.error(f"[VC_EXT] Invalid bitrate while playing track '{track.title}'") + await self.respond(ctx, "error", "У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15, ephemeral=True) + return None + async with aiofiles.open(f'music/{ctx.guild_id}.mp3', "rb") as f: track_bytes = io.BytesIO(await f.read()) song = discord.FFmpegPCMAudio(track_bytes, pipe=True, options='-vn -b:a 64k -filter:a "volume=0.15"') await self.db.set_current_track(ctx.guild_id, track) - if menu_message or guild['current_menu']: - # Updating menu message before playing to prevent delay and avoid FFMPEG lags. - await self.update_menu_embed_and_view(ctx, menu_message=menu_message, button_callback=button_callback) + if guild['current_menu']: + await self.update_menu_embed_and_view(ctx, button_callback=button_callback) if not guild['vibing']: # Giving FFMPEG enough time to process the audio file @@ -953,11 +915,7 @@ class VoiceExtension(BaseBot): 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}") - 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}'") - await self.send_response_message(ctx, f"❌ У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True) return None logging.info(f"[VC_EXT] Playing track '{track.title}'") diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index 28ccebd..4ff1f5d 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -152,7 +152,7 @@ class Voice(Cog, VoiceExtension): if len(vote_data['positive_votes']) >= required_votes: logging.info(f"[VOICE] Enough positive votes for message {payload.message_id}") await message.delete() - await self.proccess_vote(payload, guild, channel, vote_data) + await self.proccess_vote(payload, guild, vote_data) del votes[str(payload.message_id)] elif len(vote_data['negative_votes']) >= required_votes: @@ -211,7 +211,7 @@ class Voice(Cog, VoiceExtension): 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}") if await self.voice_check(ctx) and not await self.send_menu_message(ctx): - await ctx.respond("❌ Не удалось создать меню.", ephemeral=True) + await self.respond(ctx, "error", "Не удалось создать меню.", ephemeral=True) @voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.") async def join(self, ctx: discord.ApplicationContext) -> None: @@ -219,40 +219,51 @@ class Voice(Cog, VoiceExtension): if not ctx.guild_id: logging.warning("[VOICE] Join command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) + await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", ephemeral=True) return if ctx.author.id not in ctx.channel.voice_states: logging.debug("[VC_EXT] User is not connected to the voice channel") - await ctx.respond("❌ Вы должны находиться в голосовом канале.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Вы должны находиться в голосовом канале.", delete_after=15, ephemeral=True) return member = cast(discord.Member, ctx.author) guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1, 'use_single_token': 1}) + if guild['use_single_token'] and not await self.users_db.get_ym_token(ctx.author.id): + await self.respond( + ctx, "error", + "У вас нет токена Яндекс Музыки. Используйте команду /account login для установки токена, " \ + "попросите участника с токеном запустить бота или отключите использование общего токена в настройках сервера.", + delete_after=15, ephemeral=True + ) + return + await ctx.defer(ephemeral=True) + if not member.guild_permissions.manage_channels and not guild['allow_change_connect']: - response_message = "❌ У вас нет прав для выполнения этой команды." + response_message = ("error", "У вас нет прав для выполнения этой команды.") elif isinstance(ctx.channel, discord.VoiceChannel): try: await ctx.channel.connect() except TimeoutError: - response_message = "❌ Не удалось подключиться к голосовому каналу." + response_message = ("error", "Не удалось подключиться к голосовому каналу.") except discord.ClientException: - response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave." + response_message = ("error", "Бот уже находится в голосовом канале.\nВыключите его с помощью команды /voice leave.") except discord.DiscordException as e: logging.error(f"[VOICE] DiscordException: {e}") - response_message = "❌ Произошла неизвестная ошибка при подключении к голосовому каналу." + response_message = ("error", "Произошла неизвестная ошибка при подключении к голосовому каналу.") else: - response_message = "✅ Подключение успешно!" + response_message = ("success", "Подключение успешно!") - if guild['use_single_token'] and await self.users_db.get_ym_token(ctx.author.id): + if guild['use_single_token']: + response_message = ("success", "Подключение успешно! Ваш токен будет использован для всех операций с музыкой на этом сервере.") await self.db.update(ctx.guild_id, {'single_token_uid': ctx.author.id}) else: - response_message = "❌ Вы должны отправить команду в чате голосового канала." + response_message = ("error", "Вы должны отправить команду в чате голосового канала.") logging.info(f"[VOICE] Join command response: {response_message}") - await ctx.respond(response_message, delete_after=15, ephemeral=True) + await self.respond(ctx, *response_message, delete_after=15, ephemeral=True) @voice.command(description="Заставить бота покинуть голосовой канал.") async def leave(self, ctx: discord.ApplicationContext) -> None: @@ -260,7 +271,7 @@ class Voice(Cog, VoiceExtension): if not ctx.guild_id: logging.info("[VOICE] Leave command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) + await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", ephemeral=True) return member = cast(discord.Member, ctx.author) @@ -268,7 +279,7 @@ class Voice(Cog, VoiceExtension): if not member.guild_permissions.manage_channels and not guild['allow_change_connect']: 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) + await self.respond(ctx, "error", "У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) return if not await self.voice_check(ctx): @@ -276,18 +287,18 @@ class Voice(Cog, VoiceExtension): 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) + await self.respond(ctx, "error", "Бот не подключен к голосовому каналу.", 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) + await self.respond(ctx, "error", "Не удалось отключиться.", delete_after=15, ephemeral=True) return await vc.disconnect(force=True) logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild_id}") await self.db.update(ctx.guild_id, {'single_token_uid': None}) - await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True) + await self.respond(ctx, "success", "Отключение успешно!", delete_after=15, ephemeral=True) @queue.command(description="Очистить очередь треков и историю прослушивания.") async def clear(self, ctx: discord.ApplicationContext) -> None: @@ -303,7 +314,7 @@ class Voice(Cog, VoiceExtension): 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)) + message = cast(discord.Interaction, await self.respond(ctx, "info", response_message, delete_after=60)) response = await message.original_response() await response.add_reaction('✅') @@ -323,7 +334,7 @@ class Voice(Cog, VoiceExtension): return await self.db.update(ctx.guild_id, {'previous_tracks': [], 'next_tracks': []}) - await ctx.respond("✅ Очередь и история сброшены.", delete_after=15, ephemeral=True) + await self.respond(ctx, "success", "Очередь и история сброшены.", delete_after=15, ephemeral=True) logging.info(f"[VOICE] Queue and history cleared in guild {ctx.guild_id}") @queue.command(description="Получить очередь треков.") @@ -332,15 +343,13 @@ class Voice(Cog, VoiceExtension): if not await self.voice_check(ctx): return - await self.users_db.update(ctx.user.id, {'queue_page': 0}) tracks = await self.db.get_tracks_list(ctx.guild_id, 'next') if len(tracks) == 0: - await ctx.respond("❌ Очередь пуста.", ephemeral=True) + await self.respond(ctx, "error", "Очередь прослушивания пуста.", delete_after=15, ephemeral=True) return - embed = generate_queue_embed(0, tracks) - await ctx.respond(embed=embed, view=await QueueView(ctx).init(), ephemeral=True) + await ctx.respond(embed=generate_queue_embed(0, tracks), view=QueueView(ctx, tracks), ephemeral=True) logging.info(f"[VOICE] Queue embed sent to user {ctx.author.id} in guild {ctx.guild_id}") @@ -358,7 +367,7 @@ class Voice(Cog, VoiceExtension): logging.info(f"Starting vote for stopping playback in guild {ctx.guild_id}") response_message = f"{member.mention} хочет полностью остановить проигрывание.\n\n Выполнить действие?." - message = cast(discord.Interaction, await ctx.respond(response_message, delete_after=60)) + message = cast(discord.Interaction, await self.respond(ctx, "info", response_message, delete_after=60)) response = await message.original_response() await response.add_reaction('✅') @@ -380,9 +389,9 @@ class Voice(Cog, VoiceExtension): await ctx.defer(ephemeral=True) res = await self.stop_playing(ctx, full=True) if res: - await ctx.respond("✅ Воспроизведение остановлено.", delete_after=15, ephemeral=True) + await self.respond(ctx, "success", "Воспроизведение остановлено.", delete_after=15, ephemeral=True) else: - await ctx.respond("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Произошла ошибка при остановке воспроизведения.", delete_after=15, ephemeral=True) @voice.command(description="Запустить Мою Волну.") @discord.option( @@ -403,7 +412,7 @@ class Voice(Cog, VoiceExtension): if guild['vibing']: logging.info(f"[VOICE] Action declined: vibing is already enabled in guild {ctx.guild_id}") - await ctx.respond("❌ Моя Волна уже включена. Используйте /voice stop, чтобы остановить воспроизведение.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Моя Волна уже включена. Используйте /voice stop, чтобы остановить воспроизведение.", delete_after=15, ephemeral=True) return await ctx.defer(invisible=False) @@ -420,14 +429,14 @@ class Voice(Cog, VoiceExtension): if not content: logging.debug(f"[VOICE] Station {name} not found") - await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Станция не найдена.", delete_after=15, ephemeral=True) return vibe_type, vibe_id = content.ad_params.other_params.split(':') if content.ad_params else (None, None) 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) + await self.respond(ctx, "error", "Станция не найдена.", delete_after=15, ephemeral=True) return else: vibe_type, vibe_id = 'user', 'onyourwave' @@ -435,6 +444,8 @@ class Voice(Cog, VoiceExtension): member = cast(discord.Member, ctx.author) channel = cast(discord.VoiceChannel, ctx.channel) + + await self.users_db.reset_vibe_settings(ctx.user.id) 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}") @@ -445,11 +456,11 @@ class Voice(Cog, VoiceExtension): station = content.station.name else: logging.warning(f"[VOICE] Station {name} not found") - await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Станция не найдена.", delete_after=15, ephemeral=True) return response_message = f"{member.mention} хочет запустить станцию **{station}**.\n\n Выполнить действие?" - message = cast(discord.WebhookMessage, await ctx.respond(response_message)) + message = cast(discord.WebhookMessage, await self.respond(ctx, "info", response_message, delete_after=60)) await message.add_reaction('✅') await message.add_reaction('❌') @@ -468,13 +479,13 @@ class Voice(Cog, VoiceExtension): return if not await self.update_vibe(ctx, vibe_type, vibe_id): - await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True) return if guild['current_menu']: - await ctx.respond("✅ Моя Волна включена.", delete_after=15, ephemeral=True) + await self.respond(ctx, "success", "Моя Волна включена.", delete_after=15, ephemeral=True) elif not await self.send_menu_message(ctx, disable=True): - await ctx.respond("❌ Не удалось отправить меню. Попробуйте позже.", delete_after=15, ephemeral=True) + await self.respond(ctx, "error", "Не удалось отправить меню. Попробуйте позже.", delete_after=15, ephemeral=True) if (next_track := await self.db.get_track(ctx.guild_id, 'next')): await self.play_track(ctx, next_track) diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index 2581476..f5f68c9 100644 --- a/MusicBot/database/base.py +++ b/MusicBot/database/base.py @@ -20,9 +20,6 @@ guilds: AsyncCollection[ExplicitGuild] = db.guilds class BaseUsersDatabase: DEFAULT_USER = User( ym_token=None, - playlists=[], - playlists_page=0, - queue_page=0, vibe_batch_id=None, vibe_type=None, vibe_id=None, @@ -70,6 +67,16 @@ class BaseUsersDatabase: ) return cast(str | None, user.get('ym_token') if user else None) + async def reset_vibe_settings(self, uid: int) -> None: + await users.update_one( + {'_id': uid}, + {'$set': {'vibe_settings': { + 'mood': 'all', + 'diversity': 'default', + 'lang': 'any' + }}} + ) + class BaseGuildsDatabase: DEFAULT_GUILD = Guild( diff --git a/MusicBot/database/user.py b/MusicBot/database/user.py index 5f0486d..3536ed1 100644 --- a/MusicBot/database/user.py +++ b/MusicBot/database/user.py @@ -8,9 +8,6 @@ VibeSettingsOptions: TypeAlias = Literal[ class User(TypedDict, total=False): # Don't forget to change base.py if you add a new field ym_token: str | None - playlists: list[tuple[str, int]] - playlists_page: int - queue_page: int vibe_batch_id: str | None vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None vibe_id: str | int | None @@ -19,9 +16,6 @@ class User(TypedDict, total=False): # Don't forget to change base.py if you add class ExplicitUser(TypedDict): _id: int ym_token: str | None - playlists: list[tuple[str, int]] # name / tracks count - playlists_page: int - queue_page: int vibe_batch_id: str | None vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None vibe_id: str | int | None diff --git a/MusicBot/ui/find.py b/MusicBot/ui/find.py index 9ad147c..034532d 100644 --- a/MusicBot/ui/find.py +++ b/MusicBot/ui/find.py @@ -20,7 +20,7 @@ class PlayButton(Button, VoiceExtension): if not interaction.guild_id: logging.info("[FIND] No guild found in PlayButton callback") - await interaction.respond("❌ Эта команда доступна только на серверах.", ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Эта команда доступна только на серверах.", ephemeral=True, delete_after=15) return if not await self.voice_check(interaction): @@ -28,7 +28,7 @@ class PlayButton(Button, VoiceExtension): guild = await self.db.get_guild(interaction.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vote_add': 1, 'vibing': 1}) if guild['vibing']: - await interaction.respond("❌ Нельзя добавлять треки в очередь, пока запущена волна.", ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Нельзя добавлять треки в очередь, пока запущена волна.", ephemeral=True, delete_after=15) return channel = cast(discord.VoiceChannel, interaction.channel) @@ -38,54 +38,54 @@ class PlayButton(Button, VoiceExtension): tracks = [self.item] action = 'add_track' vote_message = f"{member.mention} хочет добавить трек **{self.item.title}** в очередь.\n\n Голосуйте за добавление." - response_message = f"✅ Трек **{self.item.title}** был добавлен в очередь." + response_message = f"Трек **{self.item.title}** был добавлен в очередь." elif isinstance(self.item, Album): album = await self.item.with_tracks_async() if not album or not album.volumes: logging.debug("[FIND] Failed to fetch album tracks in PlayButton callback") - await interaction.respond("❌ Не удалось получить треки альбома.", ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось получить треки альбома.", ephemeral=True, delete_after=15) return tracks = [track for volume in album.volumes for track in volume] action = 'add_album' vote_message = f"{member.mention} хочет добавить альбом **{self.item.title}** в очередь.\n\n Голосуйте за добавление." - response_message = f"✅ Альбом **{self.item.title}** был добавлен в очередь." + response_message = f"Альбом **{self.item.title}** был добавлен в очередь." elif isinstance(self.item, Artist): artist_tracks = await self.item.get_tracks_async() if not artist_tracks: logging.debug("[FIND] Failed to fetch artist tracks in PlayButton callback") - await interaction.respond("❌ Не удалось получить треки артиста.", ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось получить треки артиста.", ephemeral=True, delete_after=15) return tracks = artist_tracks.tracks.copy() action = 'add_artist' vote_message = f"{member.mention} хочет добавить треки от **{self.item.name}** в очередь.\n\n Голосуйте за добавление." - response_message = f"✅ Песни артиста **{self.item.name}** были добавлены в очередь." + response_message = f"Песни артиста **{self.item.name}** были добавлены в очередь." elif isinstance(self.item, Playlist): short_tracks = await self.item.fetch_tracks_async() if not short_tracks: logging.debug("[FIND] Failed to fetch playlist tracks in PlayButton callback") - await interaction.respond("❌ Не удалось получить треки из плейлиста.", ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось получить треки из плейлиста.", ephemeral=True, delete_after=15) return tracks = [cast(Track, short_track.track) for short_track in short_tracks] action = 'add_playlist' vote_message = f"{member.mention} хочет добавить плейлист **{self.item.title}** в очередь.\n\n Голосуйте за добавление." - response_message = f"✅ Плейлист **{self.item.title}** был добавлен в очередь." + response_message = f"Плейлист **{self.item.title}** был добавлен в очередь." elif isinstance(self.item, list): tracks = self.item.copy() if not tracks: logging.debug("[FIND] Empty tracks list in PlayButton callback") - await interaction.respond("❌ Не удалось получить треки.", ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось получить треки.", ephemeral=True, delete_after=15) return action = 'add_playlist' vote_message = f"{member.mention} хочет добавить плейлист **Мне Нравится** в очередь.\n\n Голосуйте за добавление." - response_message = f"✅ Плейлист **«Мне нравится»** был добавлен в очередь." + response_message = f"Плейлист **«Мне нравится»** был добавлен в очередь." else: raise ValueError(f"Unknown item type: '{type(self.item).__name__}'") @@ -93,7 +93,7 @@ class PlayButton(Button, VoiceExtension): if guild['vote_add'] and len(channel.members) > 2 and not member.guild_permissions.manage_channels: logging.info(f"Starting vote for '{action}' (from PlayButton callback)") - message = cast(discord.Interaction, await interaction.respond(vote_message, delete_after=60)) + message = cast(discord.Interaction, await self.respond(interaction, "info", vote_message, delete_after=60)) response = await message.original_response() await response.add_reaction('✅') @@ -113,9 +113,9 @@ class PlayButton(Button, VoiceExtension): return if guild['current_menu']: - await interaction.respond(response_message, delete_after=15) + await self.respond(interaction, "success", response_message, delete_after=15) elif not await self.send_menu_message(interaction, disable=True): - await interaction.respond('❌ Не удалось отправить сообщение.', ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось отправить сообщение.", ephemeral=True, delete_after=15) if guild['current_track']: logging.debug(f"[FIND] Adding tracks to queue") @@ -125,7 +125,7 @@ class PlayButton(Button, VoiceExtension): track = tracks.pop(0) await self.db.modify_track(interaction.guild_id, tracks, 'next', 'extend') if not await self.play_track(interaction, track): - await interaction.respond('❌ Не удалось воспроизвести трек.', ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось воспроизвести трек.", ephemeral=True, delete_after=15) if interaction.message: await interaction.message.delete() @@ -150,7 +150,7 @@ class MyVibeButton(Button, VoiceExtension): guild = await self.db.get_guild(interaction.guild_id, projection={'current_menu': 1, 'vibing': 1}) if guild['vibing']: - await interaction.respond('❌ Волна уже запущена. Остановите её с помощью команды /voice stop.', ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Волна уже запущена. Остановите её с помощью команды /voice stop.", ephemeral=True, delete_after=15) return track_type_map = { @@ -160,7 +160,7 @@ class MyVibeButton(Button, VoiceExtension): if isinstance(self.item, Playlist): if not self.item.owner: logging.warning(f"[VIBE] Playlist owner is None") - await interaction.respond("❌ Не удалось получить информацию о плейлисте. Отсутствует владелец.", ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось получить информацию о плейлисте. Отсутствует владелец.", ephemeral=True, delete_after=15) return _id = self.item.owner.login + '_' + str(self.item.kind) @@ -187,7 +187,7 @@ class MyVibeButton(Button, VoiceExtension): case list(): response_message = f"{member.mention} хочет запустить станцию **Моя Волна**.\n\n Выполнить действие?" - message = cast(discord.Interaction, await interaction.respond(response_message)) + message = cast(discord.Interaction, await self.respond(interaction, "info", response_message)) response = await message.original_response() await response.add_reaction('✅') @@ -207,7 +207,7 @@ class MyVibeButton(Button, VoiceExtension): return if not guild['current_menu'] and not await self.send_menu_message(interaction, disable=True): - await interaction.respond('❌ Не удалось отправить сообщение.', ephemeral=True, delete_after=15) + await self.respond(interaction, "error", "Не удалось отправить сообщение.", ephemeral=True, delete_after=15) await self.update_vibe(interaction, track_type_map[type(self.item)], _id) diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index afdf713..ed3b0b4 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -1,5 +1,6 @@ import logging -from typing import Self, cast +from time import monotonic +from typing import Self, Literal, cast from discord.ui import View, Button, Item, Select from discord import ( @@ -13,20 +14,21 @@ from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient from MusicBot.cogs.utils import VoiceExtension class ToggleButton(Button, VoiceExtension): - def __init__(self, *args, **kwargs): + def __init__(self, root: 'MenuView', *args, **kwargs): super().__init__(*args, **kwargs) VoiceExtension.__init__(self, None) + self.root = root async def callback(self, interaction: Interaction) -> None: - callback_type = interaction.custom_id - if callback_type not in ('repeat', 'shuffle'): + + if (callback_type := interaction.custom_id) not in ('repeat', 'shuffle'): raise ValueError(f"Invalid callback type: '{callback_type}'") logging.info(f'[MENU] {callback_type.capitalize()} button callback') if not (gid := interaction.guild_id) or not interaction.user: logging.warning('[MENU] Failed to get guild ID.') - await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True) return if not await self.voice_check(interaction): @@ -41,7 +43,7 @@ class ToggleButton(Button, VoiceExtension): action = "выключить" if guild[callback_type] else "включить" task = "перемешивание треков" if callback_type == 'shuffle' else "повтор трека" - message = cast(Interaction, await interaction.respond(f"{member.mention} хочет {action} {task}.\n\nВыполнить действие?", delete_after=60)) + message = cast(Interaction, await self.respond(interaction, "info", f"{member.mention} хочет {action} {task}.\n\nВыполнить действие?", delete_after=60)) response = await message.original_response() await response.add_reaction('✅') @@ -62,8 +64,10 @@ class ToggleButton(Button, VoiceExtension): await self.db.update(gid, {callback_type: not guild[callback_type]}) - if not await self.update_menu_view(interaction, button_callback=True): - await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True) + button = self.root.repeat_button if callback_type == 'repeat' else self.root.shuffle_button + button.style = ButtonStyle.secondary if guild[callback_type] else ButtonStyle.success + + await interaction.edit(view=await self.root.update()) class PlayPauseButton(Button, VoiceExtension): def __init__(self, **kwargs): @@ -90,7 +94,7 @@ class PlayPauseButton(Button, VoiceExtension): logging.info(f"[MENU] User {interaction.user.id} started vote to pause/resume track in guild {gid}") task = "приостановить" if vc.is_playing() else "возобновить" - message = cast(Interaction, await interaction.respond(f"{member.mention} хочет {task} проигрывание.\n\nВыполнить действие?", delete_after=60)) + message = cast(Interaction, await self.respond(interaction, "info", f"{member.mention} хочет {task} проигрывание.\n\nВыполнить действие?", delete_after=60)) response = await message.original_response() await response.add_reaction('✅') @@ -109,23 +113,31 @@ class PlayPauseButton(Button, VoiceExtension): ) return + if vc.is_paused(): + vc.resume() + else: + vc.pause() + try: embed = interaction.message.embeds[0] except IndexError: - await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True) return guild = await self.db.get_guild(interaction.guild_id, projection={'single_token_uid': 1}) - - if vc.is_paused(): - vc.resume() - if guild['single_token_uid'] and (user := await self.get_discord_user_by_id(interaction, guild['single_token_uid'])): + + if not vc.is_paused() and guild['single_token_uid']: + user = await self.get_discord_user_by_id(interaction, guild['single_token_uid']) + + if guild['single_token_uid'] and user: embed.set_footer(text=f"Используется токен {user.display_name}", icon_url=user.display_avatar.url) else: - embed.remove_footer() - else: - vc.pause() + embed.set_footer(text='Используется токен (неизвестный пользователь)') + + elif vc.is_paused(): embed.set_footer(text='Приостановлено') + else: + embed.remove_footer() await interaction.edit(embed=embed) @@ -135,8 +147,8 @@ class SwitchTrackButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - callback_type = interaction.custom_id - if callback_type not in ('next', 'previous'): + + if (callback_type := interaction.custom_id) not in ('next', 'previous'): raise ValueError(f"Invalid callback type: '{callback_type}'") if not (gid := interaction.guild_id) or not interaction.user: @@ -153,7 +165,7 @@ class SwitchTrackButton(Button, VoiceExtension): 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) + await self.respond(interaction, "error", f"Нет треков в {'очереди' if callback_type == 'next' else 'истории'}.", delete_after=15, ephemeral=True) return member = cast(Member, interaction.user) @@ -163,7 +175,7 @@ class SwitchTrackButton(Button, VoiceExtension): logging.info(f"[MENU] User {interaction.user.id} started vote to skip track in guild {gid}") task = "пропустить текущий трек" if callback_type == 'next' else "вернуться к предыдущему треку" - message = cast(Interaction, await interaction.respond(f"{member.mention} хочет {task}.\n\nВыполнить переход?", delete_after=60)) + message = cast(Interaction, await self.respond(interaction, "info", f"{member.mention} хочет {task}.\n\nВыполнить переход?", delete_after=60)) response = await message.original_response() await response.add_reaction('✅') @@ -188,12 +200,13 @@ class SwitchTrackButton(Button, VoiceExtension): title = await self.play_previous_track(interaction, button_callback=True) if not title: - await interaction.respond(f"❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) class ReactionButton(Button, VoiceExtension): - def __init__(self, *args, **kwargs): + def __init__(self, root: 'MenuView', *args, **kwargs): super().__init__(*args, **kwargs) VoiceExtension.__init__(self, None) + self.root = root async def callback(self, interaction: Interaction): callback_type = interaction.custom_id @@ -206,34 +219,81 @@ class ReactionButton(Button, VoiceExtension): return if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: - await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True) channel = cast(VoiceChannel, interaction.channel) 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=self.menu_views[gid]) - await interaction.respond( - f"✅ Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}", - delete_after=15, ephemeral=True - ) + button = self.root.like_button + response_message = f"Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}" elif callback_type == 'dislike' and res[0]: - if len(channel.members) == 2 and not await self.play_next_track(interaction, vc=vc, button_callback=True): - await interaction.respond("✅ Воспроизведение приостановлено. Нет треков в очереди.", delete_after=15) + if len(channel.members) == 2: + await self.play_next_track(interaction, vc=vc, button_callback=True) + return - 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 - ) + button = self.root.dislike_button + response_message =f"Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}" else: logging.debug(f"[VC_EXT] Failed to get {callback_type} tracks") - await interaction.respond("❌ Операция не удалась. Попробуйте позже.", delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Операция не удалась. Попробуйте позже.", delete_after=15, ephemeral=True) + return + + if len(channel.members) == 2: + button.style = ButtonStyle.success if res[1] == 'added' else ButtonStyle.secondary + await interaction.edit(view=await self.root.update()) + else: + await self.respond(interaction, "success", response_message, delete_after=15, ephemeral=True) + + async def react_track( + self, + ctx: ApplicationContext | Interaction, + action: Literal['like', 'dislike'] + ) -> tuple[bool, Literal['added', 'removed'] | None]: + """Like or dislike current track. Return track title on success. + + Args: + ctx (ApplicationContext | Interaction): Context. + action (Literal['like', 'dislike']): Action to perform. + + Returns: + (tuple[bool, Literal['added', 'removed'] | None]): Tuple with success status and action. + """ + if not (gid := ctx.guild_id) or not ctx.user: + logging.warning("[VC_EXT] Guild or User not found") + return (False, None) + + if not (current_track := await self.db.get_track(gid, 'current')): + logging.debug("[VC_EXT] Current track not found") + return (False, None) + + if not (client := await self.init_ym_client(ctx)): + return (False, None) + + if action == 'like': + tracks = await client.users_likes_tracks() + add_func = client.users_likes_tracks_add + remove_func = client.users_likes_tracks_remove + else: + tracks = await client.users_dislikes_tracks() + add_func = client.users_dislikes_tracks_add + remove_func = client.users_dislikes_tracks_remove + + if tracks is None: + logging.debug(f"[VC_EXT] No {action}s found") + return (False, None) + + if str(current_track['id']) not in [str(track.id) for track in tracks]: + logging.debug(f"[VC_EXT] Track not found in {action}s. Adding...") + await add_func(current_track['id']) + return (True, 'added') + else: + logging.debug(f"[VC_EXT] Track found in {action}s. Removing...") + await remove_func(current_track['id']) + return (True, 'removed') class LyricsButton(Button, VoiceExtension): def __init__(self, **kwargs): @@ -249,8 +309,7 @@ class LyricsButton(Button, VoiceExtension): if not (client := await self.init_ym_client(interaction)): return - current_track = await self.db.get_track(interaction.guild_id, 'current') - if not current_track: + if not (current_track := await self.db.get_track(interaction.guild_id, 'current')): logging.debug('[MENU] No current track found') return @@ -258,7 +317,7 @@ class LyricsButton(Button, VoiceExtension): lyrics = cast(TrackLyrics, await client.tracks_lyrics(current_track['id'])) except yandex_music.exceptions.NotFoundError: logging.debug('[MENU] Lyrics not found') - await interaction.respond("❌ Текст песни не найден. Яндекс нам соврал (опять)!", delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Текст песни не найден. Яндекс нам соврал (опять)!", delete_after=15, ephemeral=True) return embed = Embed( @@ -304,7 +363,7 @@ class MyVibeButton(Button, VoiceExtension): vibe_type = 'user' vibe_id = 'onyourwave' - message = cast(Interaction, await interaction.respond(response_message)) + message = cast(Interaction, await self.respond(interaction, "info", response_message)) response = await message.original_response() await response.add_reaction('✅') @@ -340,7 +399,7 @@ class MyVibeButton(Button, VoiceExtension): if not res: logging.info('[MENU] Failed to start the vibe') - await interaction.respond('❌ Не удалось запустить "Мою Волну". Возможно, у вас нет подписки на Яндекс Музыку.', ephemeral=True) + await self.respond(interaction, "error", "Не удалось запустить **Мою Волну**. Возможно, у вас нет подписки на Яндекс Музыку.", ephemeral=True) if (next_track := await self.db.get_track(interaction.guild_id, 'next')): await self.play_track(interaction, next_track, button_callback=True) @@ -359,7 +418,7 @@ class MyVibeSelect(Select, VoiceExtension): if not interaction.user: logging.warning('[MENU] No user in select callback') return - + custom_id = interaction.custom_id if custom_id not in ('diversity', 'mood', 'lang'): logging.error(f'[MENU] Unknown custom_id: {custom_id}') @@ -470,7 +529,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 self.respond(interaction, "info", "Настройки **Волны**", view=await MyVibeSettingsView(interaction).init(), ephemeral=True) class AddToPlaylistSelect(Select, VoiceExtension): def __init__(self, ym_client: YMClient, *args, **kwargs): @@ -522,11 +581,11 @@ class AddToPlaylistSelect(Select, VoiceExtension): ) if not res: - await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) elif track_in_playlist: - await interaction.respond('🗑 Трек был удалён из плейлиста.', delete_after=15, ephemeral=True) + await self.respond(interaction, "success", "🗑 Трек был удалён из плейлиста.", delete_after=15, ephemeral=True) else: - await interaction.respond('📩 Трек был добавлен в плейлист.', delete_after=15, ephemeral=True) + await self.respond(interaction, "success", "📩 Трек был добавлен в плейлист.", delete_after=15, ephemeral=True) class AddToPlaylistButton(Button, VoiceExtension): @@ -538,22 +597,20 @@ class AddToPlaylistButton(Button, VoiceExtension): if not await self.voice_check(interaction) or not interaction.guild_id: return - current_track = await self.db.get_track(interaction.guild_id, 'current') - if not current_track: - await interaction.respond('❌ Нет воспроизводимого трека.', delete_after=15, ephemeral=True) + if not await self.db.get_track(interaction.guild_id, 'current'): + await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True) return if not (client := await self.init_ym_client(interaction)): - await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) return if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: - await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) + await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True) return - playlists = await client.users_playlists_list() - if not playlists: - await interaction.respond('❌ У вас нет плейлистов.', delete_after=15, ephemeral=True) + if not (playlists := await client.users_playlists_list()): + await self.respond(interaction, "error", "У вас нет плейлистов.", delete_after=15, ephemeral=True) return view = View( @@ -580,39 +637,58 @@ class MenuView(View, VoiceExtension): VoiceExtension.__init__(self, None) self.ctx = ctx - self.repeat_button = ToggleButton(style=ButtonStyle.secondary, emoji='🔂', row=0, custom_id='repeat') - self.shuffle_button = ToggleButton(style=ButtonStyle.secondary, emoji='🔀', row=0, custom_id='shuffle') + self.repeat_button = ToggleButton(self, style=ButtonStyle.secondary, emoji='🔂', row=0, custom_id='repeat') + self.shuffle_button = ToggleButton(self, style=ButtonStyle.secondary, emoji='🔀', row=0, custom_id='shuffle') self.play_pause_button = PlayPauseButton(style=ButtonStyle.primary, emoji='⏯', row=0) self.next_button = SwitchTrackButton(style=ButtonStyle.primary, emoji='⏭', row=0, custom_id='next') self.prev_button = SwitchTrackButton(style=ButtonStyle.primary, emoji='⏮', row=0, custom_id='previous') - self.like_button = ReactionButton(style=ButtonStyle.secondary, emoji='❤️', row=1, custom_id='like') - self.dislike_button = ReactionButton(style=ButtonStyle.secondary, emoji='💔', row=1, custom_id='dislike') + self.like_button = ReactionButton(self, style=ButtonStyle.secondary, emoji='❤️', row=1, custom_id='like') + self.dislike_button = ReactionButton(self, style=ButtonStyle.secondary, emoji='💔', row=1, custom_id='dislike') self.lyrics_button = LyricsButton(style=ButtonStyle.secondary, emoji='📋', row=1) self.add_to_playlist_button = AddToPlaylistButton(style=ButtonStyle.secondary, emoji='📁', row=1) self.vibe_button = MyVibeButton(style=ButtonStyle.secondary, emoji='🌊', row=1) self.vibe_settings_button = MyVibeSettingsButton(style=ButtonStyle.success, emoji='🛠', row=1) + + self.current_vibe_button: MyVibeButton | MyVibeSettingsButton = self.vibe_button async def init(self, *, disable: bool = False) -> Self: - if not self.ctx.guild_id: - return self - - self.guild = await self.db.get_guild(self.ctx.guild_id, projection={ - 'repeat': 1, 'shuffle': 1, 'current_track': 1, 'current_menu': 1, 'vibing': 1, 'single_token_uid': 1 - }) - - if self.guild['repeat']: - self.repeat_button.style = ButtonStyle.success - if self.guild['shuffle']: - self.shuffle_button.style = ButtonStyle.success - - current_track = self.guild['current_track'] + await self.update(disable=disable) self.add_item(self.repeat_button) self.add_item(self.prev_button) self.add_item(self.play_pause_button) self.add_item(self.next_button) self.add_item(self.shuffle_button) + self.add_item(self.like_button) + self.add_item(self.dislike_button) + self.add_item(self.lyrics_button) + self.add_item(self.add_to_playlist_button) + self.add_item(self.current_vibe_button) + + return self + + async def update(self, *, disable: bool = False) -> Self: + if not self.ctx.guild_id: + return self + + self.enable_all_items() + + self.guild = await self.db.get_guild(self.ctx.guild_id, projection={ + 'repeat': 1, 'shuffle': 1, 'current_track': 1, 'current_viber_id': 1, 'vibing': 1, 'single_token_uid': 1 + }) + + if self.guild['repeat']: + self.repeat_button.style = ButtonStyle.success + else: + self.repeat_button.style = ButtonStyle.secondary + + if self.guild['shuffle']: + self.shuffle_button.style = ButtonStyle.success + else: + self.shuffle_button.style = ButtonStyle.secondary + + current_track = self.guild['current_track'] if not isinstance(self.ctx, RawReactionActionEvent) \ and len(cast(VoiceChannel, self.ctx.channel).members) == 2 \ @@ -620,9 +696,17 @@ class MenuView(View, VoiceExtension): if current_track and str(current_track['id']) in [str(like.id) for like in await self.get_reacted_tracks(self.ctx, 'like')]: self.like_button.style = ButtonStyle.success + else: + self.like_button.style = ButtonStyle.secondary if current_track and str(current_track['id']) in [str(dislike.id) for dislike in await self.get_reacted_tracks(self.ctx, 'dislike')]: self.dislike_button.style = ButtonStyle.success + else: + self.dislike_button.style = ButtonStyle.secondary + + else: + self.like_button.style = ButtonStyle.secondary + self.dislike_button.style = ButtonStyle.secondary if not current_track: self.lyrics_button.disabled = True @@ -631,32 +715,30 @@ class MenuView(View, VoiceExtension): self.add_to_playlist_button.disabled = True elif not current_track['lyrics_available']: self.lyrics_button.disabled = True - + if self.guild['single_token_uid']: self.like_button.disabled = True self.dislike_button.disabled = True self.add_to_playlist_button.disabled = True - self.add_item(self.like_button) - self.add_item(self.dislike_button) - self.add_item(self.lyrics_button) - self.add_item(self.add_to_playlist_button) - if self.guild['vibing']: - self.add_item(self.vibe_settings_button) + self.current_vibe_button = self.vibe_settings_button else: - self.add_item(self.vibe_button) + self.current_vibe_button = self.vibe_button if disable: self.disable_all_items() + + if self.timeout: + self.__timeout_expiry = monotonic() + self.timeout return self - + async def on_timeout(self) -> None: logging.debug('[MENU] Menu timed out. Deleting menu message') if not self.ctx.guild_id: return - + if self.guild['current_menu']: await self.db.update(self.ctx.guild_id, { 'current_menu': None, 'repeat': False, 'shuffle': False, @@ -670,4 +752,4 @@ class MenuView(View, VoiceExtension): else: logging.debug('[MENU] No menu message found') - self.stop() + self.stop() diff --git a/MusicBot/ui/other.py b/MusicBot/ui/other.py index 11a6f87..4a60c33 100644 --- a/MusicBot/ui/other.py +++ b/MusicBot/ui/other.py @@ -1,5 +1,5 @@ from math import ceil -from typing import Self, Any +from typing import Any from discord.ui import View, Button, Item from discord import ApplicationContext, ButtonStyle, Interaction, Embed, HTTPException @@ -9,83 +9,87 @@ from MusicBot.cogs.utils.voice_extension import VoiceExtension def generate_queue_embed(page: int, tracks_list: list[dict[str, Any]]) -> Embed: count = 15 * page length = len(tracks_list) + embed = Embed( title=f"Всего: {length}", color=0xfed42b, ) embed.set_author(name="Очередь треков") embed.set_footer(text=f"Страница {page + 1} из {ceil(length / 15)}") + for i, track in enumerate(tracks_list[count:count + 15], start=1 + count): - duration = track['duration_ms'] - if duration: - duration_m = duration // 60000 - duration_s = ceil(duration / 1000) - duration_m * 60 + if track['duration_ms']: + duration_m = track['duration_ms'] // 60000 + duration_s = ceil(track['duration_ms'] / 1000) - duration_m * 60 embed.add_field(name=f"{i} - {track['title']} - {duration_m}:{duration_s:02d}", value="", inline=False) + return embed -class QueueNextButton(Button, VoiceExtension): - def __init__(self, **kwargs): +class QueueNextButton(Button): + def __init__(self, root:' QueueView', **kwargs): Button.__init__(self, **kwargs) - VoiceExtension.__init__(self, None) + self.root = root async def callback(self, interaction: Interaction) -> None: - if not interaction.user or not interaction.guild: - return + self.root.page += 1 + self.root.update() + embed = generate_queue_embed(self.root.page, self.root.tracks) + await interaction.edit(embed=embed, view=self.root) - user = await self.users_db.get_user(interaction.user.id) - page = user['queue_page'] + 1 - await self.users_db.update(interaction.user.id, {'queue_page': page}) - tracks = await self.db.get_tracks_list(interaction.guild.id, 'next') - embed = generate_queue_embed(page, tracks) - await interaction.edit(embed=embed, view=await QueueView(interaction).init()) - -class QueuePrevButton(Button, VoiceExtension): - def __init__(self, **kwargs): +class QueuePrevButton(Button): + def __init__(self, root: 'QueueView', **kwargs): Button.__init__(self, **kwargs) - VoiceExtension.__init__(self, None) + self.root = root async def callback(self, interaction: Interaction) -> None: - if not interaction.user or not interaction.guild: - return - - user = await self.users_db.get_user(interaction.user.id) - page = user['queue_page'] - 1 - await self.users_db.update(interaction.user.id, {'queue_page': page}) - tracks = await self.db.get_tracks_list(interaction.guild.id, 'next') - embed = generate_queue_embed(page, tracks) - await interaction.edit(embed=embed, view=await QueueView(interaction).init()) + self.root.page -= 1 + self.root.update() + embed = generate_queue_embed(self.root.page, self.root.tracks) + await interaction.edit(embed=embed, view=self.root) class QueueView(View, VoiceExtension): - def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 360, disable_on_timeout: bool = False): + def __init__( + self, + ctx: ApplicationContext | Interaction, + tracks: list[dict[str, Any]], + *items: Item, + timeout: float | None = 360, + disable_on_timeout: bool = False + ): View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout) VoiceExtension.__init__(self, None) self.ctx = ctx - self.next_button = QueueNextButton(style=ButtonStyle.primary, emoji='▶️') - self.prev_button = QueuePrevButton(style=ButtonStyle.primary, emoji='◀️') + self.tracks = tracks + self.page = 0 - async def init(self) -> Self: - if not self.ctx.user or not self.ctx.guild: - return self - - tracks = await self.db.get_tracks_list(self.ctx.guild.id, 'next') - user = await self.users_db.get_user(self.ctx.user.id) - - count = 15 * user['queue_page'] - - if not tracks[count + 15:]: + self.next_button = QueueNextButton(self, style=ButtonStyle.primary, emoji='▶️') + self.prev_button = QueuePrevButton(self, style=ButtonStyle.primary, emoji='◀️', disabled=True) + + if not self.tracks[15:]: self.next_button.disabled = True - if not tracks[:count]: - self.prev_button.disabled = True + + self.prev_button.disabled = True self.add_item(self.prev_button) self.add_item(self.next_button) - return self + def update(self): + count = 15 * self.page + if self.tracks[15:]: + self.next_button.disabled = False + else: + self.next_button.disabled = True + + if self.tracks[:count]: + self.prev_button.disabled = False + else: + self.prev_button.disabled = True + async def on_timeout(self) -> None: try: await super().on_timeout() except HTTPException: pass - self.stop() \ No newline at end of file + self.stop() diff --git a/README.md b/README.md index 7a17ee1..6773dc7 100644 --- a/README.md +++ b/README.md @@ -78,15 +78,14 @@ MONGO_URI='mongodb://localhost:27017/' # Адрес сервера MongoDB Запустите бота (`python ./MusicBot/main.py`). -## Запуск в Docker ![Main Build](https://img.shields.io/github/actions/workflow/status/deadcxap/YandexMusicDiscordBot/docker-image.yml?branch=main&label=main) ![Dev Build](https://img.shields.io/github/actions/workflow/status/deadcxap/YandexMusicDiscordBot/docker-image.yml?branch=dev&label=dev) - +## Запуск в Docker ![Main Build](https://img.shields.io/github/actions/workflow/status/lemon4ksan/YandexMusicDiscordBot/docker-image.yml?branch=main&label=main) ![Dev Build](https://img.shields.io/github/actions/workflow/status/lemon4ksan/YandexMusicDiscordBot/docker-image.yml?branch=dev&label=dev) Возможен запуск как из командной строки, так и с помощью docker-compose. ### docker cli > [!NOTE] -> При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адресс сервера в команде запуска. +> При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адрес сервера в команде запуска. ```bash docker run -d \ @@ -96,7 +95,7 @@ docker run -d \ -e EXPLICIT_EID=1325879701117472869 \ -e DEBUG=False \ -e MONGO_URI="mongodb://mongodb:27017/" \ - deadcxap/yandexmusicdiscordbot:latest + lemon4ksan/yandexmusicdiscordbot:latest ``` ### docker-compose (рекомендованный) @@ -104,43 +103,6 @@ docker run -d \ > [!NOTE] > При первом запуске БД и коллекции будут созданы автоматически. -```yaml ---- -services: - app: - container_name: yandex-music-discord-bot - image: deadcxap/yandexmusicdiscordbot:latest - restart: unless-stopped - depends_on: - - mongodb - env_file: - - .env - environment: - MONGO_URI: "mongodb://ymdb-mongodb:27017" - networks: - - ymdb_network - mongodb: - container_name: ymdb-mongodb - image: mongo:latest - restart: unless-stopped - volumes: - - mongodb_data:/data/db - - ./init-mongodb.js:/docker-entrypoint-initdb.d/init-mongodb.js:ro - networks: - - ymdb_network - healthcheck: - test: echo 'db.runCommand("ping").ok' | mongo localhost:27017 --quiet - interval: 30s - timeout: 10s - retries: 5 - -volumes: - mongodb_data: - -networks: - ymdb_network: -``` - ```bash docker-compose up -d ``` diff --git a/docker-compose.yml b/docker-compose.yml index c7e7092..3b01ecd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: app: container_name: yandex-music-discord-bot - image: deadcxap/yandexmusicdiscordbot:latest + image: lemon4ksan/yandexmusicdiscordbot:latest restart: unless-stopped depends_on: - mongodb