impr: Add BaseBot class for code reduction.

This commit is contained in:
Lemon4ksan
2025-03-14 22:01:22 +03:00
parent 8a5ac35d5f
commit a9c938b736
6 changed files with 445 additions and 468 deletions

View File

@@ -9,8 +9,8 @@ from yandex_music.exceptions import UnauthorizedError
from yandex_music import ClientAsync as YMClient from yandex_music import ClientAsync as YMClient
from MusicBot.ui import ListenView from MusicBot.ui import ListenView
from MusicBot.database import BaseUsersDatabase, BaseGuildsDatabase from MusicBot.database import BaseUsersDatabase
from MusicBot.cogs.utils import generate_item_embed from MusicBot.cogs.utils import BaseBot, generate_item_embed
users_db = BaseUsersDatabase() users_db = BaseUsersDatabase()
@@ -22,8 +22,7 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
return [] return []
uid = ctx.interaction.user.id uid = ctx.interaction.user.id
token = await users_db.get_ym_token(uid) if not (token := await users_db.get_ym_token(uid)):
if not token:
logging.info(f"[GENERAL] User {uid} has no token") logging.info(f"[GENERAL] User {uid} has no token")
return [] return []
@@ -33,15 +32,13 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
logging.info(f"[GENERAL] User {uid} provided invalid token") logging.info(f"[GENERAL] User {uid} provided invalid token")
return [] return []
content_type = ctx.options['тип'] if not (search := await client.search(ctx.value)):
search = await client.search(ctx.value)
if not search:
logging.warning(f"[GENERAL] Failed to search for '{ctx.value}' for user {uid}") logging.warning(f"[GENERAL] Failed to search for '{ctx.value}' for user {uid}")
return [] return []
logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}") logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}")
if content_type not in ('Трек', 'Альбом', 'Артист', 'Плейлист'): if (content_type := ctx.options['тип']) not in ('Трек', 'Альбом', 'Артист', 'Плейлист'):
logging.error(f"[GENERAL] Invalid content type '{content_type}' for user {uid}") logging.error(f"[GENERAL] Invalid content type '{content_type}' for user {uid}")
return [] return []
@@ -64,8 +61,7 @@ async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> li
return [] return []
uid = ctx.interaction.user.id uid = ctx.interaction.user.id
token = await users_db.get_ym_token(uid) if not (token := await users_db.get_ym_token(uid)):
if not token:
logging.info(f"[GENERAL] User {uid} has no token") logging.info(f"[GENERAL] User {uid} has no token")
return [] return []
@@ -84,12 +80,10 @@ async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> li
return [playlist.title for playlist in playlists_list if playlist.title and ctx.value in playlist.title][:100] return [playlist.title for playlist in playlists_list if playlist.title and ctx.value in playlist.title][:100]
class General(Cog): class General(Cog, BaseBot):
def __init__(self, bot: discord.Bot): def __init__(self, bot: discord.Bot):
self.bot = bot BaseBot.__init__(self, bot)
self.db = BaseGuildsDatabase()
self.users_db = users_db
account = discord.SlashCommandGroup("account", "Команды, связанные с аккаунтом.") account = discord.SlashCommandGroup("account", "Команды, связанные с аккаунтом.")
@@ -174,9 +168,10 @@ class General(Cog):
await ctx.respond(embed=embed, ephemeral=True) await ctx.respond(embed=embed, ephemeral=True)
@account.command(description="Ввести токен Яндекс Музыки.") @account.command(description="Ввести токен Яндекс Музыки.")
@discord.option("token", type=discord.SlashCommandOptionType.string, description="Токен.") @discord.option("token", type=discord.SlashCommandOptionType.string, description="Токен для доступа к API Яндекс Музыки.")
async def login(self, ctx: discord.ApplicationContext, token: str) -> None: async def login(self, ctx: discord.ApplicationContext, token: str) -> None:
logging.info(f"[GENERAL] Login command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Login command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
try: try:
client = await YMClient(token).init() client = await YMClient(token).init()
except UnauthorizedError: except UnauthorizedError:
@@ -192,35 +187,31 @@ class General(Cog):
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 ctx.respond(f'✅ Привет, {client.me.account.first_name}!', delete_after=15, ephemeral=True)
self._ym_clients[token] = client
logging.info(f"[GENERAL] User {ctx.author.id} logged in successfully") logging.info(f"[GENERAL] User {ctx.author.id} logged in successfully")
@account.command(description="Удалить токен из базы данных бота.") @account.command(description="Удалить токен из базы данных бота.")
async def remove(self, ctx: discord.ApplicationContext) -> None: async def remove(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[GENERAL] Remove command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Remove command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
if not await self.users_db.get_ym_token(ctx.user.id):
if not (token := await self.users_db.get_ym_token(ctx.user.id)):
logging.info(f"[GENERAL] No token found for user {ctx.author.id}") logging.info(f"[GENERAL] No token found for user {ctx.author.id}")
await ctx.respond('❌ Токен не указан.', delete_after=15, ephemeral=True) await ctx.respond('❌ Токен не указан.', delete_after=15, ephemeral=True)
return return
if token in self._ym_clients:
del self._ym_clients[token]
await self.users_db.update(ctx.user.id, {'ym_token': None}) await self.users_db.update(ctx.user.id, {'ym_token': None})
await ctx.respond(f'✅ Токен был удалён.', delete_after=15, ephemeral=True)
logging.info(f"[GENERAL] Token removed for user {ctx.author.id}") logging.info(f"[GENERAL] Token removed for user {ctx.author.id}")
await ctx.respond(f'✅ Токен был удалён.', 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:
logging.info(f"[GENERAL] Likes command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Likes command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
token = await self.users_db.get_ym_token(ctx.user.id) if not (client := await self.init_ym_client(ctx)):
if not token:
logging.info(f"[GENERAL] No token found for user {ctx.user.id}")
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return
try:
client = await YMClient(token).init()
except UnauthorizedError:
logging.info(f"[GENERAL] Invalid token for user {ctx.user.id}")
await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True)
return return
try: try:
@@ -262,16 +253,7 @@ class General(Cog):
# NOTE: Recommendations can be accessed by using /find, but it's more convenient to have it in separate command. # NOTE: Recommendations can be accessed by using /find, but it's more convenient to have it in separate command.
logging.debug(f"[GENERAL] Recommendations command invoked by user {ctx.user.id} in guild {ctx.guild_id} for type '{content_type}'") logging.debug(f"[GENERAL] Recommendations command invoked by user {ctx.user.id} in guild {ctx.guild_id} for type '{content_type}'")
token = await self.users_db.get_ym_token(ctx.user.id) if not (client := await self.init_ym_client(ctx)):
if not token:
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return
try:
client = await YMClient(token).init()
except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token")
await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True)
return return
search = await client.search(content_type, type_='playlist') search = await client.search(content_type, type_='playlist')
@@ -280,13 +262,11 @@ class General(Cog):
await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True)
return return
playlist = search.playlists.results[0] if (playlist := search.playlists.results[0]) is None:
if playlist 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 ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True)
tracks = await playlist.fetch_tracks_async() if not await playlist.fetch_tracks_async():
if not tracks:
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 ctx.respond("❌ Пустой плейлист.", delete_after=15, ephemeral=True)
return return
@@ -304,17 +284,7 @@ class General(Cog):
async def playlist(self, ctx: discord.ApplicationContext, name: str) -> None: async def playlist(self, ctx: discord.ApplicationContext, name: str) -> None:
logging.info(f"[GENERAL] Playlist command invoked by user {ctx.user.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Playlist command invoked by user {ctx.user.id} in guild {ctx.guild_id}")
token = await self.users_db.get_ym_token(ctx.user.id) if not (client := await self.init_ym_client(ctx)):
if not token:
logging.info(f"[GENERAL] No token found for user {ctx.user.id}")
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return
try:
client = await YMClient(token).init()
except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token")
await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True)
return return
try: try:
@@ -324,14 +294,12 @@ class General(Cog):
await ctx.respond("❌ Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True) await ctx.respond("❌ Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True)
return return
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)):
if not playlist:
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 ctx.respond("❌ Плейлист не найден.", delete_after=15, ephemeral=True)
return return
tracks = await playlist.fetch_tracks_async() if not await playlist.fetch_tracks_async():
if not tracks:
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 ctx.respond("❌ Плейлист пуст.", delete_after=15, ephemeral=True)
return return
@@ -361,21 +329,10 @@ class General(Cog):
) -> None: ) -> None:
logging.info(f"[GENERAL] Find command invoked by user {ctx.user.id} in guild {ctx.guild_id} for '{content_type}' with name '{name}'") logging.info(f"[GENERAL] Find command invoked by user {ctx.user.id} in guild {ctx.guild_id} for '{content_type}' with name '{name}'")
token = await self.users_db.get_ym_token(ctx.user.id) if not (client := await self.init_ym_client(ctx)):
if not token:
logging.info(f"[GENERAL] No token found for user {ctx.user.id}")
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return return
try: if not (search_result := await client.search(name, nocorrect=True)):
client = await YMClient(token).init()
except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token")
await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True)
return
search_result = await client.search(name, nocorrect=True)
if not search_result:
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 ctx.respond("❌ Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True)
return return

View File

@@ -1,8 +1,9 @@
from .embeds import generate_item_embed from .embeds import generate_item_embed
from .voice_extension import VoiceExtension, menu_views from .voice_extension import VoiceExtension
from .base_bot import BaseBot
__all__ = [ __all__ = [
"generate_item_embed", "generate_item_embed",
"VoiceExtension", "VoiceExtension",
"menu_views" "BaseBot"
] ]

View File

@@ -0,0 +1,177 @@
import asyncio
import logging
from typing import Any, Literal, cast
import yandex_music.exceptions
from yandex_music import ClientAsync as YMClient
import discord
from discord.ui import View
from discord import Interaction, ApplicationContext, RawReactionActionEvent
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase
class BaseBot:
menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks.
_ym_clients: dict[str, YMClient] = {} # Store YM clients to prevent creating new ones for each command.
def __init__(self, bot: discord.Bot | None) -> None:
self.bot = bot
self.db = VoiceGuildsDatabase()
self.users_db = BaseUsersDatabase()
async def init_ym_client(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
token: str | None = None
) -> YMClient | None:
"""Initialize Yandex Music client. Return client on success. Return None if no token found and respond to the context.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
token (str | None, optional): Token. Fetched from database if not provided. Defaults to None.
Returns:
(YMClient | None): Client or None.
"""
logging.debug("[VC_EXT] Initializing Yandex Music client")
if not token:
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
token = await self.users_db.get_ym_token(uid) if uid else None
if not token:
logging.debug("[VC_EXT] No token found")
await self.send_response_message(ctx, "❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return None
try:
if token in self._ym_clients:
client = self._ym_clients[token]
await client.account_status()
return client
client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError:
del self._ym_clients[token]
await self.send_response_message(ctx, "❌ Недействительный токен. Обновите его с помощью /account login.", ephemeral=True, delete_after=15)
return None
self._ym_clients[token] = client
return client
async def send_response_message(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
content: str | None = None,
*,
delete_after: float | None = None,
ephemeral: bool = False,
view: discord.ui.View | None = None,
embed: discord.Embed | None = None
) -> discord.Interaction | discord.WebhookMessage | discord.Message | None:
"""Send response message based on context type. self.bot must be set in order to use RawReactionActionEvent context type.
RawReactionActionEvent can't be ephemeral.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
content (str): Message content to send.
delete_after (float | None, optional): Time after which the message will be deleted. Defaults to None.
ephemeral (bool, optional): Whether the message is ephemeral. Defaults to False.
view (discord.ui.View | None, optional): Discord view. Defaults to None.
embed (discord.Embed | None, optional): Discord embed. Defaults to None.
Returns:
(discord.InteractionMessage | discord.WebhookMessage | discord.Message | None): Message or None. Type depends on the context type.
"""
if not isinstance(ctx, RawReactionActionEvent):
return await ctx.respond(content, delete_after=delete_after, ephemeral=ephemeral, view=view, embed=embed)
elif self.bot:
channel = self.bot.get_channel(ctx.channel_id)
if isinstance(channel, (discord.abc.Messageable)):
return await channel.send(content, delete_after=delete_after, view=view, embed=embed) # type: ignore
return None
async def get_message_by_id(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
message_id: int
) -> discord.Message | None:
"""Get message by id based on context type. self.bot must be set in order to use RawReactionActionEvent context type.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
message_id (int): Message id.
Returns:
(discord.Message | None): Message or None.
Raises:
ValueError: Bot instance is not set.
discord.DiscordException: Failed to get message.
"""
try:
if isinstance(ctx, ApplicationContext):
return await ctx.fetch_message(message_id)
elif isinstance(ctx, Interaction):
return ctx.client.get_message(message_id)
elif not self.bot:
raise ValueError("Bot instance is not set.")
else:
return self.bot.get_message(message_id)
except discord.DiscordException as e:
logging.debug(f"[BASE_BOT] Failed to get message: {e}")
raise
async def update_menu_views_dict(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*,
disable: bool = False
) -> None:
"""Genereate a new menu view and update the `menu_views` dict. This prevents creating multiple menu views for the same guild.
Use guild id as a key to access menu view.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context
guild (ExplicitGuild): Guild.
disable (bool, optional): Disable menu. Defaults to False.
"""
logging.debug(f"[VC_EXT] Updating menu views dict for guild {ctx.guild_id}")
from MusicBot.ui import MenuView
if not ctx.guild_id:
logging.warning("[VC_EXT] Guild not found")
return
if ctx.guild_id in self.menu_views:
self.menu_views[ctx.guild_id].stop()
self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable)
def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop:
"""Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
Raises:
TypeError: If the context is not a RawReactionActionEvent, ApplicationContext or Interaction.
ValueError: If the context is a RawReactionActionEvent and the bot is not set.
Returns:
asyncio.AbstractEventLoop: Current event loop.
"""
if isinstance(ctx, Interaction):
return ctx.client.loop
elif isinstance(ctx, ApplicationContext):
return ctx.bot.loop
elif isinstance(ctx, RawReactionActionEvent):
if not self.bot:
raise ValueError("Bot is not set.")
return self.bot.loop
else:
raise TypeError(f"Invalid context type: '{type(ctx).__name__}'.")

View File

@@ -8,20 +8,16 @@ 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.ui import View
from discord import Interaction, ApplicationContext, RawReactionActionEvent, VoiceChannel from discord import Interaction, ApplicationContext, RawReactionActionEvent, VoiceChannel
from MusicBot.cogs.utils.base_bot import BaseBot
from MusicBot.cogs.utils import generate_item_embed from MusicBot.cogs.utils import generate_item_embed
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase, ExplicitGuild, MessageVotes from MusicBot.database import ExplicitGuild, MessageVotes
menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks. class VoiceExtension(BaseBot):
class VoiceExtension:
def __init__(self, bot: discord.Bot | None) -> None: def __init__(self, bot: discord.Bot | None) -> None:
self.bot = bot super().__init__(bot)
self.db = VoiceGuildsDatabase()
self.users_db = BaseUsersDatabase()
async def send_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *, disable: bool = False) -> bool: async def send_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *, disable: bool = False) -> bool:
"""Send menu message to the channel and delete old one if exists. Return True if sent. """Send menu message to the channel and delete old one if exists. Return True if sent.
@@ -30,16 +26,16 @@ class VoiceExtension:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
disable (bool, optional): Disable menu message buttons. Defaults to False. disable (bool, optional): Disable menu message buttons. Defaults to False.
Raises:
ValueError: If bot instance is not set and ctx is RawReactionActionEvent.
Returns: Returns:
bool: True if sent, False if not. bool: True if sent, False if not.
Raises:
ValueError: If bot instance is not set and ctx is RawReactionActionEvent.
""" """
logging.info(f"[VC_EXT] Sending menu message to channel {ctx.channel_id} in guild {ctx.guild_id}") logging.info(f"[VC_EXT] Sending menu message to channel {ctx.channel_id} in guild {ctx.guild_id}")
if not ctx.guild_id: if not ctx.guild_id:
logging.warning("[VC_EXT] Guild id not found in context inside 'create_menu'") logging.warning("[VC_EXT] Guild id not found in context")
return False return False
guild = await self.db.get_guild(ctx.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vibing': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vibing': 1})
@@ -65,28 +61,17 @@ class VoiceExtension:
if (message := await self.get_menu_message(ctx, guild['current_menu'])): if (message := await self.get_menu_message(ctx, guild['current_menu'])):
await message.delete() await message.delete()
await self._update_menu_views_dict(ctx, disable=disable) await self.update_menu_views_dict(ctx, disable=disable)
if isinstance(ctx, (ApplicationContext, Interaction)):
interaction = await ctx.respond(view=menu_views[ctx.guild_id], embed=embed)
elif not self.bot:
raise ValueError("Bot instance is not set.")
elif not (channel := self.bot.get_channel(ctx.channel_id)):
logging.warning(f"[VC_EXT] Channel {ctx.channel_id} not found in guild {ctx.guild_id}")
return False
elif isinstance(channel, discord.VoiceChannel):
interaction = await channel.send(
view=menu_views[ctx.guild_id],
embed=embed # type: ignore # Wrong typehints.
)
else:
logging.warning(f"[VC_EXT] Channel {ctx.channel_id} is not a voice channel in guild {ctx.guild_id}")
return False
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
await self.db.update(ctx.guild_id, {'current_menu': response.id})
logging.info(f"[VC_EXT] New menu message {response.id} created in guild {ctx.guild_id}") if response:
await self.db.update(ctx.guild_id, {'current_menu': response.id})
logging.info(f"[VC_EXT] New menu message {response.id} created in guild {ctx.guild_id}")
else:
logging.warning(f"[VC_EXT] Failed to save menu message id. Invalid response.")
return True return True
async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, menu_mid: int) -> discord.Message | None: async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, menu_mid: int) -> discord.Message | None:
@@ -107,18 +92,9 @@ class VoiceExtension:
return None return None
try: try:
if isinstance(ctx, ApplicationContext): menu = await self.get_message_by_id(ctx, menu_mid)
menu = await ctx.fetch_message(menu_mid) except discord.DiscordException:
elif isinstance(ctx, Interaction): menu = None
menu = ctx.client.get_message(menu_mid)
elif not self.bot:
raise ValueError("Bot instance is not set.")
else:
menu = self.bot.get_message(menu_mid)
except discord.DiscordException as e:
logging.debug(f"[VC_EXT] Failed to get menu message: {e}")
await self.db.update(ctx.guild_id, {'current_menu': None})
return None
if not menu: if not menu:
logging.debug(f"[VC_EXT] Menu message {menu_mid} not found in guild {ctx.guild_id}") logging.debug(f"[VC_EXT] Menu message {menu_mid} not found in guild {ctx.guild_id}")
@@ -128,7 +104,7 @@ class VoiceExtension:
logging.debug(f"[VC_EXT] Menu message {menu_mid} successfully fetched") logging.debug(f"[VC_EXT] Menu message {menu_mid} successfully fetched")
return menu return menu
async def update_menu_full( async def update_menu_embed_and_view(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*, *,
@@ -151,17 +127,15 @@ class VoiceExtension:
"interaction context" if isinstance(ctx, Interaction) else "interaction context" if isinstance(ctx, Interaction) else
"application context" if isinstance(ctx, ApplicationContext) else "application context" if isinstance(ctx, ApplicationContext) else
"raw reaction context" "raw reaction context"
) ))
)
gid = ctx.guild_id
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid: if not ctx.guild_id or not uid:
logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'update_menu_embed'") logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'update_menu_embed'")
return False return False
guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1})
if not guild['current_menu']: if not guild['current_menu']:
logging.debug("[VC_EXT] No current menu found") logging.debug("[VC_EXT] No current menu found")
return False return False
@@ -180,8 +154,7 @@ class VoiceExtension:
)) ))
embed = await generate_item_embed(track, guild['vibing']) embed = await generate_item_embed(track, guild['vibing'])
vc = await self.get_voice_client(ctx) if not (vc := await self.get_voice_client(ctx)):
if not vc:
logging.warning("[VC_EXT] Voice client not found") logging.warning("[VC_EXT] Voice client not found")
return False return False
@@ -190,16 +163,16 @@ class VoiceExtension:
else: else:
embed.remove_footer() embed.remove_footer()
await self._update_menu_views_dict(ctx) await self.update_menu_views_dict(ctx)
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(embed=embed, view=menu_views[gid]) await ctx.edit(embed=embed, view=self.menu_views[ctx.guild_id])
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(embed=embed, view=menu_views[gid]) await menu_message.edit(embed=embed, view=self.menu_views[ctx.guild_id])
except discord.NotFound: except discord.DiscordException as e:
logging.warning("[VC_EXT] Menu message not found") logging.warning(f"[VC_EXT] Error while updating menu message: {e}")
return False return False
logging.debug("[VC_EXT] Menu embed updated successfully") logging.debug("[VC_EXT] Menu embed updated successfully")
@@ -231,24 +204,26 @@ class VoiceExtension:
logging.warning("[VC_EXT] Guild ID not found in context inside 'update_menu_view'") logging.warning("[VC_EXT] Guild ID not found in context inside 'update_menu_view'")
return False return False
guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1}) if not menu_message:
if not guild['current_menu']: guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1})
return False if not guild['current_menu']:
return False
menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message
menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message
if not menu_message: if not menu_message:
return False return False
await self._update_menu_views_dict(ctx, disable=disable) await self.update_menu_views_dict(ctx, 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=menu_views[ctx.guild_id]) await ctx.edit(view=self.menu_views[ctx.guild_id])
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=menu_views[ctx.guild_id]) await menu_message.edit(view=self.menu_views[ctx.guild_id])
except discord.NotFound: except discord.DiscordException as e:
logging.warning("[VC_EXT] Menu message not found") logging.warning(f"[VC_EXT] Error while updating menu view: {e}")
return False return False
logging.debug("[VC_EXT] Menu view updated successfully") logging.debug("[VC_EXT] Menu view updated successfully")
@@ -257,8 +232,8 @@ class VoiceExtension:
async def update_vibe( async def update_vibe(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
type: str, vibe_type: str,
id: str | int, item_id: str | int,
*, *,
viber_id: int | None = None, viber_id: int | None = None,
update_settings: bool = False update_settings: bool = False
@@ -268,28 +243,26 @@ class VoiceExtension:
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
type (str): Type of the item. vibe_type (str): Type of the item.
id (str | int): ID of the item. item_id (str | int): ID of the item.
viber_id (int | None, optional): ID of the user who started vibe. If None, uses user id in context. Defaults to None. viber_id (int | None, optional): ID of the user who started vibe. If None, uses user id in context. Defaults to None.
update_settings (bool, optional): Update vibe settings by sending feedack usind data from database. Defaults to False. update_settings (bool, optional): Update vibe settings by sending feedack usind data from database. Defaults to False.
Returns: Returns:
bool: True if vibe was updated successfully. False otherwise. bool: True if vibe was updated successfully. False otherwise.
""" """
logging.info(f"[VC_EXT] Updating vibe for guild {ctx.guild_id} with type '{type}' and id '{id}'") logging.info(f"[VC_EXT] Updating vibe for guild {ctx.guild_id} with type '{vibe_type}' and id '{item_id}'")
gid = ctx.guild_id
uid = viber_id if viber_id else ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None uid = viber_id if viber_id else ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not uid or not gid: if not uid or not ctx.guild_id:
logging.warning("[VC_EXT] Guild ID or User ID not found in context") logging.warning("[VC_EXT] Guild ID or User ID not found in context")
return False return False
user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_settings': 1}) user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_settings': 1})
guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_track': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_track': 1})
client = await self.init_ym_client(ctx, user['ym_token'])
if not client: if not (client := await self.init_ym_client(ctx, user['ym_token'])):
return False return False
if update_settings: if update_settings:
@@ -297,7 +270,7 @@ class VoiceExtension:
settings = user['vibe_settings'] settings = user['vibe_settings']
await client.rotor_station_settings2( await client.rotor_station_settings2(
f"{type}:{id}", f"{vibe_type}:{item_id}",
mood_energy=settings['mood'], mood_energy=settings['mood'],
diversity=settings['diversity'], diversity=settings['diversity'],
language=settings['lang'] language=settings['lang']
@@ -306,7 +279,7 @@ class VoiceExtension:
if not guild['vibing']: if not guild['vibing']:
try: try:
feedback = await client.rotor_station_feedback_radio_started( feedback = await client.rotor_station_feedback_radio_started(
f"{type}:{id}", f"{vibe_type}:{item_id}",
f"desktop-user-{client.me.account.uid}", # type: ignore # That's made up, but it doesn't do much anyway. f"desktop-user-{client.me.account.uid}", # type: ignore # That's made up, but it doesn't do much anyway.
) )
except yandex_music.exceptions.BadRequestError as e: except yandex_music.exceptions.BadRequestError as e:
@@ -314,11 +287,11 @@ class VoiceExtension:
return False return False
if not feedback: if not feedback:
logging.warning(f"[VIBE] Failed to start radio '{type}:{id}'") logging.warning(f"[VIBE] Failed to start radio '{vibe_type}:{item_id}'")
return False return False
tracks = await client.rotor_station_tracks( tracks = await client.rotor_station_tracks(
f"{type}:{id}", f"{vibe_type}:{item_id}",
queue=guild['current_track']['id'] if guild['current_track'] else None # type: ignore queue=guild['current_track']['id'] if guild['current_track'] else None # type: ignore
) )
@@ -330,11 +303,11 @@ class VoiceExtension:
logging.debug(f"[VIBE] Got next vibe tracks: {[track.title for track in next_tracks]}") logging.debug(f"[VIBE] Got next vibe tracks: {[track.title for track in next_tracks]}")
await self.users_db.update(uid, { await self.users_db.update(uid, {
'vibe_type': type, 'vibe_type': vibe_type,
'vibe_id': id, 'vibe_id': item_id,
'vibe_batch_id': tracks.batch_id 'vibe_batch_id': tracks.batch_id
}) })
await self.db.update(gid, { await self.db.update(ctx.guild_id, {
'next_tracks': [track.to_dict() for track in next_tracks], 'next_tracks': [track.to_dict() for track in next_tracks],
'current_viber_id': uid, 'current_viber_id': uid,
'vibing': True 'vibing': True
@@ -352,9 +325,14 @@ class VoiceExtension:
Returns: Returns:
bool: Check result. bool: Check result.
""" """
if not ctx.user or not ctx.guild_id: if not ctx.user:
logging.warning("[VC_EXT] User or guild id 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 ctx.respond("Пользователь не найден.", delete_after=15, ephemeral=True)
return False
if not ctx.guild_id:
logging.info("[VC_EXT] Guild id not found in context inside 'voice_check'")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True)
return False return False
if not await self.users_db.get_ym_token(ctx.user.id): if not await self.users_db.get_ym_token(ctx.user.id):
@@ -400,20 +378,16 @@ class VoiceExtension:
if isinstance(ctx, (Interaction, ApplicationContext)): if isinstance(ctx, (Interaction, ApplicationContext)):
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
guild = ctx.guild guild = ctx.guild
elif isinstance(ctx, RawReactionActionEvent): elif not self.bot:
if not self.bot: raise ValueError("Bot instance is not set.")
raise ValueError("Bot instance is not set.") elif not ctx.guild_id:
if not ctx.guild_id: logging.warning("[VC_EXT] Guild ID not found in context")
logging.warning("[VC_EXT] Guild ID not found in context inside 'get_voice_client'") return None
return None else:
voice_clients = self.bot.voice_clients voice_clients = self.bot.voice_clients
guild = await self.bot.fetch_guild(ctx.guild_id) guild = await self.bot.fetch_guild(ctx.guild_id)
else:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.")
voice_client = discord.utils.get(voice_clients, guild=guild) if (voice_client := discord.utils.get(voice_clients, guild=guild)):
if voice_client:
logging.debug("[VC_EXT] Voice client found") logging.debug("[VC_EXT] Voice client found")
else: else:
logging.debug("[VC_EXT] Voice client not found") logging.debug("[VC_EXT] Voice client not found")
@@ -484,34 +458,32 @@ class VoiceExtension:
""" """
logging.debug("[VC_EXT] Stopping playback") logging.debug("[VC_EXT] Stopping playback")
gid = ctx.guild_id if not ctx.guild_id:
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid:
logging.warning("[VC_EXT] Guild ID not found in context") logging.warning("[VC_EXT] Guild ID not found in context")
return False return False
guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'current_track': 1, 'vibing': 1})
vc = await self.get_voice_client(ctx) if not vc else vc vc = await self.get_voice_client(ctx) if not vc else vc
if not vc: if not vc:
return False return False
await self.db.update(gid, {'current_track': None, 'is_stopped': True}) await self.db.update(ctx.guild_id, {'current_track': None, 'is_stopped': True})
vc.stop() vc.stop()
if full: if full:
guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1, 'current_track': 1, 'vibing': 1})
if guild['vibing'] and guild['current_track']: if guild['vibing'] and guild['current_track']:
await self.send_vibe_feedback(ctx, 'trackFinished', guild['current_track']) await self.send_vibe_feedback(ctx, 'trackFinished', guild['current_track'])
await self.db.update(ctx.guild_id, {
'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'vibing': False
})
if not guild['current_menu']: if guild['current_menu']:
return True return await self._delete_menu_message(ctx, guild['current_menu'], ctx.guild_id)
return await self._full_stop(ctx, guild['current_menu'], gid)
return True return True
async def next_track( async def play_next_track(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
@@ -537,44 +509,38 @@ class VoiceExtension:
""" """
logging.debug("[VC_EXT] Switching to next track") logging.debug("[VC_EXT] Switching to next track")
gid = ctx.guild_id
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid: if not ctx.guild_id or not uid:
logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'") 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(gid, projection={'shuffle': 1, 'repeat': 1, 'is_stopped': 1, 'current_menu': 1, 'vibing': 1, 'current_track': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'shuffle': 1, 'repeat': 1, 'is_stopped': 1, 'current_menu': 1, 'vibing': 1, 'current_track': 1})
user = await self.users_db.get_user(uid) user = await self.users_db.get_user(uid)
if guild['is_stopped'] and after: if guild['is_stopped'] and after:
logging.debug("[VC_EXT] Playback is stopped, skipping after callback.") logging.debug("[VC_EXT] Playback is stopped, skipping after callback.")
return None return None
if guild['current_track'] and guild['current_menu'] and not guild['repeat']: if guild['current_track'] and not guild['repeat']:
logging.debug("[VC_EXT] Adding current track to history") logging.debug("[VC_EXT] Adding current track to history")
await self.db.modify_track(gid, guild['current_track'], 'previous', 'insert') await self.db.modify_track(ctx.guild_id, guild['current_track'], 'previous', 'insert')
if after and guild['current_menu']: if after and not await self.update_menu_view(ctx, menu_message=menu_message, disable=True):
await self.update_menu_view(ctx, menu_message=menu_message, disable=True) await self.send_response_message(ctx, "Не удалось обновить меню.", ephemeral=True, delete_after=15)
if guild['vibing'] and guild['current_track']: if guild['vibing'] and guild['current_track']:
if not 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'])
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond("Не удалось отправить отчёт об оконачнии Моей Волны.", ephemeral=True, delete_after=15)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send("Не удалось отправить отчёт об оконачнии Моей Волны.", delete_after=15)
if guild['repeat'] and after: if guild['repeat'] and after:
logging.debug("[VC_EXT] Repeating current track") logging.debug("[VC_EXT] Repeating current track")
next_track = guild['current_track'] next_track = guild['current_track']
elif guild['shuffle']: elif guild['shuffle']:
logging.debug("[VC_EXT] Getting random track from queue") logging.debug("[VC_EXT] Getting random track from queue")
next_track = await self.db.pop_random_track(gid, 'next') next_track = await self.db.pop_random_track(ctx.guild_id, 'next')
else: else:
logging.debug("[VC_EXT] Getting next track from queue") logging.debug("[VC_EXT] Getting next track from queue")
next_track = await self.db.get_track(gid, 'next') next_track = await self.db.get_track(ctx.guild_id, 'next')
if not next_track and guild['vibing']: if not next_track and guild['vibing']:
logging.debug("[VC_EXT] No next track found, generating new vibe") logging.debug("[VC_EXT] No next track found, generating new vibe")
@@ -583,7 +549,7 @@ class VoiceExtension:
return None return None
await self.update_vibe(ctx, user['vibe_type'], user['vibe_id']) await self.update_vibe(ctx, user['vibe_type'], user['vibe_id'])
next_track = await self.db.get_track(gid, 'next') next_track = await self.db.get_track(ctx.guild_id, 'next')
if next_track: if next_track:
title = await self.play_track(ctx, next_track, client=client, vc=vc, button_callback=button_callback) title = await self.play_track(ctx, next_track, client=client, vc=vc, button_callback=button_callback)
@@ -602,11 +568,11 @@ class VoiceExtension:
logging.info("[VC_EXT] No next track found") logging.info("[VC_EXT] No next track found")
if after: if after:
await self.db.update(gid, {'is_stopped': True, 'current_track': None}) await self.db.update(ctx.guild_id, {'is_stopped': True, 'current_track': None})
return None return None
async def previous_track(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, button_callback: bool = False) -> str | None: async def play_previous_track(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, button_callback: bool = False) -> str | None:
"""Switch to the previous track in the queue. Repeat current track if no previous one found. """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.
@@ -619,15 +585,14 @@ class VoiceExtension:
""" """
logging.debug("[VC_EXT] Switching to previous track") logging.debug("[VC_EXT] Switching to previous track")
gid = ctx.guild_id
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid: if not ctx.guild_id or not uid:
logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'") logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'")
return None return None
current_track = await self.db.get_track(gid, 'current') current_track = await self.db.get_track(ctx.guild_id, 'current')
prev_track = await self.db.get_track(gid, 'previous') prev_track = await self.db.get_track(ctx.guild_id, 'previous')
if prev_track: if prev_track:
logging.debug("[VC_EXT] Previous track found") logging.debug("[VC_EXT] Previous track found")
@@ -644,34 +609,32 @@ class VoiceExtension:
return None return None
async def get_likes(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort] | None: async def get_liked_tracks(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort]:
"""Get liked tracks. Return list of tracks on success. Return None if no token found. """Get liked tracks from Yandex Music. Return list of tracks on success.
Return empty list if no likes found or error occurred.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
Returns: Returns:
(list[Track] | None): List of tracks or None. list[Track]: List of tracks.
""" """
logging.info("[VC_EXT] Getting liked tracks") logging.info("[VC_EXT] Getting liked tracks")
if not ctx.guild_id: if not ctx.guild_id:
logging.warning("Guild ID not found in context inside 'get_likes'") logging.warning("Guild ID not found in context")
return None return []
client = await self.init_ym_client(ctx)
if not await self.db.get_track(ctx.guild_id, 'current'): if not await self.db.get_track(ctx.guild_id, 'current'):
logging.debug("[VC_EXT] Current track not found in 'get_likes'") logging.debug("[VC_EXT] Current track not found. Likes can't be fetched")
return None return []
if not client: if not (client := await self.init_ym_client(ctx)):
return None return []
likes = await client.users_likes_tracks() if not (likes := await client.users_likes_tracks()):
if not likes:
logging.info("[VC_EXT] No likes found") logging.info("[VC_EXT] No likes found")
return None return []
return likes.tracks return likes.tracks
@@ -724,48 +687,6 @@ class VoiceExtension:
logging.debug(f"[VC_EXT] Track found in {action}s. Removing...") logging.debug(f"[VC_EXT] Track found in {action}s. Removing...")
await remove_func(current_track['id']) await remove_func(current_track['id'])
return (True, 'removed') return (True, 'removed')
async def init_ym_client(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, token: str | None = None) -> YMClient | None:
"""Initialize Yandex Music client. Return client on success. Return None if no token found and respond to the context.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
token (str | None, optional): Token. Fetched from database if not provided. Defaults to None.
Returns:
(YMClient | None): Client or None.
"""
logging.debug("[VC_EXT] Initializing Yandex Music client")
if not token:
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
token = await self.users_db.get_ym_token(uid) if uid else None
if not token:
logging.debug("No token found in 'init_ym_client'")
if not isinstance(ctx, discord.RawReactionActionEvent):
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return None
if not hasattr(self, '_ym_clients'):
self._ym_clients: dict[str, YMClient] = {}
if token in self._ym_clients:
client = self._ym_clients[token]
try:
await client.account_status()
return client
except yandex_music.exceptions.UnauthorizedError:
del self._ym_clients[token]
return None
try:
client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError:
logging.debug("UnauthorizedError in 'init_ym_client'")
return None
self._ym_clients[token] = client
return client
async def proccess_vote(self, ctx: RawReactionActionEvent, guild: ExplicitGuild, channel: VoiceChannel, vote_data: MessageVotes) -> bool: async def proccess_vote(self, ctx: RawReactionActionEvent, guild: ExplicitGuild, channel: VoiceChannel, vote_data: MessageVotes) -> bool:
"""Proccess vote and perform action from `vote_data` and respond. Return True on success. """Proccess vote and perform action from `vote_data` and respond. Return True on success.
@@ -787,14 +708,16 @@ class VoiceExtension:
if not guild['current_menu'] and not await self.send_menu_message(ctx): if not guild['current_menu'] and not await self.send_menu_message(ctx):
await channel.send(content=f"Не удалось отправить меню! Попробуйте ещё раз.", delete_after=15) await channel.send(content=f"Не удалось отправить меню! Попробуйте ещё раз.", delete_after=15)
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 channel.send(content=f"❌ Очередь пуста!", delete_after=15)
elif not (await self.next_track(ctx) if vote_data['action'] == 'next' else await self.previous_track(ctx)): elif not (await self.play_next_track(ctx) if vote_data['action'] == 'next' else await self.play_previous_track(ctx)):
await channel.send(content=f"❌ Ошибка при смене трека! Попробуйте ещё раз.", delete_after=15) await channel.send(content=f"❌ Ошибка при смене трека! Попробуйте ещё раз.", delete_after=15)
return False
elif vote_data['action'] == 'add_track': elif vote_data['action'] == 'add_track':
if not vote_data['vote_content']: if not vote_data['vote_content']:
@@ -805,9 +728,9 @@ class VoiceExtension:
if guild['current_track']: if guild['current_track']:
await channel.send(content=f"✅ Трек был добавлен в очередь!", delete_after=15) await channel.send(content=f"✅ Трек был добавлен в очередь!", delete_after=15)
else: elif not await self.play_next_track(ctx):
if not await self.next_track(ctx): await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15)
await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) return False
elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'): elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'):
if not vote_data['vote_content']: if not vote_data['vote_content']:
@@ -819,9 +742,9 @@ class VoiceExtension:
if guild['current_track']: if guild['current_track']:
await channel.send(content=f"✅ Контент был добавлен в очередь!", delete_after=15) await channel.send(content=f"✅ Контент был добавлен в очередь!", delete_after=15)
else: elif not await self.play_next_track(ctx):
if not await self.next_track(ctx): await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15)
await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15) 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)):
@@ -833,7 +756,7 @@ class VoiceExtension:
else: else:
vc.resume() vc.resume()
await self.update_menu_full(ctx) await self.update_menu_embed_and_view(ctx)
elif vote_data['action'] in ('repeat', 'shuffle'): elif vote_data['action'] in ('repeat', 'shuffle'):
await self.db.update(guild['_id'], {vote_data['action']: not guild[vote_data['action']]}) await self.db.update(guild['_id'], {vote_data['action']: not guild[vote_data['action']]})
@@ -844,26 +767,25 @@ class VoiceExtension:
await channel.send("✅ Очередь и история сброшены.", delete_after=15) await channel.send("✅ Очередь и история сброшены.", delete_after=15)
elif vote_data['action'] == 'stop': elif vote_data['action'] == 'stop':
res = await self.stop_playing(ctx, full=True) if await self.stop_playing(ctx, full=True):
if res:
await channel.send("✅ Воспроизведение остановлено.", delete_after=15) await channel.send("✅ Воспроизведение остановлено.", delete_after=15)
else: else:
await channel.send("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15) await channel.send("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15)
return False
elif vote_data['action'] == 'vibe_station': elif vote_data['action'] == 'vibe_station':
_type, _id, viber_id = vote_data['vote_content'] if isinstance(vote_data['vote_content'], list) else (None, None, None) vibe_type, vibe_id, viber_id = vote_data['vote_content'] if isinstance(vote_data['vote_content'], list) else (None, None, None)
if not _type or not _id or not viber_id: if not vibe_type or not vibe_id or not viber_id:
logging.warning(f"[VOICE] Recieved empty vote context for message {ctx.message_id}") logging.warning(f"[VOICE] Recieved empty vote context for message {ctx.message_id}")
await channel.send("❌ Произошла ошибка при обновлении станции.", delete_after=15) await channel.send("❌ Произошла ошибка при обновлении станции.", delete_after=15)
return False return False
if not await self.update_vibe(ctx, _type, _id, viber_id=viber_id): if not await self.update_vibe(ctx, vibe_type, vibe_id, viber_id=viber_id):
await channel.send("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15) await channel.send("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15)
return False return False
next_track = await self.db.get_track(ctx.guild_id, 'next') if (next_track := await self.db.get_track(ctx.guild_id, 'next')):
if next_track:
await self.play_track(ctx, next_track) await self.play_track(ctx, next_track)
else: else:
await channel.send("Не удалось воспроизвести трек.", delete_after=15) await channel.send("Не удалось воспроизвести трек.", delete_after=15)
@@ -908,14 +830,14 @@ class VoiceExtension:
client = await self.init_ym_client(ctx, user['ym_token']) client = await self.init_ym_client(ctx, user['ym_token'])
if not client: if not client:
logging.info(f"[VC_EXT] Failed to init YM client for user {user['_id']}") logging.info(f"[VC_EXT] Failed to init YM client for user {user['_id']}")
if not isinstance(ctx, RawReactionActionEvent): await self.send_response_message(ctx, "❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True)
await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15)
return False return False
total_play_seconds = track['duration_ms'] // 1000 if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms'] else None if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms']:
total_play_seconds = track['duration_ms'] // 1000
else:
total_play_seconds = None
try: try:
feedback = await client.rotor_station_feedback( feedback = await client.rotor_station_feedback(
f'{user['vibe_type']}:{user['vibe_id']}', f'{user['vibe_type']}:{user['vibe_id']}',
@@ -930,32 +852,6 @@ class VoiceExtension:
logging.info(f"[VC_EXT] Sent vibe feedback type '{feedback_type}' with result: {feedback}") logging.info(f"[VC_EXT] Sent vibe feedback type '{feedback_type}' with result: {feedback}")
return feedback return feedback
async def _update_menu_views_dict(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*,
disable: bool = False
) -> None:
"""Genereate a new menu view and update the `menu_views` dict. This prevents creating multiple menu views for the same guild.
Use guild id as a key to access menu view.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context
guild (ExplicitGuild): Guild.
disable (bool, optional): Disable menu. Defaults to False.
"""
logging.debug(f"[VC_EXT] Updating menu views dict for guild {ctx.guild_id}")
from MusicBot.ui import MenuView
if not ctx.guild_id:
logging.warning("[VC_EXT] Guild not found")
return
if ctx.guild_id in menu_views:
menu_views[ctx.guild_id].stop()
menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable)
async def _download_track(self, gid: int, track: Track) -> None: async def _download_track(self, gid: int, track: Track) -> None:
"""Download track to local storage. Return True on success. """Download track to local storage. Return True on success.
@@ -970,8 +866,8 @@ class VoiceExtension:
logging.warning(f"[VC_EXT] Timed out while downloading track '{track.title}'") logging.warning(f"[VC_EXT] Timed out while downloading track '{track.title}'")
raise raise
async def _full_stop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, current_menu: int, gid: int) -> Literal[True]: async def _delete_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, current_menu: int, gid: int) -> Literal[True]:
"""Stop all actions and delete menu. Return True on success. """Delete current menu message and stop menu view. Return True on success.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
@@ -982,16 +878,13 @@ class VoiceExtension:
""" """
logging.debug("[VC_EXT] Performing full stop") logging.debug("[VC_EXT] Performing full stop")
if gid in menu_views: if gid in self.menu_views:
menu_views[gid].stop() self.menu_views[gid].stop()
del menu_views[gid] del self.menu_views[gid]
if (menu := await self.get_menu_message(ctx, current_menu)): if (menu := await self.get_menu_message(ctx, current_menu)):
await menu.delete() await menu.delete()
await self.db.update(gid, {
'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'vibing': False
})
return True return True
async def _play_track( async def _play_track(
@@ -1052,7 +945,7 @@ class VoiceExtension:
if menu_message or guild['current_menu']: if menu_message or guild['current_menu']:
# Updating menu message before playing to prevent delay and avoid FFMPEG lags. # Updating menu message before playing to prevent delay and avoid FFMPEG lags.
await self.update_menu_full(ctx, menu_message=menu_message, button_callback=button_callback) await self.update_menu_embed_and_view(ctx, menu_message=menu_message, button_callback=button_callback)
if not guild['vibing']: if not guild['vibing']:
# Giving FFMPEG enough time to process the audio file # Giving FFMPEG enough time to process the audio file
@@ -1060,22 +953,14 @@ class VoiceExtension:
loop = self._get_current_event_loop(ctx) loop = self._get_current_event_loop(ctx)
try: try:
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop)) vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.play_next_track(ctx, after=True), loop))
except discord.errors.ClientException as e: 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}")
if not isinstance(ctx, RawReactionActionEvent): await self.send_response_message(ctx, f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True)
await ctx.respond(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15)
return None return None
except yandex_music.exceptions.InvalidBitrateError: except yandex_music.exceptions.InvalidBitrateError:
logging.error(f"[VC_EXT] Invalid bitrate while playing track '{track.title}'") logging.error(f"[VC_EXT] Invalid bitrate while playing track '{track.title}'")
if not isinstance(ctx, RawReactionActionEvent): await self.send_response_message(ctx, f"У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15, ephemeral=True)
await ctx.respond(f"У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send(f"У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15)
return None return None
logging.info(f"[VC_EXT] Playing track '{track.title}'") logging.info(f"[VC_EXT] Playing track '{track.title}'")
@@ -1085,27 +970,3 @@ class VoiceExtension:
await self.send_vibe_feedback(ctx, 'trackStarted', track) await self.send_vibe_feedback(ctx, 'trackStarted', track)
return track.title return track.title
def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop:
"""Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
Raises:
TypeError: If the context is not a RawReactionActionEvent, ApplicationContext or Interaction.
ValueError: If the context is a RawReactionActionEvent and the bot is not set.
Returns:
asyncio.AbstractEventLoop: Current event loop.
"""
if isinstance(ctx, Interaction):
return ctx.client.loop
elif isinstance(ctx, ApplicationContext):
return ctx.bot.loop
elif isinstance(ctx, RawReactionActionEvent):
if not self.bot:
raise ValueError("Bot is not set.")
return self.bot.loop
else:
raise TypeError(f"Invalid context type: '{type(ctx).__name__}'.")

View File

@@ -7,8 +7,8 @@ from discord.ext.commands import Cog
from yandex_music import ClientAsync as YMClient from yandex_music import ClientAsync as YMClient
from yandex_music.exceptions import UnauthorizedError from yandex_music.exceptions import UnauthorizedError
from MusicBot.cogs.utils import VoiceExtension
from MusicBot.database import BaseUsersDatabase from MusicBot.database import BaseUsersDatabase
from MusicBot.cogs.utils import VoiceExtension, menu_views
from MusicBot.ui import QueueView, generate_queue_embed from MusicBot.ui import QueueView, generate_queue_embed
def setup(bot: discord.Bot): def setup(bot: discord.Bot):
@@ -20,8 +20,7 @@ async def get_vibe_stations_suggestions(ctx: discord.AutocompleteContext) -> lis
if not ctx.interaction.user or not ctx.value or len(ctx.value) < 2: if not ctx.interaction.user or not ctx.value or len(ctx.value) < 2:
return [] return []
token = await users_db.get_ym_token(ctx.interaction.user.id) if not (token := await users_db.get_ym_token(ctx.interaction.user.id)):
if not token:
logging.info(f"[GENERAL] User {ctx.interaction.user.id} has no token") logging.info(f"[GENERAL] User {ctx.interaction.user.id} has no token")
return [] return []
@@ -46,63 +45,71 @@ class Voice(Cog, VoiceExtension):
@Cog.listener() @Cog.listener()
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None: async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None:
gid = member.guild.id guild = await self.db.get_guild(member.guild.id, projection={'current_menu': 1})
guild = await self.db.get_guild(gid, projection={'current_menu': 1})
channel = after.channel or before.channel if not after.channel or not before.channel:
if not channel:
logging.warning(f"[VOICE] No channel found for member {member.id}") logging.warning(f"[VOICE] No channel found for member {member.id}")
return return
vc = cast(discord.VoiceClient | None, discord.utils.get(self.typed_bot.voice_clients, guild=await self.typed_bot.fetch_guild(gid))) vc = cast(
discord.VoiceClient | None,
discord.utils.get(
self.typed_bot.voice_clients,
guild=await self.typed_bot.fetch_guild(member.guild.id)
)
)
for member in channel.members: if not vc:
logging.info(f"[VOICE] No voice client found for guild {member.guild.id}")
return
for member in set(before.channel.members + after.channel.members):
if member.id == self.typed_bot.user.id: # type: ignore # should be logged in if member.id == self.typed_bot.user.id: # type: ignore # should be logged in
logging.info(f"[VOICE] Voice state update for member {member.id} in guild {member.guild.id}") logging.info(f"[VOICE] Voice state update for member {member.id} in guild {member.guild.id}")
break break
else: else:
logging.debug(f"[VOICE] Bot is not in the channel {channel.id}") logging.debug(f"[VOICE] Bot is not in the channel {after.channel.id}")
return return
if not vc: if len(after.channel.members) == 1:
logging.info(f"[VOICE] No voice client found for guild {gid}") logging.info(f"[VOICE] Clearing history and stopping playback for guild {member.guild.id}")
return
if len(channel.members) == 1: if member.guild.id in self.menu_views:
logging.info(f"[VOICE] Clearing history and stopping playback for guild {gid}") self.menu_views[member.guild.id].stop()
del self.menu_views[member.guild.id]
if member.guild.id in menu_views:
menu_views[member.guild.id].stop()
del menu_views[member.guild.id]
if guild['current_menu']: if guild['current_menu']:
message = self.typed_bot.get_message(guild['current_menu']) if (message := self.typed_bot.get_message(guild['current_menu'])):
if message:
await message.delete() await message.delete()
await self.db.update(gid, { await self.db.update(member.guild.id, {
'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'previous_tracks': [], 'next_tracks': [], 'votes': {},
'current_track': None, 'current_menu': None, 'vibing': False, 'current_track': None, 'current_menu': None, 'vibing': False,
'repeat': False, 'shuffle': False, 'is_stopped': True 'repeat': False, 'shuffle': False, 'is_stopped': True
}) })
vc.stop() vc.stop()
if member.guild.id in menu_views: if member.guild.id in self.menu_views:
menu_views[member.guild.id].stop() self.menu_views[member.guild.id].stop()
del menu_views[member.guild.id] del self.menu_views[member.guild.id]
@Cog.listener() @Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None:
logging.debug(f"[VOICE] Reaction added by user {payload.user_id} in channel {payload.channel_id}") logging.debug(f"[VOICE] Reaction added by user {payload.user_id} in channel {payload.channel_id}")
if not self.typed_bot.user or not payload.member: if not self.typed_bot.user or not payload.member:
return return
bot_id = self.typed_bot.user.id if not payload.guild_id:
if payload.user_id == bot_id: logging.info(f"[VOICE] No guild id in reaction payload")
return return
channel = cast(discord.VoiceChannel, self.typed_bot.get_channel(payload.channel_id)) if payload.user_id == self.typed_bot.user.id:
if not channel: return
channel = self.typed_bot.get_channel(payload.channel_id)
if not isinstance(channel, discord.VoiceChannel):
logging.info(f"[VOICE] Channel {payload.channel_id} is not a voice channel")
return return
try: try:
@@ -114,19 +121,16 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] Message {payload.message_id} not found in channel {payload.channel_id}") logging.info(f"[VOICE] Message {payload.message_id} not found in channel {payload.channel_id}")
return return
if not message or message.author.id != bot_id: if not message or message.author.id != self.typed_bot.user.id:
logging.info(f"[VOICE] Message {payload.message_id} is not a bot message")
return return
if not await self.users_db.get_ym_token(payload.user_id): if not await self.users_db.get_ym_token(payload.user_id):
await message.remove_reaction(payload.emoji, payload.member) await message.remove_reaction(payload.emoji, payload.member)
await channel.send("Для участия в голосовании необходимо авторизоваться через /account login.", delete_after=15) await channel.send("Для участия в голосовании необходимо авторизоваться через /account login.", delete_after=15)
return return
guild_id = payload.guild_id guild = await self.db.get_guild(payload.guild_id)
if not guild_id:
return
guild = await self.db.get_guild(guild_id)
votes = guild['votes'] votes = guild['votes']
if str(payload.message_id) not in votes: if str(payload.message_id) not in votes:
@@ -156,29 +160,30 @@ class Voice(Cog, VoiceExtension):
await message.edit(content='Запрос был отклонён.', delete_after=15) await message.edit(content='Запрос был отклонён.', delete_after=15)
del votes[str(payload.message_id)] del votes[str(payload.message_id)]
await self.db.update(guild_id, {'votes': votes}) await self.db.update(payload.guild_id, {'votes': votes})
@Cog.listener() @Cog.listener()
async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent) -> None: async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent) -> None:
logging.debug(f"[VOICE] Reaction removed by user {payload.user_id} in channel {payload.channel_id}") logging.debug(f"[VOICE] Reaction removed by user {payload.user_id} in channel {payload.channel_id}")
if not self.typed_bot.user:
if not self.typed_bot.user or not payload.member:
return return
guild_id = payload.guild_id if not payload.guild_id:
if not guild_id:
return return
guild = await self.db.get_guild(guild_id, projection={'votes': 1}) channel = self.typed_bot.get_channel(payload.channel_id)
if not isinstance(channel, discord.VoiceChannel):
logging.info(f"[VOICE] Channel {payload.channel_id} is not a voice channel")
return
guild = await self.db.get_guild(payload.guild_id, projection={'votes': 1})
votes = guild['votes'] votes = guild['votes']
if str(payload.message_id) not in votes: if str(payload.message_id) not in votes:
logging.info(f"[VOICE] Message {payload.message_id} not found in votes") logging.info(f"[VOICE] Message {payload.message_id} not found in votes")
return return
channel = cast(discord.VoiceChannel, self.typed_bot.get_channel(payload.channel_id))
if not channel:
return
try: try:
message = await channel.fetch_message(payload.message_id) message = await channel.fetch_message(payload.message_id)
except discord.Forbidden: except discord.Forbidden:
@@ -199,8 +204,8 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] User {payload.user_id} removed negative vote for message {payload.message_id}") logging.info(f"[VOICE] User {payload.user_id} removed negative vote for message {payload.message_id}")
del vote_data['negative_votes'][payload.user_id] del vote_data['negative_votes'][payload.user_id]
await self.db.update(guild_id, {'votes': votes}) await self.db.update(payload.guild_id, {'votes': votes})
@voice.command(name="menu", description="Создать или обновить меню проигрывателя.") @voice.command(name="menu", description="Создать или обновить меню проигрывателя.")
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}")
@@ -240,9 +245,9 @@ class Voice(Cog, VoiceExtension):
@voice.command(description="Заставить бота покинуть голосовой канал.") @voice.command(description="Заставить бота покинуть голосовой канал.")
async def leave(self, ctx: discord.ApplicationContext) -> None: async def leave(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Leave command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[VOICE] Leave command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
if not ctx.guild_id: if not ctx.guild_id:
logging.warning("[VOICE] Leave command invoked without guild_id") logging.info("[VOICE] Leave command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return return
@@ -253,27 +258,26 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild_id}") 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 ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
return return
if not await self.voice_check(ctx):
return
if (vc := await self.get_voice_client(ctx)) and await self.voice_check(ctx) and vc.is_connected: if not (vc := await self.get_voice_client(ctx)) or not vc.is_connected:
res = await self.stop_playing(ctx, vc=vc, full=True) logging.info(f"[VOICE] Voice client is not connected in guild {ctx.guild_id}")
if not res:
await ctx.respond("Не удалось отключиться.", delete_after=15, ephemeral=True)
return
await vc.disconnect(force=True)
await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True)
logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild_id}")
else:
await ctx.respond("❌ Бот не подключен к голосовому каналу.", delete_after=15, ephemeral=True) await ctx.respond("❌ Бот не подключен к голосовому каналу.", delete_after=15, ephemeral=True)
return
if not await self.stop_playing(ctx, vc=vc, full=True):
await ctx.respond("Не удалось отключиться.", delete_after=15, ephemeral=True)
return
await vc.disconnect(force=True)
await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True)
logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild_id}")
@queue.command(description="Очистить очередь треков и историю прослушивания.") @queue.command(description="Очистить очередь треков и историю прослушивания.")
async def clear(self, ctx: discord.ApplicationContext) -> None: async def clear(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Clear queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[VOICE] Clear queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
if not ctx.guild_id:
logging.warning("[VOICE] Clear command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
return return
@@ -283,14 +287,14 @@ class Voice(Cog, VoiceExtension):
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 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 ctx.respond(response_message, delete_after=60))
response = await message.original_response() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
await response.add_reaction('') await response.add_reaction('')
await self.db.update_vote( await self.db.update_vote(
ctx.guild_id, ctx.guild_id,
response.id, response.id,
@@ -311,11 +315,6 @@ class Voice(Cog, VoiceExtension):
@queue.command(description="Получить очередь треков.") @queue.command(description="Получить очередь треков.")
async def get(self, ctx: discord.ApplicationContext) -> None: async def get(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Get queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[VOICE] Get queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
if not ctx.guild_id:
logging.warning("[VOICE] Get command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
return return
@@ -334,11 +333,6 @@ class Voice(Cog, VoiceExtension):
@voice.command(description="Прервать проигрывание, удалить историю, очередь и текущий плеер.") @voice.command(description="Прервать проигрывание, удалить историю, очередь и текущий плеер.")
async def stop(self, ctx: discord.ApplicationContext) -> None: async def stop(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Stop command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[VOICE] Stop command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
if not ctx.guild_id:
logging.warning("[VOICE] Stop command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
return return
@@ -387,13 +381,9 @@ class Voice(Cog, VoiceExtension):
) )
async def vibe(self, ctx: discord.ApplicationContext, name: str | None = None) -> None: async def vibe(self, ctx: discord.ApplicationContext, name: str | None = None) -> None:
logging.info(f"[VOICE] Vibe (user) command invoked by user {ctx.user.id} in guild {ctx.guild_id}") logging.info(f"[VOICE] Vibe (user) command invoked by user {ctx.user.id} in guild {ctx.guild_id}")
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
return return
if not ctx.guild_id:
logging.warning("[VOICE] Vibe command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return
guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1, 'vibing': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1, 'vibing': 1})
@@ -404,19 +394,11 @@ class Voice(Cog, VoiceExtension):
await ctx.defer(invisible=False) await ctx.defer(invisible=False)
if name: if name:
token = await users_db.get_ym_token(ctx.user.id)
if not token: if not (client := await self.init_ym_client(ctx)):
logging.info(f"[GENERAL] User {ctx.user.id} has no token")
return return
try: for content in (await client.rotor_stations_list()):
client = await YMClient(token).init()
except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token")
return
stations = await client.rotor_stations_list()
for content in stations:
if content.station and content.station.name == name and content.ad_params: if content.station and content.station.name == name and content.ad_params:
break break
else: else:
@@ -427,23 +409,23 @@ class Voice(Cog, VoiceExtension):
await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True) await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True)
return return
_type, _id = content.ad_params.other_params.split(':') if content.ad_params else (None, None) vibe_type, vibe_id = content.ad_params.other_params.split(':') if content.ad_params else (None, None)
if not _type or not _id: if not vibe_type or not vibe_id:
logging.debug(f"[VOICE] Station {name} has no ad params") logging.debug(f"[VOICE] Station {name} has no ad params")
await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True) await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True)
return return
else: else:
_type, _id = 'user', 'onyourwave' vibe_type, vibe_id = 'user', 'onyourwave'
content = None content = None
member = cast(discord.Member, ctx.author) member = cast(discord.Member, ctx.author)
channel = cast(discord.VoiceChannel, ctx.channel) channel = cast(discord.VoiceChannel, ctx.channel)
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}")
if _type == 'user' and _id == 'onyourwave': if vibe_type == 'user' and vibe_id == 'onyourwave':
station = "Моя Волна" station = "Моя Волна"
elif content and content.station: elif content and content.station:
station = content.station.name station = content.station.name
@@ -457,7 +439,7 @@ class Voice(Cog, VoiceExtension):
await message.add_reaction('') await message.add_reaction('')
await message.add_reaction('') await message.add_reaction('')
await self.db.update_vote( await self.db.update_vote(
ctx.guild_id, ctx.guild_id,
message.id, message.id,
@@ -466,12 +448,12 @@ class Voice(Cog, VoiceExtension):
'negative_votes': list(), 'negative_votes': list(),
'total_members': len(channel.members), 'total_members': len(channel.members),
'action': 'vibe_station', 'action': 'vibe_station',
'vote_content': [_type, _id, ctx.user.id] 'vote_content': [vibe_type, vibe_id, ctx.user.id]
} }
) )
return return
if not await self.update_vibe(ctx, _type, _id): if not await self.update_vibe(ctx, vibe_type, vibe_id):
await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True) await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True)
return return
@@ -480,6 +462,5 @@ class Voice(Cog, VoiceExtension):
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 ctx.respond("Не удалось отправить меню. Попробуйте позже.", delete_after=15, ephemeral=True)
next_track = await self.db.get_track(ctx.guild_id, 'next') if (next_track := await self.db.get_track(ctx.guild_id, 'next')):
if next_track:
await self.play_track(ctx, next_track) await self.play_track(ctx, next_track)

