Skip to content
This repository was archived by the owner on Mar 1, 2022. It is now read-only.

Commit 94fc807

Browse files
authored
Create buttons.py
1 parent 2849cc3 commit 94fc807

File tree

1 file changed

+304
-0
lines changed

1 file changed

+304
-0
lines changed

discord/ext/buttons/buttons.py

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import asyncio
2+
import discord
3+
import inspect
4+
from discord.ext import commands
5+
from functools import partial
6+
from typing import Union
7+
8+
9+
class Session:
10+
"""Interactive session class, which uses reactions as buttons.
11+
12+
timeout: int
13+
The timeout in seconds to wait for reaction responses.
14+
try_remove: bool
15+
A bool indicating whether or not the session should try to remove reactions after they have been pressed.
16+
"""
17+
18+
def __init__(self,*, timeout: int=180, try_remove: bool=True):
19+
self._buttons = {}
20+
self._gather_buttons()
21+
22+
self.page: discord.Message = None
23+
self._session_task = None
24+
self._cancelled = False
25+
self._try_remove = try_remove
26+
27+
self.timeout = timeout
28+
self.buttons = self._buttons
29+
30+
def __init_subclass__(cls, **kwargs):
31+
pass
32+
33+
def _gather_buttons(self):
34+
for _, member in inspect.getmembers(self):
35+
if hasattr(member, '__button__'):
36+
self._buttons[member.__button__[0]] = member.__button__[1]
37+
38+
async def start(self, ctx, page=None):
39+
"""Start the session with the given page.
40+
41+
Parameters
42+
-----------
43+
page: Optional[str, discord.Embed, discord.Message]
44+
If no page is given, the message used to invoke the command will be used. Otherwise if
45+
an embed or str is passed, a new message will be created.
46+
"""
47+
if not page:
48+
page = ctx.message
49+
50+
if isinstance(page, discord.Embed):
51+
self.page = await ctx.send(embed=page)
52+
elif isinstance(page, discord.Message):
53+
self.page = page
54+
else:
55+
self.page = await ctx.send(page)
56+
57+
self._session_task = ctx.bot.loop.create_task(self._session(ctx))
58+
59+
async def _session(self, ctx):
60+
for reaction in self.buttons.keys():
61+
ctx.bot.loop.create_task(self._add_reaction(reaction))
62+
63+
while True:
64+
try:
65+
payload = await ctx.bot.wait_for('raw_reaction_add', timeout=self.timeout, check=lambda _: self.check(_)(ctx))
66+
except asyncio.TimeoutError:
67+
return await self.cancel(ctx)
68+
69+
if self._try_remove:
70+
try:
71+
await self.page.remove_reaction(payload.emoji, ctx.guild.get_member(payload.user_id))
72+
except discord.HTTPException:
73+
pass
74+
75+
emoji = self.get_emoji_as_string(payload.emoji)
76+
action = self.buttons[emoji]
77+
78+
await action(self, ctx)
79+
80+
@property
81+
def is_cancelled(self):
82+
"""Return True if the session has been cancelled."""
83+
return self._cancelled
84+
85+
async def cancel(self, ctx):
86+
"""Cancel the session."""
87+
self._cancelled = True
88+
await self.teardown(ctx)
89+
90+
async def teardown(self, ctx):
91+
"""Clean the session up."""
92+
self._session_task.cancel()
93+
await self.page.delete()
94+
95+
async def _add_reaction(self, reaction):
96+
await self.page.add_reaction(reaction)
97+
98+
def get_emoji_as_string(self, emoji):
99+
return f'{emoji.name}{":" + str(emoji.id) if emoji.is_custom_emoji() else ""}'
100+
101+
def check(self, payload):
102+
"""Check which takes in a raw_reaction payload. This may be overwritten."""
103+
emoji = self.get_emoji_as_string(payload.emoji)
104+
105+
def inner(ctx):
106+
if emoji not in self.buttons.keys():
107+
return False
108+
elif payload.user_id == ctx.bot.user.id or payload.message_id != self.page.id:
109+
return False
110+
elif payload.user_id != ctx.author.id:
111+
return False
112+
return True
113+
return inner
114+
115+
116+
class Paginator(Session):
117+
"""Paginator class, that used an interactive session to display buttons.
118+
119+
title: str
120+
Only available when embed=True. The title of the embeded pages.
121+
length: int
122+
The number of entries per page.
123+
entries: list
124+
The entries to paginate.
125+
extra_pages: list
126+
Extra pages to append to our entries.
127+
prefix: Optional[str]
128+
The formatting prefix to apply to our entries.
129+
suffix: Optional[str]
130+
The formatting suffix to apply to our entries.
131+
format: Optional[str]
132+
The format string to wrap around our entries. This should be the first half of the format only,
133+
E.g to wrap **Entry**, we would only provide **.
134+
colour: discord.Colour
135+
Only available when embed=True. The colour of the embeded pages.
136+
use_defaults: bool
137+
Option which determines whether we should use default buttons as well. This is True by default.
138+
embed: bool
139+
Option that indicates that entries should embeded.
140+
joiner: str
141+
Option which allows us to specify the entries joiner. E.g self.joiner.join(self.entries)
142+
timeout: int
143+
The timeout in seconds to wait for reaction responses.
144+
thumbnail:
145+
Only available when embed=True. The thumbnail URL to set for the embeded pages.
146+
"""
147+
148+
def __init__(self, *, title: str='', length: int=10, entries: list=None,
149+
extra_pages: list=None, prefix: str='', suffix: str='', format: str='',
150+
colour: Union[int, discord.Colour]=discord.Embed.Empty,
151+
color: Union[int, discord.Colour]=discord.Embed.Empty, use_defaults: bool=True, embed: bool=True,
152+
joiner: str='\n', timeout: int=180, thumbnail: str=None):
153+
super().__init__()
154+
self._defaults = {'⏮': partial(self._default_indexer, 'start'),
155+
'◀': partial(self._default_indexer, -1),
156+
'⏹': partial(self._default_indexer, 'stop'),
157+
'▶': partial(self._default_indexer, +1),
158+
'⏭': partial(self._default_indexer, 'end')}
159+
160+
self.buttons = {}
161+
162+
self.page: discord.Message = None
163+
self._pages = []
164+
self._session_task = None
165+
self._cancelled = False
166+
self._index = 0
167+
168+
self.title = title
169+
self.colour = colour or color
170+
self.thumbnail = thumbnail
171+
self.length = length
172+
self.timeout = timeout
173+
self.entries = entries
174+
self.extra_pages = extra_pages or []
175+
176+
self.prefix = prefix
177+
self.suffix = suffix
178+
self.format = format
179+
self.joiner = joiner
180+
self.use_defaults = use_defaults
181+
self.use_embed = embed
182+
183+
def chunker(self):
184+
"""Create chunks of our entries for pagination."""
185+
for x in range(0, len(self.entries), self.length):
186+
yield self.entries[x:x + self.length]
187+
188+
def formatting(self, entry: str):
189+
"""Format our entries, with the given options."""
190+
return f'{self.prefix}{self.format}{entry}{self.format[::-1]}{self.suffix}'
191+
192+
async def start(self, ctx: commands.Context, page=None):
193+
"""Start our Paginator session."""
194+
if not self.use_defaults:
195+
if not self._buttons:
196+
raise AttributeError('Session has no buttons.') # Raise a custom exception at some point.
197+
198+
await self._paginate(ctx)
199+
200+
async def _paginate(self, ctx: commands.Context):
201+
if not self.entries and not self.extra_pages:
202+
raise AttributeError('You must provide atleast one entry or page for pagination.') # ^^
203+
204+
if self.entries:
205+
self.entries = [self.formatting(entry) for entry in self.entries]
206+
entries = list(self.chunker())
207+
else:
208+
entries = []
209+
210+
for chunk in entries:
211+
if not self.use_embed:
212+
self._pages.append(self.joiner.join(chunk))
213+
else:
214+
embed = discord.Embed(title=self.title, description=self.joiner.join(chunk), colour=self.colour)
215+
216+
if self.thumbnail:
217+
embed.set_thumbnail(url=self.thumbnail)
218+
219+
self._pages.append(embed)
220+
221+
self._pages = self._pages + self.extra_pages
222+
223+
if isinstance(self._pages[0], discord.Embed):
224+
self.page = await ctx.send(embed=self._pages[0])
225+
else:
226+
self.page = await ctx.send(self._pages[0])
227+
228+
self._session_task = ctx.bot.loop.create_task(self._session(ctx))
229+
230+
async def _session(self, ctx):
231+
if self.use_defaults:
232+
self.buttons = {**self._defaults, **self._buttons}
233+
else:
234+
self.buttons = self._buttons
235+
236+
for reaction in self.buttons.keys():
237+
ctx.bot.loop.create_task(self._add_reaction(reaction))
238+
239+
while True:
240+
try:
241+
payload = await ctx.bot.wait_for('raw_reaction_add', timeout=self.timeout, check=lambda _: self.check(_)(ctx))
242+
except asyncio.TimeoutError:
243+
return await self.cancel(ctx)
244+
245+
if self._try_remove:
246+
try:
247+
await self.page.remove_reaction(payload.emoji, ctx.guild.get_member(payload.user_id))
248+
except discord.HTTPException:
249+
pass
250+
251+
emoji = self.get_emoji_as_string(payload.emoji)
252+
action = self.buttons[emoji]
253+
254+
if action in self._defaults.values():
255+
await action(ctx)
256+
else:
257+
await action(self, ctx)
258+
259+
async def _default_indexer(self, control, ctx):
260+
previous = self._index
261+
262+
if control == 'stop':
263+
return await self.cancel(ctx)
264+
265+
if control == 'end':
266+
self._index = len(self._pages) - 1
267+
elif control == 'start':
268+
self._index = 0
269+
else:
270+
self._index += control
271+
272+
if self._index > len(self._pages) - 1 or self._index < 0:
273+
self._index = previous
274+
275+
if self._index == previous:
276+
return
277+
278+
if isinstance(self._pages[self._index], discord.Embed):
279+
await self.page.edit(embed=self._pages[self._index])
280+
else:
281+
await self.page.edit(content=self._pages[self._index])
282+
283+
284+
def button(emoji: str):
285+
"""A decorator that adds a button to your interactive session class.
286+
287+
Parameters
288+
-----------
289+
emoji: str
290+
The emoji to use as a button. This could be a unicode endpoint or in name:id format,
291+
for custom emojis
292+
293+
Raises
294+
-------
295+
TypeError
296+
The button callback is not a coroutine.
297+
"""
298+
def deco(func):
299+
if not asyncio.iscoroutinefunction(func):
300+
raise TypeError('Button callback must be a coroutine.')
301+
302+
func.__button__ = (emoji, func)
303+
return func
304+
return deco

0 commit comments

Comments
 (0)