From 4aa8e171ca83e00f125653a3dc24b81b6f25e9a0 Mon Sep 17 00:00:00 2001 From: Charles <107768424+DatGuyChucky@users.noreply.github.com> Date: Sun, 14 Aug 2022 18:17:08 +0800 Subject: [PATCH 01/15] Add files via upload --- koalabot.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/koalabot.py b/koalabot.py index c2ae50bd..8464f7d4 100644 --- a/koalabot.py +++ b/koalabot.py @@ -58,7 +58,8 @@ def is_owner(ctx): """ - A command used to check if the user of a command is the owner, or the testing bot + A command used to check if the user of a command is the owner, or the testing bot. + The command also allows Senior Devs of KoalaBot to use owner only commands (as given by Admin role in the dev portal) e.g. @commands.check(koalabot.is_owner) :param ctx: The context of the message :return: True if owner or test, False otherwise @@ -149,25 +150,32 @@ async def on_command_error(ctx, error: Exception): else: guild_id = ctx.guild.id - if error.__class__ in [commands.MissingRequiredArgument, - commands.CommandNotFound]: + if error.__class__ in [commands.MissingRequiredArgument]: + await ctx.send(embed=error_embed(error_type=str(type(error).__name__), + description=str(error)+"\nAn argument is missing from this command. " + "Use k!help for more information.")) #TODO: Make actually change to the command inputted + if error.__class__ in [commands.CommandNotFound]: await ctx.send(embed=error_embed(description=error)) if error.__class__ in [commands.CheckFailure]: await ctx.send(embed=error_embed(error_type=str(type(error).__name__), - description=str(error)+"\nPlease ensure you have administrator permissions, " - "and have enabled this extension.")) + description=str(error)+"\nThe command could not be executed with the given " + "parameter.")) elif isinstance(error, commands.CommandOnCooldown): await ctx.send(embed=error_embed(description=f"{ctx.author.mention}, this command is still on cooldown for " f"{str(error.retry_after)}s.")) elif isinstance(error, commands.errors.ChannelNotFound): await ctx.send(embed=error_embed(description=f"The channel ID provided is either invalid, or not in this server.")) + elif isinstance(error, commands.errors.Forbidden): + await ctx.send(embed=error.embed(description=f"The bot lacks necessary guild permissions, re-add the bot to " + f"this server " + f"with the correct permissions from the README.md")) elif isinstance(error, commands.CommandInvokeError): logger.error("CommandInvokeError(%s), guild_id: %s, message: %s", error.original, guild_id, ctx.message, exc_info=error) await ctx.send(embed=error_embed(description=error.original)) else: logger.error(f"Unexpected Error in guild %s : %s", guild_id, error, exc_info=error) await ctx.send(embed=error_embed( - description=f"An unexpected error occurred, please contact an administrator Timestamp: {time.time()}")) # FIXME: better timestamp + description=f"An unexpected error occurred, please contact an administrator Timestamp: {time.asctime(time.gmtime(time.time()))}")) raise error @@ -194,4 +202,4 @@ async def run_bot(): if __name__ == '__main__': # pragma: no cover loop = asyncio.get_event_loop() - loop.run_until_complete(run_bot()) + loop.run_until_complete(run_bot()) \ No newline at end of file From 1f64b6a1bfe0dd4e168ab3c048f76b83e4103112 Mon Sep 17 00:00:00 2001 From: Charles <107768424+DatGuyChucky@users.noreply.github.com> Date: Sun, 14 Aug 2022 18:17:36 +0800 Subject: [PATCH 02/15] Add files via upload --- koala/cogs/verification/cog.py | 847 +++++++++++++++++---------------- 1 file changed, 425 insertions(+), 422 deletions(-) diff --git a/koala/cogs/verification/cog.py b/koala/cogs/verification/cog.py index 07ef35cc..9f48c3f1 100644 --- a/koala/cogs/verification/cog.py +++ b/koala/cogs/verification/cog.py @@ -1,422 +1,425 @@ -#!/usr/bin/env python - -""" -Koala Bot Base Cog code and additional base cog functions -Commented using reStructuredText (reST) -""" -# Futures - -# Built-in/Generic Imports -import random -import smtplib -import string -from email.message import EmailMessage - -# Libs -import discord -from discord.ext import commands -from sqlalchemy import select, delete, and_, text - -# Own modules -import koalabot -from koala.db import session_manager, insert_extension -from .env import GMAIL_EMAIL, GMAIL_PASSWORD -from .log import logger -from .models import VerifiedEmails, NonVerifiedEmails, Roles, ToReVerify - - -# Constants - -# Variables - - - - -def verify_is_enabled(ctx): - """ - A command used to check if the guild has enabled verify - e.g. @commands.check(verify_is_enabled) - :param ctx: The context of the message - :return: True if enabled or test, False otherwise - """ - try: - result = koalabot.check_guild_has_ext(ctx, "Verify") - except PermissionError: - result = False - - return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) - - -# FIXME: Move database commands to db.py -class Verification(commands.Cog, name="Verify"): - - def __init__(self, bot): - self.bot = bot - insert_extension("Verify", 0, True, True) - - @staticmethod - def send_email(email, token): - """ - Sends an email through gmails smtp server from the email stored in the environment variables - :param email: target to send an email to - :param token: the token the recipient will need to verify with - :return: - """ - email_server = smtplib.SMTP_SSL('smtp.gmail.com', 465) - email_server.ehlo() - username = GMAIL_EMAIL - password = GMAIL_PASSWORD - - msg = EmailMessage() - msg.set_content(f"Please send the bot the command:\n\n{koalabot.COMMAND_PREFIX}confirm {token}") - msg['Subject'] = "Koalabot Verification" - msg['From'] = username - msg['To'] = email - - email_server.login(username, password) - email_server.send_message(msg) - email_server.quit() - - @commands.Cog.listener() - async def on_ready(self): - await self.assign_roles_on_startup() - - @commands.Cog.listener() - async def on_member_join(self, member): - """ - Assigns necessary roles to users upon joining a server - :param member: the member object who just joined a server - :return: - """ - with session_manager() as session: - potential_emails = session.execute(select(Roles.r_id, Roles.email_suffix) - .filter_by(s_id=member.guild.id)).all() - - if potential_emails: - roles = {} - for role_id, suffix in potential_emails: - role = discord.utils.get(member.guild.roles, id=role_id) - roles[suffix] = role - results = session.execute(select(VerifiedEmails).where( - and_( - VerifiedEmails.email.endswith(suffix), - VerifiedEmails.u_id == member.id - ))).all() - - blacklisted = session.execute(select(ToReVerify) - .filter_by(r_id=role_id, u_id=member.id)).all() - - if results and not blacklisted: - await member.add_roles(role) - message_string = f"""Welcome to {member.guild.name}. This guild has verification enabled. -Please verify one of the following emails to get the appropriate role using `{koalabot.COMMAND_PREFIX}verify your_email@example.com`. -This email is stored so you don't need to verify it multiple times across servers.""" - await member.send( - content=message_string + "\n" + "\n".join([f"`{x}` for `@{y}`" for x, y in roles.items()])) - - @commands.check(koalabot.is_admin) - @commands.command(name="verifyAdd", aliases=["addVerification"]) - @commands.check(verify_is_enabled) - async def enable_verification(self, ctx, suffix=None, role=None): - """ - Set up a role and email pair for KoalaBot to verify users with - :param ctx: context of the discord message - :param suffix: end of the email (e.g. "example.com") - :param role: the role to give users with that email verified (e.g. @students) - :return: - """ - with session_manager() as session: - if not role or not suffix: - raise self.InvalidArgumentError(f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") - - try: - role_id = int(role[3:-1]) - except ValueError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - except TypeError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - - role_valid = discord.utils.get(ctx.guild.roles, id=role_id) - if not role_valid: - raise self.InvalidArgumentError("Please mention a role in this guild") - - exists = session.execute(select(Roles) - .filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)).all() - if exists: - raise self.VerifyError("Verification is already enabled for that role") - - session.add(Roles(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) - session.commit() - - await ctx.send(f"Verification enabled for {role} for emails ending with `{suffix}`") - await self.assign_role_to_guild(ctx.guild, role_valid, suffix) - - @commands.check(koalabot.is_admin) - @commands.command(name="verifyRemove", aliases=["removeVerification"]) - @commands.check(verify_is_enabled) - async def disable_verification(self, ctx, suffix=None, role=None): - """ - Disable an existing verification listener - :param ctx: context of the discord message - :param suffix: end of the email (e.g. "example.com") - :param role: the role paired with the email (e.g. @students) - :return: - """ - with session_manager() as session: - if not role or not suffix: - raise self.InvalidArgumentError( - f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") - - try: - role_id = int(role[3:-1]) - except ValueError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - except TypeError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - - session.execute(delete(Roles).filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) - session.commit() - - await ctx.send(f"Emails ending with {suffix} no longer give {role}") - - - @commands.check(koalabot.is_dm_channel) - @commands.command(name="verify") - async def verify(self, ctx, email): - """ - Send to KoalaBot in dms to verify an email with our system - :param ctx: the context of the discord message - :param email: the email you want to verify - :return: - """ - with session_manager() as session: - already_verified = session.execute(select(VerifiedEmails).filter_by(email=email)).all() - - in_blacklist = session.execute(select(ToReVerify).filter_by(u_id=ctx.author.id)).all() - - if already_verified and not in_blacklist: - raise self.VerifyError("That email is already verified") - - verification_code = ''.join(random.choice(string.ascii_letters) for _ in range(8)) - session.add(NonVerifiedEmails(u_id=ctx.author.id, email=email, token=verification_code)) - session.commit() - - self.send_email(email, verification_code) - await ctx.send("Please verify yourself using the command you have been emailed") - - @commands.check(koalabot.is_dm_channel) - @commands.command(name="unVerify") - async def un_verify(self, ctx, email): - """ - Send to KoalaBot in dms to un-verify an email with our system - :param ctx: the context of the discord message - :param email: the email you want to un-verify - :return: - """ - with session_manager() as session: - entry = session.execute(select(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)).all() - - if not entry: - raise self.VerifyError("You have not verified that email") - - session.execute(delete(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)) - session.commit() - - await self.remove_roles_for_user(ctx.author.id, email) - await ctx.send(f"{email} has been un-verified and relevant roles have been removed") - - @commands.check(koalabot.is_dm_channel) - @commands.command(name="confirm") - async def confirm(self, ctx, token): - """ - Send to KoalaBot in dms to confirm the verification of an email - :param ctx: the context of the discord message - :param token: the token emailed to you to verify with - :return: - """ - with session_manager() as session: - entry = session.execute(select(NonVerifiedEmails).filter_by(token=token)).scalar() - - if not entry: - raise self.InvalidArgumentError("That is not a valid token") - - already_verified = session.execute(select(VerifiedEmails) - .filter_by(u_id=ctx.author.id, email=entry.email)).all() - - if not already_verified: - session.add(VerifiedEmails(u_id=ctx.author.id, email=entry.email)) - - session.execute(delete(NonVerifiedEmails).filter_by(token=token)) - - potential_roles = session.execute(select(Roles.r_id) - .where(text(":email like ('%' || email_suffix)")), - {"email": entry.email}).all() - if potential_roles: - for role_id in potential_roles: - session.execute(delete(ToReVerify).filter_by(r_id=role_id[0], u_id=ctx.author.id)) - - session.commit() - await ctx.send("Your email has been verified, thank you") - await self.assign_roles_for_user(ctx.author.id, entry.email) - - @commands.check(koalabot.is_owner) - @commands.command(name="getEmails") - async def get_emails(self, ctx, user_id: int): - """ - See the emails a user is verified with - :param ctx: the context of the discord message - :param user_id: the id of the user who's emails you want to find - :return: - """ - with session_manager() as session: - results = session.execute(select(VerifiedEmails.email).filter_by(u_id=user_id)).all() - - emails = '\n'.join([x[0] for x in results]) - await ctx.send(f"This user has registered with:\n{emails}") - - @commands.command(name="verifyList", aliases=["checkVerifications"]) - @commands.check(verify_is_enabled) - async def check_verifications(self, ctx): - """ - List the current verification setup for the server - :param ctx: the context of the discord message - :return: - """ - with session_manager() as session: - embed = discord.Embed(title=f"Current verification setup for {ctx.guild.name}") - roles = session.execute(select(Roles.r_id, Roles.email_suffix).filter_by(s_id=ctx.guild.id)).all() - - role_dict = {} - for role_id, suffix in roles: - role = discord.utils.get(ctx.guild.roles, id=role_id) - try: - if suffix in role_dict: - role_dict[suffix].append("@" + role.name) - else: - role_dict[suffix] = ["@" + role.name] - except AttributeError as e: - session.execute(delete(Roles).filter_by(r_id=role_id)) - - session.commit() - for suffix, roles in role_dict.items(): - embed.add_field(name=suffix, value='\n'.join(roles)) - - await ctx.send(embed=embed) - - @commands.check(koalabot.is_admin) - @commands.command(name="reVerify") - @commands.check(verify_is_enabled) - async def re_verify(self, ctx, role: discord.Role): - """ - Removes a role from all users who have it and marks them as needing to re-verify before giving it back - :param ctx: the context of the discord message - :param role: the role to be removed and re-verified (e.g. @students) - :return: - """ - with session_manager() as session: - exists = session.execute(select(Roles).filter_by(s_id=ctx.guild.id, r_id=role.id)).all() - - if not exists: - raise self.VerifyError("Verification is not enabled for that role") - existing_reverify = session.execute(select(ToReVerify.u_id).filter_by(r_id=role.id)).scalars().all() - for member in role.members: - await member.remove_roles(role) - if member.id not in existing_reverify: - session.add(ToReVerify(u_id=member.id, r_id=role.id)) - - session.commit() - await ctx.send("That role has now been removed from all users and they will need to re-verify the associated email.") - - class InvalidArgumentError(Exception): - pass - - class VerifyError(Exception): - pass - - async def assign_roles_on_startup(self): - with session_manager() as session: - results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix)).all() - for g_id, r_id, suffix in results: - try: - guild = self.bot.get_guild(g_id) - role = discord.utils.get(guild.roles, id=r_id) - await self.assign_role_to_guild(guild, role, suffix) - except AttributeError as e: - # bot not in guild - logger.error(e) - - async def assign_roles_for_user(self, user_id, email): - with session_manager() as session: - results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) - .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() - - for g_id, r_id, suffix in results: - blacklisted = session.execute(select(ToReVerify).filter_by(r_id=r_id, u_id=user_id)).all() - - if blacklisted: - continue - try: - guild = self.bot.get_guild(g_id) - role = discord.utils.get(guild.roles, id=r_id) - member = guild.get_member(user_id) - if not member: - member = await guild.fetch_member(user_id) - await member.add_roles(role) - except AttributeError as e: - # bot not in guild - logger.error(e) - except discord.errors.NotFound: - logger.warn(f"user with id {user_id} not found") - - async def remove_roles_for_user(self, user_id, email): - with session_manager() as session: - results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) - .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() - - for g_id, r_id, suffix in results: - try: - guild = self.bot.get_guild(g_id) - role = discord.utils.get(guild.roles, id=r_id) - member = guild.get_member(user_id) - if not member: - member = await guild.fetch_member(user_id) - await member.remove_roles(role) - except AttributeError as e: - # bot not in guild - logger.error(e) - except discord.errors.NotFound: - logger.error(f"user with id {user_id} not found in {guild}") - - async def assign_role_to_guild(self, guild, role, suffix): - with session_manager() as session: - results = session.execute(select(VerifiedEmails.u_id).where(VerifiedEmails.email.endswith(suffix))).all() - - for user_id in results: - try: - blacklisted = session.execute(select(ToReVerify).filter_by(r_id=role.id, u_id=user_id[0])).all() - - if blacklisted: - continue - member = guild.get_member(user_id[0]) - if not member: - member = await guild.fetch_member(user_id[0]) - await member.add_roles(role) - except AttributeError as e: - # bot not in guild - logger.error(e) - except discord.errors.NotFound: - logger.error(f"user with id {user_id} not found in {guild}") - - -def setup(bot: koalabot) -> None: - """ - Load this cog to the koalabot. - :param bot: the bot client for KoalaBot - """ - if GMAIL_EMAIL is None or GMAIL_PASSWORD is None: - logger.warning("Verification not started. API keys not found in environment.") - insert_extension("Verify", 0, False, False) - else: - bot.add_cog(Verification(bot)) - logger.info("Verification is ready.") - +#!/usr/bin/env python + +""" +Koala Bot Base Cog code and additional base cog functions +Commented using reStructuredText (reST) +""" +# Futures + +# Built-in/Generic Imports +import random +import smtplib +import string +from email.message import EmailMessage + +# Libs +import discord +from discord.ext import commands +from sqlalchemy import select, delete, and_, text + +# Own modules +import koalabot +from koala.db import session_manager, insert_extension +from .env import GMAIL_EMAIL, GMAIL_PASSWORD +from .log import logger +from .models import VerifiedEmails, NonVerifiedEmails, Roles, ToReVerify + + +# Constants + +# Variables + + + + +def verify_is_enabled(ctx): + """ + A command used to check if the guild has enabled verify + e.g. @commands.check(verify_is_enabled) + :param ctx: The context of the message + :return: True if enabled or test, False otherwise + """ + try: + result = koalabot.check_guild_has_ext(ctx, "Verify") + except PermissionError: + result = False + + return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) + + +# FIXME: Move database commands to db.py +class Verification(commands.Cog, name="Verify"): + + def __init__(self, bot): + self.bot = bot + insert_extension("Verify", 0, True, True) + + @staticmethod + def send_email(email, token): + """ + Sends an email through gmails smtp server from the email stored in the environment variables + :param email: target to send an email to + :param token: the token the recipient will need to verify with + :return: + """ + email_server = smtplib.SMTP_SSL('smtp.gmail.com', 465) + email_server.ehlo() + username = GMAIL_EMAIL + password = GMAIL_PASSWORD + + msg = EmailMessage() + msg.set_content(f"Please send the bot the command:\n\n{koalabot.COMMAND_PREFIX}confirm {token}") + msg['Subject'] = "Koalabot Verification" + msg['From'] = username + msg['To'] = email + + email_server.login(username, password) + email_server.send_message(msg) + email_server.quit() + + @commands.Cog.listener() + async def on_ready(self): + await self.assign_roles_on_startup() + + @commands.Cog.listener() + async def on_member_join(self, member): + """ + Assigns necessary roles to users upon joining a server + :param member: the member object who just joined a server + :return: + """ + with session_manager() as session: + potential_emails = session.execute(select(Roles.r_id, Roles.email_suffix) + .filter_by(s_id=member.guild.id)).all() + + if potential_emails: + roles = {} + for role_id, suffix in potential_emails: + role = discord.utils.get(member.guild.roles, id=role_id) + roles[suffix] = role + results = session.execute(select(VerifiedEmails).where( + and_( + VerifiedEmails.email.endswith(suffix), + VerifiedEmails.u_id == member.id + ))).all() + + blacklisted = session.execute(select(ToReVerify) + .filter_by(r_id=role_id, u_id=member.id)).all() + + if results and not blacklisted: + await member.add_roles(role) + message_string = f"""Welcome to {member.guild.name}. This guild has verification enabled. +Please verify one of the following emails to get the appropriate role using `{koalabot.COMMAND_PREFIX}verify your_email@example.com`. +This email is stored so you don't need to verify it multiple times across servers.""" + await member.send( + content=message_string + "\n" + "\n".join([f"`{x}` for `@{y}`" for x, y in roles.items()])) + + @commands.check(koalabot.is_admin) + @commands.command(name="verifyAdd", aliases=["addVerification"]) + @commands.check(verify_is_enabled) + async def enable_verification(self, ctx, suffix=None, role=None): + """ + Set up a role and email pair for KoalaBot to verify users with + :param ctx: context of the discord message + :param suffix: end of the email (e.g. "example.com") + :param role: the role to give users with that email verified (e.g. @students) + :return: + """ + with session_manager() as session: + if not role or not suffix: + raise self.InvalidArgumentError(f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") + + try: + role_id = int(role[3:-1]) + except ValueError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + except TypeError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + + role_valid = discord.utils.get(ctx.guild.roles, id=role_id) + if not role_valid: + raise self.InvalidArgumentError("Please mention a role in this guild") + + exists = session.execute(select(Roles) + .filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)).all() + if exists: + raise self.VerifyError("Verification is already enabled for that role") + + session.add(Roles(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) + session.commit() + + await ctx.send(f"Verification enabled for {role} for emails ending with `{suffix}`") + await self.assign_role_to_guild(ctx.guild, role_valid, suffix) + + @commands.check(koalabot.is_admin) + @commands.command(name="verifyRemove", aliases=["removeVerification"]) + @commands.check(verify_is_enabled) + async def disable_verification(self, ctx, suffix=None, role=None): + """ + Disable an existing verification listener + :param ctx: context of the discord message + :param suffix: end of the email (e.g. "example.com") + :param role: the role paired with the email (e.g. @students) + :return: + """ + with session_manager() as session: + if not role or not suffix: + raise self.InvalidArgumentError( + f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") + + try: + role_id = int(role[3:-1]) + except ValueError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + except TypeError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + + session.execute(delete(Roles).filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) + session.commit() + + await ctx.send(f"Emails ending with {suffix} no longer give {role}") + + + @commands.check(koalabot.is_dm_channel) + @commands.command(name="verify") + async def verify(self, ctx, email): + """ + Send to KoalaBot in dms to verify an email with our system + :param ctx: the context of the discord message + :param email: the email you want to verify + :return: + """ + with session_manager() as session: + already_verified = session.execute(select(VerifiedEmails).filter_by(email=email)).all() + + in_blacklist = session.execute(select(ToReVerify).filter_by(u_id=ctx.author.id)).all() + + if already_verified and not in_blacklist: + raise self.VerifyError("That email is already verified") + + verification_code = ''.join(random.choice(string.ascii_letters) for _ in range(8)) + session.add(NonVerifiedEmails(u_id=ctx.author.id, email=email, token=verification_code)) + session.commit() + + try: + self.send_email(email, verification_code) + except smtplib.SMTPRecipentsRefused: + raise EmailError("KoalaBot was unable to send an email to the given address.") + await ctx.send("Please verify yourself using the command you have been emailed") + + @commands.check(koalabot.is_dm_channel) + @commands.command(name="unVerify") + async def un_verify(self, ctx, email): + """ + Send to KoalaBot in dms to un-verify an email with our system + :param ctx: the context of the discord message + :param email: the email you want to un-verify + :return: + """ + with session_manager() as session: + entry = session.execute(select(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)).all() + + if not entry: + raise self.VerifyError("You have not verified that email") + + session.execute(delete(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)) + session.commit() + + await self.remove_roles_for_user(ctx.author.id, email) + await ctx.send(f"{email} has been un-verified and relevant roles have been removed") + + @commands.check(koalabot.is_dm_channel) + @commands.command(name="confirm") + async def confirm(self, ctx, token): + """ + Send to KoalaBot in dms to confirm the verification of an email + :param ctx: the context of the discord message + :param token: the token emailed to you to verify with + :return: + """ + with session_manager() as session: + entry = session.execute(select(NonVerifiedEmails).filter_by(token=token)).scalar() + + if not entry: + raise self.InvalidArgumentError("That is not a valid token") + + already_verified = session.execute(select(VerifiedEmails) + .filter_by(u_id=ctx.author.id, email=entry.email)).all() + + if not already_verified: + session.add(VerifiedEmails(u_id=ctx.author.id, email=entry.email)) + + session.execute(delete(NonVerifiedEmails).filter_by(token=token)) + + potential_roles = session.execute(select(Roles.r_id) + .where(text(":email like ('%' || email_suffix)")), + {"email": entry.email}).all() + if potential_roles: + for role_id in potential_roles: + session.execute(delete(ToReVerify).filter_by(r_id=role_id[0], u_id=ctx.author.id)) + + session.commit() + await ctx.send("Your email has been verified, thank you") + await self.assign_roles_for_user(ctx.author.id, entry.email) + + @commands.check(koalabot.is_owner) + @commands.command(name="getEmails") + async def get_emails(self, ctx, user_id: int): + """ + See the emails a user is verified with + :param ctx: the context of the discord message + :param user_id: the id of the user who's emails you want to find + :return: + """ + with session_manager() as session: + results = session.execute(select(VerifiedEmails.email).filter_by(u_id=user_id)).all() + + emails = '\n'.join([x[0] for x in results]) + await ctx.send(f"This user has registered with:\n{emails}") + + @commands.command(name="verifyList", aliases=["checkVerifications"]) + @commands.check(verify_is_enabled) + async def check_verifications(self, ctx): + """ + List the current verification setup for the server + :param ctx: the context of the discord message + :return: + """ + with session_manager() as session: + embed = discord.Embed(title=f"Current verification setup for {ctx.guild.name}") + roles = session.execute(select(Roles.r_id, Roles.email_suffix).filter_by(s_id=ctx.guild.id)).all() + + role_dict = {} + for role_id, suffix in roles: + role = discord.utils.get(ctx.guild.roles, id=role_id) + try: + if suffix in role_dict: + role_dict[suffix].append("@" + role.name) + else: + role_dict[suffix] = ["@" + role.name] + except AttributeError as e: + session.execute(delete(Roles).filter_by(r_id=role_id)) + + session.commit() + for suffix, roles in role_dict.items(): + embed.add_field(name=suffix, value='\n'.join(roles)) + + await ctx.send(embed=embed) + + @commands.check(koalabot.is_admin) + @commands.command(name="reVerify") + @commands.check(verify_is_enabled) + async def re_verify(self, ctx, role: discord.Role): + """ + Removes a role from all users who have it and marks them as needing to re-verify before giving it back + :param ctx: the context of the discord message + :param role: the role to be removed and re-verified (e.g. @students) + :return: + """ + with session_manager() as session: + exists = session.execute(select(Roles).filter_by(s_id=ctx.guild.id, r_id=role.id)).all() + + if not exists: + raise self.VerifyError("Verification is not enabled for that role") + existing_reverify = session.execute(select(ToReVerify.u_id).filter_by(r_id=role.id)).scalars().all() + for member in role.members: + await member.remove_roles(role) + if member.id not in existing_reverify: + session.add(ToReVerify(u_id=member.id, r_id=role.id)) + + session.commit() + await ctx.send("That role has now been removed from all users and they will need to re-verify the associated email.") + + class InvalidArgumentError(Exception): + pass + + class VerifyError(Exception): + pass + + async def assign_roles_on_startup(self): + with session_manager() as session: + results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix)).all() + for g_id, r_id, suffix in results: + try: + guild = self.bot.get_guild(g_id) + role = discord.utils.get(guild.roles, id=r_id) + await self.assign_role_to_guild(guild, role, suffix) + except AttributeError as e: + # bot not in guild + logger.error(e) + + async def assign_roles_for_user(self, user_id, email): + with session_manager() as session: + results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) + .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() + + for g_id, r_id, suffix in results: + blacklisted = session.execute(select(ToReVerify).filter_by(r_id=r_id, u_id=user_id)).all() + + if blacklisted: + continue + try: + guild = self.bot.get_guild(g_id) + role = discord.utils.get(guild.roles, id=r_id) + member = guild.get_member(user_id) + if not member: + member = await guild.fetch_member(user_id) + await member.add_roles(role) + except AttributeError as e: + # bot not in guild + logger.error(e) + except discord.errors.NotFound: + logger.warn(f"user with id {user_id} not found") + + async def remove_roles_for_user(self, user_id, email): + with session_manager() as session: + results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) + .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() + + for g_id, r_id, suffix in results: + try: + guild = self.bot.get_guild(g_id) + role = discord.utils.get(guild.roles, id=r_id) + member = guild.get_member(user_id) + if not member: + member = await guild.fetch_member(user_id) + await member.remove_roles(role) + except AttributeError as e: + # bot not in guild + logger.error(e) + except discord.errors.NotFound: + logger.error(f"user with id {user_id} not found in {guild}") + + async def assign_role_to_guild(self, guild, role, suffix): + with session_manager() as session: + results = session.execute(select(VerifiedEmails.u_id).where(VerifiedEmails.email.endswith(suffix))).all() + + for user_id in results: + try: + blacklisted = session.execute(select(ToReVerify).filter_by(r_id=role.id, u_id=user_id[0])).all() + + if blacklisted: + continue + member = guild.get_member(user_id[0]) + if not member: + member = await guild.fetch_member(user_id[0]) + await member.add_roles(role) + except AttributeError as e: + # bot not in guild + logger.error(e) + except discord.errors.NotFound: + logger.error(f"user with id {user_id} not found in {guild}") + + +def setup(bot: koalabot) -> None: + """ + Load this cog to the koalabot. + :param bot: the bot client for KoalaBot + """ + if GMAIL_EMAIL is None or GMAIL_PASSWORD is None: + logger.warning("Verification not started. API keys not found in environment.") + insert_extension("Verify", 0, False, False) + else: + bot.add_cog(Verification(bot)) + logger.info("Verification is ready.") + From 156b9c310c9214036d99bea026e16a6f6031c49d Mon Sep 17 00:00:00 2001 From: Charles <107768424+DatGuyChucky@users.noreply.github.com> Date: Sun, 14 Aug 2022 18:17:58 +0800 Subject: [PATCH 03/15] Add files via upload --- koala/cogs/react_for_role/cog.py | 2073 +++++++++++++++--------------- 1 file changed, 1039 insertions(+), 1034 deletions(-) diff --git a/koala/cogs/react_for_role/cog.py b/koala/cogs/react_for_role/cog.py index 15944628..21115d27 100644 --- a/koala/cogs/react_for_role/cog.py +++ b/koala/cogs/react_for_role/cog.py @@ -1,1034 +1,1039 @@ -#!/usr/bin/env python - -""" -KoalaBot Reaction Roles Code - -Author: Anan Venkatesh -Commented using reStructuredText (reST) -""" -# Futures - -# Built-in/Generic Imports -from io import BytesIO -from typing import * - -import aiohttp -import discord -import emoji -from discord.ext import commands - -# Own modules -import koalabot -from koala.colours import KOALA_GREEN -from koala.utils import wait_for_message -from koala.db import insert_extension -from .db import ReactForRoleDBManager -from .log import logger -from .utils import CUSTOM_EMOJI_REGEXP, UNICODE_EMOJI_REGEXP - - -def rfr_is_enabled(ctx): - """ - A command used to check if the guild has enabled rfr - e.g. @commands.check(rfr_is_enabled) - :param ctx: The context of the message - :return: True if enabled or test, False otherwise - """ - try: - result = koalabot.check_guild_has_ext(ctx, "ReactForRole") - except PermissionError: - result = False - - return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) - - -class ReactForRole(commands.Cog): - """ - A discord.py cog pertaining to a React for Role system to allow for automation in getting roles. - """ - - def __init__(self, bot: discord.Client): - self.bot = bot - insert_extension("ReactForRole", 0, True, True) - self.rfr_database_manager = ReactForRoleDBManager() - - @commands.check(koalabot.is_guild_channel) - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @commands.group(name="rfr", aliases=["reactForRole", "react_for_role"]) - async def react_for_role_group(self, ctx: commands.Context): - """ - Group of commands for React for Role (rfr) functionality. - :param ctx: Context of the command - :return: - """ - return - - @staticmethod - async def get_image_from_url(ctx: discord.ext.commands.Context, url: str) -> str: - """ - Gets a workable image URL that was sent/handled without a file attachment in disc, but as a raw URL in msg content. Works by sending the image in the same context, then getting that attachment - :param ctx: Context of the original message - :param url: Original raw URL - :return: URL of attachment to use in a method call - """ - - async def file_type_from_hdr(resp: aiohttp.ClientResponse): - """ - Gets a file extension based only on the Content-Type MIME type header of the HTTP GET request sent. - :param resp: Original HTTP response - :return: file ext or empty string if not compatible as discord image - """ - content_type: str = resp.content_type - if content_type == 'image/png': - return "png" - elif content_type == 'image/jpeg': - return "jpg" - elif content_type == 'image/gif': - return "gif" - else: - return None - - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status != 200: - logger.error( - "RFR: HTTP error Access code " + str(response.status) + " when attempting GET on " + url) - raise aiohttp.ClientError( - "HTTP error Access code " + str(response.status) + " when attempting GET on " + url) - image_bytes = await response.read() - data = BytesIO(image_bytes) - ftype: str = await file_type_from_hdr(response) - if not ftype: - logger.error( - "RFR: Couldn't verify image file type from " + url + " due to missing/different Content-Type header") - raise commands.BadArgument("Couldn't get an image from the message you sent.") - msg: discord.Message = await ctx.send(file=discord.File(data, f"thumbnail.{ftype}")) - try: - img = msg.attachments[0].url - await msg.delete() - return img - except Exception as e: - logger.error("RFR " + str(e)) - raise e - - @staticmethod - def attachment_img_content_type(mime: Optional[str]): - if not mime: - return False - else: - return mime.startswith("image/") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command(name="create", aliases=["createMsg", "createMessage"]) - async def rfr_create_message(self, ctx: commands.Context): - """ - Creates a new rfr message in a channel of user's choice. User is prompted for (in this order) - channel ID/name/mention, message title, message description. Default title and description exist, which are - "React for Role" and "Roles below!" respectively. User requires admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send( - "Okay, this will create a new react for role message in a channel of your choice." - "\nNote: The channel you specify will have its permissions edited to make it such that the @ everyone role " - "is unable to add new reactions to messages, they can only reaction with existing ones. Please keep this in" - " mind, or setup another channel entirely for this.") - channel_raw = await self.prompt_for_input(ctx, "channel ID, name or mention") - channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) - if not channel: - await ctx.send("Sorry, you didn't specify a valid channel ID, mention or name. Please restart the command.") - else: - del_msg = await channel.send(f"This should be a thing sent in the right channel.") - await ctx.send( - "Okay, what would you like the title of the react for role message to be? Please enter within 60" - " seconds.") - x = await wait_for_message(self.bot, ctx) - msg: discord.Message = x[0] - if not x[0]: - await ctx.send( - "Okay, didn't receive a title. Do you actually want to continue? Send anything to confirm this.") - if not await self.is_user_alive(ctx): - await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") - await del_msg.delete() - return - else: - title: str = "React for Role" - await ctx.send( - "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr edit" - " commands.") - else: - title: str = msg.content - await ctx.send( - f"Okay, the title of the message will be \"{title}\". What do you want the description to be? " - f"I'll wait 60 seconds, don't worry") - y = await wait_for_message(self.bot, ctx) - msg: discord.Message = y[0] - if not y[0]: - await ctx.send( - "Okay, didn't receive a description. Do you actually want to continue? Send anything to confirm this.") - if not await self.is_user_alive(ctx): - await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") - await del_msg.delete() - return - else: - desc: str = "Roles below!" - await ctx.send( - "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr " - "edit command.") - else: - desc: str = msg.content - await ctx.send(f"Okay, the description of the message will be \"{desc}\".\n Okay, " - f"I'll create the react for role message now.") - embed: discord.Embed = discord.Embed(title=title, description=desc, colour=KOALA_GREEN) - embed.set_footer(text="ReactForRole") - embed.set_thumbnail( - url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") - rfr_msg: discord.Message = await channel.send(embed=embed) - self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, rfr_msg.id) - await self.overwrite_channel_add_reaction_perms(ctx.guild, channel) - await ctx.send( - f"Your react for role message ID is {rfr_msg.id}, it's in {channel.mention}. You can use the other " - "k!rfr subcommands to change the message and add functionality as required.") - await del_msg.delete() - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command(name="delete", aliases=["deleteMsg", "deleteMessage"]) - async def rfr_delete_message(self, ctx: commands.Context): - """ - Deletes an existing rfr message. User is prompted for (in this order) channel ID/name/mention, message ID/URL, - Y/N confirmation. User needs admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send( - "Okay, this will delete an existing react for role message. I'll need some details first though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - await ctx.send("Please confirm that you would indeed like to delete the react for role message.") - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send("Ok") - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) - self.rfr_database_manager.remove_rfr_message_emoji_roles(rfr_msg_row[3]) - self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) - await msg.delete() - await ctx.send("ReactForRole Message deleted") - else: - await ctx.send("Cancelled command.") - - @react_for_role_group.group(name="edit", pass_context=True) - async def edit_group(self, ctx: commands.Context): - return - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="description", aliases=["desc"]) - async def rfr_edit_description(self, ctx: commands.Context): - """ - Edit the description of an existing rfr message. User is prompted for rfr message channel ID/name/mention, - rfr message ID/URL, new description, Y/N confirmation. User needs admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will edit the description of an existing react for role message. I'll need some " - "details first though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed = self.get_embed_from_message(msg) - await ctx.send(f"Your current description is {embed.description}. Please enter your new description.") - desc = await self.prompt_for_input(ctx, "description") - if desc != "": - await ctx.send(f"Your new description would be {desc}. Please confirm that you'd like this change.") - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - embed.description = desc - await msg.edit(embed=embed) - else: - await ctx.send("Okay, cancelling command.") - else: - await ctx.send("Okay, cancelling command.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="title") - async def rfr_edit_title(self, ctx: commands.Context): - """ - Edit the title of an existing rfr message. User is prompted for rfr message channel ID/name/mention, - rfr message ID/URL, new title, Y/N confirmation. User needs admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will edit the title of an existing react for role message. I'll need some details " - "first though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed = self.get_embed_from_message(msg) - await ctx.send(f"Your current title is {embed.title}. Please enter your new title.") - title = await self.prompt_for_input(ctx, "title") - if title != "": - await ctx.send(f"Your new title would be {title}. Please confirm that you'd like this change.") - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - embed.title = title - await msg.edit(embed=embed) - else: - await ctx.send("Okay, cancelling command.") - else: - await ctx.send("Okay, cancelling command.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="thumbnail", aliases=["image", "picture"]) - async def rfr_edit_thumbnail(self, ctx: commands.Context): - """ - Edit the thumbnail of an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr - message ID/URL, new thumbnail, Y/N confirmation. User needs admin perms - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will edit the thumbnail of a react for role message. I'll need some details first " - "though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed = self.get_embed_from_message(msg) - if not embed: - logger.error( - f"RFR: Can't find embed for message id {msg.id}, channel {channel.id}, guild id {ctx.guild.id}.") - await ctx.send(f"Your current image here is {embed.thumbnail.url}") - image = await self.prompt_for_input(ctx, "image you'd like to use as a thumbnail") - if not image or image == "": - await ctx.send("Okay, cancelling command.") - if isinstance(image, discord.Attachment) and self.attachment_img_content_type(image.content_type): - # correct type - if not image.url: - logger.error(f"Attachment url not found, details : {image}") - raise commands.BadArgument("Couldn't get an image from the message you sent.") - else: - embed.set_thumbnail(url=str(image.url)) - await msg.edit(embed=embed) - await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image. This will error if you " - "delete the message you sent with the image, so make sure you don't.") - elif isinstance(image, str): - # no attachment in message, just a raw URL in content - img_url = await self.get_image_from_url(ctx, image) - embed.set_thumbnail(url=img_url) - await msg.edit(embed=embed) - await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image.") - else: - raise commands.BadArgument("Couldn't get an image from the message you sent.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="inline") - async def rfr_edit_inline(self, ctx: commands.Context): - """ - Edit the inline property of embed fields in rfr embeds. Can edit all rfr messages in a server or a specific one. - User is prompted for whether they'd like inline fields or not, as well as details of the specific message if - that option is selected. User requires admin perms - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will change how your embeds look. By default fields are not inline. However, you can" - " choose to change this for a specific message or all rfr messages on the server. To do this, I'" - "ll need some information though. Can you say if you want a specific message edited, or all mess" - "ages on the server?") - input = await self.prompt_for_input(ctx, "all or specific") - if not isinstance(input, str) or not input: - await ctx.send("Okay, cancelling command") - else: - input_comm = input.lstrip().rstrip().lower() - if input_comm not in ["all", "specific"]: - await ctx.send("Okay, cancelling command.") - elif input_comm == "all": - await ctx.send( - "Okay, do you want all rfr messages in this server to have inline fields or not? Y for yes, " - "N for no.") - change_all = await self.prompt_for_input(ctx, "Y/N") - if not change_all or change_all.rstrip().lstrip().upper() not in ["Y", "N"]: - await ctx.send("Okay, cancelling command") - return - change_all = change_all.rstrip().lstrip().upper() - if change_all not in ["Y", "N"]: - await ctx.send("Invalid input for Y/N. Okay, cancelling command") - return - else: - await ctx.send( - "Keep in mind that this process may take a while if you have a lot of RFR messages on your " - "server.") - # fetch rfr messages - guild: discord.Guild = ctx.guild - text_channels: List[discord.TextChannel] = guild.text_channels - guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(guild.id) - for rfr_message in guild_rfr_messages: - channel: discord.TextChannel = discord.utils.get(text_channels, id=rfr_message[1]) - msg: discord.Message = await channel.fetch_message(id=rfr_message[2]) - embed: discord.Embed = self.get_embed_from_message(msg) - length = self.get_number_of_embed_fields(embed) - for i in range(length): - field = embed.fields[i] - embed.set_field_at(i, name=field.name, value=field.value, inline=change_all == "Y") - await msg.edit(embed=embed) - await ctx.send("Okay, the process should be finished now. Please check.") - elif input_comm.lstrip().rstrip().lower() == "specific": - # try and get specific message - await ctx.send("Okay, I'll need the information about the specific rfr message.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed: discord.Embed = self.get_embed_from_message(msg) - if not embed: - await ctx.send("Couldn't get embed, is this an RFR message?") - else: - await ctx.send("Okay, please say Y if you want inline fields, or N if you don't.") - yes_no = await self.prompt_for_input(ctx, "Y/N") - if not yes_no or yes_no.lstrip().rstrip().upper() not in ["Y", "N"]: - await ctx.send("Invalid input, cancelling command.") - return - yes_no = yes_no.lstrip().rstrip().upper() - if yes_no not in ["Y", "N"]: - await ctx.send("Invalid input, cancelling command") - else: - await ctx.send("Okay, I'll change it as requested.") - length = self.get_number_of_embed_fields(embed) - for i in range(length): - field = embed.fields[i] - embed.set_field_at(i, name=field.name, value=field.value, inline=yes_no == "Y") - await msg.edit(embed=embed) - await ctx.send("Okay, should be done. Please check.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="fixEmbed") - async def rfr_fix_embed(self, ctx: commands.Context): - """ - Cosmetic fix method if the bot ever has a moment and doesn't react with the correct emojis/has duplicates. - """ - msg, chnl = await self.get_rfr_message_from_prompts(ctx) - await self.overwrite_channel_add_reaction_perms(chnl.guild, chnl) - emb = self.get_embed_from_message(msg) - reacts: List[Union[discord.PartialEmoji, discord.Emoji, str]] = [x.emoji for x in msg.reactions] - if not emb: - logger.error( - f"RFR: Can't find embed for message id {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}.") - else: - er_id, _, _, _ = self.rfr_database_manager.get_rfr_message(ctx.guild.id, chnl.id, msg.id) - if not er_id: - logger.error( - f"RFR: Can't find rfr message with {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}. DB ER_ID : {er_id}") - else: - rfr_er = self.rfr_database_manager.get_rfr_message_emoji_roles(er_id) - if not rfr_er: - logger.error( - f"RFR: Can't retrieve RFR message (ER_ID: {er_id})'s emoji role combinations.") - else: - combos = "" - for er in rfr_er: - combos += er[1] + ", " + str(er[2]) + "\n" - er_list = await self.parse_emoji_and_role_input_str(ctx, combos, 20) - embed: discord.Embed = discord.Embed(title=emb.title, description=emb.description, - colour=KOALA_GREEN) - embed.set_footer(text=emb.footer) - embed.set_thumbnail(url=emb.thumbnail.url) - emb.set_image(url=emb.image.url) - for e in reacts: - if e not in [x for x, _ in er_list]: - await msg.clear_reaction(e) - for e, r in er_list: - embed.add_field(name=str(e), value=r.mention, inline=False) - if e not in reacts: - await msg.add_reaction(e) - await msg.edit(embed=embed) - await ctx.send("Tried fixing the message, please check that it's fixed.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="addRoles") - async def rfr_add_roles_to_msg(self, ctx: commands.Context): - """ - Adds roles to an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message ID/ - URL, emoji-role combos. Emoji-role combinations are to be given in - \\\n\", \" - \\\n\", \" - format. can be the role ID, name or mention. `emoji` can be a custom emoji from the server, or a standard - unicode emoji. \\\nUser needs admin perms to use. - :param ctx: Context of the command. - :return: - """ - - await ctx.send( - "Okay. This will add roles to an already created react for role message. I'll need some details first " - "though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) - - if not rfr_msg_row: - raise commands.CommandError("Message ID given is not that of a react for role message.") - await ctx.send("Okay, found the message you want to add to.") - remaining_slots = 20 - self.get_number_of_embed_fields(self.get_embed_from_message(msg)) - - if remaining_slots == 0: - await ctx.send( - "Unfortunately due to discord limitations that message cannot have any more reactions. If you want I " - "can create another message in the same channel though. Shall I do that?") - - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send( - "Okay, I'll continue then. The new message will have the same title and description as the " - "old one.") - old_embed = self.get_embed_from_message(msg) - embed: discord.Embed = discord.Embed(title=old_embed.title, description=old_embed.description) - embed.set_thumbnail( - url=koalabot.KOALA_IMAGE_URL) - msg: discord.Message = await channel.send(embed=embed) - msg_id = msg.id - channel = msg.channel - self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, msg_id) - await ctx.send(f"Okay, the new message has ID {msg.id} and is in {msg.channel.mention}.") - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) - else: - await ctx.send("Okay, I'll stop the command then.") - return - - await ctx.send( - "Okay. Can I get the roles and emojis you want added to the message in a list with format: \n," - " \n, \n, \netc. You can get a new line by using SHIFT + ENTER.") - await ctx.send( - f"Please note however that you've only got {remaining_slots} emoji-role combinations you can enter. I'll " - f"therefore only take the first {remaining_slots} you do. I'll wait for 3 minutes.") - - input_role_emojis = (await wait_for_message(self.bot, ctx, 180))[0].content - emoji_role_list = await self.parse_emoji_and_role_input_str(ctx, input_role_emojis, remaining_slots) - rfr_embed = self.get_embed_from_message(msg) - - for emoji_role in emoji_role_list: - discord_emoji = emoji_role[0] - role = emoji_role[1] - - if discord_emoji in [x.name for x in rfr_embed.fields]: - await ctx.send("Found duplicate emoji in the message, I'm not accepting it.") - elif role in [x.value for x in rfr_embed.fields]: - await ctx.send("Found duplicate role in the message, I'm not accepting it.") - else: - if isinstance(discord_emoji, str): - self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], emoji.demojize(discord_emoji), - role.id) - else: - self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], str(discord_emoji), role.id) - rfr_embed.add_field(name=str(discord_emoji), value=role.mention, inline=False) - await msg.add_reaction(discord_emoji) - - if isinstance(discord_emoji, str): - logger.info( - f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " - f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji}.") - else: - logger.info( - f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " - f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji.id}.") - - await msg.edit(embed=rfr_embed) - await ctx.send("Okay, you should see the message with its new emojis now.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="removeRoles") - async def rfr_remove_roles_from_msg(self, ctx: commands.Context): - """ - Removes roles from an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message - ID/URL, emojis/roles to remove. User can specify either the emoji or the role for any emoji-role combination to - remove it, but it needs to be specified in the format below. - \\\n\"/\" - \\\n\"/\" - can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard - unicode emoji. \\\nUser needs admin perms to use. - :param ctx: Context of the command. - :return: - """ - await ctx.send( - "Okay, this will remove roles from an already existing react for role message. I'll need some details first" - " though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) - - if not rfr_msg_row: - raise commands.CommandError("Message ID given is not that of a react for role message.") - await ctx.send("Okay, found the message you want to remove roles from.") - remaining_slots = self.get_number_of_embed_fields(self.get_embed_from_message(msg)) - - if remaining_slots == 0: - await ctx.send( - "Okay, it looks like you've already gotten rid of all roles from this message. Would you like me to del" - "ete the message too?") - - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send("Okay, deleting that message and removing it from the database.") - self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) - await msg.delete() - await ctx.send("Okay, deleted that react for role message. Have a nice day.") - return - else: - await ctx.send("Okay, I'll stop the command then.") - return - else: - await ctx.send( - "Okay, I'll take the info of the roles/emojis you want to remove now. Just enter it in a message " - "separated by new lines (SHIFT+ENTER). You can enter either the emojis used or the roles' ID/mention/" - "name, for each one.") - - input_emoji_roles = (await wait_for_message(self.bot, ctx, 120))[0].content - wanted_removals = await self.parse_emoji_or_roles_input_str(ctx, input_emoji_roles) - rfr_embed: discord.Embed = self.get_embed_from_message(msg) - rfr_embed_fields = rfr_embed.fields - new_embed = discord.Embed(title=rfr_embed.title, description=rfr_embed.description, - colour=KOALA_GREEN) - new_embed.set_thumbnail( - url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") - new_embed.set_footer(text="ReactForRole") - removed_field_indexes = [] - reactions_to_remove: List[discord.Reaction] = [] - - for row in wanted_removals: - if isinstance(row, discord.Emoji) or isinstance(row, str): - field_index = [x.name for x in rfr_embed_fields].index(str(row)) - if isinstance(row, str): - self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], - emoji_raw=emoji.demojize(row)) - else: - self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], emoji_raw=row) - else: - # row is instance of role - field_index = [x.value for x in rfr_embed_fields].index(row.mention) - self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], role_id=row.id) - - field = rfr_embed_fields[field_index] - removed_field_indexes.append(field_index) - reaction_emoji = await self.get_first_emoji_from_str(ctx, field.name) - reaction: discord.Reaction = [x for x in msg.reactions if str(x.emoji) == str(reaction_emoji)][0] - reactions_to_remove.append(reaction) - - new_embed_fields = [field for field in rfr_embed_fields if - rfr_embed_fields.index(field) not in removed_field_indexes] - - for field in new_embed_fields: - new_embed.add_field(name=field.name, value=field.value, inline=False) - - if self.get_number_of_embed_fields(new_embed) == 0: - await ctx.send("I see you've removed all emoji-role combinations from this react for role message. " - "Would you like to delete this message?") - - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send("Okay, I'll delete the message then.") - self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) - await msg.delete() - return - - for reaction in reactions_to_remove: - await reaction.clear() - await msg.edit(embed=new_embed) - await ctx.send("Okay, I've removed those options from the react for role message.") - - @commands.Cog.listener() - @commands.check(koalabot.is_guild_channel) - async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): - """ - Event listener for adding a reaction. Doesn't need message to be in loaded cache. - Gives the user a role if they can get it, if not strips all their roles and removes their reacts. - :param payload: RawReactionActionEvent that happened - :return: - """ - if payload.guild_id is not None: - if not payload.member.bot: - rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, - payload.message_id) - if not rfr_message: - return - - member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, - payload.channel_id, - payload.message_id, payload.user_id) - if not member_role: - # Remove the reaction - guild: discord.Guild = self.bot.get_guild(payload.guild_id) - channel: discord.TextChannel = guild.get_channel(payload.channel_id) - msg: discord.Message = await channel.fetch_message(payload.message_id) - await msg.clear_reaction(payload.emoji) - else: - if self.can_have_rfr_role(member_role[0]): - await member_role[0].add_roles(member_role[1]) - else: - # Remove all rfr roles from member - role_ids = self.rfr_database_manager.get_guild_rfr_roles(payload.guild_id) - roles: List[discord.Role] = [] - for role_id in role_ids: - role = discord.utils.get(member_role[0].guild.roles, id=role_id) - if not role: - continue - roles.append(role) - for role_to_remove in roles: - await member_role[0].remove_roles(role_to_remove) - # Remove members' reaction from all rfr messages in guild - guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(payload.guild_id) - if not guild_rfr_messages: - logger.error( - f"ReactForRole: Guild RFR messages is empty on raw reaction add. Please check" - f" guild ID {payload.guild_id}") - - else: - for guild_rfr_message in guild_rfr_messages: - guild: discord.Guild = member_role[0].guild - channel: discord.TextChannel = guild.get_channel(guild_rfr_message[1]) - msg: discord.Message = await channel.fetch_message(guild_rfr_message[2]) - for x in msg.reactions: - await x.remove(payload.member) - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command("addRequiredRole") - async def rfr_add_guild_required_role(self, ctx: commands.Context, role_str: str): - """ - Adds a role to perms to use rfr functionality in a server, so you can specify that you need, e.g. "@Student" to - be able to use rfr functionality in the server. It's server-wide permissions handling however. By default anyone - can use rfr functionality in the server. User needs to have admin perms to use. - :param ctx: Context of the command - :param role_str: Role ID/name/mention - :return: - """ - try: - role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) - await ctx.send(f"Okay, I'll add {role.name} to the list of roles required for RFR usage on the server.") - self.rfr_database_manager.add_guild_rfr_required_role(ctx.guild.id, role.id) - except (commands.CommandError, commands.BadArgument): - await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command("removeRequiredRole") - async def rfr_remove_guild_required_role(self, ctx: commands.Context, role_str: str): - """ - Removes a role from perms for use of rfr functionality in a server, so you can specify that you need, e.g. - "@Student" to be able to use rfr functionality in the server. It's server-wide permissions handling however. By - default anyone can use rfr functionality in the server. User needs to have admin perms to use. - :param ctx: Context of the command - :param role_str: Role ID/name/mention - :return: - """ - try: - role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) - await ctx.send( - f"Okay, I'll remove {role.name} from the list of roles required for RFR usage on the server.") - self.rfr_database_manager.remove_guild_rfr_required_role(ctx.guild.id, role.id) - except (commands.CommandError, commands.BadArgument): - await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command("listRequiredRoles") - async def rfr_list_guild_required_roles(self, ctx: commands.Context): - """ - Lists the server-specific role permissions for using rfr functionality. If list is empty, any role can use rfr - functionality. - :param ctx: Context of the command. - :return: - """ - role_ids = self.rfr_database_manager.get_guild_rfr_required_roles(ctx.guild.id) - msg_str = "You will need one of these roles to react to rfr messages on this server:\n" - for role_id in role_ids: - - role: discord.Role = discord.utils.get(ctx.guild.roles, id=role_id) - if not role: - logger.error(f"ReactForRole: Couldn't find role {role_id} in guild {ctx.guild.id}. Please " - f"check.") - else: - msg_str += f"{role.mention}\n" - if msg_str == "You will need one of these roles to react to rfr messages on this server:\n": - msg_str = "Anyone can react to rfr messages on this server." - await ctx.send(msg_str) - - @commands.Cog.listener() - @commands.check(koalabot.is_guild_channel) - async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): - """ - Event listener for removing a reaction. Doesn't need message to be in loaded cache. Removes the role from the - user if they have it, else does nothing. - :param payload: RawReactionActionEvent that happened. - :return: - """ - - if payload.guild_id is not None: - rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, - payload.message_id) - if not rfr_message: - return - member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, - payload.channel_id, - payload.message_id, payload.user_id) - if not member_role or member_role[0].bot: - return - await member_role[0].remove_roles(member_role[1]) - - def can_have_rfr_role(self, member: discord.Member) -> bool: - """ - check for rfr required roles, taking a member as argument - :param member: Member to check rfr perms for - :return: True if member has one of the required roles, or if there are no required roles. False otherwise - """ - required_roles: List[int] = self.rfr_database_manager.get_guild_rfr_required_roles(member.guild.id) - if not required_roles or len(required_roles) == 0: - return True - return any(x in required_roles for x in [y.id for y in member.roles]) - - async def get_rfr_message_from_prompts(self, ctx: commands.Context) -> Tuple[discord.Message, discord.TextChannel]: - """ - Gets an rfr message from prompting user, basically just calls prompt_for_input multiple times and gets value from - database based on input. - :param ctx: Context of the command this function is called in - :return: 2-Tuple of the message (if there is one, else None), and channel (if there is no message, else None). - """ - channel_raw = await self.prompt_for_input(ctx, "Channel name, mention or ID") - channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) - if not channel: - raise commands.CommandError("Invalid channel given.") - msg_id_raw = await self.prompt_for_input(ctx, "react for role message ID") - msg_id = None if (msg_id_raw == "") else int(msg_id_raw) - if not msg_id: - raise commands.CommandError("Invalid Message ID given.") - msg = await channel.fetch_message(msg_id) - if not msg: - raise commands.CommandError("Invalid Message ID given.") - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) - if not rfr_msg_row: - raise commands.CommandError("Message ID given is not that of a react for role message.") - return msg, channel - - async def get_role_member_info(self, emoji_reacted: discord.PartialEmoji, guild_id: int, - channel_id: int, message_id: int, user_id: int) -> Optional[ - Tuple[discord.Member, discord.Role]]: - """ - Gets the role that should be added/removed to/from a Member on reacting to a known RFR message, and works out - which Member reacted. - :param emoji_reacted: Emoji of the raw reaction payload - :param emoji_role_id: DB key identifying specific RFR message in a Guild - :param guild_id: ID of the guild this event occurred in - :param channel_id: ID of the channel that the message was in - :param message_id: ID of the message that was reacted to - :param user_id: ID of the user who reacted - :return: Optional 2-Tuple (member, role) where member is the Member that reacted, and Role is the role that - should be given/taken away. If a role or member couldn't be found, returns None instead. - """ - - guild: discord.Guild = self.bot.get_guild(guild_id) - member: discord.Member = discord.utils.get(guild.members, id=user_id) - if not member: - return - channel: discord.TextChannel = discord.utils.get(guild.text_channels, id=channel_id) - if not channel: - return - message: discord.Message = await channel.fetch_message(message_id) - if not message: - return - embed: discord.Embed = self.get_embed_from_message(message) - - if emoji_reacted.is_unicode_emoji(): - rep = emoji.emojize(emoji_reacted.name) - if not rep: - rep = emoji.emojize(emoji_reacted.name, use_aliases=True) - - field = await self.get_field_by_emoji(embed, rep) - if not field: - return - role_str: str = field - if not role_str: - return - role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) - if not role: - return - elif emoji_reacted.is_custom_emoji(): - rep = str(emoji_reacted) - field = await self.get_field_by_emoji(embed, rep) - if not field: - return - role_str = field - role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) - else: - logger.error( - f"ReactForRole: Database error, guild {guild_id} has no entry in rfr database for message_id " - f"{message_id} in channel_id {channel_id}. Please check this.") - return - return member, role - - async def parse_emoji_and_role_input_str(self, ctx: commands.Context, input_str: str, remaining_slots: int) -> List[ - Tuple[Union[discord.Emoji, str], discord.Role]]: - """ - Parses input for the "k!rfr edit addRoles" commmand, in the - \n", \n - , " - format. - :param ctx: context of the command that called this - :param input_str: input message content - :param remaining_slots: remaining slots left on the rfr embed referred to - :return: List of Emoji-Role pairs parsed from the input message. - """ - rows = input_str.splitlines() - arr = [] - for row in rows: - emoji_role = row.split(',') - if len(emoji_role) > 2: - raise commands.BadArgument("Too many categories/etc on one line.") - emoji: Union[discord.Emoji, str] = await self.get_first_emoji_from_str(ctx, emoji_role[0].strip()) - if not emoji: - await ctx.send(f"Yeah, didn't find emoji for `{emoji_role[0]}`") - continue - role = await commands.RoleConverter().convert(ctx, emoji_role[1].lstrip().rstrip()) - arr.append((emoji, role)) - if len(arr) == remaining_slots: - return arr - return arr - - async def parse_emoji_or_roles_input_str(self, ctx: commands.Context, input_str: str) -> List[ - Union[discord.Emoji, str, discord.Role]]: - """ - Parses input prompt for the "k!rfr edit removeRoles" command, to get a list of roles and emoji as the output. - Input format is - \n"/\n - /" - can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard - unicode emoji. - :param ctx: Context of the command that called this - :param input_str: Input message content - :return: List of roles & emojis given in the input string. - """ - rows = input_str.splitlines() - arr = [] - for row in rows: - # Try and match it to an raw_emoji first - raw_emoji = await self.get_first_emoji_from_str(ctx, row.strip()) - if not raw_emoji: - role = await commands.RoleConverter().convert(ctx, row.strip()) - if not role: - raise commands.BadArgument("Couldn't convert to a role or emoji.") - else: - arr.append(role) - else: - arr.append(raw_emoji) - return arr - - async def prompt_for_input(self, ctx: commands.Context, input_type: str) -> Union[discord.Attachment, str]: - """ - Prompts a user for input in the form of a message. Has a forced timer of 60 seconds, because it basically just - deals with the rfr specific stuff. Returns whatever was input, or cancels the calling command - :param ctx: Context of the command that calls this - :param input_type: Name of whatever info is needed from a user, just so that the message looks nice/clear - :return: User's response's content - """ - await ctx.send(f"Please enter {input_type} so I can progress further. I'll wait 60 seconds, don't worry.") - msg, channel = await wait_for_message(self.bot, ctx) - if not msg: - await channel.send("Okay, I'll cancel the command.") - return "" - elif len(msg.attachments) > 0: - return msg.attachments[0] - else: - return msg.content - - async def overwrite_channel_add_reaction_perms(self, guild: discord.Guild, channel: discord.TextChannel): - """ - Overwrites a text channel's reaction perms so that nobody can add new reactions to any message sent in the - channel, only the bot, to make sure people don't mess with the system. Relies on roles tending not to be added/ - removed constantly to keep performance satisfactory. - :param guild: Guild that the rfr message is in - :param channel: Channel that the rfr message is in - :return: - """ - # Get the @everyone role. - role: discord.Role = discord.utils.get(guild.roles, id=guild.id) - overwrite: discord.PermissionOverwrite = discord.PermissionOverwrite() - overwrite.update(add_reactions=False) - await channel.set_permissions(role, overwrite=overwrite) - bot_members = [member for member in guild.members if member.bot and member.id == self.bot.user.id] - overwrite.update(add_reactions=True) - for bot_member in bot_members: - await channel.set_permissions(bot_member, overwrite=overwrite) - - async def is_user_alive(self, ctx: commands.Context): - """ - Prompts user for message to check if they're alive. Any message will do. We hope they're alive anyways. - :param ctx: Context of the command that calls this - :return: True if message received, False otherwise. - """ - msg = await wait_for_message(self.bot, ctx, 10) - if not msg[0]: - return False - return True - - def get_embed_from_message(self, msg: discord.Message) -> Optional[discord.Embed]: - """ - Gets the embed from a given message. Yup. That's it. - :param msg: Message to check - :return: Returns the embed if there is one. If there isn't returns None - """ - if not msg: - return None - try: - embed = msg.embeds[0] - if not embed: - return None - return embed - except IndexError: - return None - - def get_number_of_embed_fields(self, embed: discord.Embed) -> int: - """ - Gets the number of fields in an embed. - :param embed: Embed to check - :return: Number of embed fields. - """ - return len(embed.fields) - - async def get_first_emoji_from_str(self, ctx: commands.Context, content: str) -> Optional[ - Union[discord.Emoji, str]]: - """ - Gets the first emoji in a string input, custom or not. Doesn't work with custom emojis the bot doesn't have - access to. - :param ctx: Context of the original command - :param content: Message content - :return: Emoji if there is a valid one. Otherwise None. - """ - # First check for a custom discord emoji in the string - search_result = CUSTOM_EMOJI_REGEXP.search(content) - if not search_result: - # Check for a unicode emoji in the string - search_result = UNICODE_EMOJI_REGEXP.search(content) - if not search_result: - return None - return content - else: - emoji_str = search_result.group().strip() - try: - discord_emoji: discord.Emoji = await commands.EmojiConverter().convert(ctx, emoji_str) - return discord_emoji - except commands.CommandError: - await ctx.send( - "An error occurred when trying to get the emoji. Please contact the bot developers for support.") - return None - except commands.BadArgument: - await ctx.send("Couldn't get the emoji you used - is it from this server or a server I'm in?") - return None - - async def get_field_by_emoji(self, embed: discord.Embed, emoji: Optional[str]): - """ - Get the specific field value of an rfr embed by the string representation of the emoji in the field name - - :param embed: RFR Embed to check - :param emoji: Emoji required - :return: If a field exists such that its name is :emoji:, then that field's value. Else None - """ - if not emoji: - return - else: - fields = embed.fields - field = discord.utils.get(fields, name=emoji) - if not field: - return - return field.value - - -def setup(bot: koalabot) -> None: - """ - Load this cog to the KoalaBot. - :param bot: the bot client for KoalaBot - """ - bot.add_cog(ReactForRole(bot)) +#!/usr/bin/env python + +""" +KoalaBot Reaction Roles Code + +Author: Anan Venkatesh +Commented using reStructuredText (reST) +""" +# Futures + +# Built-in/Generic Imports +from io import BytesIO +from typing import * + +import aiohttp +import discord +import emoji +from discord.ext import commands + +# Own modules +import koalabot +from koala.colours import KOALA_GREEN +from koala.utils import wait_for_message, is_int +from koala.db import insert_extension +from .db import ReactForRoleDBManager +from .log import logger +from .utils import CUSTOM_EMOJI_REGEXP, UNICODE_EMOJI_REGEXP + + +def rfr_is_enabled(ctx): + """ + A command used to check if the guild has enabled rfr + e.g. @commands.check(rfr_is_enabled) + :param ctx: The context of the message + :return: True if enabled or test, False otherwise + """ + try: + result = koalabot.check_guild_has_ext(ctx, "ReactForRole") + except PermissionError: + result = False + + return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) + + +class ReactForRole(commands.Cog): + """ + A discord.py cog pertaining to a React for Role system to allow for automation in getting roles. + """ + + def __init__(self, bot: discord.Client): + self.bot = bot + insert_extension("ReactForRole", 0, True, True) + self.rfr_database_manager = ReactForRoleDBManager() + + @commands.check(koalabot.is_guild_channel) + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @commands.group(name="rfr", aliases=["reactForRole", "react_for_role"]) + async def react_for_role_group(self, ctx: commands.Context): + """ + Group of commands for React for Role (rfr) functionality. + :param ctx: Context of the command + :return: + """ + return + + @staticmethod + async def get_image_from_url(ctx: discord.ext.commands.Context, url: str) -> str: + """ + Gets a workable image URL that was sent/handled without a file attachment in disc, but as a raw URL in msg content. Works by sending the image in the same context, then getting that attachment + :param ctx: Context of the original message + :param url: Original raw URL + :return: URL of attachment to use in a method call + """ + + async def file_type_from_hdr(resp: aiohttp.ClientResponse): + """ + Gets a file extension based only on the Content-Type MIME type header of the HTTP GET request sent. + :param resp: Original HTTP response + :return: file ext or empty string if not compatible as discord image + """ + content_type: str = resp.content_type + if content_type == 'image/png': + return "png" + elif content_type == 'image/jpeg': + return "jpg" + elif content_type == 'image/gif': + return "gif" + else: + return None + + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status != 200: + logger.error( + "RFR: HTTP error Access code " + str(response.status) + " when attempting GET on " + url) + raise aiohttp.ClientError( + "HTTP error Access code " + str(response.status) + " when attempting GET on " + url) + image_bytes = await response.read() + data = BytesIO(image_bytes) + ftype: str = await file_type_from_hdr(response) + if not ftype: + logger.error( + "RFR: Couldn't verify image file type from " + url + " due to missing/different Content-Type header") + raise commands.BadArgument("Couldn't get an image from the message you sent.") + msg: discord.Message = await ctx.send(file=discord.File(data, f"thumbnail.{ftype}")) + try: + img = msg.attachments[0].url + await msg.delete() + return img + except Exception as e: + logger.error("RFR " + str(e)) + raise e + + @staticmethod + def attachment_img_content_type(mime: Optional[str]): + if not mime: + return False + else: + return mime.startswith("image/") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command(name="create", aliases=["createMsg", "createMessage"]) + async def rfr_create_message(self, ctx: commands.Context): + """ + Creates a new rfr message in a channel of user's choice. User is prompted for (in this order) + channel ID/name/mention, message title, message description. Default title and description exist, which are + "React for Role" and "Roles below!" respectively. User requires admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send( + "Okay, this will create a new react for role message in a channel of your choice." + "\nNote: The channel you specify will have its permissions edited to make it such that the @ everyone role " + "is unable to add new reactions to messages, they can only reaction with existing ones. Please keep this in" + " mind, or setup another channel entirely for this.") + channel_raw = await self.prompt_for_input(ctx, "channel ID, name or mention") + channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) + if not channel: + await ctx.send("Sorry, you didn't specify a valid channel ID, mention or name. Please restart the command.") + else: + del_msg = await channel.send(f"This should be a thing sent in the right channel.") + await ctx.send( + "Okay, what would you like the title of the react for role message to be? Please enter within 60" + " seconds.") + x = await wait_for_message(self.bot, ctx) + msg: discord.Message = x[0] + if not x[0]: + await ctx.send( + "Okay, didn't receive a title. Do you actually want to continue? Send anything to confirm this.") + if not await self.is_user_alive(ctx): + await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") + await del_msg.delete() + return + else: + title: str = "React for Role" + await ctx.send( + "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr edit" + " commands.") + else: + title: str = msg.content + await ctx.send( + f"Okay, the title of the message will be \"{title}\". What do you want the description to be? " + f"I'll wait 60 seconds, don't worry") + y = await wait_for_message(self.bot, ctx) + msg: discord.Message = y[0] + if not y[0]: + await ctx.send( + "Okay, didn't receive a description. Do you actually want to continue? Send anything to confirm this.") + if not await self.is_user_alive(ctx): + await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") + await del_msg.delete() + return + else: + desc: str = "Roles below!" + await ctx.send( + "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr " + "edit command.") + else: + desc: str = msg.content + await ctx.send(f"Okay, the description of the message will be \"{desc}\".\n Okay, " + f"I'll create the react for role message now.") + embed: discord.Embed = discord.Embed(title=title, description=desc, colour=KOALA_GREEN) + embed.set_footer(text="ReactForRole") + embed.set_thumbnail( + url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") + rfr_msg: discord.Message = await channel.send(embed=embed) + self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, rfr_msg.id) + await self.overwrite_channel_add_reaction_perms(ctx.guild, channel) + await ctx.send( + f"Your react for role message ID is {rfr_msg.id}, it's in {channel.mention}. You can use the other " + "k!rfr subcommands to change the message and add functionality as required.") + await del_msg.delete() + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command(name="delete", aliases=["deleteMsg", "deleteMessage"]) + async def rfr_delete_message(self, ctx: commands.Context): + """ + Deletes an existing rfr message. User is prompted for (in this order) channel ID/name/mention, message ID/URL, + Y/N confirmation. User needs admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send( + "Okay, this will delete an existing react for role message. I'll need some details first though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + await ctx.send("Please confirm that you would indeed like to delete the react for role message.") + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send("Ok") + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) + self.rfr_database_manager.remove_rfr_message_emoji_roles(rfr_msg_row[3]) + self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) + await msg.delete() + await ctx.send("ReactForRole Message deleted") + else: + await ctx.send("Cancelled command.") + + @react_for_role_group.group(name="edit", pass_context=True) + async def edit_group(self, ctx: commands.Context): + return + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="description", aliases=["desc"]) + async def rfr_edit_description(self, ctx: commands.Context): + """ + Edit the description of an existing rfr message. User is prompted for rfr message channel ID/name/mention, + rfr message ID/URL, new description, Y/N confirmation. User needs admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will edit the description of an existing react for role message. I'll need some " + "details first though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed = self.get_embed_from_message(msg) + await ctx.send(f"Your current description is {embed.description}. Please enter your new description.") + desc = await self.prompt_for_input(ctx, "description") + if desc != "": + await ctx.send(f"Your new description would be {desc}. Please confirm that you'd like this change.") + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + embed.description = desc + await msg.edit(embed=embed) + else: + await ctx.send("Okay, cancelling command.") + else: + await ctx.send("Okay, cancelling command.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="title") + async def rfr_edit_title(self, ctx: commands.Context): + """ + Edit the title of an existing rfr message. User is prompted for rfr message channel ID/name/mention, + rfr message ID/URL, new title, Y/N confirmation. User needs admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will edit the title of an existing react for role message. I'll need some details " + "first though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed = self.get_embed_from_message(msg) + await ctx.send(f"Your current title is {embed.title}. Please enter your new title.") + title = await self.prompt_for_input(ctx, "title") + if title != "": + await ctx.send(f"Your new title would be {title}. Please confirm that you'd like this change.") + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + embed.title = title + await msg.edit(embed=embed) + else: + await ctx.send("Okay, cancelling command.") + else: + await ctx.send("Okay, cancelling command.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="thumbnail", aliases=["image", "picture"]) + async def rfr_edit_thumbnail(self, ctx: commands.Context): + """ + Edit the thumbnail of an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr + message ID/URL, new thumbnail, Y/N confirmation. User needs admin perms + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will edit the thumbnail of a react for role message. I'll need some details first " + "though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed = self.get_embed_from_message(msg) + if not embed: + logger.error( + f"RFR: Can't find embed for message id {msg.id}, channel {channel.id}, guild id {ctx.guild.id}.") + await ctx.send(f"Your current image here is {embed.thumbnail.url}") + image = await self.prompt_for_input(ctx, "image you'd like to use as a thumbnail") + if not image or image == "": + await ctx.send("Okay, cancelling command.") + if isinstance(image, discord.Attachment) and self.attachment_img_content_type(image.content_type): + # correct type + if not image.url: + logger.error(f"Attachment url not found, details : {image}") + raise commands.BadArgument("Couldn't get an image from the message you sent.") + else: + embed.set_thumbnail(url=str(image.url)) + await msg.edit(embed=embed) + await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image. This will error if you " + "delete the message you sent with the image, so make sure you don't.") + elif isinstance(image, str): + # no attachment in message, just a raw URL in content + img_url = await self.get_image_from_url(ctx, image) + embed.set_thumbnail(url=img_url) + await msg.edit(embed=embed) + await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image.") + else: + raise commands.BadArgument("Couldn't get an image from the message you sent.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="inline") + async def rfr_edit_inline(self, ctx: commands.Context): + """ + Edit the inline property of embed fields in rfr embeds. Can edit all rfr messages in a server or a specific one. + User is prompted for whether they'd like inline fields or not, as well as details of the specific message if + that option is selected. User requires admin perms + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will change how your embeds look. By default fields are not inline. However, you can" + " choose to change this for a specific message or all rfr messages on the server. To do this, I'" + "ll need some information though. Can you say if you want a specific message edited, or all mess" + "ages on the server?") + input = await self.prompt_for_input(ctx, "all or specific") + if not isinstance(input, str) or not input: + await ctx.send("Okay, cancelling command") + else: + input_comm = input.lstrip().rstrip().lower() + if input_comm not in ["all", "specific"]: + await ctx.send("Okay, cancelling command.") + elif input_comm == "all": + await ctx.send( + "Okay, do you want all rfr messages in this server to have inline fields or not? Y for yes, " + "N for no.") + change_all = await self.prompt_for_input(ctx, "Y/N") + if not change_all or change_all.rstrip().lstrip().upper() not in ["Y", "N"]: + await ctx.send("Okay, cancelling command") + return + change_all = change_all.rstrip().lstrip().upper() + if change_all not in ["Y", "N"]: + await ctx.send("Invalid input for Y/N. Okay, cancelling command") + return + else: + await ctx.send( + "Keep in mind that this process may take a while if you have a lot of RFR messages on your " + "server.") + # fetch rfr messages + guild: discord.Guild = ctx.guild + text_channels: List[discord.TextChannel] = guild.text_channels + guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(guild.id) + for rfr_message in guild_rfr_messages: + channel: discord.TextChannel = discord.utils.get(text_channels, id=rfr_message[1]) + msg: discord.Message = await channel.fetch_message(id=rfr_message[2]) + embed: discord.Embed = self.get_embed_from_message(msg) + length = self.get_number_of_embed_fields(embed) + for i in range(length): + field = embed.fields[i] + embed.set_field_at(i, name=field.name, value=field.value, inline=change_all == "Y") + await msg.edit(embed=embed) + await ctx.send("Okay, the process should be finished now. Please check.") + elif input_comm.lstrip().rstrip().lower() == "specific": + # try and get specific message + await ctx.send("Okay, I'll need the information about the specific rfr message.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed: discord.Embed = self.get_embed_from_message(msg) + if not embed: + await ctx.send("Couldn't get embed, is this an RFR message?") + else: + await ctx.send("Okay, please say Y if you want inline fields, or N if you don't.") + yes_no = await self.prompt_for_input(ctx, "Y/N") + if not yes_no or yes_no.lstrip().rstrip().upper() not in ["Y", "N"]: + await ctx.send("Invalid input, cancelling command.") + return + yes_no = yes_no.lstrip().rstrip().upper() + if yes_no not in ["Y", "N"]: + await ctx.send("Invalid input, cancelling command") + else: + await ctx.send("Okay, I'll change it as requested.") + length = self.get_number_of_embed_fields(embed) + for i in range(length): + field = embed.fields[i] + embed.set_field_at(i, name=field.name, value=field.value, inline=yes_no == "Y") + await msg.edit(embed=embed) + await ctx.send("Okay, should be done. Please check.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="fixEmbed") + async def rfr_fix_embed(self, ctx: commands.Context): + """ + Cosmetic fix method if the bot ever has a moment and doesn't react with the correct emojis/has duplicates. + """ + msg, chnl = await self.get_rfr_message_from_prompts(ctx) + await self.overwrite_channel_add_reaction_perms(chnl.guild, chnl) + emb = self.get_embed_from_message(msg) + reacts: List[Union[discord.PartialEmoji, discord.Emoji, str]] = [x.emoji for x in msg.reactions] + if not emb: + logger.error( + f"RFR: Can't find embed for message id {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}.") + else: + er_id, _, _, _ = self.rfr_database_manager.get_rfr_message(ctx.guild.id, chnl.id, msg.id) + if not er_id: + logger.error( + f"RFR: Can't find rfr message with {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}. DB ER_ID : {er_id}") + else: + rfr_er = self.rfr_database_manager.get_rfr_message_emoji_roles(er_id) + if not rfr_er: + logger.error( + f"RFR: Can't retrieve RFR message (ER_ID: {er_id})'s emoji role combinations.") + else: + combos = "" + for er in rfr_er: + combos += er[1] + ", " + str(er[2]) + "\n" + er_list = await self.parse_emoji_and_role_input_str(ctx, combos, 20) + embed: discord.Embed = discord.Embed(title=emb.title, description=emb.description, + colour=KOALA_GREEN) + embed.set_footer(text=emb.footer) + embed.set_thumbnail(url=emb.thumbnail.url) + emb.set_image(url=emb.image.url) + for e in reacts: + if e not in [x for x, _ in er_list]: + await msg.clear_reaction(e) + for e, r in er_list: + embed.add_field(name=str(e), value=r.mention, inline=False) + if e not in reacts: + await msg.add_reaction(e) + await msg.edit(embed=embed) + await ctx.send("Tried fixing the message, please check that it's fixed.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="addRoles") + async def rfr_add_roles_to_msg(self, ctx: commands.Context): + """ + Adds roles to an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message ID/ + URL, emoji-role combos. Emoji-role combinations are to be given in + \\\n\", \" + \\\n\", \" + format. can be the role ID, name or mention. `emoji` can be a custom emoji from the server, or a standard + unicode emoji. \\\nUser needs admin perms to use. + :param ctx: Context of the command. + :return: + """ + + await ctx.send( + "Okay. This will add roles to an already created react for role message. I'll need some details first " + "though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) + + if not rfr_msg_row: + raise commands.CommandError("Message ID given is not that of a react for role message.") + await ctx.send("Okay, found the message you want to add to.") + remaining_slots = 20 - self.get_number_of_embed_fields(self.get_embed_from_message(msg)) + + if remaining_slots == 0: + await ctx.send( + "Unfortunately due to discord limitations that message cannot have any more reactions. If you want I " + "can create another message in the same channel though. Shall I do that?") + + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send( + "Okay, I'll continue then. The new message will have the same title and description as the " + "old one.") + old_embed = self.get_embed_from_message(msg) + embed: discord.Embed = discord.Embed(title=old_embed.title, description=old_embed.description) + embed.set_thumbnail( + url=koalabot.KOALA_IMAGE_URL) + msg: discord.Message = await channel.send(embed=embed) + msg_id = msg.id + channel = msg.channel + self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, msg_id) + await ctx.send(f"Okay, the new message has ID {msg.id} and is in {msg.channel.mention}.") + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) + else: + await ctx.send("Okay, I'll stop the command then.") + return + + await ctx.send( + "Okay. Can I get the roles and emojis you want added to the message in a list with format: \n," + " \n, \n, \netc. You can get a new line by using SHIFT + ENTER.") + await ctx.send( + f"Please note however that you've only got {remaining_slots} emoji-role combinations you can enter. I'll " + f"therefore only take the first {remaining_slots} you do. I'll wait for 3 minutes.") + + input_role_emojis = (await wait_for_message(self.bot, ctx, 180))[0].content + emoji_role_list = await self.parse_emoji_and_role_input_str(ctx, input_role_emojis, remaining_slots) + rfr_embed = self.get_embed_from_message(msg) + + for emoji_role in emoji_role_list: + discord_emoji = emoji_role[0] + role = emoji_role[1] + + if discord_emoji in [x.name for x in rfr_embed.fields]: + await ctx.send("Found duplicate emoji in the message, I'm not accepting it.") + elif role in [x.value for x in rfr_embed.fields]: + await ctx.send("Found duplicate role in the message, I'm not accepting it.") + else: + if isinstance(discord_emoji, str): + self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], emoji.demojize(discord_emoji), + role.id) + else: + self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], str(discord_emoji), role.id) + rfr_embed.add_field(name=str(discord_emoji), value=role.mention, inline=False) + await msg.add_reaction(discord_emoji) + + if isinstance(discord_emoji, str): + logger.info( + f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " + f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji}.") + else: + logger.info( + f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " + f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji.id}.") + + await msg.edit(embed=rfr_embed) + await ctx.send("Okay, you should see the message with its new emojis now.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="removeRoles") + async def rfr_remove_roles_from_msg(self, ctx: commands.Context): + """ + Removes roles from an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message + ID/URL, emojis/roles to remove. User can specify either the emoji or the role for any emoji-role combination to + remove it, but it needs to be specified in the format below. + \\\n\"/\" + \\\n\"/\" + can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard + unicode emoji. \\\nUser needs admin perms to use. + :param ctx: Context of the command. + :return: + """ + await ctx.send( + "Okay, this will remove roles from an already existing react for role message. I'll need some details first" + " though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) + + if not rfr_msg_row: + raise commands.CommandError("Message ID given is not that of a react for role message.") + await ctx.send("Okay, found the message you want to remove roles from.") + remaining_slots = self.get_number_of_embed_fields(self.get_embed_from_message(msg)) + + if remaining_slots == 0: + await ctx.send( + "Okay, it looks like you've already gotten rid of all roles from this message. Would you like me to del" + "ete the message too?") + + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send("Okay, deleting that message and removing it from the database.") + self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) + await msg.delete() + await ctx.send("Okay, deleted that react for role message. Have a nice day.") + return + else: + await ctx.send("Okay, I'll stop the command then.") + return + else: + await ctx.send( + "Okay, I'll take the info of the roles/emojis you want to remove now. Just enter it in a message " + "separated by new lines (SHIFT+ENTER). You can enter either the emojis used or the roles' ID/mention/" + "name, for each one.") + + input_emoji_roles = (await wait_for_message(self.bot, ctx, 120))[0].content + wanted_removals = await self.parse_emoji_or_roles_input_str(ctx, input_emoji_roles) + rfr_embed: discord.Embed = self.get_embed_from_message(msg) + rfr_embed_fields = rfr_embed.fields + new_embed = discord.Embed(title=rfr_embed.title, description=rfr_embed.description, + colour=KOALA_GREEN) + new_embed.set_thumbnail( + url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") + new_embed.set_footer(text="ReactForRole") + removed_field_indexes = [] + reactions_to_remove: List[discord.Reaction] = [] + + for row in wanted_removals: + if isinstance(row, discord.Emoji) or isinstance(row, str): + field_index = [x.name for x in rfr_embed_fields].index(str(row)) + if isinstance(row, str): + self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], + emoji_raw=emoji.demojize(row)) + else: + self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], emoji_raw=row) + else: + # row is instance of role + field_index = [x.value for x in rfr_embed_fields].index(row.mention) + self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], role_id=row.id) + + field = rfr_embed_fields[field_index] + removed_field_indexes.append(field_index) + reaction_emoji = await self.get_first_emoji_from_str(ctx, field.name) + reaction: discord.Reaction = [x for x in msg.reactions if str(x.emoji) == str(reaction_emoji)][0] + reactions_to_remove.append(reaction) + + new_embed_fields = [field for field in rfr_embed_fields if + rfr_embed_fields.index(field) not in removed_field_indexes] + + for field in new_embed_fields: + new_embed.add_field(name=field.name, value=field.value, inline=False) + + if self.get_number_of_embed_fields(new_embed) == 0: + await ctx.send("I see you've removed all emoji-role combinations from this react for role message. " + "Would you like to delete this message?") + + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send("Okay, I'll delete the message then.") + self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) + await msg.delete() + return + + for reaction in reactions_to_remove: + await reaction.clear() + await msg.edit(embed=new_embed) + await ctx.send("Okay, I've removed those options from the react for role message.") + + @commands.Cog.listener() + @commands.check(koalabot.is_guild_channel) + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + """ + Event listener for adding a reaction. Doesn't need message to be in loaded cache. + Gives the user a role if they can get it, if not strips all their roles and removes their reacts. + :param payload: RawReactionActionEvent that happened + :return: + """ + if payload.guild_id is not None: + if not payload.member.bot: + rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, + payload.message_id) + if not rfr_message: + return + + member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, + payload.channel_id, + payload.message_id, payload.user_id) + if not member_role: + # Remove the reaction + guild: discord.Guild = self.bot.get_guild(payload.guild_id) + channel: discord.TextChannel = guild.get_channel(payload.channel_id) + msg: discord.Message = await channel.fetch_message(payload.message_id) + await msg.clear_reaction(payload.emoji) + else: + if self.can_have_rfr_role(member_role[0]): + await member_role[0].add_roles(member_role[1]) + else: + # Remove all rfr roles from member + role_ids = self.rfr_database_manager.get_guild_rfr_roles(payload.guild_id) + roles: List[discord.Role] = [] + for role_id in role_ids: + role = discord.utils.get(member_role[0].guild.roles, id=role_id) + if not role: + continue + roles.append(role) + for role_to_remove in roles: + await member_role[0].remove_roles(role_to_remove) + # Remove members' reaction from all rfr messages in guild + guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(payload.guild_id) + if not guild_rfr_messages: + logger.error( + f"ReactForRole: Guild RFR messages is empty on raw reaction add. Please check" + f" guild ID {payload.guild_id}") + + else: + for guild_rfr_message in guild_rfr_messages: + guild: discord.Guild = member_role[0].guild + channel: discord.TextChannel = guild.get_channel(guild_rfr_message[1]) + msg: discord.Message = await channel.fetch_message(guild_rfr_message[2]) + for x in msg.reactions: + await x.remove(payload.member) + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command("addRequiredRole") + async def rfr_add_guild_required_role(self, ctx: commands.Context, role_str: str): + """ + Adds a role to perms to use rfr functionality in a server, so you can specify that you need, e.g. "@Student" to + be able to use rfr functionality in the server. It's server-wide permissions handling however. By default anyone + can use rfr functionality in the server. User needs to have admin perms to use. + :param ctx: Context of the command + :param role_str: Role ID/name/mention + :return: + """ + try: + role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) + await ctx.send(f"Okay, I'll add {role.name} to the list of roles required for RFR usage on the server.") + self.rfr_database_manager.add_guild_rfr_required_role(ctx.guild.id, role.id) + except (commands.CommandError, commands.BadArgument): + await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command("removeRequiredRole") + async def rfr_remove_guild_required_role(self, ctx: commands.Context, role_str: str): + """ + Removes a role from perms for use of rfr functionality in a server, so you can specify that you need, e.g. + "@Student" to be able to use rfr functionality in the server. It's server-wide permissions handling however. By + default anyone can use rfr functionality in the server. User needs to have admin perms to use. + :param ctx: Context of the command + :param role_str: Role ID/name/mention + :return: + """ + try: + role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) + await ctx.send( + f"Okay, I'll remove {role.name} from the list of roles required for RFR usage on the server.") + self.rfr_database_manager.remove_guild_rfr_required_role(ctx.guild.id, role.id) + except (commands.CommandError, commands.BadArgument): + await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command("listRequiredRoles") + async def rfr_list_guild_required_roles(self, ctx: commands.Context): + """ + Lists the server-specific role permissions for using rfr functionality. If list is empty, any role can use rfr + functionality. + :param ctx: Context of the command. + :return: + """ + role_ids = self.rfr_database_manager.get_guild_rfr_required_roles(ctx.guild.id) + msg_str = "You will need one of these roles to react to rfr messages on this server:\n" + for role_id in role_ids: + + role: discord.Role = discord.utils.get(ctx.guild.roles, id=role_id) + if not role: + logger.error(f"ReactForRole: Couldn't find role {role_id} in guild {ctx.guild.id}. Please " + f"check.") + else: + msg_str += f"{role.mention}\n" + if msg_str == "You will need one of these roles to react to rfr messages on this server:\n": + msg_str = "Anyone can react to rfr messages on this server." + await ctx.send(msg_str) + + @commands.Cog.listener() + @commands.check(koalabot.is_guild_channel) + async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): + """ + Event listener for removing a reaction. Doesn't need message to be in loaded cache. Removes the role from the + user if they have it, else does nothing. + :param payload: RawReactionActionEvent that happened. + :return: + """ + + if payload.guild_id is not None: + rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, + payload.message_id) + if not rfr_message: + return + member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, + payload.channel_id, + payload.message_id, payload.user_id) + if not member_role or member_role[0].bot: + return + await member_role[0].remove_roles(member_role[1]) + + def can_have_rfr_role(self, member: discord.Member) -> bool: + """ + check for rfr required roles, taking a member as argument + :param member: Member to check rfr perms for + :return: True if member has one of the required roles, or if there are no required roles. False otherwise + """ + required_roles: List[int] = self.rfr_database_manager.get_guild_rfr_required_roles(member.guild.id) + if not required_roles or len(required_roles) == 0: + return True + return any(x in required_roles for x in [y.id for y in member.roles]) + + async def get_rfr_message_from_prompts(self, ctx: commands.Context) -> Tuple[discord.Message, discord.TextChannel]: + """ + Gets an rfr message from prompting user, basically just calls prompt_for_input multiple times and gets value from + database based on input. + :param ctx: Context of the command this function is called in + :return: 2-Tuple of the message (if there is one, else None), and channel (if there is no message, else None). + """ + channel_raw = await self.prompt_for_input(ctx, "Channel name, mention or ID") + channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) + if not channel: + raise commands.CommandError("Invalid channel given.") + msg_id_raw = await self.prompt_for_input(ctx, "react for role message ID") + if (msg_id_raw == ""): + msg_id = None + elif not is_int(msg_id_raw): + msg_id = None + else: + msg_id = int(msg_id_raw) + if not msg_id: + raise commands.CommandError("Invalid Message ID given.") + msg = await channel.fetch_message(msg_id) + if not msg: + raise commands.CommandError("Invalid Message ID given.") + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) + if not rfr_msg_row: + raise commands.CommandError("Message ID given is not that of a react for role message.") + return msg, channel + + async def get_role_member_info(self, emoji_reacted: discord.PartialEmoji, guild_id: int, + channel_id: int, message_id: int, user_id: int) -> Optional[ + Tuple[discord.Member, discord.Role]]: + """ + Gets the role that should be added/removed to/from a Member on reacting to a known RFR message, and works out + which Member reacted. + :param emoji_reacted: Emoji of the raw reaction payload + :param emoji_role_id: DB key identifying specific RFR message in a Guild + :param guild_id: ID of the guild this event occurred in + :param channel_id: ID of the channel that the message was in + :param message_id: ID of the message that was reacted to + :param user_id: ID of the user who reacted + :return: Optional 2-Tuple (member, role) where member is the Member that reacted, and Role is the role that + should be given/taken away. If a role or member couldn't be found, returns None instead. + """ + + guild: discord.Guild = self.bot.get_guild(guild_id) + member: discord.Member = discord.utils.get(guild.members, id=user_id) + if not member: + return + channel: discord.TextChannel = discord.utils.get(guild.text_channels, id=channel_id) + if not channel: + return + message: discord.Message = await channel.fetch_message(message_id) + if not message: + return + embed: discord.Embed = self.get_embed_from_message(message) + + if emoji_reacted.is_unicode_emoji(): + rep = emoji.emojize(emoji_reacted.name) + if not rep: + rep = emoji.emojize(emoji_reacted.name, use_aliases=True) + + field = await self.get_field_by_emoji(embed, rep) + if not field: + return + role_str: str = field + if not role_str: + return + role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) + if not role: + return + elif emoji_reacted.is_custom_emoji(): + rep = str(emoji_reacted) + field = await self.get_field_by_emoji(embed, rep) + if not field: + return + role_str = field + role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) + else: + logger.error( + f"ReactForRole: Database error, guild {guild_id} has no entry in rfr database for message_id " + f"{message_id} in channel_id {channel_id}. Please check this.") + return + return member, role + + async def parse_emoji_and_role_input_str(self, ctx: commands.Context, input_str: str, remaining_slots: int) -> List[ + Tuple[Union[discord.Emoji, str], discord.Role]]: + """ + Parses input for the "k!rfr edit addRoles" commmand, in the + \n", \n + , " + format. + :param ctx: context of the command that called this + :param input_str: input message content + :param remaining_slots: remaining slots left on the rfr embed referred to + :return: List of Emoji-Role pairs parsed from the input message. + """ + rows = input_str.splitlines() + arr = [] + for row in rows: + emoji_role = row.split(',') + if len(emoji_role) > 2: + raise commands.BadArgument("Too many categories/etc on one line.") + emoji: Union[discord.Emoji, str] = await self.get_first_emoji_from_str(ctx, emoji_role[0].strip()) + if not emoji: + await ctx.send(f"Yeah, didn't find emoji for `{emoji_role[0]}`") + continue + role = await commands.RoleConverter().convert(ctx, emoji_role[1].lstrip().rstrip()) + arr.append((emoji, role)) + if len(arr) == remaining_slots: + return arr + return arr + + async def parse_emoji_or_roles_input_str(self, ctx: commands.Context, input_str: str) -> List[ + Union[discord.Emoji, str, discord.Role]]: + """ + Parses input prompt for the "k!rfr edit removeRoles" command, to get a list of roles and emoji as the output. + Input format is + \n"/\n + /" + can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard + unicode emoji. + :param ctx: Context of the command that called this + :param input_str: Input message content + :return: List of roles & emojis given in the input string. + """ + rows = input_str.splitlines() + arr = [] + for row in rows: + # Try and match it to an raw_emoji first + raw_emoji = await self.get_first_emoji_from_str(ctx, row.strip()) + if not raw_emoji: + role = await commands.RoleConverter().convert(ctx, row.strip()) + if not role: + raise commands.BadArgument("Couldn't convert to a role or emoji.") + else: + arr.append(role) + else: + arr.append(raw_emoji) + return arr + + async def prompt_for_input(self, ctx: commands.Context, input_type: str) -> Union[discord.Attachment, str]: + """ + Prompts a user for input in the form of a message. Has a forced timer of 60 seconds, because it basically just + deals with the rfr specific stuff. Returns whatever was input, or cancels the calling command + :param ctx: Context of the command that calls this + :param input_type: Name of whatever info is needed from a user, just so that the message looks nice/clear + :return: User's response's content + """ + await ctx.send(f"Please enter {input_type} so I can progress further. I'll wait 60 seconds, don't worry.") + msg, channel = await wait_for_message(self.bot, ctx) + if not msg: + await channel.send("Okay, I'll cancel the command.") + return "" + elif len(msg.attachments) > 0: + return msg.attachments[0] + else: + return msg.content + + async def overwrite_channel_add_reaction_perms(self, guild: discord.Guild, channel: discord.TextChannel): + """ + Overwrites a text channel's reaction perms so that nobody can add new reactions to any message sent in the + channel, only the bot, to make sure people don't mess with the system. Relies on roles tending not to be added/ + removed constantly to keep performance satisfactory. + :param guild: Guild that the rfr message is in + :param channel: Channel that the rfr message is in + :return: + """ + # Get the @everyone role. + role: discord.Role = discord.utils.get(guild.roles, id=guild.id) + overwrite: discord.PermissionOverwrite = discord.PermissionOverwrite() + overwrite.update(add_reactions=False) + await channel.set_permissions(role, overwrite=overwrite) + bot_members = [member for member in guild.members if member.bot and member.id == self.bot.user.id] + overwrite.update(add_reactions=True) + for bot_member in bot_members: + await channel.set_permissions(bot_member, overwrite=overwrite) + + async def is_user_alive(self, ctx: commands.Context): + """ + Prompts user for message to check if they're alive. Any message will do. We hope they're alive anyways. + :param ctx: Context of the command that calls this + :return: True if message received, False otherwise. + """ + msg = await wait_for_message(self.bot, ctx, 10) + if not msg[0]: + return False + return True + + def get_embed_from_message(self, msg: discord.Message) -> Optional[discord.Embed]: + """ + Gets the embed from a given message. Yup. That's it. + :param msg: Message to check + :return: Returns the embed if there is one. If there isn't returns None + """ + if not msg: + return None + try: + embed = msg.embeds[0] + if not embed: + return None + return embed + except IndexError: + return None + + def get_number_of_embed_fields(self, embed: discord.Embed) -> int: + """ + Gets the number of fields in an embed. + :param embed: Embed to check + :return: Number of embed fields. + """ + return len(embed.fields) + + async def get_first_emoji_from_str(self, ctx: commands.Context, content: str) -> Optional[ + Union[discord.Emoji, str]]: + """ + Gets the first emoji in a string input, custom or not. Doesn't work with custom emojis the bot doesn't have + access to. + :param ctx: Context of the original command + :param content: Message content + :return: Emoji if there is a valid one. Otherwise None. + """ + # First check for a custom discord emoji in the string + search_result = CUSTOM_EMOJI_REGEXP.search(content) + if not search_result: + # Check for a unicode emoji in the string + search_result = UNICODE_EMOJI_REGEXP.search(content) + if not search_result: + return None + return content + else: + emoji_str = search_result.group().strip() + try: + discord_emoji: discord.Emoji = await commands.EmojiConverter().convert(ctx, emoji_str) + return discord_emoji + except commands.CommandError: + await ctx.send( + "An error occurred when trying to get the emoji. Please contact the bot developers for support.") + return None + except commands.BadArgument: + await ctx.send("Couldn't get the emoji you used - is it from this server or a server I'm in?") + return None + + async def get_field_by_emoji(self, embed: discord.Embed, emoji: Optional[str]): + """ + Get the specific field value of an rfr embed by the string representation of the emoji in the field name + + :param embed: RFR Embed to check + :param emoji: Emoji required + :return: If a field exists such that its name is :emoji:, then that field's value. Else None + """ + if not emoji: + return + else: + fields = embed.fields + field = discord.utils.get(fields, name=emoji) + if not field: + return + return field.value + + +def setup(bot: koalabot) -> None: + """ + Load this cog to the KoalaBot. + :param bot: the bot client for KoalaBot + """ + bot.add_cog(ReactForRole(bot)) From 7657d6a3f750c8ca317c3344906017968b8c84d8 Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 19 Aug 2022 23:54:54 +0800 Subject: [PATCH 04/15] Revert "Add files via upload" This reverts commit 156b9c310c9214036d99bea026e16a6f6031c49d. --- koala/cogs/react_for_role/cog.py | 2073 +++++++++++++++--------------- 1 file changed, 1034 insertions(+), 1039 deletions(-) diff --git a/koala/cogs/react_for_role/cog.py b/koala/cogs/react_for_role/cog.py index 21115d27..15944628 100644 --- a/koala/cogs/react_for_role/cog.py +++ b/koala/cogs/react_for_role/cog.py @@ -1,1039 +1,1034 @@ -#!/usr/bin/env python - -""" -KoalaBot Reaction Roles Code - -Author: Anan Venkatesh -Commented using reStructuredText (reST) -""" -# Futures - -# Built-in/Generic Imports -from io import BytesIO -from typing import * - -import aiohttp -import discord -import emoji -from discord.ext import commands - -# Own modules -import koalabot -from koala.colours import KOALA_GREEN -from koala.utils import wait_for_message, is_int -from koala.db import insert_extension -from .db import ReactForRoleDBManager -from .log import logger -from .utils import CUSTOM_EMOJI_REGEXP, UNICODE_EMOJI_REGEXP - - -def rfr_is_enabled(ctx): - """ - A command used to check if the guild has enabled rfr - e.g. @commands.check(rfr_is_enabled) - :param ctx: The context of the message - :return: True if enabled or test, False otherwise - """ - try: - result = koalabot.check_guild_has_ext(ctx, "ReactForRole") - except PermissionError: - result = False - - return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) - - -class ReactForRole(commands.Cog): - """ - A discord.py cog pertaining to a React for Role system to allow for automation in getting roles. - """ - - def __init__(self, bot: discord.Client): - self.bot = bot - insert_extension("ReactForRole", 0, True, True) - self.rfr_database_manager = ReactForRoleDBManager() - - @commands.check(koalabot.is_guild_channel) - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @commands.group(name="rfr", aliases=["reactForRole", "react_for_role"]) - async def react_for_role_group(self, ctx: commands.Context): - """ - Group of commands for React for Role (rfr) functionality. - :param ctx: Context of the command - :return: - """ - return - - @staticmethod - async def get_image_from_url(ctx: discord.ext.commands.Context, url: str) -> str: - """ - Gets a workable image URL that was sent/handled without a file attachment in disc, but as a raw URL in msg content. Works by sending the image in the same context, then getting that attachment - :param ctx: Context of the original message - :param url: Original raw URL - :return: URL of attachment to use in a method call - """ - - async def file_type_from_hdr(resp: aiohttp.ClientResponse): - """ - Gets a file extension based only on the Content-Type MIME type header of the HTTP GET request sent. - :param resp: Original HTTP response - :return: file ext or empty string if not compatible as discord image - """ - content_type: str = resp.content_type - if content_type == 'image/png': - return "png" - elif content_type == 'image/jpeg': - return "jpg" - elif content_type == 'image/gif': - return "gif" - else: - return None - - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status != 200: - logger.error( - "RFR: HTTP error Access code " + str(response.status) + " when attempting GET on " + url) - raise aiohttp.ClientError( - "HTTP error Access code " + str(response.status) + " when attempting GET on " + url) - image_bytes = await response.read() - data = BytesIO(image_bytes) - ftype: str = await file_type_from_hdr(response) - if not ftype: - logger.error( - "RFR: Couldn't verify image file type from " + url + " due to missing/different Content-Type header") - raise commands.BadArgument("Couldn't get an image from the message you sent.") - msg: discord.Message = await ctx.send(file=discord.File(data, f"thumbnail.{ftype}")) - try: - img = msg.attachments[0].url - await msg.delete() - return img - except Exception as e: - logger.error("RFR " + str(e)) - raise e - - @staticmethod - def attachment_img_content_type(mime: Optional[str]): - if not mime: - return False - else: - return mime.startswith("image/") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command(name="create", aliases=["createMsg", "createMessage"]) - async def rfr_create_message(self, ctx: commands.Context): - """ - Creates a new rfr message in a channel of user's choice. User is prompted for (in this order) - channel ID/name/mention, message title, message description. Default title and description exist, which are - "React for Role" and "Roles below!" respectively. User requires admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send( - "Okay, this will create a new react for role message in a channel of your choice." - "\nNote: The channel you specify will have its permissions edited to make it such that the @ everyone role " - "is unable to add new reactions to messages, they can only reaction with existing ones. Please keep this in" - " mind, or setup another channel entirely for this.") - channel_raw = await self.prompt_for_input(ctx, "channel ID, name or mention") - channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) - if not channel: - await ctx.send("Sorry, you didn't specify a valid channel ID, mention or name. Please restart the command.") - else: - del_msg = await channel.send(f"This should be a thing sent in the right channel.") - await ctx.send( - "Okay, what would you like the title of the react for role message to be? Please enter within 60" - " seconds.") - x = await wait_for_message(self.bot, ctx) - msg: discord.Message = x[0] - if not x[0]: - await ctx.send( - "Okay, didn't receive a title. Do you actually want to continue? Send anything to confirm this.") - if not await self.is_user_alive(ctx): - await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") - await del_msg.delete() - return - else: - title: str = "React for Role" - await ctx.send( - "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr edit" - " commands.") - else: - title: str = msg.content - await ctx.send( - f"Okay, the title of the message will be \"{title}\". What do you want the description to be? " - f"I'll wait 60 seconds, don't worry") - y = await wait_for_message(self.bot, ctx) - msg: discord.Message = y[0] - if not y[0]: - await ctx.send( - "Okay, didn't receive a description. Do you actually want to continue? Send anything to confirm this.") - if not await self.is_user_alive(ctx): - await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") - await del_msg.delete() - return - else: - desc: str = "Roles below!" - await ctx.send( - "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr " - "edit command.") - else: - desc: str = msg.content - await ctx.send(f"Okay, the description of the message will be \"{desc}\".\n Okay, " - f"I'll create the react for role message now.") - embed: discord.Embed = discord.Embed(title=title, description=desc, colour=KOALA_GREEN) - embed.set_footer(text="ReactForRole") - embed.set_thumbnail( - url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") - rfr_msg: discord.Message = await channel.send(embed=embed) - self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, rfr_msg.id) - await self.overwrite_channel_add_reaction_perms(ctx.guild, channel) - await ctx.send( - f"Your react for role message ID is {rfr_msg.id}, it's in {channel.mention}. You can use the other " - "k!rfr subcommands to change the message and add functionality as required.") - await del_msg.delete() - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command(name="delete", aliases=["deleteMsg", "deleteMessage"]) - async def rfr_delete_message(self, ctx: commands.Context): - """ - Deletes an existing rfr message. User is prompted for (in this order) channel ID/name/mention, message ID/URL, - Y/N confirmation. User needs admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send( - "Okay, this will delete an existing react for role message. I'll need some details first though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - await ctx.send("Please confirm that you would indeed like to delete the react for role message.") - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send("Ok") - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) - self.rfr_database_manager.remove_rfr_message_emoji_roles(rfr_msg_row[3]) - self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) - await msg.delete() - await ctx.send("ReactForRole Message deleted") - else: - await ctx.send("Cancelled command.") - - @react_for_role_group.group(name="edit", pass_context=True) - async def edit_group(self, ctx: commands.Context): - return - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="description", aliases=["desc"]) - async def rfr_edit_description(self, ctx: commands.Context): - """ - Edit the description of an existing rfr message. User is prompted for rfr message channel ID/name/mention, - rfr message ID/URL, new description, Y/N confirmation. User needs admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will edit the description of an existing react for role message. I'll need some " - "details first though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed = self.get_embed_from_message(msg) - await ctx.send(f"Your current description is {embed.description}. Please enter your new description.") - desc = await self.prompt_for_input(ctx, "description") - if desc != "": - await ctx.send(f"Your new description would be {desc}. Please confirm that you'd like this change.") - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - embed.description = desc - await msg.edit(embed=embed) - else: - await ctx.send("Okay, cancelling command.") - else: - await ctx.send("Okay, cancelling command.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="title") - async def rfr_edit_title(self, ctx: commands.Context): - """ - Edit the title of an existing rfr message. User is prompted for rfr message channel ID/name/mention, - rfr message ID/URL, new title, Y/N confirmation. User needs admin perms to use. - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will edit the title of an existing react for role message. I'll need some details " - "first though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed = self.get_embed_from_message(msg) - await ctx.send(f"Your current title is {embed.title}. Please enter your new title.") - title = await self.prompt_for_input(ctx, "title") - if title != "": - await ctx.send(f"Your new title would be {title}. Please confirm that you'd like this change.") - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - embed.title = title - await msg.edit(embed=embed) - else: - await ctx.send("Okay, cancelling command.") - else: - await ctx.send("Okay, cancelling command.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="thumbnail", aliases=["image", "picture"]) - async def rfr_edit_thumbnail(self, ctx: commands.Context): - """ - Edit the thumbnail of an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr - message ID/URL, new thumbnail, Y/N confirmation. User needs admin perms - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will edit the thumbnail of a react for role message. I'll need some details first " - "though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed = self.get_embed_from_message(msg) - if not embed: - logger.error( - f"RFR: Can't find embed for message id {msg.id}, channel {channel.id}, guild id {ctx.guild.id}.") - await ctx.send(f"Your current image here is {embed.thumbnail.url}") - image = await self.prompt_for_input(ctx, "image you'd like to use as a thumbnail") - if not image or image == "": - await ctx.send("Okay, cancelling command.") - if isinstance(image, discord.Attachment) and self.attachment_img_content_type(image.content_type): - # correct type - if not image.url: - logger.error(f"Attachment url not found, details : {image}") - raise commands.BadArgument("Couldn't get an image from the message you sent.") - else: - embed.set_thumbnail(url=str(image.url)) - await msg.edit(embed=embed) - await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image. This will error if you " - "delete the message you sent with the image, so make sure you don't.") - elif isinstance(image, str): - # no attachment in message, just a raw URL in content - img_url = await self.get_image_from_url(ctx, image) - embed.set_thumbnail(url=img_url) - await msg.edit(embed=embed) - await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image.") - else: - raise commands.BadArgument("Couldn't get an image from the message you sent.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="inline") - async def rfr_edit_inline(self, ctx: commands.Context): - """ - Edit the inline property of embed fields in rfr embeds. Can edit all rfr messages in a server or a specific one. - User is prompted for whether they'd like inline fields or not, as well as details of the specific message if - that option is selected. User requires admin perms - :param ctx: Context of the command - :return: - """ - await ctx.send("Okay, this will change how your embeds look. By default fields are not inline. However, you can" - " choose to change this for a specific message or all rfr messages on the server. To do this, I'" - "ll need some information though. Can you say if you want a specific message edited, or all mess" - "ages on the server?") - input = await self.prompt_for_input(ctx, "all or specific") - if not isinstance(input, str) or not input: - await ctx.send("Okay, cancelling command") - else: - input_comm = input.lstrip().rstrip().lower() - if input_comm not in ["all", "specific"]: - await ctx.send("Okay, cancelling command.") - elif input_comm == "all": - await ctx.send( - "Okay, do you want all rfr messages in this server to have inline fields or not? Y for yes, " - "N for no.") - change_all = await self.prompt_for_input(ctx, "Y/N") - if not change_all or change_all.rstrip().lstrip().upper() not in ["Y", "N"]: - await ctx.send("Okay, cancelling command") - return - change_all = change_all.rstrip().lstrip().upper() - if change_all not in ["Y", "N"]: - await ctx.send("Invalid input for Y/N. Okay, cancelling command") - return - else: - await ctx.send( - "Keep in mind that this process may take a while if you have a lot of RFR messages on your " - "server.") - # fetch rfr messages - guild: discord.Guild = ctx.guild - text_channels: List[discord.TextChannel] = guild.text_channels - guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(guild.id) - for rfr_message in guild_rfr_messages: - channel: discord.TextChannel = discord.utils.get(text_channels, id=rfr_message[1]) - msg: discord.Message = await channel.fetch_message(id=rfr_message[2]) - embed: discord.Embed = self.get_embed_from_message(msg) - length = self.get_number_of_embed_fields(embed) - for i in range(length): - field = embed.fields[i] - embed.set_field_at(i, name=field.name, value=field.value, inline=change_all == "Y") - await msg.edit(embed=embed) - await ctx.send("Okay, the process should be finished now. Please check.") - elif input_comm.lstrip().rstrip().lower() == "specific": - # try and get specific message - await ctx.send("Okay, I'll need the information about the specific rfr message.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - embed: discord.Embed = self.get_embed_from_message(msg) - if not embed: - await ctx.send("Couldn't get embed, is this an RFR message?") - else: - await ctx.send("Okay, please say Y if you want inline fields, or N if you don't.") - yes_no = await self.prompt_for_input(ctx, "Y/N") - if not yes_no or yes_no.lstrip().rstrip().upper() not in ["Y", "N"]: - await ctx.send("Invalid input, cancelling command.") - return - yes_no = yes_no.lstrip().rstrip().upper() - if yes_no not in ["Y", "N"]: - await ctx.send("Invalid input, cancelling command") - else: - await ctx.send("Okay, I'll change it as requested.") - length = self.get_number_of_embed_fields(embed) - for i in range(length): - field = embed.fields[i] - embed.set_field_at(i, name=field.name, value=field.value, inline=yes_no == "Y") - await msg.edit(embed=embed) - await ctx.send("Okay, should be done. Please check.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="fixEmbed") - async def rfr_fix_embed(self, ctx: commands.Context): - """ - Cosmetic fix method if the bot ever has a moment and doesn't react with the correct emojis/has duplicates. - """ - msg, chnl = await self.get_rfr_message_from_prompts(ctx) - await self.overwrite_channel_add_reaction_perms(chnl.guild, chnl) - emb = self.get_embed_from_message(msg) - reacts: List[Union[discord.PartialEmoji, discord.Emoji, str]] = [x.emoji for x in msg.reactions] - if not emb: - logger.error( - f"RFR: Can't find embed for message id {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}.") - else: - er_id, _, _, _ = self.rfr_database_manager.get_rfr_message(ctx.guild.id, chnl.id, msg.id) - if not er_id: - logger.error( - f"RFR: Can't find rfr message with {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}. DB ER_ID : {er_id}") - else: - rfr_er = self.rfr_database_manager.get_rfr_message_emoji_roles(er_id) - if not rfr_er: - logger.error( - f"RFR: Can't retrieve RFR message (ER_ID: {er_id})'s emoji role combinations.") - else: - combos = "" - for er in rfr_er: - combos += er[1] + ", " + str(er[2]) + "\n" - er_list = await self.parse_emoji_and_role_input_str(ctx, combos, 20) - embed: discord.Embed = discord.Embed(title=emb.title, description=emb.description, - colour=KOALA_GREEN) - embed.set_footer(text=emb.footer) - embed.set_thumbnail(url=emb.thumbnail.url) - emb.set_image(url=emb.image.url) - for e in reacts: - if e not in [x for x, _ in er_list]: - await msg.clear_reaction(e) - for e, r in er_list: - embed.add_field(name=str(e), value=r.mention, inline=False) - if e not in reacts: - await msg.add_reaction(e) - await msg.edit(embed=embed) - await ctx.send("Tried fixing the message, please check that it's fixed.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="addRoles") - async def rfr_add_roles_to_msg(self, ctx: commands.Context): - """ - Adds roles to an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message ID/ - URL, emoji-role combos. Emoji-role combinations are to be given in - \\\n\", \" - \\\n\", \" - format. can be the role ID, name or mention. `emoji` can be a custom emoji from the server, or a standard - unicode emoji. \\\nUser needs admin perms to use. - :param ctx: Context of the command. - :return: - """ - - await ctx.send( - "Okay. This will add roles to an already created react for role message. I'll need some details first " - "though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) - - if not rfr_msg_row: - raise commands.CommandError("Message ID given is not that of a react for role message.") - await ctx.send("Okay, found the message you want to add to.") - remaining_slots = 20 - self.get_number_of_embed_fields(self.get_embed_from_message(msg)) - - if remaining_slots == 0: - await ctx.send( - "Unfortunately due to discord limitations that message cannot have any more reactions. If you want I " - "can create another message in the same channel though. Shall I do that?") - - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send( - "Okay, I'll continue then. The new message will have the same title and description as the " - "old one.") - old_embed = self.get_embed_from_message(msg) - embed: discord.Embed = discord.Embed(title=old_embed.title, description=old_embed.description) - embed.set_thumbnail( - url=koalabot.KOALA_IMAGE_URL) - msg: discord.Message = await channel.send(embed=embed) - msg_id = msg.id - channel = msg.channel - self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, msg_id) - await ctx.send(f"Okay, the new message has ID {msg.id} and is in {msg.channel.mention}.") - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) - else: - await ctx.send("Okay, I'll stop the command then.") - return - - await ctx.send( - "Okay. Can I get the roles and emojis you want added to the message in a list with format: \n," - " \n, \n, \netc. You can get a new line by using SHIFT + ENTER.") - await ctx.send( - f"Please note however that you've only got {remaining_slots} emoji-role combinations you can enter. I'll " - f"therefore only take the first {remaining_slots} you do. I'll wait for 3 minutes.") - - input_role_emojis = (await wait_for_message(self.bot, ctx, 180))[0].content - emoji_role_list = await self.parse_emoji_and_role_input_str(ctx, input_role_emojis, remaining_slots) - rfr_embed = self.get_embed_from_message(msg) - - for emoji_role in emoji_role_list: - discord_emoji = emoji_role[0] - role = emoji_role[1] - - if discord_emoji in [x.name for x in rfr_embed.fields]: - await ctx.send("Found duplicate emoji in the message, I'm not accepting it.") - elif role in [x.value for x in rfr_embed.fields]: - await ctx.send("Found duplicate role in the message, I'm not accepting it.") - else: - if isinstance(discord_emoji, str): - self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], emoji.demojize(discord_emoji), - role.id) - else: - self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], str(discord_emoji), role.id) - rfr_embed.add_field(name=str(discord_emoji), value=role.mention, inline=False) - await msg.add_reaction(discord_emoji) - - if isinstance(discord_emoji, str): - logger.info( - f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " - f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji}.") - else: - logger.info( - f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " - f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji.id}.") - - await msg.edit(embed=rfr_embed) - await ctx.send("Okay, you should see the message with its new emojis now.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @edit_group.command(name="removeRoles") - async def rfr_remove_roles_from_msg(self, ctx: commands.Context): - """ - Removes roles from an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message - ID/URL, emojis/roles to remove. User can specify either the emoji or the role for any emoji-role combination to - remove it, but it needs to be specified in the format below. - \\\n\"/\" - \\\n\"/\" - can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard - unicode emoji. \\\nUser needs admin perms to use. - :param ctx: Context of the command. - :return: - """ - await ctx.send( - "Okay, this will remove roles from an already existing react for role message. I'll need some details first" - " though.") - msg, channel = await self.get_rfr_message_from_prompts(ctx) - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) - - if not rfr_msg_row: - raise commands.CommandError("Message ID given is not that of a react for role message.") - await ctx.send("Okay, found the message you want to remove roles from.") - remaining_slots = self.get_number_of_embed_fields(self.get_embed_from_message(msg)) - - if remaining_slots == 0: - await ctx.send( - "Okay, it looks like you've already gotten rid of all roles from this message. Would you like me to del" - "ete the message too?") - - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send("Okay, deleting that message and removing it from the database.") - self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) - await msg.delete() - await ctx.send("Okay, deleted that react for role message. Have a nice day.") - return - else: - await ctx.send("Okay, I'll stop the command then.") - return - else: - await ctx.send( - "Okay, I'll take the info of the roles/emojis you want to remove now. Just enter it in a message " - "separated by new lines (SHIFT+ENTER). You can enter either the emojis used or the roles' ID/mention/" - "name, for each one.") - - input_emoji_roles = (await wait_for_message(self.bot, ctx, 120))[0].content - wanted_removals = await self.parse_emoji_or_roles_input_str(ctx, input_emoji_roles) - rfr_embed: discord.Embed = self.get_embed_from_message(msg) - rfr_embed_fields = rfr_embed.fields - new_embed = discord.Embed(title=rfr_embed.title, description=rfr_embed.description, - colour=KOALA_GREEN) - new_embed.set_thumbnail( - url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") - new_embed.set_footer(text="ReactForRole") - removed_field_indexes = [] - reactions_to_remove: List[discord.Reaction] = [] - - for row in wanted_removals: - if isinstance(row, discord.Emoji) or isinstance(row, str): - field_index = [x.name for x in rfr_embed_fields].index(str(row)) - if isinstance(row, str): - self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], - emoji_raw=emoji.demojize(row)) - else: - self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], emoji_raw=row) - else: - # row is instance of role - field_index = [x.value for x in rfr_embed_fields].index(row.mention) - self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], role_id=row.id) - - field = rfr_embed_fields[field_index] - removed_field_indexes.append(field_index) - reaction_emoji = await self.get_first_emoji_from_str(ctx, field.name) - reaction: discord.Reaction = [x for x in msg.reactions if str(x.emoji) == str(reaction_emoji)][0] - reactions_to_remove.append(reaction) - - new_embed_fields = [field for field in rfr_embed_fields if - rfr_embed_fields.index(field) not in removed_field_indexes] - - for field in new_embed_fields: - new_embed.add_field(name=field.name, value=field.value, inline=False) - - if self.get_number_of_embed_fields(new_embed) == 0: - await ctx.send("I see you've removed all emoji-role combinations from this react for role message. " - "Would you like to delete this message?") - - if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": - await ctx.send("Okay, I'll delete the message then.") - self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) - await msg.delete() - return - - for reaction in reactions_to_remove: - await reaction.clear() - await msg.edit(embed=new_embed) - await ctx.send("Okay, I've removed those options from the react for role message.") - - @commands.Cog.listener() - @commands.check(koalabot.is_guild_channel) - async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): - """ - Event listener for adding a reaction. Doesn't need message to be in loaded cache. - Gives the user a role if they can get it, if not strips all their roles and removes their reacts. - :param payload: RawReactionActionEvent that happened - :return: - """ - if payload.guild_id is not None: - if not payload.member.bot: - rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, - payload.message_id) - if not rfr_message: - return - - member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, - payload.channel_id, - payload.message_id, payload.user_id) - if not member_role: - # Remove the reaction - guild: discord.Guild = self.bot.get_guild(payload.guild_id) - channel: discord.TextChannel = guild.get_channel(payload.channel_id) - msg: discord.Message = await channel.fetch_message(payload.message_id) - await msg.clear_reaction(payload.emoji) - else: - if self.can_have_rfr_role(member_role[0]): - await member_role[0].add_roles(member_role[1]) - else: - # Remove all rfr roles from member - role_ids = self.rfr_database_manager.get_guild_rfr_roles(payload.guild_id) - roles: List[discord.Role] = [] - for role_id in role_ids: - role = discord.utils.get(member_role[0].guild.roles, id=role_id) - if not role: - continue - roles.append(role) - for role_to_remove in roles: - await member_role[0].remove_roles(role_to_remove) - # Remove members' reaction from all rfr messages in guild - guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(payload.guild_id) - if not guild_rfr_messages: - logger.error( - f"ReactForRole: Guild RFR messages is empty on raw reaction add. Please check" - f" guild ID {payload.guild_id}") - - else: - for guild_rfr_message in guild_rfr_messages: - guild: discord.Guild = member_role[0].guild - channel: discord.TextChannel = guild.get_channel(guild_rfr_message[1]) - msg: discord.Message = await channel.fetch_message(guild_rfr_message[2]) - for x in msg.reactions: - await x.remove(payload.member) - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command("addRequiredRole") - async def rfr_add_guild_required_role(self, ctx: commands.Context, role_str: str): - """ - Adds a role to perms to use rfr functionality in a server, so you can specify that you need, e.g. "@Student" to - be able to use rfr functionality in the server. It's server-wide permissions handling however. By default anyone - can use rfr functionality in the server. User needs to have admin perms to use. - :param ctx: Context of the command - :param role_str: Role ID/name/mention - :return: - """ - try: - role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) - await ctx.send(f"Okay, I'll add {role.name} to the list of roles required for RFR usage on the server.") - self.rfr_database_manager.add_guild_rfr_required_role(ctx.guild.id, role.id) - except (commands.CommandError, commands.BadArgument): - await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command("removeRequiredRole") - async def rfr_remove_guild_required_role(self, ctx: commands.Context, role_str: str): - """ - Removes a role from perms for use of rfr functionality in a server, so you can specify that you need, e.g. - "@Student" to be able to use rfr functionality in the server. It's server-wide permissions handling however. By - default anyone can use rfr functionality in the server. User needs to have admin perms to use. - :param ctx: Context of the command - :param role_str: Role ID/name/mention - :return: - """ - try: - role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) - await ctx.send( - f"Okay, I'll remove {role.name} from the list of roles required for RFR usage on the server.") - self.rfr_database_manager.remove_guild_rfr_required_role(ctx.guild.id, role.id) - except (commands.CommandError, commands.BadArgument): - await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") - - @commands.check(koalabot.is_admin) - @commands.check(rfr_is_enabled) - @react_for_role_group.command("listRequiredRoles") - async def rfr_list_guild_required_roles(self, ctx: commands.Context): - """ - Lists the server-specific role permissions for using rfr functionality. If list is empty, any role can use rfr - functionality. - :param ctx: Context of the command. - :return: - """ - role_ids = self.rfr_database_manager.get_guild_rfr_required_roles(ctx.guild.id) - msg_str = "You will need one of these roles to react to rfr messages on this server:\n" - for role_id in role_ids: - - role: discord.Role = discord.utils.get(ctx.guild.roles, id=role_id) - if not role: - logger.error(f"ReactForRole: Couldn't find role {role_id} in guild {ctx.guild.id}. Please " - f"check.") - else: - msg_str += f"{role.mention}\n" - if msg_str == "You will need one of these roles to react to rfr messages on this server:\n": - msg_str = "Anyone can react to rfr messages on this server." - await ctx.send(msg_str) - - @commands.Cog.listener() - @commands.check(koalabot.is_guild_channel) - async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): - """ - Event listener for removing a reaction. Doesn't need message to be in loaded cache. Removes the role from the - user if they have it, else does nothing. - :param payload: RawReactionActionEvent that happened. - :return: - """ - - if payload.guild_id is not None: - rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, - payload.message_id) - if not rfr_message: - return - member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, - payload.channel_id, - payload.message_id, payload.user_id) - if not member_role or member_role[0].bot: - return - await member_role[0].remove_roles(member_role[1]) - - def can_have_rfr_role(self, member: discord.Member) -> bool: - """ - check for rfr required roles, taking a member as argument - :param member: Member to check rfr perms for - :return: True if member has one of the required roles, or if there are no required roles. False otherwise - """ - required_roles: List[int] = self.rfr_database_manager.get_guild_rfr_required_roles(member.guild.id) - if not required_roles or len(required_roles) == 0: - return True - return any(x in required_roles for x in [y.id for y in member.roles]) - - async def get_rfr_message_from_prompts(self, ctx: commands.Context) -> Tuple[discord.Message, discord.TextChannel]: - """ - Gets an rfr message from prompting user, basically just calls prompt_for_input multiple times and gets value from - database based on input. - :param ctx: Context of the command this function is called in - :return: 2-Tuple of the message (if there is one, else None), and channel (if there is no message, else None). - """ - channel_raw = await self.prompt_for_input(ctx, "Channel name, mention or ID") - channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) - if not channel: - raise commands.CommandError("Invalid channel given.") - msg_id_raw = await self.prompt_for_input(ctx, "react for role message ID") - if (msg_id_raw == ""): - msg_id = None - elif not is_int(msg_id_raw): - msg_id = None - else: - msg_id = int(msg_id_raw) - if not msg_id: - raise commands.CommandError("Invalid Message ID given.") - msg = await channel.fetch_message(msg_id) - if not msg: - raise commands.CommandError("Invalid Message ID given.") - rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) - if not rfr_msg_row: - raise commands.CommandError("Message ID given is not that of a react for role message.") - return msg, channel - - async def get_role_member_info(self, emoji_reacted: discord.PartialEmoji, guild_id: int, - channel_id: int, message_id: int, user_id: int) -> Optional[ - Tuple[discord.Member, discord.Role]]: - """ - Gets the role that should be added/removed to/from a Member on reacting to a known RFR message, and works out - which Member reacted. - :param emoji_reacted: Emoji of the raw reaction payload - :param emoji_role_id: DB key identifying specific RFR message in a Guild - :param guild_id: ID of the guild this event occurred in - :param channel_id: ID of the channel that the message was in - :param message_id: ID of the message that was reacted to - :param user_id: ID of the user who reacted - :return: Optional 2-Tuple (member, role) where member is the Member that reacted, and Role is the role that - should be given/taken away. If a role or member couldn't be found, returns None instead. - """ - - guild: discord.Guild = self.bot.get_guild(guild_id) - member: discord.Member = discord.utils.get(guild.members, id=user_id) - if not member: - return - channel: discord.TextChannel = discord.utils.get(guild.text_channels, id=channel_id) - if not channel: - return - message: discord.Message = await channel.fetch_message(message_id) - if not message: - return - embed: discord.Embed = self.get_embed_from_message(message) - - if emoji_reacted.is_unicode_emoji(): - rep = emoji.emojize(emoji_reacted.name) - if not rep: - rep = emoji.emojize(emoji_reacted.name, use_aliases=True) - - field = await self.get_field_by_emoji(embed, rep) - if not field: - return - role_str: str = field - if not role_str: - return - role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) - if not role: - return - elif emoji_reacted.is_custom_emoji(): - rep = str(emoji_reacted) - field = await self.get_field_by_emoji(embed, rep) - if not field: - return - role_str = field - role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) - else: - logger.error( - f"ReactForRole: Database error, guild {guild_id} has no entry in rfr database for message_id " - f"{message_id} in channel_id {channel_id}. Please check this.") - return - return member, role - - async def parse_emoji_and_role_input_str(self, ctx: commands.Context, input_str: str, remaining_slots: int) -> List[ - Tuple[Union[discord.Emoji, str], discord.Role]]: - """ - Parses input for the "k!rfr edit addRoles" commmand, in the - \n", \n - , " - format. - :param ctx: context of the command that called this - :param input_str: input message content - :param remaining_slots: remaining slots left on the rfr embed referred to - :return: List of Emoji-Role pairs parsed from the input message. - """ - rows = input_str.splitlines() - arr = [] - for row in rows: - emoji_role = row.split(',') - if len(emoji_role) > 2: - raise commands.BadArgument("Too many categories/etc on one line.") - emoji: Union[discord.Emoji, str] = await self.get_first_emoji_from_str(ctx, emoji_role[0].strip()) - if not emoji: - await ctx.send(f"Yeah, didn't find emoji for `{emoji_role[0]}`") - continue - role = await commands.RoleConverter().convert(ctx, emoji_role[1].lstrip().rstrip()) - arr.append((emoji, role)) - if len(arr) == remaining_slots: - return arr - return arr - - async def parse_emoji_or_roles_input_str(self, ctx: commands.Context, input_str: str) -> List[ - Union[discord.Emoji, str, discord.Role]]: - """ - Parses input prompt for the "k!rfr edit removeRoles" command, to get a list of roles and emoji as the output. - Input format is - \n"/\n - /" - can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard - unicode emoji. - :param ctx: Context of the command that called this - :param input_str: Input message content - :return: List of roles & emojis given in the input string. - """ - rows = input_str.splitlines() - arr = [] - for row in rows: - # Try and match it to an raw_emoji first - raw_emoji = await self.get_first_emoji_from_str(ctx, row.strip()) - if not raw_emoji: - role = await commands.RoleConverter().convert(ctx, row.strip()) - if not role: - raise commands.BadArgument("Couldn't convert to a role or emoji.") - else: - arr.append(role) - else: - arr.append(raw_emoji) - return arr - - async def prompt_for_input(self, ctx: commands.Context, input_type: str) -> Union[discord.Attachment, str]: - """ - Prompts a user for input in the form of a message. Has a forced timer of 60 seconds, because it basically just - deals with the rfr specific stuff. Returns whatever was input, or cancels the calling command - :param ctx: Context of the command that calls this - :param input_type: Name of whatever info is needed from a user, just so that the message looks nice/clear - :return: User's response's content - """ - await ctx.send(f"Please enter {input_type} so I can progress further. I'll wait 60 seconds, don't worry.") - msg, channel = await wait_for_message(self.bot, ctx) - if not msg: - await channel.send("Okay, I'll cancel the command.") - return "" - elif len(msg.attachments) > 0: - return msg.attachments[0] - else: - return msg.content - - async def overwrite_channel_add_reaction_perms(self, guild: discord.Guild, channel: discord.TextChannel): - """ - Overwrites a text channel's reaction perms so that nobody can add new reactions to any message sent in the - channel, only the bot, to make sure people don't mess with the system. Relies on roles tending not to be added/ - removed constantly to keep performance satisfactory. - :param guild: Guild that the rfr message is in - :param channel: Channel that the rfr message is in - :return: - """ - # Get the @everyone role. - role: discord.Role = discord.utils.get(guild.roles, id=guild.id) - overwrite: discord.PermissionOverwrite = discord.PermissionOverwrite() - overwrite.update(add_reactions=False) - await channel.set_permissions(role, overwrite=overwrite) - bot_members = [member for member in guild.members if member.bot and member.id == self.bot.user.id] - overwrite.update(add_reactions=True) - for bot_member in bot_members: - await channel.set_permissions(bot_member, overwrite=overwrite) - - async def is_user_alive(self, ctx: commands.Context): - """ - Prompts user for message to check if they're alive. Any message will do. We hope they're alive anyways. - :param ctx: Context of the command that calls this - :return: True if message received, False otherwise. - """ - msg = await wait_for_message(self.bot, ctx, 10) - if not msg[0]: - return False - return True - - def get_embed_from_message(self, msg: discord.Message) -> Optional[discord.Embed]: - """ - Gets the embed from a given message. Yup. That's it. - :param msg: Message to check - :return: Returns the embed if there is one. If there isn't returns None - """ - if not msg: - return None - try: - embed = msg.embeds[0] - if not embed: - return None - return embed - except IndexError: - return None - - def get_number_of_embed_fields(self, embed: discord.Embed) -> int: - """ - Gets the number of fields in an embed. - :param embed: Embed to check - :return: Number of embed fields. - """ - return len(embed.fields) - - async def get_first_emoji_from_str(self, ctx: commands.Context, content: str) -> Optional[ - Union[discord.Emoji, str]]: - """ - Gets the first emoji in a string input, custom or not. Doesn't work with custom emojis the bot doesn't have - access to. - :param ctx: Context of the original command - :param content: Message content - :return: Emoji if there is a valid one. Otherwise None. - """ - # First check for a custom discord emoji in the string - search_result = CUSTOM_EMOJI_REGEXP.search(content) - if not search_result: - # Check for a unicode emoji in the string - search_result = UNICODE_EMOJI_REGEXP.search(content) - if not search_result: - return None - return content - else: - emoji_str = search_result.group().strip() - try: - discord_emoji: discord.Emoji = await commands.EmojiConverter().convert(ctx, emoji_str) - return discord_emoji - except commands.CommandError: - await ctx.send( - "An error occurred when trying to get the emoji. Please contact the bot developers for support.") - return None - except commands.BadArgument: - await ctx.send("Couldn't get the emoji you used - is it from this server or a server I'm in?") - return None - - async def get_field_by_emoji(self, embed: discord.Embed, emoji: Optional[str]): - """ - Get the specific field value of an rfr embed by the string representation of the emoji in the field name - - :param embed: RFR Embed to check - :param emoji: Emoji required - :return: If a field exists such that its name is :emoji:, then that field's value. Else None - """ - if not emoji: - return - else: - fields = embed.fields - field = discord.utils.get(fields, name=emoji) - if not field: - return - return field.value - - -def setup(bot: koalabot) -> None: - """ - Load this cog to the KoalaBot. - :param bot: the bot client for KoalaBot - """ - bot.add_cog(ReactForRole(bot)) +#!/usr/bin/env python + +""" +KoalaBot Reaction Roles Code + +Author: Anan Venkatesh +Commented using reStructuredText (reST) +""" +# Futures + +# Built-in/Generic Imports +from io import BytesIO +from typing import * + +import aiohttp +import discord +import emoji +from discord.ext import commands + +# Own modules +import koalabot +from koala.colours import KOALA_GREEN +from koala.utils import wait_for_message +from koala.db import insert_extension +from .db import ReactForRoleDBManager +from .log import logger +from .utils import CUSTOM_EMOJI_REGEXP, UNICODE_EMOJI_REGEXP + + +def rfr_is_enabled(ctx): + """ + A command used to check if the guild has enabled rfr + e.g. @commands.check(rfr_is_enabled) + :param ctx: The context of the message + :return: True if enabled or test, False otherwise + """ + try: + result = koalabot.check_guild_has_ext(ctx, "ReactForRole") + except PermissionError: + result = False + + return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) + + +class ReactForRole(commands.Cog): + """ + A discord.py cog pertaining to a React for Role system to allow for automation in getting roles. + """ + + def __init__(self, bot: discord.Client): + self.bot = bot + insert_extension("ReactForRole", 0, True, True) + self.rfr_database_manager = ReactForRoleDBManager() + + @commands.check(koalabot.is_guild_channel) + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @commands.group(name="rfr", aliases=["reactForRole", "react_for_role"]) + async def react_for_role_group(self, ctx: commands.Context): + """ + Group of commands for React for Role (rfr) functionality. + :param ctx: Context of the command + :return: + """ + return + + @staticmethod + async def get_image_from_url(ctx: discord.ext.commands.Context, url: str) -> str: + """ + Gets a workable image URL that was sent/handled without a file attachment in disc, but as a raw URL in msg content. Works by sending the image in the same context, then getting that attachment + :param ctx: Context of the original message + :param url: Original raw URL + :return: URL of attachment to use in a method call + """ + + async def file_type_from_hdr(resp: aiohttp.ClientResponse): + """ + Gets a file extension based only on the Content-Type MIME type header of the HTTP GET request sent. + :param resp: Original HTTP response + :return: file ext or empty string if not compatible as discord image + """ + content_type: str = resp.content_type + if content_type == 'image/png': + return "png" + elif content_type == 'image/jpeg': + return "jpg" + elif content_type == 'image/gif': + return "gif" + else: + return None + + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status != 200: + logger.error( + "RFR: HTTP error Access code " + str(response.status) + " when attempting GET on " + url) + raise aiohttp.ClientError( + "HTTP error Access code " + str(response.status) + " when attempting GET on " + url) + image_bytes = await response.read() + data = BytesIO(image_bytes) + ftype: str = await file_type_from_hdr(response) + if not ftype: + logger.error( + "RFR: Couldn't verify image file type from " + url + " due to missing/different Content-Type header") + raise commands.BadArgument("Couldn't get an image from the message you sent.") + msg: discord.Message = await ctx.send(file=discord.File(data, f"thumbnail.{ftype}")) + try: + img = msg.attachments[0].url + await msg.delete() + return img + except Exception as e: + logger.error("RFR " + str(e)) + raise e + + @staticmethod + def attachment_img_content_type(mime: Optional[str]): + if not mime: + return False + else: + return mime.startswith("image/") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command(name="create", aliases=["createMsg", "createMessage"]) + async def rfr_create_message(self, ctx: commands.Context): + """ + Creates a new rfr message in a channel of user's choice. User is prompted for (in this order) + channel ID/name/mention, message title, message description. Default title and description exist, which are + "React for Role" and "Roles below!" respectively. User requires admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send( + "Okay, this will create a new react for role message in a channel of your choice." + "\nNote: The channel you specify will have its permissions edited to make it such that the @ everyone role " + "is unable to add new reactions to messages, they can only reaction with existing ones. Please keep this in" + " mind, or setup another channel entirely for this.") + channel_raw = await self.prompt_for_input(ctx, "channel ID, name or mention") + channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) + if not channel: + await ctx.send("Sorry, you didn't specify a valid channel ID, mention or name. Please restart the command.") + else: + del_msg = await channel.send(f"This should be a thing sent in the right channel.") + await ctx.send( + "Okay, what would you like the title of the react for role message to be? Please enter within 60" + " seconds.") + x = await wait_for_message(self.bot, ctx) + msg: discord.Message = x[0] + if not x[0]: + await ctx.send( + "Okay, didn't receive a title. Do you actually want to continue? Send anything to confirm this.") + if not await self.is_user_alive(ctx): + await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") + await del_msg.delete() + return + else: + title: str = "React for Role" + await ctx.send( + "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr edit" + " commands.") + else: + title: str = msg.content + await ctx.send( + f"Okay, the title of the message will be \"{title}\". What do you want the description to be? " + f"I'll wait 60 seconds, don't worry") + y = await wait_for_message(self.bot, ctx) + msg: discord.Message = y[0] + if not y[0]: + await ctx.send( + "Okay, didn't receive a description. Do you actually want to continue? Send anything to confirm this.") + if not await self.is_user_alive(ctx): + await ctx.send("Okay, didn't receive any confirmation. Cancelling command. Please restart.") + await del_msg.delete() + return + else: + desc: str = "Roles below!" + await ctx.send( + "Okay, I'll just put in a default value for you, you can edit it later by using the k!rfr " + "edit command.") + else: + desc: str = msg.content + await ctx.send(f"Okay, the description of the message will be \"{desc}\".\n Okay, " + f"I'll create the react for role message now.") + embed: discord.Embed = discord.Embed(title=title, description=desc, colour=KOALA_GREEN) + embed.set_footer(text="ReactForRole") + embed.set_thumbnail( + url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") + rfr_msg: discord.Message = await channel.send(embed=embed) + self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, rfr_msg.id) + await self.overwrite_channel_add_reaction_perms(ctx.guild, channel) + await ctx.send( + f"Your react for role message ID is {rfr_msg.id}, it's in {channel.mention}. You can use the other " + "k!rfr subcommands to change the message and add functionality as required.") + await del_msg.delete() + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command(name="delete", aliases=["deleteMsg", "deleteMessage"]) + async def rfr_delete_message(self, ctx: commands.Context): + """ + Deletes an existing rfr message. User is prompted for (in this order) channel ID/name/mention, message ID/URL, + Y/N confirmation. User needs admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send( + "Okay, this will delete an existing react for role message. I'll need some details first though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + await ctx.send("Please confirm that you would indeed like to delete the react for role message.") + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send("Ok") + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) + self.rfr_database_manager.remove_rfr_message_emoji_roles(rfr_msg_row[3]) + self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) + await msg.delete() + await ctx.send("ReactForRole Message deleted") + else: + await ctx.send("Cancelled command.") + + @react_for_role_group.group(name="edit", pass_context=True) + async def edit_group(self, ctx: commands.Context): + return + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="description", aliases=["desc"]) + async def rfr_edit_description(self, ctx: commands.Context): + """ + Edit the description of an existing rfr message. User is prompted for rfr message channel ID/name/mention, + rfr message ID/URL, new description, Y/N confirmation. User needs admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will edit the description of an existing react for role message. I'll need some " + "details first though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed = self.get_embed_from_message(msg) + await ctx.send(f"Your current description is {embed.description}. Please enter your new description.") + desc = await self.prompt_for_input(ctx, "description") + if desc != "": + await ctx.send(f"Your new description would be {desc}. Please confirm that you'd like this change.") + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + embed.description = desc + await msg.edit(embed=embed) + else: + await ctx.send("Okay, cancelling command.") + else: + await ctx.send("Okay, cancelling command.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="title") + async def rfr_edit_title(self, ctx: commands.Context): + """ + Edit the title of an existing rfr message. User is prompted for rfr message channel ID/name/mention, + rfr message ID/URL, new title, Y/N confirmation. User needs admin perms to use. + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will edit the title of an existing react for role message. I'll need some details " + "first though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed = self.get_embed_from_message(msg) + await ctx.send(f"Your current title is {embed.title}. Please enter your new title.") + title = await self.prompt_for_input(ctx, "title") + if title != "": + await ctx.send(f"Your new title would be {title}. Please confirm that you'd like this change.") + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + embed.title = title + await msg.edit(embed=embed) + else: + await ctx.send("Okay, cancelling command.") + else: + await ctx.send("Okay, cancelling command.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="thumbnail", aliases=["image", "picture"]) + async def rfr_edit_thumbnail(self, ctx: commands.Context): + """ + Edit the thumbnail of an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr + message ID/URL, new thumbnail, Y/N confirmation. User needs admin perms + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will edit the thumbnail of a react for role message. I'll need some details first " + "though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed = self.get_embed_from_message(msg) + if not embed: + logger.error( + f"RFR: Can't find embed for message id {msg.id}, channel {channel.id}, guild id {ctx.guild.id}.") + await ctx.send(f"Your current image here is {embed.thumbnail.url}") + image = await self.prompt_for_input(ctx, "image you'd like to use as a thumbnail") + if not image or image == "": + await ctx.send("Okay, cancelling command.") + if isinstance(image, discord.Attachment) and self.attachment_img_content_type(image.content_type): + # correct type + if not image.url: + logger.error(f"Attachment url not found, details : {image}") + raise commands.BadArgument("Couldn't get an image from the message you sent.") + else: + embed.set_thumbnail(url=str(image.url)) + await msg.edit(embed=embed) + await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image. This will error if you " + "delete the message you sent with the image, so make sure you don't.") + elif isinstance(image, str): + # no attachment in message, just a raw URL in content + img_url = await self.get_image_from_url(ctx, image) + embed.set_thumbnail(url=img_url) + await msg.edit(embed=embed) + await ctx.send("Okay, set the thumbnail of the thumbnail to your desired image.") + else: + raise commands.BadArgument("Couldn't get an image from the message you sent.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="inline") + async def rfr_edit_inline(self, ctx: commands.Context): + """ + Edit the inline property of embed fields in rfr embeds. Can edit all rfr messages in a server or a specific one. + User is prompted for whether they'd like inline fields or not, as well as details of the specific message if + that option is selected. User requires admin perms + :param ctx: Context of the command + :return: + """ + await ctx.send("Okay, this will change how your embeds look. By default fields are not inline. However, you can" + " choose to change this for a specific message or all rfr messages on the server. To do this, I'" + "ll need some information though. Can you say if you want a specific message edited, or all mess" + "ages on the server?") + input = await self.prompt_for_input(ctx, "all or specific") + if not isinstance(input, str) or not input: + await ctx.send("Okay, cancelling command") + else: + input_comm = input.lstrip().rstrip().lower() + if input_comm not in ["all", "specific"]: + await ctx.send("Okay, cancelling command.") + elif input_comm == "all": + await ctx.send( + "Okay, do you want all rfr messages in this server to have inline fields or not? Y for yes, " + "N for no.") + change_all = await self.prompt_for_input(ctx, "Y/N") + if not change_all or change_all.rstrip().lstrip().upper() not in ["Y", "N"]: + await ctx.send("Okay, cancelling command") + return + change_all = change_all.rstrip().lstrip().upper() + if change_all not in ["Y", "N"]: + await ctx.send("Invalid input for Y/N. Okay, cancelling command") + return + else: + await ctx.send( + "Keep in mind that this process may take a while if you have a lot of RFR messages on your " + "server.") + # fetch rfr messages + guild: discord.Guild = ctx.guild + text_channels: List[discord.TextChannel] = guild.text_channels + guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(guild.id) + for rfr_message in guild_rfr_messages: + channel: discord.TextChannel = discord.utils.get(text_channels, id=rfr_message[1]) + msg: discord.Message = await channel.fetch_message(id=rfr_message[2]) + embed: discord.Embed = self.get_embed_from_message(msg) + length = self.get_number_of_embed_fields(embed) + for i in range(length): + field = embed.fields[i] + embed.set_field_at(i, name=field.name, value=field.value, inline=change_all == "Y") + await msg.edit(embed=embed) + await ctx.send("Okay, the process should be finished now. Please check.") + elif input_comm.lstrip().rstrip().lower() == "specific": + # try and get specific message + await ctx.send("Okay, I'll need the information about the specific rfr message.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + embed: discord.Embed = self.get_embed_from_message(msg) + if not embed: + await ctx.send("Couldn't get embed, is this an RFR message?") + else: + await ctx.send("Okay, please say Y if you want inline fields, or N if you don't.") + yes_no = await self.prompt_for_input(ctx, "Y/N") + if not yes_no or yes_no.lstrip().rstrip().upper() not in ["Y", "N"]: + await ctx.send("Invalid input, cancelling command.") + return + yes_no = yes_no.lstrip().rstrip().upper() + if yes_no not in ["Y", "N"]: + await ctx.send("Invalid input, cancelling command") + else: + await ctx.send("Okay, I'll change it as requested.") + length = self.get_number_of_embed_fields(embed) + for i in range(length): + field = embed.fields[i] + embed.set_field_at(i, name=field.name, value=field.value, inline=yes_no == "Y") + await msg.edit(embed=embed) + await ctx.send("Okay, should be done. Please check.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="fixEmbed") + async def rfr_fix_embed(self, ctx: commands.Context): + """ + Cosmetic fix method if the bot ever has a moment and doesn't react with the correct emojis/has duplicates. + """ + msg, chnl = await self.get_rfr_message_from_prompts(ctx) + await self.overwrite_channel_add_reaction_perms(chnl.guild, chnl) + emb = self.get_embed_from_message(msg) + reacts: List[Union[discord.PartialEmoji, discord.Emoji, str]] = [x.emoji for x in msg.reactions] + if not emb: + logger.error( + f"RFR: Can't find embed for message id {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}.") + else: + er_id, _, _, _ = self.rfr_database_manager.get_rfr_message(ctx.guild.id, chnl.id, msg.id) + if not er_id: + logger.error( + f"RFR: Can't find rfr message with {msg.id}, channel {chnl.id}, guild id {ctx.guild.id}. DB ER_ID : {er_id}") + else: + rfr_er = self.rfr_database_manager.get_rfr_message_emoji_roles(er_id) + if not rfr_er: + logger.error( + f"RFR: Can't retrieve RFR message (ER_ID: {er_id})'s emoji role combinations.") + else: + combos = "" + for er in rfr_er: + combos += er[1] + ", " + str(er[2]) + "\n" + er_list = await self.parse_emoji_and_role_input_str(ctx, combos, 20) + embed: discord.Embed = discord.Embed(title=emb.title, description=emb.description, + colour=KOALA_GREEN) + embed.set_footer(text=emb.footer) + embed.set_thumbnail(url=emb.thumbnail.url) + emb.set_image(url=emb.image.url) + for e in reacts: + if e not in [x for x, _ in er_list]: + await msg.clear_reaction(e) + for e, r in er_list: + embed.add_field(name=str(e), value=r.mention, inline=False) + if e not in reacts: + await msg.add_reaction(e) + await msg.edit(embed=embed) + await ctx.send("Tried fixing the message, please check that it's fixed.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="addRoles") + async def rfr_add_roles_to_msg(self, ctx: commands.Context): + """ + Adds roles to an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message ID/ + URL, emoji-role combos. Emoji-role combinations are to be given in + \\\n\", \" + \\\n\", \" + format. can be the role ID, name or mention. `emoji` can be a custom emoji from the server, or a standard + unicode emoji. \\\nUser needs admin perms to use. + :param ctx: Context of the command. + :return: + """ + + await ctx.send( + "Okay. This will add roles to an already created react for role message. I'll need some details first " + "though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) + + if not rfr_msg_row: + raise commands.CommandError("Message ID given is not that of a react for role message.") + await ctx.send("Okay, found the message you want to add to.") + remaining_slots = 20 - self.get_number_of_embed_fields(self.get_embed_from_message(msg)) + + if remaining_slots == 0: + await ctx.send( + "Unfortunately due to discord limitations that message cannot have any more reactions. If you want I " + "can create another message in the same channel though. Shall I do that?") + + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send( + "Okay, I'll continue then. The new message will have the same title and description as the " + "old one.") + old_embed = self.get_embed_from_message(msg) + embed: discord.Embed = discord.Embed(title=old_embed.title, description=old_embed.description) + embed.set_thumbnail( + url=koalabot.KOALA_IMAGE_URL) + msg: discord.Message = await channel.send(embed=embed) + msg_id = msg.id + channel = msg.channel + self.rfr_database_manager.add_rfr_message(ctx.guild.id, channel.id, msg_id) + await ctx.send(f"Okay, the new message has ID {msg.id} and is in {msg.channel.mention}.") + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) + else: + await ctx.send("Okay, I'll stop the command then.") + return + + await ctx.send( + "Okay. Can I get the roles and emojis you want added to the message in a list with format: \n," + " \n, \n, \netc. You can get a new line by using SHIFT + ENTER.") + await ctx.send( + f"Please note however that you've only got {remaining_slots} emoji-role combinations you can enter. I'll " + f"therefore only take the first {remaining_slots} you do. I'll wait for 3 minutes.") + + input_role_emojis = (await wait_for_message(self.bot, ctx, 180))[0].content + emoji_role_list = await self.parse_emoji_and_role_input_str(ctx, input_role_emojis, remaining_slots) + rfr_embed = self.get_embed_from_message(msg) + + for emoji_role in emoji_role_list: + discord_emoji = emoji_role[0] + role = emoji_role[1] + + if discord_emoji in [x.name for x in rfr_embed.fields]: + await ctx.send("Found duplicate emoji in the message, I'm not accepting it.") + elif role in [x.value for x in rfr_embed.fields]: + await ctx.send("Found duplicate role in the message, I'm not accepting it.") + else: + if isinstance(discord_emoji, str): + self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], emoji.demojize(discord_emoji), + role.id) + else: + self.rfr_database_manager.add_rfr_message_emoji_role(rfr_msg_row[3], str(discord_emoji), role.id) + rfr_embed.add_field(name=str(discord_emoji), value=role.mention, inline=False) + await msg.add_reaction(discord_emoji) + + if isinstance(discord_emoji, str): + logger.info( + f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " + f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji}.") + else: + logger.info( + f"ReactForRole: Added role ID {str(role.id)} to rfr message (channel, guild) {msg.id} " + f"({str(channel.id)}, {str(ctx.guild.id)}) with emoji {discord_emoji.id}.") + + await msg.edit(embed=rfr_embed) + await ctx.send("Okay, you should see the message with its new emojis now.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @edit_group.command(name="removeRoles") + async def rfr_remove_roles_from_msg(self, ctx: commands.Context): + """ + Removes roles from an existing rfr message. User is prompted for rfr message channel ID/name/mention, rfr message + ID/URL, emojis/roles to remove. User can specify either the emoji or the role for any emoji-role combination to + remove it, but it needs to be specified in the format below. + \\\n\"/\" + \\\n\"/\" + can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard + unicode emoji. \\\nUser needs admin perms to use. + :param ctx: Context of the command. + :return: + """ + await ctx.send( + "Okay, this will remove roles from an already existing react for role message. I'll need some details first" + " though.") + msg, channel = await self.get_rfr_message_from_prompts(ctx) + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg.id) + + if not rfr_msg_row: + raise commands.CommandError("Message ID given is not that of a react for role message.") + await ctx.send("Okay, found the message you want to remove roles from.") + remaining_slots = self.get_number_of_embed_fields(self.get_embed_from_message(msg)) + + if remaining_slots == 0: + await ctx.send( + "Okay, it looks like you've already gotten rid of all roles from this message. Would you like me to del" + "ete the message too?") + + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send("Okay, deleting that message and removing it from the database.") + self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) + await msg.delete() + await ctx.send("Okay, deleted that react for role message. Have a nice day.") + return + else: + await ctx.send("Okay, I'll stop the command then.") + return + else: + await ctx.send( + "Okay, I'll take the info of the roles/emojis you want to remove now. Just enter it in a message " + "separated by new lines (SHIFT+ENTER). You can enter either the emojis used or the roles' ID/mention/" + "name, for each one.") + + input_emoji_roles = (await wait_for_message(self.bot, ctx, 120))[0].content + wanted_removals = await self.parse_emoji_or_roles_input_str(ctx, input_emoji_roles) + rfr_embed: discord.Embed = self.get_embed_from_message(msg) + rfr_embed_fields = rfr_embed.fields + new_embed = discord.Embed(title=rfr_embed.title, description=rfr_embed.description, + colour=KOALA_GREEN) + new_embed.set_thumbnail( + url="https://cdn.discordapp.com/attachments/737280260541907015/752024535985029240/discord1.png") + new_embed.set_footer(text="ReactForRole") + removed_field_indexes = [] + reactions_to_remove: List[discord.Reaction] = [] + + for row in wanted_removals: + if isinstance(row, discord.Emoji) or isinstance(row, str): + field_index = [x.name for x in rfr_embed_fields].index(str(row)) + if isinstance(row, str): + self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], + emoji_raw=emoji.demojize(row)) + else: + self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], emoji_raw=row) + else: + # row is instance of role + field_index = [x.value for x in rfr_embed_fields].index(row.mention) + self.rfr_database_manager.remove_rfr_message_emoji_role(rfr_msg_row[3], role_id=row.id) + + field = rfr_embed_fields[field_index] + removed_field_indexes.append(field_index) + reaction_emoji = await self.get_first_emoji_from_str(ctx, field.name) + reaction: discord.Reaction = [x for x in msg.reactions if str(x.emoji) == str(reaction_emoji)][0] + reactions_to_remove.append(reaction) + + new_embed_fields = [field for field in rfr_embed_fields if + rfr_embed_fields.index(field) not in removed_field_indexes] + + for field in new_embed_fields: + new_embed.add_field(name=field.name, value=field.value, inline=False) + + if self.get_number_of_embed_fields(new_embed) == 0: + await ctx.send("I see you've removed all emoji-role combinations from this react for role message. " + "Would you like to delete this message?") + + if (await self.prompt_for_input(ctx, "Y/N")).lstrip().strip().upper() == "Y": + await ctx.send("Okay, I'll delete the message then.") + self.rfr_database_manager.remove_rfr_message(ctx.guild.id, channel.id, msg.id) + await msg.delete() + return + + for reaction in reactions_to_remove: + await reaction.clear() + await msg.edit(embed=new_embed) + await ctx.send("Okay, I've removed those options from the react for role message.") + + @commands.Cog.listener() + @commands.check(koalabot.is_guild_channel) + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + """ + Event listener for adding a reaction. Doesn't need message to be in loaded cache. + Gives the user a role if they can get it, if not strips all their roles and removes their reacts. + :param payload: RawReactionActionEvent that happened + :return: + """ + if payload.guild_id is not None: + if not payload.member.bot: + rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, + payload.message_id) + if not rfr_message: + return + + member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, + payload.channel_id, + payload.message_id, payload.user_id) + if not member_role: + # Remove the reaction + guild: discord.Guild = self.bot.get_guild(payload.guild_id) + channel: discord.TextChannel = guild.get_channel(payload.channel_id) + msg: discord.Message = await channel.fetch_message(payload.message_id) + await msg.clear_reaction(payload.emoji) + else: + if self.can_have_rfr_role(member_role[0]): + await member_role[0].add_roles(member_role[1]) + else: + # Remove all rfr roles from member + role_ids = self.rfr_database_manager.get_guild_rfr_roles(payload.guild_id) + roles: List[discord.Role] = [] + for role_id in role_ids: + role = discord.utils.get(member_role[0].guild.roles, id=role_id) + if not role: + continue + roles.append(role) + for role_to_remove in roles: + await member_role[0].remove_roles(role_to_remove) + # Remove members' reaction from all rfr messages in guild + guild_rfr_messages = self.rfr_database_manager.get_guild_rfr_messages(payload.guild_id) + if not guild_rfr_messages: + logger.error( + f"ReactForRole: Guild RFR messages is empty on raw reaction add. Please check" + f" guild ID {payload.guild_id}") + + else: + for guild_rfr_message in guild_rfr_messages: + guild: discord.Guild = member_role[0].guild + channel: discord.TextChannel = guild.get_channel(guild_rfr_message[1]) + msg: discord.Message = await channel.fetch_message(guild_rfr_message[2]) + for x in msg.reactions: + await x.remove(payload.member) + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command("addRequiredRole") + async def rfr_add_guild_required_role(self, ctx: commands.Context, role_str: str): + """ + Adds a role to perms to use rfr functionality in a server, so you can specify that you need, e.g. "@Student" to + be able to use rfr functionality in the server. It's server-wide permissions handling however. By default anyone + can use rfr functionality in the server. User needs to have admin perms to use. + :param ctx: Context of the command + :param role_str: Role ID/name/mention + :return: + """ + try: + role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) + await ctx.send(f"Okay, I'll add {role.name} to the list of roles required for RFR usage on the server.") + self.rfr_database_manager.add_guild_rfr_required_role(ctx.guild.id, role.id) + except (commands.CommandError, commands.BadArgument): + await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command("removeRequiredRole") + async def rfr_remove_guild_required_role(self, ctx: commands.Context, role_str: str): + """ + Removes a role from perms for use of rfr functionality in a server, so you can specify that you need, e.g. + "@Student" to be able to use rfr functionality in the server. It's server-wide permissions handling however. By + default anyone can use rfr functionality in the server. User needs to have admin perms to use. + :param ctx: Context of the command + :param role_str: Role ID/name/mention + :return: + """ + try: + role: discord.Role = await commands.RoleConverter().convert(ctx, role_str) + await ctx.send( + f"Okay, I'll remove {role.name} from the list of roles required for RFR usage on the server.") + self.rfr_database_manager.remove_guild_rfr_required_role(ctx.guild.id, role.id) + except (commands.CommandError, commands.BadArgument): + await ctx.send("Found an issue with your provided argument, couldn't get an actual role. Please try again.") + + @commands.check(koalabot.is_admin) + @commands.check(rfr_is_enabled) + @react_for_role_group.command("listRequiredRoles") + async def rfr_list_guild_required_roles(self, ctx: commands.Context): + """ + Lists the server-specific role permissions for using rfr functionality. If list is empty, any role can use rfr + functionality. + :param ctx: Context of the command. + :return: + """ + role_ids = self.rfr_database_manager.get_guild_rfr_required_roles(ctx.guild.id) + msg_str = "You will need one of these roles to react to rfr messages on this server:\n" + for role_id in role_ids: + + role: discord.Role = discord.utils.get(ctx.guild.roles, id=role_id) + if not role: + logger.error(f"ReactForRole: Couldn't find role {role_id} in guild {ctx.guild.id}. Please " + f"check.") + else: + msg_str += f"{role.mention}\n" + if msg_str == "You will need one of these roles to react to rfr messages on this server:\n": + msg_str = "Anyone can react to rfr messages on this server." + await ctx.send(msg_str) + + @commands.Cog.listener() + @commands.check(koalabot.is_guild_channel) + async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): + """ + Event listener for removing a reaction. Doesn't need message to be in loaded cache. Removes the role from the + user if they have it, else does nothing. + :param payload: RawReactionActionEvent that happened. + :return: + """ + + if payload.guild_id is not None: + rfr_message = self.rfr_database_manager.get_rfr_message(payload.guild_id, payload.channel_id, + payload.message_id) + if not rfr_message: + return + member_role = await self.get_role_member_info(payload.emoji, payload.guild_id, + payload.channel_id, + payload.message_id, payload.user_id) + if not member_role or member_role[0].bot: + return + await member_role[0].remove_roles(member_role[1]) + + def can_have_rfr_role(self, member: discord.Member) -> bool: + """ + check for rfr required roles, taking a member as argument + :param member: Member to check rfr perms for + :return: True if member has one of the required roles, or if there are no required roles. False otherwise + """ + required_roles: List[int] = self.rfr_database_manager.get_guild_rfr_required_roles(member.guild.id) + if not required_roles or len(required_roles) == 0: + return True + return any(x in required_roles for x in [y.id for y in member.roles]) + + async def get_rfr_message_from_prompts(self, ctx: commands.Context) -> Tuple[discord.Message, discord.TextChannel]: + """ + Gets an rfr message from prompting user, basically just calls prompt_for_input multiple times and gets value from + database based on input. + :param ctx: Context of the command this function is called in + :return: 2-Tuple of the message (if there is one, else None), and channel (if there is no message, else None). + """ + channel_raw = await self.prompt_for_input(ctx, "Channel name, mention or ID") + channel = None if (channel_raw == "") else await commands.TextChannelConverter().convert(ctx, channel_raw) + if not channel: + raise commands.CommandError("Invalid channel given.") + msg_id_raw = await self.prompt_for_input(ctx, "react for role message ID") + msg_id = None if (msg_id_raw == "") else int(msg_id_raw) + if not msg_id: + raise commands.CommandError("Invalid Message ID given.") + msg = await channel.fetch_message(msg_id) + if not msg: + raise commands.CommandError("Invalid Message ID given.") + rfr_msg_row = self.rfr_database_manager.get_rfr_message(ctx.guild.id, channel.id, msg_id) + if not rfr_msg_row: + raise commands.CommandError("Message ID given is not that of a react for role message.") + return msg, channel + + async def get_role_member_info(self, emoji_reacted: discord.PartialEmoji, guild_id: int, + channel_id: int, message_id: int, user_id: int) -> Optional[ + Tuple[discord.Member, discord.Role]]: + """ + Gets the role that should be added/removed to/from a Member on reacting to a known RFR message, and works out + which Member reacted. + :param emoji_reacted: Emoji of the raw reaction payload + :param emoji_role_id: DB key identifying specific RFR message in a Guild + :param guild_id: ID of the guild this event occurred in + :param channel_id: ID of the channel that the message was in + :param message_id: ID of the message that was reacted to + :param user_id: ID of the user who reacted + :return: Optional 2-Tuple (member, role) where member is the Member that reacted, and Role is the role that + should be given/taken away. If a role or member couldn't be found, returns None instead. + """ + + guild: discord.Guild = self.bot.get_guild(guild_id) + member: discord.Member = discord.utils.get(guild.members, id=user_id) + if not member: + return + channel: discord.TextChannel = discord.utils.get(guild.text_channels, id=channel_id) + if not channel: + return + message: discord.Message = await channel.fetch_message(message_id) + if not message: + return + embed: discord.Embed = self.get_embed_from_message(message) + + if emoji_reacted.is_unicode_emoji(): + rep = emoji.emojize(emoji_reacted.name) + if not rep: + rep = emoji.emojize(emoji_reacted.name, use_aliases=True) + + field = await self.get_field_by_emoji(embed, rep) + if not field: + return + role_str: str = field + if not role_str: + return + role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) + if not role: + return + elif emoji_reacted.is_custom_emoji(): + rep = str(emoji_reacted) + field = await self.get_field_by_emoji(embed, rep) + if not field: + return + role_str = field + role: discord.Role = discord.utils.get(guild.roles, mention=role_str.lstrip().rstrip()) + else: + logger.error( + f"ReactForRole: Database error, guild {guild_id} has no entry in rfr database for message_id " + f"{message_id} in channel_id {channel_id}. Please check this.") + return + return member, role + + async def parse_emoji_and_role_input_str(self, ctx: commands.Context, input_str: str, remaining_slots: int) -> List[ + Tuple[Union[discord.Emoji, str], discord.Role]]: + """ + Parses input for the "k!rfr edit addRoles" commmand, in the + \n", \n + , " + format. + :param ctx: context of the command that called this + :param input_str: input message content + :param remaining_slots: remaining slots left on the rfr embed referred to + :return: List of Emoji-Role pairs parsed from the input message. + """ + rows = input_str.splitlines() + arr = [] + for row in rows: + emoji_role = row.split(',') + if len(emoji_role) > 2: + raise commands.BadArgument("Too many categories/etc on one line.") + emoji: Union[discord.Emoji, str] = await self.get_first_emoji_from_str(ctx, emoji_role[0].strip()) + if not emoji: + await ctx.send(f"Yeah, didn't find emoji for `{emoji_role[0]}`") + continue + role = await commands.RoleConverter().convert(ctx, emoji_role[1].lstrip().rstrip()) + arr.append((emoji, role)) + if len(arr) == remaining_slots: + return arr + return arr + + async def parse_emoji_or_roles_input_str(self, ctx: commands.Context, input_str: str) -> List[ + Union[discord.Emoji, str, discord.Role]]: + """ + Parses input prompt for the "k!rfr edit removeRoles" command, to get a list of roles and emoji as the output. + Input format is + \n"/\n + /" + can be the role ID, name or mention. emoji can be a custom emoji from the server, or a standard + unicode emoji. + :param ctx: Context of the command that called this + :param input_str: Input message content + :return: List of roles & emojis given in the input string. + """ + rows = input_str.splitlines() + arr = [] + for row in rows: + # Try and match it to an raw_emoji first + raw_emoji = await self.get_first_emoji_from_str(ctx, row.strip()) + if not raw_emoji: + role = await commands.RoleConverter().convert(ctx, row.strip()) + if not role: + raise commands.BadArgument("Couldn't convert to a role or emoji.") + else: + arr.append(role) + else: + arr.append(raw_emoji) + return arr + + async def prompt_for_input(self, ctx: commands.Context, input_type: str) -> Union[discord.Attachment, str]: + """ + Prompts a user for input in the form of a message. Has a forced timer of 60 seconds, because it basically just + deals with the rfr specific stuff. Returns whatever was input, or cancels the calling command + :param ctx: Context of the command that calls this + :param input_type: Name of whatever info is needed from a user, just so that the message looks nice/clear + :return: User's response's content + """ + await ctx.send(f"Please enter {input_type} so I can progress further. I'll wait 60 seconds, don't worry.") + msg, channel = await wait_for_message(self.bot, ctx) + if not msg: + await channel.send("Okay, I'll cancel the command.") + return "" + elif len(msg.attachments) > 0: + return msg.attachments[0] + else: + return msg.content + + async def overwrite_channel_add_reaction_perms(self, guild: discord.Guild, channel: discord.TextChannel): + """ + Overwrites a text channel's reaction perms so that nobody can add new reactions to any message sent in the + channel, only the bot, to make sure people don't mess with the system. Relies on roles tending not to be added/ + removed constantly to keep performance satisfactory. + :param guild: Guild that the rfr message is in + :param channel: Channel that the rfr message is in + :return: + """ + # Get the @everyone role. + role: discord.Role = discord.utils.get(guild.roles, id=guild.id) + overwrite: discord.PermissionOverwrite = discord.PermissionOverwrite() + overwrite.update(add_reactions=False) + await channel.set_permissions(role, overwrite=overwrite) + bot_members = [member for member in guild.members if member.bot and member.id == self.bot.user.id] + overwrite.update(add_reactions=True) + for bot_member in bot_members: + await channel.set_permissions(bot_member, overwrite=overwrite) + + async def is_user_alive(self, ctx: commands.Context): + """ + Prompts user for message to check if they're alive. Any message will do. We hope they're alive anyways. + :param ctx: Context of the command that calls this + :return: True if message received, False otherwise. + """ + msg = await wait_for_message(self.bot, ctx, 10) + if not msg[0]: + return False + return True + + def get_embed_from_message(self, msg: discord.Message) -> Optional[discord.Embed]: + """ + Gets the embed from a given message. Yup. That's it. + :param msg: Message to check + :return: Returns the embed if there is one. If there isn't returns None + """ + if not msg: + return None + try: + embed = msg.embeds[0] + if not embed: + return None + return embed + except IndexError: + return None + + def get_number_of_embed_fields(self, embed: discord.Embed) -> int: + """ + Gets the number of fields in an embed. + :param embed: Embed to check + :return: Number of embed fields. + """ + return len(embed.fields) + + async def get_first_emoji_from_str(self, ctx: commands.Context, content: str) -> Optional[ + Union[discord.Emoji, str]]: + """ + Gets the first emoji in a string input, custom or not. Doesn't work with custom emojis the bot doesn't have + access to. + :param ctx: Context of the original command + :param content: Message content + :return: Emoji if there is a valid one. Otherwise None. + """ + # First check for a custom discord emoji in the string + search_result = CUSTOM_EMOJI_REGEXP.search(content) + if not search_result: + # Check for a unicode emoji in the string + search_result = UNICODE_EMOJI_REGEXP.search(content) + if not search_result: + return None + return content + else: + emoji_str = search_result.group().strip() + try: + discord_emoji: discord.Emoji = await commands.EmojiConverter().convert(ctx, emoji_str) + return discord_emoji + except commands.CommandError: + await ctx.send( + "An error occurred when trying to get the emoji. Please contact the bot developers for support.") + return None + except commands.BadArgument: + await ctx.send("Couldn't get the emoji you used - is it from this server or a server I'm in?") + return None + + async def get_field_by_emoji(self, embed: discord.Embed, emoji: Optional[str]): + """ + Get the specific field value of an rfr embed by the string representation of the emoji in the field name + + :param embed: RFR Embed to check + :param emoji: Emoji required + :return: If a field exists such that its name is :emoji:, then that field's value. Else None + """ + if not emoji: + return + else: + fields = embed.fields + field = discord.utils.get(fields, name=emoji) + if not field: + return + return field.value + + +def setup(bot: koalabot) -> None: + """ + Load this cog to the KoalaBot. + :param bot: the bot client for KoalaBot + """ + bot.add_cog(ReactForRole(bot)) From c76c43b9350d8a0b047abcbed7b59bb5f4ae5d44 Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 19 Aug 2022 23:55:00 +0800 Subject: [PATCH 05/15] Revert "Add files via upload" This reverts commit 1f64b6a1bfe0dd4e168ab3c048f76b83e4103112. --- koala/cogs/verification/cog.py | 847 ++++++++++++++++----------------- 1 file changed, 422 insertions(+), 425 deletions(-) diff --git a/koala/cogs/verification/cog.py b/koala/cogs/verification/cog.py index 9f48c3f1..07ef35cc 100644 --- a/koala/cogs/verification/cog.py +++ b/koala/cogs/verification/cog.py @@ -1,425 +1,422 @@ -#!/usr/bin/env python - -""" -Koala Bot Base Cog code and additional base cog functions -Commented using reStructuredText (reST) -""" -# Futures - -# Built-in/Generic Imports -import random -import smtplib -import string -from email.message import EmailMessage - -# Libs -import discord -from discord.ext import commands -from sqlalchemy import select, delete, and_, text - -# Own modules -import koalabot -from koala.db import session_manager, insert_extension -from .env import GMAIL_EMAIL, GMAIL_PASSWORD -from .log import logger -from .models import VerifiedEmails, NonVerifiedEmails, Roles, ToReVerify - - -# Constants - -# Variables - - - - -def verify_is_enabled(ctx): - """ - A command used to check if the guild has enabled verify - e.g. @commands.check(verify_is_enabled) - :param ctx: The context of the message - :return: True if enabled or test, False otherwise - """ - try: - result = koalabot.check_guild_has_ext(ctx, "Verify") - except PermissionError: - result = False - - return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) - - -# FIXME: Move database commands to db.py -class Verification(commands.Cog, name="Verify"): - - def __init__(self, bot): - self.bot = bot - insert_extension("Verify", 0, True, True) - - @staticmethod - def send_email(email, token): - """ - Sends an email through gmails smtp server from the email stored in the environment variables - :param email: target to send an email to - :param token: the token the recipient will need to verify with - :return: - """ - email_server = smtplib.SMTP_SSL('smtp.gmail.com', 465) - email_server.ehlo() - username = GMAIL_EMAIL - password = GMAIL_PASSWORD - - msg = EmailMessage() - msg.set_content(f"Please send the bot the command:\n\n{koalabot.COMMAND_PREFIX}confirm {token}") - msg['Subject'] = "Koalabot Verification" - msg['From'] = username - msg['To'] = email - - email_server.login(username, password) - email_server.send_message(msg) - email_server.quit() - - @commands.Cog.listener() - async def on_ready(self): - await self.assign_roles_on_startup() - - @commands.Cog.listener() - async def on_member_join(self, member): - """ - Assigns necessary roles to users upon joining a server - :param member: the member object who just joined a server - :return: - """ - with session_manager() as session: - potential_emails = session.execute(select(Roles.r_id, Roles.email_suffix) - .filter_by(s_id=member.guild.id)).all() - - if potential_emails: - roles = {} - for role_id, suffix in potential_emails: - role = discord.utils.get(member.guild.roles, id=role_id) - roles[suffix] = role - results = session.execute(select(VerifiedEmails).where( - and_( - VerifiedEmails.email.endswith(suffix), - VerifiedEmails.u_id == member.id - ))).all() - - blacklisted = session.execute(select(ToReVerify) - .filter_by(r_id=role_id, u_id=member.id)).all() - - if results and not blacklisted: - await member.add_roles(role) - message_string = f"""Welcome to {member.guild.name}. This guild has verification enabled. -Please verify one of the following emails to get the appropriate role using `{koalabot.COMMAND_PREFIX}verify your_email@example.com`. -This email is stored so you don't need to verify it multiple times across servers.""" - await member.send( - content=message_string + "\n" + "\n".join([f"`{x}` for `@{y}`" for x, y in roles.items()])) - - @commands.check(koalabot.is_admin) - @commands.command(name="verifyAdd", aliases=["addVerification"]) - @commands.check(verify_is_enabled) - async def enable_verification(self, ctx, suffix=None, role=None): - """ - Set up a role and email pair for KoalaBot to verify users with - :param ctx: context of the discord message - :param suffix: end of the email (e.g. "example.com") - :param role: the role to give users with that email verified (e.g. @students) - :return: - """ - with session_manager() as session: - if not role or not suffix: - raise self.InvalidArgumentError(f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") - - try: - role_id = int(role[3:-1]) - except ValueError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - except TypeError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - - role_valid = discord.utils.get(ctx.guild.roles, id=role_id) - if not role_valid: - raise self.InvalidArgumentError("Please mention a role in this guild") - - exists = session.execute(select(Roles) - .filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)).all() - if exists: - raise self.VerifyError("Verification is already enabled for that role") - - session.add(Roles(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) - session.commit() - - await ctx.send(f"Verification enabled for {role} for emails ending with `{suffix}`") - await self.assign_role_to_guild(ctx.guild, role_valid, suffix) - - @commands.check(koalabot.is_admin) - @commands.command(name="verifyRemove", aliases=["removeVerification"]) - @commands.check(verify_is_enabled) - async def disable_verification(self, ctx, suffix=None, role=None): - """ - Disable an existing verification listener - :param ctx: context of the discord message - :param suffix: end of the email (e.g. "example.com") - :param role: the role paired with the email (e.g. @students) - :return: - """ - with session_manager() as session: - if not role or not suffix: - raise self.InvalidArgumentError( - f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") - - try: - role_id = int(role[3:-1]) - except ValueError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - except TypeError: - raise self.InvalidArgumentError("Please give a role by @mentioning it") - - session.execute(delete(Roles).filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) - session.commit() - - await ctx.send(f"Emails ending with {suffix} no longer give {role}") - - - @commands.check(koalabot.is_dm_channel) - @commands.command(name="verify") - async def verify(self, ctx, email): - """ - Send to KoalaBot in dms to verify an email with our system - :param ctx: the context of the discord message - :param email: the email you want to verify - :return: - """ - with session_manager() as session: - already_verified = session.execute(select(VerifiedEmails).filter_by(email=email)).all() - - in_blacklist = session.execute(select(ToReVerify).filter_by(u_id=ctx.author.id)).all() - - if already_verified and not in_blacklist: - raise self.VerifyError("That email is already verified") - - verification_code = ''.join(random.choice(string.ascii_letters) for _ in range(8)) - session.add(NonVerifiedEmails(u_id=ctx.author.id, email=email, token=verification_code)) - session.commit() - - try: - self.send_email(email, verification_code) - except smtplib.SMTPRecipentsRefused: - raise EmailError("KoalaBot was unable to send an email to the given address.") - await ctx.send("Please verify yourself using the command you have been emailed") - - @commands.check(koalabot.is_dm_channel) - @commands.command(name="unVerify") - async def un_verify(self, ctx, email): - """ - Send to KoalaBot in dms to un-verify an email with our system - :param ctx: the context of the discord message - :param email: the email you want to un-verify - :return: - """ - with session_manager() as session: - entry = session.execute(select(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)).all() - - if not entry: - raise self.VerifyError("You have not verified that email") - - session.execute(delete(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)) - session.commit() - - await self.remove_roles_for_user(ctx.author.id, email) - await ctx.send(f"{email} has been un-verified and relevant roles have been removed") - - @commands.check(koalabot.is_dm_channel) - @commands.command(name="confirm") - async def confirm(self, ctx, token): - """ - Send to KoalaBot in dms to confirm the verification of an email - :param ctx: the context of the discord message - :param token: the token emailed to you to verify with - :return: - """ - with session_manager() as session: - entry = session.execute(select(NonVerifiedEmails).filter_by(token=token)).scalar() - - if not entry: - raise self.InvalidArgumentError("That is not a valid token") - - already_verified = session.execute(select(VerifiedEmails) - .filter_by(u_id=ctx.author.id, email=entry.email)).all() - - if not already_verified: - session.add(VerifiedEmails(u_id=ctx.author.id, email=entry.email)) - - session.execute(delete(NonVerifiedEmails).filter_by(token=token)) - - potential_roles = session.execute(select(Roles.r_id) - .where(text(":email like ('%' || email_suffix)")), - {"email": entry.email}).all() - if potential_roles: - for role_id in potential_roles: - session.execute(delete(ToReVerify).filter_by(r_id=role_id[0], u_id=ctx.author.id)) - - session.commit() - await ctx.send("Your email has been verified, thank you") - await self.assign_roles_for_user(ctx.author.id, entry.email) - - @commands.check(koalabot.is_owner) - @commands.command(name="getEmails") - async def get_emails(self, ctx, user_id: int): - """ - See the emails a user is verified with - :param ctx: the context of the discord message - :param user_id: the id of the user who's emails you want to find - :return: - """ - with session_manager() as session: - results = session.execute(select(VerifiedEmails.email).filter_by(u_id=user_id)).all() - - emails = '\n'.join([x[0] for x in results]) - await ctx.send(f"This user has registered with:\n{emails}") - - @commands.command(name="verifyList", aliases=["checkVerifications"]) - @commands.check(verify_is_enabled) - async def check_verifications(self, ctx): - """ - List the current verification setup for the server - :param ctx: the context of the discord message - :return: - """ - with session_manager() as session: - embed = discord.Embed(title=f"Current verification setup for {ctx.guild.name}") - roles = session.execute(select(Roles.r_id, Roles.email_suffix).filter_by(s_id=ctx.guild.id)).all() - - role_dict = {} - for role_id, suffix in roles: - role = discord.utils.get(ctx.guild.roles, id=role_id) - try: - if suffix in role_dict: - role_dict[suffix].append("@" + role.name) - else: - role_dict[suffix] = ["@" + role.name] - except AttributeError as e: - session.execute(delete(Roles).filter_by(r_id=role_id)) - - session.commit() - for suffix, roles in role_dict.items(): - embed.add_field(name=suffix, value='\n'.join(roles)) - - await ctx.send(embed=embed) - - @commands.check(koalabot.is_admin) - @commands.command(name="reVerify") - @commands.check(verify_is_enabled) - async def re_verify(self, ctx, role: discord.Role): - """ - Removes a role from all users who have it and marks them as needing to re-verify before giving it back - :param ctx: the context of the discord message - :param role: the role to be removed and re-verified (e.g. @students) - :return: - """ - with session_manager() as session: - exists = session.execute(select(Roles).filter_by(s_id=ctx.guild.id, r_id=role.id)).all() - - if not exists: - raise self.VerifyError("Verification is not enabled for that role") - existing_reverify = session.execute(select(ToReVerify.u_id).filter_by(r_id=role.id)).scalars().all() - for member in role.members: - await member.remove_roles(role) - if member.id not in existing_reverify: - session.add(ToReVerify(u_id=member.id, r_id=role.id)) - - session.commit() - await ctx.send("That role has now been removed from all users and they will need to re-verify the associated email.") - - class InvalidArgumentError(Exception): - pass - - class VerifyError(Exception): - pass - - async def assign_roles_on_startup(self): - with session_manager() as session: - results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix)).all() - for g_id, r_id, suffix in results: - try: - guild = self.bot.get_guild(g_id) - role = discord.utils.get(guild.roles, id=r_id) - await self.assign_role_to_guild(guild, role, suffix) - except AttributeError as e: - # bot not in guild - logger.error(e) - - async def assign_roles_for_user(self, user_id, email): - with session_manager() as session: - results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) - .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() - - for g_id, r_id, suffix in results: - blacklisted = session.execute(select(ToReVerify).filter_by(r_id=r_id, u_id=user_id)).all() - - if blacklisted: - continue - try: - guild = self.bot.get_guild(g_id) - role = discord.utils.get(guild.roles, id=r_id) - member = guild.get_member(user_id) - if not member: - member = await guild.fetch_member(user_id) - await member.add_roles(role) - except AttributeError as e: - # bot not in guild - logger.error(e) - except discord.errors.NotFound: - logger.warn(f"user with id {user_id} not found") - - async def remove_roles_for_user(self, user_id, email): - with session_manager() as session: - results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) - .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() - - for g_id, r_id, suffix in results: - try: - guild = self.bot.get_guild(g_id) - role = discord.utils.get(guild.roles, id=r_id) - member = guild.get_member(user_id) - if not member: - member = await guild.fetch_member(user_id) - await member.remove_roles(role) - except AttributeError as e: - # bot not in guild - logger.error(e) - except discord.errors.NotFound: - logger.error(f"user with id {user_id} not found in {guild}") - - async def assign_role_to_guild(self, guild, role, suffix): - with session_manager() as session: - results = session.execute(select(VerifiedEmails.u_id).where(VerifiedEmails.email.endswith(suffix))).all() - - for user_id in results: - try: - blacklisted = session.execute(select(ToReVerify).filter_by(r_id=role.id, u_id=user_id[0])).all() - - if blacklisted: - continue - member = guild.get_member(user_id[0]) - if not member: - member = await guild.fetch_member(user_id[0]) - await member.add_roles(role) - except AttributeError as e: - # bot not in guild - logger.error(e) - except discord.errors.NotFound: - logger.error(f"user with id {user_id} not found in {guild}") - - -def setup(bot: koalabot) -> None: - """ - Load this cog to the koalabot. - :param bot: the bot client for KoalaBot - """ - if GMAIL_EMAIL is None or GMAIL_PASSWORD is None: - logger.warning("Verification not started. API keys not found in environment.") - insert_extension("Verify", 0, False, False) - else: - bot.add_cog(Verification(bot)) - logger.info("Verification is ready.") - +#!/usr/bin/env python + +""" +Koala Bot Base Cog code and additional base cog functions +Commented using reStructuredText (reST) +""" +# Futures + +# Built-in/Generic Imports +import random +import smtplib +import string +from email.message import EmailMessage + +# Libs +import discord +from discord.ext import commands +from sqlalchemy import select, delete, and_, text + +# Own modules +import koalabot +from koala.db import session_manager, insert_extension +from .env import GMAIL_EMAIL, GMAIL_PASSWORD +from .log import logger +from .models import VerifiedEmails, NonVerifiedEmails, Roles, ToReVerify + + +# Constants + +# Variables + + + + +def verify_is_enabled(ctx): + """ + A command used to check if the guild has enabled verify + e.g. @commands.check(verify_is_enabled) + :param ctx: The context of the message + :return: True if enabled or test, False otherwise + """ + try: + result = koalabot.check_guild_has_ext(ctx, "Verify") + except PermissionError: + result = False + + return result or (str(ctx.author) == koalabot.TEST_USER and koalabot.is_dpytest) + + +# FIXME: Move database commands to db.py +class Verification(commands.Cog, name="Verify"): + + def __init__(self, bot): + self.bot = bot + insert_extension("Verify", 0, True, True) + + @staticmethod + def send_email(email, token): + """ + Sends an email through gmails smtp server from the email stored in the environment variables + :param email: target to send an email to + :param token: the token the recipient will need to verify with + :return: + """ + email_server = smtplib.SMTP_SSL('smtp.gmail.com', 465) + email_server.ehlo() + username = GMAIL_EMAIL + password = GMAIL_PASSWORD + + msg = EmailMessage() + msg.set_content(f"Please send the bot the command:\n\n{koalabot.COMMAND_PREFIX}confirm {token}") + msg['Subject'] = "Koalabot Verification" + msg['From'] = username + msg['To'] = email + + email_server.login(username, password) + email_server.send_message(msg) + email_server.quit() + + @commands.Cog.listener() + async def on_ready(self): + await self.assign_roles_on_startup() + + @commands.Cog.listener() + async def on_member_join(self, member): + """ + Assigns necessary roles to users upon joining a server + :param member: the member object who just joined a server + :return: + """ + with session_manager() as session: + potential_emails = session.execute(select(Roles.r_id, Roles.email_suffix) + .filter_by(s_id=member.guild.id)).all() + + if potential_emails: + roles = {} + for role_id, suffix in potential_emails: + role = discord.utils.get(member.guild.roles, id=role_id) + roles[suffix] = role + results = session.execute(select(VerifiedEmails).where( + and_( + VerifiedEmails.email.endswith(suffix), + VerifiedEmails.u_id == member.id + ))).all() + + blacklisted = session.execute(select(ToReVerify) + .filter_by(r_id=role_id, u_id=member.id)).all() + + if results and not blacklisted: + await member.add_roles(role) + message_string = f"""Welcome to {member.guild.name}. This guild has verification enabled. +Please verify one of the following emails to get the appropriate role using `{koalabot.COMMAND_PREFIX}verify your_email@example.com`. +This email is stored so you don't need to verify it multiple times across servers.""" + await member.send( + content=message_string + "\n" + "\n".join([f"`{x}` for `@{y}`" for x, y in roles.items()])) + + @commands.check(koalabot.is_admin) + @commands.command(name="verifyAdd", aliases=["addVerification"]) + @commands.check(verify_is_enabled) + async def enable_verification(self, ctx, suffix=None, role=None): + """ + Set up a role and email pair for KoalaBot to verify users with + :param ctx: context of the discord message + :param suffix: end of the email (e.g. "example.com") + :param role: the role to give users with that email verified (e.g. @students) + :return: + """ + with session_manager() as session: + if not role or not suffix: + raise self.InvalidArgumentError(f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") + + try: + role_id = int(role[3:-1]) + except ValueError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + except TypeError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + + role_valid = discord.utils.get(ctx.guild.roles, id=role_id) + if not role_valid: + raise self.InvalidArgumentError("Please mention a role in this guild") + + exists = session.execute(select(Roles) + .filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)).all() + if exists: + raise self.VerifyError("Verification is already enabled for that role") + + session.add(Roles(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) + session.commit() + + await ctx.send(f"Verification enabled for {role} for emails ending with `{suffix}`") + await self.assign_role_to_guild(ctx.guild, role_valid, suffix) + + @commands.check(koalabot.is_admin) + @commands.command(name="verifyRemove", aliases=["removeVerification"]) + @commands.check(verify_is_enabled) + async def disable_verification(self, ctx, suffix=None, role=None): + """ + Disable an existing verification listener + :param ctx: context of the discord message + :param suffix: end of the email (e.g. "example.com") + :param role: the role paired with the email (e.g. @students) + :return: + """ + with session_manager() as session: + if not role or not suffix: + raise self.InvalidArgumentError( + f"Please provide the correct arguments\n(`{koalabot.COMMAND_PREFIX}enable_verification <@role>`") + + try: + role_id = int(role[3:-1]) + except ValueError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + except TypeError: + raise self.InvalidArgumentError("Please give a role by @mentioning it") + + session.execute(delete(Roles).filter_by(s_id=ctx.guild.id, r_id=role_id, email_suffix=suffix)) + session.commit() + + await ctx.send(f"Emails ending with {suffix} no longer give {role}") + + + @commands.check(koalabot.is_dm_channel) + @commands.command(name="verify") + async def verify(self, ctx, email): + """ + Send to KoalaBot in dms to verify an email with our system + :param ctx: the context of the discord message + :param email: the email you want to verify + :return: + """ + with session_manager() as session: + already_verified = session.execute(select(VerifiedEmails).filter_by(email=email)).all() + + in_blacklist = session.execute(select(ToReVerify).filter_by(u_id=ctx.author.id)).all() + + if already_verified and not in_blacklist: + raise self.VerifyError("That email is already verified") + + verification_code = ''.join(random.choice(string.ascii_letters) for _ in range(8)) + session.add(NonVerifiedEmails(u_id=ctx.author.id, email=email, token=verification_code)) + session.commit() + + self.send_email(email, verification_code) + await ctx.send("Please verify yourself using the command you have been emailed") + + @commands.check(koalabot.is_dm_channel) + @commands.command(name="unVerify") + async def un_verify(self, ctx, email): + """ + Send to KoalaBot in dms to un-verify an email with our system + :param ctx: the context of the discord message + :param email: the email you want to un-verify + :return: + """ + with session_manager() as session: + entry = session.execute(select(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)).all() + + if not entry: + raise self.VerifyError("You have not verified that email") + + session.execute(delete(VerifiedEmails).filter_by(u_id=ctx.author.id, email=email)) + session.commit() + + await self.remove_roles_for_user(ctx.author.id, email) + await ctx.send(f"{email} has been un-verified and relevant roles have been removed") + + @commands.check(koalabot.is_dm_channel) + @commands.command(name="confirm") + async def confirm(self, ctx, token): + """ + Send to KoalaBot in dms to confirm the verification of an email + :param ctx: the context of the discord message + :param token: the token emailed to you to verify with + :return: + """ + with session_manager() as session: + entry = session.execute(select(NonVerifiedEmails).filter_by(token=token)).scalar() + + if not entry: + raise self.InvalidArgumentError("That is not a valid token") + + already_verified = session.execute(select(VerifiedEmails) + .filter_by(u_id=ctx.author.id, email=entry.email)).all() + + if not already_verified: + session.add(VerifiedEmails(u_id=ctx.author.id, email=entry.email)) + + session.execute(delete(NonVerifiedEmails).filter_by(token=token)) + + potential_roles = session.execute(select(Roles.r_id) + .where(text(":email like ('%' || email_suffix)")), + {"email": entry.email}).all() + if potential_roles: + for role_id in potential_roles: + session.execute(delete(ToReVerify).filter_by(r_id=role_id[0], u_id=ctx.author.id)) + + session.commit() + await ctx.send("Your email has been verified, thank you") + await self.assign_roles_for_user(ctx.author.id, entry.email) + + @commands.check(koalabot.is_owner) + @commands.command(name="getEmails") + async def get_emails(self, ctx, user_id: int): + """ + See the emails a user is verified with + :param ctx: the context of the discord message + :param user_id: the id of the user who's emails you want to find + :return: + """ + with session_manager() as session: + results = session.execute(select(VerifiedEmails.email).filter_by(u_id=user_id)).all() + + emails = '\n'.join([x[0] for x in results]) + await ctx.send(f"This user has registered with:\n{emails}") + + @commands.command(name="verifyList", aliases=["checkVerifications"]) + @commands.check(verify_is_enabled) + async def check_verifications(self, ctx): + """ + List the current verification setup for the server + :param ctx: the context of the discord message + :return: + """ + with session_manager() as session: + embed = discord.Embed(title=f"Current verification setup for {ctx.guild.name}") + roles = session.execute(select(Roles.r_id, Roles.email_suffix).filter_by(s_id=ctx.guild.id)).all() + + role_dict = {} + for role_id, suffix in roles: + role = discord.utils.get(ctx.guild.roles, id=role_id) + try: + if suffix in role_dict: + role_dict[suffix].append("@" + role.name) + else: + role_dict[suffix] = ["@" + role.name] + except AttributeError as e: + session.execute(delete(Roles).filter_by(r_id=role_id)) + + session.commit() + for suffix, roles in role_dict.items(): + embed.add_field(name=suffix, value='\n'.join(roles)) + + await ctx.send(embed=embed) + + @commands.check(koalabot.is_admin) + @commands.command(name="reVerify") + @commands.check(verify_is_enabled) + async def re_verify(self, ctx, role: discord.Role): + """ + Removes a role from all users who have it and marks them as needing to re-verify before giving it back + :param ctx: the context of the discord message + :param role: the role to be removed and re-verified (e.g. @students) + :return: + """ + with session_manager() as session: + exists = session.execute(select(Roles).filter_by(s_id=ctx.guild.id, r_id=role.id)).all() + + if not exists: + raise self.VerifyError("Verification is not enabled for that role") + existing_reverify = session.execute(select(ToReVerify.u_id).filter_by(r_id=role.id)).scalars().all() + for member in role.members: + await member.remove_roles(role) + if member.id not in existing_reverify: + session.add(ToReVerify(u_id=member.id, r_id=role.id)) + + session.commit() + await ctx.send("That role has now been removed from all users and they will need to re-verify the associated email.") + + class InvalidArgumentError(Exception): + pass + + class VerifyError(Exception): + pass + + async def assign_roles_on_startup(self): + with session_manager() as session: + results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix)).all() + for g_id, r_id, suffix in results: + try: + guild = self.bot.get_guild(g_id) + role = discord.utils.get(guild.roles, id=r_id) + await self.assign_role_to_guild(guild, role, suffix) + except AttributeError as e: + # bot not in guild + logger.error(e) + + async def assign_roles_for_user(self, user_id, email): + with session_manager() as session: + results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) + .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() + + for g_id, r_id, suffix in results: + blacklisted = session.execute(select(ToReVerify).filter_by(r_id=r_id, u_id=user_id)).all() + + if blacklisted: + continue + try: + guild = self.bot.get_guild(g_id) + role = discord.utils.get(guild.roles, id=r_id) + member = guild.get_member(user_id) + if not member: + member = await guild.fetch_member(user_id) + await member.add_roles(role) + except AttributeError as e: + # bot not in guild + logger.error(e) + except discord.errors.NotFound: + logger.warn(f"user with id {user_id} not found") + + async def remove_roles_for_user(self, user_id, email): + with session_manager() as session: + results = session.execute(select(Roles.s_id, Roles.r_id, Roles.email_suffix) + .where(text(":email like ('%' || email_suffix)")), {"email": email}).all() + + for g_id, r_id, suffix in results: + try: + guild = self.bot.get_guild(g_id) + role = discord.utils.get(guild.roles, id=r_id) + member = guild.get_member(user_id) + if not member: + member = await guild.fetch_member(user_id) + await member.remove_roles(role) + except AttributeError as e: + # bot not in guild + logger.error(e) + except discord.errors.NotFound: + logger.error(f"user with id {user_id} not found in {guild}") + + async def assign_role_to_guild(self, guild, role, suffix): + with session_manager() as session: + results = session.execute(select(VerifiedEmails.u_id).where(VerifiedEmails.email.endswith(suffix))).all() + + for user_id in results: + try: + blacklisted = session.execute(select(ToReVerify).filter_by(r_id=role.id, u_id=user_id[0])).all() + + if blacklisted: + continue + member = guild.get_member(user_id[0]) + if not member: + member = await guild.fetch_member(user_id[0]) + await member.add_roles(role) + except AttributeError as e: + # bot not in guild + logger.error(e) + except discord.errors.NotFound: + logger.error(f"user with id {user_id} not found in {guild}") + + +def setup(bot: koalabot) -> None: + """ + Load this cog to the koalabot. + :param bot: the bot client for KoalaBot + """ + if GMAIL_EMAIL is None or GMAIL_PASSWORD is None: + logger.warning("Verification not started. API keys not found in environment.") + insert_extension("Verify", 0, False, False) + else: + bot.add_cog(Verification(bot)) + logger.info("Verification is ready.") + From 424e02b2ec49ccf4896c902b2c52b701b885424f Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 21 Aug 2022 19:11:28 +0800 Subject: [PATCH 06/15] Fixes to RFR and Verify cogs --- koala/cogs/react_for_role/cog.py | 9 +++++++-- koala/cogs/verification/cog.py | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/koala/cogs/react_for_role/cog.py b/koala/cogs/react_for_role/cog.py index 15944628..80d026f4 100644 --- a/koala/cogs/react_for_role/cog.py +++ b/koala/cogs/react_for_role/cog.py @@ -20,7 +20,7 @@ # Own modules import koalabot from koala.colours import KOALA_GREEN -from koala.utils import wait_for_message +from koala.utils import wait_for_message, is_int from koala.db import insert_extension from .db import ReactForRoleDBManager from .log import logger @@ -783,7 +783,12 @@ async def get_rfr_message_from_prompts(self, ctx: commands.Context) -> Tuple[dis if not channel: raise commands.CommandError("Invalid channel given.") msg_id_raw = await self.prompt_for_input(ctx, "react for role message ID") - msg_id = None if (msg_id_raw == "") else int(msg_id_raw) + if (msg_id_raw == ""): + msg_id = None + elif not is_int(msg_id_raw): + msg_id = None + else: + msg_id = int(msg_id_raw) if not msg_id: raise commands.CommandError("Invalid Message ID given.") msg = await channel.fetch_message(msg_id) diff --git a/koala/cogs/verification/cog.py b/koala/cogs/verification/cog.py index 07ef35cc..f239cd56 100644 --- a/koala/cogs/verification/cog.py +++ b/koala/cogs/verification/cog.py @@ -201,7 +201,10 @@ async def verify(self, ctx, email): session.add(NonVerifiedEmails(u_id=ctx.author.id, email=email, token=verification_code)) session.commit() - self.send_email(email, verification_code) + try: + self.send_email(email, verification_code) + except smtplib.SMTPRecipentsRefused: + raise EmailError("KoalaBot was unable to send an email to the given address.") await ctx.send("Please verify yourself using the command you have been emailed") @commands.check(koalabot.is_dm_channel) From 8692e2a93ec33ff40a8aa60c9e2659fdea45ed78 Mon Sep 17 00:00:00 2001 From: Charles <107768424+DatGuyChucky@users.noreply.github.com> Date: Tue, 23 Aug 2022 01:08:01 +0800 Subject: [PATCH 07/15] Error type change --- koala/cogs/verification/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/koala/cogs/verification/cog.py b/koala/cogs/verification/cog.py index f239cd56..db173fd6 100644 --- a/koala/cogs/verification/cog.py +++ b/koala/cogs/verification/cog.py @@ -204,7 +204,7 @@ async def verify(self, ctx, email): try: self.send_email(email, verification_code) except smtplib.SMTPRecipentsRefused: - raise EmailError("KoalaBot was unable to send an email to the given address.") + raise Error("KoalaBot was unable to send an email to the given address.") await ctx.send("Please verify yourself using the command you have been emailed") @commands.check(koalabot.is_dm_channel) From 894e1a1df0466152e57a0583b9c7b931fdc0c69a Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 23 Aug 2022 01:27:41 +0800 Subject: [PATCH 08/15] Change error instance to 'Exception' Should fix current CI issues --- koala/cogs/verification/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/koala/cogs/verification/cog.py b/koala/cogs/verification/cog.py index f239cd56..2c9ea380 100644 --- a/koala/cogs/verification/cog.py +++ b/koala/cogs/verification/cog.py @@ -204,7 +204,7 @@ async def verify(self, ctx, email): try: self.send_email(email, verification_code) except smtplib.SMTPRecipentsRefused: - raise EmailError("KoalaBot was unable to send an email to the given address.") + raise Exception("KoalaBot was unable to send an email to the given address.") await ctx.send("Please verify yourself using the command you have been emailed") @commands.check(koalabot.is_dm_channel) From ca5374b5b4e48c8cd6e9dda36837d54bc303e909 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 10 Oct 2022 16:33:15 +0100 Subject: [PATCH 09/15] Message slight changes Improvements based on pull request response. As CheckFailure is raised due to a commands.Check() error, it is unclear specifically what can cause it. --- koalabot.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/koalabot.py b/koalabot.py index 8464f7d4..d3501273 100644 --- a/koalabot.py +++ b/koalabot.py @@ -59,7 +59,6 @@ def is_owner(ctx): """ A command used to check if the user of a command is the owner, or the testing bot. - The command also allows Senior Devs of KoalaBot to use owner only commands (as given by Admin role in the dev portal) e.g. @commands.check(koalabot.is_owner) :param ctx: The context of the message :return: True if owner or test, False otherwise @@ -158,24 +157,25 @@ async def on_command_error(ctx, error: Exception): await ctx.send(embed=error_embed(description=error)) if error.__class__ in [commands.CheckFailure]: await ctx.send(embed=error_embed(error_type=str(type(error).__name__), - description=str(error)+"\nThe command could not be executed with the given " - "parameter.")) + description=str(error)+"\nThere was an issue executing the code with the " + "given parameter. Use k!help for more " + "information.")) elif isinstance(error, commands.CommandOnCooldown): await ctx.send(embed=error_embed(description=f"{ctx.author.mention}, this command is still on cooldown for " f"{str(error.retry_after)}s.")) elif isinstance(error, commands.errors.ChannelNotFound): await ctx.send(embed=error_embed(description=f"The channel ID provided is either invalid, or not in this server.")) elif isinstance(error, commands.errors.Forbidden): - await ctx.send(embed=error.embed(description=f"The bot lacks necessary guild permissions, re-add the bot to " - f"this server " - f"with the correct permissions from the README.md")) + await ctx.send(embed=error.embed(description=f"The bot lacks necessary guild permissions, please ensure the bot " + f"has Administrator permissions, and permission to edit all other " + f"roles in the server.")) elif isinstance(error, commands.CommandInvokeError): logger.error("CommandInvokeError(%s), guild_id: %s, message: %s", error.original, guild_id, ctx.message, exc_info=error) await ctx.send(embed=error_embed(description=error.original)) else: logger.error(f"Unexpected Error in guild %s : %s", guild_id, error, exc_info=error) await ctx.send(embed=error_embed( - description=f"An unexpected error occurred, please contact an administrator Timestamp: {time.asctime(time.gmtime(time.time()))}")) + description=f"An unexpected error occurred, please contact an administrator. Timestamp: {time.asctime(time.gmtime(time.time()))}")) raise error From d543de6ba48ded7aeeba95fd1063302447f31c14 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 12 Dec 2022 15:48:14 +0000 Subject: [PATCH 10/15] testing new unit test for code coverage --- tests/test_koalabot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_koalabot.py b/tests/test_koalabot.py index c1b00aba..d121432f 100644 --- a/tests/test_koalabot.py +++ b/tests/test_koalabot.py @@ -94,6 +94,9 @@ def test_not_admin_is_admin(test_ctx): assert not koalabot.is_admin(test_ctx) koalabot.is_dpytest = True +async def test_command_not_found(): + await dpytest.message(koalabot.COMMAND_PREFIX + 'invalid_command') + assert koalabot.on_command_error(commands.CommandNotFound) @mock.patch("koalabot.COGS_PACKAGE", "tests.tests_utils.fake_load_all_cogs") @mock.patch("koalabot.ENABLED_COGS", ['greetings_cog']) From 53d77ab0bfc092ea3588b8446bf32e21df92af29 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 4 Jan 2023 17:05:17 +0000 Subject: [PATCH 11/15] Fixed problematic unit test --- tests/test_koalabot.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_koalabot.py b/tests/test_koalabot.py index d121432f..61ea34ca 100644 --- a/tests/test_koalabot.py +++ b/tests/test_koalabot.py @@ -94,9 +94,10 @@ def test_not_admin_is_admin(test_ctx): assert not koalabot.is_admin(test_ctx) koalabot.is_dpytest = True +@pytest.mark.asyncio async def test_command_not_found(): - await dpytest.message(koalabot.COMMAND_PREFIX + 'invalid_command') - assert koalabot.on_command_error(commands.CommandNotFound) + with pytest.raises(discord.ext.commands.errors.CommandNotFound): + await dpytest.message(koalabot.COMMAND_PREFIX + 'fakecommand') @mock.patch("koalabot.COGS_PACKAGE", "tests.tests_utils.fake_load_all_cogs") @mock.patch("koalabot.ENABLED_COGS", ['greetings_cog']) From 68cc559f4b9b4ef03eeeb96ed627be852496e069 Mon Sep 17 00:00:00 2001 From: DatGuyChucky Date: Sat, 16 Jul 2022 18:05:22 +0800 Subject: [PATCH 12/15] is_owner Overhaul --- koala/env.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/koala/env.py b/koala/env.py index 94c9c373..8377ae8d 100644 --- a/koala/env.py +++ b/koala/env.py @@ -8,6 +8,8 @@ API_PORT = os.environ.get("API_PORT", 8080) +ADMINS = os.getenv('ADMINS') + DB_KEY = os.environ.get('SQLITE_KEY', "2DD29CA851E7B56E4697B0E1F08507293D761A05CE4D1B628663F411A8086D99") ENCRYPTED_DB = (not os.name == 'nt') and eval(os.environ.get('ENCRYPTED', "True")) From 5acb1129649704f8b296037f70ade9718476b767 Mon Sep 17 00:00:00 2001 From: DatGuyChucky Date: Sun, 14 Aug 2022 16:14:36 +0800 Subject: [PATCH 13/15] Verify RFR error message improvement --- koala/cogs/react_for_role/cog.py | 9 +++++++-- koala/cogs/verification/cog.py | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/koala/cogs/react_for_role/cog.py b/koala/cogs/react_for_role/cog.py index 2d0f797e..d20db794 100644 --- a/koala/cogs/react_for_role/cog.py +++ b/koala/cogs/react_for_role/cog.py @@ -20,7 +20,7 @@ # Own modules import koalabot from koala.colours import KOALA_GREEN -from koala.utils import wait_for_message +from koala.utils import wait_for_message, is_int from koala.db import insert_extension from .db import ReactForRoleDBManager from .log import logger @@ -783,7 +783,12 @@ async def get_rfr_message_from_prompts(self, ctx: commands.Context) -> Tuple[dis if not channel: raise commands.CommandError("Invalid channel given.") msg_id_raw = await self.prompt_for_input(ctx, "react for role message ID") - msg_id = None if (msg_id_raw == "") else int(msg_id_raw) + if (msg_id_raw == ""): + msg_id = None + elif not is_int(msg_id_raw): + msg_id = None + else: + msg_id = int(msg_id_raw) if not msg_id: raise commands.CommandError("Invalid Message ID given.") msg = await channel.fetch_message(msg_id) diff --git a/koala/cogs/verification/cog.py b/koala/cogs/verification/cog.py index ae66fa55..2f3c4b88 100644 --- a/koala/cogs/verification/cog.py +++ b/koala/cogs/verification/cog.py @@ -201,7 +201,10 @@ async def verify(self, ctx, email): session.add(NonVerifiedEmails(u_id=ctx.author.id, email=email, token=verification_code)) session.commit() - self.send_email(email, verification_code) + try: + self.send_email(email, verification_code) + except smtplib.SMTPRecipentsRefused: + raise EmailError("KoalaBot was unable to send an email to the given address.") await ctx.send("Please verify yourself using the command you have been emailed") @commands.check(koalabot.is_dm_channel) From 95f228835fc38cfe01db3846cdab72cea35952dd Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 20 Feb 2023 19:20:23 +0000 Subject: [PATCH 14/15] Update to match master changes --- koalabot.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/koalabot.py b/koalabot.py index 211c2b0c..f9ae6d97 100644 --- a/koalabot.py +++ b/koalabot.py @@ -180,9 +180,6 @@ def check_guild_has_ext(ctx, extension_id): raise PermissionError(PERMISSION_ERROR_TEXT) return True - - -@bot.event async def on_command_error(ctx, error: Exception): if ctx.guild is None: guild_id = "UNKNOWN" From 3aee5020056196cb9512d4e0ae62153ec3fb8516 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 4 Apr 2023 19:36:23 +0100 Subject: [PATCH 15/15] Merging extra stuff from master --- koala/env.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/koala/env.py b/koala/env.py index 8377ae8d..183b2244 100644 --- a/koala/env.py +++ b/koala/env.py @@ -4,12 +4,12 @@ load_dotenv() BOT_TOKEN = os.environ['DISCORD_TOKEN'] -BOT_OWNER = os.environ.get('BOT_OWNER') +BOT_OWNER_ENV = os.environ.get('BOT_OWNER') +BOT_OWNER_STR = BOT_OWNER_ENV.split(',') +BOT_OWNER = [int(item) for item in BOT_OWNER_STR] API_PORT = os.environ.get("API_PORT", 8080) -ADMINS = os.getenv('ADMINS') - DB_KEY = os.environ.get('SQLITE_KEY', "2DD29CA851E7B56E4697B0E1F08507293D761A05CE4D1B628663F411A8086D99") ENCRYPTED_DB = (not os.name == 'nt') and eval(os.environ.get('ENCRYPTED', "True"))