up-to-date actual

This commit is contained in:
2025-03-20 18:47:51 +03:00
parent 76c7bcf2c6
commit 74d886bf0e
12 changed files with 473 additions and 419 deletions

View File

@@ -162,7 +162,7 @@ class General(Cog, BaseBot):
"Запустить станцию. Без уточнения станции, запускает Мою Волну.\n```/voice vibe <название станции>```" "Запустить станцию. Без уточнения станции, запускает Мою Волну.\n```/voice vibe <название станции>```"
) )
else: else:
await ctx.respond('Неизвестная команда.', delete_after=15, ephemeral=True) await self.respond(ctx, "error", "Неизвестная команда.", delete_after=15, ephemeral=True)
return return
await ctx.respond(embed=embed, ephemeral=True) await ctx.respond(embed=embed, ephemeral=True)
@@ -176,16 +176,16 @@ class General(Cog, BaseBot):
client = await YMClient(token).init() client = await YMClient(token).init()
except UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] Invalid token provided by user {ctx.author.id}") 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 return
if not client.me or not client.me.account: if not client.me or not client.me.account:
logging.warning(f"[GENERAL] Failed to get user info for user {ctx.author.id}") 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 return
await self.users_db.update(ctx.author.id, {'ym_token': token}) 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 self._ym_clients[token] = client
logging.info(f"[GENERAL] User {ctx.author.id} logged in successfully") 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)): 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}") 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 return
if token in self._ym_clients: 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}) await self.users_db.update(ctx.user.id, {'ym_token': None})
logging.info(f"[GENERAL] Token removed for user {ctx.author.id}") 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="Получить плейлист «Мне нравится»") @account.command(description="Получить плейлист «Мне нравится»")
async def likes(self, ctx: discord.ApplicationContext) -> None: 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'}) 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']: 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 return
if not (client := await self.init_ym_client(ctx)): if not (client := await self.init_ym_client(ctx)):
@@ -223,16 +223,20 @@ class General(Cog, BaseBot):
likes = await client.users_likes_tracks() likes = await client.users_likes_tracks()
except UnauthorizedError: except UnauthorizedError:
logging.warning(f"[GENERAL] Unknown token error for user {ctx.user.id}") 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 return
if likes is None: if likes is None:
logging.info(f"[GENERAL] Failed to fetch likes for user {ctx.user.id}") 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 return
elif not likes: elif not likes:
logging.info(f"[GENERAL] Empty likes for user {ctx.user.id}") 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 return
await ctx.defer() # Sometimes it takes a while to fetch all tracks, so we defer the response 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'}) 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']: 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 return
if not (client := await self.init_ym_client(ctx)): 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') search = await client.search(content_type, type_='playlist')
if not search or not search.playlists: if not search or not search.playlists:
logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}") 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 return
if (playlist := search.playlists.results[0]) is None: if (playlist := search.playlists.results[0]) is None:
logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}") 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(): if not await playlist.fetch_tracks_async():
logging.info(f"[GENERAL] User {ctx.user.id} search for '{content_type}' returned no tracks") 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 return
await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist)) 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'}) 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']: 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 return
if not (client := await self.init_ym_client(ctx)): if not (client := await self.init_ym_client(ctx)):
@@ -306,17 +310,17 @@ class General(Cog, BaseBot):
playlists = await client.users_playlists_list() playlists = await client.users_playlists_list()
except UnauthorizedError: except UnauthorizedError:
logging.warning(f"[GENERAL] Unknown token error for user {ctx.user.id}") 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 return
if not (playlist := next((playlist for playlist in playlists if playlist.title == name), None)): 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") 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 return
if not await playlist.fetch_tracks_async(): if not await playlist.fetch_tracks_async():
logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' is empty") 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 return
await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist)) 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)): if not (search_result := await client.search(name, nocorrect=True)):
logging.warning(f"Failed to search for '{name}' for user {ctx.user.id}") 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 return
if content_type == 'Трек': if content_type == 'Трек':
@@ -363,7 +367,7 @@ class General(Cog, BaseBot):
if not content: if not content:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no results") 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 return
result = content.results[0] result = content.results[0]

View File

@@ -5,11 +5,12 @@ import discord
from discord.ext.commands import Cog from discord.ext.commands import Cog
from MusicBot.database import BaseUsersDatabase, BaseGuildsDatabase from MusicBot.database import BaseUsersDatabase, BaseGuildsDatabase
from MusicBot.cogs.utils import BaseBot
def setup(bot): def setup(bot):
bot.add_cog(Settings(bot)) bot.add_cog(Settings(bot))
class Settings(Cog): class Settings(Cog, BaseBot):
settings = discord.SlashCommandGroup("settings", "Команды для изменения настроек бота.") settings = discord.SlashCommandGroup("settings", "Команды для изменения настроек бота.")
@@ -22,7 +23,7 @@ class Settings(Cog):
async def show(self, ctx: discord.ApplicationContext) -> None: async def show(self, ctx: discord.ApplicationContext) -> None:
if not ctx.guild_id: if not ctx.guild_id:
logging.info("[SETTINGS] Show command invoked without guild_id") logging.info("[SETTINGS] Show command invoked without guild_id")
await ctx.respond("Эта команда может быть использована только на сервере.", ephemeral=True) await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", ephemeral=True)
return return
guild = await self.db.get_guild(ctx.guild_id, projection={ guild = await self.db.get_guild(ctx.guild_id, projection={
@@ -37,6 +38,8 @@ class Settings(Cog):
token = "🔐 - Используется токен пользователя, запустившего бота" if guild['use_single_token'] else "🔒 - Используется личный токен пользователя" token = "🔐 - Используется токен пользователя, запустившего бота" if guild['use_single_token'] else "🔒 - Используется личный токен пользователя"
embed = discord.Embed(title="Настройки бота", color=0xfed42b) 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=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) embed.add_field(name="__Токен__", value=token, inline=False)
@@ -52,8 +55,8 @@ class Settings(Cog):
choices=[ choices=[
'Переключение треков без голосования для всех', 'Переключение треков без голосования для всех',
'Добавление в очередь без голосования для всех', 'Добавление в очередь без голосования для всех',
'Добавление/Отключение бота из канала для всех', 'Добавление/Отключение бота от канала для всех',
'Использовать единый токен для прослушивания' 'Использовать токен запустившего пользователя для всех'
] ]
) )
async def toggle( async def toggle(
@@ -62,18 +65,18 @@ class Settings(Cog):
vote_type: Literal[ vote_type: Literal[
'Переключение треков без голосования для всех', 'Переключение треков без голосования для всех',
'Добавление в очередь без голосования для всех', 'Добавление в очередь без голосования для всех',
'Добавление/Отключение бота из канала для всех', 'Добавление/Отключение бота от канала для всех',
'Использовать единый токен для прослушивания' 'Использовать токен запустившего пользователя для всех'
] ]
) -> None: ) -> None:
if not ctx.guild_id: if not ctx.guild_id:
logging.info("[SETTINGS] Toggle command invoked without 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 return
member = cast(discord.Member, ctx.user) member = cast(discord.Member, ctx.user)
if not member.guild_permissions.manage_channels: 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 return
guild = await self.db.get_guild(ctx.guild_id, projection={ 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']}) await self.db.update(ctx.guild_id, {'vote_add': not guild['vote_add']})
response_message = "Голосование за добавление в очередь " + ("❌ выключено." if guild['vote_add'] else "✅ включено.") 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']}) 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 '❌ запрещено'} участникам без прав управления каналом." 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']}) 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 '❌ выключено'}." response_message = f"Использование единого токена для прослушивания теперь {'✅ включено' if not guild['use_single_token'] else '❌ выключено'}."
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)

View File

