impr: Enforce menu view, vote improvement and bug fixes.

This commit is contained in:
Lemon4ksan
2025-02-21 17:03:39 +03:00
parent 617a7a6de9
commit 12f7c96c93
9 changed files with 457 additions and 505 deletions

View File

@@ -41,13 +41,13 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}")
if content_type == 'Трек' and search.tracks:
if content_type == 'Трек' and search.tracks is not None:
res = [f"{item.title} {f"({item.version})" if item.version else ''} - {", ".join(item.artists_name())}" for item in search.tracks.results]
elif content_type == 'Альбом' and search.albums:
elif content_type == 'Альбом' and search.albums is not None:
res = [f"{item.title} - {", ".join(item.artists_name())}" for item in search.albums.results]
elif content_type == 'Артист' and search.artists:
elif content_type == 'Артист' and search.artists is not None:
res = [f"{item.name}" for item in search.artists.results]
elif content_type == 'Плейлист' and search.playlists:
elif content_type == 'Плейлист' and search.playlists is not None:
res = [f"{item.title}" for item in search.playlists.results]
else:
logging.warning(f"[GENERAL] Invalid content type '{content_type}' for user {uid}")
@@ -108,7 +108,7 @@ class General(Cog):
"Зарегистрируйте свой токен с помощью /login. Его можно получить [здесь](https://github.com/MarshalX/yandex-music-api/discussions/513).\n"
"Для получения помощи по конкретной команде, введите /help <команда>.\n"
"Для изменения настроек необходимо иметь права управления каналами на сервере.\n\n"
"**Для дополнительной помощи, присоединяйтесь к [серверу сообщества](https://discord.gg/gkmFDaPMeC).**"
"**Присоединяйтесь к нашему [серверу сообщества](https://discord.gg/TgnW8nfbFn)!**"
)
embed.add_field(
name='__Основные команды__',
@@ -117,7 +117,6 @@ class General(Cog):
`help`
`queue`
`settings`
`track`
`voice`"""
)
embed.set_footer(text='©️ Bananchiki')
@@ -149,30 +148,19 @@ class General(Cog):
embed.description += (
"`Примечание`: Только пользователи с разрешением управления каналом могут менять настройки.\n\n"
"Получить текущие настройки.\n```/settings show```\n"
"Разрешить или запретить создание меню проигрывателя, когда в канале больше одного человека.\n```/settings menu```\n"
"Разрешить или запретить голосование.\n```/settings vote <тип>```\n"
"Разрешить или запретить отключение/подключение бота к каналу участникам без прав управления каналом.\n```/settings connect```\n"
)
elif command == 'track':
embed.description += (
"`Примечание`: Если вы один в голосовом канале или имеете разрешение управления каналом, голосование не начинается.\n\n"
"Переключиться на следующий трек в очереди. \n```/track next```\n"
"Приостановить текущий трек.\n```/track pause```\n"
"Возобновить текущий трек.\n```/track resume```\n"
"Прервать проигрывание, удалить историю, очередь и текущий плеер.\n ```/track stop```\n"
"Добавить трек в плейлист «Мне нравится» или удалить его, если он уже там.\n```/track like```"
"Запустить Мою Волну по текущему треку.\n```/track vibe```"
"Переключить параметр настроек.\n```/settings toggle <параметр>```\n"
)
elif command == 'voice':
embed.description += (
"`Примечание`: Доступность меню и Моей Волны зависит от настроек сервера.\n\n"
"Присоединить бота в голосовой канал.\n```/voice join```\n"
"Заставить бота покинуть голосовой канал.\n ```/voice leave```\n"
"Прервать проигрывание, удалить историю, очередь и текущий плеер.\n ```/track stop```\n"
"Создать меню проигрывателя. \n```/voice menu```\n"
"Запустить станцию. Без уточнения станции, запускает Мою Волну.\n```/voice vibe <название станции>```"
)
else:
await ctx.respond('❌ Неизвестная команда.')
await ctx.respond('❌ Неизвестная команда.', delete_after=15, ephemeral=True)
return
await ctx.respond(embed=embed, ephemeral=True)
@@ -236,6 +224,7 @@ class General(Cog):
await ctx.respond('У вас нет треков в плейлисте «Мне нравится».', delete_after=15, ephemeral=True)
return
await ctx.defer() # Sometimes it takes a while to fetch all tracks, so we defer the response
real_tracks = await gather(*[track_short.fetch_track_async() for track_short in likes.tracks], return_exceptions=True)
tracks = [track for track in real_tracks if not isinstance(track, BaseException)] # Can't fetch user tracks

View File

@@ -19,100 +19,53 @@ class Settings(Cog):
@settings.command(name="show", description="Показать текущие настройки бота.")
async def show(self, ctx: discord.ApplicationContext) -> None:
guild = await self.db.get_guild(ctx.guild.id, projection={
'always_allow_menu': 1, 'allow_connect': 1, 'allow_disconnect': 1,
'vote_next_track': 1, 'vote_add_track': 1, 'vote_add_album': 1, 'vote_add_artist': 1, 'vote_add_playlist': 1
})
guild = await self.db.get_guild(ctx.guild.id, projection={'allow_change_connect': 1, 'vote_switch_track': 1, 'vote_add': 1})
vote = "✅ - Переключение" if guild['vote_switch_track'] else "❌ - Переключение"
vote += "\n✅ - Добавление в очередь" if guild['vote_add'] else "\n❌ - Добавление в очередь"
connect = "\n✅ - Разрешено всем" if guild['allow_change_connect'] else "\n❌ - Только для участникам с правами управления каналом"
embed = discord.Embed(title="Настройки бота", color=0xfed42b)
menu = "✅ - Всегда доступно" if guild['always_allow_menu'] else "❌ - Если в канале 1 человек."
vote = "✅ - Переключение" if guild['vote_next_track'] else "❌ - Переключение"
vote += "\n✅ - Добавление треков" if guild['vote_add_track'] else "\n❌ - Добавление треков"
vote += "\n✅ - Добавление альбомов" if guild['vote_add_album'] else "\n❌ - Добавление альбомов"
vote += "\n✅ - Добавление артистов" if guild['vote_add_artist'] else "\n❌ - Добавление артистов"
vote += "\n✅ - Добавление плейлистов" if guild['vote_add_playlist'] else "\n❌ - Добавление плейлистов"
connect = "\n✅ - Разрешено всем" if guild['allow_connect'] else "\n❌ - Только для участникам с правами управления каналом"
embed.add_field(name="__Меню проигрывателя__", value=menu, inline=False)
embed.add_field(name="__Голосование__", value=vote, inline=False)
embed.add_field(name="__Подключение/Отключение__", value=connect, inline=False)
embed.add_field(name="__Подключение/Отключение бота__", value=connect, inline=False)
await ctx.respond(embed=embed, ephemeral=True)
@settings.command(name="connect", description="Разрешить/запретить отключение/подключение бота к каналу участникам без прав управления каналом.")
async def connect(self, ctx: discord.ApplicationContext) -> None:
member = cast(discord.Member, ctx.author)
if not member.guild_permissions.manage_channels:
await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
return
guild = await self.db.get_guild(ctx.guild.id, projection={'allow_connect': 1})
await self.db.update(ctx.guild.id, {'allow_connect': not guild['allow_connect']})
await ctx.respond(f"Отключение/подключение бота к каналу теперь {'✅ разрешено' if not guild['allow_connect'] else '❌ запрещено'} участникам без прав управления каналом.", delete_after=15, ephemeral=True)
@settings.command(name="menu", description="Разрешить или запретить использование меню проигрывателя, если в канале больше одного человека.")
async def menu(self, ctx: discord.ApplicationContext) -> None:
member = cast(discord.Member, ctx.author)
if not member.guild_permissions.manage_channels:
await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
return
guild = await self.db.get_guild(ctx.guild.id, projection={'always_allow_menu': 1})
await self.db.update(ctx.guild.id, {'always_allow_menu': not guild['always_allow_menu']})
await ctx.respond(f"Меню проигрывателя теперь {'✅ доступно' if not guild['always_allow_menu'] else '❌ недоступно'} в каналах с несколькими людьми.", delete_after=15, ephemeral=True)
@settings.command(name="vote", description="Настроить голосование.")
@settings.command(name="toggle", description="Переключить параметр настроек.")
@discord.option(
"vote_type",
"параметр",
parameter_name="vote_type",
description="Тип голосования.",
type=discord.SlashCommandOptionType.string,
choices=['+Всё', '-Всё', 'Переключение', 'Трек', 'Альбом', 'Плейлист'],
default='+Всё'
choices=['Переключение', 'Добавление в очередь', 'Добавление/Отключение бота']
)
async def vote(self, ctx: discord.ApplicationContext, vote_type: Literal['+Всё', '-Всё', 'Переключение', 'Трек', 'Альбом', 'Плейлист']) -> None:
async def toggle(
self,
ctx: discord.ApplicationContext,
vote_type: Literal['Переключение', 'Добавление в очередь', 'Добавление/Отключение бота']
) -> None:
member = cast(discord.Member, ctx.author)
if not member.guild_permissions.manage_channels:
await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
return
guild = await self.db.get_guild(ctx.guild.id, projection={'vote_next_track': 1, 'vote_add_track': 1, 'vote_add_album': 1, 'vote_add_artist': 1, 'vote_add_playlist': 1})
if vote_type == '-Всё':
await self.db.update(ctx.guild.id, {
'vote_next_track': False,
'vote_add_track': False,
'vote_add_album': False,
'vote_add_artist': False,
'vote_add_playlist': False
}
)
response_message = "Голосование ❌ выключено."
elif vote_type == '+Всё':
await self.db.update(ctx.guild.id, {
'vote_next_track': True,
'vote_add_track': True,
'vote_add_album': True,
'vote_add_artist': True,
'vote_add_playlist': True
}
)
response_message = "Голосование ✅ включено."
elif vote_type == 'Переключение':
await self.db.update(ctx.guild.id, {'vote_next_track': not guild['vote_next_track']})
response_message = "Голосование за переключение трека " + ("❌ выключено." if guild['vote_next_track'] else "✅ включено.")
elif vote_type == 'Трек':
await self.db.update(ctx.guild.id, {'vote_add_track': not guild['vote_add_track']})
response_message = "Голосование за добавление трека " + ("❌ выключено." if guild['vote_add_track'] else "✅ включено.")
elif vote_type == 'Альбом':
await self.db.update(ctx.guild.id, {'vote_add_album': not guild['vote_add_album']})
response_message = "Голосование за добавление альбома " + ("❌ выключено." if guild['vote_add_album'] else "✅ включено.")
elif vote_type == 'Артист':
await self.db.update(ctx.guild.id, {'vote_add_artist': not guild['vote_add_artist']})
response_message = "Голосование за добавление артиста " + ("❌ выключено." if guild['vote_add_artist'] else "✅ включено.")
elif vote_type == 'Плейлист':
await self.db.update(ctx.guild.id, {'vote_add_playlist': not guild['vote_add_playlist']})
response_message = "Голосование за добавление плейлиста " + ("❌ выключено." if guild['vote_add_playlist'] else "✅ включено.")
guild = await self.db.get_guild(ctx.guild.id, projection={
'vote_switch_track': 1, 'vote_add': 1, 'allow_change_connect': 1})
if vote_type == 'Переключение':
await self.db.update(ctx.guild.id, {'vote_switch_track': not guild['vote_switch_track']})
response_message = "Голосование за переключение трека " + ("❌ выключено." if guild['vote_switch_track'] else "✅ включено.")
elif vote_type == 'Добавление в очередь':
await self.db.update(ctx.guild.id, {'vote_add': not guild['vote_add']})
response_message = "Голосование за добавление в очередь " + ("❌ выключено." if guild['vote_add'] else "✅ включено.")
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 '❌ запрещено'} участникам без прав управления каналом."
else:
response_message = "❌ Неизвестный тип голосования."
await ctx.respond(response_message, delete_after=15, ephemeral=True)

View File

@@ -9,10 +9,10 @@ from yandex_music import Track, TrackShort, ClientAsync as YMClient
import discord
from discord.ui import View
from discord import Interaction, ApplicationContext, RawReactionActionEvent
from discord import Interaction, ApplicationContext, RawReactionActionEvent, VoiceChannel
from MusicBot.cogs.utils import generate_item_embed
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase, ExplicitGuild, ExplicitUser
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase, ExplicitGuild, ExplicitUser, MessageVotes
menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks.
@@ -23,13 +23,16 @@ class VoiceExtension:
self.db = VoiceGuildsDatabase()
self.users_db = BaseUsersDatabase()
async def send_menu_message(self, ctx: ApplicationContext | Interaction, *, 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 menu message if exists. Return True if sent.
Args:
ctx (ApplicationContext | Interaction): Context.
disable (bool, optional): Disable menu message. Defaults to False.
Raises:
ValueError: If bot instance is not set and ctx is RawReactionActionEvent.
Returns:
bool: True if sent, False if not.
"""
@@ -65,7 +68,23 @@ class VoiceExtension:
await message.delete()
await self._update_menu_views_dict(ctx, disable=disable)
interaction = await ctx.respond(view=menu_views[ctx.guild_id], embed=embed)
if isinstance(ctx, (ApplicationContext, Interaction)):
interaction = await ctx.respond(view=menu_views[ctx.guild_id], embed=embed)
else:
if not self.bot:
raise ValueError("Bot instance is not set.")
channel = cast(VoiceChannel, self.bot.get_channel(ctx.channel_id))
if not channel:
logging.warning(f"[VC_EXT] Channel {ctx.channel_id} not found in guild {ctx.guild_id}")
return False
interaction = await channel.send(
view=menu_views[ctx.guild_id],
embed=embed # type: ignore # Wrong typehints.
)
response = await interaction.original_response() if isinstance(interaction, discord.Interaction) else interaction
await self.db.update(ctx.guild_id, {'current_menu': response.id})
@@ -105,18 +124,17 @@ class VoiceExtension:
await self.db.update(ctx.guild_id, {'current_menu': None})
return None
if menu:
logging.debug(f"[VC_EXT] Menu message {menu_mid} successfully fetched")
else:
if not menu:
logging.debug(f"[VC_EXT] Menu message {menu_mid} not found in guild {ctx.guild_id}")
await self.db.update(ctx.guild_id, {'current_menu': None})
return None
logging.debug(f"[VC_EXT] Menu message {menu_mid} successfully fetched")
return menu
async def update_menu_full(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
menu_mid: int | None = None,
*,
menu_message: discord.Message | None = None,
button_callback: bool = False
@@ -132,7 +150,7 @@ class VoiceExtension:
Returns:
bool: True if updated, False if not.
"""
logging.debug(
logging.info(
f"[VC_EXT] Updating menu embed using " + (
"interaction context" if isinstance(ctx, Interaction) else
"application context" if isinstance(ctx, ApplicationContext) else
@@ -147,14 +165,11 @@ class VoiceExtension:
logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'update_menu_embed'")
return False
guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_track': 1})
if not menu_message:
if not menu_mid:
logging.warning("[VC_EXT] No menu message or menu message id provided")
return False
menu_message = await self.get_menu_message(ctx, menu_mid)
guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1})
if not guild['current_menu']:
return False
menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message
if not menu_message:
return False
@@ -168,6 +183,16 @@ class VoiceExtension:
))
embed = await generate_item_embed(track, guild['vibing'])
vc = await self.get_voice_client(ctx)
if not vc:
logging.warning("[VC_EXT] Voice client not found")
return False
if vc.is_paused():
embed.set_footer(text='Приостановлено')
else:
embed.remove_footer()
await self._update_menu_views_dict(ctx)
try:
if isinstance(ctx, Interaction) and button_callback:
@@ -186,7 +211,6 @@ class VoiceExtension:
async def update_menu_view(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
guild: ExplicitGuild,
*,
menu_message: discord.Message | None = None,
button_callback: bool = False,
@@ -206,6 +230,11 @@ class VoiceExtension:
"""
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'")
return False
guild = await self.db.get_guild(ctx.guild_id, projection={'current_menu': 1})
if not guild['current_menu']:
return False
@@ -217,10 +246,10 @@ class VoiceExtension:
try:
if isinstance(ctx, Interaction) and button_callback:
# If interaction from menu buttons
await ctx.edit(view=menu_views[guild['_id']])
await ctx.edit(view=menu_views[ctx.guild_id])
else:
# If interaction from other buttons or commands. They should have their own response.
await menu_message.edit(view=menu_views[guild['_id']])
await menu_message.edit(view=menu_views[ctx.guild_id])
except discord.NotFound:
logging.warning("[VC_EXT] Menu message not found")
return False
@@ -340,7 +369,7 @@ class VoiceExtension:
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 ctx.respond("❌ Вы должны отправить команду в чате голосового канала.", delete_after=15, ephemeral=True)
return False
if ctx.user.id not in ctx.channel.voice_states:
@@ -408,7 +437,7 @@ class VoiceExtension:
retry: bool = False
) -> str | None:
"""Download ``track`` by its id and play it in the voice channel. Return track title on success.
Send feedback for vibe track playing if vibing. Should be called if voice requirements are met.
Send vibe feedback for playing track if vibing. Should be called when voice requirements are met.
Args:
ctx (ApplicationContext | Interaction): Context.
@@ -457,25 +486,31 @@ class VoiceExtension:
if menu_message or guild['current_menu']:
# Updating menu message before playing to prevent delay and avoid FFMPEG lags.
await self.update_menu_full(ctx, guild['current_menu'], menu_message=menu_message, button_callback=button_callback)
await self.update_menu_full(ctx, menu_message=menu_message, button_callback=button_callback)
if not guild['vibing']:
# Giving FFMPEG enough time to process the audio file
await asyncio.sleep(1)
loop = self._get_current_event_loop(ctx)
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop))
try:
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop))
except discord.errors.ClientException as e:
logging.error(f"[VC_EXT] Error while playing track '{track.title}': {e}")
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True)
logging.info(f"[VC_EXT] Playing track '{track.title}'")
await self.db.update(gid, {'is_stopped': False})
if guild['vibing']:
await self._my_vibe_send_start_feedback(ctx, track, uid)
await self._my_vibe_start_feedback(ctx, track, uid)
return track.title
async def stop_playing(
self, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*,
vc: discord.VoiceClient | None = None,
full: bool = False
@@ -514,7 +549,7 @@ class VoiceExtension:
return False
if guild['vibing'] and guild['current_track']:
if not await self._my_vibe_send_stop_feedback(ctx, guild, user):
if not await self._my_vibe_stop_feedback(ctx, guild, user):
return False
return True
@@ -571,10 +606,10 @@ class VoiceExtension:
await self.db.modify_track(gid, guild['current_track'], 'previous', 'insert')
if after and guild['current_menu']:
await self.update_menu_view(ctx, guild, menu_message=menu_message, disable=True)
await self.update_menu_view(ctx, menu_message=menu_message, disable=True)
if guild['vibing'] and guild['current_track'] and not isinstance(ctx, discord.RawReactionActionEvent):
if not await self._send_next_vibe_feedback(ctx, guild, user, client, after=after):
if not await self._my_vibe_feedback(ctx, guild, user, client, after=after):
await ctx.respond("❌ Что-то пошло не так. Попробуйте снова.", ephemeral=True)
return None
@@ -598,7 +633,7 @@ class VoiceExtension:
next_track = await self.db.get_track(gid, 'next')
if next_track:
title = await self._play_next_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)
if after and not guild['current_menu']:
if isinstance(ctx, discord.RawReactionActionEvent):
@@ -618,7 +653,7 @@ class VoiceExtension:
return None
async def prev_track(self, ctx: ApplicationContext | Interaction, button_callback: bool = False) -> str | None:
async def 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.
@@ -629,12 +664,17 @@ class VoiceExtension:
Returns:
(str | None): Track title or None.
"""
if not ctx.guild or not ctx.user:
logging.warning("Guild or User not found in context inside 'prev_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
if not gid or not uid:
logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'")
return None
current_track = await self.db.get_track(ctx.guild.id, 'current')
prev_track = await self.db.get_track(ctx.guild.id, 'previous')
current_track = await self.db.get_track(gid, 'current')
prev_track = await self.db.get_track(gid, 'previous')
if prev_track:
logging.debug("[VC_EXT] Previous track found")
@@ -647,7 +687,7 @@ class VoiceExtension:
track = None
if track:
return await self._play_next_track(ctx, track, button_callback=button_callback)
return await self._play_track(ctx, track, button_callback=button_callback)
return None
@@ -721,7 +761,8 @@ class VoiceExtension:
add_func = client.users_dislikes_tracks_add
remove_func = client.users_dislikes_tracks_remove
if not tracks:
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]:
@@ -771,6 +812,82 @@ class VoiceExtension:
self._ym_clients[token] = client
return client
async def proccess_vote(self, ctx: RawReactionActionEvent, guild: ExplicitGuild, channel: VoiceChannel, vote_data: MessageVotes) -> bool:
"""Proccess vote and perform action from `vote_data` and respond. Return True on success.
Args:
ctx (RawReactionActionEvent): Context.
guild (ExplicitGuild): Guild data.
message (Message): Message.
vote_data (MessageVotes): Vote data.
Returns:
bool: Success status.
"""
logging.info(f"[VOICE] Performing '{vote_data['action']}' action for message {ctx.message_id}")
if not guild['current_menu']:
await self.send_menu_message(ctx)
if vote_data['action'] in ('next', 'previous'):
if not guild.get(f'{vote_data['action']}_tracks'):
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)):
await channel.send(content=f"❌ Ошибка при смене трека! Попробуйте ещё раз.", delete_after=15)
elif vote_data['action'] == 'add_track':
track = vote_data['vote_content']
if not track:
logging.info(f"[VOICE] Recieved empty vote context for message {ctx.message_id}")
return False
await self.db.modify_track(guild['_id'], track, 'next', 'append')
if guild['current_track']:
await channel.send(content=f"✅ Трек был добавлен в очередь!", delete_after=15)
else:
if not await self.next_track(ctx):
await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15)
elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'):
tracks = vote_data['vote_content']
if not tracks:
logging.info(f"[VOICE] Recieved empty vote context for message {ctx.message_id}")
return False
await self.db.update(guild['_id'], {'is_stopped': False})
await self.db.modify_track(guild['_id'], tracks, 'next', 'extend')
if guild['current_track']:
await channel.send(content=f"✅ Контент был добавлен в очередь!", delete_after=15)
else:
if not await self.next_track(ctx):
await channel.send(content=f"❌ Ошибка при воспроизведении! Попробуйте ещё раз.", delete_after=15)
elif vote_data['action'] == 'play/pause':
vc = await self.get_voice_client(ctx)
if not vc:
await channel.send(content=f"❌ Ошибка при изменении воспроизведения! Попробуйте ещё раз.", delete_after=15)
return False
if vc.is_playing():
vc.pause()
else:
vc.resume()
await self.update_menu_full(ctx)
elif vote_data['action'] in ('repeat', 'shuffle'):
await self.db.update(guild['_id'], {vote_data['action']: not guild[vote_data['action']]})
await self.update_menu_view(ctx)
else:
logging.warning(f"[VOICE] Unknown action '{vote_data['action']}' for message {ctx.message_id}")
return False
return True
async def _update_menu_views_dict(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
@@ -836,7 +953,7 @@ class VoiceExtension:
})
return True
async def _my_vibe_send_start_feedback(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, track: Track, uid: int):
async def _my_vibe_start_feedback(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, track: Track, uid: int):
"""Send vibe start feedback to Yandex Music. Return True on success.
Args:
@@ -861,7 +978,7 @@ class VoiceExtension:
logging.debug(f"[VIBE] Track started feedback: {feedback}")
return True
async def _my_vibe_send_stop_feedback(
async def _my_vibe_stop_feedback(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
guild: ExplicitGuild,
@@ -904,7 +1021,7 @@ class VoiceExtension:
logging.info(f"[VOICE] User {user['_id']} finished vibing with result: {res}")
return True
async def _send_next_vibe_feedback(
async def _my_vibe_feedback(
self,
ctx: ApplicationContext | Interaction,
guild: ExplicitGuild,
@@ -926,6 +1043,7 @@ class VoiceExtension:
Returns:
bool: True on success, False otherwise.
"""
# TODO: Should be refactored to prevent duplication with `_my_vibe_stop_feedback` and `_my_vibe_start_feedback`
logging.debug(f"[VC_EXT] Sending vibe feedback, after: {after}")
if not user['vibe_type'] or not user['vibe_id']:
@@ -964,21 +1082,21 @@ class VoiceExtension:
return feedback
async def _play_next_track(
async def _play_track(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
next_track: dict[str, Any],
track: dict[str, Any],
*,
client: YMClient | None = None,
vc: discord.VoiceClient | None = None,
menu_message: discord.Message | None = None,
button_callback: bool = False,
) -> str | None:
"""Play the `next_track` in the voice channel. Avoids additional button and vibe checks.
"""Play `track` in the voice channel. Avoids additional vibe checks used in `next_track` and `previous_track`.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
next_track (dict[str, Any]): Next track to play.
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.
@@ -986,6 +1104,7 @@ class VoiceExtension:
Returns:
str | None: Song title or None.
"""
# TODO: This should be refactored to avoid code duplication with `next_track` and `previous_track`.
client = await self.init_ym_client(ctx) if not client else client
if not client:
@@ -998,7 +1117,7 @@ class VoiceExtension:
return None
ym_track = cast(Track, Track.de_json(
next_track,
track,
client=client # type: ignore # Async client can be used here.
))
return await self.play_track(
@@ -1010,7 +1129,7 @@ class VoiceExtension:
)
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 bot.
"""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.

View File

@@ -39,7 +39,6 @@ class Voice(Cog, VoiceExtension):
voice = discord.SlashCommandGroup("voice", "Команды, связанные с голосовым каналом.")
queue = discord.SlashCommandGroup("queue", "Команды, связанные с очередью треков.")
track = discord.SlashCommandGroup("track", "Команды, связанные с треками в голосовом канале.")
def __init__(self, bot: discord.Bot):
VoiceExtension.__init__(self, bot)
@@ -48,7 +47,7 @@ class Voice(Cog, VoiceExtension):
@Cog.listener()
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None:
gid = member.guild.id
guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'always_allow_menu': 1})
guild = await self.db.get_guild(gid, projection={'current_menu': 1})
channel = after.channel or before.channel
if not channel:
@@ -87,18 +86,7 @@ class Voice(Cog, VoiceExtension):
'repeat': False, 'shuffle': False, 'is_stopped': True
})
vc.stop()
elif len(channel.members) > 2 and not guild['always_allow_menu']:
if guild['current_menu']:
logging.info(f"[VOICE] Disabling current menu for guild {gid} due to multiple members")
await self.db.update(gid, {'current_menu': None, 'repeat': False, 'shuffle': False, 'vibing': False})
try:
message = await channel.fetch_message(guild['current_menu'])
await message.delete()
await channel.send("Меню отключено из-за большого количества участников.", delete_after=15)
except (discord.NotFound, discord.Forbidden):
pass
if member.guild.id in menu_views:
menu_views[member.guild.id].stop()
del menu_views[member.guild.id]
@@ -138,7 +126,7 @@ class Voice(Cog, VoiceExtension):
if not guild_id:
return
guild = await self.db.get_guild(guild_id, projection={'votes': 1, 'current_track': 1})
guild = await self.db.get_guild(guild_id)
votes = guild['votes']
if str(payload.message_id) not in votes:
@@ -158,54 +146,9 @@ class Voice(Cog, VoiceExtension):
required_votes = 2 if total_members <= 5 else 4 if total_members <= 10 else 6 if total_members <= 15 else 9
if len(vote_data['positive_votes']) >= required_votes:
logging.info(f"[VOICE] Enough positive votes for message {payload.message_id}")
if vote_data['action'] == 'next':
logging.info(f"[VOICE] Skipping track for message {payload.message_id}")
title = await self.next_track(payload)
await message.clear_reactions()
await message.edit(content=f"Сейчас играет: **{title}**!", delete_after=15)
del votes[str(payload.message_id)]
elif vote_data['action'] == 'add_track':
logging.info(f"[VOICE] Adding track for message {payload.message_id}")
await message.clear_reactions()
track = vote_data['vote_content']
if not track:
logging.info(f"[VOICE] Recieved empty vote context for message {payload.message_id}")
return
await self.db.modify_track(guild_id, track, 'next', 'append')
if guild['current_track']:
await message.edit(content=f"Трек был добавлен в очередь!", delete_after=15)
else:
title = await self.next_track(payload)
await message.edit(content=f"Сейчас играет: **{title}**!", delete_after=15)
del votes[str(payload.message_id)]
elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'):
logging.info(f"[VOICE] Performing '{vote_data['action']}' action for message {payload.message_id}")
await message.clear_reactions()
tracks = vote_data['vote_content']
if not tracks:
logging.info(f"[VOICE] Recieved empty vote context for message {payload.message_id}")
return
await self.db.update(guild_id, {'is_stopped': False})
await self.db.modify_track(guild_id, tracks, 'next', 'extend')
if guild['current_track']:
await message.edit(content=f"Контент был добавлен в очередь!", delete_after=15)
else:
title = await self.next_track(payload)
await message.edit(content=f"Сейчас играет: **{title}**!", delete_after=15)
del votes[str(payload.message_id)]
await message.delete()
await self.proccess_vote(payload, guild, channel, vote_data)
del votes[str(payload.message_id)]
elif len(vote_data['negative_votes']) >= required_votes:
logging.info(f"[VOICE] Enough negative votes for message {payload.message_id}")
@@ -224,9 +167,10 @@ class Voice(Cog, VoiceExtension):
guild_id = payload.guild_id
if not guild_id:
return
guild = await self.db.get_guild(guild_id, projection={'votes': 1})
votes = guild['votes']
if str(payload.message_id) not in votes:
logging.info(f"[VOICE] Message {payload.message_id} not found in votes")
return
@@ -257,38 +201,33 @@ class Voice(Cog, VoiceExtension):
await self.db.update(guild_id, {'votes': votes})
@voice.command(name="menu", description="Создать меню проигрывателя. Доступно только если вы единственный в голосовом канале.")
@voice.command(name="menu", description="Создать или обновить меню проигрывателя.")
async def menu(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Menu command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
if not await self.voice_check(ctx):
return
guild = await self.db.get_guild(ctx.guild.id, projection={'always_allow_menu': 1})
channel = cast(discord.VoiceChannel, ctx.channel)
if len(channel.members) > 2 and not guild['always_allow_menu']:
logging.info(f"[VOICE] Action declined: other members are present in the voice channel")
await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True)
return
await self.send_menu_message(ctx)
if await self.voice_check(ctx):
await self.send_menu_message(ctx)
@voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.")
async def join(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Join command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
member = cast(discord.Member, ctx.author)
guild = await self.db.get_guild(ctx.guild.id, projection={'allow_connect': 1})
guild = await self.db.get_guild(ctx.guild.id, projection={'allow_change_connect': 1})
vc = await self.get_voice_client(ctx)
if not member.guild_permissions.manage_channels and not guild['allow_connect']:
if not member.guild_permissions.manage_channels and not guild['allow_change_connect']:
response_message = "У вас нет прав для выполнения этой команды."
elif (vc := await self.get_voice_client(ctx)) and vc.is_connected():
elif vc and vc.is_connected():
response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave."
elif isinstance(ctx.channel, discord.VoiceChannel):
await ctx.channel.connect(timeout=15)
response_message = "Подключение успешно!"
try:
await ctx.channel.connect()
except TimeoutError:
response_message = "Не удалось подключиться к голосовому каналу."
else:
response_message = "✅ Подключение успешно!"
else:
response_message = "❌ Вы должны отправить команду в голосовом канале."
response_message = "❌ Вы должны отправить команду в чате голосового канала."
logging.info(f"[VOICE] Join command response: {response_message}")
await ctx.respond(response_message, delete_after=15, ephemeral=True)
@@ -298,22 +237,22 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] Leave command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
member = cast(discord.Member, ctx.author)
guild = await self.db.get_guild(ctx.guild.id, projection={'allow_connect': 1})
if not member.guild_permissions.manage_channels and not guild['allow_connect']:
guild = await self.db.get_guild(ctx.guild.id, projection={'allow_change_connect': 1})
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)
return
if (vc := await self.get_voice_client(ctx)) and await self.voice_check(ctx) and vc.is_connected:
res = await self.stop_playing(ctx, full=True)
if res:
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}")
return
else:
res = await self.stop_playing(ctx, vc=vc, full=True)
if not res:
await ctx.respond("Не удалось отключиться.", delete_after=15, ephemeral=True)
return
await vc.disconnect(force=True)
await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True)
logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild.id}")
else:
await ctx.respond("❌ Бот не подключен к голосовому каналу.", delete_after=15, ephemeral=True)
@@ -329,7 +268,7 @@ class Voice(Cog, VoiceExtension):
await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
elif await self.voice_check(ctx):
await self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': []})
await ctx.respond("Очередь и история сброшены.", delete_after=15, ephemeral=True)
await ctx.respond("Очередь и история сброшены.", delete_after=15, ephemeral=True)
logging.info(f"[VOICE] Queue and history cleared in guild {ctx.guild.id}")
@queue.command(description="Получить очередь треков.")
@@ -346,55 +285,7 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] Queue embed sent to user {ctx.author.id} in guild {ctx.guild.id}")
@track.command(description="Приостановить текущий трек.")
async def pause(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Pause command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
member = cast(discord.Member, ctx.author)
channel = cast(discord.VoiceChannel, ctx.channel)
if len(channel.members) > 2 and not member.guild_permissions.manage_channels:
logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to pause the track in guild {ctx.guild.id}")
await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True)
elif await self.voice_check(ctx) and (vc := await self.get_voice_client(ctx)) is not None:
if not vc.is_paused():
vc.pause()
menu = await self.db.get_current_menu(ctx.guild.id)
if menu:
await self.update_menu_full(ctx, menu)
logging.info(f"[VOICE] Track paused in guild {ctx.guild.id}")
await ctx.respond("Воспроизведение приостановлено.", delete_after=15, ephemeral=True)
else:
logging.info(f"[VOICE] Track already paused in guild {ctx.guild.id}")
await ctx.respond("Воспроизведение уже приостановлено.", delete_after=15, ephemeral=True)
@track.command(description="Возобновить текущий трек.")
async def resume(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Resume command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
member = cast(discord.Member, ctx.author)
channel = cast(discord.VoiceChannel, ctx.channel)
if len(channel.members) > 2 and not member.guild_permissions.manage_channels:
logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to resume the track in guild {ctx.guild.id}")
await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True)
elif await self.voice_check(ctx) and (vc := await self.get_voice_client(ctx)):
if vc.is_paused():
vc.resume()
menu = await self.db.get_current_menu(ctx.guild.id)
if menu:
await self.update_menu_full(ctx, menu)
logging.info(f"[VOICE] Track resumed in guild {ctx.guild.id}")
await ctx.respond("Воспроизведение восстановлено.", delete_after=15, ephemeral=True)
else:
logging.info(f"[VOICE] Track is not paused in guild {ctx.guild.id}")
await ctx.respond("Воспроизведение не на паузе.", delete_after=15, ephemeral=True)
@track.command(description="Прервать проигрывание, удалить историю, очередь и текущий плеер.")
@voice.command(description="Прервать проигрывание, удалить историю, очередь и текущий плеер.")
async def stop(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Stop command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
@@ -408,115 +299,10 @@ class Voice(Cog, VoiceExtension):
elif await self.voice_check(ctx):
res = await self.stop_playing(ctx, full=True)
if res:
await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True)
await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True)
else:
await ctx.respond("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15, ephemeral=True)
@track.command(description="Переключиться на следующую песню в очереди.")
async def next(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Next command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
if not await self.voice_check(ctx):
return
gid = ctx.guild.id
guild = await self.db.get_guild(gid, projection={'next_tracks': 1, 'vote_next_track': 1})
if not guild['next_tracks']:
logging.info(f"[VOICE] No tracks in queue in guild {ctx.guild.id}")
await ctx.respond("❌ Нет песенен в очереди.", delete_after=15, ephemeral=True)
return
member = cast(discord.Member, ctx.author)
channel = cast(discord.VoiceChannel, ctx.channel)
if guild['vote_next_track'] and len(channel.members) > 2 and not member.guild_permissions.manage_channels:
logging.info(f"[VOICE] User {ctx.author.id} started vote to skip track in guild {ctx.guild.id}")
message = cast(discord.Interaction, await ctx.respond(f"{member.mention} хочет пропустить текущий трек.\n\nВыполнить переход?", delete_after=30))
response = await message.original_response()
await response.add_reaction('')
await response.add_reaction('')
await self.db.update_vote(
gid,
response.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': 'next',
'vote_content': None
}
)
else:
logging.info(f"[VOICE] Skipping vote for user {ctx.author.id} in guild {ctx.guild.id}")
await self.db.update(gid, {'is_stopped': False})
title = await self.next_track(ctx)
await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15)
@track.command(description="Добавить трек в избранное или убрать, если он уже там.")
async def like(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Like command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
if not await self.voice_check(ctx):
return
vc = await self.get_voice_client(ctx)
if not vc or not vc.is_playing:
logging.info(f"[VOICE] No current track in {ctx.guild.id}")
await ctx.respond("Нет воспроизводимого трека.", delete_after=15, ephemeral=True)
return
result = await self.react_track(ctx, 'like')
if not result[0]:
logging.warning(f"Like command failed for user {ctx.author.id} in guild {ctx.guild.id}")
await ctx.respond("❌ Операция не удалась.", delete_after=15, ephemeral=True)
elif result[1] == 'removed':
logging.info(f"[VOICE] Track removed from favorites for user {ctx.author.id} in guild {ctx.guild.id}")
await ctx.respond("Трек был удалён из избранного.", delete_after=15, ephemeral=True)
elif result[1] == 'added':
logging.info(f"[VOICE] Track added to favorites for user {ctx.author.id} in guild {ctx.guild.id}")
await ctx.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True)
else:
raise ValueError(f"Unknown like command result: '{result}'")
@track.command(name='vibe', description="Запустить Мою Волну по текущему треку.")
async def track_vibe(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Vibe (track) command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
if not await self.voice_check(ctx):
return
guild = await self.db.get_guild(ctx.guild.id, projection={'always_allow_menu': 1, 'current_track': 1, 'current_menu': 1, 'vibing': 1})
channel = cast(discord.VoiceChannel, ctx.channel)
if len(channel.members) > 2 and not guild['always_allow_menu']:
logging.info(f"[VOICE] Action declined: other members are present in the voice channel")
await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True)
return
if guild['vibing']:
logging.info(f"[VOICE] Action declined: vibing is already enabled in guild {ctx.guild.id}")
await ctx.respond("❌ Моя Волна уже включена. Используйте /track stop, чтобы остановить воспроизведение.", ephemeral=True)
return
if not guild['current_track']:
logging.info(f"[VOICE] No current track in {ctx.guild.id}")
await ctx.respond("❌ Нет воспроизводимого трека.", ephemeral=True)
return
feedback = await self.update_vibe(ctx, 'track', guild['current_track']['id'])
if not feedback:
await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", ephemeral=True)
return
if not guild['current_menu']:
await self.send_menu_message(ctx, disable=True)
next_track = await self.db.get_track(ctx.guild_id, 'next')
if next_track:
await self._play_next_track(ctx, next_track)
@voice.command(name='vibe', description="Запустить Мою Волну.")
@discord.option(
"запрос",
@@ -531,18 +317,14 @@ class Voice(Cog, VoiceExtension):
if not await self.voice_check(ctx):
return
guild = await self.db.get_guild(ctx.guild.id, projection={'always_allow_menu': 1, 'current_menu': 1, 'vibing': 1})
channel = cast(discord.VoiceChannel, ctx.channel)
guild = await self.db.get_guild(ctx.guild.id, projection={'current_menu': 1, 'vibing': 1})
if len(channel.members) > 2 and not guild['always_allow_menu']:
logging.info(f"[VOICE] Action declined: other members are present in the voice channel")
await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True)
return
if guild['vibing']:
logging.info(f"[VOICE] Action declined: vibing is already enabled in guild {ctx.guild.id}")
await ctx.respond("❌ Моя Волна уже включена. Используйте /track stop, чтобы остановить воспроизведение.", ephemeral=True)
await ctx.respond("❌ Моя Волна уже включена. Используйте /track stop, чтобы остановить воспроизведение.", delete_after=15, ephemeral=True)
return
await ctx.defer(invisible=False)
if name:
token = await users_db.get_ym_token(ctx.user.id)
if not token:
@@ -564,27 +346,29 @@ class Voice(Cog, VoiceExtension):
if not content:
logging.debug(f"[VOICE] Station {name} not found")
await ctx.respond("❌ Станция не найдена.", ephemeral=True)
await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True)
return
_type, _id = content.ad_params.other_params.split(':') if content.ad_params else (None, None)
if not _type or not _id:
logging.debug(f"[VOICE] Station {name} has no ad params")
await ctx.respond("❌ Станция не найдена.", ephemeral=True)
await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True)
return
feedback = await self.update_vibe(ctx, _type, _id)
else:
feedback = await self.update_vibe(ctx, 'user', 'onyourwave')
_type, _id = 'user', 'onyourwave'
feedback = await self.update_vibe(ctx, _type, _id)
if not feedback:
await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", ephemeral=True)
await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True)
return
if not guild['current_menu']:
if guild['current_menu']:
await ctx.respond("✅ Моя Волна включена.", delete_after=15, ephemeral=True)
else:
await self.send_menu_message(ctx, disable=True)
next_track = await self.db.get_track(ctx.guild_id, 'next')
if next_track:
await self._play_next_track(ctx, next_track)
await self._play_track(ctx, next_track)

