Merge pull request #2 from Lemon4ksan/dev

Обновление бота #2
This commit is contained in:
Bananchiki
2025-02-21 19:44:58 +03:00
committed by GitHub
9 changed files with 711 additions and 685 deletions

View File

@@ -1,19 +1,16 @@
import logging import logging
from typing import Literal, cast from typing import Literal
from asyncio import gather from asyncio import gather
import discord import discord
from discord.ext.commands import Cog from discord.ext.commands import Cog
import yandex_music from yandex_music.exceptions import UnauthorizedError
import yandex_music.exceptions
from yandex_music import ClientAsync as YMClient from yandex_music import ClientAsync as YMClient
from yandex_music import Track, Album, Artist, Playlist
from MusicBot.database import BaseUsersDatabase, BaseGuildsDatabase
from MusicBot.ui import ListenView from MusicBot.ui import ListenView
from MusicBot.cogs.utils.embeds import generate_item_embed from MusicBot.database import BaseUsersDatabase, BaseGuildsDatabase
from MusicBot.cogs.utils import generate_item_embed
users_db = BaseUsersDatabase() users_db = BaseUsersDatabase()
@@ -24,41 +21,37 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
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) uid = ctx.interaction.user.id
token = await users_db.get_ym_token(uid)
if not token: if not token:
logging.info(f"[GENERAL] User {ctx.interaction.user.id} has no token") logging.info(f"[GENERAL] User {uid} has no token")
return [] return []
try: try:
client = await YMClient(token).init() client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.interaction.user.id} provided invalid token") logging.info(f"[GENERAL] User {uid} provided invalid token")
return [] return []
content_type = ctx.options['тип'] content_type = ctx.options['тип']
search = await client.search(ctx.value) search = await client.search(ctx.value)
if not search: if not search:
logging.warning(f"[GENERAL] Failed to search for '{ctx.value}' for user {ctx.interaction.user.id}") logging.warning(f"[GENERAL] Failed to search for '{ctx.value}' for user {uid}")
return [] return []
res = [] logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}")
logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {ctx.interaction.user.id}")
if content_type == 'Трек' and search.tracks: if content_type == 'Трек' and search.tracks is not None:
for item in search.tracks.results: res = [f"{item.title} {f"({item.version})" if item.version else ''} - {", ".join(item.artists_name())}" for item in search.tracks.results]
res.append(f"{item.title} {f"({item.version})" if item.version else ''} - {", ".join(item.artists_name())}") elif content_type == 'Альбом' and search.albums is not None:
elif content_type == 'Альбом' and search.albums: res = [f"{item.title} - {", ".join(item.artists_name())}" for item in search.albums.results]
for item in search.albums.results: elif content_type == 'Артист' and search.artists is not None:
res.append(f"{item.title} - {", ".join(item.artists_name())}") res = [f"{item.name}" for item in search.artists.results]
elif content_type == 'Артист' and search.artists: elif content_type == 'Плейлист' and search.playlists is not None:
for item in search.artists.results: res = [f"{item.title}" for item in search.playlists.results]
res.append(f"{item.name}") else:
elif content_type == 'Плейлист' and search.playlists: logging.warning(f"[GENERAL] Invalid content type '{content_type}' for user {uid}")
for item in search.playlists.results: return []
res.append(f"{item.title}")
elif content_type == "Свой плейлист":
playlists_list = await client.users_playlists_list()
res = [playlist.title if playlist.title else 'Без названия' for playlist in playlists_list]
return res[:100] return res[:100]
@@ -66,17 +59,20 @@ async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> li
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) uid = ctx.interaction.user.id
token = await users_db.get_ym_token(uid)
if not token: if not token:
logging.info(f"[GENERAL] User {ctx.interaction.user.id} has no token") logging.info(f"[GENERAL] User {uid} has no token")
return [] return []
try: try:
client = await YMClient(token).init() client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.interaction.user.id} provided invalid token") logging.info(f"[GENERAL] User {uid} provided invalid token")
return [] return []
logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}")
playlists_list = await client.users_playlists_list() playlists_list = await client.users_playlists_list()
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]
@@ -94,12 +90,11 @@ class General(Cog):
"command", "command",
description="Название команды.", description="Название команды.",
type=discord.SlashCommandOptionType.string, type=discord.SlashCommandOptionType.string,
default='all' required=False
) )
async def help(self, ctx: discord.ApplicationContext, command: str) -> None: async def help(self, ctx: discord.ApplicationContext, command: str = 'all') -> None:
logging.info(f"[GENERAL] Help command invoked by {ctx.user.id} for command '{command}'") logging.info(f"[GENERAL] Help command invoked by {ctx.user.id} for command '{command}'")
response_message = None
embed = discord.Embed( embed = discord.Embed(
title='Помощь', title='Помощь',
color=0xfed42b color=0xfed42b
@@ -113,10 +108,8 @@ class General(Cog):
"Зарегистрируйте свой токен с помощью /login. Его можно получить [здесь](https://github.com/MarshalX/yandex-music-api/discussions/513).\n" "Зарегистрируйте свой токен с помощью /login. Его можно получить [здесь](https://github.com/MarshalX/yandex-music-api/discussions/513).\n"
"Для получения помощи по конкретной команде, введите /help <команда>.\n" "Для получения помощи по конкретной команде, введите /help <команда>.\n"
"Для изменения настроек необходимо иметь права управления каналами на сервере.\n\n" "Для изменения настроек необходимо иметь права управления каналами на сервере.\n\n"
"Помните, что это **не замена Яндекс Музыки**, а лишь её дополнение. Не ожидайте безупречного звука.\n\n" "**Присоединяйтесь к нашему [серверу сообщества](https://discord.gg/TgnW8nfbFn)!**"
"**Для дополнительной помощи, присоединяйтесь к [серверу любителей Яндекс Музыки](https://discord.gg/gkmFDaPMeC).**"
) )
embed.add_field( embed.add_field(
name='__Основные команды__', name='__Основные команды__',
value="""`account` value="""`account`
@@ -124,10 +117,8 @@ class General(Cog):
`help` `help`
`queue` `queue`
`settings` `settings`
`track`
`voice`""" `voice`"""
) )
embed.set_footer(text='©️ Bananchiki') embed.set_footer(text='©️ Bananchiki')
elif command == 'account': elif command == 'account':
embed.description += ( embed.description += (
@@ -145,8 +136,7 @@ class General(Cog):
) )
elif command == 'help': elif command == 'help':
embed.description += ( embed.description += (
"Вывести список всех команд.\n```/help```\n" "Вывести список всех команд или информацию по конкретной команде.\n```/help <команда>```\n"
"Получить информацию о конкретной команде.\n```/help <команда>```"
) )
elif command == 'queue': elif command == 'queue':
embed.description += ( embed.description += (
@@ -158,34 +148,22 @@ class General(Cog):
embed.description += ( embed.description += (
"`Примечание`: Только пользователи с разрешением управления каналом могут менять настройки.\n\n" "`Примечание`: Только пользователи с разрешением управления каналом могут менять настройки.\n\n"
"Получить текущие настройки.\n```/settings show```\n" "Получить текущие настройки.\n```/settings show```\n"
"Разрешить или запретить воспроизведение Explicit треков и альбомов. Если автор или плейлист содержат Explicit треки, убираются кнопки для доступа к ним.\n```/settings explicit```\n" "Переключить параметр настроек.\n```/settings toggle <параметр>```\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```"
) )
elif command == 'voice': elif command == 'voice':
embed.description += ( embed.description += (
"`Примечание`: Доступность меню и Моей Волны зависит от настроек сервера.\n\n" "`Примечание`: Доступность меню и Моей Волны зависит от настроек сервера.\n\n"
"Присоединить бота в голосовой канал.\n```/voice join```\n" "Присоединить бота в голосовой канал.\n```/voice join```\n"
"Заставить бота покинуть голосовой канал.\n ```/voice leave```\n" "Заставить бота покинуть голосовой канал.\n ```/voice leave```\n"
"Прервать проигрывание, удалить историю, очередь и текущий плеер.\n ```/voice stop```\n"
"Создать меню проигрывателя. \n```/voice menu```\n" "Создать меню проигрывателя. \n```/voice menu```\n"
"Запустить станцию. Без уточнения станции, запускает Мою Волну.\n```/voice vibe <название станции>```" "Запустить станцию. Без уточнения станции, запускает Мою Волну.\n```/voice vibe <название станции>```"
) )
else: else:
response_message = '❌ Неизвестная команда.' await ctx.respond('❌ Неизвестная команда.', delete_after=15, ephemeral=True)
embed = None return
await ctx.respond(response_message, 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="Токен.")
@@ -193,16 +171,20 @@ class General(Cog):
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 yandex_music.exceptions.UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] Invalid token provided by user {ctx.author.id}") logging.info(f"[GENERAL] Invalid token provided by user {ctx.author.id}")
await ctx.respond('❌ Недействительный токен.', delete_after=15, ephemeral=True) await ctx.respond('❌ Недействительный токен.', delete_after=15, ephemeral=True)
return return
about = cast(yandex_music.Status, client.me).to_dict()
uid = ctx.author.id
await self.users_db.update(uid, {'ym_token': token}) if not client.me or not client.me.account:
logging.info(f"[GENERAL] Token saved for user {ctx.author.id}") logging.warning(f"[GENERAL] Failed to get user info for user {ctx.author.id}")
await ctx.respond(f'Привет, {about['account']['first_name']}!', delete_after=15, ephemeral=True) await ctx.respond('Не удалось получить информацию о пользователе.', delete_after=15, ephemeral=True)
return
await self.users_db.update(ctx.author.id, {'ym_token': token})
await ctx.respond(f'✅ Привет, {client.me.account.first_name}!', delete_after=15, ephemeral=True)
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:
@@ -213,7 +195,8 @@ class General(Cog):
return return
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) await ctx.respond(f'Токен был удалён.', delete_after=15, ephemeral=True)
logging.info(f"[GENERAL] Token removed for user {ctx.author.id}")
@account.command(description="Получить плейлист «Мне нравится»") @account.command(description="Получить плейлист «Мне нравится»")
async def likes(self, ctx: discord.ApplicationContext) -> None: async def likes(self, ctx: discord.ApplicationContext) -> None:
@@ -241,11 +224,12 @@ class General(Cog):
await ctx.respond('У вас нет треков в плейлисте «Мне нравится».', delete_after=15, ephemeral=True) await ctx.respond('У вас нет треков в плейлисте «Мне нравится».', delete_after=15, ephemeral=True)
return 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) 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 tracks = [track for track in real_tracks if not isinstance(track, BaseException)] # Can't fetch user tracks
embed = await generate_item_embed(tracks)
logging.info(f"[GENERAL] Successfully fetched likes for user {ctx.user.id}") await ctx.respond(embed=await generate_item_embed(tracks), view=ListenView(tracks))
await ctx.respond(embed=embed, view=ListenView(tracks)) logging.info(f"[GENERAL] Successfully generated likes message for user {ctx.user.id}")
@account.command(description="Получить ваши рекомендации.") @account.command(description="Получить ваши рекомендации.")
@discord.option( @discord.option(
@@ -263,15 +247,19 @@ 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}'")
guild = await self.db.get_guild(ctx.guild_id)
token = await self.users_db.get_ym_token(ctx.user.id) token = await self.users_db.get_ym_token(ctx.user.id)
if not token: if not token:
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return return
try:
client = await YMClient(token).init() 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 = await client.search(content_type, False, 'playlist') search = await client.search(content_type, type_='playlist')
if not search or not search.playlists: if not search or not search.playlists:
logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}") logging.info(f"[GENERAL] Failed to fetch recommendations for user {ctx.user.id}")
await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True)
@@ -288,18 +276,7 @@ class General(Cog):
await ctx.respond("❌ Пустой плейлист.", delete_after=15, ephemeral=True) await ctx.respond("❌ Пустой плейлист.", delete_after=15, ephemeral=True)
return return
embed = await generate_item_embed(playlist) await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist))
view = ListenView(playlist)
for track_short in playlist.tracks:
track = cast(Track, track_short.track)
if (track.explicit or track.content_warning) and not guild['allow_explicit']:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{content_type}' returned explicit content and is not allowed on this server")
embed.set_footer(text="Воспроизведение недоступно, так как в плейлисте присутствуют Explicit треки")
view = None
break
await ctx.respond(embed=embed, view=view)
@account.command(description="Получить ваш плейлист.") @account.command(description="Получить ваш плейлист.")
@discord.option( @discord.option(
@@ -312,7 +289,6 @@ 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] Playlists command invoked by user {ctx.user.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Playlists command invoked by user {ctx.user.id} in guild {ctx.guild_id}")
guild = await self.db.get_guild(ctx.guild_id, projection={'allow_explicit': 1})
token = await self.users_db.get_ym_token(ctx.user.id) token = await self.users_db.get_ym_token(ctx.user.id)
if not token: if not token:
logging.info(f"[GENERAL] No token found for user {ctx.user.id}") logging.info(f"[GENERAL] No token found for user {ctx.user.id}")
@@ -321,7 +297,7 @@ class General(Cog):
try: try:
client = await YMClient(token).init() client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token") logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token")
await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True) await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True)
return return
@@ -340,18 +316,7 @@ class General(Cog):
await ctx.respond("❌ Плейлист пуст.", delete_after=15, ephemeral=True) await ctx.respond("❌ Плейлист пуст.", delete_after=15, ephemeral=True)
return return
embed = await generate_item_embed(playlist) await ctx.respond(embed=await generate_item_embed(playlist), view=ListenView(playlist))
view = ListenView(playlist)
for track_short in playlist.tracks:
track = cast(Track, track_short.track)
if (track.explicit or track.content_warning) and not guild['allow_explicit']:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server")
embed.set_footer(text="Воспроизведение недоступно, так как в плейлисте присутствуют Explicit треки")
view = None
break
await ctx.respond(embed=embed, view=view)
@discord.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.") @discord.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.")
@discord.option( @discord.option(
@@ -374,11 +339,8 @@ class General(Cog):
content_type: Literal['Трек', 'Альбом', 'Артист', 'Плейлист'], content_type: Literal['Трек', 'Альбом', 'Артист', 'Плейлист'],
name: str name: str
) -> None: ) -> None:
# TODO: Improve explicit check by excluding bad tracks from the queue and not fully discard the artist/album/playlist.
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}'")
guild = await self.db.get_guild(ctx.guild_id, projection={'allow_explicit': 1})
token = await self.users_db.get_ym_token(ctx.user.id) token = await self.users_db.get_ym_token(ctx.user.id)
if not token: if not token:
logging.info(f"[GENERAL] No token found for user {ctx.user.id}") logging.info(f"[GENERAL] No token found for user {ctx.user.id}")
@@ -387,66 +349,32 @@ class General(Cog):
try: try:
client = await YMClient(token).init() client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token") logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token")
await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True) await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True)
return return
result = await client.search(name, nocorrect=True) search_result = await client.search(name, nocorrect=True)
if not search_result:
if not 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
if content_type == 'Трек': if content_type == 'Трек':
content = result.tracks content = search_result.tracks
elif content_type == 'Альбом': elif content_type == 'Альбом':
content = result.albums content = search_result.albums
elif content_type == 'Артист': elif content_type == 'Артист':
content = result.artists content = search_result.artists
elif content_type == 'Плейлист': else:
content = result.playlists content = search_result.playlists
if not content: if not content:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no results") logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no results")
await ctx.respond("❌ По запросу ничего не найдено.", delete_after=15, ephemeral=True) await ctx.respond("❌ По запросу ничего не найдено.", delete_after=15, ephemeral=True)
return return
content = content.results[0] result = content.results[0]
embed = await generate_item_embed(content) await ctx.respond(embed=await generate_item_embed(result), view=ListenView(result))
view = ListenView(content)
if isinstance(content, (Track, Album)) and (content.explicit or content.content_warning) and not guild['allow_explicit']:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server")
await ctx.respond("❌ Explicit контент запрещён на этом сервере.", delete_after=15, ephemeral=True)
return
elif isinstance(content, Artist):
tracks = await content.get_tracks_async()
if not tracks:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no tracks")
await ctx.respond("❌ Треки от этого исполнителя не найдены.", delete_after=15, ephemeral=True)
return
for track in tracks:
if (track.explicit or track.content_warning) and not guild['allow_explicit']:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server")
view = None
embed.set_footer(text="Воспроизведение недоступно, так как у автора присутствуют Explicit треки")
break
elif isinstance(content, Playlist):
tracks = await content.fetch_tracks_async()
if not tracks:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no tracks")
await ctx.respond("❌ Пустой плейлист.", delete_after=15, ephemeral=True)
return
for track_short in content.tracks:
track = cast(Track, track_short.track)
if (track.explicit or track.content_warning) and not guild['allow_explicit']:
logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server")
view = None
embed.set_footer(text="Воспроизведение недоступно, так как в плейлисте присутствуют Explicit треки")
break
logging.info(f"[GENERAL] Successfully generated '{content_type}' message for user {ctx.author.id}") logging.info(f"[GENERAL] Successfully generated '{content_type}' message for user {ctx.author.id}")
await ctx.respond(embed=embed, view=view)

