From 8b35088d04a0d0eb9e73bc53f116c6a9edb55eaa Mon Sep 17 00:00:00 2001 From: Ev-1 <33662061+Ev-1@users.noreply.github.com> Date: Sat, 1 Oct 2022 00:36:24 +0200 Subject: [PATCH] Implement Selector that inherits from scroller for better code reuse --- musicbot/cogs/helpformatter/help.py | 5 +- musicbot/cogs/music/__init__.py | 311 +++++++++++---------- musicbot/utils/userinteraction/scroller.py | 115 ++++---- musicbot/utils/userinteraction/selector.py | 94 +++++++ 4 files changed, 324 insertions(+), 201 deletions(-) diff --git a/musicbot/cogs/helpformatter/help.py b/musicbot/cogs/helpformatter/help.py index eff2cbe..7bb1703 100644 --- a/musicbot/cogs/helpformatter/help.py +++ b/musicbot/cogs/helpformatter/help.py @@ -1,6 +1,8 @@ # Discord Packages from discord.ext import commands +from musicbot.utils.userinteraction.scroller import ScrollClearSettings + from ...utils.userinteraction import Scroller from .helpformatter import coghelper, commandhelper, helper, prefix_cleaner @@ -32,5 +34,6 @@ class Help(commands.Cog): else: paginator = await coghelper(ctx, thing) - scroller = Scroller(ctx, paginator) + scroller = Scroller(ctx, paginator, + ScrollClearSettings.OnTimeout | ScrollClearSettings.OnInteractionExit) await scroller.start_scrolling() diff --git a/musicbot/cogs/music/__init__.py b/musicbot/cogs/music/__init__.py index e1ff46c..9a1dded 100644 --- a/musicbot/cogs/music/__init__.py +++ b/musicbot/cogs/music/__init__.py @@ -1,24 +1,25 @@ # Discord Packages import discord import lavalink +import asyncio from discord.ext import commands, tasks -from ...utils import thumbnailer, timeformatter -from .music_errors import WrongTextChannelError import re from bs4 import BeautifulSoup -from ...utils import checks -from ...utils.userinteraction import Scroller, TextPaginator -from .decorators import require_playing, require_voice_connection, voteable -from ...utils import checks, timeformatter -from ...utils.userinteraction import QueuePaginator, Scroller, Selector +from musicbot.utils.userinteraction.scroller import ScrollClearSettings + +from ...utils import checks, thumbnailer, timeformatter +from ...utils.userinteraction import QueuePaginator, Scroller, Selector, TextPaginator +from ...utils.userinteraction.selector import Selector2, SelectorButton, SelectorItem from .decorators import require_playing, require_queue, require_voice_connection, voteable +from .music_errors import WrongTextChannelError time_rx = re.compile('[0-9]+') url_rx = re.compile('https?:\\/\\/(?:www\\.)?.+') +# Discord Packages import lavalink.events @@ -45,7 +46,7 @@ class Music(commands.Cog): # Creates a new only if one doesn't exist, ensures a valid player for all checks. self.bot.lavalink.player_manager.create(ctx.guild.id) - async def enqueue(self, ctx, track, embed, silent=False, check_max_length=True): + async def enqueue(self, ctx, track, embed, silent=False, check_max_length=True) -> tuple[discord.Embed, bool]: player = self.bot.lavalink.player_manager.get(ctx.guild.id) # Only add tracks that don't exceed the max track length @@ -61,7 +62,7 @@ class Music(commands.Cog): return embed, False # Add thumbnail, turn track into track class - thumbnail_url = await thumbnailer.ThumbNailer.identify(self, track.identifier, track.uri) + thumbnail_url = None #await thumbnailer.ThumbNailer.identify(self, track.identifier, track.uri) track = lavalink.models.AudioTrack(track, ctx.author.id, thumbnail_url=thumbnail_url) # Add to player @@ -127,14 +128,11 @@ class Music(commands.Cog): else: return None - - @commands.command(name='play') @require_voice_connection(should_connect=True) async def _play(self, ctx, *, query: str): """ Searches and plays a song from a given query. """ self.logger.debug("Query: %s" % query) - print(query) player = self.bot.lavalink.player_manager.get(ctx.guild.id) query = query.strip('<>') @@ -250,7 +248,7 @@ class Music(commands.Cog): """ Shows the player's queue. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) pagified_queue = QueuePaginator(ctx.localizer, player, color=ctx.me.color, member=member) - scroller = Scroller(ctx, pagified_queue) + scroller = Scroller(ctx, pagified_queue, ScrollClearSettings.OnInteractionExit | ScrollClearSettings.OnTimeout) await scroller.start_scrolling() @@ -314,53 +312,52 @@ class Music(commands.Cog): selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, round_titles=round_titles, color=ctx.me.color, title=ctx.localizer.format_str("{moved.choose}")) - + # Get the initial and final position message, page, selections = await selector.start_scrolling() - + try: pos_initial, pos_final = selections[0], selections[1] except (IndexError, TypeError): return await message.delete() - + # Move the track moved = player.move_user_track(ctx.author.id, pos_initial, pos_final) - + # Create a nice embed explaining what happened song = f'**[{moved.title}]({moved.uri})**' embed = discord.Embed(color=ctx.me.color, description=song, title='{moved.moved}') thumbnail_url = None #moved.extra["thumbnail_url"] member = ctx.guild.get_member(moved.requester) + if thumbnail_url: - embed.set_thumbnail(url=thumbnail_url) - + embed.add_field(name="{moved.local}", value=f"`{pos_initial + 1} β†’ {pos_final + 1}`", inline=True) embed.add_field(name="{moved.global}", value=f"`{player.queue._loc_to_glob(ctx.author.id, pos_initial) + 1}\ - β†’ {player.queue._loc_to_glob(ctx.author.id, pos_final) + 1}`", inline=True) + β†’ {player.queue._loc_to_glob(ctx.author.id, pos_final) + 1}`", inline=True) embed.set_footer(text=f'{{requested_by}} {member.display_name}', icon_url=member.display_avatar.url) embed = ctx.localizer.format_embed(embed) - + await message.edit(embed=embed) - - + @commands.command(name='remove') @require_voice_connection() @require_queue(require_author_queue=True) async def _remove(self, ctx): player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + user_queue = player.user_queue(ctx.author.id, dual=True) functions = [player.remove_user_track]*len(user_queue) arguments = [(ctx.author.id, i) for i in range(len(user_queue))] identifiers = [] - + for index, temp in enumerate(user_queue): track, globpos = temp identifiers.append(ctx.localizer.format_str("{queue.usertrack}", _index=index+1, - _globalindex=globpos+1, _title=track.title, - _uri=track.uri)) - + _globalindex=globpos+1, _title=track.title, + _uri=track.uri)) + selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, color=0xFF0000, title='Remove song plz') message, _, removed = await selector.start_scrolling() @@ -368,8 +365,7 @@ class Music(commands.Cog): await message.edit(content=ctx.localizer.format_str("{remove}", _title=removed.title), embed=None) else: await message.delete() - - + @commands.command(name="DJremove") @checks.dj_or() @require_voice_connection() @@ -377,23 +373,22 @@ class Music(commands.Cog): async def _djremove(self, ctx, pos: int, member: discord.Member = None): """ Remove a song from either the global queue or a users queue""" player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + # TODO: add way to allow "member" as regular arg in require_queue() instead of only kwargs queue = player.user_queue(member.id) if member else player.queue - + # TODO: Do all queue out of range messages the same way if pos <= len(queue) and pos >= 1: if member is None: removed = player.remove_global_track(pos - 1) else: removed = player.remove_user_track(member.id, pos - 1) - + requester = self.bot.get_user(removed.requester) await ctx.send(ctx.localizer.format_str("{dj_removed}", _title=removed.title, _user=requester.display_name)) else: await ctx.send(ctx.localizer.format_str("{out_of_range}", _len=len(queue))) - - + @commands.command(name='removeuser') @checks.dj_or(alone=True) @require_voice_connection() @@ -402,73 +397,103 @@ class Music(commands.Cog): """ Remove a song from either the global queue or a users queue""" player = self.bot.lavalink.player_manager.get(ctx.guild.id) player.remove_user_queue(member.id) - + embed = discord.Embed(description="{dj_remove_user}", color=ctx.me.color) embed = ctx.localizer.format_embed(embed, _id=member.id) - + await ctx.send(embed=embed) - - - @commands.command(name='search') + + @commands.command(name='search2') @require_voice_connection(should_connect=True) - async def _search(self, ctx, *, query): + async def _search2(self, ctx, *, query): player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + if not query.startswith('ytsearch:') and not query.startswith('scsearch:'): query = 'ytsearch:' + query - + results = await player.node.get_tracks(query) - + embed = discord.Embed(description='{nothing_found}', color=0x36393F) if not results or not results['tracks']: embed = ctx.localizer.format_embed(embed) return await ctx.send(embed=embed) - + + buttons = [] + for track in results['tracks']: + duration = timeformatter.format_ms(int(track.duration)) + interaction = SelectorItem(f'[{track.title}]({track.uri}) `{duration}`', + wrap_in_button_callback(self.enqueue, ctx, track, discord.Embed(color=ctx.me.color))) + buttons.append(interaction) + + search_selector = Selector2(ctx, buttons, color=ctx.me.color, title=ctx.localizer.format_str('{results}')) + message, callback_results = await search_selector.start_scrolling() + + song_embed, song_added = callback_results[0] + if song_added: + embed = ctx.localizer.format_embed(song_embed) + await message.edit(embed=embed, view=None) + + if not player.is_playing: + await player.play() + + @commands.command(name='search') + @require_voice_connection(should_connect=True) + async def _search(self, ctx, *, query): + player = self.bot.lavalink.player_manager.get(ctx.guild.id) + + if not query.startswith('ytsearch:') and not query.startswith('scsearch:'): + query = 'ytsearch:' + query + + results = await player.node.get_tracks(query) + + embed = discord.Embed(description='{nothing_found}', color=0x36393F) + if not results or not results['tracks']: + embed = ctx.localizer.format_embed(embed) + return await ctx.send(embed=embed) + tracks = results['tracks'] result_count = min(len(tracks), 15) - + # Setup for the selector identifiers = [] functions = [self.enqueue]*result_count arguments = [(ctx, tracks[i], discord.Embed(color=ctx.me.color)) for i in range(result_count)] - + for index, track in enumerate(tracks, start=1): duration = timeformatter.format_ms(int(track.duration)) identifiers.append(f'`{index}.` [{track.title}]({track.uri}) `{duration}`') if index == result_count: break - + search_selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, - color=ctx.me.color, title=ctx.localizer.format_str('{results}')) + color=ctx.me.color, title=ctx.localizer.format_str('{results}')) # Let the user scroll through results message, current_page, result = await search_selector.start_scrolling() - + if result: (embed, added) = result embed = ctx.localizer.format_embed(embed) await message.edit(embed=embed) - + if not player.is_playing: await player.play() else: await message.delete() - - + @commands.command(name='disconnect') @require_voice_connection() @voteable(DJ_override=True, react_to_vote=True) async def _disconnect(self, ctx): """ Disconnects the player from the voice channel and clears its queue. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + player.queue.clear() await player.stop() await self.voice_client.disconnect() embed = discord.Embed(description='{disconnect.disconnected}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - - + @commands.command(name='reconnect') @require_voice_connection() @voteable(DJ_override=True, react_to_vote=True) @@ -476,13 +501,13 @@ class Music(commands.Cog): """ Tries to disconnect then reconnect the player in case the bot gets stuck on a song """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) current_channel = player.channel_id - + async def recon(): await player.stop() await self.voice_client.disconnect() await asyncio.sleep(1) # Pretend stuff is happening/give everything some time to reset. await self.voice_client.connect()#//connect_to(ctx.guild.id, current_channel) - + if player.current: track = player.current start_time = player.position @@ -491,30 +516,28 @@ class Music(commands.Cog): await player.play(track, start_time=start_time) else: await recon() - - + @commands.command(name='volume') @checks.dj_or(alone=True, track_requester=True) @require_playing(require_user_listening=True) async def _volume(self, ctx, volume: int = None): """ Changes the player's volume. Must be between 0 and 1000. Error Handling for that is done by Lavalink. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + if not volume: return await ctx.send(f'πŸ”ˆ | {player.volume}%') - + is_dj = checks.is_dj(ctx) is_admin = getattr(ctx.author.guild_permissions, 'administrator', None) is True if player.current and (int(player.current.requester) == ctx.author.id and not is_dj and not is_admin): if not 50 <= volume <= 125: return await ctx.send(ctx.localizer.format_str("{volume.out_of_range}")) await player.set_volume(volume) - + embed = discord.Embed(description="{volume.set_to}", color=ctx.me.color) embed = ctx.localizer.format_embed(embed, _volume=player.volume) await ctx.send(embed=embed) - - + @commands.command(name='forceplay') @require_voice_connection(should_connect=True) @voteable(DJ_override=True, react_to_vote=True) @@ -522,37 +545,36 @@ class Music(commands.Cog): """ Searches and plays a song from a given query. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) query = query.strip('<>') - + if not url_rx.match(query): query = f'ytsearch:{query}' - + results = await player.node.get_tracks(query) - + if not results or not results.tracks: return await ctx.send(ctx.localizer.format_str("{nothing_found}")) - + embed = discord.Embed(color=ctx.me.color) - + if results.load_type == 'PLAYLIST_LOADED': numtracks = 0 for track in results.tracks: _, track_added = await self.enqueue(ctx, track, embed, silent=True, check_max_length=False) if track_added: numtracks += 1 - + embed.title = '{playlist_enqued}' embed.description = f'{results.playlist_info.name} - {numtracks} {{tracks}}' else: track = results.tracks[0] await self.enqueue(ctx, track, embed, check_max_length=False) - + embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - + if not player.is_playing: await player.play() - - + @commands.command(name='forcedisconnect') @checks.dj_or(alone=True) async def _forcedisconnect(self, ctx): @@ -567,44 +589,36 @@ class Music(commands.Cog): embed = discord.Embed(description='{disconnect.disconnected}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - # commands - # from .basic_commands import ( - # _disconnect, _djremove, _forcedisconnect, _forceplay, _move, _myqueue, _now, _pause, _play, _queue, _reconnect, - # _remove, _search, _seek, _shuffle, _skip, _skip_to, _stop, _user_queue_remove, _volume) - - - + @commands.command(name='normalize') @checks.dj_or(alone=True) @require_voice_connection() async def _normalize(self, ctx): """ Reset the equalizer and volume """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + await player.set_volume(100) await player.bassboost(False) - + embed = discord.Embed(description='{volume.reset}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - - + @commands.command(name='boost') @checks.dj_or(alone=True) async def _boost(self, ctx, boost: bool = None): """ Set the equalizer to bass boost the music """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + if boost is not None: await player.bassboost(boost) - + embed = discord.Embed(color=ctx.me.color) embed.description = '{boost.on}' if player.boosted else '{boost.off}' - + embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - - + @commands.command(name='history') async def _history(self, ctx): """ Show the last 10 songs played """ @@ -616,88 +630,86 @@ class Music(commands.Cog): return await ctx.send(embed=embed) track = history[0] description = ctx.localizer.format_str("{history.current}", _title=track.title, _uri=track.uri, - _id=track.requester) + '\n\n' + _id=track.requester) + '\n\n' description += ctx.localizer.format_str("{history.previous}", _len=len(history)-1) + '\n' thumbnail_url = None # track.extra["thumbnail_url"] for index, track in enumerate(history[1:], start=1): description += ctx.localizer.format_str("{history.track}", _index=-index, _title=track.title, - _uri=track.uri, _id=track.requester) + '\n' - + _uri=track.uri, _id=track.requester) + '\n' + embed = discord.Embed(title=ctx.localizer.format_str('{history.title}'), color=ctx.me.color, - description=description) - - #if thumbnail_url: - # embed.set_thumbnail(url=thumbnail_url) + description=description) + + if thumbnail_url: + embed.set_thumbnail(url=thumbnail_url) await ctx.send(embed=embed) - - + @commands.cooldown(1, 5, commands.BucketType.guild) @commands.command(name='lyrics') async def _lyrics(self, ctx, *query: str): - + # TODO: player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + genius_access_token = self.bot.APIkeys.get('genius', None) - + if genius_access_token is None: return await ctx.send('Missing API key') - + excluded_words = ['music', 'video', 'version', 'original', 'lyrics', 'lyric', - 'official', 'live', 'instrumental', 'audio', 'hd'] - + 'official', 'live', 'instrumental', 'audio', 'hd'] + query = ' '.join(query) - + if not query and player.is_playing: query = player.current.title query = re.sub(r'[-()_\[\]]', '', query) filtered_words = [word for word in query.split() if word.lower() not in excluded_words] query = ' '.join(filtered_words) - + async def get_site_content(url): async with self.bot.session.get(url) as resp: response = await resp.read() return response.decode('utf-8') - + embed = discord.Embed(description=':mag_right:') status_msg = await ctx.send(embed=embed) - + try: url = 'https://api.genius.com/search?' + urllib.parse.urlencode( - {'access_token': genius_access_token, 'q': query}) + {'access_token': genius_access_token, 'q': query}) result = await get_site_content(url) response = json.loads(result) song_id = response['response']['hits'][0]['result']['id'] except Exception: embed = discord.Embed(description=':x:', color=0xFF0000) return await status_msg.edit(embed=embed) - + result = await get_site_content(f'https://api.genius.com/songs/{song_id}?access_token={genius_access_token}') song = json.loads(result)['response']['song'] - + response = await get_site_content(song['url']) scraped_data = BeautifulSoup(response, 'html.parser') lyrics = scraped_data.find(class_='lyrics').get_text() - + paginator = TextPaginator(max_size=2000, max_lines=50, color=0xFFFF64) for line in lyrics.split('\n'): paginator.add_line(line) - + await status_msg.delete() - + paginator.pages[0].url = song['url'] paginator.pages[0].title = song['full_title'] paginator.pages[0].set_thumbnail(url=song['header_image_thumbnail_url']) paginator.pages[0].set_author(name='Genius', icon_url='https://i.imgur.com/NmCTsoF.png') - + if len(paginator.pages) < 4: for page in paginator.pages: await ctx.send(embed=page) else: paginator.add_page_indicator(ctx.localizer) await Scroller(ctx, paginator).start_scrolling() - - + @commands.command(name='scrub') @checks.dj_or(alone=True) @require_voice_connection() @@ -705,9 +717,9 @@ class Music(commands.Cog): async def _scrub(self, ctx): """ Lists the first 10 search results from a given query. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + controls = '{scrub.controls}' - + scrubber = [ ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', player.seek, -1000), ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}', player.seek, -15), @@ -716,17 +728,17 @@ class Music(commands.Cog): ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}', player.seek, 15), ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', player.seek, 1000), ] - + selection = None arg = None - + def check(reaction, user): if user is None or user.id != ctx.author.id: return False - + if reaction.message.id != msg.id: return False - + for (emoji, func, _arg) in scrubber: if emoji == reaction.emoji: nonlocal selection @@ -735,14 +747,14 @@ class Music(commands.Cog): arg = _arg return True return False - + embed = discord.Embed(description='{scrub.add}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed) msg = await ctx.send(embed=embed) - + for (emoji, _, _) in scrubber: await msg.add_reaction(emoji) - + embed.description = controls embed = ctx.localizer.format_embed(embed) await msg.edit(embed=embed) @@ -759,7 +771,7 @@ class Music(commands.Cog): pass finally: break - + try: await msg.remove_reaction(reaction, user) except discord.Forbidden: @@ -768,22 +780,20 @@ class Music(commands.Cog): if not isinstance(arg, bool): arg = player.position + arg * 1000 await selection(arg) - - + @commands.group(name='loop') async def _loop(self, ctx): # This is done using subcommands to have separate vote counts for starting and stopping. if ctx.invoked_subcommand is None: player = self.bot.lavalink.player_manager.get(ctx.guild.id) embed = discord.Embed(color=ctx.me.color, title='Loop status') - + if player.looping: embed.description = 'The bot is currently looping: ♾️' else: embed.description = 'The bot is currently not looping' await ctx.send(embed=embed) - - + @_loop.command(name='start') @require_playing(require_user_listening=True) @voteable(DJ_override=True, react_to_vote=True) @@ -795,8 +805,7 @@ class Music(commands.Cog): embed.description = 'Looping turned on' embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - - + @_loop.command(name='stop') @require_playing(require_user_listening=True) @voteable(DJ_override=True, react_to_vote=True) @@ -808,18 +817,16 @@ class Music(commands.Cog): embed.description = 'Looping turned off' embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - - + async def cog_unload(self): self.bot.lavalink._event_hooks.clear() - - + async def track_hook(self, event): if isinstance(event, lavalink.events.TrackEndEvent): channel = self.bot.get_channel(event.player.fetch('channel')) player = self.bot.lavalink.player_manager.get(channel.guild.id) player.skip_voters.clear() - + if isinstance(event, lavalink.events.TrackStartEvent): pass if isinstance(event, lavalink.events.QueueEndEvent): @@ -834,9 +841,8 @@ class Music(commands.Cog): if isinstance(event, lavalink.events.NodeChangedEvent): pass if isinstance(event, lavalink.events.TrackStuckEvent): - print(event) - - + pass + @commands.Cog.listener() async def on_voice_state_update(self, member, before, after): """ Updates listeners when the bot or a user changes voice state """ @@ -847,14 +853,14 @@ class Music(commands.Cog): for member in voice_channel.members: if not member.bot: player.update_listeners(member, member.voice) - + if not member.bot: player = self.bot.lavalink.player_manager.get(member.guild.id) if player is not None: player.update_listeners(member, after) await self.check_leave_voice(member.guild) - - + + async def check_leave_voice(self, guild): """ Checks if the bot should leave the voice channel """ # TODO, disconnect timer? @@ -863,13 +869,11 @@ class Music(commands.Cog): if player.queue.empty and player.current is None: await player.stop() await self.voice_client.disconnect() - - + async def leave_check(self): for player_id in self.bot.lavalink.player_manager.players: await self.check_leave_voice(self.bot.get_guild(player_id)) - - + @tasks.loop(seconds=10.0) async def leave_timer(self): try: @@ -877,5 +881,10 @@ class Music(commands.Cog): except Exception as err: self.logger.debug("Error in leave_timer loop.\nTraceback: %s" % (err)) +def wrap_in_button_callback(func, *args): + async def inner(_interaction, _button): + return await func(*args) + return inner + async def setup(bot): await bot.add_cog(Music(bot)) diff --git a/musicbot/utils/userinteraction/scroller.py b/musicbot/utils/userinteraction/scroller.py index 8cfe923..5b37705 100644 --- a/musicbot/utils/userinteraction/scroller.py +++ b/musicbot/utils/userinteraction/scroller.py @@ -1,44 +1,54 @@ import discord from enum import Flag, auto +from typing import Callable, Any, Tuple + +from lavalink.client import asyncio from .paginators import BasePaginator, CantScrollException + class ScrollerButton(discord.ui.Button): def __init__(self, method, label, style=discord.ButtonStyle.gray, **kwargs): super().__init__(label=label, style=style, **kwargs) self.callback = method + class ScrollerNav(discord.ui.Select): def __init__(self, method, **kwargs): super().__init__(**kwargs) self.callback = method + class ScrollClearSettings(Flag): OnTimeout = auto() OnInteractionExit = auto() + class Scroller: - def __init__(self, ctx, paginator, timeout=20.0, clear_mode: ScrollClearSettings = ScrollClearSettings.OnInteractionExit, with_nav_bar: bool=True): + def __init__(self, ctx, paginator, clear_mode: ScrollClearSettings, timeout=20.0, + with_nav_bar: bool = True): + if not isinstance(paginator, BasePaginator): raise TypeError('Paginator needs to be a subclass of BasePaginator.') - self.bot = ctx.bot - self.pages = paginator.pages - self.cmdmsg = ctx.message - self.message = ctx.message - self.channel = ctx.channel - self.author = ctx.author - - # Initialize the view we're using for scrolling - self.view = discord.ui.View(timeout=timeout) - self.view.on_timeout = self.on_timeout - self.clear_mode = clear_mode self.use_nav_bar = with_nav_bar + self.paginator = paginator + self.ctx = ctx - if len(self.pages) > 1: - self.scrolling = True - else: - self.scrolling = False + # No embeds to scroll through + if not self.paginator.pages: + raise Exception("Paginator contained no pages to display") # TODO: proper error + + self.bot = ctx.bot + self.channel = ctx.channel + self.message = None + + # Initialize the view we'll be using for scrolling + self.view = discord.ui.View(timeout=timeout) + self.view.on_timeout = self.on_timeout + + self.is_scrolling_paginator = len(self.paginator.pages) > 1 + self.scrolling_done = asyncio.Event() self.interaction_mapping = [ ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.first_page), @@ -48,10 +58,8 @@ class Scroller: ('❌', self.stop), ] - if ctx.guild is not None: - self.permissions = self.channel.permissions_for(ctx.guild.me) - else: - self.permissions = self.channel.permissions_for(ctx.bot.user) + bot_user = ctx.guild if ctx.guild.me is not None else ctx.bot.user + self.permissions = self.channel.permissions_for(bot_user) if not self.permissions.embed_links: raise CantScrollException('Bot does not have embed links permission.') @@ -59,68 +67,77 @@ class Scroller: if not self.permissions.send_messages: raise CantScrollException('Bot cannot send messages.') - if self.scrolling: - if not self.permissions.read_message_history: - raise CantScrollException('Bot does not have Read Message History permission.') - - async def start_scrolling(self): + async def start_scrolling(self) -> discord.Message: self.current_page_number = 0 - # No embeds to scroll through - if not self.pages: - return + self.build_view() - if not self.scrolling: - return await self.channel.send(embed=self.pages[0]) + if not self.is_scrolling_paginator: + return await self.channel.send(embed=self.paginator.pages[0]) + self.message = await self.channel.send(embed=self.paginator.pages[0], view=self.view) + await self.scrolling_done.wait() + + return self.message + + def update_view(self, interaction: discord.Interaction): + self.navigator.placeholder = f"Page: {self.current_page_number + 1}/{len(self.paginator.pages)}" + + def build_view(self): for (reaction, callback) in self.interaction_mapping: - self.view.add_item(item=ScrollerButton(callback, label=reaction, row=3)) + self.view.add_item(item=ScrollerButton(method=callback, label=reaction, row=3)) if self.use_nav_bar: self.navigator = ScrollerNav(self.navigate, placeholder="Navigate to page", row=4) self.view.add_item(item=self.navigator) - for (i, _) in enumerate(self.pages): + for (i, _) in enumerate(self.paginator.pages): self.navigator.add_option(label=str(i+1), value=str(i)) - self.message = await self.channel.send(embed=self.pages[0], view=self.view) - async def stop(self, user_stopped: bool): - self.scrolling = False + self.is_scrolling_paginator = False self.view.stop() self.view.clear_items() - if user_stopped and self.clear_mode & ScrollClearSettings.OnInteractionExit or \ - not user_stopped and self.clear_mode & ScrollClearSettings.OnTimeout: + if (user_stopped and self.clear_mode & ScrollClearSettings.OnInteractionExit or + not user_stopped and self.clear_mode & ScrollClearSettings.OnTimeout): await self.message.delete() - await self.cmdmsg.delete() + self.message = None + await self.ctx.message.delete() else: - await self.message.edit(embed=self.pages[self.current_page_number], view=self.view) + await self.message.edit(embed=self.paginator.pages[self.current_page_number]) - async def scroll(self, page, interaction: discord.Interaction): - if page < 0 or page >= len(self.pages): + # Notify the start_scrolling function that we're done + self.scrolling_done.set() + + async def _scroll(self, page, interaction: discord.Interaction): + if interaction.user.id != self.ctx.author.id: + return await interaction.response.defer() + + if page < 0 or page >= len(self.paginator.pages): return self.current_page_number = page + if interaction.message: - await interaction.message.edit(embed=self.pages[page], view=self.view) + # Update the view before we edit the message + self.update_view(interaction) + await interaction.message.edit(embed=self.paginator.pages[page], view=self.view) await interaction.response.defer() async def first_page(self, interaction: discord.Interaction): - await self.scroll(0, interaction) + await self._scroll(0, interaction) async def last_page(self, interaction: discord.Interaction): - await self.scroll(len(self.pages) - 1, interaction) + await self._scroll(len(self.paginator.pages) - 1, interaction) async def next_page(self, interaction: discord.Interaction): - await self.scroll(self.current_page_number + 1, interaction) + await self._scroll(self.current_page_number + 1, interaction) async def previous_page(self, interaction: discord.Interaction): - await self.scroll(self.current_page_number - 1, interaction) + await self._scroll(self.current_page_number - 1, interaction) async def navigate(self, interaction: discord.Interaction): - page = int(self.navigator.values[0]) - await self.scroll(page, interaction) + await self._scroll(int(self.navigator.values[0]), interaction) async def stop_scrolling(self, _: discord.Interaction): await self.stop(user_stopped=True) async def on_timeout(self): await self.stop(user_stopped=False) - diff --git a/musicbot/utils/userinteraction/selector.py b/musicbot/utils/userinteraction/selector.py index 679b7ad..f5ec738 100644 --- a/musicbot/utils/userinteraction/selector.py +++ b/musicbot/utils/userinteraction/selector.py @@ -1,10 +1,104 @@ # Discord Packages +from __future__ import annotations +from locale import resetlocale import discord import asyncio import inspect +from typing import List, Callable, Coroutine from .paginators import TextPaginator +from .scroller import ScrollClearSettings, Scroller + +class SelectorButton(discord.ui.Button): + def __init__(self, label, callback: Callable[[discord.Interaction, SelectorButton], Coroutine], style=discord.ButtonStyle.gray, **kwargs): + super().__init__(label=label, style=style, **kwargs) + + # The callback we need to supply to disord.py takes only a discord.Interaction + # we want to have access to the button that caused that was clicked + # so we can update it, i.e. change color + def add_button_to_callback(func): + async def wrapped_callback(interaction: discord.Interaction): + await func(interaction, self) + return wrapped_callback + + self.callback = add_button_to_callback(callback) + + +class SelectorItem: + def __init__(self, identifier, callback: Callable[[discord.Interaction, SelectorButton], Coroutine]): + self.identifier = identifier + self.callback = callback + + +class Selector2(TextPaginator, Scroller): + def __init__(self, ctx, choices: List[SelectorItem], round_titles=None, terminate_on_select: bool = True, max_size=2000, **embed_base): + self.match = None + if round_titles is None: + round_titles = [] + self.match = None + self.terminate_on_select = terminate_on_select + self.round_titles = round_titles + + self.max_selections_per_page = 5 + self.selections = choices + self.max_selections = min(len(self.selections), self.max_selections_per_page) + + TextPaginator.__init__(self, max_size=max_size, max_lines=self.max_selections_per_page, **embed_base) + for index, selection in enumerate(self.selections, start=1): + self.add_line(f"`{index}.` {selection.identifier}") + self.close_page() + + Scroller.__init__(self, ctx, self, ScrollClearSettings.OnTimeout) + + # Contains a list of all buttons that can be interacted with, both + # currently visible and not. + self.buttons = [] + + # The list of currently visible buttons, gets cleared upon scroll. + self.visible_buttons = [] + + # Depending on the selector we might want the result of callbacks + # after we have finished interacting with the scroller + self.callback_results = [] + + def with_update_view(func): + async def wrapped_callback(interaction: discord.Interaction, button: SelectorButton): + result = await func(interaction, button) + self.callback_results.append(result) + self.update_view(interaction) + await interaction.response.defer() + if terminate_on_select: + await self.stop(True) + return wrapped_callback + + for i, choice in enumerate(self.selections): + self.buttons.append(SelectorButton(label=i+1, callback=with_update_view(choice.callback), row=0)) + + def build_view(self): + super().build_view() + self.update_buttons() + + def update_view(self, interaction: discord.Interaction): + super().update_view(interaction) + self.update_buttons() + + def update_buttons(self): + for button in self.visible_buttons: + self.view.remove_item(item=button) + + self.visible_buttons = [] + self.current_page_number + start = self.current_page_number * self.max_selections_per_page + end = min((self.current_page_number + 1) * self.max_selections_per_page, len(self.selections)) + + for button in self.buttons[start:end]: + self.view.add_item(item=button) + self.visible_buttons.append(button) + + async def start_scrolling(self): + message = await super().start_scrolling() + return message, self.callback_results class Selector(TextPaginator):