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 <название станции>```"
)
else:
await ctx.respond('Неизвестная команда.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Неизвестная команда.", delete_after=15, ephemeral=True)
return
await ctx.respond(embed=embed, ephemeral=True)
@@ -176,16 +176,16 @@ class General(Cog, BaseBot):
client = await YMClient(token).init()
except UnauthorizedError:
logging.info(f"[GENERAL] Invalid token provided by user {ctx.author.id}")
await ctx.respond('Недействительный токен.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Недействительный токен.", delete_after=15, ephemeral=True)
return
if not client.me or not client.me.account:
logging.warning(f"[GENERAL] Failed to get user info for user {ctx.author.id}")
await ctx.respond('Не удалось получить информацию о пользователе.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Не удалось получить информацию о пользователе.", delete_after=15, ephemeral=True)
return
await self.users_db.update(ctx.author.id, {'ym_token': token})
await ctx.respond(f'Привет, {client.me.account.first_name}!', delete_after=15, ephemeral=True)
await self.respond(ctx, "success", f"Привет, {client.me.account.first_name}!", delete_after=15, ephemeral=True)
self._ym_clients[token] = client
logging.info(f"[GENERAL] User {ctx.author.id} logged in successfully")
@@ -196,7 +196,7 @@ class General(Cog, BaseBot):
if not (token := await self.users_db.get_ym_token(ctx.user.id)):
logging.info(f"[GENERAL] No token found for user {ctx.author.id}")
await ctx.respond('Токен не указан.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Токен не указан.", delete_after=15, ephemeral=True)
return
if token in self._ym_clients:
@@ -205,7 +205,7 @@ class General(Cog, BaseBot):
await self.users_db.update(ctx.user.id, {'ym_token': None})
logging.info(f"[GENERAL] Token removed for user {ctx.author.id}")
await ctx.respond(f'Токен был удалён.', delete_after=15, ephemeral=True)
await self.respond(ctx, "success", "Токен был удалён.", delete_after=15, ephemeral=True)
@account.command(description="Получить плейлист «Мне нравится»")
async def likes(self, ctx: discord.ApplicationContext) -> None:
@@ -213,7 +213,7 @@ class General(Cog, BaseBot):
guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'})
if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']:
await ctx.respond('Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Только владелец токена может делиться личными плейлистами.", delete_after=15, ephemeral=True)
return
if not (client := await self.init_ym_client(ctx)):
@@ -223,16 +223,20 @@ class General(Cog, BaseBot):
likes = await client.users_likes_tracks()
except UnauthorizedError:
logging.warning(f"[GENERAL] Unknown token error for user {ctx.user.id}")
await ctx.respond("❌ Произошла неизвестная ошибка при попытке получения лайков. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True)
await self.respond(
ctx, "error",
"Произошла неизвестная ошибка при попытке получения лайков. Пожалуйста, сообщите об этом разработчику.",
delete_after=15, ephemeral=True
)
return
if likes is None:
logging.info(f"[GENERAL] Failed to fetch likes for user {ctx.user.id}")
await ctx.respond('Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True)
return
elif not likes:
logging.info(f"[GENERAL] Empty likes for user {ctx.user.id}")
await ctx.respond('У вас нет треков в плейлисте «Мне нравится».', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "У вас нет треков в плейлисте «Мне нравится».", delete_after=15, ephemeral=True)
return
await ctx.defer() # Sometimes it takes a while to fetch all tracks, so we defer the response
@@ -260,7 +264,7 @@ class General(Cog, BaseBot):
guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'})
if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']:
await ctx.respond('Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Только владелец токена может делиться личными плейлистами.", delete_after=15, ephemeral=True)
return
if not (client := await self.init_ym_client(ctx)):
@@ -269,16 +273,16 @@ class General(Cog, BaseBot):
search = await client.search(content_type, type_='playlist')
if not search or not search.playlists:
logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}")
await ctx.respond('Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True)
return
if (playlist := search.playlists.results[0]) is None:
logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}")
await ctx.respond('Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True)
if not await playlist.fetch_tracks_async():
logging.info(f"[GENERAL] User {ctx.user.id} search for '{content_type}' returned no tracks")
await ctx.respond("Пустой плейлист.", delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Пустой плейлист.", delete_after=15, ephemeral=True)
return
await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist))
@@ -296,7 +300,7 @@ class General(Cog, BaseBot):
guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'})
if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']:
await ctx.respond('Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "олько владелец токена может делиться личными плейлистами.", delete_after=15, ephemeral=True)
return
if not (client := await self.init_ym_client(ctx)):
@@ -306,17 +310,17 @@ class General(Cog, BaseBot):
playlists = await client.users_playlists_list()
except UnauthorizedError:
logging.warning(f"[GENERAL] Unknown token error for user {ctx.user.id}")
await ctx.respond("Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True)
return
if not (playlist := next((playlist for playlist in playlists if playlist.title == name), None)):
logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' not found")
await ctx.respond("Плейлист не найден.", delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Плейлист не найден.", delete_after=15, ephemeral=True)
return
if not await playlist.fetch_tracks_async():
logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' is empty")
await ctx.respond("Плейлист пуст.", delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Плейлист пуст.", delete_after=15, ephemeral=True)
return
await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist))
@@ -349,7 +353,7 @@ class General(Cog, BaseBot):
if not (search_result := await client.search(name, nocorrect=True)):
logging.warning(f"Failed to search for '{name}' for user {ctx.user.id}")
await ctx.respond("Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True)
return
if content_type == 'Трек':
@@ -363,7 +367,7 @@ class General(Cog, BaseBot):
if not content:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no results")
await ctx.respond("По запросу ничего не найдено.", delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "По запросу ничего не найдено.", delete_after=15, ephemeral=True)
return
result = content.results[0]

View File

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

View File

@@ -6,14 +6,13 @@ import yandex_music.exceptions
from yandex_music import ClientAsync as YMClient
import discord
from discord.ui import View
from discord import Interaction, ApplicationContext, RawReactionActionEvent
from discord import Interaction, ApplicationContext, RawReactionActionEvent, MISSING
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase
class BaseBot:
menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks.
menu_views: dict[int, Any] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks.
_ym_clients: dict[str, YMClient] = {} # Store YM clients to prevent creating new ones for each command.
def __init__(self, bot: discord.Bot | None) -> None:
@@ -39,7 +38,7 @@ class BaseBot:
if not (token := await self.get_ym_token(ctx)):
logging.debug("[BASE_BOT] No token found")
await self.send_response_message(ctx, "Укажите токен через /account login.", delete_after=15, ephemeral=True)
await self.respond(ctx, "error", "Укажите токен через /account login.", delete_after=15, ephemeral=True)
return None
try:
@@ -52,7 +51,7 @@ class BaseBot:
client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError:
del self._ym_clients[token]
await self.send_response_message(ctx, "Недействительный токен Yandex Music.", ephemeral=True, delete_after=15)
await self.respond(ctx, "error", "Недействительный токен Yandex Music.", ephemeral=True, delete_after=15)
return None
self._ym_clients[token] = client
@@ -74,30 +73,44 @@ class BaseBot:
else:
return await self.users_db.get_ym_token(uid)
async def send_response_message(
async def respond(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
response_type: Literal['info', 'success', 'error'] | None = None,
content: str | None = None,
*,
delete_after: float | None = None,
ephemeral: bool = False,
embed: discord.Embed | None = None,
view: discord.ui.View | None = None,
embed: discord.Embed | None = None
**kwargs: Any
) -> discord.Interaction | discord.WebhookMessage | discord.Message | None:
"""Send response message based on context type. self.bot must be set in order to use RawReactionActionEvent context type.
"""Send response message based on context type. `self.bot` must be set in order to use RawReactionActionEvent context type.
RawReactionActionEvent can't be ephemeral.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
content (str): Message content to send.
delete_after (float | None, optional): Time after which the message will be deleted. Defaults to None.
content (str): Message content to send. If embed is not set, used as description.
response_type (Literal['info', 'success', 'error'] | None, optional): Response type. Applies if embed is not specified.
delete_after (float, optional): Time after which the message will be deleted. Defaults to None.
ephemeral (bool, optional): Whether the message is ephemeral. Defaults to False.
view (discord.ui.View | None, optional): Discord view. Defaults to None.
embed (discord.Embed | None, optional): Discord embed. Defaults to None.
embed (discord.Embed, optional): Discord embed. Defaults to None.
view (discord.ui.View, optional): Discord view. Defaults to None.
kwargs: Additional arguments for embed generation. Applies if embed is not specified.
Returns:
(discord.InteractionMessage | discord.WebhookMessage | discord.Message | None): Message or None. Type depends on the context type.
"""
if not embed and response_type:
if content:
kwargs['description'] = content
embed = self.generate_response_embed(ctx, response_type, **kwargs)
content = None
if not isinstance(ctx, RawReactionActionEvent) and not view and ctx.response.is_done():
view = MISSING
if not isinstance(ctx, RawReactionActionEvent):
return await ctx.respond(content, delete_after=delete_after, ephemeral=ephemeral, view=view, embed=embed)
elif self.bot:
@@ -106,7 +119,7 @@ class BaseBot:
return await channel.send(content, delete_after=delete_after, view=view, embed=embed) # type: ignore
return None
async def get_message_by_id(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
@@ -162,33 +175,49 @@ class BaseBot:
return guild['current_viber_id']
return ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
async def update_menu_views_dict(
async def init_menu_view(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, gid: int, *, disable: bool = False) -> None:
from MusicBot.ui import MenuView
self.menu_views[gid] = await MenuView(ctx).init(disable=disable)
def generate_response_embed(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*,
disable: bool = False
) -> None:
"""Genereate a new menu view and update the `menu_views` dict. This prevents creating multiple menu views for the same guild.
Use guild id as a key to access menu view.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context
guild (ExplicitGuild): Guild.
disable (bool, optional): Disable menu. Defaults to False.
"""
logging.debug(f"[BASE_BOT] Updating menu views dict for guild {ctx.guild_id}")
from MusicBot.ui import MenuView
embed_type: Literal['info', 'success', 'error'] = 'info',
**kwargs: Any
) -> discord.Embed:
if not ctx.guild_id:
logging.warning("[BASE_BOT] Guild not found")
return
if isinstance(ctx, Interaction):
name = ctx.client.user.name if ctx.client.user else None
icon_url = ctx.client.user.avatar.url if ctx.client.user and ctx.client.user.avatar else None
elif isinstance(ctx, ApplicationContext):
name = ctx.bot.user.name if ctx.bot.user else None
icon_url = ctx.bot.user.avatar.url if ctx.bot.user and ctx.bot.user.avatar else None
elif self.bot:
name = self.bot.user.name if self.bot.user else None
icon_url = self.bot.user.avatar.url if self.bot.user and self.bot.user.avatar else None
else:
name = icon_url = None
if not name:
name = 'YandexMusic'
if not icon_url:
icon_url="https://github.com/Lemon4ksan/YandexMusicDiscordBot/blob/main/assets/Logo.png?raw=true"
if ctx.guild_id in self.menu_views:
self.menu_views[ctx.guild_id].stop()
self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable)
embed = discord.Embed(**kwargs)
embed.set_author(name=name, icon_url=icon_url)
if embed_type == 'info':
embed.color = 0xfed42b
elif embed_type == 'success':
embed.set_author(name = "✅ Успех")
embed.color = discord.Color.green()
else:
embed.set_author(name = "❌ Ошибка")
embed.color = discord.Color.red()
return embed
def get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop:
"""Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance.

View File

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

View File

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

View File

@@ -20,9 +20,6 @@ guilds: AsyncCollection[ExplicitGuild] = db.guilds
class BaseUsersDatabase:
DEFAULT_USER = User(
ym_token=None,
playlists=[],
playlists_page=0,
queue_page=0,
vibe_batch_id=None,
vibe_type=None,
vibe_id=None,
@@ -70,6 +67,16 @@ class BaseUsersDatabase:
)
return cast(str | None, user.get('ym_token') if user else None)
async def reset_vibe_settings(self, uid: int) -> None:
await users.update_one(
{'_id': uid},
{'$set': {'vibe_settings': {
'mood': 'all',
'diversity': 'default',
'lang': 'any'
}}}
)
class BaseGuildsDatabase:
DEFAULT_GUILD = Guild(

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

View File

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

View File

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

View File

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

View File

@@ -78,15 +78,14 @@ MONGO_URI='mongodb://localhost:27017/' # Адрес сервера MongoDB
Запустите бота (`python ./MusicBot/main.py`).
## Запуск в Docker ![Main Build](https://img.shields.io/github/actions/workflow/status/deadcxap/YandexMusicDiscordBot/docker-image.yml?branch=main&label=main) ![Dev Build](https://img.shields.io/github/actions/workflow/status/deadcxap/YandexMusicDiscordBot/docker-image.yml?branch=dev&label=dev)
## Запуск в Docker ![Main Build](https://img.shields.io/github/actions/workflow/status/lemon4ksan/YandexMusicDiscordBot/docker-image.yml?branch=main&label=main) ![Dev Build](https://img.shields.io/github/actions/workflow/status/lemon4ksan/YandexMusicDiscordBot/docker-image.yml?branch=dev&label=dev)
Возможен запуск как из командной строки, так и с помощью docker-compose.
### docker cli
> [!NOTE]
> При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адресс сервера в команде запуска.
> При этом методе запуска вам необходимо самостоятельно установить MongoDB и указать адрес сервера в команде запуска.
```bash
docker run -d \
@@ -96,7 +95,7 @@ docker run -d \
-e EXPLICIT_EID=1325879701117472869 \
-e DEBUG=False \
-e MONGO_URI="mongodb://mongodb:27017/" \
deadcxap/yandexmusicdiscordbot:latest
lemon4ksan/yandexmusicdiscordbot:latest
```
### docker-compose (рекомендованный)
@@ -104,43 +103,6 @@ docker run -d \
> [!NOTE]
> При первом запуске БД и коллекции будут созданы автоматически.
```yaml
---
services:
app:
container_name: yandex-music-discord-bot
image: deadcxap/yandexmusicdiscordbot:latest
restart: unless-stopped
depends_on:
- mongodb
env_file:
- .env
environment:
MONGO_URI: "mongodb://ymdb-mongodb:27017"
networks:
- ymdb_network
mongodb:
container_name: ymdb-mongodb
image: mongo:latest
restart: unless-stopped
volumes:
- mongodb_data:/data/db
- ./init-mongodb.js:/docker-entrypoint-initdb.d/init-mongodb.js:ro
networks:
- ymdb_network
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongo localhost:27017 --quiet
interval: 30s
timeout: 10s
retries: 5
volumes:
mongodb_data:
networks:
ymdb_network:
```
```bash
docker-compose up -d
```

View File

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