async def member_data(self, ctx, member: discord.Member = None): """ Shows active subscriptions of member. Sends result in DMs """ if member is None: member = ctx.author else: # If called member is the same as author allow him to see since it's himself if ctx.author == member: pass # We require admin permissions if you want to see other members data elif not ctx.author.guild_permissions.administrator: await ctx.send(embed=failure_embed( "You need administrator permission to see other members data." )) return table = texttable.Texttable(max_width=90) table.set_cols_dtype(["t", "t"]) table.set_cols_align(["c", "c"]) header = ("Licensed role", "Expiration date") table.add_row(header) all_active = await self.bot.main_db.get_member_data( ctx.guild.id, member.id) if not all_active: await ctx.send(embed=failure_embed("Nothing to show.")) return for entry in all_active: # Entry is in form ("license_id", "expiration_date) try: role = ctx.guild.get_role(int(entry[0])) table.add_row((role.name, entry[1])) except (ValueError, AttributeError): # Just in case if error in case role is None (deleted from guild) just show IDs from database table.add_row(entry) local_time = datetime.now() title = ( f"Server local time: {local_time}\n\n" f"{member.name} active subscriptions in guild '{ctx.guild.name}':\n\n" ) await ctx.send(embed=info_embed("Sent in Dms!", ctx.me), delete_after=5) await Paginator.paginate(self.bot, ctx.author, ctx.author, table.draw(), title=title, prefix="```DNS\n")
async def giveaway(self, ctx, duration_minutes: positive_integer, channel: discord.TextChannel): if duration_minutes > 1440: await ctx.send(embed=failure_embed("Maximum duration is 24h!"), delete_after=5) return description = "React to this message to enter the giveaway and a chance to win license!" event_title = "Giveaway!" emoji = "🎉" message = await channel.send(embed=info_embed(description, ctx.me, title=event_title)) await message.add_reaction(emoji) await asyncio.sleep(duration_minutes*60) try: done_message = await channel.fetch_message(message.id) except NotFound: logger.info(f"Event message deleted! Event canceled! Guild:{ctx.guild.id} {ctx.guild.name}, " f"channel: {channel.id} {channel.name}") return for reaction in done_message.reactions: if str(reaction.emoji) == emoji: choices = [] async for user in reaction.users(): if not user.bot: choices.append(user) if choices: winner = random.choice(choices) edit_description = f"Giveaway has finished,\n{winner.mention} has won the raffle!" await message.edit(embed=info_embed(edit_description, ctx.me, title=event_title)) await channel.send(f"{winner.mention} has won the raffle.", delete_after=10) else: edit_description = "Giveaway has finished, no one reacted :(" await message.edit(embed=info_embed(edit_description, ctx.me, title=event_title)) break
async def random_license(self, ctx, number: int = 10): """ Shows random guild licenses in DM. If number is not passed default value is 10. Maximum licenses to show is 100. Sends results in DM to the user who invoked the command. """ maximum_number = self.bot.config.get_maximum_unused_guild_licences() if number > maximum_number: await ctx.send(embed=failure_embed( f"Number can't be larger than {maximum_number}!")) return to_show = await self.bot.main_db.get_random_licenses( ctx.guild.id, number) if not to_show: await ctx.send(embed=failure_embed("No licenses saved in db.")) return table = texttable.Texttable(max_width=90) table.set_cols_dtype(["t", "t", "t"]) table.set_cols_align(["c", "c", "c"]) header = ("License", "Role", "Duration (h)") table.add_row(header) for entry in to_show: # Entry is in form ('I0QSZeyPJTy3H8tNsmUihKsn8JH48y', '617484493296631839', 720) try: role = ctx.guild.get_role(int(entry[1])) table.add_row((entry[0], role.name, entry[2])) except (ValueError, AttributeError): # Just in case if error in case role is None (deleted from guild) just show IDs from database table.add_row(entry) title = f"Showing {number} random licenses from guild '{ctx.guild.name}':\n\n" await ctx.send(embed=success_embed("Sent to DM!", ctx.me), delete_after=5) await Paginator.paginate(self.bot, ctx.author, ctx.author, table.draw(), title=title)
async def valid(self, ctx, license): """ Checks if passed license is valid """ if await self.bot.main_db.is_valid_license(license, ctx.guild.id): await ctx.send(embed=success_embed("License is valid", ctx.me)) else: await ctx.send(embed=failure_embed(f"License is not valid."))
async def streaming(self, ctx, name, url): """ Discord py currently only supports twitch urls. """ if "//www.twitch.tv" not in url: await ctx.send(embed=failure_embed("Only twitch urls supported!")) return await self.bot.change_presence( activity=discord.Streaming(name=name, url=url)) logger.info(f"Successfully set presence to **Streaming {name}**.")
async def prefix(self, ctx, *, prefix): """ Changes guild prefix. Maximum prefix size is 5 characters. """ if ctx.prefix == prefix: await ctx.send( embed=failure_embed(f"Already using prefix **{prefix}**")) return try: await self.bot.main_db.change_guild_prefix(ctx.guild.id, prefix) except IntegrityError: await ctx.send(embed=failure_embed( "Prefix is too long! Maximum of 5 characters please.")) return await ctx.send(embed=success_embed( f"Successfully changed prefix to **{prefix}**", ctx.me))
async def delete_license(self, ctx, license): """ Deletes specified stored license. """ if await self.bot.main_db.is_valid_license(license, ctx.guild.id): await self.bot.main_db.delete_license(license) await ctx.send(embed=success_embed("License deleted.", ctx.me)) logger.info( f"{ctx.author} is deleting license {license} from guild {ctx.guild}" ) else: await ctx.send(embed=failure_embed("License not valid."))
async def licenses(self, ctx, license_role: discord.Role = None): """ Shows all licenses for a role in DM. Shows licenses linked to license_role and your guild. If license_role is not passed then default guild role is used. Sends results in DM to the user who invoked the command. """ num = self.bot.config.get_maximum_unused_guild_licences() guild_id = ctx.guild.id if license_role is None: # If license role is not passed just use the guild default license role # We load it from database licensed_role_id = await self.bot.main_db.get_default_guild_license_role_id( guild_id) license_role = ctx.guild.get_role(licensed_role_id) if license_role is None: await self.handle_missing_default_role(ctx, licensed_role_id) return to_show = await self.bot.main_db.get_guild_licenses( num, guild_id, licensed_role_id) else: to_show = await self.bot.main_db.get_guild_licenses( num, guild_id, license_role.id) if len(to_show) == 0: await ctx.send( embed=failure_embed("No available licenses for that role.")) return table = texttable.Texttable(max_width=60) table.set_cols_dtype(["t", "t"]) table.set_cols_align(["c", "c"]) header = ("License", "Duration(h)") table.add_row(header) for tple in to_show: table.add_row((tple[0], tple[1])) dm_title = f"Showing {len(to_show)} licenses for role '{license_role.name}' in guild '{ctx.guild.name}':\n\n" await ctx.send(embed=success_embed("Sent to DM!", ctx.me), delete_after=5) await Paginator.paginate(self.bot, ctx.author, ctx.author, table.draw(), title=dm_title)
async def default_role(self, ctx, role: discord.Role): """ Changes default guild license role. When creating new license, and role is not passed, this is the default role the license will use. Role tied to license is the role that the member will get when he redeems it. """ # Check if the role is manageable by bot if not ctx.me.top_role > role: await ctx.send(embed=failure_embed( "I can only manage roles **below** me in hierarchy.")) return await self.bot.main_db.change_default_guild_role(ctx.guild.id, role.id) await ctx.send( embed=success_embed(f"{role.mention} set as default!", ctx.me))
async def revoke_all(self, ctx, member: discord.Member): """ Revoke ALL active subscriptions from member. Removes both the database entry and the role from a member. """ member_data = await self.bot.main_db.get_member_data( ctx.guild.id, member.id) count = 0 for tple in member_data: role_id = int(tple[0]) role = ctx.guild.get_role(role_id) if role is None: logger.info( f"'revoke_all' called in guild {ctx.guild} and role that's loaded from database with " f"ID:{role_id} cannot be removed from {member} because it doesn't exist in guild anymore! " f"Continuing to removal from database.") await self.bot.main_db.delete_licensed_member( member.id, role_id) count += 1 else: try: # First remove the role from member because this can fail in case of changed role hierarchy. await member.remove_roles(role) await self.bot.main_db.delete_licensed_member( member.id, role_id) count += 1 except Forbidden as e: msg = ( f"Can't remove {role.mention} from {member.mention}, no permissions to manage that role as " f" I can only manage role below me in hierarchy. This probably means that {role.mention} " f"was moved up in the hierarchy **after** it was registered in my system " f"(or mine was moved down).\n" f"{e}") await ctx.send(embed=failure_embed(msg)) if count: msg = f"Successfully revoked {count} subscriptions from {member.mention}!" await ctx.send(embed=success_embed(msg, ctx.me)) logger.info( f"{ctx.author} has revoked all subscription for member {member} in guild {ctx.guild}" ) else: msg = f"Couldn't revoke even a single subscription from member {member.mention}!" await ctx.send(embed=warning_embed(msg))
async def guild_diagnostic(self, ctx, guild_id: int): """ A shortened version of guild_info command without any checks and including additional data from the guild object. minus DRY :( """ guild = self.bot.get_guild(guild_id) if guild is None: await ctx.send( embed=failure_embed("Guild ID not found in loaded guilds.")) return prefix, role_id, expiration = await self.bot.main_db.get_guild_info( guild_id) stored_license_count = await self.bot.main_db.get_guild_license_total_count( guild_id) active_license_count = await self.bot.main_db.get_guild_licensed_roles_total_count( guild_id) if role_id is None: default_license_role = "**Not set!**" else: default_license_role = guild.get_role(int(role_id)) msg = (f"Database guild info:\n" f"Prefix: **{prefix}**\n" f"Default license role: {default_license_role}\n" f"Default license expiration time: **{expiration}h**\n" f"Stored licenses: **{stored_license_count}**\n" f"Active role subscriptions: **{active_license_count}**\n\n" f"Guild info:\n" f"Name: **{guild.name}**\n" f"Description: **{guild.description}**\n" f"Owner ID: **{guild.owner_id}**\n" f"Member count: **{guild.member_count}**\n" f"Role count: **{len(guild.roles)}**\n" f"Verification level: **{guild.verification_level}**\n" f"Premium tier: **{guild.premium_tier}**\n" f"System channel: **{guild.system_channel.id}**\n" f"Region: **{guild.region}**\n" f"Unavailable: **{guild.unavailable}**\n" f"Created date: **{guild.created_at}**\n" f"Features: **{guild.features}**") await ctx.send(embed=success_embed(msg, ctx.me))
async def developer_bypass(self, ctx): """ Developers can bypass guild permissions/ cooldowns etc. Re-invokes the command. :param ctx: ctx to re-invoke the command from (if the author is bot developer) :return: Bool if developer or not. """ # Developers can bypass guild permissions if ctx.message.author.id in self.bot.config.get_developers().values(): # reinvoke() bypasses error handlers so we surround it with try/catch and just # send errors to ctx try: await ctx.reinvoke() except Exception as e: await ctx.send(embed=failure_embed(str(e))) return True else: return False
async def handle_missing_default_role(self, ctx, missing_role_id: int): """ Guilds have a default license role that will be used if no role argument is passed when generating licenses. But it can happen that that role gets deleted while it's still in database (similar problem as in check_all_active_licenses) :param missing_role_id: role that is in db but is missing in guild TODO: on startup/reconnect check if default role from db is valid """ msg = ( f"Trying to use role with ID {missing_role_id} that was set " f"as default role for guild {ctx.guild.name} but cannot find it " f"anymore in the list of roles!\n\n" f"It's saved in the database but it looks like it was deleted from the guild.\n" f"Please update it.") await ctx.send(embed=failure_embed(msg))
async def guild_info(self, ctx): """ Shows database data for the guild. """ prefix, role_id, expiration = await self.bot.main_db.get_guild_info( ctx.guild.id) stored_license_count = await self.bot.main_db.get_guild_license_total_count( ctx.guild.id) active_license_count = await self.bot.main_db.get_guild_licensed_roles_total_count( ctx.guild.id) # If the bot just joined the guild it can happen that the default license role is not set. if role_id is not None: default_license_role = discord.utils.get(ctx.guild.roles, id=int(role_id)) # In case it is set in db but was deleted from the guild. # This is needed in case the bot was offline and role was deleted # because on_guild_role_delete will not fire (we delete it from db in that event if that deleted role # is set as default guild role). # TODO: ABOVE EVENT if default_license_role is None: default_license_role = role_id log = f"Can't find default license role {role_id} from guild {ctx.guild.name},{ctx.guild.id}" msg = ( f"Can't find default role {role_id} in this guild!\n" f"It's saved in the database but it looks like it was deleted from the guild.\n" f"Please update it.") logger.critical(log) await ctx.send(embed=failure_embed(msg)) else: default_license_role = default_license_role.mention else: default_license_role = "**Not set!**" msg = (f"Database guild info:\n" f"Prefix: **{prefix}**\n" f"Default license role: {default_license_role}\n" f"Default license expiration time: **{expiration}h**\n\n" f"Stored licenses: **{stored_license_count}**\n" f"Active role subscriptions: **{active_license_count}**") await ctx.send(embed=success_embed(msg, ctx.me))
async def revoke(self, ctx, member: discord.Member, role: discord.Role): """ Revoke active subscription from member. Removes both the database entry and the role from a member. """ try: # DRY await self.bot.main_db.get_member_license_expiration_date( member.id, role.id) except DatabaseMissingData: msg = f"{member.mention} doesn't have a subscription for {role.mention} saved in the database!" await ctx.send(embed=failure_embed(msg)) return # First remove the role from member because this can fail in case of changed role hierarchy. await member.remove_roles(role) await self.bot.main_db.delete_licensed_member(member.id, role.id) msg = f"Successfully revoked subscription for {role.mention} from {member.mention}" await ctx.send(embed=success_embed(msg, ctx.me)) logger.info( f"{ctx.author} is revoking subscription for role {role} from member {member} in guild {ctx.guild}" )
async def on_command_error(self, ctx, error): # If command has local error handler, return if hasattr(ctx.command, "on_error"): return # Get the original exception error = getattr(error, "original", error) if isinstance(error, commands.CommandNotFound): await ctx.send(embed=failure_embed("Command not found.")) return if isinstance(error, commands.BotMissingPermissions): """ Note that this is only for checks of the command , specifically for bot_has_permissions example @commands.bot_has_permissions(administrator=True) It will not work for example if in command role.edit is called but bot doesn't have manage role permission. In that case a simple "Forbidden" will be raised. """ missing = [perm.replace("_", " ").replace("guild", "server").title() for perm in error.missing_perms] if len(missing) > 2: fmt = "{}, and {}".format("**, **".join(missing[:-1]), missing[-1]) else: fmt = " and ".join(missing) _message = f"I need the **{fmt}** permission(s) to run this command." await ctx.send(embed=failure_embed(_message)) return if isinstance(error, commands.DisabledCommand): await ctx.send(embed=failure_embed("This command has been disabled.")) return if isinstance(error, commands.CommandOnCooldown): # Cooldowns are ignored for developers if not await self.developer_bypass(ctx): msg = f"This command is on cooldown, please retry in {math.ceil(error.retry_after)}s." await ctx.send(embed=failure_embed(msg)) return if isinstance(error, commands.MissingPermissions): """ Note that this is only for checks of the command , example @commands.has_permissions(administrator=True) MissingPermissions is raised if check for permissions of the member who invoked the command has failed. """ # Developers can bypass guild permissions if not await self.developer_bypass(ctx): missing = [perm.replace("_", " ").replace("guild", "server").title() for perm in error.missing_perms] if len(missing) > 2: fmt = "{}, and {}".format("**, **".join(missing[:-1]), missing[-1]) else: fmt = " and ".join(missing) _message = f"You need the **{fmt}** permission(s) to use this command." await ctx.send(embed=failure_embed(_message)) return if isinstance(error, commands.UserInputError): await ctx.send(embed=failure_embed(f"Invalid command input: {error}")) return if isinstance(error, commands.NoPrivateMessage): try: await ctx.author.send(embed=failure_embed("This command cannot be used in direct messages.")) except Forbidden: pass return if isinstance(error, commands.CheckFailure): await ctx.send(embed=failure_embed("You do not have permission to use this command.")) return if isinstance(error, Forbidden): # 403 FORBIDDEN (error code: 50013): Missing Permissions if error.code == 50013: msg = (f"{error}.\n" f"Check role hierarchy - I can only manage roles below me.") try: await ctx.send(embed=failure_embed(msg)) except Forbidden: # Forbidden can also mean no permissions to send to that channel # Ignore so we don't get useless errors return # 403 FORBIDDEN (error code: 50007): Cannot send messages to this user. elif error.code == 50007: msg = (f"{error}.\n" f"Hint: Disabled DMs?") await ctx.send(embed=failure_embed(msg)) else: await ctx.send(embed=failure_embed(f"{error}.")) return if isinstance(error, RoleNotFound): await ctx.send(embed=failure_embed(error.message)) return if isinstance(error, DefaultGuildRoleNotSet): new_msg = error.message.replace("{prefix}", ctx.prefix) await ctx.send(embed=failure_embed(f"Trying to use default guild license but: {new_msg}")) return if isinstance(error, DatabaseMissingData): await ctx.send(embed=failure_embed(f"Database error: {error.message}")) await self.log_traceback(ctx, error) return if isinstance(error, TimeoutError): await ctx.send(embed=failure_embed("You took too long to reply."), delete_after=5) return await self.log_traceback(ctx, error) msg = (f"Ignoring exception **{error.__class__.__name__}** that happened while processing command " f"**{ctx.command}**:\n{error}") await ctx.send(embed=failure_embed(msg))
async def activate_license(self, ctx, license, member): """ :param ctx: invoked context :param license: license to add :param member: who to give role to """ guild = ctx.guild if await self.bot.main_db.is_valid_license(license, guild.id): # Adding role to the member requires that role object # First we get the role linked to the license role_id = await self.bot.main_db.get_license_role_id(license) role = ctx.guild.get_role(role_id) if role is None: log_error_msg = ( f"Can't find role {role_id} in guild {guild.id} '{guild.name}' " f"from license: '{license}' member to give the role to: {member.id} '{member.name}'" "\n\nProceeding to delete this invalid license from database!" ) logger.critical(log_error_msg) msg = ( "Well this is awkward...\n\n" "The role that was supposed to be given out by this license has been deleted from this guild!" f"\n\nError message:\n\n{log_error_msg}") await ctx.send(embed=failure_embed(msg)) await self.bot.main_db.delete_license(license) return # Now before doing anything check if member already has the role # Beside for logic (why redeem already existing subscription?) if we don't check this we will get # sqlite3.IntegrityError: # UNIQUE constraint failed:LICENSED_MEMBERS.MEMBER_ID,LICENSED_MEMBERS.LICENSED_ROLE_ID # when adding new licensed member to table LICENSED_MEMBERS if member already has the role (because in that # table the member id and role id is unique aka can only have uniques roles tied to member id) if role in member.roles: # We notify user that he already has the role, we also show him the expiration date try: expiration_date = await self.bot.main_db.get_member_license_expiration_date( member.id, role_id) except DatabaseMissingData as e: # TODO print role name instead of ID (from e) msg = e.message msg += ( f"\nThe bot did not register {member.mention} in the database with that role but somehow they have it." "\nThis probably means that they were manually assigned this role without using the bot license system." "\nHave someone remove the role from them and call this command again." ) await ctx.send(embed=failure_embed(msg)) await ctx.message.delete() return remaining_time = get_remaining_time(expiration_date) msg = ( f"{member.mention} already has an active subscription for the {role.mention} role!" f"\nIt's valid for another {remaining_time}") await ctx.send(embed=warning_embed(msg)) await ctx.message.delete() return # We add the role to the member, we do this before adding/removing stuff from db # just in case the bot doesn't have perms and throws exception (we already # checked for bot_has_permissions(manage_roles=True) but it can happen that bot has # that permission and check is passed but it's still forbidden to alter role for the # member because of it's role hierarchy.) -> will raise Forbidden and be caught by cmd error handler await member.add_roles(role, reason="Redeemed license.") # We add entry to db table LICENSED_MEMBERS (which we will checked periodically for expiration) # First we get the license duration so we can calculate expiration date license_duration = await self.bot.main_db.get_license_duration_hours( license) expiration_date = construct_expiration_date(license_duration) # In case where you successfully redeemed the role and it's still in database(not expired) # BUT someone manually removed the role, in that case when you try to redeem a valid license # for the said role you will get IntegrityError because LICENSED_ROLE_ID and MEMBER_ID have to # be unique (and the entry still exists in database). # Even when caught by remove role event leave this try: await self.bot.main_db.add_new_licensed_member( member.id, guild.id, expiration_date, role_id) except IntegrityError: # We remove the database entry because when role was remove the bot was # probably offline and couldn't register the role remove event await self.bot.main_db.delete_licensed_member( member.id, role_id) await self.bot.main_db.add_new_licensed_member( member.id, guild.id, expiration_date, role_id) msg = ( f"Someone removed the role manually from {member.mention} but no worries,\n" "since the license is valid we're just gonna reactivate it :)" ) await ctx.send(embed=info_embed(msg, ctx.me)) # Remove guild license from database, so it can't be redeemed again await self.bot.main_db.delete_license(license) # Send message notifying user msg = f"License valid - adding role {role.mention} to {member.mention} in duration of {license_duration}h" await ctx.send(embed=success_embed(msg, ctx.me)) else: await ctx.send(embed=failure_embed( "The license key you entered is invalid/deactivated."))
async def generate(self, ctx, num: positive_integer = 3, license_role: discord.Role = None, *, license_duration: license_duration = None): """ Generates new guild licenses. Max licenses to generate at once is 25. All Arguments are optional, if not passed default guild values are used. Arguments are stacked, meaning you can't pass 'license_duration' without the first 2 arguments. On the other hand you can pass only 'num'. Example usages: generate generate 10 generate 5 @role generate 7 @role 1w License duration is either a number representing hours or a string consisting of words in format: each word has to contain [integer][format] , entries are separated by space. Formats are: years y months m weeks w days d hours h License duration examples: 20 2y 5months 1m 3d 12h 1w 2m 1w 1week 1week 12hours 5d ... """ if num > 25: await ctx.send(embed=failure_embed( "Maximum number of licenses to generate at once is 25.")) return # Check if the role is manageable by bot # Needed since bot isn't doing anything with the role, so no exception will occur. if license_role is not None and not ctx.me.top_role > license_role: await ctx.send(embed=failure_embed( "I can only manage roles **below** me in hierarchy.")) return guild_id = ctx.guild.id # Maximum number of unused licenses max_licenses_per_guild = self.bot.config.get_maximum_unused_guild_licences( ) guild_licences_count = await self.bot.main_db.get_guild_license_total_count( guild_id) if guild_licences_count == max_licenses_per_guild: msg = f"You have reached maximum number of unused licenses per guild: {max_licenses_per_guild}!" await ctx.send(embed=warning_embed(msg)) return if guild_licences_count + num > max_licenses_per_guild: msg = ( f"I can't generate since you will exceed the limit of {max_licenses_per_guild} licenses!\n" f"Remaining licenses to generate: {max_licenses_per_guild-guild_licences_count}." ) await ctx.send(embed=failure_embed(msg)) return if license_duration is None: license_duration = await self.bot.main_db.get_default_guild_license_duration_hours( guild_id) if license_role is None: licensed_role_id = await self.bot.main_db.get_default_guild_license_role_id( guild_id) license_role = ctx.guild.get_role(licensed_role_id) if license_role is None: await self.handle_missing_default_role(ctx, licensed_role_id) return generated = await self.bot.main_db.generate_guild_licenses( num, guild_id, licensed_role_id, license_duration) else: generated = await self.bot.main_db.generate_guild_licenses( num, guild_id, license_role.id, license_duration) count_generated = len(generated) ctx_msg = ( f"Successfully generated {count_generated} licenses for role {license_role.mention}" f" in duration of {license_duration}h.\n" f"Sending generated licenses in DM for quick use.") await ctx.send(embed=success_embed(ctx_msg, ctx.me)) table = texttable.Texttable(max_width=45) table.set_cols_dtype(["t"]) table.set_cols_align(["c"]) header = ("License", ) table.add_row(header) for license in generated: table.add_row((license, )) dm_msg = ( f"Generated {count_generated} licenses for role '{license_role.name}' in " f"guild '{ctx.guild.name}' in duration of {license_duration}h:\n" f"{table.draw()}") await ctx.author.send(f"```{misc.maximize_size(dm_msg)}```")