From bcfef292079370c86bb80ea6448b8469dcd9c1d1 Mon Sep 17 00:00:00 2001 From: deadcxap Date: Mon, 10 Mar 2025 06:22:21 +0300 Subject: [PATCH 01/16] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B4=D0=BE=D0=BA=D0=B5=D1=80=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D1=80-=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 28 ++++++++++++++++++++++++++++ Dockerfile | 15 +++++++++++++++ MusicBot/database/base.py | 4 +++- docker-compose.yml | 33 +++++++++++++++++++++++++++++++++ init-mongodb.js | 3 +++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 init-mongodb.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fbf7a02 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Исключаем файлы Git +.git +.gitignore + +# Исключаем каталоги виртуальных окружений +venv/ +env/ +ENV/ + +# Исключаем кешированные файлы Python +__pycache__/ +*.py[cod] +*$py.class + +# Исключаем файлы сборки +build/ +dist/ + +# Исключаем конфигурационные файлы IDE +.vscode/ +.idea/ + +# Исключаем системные файлы +.DS_Store + +# Не включаем .env с чувствительными данными, +# т.к. переменные передаются через переменные окружения контейнера +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c2c331f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +RUN apt-get update && apt-get install -y ffmpeg && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY MusicBot /app/MusicBot + +ENV PYTHONPATH=/app + +# Команда для запуска бота +CMD ["python", "./MusicBot/main.py"] diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index ea91efe..cd49b5d 100644 --- a/MusicBot/database/base.py +++ b/MusicBot/database/base.py @@ -1,3 +1,4 @@ +import os from typing import Iterable, Any, cast from pymongo import AsyncMongoClient, ReturnDocument, UpdateOne from pymongo.asynchronous.collection import AsyncCollection @@ -6,7 +7,8 @@ from pymongo.results import UpdateResult from .user import User, ExplicitUser from .guild import Guild, ExplicitGuild, MessageVotes -client: AsyncMongoClient = AsyncMongoClient("mongodb://localhost:27017/") +mongo_server = os.getenv('MONGO_URI') +client: AsyncMongoClient = AsyncMongoClient(mongo_server) db = client.YandexMusicBot users: AsyncCollection[ExplicitUser] = db.users diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c7e7092 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +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: \ No newline at end of file diff --git a/init-mongodb.js b/init-mongodb.js new file mode 100644 index 0000000..0b8f4bf --- /dev/null +++ b/init-mongodb.js @@ -0,0 +1,3 @@ +db = db.getSiblingDB('YandexMusicBot'); +db.createCollection('guilds'); +db.createCollection('users'); \ No newline at end of file From 6112014f4e4c597ccf162f51e913b14fc275deb8 Mon Sep 17 00:00:00 2001 From: deadcxap Date: Mon, 10 Mar 2025 09:43:01 +0300 Subject: [PATCH 02/16] Edit README.md and delete unnecessary comments. --- .dockerignore | 8 ------- Dockerfile | 1 - README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/.dockerignore b/.dockerignore index fbf7a02..1148a71 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,28 +1,20 @@ -# Исключаем файлы Git .git .gitignore -# Исключаем каталоги виртуальных окружений venv/ env/ ENV/ -# Исключаем кешированные файлы Python __pycache__/ *.py[cod] *$py.class -# Исключаем файлы сборки build/ dist/ -# Исключаем конфигурационные файлы IDE .vscode/ .idea/ -# Исключаем системные файлы .DS_Store -# Не включаем .env с чувствительными данными, -# т.к. переменные передаются через переменные окружения контейнера .env diff --git a/Dockerfile b/Dockerfile index c2c331f..b9e799f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,5 +11,4 @@ COPY MusicBot /app/MusicBot ENV PYTHONPATH=/app -# Команда для запуска бота CMD ["python", "./MusicBot/main.py"] diff --git a/README.md b/README.md index 973b5de..6683935 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,72 @@ DEBUG='False' # Включение DEBUG логов (True/False) Запустите бота (`python ./MusicBot/main.py`). +## Запуск в Docker + +Возможен запуск как из командной строки, так и с помощью docker-compose. + +### docker cli + +>[!NOTE] +>При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адресс сервера в команде запуска. + +```bash +docker run -d \ + --name yandex-music-discord-bot \ + --restart unless-stopped \ + -e TOKEN=XXXXXX \ + -e EXPLICIT_EID=1325879701117472869 \ + -e DEBUG=False \ + -e MONGO_URI="mongodb://mongodb:27017" \ + deadcxap/yandexmusicdiscordbot:latest +``` + +### docker-compose (рекомендованный) + +>[!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 +``` + ## Настройка бота Так должны выглядить настройки бота: From a9c938b736d86ecdcd339380d29981af434a009b Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Fri, 14 Mar 2025 22:01:22 +0300 Subject: [PATCH 03/16] impr: Add BaseBot class for code reduction. --- MusicBot/cogs/general.py | 97 ++---- MusicBot/cogs/utils/__init__.py | 5 +- MusicBot/cogs/utils/base_bot.py | 177 +++++++++++ MusicBot/cogs/utils/voice_extension.py | 417 +++++++++---------------- MusicBot/cogs/voice.py | 187 +++++------ MusicBot/ui/menu.py | 30 +- 6 files changed, 445 insertions(+), 468 deletions(-) create mode 100644 MusicBot/cogs/utils/base_bot.py 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: From 7fe9d699b1800d56dbea11a39018e56930e7ce99 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sat, 15 Mar 2025 17:30:42 +0300 Subject: [PATCH 04/16] feat: Add the ability to use the bot with single YM token. --- MusicBot/cogs/general.py | 15 ++++ MusicBot/cogs/settings.py | 52 ++++++++---- MusicBot/cogs/utils/base_bot.py | 40 ++++++++-- MusicBot/cogs/utils/voice_extension.py | 106 +++++++++++++------------ MusicBot/cogs/voice.py | 24 ++++-- MusicBot/database/base.py | 20 ++--- MusicBot/database/guild.py | 12 +-- MusicBot/database/user.py | 2 +- 8 files changed, 171 insertions(+), 100 deletions(-) diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py index f85acee..a96f67c 100644 --- a/MusicBot/cogs/general.py +++ b/MusicBot/cogs/general.py @@ -211,6 +211,11 @@ class General(Cog, BaseBot): 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}") + 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) + return + if not (client := await self.init_ym_client(ctx)): return @@ -253,6 +258,11 @@ class General(Cog, BaseBot): # 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}'") + 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) + return + if not (client := await self.init_ym_client(ctx)): return @@ -284,6 +294,11 @@ class General(Cog, BaseBot): 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}") + 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) + return + if not (client := await self.init_ym_client(ctx)): return diff --git a/MusicBot/cogs/settings.py b/MusicBot/cogs/settings.py index 39ad4ad..f5fef41 100644 --- a/MusicBot/cogs/settings.py +++ b/MusicBot/cogs/settings.py @@ -21,60 +21,80 @@ class Settings(Cog): @settings.command(name="show", description="Показать текущие настройки бота.") async def show(self, ctx: discord.ApplicationContext) -> None: if not ctx.guild_id: - logging.warning("[SETTINGS] Show command invoked without guild_id") + logging.info("[SETTINGS] Show command invoked without guild_id") await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) return - guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1, 'vote_switch_track': 1, 'vote_add': 1}) + guild = await self.db.get_guild(ctx.guild_id, projection={ + 'allow_change_connect': 1, 'vote_switch_track': 1, 'vote_add': 1, 'use_single_token': 1 + }) vote = "✅ - Переключение" if guild['vote_switch_track'] else "❌ - Переключение" vote += "\n✅ - Добавление в очередь" if guild['vote_add'] else "\n❌ - Добавление в очередь" connect = "\n✅ - Разрешено всем" if guild['allow_change_connect'] else "\n❌ - Только для участникам с правами управления каналом" + token = "🔐 - Используется токен пользователя, запустившего бота" if guild['use_single_token'] else "🔒 - Используется личный токен пользователя" + embed = discord.Embed(title="Настройки бота", color=0xfed42b) embed.add_field(name="__Голосование__", value=vote, inline=False) - embed.add_field(name="__Подключение/Отключение бота__", value=connect, inline=False) + embed.add_field(name="__Подключение/Отключение__", value=connect, inline=False) + embed.add_field(name="__Токен__", value=token, inline=False) await ctx.respond(embed=embed, ephemeral=True) - @settings.command(name="toggle", description="Переключить параметр настроек.") + @settings.command(name="toggle", description="Переключить параметры основных настроек.") @discord.option( "параметр", parameter_name="vote_type", description="Тип голосования.", type=discord.SlashCommandOptionType.string, - choices=['Переключение', 'Добавление в очередь', 'Добавление/Отключение бота'] + choices=[ + 'Переключение треков без голосования для всех', + 'Добавление в очередь без голосования для всех', + 'Добавление/Отключение бота из канала для всех', + 'Использовать единый токен для прослушивания' + ] ) async def toggle( self, ctx: discord.ApplicationContext, - vote_type: Literal['Переключение', 'Добавление в очередь', 'Добавление/Отключение бота'] + vote_type: Literal[ + 'Переключение треков без голосования для всех', + 'Добавление в очередь без голосования для всех', + 'Добавление/Отключение бота из канала для всех', + 'Использовать единый токен для прослушивания' + ] ) -> None: - member = cast(discord.Member, ctx.author) + if not ctx.guild_id: + logging.info("[SETTINGS] Toggle command invoked without guild_id") + await ctx.respond("❌ Эта команда может быть использована только на сервере.", 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) return - - if not ctx.guild_id: - logging.warning("[SETTINGS] Toggle command invoked without guild_id") - await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) - return guild = await self.db.get_guild(ctx.guild_id, projection={ - 'vote_switch_track': 1, 'vote_add': 1, 'allow_change_connect': 1}) + 'vote_switch_track': 1, 'vote_add': 1, 'allow_change_connect': 1, 'use_single_token': 1 + }) - if vote_type == 'Переключение': + if vote_type == 'Переключение треков без голосования для всех': await self.db.update(ctx.guild_id, {'vote_switch_track': not guild['vote_switch_track']}) response_message = "Голосование за переключение трека " + ("❌ выключено." if guild['vote_switch_track'] else "✅ включено.") - elif vote_type == 'Добавление в очередь': + elif vote_type == 'Добавление в очередь без голосования для всех': 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 == 'Использовать единый токен для прослушивания': + 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 = "❌ Неизвестный тип голосования." diff --git a/MusicBot/cogs/utils/base_bot.py b/MusicBot/cogs/utils/base_bot.py index be8c344..251e3b9 100644 --- a/MusicBot/cogs/utils/base_bot.py +++ b/MusicBot/cogs/utils/base_bot.py @@ -37,11 +37,7 @@ class BaseBot: """ 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: + if not (token := await self.get_ym_token(ctx)): logging.debug("[VC_EXT] No token found") await self.send_response_message(ctx, "❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) return None @@ -56,12 +52,28 @@ class BaseBot: 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) + await self.send_response_message(ctx, "❌ Недействительный токен Yandex Music.", ephemeral=True, delete_after=15) return None self._ym_clients[token] = client return client + async def get_ym_token(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> str | None: + """Get Yandex Music token from context. It's either individual or single.""" + + uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None + + if not ctx.guild_id or not uid: + logging.info("[VC_EXT] No guild id or user id found") + return None + + guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid': 1}) + + if guild['single_token_uid']: + return await self.users_db.get_ym_token(guild['single_token_uid']) + else: + return await self.users_db.get_ym_token(uid) + async def send_response_message( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, @@ -151,8 +163,22 @@ class BaseBot: self.menu_views[ctx.guild_id].stop() self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable) + + async def get_discord_user_by_id(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, user_id: int) -> discord.User | None: + + if isinstance(ctx, ApplicationContext) and ctx.user: + logging.debug(f"[BASE_BOT] Getting user {user_id} from ApplicationContext") + return await ctx.bot.fetch_user(user_id) + elif isinstance(ctx, Interaction): + logging.debug(f"[BASE_BOT] Getting user {user_id} from Interaction") + return await ctx.client.fetch_user(user_id) + elif not self.bot: + raise ValueError("Bot instance is not available") + else: + logging.debug(f"[BASE_BOT] Getting user {user_id} from bot instance") + return await self.bot.fetch_user(user_id) - def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop: + 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: diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index ce9c9e6..65b2a4f 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -38,7 +38,9 @@ class VoiceExtension(BaseBot): 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}) + guild = await self.db.get_guild(ctx.guild_id, projection={ + 'current_track': 1, 'current_menu': 1, 'vibing': 1, 'single_token_uid': 1 + }) if not guild['current_track']: embed = None @@ -49,10 +51,13 @@ class VoiceExtension(BaseBot): guild['current_track'], client=YMClient() # type: ignore )) + embed = await generate_item_embed(track, guild['vibing']) if vc.is_paused(): embed.set_footer(text='Приостановлено') + elif guild['single_token_uid'] and (user := await self.get_discord_user_by_id(ctx, guild['single_token_uid'])): + embed.set_footer(text=f"Используется токен {user.display_name}", icon_url=user.display_avatar.url) else: embed.remove_footer() @@ -135,7 +140,10 @@ class VoiceExtension(BaseBot): 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(ctx.guild_id, 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, 'single_token_uid': 1 + }) + if not guild['current_menu']: logging.debug("[VC_EXT] No current menu found") return False @@ -152,6 +160,7 @@ class VoiceExtension(BaseBot): guild['current_track'], client=YMClient() # type: ignore )) + embed = await generate_item_embed(track, guild['vibing']) if not (vc := await self.get_voice_client(ctx)): @@ -160,6 +169,8 @@ class VoiceExtension(BaseBot): if vc.is_paused(): embed.set_footer(text='Приостановлено') + elif guild['single_token_uid'] and (user := await self.get_discord_user_by_id(ctx, guild['single_token_uid'])): + embed.set_footer(text=f"Используется токен {user.display_name}", icon_url=user.display_avatar.url) else: embed.remove_footer() @@ -259,10 +270,10 @@ class VoiceExtension(BaseBot): 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}) + user = await self.users_db.get_user(uid, projection={'vibe_settings': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_track': 1}) - if not (client := await self.init_ym_client(ctx, user['ym_token'])): + if not (client := await self.init_ym_client(ctx)): return False if update_settings: @@ -335,7 +346,7 @@ class VoiceExtension(BaseBot): await ctx.respond("❌ Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True) return False - if not await self.users_db.get_ym_token(ctx.user.id): + 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) return False @@ -399,7 +410,6 @@ class VoiceExtension(BaseBot): ctx: ApplicationContext | Interaction | RawReactionActionEvent, track: Track | dict[str, Any], *, - client: YMClient | None = None, vc: discord.VoiceClient | None = None, menu_message: discord.Message | None = None, button_callback: bool = False, @@ -427,7 +437,7 @@ class VoiceExtension(BaseBot): if isinstance(track, dict): track = cast(Track, Track.de_json( track, - client=await self.init_ym_client(ctx) if not client else client # type: ignore # Async client can be used here. + client=await self.init_ym_client(ctx) # type: ignore # Async client can be used here. )) return await self._play_track( @@ -475,7 +485,9 @@ class VoiceExtension(BaseBot): 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 + 'current_menu': None, 'repeat': False, 'shuffle': False, + 'previous_tracks': [], 'next_tracks': [], 'votes': {}, + 'vibing': False, 'current_viber_id': None }) if guild['current_menu']: @@ -489,7 +501,6 @@ class VoiceExtension(BaseBot): vc: discord.VoiceClient | None = None, *, after: bool = False, - client: YMClient | None = None, menu_message: discord.Message | None = None, button_callback: bool = False ) -> str | None: @@ -500,7 +511,6 @@ class VoiceExtension(BaseBot): ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context vc (discord.VoiceClient, optional): Voice client. after (bool, optional): Whether the function is being called by the after callback. Defaults to False. - client (YMClient | None, optional): Yandex Music client. Defaults to None. menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False. @@ -552,19 +562,7 @@ class VoiceExtension(BaseBot): 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) - - if after and not guild['current_menu']: - if isinstance(ctx, discord.RawReactionActionEvent): - if not self.bot: - raise ValueError("Bot instance not found") - - channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) - await channel.send(f"Сейчас играет: **{title}**!", delete_after=15) - else: - await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15) - - return title + return await self.play_track(ctx, next_track, vc=vc, button_callback=button_callback) logging.info("[VC_EXT] No next track found") if after: @@ -609,15 +607,20 @@ class VoiceExtension(BaseBot): return None - async def get_liked_tracks(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort]: - """Get liked tracks from Yandex Music. Return list of tracks on success. + async def get_reacted_tracks( + self, + ctx: ApplicationContext | Interaction | RawReactionActionEvent, + tracks_type: Literal['like', 'dislike'] + ) -> list[TrackShort]: + """Get liked or disliked 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. - + tracks_type (Literal['like', 'dislike']): Type of tracks to get. + Returns: - list[Track]: List of tracks. + list[TrackShort]: List of tracks. """ logging.info("[VC_EXT] Getting liked tracks") @@ -632,11 +635,11 @@ class VoiceExtension(BaseBot): if not (client := await self.init_ym_client(ctx)): return [] - if not (likes := await client.users_likes_tracks()): - logging.info("[VC_EXT] No likes found") + if not (collection := await client.users_likes_tracks() if tracks_type == 'like' else await client.users_dislikes_tracks()): + logging.info(f"[VC_EXT] No {tracks_type}s found") return [] - return likes.tracks + return collection.tracks async def react_track( self, @@ -656,14 +659,11 @@ class VoiceExtension(BaseBot): logging.warning("[VC_EXT] Guild or User not found") return (False, None) - current_track = await self.db.get_track(gid, 'current') - client = await self.init_ym_client(ctx, await self.users_db.get_ym_token(ctx.user.id)) - - if not current_track: + 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: + if not (client := await self.init_ym_client(ctx)): return (False, None) if action == 'like': @@ -701,6 +701,9 @@ class VoiceExtension(BaseBot): bool: Success status. """ logging.info(f"[VOICE] Performing '{vote_data['action']}' action for message {ctx.message_id}") + + if guild['current_viber_id']: + ctx.user_id = guild['current_viber_id'] if not ctx.guild_id: logging.warning("[VOICE] Guild not found") @@ -817,18 +820,20 @@ class VoiceExtension(BaseBot): uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - if not uid: - logging.warning("[VC_EXT] User id not found") + if not uid or not ctx.guild_id: + logging.warning("[VC_EXT] User id or guild id not found") return False - user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_batch_id': 1, 'vibe_type': 1, 'vibe_id': 1}) + guild = await self.db.get_guild(ctx.guild_id, projection={'current_viber_id': 1}) - if not user['ym_token']: - logging.warning(f"[VC_EXT] No YM token for user {user['_id']}.") - return False + if guild['current_viber_id']: + viber_id = guild['current_viber_id'] + else: + viber_id = uid - client = await self.init_ym_client(ctx, user['ym_token']) - if not client: + user = await self.users_db.get_user(viber_id, 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 @@ -854,7 +859,7 @@ class VoiceExtension(BaseBot): return feedback async def _download_track(self, gid: int, track: Track) -> None: - """Download track to local storage. Return True on success. + """Download track to local storage. Args: gid (int): Guild ID. @@ -927,14 +932,11 @@ class VoiceExtension(BaseBot): if not guild['current_track'] or track.id != guild['current_track']['id']: await self._download_track(gid, track) except yandex_music.exceptions.TimedOutError: - if not isinstance(ctx, RawReactionActionEvent) and ctx.channel: - channel = cast(discord.VoiceChannel, ctx.channel) - elif not retry: + if not retry: return await self._play_track(ctx, track, vc=vc, menu_message=menu_message, button_callback=button_callback, retry=True) - elif self.bot and isinstance(ctx, RawReactionActionEvent): - channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) + else: + await self.send_response_message(ctx, f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) logging.error(f"[VC_EXT] Failed to download track '{track.title}'") - await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) return None async with aiofiles.open(f'music/{gid}.mp3', "rb") as f: @@ -951,7 +953,7 @@ class VoiceExtension(BaseBot): # Giving FFMPEG enough time to process the audio file await asyncio.sleep(1) - loop = self._get_current_event_loop(ctx) + loop = self.get_current_event_loop(ctx) try: vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.play_next_track(ctx, after=True), loop)) except discord.errors.ClientException as e: diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index 0aca49f..28ccebd 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -48,7 +48,7 @@ class Voice(Cog, VoiceExtension): guild = await self.db.get_guild(member.guild.id, projection={'current_menu': 1}) if not after.channel or not before.channel: - logging.warning(f"[VOICE] No channel found for member {member.id}") + logging.debug(f"[VOICE] No channel found for member {member.id}") return vc = cast( @@ -125,12 +125,13 @@ class Voice(Cog, VoiceExtension): 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): + guild = await self.db.get_guild(payload.guild_id) + + if not guild['use_single_token'] and not (guild['single_token_uid'] or 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) return - guild = await self.db.get_guild(payload.guild_id) votes = guild['votes'] if str(payload.message_id) not in votes: @@ -220,9 +221,14 @@ class Voice(Cog, VoiceExtension): logging.warning("[VOICE] Join command invoked without guild_id") await ctx.respond("❌ Эта команда может быть использована только на сервере.", 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) + return member = cast(discord.Member, ctx.author) - guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1}) + guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1, 'use_single_token': 1}) await ctx.defer(ephemeral=True) if not member.guild_permissions.manage_channels and not guild['allow_change_connect']: @@ -234,8 +240,14 @@ class Voice(Cog, VoiceExtension): response_message = "❌ Не удалось подключиться к голосовому каналу." except discord.ClientException: response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave." + except discord.DiscordException as e: + logging.error(f"[VOICE] DiscordException: {e}") + response_message = "❌ Произошла неизвестная ошибка при подключении к голосовому каналу." else: response_message = "✅ Подключение успешно!" + + if guild['use_single_token'] and await self.users_db.get_ym_token(ctx.author.id): + await self.db.update(ctx.guild_id, {'single_token_uid': ctx.author.id}) else: response_message = "❌ Вы должны отправить команду в чате голосового канала." @@ -272,9 +284,11 @@ class Voice(Cog, VoiceExtension): 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}") + await self.db.update(ctx.guild_id, {'single_token_uid': None}) + await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True) + @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}") diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index cd49b5d..2581476 100644 --- a/MusicBot/database/base.py +++ b/MusicBot/database/base.py @@ -8,6 +8,9 @@ from .user import User, ExplicitUser from .guild import Guild, ExplicitGuild, MessageVotes mongo_server = os.getenv('MONGO_URI') +if not mongo_server: + raise ValueError('MONGO_URI environment variable is not set') + client: AsyncMongoClient = AsyncMongoClient(mongo_server) db = client.YandexMusicBot @@ -67,12 +70,6 @@ class BaseUsersDatabase: ) return cast(str | None, user.get('ym_token') if user else None) - async def add_playlist(self, uid: int, playlist_data: dict) -> UpdateResult: - return await users.update_one( - {'_id': uid}, - {'$push': {'playlists': playlist_data}} - ) - class BaseGuildsDatabase: DEFAULT_GUILD = Guild( @@ -81,7 +78,6 @@ class BaseGuildsDatabase: current_track=None, current_menu=None, is_stopped=True, - always_allow_menu=False, allow_change_connect=True, vote_switch_track=True, vote_add=True, @@ -89,7 +85,9 @@ class BaseGuildsDatabase: repeat=False, votes={}, vibing=False, - current_viber_id=None + current_viber_id=None, + use_single_token=False, + single_token_uid=None ) async def update(self, gid: int, data: Guild | dict[str, Any]) -> UpdateResult: @@ -127,9 +125,3 @@ class BaseGuildsDatabase: {'_id': gid}, {'$set': {f'votes.{mid}': data}} ) - - async def clear_queue(self, gid: int) -> UpdateResult: - return await guilds.update_one( - {'_id': gid}, - {'$set': {'next_tracks': []}} - ) diff --git a/MusicBot/database/guild.py b/MusicBot/database/guild.py index bc26157..0fa8e5e 100644 --- a/MusicBot/database/guild.py +++ b/MusicBot/database/guild.py @@ -10,13 +10,12 @@ class MessageVotes(TypedDict): ] vote_content: Any | None -class Guild(TypedDict, total=False): +class Guild(TypedDict, total=False): # Don't forget to change base.py if you add a new field next_tracks: list[dict[str, Any]] previous_tracks: list[dict[str, Any]] current_track: dict[str, Any] | None current_menu: int | None - is_stopped: bool - always_allow_menu: bool + is_stopped: bool # Prevents the `after` callback of play_track allow_change_connect: bool vote_switch_track: bool vote_add: bool @@ -25,6 +24,8 @@ class Guild(TypedDict, total=False): votes: dict[str, MessageVotes] vibing: bool current_viber_id: int | None + use_single_token: bool + single_token_uid: int | None class ExplicitGuild(TypedDict): _id: int @@ -32,8 +33,7 @@ class ExplicitGuild(TypedDict): previous_tracks: list[dict[str, Any]] current_track: dict[str, Any] | None current_menu: int | None - is_stopped: bool # Prevents the `after` callback of play_track - always_allow_menu: bool + is_stopped: bool allow_change_connect: bool vote_switch_track: bool vote_add: bool @@ -42,3 +42,5 @@ class ExplicitGuild(TypedDict): votes: dict[str, MessageVotes] vibing: bool current_viber_id: int | None + use_single_token: bool + single_token_uid: int | None diff --git a/MusicBot/database/user.py b/MusicBot/database/user.py index 9ba049c..5f0486d 100644 --- a/MusicBot/database/user.py +++ b/MusicBot/database/user.py @@ -6,7 +6,7 @@ VibeSettingsOptions: TypeAlias = Literal[ 'russian', 'not-russian', 'without-words', 'any', ] -class User(TypedDict, total=False): +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 From 1f63a4c50dab2c9dee9ff98480996346dcd2d173 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sat, 15 Mar 2025 21:43:41 +0300 Subject: [PATCH 05/16] impr: Remove restricted access to the Vibe. --- MusicBot/cogs/utils/base_bot.py | 49 +++++++++++------- MusicBot/cogs/utils/voice_extension.py | 69 ++++++++++++------------- MusicBot/ui/menu.py | 71 ++++++++++++++++---------- 3 files changed, 106 insertions(+), 83 deletions(-) diff --git a/MusicBot/cogs/utils/base_bot.py b/MusicBot/cogs/utils/base_bot.py index 251e3b9..762872f 100644 --- a/MusicBot/cogs/utils/base_bot.py +++ b/MusicBot/cogs/utils/base_bot.py @@ -35,10 +35,10 @@ class BaseBot: Returns: (YMClient | None): Client or None. """ - logging.debug("[VC_EXT] Initializing Yandex Music client") + logging.debug("[BASE_BOT] Initializing Yandex Music client") if not (token := await self.get_ym_token(ctx)): - logging.debug("[VC_EXT] No token found") + logging.debug("[BASE_BOT] No token found") await self.send_response_message(ctx, "❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) return None @@ -138,6 +138,31 @@ class BaseBot: logging.debug(f"[BASE_BOT] Failed to get message: {e}") raise + async def get_discord_user_by_id(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, user_id: int) -> discord.User | None: + if isinstance(ctx, ApplicationContext) and ctx.user: + logging.debug(f"[BASE_BOT] Getting user {user_id} from ApplicationContext") + return await ctx.bot.fetch_user(user_id) + elif isinstance(ctx, Interaction): + logging.debug(f"[BASE_BOT] Getting user {user_id} from Interaction") + return await ctx.client.fetch_user(user_id) + elif not self.bot: + raise ValueError("Bot instance is not available") + else: + logging.debug(f"[BASE_BOT] Getting user {user_id} from bot instance") + return await self.bot.fetch_user(user_id) + + async def get_viber_id_from_ctx(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> int | None: + if not ctx.guild_id: + logging.warning("[BASE_BOT] Guild not found") + return None + + guild = await self.db.get_guild(ctx.guild_id, projection={'current_viber_id': 1}) + + if guild['current_viber_id']: + 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( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, @@ -152,31 +177,17 @@ class BaseBot: 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}") + logging.debug(f"[BASE_BOT] 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") + logging.warning("[BASE_BOT] 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) - - async def get_discord_user_by_id(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, user_id: int) -> discord.User | None: - - if isinstance(ctx, ApplicationContext) and ctx.user: - logging.debug(f"[BASE_BOT] Getting user {user_id} from ApplicationContext") - return await ctx.bot.fetch_user(user_id) - elif isinstance(ctx, Interaction): - logging.debug(f"[BASE_BOT] Getting user {user_id} from Interaction") - return await ctx.client.fetch_user(user_id) - elif not self.bot: - raise ValueError("Bot instance is not available") - else: - logging.debug(f"[BASE_BOT] Getting user {user_id} from bot instance") - return await self.bot.fetch_user(user_id) 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. @@ -200,4 +211,4 @@ class BaseBot: 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 + raise TypeError(f"Invalid context type: '{type(ctx).__name__}'.") diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index 65b2a4f..95b1495 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -264,21 +264,19 @@ class VoiceExtension(BaseBot): """ logging.info(f"[VC_EXT] Updating vibe for guild {ctx.guild_id} with type '{vibe_type}' and id '{item_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 + uid = viber_id if viber_id else await self.get_viber_id_from_ctx(ctx) 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={'vibe_settings': 1}) - guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_track': 1}) - if not (client := await self.init_ym_client(ctx)): return False if update_settings: logging.debug("[VIBE] Updating vibe settings") + user = await self.users_db.get_user(uid, projection={'vibe_settings': 1}) settings = user['vibe_settings'] await client.rotor_station_settings2( f"{vibe_type}:{item_id}", @@ -287,6 +285,8 @@ class VoiceExtension(BaseBot): language=settings['lang'] ) + guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_track': 1}) + if not guild['vibing']: try: feedback = await client.rotor_station_feedback_radio_started( @@ -371,7 +371,7 @@ class VoiceExtension(BaseBot): 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 ctx.respond("❌ Вы не можете изменять чужую волну!", delete_after=15, ephemeral=True) return False logging.debug("[VC_EXT] Voice requirements met") @@ -504,7 +504,8 @@ class VoiceExtension(BaseBot): 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. Performs all additional actions like updating menu and sending vibe feedback. + """Switch to the next track in the queue. Return track title on success. + Performs all additional actions like updating menu and sending vibe feedback. Doesn't change track if stopped. Stop playing if tracks list is empty. Args: @@ -519,14 +520,11 @@ class VoiceExtension(BaseBot): """ logging.debug("[VC_EXT] Switching to next track") - uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - - if not ctx.guild_id or not uid: + if not (uid := await self.get_viber_id_from_ctx(ctx)) or not ctx.guild_id: 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}) - user = await self.users_db.get_user(uid) if guild['is_stopped'] and after: logging.debug("[VC_EXT] Playback is stopped, skipping after callback.") @@ -553,7 +551,12 @@ class VoiceExtension(BaseBot): next_track = await self.db.get_track(ctx.guild_id, 'next') if not next_track and guild['vibing']: + # NOTE: Real vibe gets next tracks after each skip. For smoother experience + # we get next tracks only after all the other tracks are finished + logging.debug("[VC_EXT] No next track found, generating new vibe") + + user = await self.users_db.get_user(uid) if not user['vibe_type'] or not user['vibe_id']: logging.warning("[VC_EXT] No vibe type or vibe id found in user data") return None @@ -701,9 +704,6 @@ class VoiceExtension(BaseBot): bool: Success status. """ logging.info(f"[VOICE] Performing '{vote_data['action']}' action for message {ctx.message_id}") - - if guild['current_viber_id']: - ctx.user_id = guild['current_viber_id'] if not ctx.guild_id: logging.warning("[VOICE] Guild not found") @@ -818,20 +818,11 @@ class VoiceExtension(BaseBot): """ logging.debug(f"[VC_EXT] Sending vibe feedback, type: {feedback_type}") - uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None - - if not uid or not ctx.guild_id: + if not (uid := await self.get_viber_id_from_ctx(ctx)) or not ctx.guild_id: logging.warning("[VC_EXT] User id or guild id not found") return False - guild = await self.db.get_guild(ctx.guild_id, projection={'current_viber_id': 1}) - - if guild['current_viber_id']: - viber_id = guild['current_viber_id'] - else: - viber_id = uid - - user = await self.users_db.get_user(viber_id, projection={'vibe_batch_id': 1, 'vibe_type': 1, 'vibe_id': 1}) + 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']}") @@ -871,12 +862,18 @@ class VoiceExtension(BaseBot): logging.warning(f"[VC_EXT] Timed out while downloading track '{track.title}'") raise - async def _delete_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, current_menu: int, gid: int) -> Literal[True]: + 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. - guild (ExplicitGuild): Guild. + current_menu (int): Current menu message ID. + gid (int): Guild ID. Returns: Literal[True]: Always returns True. @@ -916,34 +913,32 @@ class VoiceExtension(BaseBot): Returns: (str | None): Song title or None. """ - 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("Guild ID or User ID not found in context") return None - guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'vibing': 1, 'current_track': 1}) + guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1, 'vibing': 1, 'current_track': 1}) if not (vc := await self.get_voice_client(ctx) if not vc else vc): return None try: if not guild['current_track'] or track.id != guild['current_track']['id']: - await self._download_track(gid, track) + 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) - else: - await self.send_response_message(ctx, f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) - logging.error(f"[VC_EXT] Failed to download track '{track.title}'") + + await self.send_response_message(ctx, f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) + logging.error(f"[VC_EXT] Failed to download track '{track.title}'") return None - async with aiofiles.open(f'music/{gid}.mp3', "rb") as f: + 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(gid, track) + 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. @@ -966,7 +961,7 @@ class VoiceExtension(BaseBot): return None logging.info(f"[VC_EXT] Playing track '{track.title}'") - await self.db.update(gid, {'is_stopped': False}) + await self.db.update(ctx.guild_id, {'is_stopped': False}) if guild['vibing']: await self.send_vibe_feedback(ctx, 'trackStarted', track) diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index 194ddec..c2a90dc 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -29,7 +29,7 @@ class ToggleButton(Button, VoiceExtension): await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True) return - if not await self.voice_check(interaction, check_vibe_privilage=True): + if not await self.voice_check(interaction): return guild = await self.db.get_guild(gid) @@ -72,7 +72,8 @@ class PlayPauseButton(Button, VoiceExtension): async def callback(self, interaction: Interaction) -> None: logging.info('[MENU] Play/Pause button callback...') - if not await self.voice_check(interaction, check_vibe_privilage=True): + + if not await self.voice_check(interaction): return if not (gid := interaction.guild_id) or not interaction.user: @@ -114,11 +115,15 @@ class PlayPauseButton(Button, VoiceExtension): await interaction.respond("❌ Нет воспроизводимого трека.", 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() - embed.remove_footer() + if guild['single_token_uid'] and (user := await self.get_discord_user_by_id(interaction, guild['single_token_uid'])): + 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='Приостановлено') await interaction.edit(embed=embed) @@ -139,7 +144,7 @@ class SwitchTrackButton(Button, VoiceExtension): logging.info(f'[MENU] {callback_type.capitalize()} track button callback') - if not await self.voice_check(interaction, check_vibe_privilage=True): + if not await self.voice_check(interaction): return tracks_type = callback_type + '_tracks' @@ -240,8 +245,7 @@ class LyricsButton(Button, VoiceExtension): if not await self.voice_check(interaction) or not interaction.guild_id or not interaction.user: return - client = await self.init_ym_client(interaction) - if not client: + if not (client := await self.init_ym_client(interaction)): return current_track = await self.db.get_track(interaction.guild_id, 'current') @@ -286,18 +290,18 @@ class MyVibeButton(Button, VoiceExtension): member = cast(Member, interaction.user) channel = cast(VoiceChannel, interaction.channel) track = await self.db.get_track(interaction.guild_id, 'current') - + if len(channel.members) > 2 and not member.guild_permissions.manage_channels: logging.info(f"Starting vote for starting vibe in guild {interaction.guild_id}") if track: response_message = f"{member.mention} хочет запустить волну по треку **{track['title']}**.\n\n Выполнить действие?" - _type = 'track' - _id = track['id'] + vibe_type = 'track' + vibe_id = track['id'] else: response_message = f"{member.mention} хочет запустить станцию **Моя Волна**.\n\n Выполнить действие?" - _type = 'user' - _id = 'onyourwave' + vibe_type = 'user' + vibe_id = 'onyourwave' message = cast(Interaction, await interaction.respond(response_message)) response = await message.original_response() @@ -313,7 +317,7 @@ class MyVibeButton(Button, VoiceExtension): 'negative_votes': list(), 'total_members': len(channel.members), 'action': 'vibe_station', - 'vote_content': [_type, _id, interaction.user.id] + 'vote_content': [vibe_type, vibe_id, interaction.user.id] } ) return @@ -337,8 +341,7 @@ class MyVibeButton(Button, VoiceExtension): logging.info('[MENU] Failed to start the vibe') await interaction.respond('❌ Не удалось запустить "Мою Волну". Возможно, у вас нет подписки на Яндекс Музыку.', ephemeral=True) - next_track = await self.db.get_track(interaction.guild_id, 'next') - if next_track: + if (next_track := await self.db.get_track(interaction.guild_id, 'next')): await self.play_track(interaction, next_track, button_callback=True) class MyVibeSelect(Select, VoiceExtension): @@ -539,8 +542,7 @@ class AddToPlaylistButton(Button, VoiceExtension): await interaction.respond('❌ Нет воспроизводимого трека.', delete_after=15, ephemeral=True) return - client = await self.init_ym_client(interaction) - if not client: + if not (client := await self.init_ym_client(interaction)): await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True) return @@ -582,7 +584,7 @@ class MenuView(View, VoiceExtension): 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.lyrics_button = LyricsButton(style=ButtonStyle.secondary, emoji='📋', row=1) @@ -594,26 +596,33 @@ class MenuView(View, VoiceExtension): 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}) - + 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'] - likes = await self.get_liked_tracks(self.ctx) 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) - - if not isinstance(self.ctx, RawReactionActionEvent) and len(cast(VoiceChannel, self.ctx.channel).members) == 2: - if current_track and str(current_track['id']) in [str(like.id) for like in likes]: + + if not isinstance(self.ctx, RawReactionActionEvent) \ + and len(cast(VoiceChannel, self.ctx.channel).members) == 2 \ + and not self.guild['single_token_uid']: + + 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 + 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 + if not current_track: self.lyrics_button.disabled = True self.like_button.disabled = True @@ -621,6 +630,11 @@ 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) @@ -643,8 +657,11 @@ class MenuView(View, VoiceExtension): return if self.guild['current_menu']: - await self.stop_playing(self.ctx) - await self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': [], 'vibing': False}) + await self.db.update(self.ctx.guild_id, { + 'current_menu': None, 'repeat': False, 'shuffle': False, + 'previous_tracks': [], 'next_tracks': [], 'votes': {}, + 'vibing': False, 'current_viber_id': None + }) if (message := await self.get_menu_message(self.ctx, self.guild['current_menu'])): await message.delete() From ba9d30ff6011623534b3d397bf0e35cd2ea8fb04 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sat, 15 Mar 2025 21:44:12 +0300 Subject: [PATCH 06/16] fix: Spelling mistake in the url. --- MusicBot/ui/find.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MusicBot/ui/find.py b/MusicBot/ui/find.py index f795ade..9ad147c 100644 --- a/MusicBot/ui/find.py +++ b/MusicBot/ui/find.py @@ -229,8 +229,8 @@ class ListenView(View): link_app = f"yandexmusic://artist/{item.id}" link_web = f"https://music.yandex.ru/artist/{item.id}" elif isinstance(item, Playlist): - link_app = f"yandexmusic://playlist/{item.playlist_uuid}" - link_web = f"https://music.yandex.ru/playlist/{item.playlist_uuid}" + link_app = f"yandexmusic://playlists/{item.playlist_uuid}" + link_web = f"https://music.yandex.ru/playlists/{item.playlist_uuid}" elif isinstance(item, list): # Can't open other person's likes self.add_item(PlayButton(item, label="Слушать в голосовом канале", style=ButtonStyle.gray)) self.add_item(MyVibeButton(item, label="Моя Волна", style=ButtonStyle.gray, emoji="🌊", row=1)) From 3d5c11dad4856706ad7f8a29af0720eefee3d927 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sat, 15 Mar 2025 21:46:14 +0300 Subject: [PATCH 07/16] git: Update README.md --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6683935..a28f377 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,10 @@ pip install -r requirements.txt Создайте файл `.env` и добавьте в него переменные окружения. Пример: ```env -TOKEN='XXXXXX' # Токен бота -EXPLICIT_EID='1325879701117472869' # ID эмодзи explicit -DEBUG='False' # Включение DEBUG логов (True/False) +TOKEN='XXXXXX' # Токен бота +DEBUG='False' # Включение DEBUG логов (True/False) +EXPLICIT_EID='1325879701117472869' # ID эмодзи explicit +MONGO_URI='mongodb://localhost:27017/' # Адрес сервера MongoDB ``` Запустите сервер MongoDB (настройки по умолчанию) и создайте базу данных YandexMusicBot с коллекциями guilds и users (через Compass или mongosh). @@ -83,8 +84,8 @@ DEBUG='False' # Включение DEBUG логов (True/False) ### docker cli ->[!NOTE] ->При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адресс сервера в команде запуска. +> [!NOTE] +> При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адресс сервера в команде запуска. ```bash docker run -d \ @@ -93,14 +94,14 @@ docker run -d \ -e TOKEN=XXXXXX \ -e EXPLICIT_EID=1325879701117472869 \ -e DEBUG=False \ - -e MONGO_URI="mongodb://mongodb:27017" \ + -e MONGO_URI="mongodb://mongodb:27017/" \ deadcxap/yandexmusicdiscordbot:latest ``` ### docker-compose (рекомендованный) ->[!NOTE] ->При первом запуске БД и коллекции будут созданы автоматически. +> [!NOTE] +> При первом запуске БД и коллекции будут созданы автоматически. ```yaml --- From 50ed08371281e4f8216408c1bd17f0c014b70220 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sun, 16 Mar 2025 11:25:58 +0300 Subject: [PATCH 08/16] fix: Missing pause in PlayPauseButton. --- MusicBot/ui/menu.py | 1 + 1 file changed, 1 insertion(+) diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index c2a90dc..afdf713 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -124,6 +124,7 @@ class PlayPauseButton(Button, VoiceExtension): else: embed.remove_footer() else: + vc.pause() embed.set_footer(text='Приостановлено') await interaction.edit(embed=embed) From 122851680047eb9f169970a10563e5c9c105cfc1 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sun, 16 Mar 2025 18:13:17 +0300 Subject: [PATCH 09/16] impr: Replace all of the responses to embeds. --- MusicBot/cogs/general.py | 44 +++++---- MusicBot/cogs/settings.py | 27 +++--- MusicBot/cogs/utils/base_bot.py | 56 +++++++++--- MusicBot/cogs/utils/voice_extension.py | 122 +++++++++---------------- MusicBot/cogs/voice.py | 61 +++++++------ MusicBot/ui/find.py | 38 ++++---- MusicBot/ui/menu.py | 108 ++++++++++++++++------ 7 files changed, 253 insertions(+), 203 deletions(-) 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..5262a9b 100644 --- a/MusicBot/cogs/utils/base_bot.py +++ b/MusicBot/cogs/utils/base_bot.py @@ -7,7 +7,7 @@ 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 @@ -39,7 +39,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 +52,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 +74,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(response_type, **kwargs) + content = None + + if not isinstance(ctx, RawReactionActionEvent) 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 +120,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, @@ -188,7 +202,27 @@ class BaseBot: self.menu_views[ctx.guild_id].stop() self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable) + + def generate_response_embed( + self, + embed_type: Literal['info', 'success', 'error'] = 'info', + **kwargs: Any + ) -> discord.Embed: + + embed = discord.Embed(**kwargs) + embed.set_author(name='YandexMusic', icon_url="https://github.com/Lemon4ksan/YandexMusicDiscordBot/blob/main/assets/Logo.png?raw=true") + 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..0c3c9a7 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -68,7 +68,7 @@ class VoiceExtension(BaseBot): 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]) + 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: @@ -338,40 +338,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") @@ -535,7 +535,7 @@ class VoiceExtension(BaseBot): 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) + 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']) @@ -573,7 +573,11 @@ class VoiceExtension(BaseBot): 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 +647,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 +671,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 +691,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 +705,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 +728,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 +786,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']: @@ -930,10 +889,15 @@ class VoiceExtension(BaseBot): if not retry: return await self._play_track(ctx, track, vc=vc, menu_message=menu_message, 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"') @@ -953,11 +917,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..80aea91 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,41 @@ 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}) 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): 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 +261,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 +269,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 +277,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 +304,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 +324,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="Получить очередь треков.") @@ -336,7 +337,7 @@ class Voice(Cog, VoiceExtension): 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) @@ -358,7 +359,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 +381,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 +404,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 +421,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' @@ -445,11 +446,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 +469,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/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..062e463 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -1,5 +1,5 @@ import logging -from typing import Self, cast +from typing import Self, Literal, cast from discord.ui import View, Button, Item, Select from discord import ( @@ -26,7 +26,7 @@ class ToggleButton(Button, VoiceExtension): 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 +41,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('✅') @@ -63,7 +63,7 @@ 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) + await self.respond(interaction, "error", "Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True) class PlayPauseButton(Button, VoiceExtension): def __init__(self, **kwargs): @@ -90,7 +90,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('✅') @@ -112,7 +112,7 @@ class PlayPauseButton(Button, VoiceExtension): 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}) @@ -153,7 +153,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 +163,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,7 +188,7 @@ 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): @@ -206,7 +206,7 @@ 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) @@ -214,26 +214,75 @@ class ReactionButton(Button, VoiceExtension): 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 'удалён из понравившихся.'}", + await self.respond( + interaction, "success", + f"Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}", delete_after=15, ephemeral=True ) 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) + await self.respond(interaction, "info", "Воспроизведение приостановлено. Нет треков в очереди.", delete_after=15) await self.update_menu_views_dict(interaction) await interaction.edit(view=self.menu_views[gid]) - await interaction.respond( - f"✅ Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}", + await self.respond( + interaction, "success", + f"Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}", delete_after=15, ephemeral=True ) 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) + + 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 +298,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 +306,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 +352,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 +388,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 +407,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 +518,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 +570,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): @@ -540,20 +588,20 @@ class AddToPlaylistButton(Button, VoiceExtension): current_track = await self.db.get_track(interaction.guild_id, 'current') if not current_track: - await interaction.respond('❌ Нет воспроизводимого трека.', delete_after=15, ephemeral=True) + 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) + await self.respond(interaction, "error", "У вас нет плейлистов.", delete_after=15, ephemeral=True) return view = View( From bb0135f1d6b800b9e20c1d5848902783452895f7 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sun, 16 Mar 2025 18:18:22 +0300 Subject: [PATCH 10/16] impr: Rework QueueView. --- MusicBot/cogs/voice.py | 4 +- MusicBot/database/base.py | 3 -- MusicBot/database/user.py | 6 --- MusicBot/ui/other.py | 100 +++++++++++++++++++------------------- 4 files changed, 51 insertions(+), 62 deletions(-) diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index 80aea91..0dce023 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -333,15 +333,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 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}") diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index 2581476..32b12a0 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, 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/other.py b/MusicBot/ui/other.py index 11a6f87..234325f 100644 --- a/MusicBot/ui/other.py +++ b/MusicBot/ui/other.py @@ -1,91 +1,91 @@ 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 +from discord import ApplicationContext, ButtonStyle, Interaction, Embed 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 From 26bf1fa7218fd891d58099042872a965060c79d1 Mon Sep 17 00:00:00 2001 From: deadcxap <36386824+deadcxap@users.noreply.github.com> Date: Mon, 17 Mar 2025 21:00:56 +0300 Subject: [PATCH 11/16] impr: Docker Workflow (#7) * automation of creating docker images and sending to dockerhub * badges for main and dev branches, latest-dev tag for dev builds --------- Co-authored-by: deadcxap --- .github/workflows/docker-image.yml | 52 ++++++++++++++++++++++++++++++ README.md | 3 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docker-image.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..fab527a --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,52 @@ +name: Docker Image CI + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + +jobs: + build: + runs-on: ubuntu-latest + env: + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/yandexmusicdiscordbot + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Get short SHA + id: vars + run: echo "short_sha=${GITHUB_SHA:0:7}" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ steps.vars.outputs.short_sha }} + + - name: Set the latest tag for the main branch + if: github.ref == 'refs/heads/main' + run: | + docker pull $IMAGE_NAME:${{ github.ref_name }}-${{ steps.vars.outputs.short_sha }} + docker tag $IMAGE_NAME:${{ github.ref_name }}-${{ steps.vars.outputs.short_sha }} $IMAGE_NAME:latest + docker push $IMAGE_NAME:latest + + - name: Set the latest tag for the dev branch + if: github.ref == 'refs/heads/dev' + run: | + docker pull $IMAGE_NAME:${{ github.ref_name }}-${{ steps.vars.outputs.short_sha }} + docker tag $IMAGE_NAME:${{ github.ref_name }}-${{ steps.vars.outputs.short_sha }} $IMAGE_NAME:latest-dev + docker push $IMAGE_NAME:latest-dev \ No newline at end of file diff --git a/README.md b/README.md index a28f377..7a17ee1 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ MONGO_URI='mongodb://localhost:27017/' # Адрес сервера MongoDB Запустите бота (`python ./MusicBot/main.py`). -## Запуск в Docker +## Запуск в 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-compose. From 3ddbaa517d7190d0347f28a98b35d6978fd8dc13 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Mon, 17 Mar 2025 21:14:18 +0300 Subject: [PATCH 12/16] fix: Update Docker image link. --- README.md | 42 ++---------------------------------------- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 7a17ee1..518627d 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 \ @@ -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 From 76dd9d5bfc7e70d6bba15cb01490a6493d4b87e4 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Mon, 17 Mar 2025 21:17:11 +0300 Subject: [PATCH 13/16] !fixup: Update Docker image link. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 518627d..6773dc7 100644 --- a/README.md +++ b/README.md @@ -95,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 (рекомендованный) From e320e10547943dcdb8e4bfc2f969a4f1b4a248ca Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Wed, 19 Mar 2025 23:23:40 +0300 Subject: [PATCH 14/16] impr: Rework MenuView. --- MusicBot/cogs/utils/base_bot.py | 57 +++++---- MusicBot/cogs/utils/voice_extension.py | 70 ++++++----- MusicBot/ui/menu.py | 156 +++++++++++++++---------- 3 files changed, 153 insertions(+), 130 deletions(-) diff --git a/MusicBot/cogs/utils/base_bot.py b/MusicBot/cogs/utils/base_bot.py index 5262a9b..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, 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: @@ -106,10 +105,10 @@ class BaseBot: if not embed and response_type: if content: kwargs['description'] = content - embed = self.generate_response_embed(response_type, **kwargs) + embed = self.generate_response_embed(ctx, response_type, **kwargs) content = None - if not isinstance(ctx, RawReactionActionEvent) and ctx.response.is_done(): + if not isinstance(ctx, RawReactionActionEvent) and not view and ctx.response.is_done(): view = MISSING if not isinstance(ctx, RawReactionActionEvent): @@ -176,41 +175,37 @@ 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( - 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}") + + async def init_menu_view(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, gid: int, *, disable: bool = False) -> None: from MusicBot.ui import MenuView - - if not ctx.guild_id: - logging.warning("[BASE_BOT] 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) + self.menu_views[gid] = await MenuView(ctx).init(disable=disable) def generate_response_embed( self, + ctx: ApplicationContext | Interaction | RawReactionActionEvent, embed_type: Literal['info', 'success', 'error'] = 'info', **kwargs: Any ) -> discord.Embed: + 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" + embed = discord.Embed(**kwargs) - embed.set_author(name='YandexMusic', icon_url="https://github.com/Lemon4ksan/YandexMusicDiscordBot/blob/main/assets/Logo.png?raw=true") + embed.set_author(name=name, icon_url=icon_url) if embed_type == 'info': embed.color = 0xfed42b diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index 0c3c9a7..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,11 +63,9 @@ 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.update_menu_views_dict(ctx, disable=disable) + await self._delete_menu_message(ctx, guild['current_menu'], 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 @@ -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 @@ -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.respond(ctx, "error", "Не удалось обновить меню.", 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,6 +568,9 @@ 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 @@ -854,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: @@ -865,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. @@ -887,7 +886,7 @@ 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.respond(ctx, "error", "Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) logging.error(f"[VC_EXT] Failed to download track '{track.title}'") @@ -904,9 +903,8 @@ class VoiceExtension(BaseBot): 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 diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index 062e463..275d6b8 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -13,13 +13,14 @@ 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') @@ -62,8 +63,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 self.respond(interaction, "error", "Что-то пошло не так. Попробуйте снова.", 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): @@ -109,6 +112,11 @@ class PlayPauseButton(Button, VoiceExtension): ) return + if vc.is_paused(): + vc.resume() + else: + vc.pause() + try: embed = interaction.message.embeds[0] except IndexError: @@ -116,16 +124,19 @@ class PlayPauseButton(Button, VoiceExtension): 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 +146,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: @@ -191,9 +202,10 @@ class SwitchTrackButton(Button, VoiceExtension): 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 @@ -212,30 +224,28 @@ 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=self.menu_views[gid]) - await self.respond( - interaction, "success", - 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 self.respond(interaction, "info", "Воспроизведение приостановлено. Нет треков в очереди.", 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 self.respond( - interaction, "success", - 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 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, @@ -586,8 +596,7 @@ 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: + if not await self.db.get_track(interaction.guild_id, 'current'): await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True) return @@ -599,8 +608,7 @@ class AddToPlaylistButton(Button, VoiceExtension): await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True) return - playlists = await client.users_playlists_list() - if not playlists: + if not (playlists := await client.users_playlists_list()): await self.respond(interaction, "error", "У вас нет плейлистов.", delete_after=15, ephemeral=True) return @@ -628,39 +636,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 \ @@ -668,9 +695,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 @@ -679,32 +714,27 @@ 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() 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, @@ -718,4 +748,4 @@ class MenuView(View, VoiceExtension): else: logging.debug('[MENU] No menu message found') - self.stop() + self.stop() From 170b8f3c0ed42ebd2bd553e064dd4ccecf4ce5e5 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Thu, 20 Mar 2025 10:54:45 +0300 Subject: [PATCH 15/16] fix: Add missing "use_single_token" checks. --- MusicBot/cogs/voice.py | 14 +++++++++++++- MusicBot/database/base.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index 0dce023..4ff1f5d 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -230,6 +230,15 @@ class Voice(Cog, VoiceExtension): 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']: @@ -247,7 +256,8 @@ class Voice(Cog, VoiceExtension): else: 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 = ("error", "Вы должны отправить команду в чате голосового канала.") @@ -434,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}") diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index 32b12a0..f5f68c9 100644 --- a/MusicBot/database/base.py +++ b/MusicBot/database/base.py @@ -67,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( From 43de5636d8277144742792155ee5f66b8db301fb Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Thu, 20 Mar 2025 14:11:47 +0300 Subject: [PATCH 16/16] fix: Increase timeout on update. --- MusicBot/ui/menu.py | 4 ++++ MusicBot/ui/other.py | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index 275d6b8..ed3b0b4 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -1,4 +1,5 @@ import logging +from time import monotonic from typing import Self, Literal, cast from discord.ui import View, Button, Item, Select @@ -727,6 +728,9 @@ class MenuView(View, VoiceExtension): if disable: self.disable_all_items() + + if self.timeout: + self.__timeout_expiry = monotonic() + self.timeout return self diff --git a/MusicBot/ui/other.py b/MusicBot/ui/other.py index 234325f..4a60c33 100644 --- a/MusicBot/ui/other.py +++ b/MusicBot/ui/other.py @@ -2,7 +2,7 @@ from math import ceil from typing import Any from discord.ui import View, Button, Item -from discord import ApplicationContext, ButtonStyle, Interaction, Embed +from discord import ApplicationContext, ButtonStyle, Interaction, Embed, HTTPException from MusicBot.cogs.utils.voice_extension import VoiceExtension @@ -88,4 +88,8 @@ class QueueView(View, VoiceExtension): self.prev_button.disabled = True async def on_timeout(self) -> None: - self.stop() \ No newline at end of file + try: + await super().on_timeout() + except HTTPException: + pass + self.stop()