View File

@@ -9,7 +9,8 @@ from discord import (
import yandex_music.exceptions import yandex_music.exceptions
from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient
from MusicBot.cogs.utils.voice_extension import VoiceExtension, menu_views
from MusicBot.cogs.utils import VoiceExtension
class ToggleButton(Button, VoiceExtension): class ToggleButton(Button, VoiceExtension):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -142,9 +143,9 @@ class SwitchTrackButton(Button, VoiceExtension):
return return
tracks_type = callback_type + '_tracks' tracks_type = callback_type + '_tracks'
guild = await self.db.get_guild(gid, projection={tracks_type: 1, 'vote_switch_track': 1}) guild = await self.db.get_guild(gid, projection={tracks_type: 1, 'vote_switch_track': 1, 'vibing': 1})
if not guild[tracks_type]: if not guild[tracks_type] and not guild['vibing']:
logging.info(f"[MENU] No tracks in '{tracks_type}' list in guild {gid}") 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 interaction.respond(f"❌ Нет треков в {'очереди' if callback_type == 'next' else 'истории'}.", delete_after=15, ephemeral=True)
return return
@@ -176,9 +177,9 @@ class SwitchTrackButton(Button, VoiceExtension):
return return
if callback_type == 'next': if callback_type == 'next':
title = await self.next_track(interaction, button_callback=True) title = await self.play_next_track(interaction, button_callback=True)
else: else:
title = await self.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 interaction.respond(f"❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True)
@@ -205,8 +206,8 @@ class ReactionButton(Button, VoiceExtension):
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) await self.update_menu_views_dict(interaction)
await interaction.edit(view=menu_views[gid]) await interaction.edit(view=self.menu_views[gid])
await interaction.respond( await interaction.respond(
f"✅ Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}", f"✅ Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}",
delete_after=15, ephemeral=True delete_after=15, ephemeral=True
@@ -214,11 +215,11 @@ class ReactionButton(Button, VoiceExtension):
elif callback_type == 'dislike' and res[0]: elif callback_type == 'dislike' and res[0]:
if len(channel.members) == 2 and not await self.next_track(interaction, vc=vc, button_callback=True): if len(channel.members) == 2 and not await self.play_next_track(interaction, vc=vc, button_callback=True):
await interaction.respond("✅ Воспроизведение приостановлено. Нет треков в очереди.", delete_after=15) await interaction.respond("✅ Воспроизведение приостановлено. Нет треков в очереди.", delete_after=15)
await self._update_menu_views_dict(interaction) await self.update_menu_views_dict(interaction)
await interaction.edit(view=menu_views[gid]) await interaction.edit(view=self.menu_views[gid])
await interaction.respond( await interaction.respond(
f"✅ Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}", f"✅ Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}",
delete_after=15, ephemeral=True delete_after=15, ephemeral=True
@@ -465,7 +466,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 interaction.respond('Настройки **Волны**', 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):
@@ -601,7 +602,7 @@ class MenuView(View, VoiceExtension):
self.shuffle_button.style = ButtonStyle.success self.shuffle_button.style = ButtonStyle.success
current_track = self.guild['current_track'] current_track = self.guild['current_track']
likes = await self.get_likes(self.ctx) likes = await self.get_liked_tracks(self.ctx)
self.add_item(self.repeat_button) self.add_item(self.repeat_button)
self.add_item(self.prev_button) self.add_item(self.prev_button)
@@ -610,7 +611,7 @@ class MenuView(View, VoiceExtension):
self.add_item(self.shuffle_button) self.add_item(self.shuffle_button)
if not isinstance(self.ctx, RawReactionActionEvent) and len(cast(VoiceChannel, self.ctx.channel).members) == 2: if not isinstance(self.ctx, RawReactionActionEvent) and len(cast(VoiceChannel, self.ctx.channel).members) == 2:
if likes and current_track and str(current_track['id']) in [str(like.id) for like in likes]: if current_track and str(current_track['id']) in [str(like.id) for like in likes]:
self.like_button.style = ButtonStyle.success self.like_button.style = ButtonStyle.success
if not current_track: if not current_track:
@@ -645,8 +646,7 @@ class MenuView(View, VoiceExtension):
await self.stop_playing(self.ctx) await self.stop_playing(self.ctx)
await self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': [], 'vibing': False}) await self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': [], 'vibing': False})
message = await self.get_menu_message(self.ctx, self.guild['current_menu']) if (message := await self.get_menu_message(self.ctx, self.guild['current_menu'])):
if message:
await message.delete() await message.delete()
logging.debug('[MENU] Successfully deleted menu message') logging.debug('[MENU] Successfully deleted menu message')
else: else: