Implement Selector that inherits from scroller for better code reuse

This commit is contained in:
Ev-1 committed 2022-10-01 00:36:24 +02:00
1 parent 28bbb1effa
commit 8b35088d04
4 files changed
+324 -201

No files matched your search

+4 -1
View File
@@ -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()
+160 -151
View File
@@ -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))
+66 -49
View File
@@ -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)
@@ -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):