@@ -6,14 +6,13 @@ import yandex_music.exceptions
from yandex_music import ClientAsync as YMClient from yandex_music import ClientAsync as YMClient
import discord import discord
from discord.ui import View from discord import Interaction, ApplicationContext, RawReactionActionEvent, MISSING
from discord import Interaction, ApplicationContext, RawReactionActionEvent
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase
class BaseBot: 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. _ym_clients: dict[str, YMClient] = {} # Store YM clients to prevent creating new ones for each command.
def __init__(self, bot: discord.Bot | None) -> None: def __init__(self, bot: discord.Bot | None) -> None:
@@ -39,7 +38,7 @@ class BaseBot:
if not (token := await self.get_ym_token(ctx)): if not (token := await self.get_ym_token(ctx)):
logging.debug("[BASE_BOT] No token found") 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 return None
try: try:
@@ -52,7 +51,7 @@ class BaseBot:
client = await YMClient(token).init() client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError: except yandex_music.exceptions.UnauthorizedError:
del self._ym_clients[token] 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 return None
self._ym_clients[token] = client self._ym_clients[token] = client
@@ -74,30 +73,44 @@ class BaseBot:
else: else:
return await self.users_db.get_ym_token(uid) return await self.users_db.get_ym_token(uid)
async def send_response_message( async def respond(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
response_type: Literal['info', 'success', 'error'] | None = None,
content: str | None = None, content: str | None = None,
*, *,
delete_after: float | None = None, delete_after: float | None = None,
ephemeral: bool = False, ephemeral: bool = False,
embed: discord.Embed | None = None,
view: discord.ui.View | None = None, view: discord.ui.View | None = None,
embed: discord.Embed | None = None **kwargs: Any
) -> discord.Interaction | discord.WebhookMessage | discord.Message | 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. """Send response message based on context type. `self.bot` must be set in order to use RawReactionActionEvent context type.
RawReactionActionEvent can't be ephemeral. RawReactionActionEvent can't be ephemeral.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
content (str): Message content to send. content (str): Message content to send. If embed is not set, used as description.
delete_after (float | None, optional): Time after which the message will be deleted. Defaults to None. 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. 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, optional): Discord embed. Defaults to None.
embed (discord.Embed | None, 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: Returns:
(discord.InteractionMessage | discord.WebhookMessage | discord.Message | None): Message or None. Type depends on the context type. (discord.InteractionMessage | discord.WebhookMessage | discord.Message | None): Message or None. Type depends on the context type.
""" """
if not embed and response_type:
if content:
kwargs['description'] = content
embed = self.generate_response_embed(ctx, response_type, **kwargs)
content = None
if not isinstance(ctx, RawReactionActionEvent) and not view and ctx.response.is_done():
view = MISSING
if not isinstance(ctx, RawReactionActionEvent): if not isinstance(ctx, RawReactionActionEvent):
return await ctx.respond(content, delete_after=delete_after, ephemeral=ephemeral, view=view, embed=embed) return await ctx.respond(content, delete_after=delete_after, ephemeral=ephemeral, view=view, embed=embed)
elif self.bot: elif self.bot:
@@ -106,7 +119,7 @@ class BaseBot:
return await channel.send(content, delete_after=delete_after, view=view, embed=embed) # type: ignore return await channel.send(content, delete_after=delete_after, view=view, embed=embed) # type: ignore
return None return None
async def get_message_by_id( async def get_message_by_id(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
@@ -162,33 +175,49 @@ class BaseBot:
return 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 return ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
async def update_menu_views_dict( async def init_menu_view(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, gid: int, *, disable: bool = False) -> None:
from MusicBot.ui import MenuView
self.menu_views[gid] = await MenuView(ctx).init(disable=disable)
def generate_response_embed(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*, embed_type: Literal['info', 'success', 'error'] = 'info',
disable: bool = False **kwargs: Any
) -> None: ) -> discord.Embed:
"""Genereate a new menu view and update the `menu_views` dict. This prevents creating multiple menu views for the same guild.
Use guild id as a key to access menu view.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context
guild (ExplicitGuild): Guild.
disable (bool, optional): Disable menu. Defaults to False.
"""
logging.debug(f"[BASE_BOT] Updating menu views dict for guild {ctx.guild_id}")
from MusicBot.ui import MenuView
if not ctx.guild_id: if isinstance(ctx, Interaction):
logging.warning("[BASE_BOT] Guild not found") name = ctx.client.user.name if ctx.client.user else None
return icon_url = ctx.client.user.avatar.url if ctx.client.user and ctx.client.user.avatar else None
elif isinstance(ctx, ApplicationContext):
name = ctx.bot.user.name if ctx.bot.user else None
icon_url = ctx.bot.user.avatar.url if ctx.bot.user and ctx.bot.user.avatar else None
elif self.bot:
name = self.bot.user.name if self.bot.user else None
icon_url = self.bot.user.avatar.url if self.bot.user and self.bot.user.avatar else None
else:
name = icon_url = None
if not name:
name = 'YandexMusic'
if not icon_url:
icon_url="https://github.com/Lemon4ksan/YandexMusicDiscordBot/blob/main/assets/Logo.png?raw=true"
if ctx.guild_id in self.menu_views: embed = discord.Embed(**kwargs)
self.menu_views[ctx.guild_id].stop() embed.set_author(name=name, icon_url=icon_url)
self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable)
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: 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. """Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance.

View File

@@ -8,7 +8,7 @@ import yandex_music.exceptions
from yandex_music import Track, TrackShort, ClientAsync as YMClient from yandex_music import Track, TrackShort, ClientAsync as YMClient
import discord 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.base_bot import BaseBot
from MusicBot.cogs.utils import generate_item_embed from MusicBot.cogs.utils import generate_item_embed
@@ -63,12 +63,10 @@ class VoiceExtension(BaseBot):
if guild['current_menu']: if guild['current_menu']:
logging.info(f"[VC_EXT] Deleting old menu message {guild['current_menu']} in guild {ctx.guild_id}") 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 self._delete_menu_message(ctx, guild['current_menu'], ctx.guild_id)
await message.delete()
await self.update_menu_views_dict(ctx, disable=disable) 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])
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 response = await interaction.original_response() if isinstance(interaction, discord.Interaction) else interaction
if response: if response:
@@ -120,7 +118,6 @@ class VoiceExtension(BaseBot):
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. 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. 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. 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: else:
embed.remove_footer() embed.remove_footer()
await self.update_menu_views_dict(ctx) await self.menu_views[ctx.guild_id].update()
try: try:
if isinstance(ctx, Interaction) and button_callback: if isinstance(ctx, Interaction) and button_callback:
# If interaction from menu buttons # If interaction from menu buttons
@@ -193,7 +190,6 @@ class VoiceExtension(BaseBot):
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*, *,
menu_message: discord.Message | None = None,
button_callback: bool = False, button_callback: bool = False,
disable: bool = False disable: bool = False
) -> bool: ) -> bool:
@@ -201,8 +197,6 @@ class VoiceExtension(BaseBot):
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. 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. 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. 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. bool: True if the view was updated, False otherwise.
""" """
logging.debug("[VC_EXT] Updating menu view") logging.debug("[VC_EXT] Updating menu view")
if not ctx.guild_id: 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 return False
if not menu_message: guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1})
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 if not guild['current_menu']:
logging.warning("[VC_EXT] Current menu not found in guild data")
if not menu_message:
return False 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: try:
if isinstance(ctx, Interaction) and button_callback: if isinstance(ctx, Interaction) and button_callback:
# If interaction from menu buttons # If interaction from menu buttons
await ctx.edit(view=self.menu_views[ctx.guild_id]) await ctx.edit(view=view)
else: else:
# If interaction from other buttons or commands. They should have their own response. # 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: except discord.DiscordException as e:
logging.warning(f"[VC_EXT] Error while updating menu view: {e}") logging.warning(f"[VC_EXT] Error while updating menu view: {e}")
return False return False
@@ -338,40 +336,40 @@ class VoiceExtension(BaseBot):
""" """
if not ctx.user: if not ctx.user:
logging.info("[VC_EXT] User not found in context inside 'voice_check'") 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 return False
if not ctx.guild_id: if not ctx.guild_id:
logging.info("[VC_EXT] Guild id not found in context inside 'voice_check'") 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 return False
if not await self.get_ym_token(ctx): if not await self.get_ym_token(ctx):
logging.debug(f"[VC_EXT] No token found for user {ctx.user.id}") 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 return False
if not isinstance(ctx.channel, discord.VoiceChannel): if not isinstance(ctx.channel, discord.VoiceChannel):
logging.debug("[VC_EXT] User is not in a voice channel") 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 return False
if ctx.user.id not in ctx.channel.voice_states: if ctx.user.id not in ctx.channel.voice_states:
logging.debug("[VC_EXT] User is not connected to the voice channel") 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 return False
voice_clients = ctx.client.voice_clients if isinstance(ctx, Interaction) else ctx.bot.voice_clients 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): if not discord.utils.get(voice_clients, guild=ctx.guild):
logging.debug("[VC_EXT] Voice client not found") 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 return False
if check_vibe_privilage: if check_vibe_privilage:
guild = await self.db.get_guild(ctx.guild_id, projection={'current_viber_id': 1, 'vibing': 1}) 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']: if guild['vibing'] and ctx.user.id != guild['current_viber_id']:
logging.debug("[VIBE] Context user is not the current viber") 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 return False
logging.debug("[VC_EXT] Voice requirements met") logging.debug("[VC_EXT] Voice requirements met")
@@ -411,7 +409,6 @@ class VoiceExtension(BaseBot):
track: Track | dict[str, Any], track: Track | dict[str, Any],
*, *,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
menu_message: discord.Message | None = None,
button_callback: bool = False, button_callback: bool = False,
) -> str | None: ) -> str | None:
"""Play `track` in the voice channel. Avoids additional vibe feedback used in `next_track` and `previous_track`. """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. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
track (dict[str, Any]): Track to play. track (dict[str, Any]): Track to play.
vc (discord.VoiceClient | None, optional): Voice client. Defaults to None. 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. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False.
Returns: Returns:
@@ -444,7 +440,6 @@ class VoiceExtension(BaseBot):
ctx, ctx,
track, track,
vc=vc, vc=vc,
menu_message=menu_message,
button_callback=button_callback button_callback=button_callback
) )
@@ -501,7 +496,6 @@ class VoiceExtension(BaseBot):
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
*, *,
after: bool = False, after: bool = False,
menu_message: discord.Message | None = None,
button_callback: bool = False button_callback: bool = False
) -> str | None: ) -> str | None:
"""Switch to the next track in the queue. Return track title on success. """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'") logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'")
return None 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.") logging.debug("[VC_EXT] Playback is stopped, skipping after callback.")
return None return None
@@ -534,8 +531,9 @@ class VoiceExtension(BaseBot):
logging.debug("[VC_EXT] Adding current track to history") logging.debug("[VC_EXT] Adding current track to history")
await self.db.modify_track(ctx.guild_id, guild['current_track'], 'previous', 'insert') 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): if after and guild['current_menu']:
await self.send_response_message(ctx, "Не удалось обновить меню.", ephemeral=True, delete_after=15) 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']: if guild['vibing'] and guild['current_track']:
await self.send_vibe_feedback(ctx, 'trackFinished' if after else 'skip', guild['current_track']) await self.send_vibe_feedback(ctx, 'trackFinished' if after else 'skip', guild['current_track'])
@@ -570,10 +568,17 @@ class VoiceExtension(BaseBot):
logging.info("[VC_EXT] No next track found") logging.info("[VC_EXT] No next track found")
if after: if after:
await self.db.update(ctx.guild_id, {'is_stopped': True, 'current_track': None}) 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 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. """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. Return track title on success. Should be called only if there's already track playing.
@@ -643,55 +648,12 @@ class VoiceExtension(BaseBot):
return [] return []
return collection.tracks 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. """Proccess vote and perform action from `vote_data` and respond. Return True on success.
Args: Args:
@@ -710,16 +672,16 @@ class VoiceExtension(BaseBot):
return False return False
if not guild['current_menu'] and not await self.send_menu_message(ctx): 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 return False
if vote_data['action'] in ('next', 'previous'): if vote_data['action'] in ('next', 'previous'):
if not guild.get(f'{vote_data['action']}_tracks'): if not guild.get(f'{vote_data['action']}_tracks'):
logging.info(f"[VOICE] No {vote_data['action']} tracks found for message {ctx.message_id}") 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)): 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 return False
elif vote_data['action'] == 'add_track': elif vote_data['action'] == 'add_track':
@@ -730,9 +692,9 @@ class VoiceExtension(BaseBot):
await self.db.modify_track(guild['_id'], vote_data['vote_content'], 'next', 'append') await self.db.modify_track(guild['_id'], vote_data['vote_content'], 'next', 'append')
if guild['current_track']: 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): 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 return False
elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'): elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'):
@@ -744,14 +706,14 @@ class VoiceExtension(BaseBot):
await self.db.modify_track(guild['_id'], vote_data['vote_content'], 'next', 'extend') await self.db.modify_track(guild['_id'], vote_data['vote_content'], 'next', 'extend')
if guild['current_track']: 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): 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 return False
elif vote_data['action'] == 'play/pause': elif vote_data['action'] == 'play/pause':
if not (vc := await self.get_voice_client(ctx)): 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 return False
if vc.is_playing(): if vc.is_playing():
@@ -767,31 +729,31 @@ class VoiceExtension(BaseBot):
elif vote_data['action'] == 'clear_queue': elif vote_data['action'] == 'clear_queue':
await self.db.update(ctx.guild_id, {'previous_tracks': [], 'next_tracks': []}) 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': elif vote_data['action'] == 'stop':
if await self.stop_playing(ctx, full=True): if await self.stop_playing(ctx, full=True):
await channel.send("Воспроизведение остановлено.", delete_after=15) await self.respond(ctx, "success", "Воспроизведение остановлено.", delete_after=15)
else: else:
await channel.send("Произошла ошибка при остановке воспроизведения.", delete_after=15) await self.respond(ctx, "error", "Произошла ошибка при остановке воспроизведения.", delete_after=15)
return False return False
elif vote_data['action'] == 'vibe_station': 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) 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: 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}") 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 return False
if not await self.update_vibe(ctx, vibe_type, vibe_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) await self.respond(ctx, "error", "Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15)
return False return False
if (next_track := await self.db.get_track(ctx.guild_id, 'next')): if (next_track := await self.db.get_track(ctx.guild_id, 'next')):
await self.play_track(ctx, next_track) await self.play_track(ctx, next_track)
else: else:
await channel.send("Не удалось воспроизвести трек.", delete_after=15) await self.respond(ctx, "error", "Не удалось воспроизвести трек.", delete_after=15)
return False return False
else: else:
@@ -825,8 +787,6 @@ class VoiceExtension(BaseBot):
user = await self.users_db.get_user(uid, projection={'vibe_batch_id': 1, 'vibe_type': 1, 'vibe_id': 1}) 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)): 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 return False
if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms']: if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms']:
@@ -895,7 +855,6 @@ class VoiceExtension(BaseBot):
track: Track, track: Track,
*, *,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
menu_message: discord.Message | None = None,
button_callback: bool = False, button_callback: bool = False,
retry: bool = False retry: bool = False
) -> str | None: ) -> str | None:
@@ -906,7 +865,6 @@ class VoiceExtension(BaseBot):
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
track (Track): Track to play. track (Track): Track to play.
vc (discord.VoiceClient | None): Voice client. 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. 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. retry (bool): Whether the function is called again.
@@ -928,21 +886,25 @@ class VoiceExtension(BaseBot):
await self._download_track(ctx.guild_id, track) await self._download_track(ctx.guild_id, track)
except yandex_music.exceptions.TimedOutError: except yandex_music.exceptions.TimedOutError:
if not retry: if not retry:
return await self._play_track(ctx, track, vc=vc, menu_message=menu_message, button_callback=button_callback, retry=True) return await self._play_track(ctx, track, vc=vc, button_callback=button_callback, retry=True)
await self.send_response_message(ctx, f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) await self.respond(ctx, "error", "Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15)
logging.error(f"[VC_EXT] Failed to download track '{track.title}'") logging.error(f"[VC_EXT] Failed to download track '{track.title}'")
return None 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: async with aiofiles.open(f'music/{ctx.guild_id}.mp3', "rb") as f:
track_bytes = io.BytesIO(await f.read()) track_bytes = io.BytesIO(await f.read())
song = discord.FFmpegPCMAudio(track_bytes, pipe=True, options='-vn -b:a 64k -filter:a "volume=0.15"') song = discord.FFmpegPCMAudio(track_bytes, pipe=True, options='-vn -b:a 64k -filter:a "volume=0.15"')
await self.db.set_current_track(ctx.guild_id, track) await self.db.set_current_track(ctx.guild_id, track)
if menu_message or guild['current_menu']: if guild['current_menu']:
# Updating menu message before playing to prevent delay and avoid FFMPEG lags. await self.update_menu_embed_and_view(ctx, button_callback=button_callback)
await self.update_menu_embed_and_view(ctx, menu_message=menu_message, button_callback=button_callback)
if not guild['vibing']: if not guild['vibing']:
# Giving FFMPEG enough time to process the audio file # Giving FFMPEG enough time to process the audio file
@@ -953,11 +915,7 @@ class VoiceExtension(BaseBot):
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.play_next_track(ctx, after=True), loop)) vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.play_next_track(ctx, after=True), loop))
except discord.errors.ClientException as e: except discord.errors.ClientException as e:
logging.error(f"[VC_EXT] Error while playing track '{track.title}': {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) await self.respond(ctx, "error", "Не удалось проиграть трек. Попробуйте сбросить меню.", 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)
return None return None
logging.info(f"[VC_EXT] Playing track '{track.title}'") logging.info(f"[VC_EXT] Playing track '{track.title}'")

View File

@@ -152,7 +152,7 @@ class Voice(Cog, VoiceExtension):
if len(vote_data['positive_votes']) >= required_votes: if len(vote_data['positive_votes']) >= required_votes:
logging.info(f"[VOICE] Enough positive votes for message {payload.message_id}") logging.info(f"[VOICE] Enough positive votes for message {payload.message_id}")
await message.delete() 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)] del votes[str(payload.message_id)]
elif len(vote_data['negative_votes']) >= required_votes: elif len(vote_data['negative_votes']) >= required_votes:
@@ -211,7 +211,7 @@ class Voice(Cog, VoiceExtension):
async def menu(self, ctx: discord.ApplicationContext) -> None: 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}") 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): 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="Подключиться к голосовому каналу, в котором вы сейчас находитесь.") @voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.")
async def join(self, ctx: discord.ApplicationContext) -> None: async def join(self, ctx: discord.ApplicationContext) -> None:
@@ -219,40 +219,51 @@ class Voice(Cog, VoiceExtension):
if not ctx.guild_id: if not ctx.guild_id:
logging.warning("[VOICE] Join command invoked without guild_id") logging.warning("[VOICE] Join command invoked without guild_id")
await ctx.respond("Эта команда может быть использована только на сервере.", ephemeral=True) await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", ephemeral=True)
return return
if ctx.author.id not in ctx.channel.voice_states: if ctx.author.id not in ctx.channel.voice_states:
logging.debug("[VC_EXT] User is not connected to the voice channel") 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 return
member = cast(discord.Member, ctx.author) member = cast(discord.Member, ctx.author)
guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1, 'use_single_token': 1}) 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) await ctx.defer(ephemeral=True)
if not member.guild_permissions.manage_channels and not guild['allow_change_connect']: if not member.guild_permissions.manage_channels and not guild['allow_change_connect']:
response_message = "У вас нет прав для выполнения этой команды." response_message = ("error", "У вас нет прав для выполнения этой команды.")
elif isinstance(ctx.channel, discord.VoiceChannel): elif isinstance(ctx.channel, discord.VoiceChannel):
try: try:
await ctx.channel.connect() await ctx.channel.connect()
except TimeoutError: except TimeoutError:
response_message = "Не удалось подключиться к голосовому каналу." response_message = ("error", "Не удалось подключиться к голосовому каналу.")
except discord.ClientException: except discord.ClientException:
response_message = "Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave." response_message = ("error", "Бот уже находится в голосовом канале.\nВыключите его с помощью команды /voice leave.")
except discord.DiscordException as e: except discord.DiscordException as e:
logging.error(f"[VOICE] DiscordException: {e}") logging.error(f"[VOICE] DiscordException: {e}")
response_message = "Произошла неизвестная ошибка при подключении к голосовому каналу." response_message = ("error", "Произошла неизвестная ошибка при подключении к голосовому каналу.")
else: else:
response_message = "Подключение успешно!" response_message = ("success", "Подключение успешно!")
if guild['use_single_token'] and await self.users_db.get_ym_token(ctx.author.id): if guild['use_single_token']:
response_message = ("success", "Подключение успешно! Ваш токен будет использован для всех операций с музыкой на этом сервере.")
await self.db.update(ctx.guild_id, {'single_token_uid': ctx.author.id}) await self.db.update(ctx.guild_id, {'single_token_uid': ctx.author.id})
else: else:
response_message = "Вы должны отправить команду в чате голосового канала." response_message = ("error", "Вы должны отправить команду в чате голосового канала.")
logging.info(f"[VOICE] Join command response: {response_message}") 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="Заставить бота покинуть голосовой канал.") @voice.command(description="Заставить бота покинуть голосовой канал.")
async def leave(self, ctx: discord.ApplicationContext) -> None: async def leave(self, ctx: discord.ApplicationContext) -> None:
@@ -260,7 +271,7 @@ class Voice(Cog, VoiceExtension):
if not ctx.guild_id: if not ctx.guild_id:
logging.info("[VOICE] Leave command invoked without guild_id") logging.info("[VOICE] Leave command invoked without guild_id")
await ctx.respond("Эта команда может быть использована только на сервере.", ephemeral=True) await self.respond(ctx, "error", "Эта команда может быть использована только на сервере.", ephemeral=True)
return return
member = cast(discord.Member, ctx.author) member = cast(discord.Member, ctx.author)
@@ -268,7 +279,7 @@ class Voice(Cog, VoiceExtension):
if not member.guild_permissions.manage_channels and not guild['allow_change_connect']: 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}") 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 return
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
@@ -276,18 +287,18 @@ class Voice(Cog, VoiceExtension):
if not (vc := await self.get_voice_client(ctx)) or not vc.is_connected: 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}") 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 return
if not await self.stop_playing(ctx, vc=vc, full=True): 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 return
await vc.disconnect(force=True) await vc.disconnect(force=True)
logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild_id}") 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 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="Очистить очередь треков и историю прослушивания.") @queue.command(description="Очистить очередь треков и историю прослушивания.")
async def clear(self, ctx: discord.ApplicationContext) -> None: async def clear(self, ctx: discord.ApplicationContext) -> None:
@@ -303,7 +314,7 @@ class Voice(Cog, VoiceExtension):
logging.info(f"Starting vote for clearing queue in guild {ctx.guild_id}") logging.info(f"Starting vote for clearing queue in guild {ctx.guild_id}")
response_message = f"{member.mention} хочет очистить историю прослушивания и очередь треков.\n\n Выполнить действие?." 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -323,7 +334,7 @@ class Voice(Cog, VoiceExtension):
return return
await self.db.update(ctx.guild_id, {'previous_tracks': [], 'next_tracks': []}) 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}") logging.info(f"[VOICE] Queue and history cleared in guild {ctx.guild_id}")
@queue.command(description="Получить очередь треков.") @queue.command(description="Получить очередь треков.")
@@ -332,15 +343,13 @@ class Voice(Cog, VoiceExtension):
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
return return
await self.users_db.update(ctx.user.id, {'queue_page': 0})
tracks = await self.db.get_tracks_list(ctx.guild_id, 'next') tracks = await self.db.get_tracks_list(ctx.guild_id, 'next')
if len(tracks) == 0: if len(tracks) == 0:
await ctx.respond("❌ Очередь пуста.", ephemeral=True) await self.respond(ctx, "error", "Очередь прослушивания пуста.", delete_after=15, ephemeral=True)
return return
embed = generate_queue_embed(0, tracks) await ctx.respond(embed=generate_queue_embed(0, tracks), view=QueueView(ctx, tracks), ephemeral=True)
await ctx.respond(embed=embed, view=await QueueView(ctx).init(), ephemeral=True)
logging.info(f"[VOICE] Queue embed sent to user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[VOICE] Queue embed sent to user {ctx.author.id} in guild {ctx.guild_id}")
@@ -358,7 +367,7 @@ class Voice(Cog, VoiceExtension):
logging.info(f"Starting vote for stopping playback in guild {ctx.guild_id}") logging.info(f"Starting vote for stopping playback in guild {ctx.guild_id}")
response_message = f"{member.mention} хочет полностью остановить проигрывание.\n\n Выполнить действие?." 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -380,9 +389,9 @@ class Voice(Cog, VoiceExtension):
await ctx.defer(ephemeral=True) await ctx.defer(ephemeral=True)
res = await self.stop_playing(ctx, full=True) res = await self.stop_playing(ctx, full=True)
if res: if res:
await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True) await self.respond(ctx, "success", "Воспроизведение остановлено.", delete_after=15, ephemeral=True)
else: else:
await ctx.respond("Произошла ошибка при остановке воспроизведения.", delete_after=15, ephemeral=True) await self.respond(ctx, "error", "Произошла ошибка при остановке воспроизведения.", delete_after=15, ephemeral=True)
@voice.command(description="Запустить Мою Волну.") @voice.command(description="Запустить Мою Волну.")
@discord.option( @discord.option(
@@ -403,7 +412,7 @@ class Voice(Cog, VoiceExtension):
if guild['vibing']: if guild['vibing']:
logging.info(f"[VOICE] Action declined: vibing is already enabled in guild {ctx.guild_id}") 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 return
await ctx.defer(invisible=False) await ctx.defer(invisible=False)
@@ -420,14 +429,14 @@ class Voice(Cog, VoiceExtension):
if not content: if not content:
logging.debug(f"[VOICE] Station {name} not found") 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 return
vibe_type, vibe_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 vibe_type or not vibe_id: if not vibe_type or not vibe_id:
logging.debug(f"[VOICE] Station {name} has no ad params") 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 return
else: else:
vibe_type, vibe_id = 'user', 'onyourwave' vibe_type, vibe_id = 'user', 'onyourwave'
@@ -435,6 +444,8 @@ class Voice(Cog, VoiceExtension):
member = cast(discord.Member, ctx.author) member = cast(discord.Member, ctx.author)
channel = cast(discord.VoiceChannel, ctx.channel) 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: 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}") logging.info(f"Starting vote for starting vibe in guild {ctx.guild_id}")
@@ -445,11 +456,11 @@ class Voice(Cog, VoiceExtension):
station = content.station.name station = content.station.name
else: else:
logging.warning(f"[VOICE] Station {name} not found") 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 return
response_message = f"{member.mention} хочет запустить станцию **{station}**.\n\n Выполнить действие?" 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('')
await message.add_reaction('') await message.add_reaction('')
@@ -468,13 +479,13 @@ class Voice(Cog, VoiceExtension):
return return
if not await self.update_vibe(ctx, vibe_type, vibe_id): 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 return
if guild['current_menu']: 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): 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')): if (next_track := await self.db.get_track(ctx.guild_id, 'next')):
await self.play_track(ctx, next_track) await self.play_track(ctx, next_track)