View File

@@ -80,12 +80,9 @@ class BaseGuildsDatabase:
current_menu=None,
is_stopped=True,
always_allow_menu=False,
allow_connect=True,
vote_next_track=True,
vote_add_track=True,
vote_add_album=True,
vote_add_artist=True,
vote_add_playlist=True,
allow_change_connect=True,
vote_switch_track=True,
vote_add=True,
shuffle=False,
repeat=False,
votes={},

View File

@@ -19,28 +19,29 @@ class VoiceGuildsDatabase(BaseGuildsDatabase):
if list_type not in ('next', 'previous', 'current'):
raise ValueError("list_type must be either 'next' or 'previous'")
if list_type == 'current':
return (await self.get_guild(gid, projection={'current_track': 1}))['current_track']
field = f'{list_type}_tracks'
update = {'$pop': {field: -1}}
guild = await self.get_guild(gid, projection={'current_track': 1, field: 1})
if list_type == 'current':
return guild['current_track']
result = await guilds.find_one_and_update(
{'_id': gid},
update,
{'$pop': {field: -1}},
projection={field: 1},
return_document=ReturnDocument.BEFORE
)
res = result.get(field, [])[0] if result and result.get(field) else None
res = result.get(field, []) if result else None
if field == 'previous_tracks' and res:
await guilds.find_one_and_update(
{'_id': gid},
{'$push': {'next_tracks': {'$each': [res], '$position': 0}}},
{'$push': {'next_tracks': {'$each': [guild['current_track']], '$position': 0}}},
projection={'next_tracks': 1}
)
return res
return res[0] if res else None
async def modify_track(
self,

View File

@@ -4,8 +4,8 @@ class MessageVotes(TypedDict):
positive_votes: list[int]
negative_votes: list[int]
total_members: int
action: Literal['next', 'add_track', 'add_album', 'add_artist', 'add_playlist']
vote_content: dict[str, Any] | list[dict[str, Any]] | None
action: Literal['next', 'play/pause', 'repeat', 'shuffle', 'previous', 'add_track', 'add_album', 'add_artist', 'add_playlist']
vote_content: Any | None
class Guild(TypedDict, total=False):
next_tracks: list[dict[str, Any]]
@@ -14,12 +14,9 @@ class Guild(TypedDict, total=False):
current_menu: int | None
is_stopped: bool
always_allow_menu: bool
allow_connect: bool
vote_next_track: bool
vote_add_track: bool
vote_add_album: bool
vote_add_artist: bool
vote_add_playlist: bool
allow_change_connect: bool
vote_switch_track: bool
vote_add: bool
shuffle: bool
repeat: bool
votes: dict[str, MessageVotes]
@@ -34,12 +31,9 @@ class ExplicitGuild(TypedDict):
current_menu: int | None
is_stopped: bool # Prevents the `after` callback of play_track
always_allow_menu: bool
allow_connect: bool
vote_next_track: bool
vote_add_track: bool
vote_add_album: bool
vote_add_artist: bool
vote_add_playlist: bool
allow_change_connect: bool
vote_switch_track: bool
vote_add: bool
shuffle: bool
repeat: bool
votes: dict[str, MessageVotes]

View File

@@ -1,5 +1,5 @@
import logging
from typing import Any, Literal, cast
from typing import cast
import discord
from yandex_music import Track, Album, Artist, Playlist
@@ -27,40 +27,39 @@ class PlayButton(Button, VoiceExtension):
return
gid = interaction.guild.id
guild = await self.db.get_guild(gid, projection={'current_track': 1, 'current_menu': 1, 'vote_add_track': 1, 'vote_add_album': 1, 'vote_add_artist': 1, 'vote_add_playlist': 1})
guild = await self.db.get_guild(gid, projection={'current_track': 1, 'current_menu': 1, 'vote_add': 1})
channel = cast(discord.VoiceChannel, interaction.channel)
member = cast(discord.Member, interaction.user)
action: Literal['add_track', 'add_album', 'add_artist', 'add_playlist']
if isinstance(self.item, Track):
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)
await interaction.respond("Не удалось получить треки альбома.", ephemeral=True)
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)
await interaction.respond("Не удалось получить треки артиста.", ephemeral=True)
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()
@@ -72,7 +71,7 @@ class PlayButton(Button, VoiceExtension):
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()
@@ -83,12 +82,12 @@ class PlayButton(Button, VoiceExtension):
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__}'")
if guild.get(f'vote_{action}') and len(channel.members) > 2 and not member.guild_permissions.manage_channels:
if guild['vote_add'] and len(channel.members) > 2 and not member.guild_permissions.manage_channels:
logging.debug(f"Starting vote for '{action}' (from PlayButton callback)")
message = cast(discord.Interaction, await interaction.respond(vote_message, delete_after=30))
@@ -108,26 +107,28 @@ class PlayButton(Button, VoiceExtension):
'vote_content': [track.to_dict() for track in tracks]
}
)
return
logging.debug(f"[FIND] Skipping vote for '{action}'")
if guild['current_menu']:
await interaction.respond(response_message, delete_after=15)
else:
logging.debug(f"[FIND] Skipping vote for '{action}' (from PlayButton callback)")
await self.send_menu_message(interaction, disable=True)
current_menu = await self.get_menu_message(interaction, guild['current_menu']) if guild['current_menu'] else None
if guild['current_track'] is not None:
logging.debug(f"[FIND] Adding tracks to queue")
await self.db.modify_track(gid, tracks, 'next', 'extend')
else:
logging.debug(f"[FIND] Playing track")
track = tracks.pop(0)
await self.db.modify_track(gid, tracks, 'next', 'extend')
await self.play_track(interaction, track)
if guild['current_track'] is not None:
logging.debug(f"[FIND] Adding tracks to queue (from PlayButton callback)")
await self.db.modify_track(gid, tracks, 'next', 'extend')
else:
logging.debug(f"[FIND] Playing track (from PlayButton callback)")
track = tracks.pop(0)
await self.db.modify_track(gid, tracks, 'next', 'extend')
await self.play_track(interaction, track)
response_message = f"Сейчас играет: **{track.title}**!"
if current_menu and interaction.message:
logging.debug(f"[FIND] Deleting interaction message {interaction.message.id}: current player {current_menu.id} found")
await interaction.message.delete()
else:
await interaction.respond(response_message, delete_after=15)
if interaction.message:
await interaction.message.delete()
else:
logging.warning(f"[FIND] Interaction message is None")
class MyVibeButton(Button, VoiceExtension):
def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *args, **kwargs):
@@ -145,14 +146,6 @@ class MyVibeButton(Button, VoiceExtension):
logging.warning(f"[VIBE] Guild ID is None in button callback")
return
guild = await self.db.get_guild(gid)
channel = cast(discord.VoiceChannel, interaction.channel)
if len(channel.members) > 2 and not guild['always_allow_menu']:
logging.info(f"[VIBE] Button callback declined: other members are present in the voice channel")
await interaction.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True)
return
track_type_map = {
Track: 'track', Album: 'album', Artist: 'artist', Playlist: 'playlist', list: 'user'
}
@@ -178,7 +171,7 @@ class MyVibeButton(Button, VoiceExtension):
next_track = await self.db.get_track(gid, 'next')
if next_track:
await self._play_next_track(interaction, next_track)
await self._play_track(interaction, next_track)
class ListenView(View):
def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *items: Item, timeout: float | None = 360, disable_on_timeout: bool = True):
@@ -217,3 +210,4 @@ class ListenView(View):
return await super().on_timeout()
except discord.NotFound:
pass
self.stop()

View File

@@ -2,7 +2,7 @@ import logging
from typing import Self, cast
from discord.ui import View, Button, Item, Select
from discord import VoiceChannel, ButtonStyle, Interaction, ApplicationContext, RawReactionActionEvent, Embed, ComponentType, SelectOption
from discord import VoiceChannel, ButtonStyle, Interaction, ApplicationContext, RawReactionActionEvent, Embed, ComponentType, SelectOption, Member
import yandex_music.exceptions
from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient
@@ -13,14 +13,14 @@ class ToggleButton(Button, VoiceExtension):
super().__init__(*args, **kwargs)
VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction):
async def callback(self, interaction: Interaction) -> None:
callback_type = interaction.custom_id
if callback_type 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):
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)
return
@@ -29,9 +29,36 @@ class ToggleButton(Button, VoiceExtension):
return
guild = await self.db.get_guild(gid)
member = cast(Member, interaction.user)
channel = cast(VoiceChannel, interaction.channel)
if len(channel.members) > 2 and not member.guild_permissions.manage_channels:
logging.info(f"[MENU] User {interaction.user.id} started vote to pause/resume track in guild {gid}")
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))
response = await message.original_response()
await response.add_reaction('')
await response.add_reaction('')
await self.db.update_vote(
gid,
response.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': callback_type,
'vote_content': None
}
)
return
await self.db.update(gid, {callback_type: not guild[callback_type]})
if not await self.update_menu_view(interaction, guild, button_callback=True):
if not await self.update_menu_view(interaction, button_callback=True):
await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True)
class PlayPauseButton(Button, VoiceExtension):
@@ -44,9 +71,39 @@ class PlayPauseButton(Button, VoiceExtension):
if not await self.voice_check(interaction, check_vibe_privilage=True):
return
if not (gid := interaction.guild_id) or not interaction.user:
logging.warning('[MENU] Failed to get guild ID or user.')
return
if not (vc := await self.get_voice_client(interaction)) or not interaction.message:
return
member = cast(Member, interaction.user)
channel = cast(VoiceChannel, interaction.channel)
if len(channel.members) > 2 and not member.guild_permissions.manage_channels:
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))
response = await message.original_response()
await response.add_reaction('')
await response.add_reaction('')
await self.db.update_vote(
gid,
response.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': "play/pause",
'vote_content': None
}
)
return
try:
embed = interaction.message.embeds[0]
except IndexError:
@@ -67,23 +124,61 @@ class SwitchTrackButton(Button, VoiceExtension):
super().__init__(*args, **kwargs)
VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction):
async def callback(self, interaction: Interaction) -> None:
callback_type = interaction.custom_id
if callback_type not in ('next', 'previous'):
raise ValueError(f"Invalid callback type: '{callback_type}'")
if not (gid := interaction.guild_id) or not interaction.user:
logging.warning(f"[MENU] {callback_type.capitalize()} track button callback without guild id or user")
return
logging.info(f'[MENU] {callback_type.capitalize()} track button callback')
if not await self.voice_check(interaction, check_vibe_privilage=True):
return
tracks_type = callback_type + '_tracks'
guild = await self.db.get_guild(gid, projection={tracks_type: 1, 'vote_switch_track': 1})
if not guild[tracks_type]:
logging.info(f"[MENU] No tracks in '{tracks_type}' list in guild {gid}")
await interaction.respond(f"❌ Нет треков в {'очереди' if callback_type == 'next' else 'истории'}.", delete_after=15, ephemeral=True)
return
member = cast(Member, interaction.user)
channel = cast(VoiceChannel, interaction.channel)
if guild['vote_switch_track'] and len(channel.members) > 2 and not member.guild_permissions.manage_channels:
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))
response = await message.original_response()
await response.add_reaction('')
await response.add_reaction('')
await self.db.update_vote(
gid,
response.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': callback_type,
'vote_content': None
}
)
return
if callback_type == 'next':
title = await self.next_track(interaction, button_callback=True)
else:
title = await self.prev_track(interaction, button_callback=True)
title = await self.previous_track(interaction, button_callback=True)
if not title:
await interaction.respond(f"Нет треков в очереди.", delete_after=15, ephemeral=True)
await interaction.respond(f"Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True)
class ReactionButton(Button, VoiceExtension):
def __init__(self, *args, **kwargs):
@@ -103,16 +198,32 @@ class ReactionButton(Button, VoiceExtension):
if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing:
await interaction.respond("❌ Нет воспроизводимого трека.", 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=menu_views[gid])
await interaction.respond(
f"✅ Трек был {'добавлен в понравившиеся.' if res[1] == 'added' else 'удалён из понравившихся.'}",
delete_after=15, ephemeral=True
)
elif callback_type == 'dislike' and res[0]:
await self.next_track(interaction, vc=vc, button_callback=True)
if len(channel.members) == 2 and not await self.next_track(interaction, vc=vc, button_callback=True):
await interaction.respond("✅ Воспроизведение приостановлено. Нет треков в очереди.", delete_after=15)
await self._update_menu_views_dict(interaction)
await interaction.edit(view=menu_views[gid])
await interaction.respond(
f"✅ Трек был {'добавлен в дизлайки.' if res[1] == 'added' else 'удалён из дизлайков.'}",
delete_after=15, ephemeral=True
)
else:
logging.debug(f"[VC_EXT] Failed to {callback_type} track")
await interaction.respond("❌ Операция не удалась. Попробуйте позже.")
logging.debug(f"[VC_EXT] Failed to get {callback_type} tracks")
await interaction.respond("❌ Операция не удалась. Попробуйте позже.", delete_after=15, ephemeral=True)
class LyricsButton(Button, VoiceExtension):
def __init__(self, **kwargs):
@@ -192,7 +303,7 @@ class MyVibeButton(Button, VoiceExtension):
if next_track:
# Need to avoid additional feedback.
# TODO: Make it more elegant
await self._play_next_track(interaction, next_track, button_callback=True)
await self._play_track(interaction, next_track, button_callback=True)
class MyVibeSelect(Select, VoiceExtension):
def __init__(self, *args, **kwargs):
@@ -214,23 +325,23 @@ class MyVibeSelect(Select, VoiceExtension):
logging.warning(f'[MENU] Unknown custom_id: {custom_id}')
return
if not interaction.data or 'values' not in interaction.data:
if not interaction.data:
logging.warning('[MENU] No data in select callback')
return
data_value = interaction.data['values'][0]
if data_value not in (
data_values = cast(list[str] | None, interaction.data.get('values'))
if not data_values or data_values[0] not in (
'fun', 'active', 'calm', 'sad', 'all',
'favorite', 'popular', 'discover', 'default',
'not-russian', 'russian', 'without-words', 'any'
):
logging.warning(f'[MENU] Unknown data_value: {data_value}')
logging.warning(f'[MENU] Unknown data_value: {data_values}')
return
logging.info(f"[MENU] Settings option '{custom_id}' updated to {data_value}")
await self.users_db.update(interaction.user.id, {f'vibe_settings.{custom_id}': data_value})
logging.info(f"[MENU] Settings option '{custom_id}' updated to '{data_values[0]}'")
await self.users_db.update(interaction.user.id, {f'vibe_settings.{custom_id}': data_values[0]})
view = MyVibeSettingsView(interaction)
view = await MyVibeSettingsView(interaction).init()
view.disable_all_items()
await interaction.edit(view=view)
@@ -330,10 +441,15 @@ class AddToPlaylistSelect(Select, VoiceExtension):
logging.warning('[MENU] No data in select callback')
return
data = interaction.data['values'][0].split(';')
logging.debug(f"[MENU] Add to playlist select callback: {data}")
data_values = cast(list[str] | None, interaction.data.get('values'))
logging.debug(f"[MENU] Add to playlist select callback: {data_values}")
playlist = cast(Playlist, await self.ym_client.users_playlists(kind=data[0], user_id=data[1]))
if not data_values:
logging.warning('[MENU] No data in select callback')
return
kind, user_id = data_values[0].split(';')
playlist = cast(Playlist, await self.ym_client.users_playlists(kind=kind, user_id=user_id))
current_track = await self.db.get_track(interaction.guild_id, 'current')
if not current_track:
@@ -362,14 +478,19 @@ class AddToPlaylistButton(Button, VoiceExtension):
return
client = await self.init_ym_client(interaction)
if not client or not client.me or not client.me.account or not client.me.account.uid:
await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', ephemeral=True)
if not client:
await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', 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)
return
playlists = await client.users_playlists_list()
if not playlists:
await interaction.respond('У вас нет плейлистов.', delete_after=15, ephemeral=True)
return
view = View(
AddToPlaylistSelect(
client,
@@ -379,7 +500,7 @@ class AddToPlaylistButton(Button, VoiceExtension):
SelectOption(
label=playlist.title or "Без названия",
value=f"{playlist.kind or "-1"};{playlist.uid}"
) for playlist in await client.users_playlists_list(client.me.account.uid)
) for playlist in playlists
]
)
)
@@ -427,10 +548,9 @@ class MenuView(View, VoiceExtension):
self.add_item(self.next_button)
self.add_item(self.shuffle_button)
if not isinstance(self.ctx, RawReactionActionEvent) and len(cast(VoiceChannel, self.ctx.channel).members) > 2:
self.dislike_button.disabled = True
elif likes and current_track and str(current_track['id']) in [str(like.id) for like in likes]:
self.like_button.style = ButtonStyle.success
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]:
self.like_button.style = ButtonStyle.success
if not current_track:
self.lyrics_button.disabled = True
@@ -444,7 +564,7 @@ class MenuView(View, VoiceExtension):
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)
else:
@@ -469,3 +589,4 @@ class MenuView(View, VoiceExtension):
logging.debug('[MENU] Successfully deleted menu message')
else:
logging.debug('[MENU] No menu message found')
self.stop()