View File

@@ -19,114 +19,53 @@ class Settings(Cog):
@settings.command(name="show", description="Показать текущие настройки бота.") @settings.command(name="show", description="Показать текущие настройки бота.")
async def show(self, ctx: discord.ApplicationContext) -> None: async def show(self, ctx: discord.ApplicationContext) -> None:
guild = await self.db.get_guild(ctx.guild.id, projection={ guild = await self.db.get_guild(ctx.guild.id, projection={'allow_change_connect': 1, 'vote_switch_track': 1, 'vote_add': 1})
'allow_explicit': 1, 'always_allow_menu': 1,
'vote_next_track': 1, 'vote_add_track': 1, 'vote_add_album': 1, 'vote_add_artist': 1, 'vote_add_playlist': 1, vote = "✅ - Переключение" if guild['vote_switch_track'] else "❌ - Переключение"
'allow_connect': 1, 'allow_disconnect': 1 vote += "\n✅ - Добавление в очередь" if guild['vote_add'] else "\n❌ - Добавление в очередь"
})
connect = "\n✅ - Разрешено всем" if guild['allow_change_connect'] else "\n❌ - Только для участникам с правами управления каналом"
embed = discord.Embed(title="Настройки бота", color=0xfed42b) embed = discord.Embed(title="Настройки бота", color=0xfed42b)
explicit = "✅ - Разрешены" if guild['allow_explicit'] else "❌ - Запрещены"
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="__Explicit треки__", value=explicit, inline=False)
embed.add_field(name="__Меню проигрывателя__", value=menu, inline=False)
embed.add_field(name="__Голосование__", value=vote, inline=False) embed.add_field(name="__Голосование__", value=vote, inline=False)
embed.add_field(name="__Подключение и Отключение__", value=connect, inline=False) embed.add_field(name="__Подключение/Отключение бота__", value=connect, inline=False)
await ctx.respond(embed=embed, ephemeral=True) await ctx.respond(embed=embed, ephemeral=True)
@settings.command(name="connect", description="Разрешить/запретить отключение/подключение бота к каналу участникам без прав управления каналом.") @settings.command(name="toggle", 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="explicit", description="Разрешить или запретить воспроизведение Explicit треков (пока что неполноценно).")
async def explicit(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_explicit': 1})
await self.db.update(ctx.guild.id, {'allow_explicit': not guild['allow_explicit']})
await ctx.respond(f"Треки с содержанием не для детей теперь {'✅ разрешены' if not guild['allow_explicit'] 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="Настроить голосование.")
@discord.option( @discord.option(
"vote_type", "параметр",
parameter_name="vote_type",
description="Тип голосования.", description="Тип голосования.",
type=discord.SlashCommandOptionType.string, type=discord.SlashCommandOptionType.string,
choices=['+Всё', '-Всё', 'Переключение', 'Трек', 'Альбом', 'Плейлист'], choices=['Переключение', 'Добавление в очередь', 'Добавление/Отключение бота']
default='+Всё'
) )
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) member = cast(discord.Member, ctx.author)
if not member.guild_permissions.manage_channels: if not member.guild_permissions.manage_channels:
await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
return 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}) guild = await self.db.get_guild(ctx.guild.id, projection={
'vote_switch_track': 1, 'vote_add': 1, 'allow_change_connect': 1})
if vote_type == '-Всё': if vote_type == 'Переключение':
await self.db.update(ctx.guild.id, { await self.db.update(ctx.guild.id, {'vote_switch_track': not guild['vote_switch_track']})
'vote_next_track': False, response_message = "Голосование за переключение трека " + ("❌ выключено." if guild['vote_switch_track'] else "✅ включено.")
'vote_add_track': False,
'vote_add_album': False, elif vote_type == 'Добавление в очередь':
'vote_add_artist': False, await self.db.update(ctx.guild.id, {'vote_add': not guild['vote_add']})
'vote_add_playlist': False response_message = "Голосование за добавление в очередь " + ("❌ выключено." if guild['vote_add'] else "✅ включено.")
}
) elif vote_type == 'Добавление/Отключение бота':
response_message = "Голосование ❌ выключено." await self.db.update(ctx.guild.id, {'allow_change_connect': not guild['allow_change_connect']})
elif vote_type == '+Всё': response_message = f"Добавление/Отключение бота от канала теперь {'✅ разрешено' if not guild['allow_change_connect'] else '❌ запрещено'} участникам без прав управления каналом."
await self.db.update(ctx.guild.id, {
'vote_next_track': True, else:
'vote_add_track': True, response_message = "❌ Неизвестный тип голосования."
'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 "✅ включено.")
await ctx.respond(response_message, delete_after=15, ephemeral=True) 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 import discord
from discord.ui import View 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.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. 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.db = VoiceGuildsDatabase()
self.users_db = BaseUsersDatabase() 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. """Send menu message to the channel and delete old menu message if exists. Return True if sent.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
disable (bool, optional): Disable menu message. Defaults to False. disable (bool, optional): Disable menu message. 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.
""" """
@@ -65,7 +68,23 @@ class VoiceExtension:
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) 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 response = await interaction.original_response() if isinstance(interaction, discord.Interaction) else interaction
await self.db.update(ctx.guild_id, {'current_menu': response.id}) await self.db.update(ctx.guild_id, {'current_menu': response.id})
@@ -77,7 +96,7 @@ class VoiceExtension:
Reset `current_menu` field in the database if not found. Reset `current_menu` field in the database if not found.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
menu_mid (int): Id of the menu message to fetch. menu_mid (int): Id of the menu message to fetch.
Returns: Returns:
@@ -105,18 +124,17 @@ class VoiceExtension:
await self.db.update(ctx.guild_id, {'current_menu': None}) await self.db.update(ctx.guild_id, {'current_menu': None})
return None return None
if menu: if not menu:
logging.debug(f"[VC_EXT] Menu message {menu_mid} successfully fetched")
else:
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}")
await self.db.update(ctx.guild_id, {'current_menu': None}) 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 return menu
async def update_menu_full( async def update_menu_full(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
menu_mid: int | None = None,
*, *,
menu_message: discord.Message | None = None, menu_message: discord.Message | None = None,
button_callback: bool = False button_callback: bool = False
@@ -124,7 +142,7 @@ class VoiceExtension:
"""Update embed and view of the current menu message. Return True if updated. """Update embed and view of the current menu message. Return True if updated.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
menu_mid (int): Id of the menu message to update. Defaults to None. menu_mid (int): Id of the menu message to update. Defaults to None.
menu_message (discord.Message | None): Message to update. If None, fetches menu from channel using `menu_mid`. Defaults to None. menu_message (discord.Message | None): Message to update. If None, fetches menu from channel using `menu_mid`. Defaults to None.
button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False.
@@ -132,7 +150,7 @@ class VoiceExtension:
Returns: Returns:
bool: True if updated, False if not. bool: True if updated, False if not.
""" """
logging.debug( logging.info(
f"[VC_EXT] Updating menu embed using " + ( f"[VC_EXT] Updating menu embed using " + (
"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
@@ -147,14 +165,11 @@ class VoiceExtension:
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_track': 1}) guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1})
if not guild['current_menu']:
if not menu_message:
if not menu_mid:
logging.warning("[VC_EXT] No menu message or menu message id provided")
return False return False
menu_message = await self.get_menu_message(ctx, menu_mid)
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
@@ -168,6 +183,16 @@ 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:
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) await self._update_menu_views_dict(ctx)
try: try:
if isinstance(ctx, Interaction) and button_callback: if isinstance(ctx, Interaction) and button_callback:
@@ -186,7 +211,6 @@ class VoiceExtension:
async def update_menu_view( async def update_menu_view(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
guild: ExplicitGuild,
*, *,
menu_message: discord.Message | None = None, menu_message: discord.Message | None = None,
button_callback: bool = False, button_callback: bool = False,
@@ -206,6 +230,11 @@ class VoiceExtension:
""" """
logging.debug("[VC_EXT] Updating menu view") 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']: if not guild['current_menu']:
return False return False
@@ -217,10 +246,10 @@ class VoiceExtension:
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[guild['_id']]) await ctx.edit(view=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[guild['_id']]) await menu_message.edit(view=menu_views[ctx.guild_id])
except discord.NotFound: except discord.NotFound:
logging.warning("[VC_EXT] Menu message not found") logging.warning("[VC_EXT] Menu message not found")
return False return False
@@ -230,19 +259,21 @@ class VoiceExtension:
async def update_vibe( async def update_vibe(
self, self,
ctx: ApplicationContext | Interaction, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
type: str, type: str,
id: str | int, id: str | int,
*, *,
viber_id: int | None = None,
update_settings: bool = False update_settings: bool = False
) -> bool: ) -> bool:
"""Update vibe state or initialize it if not `guild['vibing']` and replace queue with next tracks. """Update vibe state or initialize it if not `guild['vibing']` and replace queue with next tracks.
User's vibe has type `user` and id `onyourwave`. User's vibe has type `user` and id `onyourwave`.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
type (str): Type of the item. type (str): Type of the item.
id (str | int): ID of the item. id (str | int): ID of the item.
viber_id (int | None, optional): ID of the user who started vibe. If None, uses user id in context. Defaults to None.
update_settings (bool, optional): Update vibe settings by sending feedack usind data from database. Defaults to False. update_settings (bool, optional): Update vibe settings by sending feedack usind data from database. Defaults to False.
Returns: Returns:
@@ -251,7 +282,7 @@ class VoiceExtension:
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 '{type}' and id '{id}'")
gid = ctx.guild_id gid = ctx.guild_id
uid = 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 gid:
logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'vibe_update'") logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'vibe_update'")
@@ -340,7 +371,7 @@ class VoiceExtension:
if not isinstance(ctx.channel, discord.VoiceChannel): if not isinstance(ctx.channel, discord.VoiceChannel):
logging.debug("[VC_EXT] User is not in a voice channel") logging.debug("[VC_EXT] User is not in a voice channel")
await ctx.respond("❌ Вы должны отправить команду в голосовом канале.", delete_after=15, ephemeral=True) await ctx.respond("❌ Вы должны отправить команду в чате голосового канала.", delete_after=15, ephemeral=True)
return False return False
if ctx.user.id not in ctx.channel.voice_states: if ctx.user.id not in ctx.channel.voice_states:
@@ -356,8 +387,7 @@ class VoiceExtension:
if check_vibe_privilage: if check_vibe_privilage:
guild = await self.db.get_guild(ctx.guild.id, projection={'current_viber_id': 1, 'vibing': 1}) guild = await self.db.get_guild(ctx.guild.id, projection={'current_viber_id': 1, 'vibing': 1})
member = cast(discord.Member, ctx.user) if guild['vibing'] and ctx.user.id != guild['current_viber_id']:
if guild['vibing'] and ctx.user.id != guild['current_viber_id'] and not member.guild_permissions.manage_channels:
logging.debug("[VIBE] Context user is not the current viber") logging.debug("[VIBE] Context user is not the current viber")
await ctx.respond("❌ Вы не можете взаимодействовать с чужой волной!", delete_after=15, ephemeral=True) await ctx.respond("❌ Вы не можете взаимодействовать с чужой волной!", delete_after=15, ephemeral=True)
return False return False
@@ -369,7 +399,7 @@ class VoiceExtension:
"""Return voice client for the given guild id. Return None if not present. """Return voice client for the given guild id. Return None if not present.
Args: Args:
ctx (ApplicationContext | Interaction): Command context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Command context.
Returns: Returns:
(discord.VoiceClient | None): Voice client or None. (discord.VoiceClient | None): Voice client or None.
@@ -408,10 +438,10 @@ class VoiceExtension:
retry: bool = False retry: bool = False
) -> str | None: ) -> str | None:
"""Download ``track`` by its id and play it in the voice channel. Return track title on success. """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: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
track (Track): Track to play. track (Track): Track to play.
vc (discord.VoiceClient | None): Voice client. vc (discord.VoiceClient | None): Voice client.
menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None. menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None.
@@ -442,10 +472,13 @@ class VoiceExtension:
if not isinstance(ctx, RawReactionActionEvent) and ctx.channel: if not isinstance(ctx, RawReactionActionEvent) and ctx.channel:
channel = cast(discord.VoiceChannel, ctx.channel) channel = cast(discord.VoiceChannel, ctx.channel)
elif self.bot and isinstance(ctx, RawReactionActionEvent):
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
if not retry: if not retry:
return await self.play_track(ctx, track, vc=vc, button_callback=button_callback, retry=True) return await self.play_track(ctx, track, vc=vc, button_callback=button_callback, retry=True)
logging.error(f"[VC_EXT] Failed to download track '{track.title}'")
await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15)
return None return None
@@ -457,25 +490,35 @@ 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, 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']: if not guild['vibing']:
# Giving FFMPEG enough time to process the audio file # Giving FFMPEG enough time to process the audio file
await asyncio.sleep(1) await asyncio.sleep(1)
loop = self._get_current_event_loop(ctx) loop = self._get_current_event_loop(ctx)
try:
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop)) 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)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15)
return None
logging.info(f"[VC_EXT] Playing track '{track.title}'") logging.info(f"[VC_EXT] Playing track '{track.title}'")
await self.db.update(gid, {'is_stopped': False}) await self.db.update(gid, {'is_stopped': False})
if guild['vibing']: 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 return track.title
async def stop_playing( async def stop_playing(
self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*, *,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
full: bool = False full: bool = False
@@ -514,7 +557,7 @@ class VoiceExtension:
return False return False
if guild['vibing'] and guild['current_track']: 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 False
return True return True
@@ -532,7 +575,7 @@ class VoiceExtension:
Doesn't change track if stopped. Stop playing if tracks list is empty. Doesn't change track if stopped. Stop playing if tracks list is empty.
Args: Args:
ctx (ApplicationContext | Interaction): Context ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context
vc (discord.VoiceClient, optional): Voice client. vc (discord.VoiceClient, optional): Voice client.
after (bool, optional): Whether the function is being called by the after callback. Defaults to False. after (bool, optional): Whether the function is being called by the after callback. Defaults to False.
menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None. menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None.
@@ -571,11 +614,16 @@ class VoiceExtension:
await self.db.modify_track(gid, guild['current_track'], 'previous', 'insert') await self.db.modify_track(gid, guild['current_track'], 'previous', 'insert')
if after and guild['current_menu']: 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 guild['vibing'] and guild['current_track']:
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):
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond("❌ Что-то пошло не так. Попробуйте снова.", ephemeral=True) await ctx.respond("❌ Что-то пошло не так. Попробуйте снова.", ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15)
return None return None
if guild['repeat'] and after: if guild['repeat'] and after:
@@ -588,7 +636,7 @@ class VoiceExtension:
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(gid, 'next')
if not next_track and guild['vibing'] and not isinstance(ctx, discord.RawReactionActionEvent): 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")
if not user['vibe_type'] or not user['vibe_id']: if not user['vibe_type'] or not user['vibe_id']:
logging.warning("[VC_EXT] No vibe type or vibe id found in user data") logging.warning("[VC_EXT] No vibe type or vibe id found in user data")
@@ -598,7 +646,7 @@ class VoiceExtension:
next_track = await self.db.get_track(gid, 'next') next_track = await self.db.get_track(gid, 'next')
if next_track: 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 after and not guild['current_menu']:
if isinstance(ctx, discord.RawReactionActionEvent): if isinstance(ctx, discord.RawReactionActionEvent):
@@ -618,23 +666,28 @@ class VoiceExtension:
return None 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. """Switch to the previous track in the queue. Repeat current track if no previous one found.
Return track title on success. Return track title on success.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
button_callback (bool, optional): Whether the command was called by a button interaction. Defaults to False. button_callback (bool, optional): Whether the command was called by a button interaction. Defaults to False.
Returns: Returns:
(str | None): Track title or None. (str | None): Track title or None.
""" """
if not ctx.guild or not ctx.user: logging.debug("[VC_EXT] Switching to previous track")
logging.warning("Guild or User not found in context inside 'prev_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 return None
current_track = await self.db.get_track(ctx.guild.id, 'current') current_track = await self.db.get_track(gid, 'current')
prev_track = await self.db.get_track(ctx.guild.id, 'previous') prev_track = await self.db.get_track(gid, 'previous')
if prev_track: if prev_track:
logging.debug("[VC_EXT] Previous track found") logging.debug("[VC_EXT] Previous track found")
@@ -647,7 +700,7 @@ class VoiceExtension:
track = None track = None
if track: 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 return None
@@ -655,7 +708,7 @@ class VoiceExtension:
"""Get liked tracks. Return list of tracks on success. Return None if no token found. """Get liked tracks. Return list of tracks on success. Return None if no token found.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
Returns: Returns:
(list[Track] | None): List of tracks or None. (list[Track] | None): List of tracks or None.
@@ -721,7 +774,8 @@ class VoiceExtension:
add_func = client.users_dislikes_tracks_add add_func = client.users_dislikes_tracks_add
remove_func = client.users_dislikes_tracks_remove 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) return (False, None)
if str(current_track['id']) not in [str(track.id) for track in tracks]: if str(current_track['id']) not in [str(track.id) for track in tracks]:
@@ -737,7 +791,7 @@ class VoiceExtension:
"""Initialize Yandex Music client. Return client on success. Return None if no token found and respond to the context. """Initialize Yandex Music client. Return client on success. Return None if no token found and respond to the context.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
token (str | None, optional): Token. Fetched from database if not provided. Defaults to None. token (str | None, optional): Token. Fetched from database if not provided. Defaults to None.
Returns: Returns:
@@ -771,6 +825,113 @@ class VoiceExtension:
self._ym_clients[token] = client self._ym_clients[token] = client
return 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 ctx.guild_id:
logging.warning("[VOICE] Guild not found")
return False
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'):
logging.info(f"[VOICE] No {vote_data['action']} tracks found for message {ctx.message_id}")
await channel.send(content=f"❌ Очередь пуста!", delete_after=15)
elif not (await self.next_track(ctx) if vote_data['action'] == 'next' else await self.previous_track(ctx)):
await channel.send(content=f"❌ Ошибка при смене трека! Попробуйте ещё раз.", delete_after=15)
elif vote_data['action'] == 'add_track':
if not vote_data['vote_content']:
logging.info(f"[VOICE] Recieved empty vote context for message {ctx.message_id}")
return False
await self.db.modify_track(guild['_id'], vote_data['vote_content'], 'next', 'append')
if guild['current_track']:
await channel.send(content=f"✅ Трек был добавлен в очередь!", delete_after=15)
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'):
if not vote_data['vote_content']:
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'], vote_data['vote_content'], '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':
if not (vc := await self.get_voice_client(ctx)):
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)
elif vote_data['action'] == 'clear_queue':
await self.db.update(ctx.guild_id, {'previous_tracks': [], 'next_tracks': []})
await channel.send("✅ Очередь и история сброшены.", delete_after=15)
elif vote_data['action'] == 'stop':
res = await self.stop_playing(ctx, full=True)
if res:
await channel.send("✅ Воспроизведение остановлено.", delete_after=15)
else:
await channel.send("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15)
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)
if not _type or not _id or not viber_id:
logging.warning(f"[VOICE] Recieved empty vote context for message {ctx.message_id}")
await channel.send("❌ Произошла ошибка при обновлении станции.", delete_after=15)
return False
feedback = await self.update_vibe(ctx, _type, _id, viber_id=viber_id)
if not feedback:
await channel.send("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15)
return False
next_track = await self.db.get_track(ctx.guild_id, 'next')
if next_track:
await self._play_track(ctx, next_track)
else:
logging.error(f"[VOICE] Unknown action '{vote_data['action']}' for message {ctx.message_id}")
return False
return True
async def _update_menu_views_dict( async def _update_menu_views_dict(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
@@ -836,10 +997,11 @@ class VoiceExtension:
}) })
return True 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. """Send vibe start feedback to Yandex Music. Return True on success.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
track (Track): Track. track (Track): Track.
uid (int): User ID. uid (int): User ID.
@@ -861,7 +1023,7 @@ class VoiceExtension:
logging.debug(f"[VIBE] Track started feedback: {feedback}") logging.debug(f"[VIBE] Track started feedback: {feedback}")
return True return True
async def _my_vibe_send_stop_feedback( async def _my_vibe_stop_feedback(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
guild: ExplicitGuild, guild: ExplicitGuild,
@@ -888,6 +1050,9 @@ class VoiceExtension:
logging.info(f"[VOICE] Failed to init YM client for user {user['_id']}") logging.info(f"[VOICE] Failed to init YM client for user {user['_id']}")
if not isinstance(ctx, RawReactionActionEvent): if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", 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
track = guild['current_track'] track = guild['current_track']
@@ -904,9 +1069,9 @@ class VoiceExtension:
logging.info(f"[VOICE] User {user['_id']} finished vibing with result: {res}") logging.info(f"[VOICE] User {user['_id']} finished vibing with result: {res}")
return True return True
async def _send_next_vibe_feedback( async def _my_vibe_feedback(
self, self,
ctx: ApplicationContext | Interaction, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
guild: ExplicitGuild, guild: ExplicitGuild,
user: ExplicitUser, user: ExplicitUser,
client: YMClient, client: YMClient,
@@ -917,7 +1082,7 @@ class VoiceExtension:
This is called when a user skips a track or when a track finishes and not when a user stops the player. This is called when a user skips a track or when a track finishes and not when a user stops the player.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
guild (ExplicitGuild): Guild. guild (ExplicitGuild): Guild.
user (ExplicitUser): User. user (ExplicitUser): User.
client (YMClient): Yandex Music client. client (YMClient): Yandex Music client.
@@ -926,6 +1091,7 @@ class VoiceExtension:
Returns: Returns:
bool: True on success, False otherwise. 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}") logging.debug(f"[VC_EXT] Sending vibe feedback, after: {after}")
if not user['vibe_type'] or not user['vibe_id']: if not user['vibe_type'] or not user['vibe_id']:
@@ -964,21 +1130,21 @@ class VoiceExtension:
return feedback return feedback
async def _play_next_track( async def _play_track(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
next_track: dict[str, Any], track: dict[str, Any],
*, *,
client: YMClient | None = None, client: YMClient | None = None,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
menu_message: discord.Message | None = None, menu_message: discord.Message | None = None,
button_callback: bool = False, button_callback: bool = False,
) -> str | None: ) -> 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: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. 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. vc (discord.VoiceClient | None, optional): Voice client. Defaults to None.
menu_message (discord.Message | None, optional): Menu message to update. Defaults to None. menu_message (discord.Message | None, optional): Menu message to update. Defaults to None.
button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False.
@@ -986,6 +1152,7 @@ class VoiceExtension:
Returns: Returns:
str | None: Song title or None. 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 client = await self.init_ym_client(ctx) if not client else client
if not client: if not client:
@@ -998,7 +1165,7 @@ class VoiceExtension:
return None return None
ym_track = cast(Track, Track.de_json( ym_track = cast(Track, Track.de_json(
next_track, track,
client=client # type: ignore # Async client can be used here. client=client # type: ignore # Async client can be used here.
)) ))
return await self.play_track( return await self.play_track(
@@ -1010,7 +1177,7 @@ class VoiceExtension:
) )
def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop: def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop:
"""Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the bot. """Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.

View File

@@ -39,7 +39,6 @@ class Voice(Cog, VoiceExtension):
voice = discord.SlashCommandGroup("voice", "Команды, связанные с голосовым каналом.") voice = discord.SlashCommandGroup("voice", "Команды, связанные с голосовым каналом.")
queue = discord.SlashCommandGroup("queue", "Команды, связанные с очередью треков.") queue = discord.SlashCommandGroup("queue", "Команды, связанные с очередью треков.")
track = discord.SlashCommandGroup("track", "Команды, связанные с треками в голосовом канале.")
def __init__(self, bot: discord.Bot): def __init__(self, bot: discord.Bot):
VoiceExtension.__init__(self, bot) VoiceExtension.__init__(self, bot)
@@ -47,21 +46,29 @@ 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:
logging.info(f"[VOICE] Voice state update for member {member.id} in guild {member.guild.id}")
gid = member.guild.id 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})
discord_guild = await self.typed_bot.fetch_guild(gid)
current_menu = guild['current_menu']
channel = after.channel or before.channel channel = after.channel or before.channel
if not channel: if not channel:
logging.info(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=discord_guild)) vc = cast(discord.VoiceClient | None, discord.utils.get(self.typed_bot.voice_clients, guild=await self.typed_bot.fetch_guild(gid)))
if len(channel.members) == 1 and vc: for member in channel.members:
if member.id == self.typed_bot.user.id: # type: ignore # should be logged in
logging.info(f"[VOICE] Voice state update for member {member.id} in guild {member.guild.id}")
break
else:
logging.debug(f"[VOICE] Bot is not in the channel {channel.id}")
return
if not vc:
logging.info(f"[VOICE] No voice client found for guild {gid}")
return
if len(channel.members) == 1:
logging.info(f"[VOICE] Clearing history and stopping playback for guild {gid}") logging.info(f"[VOICE] Clearing history and stopping playback for guild {gid}")
if member.guild.id in menu_views: if member.guild.id in menu_views:
@@ -74,22 +81,11 @@ class Voice(Cog, VoiceExtension):
await message.delete() await message.delete()
await self.db.update(gid, { await self.db.update(gid, {
'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()
elif len(channel.members) > 2 and not guild['always_allow_menu']:
if 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(current_menu)
await message.delete()
await channel.send("Меню отключено из-за большого количества участников.", delete_after=15)
except (discord.NotFound, discord.Forbidden):
pass
if member.guild.id in menu_views: if member.guild.id in menu_views:
menu_views[member.guild.id].stop() menu_views[member.guild.id].stop()
@@ -97,7 +93,7 @@ class Voice(Cog, VoiceExtension):
@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.info(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
@@ -109,7 +105,15 @@ class Voice(Cog, VoiceExtension):
if not channel: if not channel:
return return
try:
message = await channel.fetch_message(payload.message_id) message = await channel.fetch_message(payload.message_id)
except discord.Forbidden:
logging.info(f"[VOICE] Bot does not have permissions to read messages in channel {payload.channel_id}")
return
except discord.NotFound:
logging.info(f"[VOICE] Message {payload.message_id} not found in channel {payload.channel_id}")
return
if not message or message.author.id != bot_id: if not message or message.author.id != bot_id:
return return
@@ -122,7 +126,7 @@ class Voice(Cog, VoiceExtension):
if not guild_id: if not guild_id:
return 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'] votes = guild['votes']
if str(payload.message_id) not in votes: if str(payload.message_id) not in votes:
@@ -130,7 +134,6 @@ class Voice(Cog, VoiceExtension):
return return
vote_data = votes[str(payload.message_id)] vote_data = votes[str(payload.message_id)]
logging.debug(f"[VOICE] Vote data for message {payload.message_id}: {vote_data}")
if payload.emoji.name == '': if payload.emoji.name == '':
logging.info(f"[VOICE] User {payload.user_id} voted positively for message {payload.message_id}") logging.info(f"[VOICE] User {payload.user_id} voted positively for message {payload.message_id}")
@@ -143,53 +146,8 @@ 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 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: if len(vote_data['positive_votes']) >= required_votes:
logging.info(f"[VOICE] Enough positive votes for message {payload.message_id}") logging.info(f"[VOICE] Enough positive votes for message {payload.message_id}")
await message.delete()
if vote_data['action'] == 'next': await self.proccess_vote(payload, guild, channel, vote_data)
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)] del votes[str(payload.message_id)]
elif len(vote_data['negative_votes']) >= required_votes: elif len(vote_data['negative_votes']) >= required_votes:
@@ -202,13 +160,14 @@ class Voice(Cog, VoiceExtension):
@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.info(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:
return return
guild_id = payload.guild_id guild_id = payload.guild_id
if not guild_id: if not guild_id:
return return
guild = await self.db.get_guild(guild_id, projection={'votes': 1}) guild = await self.db.get_guild(guild_id, projection={'votes': 1})
votes = guild['votes'] votes = guild['votes']
@@ -220,7 +179,15 @@ class Voice(Cog, VoiceExtension):
if not channel: if not channel:
return return
try:
message = await channel.fetch_message(payload.message_id) message = await channel.fetch_message(payload.message_id)
except discord.Forbidden:
logging.info(f"[VOICE] Bot does not have permissions to read messages in channel {payload.channel_id}")
return
except discord.NotFound:
logging.info(f"[VOICE] Message {payload.message_id} not found in channel {payload.channel_id}")
return
if not message or message.author.id != self.typed_bot.user.id: if not message or message.author.id != self.typed_bot.user.id:
return return
@@ -234,20 +201,10 @@ class Voice(Cog, VoiceExtension):
await self.db.update(guild_id, {'votes': votes}) 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: async def menu(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Menu command invoked by user {ctx.author.id} in guild {ctx.guild.id}") logging.info(f"[VOICE] Menu command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
if not await self.voice_check(ctx): if 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) await self.send_menu_message(ctx)
@voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.") @voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.")
@@ -255,17 +212,22 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] Join command invoked by user {ctx.author.id} in guild {ctx.guild.id}") logging.info(f"[VOICE] Join command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
member = cast(discord.Member, ctx.author) 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 = "У вас нет прав для выполнения этой команды." response_message = "У вас нет прав для выполнения этой команды."
elif (vc := await self.get_voice_client(ctx)) and vc.is_connected(): elif vc and vc.is_connected():
response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave." response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave."
elif isinstance(ctx.channel, discord.VoiceChannel): elif isinstance(ctx.channel, discord.VoiceChannel):
await ctx.channel.connect(timeout=15) try:
response_message = "Подключение успешно!" await ctx.channel.connect()
except TimeoutError:
response_message = "Не удалось подключиться к голосовому каналу."
else: else:
response_message = "❌ Вы должны отправить команду в голосовом канале." response_message = "✅ Подключение успешно!"
else:
response_message = "❌ Вы должны отправить команду в чате голосового канала."
logging.info(f"[VOICE] Join command response: {response_message}") logging.info(f"[VOICE] Join command response: {response_message}")
await ctx.respond(response_message, delete_after=15, ephemeral=True) await ctx.respond(response_message, delete_after=15, ephemeral=True)
@@ -275,22 +237,22 @@ class Voice(Cog, VoiceExtension):
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}")
member = cast(discord.Member, ctx.author) 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})
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']:
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 (vc := await self.get_voice_client(ctx)) and await self.voice_check(ctx) and vc.is_connected: 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) res = await self.stop_playing(ctx, vc=vc, full=True)
if res: if not 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:
await ctx.respond("Не удалось отключиться.", delete_after=15, ephemeral=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}")
else: else:
await ctx.respond("❌ Бот не подключен к голосовому каналу.", delete_after=15, ephemeral=True) await ctx.respond("❌ Бот не подключен к голосовому каналу.", delete_after=15, ephemeral=True)
@@ -298,15 +260,37 @@ class Voice(Cog, VoiceExtension):
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 await self.voice_check(ctx):
return
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"[VOICE] User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild.id}") logging.info(f"Starting vote for clearing queue in guild {ctx.guild.id}")
await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
elif await self.voice_check(ctx): response_message = f"{member.mention} хочет очистить историю прослушивания и очередь треков.\n\n Выполнить действие?."
message = cast(discord.Interaction, await ctx.respond(response_message, delete_after=60))
response = await message.original_response()
await response.add_reaction('')
await response.add_reaction('')
await self.db.update_vote(
ctx.guild_id,
response.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': 'clear_queue',
'vote_content': None
}
)
return
await self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': []}) await self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': []})
await ctx.respond("Очередь и история сброшены.", delete_after=15, ephemeral=True) await ctx.respond("Очередь и история сброшены.", delete_after=15, ephemeral=True)
logging.info(f"[VOICE] Queue and history cleared in guild {ctx.guild.id}") logging.info(f"[VOICE] Queue and history cleared in guild {ctx.guild.id}")
@queue.command(description="Получить очередь треков.") @queue.command(description="Получить очередь треков.")
@@ -323,176 +307,44 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] Queue embed sent to user {ctx.author.id} in guild {ctx.guild.id}") logging.info(f"[VOICE] Queue embed sent to user {ctx.author.id} in guild {ctx.guild.id}")
@track.command(description="Приостановить текущий трек.") @voice.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="Прервать проигрывание, удалить историю, очередь и текущий плеер.")
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 await self.voice_check(ctx):
return
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"[VOICE] User {ctx.author.id} tried to stop playback in guild {ctx.guild.id} but there are other users in the channel") logging.info(f"Starting vote for stopping playback in guild {ctx.guild.id}")
await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True)
elif await self.voice_check(ctx): response_message = f"{member.mention} хочет полностью остановить проигрывание.\n\n Выполнить действие?."
res = await self.stop_playing(ctx, full=True) message = cast(discord.Interaction, await ctx.respond(response_message, delete_after=60))
if res:
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() 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(
gid, ctx.guild_id,
response.id, response.id,
{ {
'positive_votes': list(), 'positive_votes': list(),
'negative_votes': list(), 'negative_votes': list(),
'total_members': len(channel.members), 'total_members': len(channel.members),
'action': 'next', 'action': 'stop',
'vote_content': None 'vote_content': None
} }
) )
return
res = await self.stop_playing(ctx, full=True)
if res:
await ctx.respond("✅ Воспроизведение остановлено.", delete_after=15, ephemeral=True)
else: else:
logging.info(f"[VOICE] Skipping vote for user {ctx.author.id} in guild {ctx.guild.id}") await ctx.respond("❌ Произошла ошибка при остановке воспроизведения.", delete_after=15, ephemeral=True)
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="Запустить Мою Волну.") @voice.command(name='vibe', description="Запустить Мою Волну.")
@discord.option( @discord.option(
@@ -508,18 +360,14 @@ class Voice(Cog, VoiceExtension):
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
return return
guild = await self.db.get_guild(ctx.guild.id, projection={'always_allow_menu': 1, 'current_menu': 1, 'vibing': 1}) guild = await self.db.get_guild(ctx.guild.id, projection={'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']: if guild['vibing']:
logging.info(f"[VOICE] Action declined: vibing is already enabled in guild {ctx.guild.id}") logging.info(f"[VOICE] Action declined: vibing is already enabled in guild {ctx.guild.id}")
await ctx.respond("❌ Моя Волна уже включена. Используйте /track stop, чтобы остановить воспроизведение.", ephemeral=True) await ctx.respond("❌ Моя Волна уже включена. Используйте /voice stop, чтобы остановить воспроизведение.", delete_after=15, ephemeral=True)
return return
await ctx.defer(invisible=False)
if name: if name:
token = await users_db.get_ym_token(ctx.user.id) token = await users_db.get_ym_token(ctx.user.id)
if not token: if not token:
@@ -541,27 +389,64 @@ class Voice(Cog, VoiceExtension):
if not content: if not content:
logging.debug(f"[VOICE] Station {name} not found") logging.debug(f"[VOICE] Station {name} not found")
await ctx.respond("❌ Станция не найдена.", 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) _type, _id = content.ad_params.other_params.split(':') if content.ad_params else (None, None)
if not _type or not _id: if not _type or not _id:
logging.debug(f"[VOICE] Station {name} has no ad params") logging.debug(f"[VOICE] Station {name} has no ad params")
await ctx.respond("❌ Станция не найдена.", ephemeral=True) await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True)
return
else:
_type, _id = 'user', 'onyourwave'
content = None
member = cast(discord.Member, ctx.author)
channel = cast(discord.VoiceChannel, ctx.channel)
if len(channel.members) > 2 and not member.guild_permissions.manage_channels:
logging.info(f"Starting vote for starting vibe in guild {ctx.guild.id}")
if _type == 'user' and _id == 'onyourwave':
station = "Моя Волна"
elif content and content.station:
station = content.station.name
else:
logging.warning(f"[VOICE] Station {name} not found")
await ctx.respond("❌ Станция не найдена.", delete_after=15, ephemeral=True)
return
response_message = f"{member.mention} хочет запустить станцию **{station}**.\n\n Выполнить действие?"
message = cast(discord.WebhookMessage, await ctx.respond(response_message))
await message.add_reaction('')
await message.add_reaction('')
await self.db.update_vote(
ctx.guild_id,
message.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': 'vibe_station',
'vote_content': [_type, _id, ctx.user.id]
}
)
return return
feedback = await self.update_vibe(ctx, _type, _id) feedback = await self.update_vibe(ctx, _type, _id)
else:
feedback = await self.update_vibe(ctx, 'user', 'onyourwave')
if not feedback: if not feedback:
await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", ephemeral=True) await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True)
return 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) await self.send_menu_message(ctx, disable=True)
next_track = await self.db.get_track(ctx.guild_id, 'next') next_track = await self.db.get_track(ctx.guild_id, 'next')
if next_track: if next_track:
await self._play_next_track(ctx, next_track) await self._play_track(ctx, next_track)

View File

@@ -79,14 +79,10 @@ class BaseGuildsDatabase:
current_track=None, current_track=None,
current_menu=None, current_menu=None,
is_stopped=True, is_stopped=True,
allow_explicit=True,
always_allow_menu=False, always_allow_menu=False,
allow_connect=True, allow_change_connect=True,
vote_next_track=True, vote_switch_track=True,
vote_add_track=True, vote_add=True,
vote_add_album=True,
vote_add_artist=True,
vote_add_playlist=True,
shuffle=False, shuffle=False,
repeat=False, repeat=False,
votes={}, votes={},

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import logging import logging
from typing import Any, Literal, cast from typing import cast
import discord import discord
from yandex_music import Track, Album, Artist, Playlist from yandex_music import Track, Album, Artist, Playlist
@@ -27,40 +27,39 @@ class PlayButton(Button, VoiceExtension):
return return
gid = interaction.guild.id 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) channel = cast(discord.VoiceChannel, interaction.channel)
member = cast(discord.Member, interaction.user) member = cast(discord.Member, interaction.user)
action: Literal['add_track', 'add_album', 'add_artist', 'add_playlist']
if isinstance(self.item, Track): if isinstance(self.item, Track):
tracks = [self.item] tracks = [self.item]
action = 'add_track' action = 'add_track'
vote_message = f"{member.mention} хочет добавить трек **{self.item.title}** в очередь.\n\n Голосуйте за добавление." vote_message = f"{member.mention} хочет добавить трек **{self.item.title}** в очередь.\n\n Голосуйте за добавление."
response_message = f"Трек **{self.item.title}** был добавлен в очередь." response_message = f"Трек **{self.item.title}** был добавлен в очередь."
elif isinstance(self.item, Album): elif isinstance(self.item, Album):
album = await self.item.with_tracks_async() album = await self.item.with_tracks_async()
if not album or not album.volumes: if not album or not album.volumes:
logging.debug("[FIND] Failed to fetch album tracks in PlayButton callback") logging.debug("[FIND] Failed to fetch album tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки альбома.", ephemeral=True) await interaction.respond("Не удалось получить треки альбома.", ephemeral=True)
return return
tracks = [track for volume in album.volumes for track in volume] tracks = [track for volume in album.volumes for track in volume]
action = 'add_album' action = 'add_album'
vote_message = f"{member.mention} хочет добавить альбом **{self.item.title}** в очередь.\n\n Голосуйте за добавление." vote_message = f"{member.mention} хочет добавить альбом **{self.item.title}** в очередь.\n\n Голосуйте за добавление."
response_message = f"Альбом **{self.item.title}** был добавлен в очередь." response_message = f"Альбом **{self.item.title}** был добавлен в очередь."
elif isinstance(self.item, Artist): elif isinstance(self.item, Artist):
artist_tracks = await self.item.get_tracks_async() artist_tracks = await self.item.get_tracks_async()
if not artist_tracks: if not artist_tracks:
logging.debug("[FIND] Failed to fetch artist tracks in PlayButton callback") logging.debug("[FIND] Failed to fetch artist tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки артиста.", ephemeral=True) await interaction.respond("Не удалось получить треки артиста.", ephemeral=True)
return return
tracks = artist_tracks.tracks.copy() tracks = artist_tracks.tracks.copy()
action = 'add_artist' action = 'add_artist'
vote_message = f"{member.mention} хочет добавить треки от **{self.item.name}** в очередь.\n\n Голосуйте за добавление." vote_message = f"{member.mention} хочет добавить треки от **{self.item.name}** в очередь.\n\n Голосуйте за добавление."
response_message = f"Песни артиста **{self.item.name}** были добавлены в очередь." response_message = f"Песни артиста **{self.item.name}** были добавлены в очередь."
elif isinstance(self.item, Playlist): elif isinstance(self.item, Playlist):
short_tracks = await self.item.fetch_tracks_async() short_tracks = await self.item.fetch_tracks_async()
@@ -72,7 +71,7 @@ class PlayButton(Button, VoiceExtension):
tracks = [cast(Track, short_track.track) for short_track in short_tracks] tracks = [cast(Track, short_track.track) for short_track in short_tracks]
action = 'add_playlist' action = 'add_playlist'
vote_message = f"{member.mention} хочет добавить плейлист **{self.item.title}** в очередь.\n\n Голосуйте за добавление." vote_message = f"{member.mention} хочет добавить плейлист **{self.item.title}** в очередь.\n\n Голосуйте за добавление."
response_message = f"Плейлист **{self.item.title}** был добавлен в очередь." response_message = f"Плейлист **{self.item.title}** был добавлен в очередь."
elif isinstance(self.item, list): elif isinstance(self.item, list):
tracks = self.item.copy() tracks = self.item.copy()
@@ -83,15 +82,15 @@ class PlayButton(Button, VoiceExtension):
action = 'add_playlist' action = 'add_playlist'
vote_message = f"{member.mention} хочет добавить плейлист **Мне Нравится** в очередь.\n\n Голосуйте за добавление." vote_message = f"{member.mention} хочет добавить плейлист **Мне Нравится** в очередь.\n\n Голосуйте за добавление."
response_message = f"Плейлист **«Мне нравится»** был добавлен в очередь." response_message = f"Плейлист **«Мне нравится»** был добавлен в очередь."
else: else:
raise ValueError(f"Unknown item type: '{type(self.item).__name__}'") raise ValueError(f"Unknown item type: '{type(self.item).__name__}'")
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)") logging.info(f"Starting vote for '{action}' (from PlayButton callback)")
message = cast(discord.Interaction, await interaction.respond(vote_message, delete_after=30)) message = cast(discord.Interaction, await interaction.respond(vote_message, delete_after=60))
response = await message.original_response() response = await message.original_response()
await response.add_reaction('') await response.add_reaction('')
@@ -108,26 +107,28 @@ class PlayButton(Button, VoiceExtension):
'vote_content': [track.to_dict() for track in tracks] 'vote_content': [track.to_dict() for track in tracks]
} }
) )
else: return
logging.debug(f"[FIND] Skipping vote for '{action}' (from PlayButton callback)")
current_menu = await self.get_menu_message(interaction, guild['current_menu']) if guild['current_menu'] else None logging.debug(f"[FIND] Skipping vote for '{action}'")
if guild['current_menu']:
await interaction.respond(response_message, delete_after=15)
else:
await self.send_menu_message(interaction, disable=True)
if guild['current_track'] is not None: if guild['current_track'] is not None:
logging.debug(f"[FIND] Adding tracks to queue (from PlayButton callback)") logging.debug(f"[FIND] Adding tracks to queue")
await self.db.modify_track(gid, tracks, 'next', 'extend') await self.db.modify_track(gid, tracks, 'next', 'extend')
else: else:
logging.debug(f"[FIND] Playing track (from PlayButton callback)") logging.debug(f"[FIND] Playing track")
track = tracks.pop(0) track = tracks.pop(0)
await self.db.modify_track(gid, tracks, 'next', 'extend') await self.db.modify_track(gid, tracks, 'next', 'extend')
await self.play_track(interaction, track) await self.play_track(interaction, track)
response_message = f"Сейчас играет: **{track.title}**!"
if current_menu and interaction.message: if interaction.message:
logging.debug(f"[FIND] Deleting interaction message {interaction.message.id}: current player {current_menu.id} found")
await interaction.message.delete() await interaction.message.delete()
else: else:
await interaction.respond(response_message, delete_after=15) logging.warning(f"[FIND] Interaction message is None")
class MyVibeButton(Button, VoiceExtension): class MyVibeButton(Button, VoiceExtension):
def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *args, **kwargs): 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") logging.warning(f"[VIBE] Guild ID is None in button callback")
return 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_type_map = {
Track: 'track', Album: 'album', Artist: 'artist', Playlist: 'playlist', list: 'user' 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') next_track = await self.db.get_track(gid, 'next')
if next_track: if next_track:
await self._play_next_track(interaction, next_track) await self._play_track(interaction, next_track)
class ListenView(View): class ListenView(View):
def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *items: Item, timeout: float | None = 360, disable_on_timeout: bool = True): 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() return await super().on_timeout()
except discord.NotFound: except discord.NotFound:
pass pass
self.stop()

View File

@@ -2,7 +2,7 @@ import logging
from typing import Self, cast from typing import Self, cast
from discord.ui import View, Button, Item, Select 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 import yandex_music.exceptions
from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient
@@ -13,14 +13,14 @@ class ToggleButton(Button, VoiceExtension):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction): async def callback(self, interaction: Interaction) -> None:
callback_type = interaction.custom_id callback_type = interaction.custom_id
if callback_type not in ('repeat', 'shuffle'): if callback_type not in ('repeat', 'shuffle'):
raise ValueError(f"Invalid callback type: '{callback_type}'") raise ValueError(f"Invalid callback type: '{callback_type}'")
logging.info(f'[MENU] {callback_type.capitalize()} button callback') logging.info(f'[MENU] {callback_type.capitalize()} button callback')
if not (gid := interaction.guild_id): if not (gid := interaction.guild_id) or not interaction.user:
logging.warning('[MENU] Failed to get guild ID.') logging.warning('[MENU] Failed to get guild ID.')
await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True) await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True)
return return
@@ -29,9 +29,36 @@ class ToggleButton(Button, VoiceExtension):
return return
guild = await self.db.get_guild(gid) 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]}) 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) await interaction.respond("❌ Что-то пошло не так. Попробуйте снова.", delete_after=15, ephemeral=True)
class PlayPauseButton(Button, VoiceExtension): class PlayPauseButton(Button, VoiceExtension):
@@ -44,9 +71,39 @@ class PlayPauseButton(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
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: if not (vc := await self.get_voice_client(interaction)) or not interaction.message:
return 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: try:
embed = interaction.message.embeds[0] embed = interaction.message.embeds[0]
except IndexError: except IndexError:
@@ -67,23 +124,61 @@ class SwitchTrackButton(Button, VoiceExtension):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction): async def callback(self, interaction: Interaction) -> None:
callback_type = interaction.custom_id callback_type = interaction.custom_id
if callback_type not in ('next', 'previous'): if callback_type not in ('next', 'previous'):
raise ValueError(f"Invalid callback type: '{callback_type}'") raise ValueError(f"Invalid callback type: '{callback_type}'")
if not (gid := interaction.guild_id) or not interaction.user:
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') logging.info(f'[MENU] {callback_type.capitalize()} track button callback')
if not await self.voice_check(interaction, check_vibe_privilage=True): if not await self.voice_check(interaction, check_vibe_privilage=True):
return 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': if callback_type == 'next':
title = await self.next_track(interaction, button_callback=True) title = await self.next_track(interaction, button_callback=True)
else: else:
title = await self.prev_track(interaction, button_callback=True) title = await self.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)
class ReactionButton(Button, VoiceExtension): class ReactionButton(Button, VoiceExtension):
def __init__(self, *args, **kwargs): 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: if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing:
await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True)
channel = cast(VoiceChannel, interaction.channel)
res = await self.react_track(interaction, callback_type) res = await self.react_track(interaction, callback_type)
if callback_type == 'like' and res[0]: if callback_type == 'like' and res[0]:
await self._update_menu_views_dict(interaction) await self._update_menu_views_dict(interaction)
await interaction.edit(view=menu_views[gid]) 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]: 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: else:
logging.debug(f"[VC_EXT] Failed to {callback_type} track") logging.debug(f"[VC_EXT] Failed to get {callback_type} tracks")
await interaction.respond("❌ Операция не удалась. Попробуйте позже.") await interaction.respond("❌ Операция не удалась. Попробуйте позже.", delete_after=15, ephemeral=True)
class LyricsButton(Button, VoiceExtension): class LyricsButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -192,7 +303,7 @@ class MyVibeButton(Button, VoiceExtension):
if next_track: if next_track:
# Need to avoid additional feedback. # Need to avoid additional feedback.
# TODO: Make it more elegant # 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): class MyVibeSelect(Select, VoiceExtension):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -211,26 +322,26 @@ class MyVibeSelect(Select, VoiceExtension):
custom_id = interaction.custom_id custom_id = interaction.custom_id
if custom_id not in ('diversity', 'mood', 'lang'): if custom_id not in ('diversity', 'mood', 'lang'):
logging.warning(f'[MENU] Unknown custom_id: {custom_id}') logging.error(f'[MENU] Unknown custom_id: {custom_id}')
return return
if not interaction.data or 'values' not in interaction.data: if not interaction.data:
logging.warning('[MENU] No data in select callback') logging.warning('[MENU] No data in select callback')
return return
data_value = interaction.data['values'][0] data_values = cast(list[str] | None, interaction.data.get('values'))
if data_value not in ( if not data_values or data_values[0] not in (
'fun', 'active', 'calm', 'sad', 'all', 'fun', 'active', 'calm', 'sad', 'all',
'favorite', 'popular', 'discover', 'default', 'favorite', 'popular', 'discover', 'default',
'not-russian', 'russian', 'without-words', 'any' 'not-russian', 'russian', 'without-words', 'any'
): ):
logging.warning(f'[MENU] Unknown data_value: {data_value}') logging.error(f'[MENU] Unknown data_value: {data_values}')
return return
logging.info(f"[MENU] Settings option '{custom_id}' updated to {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_value}) 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() view.disable_all_items()
await interaction.edit(view=view) await interaction.edit(view=view)
@@ -330,10 +441,15 @@ class AddToPlaylistSelect(Select, VoiceExtension):
logging.warning('[MENU] No data in select callback') logging.warning('[MENU] No data in select callback')
return return
data = interaction.data['values'][0].split(';') data_values = cast(list[str] | None, interaction.data.get('values'))
logging.debug(f"[MENU] Add to playlist select callback: {data}") 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') current_track = await self.db.get_track(interaction.guild_id, 'current')
if not current_track: if not current_track:
@@ -362,14 +478,19 @@ class AddToPlaylistButton(Button, VoiceExtension):
return return
client = await self.init_ym_client(interaction) 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: if not client:
await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', ephemeral=True) await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True)
return return
if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing:
await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True)
return return
playlists = await client.users_playlists_list()
if not playlists:
await interaction.respond('У вас нет плейлистов.', delete_after=15, ephemeral=True)
return
view = View( view = View(
AddToPlaylistSelect( AddToPlaylistSelect(
client, client,
@@ -379,7 +500,7 @@ class AddToPlaylistButton(Button, VoiceExtension):
SelectOption( SelectOption(
label=playlist.title or "Без названия", label=playlist.title or "Без названия",
value=f"{playlist.kind or "-1"};{playlist.uid}" 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,9 +548,8 @@ class MenuView(View, VoiceExtension):
self.add_item(self.next_button) self.add_item(self.next_button)
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:
self.dislike_button.disabled = True if likes and current_track and str(current_track['id']) in [str(like.id) for like in likes]:
elif likes and 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:
@@ -469,3 +589,4 @@ class MenuView(View, VoiceExtension):
logging.debug('[MENU] Successfully deleted menu message') logging.debug('[MENU] Successfully deleted menu message')
else: else:
logging.debug('[MENU] No menu message found') logging.debug('[MENU] No menu message found')
self.stop()