From e2dd8fed2a28dbdc724ef56e672e25097c0ad971 Mon Sep 17 00:00:00 2001 From: brandons209 Date: Sat, 8 Feb 2020 14:52:28 -0500 Subject: [PATCH] numerous bug fixes, additions, and edits. new cog: nitroemoji --- README.md | 5 +- activitylog/activitylog.py | 8 +- disable/info.json | 19 +++ economytrickle/__init__.py | 2 - economytrickle/converters.py | 10 ++ economytrickle/core.py | 83 +++++++++-- moreadmin/moreadmin.py | 54 ++++++-- nitroemoji/__init__.py | 6 + nitroemoji/info.json | 21 +++ nitroemoji/nitroemoji.py | 257 +++++++++++++++++++++++++++++++++++ rolemanagement/core.py | 148 +++++++++++++++++--- rolemanagement/events.py | 22 +++ rolemanagement/utils.py | 5 +- 13 files changed, 591 insertions(+), 49 deletions(-) create mode 100644 disable/info.json create mode 100644 nitroemoji/__init__.py create mode 100644 nitroemoji/info.json create mode 100644 nitroemoji/nitroemoji.py diff --git a/README.md b/README.md index fee8fa7..65d2dec 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ More admin commands that provide various functionality. - List all users with a role/roles quickly and easily. +#### Nitro Emoji +Allows nitro boosters to add one emoji to your server. Log's additions and removal of custom emojis to a channel. Can turn this off to stop more people from adding, but those who added can remove their emoji. + + #### Pony Search derpibooru for pony images. Ported from [Alzarath](https://github.com/Alzarath/Booru-Cogs). **Features:** @@ -75,7 +79,6 @@ Modified from [Sinbad](https://github.com/mikeshardmind/SinbadCogs). - Adds in subscription based roles which renew every customized interval. - Allows settings messages through DM to users who obtain a specific role (such as role info). - Renames srole to selfrole and removes Red's default selfrole. -**In progress:** - Makes listing roles a bit prettier. - Allow setting roles to automatically add on guild join. - Enhance exclusive roles, allow setting custom role groups where the bot enforces only one role to be on a user at a time, even if it isn't a selfrole. The bot will automatically remove the old role if a new role from the same group is added. Also lists name of role group in list command to make it clearer. diff --git a/activitylog/activitylog.py b/activitylog/activitylog.py index 816edf1..3fcb924 100644 --- a/activitylog/activitylog.py +++ b/activitylog/activitylog.py @@ -154,10 +154,10 @@ class ActivityLogger(commands.Cog): for guild in guilds: self.cache[guild.id] = self.default_guild.copy() - if not channel_data: - guilds = self.bot.guilds - for guild in guilds: - for channel in guild.channels: + guilds = self.bot.guilds + for guild in guilds: + for channel in guild.channels: + if not channel.id in self.cache.keys(): self.cache[channel.id] = self.default_channel.copy() for guild in self.bot.guilds: diff --git a/disable/info.json b/disable/info.json new file mode 100644 index 0000000..98bd0b8 --- /dev/null +++ b/disable/info.json @@ -0,0 +1,19 @@ +{ + "author": [ + "Brandons209" + ], + "bot_version": [ + 3, + 3, + 0 + ], + "description": "Small cog that allows disabling all bot commands for everyone except Admins/owner in a guild. Nice for things like bot setup. You can also set the error message when a person trys using a command when its disabled.", + "hidden": false, + "install_msg": "Thank you for using this cog!", + "requirements": [], + "short": "Disable all commands for everyone except admins.", + "tags": [ + "brandons209", + "redbot" + ] +} diff --git a/economytrickle/__init__.py b/economytrickle/__init__.py index 303d06c..9778b1b 100644 --- a/economytrickle/__init__.py +++ b/economytrickle/__init__.py @@ -1,7 +1,5 @@ from . import core -from cog_shared.sinbad_libs import extra_setup -@extra_setup def setup(bot): bot.add_cog(core.EconomyTrickle(bot)) diff --git a/economytrickle/converters.py b/economytrickle/converters.py index bca9d08..75e3ea7 100644 --- a/economytrickle/converters.py +++ b/economytrickle/converters.py @@ -4,6 +4,8 @@ from redbot.core import commands configable_guild_defaults = { "interval": 5, + "fail_rate": 0.2, + "decay_rate": 0.5, "level_xp_base": 100, "xp_lv_increase": 50, "maximum_level": None, @@ -56,4 +58,12 @@ def settings_converter(user_input: str) -> dict: except AssertionError: raise commands.BadArgument(f"{value} must be a non-negative integer value or `null`") + for value in ("fail_rate", "decay_rate"): + if value in args: + try: + assert args[value] >= 0 + assert args[value] <= 1 + except AssertionError: + raise commands.BadArgument(f"{value} must be a decimal value between 0 and 1.") + return args diff --git a/economytrickle/core.py b/economytrickle/core.py index 43bc966..001cffc 100644 --- a/economytrickle/core.py +++ b/economytrickle/core.py @@ -4,6 +4,7 @@ import contextlib from typing import no_type_check, Union from datetime import datetime, timedelta from collections import defaultdict +import random import discord from redbot.core import commands, checks @@ -19,7 +20,7 @@ class EconomyTrickle(commands.Cog): Automatic Economy gains for active users """ - __version__ = "2.0.2" + __version__ = "2.1.0" def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) @@ -30,6 +31,7 @@ class EconomyTrickle(commands.Cog): mode="blacklist", blacklist=[], whitelist=[], + min_voice_members=2, **configable_guild_defaults, # custom_level_table={}, # TODO ) @@ -55,21 +57,24 @@ class EconomyTrickle(commands.Cog): while self is self.bot.get_cog(self.__class__.__name__): await asyncio.sleep(60) - now = datetime.utcnow() data = await self.config.all_guilds() for g in self.bot.guilds: if g.id in data and data[g.id]["active"]: minutes[g] += 1 - if minutes[g] % data[g.id]["interval"]: + if minutes[g] % data[g.id]["interval"] == 0: + minutes[g] = 0 + print(f"processing...{minutes[g]}, {data[g.id]['interval']}") + now = datetime.utcnow() tsk = self.bot.loop.create_task(self.do_rewards_for(g, now, data[g.id])) self.extra_tasks.append(tsk) async def do_rewards_for(self, guild: discord.Guild, now: datetime, data: dict): - after = now - timedelta(minutes=data["interval"]) - + after = now - timedelta(minutes=data["interval"], seconds=10) + print(f"after: {after}") + voice_mem = await self.config.guild(guild).min_voice_members() if data["mode"] == "blacklist": def mpred(m: discord.Message): @@ -77,7 +82,7 @@ class EconomyTrickle(commands.Cog): def vpred(mem: discord.Member): with contextlib.suppress(AttributeError): - return mem.voice.channel.id not in data["blacklist"] and not mem.bot + return len(mem.voice.channel.members) > voice_mem and mem.voice.channel.id not in data["blacklist"] and not mem.bot else: @@ -86,16 +91,42 @@ class EconomyTrickle(commands.Cog): def vpred(mem: discord.Member): with contextlib.suppress(AttributeError): - return mem.voice.channel.id in data["whitelist"] and not mem.bot + return len(mem.voice.channel.members) > voice_mem and mem.voice.channel.id in data["whitelist"] and not mem.bot has_active_message = set(self.recordhandler.get_active_for_guild(guild=guild, after=after, message_check=mpred)) is_active_voice = {m for m in guild.members if vpred(m)} - + print(f"active: {[m.name for m in has_active_message]}") is_active = has_active_message | is_active_voice - for member in is_active: + # take exp away from inactive users + for member in guild.members: + if member in is_active: + continue + # loose exp per interval + xp = min(data["xp_per_interval"] * data["decay_rate"], 1) + xp = await self.config.member(member).xp() - xp + xp = max(xp, 0) + await self.config.member(member).xp.set(xp) + + # update level on these users + level, next_needed = 0, data["level_xp_base"] + + while xp >= next_needed: + level += 1 + xp -= next_needed + next_needed += data["xp_lv_increase"] + + if data["maximum_level"] is not None: + level = min(data["maximum_level"], level) + + await self.config.member(member).level.set(level) + + for member in is_active: + # failed for this member, skip + if data["fail_rate"] > random.random(): + continue # xp processing first xp = data["xp_per_interval"] if member in has_active_message: @@ -155,15 +186,17 @@ class EconomyTrickle(commands.Cog): @ect.command() @no_type_check - async def setstuff(self, ctx, *, data: settings_converter): + async def setstuff(self, ctx, *, data: settings_converter = None): """ Set other variables - format for this (and defaults): + format *example* for this (and defaults): ```yaml bonus_per_level: 5 econ_per_interval: 20 + fail_rate: 0.2 + decay_rate: 0.5 extra_message_xp: 0 extra_voice_xp: 0 interval: 5 @@ -174,6 +207,17 @@ class EconomyTrickle(commands.Cog): xp_per_interval: 10 ``` """ + if not data: + data = await self.config.guild(ctx.guild).all() + keys = list(configable_guild_defaults.keys()) + msg = "Current data: (run `help trickleset setstuff to set`)\n```yaml\n" + for key in keys: + msg += f"{key}: {data[key]}\n" + msg += "```" + + await ctx.send(msg) + return + for k, v in data.items(): await self.config.guild(ctx.guild).get_attr(k).set(v) await ctx.tick() @@ -191,6 +235,23 @@ class EconomyTrickle(commands.Cog): await self.config.guild(ctx.guild).mode.set(mode) await ctx.tick() + @ect.command(name="voice") + async def rset_voicemem(self, ctx, min_voice_members: int = 0): + """ + Minimum number of voice members needed to count as active. + + If users are in a voice chat, only trickle if there are at least min_voice_members + in there. + """ + + if min_voice_members < 1: + curr = await self.config.guild(ctx.guild).min_voice_members() + await ctx.send(f"Current: {curr}") + return + + await self.config.guild(ctx.guild).min_voice_members.set(min_voice_members) + await ctx.tick() + @ect.command(name="addchan") async def rset_add_chan(self, ctx, *channels: Union[discord.TextChannel, discord.VoiceChannel]): """ diff --git a/moreadmin/moreadmin.py b/moreadmin/moreadmin.py index 4a5213b..62dc323 100644 --- a/moreadmin/moreadmin.py +++ b/moreadmin/moreadmin.py @@ -47,7 +47,7 @@ class MoreAdmin(commands.Cog): self.bot = bot self.config = Config.get_conf(self, identifier=213438438248, force_registration=True) - default_guild = {"user_count_channel": None, "sus_user_channel": None, "sus_user_threshold": None} + default_guild = {"user_count_channel": None, "sus_user_channel": None, "sus_user_threshold": None, "prefixes": []} default_role = {"addable": []} # role ids who can add this role self.config.register_role(**default_role) @@ -58,6 +58,11 @@ class MoreAdmin(commands.Cog): async def initialize(self): await self.register_casetypes() + for guild in self.bot.guilds: + async with self.config.guild(guild).prefixes() as prefixes: + if not prefixes: + curr = await self.bot.get_valid_prefixes() + prefixes.extend(curr) def cog_unload(self): self.user_task.cancel() @@ -65,19 +70,18 @@ class MoreAdmin(commands.Cog): @staticmethod async def register_casetypes(): # register mod case - punish_case = { + purge_case = { "name": "Purge", "default_setting": True, "image": "\N{WOMANS BOOTS}", "case_str": "Purge", } try: - await modlog.register_casetype(**punish_case) + await modlog.register_casetype(**purge_case) except RuntimeError: pass - @staticmethod - async def find_last_message(guild: discord.Guild, role: discord.Role): + async def find_last_message(self, guild: discord.Guild, role: discord.Role, include_bot_commands: bool): """ Finds last message of EVERY user with role in a guild. **WARNING VERY SLOW AND COSTLY OPERATION!** @@ -86,12 +90,21 @@ class MoreAdmin(commands.Cog): """ last_msgs = {} text_channels = [channel for channel in guild.channels if isinstance(channel, discord.TextChannel)] + prefixes = await self.config.guild(guild).prefixes() for channel in text_channels: async for message in channel.history(limit=None): if isinstance(message.author, discord.Member) and role in message.author.roles: - if message.author.id not in last_msgs.keys(): + # prefix check + skip = False + if include_bot_commands: + for prefix in prefixes: + if message.content and prefix == message.content[:len(prefix)]: + skip = True + break + + if message.author.id not in last_msgs.keys() and not skip: last_msgs[message.author.id] = message - else: + elif not skip: curr_last = last_msgs[message.author.id] if message.created_at > curr_last.created_at: last_msgs[message.author.id] = message @@ -209,6 +222,26 @@ class MoreAdmin(commands.Cog): await self.config.guild(ctx.guild).sus_user_threshold.set(int(threshold.total_seconds())) await ctx.tick() + @adminset.command(name="prefixes") + async def adminset_prefixes(self, ctx, *, prefixes: str = None): + """ + Set prefixes for bot commands to check for when purging. + + Seperate prefixes with spaces. + + Used for purge command. + """ + if not prefixes: + prefixes = await self.config.guild(ctx.guild).prefixes() + curr = [f"`{p}`" for p in prefixes] + await ctx.send("Current Prefixes: " + humanize_list(curr)) + return + + prefixes = [p for p in prefixes.split(" ")] + await self.config.guild(ctx.guild).prefixes.set(prefixes) + prefixes = [f"`{p}`" for p in prefixes] + await ctx.send("Prefixes set to: " + humanize_list(prefixes)) + @adminset.command(name="addable") async def adminset_addable(self, ctx, role: discord.Role, *, role_list: str = None): """ @@ -358,7 +391,7 @@ class MoreAdmin(commands.Cog): @commands.command(name="purge") @checks.admin_or_permissions(administrator=True) @checks.bot_has_permissions(kick_members=True) - async def purge(self, ctx, role: discord.Role, check_messages: bool = True, *, threshold: str = None): + async def purge(self, ctx, role: discord.Role, check_messages: bool = True, include_bot_commands: bool = False, *, threshold: str = None): """ Purge inactive users with role. @@ -368,6 +401,9 @@ class MoreAdmin(commands.Cog): If check_messages is yes/true/1 then purging is dictated by the user's last message. If check_messages is no/false/0 then purging is dictated by the user's join date. + If checking last message and bot is yes/true/1 then the bot won't count bot include_bot_commands as a valid last message for purge. + **Make sure to set prefixes with [p]adminset** + Threshold should be an interval. Intervals look like: @@ -389,7 +425,7 @@ class MoreAdmin(commands.Cog): errored = [] start_time = time.time() if check_messages: - last_msgs = await self.find_last_message(guild, role) + last_msgs = await self.find_last_message(guild, role, include_bot_commands) for member in guild.members: if role in member.roles: diff --git a/nitroemoji/__init__.py b/nitroemoji/__init__.py new file mode 100644 index 0000000..6cf6152 --- /dev/null +++ b/nitroemoji/__init__.py @@ -0,0 +1,6 @@ +from .nitroemoji import NitroEmoji + +async def setup(bot): + n = NitroEmoji(bot) + await n.initialize() + bot.add_cog(n) diff --git a/nitroemoji/info.json b/nitroemoji/info.json new file mode 100644 index 0000000..f18f925 --- /dev/null +++ b/nitroemoji/info.json @@ -0,0 +1,21 @@ +{ + "author": [ + "Brandons209" + ], + "bot_version": [ + 3, + 3, + 0 + ], + "description": "Allow nitro boosters to add an emoji to the server, with channel logging.", + "hidden": false, + "install_msg": "Thank you for using this cog! Please make sure to set the log channel using `[p]nitroset`", + "requirements": ["aiohttp"], + "short": "Allow boosters to have a server emoji.", + "tags": [ + "brandons209", + "nitro", + "boost", + "emoji" + ] +} diff --git a/nitroemoji/nitroemoji.py b/nitroemoji/nitroemoji.py new file mode 100644 index 0000000..e3958c3 --- /dev/null +++ b/nitroemoji/nitroemoji.py @@ -0,0 +1,257 @@ +from redbot.core.utils.chat_formatting import * +from redbot.core import Config, checks, commands, bank +from redbot.core.data_manager import cog_data_path +import discord + +import aiohttp +import PIL +import os + + +class NitroEmoji(commands.Cog): + """ + Reward nitro boosters with a custom emoji. + """ + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=123859659843, force_registration=True) + default_guild = { + "channel": None, + "disabled": False + } + default_member = { + "emojis": [] + } + self.config.register_guild(**default_guild) + self.config.register_member(**default_member) + + async def initialize(self): + for member in self.bot.get_all_members(): + async with self.config.member(member).emojis() as data: + to_remove = [] + for e in data: + emoji = self.find_emoji(member.guild, e) + # clean removed emojis if bot is down + if not emoji: + to_remove.append(e) + + for r in to_remove: + data.remove(r) + + @staticmethod + def get_boosts(member: discord.Member): + # this will return number of boosts once api supports it + return member.premium_since is not None + + def find_emoji(self, guild, name): + emoji = self.bot.get_emoji(name) + # find by name: + if not emoji: + emoji = discord.utils.get(guild.emojis, name=name) + return emoji + + + async def add_emoji(self, member, name, attachment_or_url, reason=None): + path = str(cog_data_path(cog_instance=self)) + path = os.path.join(path, str(member.id) + name) + if isinstance(attachment_or_url, discord.Attachment): + await attachment_or_url.save(path) + elif isinstance(attachment_or_url, str): + async with aiohttp.ClientSession(loop=self.bot.loop) as session: + async with session.get(attachment_or_url) as r: + if r.status == 200: + with open(path, "wb") as f: + f.write(await r.read()) + + # verify image + im = PIL.Image.open(path) + im.verify() + + # upload emoji + with open(path, "rb") as f: + emoji = await member.guild.create_custom_emoji(name=name, image=f.read()) + + os.remove(path) + + async with self.config.member(member).emojis() as e: + e.append(emoji.id) + + channel = await self.config.guild(member.guild).channel() + channel = member.guild.get_channel(channel) + if not channel: + return + + embed = discord.Embed(title="Custom Emoji Added", colour=member.colour) + embed.set_footer(text="User ID:{}".format(member.id)) + + embed.set_author(name=str(member), url=emoji.url) + embed.set_thumbnail(url=emoji.url) + embed.set_author(name=str(member)) + + if reason: + embed.add_field(name="Reason", value=reason) + + await channel.send(embed=embed) + + async def del_emoji(self, guild, member, emoji=None, reason=None): + channel = await self.config.guild(member.guild).channel() + channel = member.guild.get_channel(channel) + if channel: + embed = discord.Embed(title="Custom Emoji Removed", colour=member.colour) + embed.set_footer(text="User ID:{}".format(member.id)) + + embed.set_author(name=str(member), url=emoji.url) + embed.set_thumbnail(url=emoji.url) + embed.set_author(name=str(member)) + + if reason: + embed.add_field(name="Reason", value=reason) + + await channel.send(embed=embed) + + if emoji: + await emoji.delete() + async with self.config.member(member).emojis() as e: + e.remove(emoji.id) + + @commands.group(name="nitroset") + @commands.guild_only() + @checks.admin() + async def nitroset(self, ctx): + """Manage nitro emoji settings.""" + pass + + @nitroset.command(name="channel") + async def nitroset_channel(self, ctx, channel: discord.TextChannel): + """ + Set the channel to log nitro events. + + Logs boosts, unboosts, and added emojis. + """ + + await self.config.guild(ctx.guild).channel.set(channel.id) + await ctx.tick() + + @nitroset.command(name="disable") + async def nitroset_disable(self, ctx, *, on_off: bool = None): + """ + Disable users from adding more emojis. + + Users can still remove and list their own emojis. + """ + if on_off is None: + curr = await self.config.guild(ctx.guild).disabled() + msg = "enabled" if not curr else "disabled" + await ctx.send(f"Nitro emojis is {msg}.") + return + + await self.config.guild(ctx.guild).disabled.set(on_off) + await ctx.tick() + + + @commands.group(name="nitroemoji") + @checks.bot_has_permissions(manage_emojis=True) + async def nitroemoji(self, ctx): + """ + Manage your emojis if you boosted the server. + """ + pass + + @nitroemoji.command(name="add") + async def nitroemoji_add(self, ctx, name: str, *, url: str = None): + """ + Add an emoji to the server, if you boosted. + + Can only add an emoji for every boost you have in the server. + """ + disabled = await self.config.guild(ctx.guild).disabled() + if disabled: + await ctx.send("Sorry, adding emojis is currently disabled right now.") + return + + curr = await self.config.member(ctx.author).emojis() + boosts = self.get_boosts(ctx.author) + # TODO: add in checking for multiple emojis once supported + if boosts and not curr: + try: + if url: + emoji = await self.add_emoji(ctx.author, name, url) + else: + emoji = await self.add_emoji(ctx.author, name, ctx.message.attachments[0]) + await ctx.tick() + except discord.errors.HTTPException as e: + await ctx.send(e.text) + except PIL.UnidentifiedImageError: + await ctx.send("That is not a valid picture! Pictures must be in PNG, JPEG, or GIF format.") + except: + await ctx.send("Something went wrong, make sure to add a valid picture (PNG, JPG, or GIF) of the right size (256KB) and a valid name.") + return + elif not boosts: + await ctx.send("Sorry, you need to be a nitro booster to add an emoji!") + elif curr: + await ctx.send("You already have a custom emoji, please delete it first before adding another one.") + + + @nitroemoji.command(name="rem") + async def nitroemoji_rem(self, ctx, name: str): + """ + Remove an emoji to the server, if you boosted. + """ + curr = await self.config.member(ctx.author).emojis() + emoji = self.find_emoji(ctx.guild, name) + if emoji: + if emoji.id in curr: + await self.del_emoji(ctx.guild, ctx.author, emoji=emoji, reason="Removed by user.") + await ctx.tick() + else: + await ctx.send("That isn't your custom emoji.") + else: + await ctx.send(warning("Emoji not found.")) + + + @nitroemoji.command(name="list") + async def nitroemoji_list(self, ctx): + """ + List your custom emojis in the server + """ + curr = await self.config.member(ctx.author).emojis() + msg = "" + if curr: + msg += "Current emojis:\n" + for emoji in curr: + emoji = self.find_emoji(ctx.guild, emoji) + if not emoji: + continue + msg += f"{emoji.url}\n" + else: + msg += "You have no custom emojis." + + for page in pagify(msg): + await ctx.send(page) + + + @commands.Cog.listener() + async def on_member_update(self, before, after): + # check if they stopped boosting + if before.premium_since != after.premium_since and after.premium_since is None: + emojis = await self.config.member(after).emojis() + for emoji in emojis: + emoji = self.find_emoji(after.guild, emoji) + if not emoji: + continue + await self.del_emoji(after.guild, after, emoji=emoji, reason="Stopped boosting.") + + @commands.Cog.listener() + async def on_guild_emojis_update(self, guild, before, after): + b_e = set(before) + a_e = set(after) + diff = b_e - a_e + if diff: + for e in diff: + for member in guild.premium_subscribers: + curr = await self.config.member(member).emojis() + if e.id in curr: + curr.remove(e.id) + await self.config.member(member).emojis.set(curr) + await self.del_emoji(guild, member, emoji=e, reason="Manually deleted by admin.") + break diff --git a/rolemanagement/core.py b/rolemanagement/core.py index 6e56b81..1d8c0b3 100644 --- a/rolemanagement/core.py +++ b/rolemanagement/core.py @@ -11,10 +11,10 @@ import discord from discord.ext.commands import CogMeta as DPYCogMeta from redbot.core import checks, commands, bank from redbot.core.config import Config -from redbot.core.utils.chat_formatting import box, pagify, warning +from redbot.core.utils.chat_formatting import box, pagify, warning, humanize_list from .events import EventMixin -from .exceptions import RoleManagementException, PermissionOrHierarchyException +from .exceptions import RoleManagementException, PermissionOrHierarchyException, MissingRequirementsException, ConflictingRoleException from .massmanager import MassManagementMixin from .utils import UtilMixin, variation_stripper_re, parse_timedelta, parse_seconds @@ -41,7 +41,7 @@ class CompositeMetaClass(DPYCogMeta, ABCMeta): MIN_SUB_TIME = 3600 SLEEP_TIME = 300 - +MAX_EMBED = 25 class RoleManagement( UtilMixin, MassManagementMixin, EventMixin, commands.Cog, metaclass=CompositeMetaClass, @@ -62,7 +62,7 @@ class RoleManagement( self.config = Config.get_conf(self, identifier=78631113035100160, force_registration=True) self.config.register_global(handled_variation=False, handled_full_str_emoji=False) self.config.register_role( - exclusive_to=[], + exclusive_to={}, requires_any=[], requires_all=[], sticky=False, @@ -79,7 +79,7 @@ class RoleManagement( self.config.register_custom( "REACTROLE", roleid=None, channelid=None, guildid=None ) # ID : Message.id, str(React) - self.config.register_guild(notify_channel=None, s_roles=[], free_roles=[]) + self.config.register_guild(notify_channel=None, s_roles=[], free_roles=[], join_roles=[]) self._ready = asyncio.Event() self._start_task: Optional[asyncio.Task] = None self.loop = asyncio.get_event_loop() @@ -155,6 +155,10 @@ class RoleManagement( if not member: # clean absent members del role_data["subscribed_users"][user_id] continue + # make sure they still have the role + if role not in member.roles: + del role_data["subscribed_users"][user_id] + continue # charge user cost = await self.config.role(role).cost() currency_name = await bank.get_currency_name(guild) @@ -406,6 +410,56 @@ class RoleManagement( await self.config.role(role).dm_msg.set(msg) await ctx.tick() + @rgroup.group(name="join") + async def join_roles(self, ctx: GuildContext): + """ + Set roles to add to users on join. + """ + pass + + @join_roles.command(name="add") + async def join_roles_add(self, ctx: GuildContext, *, role: discord.Role): + """ + Add a role to the join list. + """ + async with self.config.guild(ctx.guild).join_roles() as join_roles: + if role.id not in join_roles: + join_roles.append(role.id) + + await ctx.tick() + + @join_roles.command(name="rem") + async def join_roles_rem(self, ctx: GuildContext, *, role: discord.Role): + """ + Remove a role from the join list. + """ + async with self.config.guild(ctx.guild).join_roles() as join_roles: + try: + join_roles.remove(role.id) + except: + await ctx.send("Role not in join list!") + return + + await ctx.tick() + + @join_roles.command(name="list") + async def join_roles_list(self, ctx: GuildContext): + """ + List join roles. + """ + roles = await self.config.guild(ctx.guild).join_roles() + if not roles: + await ctx.send("No roles defined.") + return + roles = [ctx.guild.get_role(role) for role in roles] + missing = len([role for role in roles if role is None]) + roles = [f"{i+1}.{role.name}" for i, role in enumerate(roles) if role is not None] + + msg = "\n".join(sorted(roles)) + msg = pagify(msg) + for m in msg: + await ctx.send(box(m)) + @rgroup.command(name="viewrole") async def rg_view_role(self, ctx: GuildContext, *, role: discord.Role): """ @@ -426,8 +480,12 @@ class RoleManagement( rstring = ", ".join(r.name for r in ctx.guild.roles if r.id in rsets["requires_all"]) output += f"\nThis role requires all of the following roles: {rstring}" if rsets["exclusive_to"]: - rstring = ", ".join(r.name for r in ctx.guild.roles if r.id in rsets["exclusive_to"]) - output += f"\nThis role is mutually exclusive to the following roles: {rstring}" + rstring = "" + for group, roles in rsets["exclusive_to"].items(): + rstring = f"`{group}`: " + rstring += ", ".join(r.name for r in ctx.guild.roles if r.id in roles) + rstring += "\n" + output += f"\nThis role is mutually exclusive to the following role groups:\n{rstring}" if rsets["cost"]: curr = await bank.get_currency_name(ctx.guild) cost = rsets["cost"] @@ -535,9 +593,13 @@ class RoleManagement( await ctx.tick() @rgroup.command(name="exclusive") - async def set_exclusivity(self, ctx: GuildContext, *roles: discord.Role): + async def set_exclusivity(self, ctx: GuildContext, group: str, *roles: discord.Role): """ + Set exclusive roles for group Takes 2 or more roles and sets them as exclusive to eachother + + The group can be any name, use spaces for names with spaces. + Groups will show up in role list etc. """ _roles = set(roles) @@ -547,13 +609,20 @@ class RoleManagement( for role in _roles: async with self.config.role(role).exclusive_to() as ex_list: - ex_list.extend([r.id for r in _roles if r != role and r.id not in ex_list]) + if group not in ex_list.keys(): + ex_list[group] = [] + ex_list[group].extend([r.id for r in _roles if r != role and r.id not in ex_list[group]]) + await ctx.tick() @rgroup.command(name="unexclusive") - async def unset_exclusivity(self, ctx: GuildContext, *roles: discord.Role): + async def unset_exclusivity(self, ctx: GuildContext, group: str, *roles: discord.Role): """ + Remove exclusive roles for group Takes any number of roles, and removes their exclusivity settings + + The group can be any name, use spaces for names with spaces. + If all roles are removed from a group then """ _roles = set(roles) @@ -563,7 +632,11 @@ class RoleManagement( for role in _roles: ex_list = await self.config.role(role).exclusive_to() - ex_list = [idx for idx in ex_list if idx not in [r.id for r in _roles]] + if group not in ex_list.keys(): + continue + ex_list[group] = [idx for idx in ex_list if idx not in [r.id for r in _roles]] + if not ex_list[group]: + del ex_list[group] await self.config.role(role).exclusive_to.set(ex_list) await ctx.tick() @@ -586,7 +659,6 @@ class RoleManagement( await ctx.tick() - # TODO set roles who don't need to pay for roles @rgroup.command(name="requireall") async def reqall(self, ctx: GuildContext, role: discord.Role, *roles: discord.Role): """ @@ -724,7 +796,7 @@ class RoleManagement( data[role] = vals["cost"] else: data = { - role: (vals["cost"], vals["subscription"]) + role: (vals["cost"], vals["subscription"], vals["exclusive_to"]) for role_id, vals in (await self.config.all_roles()).items() if (role := ctx.guild.get_role(role_id)) and vals["self_role"] } @@ -734,15 +806,20 @@ class RoleManagement( embed = discord.Embed(title="Roles", colour=ctx.guild.me.colour) i = 0 - for role, (cost, sub) in sorted(data.items(), key=lambda kv: kv[1]): + for role, (cost, sub, ex_groups) in sorted(data.items(), key=lambda kv: kv[1][0]): + if ex_groups: + groups = humanize_list(list(ex_groups.keys())) + else: + groups = None embed.add_field( name=f"__**{i+1}. {role.name}**__", - value="%s%s" - % ((f"Cost: {cost}" if cost else "Free"), (f", every {parse_seconds(sub)}" if sub else "")), + value="%s%s%s" + % ((f"Cost: {cost}" if cost else "Free"), (f", every {parse_seconds(sub)}" if sub else ""), (f"\nunique groups: `{groups}`" if groups else "")) ) i += 1 - if i % 25 == 0: + if i % MAX_EMBED == 0: await ctx.send(embed=embed) + embed.set_footer(text="You can only have one role in the same unique group!") await ctx.send(embed=embed) @@ -759,10 +836,21 @@ class RoleManagement( eligible = await self.config.role(role).self_role() cost = await self.config.role(role).cost() subscription = await self.config.role(role).subscription() - except RoleManagementException: - return except PermissionOrHierarchyException: await ctx.send("I cannot assign roles which I can not manage. (Discord Hierarchy)") + except MissingRequirementsException as e: + msg = "" + if e.miss_all: + roles = [r for r in ctx.guild.roles if r in e.miss_all] + msg += f"You need all of these roles in order to get this role: {humanize_list(roles)}\n" + if e.miss_any: + roles = [r for r in ctx.guild.roles if r in e.miss_any] + msg += f"You need one of these roles in order to get this role: {humanize_list(roles)}\n" + await ctx.send(msg) + except ConflictingRoleException as e: + roles = [r for r in ctx.guild.roles if r in e.conflicts] + plural = "are" if len(roles) > 1 else "is" + await ctx.send(f"You have {humanize_list(roles)}, which you are not allowed to remove and {plural} exclusive to: {role.name}") else: if not eligible: return await ctx.send(f"You aren't allowed to add `{role}` to yourself {ctx.author.mention}!") @@ -792,6 +880,9 @@ class RoleManagement( if role.id not in s: s.append(role.id) + if remove: + plural = "s" if len(remove) > 1 else "" + await ctx.send(f"Removed `{humanize_list([r.name for r in remove])}` role{plural} since they are exclusive to the role you added.") await self.update_roles_atomically(who=ctx.author, give=[role], remove=remove) await self.dm_user(ctx, role) await ctx.tick() @@ -808,10 +899,22 @@ class RoleManagement( remove = await self.is_self_assign_eligible(ctx.author, role) eligible = await self.config.role(role).self_role() cost = await self.config.role(role).cost() - except RoleManagementException: - return except PermissionOrHierarchyException: await ctx.send("I cannot assign roles which I can not manage. (Discord Hierarchy)") + except MissingRequirementsException as e: + msg = "" + if e.miss_all: + roles = [r for r in ctx.guild.roles if r in e.miss_all] + msg += f"You need all of these roles in order to get this role: {humanize_list(roles)}\n" + if e.miss_any: + roles = [r for r in ctx.guild.roles if r in e.miss_any] + msg += f"You need one of these roles in order to get this role: {humanize_list(roles)}\n" + await ctx.send(msg) + except ConflictingRoleException as e: + print(e.conflicts) + roles = [r for r in ctx.guild.roles if r in e.conflicts] + plural = "are" if len(roles) > 1 else "is" + await ctx.send(f"You have {humanize_list(roles)}, which you are not allowed to remove and {plural} exclusive to: {role.name}") else: if not eligible: await ctx.send(f"You aren't allowed to add `{role}` to yourself {ctx.author.mention}!") @@ -821,6 +924,9 @@ class RoleManagement( "This role is not free. " "Please use `[p]selfrole buy` if you would like to purchase it." ) else: + if remove: + plural = "s" if len(remove) > 1 else "" + await ctx.send(f"Removed `{humanize_list([r.name for r in remove])}` role{plural} since they are exclusive to the role you added.") await self.update_roles_atomically(who=ctx.author, give=[role], remove=remove) await self.dm_user(ctx, role) await ctx.tick() diff --git a/rolemanagement/events.py b/rolemanagement/events.py index d5ae54c..ea5adca 100644 --- a/rolemanagement/events.py +++ b/rolemanagement/events.py @@ -51,6 +51,17 @@ class EventMixin(MixinMeta): lost, gained = lost - gained, gained - lost sym_diff = lost | gained + # check if new member roles are exclusive to others. + ex = [] + for r in gained: + ex_groups = (await self.config.role_from_id(r).exclusive_to()).values() + for ex_roles in ex_groups: + ex.extend(ex_roles) + + to_remove = [r for r in after.roles if r.id in ex] + if to_remove: + await after.remove_roles(*to_remove, reason="conflict with exclusive roles") + for r in sym_diff: if not await self.config.role_from_id(r).sticky(): lost.discard(r) @@ -83,6 +94,17 @@ class EventMixin(MixinMeta): to_add = [r for r in to_add if r < guild.me.top_role] await member.add_roles(*to_add) + # join roles + async with self.config.guild(guild).join_roles() as join_roles: + to_add: List[discord.Role] = [] + for role_id in join_roles: + role = discord.utils.get(guild.roles, id=role_id) + if role: + to_add.append(role) + if to_add: + to_add = [r for r in to_add if r < guild.me.top_role] + await member.add_roles(*to_add) + @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.raw_models.RawReactionActionEvent): await self.wait_for_ready() diff --git a/rolemanagement/utils.py b/rolemanagement/utils.py index 6752533..6e3e606 100644 --- a/rolemanagement/utils.py +++ b/rolemanagement/utils.py @@ -170,7 +170,10 @@ class UtilMixin(MixinMeta): """ data = await self.config.all_roles() - ex = data.get(role.id, {}).get("exclusive_to", []) + ex_data = data.get(role.id, {}).get("exclusive_to", {}).values() + ex = [] + for ex_roles in ex_data: + ex.extend(ex_roles) conflicts: List[discord.Role] = [r for r in who.roles if r.id in ex] for r in conflicts: