feat: Add "My Vibe" button to menu and enhance interactions.

This commit is contained in:
Lemon4ksan
2025-01-27 23:32:08 +03:00
parent c49ff949cf
commit c353de429f
8 changed files with 332 additions and 153 deletions

View File

@@ -245,7 +245,6 @@ class General(Cog):
logging.info(f"Successfully fetched playlists for user {ctx.user.id}")
await ctx.respond(embed=embed, view=MyPlaylists(ctx), ephemeral=True)
discord.Option
@discord.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.")
@discord.option(
"тип",

View File

@@ -58,6 +58,10 @@ def _generate_likes_embed(tracks: list[Track]) -> Embed:
duration_m = duration // 60000
duration_s = ceil(duration / 1000) - duration_m * 60
if duration_s == 60:
duration_m += 1
duration_s = 0
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if track_count is not None:
@@ -89,6 +93,9 @@ async def _generate_track_embed(track: Track) -> Embed:
duration_m = duration // 60000
duration_s = ceil(duration / 1000) - duration_m * 60
if duration_s == 60:
duration_m += 1
duration_s = 0
artist_url = f"https://music.yandex.ru/artist/{artist.id}"
artist_cover = artist.cover
@@ -184,6 +191,9 @@ async def _generate_album_embed(album: Album) -> Embed:
if duration:
duration_m = duration // 60000
duration_s = ceil(duration / 1000) - duration_m * 60
if duration_s == 60:
duration_m += 1
duration_s = 0
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if track_count is not None:
@@ -291,6 +301,9 @@ async def _generate_playlist_embed(playlist: Playlist) -> Embed:
if duration:
duration_m = duration // 60000
duration_s = ceil(duration / 1000) - duration_m * 60
if duration_s == 60:
duration_m += 1
duration_s = 0
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if track_count is not None:

View File

@@ -12,6 +12,8 @@ from discord import Interaction, ApplicationContext, RawReactionActionEvent
from MusicBot.cogs.utils import generate_item_embed
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase
# TODO: RawReactionActionEvent is poorly supported.
class VoiceExtension:
def __init__(self, bot: discord.Bot | None) -> None:
@@ -21,23 +23,21 @@ class VoiceExtension:
async def send_menu_message(self, ctx: ApplicationContext | Interaction) -> None:
from MusicBot.ui import MenuView
logging.info(f"Sending player menu")
logging.info("[VC] Sending player menu")
if not ctx.guild:
logging.warning("Guild not found in context inside 'create_menu'")
logging.warning("[VC] Guild not found in context inside 'create_menu'")
return
guild = self.db.get_guild(ctx.guild.id)
embed = None
if guild['current_track']:
embed = await generate_item_embed(
Track.de_json(
guild['current_track'],
client=YMClient() # type: ignore # Async client can be used here.
),
guild['vibing']
)
track = cast(Track, Track.de_json(
guild['current_track'],
client=YMClient() # type: ignore # Async client can be used here.
))
embed = await generate_item_embed(track, guild['vibing'])
vc = await self.get_voice_client(ctx)
if vc and vc.is_paused():
embed.set_footer(text='Приостановлено')
@@ -45,7 +45,7 @@ class VoiceExtension:
embed.remove_footer()
if guild['current_menu']:
logging.info(f"Deleteing old player menu {guild['current_menu']} in guild {ctx.guild.id}")
logging.info(f"[VC] Deleting old player menu {guild['current_menu']} in guild {ctx.guild.id}")
message = await self.get_menu_message(ctx, guild['current_menu'])
if message:
await message.delete()
@@ -54,19 +54,60 @@ class VoiceExtension:
response = await interaction.original_response()
self.db.update(ctx.guild.id, {'current_menu': response.id})
logging.info(f"New player menu {response.id} created in guild {ctx.guild.id}")
logging.info(f"[VC] New player menu {response.id} created in guild {ctx.guild.id}")
async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, player_mid: int) -> discord.Message | None:
"""Fetch the player message by its id. Return the message if found, None if not.
Reset `current_menu` field in the database if not found.
Args:
ctx (ApplicationContext | Interaction): Context.
player_mid (int): Id of the player message.
Returns:
discord.Message | None: Player message or None.
"""
logging.debug(f"[VC] Fetching player message {player_mid}...")
if not ctx.guild_id:
logging.warning("[VC] Guild ID not found in context")
return None
try:
if isinstance(ctx, Interaction):
player = ctx.client.get_message(player_mid)
elif isinstance(ctx, RawReactionActionEvent):
if not self.bot:
raise ValueError("Bot instance is not set.")
player = self.bot.get_message(player_mid)
elif isinstance(ctx, ApplicationContext):
player = await ctx.fetch_message(player_mid)
else:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.")
except discord.DiscordException as e:
logging.debug(f"[VC] Failed to get player message: {e}")
self.db.update(ctx.guild_id, {'current_menu': None})
return None
if player:
logging.debug("[VC] Player message found")
else:
logging.debug("[VC] Player message not found. Resetting current_menu field.")
self.db.update(ctx.guild_id, {'current_menu': None})
return player
async def update_menu_embed(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
player_mid: int,
menu_mid: int,
button_callback: bool = False
) -> bool:
"""Update current player message by its id. Return True if updated, False if not.
Args:
ctx (ApplicationContext | Interaction): Context.
player_mid (int): Id of the player message. There can only be only one player in the guild.
menu_mid (int): Id of the player message. There can only be only one player in the guild.
button_callback (bool, optional): If True, the interaction is a button interaction. Defaults to False.
Returns:
@@ -74,32 +115,33 @@ class VoiceExtension:
"""
from MusicBot.ui import MenuView
logging.debug(
f"Updating player embed using " + (
f"[VC] Updating player embed using " + (
"interaction context" if isinstance(ctx, Interaction) else
"application context" if isinstance(ctx, ApplicationContext) else
"raw reaction context")
"raw reaction context"
)
)
gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid:
logging.warning("Guild ID or User ID not found in context inside 'update_player_embed'")
logging.warning("[VC] Guild ID or User ID not found in context inside 'update_player_embed'")
return False
player = await self.get_menu_message(ctx, player_mid)
player = await self.get_menu_message(ctx, menu_mid)
if not player:
return False
token = self.users_db.get_ym_token(uid)
if not token:
logging.debug(f"No token found for user {uid}")
logging.debug(f"[VC] No token found for user {uid}")
return False
guild = self.db.get_guild(gid)
current_track = guild['current_track']
if not current_track:
logging.debug("No current track found")
logging.debug("[VC] No current track found")
return False
track = cast(Track, Track.de_json(
@@ -117,56 +159,54 @@ class VoiceExtension:
# If interaction from other buttons or commands. They should have their own response.
await player.edit(embed=embed, view=await MenuView(ctx).init())
except discord.NotFound:
logging.warning("[VC] Player message not found")
return False
return True
async def update_vibe(
self, ctx: ApplicationContext | Interaction,
type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None = None,
id: str | int | None = None,
self,
ctx: ApplicationContext | Interaction,
type: Literal['track', 'album', 'artist', 'playlist', 'user'],
id: str | int,
*,
button_callback: bool = False
) -> str | None:
"""Get next vibe track. Return track title on success. If type or id is None, user's vibe will be used.
"""Update vibe state. Return track title on success.
Args:
ctx (ApplicationContext | Interaction): Context.
type (Literal['track', 'album', 'artist', 'playlist', 'user'] | None, optional): Type of the item. Defaults to None.
id (str | int | Literal['onyourwave'] | None, optional): ID of the item. Defaults to None.
type (Literal['track', 'album', 'artist', 'playlist', 'user']): Type of the item.
id (str | int): ID of the item.
button_callback (bool, optional): If the function is called from button callback. Defaults to False.
Returns:
str | None: Track title or None.
"""
logging.info(f"Updating vibe for guild {ctx.guild_id} with type '{type}' and id '{id}'")
logging.info(f"[VC] Updating vibe for guild {ctx.guild_id} with type '{type}' and id '{id}'")
gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not uid or not gid:
logging.warning("Guild ID or User ID not found in context inside 'vibe_update'")
logging.warning("[VC] Guild ID or User ID not found in context inside 'vibe_update'")
return None
token = self.users_db.get_ym_token(uid)
if not token:
logging.info(f"User {uid} has no YM token")
logging.info(f"[VC] User {uid} has no YM token")
await ctx.respond("❌ Укажите токен через /account login.", ephemeral=True)
return
try:
client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError:
logging.info(f"User {uid} provided invalid token")
logging.info(f"[VC] User {uid} provided invalid token")
await ctx.respond('❌ Недействительный токен.')
return
if type and id:
self.users_db.update(uid, {'vibe_type': type, 'vibe_id': id})
else:
logging.info(f"[VIBE] Using user's vibe for guild {gid}")
type = 'user'
id = 'onyourwave'
self.users_db.update(uid, {'vibe_type': type, 'vibe_id': id})
guild = self.db.get_guild(gid)
if not guild['vibing']:
feedback = await client.rotor_station_feedback_radio_started(
f"{type}:{id}",
@@ -175,9 +215,7 @@ class VoiceExtension:
)
logging.debug(f"[VIBE] Radio started feedback: {feedback}")
tracks = await client.rotor_station_tracks(
f"{type}:{id}"
)
tracks = await client.rotor_station_tracks(f"{type}:{id}")
self.db.update(gid, {'vibing': True})
elif guild['current_track']:
tracks = await client.rotor_station_tracks(
@@ -197,50 +235,12 @@ class VoiceExtension:
next_tracks = [cast(Track, track.track) for track in tracks.sequence]
self.db.update(gid, {'next_tracks': [track.to_dict() for track in next_tracks[1:]]})
self.db.update(gid, {
'next_tracks': [track.to_dict() for track in next_tracks[1:]],
'current_viber_id': uid
})
await self.stop_playing(ctx)
return await self.play_track(ctx, next_tracks[0], button_callback=button_callback)
async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, player_mid: int) -> discord.Message | None:
"""Fetch the player message by its id. Return the message if found, None if not.
Reset `current_menu` field in the database if not found.
Args:
ctx (ApplicationContext | Interaction): Context.
player_mid (int): Id of the player message.
Returns:
discord.Message | None: Player message or None.
"""
logging.debug(f"Fetching player message {player_mid}...")
if not ctx.guild_id:
logging.warning("Guild ID not found in context")
return None
try:
if isinstance(ctx, Interaction):
player = ctx.client.get_message(player_mid)
elif isinstance(ctx, RawReactionActionEvent):
if not self.bot:
raise ValueError("Bot instance is not set.")
player = self.bot.get_message(player_mid)
elif isinstance(ctx, ApplicationContext):
player = await ctx.fetch_message(player_mid)
else:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.")
except discord.DiscordException as e:
logging.debug(f"Failed to get player message: {e}")
self.db.update(ctx.guild_id, {'current_menu': None})
return None
if player:
logging.debug(f"Player message found")
else:
logging.debug("Player message not found. Resetting current_menu field.")
self.db.update(ctx.guild_id, {'current_menu': None})
return player
async def voice_check(self, ctx: ApplicationContext | Interaction) -> bool:
"""Check if bot can perform voice tasks and respond if failed.
@@ -251,33 +251,36 @@ class VoiceExtension:
Returns:
bool: Check result.
"""
if not ctx.user:
logging.warning("User not found in context inside 'voice_check'")
if not ctx.user or not ctx.guild:
logging.warning("[VC] User or guild not found in context inside 'voice_check'")
return False
token = self.users_db.get_ym_token(ctx.user.id)
if not token:
logging.debug(f"No token found for user {ctx.user.id}")
logging.debug(f"[VC] No token found for user {ctx.user.id}")
await ctx.respond("❌ Необходимо указать свой токен доступа с помощью команды /login.", delete_after=15, ephemeral=True)
return False
channel = ctx.channel
if not isinstance(channel, discord.VoiceChannel):
logging.debug("User is not in a voice channel")
if not isinstance(ctx.channel, discord.VoiceChannel):
logging.debug("[VC] User is not in a voice channel")
await ctx.respond("❌ Вы должны отправить команду в голосовом канале.", delete_after=15, ephemeral=True)
return False
if isinstance(ctx, Interaction):
channels = ctx.client.voice_clients
else:
channels = ctx.bot.voice_clients
voice_chat = discord.utils.get(channels, guild=ctx.guild)
voice_clients = ctx.client.voice_clients if isinstance(ctx, Interaction) else ctx.bot.voice_clients
voice_chat = discord.utils.get(voice_clients, guild=ctx.guild)
if not voice_chat:
logging.debug("Voice client not found")
logging.debug("[VC] Voice client not found")
await ctx.respond("❌ Добавьте бота в голосовой канал при помощи команды /voice join.", delete_after=15, ephemeral=True)
return False
logging.debug("Voice requirements met")
guild = self.db.get_guild(ctx.guild.id)
member = cast(discord.Member, ctx.user)
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")
await ctx.respond("❌ Вы не можете взаимодействовать с чужой волной!", delete_after=15, ephemeral=True)
return False
logging.debug("[VC] Voice requirements met")
return True
async def get_voice_client(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> discord.VoiceClient | None:
@@ -290,25 +293,30 @@ class VoiceExtension:
discord.VoiceClient | None: Voice client or None.
"""
if isinstance(ctx, Interaction):
voice_chat = discord.utils.get(ctx.client.voice_clients, guild=ctx.guild)
voice_clients = ctx.client.voice_clients
guild = ctx.guild
elif isinstance(ctx, RawReactionActionEvent):
if not self.bot:
raise ValueError("Bot instance is not set.")
if not ctx.guild_id:
logging.warning("Guild ID not found in context inside get_voice_client")
logging.warning("[VC] Guild ID not found in context inside get_voice_client")
return None
voice_chat = discord.utils.get(self.bot.voice_clients, guild=await self.bot.fetch_guild(ctx.guild_id))
voice_clients = self.bot.voice_clients
guild = await self.bot.fetch_guild(ctx.guild_id)
elif isinstance(ctx, ApplicationContext):
voice_chat = discord.utils.get(ctx.bot.voice_clients, guild=ctx.guild)
voice_clients = ctx.bot.voice_clients
guild = ctx.guild
else:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.")
if voice_chat:
logging.debug("Voice client found")
else:
logging.debug("Voice client not found")
voice_chat = discord.utils.get(voice_clients, guild=guild)
return cast((discord.VoiceClient | None), voice_chat)
if voice_chat:
logging.debug("[VC] Voice client found")
else:
logging.debug("[VC] Voice client not found")
return cast(discord.VoiceClient | None, voice_chat)
async def play_track(
self,
@@ -316,7 +324,9 @@ class VoiceExtension:
track: Track,
*,
vc: discord.VoiceClient | None = None,
button_callback: bool = False
menu_message: discord.Message | None = None,
button_callback: bool = False,
retry: bool = False
) -> str | None:
"""Download ``track`` by its id and play it in the voice channel. Return track title on success.
If sound is already playing, add track id to the queue. There's no response to the context.
@@ -325,15 +335,19 @@ class VoiceExtension:
ctx (ApplicationContext | Interaction): Context
track (Track): Track to play.
vc (discord.VoiceClient | None): Voice client.
menu_message (discord.Message | None): Menu message.
button_callback (bool): Whether the interaction is a button callback.
retry (bool): Whether the function is called again.
Returns:
str | None: Song title or None.
"""
from MusicBot.ui import MenuView
gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid:
logging.warning("Guild ID or User ID not found in context inside 'play_track'")
logging.warning("[VC] Guild ID or User ID not found in context inside 'play_track'")
return None
if not vc:
@@ -352,25 +366,36 @@ class VoiceExtension:
else:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.")
self.db.set_current_track(gid, track)
guild = self.db.get_guild(gid)
if guild['current_menu'] and not isinstance(ctx, RawReactionActionEvent):
if menu_message:
try:
await menu_message.edit(embed=await generate_item_embed(track, guild['vibing']), view=await MenuView(ctx).init())
except discord.errors.NotFound:
logging.warning("[VC] Menu message not found. Using 'update_menu_embed' instead.")
await self._retry_update_menu_embed(ctx, guild['current_menu'], button_callback)
else:
await self._retry_update_menu_embed(ctx, guild['current_menu'], button_callback)
try:
await track.download_async(f'music/{gid}.mp3')
song = discord.FFmpegPCMAudio(f'music/{gid}.mp3', options='-vn -filter:a "volume=0.15"')
except yandex_music.exceptions.TimedOutError: # Not sure why that happens. Probably should add timeout for buttons.
except yandex_music.exceptions.TimedOutError: # sometimes track takes too long to download.
logging.warning(f"[VC] Timed out while downloading track '{track.title}'")
if not isinstance(ctx, RawReactionActionEvent) and ctx.user and ctx.channel:
channel = cast(discord.VoiceChannel, ctx.channel)
await channel.send(f"😔 {ctx.user.mention}, не удалось загрузить трек. Яндекс Музыка не отвечает или блокирует запросы.")
if not retry:
channel = cast(discord.VoiceChannel, ctx.channel)
await channel.send(f"Не удалось загрузить трек. Пробуем заного...", delete_after=5)
return await self.play_track(ctx, track, vc=vc, button_callback=button_callback, retry=True)
await channel.send(f"😔 Снова не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15)
return None
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop))
logging.info(f"Playing track '{track.title}'")
logging.info(f"[VC] Playing track '{track.title}'")
self.db.set_current_track(gid, track)
self.db.update(gid, {'is_stopped': False})
player = guild['current_menu']
if player is not None:
await self.update_menu_embed(ctx, player, button_callback)
if guild['vibing']:
user = self.users_db.get_user(uid)
@@ -388,13 +413,13 @@ class VoiceExtension:
gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None
if not gid:
logging.warning("Guild ID not found in context")
logging.warning("[VC] Guild ID not found in context")
return
if not vc:
vc = await self.get_voice_client(ctx)
if vc:
logging.debug("Stopping playback")
logging.debug("[VC] Stopping playback")
self.db.update(gid, {'current_track': None, 'is_stopped': True})
vc.stop()
@@ -418,8 +443,12 @@ class VoiceExtension:
Returns:
str | None: Track title or None.
"""
from MusicBot.ui import MenuView
gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
menu_message = None
if not gid or not uid:
logging.warning("Guild ID or User ID not found in context inside 'next_track'")
return None
@@ -430,7 +459,7 @@ class VoiceExtension:
if not token:
logging.debug(f"No token found for user {uid}")
return None
client = await YMClient(token).init()
if guild['is_stopped'] and after:
@@ -442,7 +471,16 @@ class VoiceExtension:
if not vc: # Silently return if bot got kicked
return None
if after and guild['current_menu']:
menu_message = await self.get_menu_message(ctx, guild['current_menu'])
if menu_message:
await menu_message.edit(view=await MenuView(ctx).init(disable=True))
if guild['vibing'] and not isinstance(ctx, RawReactionActionEvent):
if not user['vibe_type'] or not user['vibe_id']:
logging.warning("[VIBE] No vibe type or id found")
return None
if guild['current_track']:
if after:
res = await client.rotor_station_feedback_track_finished(
@@ -462,8 +500,13 @@ class VoiceExtension:
time()
)
logging.debug(f"[VIBE] Skipped track: {res}")
return await self.update_vibe(ctx, user['vibe_type'], user['vibe_id'], button_callback)
return await self.update_vibe(
ctx,
user['vibe_type'],
user['vibe_id'],
button_callback=button_callback
)
if guild['repeat'] and after:
logging.debug("Repeating current track")
next_track = guild['current_track']
@@ -488,6 +531,7 @@ class VoiceExtension:
ctx,
ym_track, # type: ignore # de_json should always work here.
vc=vc,
menu_message=menu_message,
button_callback=button_callback
)
@@ -495,9 +539,19 @@ class VoiceExtension:
await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15)
return title
elif guild['vibing'] and not isinstance(ctx, RawReactionActionEvent):
logging.debug("[VIBE] No next track found, updating vibe")
return await self.update_vibe(ctx, user['vibe_type'], user['vibe_id'], button_callback)
if not user['vibe_type'] or not user['vibe_id']:
logging.warning("[VIBE] No vibe type or id found")
return None
return await self.update_vibe(
ctx,
user['vibe_type'],
user['vibe_id'],
button_callback=button_callback
)
logging.info("No next track found")
self.db.update(gid, {'is_stopped': True, 'current_track': None})
@@ -553,14 +607,14 @@ class VoiceExtension:
async def get_likes(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort] | None:
"""Get liked tracks. Return list of tracks on success. Return None if no token found.
Args:
ctx (ApplicationContext | Interaction): Context.
Returns:
list[Track] | None: List of tracks or None.
"""
gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid:
@@ -583,7 +637,7 @@ class VoiceExtension:
return None
return likes.tracks
async def like_track(self, ctx: ApplicationContext | Interaction) -> str | Literal['TRACK REMOVED'] | None:
"""Like current track. Return track title on success.
@@ -623,4 +677,16 @@ class VoiceExtension:
logging.debug("Client account not found")
return None
await client.users_likes_tracks_remove(ym_track.id, client.me.account.uid)
return 'TRACK REMOVED'
return 'TRACK REMOVED'
async def _retry_update_menu_embed(
self,
ctx: ApplicationContext | Interaction,
menu_mid: int,
button_callback: bool
) -> None:
update = await self.update_menu_embed(ctx, menu_mid, button_callback)
for _ in range(10):
if update:
break
update = await self.update_menu_embed(ctx, menu_mid, button_callback)

View File

@@ -157,7 +157,7 @@ class Voice(Cog, VoiceExtension):
await message.clear_reactions()
await message.edit(content='Запрос был отклонён.', delete_after=15)
del votes[str(payload.message_id)]
self.db.update(guild_id, {'votes': votes})
@Cog.listener()
@@ -165,7 +165,7 @@ class Voice(Cog, VoiceExtension):
logging.info(f"Reaction removed by user {payload.user_id} in channel {payload.channel_id}")
if not self.typed_bot.user:
return
guild_id = payload.guild_id
if not guild_id:
return
@@ -187,7 +187,7 @@ class Voice(Cog, VoiceExtension):
elif payload.emoji.name == '':
logging.info(f"User {payload.user_id} removed negative vote for message {payload.message_id}")
del vote_data['negative_votes'][payload.user_id]
self.db.update(guild_id, {'votes': votes})
@voice.command(name="menu", description="Создать меню проигрывателя. Доступно только если вы единственный в голосовом канале.")
@@ -231,7 +231,7 @@ class Voice(Cog, VoiceExtension):
logging.info(f"User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild.id}")
await ctx.respond("У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True)
return
vc = await self.get_voice_client(ctx)
if await self.voice_check(ctx) and vc:
self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': [], 'current_track': None, 'is_stopped': True})
@@ -440,9 +440,9 @@ class Voice(Cog, VoiceExtension):
logging.info(f"Track added to favorites for user {ctx.author.id} in guild {ctx.guild.id}")
await ctx.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True)
@track.command(description="Запустить мою волну по текущему треку.")
async def vibe(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"Vibe command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
@track.command(name='vibe', description="Запустить мою волну по текущему треку.")
async def track_vibe(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"Vibe (track) command invoked by user {ctx.author.id} in guild {ctx.guild.id}")
if not await self.voice_check(ctx):
return
@@ -460,3 +460,20 @@ class Voice(Cog, VoiceExtension):
await self.send_menu_message(ctx)
await self.update_vibe(ctx, 'track', guild['current_track']['id'])
@discord.slash_command(name='vibe', description="Запустить Мою Волну.")
async def user_vibe(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"Vibe (user) command invoked by user {ctx.user.id} in guild {ctx.guild_id}")
if not await self.voice_check(ctx):
return
guild = self.db.get_guild(ctx.guild.id)
channel = cast(discord.VoiceChannel, ctx.channel)
if len(channel.members) > 2 and not guild['always_allow_menu']:
logging.info(f"Action declined: other members are present in the voice channel")
await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True)
return
await self.send_menu_message(ctx)
await self.update_vibe(ctx, 'user', 'onyourwave')

View File

@@ -106,7 +106,8 @@ class BaseGuildsDatabase:
shuffle=False,
repeat=False,
votes={},
vibing=False
vibing=False,
current_viber_id=None
))
def update(self, gid: int, data: Guild) -> None:
@@ -152,7 +153,8 @@ class BaseGuildsDatabase:
shuffle=False,
repeat=False,
votes={},
vibing=False
vibing=False,
current_viber_id=None
)
for field, default_value in fields.items():
if field not in existing_fields:

