"""Utilities for Dozer.""" import asyncio import inspect import typing from collections.abc import Mapping import discord from discord.ext import commands __all__ = ['bot_has_permissions', 'command', 'group', 'Cog', 'Reactor', 'Paginator', 'paginate', 'chunk', 'dev_check'] class CommandMixin: """Example usage processing""" # Keyword-arg dictionary passed to __init__ when copying/updating commands when Cog instances are created # inherited from discord.ext.command.Command __original_kwargs__: typing.Dict[str, typing.Any] _required_permissions = None def __init__(self, func, **kwargs): super().__init__(func, **kwargs) self.example_usage = kwargs.pop('example_usage', '') if hasattr(func, '__required_permissions__'): # This doesn't need to go into __original_kwargs__ because it'll be read from func each time self._required_permissions = func.__required_permissions__ @property def required_permissions(self): """Required permissions handler""" if self._required_permissions is None: self._required_permissions = discord.Permissions() return self._required_permissions @property def example_usage(self): """Example usage property""" return self._example_usage @example_usage.setter def example_usage(self, usage): """Sets example usage""" self._example_usage = self.__original_kwargs__['example_usage'] = inspect.cleandoc(usage) class Command(CommandMixin, commands.Command): """Represents a command""" class Group(CommandMixin, commands.Group): """Class for command groups""" def command(self, *args, **kwargs): """Initiates a command""" kwargs.setdefault('cls', Command) return super(Group, self).command(*args, **kwargs) def group(self, *args, **kwargs): """Initiates a command group""" kwargs.setdefault('cls', Group) return super(Group, self).command(*args, **kwargs) def command(**kwargs): """Represents bot commands""" kwargs.setdefault('cls', Command) return commands.command(**kwargs) def group(**kwargs): """Links command groups""" kwargs.setdefault('cls', Group) return commands.group(**kwargs) class Cog(commands.Cog): """Initiates cogs.""" def __init__(self, bot): super().__init__() self.bot = bot def dev_check(): """Function decorator to check that the calling user is a developer""" async def predicate(ctx): if ctx.author.id not in ctx.bot.config['developers']: raise commands.NotOwner('you are not a developer!') return True return commands.check(predicate) class Reactor: """ A simple way to respond to Discord reactions. Usage: from ._utils import Reactor # in a command initial_reactions = [...] # Initial reactions (str or Emoji) to add reactor = Reactor(ctx, initial_reactions) # Timeout is optional, and defaults to 1 minute async for reaction in reactor: # reaction is the str/Emoji that was added. # reaction will not necessarily be in initial_reactions. if reaction == this_emoji: reactor.do(reactor.message.edit(content='This!')) # Any coroutine elif reaction == that_emoji: reactor.do(reactor.message.edit(content='That!')) elif reaction == stop_emoji: reactor.stop() # The next time around, the message will be deleted and the async-for will end # If no action is set (e.g. unknown emoji), nothing happens """ _stop_reaction = object() def __init__(self, ctx, initial_reactions, *, auto_remove=True, timeout=60): """ ctx: command context initial_reactions: iterable of emoji to react with on start auto_remove: if True, reactions are removed once processed timeout: time, in seconds, to wait before stopping automatically. Set to None to wait forever. """ self.dest = ctx.channel self.bot = ctx.bot self.caller = ctx.author self.me = ctx.me self._reactions = tuple(initial_reactions) self._remove_reactions = auto_remove and ctx.channel.permissions_for( ctx.me).manage_messages # Check for required permissions self.timeout = timeout self._action = None self.message = None async def __aiter__(self): self.message = await self.dest.send(embed=self.pages[self.page]) for emoji in self._reactions: await self.message.add_reaction(emoji) while True: try: reaction, reacting_member = await self.bot.wait_for('reaction_add', check=self._check_reaction, timeout=self.timeout) except asyncio.TimeoutError: break yield reaction.emoji # Caller calls methods to set self._action; end of async for block, control resumes here if self._remove_reactions: await self.message.remove_reaction(reaction.emoji, reacting_member) if self._action is self._stop_reaction: break elif self._action is None: pass else: await self._action for emoji in reversed(self._reactions): await self.message.remove_reaction(emoji, self.me) def do(self, action): """If there's an action reaction, do the action.""" self._action = action def stop(self): """Listener for stop reactions.""" self._action = self._stop_reaction def _check_reaction(self, reaction, member): return reaction.message.id == self.message.id and member.id == self.caller.id class Paginator(Reactor): """ Extends functionality of Reactor for pagination. Left- and right- arrow reactions are used to move between pages. :stop: will stop the pagination. Other reactions are given to the caller like normal. Usage: from ._utils import Reactor # in a command initial_reactions = [...] # Initial reactions (str or Emoji) to add (in addition to normal pagination reactions) pages = [...] # Embeds to use for each page paginator = Paginator(ctx, initial_reactions, pages) async for reaction in paginator: # See Reactor for how to handle reactions # Paginator reactions will not be yielded here - only unknowns """ pagination_reactions = ( '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', # :track_previous: '\N{BLACK LEFT-POINTING TRIANGLE}', # :arrow_backward: '\N{BLACK RIGHT-POINTING TRIANGLE}', # :arrow_forward: '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', # :track_next: '\N{BLACK SQUARE FOR STOP}' # :stop_button: ) def __init__(self, ctx, initial_reactions, pages, *, start=0, auto_remove=True, timeout=60): all_reactions = list(initial_reactions) ind = all_reactions.index(Ellipsis) all_reactions[ind:ind + 1] = self.pagination_reactions super().__init__(ctx, all_reactions, auto_remove=auto_remove, timeout=timeout) if pages and isinstance(pages[-1], Mapping): named_pages = pages.pop() self.pages = dict(enumerate(pages), **named_pages) else: self.pages = pages self.len_pages = len(pages) self.page = start self.message = None self.reactor = None async def __aiter__(self): self.reactor = super().__aiter__() async for reaction in self.reactor: try: ind = self.pagination_reactions.index(reaction) except ValueError: # Not in list - send to caller yield reaction else: if ind == 0: self.go_to_page(0) elif ind == 1: self.prev() # pylint: disable=not-callable elif ind == 2: self.next() # pylint: disable=not-callable elif ind == 3: self.go_to_page(-1) else: # Only valid option left is 4 self.stop() def go_to_page(self, page): """Goes to a specific help page""" if isinstance(page, int): page = page % self.len_pages if page < 0: page += self.len_pages self.page = page self.do(self.message.edit(embed=self.pages[self.page])) def next(self, amt=1): """Goes to the next help page""" if isinstance(self.page, int): self.go_to_page(self.page + amt) else: self.go_to_page(amt - 1) def prev(self, amt=1): """Goes to the previous help page""" if isinstance(self.page, int): self.go_to_page(self.page - amt) else: self.go_to_page(-amt) async def paginate(ctx, pages, *, start=0, auto_remove=True, timeout=60): """ Simple pagination based on Paginator. Pagination is handled normally and other reactions are ignored. """ paginator = Paginator(ctx, (...,), pages, start=start, auto_remove=auto_remove, timeout=timeout) async for reaction in paginator: pass # The normal pagination reactions are handled - just drop anything else def chunk(iterable, size): """ Break an iterable into chunks of a fixed size. Returns an iterable of iterables. Almost-inverse of itertools.chain.from_iterable - passing the output of this into that function will reconstruct the original iterable. If the last chunk is not the full length, it will be returned but not padded. """ contents = list(iterable) for i in range(0, len(contents), size): yield contents[i:i + size] def bot_has_permissions(**required): """Decorator to check if bot has certain permissions when added to a command""" def predicate(ctx): """Function to tell the bot if it has the right permissions""" given = ctx.channel.permissions_for((ctx.guild or ctx.channel).me) missing = [name for name, value in required.items() if getattr(given, name) != value] if missing: raise commands.BotMissingPermissions(missing) else: return True def decorator(func): """Defines the bot_has_permissions decorator""" if isinstance(func, Command): func.checks.append(predicate) func.required_permissions.update(**required) else: if hasattr(func, '__commands_checks__'): func.__commands_checks__.append(predicate) else: func.__commands_checks__ = [predicate] func.__required_permissions__ = discord.Permissions() func.__required_permissions__.update(**required) return func return decorator