View File

@@ -20,9 +20,6 @@ guilds: AsyncCollection[ExplicitGuild] = db.guilds
class BaseUsersDatabase: class BaseUsersDatabase:
DEFAULT_USER = User( DEFAULT_USER = User(
ym_token=None, ym_token=None,
playlists=[],
playlists_page=0,
queue_page=0,
vibe_batch_id=None, vibe_batch_id=None,
vibe_type=None, vibe_type=None,
vibe_id=None, vibe_id=None,
@@ -70,6 +67,16 @@ class BaseUsersDatabase:
) )
return cast(str | None, user.get('ym_token') if user else None) 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: class BaseGuildsDatabase:
DEFAULT_GUILD = Guild( DEFAULT_GUILD = Guild(

View File

@@ -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 class User(TypedDict, total=False): # Don't forget to change base.py if you add a new field
ym_token: str | None ym_token: str | None
playlists: list[tuple[str, int]]
playlists_page: int
queue_page: int
vibe_batch_id: str | None vibe_batch_id: str | None
vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None
vibe_id: str | int | 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): class ExplicitUser(TypedDict):
_id: int _id: int
ym_token: str | None ym_token: str | None
playlists: list[tuple[str, int]] # name / tracks count
playlists_page: int
queue_page: int
vibe_batch_id: str | None vibe_batch_id: str | None
vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None
vibe_id: str | int | None vibe_id: str | int | None

View File

@@ -20,7 +20,7 @@ class PlayButton(Button, VoiceExtension):
if not interaction.guild_id: if not interaction.guild_id:
logging.info("[FIND] No guild found in PlayButton callback") 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 return
if not await self.voice_check(interaction): 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}) guild = await self.db.get_guild(interaction.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vote_add': 1, 'vibing': 1})
if guild['vibing']: if guild['vibing']:
await interaction.respond("Нельзя добавлять треки в очередь, пока запущена волна.", ephemeral=True, delete_after=15) await self.respond(interaction, "error", "Нельзя добавлять треки в очередь, пока запущена волна.", ephemeral=True, delete_after=15)
return return
channel = cast(discord.VoiceChannel, interaction.channel) channel = cast(discord.VoiceChannel, interaction.channel)
@@ -38,54 +38,54 @@ class PlayButton(Button, VoiceExtension):
tracks = [self.item] tracks = [self.item]
action = 'add_track' action = 'add_track'
vote_message = f"{member.mention} хочет добавить трек **{self.item.title}** в очередь.\n\n Голосуйте за добавление." 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): elif isinstance(self.item, Album):
album = await self.item.with_tracks_async() album = await self.item.with_tracks_async()
if not album or not album.volumes: if not album or not album.volumes:
logging.debug("[FIND] Failed to fetch album tracks in PlayButton callback") 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 return
tracks = [track for volume in album.volumes for track in volume] tracks = [track for volume in album.volumes for track in volume]
action = 'add_album' action = 'add_album'
vote_message = f"{member.mention} хочет добавить альбом **{self.item.title}** в очередь.\n\n Голосуйте за добавление." 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): elif isinstance(self.item, Artist):
artist_tracks = await self.item.get_tracks_async() artist_tracks = await self.item.get_tracks_async()
if not artist_tracks: if not artist_tracks:
logging.debug("[FIND] Failed to fetch artist tracks in PlayButton callback") 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 return
tracks = artist_tracks.tracks.copy() tracks = artist_tracks.tracks.copy()
action = 'add_artist' action = 'add_artist'
vote_message = f"{member.mention} хочет добавить треки от **{self.item.name}** в очередь.\n\n Голосуйте за добавление." 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): elif isinstance(self.item, Playlist):
short_tracks = await self.item.fetch_tracks_async() short_tracks = await self.item.fetch_tracks_async()
if not short_tracks: if not short_tracks:
logging.debug("[FIND] Failed to fetch playlist tracks in PlayButton callback") 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 return
tracks = [cast(Track, short_track.track) for short_track in short_tracks] tracks = [cast(Track, short_track.track) for short_track in short_tracks]
action = 'add_playlist' action = 'add_playlist'
vote_message = f"{member.mention} хочет добавить плейлист **{self.item.title}** в очередь.\n\n Голосуйте за добавление." 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): elif isinstance(self.item, list):
tracks = self.item.copy() tracks = self.item.copy()
if not tracks: if not tracks:
logging.debug("[FIND] Empty tracks list in PlayButton callback") 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 return
action = 'add_playlist' action = 'add_playlist'
vote_message = f"{member.mention} хочет добавить плейлист **Мне Нравится** в очередь.\n\n Голосуйте за добавление." vote_message = f"{member.mention} хочет добавить плейлист **Мне Нравится** в очередь.\n\n Голосуйте за добавление."
response_message = f"Плейлист **«Мне нравится»** был добавлен в очередь." response_message = f"Плейлист **«Мне нравится»** был добавлен в очередь."
else: else:
raise ValueError(f"Unknown item type: '{type(self.item).__name__}'") 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: 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)") 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -113,9 +113,9 @@ class PlayButton(Button, VoiceExtension):
return return
if guild['current_menu']: 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): 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']: if guild['current_track']:
logging.debug(f"[FIND] Adding tracks to queue") logging.debug(f"[FIND] Adding tracks to queue")
@@ -125,7 +125,7 @@ class PlayButton(Button, VoiceExtension):
track = tracks.pop(0) track = tracks.pop(0)
await self.db.modify_track(interaction.guild_id, tracks, 'next', 'extend') await self.db.modify_track(interaction.guild_id, tracks, 'next', 'extend')
if not await self.play_track(interaction, track): 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: if interaction.message:
await interaction.message.delete() 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}) guild = await self.db.get_guild(interaction.guild_id, projection={'current_menu': 1, 'vibing': 1})
if guild['vibing']: 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 return
track_type_map = { track_type_map = {
@@ -160,7 +160,7 @@ class MyVibeButton(Button, VoiceExtension):
if isinstance(self.item, Playlist): if isinstance(self.item, Playlist):
if not self.item.owner: if not self.item.owner:
logging.warning(f"[VIBE] Playlist owner is None") 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 return
_id = self.item.owner.login + '_' + str(self.item.kind) _id = self.item.owner.login + '_' + str(self.item.kind)
@@ -187,7 +187,7 @@ class MyVibeButton(Button, VoiceExtension):
case list(): case list():
response_message = f"{member.mention} хочет запустить станцию **Моя Волна**.\n\n Выполнить действие?" 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -207,7 +207,7 @@ class MyVibeButton(Button, VoiceExtension):
return return
if not guild['current_menu'] and not await self.send_menu_message(interaction, disable=True): 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) await self.update_vibe(interaction, track_type_map[type(self.item)], _id)

View File

@@ -1,5 +1,6 @@
import logging import logging
from typing import Self, cast from time import monotonic
from typing import Self, Literal, cast
from discord.ui import View, Button, Item, Select from discord.ui import View, Button, Item, Select
from discord import ( from discord import (
@@ -13,20 +14,21 @@ from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient
from MusicBot.cogs.utils import VoiceExtension from MusicBot.cogs.utils import VoiceExtension
class ToggleButton(Button, VoiceExtension): class ToggleButton(Button, VoiceExtension):
def __init__(self, *args, **kwargs): def __init__(self, root: 'MenuView', *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
self.root = root
async def callback(self, interaction: Interaction) -> None: 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}'") raise ValueError(f"Invalid callback type: '{callback_type}'")
logging.info(f'[MENU] {callback_type.capitalize()} button callback') logging.info(f'[MENU] {callback_type.capitalize()} button callback')
if not (gid := interaction.guild_id) or not interaction.user: if not (gid := interaction.guild_id) or not interaction.user:
logging.warning('[MENU] Failed to get guild ID.') 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 return
if not await self.voice_check(interaction): if not await self.voice_check(interaction):
@@ -41,7 +43,7 @@ class ToggleButton(Button, VoiceExtension):
action = "выключить" if guild[callback_type] else "включить" action = "выключить" if guild[callback_type] else "включить"
task = "перемешивание треков" if callback_type == 'shuffle' 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -62,8 +64,10 @@ class ToggleButton(Button, VoiceExtension):
await self.db.update(gid, {callback_type: not guild[callback_type]}) await self.db.update(gid, {callback_type: not guild[callback_type]})
if not await self.update_menu_view(interaction, button_callback=True): button = self.root.repeat_button if callback_type == 'repeat' else self.root.shuffle_button
await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True) button.style = ButtonStyle.secondary if guild[callback_type] else ButtonStyle.success
await interaction.edit(view=await self.root.update())
class PlayPauseButton(Button, VoiceExtension): class PlayPauseButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -90,7 +94,7 @@ class PlayPauseButton(Button, VoiceExtension):
logging.info(f"[MENU] User {interaction.user.id} started vote to pause/resume track in guild {gid}") logging.info(f"[MENU] User {interaction.user.id} started vote to pause/resume track in guild {gid}")
task = "приостановить" if vc.is_playing() else "возобновить" 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -109,23 +113,31 @@ class PlayPauseButton(Button, VoiceExtension):
) )
return return
if vc.is_paused():
vc.resume()
else:
vc.pause()
try: try:
embed = interaction.message.embeds[0] embed = interaction.message.embeds[0]
except IndexError: except IndexError:
await interaction.respond("Нет воспроизводимого трека.", delete_after=15, ephemeral=True) await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True)
return return
guild = await self.db.get_guild(interaction.guild_id, projection={'single_token_uid': 1}) guild = await self.db.get_guild(interaction.guild_id, projection={'single_token_uid': 1})
if vc.is_paused(): if not vc.is_paused() and guild['single_token_uid']:
vc.resume() user = await self.get_discord_user_by_id(interaction, guild['single_token_uid'])
if guild['single_token_uid'] and (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) embed.set_footer(text=f"Используется токен {user.display_name}", icon_url=user.display_avatar.url)
else: else:
embed.remove_footer() embed.set_footer(text='Используется токен (неизвестный пользователь)')
else:
vc.pause() elif vc.is_paused():
embed.set_footer(text='Приостановлено') embed.set_footer(text='Приостановлено')
else:
embed.remove_footer()
await interaction.edit(embed=embed) await interaction.edit(embed=embed)
@@ -135,8 +147,8 @@ class SwitchTrackButton(Button, VoiceExtension):
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction) -> 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}'") raise ValueError(f"Invalid callback type: '{callback_type}'")
if not (gid := interaction.guild_id) or not interaction.user: if not (gid := interaction.guild_id) or not interaction.user:
@@ -153,7 +165,7 @@ class SwitchTrackButton(Button, VoiceExtension):
if not guild[tracks_type] and not guild['vibing']: if not guild[tracks_type] and not guild['vibing']:
logging.info(f"[MENU] No tracks in '{tracks_type}' list in guild {gid}") 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 return
member = cast(Member, interaction.user) member = cast(Member, interaction.user)
@@ -163,7 +175,7 @@ class SwitchTrackButton(Button, VoiceExtension):
logging.info(f"[MENU] User {interaction.user.id} started vote to skip track in guild {gid}") logging.info(f"[MENU] User {interaction.user.id} started vote to skip track in guild {gid}")
task = "пропустить текущий трек" if callback_type == 'next' else "вернуться к предыдущему треку" 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -188,12 +200,13 @@ class SwitchTrackButton(Button, VoiceExtension):
title = await self.play_previous_track(interaction, button_callback=True) title = await self.play_previous_track(interaction, button_callback=True)
if not title: 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): class ReactionButton(Button, VoiceExtension):
def __init__(self, *args, **kwargs): def __init__(self, root: 'MenuView', *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
self.root = root
async def callback(self, interaction: Interaction): async def callback(self, interaction: Interaction):
callback_type = interaction.custom_id callback_type = interaction.custom_id
@@ -206,34 +219,81 @@ class ReactionButton(Button, VoiceExtension):
return return
if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: 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) channel = cast(VoiceChannel, interaction.channel)
res = await self.react_track(interaction, callback_type) res = await self.react_track(interaction, callback_type)
if callback_type == 'like' and res[0]: if callback_type == 'like' and res[0]:
await self.update_menu_views_dict(interaction) button = self.root.like_button
await interaction.edit(view=self.menu_views[gid]) response_message = f"Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}"
await interaction.respond(
f"✅ Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}",
delete_after=15, ephemeral=True
)
elif callback_type == 'dislike' and res[0]: 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): if len(channel.members) == 2:
await interaction.respond("✅ Воспроизведение приостановлено. Нет треков в очереди.", delete_after=15) await self.play_next_track(interaction, vc=vc, button_callback=True)
return
await self.update_menu_views_dict(interaction) button = self.root.dislike_button
await interaction.edit(view=self.menu_views[gid]) response_message =f"Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}"
await interaction.respond(
f"✅ Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}",
delete_after=15, ephemeral=True
)
else: else:
logging.debug(f"[VC_EXT] Failed to get {callback_type} tracks") logging.debug(f"[VC_EXT] Failed to get {callback_type} tracks")
await interaction.respond("Операция не удалась. Попробуйте позже.", delete_after=15, ephemeral=True) await self.respond(interaction, "error", "Операция не удалась. Попробуйте позже.", delete_after=15, ephemeral=True)
return
if len(channel.members) == 2:
button.style = ButtonStyle.success if res[1] == 'added' else ButtonStyle.secondary
await interaction.edit(view=await self.root.update())
else:
await self.respond(interaction, "success", response_message, delete_after=15, ephemeral=True)
async def react_track(
self,
ctx: ApplicationContext | Interaction,
action: Literal['like', 'dislike']
) -> tuple[bool, Literal['added', 'removed'] | None]:
"""Like or dislike current track. Return track title on success.
Args:
ctx (ApplicationContext | Interaction): Context.
action (Literal['like', 'dislike']): Action to perform.
Returns:
(tuple[bool, Literal['added', 'removed'] | None]): Tuple with success status and action.
"""
if not (gid := ctx.guild_id) or not ctx.user:
logging.warning("[VC_EXT] Guild or User not found")
return (False, None)
if not (current_track := await self.db.get_track(gid, 'current')):
logging.debug("[VC_EXT] Current track not found")
return (False, None)
if not (client := await self.init_ym_client(ctx)):
return (False, None)
if action == 'like':
tracks = await client.users_likes_tracks()
add_func = client.users_likes_tracks_add
remove_func = client.users_likes_tracks_remove
else:
tracks = await client.users_dislikes_tracks()
add_func = client.users_dislikes_tracks_add
remove_func = client.users_dislikes_tracks_remove
if tracks is None:
logging.debug(f"[VC_EXT] No {action}s found")
return (False, None)
if str(current_track['id']) not in [str(track.id) for track in tracks]:
logging.debug(f"[VC_EXT] Track not found in {action}s. Adding...")
await add_func(current_track['id'])
return (True, 'added')
else:
logging.debug(f"[VC_EXT] Track found in {action}s. Removing...")
await remove_func(current_track['id'])
return (True, 'removed')
class LyricsButton(Button, VoiceExtension): class LyricsButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -249,8 +309,7 @@ class LyricsButton(Button, VoiceExtension):
if not (client := await self.init_ym_client(interaction)): if not (client := await self.init_ym_client(interaction)):
return return
current_track = await self.db.get_track(interaction.guild_id, 'current') if not (current_track := await self.db.get_track(interaction.guild_id, 'current')):
if not current_track:
logging.debug('[MENU] No current track found') logging.debug('[MENU] No current track found')
return return
@@ -258,7 +317,7 @@ class LyricsButton(Button, VoiceExtension):
lyrics = cast(TrackLyrics, await client.tracks_lyrics(current_track['id'])) lyrics = cast(TrackLyrics, await client.tracks_lyrics(current_track['id']))
except yandex_music.exceptions.NotFoundError: except yandex_music.exceptions.NotFoundError:
logging.debug('[MENU] Lyrics not found') 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 return
embed = Embed( embed = Embed(
@@ -304,7 +363,7 @@ class MyVibeButton(Button, VoiceExtension):
vibe_type = 'user' vibe_type = 'user'
vibe_id = 'onyourwave' 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() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -340,7 +399,7 @@ class MyVibeButton(Button, VoiceExtension):
if not res: if not res:
logging.info('[MENU] Failed to start the vibe') 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')): if (next_track := await self.db.get_track(interaction.guild_id, 'next')):
await self.play_track(interaction, next_track, button_callback=True) await self.play_track(interaction, next_track, button_callback=True)
@@ -359,7 +418,7 @@ class MyVibeSelect(Select, VoiceExtension):
if not interaction.user: if not interaction.user:
logging.warning('[MENU] No user in select callback') logging.warning('[MENU] No user in select callback')
return return
custom_id = interaction.custom_id custom_id = interaction.custom_id
if custom_id not in ('diversity', 'mood', 'lang'): if custom_id not in ('diversity', 'mood', 'lang'):
logging.error(f'[MENU] Unknown custom_id: {custom_id}') logging.error(f'[MENU] Unknown custom_id: {custom_id}')
@@ -470,7 +529,7 @@ class MyVibeSettingsButton(Button, VoiceExtension):
if not await self.voice_check(interaction, check_vibe_privilage=True): if not await self.voice_check(interaction, check_vibe_privilage=True):
return 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): class AddToPlaylistSelect(Select, VoiceExtension):
def __init__(self, ym_client: YMClient, *args, **kwargs): def __init__(self, ym_client: YMClient, *args, **kwargs):
@@ -522,11 +581,11 @@ class AddToPlaylistSelect(Select, VoiceExtension):
) )
if not res: 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: elif track_in_playlist:
await interaction.respond('🗑 Трек был удалён из плейлиста.', delete_after=15, ephemeral=True) await self.respond(interaction, "success", "🗑 Трек был удалён из плейлиста.", delete_after=15, ephemeral=True)
else: else:
await interaction.respond('📩 Трек был добавлен в плейлист.', delete_after=15, ephemeral=True) await self.respond(interaction, "success", "📩 Трек был добавлен в плейлист.", delete_after=15, ephemeral=True)
class AddToPlaylistButton(Button, VoiceExtension): class AddToPlaylistButton(Button, VoiceExtension):
@@ -538,22 +597,20 @@ class AddToPlaylistButton(Button, VoiceExtension):
if not await self.voice_check(interaction) or not interaction.guild_id: if not await self.voice_check(interaction) or not interaction.guild_id:
return return
current_track = await self.db.get_track(interaction.guild_id, 'current') if not await self.db.get_track(interaction.guild_id, 'current'):
if not current_track: await self.respond(interaction, "error", "Нет воспроизводимого трека.", delete_after=15, ephemeral=True)
await interaction.respond('❌ Нет воспроизводимого трека.', delete_after=15, ephemeral=True)
return return
if not (client := await self.init_ym_client(interaction)): 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 return
if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: 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 return
playlists = await client.users_playlists_list() if not (playlists := await client.users_playlists_list()):
if not playlists: await self.respond(interaction, "error", "У вас нет плейлистов.", delete_after=15, ephemeral=True)
await interaction.respond('У вас нет плейлистов.', delete_after=15, ephemeral=True)
return return
view = View( view = View(
@@ -580,39 +637,58 @@ class MenuView(View, VoiceExtension):
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
self.ctx = ctx self.ctx = ctx
self.repeat_button = ToggleButton(style=ButtonStyle.secondary, emoji='🔂', row=0, custom_id='repeat') self.repeat_button = ToggleButton(self, style=ButtonStyle.secondary, emoji='🔂', row=0, custom_id='repeat')
self.shuffle_button = ToggleButton(style=ButtonStyle.secondary, emoji='🔀', row=0, custom_id='shuffle') 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.play_pause_button = PlayPauseButton(style=ButtonStyle.primary, emoji='', row=0)
self.next_button = SwitchTrackButton(style=ButtonStyle.primary, emoji='', row=0, custom_id='next') 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.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.like_button = ReactionButton(self, style=ButtonStyle.secondary, emoji='❤️', row=1, custom_id='like')
self.dislike_button = ReactionButton(style=ButtonStyle.secondary, emoji='💔', row=1, custom_id='dislike') 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.lyrics_button = LyricsButton(style=ButtonStyle.secondary, emoji='📋', row=1)
self.add_to_playlist_button = AddToPlaylistButton(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_button = MyVibeButton(style=ButtonStyle.secondary, emoji='🌊', row=1)
self.vibe_settings_button = MyVibeSettingsButton(style=ButtonStyle.success, 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: async def init(self, *, disable: bool = False) -> Self:
if not self.ctx.guild_id: await self.update(disable=disable)
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']
self.add_item(self.repeat_button) self.add_item(self.repeat_button)
self.add_item(self.prev_button) self.add_item(self.prev_button)
self.add_item(self.play_pause_button) self.add_item(self.play_pause_button)
self.add_item(self.next_button) self.add_item(self.next_button)
self.add_item(self.shuffle_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) \ if not isinstance(self.ctx, RawReactionActionEvent) \
and len(cast(VoiceChannel, self.ctx.channel).members) == 2 \ and len(cast(VoiceChannel, self.ctx.channel).members) == 2 \
@@ -620,9 +696,17 @@ class MenuView(View, VoiceExtension):
if current_track and str(current_track['id']) in [str(like.id) for like in await self.get_reacted_tracks(self.ctx, 'like')]: 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 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')]: 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 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: if not current_track:
self.lyrics_button.disabled = True self.lyrics_button.disabled = True
@@ -631,32 +715,30 @@ class MenuView(View, VoiceExtension):
self.add_to_playlist_button.disabled = True self.add_to_playlist_button.disabled = True
elif not current_track['lyrics_available']: elif not current_track['lyrics_available']:
self.lyrics_button.disabled = True self.lyrics_button.disabled = True
if self.guild['single_token_uid']: if self.guild['single_token_uid']:
self.like_button.disabled = True self.like_button.disabled = True
self.dislike_button.disabled = True self.dislike_button.disabled = True
self.add_to_playlist_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']: if self.guild['vibing']:
self.add_item(self.vibe_settings_button) self.current_vibe_button = self.vibe_settings_button
else: else:
self.add_item(self.vibe_button) self.current_vibe_button = self.vibe_button
if disable: if disable:
self.disable_all_items() self.disable_all_items()
if self.timeout:
self.__timeout_expiry = monotonic() + self.timeout
return self return self
async def on_timeout(self) -> None: async def on_timeout(self) -> None:
logging.debug('[MENU] Menu timed out. Deleting menu message') logging.debug('[MENU] Menu timed out. Deleting menu message')
if not self.ctx.guild_id: if not self.ctx.guild_id:
return return
if self.guild['current_menu']: if self.guild['current_menu']:
await self.db.update(self.ctx.guild_id, { await self.db.update(self.ctx.guild_id, {
'current_menu': None, 'repeat': False, 'shuffle': False, 'current_menu': None, 'repeat': False, 'shuffle': False,
@@ -670,4 +752,4 @@ class MenuView(View, VoiceExtension):
else: else:
logging.debug('[MENU] No menu message found') logging.debug('[MENU] No menu message found')
self.stop() self.stop()

View File

@@ -1,5 +1,5 @@
from math import ceil from math import ceil
from typing import Self, Any from typing import Any
from discord.ui import View, Button, Item from discord.ui import View, Button, Item
from discord import ApplicationContext, ButtonStyle, Interaction, Embed, HTTPException from discord import ApplicationContext, ButtonStyle, Interaction, Embed, HTTPException
@@ -9,83 +9,87 @@ from MusicBot.cogs.utils.voice_extension import VoiceExtension
def generate_queue_embed(page: int, tracks_list: list[dict[str, Any]]) -> Embed: def generate_queue_embed(page: int, tracks_list: list[dict[str, Any]]) -> Embed:
count = 15 * page count = 15 * page
length = len(tracks_list) length = len(tracks_list)
embed = Embed( embed = Embed(
title=f"Всего: {length}", title=f"Всего: {length}",
color=0xfed42b, color=0xfed42b,
) )
embed.set_author(name="Очередь треков") embed.set_author(name="Очередь треков")
embed.set_footer(text=f"Страница {page + 1} из {ceil(length / 15)}") embed.set_footer(text=f"Страница {page + 1} из {ceil(length / 15)}")
for i, track in enumerate(tracks_list[count:count + 15], start=1 + count): for i, track in enumerate(tracks_list[count:count + 15], start=1 + count):
duration = track['duration_ms'] if track['duration_ms']:
if duration: duration_m = track['duration_ms'] // 60000
duration_m = duration // 60000 duration_s = ceil(track['duration_ms'] / 1000) - duration_m * 60
duration_s = ceil(duration / 1000) - duration_m * 60
embed.add_field(name=f"{i} - {track['title']} - {duration_m}:{duration_s:02d}", value="", inline=False) embed.add_field(name=f"{i} - {track['title']} - {duration_m}:{duration_s:02d}", value="", inline=False)
return embed return embed
class QueueNextButton(Button, VoiceExtension): class QueueNextButton(Button):
def __init__(self, **kwargs): def __init__(self, root:' QueueView', **kwargs):
Button.__init__(self, **kwargs) Button.__init__(self, **kwargs)
VoiceExtension.__init__(self, None) self.root = root
async def callback(self, interaction: Interaction) -> None: async def callback(self, interaction: Interaction) -> None:
if not interaction.user or not interaction.guild: self.root.page += 1
return 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) class QueuePrevButton(Button):
page = user['queue_page'] + 1 def __init__(self, root: 'QueueView', **kwargs):
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):
Button.__init__(self, **kwargs) Button.__init__(self, **kwargs)
VoiceExtension.__init__(self, None) self.root = root
async def callback(self, interaction: Interaction) -> None: async def callback(self, interaction: Interaction) -> None:
if not interaction.user or not interaction.guild: self.root.page -= 1
return self.root.update()
embed = generate_queue_embed(self.root.page, self.root.tracks)
user = await self.users_db.get_user(interaction.user.id) await interaction.edit(embed=embed, view=self.root)
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 QueueView(View, VoiceExtension): 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) View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
self.ctx = ctx self.ctx = ctx
self.next_button = QueueNextButton(style=ButtonStyle.primary, emoji='▶️') self.tracks = tracks
self.prev_button = QueuePrevButton(style=ButtonStyle.primary, emoji='◀️') self.page = 0
async def init(self) -> Self: self.next_button = QueueNextButton(self, style=ButtonStyle.primary, emoji='▶️')
if not self.ctx.user or not self.ctx.guild: self.prev_button = QueuePrevButton(self, style=ButtonStyle.primary, emoji='◀️', disabled=True)
return self
if not self.tracks[15:]:
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.disabled = True 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.prev_button)
self.add_item(self.next_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: async def on_timeout(self) -> None:
try: try:
await super().on_timeout() await super().on_timeout()
except HTTPException: except HTTPException:
pass pass
self.stop() self.stop()

View File

@@ -78,15 +78,14 @@ MONGO_URI='mongodb://localhost:27017/' # Адрес сервера MongoDB
Запустите бота (`python ./MusicBot/main.py`). Запустите бота (`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-compose.
### docker cli ### docker cli
> [!NOTE] > [!NOTE]
> При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адресс сервера в команде запуска. > При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адрес сервера в команде запуска.
```bash ```bash
docker run -d \ docker run -d \
@@ -96,7 +95,7 @@ docker run -d \
-e EXPLICIT_EID=1325879701117472869 \ -e EXPLICIT_EID=1325879701117472869 \
-e DEBUG=False \ -e DEBUG=False \
-e MONGO_URI="mongodb://mongodb:27017/" \ -e MONGO_URI="mongodb://mongodb:27017/" \
deadcxap/yandexmusicdiscordbot:latest lemon4ksan/yandexmusicdiscordbot:latest
``` ```
### docker-compose (рекомендованный) ### docker-compose (рекомендованный)
@@ -104,43 +103,6 @@ docker run -d \
> [!NOTE] > [!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 ```bash
docker-compose up -d docker-compose up -d
``` ```

View File

@@ -1,7 +1,7 @@
services: services:
app: app:
container_name: yandex-music-discord-bot container_name: yandex-music-discord-bot
image: deadcxap/yandexmusicdiscordbot:latest image: lemon4ksan/yandexmusicdiscordbot:latest
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- mongodb - mongodb