View File

@@ -24,6 +24,7 @@ class Guild(TypedDict, total=False):
repeat: bool
votes: dict[str, MessageVotes]
vibing: bool
current_viber_id: int | None
class ExplicitGuild(TypedDict):
_id: int
@@ -42,4 +43,5 @@ class ExplicitGuild(TypedDict):
shuffle: bool
repeat: bool
votes: dict[str, MessageVotes]
vibing: bool
vibing: bool
current_viber_id: int | None

View File

@@ -1,5 +1,5 @@
import logging
from typing import Literal, cast
from typing import Any, Literal, cast
import discord
from yandex_music import Track, Album, Artist, Playlist
@@ -19,11 +19,11 @@ class PlayButton(Button, VoiceExtension):
logging.debug(f"Callback triggered for type: '{type(self.item).__name__}'")
if not interaction.guild:
logging.warning("No guild found in context.")
logging.warning("No guild found in PlayButton callback")
return
if not await self.voice_check(interaction):
logging.debug("Voice check failed")
logging.debug("Voice check failed in PlayButton callback")
return
gid = interaction.guild.id
@@ -41,7 +41,7 @@ class PlayButton(Button, VoiceExtension):
elif isinstance(self.item, Album):
album = await self.item.with_tracks_async()
if not album or not album.volumes:
logging.debug("Failed to fetch album tracks")
logging.debug("Failed to fetch album tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки альбома.", ephemeral=True)
return
@@ -53,7 +53,7 @@ class PlayButton(Button, VoiceExtension):
elif isinstance(self.item, Artist):
artist_tracks = await self.item.get_tracks_async()
if not artist_tracks:
logging.debug("Failed to fetch artist tracks")
logging.debug("Failed to fetch artist tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки артиста.", ephemeral=True)
return
@@ -65,7 +65,7 @@ class PlayButton(Button, VoiceExtension):
elif isinstance(self.item, Playlist):
short_tracks = await self.item.fetch_tracks_async()
if not short_tracks:
logging.debug("Failed to fetch playlist tracks")
logging.debug("Failed to fetch playlist tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки из плейлиста.", delete_after=15)
return
@@ -77,7 +77,7 @@ class PlayButton(Button, VoiceExtension):
elif isinstance(self.item, list):
tracks = self.item.copy()
if not tracks:
logging.debug("Empty tracks list")
logging.debug("Empty tracks list in PlayButton callback")
await interaction.respond("Не удалось получить треки.", delete_after=15)
return
@@ -89,7 +89,7 @@ class PlayButton(Button, VoiceExtension):
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:
logging.debug(f"Starting vote for '{action}'")
logging.debug(f"Starting vote for '{action}' (from PlayButton callback)")
message = cast(discord.Interaction, await interaction.respond(vote_message, delete_after=30))
response = await message.original_response()
@@ -109,7 +109,7 @@ class PlayButton(Button, VoiceExtension):
}
)
else:
logging.debug(f"Skipping vote for '{action}'")
logging.debug(f"Skipping vote for '{action}' (from PlayButton callback)")
if guild['current_track'] is not None:
self.db.modify_track(gid, tracks, 'next', 'extend')
@@ -129,6 +129,41 @@ class PlayButton(Button, VoiceExtension):
else:
await interaction.respond(response_message, delete_after=15)
class MyVibeButton(Button, VoiceExtension):
def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *args, **kwargs):
Button.__init__(self, *args, **kwargs)
VoiceExtension.__init__(self, None)
self.item = item
async def callback(self, interaction: discord.Interaction):
logging.debug(f"[VIBE] Button callback for '{type(self.item).__name__}'")
if not await self.voice_check(interaction):
return
gid = interaction.guild_id
if not gid:
logging.warning(f"[VIBE] Guild ID is None in button callback")
return
guild = 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: dict[Any, Literal['track', 'album', 'artist', 'playlist', 'user']] = {
Track: 'track', Album: 'album', Artist: 'artist', Playlist: 'playlist', list: 'user'
} # NOTE: Likes playlist should have its own entry instead of 'user:onyourwave'
await self.send_menu_message(interaction)
await self.update_vibe(
interaction,
track_type_map[type(self.item)],
cast(int, self.item.uid) if isinstance(self.item, Playlist) else cast(int | str, self.item.id) if not isinstance(self.item, list) else 'onyourwave'
)
class ListenView(View):
def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = False):
super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout)
@@ -150,11 +185,13 @@ class ListenView(View):
self.add_item(PlayButton(item, label="Слушать в голосовом канале", style=ButtonStyle.gray))
return
self.button1: Button = Button(label="Слушать в приложении", style=ButtonStyle.gray, url=link_app)
self.button2: Button = Button(label="Слушать в браузере", style=ButtonStyle.gray, url=link_web)
self.button3: PlayButton = PlayButton(item, label="Слушать в голосовом канале", style=ButtonStyle.gray)
self.button1: Button = Button(label="Слушать в приложении", style=ButtonStyle.gray, url=link_app, row=0)
self.button2: Button = Button(label="Слушать в браузере", style=ButtonStyle.gray, url=link_web, row=0)
self.button3: PlayButton = PlayButton(item, label="Слушать в голосовом канале", style=ButtonStyle.gray, row=0)
self.button4: MyVibeButton = MyVibeButton(item, label="Моя Волна", style=ButtonStyle.gray, emoji="🌊", row=1)
if item.available:
# self.add_item(self.button1) # Discord doesn't allow well formed URLs in buttons for some reason.
self.add_item(self.button2)
self.add_item(self.button3)
self.add_item(self.button3)
self.add_item(self.button4)

