feat: Add the ability to use the bot with single YM token.

This commit is contained in:
Lemon4ksan
2025-03-15 17:30:42 +03:00
parent a9c938b736
commit 7fe9d699b1
8 changed files with 171 additions and 100 deletions

View File

@@ -211,6 +211,11 @@ class General(Cog, BaseBot):
async def likes(self, ctx: discord.ApplicationContext) -> None: async def likes(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[GENERAL] Likes command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Likes command invoked by user {ctx.author.id} in guild {ctx.guild_id}")
guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'})
if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']:
await ctx.respond('❌ Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True)
return
if not (client := await self.init_ym_client(ctx)): if not (client := await self.init_ym_client(ctx)):
return return
@@ -253,6 +258,11 @@ class General(Cog, BaseBot):
# 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, projection={'single_token_uid'})
if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']:
await ctx.respond('❌ Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True)
return
if not (client := await self.init_ym_client(ctx)): if not (client := await self.init_ym_client(ctx)):
return return
@@ -284,6 +294,11 @@ class General(Cog, BaseBot):
async def playlist(self, ctx: discord.ApplicationContext, name: str) -> None: async def playlist(self, ctx: discord.ApplicationContext, name: str) -> None:
logging.info(f"[GENERAL] Playlist command invoked by user {ctx.user.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Playlist command invoked by user {ctx.user.id} in guild {ctx.guild_id}")
guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid'})
if guild['single_token_uid'] and ctx.author.id != guild['single_token_uid']:
await ctx.respond('❌ Только владелец токена может делиться личными плейлистами.', delete_after=15, ephemeral=True)
return
if not (client := await self.init_ym_client(ctx)): if not (client := await self.init_ym_client(ctx)):
return return

View File

@@ -21,60 +21,80 @@ 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:
if not ctx.guild_id: if not ctx.guild_id:
logging.warning("[SETTINGS] Show command invoked without guild_id") logging.info("[SETTINGS] Show command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return return
guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1, 'vote_switch_track': 1, 'vote_add': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={
'allow_change_connect': 1, 'vote_switch_track': 1, 'vote_add': 1, 'use_single_token': 1
})
vote = "✅ - Переключение" if guild['vote_switch_track'] else "❌ - Переключение" vote = "✅ - Переключение" if guild['vote_switch_track'] else "❌ - Переключение"
vote += "\n✅ - Добавление в очередь" if guild['vote_add'] else "\n❌ - Добавление в очередь" vote += "\n✅ - Добавление в очередь" if guild['vote_add'] else "\n❌ - Добавление в очередь"
connect = "\n✅ - Разрешено всем" if guild['allow_change_connect'] else "\n❌ - Только для участникам с правами управления каналом" connect = "\n✅ - Разрешено всем" if guild['allow_change_connect'] else "\n❌ - Только для участникам с правами управления каналом"
token = "🔐 - Используется токен пользователя, запустившего бота" if guild['use_single_token'] else "🔒 - Используется личный токен пользователя"
embed = discord.Embed(title="Настройки бота", color=0xfed42b) embed = discord.Embed(title="Настройки бота", color=0xfed42b)
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)
embed.add_field(name="__Токен__", value=token, inline=False)
await ctx.respond(embed=embed, ephemeral=True) await ctx.respond(embed=embed, ephemeral=True)
@settings.command(name="toggle", description="Переключить параметр настроек.") @settings.command(name="toggle", description="Переключить параметры основных настроек.")
@discord.option( @discord.option(
"параметр", "параметр",
parameter_name="vote_type", parameter_name="vote_type",
description="Тип голосования.", description="Тип голосования.",
type=discord.SlashCommandOptionType.string, type=discord.SlashCommandOptionType.string,
choices=['Переключение', 'Добавление в очередь', 'Добавление/Отключение бота'] choices=[
'Переключение треков без голосования для всех',
'Добавление в очередь без голосования для всех',
'Добавление/Отключение бота из канала для всех',
'Использовать единый токен для прослушивания'
]
) )
async def toggle( async def toggle(
self, self,
ctx: discord.ApplicationContext, ctx: discord.ApplicationContext,
vote_type: Literal['Переключение', 'Добавление в очередь', 'Добавление/Отключение бота'] vote_type: Literal[
'Переключение треков без голосования для всех',
'Добавление в очередь без голосования для всех',
'Добавление/Отключение бота из канала для всех',
'Использовать единый токен для прослушивания'
]
) -> None: ) -> None:
member = cast(discord.Member, ctx.author) if not ctx.guild_id:
logging.info("[SETTINGS] Toggle command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True)
return
member = cast(discord.Member, ctx.user)
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
if not ctx.guild_id:
logging.warning("[SETTINGS] Toggle command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return
guild = await self.db.get_guild(ctx.guild_id, projection={ guild = await self.db.get_guild(ctx.guild_id, projection={
'vote_switch_track': 1, 'vote_add': 1, 'allow_change_connect': 1}) 'vote_switch_track': 1, 'vote_add': 1, 'allow_change_connect': 1, 'use_single_token': 1
})
if vote_type == 'Переключение': if vote_type == 'Переключение треков без голосования для всех':
await self.db.update(ctx.guild_id, {'vote_switch_track': not guild['vote_switch_track']}) await self.db.update(ctx.guild_id, {'vote_switch_track': not guild['vote_switch_track']})
response_message = "Голосование за переключение трека " + ("❌ выключено." if guild['vote_switch_track'] else "✅ включено.") response_message = "Голосование за переключение трека " + ("❌ выключено." if guild['vote_switch_track'] else "✅ включено.")
elif vote_type == 'Добавление в очередь': elif vote_type == 'Добавление в очередь без голосования для всех':
await self.db.update(ctx.guild_id, {'vote_add': not guild['vote_add']}) await self.db.update(ctx.guild_id, {'vote_add': not guild['vote_add']})
response_message = "Голосование за добавление в очередь " + ("❌ выключено." if guild['vote_add'] else "✅ включено.") response_message = "Голосование за добавление в очередь " + ("❌ выключено." if guild['vote_add'] else "✅ включено.")
elif vote_type == 'Добавление/Отключение бота': elif vote_type == 'Добавление/Отключение бота из канала для всех':
await self.db.update(ctx.guild_id, {'allow_change_connect': not guild['allow_change_connect']}) await self.db.update(ctx.guild_id, {'allow_change_connect': not guild['allow_change_connect']})
response_message = f"Добавление/Отключение бота от канала теперь {'✅ разрешено' if not guild['allow_change_connect'] else '❌ запрещено'} участникам без прав управления каналом." response_message = f"Добавление/Отключение бота от канала теперь {'✅ разрешено' if not guild['allow_change_connect'] else '❌ запрещено'} участникам без прав управления каналом."
elif vote_type == 'Использовать единый токен для прослушивания':
await self.db.update(ctx.guild_id, {'use_single_token': not guild['use_single_token']})
response_message = f"Использование единого токена для прослушивания теперь {'✅ включено' if not guild['use_single_token'] else '❌ выключено'}."
else: else:
response_message = "❌ Неизвестный тип голосования." response_message = "❌ Неизвестный тип голосования."

View File

@@ -37,11 +37,7 @@ class BaseBot:
""" """
logging.debug("[VC_EXT] Initializing Yandex Music client") logging.debug("[VC_EXT] Initializing Yandex Music client")
if not token: if not (token := await self.get_ym_token(ctx)):
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
token = await self.users_db.get_ym_token(uid) if uid else None
if not token:
logging.debug("[VC_EXT] No token found") logging.debug("[VC_EXT] No token found")
await self.send_response_message(ctx, "❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) await self.send_response_message(ctx, "❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return None return None
@@ -56,12 +52,28 @@ class BaseBot:
client = await YMClient(token).init() client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError: except yandex_music.exceptions.UnauthorizedError:
del self._ym_clients[token] del self._ym_clients[token]
await self.send_response_message(ctx, "❌ Недействительный токен. Обновите его с помощью /account login.", ephemeral=True, delete_after=15) await self.send_response_message(ctx, "❌ Недействительный токен Yandex Music.", ephemeral=True, delete_after=15)
return None return None
self._ym_clients[token] = client self._ym_clients[token] = client
return client return client
async def get_ym_token(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> str | None:
"""Get Yandex Music token from context. It's either individual or single."""
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not ctx.guild_id or not uid:
logging.info("[VC_EXT] No guild id or user id found")
return None
guild = await self.db.get_guild(ctx.guild_id, projection={'single_token_uid': 1})
if guild['single_token_uid']:
return await self.users_db.get_ym_token(guild['single_token_uid'])
else:
return await self.users_db.get_ym_token(uid)
async def send_response_message( async def send_response_message(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
@@ -151,8 +163,22 @@ class BaseBot:
self.menu_views[ctx.guild_id].stop() self.menu_views[ctx.guild_id].stop()
self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable) self.menu_views[ctx.guild_id] = await MenuView(ctx).init(disable=disable)
async def get_discord_user_by_id(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, user_id: int) -> discord.User | None:
if isinstance(ctx, ApplicationContext) and ctx.user:
logging.debug(f"[BASE_BOT] Getting user {user_id} from ApplicationContext")
return await ctx.bot.fetch_user(user_id)
elif isinstance(ctx, Interaction):
logging.debug(f"[BASE_BOT] Getting user {user_id} from Interaction")
return await ctx.client.fetch_user(user_id)
elif not self.bot:
raise ValueError("Bot instance is not available")
else:
logging.debug(f"[BASE_BOT] Getting user {user_id} from bot instance")
return await self.bot.fetch_user(user_id)
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 self.bot instance. """Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance.
Args: Args:

View File

@@ -38,7 +38,9 @@ class VoiceExtension(BaseBot):
logging.warning("[VC_EXT] Guild id not found in context") logging.warning("[VC_EXT] Guild id not found in context")
return False return False
guild = await self.db.get_guild(ctx.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vibing': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={
'current_track': 1, 'current_menu': 1, 'vibing': 1, 'single_token_uid': 1
})
if not guild['current_track']: if not guild['current_track']:
embed = None embed = None
@@ -49,10 +51,13 @@ class VoiceExtension(BaseBot):
guild['current_track'], guild['current_track'],
client=YMClient() # type: ignore client=YMClient() # type: ignore
)) ))
embed = await generate_item_embed(track, guild['vibing']) embed = await generate_item_embed(track, guild['vibing'])
if vc.is_paused(): if vc.is_paused():
embed.set_footer(text='Приостановлено') embed.set_footer(text='Приостановлено')
elif guild['single_token_uid'] and (user := await self.get_discord_user_by_id(ctx, guild['single_token_uid'])):
embed.set_footer(text=f"Используется токен {user.display_name}", icon_url=user.display_avatar.url)
else: else:
embed.remove_footer() embed.remove_footer()
@@ -135,7 +140,10 @@ class VoiceExtension(BaseBot):
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(ctx.guild_id, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={
'vibing': 1, 'current_menu': 1, 'current_track': 1, 'single_token_uid': 1
})
if not guild['current_menu']: if not guild['current_menu']:
logging.debug("[VC_EXT] No current menu found") logging.debug("[VC_EXT] No current menu found")
return False return False
@@ -152,6 +160,7 @@ class VoiceExtension(BaseBot):
guild['current_track'], guild['current_track'],
client=YMClient() # type: ignore client=YMClient() # type: ignore
)) ))
embed = await generate_item_embed(track, guild['vibing']) embed = await generate_item_embed(track, guild['vibing'])
if not (vc := await self.get_voice_client(ctx)): if not (vc := await self.get_voice_client(ctx)):
@@ -160,6 +169,8 @@ class VoiceExtension(BaseBot):
if vc.is_paused(): if vc.is_paused():
embed.set_footer(text='Приостановлено') embed.set_footer(text='Приостановлено')
elif guild['single_token_uid'] and (user := await self.get_discord_user_by_id(ctx, guild['single_token_uid'])):
embed.set_footer(text=f"Используется токен {user.display_name}", icon_url=user.display_avatar.url)
else: else:
embed.remove_footer() embed.remove_footer()
@@ -259,10 +270,10 @@ class VoiceExtension(BaseBot):
logging.warning("[VC_EXT] Guild ID or User ID not found in context") logging.warning("[VC_EXT] Guild ID or User ID not found in context")
return False return False
user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_settings': 1}) user = await self.users_db.get_user(uid, projection={'vibe_settings': 1})
guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_track': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'vibing': 1, 'current_track': 1})
if not (client := await self.init_ym_client(ctx, user['ym_token'])): if not (client := await self.init_ym_client(ctx)):
return False return False
if update_settings: if update_settings:
@@ -335,7 +346,7 @@ class VoiceExtension(BaseBot):
await ctx.respond("❌ Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True) await ctx.respond("❌ Эта команда может быть использована только на сервере.", delete_after=15, ephemeral=True)
return False return False
if not await self.users_db.get_ym_token(ctx.user.id): if not await self.get_ym_token(ctx):
logging.debug(f"[VC_EXT] No token found for user {ctx.user.id}") logging.debug(f"[VC_EXT] No token found for user {ctx.user.id}")
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return False return False
@@ -399,7 +410,6 @@ class VoiceExtension(BaseBot):
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
track: Track | dict[str, Any], track: Track | dict[str, Any],
*, *,
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,
@@ -427,7 +437,7 @@ class VoiceExtension(BaseBot):
if isinstance(track, dict): if isinstance(track, dict):
track = cast(Track, Track.de_json( track = cast(Track, Track.de_json(
track, track,
client=await self.init_ym_client(ctx) if not client else client # type: ignore # Async client can be used here. client=await self.init_ym_client(ctx) # type: ignore # Async client can be used here.
)) ))
return await self._play_track( return await self._play_track(
@@ -475,7 +485,9 @@ class VoiceExtension(BaseBot):
await self.send_vibe_feedback(ctx, 'trackFinished', guild['current_track']) await self.send_vibe_feedback(ctx, 'trackFinished', guild['current_track'])
await self.db.update(ctx.guild_id, { await self.db.update(ctx.guild_id, {
'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'vibing': False 'current_menu': None, 'repeat': False, 'shuffle': False,
'previous_tracks': [], 'next_tracks': [], 'votes': {},
'vibing': False, 'current_viber_id': None
}) })
if guild['current_menu']: if guild['current_menu']:
@@ -489,7 +501,6 @@ class VoiceExtension(BaseBot):
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
*, *,
after: bool = False, after: bool = False,
client: YMClient | 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:
@@ -500,7 +511,6 @@ class VoiceExtension(BaseBot):
ctx (ApplicationContext | Interaction | RawReactionActionEvent): 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.
client (YMClient | None, optional): Yandex Music client. Defaults to None.
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.
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.
@@ -552,19 +562,7 @@ class VoiceExtension(BaseBot):
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:
title = await self.play_track(ctx, next_track, client=client, vc=vc, button_callback=button_callback) return await self.play_track(ctx, next_track, vc=vc, button_callback=button_callback)
if after and not guild['current_menu']:
if isinstance(ctx, discord.RawReactionActionEvent):
if not self.bot:
raise ValueError("Bot instance not found")
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send(f"Сейчас играет: **{title}**!", delete_after=15)
else:
await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15)
return title
logging.info("[VC_EXT] No next track found") logging.info("[VC_EXT] No next track found")
if after: if after:
@@ -609,15 +607,20 @@ class VoiceExtension(BaseBot):
return None return None
async def get_liked_tracks(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort]: async def get_reacted_tracks(
"""Get liked tracks from Yandex Music. Return list of tracks on success. self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
tracks_type: Literal['like', 'dislike']
) -> list[TrackShort]:
"""Get liked or disliked tracks from Yandex Music. Return list of tracks on success.
Return empty list if no likes found or error occurred. Return empty list if no likes found or error occurred.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
tracks_type (Literal['like', 'dislike']): Type of tracks to get.
Returns: Returns:
list[Track]: List of tracks. list[TrackShort]: List of tracks.
""" """
logging.info("[VC_EXT] Getting liked tracks") logging.info("[VC_EXT] Getting liked tracks")
@@ -632,11 +635,11 @@ class VoiceExtension(BaseBot):
if not (client := await self.init_ym_client(ctx)): if not (client := await self.init_ym_client(ctx)):
return [] return []
if not (likes := await client.users_likes_tracks()): if not (collection := await client.users_likes_tracks() if tracks_type == 'like' else await client.users_dislikes_tracks()):
logging.info("[VC_EXT] No likes found") logging.info(f"[VC_EXT] No {tracks_type}s found")
return [] return []
return likes.tracks return collection.tracks
async def react_track( async def react_track(
self, self,
@@ -656,14 +659,11 @@ class VoiceExtension(BaseBot):
logging.warning("[VC_EXT] Guild or User not found") logging.warning("[VC_EXT] Guild or User not found")
return (False, None) return (False, None)
current_track = await self.db.get_track(gid, 'current') if not (current_track := await self.db.get_track(gid, 'current')):
client = await self.init_ym_client(ctx, await self.users_db.get_ym_token(ctx.user.id))
if not current_track:
logging.debug("[VC_EXT] Current track not found") logging.debug("[VC_EXT] Current track not found")
return (False, None) return (False, None)
if not client: if not (client := await self.init_ym_client(ctx)):
return (False, None) return (False, None)
if action == 'like': if action == 'like':
@@ -701,6 +701,9 @@ class VoiceExtension(BaseBot):
bool: Success status. bool: Success status.
""" """
logging.info(f"[VOICE] Performing '{vote_data['action']}' action for message {ctx.message_id}") logging.info(f"[VOICE] Performing '{vote_data['action']}' action for message {ctx.message_id}")
if guild['current_viber_id']:
ctx.user_id = guild['current_viber_id']
if not ctx.guild_id: if not ctx.guild_id:
logging.warning("[VOICE] Guild not found") logging.warning("[VOICE] Guild not found")
@@ -817,18 +820,20 @@ class VoiceExtension(BaseBot):
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not uid: if not uid or not ctx.guild_id:
logging.warning("[VC_EXT] User id not found") logging.warning("[VC_EXT] User id or guild id not found")
return False return False
user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_batch_id': 1, 'vibe_type': 1, 'vibe_id': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'current_viber_id': 1})
if not user['ym_token']: if guild['current_viber_id']:
logging.warning(f"[VC_EXT] No YM token for user {user['_id']}.") viber_id = guild['current_viber_id']
return False else:
viber_id = uid
client = await self.init_ym_client(ctx, user['ym_token']) user = await self.users_db.get_user(viber_id, projection={'vibe_batch_id': 1, 'vibe_type': 1, 'vibe_id': 1})
if not client:
if not (client := await self.init_ym_client(ctx)):
logging.info(f"[VC_EXT] Failed to init YM client for user {user['_id']}") logging.info(f"[VC_EXT] Failed to init YM client for user {user['_id']}")
await self.send_response_message(ctx, "❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) await self.send_response_message(ctx, "❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True)
return False return False
@@ -854,7 +859,7 @@ class VoiceExtension(BaseBot):
return feedback return feedback
async def _download_track(self, gid: int, track: Track) -> None: async def _download_track(self, gid: int, track: Track) -> None:
"""Download track to local storage. Return True on success. """Download track to local storage.
Args: Args:
gid (int): Guild ID. gid (int): Guild ID.
@@ -927,14 +932,11 @@ class VoiceExtension(BaseBot):
if not guild['current_track'] or track.id != guild['current_track']['id']: if not guild['current_track'] or track.id != guild['current_track']['id']:
await self._download_track(gid, track) await self._download_track(gid, track)
except yandex_music.exceptions.TimedOutError: except yandex_music.exceptions.TimedOutError:
if not isinstance(ctx, RawReactionActionEvent) and ctx.channel: if not retry:
channel = cast(discord.VoiceChannel, ctx.channel)
elif not retry:
return await self._play_track(ctx, track, vc=vc, menu_message=menu_message, button_callback=button_callback, retry=True) return await self._play_track(ctx, track, vc=vc, menu_message=menu_message, button_callback=button_callback, retry=True)
elif self.bot and isinstance(ctx, RawReactionActionEvent): else:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) await self.send_response_message(ctx, f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15)
logging.error(f"[VC_EXT] Failed to download track '{track.title}'") logging.error(f"[VC_EXT] Failed to download track '{track.title}'")
await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15)
return None return None
async with aiofiles.open(f'music/{gid}.mp3', "rb") as f: async with aiofiles.open(f'music/{gid}.mp3', "rb") as f:
@@ -951,7 +953,7 @@ class VoiceExtension(BaseBot):
# 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: try:
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.play_next_track(ctx, after=True), loop)) vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.play_next_track(ctx, after=True), loop))
except discord.errors.ClientException as e: except discord.errors.ClientException as e:

View File

@@ -48,7 +48,7 @@ class Voice(Cog, VoiceExtension):
guild = await self.db.get_guild(member.guild.id, projection={'current_menu': 1}) guild = await self.db.get_guild(member.guild.id, projection={'current_menu': 1})
if not after.channel or not before.channel: if not after.channel or not before.channel:
logging.warning(f"[VOICE] No channel found for member {member.id}") logging.debug(f"[VOICE] No channel found for member {member.id}")
return return
vc = cast( vc = cast(
@@ -125,12 +125,13 @@ class Voice(Cog, VoiceExtension):
logging.info(f"[VOICE] Message {payload.message_id} is not a bot message") logging.info(f"[VOICE] Message {payload.message_id} is not a bot message")
return return
if not await self.users_db.get_ym_token(payload.user_id): guild = await self.db.get_guild(payload.guild_id)
if not guild['use_single_token'] and not (guild['single_token_uid'] or await self.users_db.get_ym_token(payload.user_id)):
await message.remove_reaction(payload.emoji, payload.member) await message.remove_reaction(payload.emoji, payload.member)
await channel.send("❌ Для участия в голосовании необходимо авторизоваться через /account login.", delete_after=15) await channel.send("❌ Для участия в голосовании необходимо авторизоваться через /account login.", delete_after=15)
return return
guild = await self.db.get_guild(payload.guild_id)
votes = guild['votes'] votes = guild['votes']
if str(payload.message_id) not in votes: if str(payload.message_id) not in votes:
@@ -220,9 +221,14 @@ class Voice(Cog, VoiceExtension):
logging.warning("[VOICE] Join command invoked without guild_id") logging.warning("[VOICE] Join command invoked without guild_id")
await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True) await ctx.respond("❌ Эта команда может быть использована только на сервере.", ephemeral=True)
return return
if ctx.author.id not in ctx.channel.voice_states:
logging.debug("[VC_EXT] User is not connected to the voice channel")
await ctx.respond("❌ Вы должны находиться в голосовом канале.", delete_after=15, ephemeral=True)
return
member = cast(discord.Member, ctx.author) member = cast(discord.Member, ctx.author)
guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'allow_change_connect': 1, 'use_single_token': 1})
await ctx.defer(ephemeral=True) await ctx.defer(ephemeral=True)
if not member.guild_permissions.manage_channels and not guild['allow_change_connect']: if not member.guild_permissions.manage_channels and not guild['allow_change_connect']:
@@ -234,8 +240,14 @@ class Voice(Cog, VoiceExtension):
response_message = "Не удалось подключиться к голосовому каналу." response_message = "Не удалось подключиться к голосовому каналу."
except discord.ClientException: except discord.ClientException:
response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave." response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave."
except discord.DiscordException as e:
logging.error(f"[VOICE] DiscordException: {e}")
response_message = "❌ Произошла неизвестная ошибка при подключении к голосовому каналу."
else: else:
response_message = "✅ Подключение успешно!" response_message = "✅ Подключение успешно!"
if guild['use_single_token'] and await self.users_db.get_ym_token(ctx.author.id):
await self.db.update(ctx.guild_id, {'single_token_uid': ctx.author.id})
else: else:
response_message = "❌ Вы должны отправить команду в чате голосового канала." response_message = "❌ Вы должны отправить команду в чате голосового канала."
@@ -272,9 +284,11 @@ class Voice(Cog, VoiceExtension):
return return
await vc.disconnect(force=True) 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}") logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild_id}")
await self.db.update(ctx.guild_id, {'single_token_uid': None})
await ctx.respond("✅ Отключение успешно!", delete_after=15, ephemeral=True)
@queue.command(description="Очистить очередь треков и историю прослушивания.") @queue.command(description="Очистить очередь треков и историю прослушивания.")
async def clear(self, ctx: discord.ApplicationContext) -> None: async def clear(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] Clear queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}") logging.info(f"[VOICE] Clear queue command invoked by user {ctx.author.id} in guild {ctx.guild_id}")

View File

@@ -8,6 +8,9 @@ from .user import User, ExplicitUser
from .guild import Guild, ExplicitGuild, MessageVotes from .guild import Guild, ExplicitGuild, MessageVotes
mongo_server = os.getenv('MONGO_URI') mongo_server = os.getenv('MONGO_URI')
if not mongo_server:
raise ValueError('MONGO_URI environment variable is not set')
client: AsyncMongoClient = AsyncMongoClient(mongo_server) client: AsyncMongoClient = AsyncMongoClient(mongo_server)
db = client.YandexMusicBot db = client.YandexMusicBot
@@ -67,12 +70,6 @@ class BaseUsersDatabase:
) )
return cast(str | None, user.get('ym_token') if user else None) return cast(str | None, user.get('ym_token') if user else None)
async def add_playlist(self, uid: int, playlist_data: dict) -> UpdateResult:
return await users.update_one(
{'_id': uid},
{'$push': {'playlists': playlist_data}}
)
class BaseGuildsDatabase: class BaseGuildsDatabase:
DEFAULT_GUILD = Guild( DEFAULT_GUILD = Guild(
@@ -81,7 +78,6 @@ class BaseGuildsDatabase:
current_track=None, current_track=None,
current_menu=None, current_menu=None,
is_stopped=True, is_stopped=True,
always_allow_menu=False,
allow_change_connect=True, allow_change_connect=True,
vote_switch_track=True, vote_switch_track=True,
vote_add=True, vote_add=True,
@@ -89,7 +85,9 @@ class BaseGuildsDatabase:
repeat=False, repeat=False,
votes={}, votes={},
vibing=False, vibing=False,
current_viber_id=None current_viber_id=None,
use_single_token=False,
single_token_uid=None
) )
async def update(self, gid: int, data: Guild | dict[str, Any]) -> UpdateResult: async def update(self, gid: int, data: Guild | dict[str, Any]) -> UpdateResult:
@@ -127,9 +125,3 @@ class BaseGuildsDatabase:
{'_id': gid}, {'_id': gid},
{'$set': {f'votes.{mid}': data}} {'$set': {f'votes.{mid}': data}}
) )
async def clear_queue(self, gid: int) -> UpdateResult:
return await guilds.update_one(
{'_id': gid},
{'$set': {'next_tracks': []}}
)

View File

@@ -10,13 +10,12 @@ class MessageVotes(TypedDict):
] ]
vote_content: Any | None vote_content: Any | None
class Guild(TypedDict, total=False): class Guild(TypedDict, total=False): # Don't forget to change base.py if you add a new field
next_tracks: list[dict[str, Any]] next_tracks: list[dict[str, Any]]
previous_tracks: list[dict[str, Any]] previous_tracks: list[dict[str, Any]]
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 # Prevents the `after` callback of play_track
always_allow_menu: bool
allow_change_connect: bool allow_change_connect: bool
vote_switch_track: bool vote_switch_track: bool
vote_add: bool vote_add: bool
@@ -25,6 +24,8 @@ class Guild(TypedDict, total=False):
votes: dict[str, MessageVotes] votes: dict[str, MessageVotes]
vibing: bool vibing: bool
current_viber_id: int | None current_viber_id: int | None
use_single_token: bool
single_token_uid: int | None
class ExplicitGuild(TypedDict): class ExplicitGuild(TypedDict):
_id: int _id: int
@@ -32,8 +33,7 @@ class ExplicitGuild(TypedDict):
previous_tracks: list[dict[str, Any]] previous_tracks: list[dict[str, Any]]
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
always_allow_menu: bool
allow_change_connect: bool allow_change_connect: bool
vote_switch_track: bool vote_switch_track: bool
vote_add: bool vote_add: bool
@@ -42,3 +42,5 @@ class ExplicitGuild(TypedDict):
votes: dict[str, MessageVotes] votes: dict[str, MessageVotes]
vibing: bool vibing: bool
current_viber_id: int | None current_viber_id: int | None
use_single_token: bool
single_token_uid: int | None

View File

@@ -6,7 +6,7 @@ VibeSettingsOptions: TypeAlias = Literal[
'russian', 'not-russian', 'without-words', 'any', 'russian', 'not-russian', 'without-words', 'any',
] ]
class User(TypedDict, total=False): class User(TypedDict, total=False): # Don't forget to change base.py if you add a new field
ym_token: str | None ym_token: str | None
playlists: list[tuple[str, int]] playlists: list[tuple[str, int]]
playlists_page: int playlists_page: int