From bc5a387f961965cbbf6b473e481d346b826eadd7 Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Thu, 23 Jul 2020 20:25:21 -0400 Subject: [PATCH] [Invites] Initial commit --- README.md | 2 + invites/__init__.py | 5 + invites/info.json | 10 ++ invites/invites.py | 282 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+) create mode 100644 invites/__init__.py create mode 100644 invites/info.json create mode 100644 invites/invites.py diff --git a/README.md b/README.md index b87ba88..8e6dfc5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ imgwelcome - Welcome users to your server(s) with an image. The repo can be foun inspirobot - Fetch "inspirational" messages from inspirobot.me with [p]inspireme. +invites - Display invites that are available on the server and the information those invites contain. The bot must have the administrator permission granted on the guild to be able to use this cog. + luigipoker - Play the Luigi Poker minigame from New Super Mario Brothers. Ported from the v2 version written by themario30. noflippedtables - A v3 port of irdumb's v2 cog with a little extra surprise included. Unflip all the tables. diff --git a/invites/__init__.py b/invites/__init__.py new file mode 100644 index 0000000..cf0bf63 --- /dev/null +++ b/invites/__init__.py @@ -0,0 +1,5 @@ +from .invites import Invites + + +def setup(bot): + bot.add_cog(Invites(bot)) diff --git a/invites/info.json b/invites/info.json new file mode 100644 index 0000000..08e6516 --- /dev/null +++ b/invites/info.json @@ -0,0 +1,10 @@ +{ + "author": ["aikaterna"], + "description": "Invite count display and leaderboard.", + "install_msg": "Thanks for installing. Use `[p]invites` to get started.", + "short": "Invite count display and leaderboard.", + "tags": ["invites"], + "permissions": ["administrator", "embed_links"], + "type": "COG" +} + diff --git a/invites/invites.py b/invites/invites.py new file mode 100644 index 0000000..8cb2e82 --- /dev/null +++ b/invites/invites.py @@ -0,0 +1,282 @@ +from __future__ import annotations +import discord +from datetime import datetime +import re +from typing import List, Callable +from redbot.core import commands, checks, Config +from redbot.core.utils import chat_formatting as cf +from redbot.vendored.discord.ext import menus + +OLD_CODE_RE = re.compile("^[0-9a-zA-Z]{16}$") +CODE_RE = re.compile("^[0-9a-zA-Z]{6}$") + +FAILURE_MSG = "That invite doesn't seem to be valid." +PERM_MSG = "I need the Administrator permission on this guild to view invite information." + + +class Invites(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, 2713371001, force_registration=True) + + default_guild = {"pinned_invites": []} + + self.config.register_guild(**default_guild) + + @commands.guild_only() + @commands.group() + async def invites(self, ctx): + """Invite information.""" + pass + + @commands.max_concurrency(1, commands.BucketType.user) + @invites.command() + async def show(self, ctx, invite_code_or_url=None): + """Show the stats for an invite, or show all invites.""" + if not ctx.me.permissions_in(ctx.channel).administrator: + return await self._send_embed(ctx, PERM_MSG) + + if not invite_code_or_url: + pages = MenuPages(await ctx.guild.invites()) + await self._menu(ctx, pages) + else: + invite_code = await self._find_invite_code(invite_code_or_url) + if not invite_code: + return await self._send_embed(ctx, msg) + + @invites.command() + async def leaderboard(self, ctx, list_all_invites=False): + """List pinned invites or all invites in a leaderboard style.""" + if not ctx.me.permissions_in(ctx.channel).administrator: + return await self._send_embed(ctx, PERM_MSG) + + if not list_all_invites: + pinned_invites = await self.config.guild(ctx.guild).pinned_invites() + if not pinned_invites: + return await self._send_embed(ctx, "No invites are pinned, or there are no invites to display.") + else: + pinned_invites = await ctx.guild.invites() + invite_info = "" + for i, invite_code_or_object in enumerate(pinned_invites): + if not list_all_invites: + inv_object = await self._get_invite_from_code(ctx, invite_code_or_object) + else: + inv_object = invite_code_or_object + max_uses = await self.get_invite_max_uses(ctx, inv_object) + inv_details = f"{i+1}. {inv_object.url} [ {inv_object.uses} uses / {max_uses} max ]\n" + invite_info += inv_details + embed = discord.Embed(title=f"Invite Usage for {ctx.guild.name}", description=invite_info) + if not list_all_invites: + embed.set_footer(text="Only displaying pinned invites.") + else: + embed.set_footer(text="Displaying all invites.") + await ctx.send(embed=embed) + + @invites.command(aliases=["listpinned"]) + async def listpin(self, ctx): + """List pinned invites.""" + pinned_invites = await self.config.guild(ctx.guild).pinned_invites() + invite_list = "None." if len(pinned_invites) == 0 else "\n".join(pinned_invites) + await self._send_embed(ctx, "Pinned Invites", invite_list) + + @invites.command() + async def pin(self, ctx, invite_code_or_url: str): + """Pin an invite to the leaderboard.""" + if not ctx.me.permissions_in(ctx.channel).administrator: + return await self._send_embed(ctx, PERM_MSG) + + invite_code = await self._find_invite_code(invite_code_or_url) + invite_code = await self._check_invite_code(ctx, invite_code) + if not invite_code: + return await self._send_embed(ctx, FAILURE_MSG) + + existing_pins = await self.config.guild(ctx.guild).pinned_invites() + if invite_code not in existing_pins: + existing_pins.append(invite_code) + await self.config.guild(ctx.guild).pinned_invites.set(existing_pins) + await self._send_embed(ctx, f"{invite_code} was added to the pinned list.") + else: + await self._send_embed(ctx, f"{invite_code} is already in the pinned list.") + + @invites.command() + async def unpin(self, ctx, invite_code_or_url: str): + """Unpin an invite from the leaderboard.""" + invite_code = await self._find_invite_code(invite_code_or_url) + if not invite_code: + return await self._send_embed(ctx, FAILURE_MSG) + + pinned_invites = await self.config.guild(ctx.guild).pinned_invites() + if invite_code in pinned_invites: + pinned_invites.remove(invite_code) + else: + return await self._send_embed(ctx, f"{invite_code} isn't in the pinned list.") + await self.config.guild(ctx.guild).pinned_invites.set(pinned_invites) + await self._send_embed(ctx, f"{invite_code} was removed from the pinned list.") + + @invites.command(hidden=True) + async def version(self, ctx): + """Invites version.""" + await self._send_embed(ctx, self.__version__) + + @staticmethod + async def _check_invite_code(ctx, invite_code): + for invite in await ctx.guild.invites(): + if invite.code == invite_code: + return invite_code + else: + continue + + return None + + @staticmethod + async def _find_invite_code(invite_code_or_url: str): + invite_match = re.fullmatch(OLD_CODE_RE, invite_code_or_url) or re.fullmatch(CODE_RE, invite_code_or_url) + if invite_match: + return invite_code_or_url + else: + sep = invite_code_or_url.rfind("/") + if sep: + try: + invite_code = invite_code_or_url.rsplit("/", 1)[1] + return invite_code + except IndexError: + return None + + return None + + @staticmethod + async def _get_invite_from_code(ctx, invite_code): + for invite in await ctx.guild.invites(): + if invite.code == invite_code: + return invite + else: + continue + + return None + + @classmethod + async def get_invite_max_uses(self, ctx, invite_object): + if invite_object.max_uses == 0: + return "\N{INFINITY}" + else: + return invite_object.max_uses + + async def _menu(self, ctx, pages): + # `wait` in this function is whether the menus wait for completion. + # An example of this is with concurrency: + # If max_concurrency's wait arg is False (the default): + # This function's wait being False will not follow the expected max_concurrency behaviour + # This function's wait being True will follow the expected max_concurrency behaviour + await MenuActions(source=pages, delete_message_after=False, clear_reactions_after=True, timeout=180).start( + ctx, wait=True + ) + + @staticmethod + async def _send_embed(ctx, title: str = None, description: str = None): + title = "\N{ZERO WIDTH SPACE}" if title is None else title + embed = discord.Embed() + embed.title = title + if description: + embed.description = description + embed.colour = await ctx.embed_colour() + await ctx.send(embed=embed) + + +class MenuPages(menus.ListPageSource): + def __init__(self, methods: List[discord.Invite]): + super().__init__(methods, per_page=1) + + async def format_page(self, menu: MenuActions, invite: discord.Invite) -> discord.Embed: + # Use the menu to generate pages as they are needed instead of giving it a list of + # already-generated embeds. + embed = discord.Embed(title=f"Invites for {menu.ctx.guild.name}") + max_uses = await Invites.get_invite_max_uses(menu.ctx, invite) + msg = f"{cf.bold(invite.url)}\n\n" + msg += f"Uses: {invite.uses}/{max_uses}\n" + msg += f"Target Channel: {invite.channel.mention}\n" + msg += f"Created by: {invite.inviter.mention}\n" + msg += f"Created at: {invite.created_at.strftime('%m-%d-%Y @ %H:%M:%S UTC')}\n" + if invite.temporary: + msg += "Temporary invite\n" + if invite.max_age == 0: + max_age = f"" + else: + max_age = f"Max age: {self._dynamic_time(int(invite.max_age))}" + msg += f"{max_age}" + embed.description = msg + + return embed + + @staticmethod + def _dynamic_time(time): + m, s = divmod(time, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + + if d > 0: + msg = "{0}d {1}h" + elif d == 0 and h > 0: + msg = "{1}h {2}m" + elif d == 0 and h == 0 and m > 0: + msg = "{2}m {3}s" + elif d == 0 and h == 0 and m == 0 and s > 0: + msg = "{3}s" + else: + msg = "" + return msg.format(d, h, m, s) + + +class MenuActions(menus.MenuPages, inherit_buttons=False): + def reaction_check(self, payload): + """The function that is used to check whether the payload should be processed. + This is passed to :meth:`discord.ext.commands.Bot.wait_for `. + + There should be no reason to override this function for most users. + This is done this way in this cog to let a bot owner operate the menu + along with the original command invoker. + + Parameters + ------------ + payload: :class:`discord.RawReactionActionEvent` + The payload to check. + + Returns + --------- + :class:`bool` + Whether the payload should be processed. + """ + if payload.message_id != self.message.id: + return False + if payload.user_id not in (*self.bot.owner_ids, self._author_id): + return False + + return payload.emoji in self.buttons + + async def show_checked_page(self, page_number: int) -> None: + # This is a custom impl of show_checked_page that allows looping around back to the + # beginning of the page stack when at the end and using next, or looping to the end + # when at the beginning page and using prev. + max_pages = self._source.get_max_pages() + try: + if max_pages is None: + await self.show_page(page_number) + elif page_number >= max_pages: + await self.show_page(0) + elif page_number < 0: + await self.show_page(max_pages - 1) + elif max_pages > page_number >= 0: + await self.show_page(page_number) + except IndexError: + pass + + @menus.button("\N{UP-POINTING RED TRIANGLE}", position=menus.First(1)) + async def prev(self, payload: discord.RawReactionActionEvent): + await self.show_checked_page(self.current_page - 1) + + @menus.button("\N{DOWN-POINTING RED TRIANGLE}", position=menus.First(2)) + async def next(self, payload: discord.RawReactionActionEvent): + await self.show_checked_page(self.current_page + 1) + + @menus.button("\N{CROSS MARK}", position=menus.Last(0)) + async def close_menu(self, payload: discord.RawReactionActionEvent) -> None: + self.stop()