View File

@@ -1,9 +1,10 @@
import logging
from typing import Self, cast
from discord.ui import View, Button, Item
from discord import VoiceChannel, ButtonStyle, Interaction, ApplicationContext, RawReactionActionEvent, Embed
from discord.ui import View, Button, Item, Modal, Select
from discord import VoiceChannel, ButtonStyle, Interaction, ApplicationContext, RawReactionActionEvent, Embed, ComponentType, SelectOption
import yandex_music.exceptions
from yandex_music import Track, ClientAsync
from MusicBot.cogs.utils.voice_extension import VoiceExtension
@@ -123,8 +124,15 @@ class LyricsButton(Button, VoiceExtension):
ClientAsync(ym_token), # type: ignore # Async client can be used here
))
lyrics = await track.get_lyrics_async()
try:
lyrics = await track.get_lyrics_async()
except yandex_music.exceptions.NotFoundError:
logging.debug('Lyrics not found')
await interaction.respond("❌ Текст песни не найден. Яндекс нам соврал (опять)!", delete_after=15, ephemeral=True)
return
if not lyrics:
logging.debug('Lyrics not found')
return
embed = Embed(
@@ -137,6 +145,36 @@ class LyricsButton(Button, VoiceExtension):
embed.add_field(name='', value=subtext, inline=False)
await interaction.respond(embed=embed, ephemeral=True)
class MyVibeButton(Button, VoiceExtension):
def __init__(self, **kwargs):
Button.__init__(self, **kwargs)
VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction) -> None:
logging.info('[VIBE] My vibe button callback')
if not await self.voice_check(interaction):
return
if not interaction.guild_id:
logging.warning('[VIBE] No guild id in button callback')
return
track = self.db.get_track(interaction.guild_id, 'current')
if track:
logging.info(f"[VIBE] Playing vibe for track '{track["id"]}'")
await self.update_vibe(
interaction,
'track',
track['id'],
button_callback=True
)
else:
logging.info('[VIBE] Playing on your wave')
await self.update_vibe(
interaction,
'user',
'onyourwave',
button_callback=True
)
class MenuView(View, VoiceExtension):
@@ -156,8 +194,9 @@ class MenuView(View, VoiceExtension):
self.like_button = LikeButton(style=ButtonStyle.secondary, emoji='❤️', row=1)
self.lyrics_button = LyricsButton(style=ButtonStyle.secondary, emoji='📋', row=1)
self.vibe_button = MyVibeButton(style=ButtonStyle.secondary, emoji='🌊', row=1)
async def init(self) -> Self:
async def init(self, *, disable: bool = False) -> Self:
current_track = self.guild['current_track']
likes = await self.get_likes(self.ctx)
@@ -177,6 +216,10 @@ class MenuView(View, VoiceExtension):
self.add_item(self.like_button)
self.add_item(self.lyrics_button)
self.add_item(self.vibe_button)
if disable:
self.disable_all_items()
return self
@@ -186,7 +229,7 @@ class MenuView(View, VoiceExtension):
return
if self.guild['current_menu']:
self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': []})
self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': [], 'vibing': False})
message = await self.get_menu_message(self.ctx, self.guild['current_menu'])
if message:
await message.delete()