├── .gitignore ├── LICENSE ├── README.md ├── assign_roles ├── assign_roles.py └── info.json ├── birthdays ├── birthdays.py └── info.json ├── client_modification ├── client_modification.py └── info.json ├── embed_reactor ├── embed_reactor.py └── info.json ├── help_autodelete ├── help_autodelete.py └── info.json ├── indexed_search ├── indexed_search.py └── info.json ├── info.json ├── message_proxy ├── info.json └── message_proxy.py ├── periodic ├── info.json └── periodic.py ├── react_roles ├── info.json └── react_roles.py ├── react_roles_bundled ├── info.json └── react_roles_bundled.py ├── reminder ├── info.json └── reminder.py ├── slowmode ├── info.json └── slowmode.py ├── timezone_conversion ├── info.json └── timezone_conversion.py ├── uploads_filter ├── info.json └── uploads_filter.py ├── voice_channel_generator ├── info.json └── voice_channel_generator.py ├── voice_lock ├── info.json └── voice_lock.py ├── voice_logs ├── info.json └── voice_logs.py └── welcome ├── info.json └── welcome.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NOT MAINTAINED ANYMORE. USE AT YOUR OWN RISK 2 | 3 | # ZeCogs (V2) 4 | My cogs repository for [Red-DiscordBot](https://github.com/Twentysix26/Red-DiscordBot) V2 (**NOT V3**) 5 | 6 | Meant to be used with Red's downloader by adding it with: 7 | `[p]cog repo add ZeCogs https://github.com/ZeLarpMaster/ZeCogs` 8 | 9 | ## Note: THIS COG REPO IS FOR V2 OF RED-DISCORDBOT AND WILL NOT WORK FOR V3 10 | 11 | ## Having issues? 12 | Try to find your answer in the [Frequently Asked Questions](#frequently-asked-questions). 13 | 14 | If it's not there already, [open an issue](../../issues). 15 | 16 | # Contact 17 | You can find me on the [Red - Cog Support](https://discord.gg/GET4DVk). 18 | I'm ZeLarpMaster#0818 19 | 20 | # Frequently Asked Questions 21 | ### Roles aren't assigned after restarting the bot or reloading the cog 22 | You need to install my `client_modification` cog. It provides a feature on which `react_roles` depends on to let the bot assign roles after restarting the bot. 23 | -------------------------------------------------------------------------------- /assign_roles/assign_roles.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import os.path 3 | import os 4 | 5 | from .utils.dataIO import dataIO 6 | from discord.ext import commands 7 | from cogs.utils import checks 8 | 9 | 10 | class AssignRoles: 11 | """Authorize one role to give another role.""" 12 | 13 | DATA_FOLDER = "data/assign_roles" 14 | CONFIG_FILE_PATH = DATA_FOLDER + "/config.json" 15 | 16 | CONFIG_DEFAULT = {} # Structure: {server.id: {giveable_role: [authorized_roles]}} 17 | 18 | ASSIGN_ADDED = ":white_check_mark: Successfully assigned the `{}` role." 19 | ASSIGN_REMOVED = ":put_litter_in_its_place: Successfully removed the `{}` role." 20 | ASSIGN_NO_EVERYONE = ":x: Error: you cannot give someone the Everyone role!" 21 | AUTHORIZE_EXISTS = ":x: Error: the role you want to authorized is already authorized to give this role." 22 | AUTHORIZE_EMPTY = ":x: Error: `{}` is not authorized to be assigned by any other roles." 23 | AUTHORIZE_MISMATCH = ":x: Error: {} is not currently authorized to give the `{}` role." 24 | AUTHORIZE_NO_EVERYONE = ":x: Error: you cannot authorize everyone to give a role!" 25 | AUTHORIZE_NO_HIGHER = ":x: Error: you cannot authorize a role that is not below your highest role!" 26 | AUTHORIZE_SUCCESS = ":white_check_mark: Successfully authorized `{}` to assign the `{}` role." 27 | CLEAN_SUCCESS = ":white_check_mark: Successfully cleaned the role authorizations." 28 | DEAUTHORIZE_SUCCESS = ":put_litter_in_its_place: Successfully de-authorized `{}` to assign the `{}` role." 29 | LIST_DESC_NORMAL = "The roles below can be given by the mentioned roles." 30 | LIST_DESC_EMPTY = "No roles are authorized to give other roles." 31 | 32 | def __init__(self, bot: discord.Client): 33 | self.bot = bot 34 | self.check_configs() 35 | self.load_data() 36 | 37 | # Events 38 | 39 | # Commands 40 | @commands.group(name="assign", pass_context=True, invoke_without_command=True, no_pm=True) 41 | async def _assign(self, ctx, role: discord.Role, user: discord.User=None): 42 | """Assign a role to a user""" 43 | msg = ctx.message 44 | author = msg.author 45 | if user is None: 46 | user = author 47 | server_dict = self.config.setdefault(msg.server.id, {}) 48 | role_id = role.id 49 | 50 | if role.is_everyone: 51 | notice = self.ASSIGN_NO_EVERYONE 52 | elif role_id not in server_dict: # No role authorized to give this role. 53 | notice = self.AUTHORIZE_EMPTY.format(role.name) 54 | # Check if any of the author's roles is authorized to grant the role. 55 | elif not any(r.id in server_dict[role_id] for r in author.roles): 56 | notice = self.AUTHORIZE_MISMATCH.format(author.mention, role.name) 57 | else: # Role "transaction" is valid. 58 | if role in user.roles: 59 | await self.bot.remove_roles(user, role) 60 | notice = self.ASSIGN_REMOVED.format(role.name) 61 | else: 62 | await self.bot.add_roles(user, role) 63 | notice = self.ASSIGN_ADDED.format(role.name) 64 | await self.bot.send_message(msg.channel, notice) 65 | 66 | @_assign.command(pass_context=True, no_pm=True) 67 | @checks.admin_or_permissions(manage_server=True) 68 | async def authorize(self, ctx, authorized_role: discord.Role, giveable_role: discord.Role): 69 | """Authorize one role to give another role 70 | 71 | Allows all members with the role `authorized_role` to give the role `giveable_role` to everyone. 72 | In order to authorize, your highest role must be strictly higher than `authorized_role`.""" 73 | msg = ctx.message 74 | server_dict = self.config.setdefault(msg.server.id, {}) 75 | 76 | author_max_role = max(r for r in msg.author.roles) 77 | authorized_id = authorized_role.id 78 | giveable_id = giveable_role.id 79 | 80 | if authorized_role.is_everyone: # Role to be authorized should not be @everyone. 81 | notice = self.AUTHORIZE_NO_EVERYONE 82 | elif authorized_role >= author_max_role: # Hierarchical role order check. 83 | notice = self.AUTHORIZE_NO_HIGHER 84 | # Check if "pair" already exists. 85 | elif giveable_id in server_dict and authorized_id in server_dict[giveable_id]: 86 | notice = self.AUTHORIZE_EXISTS 87 | else: # Role authorization is valid. 88 | server_dict.setdefault(giveable_id, []).append(authorized_id) 89 | self.save_data() 90 | notice = self.AUTHORIZE_SUCCESS.format(authorized_role.name, giveable_role.name) 91 | await self.bot.send_message(msg.channel, notice) 92 | 93 | @_assign.command(pass_context=True, no_pm=True) 94 | @checks.admin_or_permissions(manage_server=True) 95 | async def deauthorize(self, ctx, authorized_role: discord.Role, giveable_role: discord.Role): 96 | """De-authorize one role to give another role 97 | 98 | In order to de-authorize, your highest role must be strictly higher than `authorized_role`.""" 99 | msg = ctx.message 100 | server_dict = self.config.setdefault(msg.server.id, {}) 101 | 102 | author_max_role = max(r for r in msg.author.roles) 103 | authorized_id = authorized_role.id 104 | giveable_id = giveable_role.id 105 | 106 | if authorized_role.is_everyone: # Role to be de-authorized should not be @everyone. 107 | notice = self.AUTHORIZE_NO_EVERYONE 108 | elif authorized_role >= author_max_role: # Hierarchical role order check. 109 | notice = self.AUTHORIZE_NO_HIGHER 110 | elif giveable_id not in server_dict: 111 | notice = self.AUTHORIZE_EMPTY.format(giveable_role.name) 112 | elif authorized_id not in server_dict[giveable_id]: 113 | notice = self.AUTHORIZE_MISMATCH.format(authorized_role.name, giveable_role.name) 114 | else: # Role de-authorization is valid. 115 | server_dict[giveable_id].remove(authorized_id) 116 | self.save_data() 117 | notice = self.DEAUTHORIZE_SUCCESS.format(authorized_role.name, giveable_role.name) 118 | await self.bot.send_message(msg.channel, notice) 119 | 120 | @_assign.command(pass_context=True, no_pm=True) 121 | @checks.mod_or_permissions(manage_server=True) 122 | async def list(self, ctx): 123 | """Send an embed showing which roles can be given by other roles""" 124 | msg = ctx.message 125 | srv = msg.server 126 | server_dict = self.config.setdefault(srv.id, {}) 127 | embed = discord.Embed(colour=0x00D8FF, title="Assign authorizations") 128 | 129 | for role_id, auth_list in server_dict.items(): 130 | role = discord.utils.get(srv.roles, id=role_id) 131 | if role is not None: 132 | auth_roles = (discord.utils.get(srv.roles, id=i) for i in auth_list) 133 | mentions_str = ", ".join(r.mention for r in auth_roles if r is not None) 134 | if len(mentions_str) > 0: # Prevent empty fields from being sent. 135 | embed.add_field(name=role.name, value=mentions_str) 136 | 137 | embed.description = self.LIST_DESC_EMPTY if len(embed.fields) == 0 else self.LIST_DESC_NORMAL 138 | await self.bot.send_message(msg.channel, embed=embed) 139 | 140 | # Config 141 | def check_configs(self): 142 | self.check_folders() 143 | self.check_files() 144 | 145 | def check_folders(self): 146 | if not os.path.exists(self.DATA_FOLDER): 147 | os.makedirs(self.DATA_FOLDER, exist_ok=True) 148 | 149 | def check_files(self): 150 | self.check_file(self.CONFIG_FILE_PATH, self.CONFIG_DEFAULT) 151 | 152 | def check_file(self, file, default): 153 | if not dataIO.is_valid_json(file): 154 | dataIO.save_json(file, default) 155 | 156 | def load_data(self): 157 | self.config = dataIO.load_json(self.CONFIG_FILE_PATH) 158 | 159 | def save_data(self): 160 | dataIO.save_json(self.CONFIG_FILE_PATH, self.config) 161 | 162 | 163 | def setup(bot): 164 | bot.add_cog(AssignRoles(bot)) 165 | -------------------------------------------------------------------------------- /assign_roles/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "#s#8059", 3 | "INSTALL_MSG" : "Enjoy your assign_roles cog!", 4 | "NAME" : "AssignRoles", 5 | "SHORT" : "Allow users with specific roles to give other roles to other users", 6 | "DESCRIPTION" : "Basically the Manage Roles permission, but specific to pairs of roles.\nFor example, allow role A to give role B to anyone else, but without allowing role A to give role C, D, etc.", 7 | "TAGS" : ["role", "permission", "assign"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /birthdays/birthdays.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import os.path 4 | import os 5 | import datetime 6 | import itertools 7 | import contextlib 8 | 9 | from discord.ext import commands 10 | from .utils import checks 11 | from .utils.dataIO import dataIO 12 | 13 | 14 | class Birthdays: 15 | """Announces people's birthdays and gives them a birthday role for the whole UTC day""" 16 | 17 | # File related constants 18 | DATA_FOLDER = "data/birthdays" 19 | CONFIG_FILE_PATH = DATA_FOLDER + "/config.json" 20 | 21 | # Configuration default 22 | CONFIG_DEFAULT = { 23 | "roles": {}, # {server.id: role.id} of the birthday roles 24 | "channels": {}, # {server.id: channel.id} of the birthday announcement channels 25 | "birthdays": {}, # {date: {user.id: year}} of the users' birthdays 26 | "yesterday": [] # List of user ids who's birthday was done yesterday 27 | } 28 | 29 | # Message constants 30 | ROLE_SET = ":white_check_mark: The birthday role on **{s}** has been set to: **{r}**." 31 | BDAY_INVALID = ":x: The birthday date you entered is invalid. It must be `MM-DD`." 32 | BDAY_SET = ":white_check_mark: Your birthday has been set to: **{}**." 33 | CHANNEL_SET = ":white_check_mark: The channel for announcing birthdays on **{s}** has been set to: **{c}**." 34 | BDAY_REMOVED = ":put_litter_in_its_place: Your birthday has been removed." 35 | 36 | def __init__(self, bot: discord.Client): 37 | self.bot = bot 38 | self.check_configs() 39 | self.load_data() 40 | self.bday_loop = asyncio.ensure_future(self.initialise()) # Starts a loop which checks daily for birthdays 41 | 42 | # Events 43 | async def initialise(self): 44 | await self.bot.wait_until_ready() 45 | with contextlib.suppress(RuntimeError): 46 | while self == self.bot.get_cog(self.__class__.__name__): # Stops the loop when the cog is reloaded 47 | now = datetime.datetime.utcnow() 48 | tomorrow = (now + datetime.timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) 49 | await asyncio.sleep((tomorrow - now).total_seconds()) 50 | self.clean_yesterday_bdays() 51 | self.do_today_bdays() 52 | self.save_data() 53 | 54 | def __unload(self): 55 | self.bday_loop.cancel() # Forcefully cancel the loop when unloaded 56 | 57 | # Commands 58 | @commands.group(pass_context=True, invoke_without_command=True) 59 | async def bday(self, ctx): 60 | """Birthday settings""" 61 | await self.bot.send_cmd_help(ctx) 62 | 63 | @bday.command(name="channel", pass_context=True, no_pm=True) 64 | @checks.mod_or_permissions(manage_roles=True) 65 | async def bday_channel(self, ctx, channel: discord.Channel): 66 | """Sets the birthday announcement channel for this server""" 67 | message = ctx.message 68 | c = message.channel 69 | server = message.server 70 | self.config["channels"][server.id] = channel.id 71 | self.save_data() 72 | await self.bot.send_message(c, self.CHANNEL_SET.format(s=server.name, c=channel.name)) 73 | 74 | @bday.command(name="role", pass_context=True, no_pm=True) 75 | @checks.mod_or_permissions(manage_roles=True) 76 | async def bday_role(self, ctx, role: discord.Role): 77 | """Sets the birthday role for this server""" 78 | message = ctx.message 79 | channel = message.channel 80 | server = message.server 81 | self.config["roles"][server.id] = role.id 82 | self.save_data() 83 | await self.bot.send_message(channel, self.ROLE_SET.format(s=server.name, r=role.name)) 84 | 85 | @bday.command(name="remove", aliases=["del", "clear", "rm"], pass_context=True) 86 | async def bday_remove(self, ctx): 87 | """Unsets your birthday date""" 88 | message = ctx.message 89 | channel = message.channel 90 | author = message.author 91 | self.remove_user_bday(author.id) 92 | self.save_data() 93 | await self.bot.send_message(channel, self.BDAY_REMOVED) 94 | 95 | @bday.command(name="set", pass_context=True) 96 | async def bday_set(self, ctx, date, year: int=None): 97 | """Sets your birthday date 98 | 99 | The given date must be given as: MM-DD 100 | Year is optional. If ungiven, the age won't be displayed.""" 101 | message = ctx.message 102 | channel = message.channel 103 | author = message.author 104 | birthday = self.parse_date(date) 105 | if birthday is None: 106 | await self.bot.send_message(channel, self.BDAY_INVALID) 107 | else: 108 | self.remove_user_bday(author.id) 109 | self.config["birthdays"].setdefault(str(birthday.toordinal()), {})[author.id] = year 110 | self.save_data() 111 | bday_month_str = birthday.strftime("%B") 112 | bday_day_str = birthday.strftime("%d").lstrip("0") # To remove the zero-capped 113 | await self.bot.send_message(channel, self.BDAY_SET.format(bday_month_str + " " + bday_day_str)) 114 | 115 | @bday.command(name="list", pass_context=True) 116 | async def bday_list(self, ctx): 117 | """Lists the birthdays 118 | 119 | If a user has their year set, it will display the age they'll get after their birthday this year""" 120 | message = ctx.message 121 | channel = message.channel 122 | self.clean_bdays() 123 | self.save_data() 124 | bdays = self.config["birthdays"] 125 | this_year = datetime.date.today().year 126 | embed = discord.Embed(title="Birthday List", color=discord.Colour.lighter_grey()) 127 | for k, g in itertools.groupby(sorted(datetime.datetime.fromordinal(int(o)) for o in bdays.keys()), 128 | lambda i: i.month): 129 | # Basically separates days with "\n" and people on the same day with ", " 130 | value = "\n".join(date.strftime("%d").lstrip("0") + ": " 131 | + ", ".join("<@!{}>".format(u_id) 132 | + ("" if year is None else " ({})".format(this_year - int(year))) 133 | for u_id, year in bdays.get(str(date.toordinal()), {}).items()) 134 | for date in g if len(bdays.get(str(date.toordinal()))) > 0) 135 | if not value.isspace(): # Only contains whitespace when there's no birthdays in that month 136 | embed.add_field(name=datetime.datetime(year=1, month=k, day=1).strftime("%B"), value=value) 137 | await self.bot.send_message(channel, embed=embed) 138 | 139 | # Utilities 140 | async def clean_bday(self, user_id): 141 | for server_id, role_id in self.config["roles"].items(): 142 | server = self.bot.get_server(server_id) 143 | if server is not None: 144 | role = discord.utils.find(lambda r: r.id == role_id, server.roles) 145 | # If discord.Server.roles was an OrderedDict instead... 146 | member = server.get_member(user_id) 147 | if member is not None and role is not None and role in member.roles: 148 | # If the user and the role are still on the server and the user has the bday role 149 | await self.bot.remove_roles(member, role) 150 | 151 | async def handle_bday(self, user_id, year): 152 | embed = discord.Embed(color=discord.Colour.gold()) 153 | if year is not None: 154 | age = datetime.date.today().year - int(year) # Doesn't support non-western age counts but whatever 155 | embed.description = "<@!{}> is now **{} years old**. :tada:".format(user_id, age) 156 | else: 157 | embed.description = "It's <@!{}>'s birthday today! :tada:".format(user_id) 158 | for server_id, channel_id in self.config["channels"].items(): 159 | server = self.bot.get_server(server_id) 160 | if server is not None: # Ignore unavailable servers or servers the bot isn't in anymore 161 | member = server.get_member(user_id) 162 | if member is not None: 163 | role_id = self.config["roles"].get(server_id) 164 | if role_id is not None: 165 | role = discord.utils.find(lambda r: r.id == role_id, server.roles) 166 | if role is not None: 167 | try: 168 | await self.bot.add_roles(member, role) 169 | except (discord.Forbidden, discord.HTTPException): 170 | pass 171 | else: 172 | self.config["yesterday"].append(member.id) 173 | channel = server.get_channel(channel_id) 174 | if channel is not None: 175 | await self.bot.send_message(channel, embed=embed) 176 | 177 | def clean_bdays(self): 178 | """Cleans the birthday entries with no user's birthday 179 | Also removes birthdays of users who aren't in any visible server anymore 180 | 181 | Happens when someone changes their birthday and there's nobody else in the same day""" 182 | birthdays = self.config["birthdays"] 183 | for date, bdays in birthdays.copy().items(): 184 | for user_id, year in bdays.copy().items(): 185 | if not any(s.get_member(user_id) is not None for s in self.bot.servers): 186 | del birthdays[date][user_id] 187 | if len(bdays) == 0: 188 | del birthdays[date] 189 | 190 | def remove_user_bday(self, user_id): 191 | for date, user_ids in self.config["birthdays"].items(): 192 | if user_id in user_ids: 193 | del self.config["birthdays"][date][user_id] 194 | # Won't prevent the cleaning problem here cause the users can leave so we'd still want to clean anyway 195 | 196 | def clean_yesterday_bdays(self): 197 | for user_id in self.config["yesterday"]: 198 | asyncio.ensure_future(self.clean_bday(user_id)) 199 | self.config["yesterday"].clear() 200 | 201 | def do_today_bdays(self): 202 | this_date = datetime.datetime.utcnow().date().replace(year=1) 203 | for user_id, year in self.config["birthdays"].get(str(this_date.toordinal()), {}).items(): 204 | asyncio.ensure_future(self.handle_bday(user_id, year)) 205 | 206 | def parse_date(self, date_str): 207 | result = None 208 | try: 209 | result = datetime.datetime.strptime(date_str, "%m-%d").date().replace(year=1) 210 | except ValueError: 211 | pass 212 | return result 213 | 214 | # Config 215 | def check_configs(self): 216 | self.check_folders() 217 | self.check_files() 218 | 219 | def check_folders(self): 220 | if not os.path.exists(self.DATA_FOLDER): 221 | print("Creating data folder...") 222 | os.makedirs(self.DATA_FOLDER, exist_ok=True) 223 | 224 | def check_files(self): 225 | self.check_file(self.CONFIG_FILE_PATH, self.CONFIG_DEFAULT) 226 | 227 | def check_file(self, file, default): 228 | if not dataIO.is_valid_json(file): 229 | print("Creating empty " + file + "...") 230 | dataIO.save_json(file, default) 231 | 232 | def load_data(self): 233 | self.config = dataIO.load_json(self.CONFIG_FILE_PATH) 234 | 235 | def save_data(self): 236 | dataIO.save_json(self.CONFIG_FILE_PATH, self.config) 237 | 238 | 239 | def setup(bot): 240 | # Creating the cog 241 | cog = Birthdays(bot) 242 | # Finally, add the cog to the bot. 243 | bot.add_cog(cog) 244 | -------------------------------------------------------------------------------- /birthdays/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Enjoy your birthday cog!\nRemember to setup the role and channel or else it will only act as a list of birthdays :wink:", 4 | "NAME" : "Birthdays", 5 | "SHORT" : "Announce birthdays and give a role during people's birthdays", 6 | "DESCRIPTION" : "Users set their birthday and optionaly their age and they automatically receive a role on their birthday with an announcement in a channel", 7 | "TAGS" : ["birthday"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /client_modification/client_modification.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | 4 | 5 | class ClientModification: 6 | """Cog which provides endpoints to use modified Client features 7 | Usage example: bot.get_cog("ClientModification").add_cached_message(msg) 8 | 9 | Currently supports: 10 | - Adding messages to the client message cache 11 | 12 | This cog mostly exists because I was denied a change on discord.connection to allow adding messages into the cache. 13 | This could've been done cleanly by adding a side dictionary of messages cached by the client which can be 14 | manipulated by cogs through discord.Client, but I was told this change didn't have it's place in discord.py. 15 | 16 | We need to be able to add messages into the cache to listen to events on messages which weren't received while the 17 | bot was online. This is because discord.py doesn't throw events when they happen on an Object which isn't in 18 | cache. This is because Discord only sends the message id for events which happen on it so the library isn't 19 | able to send an event with a Message object. The developers decided they just wouldn't send the event if that 20 | happened. The only alternative they give us is on_socket_raw_receive, but that would me rewriting all the 21 | parsing internally in every cog which needs it and that's very redundant. 22 | 23 | As an example, if someone wanted to listen to reactions on a specific message which could've been posted months 24 | ago, you wouldn't be able to go grab the actual message object through endpoints and add it to the cache to then 25 | receive the events because Danny decided so. So here's a monkey patch to support it. 26 | 27 | If you want to fight for it to be added natively, go ahead. I failed at expressing the need for it when I tried. 28 | 29 | Changes like these are in this centralized cog to prevent conflicts when monkey patching. 30 | Basically to ensure `revert_modifications` doesn't remove another monkey patch which might've been done later""" 31 | 32 | def __init__(self, bot): 33 | self.bot = bot 34 | asyncio.ensure_future(self._init_modifications()) 35 | self.cached_messages = {} 36 | 37 | # Events 38 | async def _init_modifications(self): 39 | await self.bot.wait_until_ready() 40 | self._init_message_modifs() 41 | 42 | def __unload(self): 43 | # This method is ran whenever the bot unloads this cog. 44 | self.revert_modifications() 45 | 46 | # Endpoints 47 | def add_cached_messages(self, messages): 48 | self.cached_messages.update((m.id, m) for m in messages if isinstance(m, discord.Message)) 49 | 50 | def add_cached_message(self, message): 51 | if isinstance(message, discord.Message): 52 | self.cached_messages[message.id] = message 53 | 54 | def remove_cached_message(self, message): 55 | if isinstance(message, discord.Message): 56 | if message.id in self.cached_messages: 57 | del self.cached_messages[message.id] 58 | elif isinstance(message, str): 59 | if message in self.cached_messages: 60 | del self.cached_messages[message] 61 | 62 | # Utilities 63 | def _init_message_modifs(self): 64 | def _get_modified_message(message_id): 65 | message = None 66 | cm = self.bot.get_cog("ClientModification") 67 | # Checking if ClientModification is still loaded in case it was unloaded without reverting this 68 | if cm is not None: 69 | message = cm.cached_messages.get(message_id) 70 | return message or self.__og_get_message(message_id) 71 | self.__og_get_message = self.bot.connection._get_message 72 | self.bot.connection._get_message = _get_modified_message 73 | 74 | def revert_modifications(self): 75 | self.bot.connection._get_message = self.__og_get_message 76 | 77 | 78 | def setup(bot): 79 | bot.add_cog(ClientModification(bot)) 80 | -------------------------------------------------------------------------------- /client_modification/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Thanks for installing my ClientModification cog.", 4 | "NAME" : "ClientModification", 5 | "SHORT" : "Enables obscure features needed by my other cogs.", 6 | "DESCRIPTION" : "Mainly used to enable obscure features on my other cogs.", 7 | "TAGS" : [], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /embed_reactor/embed_reactor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import os.path 4 | import os 5 | import logging 6 | import re 7 | import contextlib 8 | 9 | from typing import List 10 | 11 | from discord.ext import commands 12 | from discord.ext.commands.context import Context 13 | from .utils.dataIO import dataIO 14 | from .utils import checks 15 | 16 | 17 | class EmbedReactor: 18 | """Reacts to embeds in specific channels""" 19 | 20 | DATA_FOLDER = "data/embed_reactor" 21 | CONFIG_FILE_PATH = DATA_FOLDER + "/config.json" 22 | 23 | CONFIG_DEFAULT = {} 24 | 25 | REACTION_CAP = 20 26 | EMOTE_REGEX = re.compile("") 27 | URL_REGEX = re.compile("?") 28 | 29 | REMOVED_CHANNEL_REACTOR = ":put_litter_in_its_place: Successfully removed the reactor from {}." 30 | INVALID_EMOTES = ":x: The following emotes are invalid: {}." 31 | SET_CHANNEL_REACTOR = ":white_check_mark: Successfully set the channel reactor for {} to {}." 32 | LACKING_PERMISSIONS = ":x: I don't have the permission to add reactions in that channel." 33 | LACKING_PERMISSIONS_TO_TEST = ":x: I can't test the existence of those emotes because I can't add reactions here." 34 | MUST_BE_SERVER_CHANNEL = ":x: The channel must be in a server." 35 | TOO_MANY_REACTIONS = ":x: Too many reactions! I can't add more than {} reactions.".format(REACTION_CAP) 36 | 37 | def __init__(self, bot: discord.Client): 38 | self.bot = bot 39 | self.logger = logging.getLogger("red.ZeCogs.embed_reactor") 40 | self.check_configs() 41 | self.load_data() 42 | self.emote_cache = {} 43 | self.preprocessed_config = {} 44 | asyncio.ensure_future(self.initialize()) 45 | 46 | # Events 47 | async def initialize(self): 48 | await self.bot.wait_until_ready() 49 | for server in self.bot.servers: 50 | for emote in server.emojis: 51 | self.emote_cache[emote.id] = emote 52 | 53 | for channel_id, reactions in self.config.items(): 54 | channel_cache = self.preprocessed_config.setdefault(channel_id, []) 55 | for reaction in reactions: 56 | channel_cache.append(self.find_emote(reaction) or reaction) 57 | 58 | async def on_message(self, message: discord.Message): 59 | reactions = self.preprocessed_config.get(message.channel.id) 60 | if reactions is not None: 61 | match = self.URL_REGEX.search(message.content) 62 | if len(message.attachments) > 0 or (match and not match.group(0).startswith("<") and not match.group(0).endswith(">")): 63 | for reaction in reactions: 64 | with contextlib.suppress(Exception): 65 | await self.bot.add_reaction(message, reaction) 66 | 67 | async def on_server_emojis_update(self, before: List[discord.Emoji], after: List[discord.Emoji]): 68 | after = {e.id: e for e in after} 69 | before_ids, after_ids = set(e.id for e in before), set(after) 70 | for i in before_ids - after_ids: 71 | del self.emote_cache[i] 72 | for i in after_ids - before_ids: 73 | self.emote_cache[i] = after[i] 74 | 75 | # Commands 76 | @commands.command(name="embed_reactor", pass_context=True) 77 | @checks.mod_or_permissions(manage_channels=True) 78 | async def _embed_reactor(self, ctx: Context, channel: discord.Channel, *reactions): 79 | """Sets the reactions added to embeds in a channel 80 | 81 | If no reaction is given, resets the reactions for the channel 82 | reactions is a list of space-separated emojis (server or unicode) 83 | Custom server emojis must be visible to the bot 84 | Example to set 👍 👎 as reactions: [p]embed_reactor #my-channel 👍 👎 85 | Example to remove the reactions: [p]embed_reactor #my-channel""" 86 | message = ctx.message 87 | invalid_emotes = [] 88 | if len(reactions) <= self.REACTION_CAP: 89 | for emote in reactions: 90 | if await self.is_valid_emote(emote, message) is False: 91 | invalid_emotes.append(emote) 92 | 93 | if len(reactions) > self.REACTION_CAP: 94 | response = self.TOO_MANY_REACTIONS 95 | elif len(reactions) == 0: 96 | self.config.pop(channel.id, None) 97 | self.save_data() 98 | self.preprocessed_config.pop(channel.id, None) 99 | response = self.REMOVED_CHANNEL_REACTOR.format(channel.mention) 100 | elif channel.server is None: 101 | response = self.MUST_BE_SERVER_CHANNEL 102 | elif not channel.permissions_for(channel.server.me).add_reactions: 103 | response = self.LACKING_PERMISSIONS 104 | elif len(invalid_emotes) > 0: 105 | if message.channel.server is not None and not channel.permissions_for(channel.server.me).add_reactions: 106 | response = self.LACKING_PERMISSIONS_TO_TEST 107 | else: 108 | response = self.INVALID_EMOTES.format(", ".join(invalid_emotes)) 109 | else: 110 | self.config[channel.id] = reactions 111 | self.save_data() 112 | self.preprocessed_config[channel.id] = [self.find_emote(emote) or emote for emote in reactions] 113 | response = self.SET_CHANNEL_REACTOR.format(channel.mention, ", ".join(reactions)) 114 | await self.bot.send_message(message.channel, response) 115 | 116 | # Utilities 117 | async def is_valid_emote(self, emote: str, message: discord.Message) -> bool: 118 | emote_match = self.EMOTE_REGEX.fullmatch(emote) 119 | emote_id = emote if emote_match is None else emote_match.group(1) 120 | server_emote = self.find_emote(emote_id) 121 | try: 122 | await self.bot.add_reaction(message, server_emote or emote_id) 123 | except discord.HTTPException: # Failed to find the emoji 124 | result = False 125 | else: 126 | await self.bot.remove_reaction(message, server_emote or emote_id, self.bot.user) 127 | result = True 128 | return result 129 | 130 | def find_emote(self, emote: str): 131 | return self.emote_cache.get(emote) 132 | 133 | # Config 134 | def check_configs(self): 135 | self.check_folders() 136 | self.check_files() 137 | 138 | def check_folders(self): 139 | self.check_folder(self.DATA_FOLDER) 140 | 141 | def check_folder(self, name): 142 | if not os.path.exists(name): 143 | self.logger.debug("Creating " + name + " folder...") 144 | os.makedirs(name, exist_ok=True) 145 | 146 | def check_files(self): 147 | self.check_file(self.CONFIG_FILE_PATH, self.CONFIG_DEFAULT) 148 | 149 | def check_file(self, file, default): 150 | if not dataIO.is_valid_json(file): 151 | self.logger.debug("Creating empty " + file + "...") 152 | dataIO.save_json(file, default) 153 | 154 | def load_data(self): 155 | self.config = dataIO.load_json(self.CONFIG_FILE_PATH) 156 | 157 | def save_data(self): 158 | dataIO.save_json(self.CONFIG_FILE_PATH, self.config) 159 | 160 | 161 | def setup(bot): 162 | bot.add_cog(EmbedReactor(bot)) 163 | -------------------------------------------------------------------------------- /embed_reactor/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Enjoy adding reactions to every embed in some channels!", 4 | "NAME" : "EmbedReactor", 5 | "SHORT" : "Allows you to add specific reactions to every embed sent in specified channels.", 6 | "DESCRIPTION" : "Choose which reactions to send to embeds sent in a channel. You can send different reactions in different channels as desired.", 7 | "TAGS" : ["embed", "reaction", "administration"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /help_autodelete/help_autodelete.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import copy 3 | import discord 4 | import os 5 | import os.path 6 | import re 7 | 8 | from discord.ext import commands 9 | from .utils.dataIO import dataIO 10 | from .utils import checks 11 | 12 | 13 | class HelpAutoDelete: 14 | """Cog which allows you to set a timeout after which help messages delete themselves""" 15 | 16 | DATA_FOLDER = "data/client_modification" 17 | CONFIG_FILE_PATH = DATA_FOLDER + "/config.json" 18 | 19 | SERVER_DEFAULT = {"help_timeout": 0} 20 | CONFIG_DEFAULT = {} 21 | 22 | _MENTIONS_REPLACE = { 23 | '@everyone': '@\u200beveryone', 24 | '@here': '@\u200bhere' 25 | } 26 | _MENTION_PATTERN = re.compile('|'.join(_MENTIONS_REPLACE.keys())) 27 | 28 | def __init__(self, bot): 29 | self.bot = bot 30 | self.check_configs() 31 | self.load_data() 32 | asyncio.ensure_future(self._init_modifications()) 33 | 34 | # Events 35 | async def _init_modifications(self): 36 | await self.bot.wait_until_ready() 37 | self._init_help_modif() 38 | 39 | def __unload(self): 40 | # This method is ran whenever the bot unloads this cog. 41 | self.revert_modifications() 42 | 43 | # Commands 44 | @commands.command(name="help_timeout", pass_context=True, no_pm=True) 45 | @checks.admin_or_permissions(manage_server=True) 46 | async def _set_help_timeout(self, ctx, timeout: float): 47 | """Sets the timeout for the help message in the current server""" 48 | if timeout >= 0: 49 | conf = self.get_config(ctx.message.server.id) 50 | conf["help_timeout"] = timeout 51 | self.save_data() 52 | if ctx.message.channel.permissions_for(ctx.message.channel.server.me).manage_messages: 53 | await self.bot.delete_message(ctx.message) 54 | 55 | # Utilities 56 | def _init_help_modif(self): 57 | self.__og_default_help_cmd = self.bot.commands["help"].callback 58 | self.__og_send_cmd_help = self.bot.send_cmd_help 59 | self.bot.commands["help"].callback = self._default_help_command 60 | self.bot.send_cmd_help = self.send_cmd_help 61 | 62 | async def send_cmd_help(self, ctx): # Used users FailFish a command 63 | invoked_command = ctx.invoked_subcommand if ctx.invoked_subcommand else ctx.command 64 | pages = self.bot.formatter.format_help_for(ctx, invoked_command) 65 | await self.temp_send(ctx.message.channel, pages, [ctx.message]) 66 | 67 | async def _default_help_command(self, ctx, *cmds: str): # [p]help 68 | """Shows this message""" 69 | bot = ctx.bot 70 | destination = ctx.message.author if bot.pm_help else ctx.message.channel 71 | 72 | def repl(obj): 73 | return self._MENTIONS_REPLACE.get(obj.group(0), "") 74 | 75 | pages = None 76 | command = bot 77 | for key in cmds: 78 | name = self._MENTION_PATTERN.sub(repl, key) 79 | if name in bot.cogs: 80 | command = bot.cogs.get(name) 81 | elif isinstance(command, discord.ext.commands.GroupMixin): 82 | command = command.commands.get(name) 83 | if command is None: 84 | pages = [bot.command_not_found.format(name)] 85 | break 86 | else: 87 | pages = [bot.command_has_no_subcommands.format(command, name)] 88 | break 89 | if pages is None: 90 | pages = bot.formatter.format_help_for(ctx, command) 91 | 92 | if bot.pm_help is None: 93 | characters = sum(map(lambda l: len(l), pages)) 94 | if characters > 1000: 95 | destination = ctx.message.author 96 | 97 | await self.temp_send(destination, pages, [ctx.message]) # All of that copy paste for this one line change 98 | 99 | def revert_modifications(self): 100 | self.bot.send_cmd_help = self.__og_send_cmd_help 101 | self.bot.commands["help"].callback = self.__og_default_help_cmd 102 | 103 | async def temp_send(self, channel, pages, msgs): 104 | for page in pages: 105 | msgs.append(await self.bot.send_message(channel, page)) 106 | if self and hasattr(channel, "server"): 107 | config = self.get_config(channel.server.id) 108 | if config is not None: 109 | seconds = config.get("help_timeout", 0) 110 | if seconds > 0: 111 | await asyncio.sleep(seconds) 112 | await self.delete_messages(msgs) 113 | 114 | async def delete_messages(self, messages): 115 | while len(messages) > 0: 116 | if len(messages) == 1: 117 | await self.bot.delete_message(messages[0]) 118 | messages = messages[:-1] 119 | else: 120 | await self.bot.delete_messages(messages[-100:]) 121 | messages = messages[:-100] 122 | 123 | # Config 124 | def get_config(self, server_id): 125 | config = self.config.get(server_id) 126 | if config is None: 127 | config = copy.deepcopy(self.SERVER_DEFAULT) 128 | self.config[server_id] = config 129 | return self.config.get(server_id) 130 | 131 | def check_configs(self): 132 | self.check_folders() 133 | self.check_files() 134 | 135 | def check_folders(self): 136 | if not os.path.exists(self.DATA_FOLDER): 137 | print("Creating data folder...") 138 | os.makedirs(self.DATA_FOLDER, exist_ok=True) 139 | 140 | def check_files(self): 141 | self.check_file(self.CONFIG_FILE_PATH, self.CONFIG_DEFAULT) 142 | 143 | def check_file(self, file, default): 144 | if not dataIO.is_valid_json(file): 145 | print("Creating empty " + file + "...") 146 | dataIO.save_json(file, default) 147 | 148 | def load_data(self): 149 | # Here, you load the data from the config file. 150 | self.config = dataIO.load_json(self.CONFIG_FILE_PATH) 151 | 152 | def save_data(self): 153 | # Save all the data (if needed) 154 | dataIO.save_json(self.CONFIG_FILE_PATH, self.config) 155 | 156 | 157 | def setup(bot): 158 | bot.add_cog(HelpAutoDelete(bot)) 159 | -------------------------------------------------------------------------------- /help_autodelete/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Thanks for installing my HelpAutoDelete cog.\nThis also allows you to make the help messages autodelete after a configurable amount of time.", 4 | "NAME" : "HelpAutoDelete", 5 | "SHORT" : "Make help messages autodelete after a while.", 6 | "DESCRIPTION" : "Allows you to configure an amount of time after which help messages will self-destruct.", 7 | "TAGS" : [], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /indexed_search/indexed_search.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import os.path 4 | import os 5 | import logging 6 | import datetime 7 | import typing 8 | import aiohttp 9 | import json 10 | 11 | from .utils import checks 12 | from .utils.dataIO import dataIO 13 | from discord.ext import commands 14 | from discord.ext.commands.context import Context 15 | 16 | 17 | MessageList = typing.List[discord.Message] 18 | TreeCache = typing.Dict[str, typing.Set[discord.Message]] 19 | 20 | 21 | class IndexedSearch: 22 | """Search through predefined channels to find messages which contain""" 23 | 24 | # Config 25 | DATA_FOLDER = "data/indexed_search" 26 | CONFIG_FILE_PATH = DATA_FOLDER + "/cache.json" 27 | DEFAULT_SERVER_CONFIG = { 28 | "haystacks": {}, # {name: channel.id} 29 | "maximum_days": 3, # Maximum number of days to go back to build the cache 30 | "abbreviations": {} # {abbreviation: word} 31 | } 32 | 33 | # Behavior constants 34 | TEMP_MESSAGE_TIMEOUT = 60 * 5 # seconds 35 | DOWNLOAD_TIMEOUT = 15 # seconds 36 | DOWNLOAD_HEADERS = {"User-Agent": "Mozilla"} 37 | 38 | # Time humanization 39 | TIME_FORMATS = ["{} seconds", "{} minutes", "{} hours", "{} days", "{} weeks"] 40 | TIME_FRACTIONS = [60, 60, 24, 7] 41 | 42 | # Messages 43 | CATEGORY_NOT_FOUND = ":x: Category not found" 44 | WAITING = "The search cache is not fully loaded yet. Waiting..." 45 | SEARCH_RESULT_TITLE = "Searched: '{}'" 46 | SEARCH_RESULT_DESCRIPTION = "Results found in the latest {time} of messages in {channel}" 47 | SEARCH_RESULT_MESSAGE = "{author} **{time} ago**: {content}" 48 | SEARCH_RESULT_MORE = "And more..." 49 | NO_RESULTS_EMBED = {"title": "Searched: '{}'", 50 | "description": "No one has posted that term in the last {time}.", 51 | "colour": discord.Colour.red()} 52 | CHANNEL_ALREADY_INDEXED = ":x: The channel {} is already indexed" 53 | CATEGORY_ALREADY_USED = ":x: The category `{}` is already used" 54 | INDEX_ADDED = ":white_check_mark: The channel {} is now indexed under `{}`" 55 | INDEX_LIST_EMBED = {"title": "Indexes in {}", "description": "", "colour": discord.Colour.light_grey()} 56 | INDEX_LIST_ENTRY = "{name} --> <#{channel}>" 57 | INDEX_LIST_EMPTY = "There is no index here" 58 | CHANNEL_NOT_INDEXED = ":x: {} is already not indexed" 59 | INDEX_REMOVED = ":put_litter_in_its_place: The index for {channel} has been deleted" 60 | MAX_DAYS_SET = ":white_check_mark: The maximum number of days to go back has been set to {days} in {server}" 61 | CANNOT_HAVE_NEGATIVE_DAYS = ":x: The maximum number of days cannot be negative" 62 | NO_SEARCH_TEXT = ":x: You must search for something" 63 | MUST_PROVIDE_ONE_JSON = ":x: You must provide one .json file" 64 | SUCCESSFULLY_SET_ABBREVIATIONS = ":white_check_mark: Successfully imported the abbreviations" 65 | PROVIDED_JSON_INVALID = ":x: The JSON file you provided is invalid" 66 | ABBREV_LIST_EMBED = {"title": "List of abbreviations in {server}", 67 | "colour": discord.Colour.lighter_grey(), 68 | "description": ""} 69 | ABBREV_LIST_EMPTY = "The abbreviation list is empty" 70 | 71 | def __init__(self, bot: discord.Client): 72 | self.bot = bot 73 | self.logger = logging.getLogger("red.ZeCogs.indexed_search") 74 | self.check_configs() 75 | self.load_data() 76 | self.cache_ready = asyncio.Event() 77 | self.cache = {} # {server.id: {channel.id: TreeCache}} 78 | self.session = None 79 | asyncio.ensure_future(self.fetch_cache()) 80 | 81 | # Events 82 | async def on_message(self, message: discord.Message): 83 | self._on_message_action(parse=message) 84 | 85 | async def on_message_edit(self, before: discord.Message, after: discord.Message): 86 | self._on_message_action(remove=before, parse=after) 87 | 88 | async def on_message_delete(self, message: discord.Message): 89 | self._on_message_action(remove=message) 90 | 91 | def __unload(self): 92 | if self.session is not None: 93 | self.session.close() 94 | 95 | # Commands 96 | @commands.group(name="search", pass_context=True, no_pm=True, invoke_without_command=True) 97 | async def _search(self, ctx: Context, index: str, *, text: str): 98 | """Search through the index for the given text 99 | 100 | The category must be a valid index given by [p]search list_index""" 101 | words = text.split(" ") 102 | message = ctx.message 103 | server = message.server 104 | args = [] 105 | kwargs = {} 106 | server_config = self.config.get(server.id, self.DEFAULT_SERVER_CONFIG) 107 | channel_id = server_config["haystacks"].get(index.lower()) 108 | trees = self.cache.get(server.id, {}).get(channel_id) 109 | if len(words) == 0: 110 | args.append(self.NO_SEARCH_TEXT) 111 | elif trees is None: 112 | args.append(self.CATEGORY_NOT_FOUND) 113 | else: 114 | if not self.cache_ready.is_set(): 115 | msg = await self.bot.send_message(message.channel, self.WAITING) 116 | await self.cache_ready.wait() 117 | await self.bot.delete_message(msg) 118 | abbrevs = server_config["abbreviations"] 119 | msgs = self.find_in_trees(self.wordify(abbrevs, words), abbrevs, trees) 120 | days_diff = datetime.timedelta(days=server_config["maximum_days"]) 121 | humanized_days = self.humanize_time(days_diff.total_seconds()) 122 | minimum_date = datetime.datetime.utcnow() - days_diff 123 | msgs = sorted(filter(lambda m: m[1].timestamp > minimum_date, msgs), 124 | key=lambda m: m[1].timestamp, reverse=True) 125 | if len(msgs) > 0: 126 | embed = self.build_search_embed(words, abbrevs, humanized_days, msgs[:10]) 127 | if len(msgs) > 10: 128 | embed.set_footer(text=self.SEARCH_RESULT_MORE) 129 | else: 130 | embed = discord.Embed(**self.NO_RESULTS_EMBED) 131 | embed.title = embed.title.format(text) 132 | embed.description = embed.description.format(time=humanized_days) 133 | kwargs["embed"] = embed 134 | await self.temp_send(message.channel, [message], *args, **kwargs) 135 | 136 | @_search.command(name="add_index", pass_context=True, no_pm=True) 137 | @checks.mod_or_permissions(manage_channels=True) 138 | async def _search_add_index(self, ctx: Context, channel: discord.Channel, name: str): 139 | """Add a channel to the index 140 | 141 | Adds the `channel` to the indexed channels with `name` as it's index name 142 | The `name` is case insensitive""" 143 | message = ctx.message 144 | server = message.server 145 | server_conf = self.config.setdefault(server.id, self.DEFAULT_SERVER_CONFIG) 146 | haystacks = server_conf["haystacks"] 147 | if channel.id in haystacks.values(): 148 | response = self.CHANNEL_ALREADY_INDEXED.format(channel.mention) 149 | elif name.lower() in haystacks: 150 | response = self.CATEGORY_ALREADY_USED.format(name) 151 | else: 152 | haystacks[name.lower()] = channel.id 153 | await self.fetch_channel_cache(channel, datetime.timedelta(days=server_conf["maximum_days"])) 154 | self.save_data() 155 | response = self.INDEX_ADDED.format(channel.mention, name) 156 | await self.temp_send(message.channel, [message], response) 157 | 158 | @_search.command(name="list_index", aliases=["list_indexes"], pass_context=True, no_pm=True) 159 | async def _search_list_indexes(self, ctx: Context): 160 | """Lists the search indexes of the current server""" 161 | message = ctx.message 162 | server = message.server 163 | haystacks = self.config.get(server.id, {}).get("haystacks", {}) 164 | embed = discord.Embed(**self.INDEX_LIST_EMBED) 165 | embed.title = embed.title.format(server.name) 166 | for name, channel_id in haystacks.items(): 167 | embed.description += self.INDEX_LIST_ENTRY.format(channel=channel_id, name=name) + "\n" 168 | embed.description = embed.description or self.INDEX_LIST_EMPTY 169 | await self.temp_send(message.channel, [message], embed=embed) 170 | 171 | @_search.command(name="remove_index", aliases=["del_index"], pass_context=True, no_pm=True) 172 | @checks.mod_or_permissions(manage_channels=True) 173 | async def _search_remove_index(self, ctx: Context, channel: discord.Channel): 174 | """Removes a channel from the index""" 175 | message = ctx.message 176 | server = message.server 177 | server_conf = self.config.get(server.id, self.DEFAULT_SERVER_CONFIG) 178 | pair = discord.utils.find(lambda o: o[1] == channel.id, server_conf["haystacks"].items()) 179 | if pair is None: 180 | response = self.CHANNEL_NOT_INDEXED.format(channel.mention) 181 | else: 182 | server_conf["haystacks"].pop(pair[0], ...) 183 | self.save_data() 184 | self.cache.get(server.id, {}).pop(pair[1], ...) 185 | response = self.INDEX_REMOVED.format(channel=channel.mention) 186 | await self.temp_send(message.channel, [message], response) 187 | 188 | @_search.command(name="set_max_days", aliases=["set_days"], pass_context=True, no_pm=True) 189 | @checks.mod_or_permissions(manage_channels=True) 190 | async def _search_set_max_days(self, ctx: Context, days: float): 191 | """Sets the maximum number of days to search back 192 | 193 | The number of days can have a fractional part (ie.: 2.5 days = 2 days 12 hours)""" 194 | message = ctx.message 195 | if days <= 0: 196 | response = self.CANNOT_HAVE_NEGATIVE_DAYS 197 | else: 198 | server = message.server 199 | server_conf = self.config.setdefault(server.id, self.DEFAULT_SERVER_CONFIG) 200 | server_conf["maximum_days"] = days 201 | self.save_data() 202 | self.cache.clear() 203 | await self.fetch_cache() 204 | response = self.MAX_DAYS_SET.format(server=server.name, days=days) 205 | await self.temp_send(message.channel, [message], response) 206 | 207 | @_search.command(name="import_abbreviations", aliases=["import_abbrevs", "import"], pass_context=True, no_pm=True) 208 | @checks.mod_or_permissions(manage_channels=True) 209 | async def _search_import_abbreviations(self, ctx): 210 | """Sets the abbreviations 211 | 212 | You must attach a JSON file with the command (must be a .json file) 213 | The JSON file must be an object containing key/value pairs of abbreviation/word 214 | Example of a JSON's contents: { 215 | "hi": "hello", 216 | "greetings": "hello", 217 | "ez": "easy" 218 | }""" 219 | message = ctx.message 220 | attachments = [attachment for attachment in message.attachments if attachment["filename"].endswith(".json")] 221 | if len(attachments) != 1: 222 | response = self.MUST_PROVIDE_ONE_JSON 223 | else: 224 | abbreviations = await self.download_json_file(attachments[0]["url"]) 225 | if abbreviations is None or \ 226 | not all((isinstance(k, str) and isinstance(v, str)) 227 | for k, v in abbreviations.items()): 228 | response = self.PROVIDED_JSON_INVALID 229 | else: 230 | server_conf = self.config.setdefault(message.server.id, self.DEFAULT_SERVER_CONFIG) 231 | server_conf["abbreviations"] = abbreviations 232 | self.save_data() 233 | response = self.SUCCESSFULLY_SET_ABBREVIATIONS 234 | await self.temp_send(message.channel, [message], response) 235 | 236 | @_search.command(name="list_abbreviations", aliases=["list_abbrevs"], pass_context=True, no_pm=True) 237 | @checks.mod_or_permissions(manage_channels=True) 238 | async def _search_list_abbreviations(self, ctx): 239 | """Lists the current server's abbreviations""" 240 | message = ctx.message 241 | embed = discord.Embed(**self.ABBREV_LIST_EMBED) 242 | embed.title = embed.title.format(server=message.server.name) 243 | abbrevs = self.config.get(message.server.id, self.DEFAULT_SERVER_CONFIG)["abbreviations"] 244 | if len(abbrevs) == 0: 245 | embed.description = self.ABBREV_LIST_EMPTY 246 | for abbrev, word in abbrevs.items(): 247 | embed.description += "{} **-->** {}\n".format(abbrev, word) 248 | await self.temp_send(message.channel, [message], embed=embed) 249 | 250 | # Utilities 251 | async def download_json_file(self, url: str) -> dict: 252 | """Downloads the content of "url" into a BytesIO object asynchronously""" 253 | if self.session is None: 254 | self.session = aiohttp.ClientSession() 255 | async with self.session.get(url, timeout=self.DOWNLOAD_TIMEOUT, headers=self.DOWNLOAD_HEADERS) as response: 256 | try: 257 | content = await response.json() 258 | except (json.JSONDecodeError, aiohttp.ClientResponseError): 259 | content = None 260 | else: 261 | if not isinstance(content, dict): 262 | content = None 263 | return content 264 | 265 | def build_search_embed(self, search_terms: typing.List[str], abbrevs: typing.Dict[str, str], days: str, 266 | results: typing.List[typing.Tuple[int, discord.Message]]) -> discord.Embed: 267 | embed = discord.Embed(title=self.SEARCH_RESULT_TITLE.format(" ".join(search_terms))) 268 | embed.description = self.SEARCH_RESULT_DESCRIPTION.format(time=days, channel=results[0][1].channel.mention) 269 | embed.description += "\n" 270 | embed.colour = discord.Colour.green() 271 | search_terms = self.wordify(abbrevs, search_terms) 272 | now = datetime.datetime.utcnow() 273 | for i, message in results: 274 | author = message.author.mention 275 | content = message.content.splitlines()[i] 276 | raw_words = content.split(" ") 277 | words = self.wordify(abbrevs, raw_words) 278 | match = self.subsequence_in_sequence(words, search_terms) 279 | raw_words[match] = "`" + raw_words[match] 280 | raw_words[match + len(search_terms) - 1] = raw_words[match + len(search_terms) - 1] + "`" 281 | content = " ".join(raw_words) 282 | time_diff = now - message.timestamp 283 | time = self.humanize_time(time_diff.total_seconds()) 284 | embed.description += "\n" + self.SEARCH_RESULT_MESSAGE.format(author=author, time=time, content=content) 285 | return embed 286 | 287 | async def fetch_cache(self): 288 | self.cache_ready.clear() 289 | await self.bot.wait_until_ready() 290 | for server_config in self.config.values(): 291 | go_back = datetime.timedelta(days=server_config["maximum_days"]) 292 | for channel_id in server_config["haystacks"].values(): 293 | channel = self.bot.get_channel(channel_id) 294 | if channel is not None: 295 | await self.fetch_channel_cache(channel, go_back) 296 | self.cache_ready.set() 297 | 298 | async def fetch_channel_cache(self, channel: discord.Channel, go_back: datetime.timedelta): 299 | trees = self.cache.setdefault(channel.server.id, {}).setdefault(channel.id, {}) 300 | after = datetime.datetime.utcnow() - go_back 301 | total = 0 302 | keep_going = True 303 | while keep_going: 304 | count = 0 305 | async for message in self.bot.logs_from(channel, after=after, reverse=True): 306 | self.parse_message(message, trees) 307 | count += 1 308 | after = message 309 | keep_going = count == 100 310 | total += count 311 | self.logger.info("Cached {} messages for #{}".format(total, channel.name)) 312 | 313 | def find_in_trees(self, search_terms: typing.List[str], abbrevs: typing.Dict[str, str], tree: TreeCache) \ 314 | -> typing.List[typing.Tuple[int, discord.Message]]: 315 | result = [] 316 | msgs = tree.get(search_terms[0], set()) 317 | for msg in msgs: 318 | try: 319 | line = discord.utils.find(self.create_matcher(abbrevs, search_terms), 320 | enumerate(msg.content.splitlines()))[0] 321 | except TypeError as e: 322 | self.logger.warning("Error: {}, Search terms: {}, message's lines: {}".format(e, search_terms, 323 | msg.content.splitlines())) 324 | else: 325 | if line is not None: 326 | result.append((line, msg)) 327 | return result 328 | 329 | def create_matcher(self, abbrevs: typing.Dict[str, str], terms: typing.List[str]): 330 | def match_line(line: str): 331 | match = self.subsequence_in_sequence(self.wordify(abbrevs, line[1].split(" ")), 332 | self.wordify(abbrevs, terms)) 333 | return match is not None 334 | return match_line 335 | 336 | def wordify(self, abbrevs: typing.Dict[str, str], word_list: typing.List[str]) -> typing.List[str]: 337 | result = [] 338 | for word in word_list: 339 | word = word.lower().strip() 340 | result.append(abbrevs.get(word, word)) 341 | return result 342 | 343 | def subsequence_in_sequence(self, source, target, start=0, end=None): 344 | """Naive search for target in source""" 345 | m = len(source) 346 | n = len(target) 347 | if end is None: 348 | end = m 349 | else: 350 | end = min(end, m) 351 | if n == 0 or (end - start) < n: 352 | # target is empty, or longer than source, so obviously can't be found. 353 | return None 354 | for i in range(start, end - n + 1): 355 | if source[i:i + n] == target: 356 | return i 357 | return None 358 | 359 | def parse_message(self, message: discord.Message, tree: TreeCache): 360 | self._do_for_word_on_cache(message, tree, set.add) 361 | 362 | def remove_message(self, message: discord.Message, tree: TreeCache): 363 | self._do_for_word_on_cache(message, tree, set.discard) 364 | 365 | def _do_for_word_on_cache(self, message: discord.Message, tree: TreeCache, 366 | func: typing.Callable[[set, discord.Message], None]): 367 | if message is not None: 368 | abbrevs = self.config.get(message.server.id, self.DEFAULT_SERVER_CONFIG)["abbreviations"] 369 | for word in message.content.split(" "): 370 | word = word.lower().strip() 371 | word = abbrevs.get(word, word) 372 | func(tree.setdefault(word, set()), message) 373 | 374 | def _on_message_action(self, *, parse: discord.Message=None, remove: discord.Message=None): 375 | either = parse or remove 376 | server = either.server 377 | if server is not None and self.cache_ready.is_set(): 378 | tree = self.cache.get(server.id, {}).get(either.channel.id) 379 | if tree is not None: 380 | self.remove_message(remove, tree) 381 | self.parse_message(parse, tree) 382 | 383 | async def temp_send(self, channel: discord.Channel, messages: MessageList, *args, **kwargs): 384 | """Sends a message with *args **kwargs in `channel` and deletes it after some time 385 | 386 | If sleep_timeout is given as a named parameter (in kwargs), uses it 387 | Else it defaults to TEMP_MESSAGE_TIMEOUT 388 | 389 | Deletes all messages in `messages` if we have the manage_messages perms 390 | Else, deletes only the sent message""" 391 | sleep_timeout = kwargs.pop("sleep_timeout", self.TEMP_MESSAGE_TIMEOUT) 392 | messages.append(await self.bot.send_message(channel, *args, **kwargs)) 393 | await asyncio.sleep(sleep_timeout) 394 | await self.delete_messages(messages) 395 | 396 | async def delete_messages(self, messages: MessageList): 397 | """Deletes an arbitrary number of messages by batches 398 | 399 | Basically runs discord.Client.delete_messages for every 100 messages until none are left""" 400 | messages = list(filter(self.message_filter, messages)) 401 | while len(messages) > 0: 402 | if len(messages) == 1: 403 | await self.bot.delete_message(messages.pop()) 404 | else: 405 | await self.bot.delete_messages(messages[-100:]) 406 | messages = messages[:-100] 407 | 408 | def message_filter(self, message: discord.Message) -> bool: 409 | result = False 410 | channel = message.channel 411 | if not channel.is_private: 412 | if channel.permissions_for(channel.server.me).manage_messages: 413 | result = True 414 | return result 415 | 416 | def humanize_time(self, time: int) -> str: 417 | """Returns a string of the humanized given time keeping only the 2 biggest formats 418 | Examples: 419 | 1661410 --> 2 weeks 5 days (hours, mins, seconds are ignored) 420 | 30 --> 30 seconds""" 421 | times = [] 422 | # 90 --> divmod(90, 60) --> (1, 30) --> (1m + 30s) 423 | for time_f in zip(self.TIME_FRACTIONS, self.TIME_FORMATS): 424 | time, units = divmod(time, time_f[0]) 425 | if units > 0: 426 | times.append(self.plural_format(int(units), time_f[1])) 427 | if time > 0: 428 | times.append(self.plural_format(int(time), self.TIME_FORMATS[-1])) 429 | return times[-1] 430 | 431 | def plural_format(self, raw_amount: typing.Union[int, float], format_string: str, *, 432 | singular_format: str=None) -> str: 433 | """Formats a string for plural and singular forms of an amount 434 | 435 | The amount given is rounded. 436 | raw_amount is an integer (rounded if something else is given) 437 | format_string is the string to use when formatting in plural 438 | singular_format is the string to use for singular 439 | By default uses the plural and removes the last character""" 440 | amount = round(raw_amount) 441 | result = format_string.format(raw_amount) 442 | if singular_format is None: 443 | result = format_string.format(raw_amount)[:-1 if amount == 1 else None] 444 | elif amount == 1: 445 | result = singular_format.format(raw_amount) 446 | return result 447 | 448 | # Config 449 | def check_configs(self): 450 | self.check_folders() 451 | self.check_files() 452 | 453 | def check_folders(self): 454 | if not os.path.exists(self.DATA_FOLDER): 455 | self.logger.debug("Creating data folder...") 456 | os.makedirs(self.DATA_FOLDER, exist_ok=True) 457 | 458 | def check_files(self): 459 | self.check_file(self.CONFIG_FILE_PATH, {}) 460 | 461 | def check_file(self, file, default): 462 | if not dataIO.is_valid_json(file): 463 | self.logger.debug("Creating empty " + file + "...") 464 | dataIO.save_json(file, default) 465 | 466 | def load_data(self): 467 | self.config = dataIO.load_json(self.CONFIG_FILE_PATH) 468 | 469 | def save_data(self): 470 | dataIO.save_json(self.CONFIG_FILE_PATH, self.config) 471 | 472 | 473 | def setup(bot): 474 | bot.add_cog(IndexedSearch(bot)) 475 | -------------------------------------------------------------------------------- /indexed_search/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Thanks for installing my IndexedSearch cog.", 4 | "NAME" : "IndexedSearch", 5 | "SHORT" : "Specify channels to be indexed and potentially searched", 6 | "DESCRIPTION" : "Add channels to an index and allow users to search through the an index's latest messages. Supports 'abbreviations' aka synonyms.", 7 | "TAGS" : ["indexed", "textsearch"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Thanks for using my cogs repository.\nIf you run into errors `delimited like this` or have suggestions, you can send me a PM on Discord.", 4 | "NAME" : "ZeCogs", 5 | "SHORT" : "Various cogs, mostly from the Rocket League Discord", 6 | "DESCRIPTION" : "Various cogs including, but not limited to, voice channel locking, reaction-role associations, voice channel generation, timezone convertion, per-channel slowmode, and reminders." 7 | } 8 | -------------------------------------------------------------------------------- /message_proxy/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Thanks for installing my MessageProxy cog.", 4 | "NAME" : "MessageProxy", 5 | "SHORT" : "Send and edit messages through the bot to allow multiple people to manage the same message.", 6 | "DESCRIPTION" : "Send and edit messages through the bot thus allowing different people access to the same message.", 7 | "TAGS" : ["message_proxy"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /message_proxy/message_proxy.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import copy 3 | import discord 4 | import os.path 5 | import os 6 | import math 7 | import logging 8 | import io 9 | 10 | import aiohttp # Ensured by discord since this is a dependency of discord.py 11 | 12 | from discord.ext import commands 13 | from .utils import checks 14 | from .utils.dataIO import dataIO 15 | 16 | 17 | class MessageProxy: 18 | """Send and edit messages through the bot""" 19 | 20 | # Message constants 21 | MESSAGE_LINK = "" 22 | MESSAGE_SENT = ":white_check_mark: Sent " + MESSAGE_LINK 23 | FAILED_TO_FIND_MESSAGE = ":x: Failed to find the message with id {} in {}." 24 | COMMAND_FORMAT = "{p}msg edit <#{c_id}> {m_id} ```\n{content}```" 25 | 26 | def __init__(self, bot: discord.Client): 27 | self.bot = bot 28 | self.logger = logging.getLogger("red.ZeCogs.message_proxy") 29 | 30 | # Commands 31 | @commands.group(name="message", aliases=["msg"], pass_context=True, no_pm=True, invoke_without_command=True) 32 | @checks.mod_or_permissions(manage_server=True) 33 | async def _messages(self, ctx): 34 | """Message proxy""" 35 | await self.bot.send_cmd_help(ctx) 36 | 37 | @_messages.command(name="send", pass_context=True) 38 | @checks.mod_or_permissions(manage_server=True) 39 | async def _messages_send(self, ctx, channel: discord.Channel, *, content=None): 40 | """Send a message in the given channel 41 | 42 | An attachment can be provided. 43 | If no content is provided, at least an attachment must be provided.""" 44 | message = ctx.message 45 | attachment = self.get_attachment(message) 46 | if attachment is not None: 47 | async with aiohttp.ClientSession() as session: 48 | async with session.get(url=attachment[0], headers={"User-Agent": "Mozilla"}) as response: 49 | file = io.BytesIO(await response.read()) 50 | msg = await self.bot.send_file(channel, file, content=content and "Placeholder", filename=attachment[1]) 51 | else: 52 | msg = await self.bot.send_message(channel, "Placeholder") 53 | if content is not None: 54 | await self.bot.edit_message(msg, new_content=content) 55 | reply = self.COMMAND_FORMAT.format(p=ctx.prefix, content=content, m_id=msg.id, c_id=channel.id) 56 | await self.bot.delete_message(ctx.message) 57 | else: 58 | reply = self.MESSAGE_SENT.format(m=msg.id, c=channel.id, s=channel.server.id) 59 | await self.bot.send_message(message.channel, reply) 60 | 61 | @_messages.command(name="edit", pass_context=True) 62 | @checks.mod_or_permissions(manage_server=True) 63 | async def _messages_edit(self, ctx, channel: discord.Channel, message_id: str, *, new_content): 64 | """Edit the message with id message_id in the given channel 65 | 66 | No attachment can be provided.""" 67 | try: 68 | msg = await self.bot.get_message(channel, message_id) 69 | except discord.errors.HTTPException: 70 | response = self.FAILED_TO_FIND_MESSAGE.format(message_id, channel.mention) 71 | else: 72 | await self.bot.edit_message(msg, new_content=new_content) 73 | response = self.COMMAND_FORMAT.format(p=ctx.prefix, content=new_content, m_id=msg.id, c_id=channel.id) 74 | await self.bot.delete_message(ctx.message) 75 | await self.bot.send_message(ctx.message.channel, response) 76 | 77 | # Utilities 78 | def get_attachment(self, message): 79 | if not message.attachments: 80 | return None 81 | attachment = message.attachments[0] 82 | return attachment["url"], attachment["filename"] 83 | 84 | 85 | def setup(bot): 86 | bot.add_cog(MessageProxy(bot)) 87 | -------------------------------------------------------------------------------- /periodic/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Enjoy sending messages periodically", 4 | "NAME" : "Periodic", 5 | "SHORT" : "Allows you to send messages or custom commands in channels periodically.", 6 | "DESCRIPTION" : "Send messages and/or custom commands in channels every X messages and/or every Y seconds. You can specify different messages/customcommands with different intervals in different channels.", 7 | "TAGS" : ["periodic", "messages", "automation", "administration"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /periodic/periodic.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import os.path 3 | import os 4 | import logging 5 | import asyncio 6 | import collections 7 | import random 8 | 9 | from discord.ext import commands 10 | from discord.ext.commands import Context 11 | 12 | from .utils.dataIO import dataIO 13 | from .utils import checks 14 | 15 | 16 | def escape(text): 17 | return text.replace("@", "@\u200b") 18 | 19 | 20 | class Periodic: 21 | """Sends messages periodically""" 22 | 23 | DATA_FOLDER = "data/periodic" 24 | CONFIG_FILE_PATH = DATA_FOLDER + "/config.json" 25 | 26 | CONFIG_DEFAULT = {} 27 | """ 28 | { 29 | server.id: { 30 | channel.id: { 31 | "messages": [ 32 | { 33 | "type": "message" | "customcommand", 34 | "value": "content" | "command", 35 | } 36 | ], 37 | "cursor": int, 38 | "time_interval": int, # seconds 39 | "message_interval": int, # messages 40 | "last_sent_id": str, 41 | } 42 | } 43 | } 44 | """ 45 | 46 | TEMP_MESSAGE_TIMEOUT = 30 47 | LOOP_DELETE_TIMEOUT = 1 48 | CUSTOMCOM_PREFIX = "\u200b" 49 | 50 | INTERVAL_POSITIVE = ":x: The given interval(s) must be positive!" 51 | AT_LEAST_ONE_INTERVAL = ":x: At least one interval must not be 0!" 52 | CANT_BE_PMS = ":x: The channel must be in a server!" 53 | ALREADY_EXISTS = ":x: That channel already has a periodic action!" 54 | CREATED = ":white_check_mark: The periodic action for {channel} has been created." 55 | DOESNT_EXIST = ":x: That channel doesn't have periodic actions! Use `{p}periodic create` to create it." 56 | ADDED_MESSAGE = ":white_check_mark: The message has been added!" 57 | CUSTOM_COMMAND_ADD_FOOTER = "\n**Note**: If the custom command doesn't exist when it's attempted to be sent, " \ 58 | "the bot will just ignore it and try another message." 59 | NOTHING_IN_THAT_CHANNEL = ":x: There is no periodic action in that channel." 60 | SHUFFLED_MESSAGES = "🔀 The messages have been shuffled." 61 | SUCCESSFULLY_DELETED = ":put_litter_in_its_place: That channel's periodic actions have been deleted." 62 | 63 | LIST_PREV_PAGE = "⬅" 64 | LIST_NEXT_PAGE = "➡" 65 | LIST_DELETE = "🚮" 66 | LIST_STOP = "🛑" 67 | LIST_DELETE_AFFIRM = "✅" 68 | LIST_DELETE_CANCEL = "❌" 69 | LIST_REACTIONS = LIST_PREV_PAGE, LIST_NEXT_PAGE, LIST_STOP, LIST_DELETE 70 | LIST_TITLE = "Periodic Actions List (#{} out of {})" 71 | LIST_DESCRIPTION = "**Type**: {type}\n**Content**: {value}" 72 | LIST_DELETE_CONFIRM = "Are you sure you want to delete the action #{}?" 73 | LIST_DELETED = "The action #{} has been deleted!" 74 | LIST_DELETE_CANCELED = "Cancelled." 75 | 76 | def __init__(self, bot: discord.Client): 77 | self.bot = bot 78 | self.logger = logging.getLogger("red.ZeCogs.periodic") 79 | self.check_configs() 80 | self.load_data() 81 | self.type_map = {"message": self.bot.send_message, "customcommand": self.send_customcom} 82 | self.channel_events = {} # channel.id: asyncio.Event 83 | self.channel_loops = {} # channel.id: asyncio.Future 84 | self.channel_triggers = {} # channel.id: [asyncio.Future] 85 | self.channel_messages = collections.Counter() # channel.id: count (decrements from message_interval to 0) 86 | asyncio.ensure_future(self.initialize()) 87 | 88 | # Events 89 | async def initialize(self): 90 | await self.bot.wait_until_ready() 91 | for server_id, server_conf in self.config.items(): 92 | server = self.bot.get_server(server_id) 93 | if server is not None: 94 | for channel_id, config in server_conf.items(): 95 | channel = server.get_channel(channel_id) 96 | if channel is not None: 97 | self.start_triggers(config, channel) 98 | 99 | async def wait_for_channel(self, channel: discord.Channel): 100 | while channel.id in self.channel_events: 101 | await self.channel_events[channel.id].wait() 102 | if channel.id in self.channel_events: 103 | self.stop_triggers(channel.id) 104 | config = self.get_config(channel.server.id, channel.id) 105 | messages = config["messages"] 106 | cursor = (config["cursor"] + len(messages)) % len(messages) 107 | og_cursor = cursor 108 | last_sent_id = config.get("last_sent_id") 109 | if last_sent_id is not None: 110 | try: 111 | msg = await self.bot.get_message(channel, last_sent_id) 112 | await self.bot.delete_message(msg) 113 | except discord.errors.DiscordException: 114 | pass 115 | output = None 116 | while output is None: 117 | chosen_one = messages[cursor] 118 | consumer = self.type_map[chosen_one["type"]] 119 | output = await consumer(channel, chosen_one["value"]) 120 | cursor = (cursor + 1) % len(messages) 121 | if cursor == og_cursor: # Gone full circle 122 | output = output or False 123 | if isinstance(output, discord.Message): 124 | config["last_sent_id"] = output.id 125 | config["cursor"] = cursor 126 | self.save_data() 127 | self.start_triggers(config, channel) 128 | 129 | async def on_message(self, message: discord.Message): 130 | channel_id = message.channel.id 131 | if not message.author.bot and channel_id in self.channel_messages: 132 | self.channel_messages[channel_id] -= 1 133 | if self.channel_messages[channel_id] <= 0: 134 | event = self.channel_events.get(channel_id) 135 | if event is not None: 136 | event.set() 137 | 138 | def __unload(self): 139 | for channel_id in self.channel_loops: 140 | asyncio.ensure_future(self.stop_loop(channel_id)) 141 | 142 | # Commands 143 | @commands.group(pass_context=True, invoke_without_command=True, no_pm=True) 144 | @checks.mod_or_permissions(manage_channels=True) 145 | async def periodic(self, ctx: Context): 146 | """Commands to configure the periodic actions""" 147 | await self.bot.send_cmd_help(ctx) 148 | 149 | @periodic.command(name="delete", aliases=["remove", "del"], pass_context=True, no_pm=True) 150 | @checks.mod_or_permissions(manage_channels=True) 151 | async def periodic_delete(self, ctx: Context, channel: discord.Channel): 152 | """Deletes a channel's periodic actions 153 | 154 | Note: This completely removes a channel, not just one of its actions""" 155 | config = self.get_config(channel.server.id, channel.id) 156 | if config is None: 157 | response = self.NOTHING_IN_THAT_CHANNEL 158 | else: 159 | response = self.SUCCESSFULLY_DELETED 160 | del self.channel_messages[channel.id] 161 | del self.config[channel.server.id][channel.id] 162 | await self.stop_loop(channel.id) 163 | self.save_data() 164 | await self.bot.send_message(ctx.message.channel, response) 165 | 166 | @periodic.command(name="create", pass_context=True, no_pm=True) 167 | @checks.mod_or_permissions(manage_channels=True) 168 | async def periodic_create(self, ctx: Context, channel: discord.Channel, time_interval: int, message_interval: int): 169 | """Creates a periodic action in a channel 170 | 171 | time_interval is the amount of time in between new posts (if zero (0), message_interval must be given) 172 | message_interval is the number of messages in between posts (if zero (0), time_interval must be given) 173 | 174 | Which ever comes first (time or messages) will trigger a post""" 175 | server = ctx.message.channel.server 176 | if time_interval < 0 or message_interval < 0: 177 | response = self.INTERVAL_POSITIVE 178 | elif time_interval == 0 and message_interval == 0: 179 | response = self.AT_LEAST_ONE_INTERVAL 180 | elif channel.server is None: 181 | response = self.CANT_BE_PMS 182 | elif self.get_config(server.id, channel.id) is not None: 183 | response = self.ALREADY_EXISTS 184 | else: 185 | config = self.create_periodic(server.id, channel.id) 186 | config["time_interval"] = time_interval 187 | config["message_interval"] = message_interval 188 | config["cursor"] = 0 189 | config["last_sent_id"] = None 190 | self.save_data() 191 | response = self.CREATED.format(channel=channel.mention) 192 | await self.bot.send_message(ctx.message.channel, response) 193 | 194 | @periodic.command(name="add_message", aliases=["add_m", "a_m"], pass_context=True, no_pm=True) 195 | @checks.mod_or_permissions(manage_channels=True) 196 | async def periodic_add_message(self, ctx: Context, channel: discord.Channel, *, content): 197 | """Adds the message to the channel's periodic actions""" 198 | reply_channel = ctx.message.channel 199 | config = self.get_config(reply_channel.server.id, channel.id) 200 | if config is None: 201 | response = self.DOESNT_EXIST.format(p=ctx.prefix) 202 | else: 203 | config["messages"].append({"type": "message", "value": escape(content)}) 204 | self.save_data() 205 | if len(config["messages"]) == 1: 206 | self.start_triggers(config, channel) 207 | response = self.ADDED_MESSAGE 208 | await self.bot.send_message(reply_channel, response) 209 | 210 | @periodic.command(name="add_command", aliases=["add_c", "a_c"], pass_context=True, no_pm=True) 211 | @checks.mod_or_permissions(manage_channels=True) 212 | async def periodic_add_command(self, ctx: Context, channel: discord.Channel, *, command): 213 | """Adds the customcommand to the channel's periodic actions""" 214 | reply_channel = ctx.message.channel 215 | config = self.get_config(reply_channel.server.id, channel.id) 216 | if config is None: 217 | response = self.DOESNT_EXIST.format(p=ctx.prefix) 218 | else: 219 | config["messages"].append({"type": "customcommand", "value": command}) 220 | self.save_data() 221 | if len(config["messages"]) == 1: 222 | self.start_triggers(config, channel) 223 | response = self.ADDED_MESSAGE + self.CUSTOM_COMMAND_ADD_FOOTER 224 | await self.bot.send_message(reply_channel, response) 225 | 226 | @periodic.command(name="shuffle", pass_context=True, no_pm=True) 227 | @checks.mod_or_permissions(manage_channels=True) 228 | async def periodic_shuffle(self, ctx: Context, channel: discord.Channel): 229 | """Shuffles a channel's periodic actions""" 230 | reply_channel = ctx.message.channel 231 | config = self.get_config(reply_channel.server.id, channel.id) 232 | if config is None: 233 | response = self.DOESNT_EXIST.format(p=ctx.prefix) 234 | else: 235 | random.shuffle(config["messages"]) 236 | self.save_data() 237 | response = self.SHUFFLED_MESSAGES 238 | await self.bot.send_message(reply_channel, response) 239 | 240 | @periodic.command(name="list", pass_context=True, no_pm=True) 241 | @checks.mod_or_permissions(manage_channels=True) 242 | async def periodic_list(self, ctx: Context, channel: discord.Channel): 243 | """Lists and allows you to delete entries from a channel's periodic actions""" 244 | reply_channel = ctx.message.channel 245 | author = ctx.message.author 246 | config = self.get_config(reply_channel.server.id, channel.id) 247 | if config is None or len(config["messages"]) == 0: 248 | await self.bot.send_message(reply_channel, self.NOTHING_IN_THAT_CHANNEL) 249 | else: 250 | messages = config["messages"] 251 | current = 0 252 | embed = discord.Embed(colour=discord.Colour.light_grey(), title="Processing...") 253 | msg = await self.bot.send_message(reply_channel, embed=embed) 254 | for e in self.LIST_REACTIONS: 255 | asyncio.ensure_future(self.bot.add_reaction(msg, e)) 256 | while current >= 0 and len(messages) > 0: 257 | pages = len(messages) 258 | current %= pages 259 | embed.title = self.LIST_TITLE.format(current, pages) 260 | embed.description = self.LIST_DESCRIPTION.format(**messages[current]) 261 | await self.bot.edit_message(msg, embed=embed) 262 | r, _ = await self.bot.wait_for_reaction(self.LIST_REACTIONS, user=author, message=msg) 263 | asyncio.ensure_future(self.bot.remove_reaction(msg, r.emoji, author)) 264 | if r.emoji == self.LIST_PREV_PAGE: 265 | current += pages - 1 266 | elif r.emoji == self.LIST_NEXT_PAGE: 267 | current += 1 268 | elif r.emoji == self.LIST_STOP: 269 | current = -1 270 | elif r.emoji == self.LIST_DELETE: 271 | confirm_msg = await self.bot.send_message(reply_channel, self.LIST_DELETE_CONFIRM.format(current)) 272 | asyncio.ensure_future(self.bot.add_reaction(confirm_msg, self.LIST_DELETE_AFFIRM)) 273 | asyncio.ensure_future(self.bot.add_reaction(confirm_msg, self.LIST_DELETE_CANCEL)) 274 | r, _ = await self.bot.wait_for_reaction((self.LIST_DELETE_AFFIRM, self.LIST_DELETE_CANCEL), 275 | user=author, message=confirm_msg) 276 | asyncio.ensure_future(self.bot.delete_message(confirm_msg)) 277 | if r.emoji == self.LIST_DELETE_AFFIRM: 278 | asyncio.ensure_future(self.temp_send(reply_channel, self.LIST_DELETED.format(current))) 279 | del messages[current] 280 | self.save_data() 281 | if len(messages) == 0: 282 | await self.stop_loop(channel.id) 283 | elif r.emoji == self.LIST_DELETE_CANCEL: 284 | asyncio.ensure_future(self.temp_send(reply_channel, self.LIST_DELETE_CANCELED)) 285 | await self.bot.delete_message(msg) 286 | 287 | # Utilities 288 | async def send_customcom(self, channel: discord.Channel, command_name: str): 289 | customcom = self.bot.get_cog("CustomCommands") 290 | cmds = customcom.c_commands.get(channel.server.id, {}) 291 | cmd = cmds.get(command_name) or cmds.get(command_name.lower()) 292 | return cmd and await self.bot.send_message(channel, self.CUSTOMCOM_PREFIX + escape(cmd)) 293 | 294 | def start_triggers(self, config: dict, channel: discord.Channel): 295 | event = self.channel_events.get(channel.id) or asyncio.Event() 296 | triggers = self.channel_triggers.setdefault(channel.id, []) 297 | if config.get("time_interval", 0) > 0: 298 | triggers.append(asyncio.ensure_future(self.call_later(config["time_interval"], event.set))) 299 | if config.get("message_interval", 0) > 0: 300 | self.channel_messages[channel.id] = config["message_interval"] 301 | if channel.id not in self.channel_events: 302 | self.channel_events[channel.id] = event 303 | else: 304 | event.clear() 305 | if channel.id not in self.channel_loops: 306 | self.channel_loops[channel.id] = asyncio.ensure_future(self.wait_for_channel(channel)) 307 | 308 | def stop_triggers(self, channel_id: str): 309 | triggers = self.channel_triggers.get(channel_id, []) 310 | while len(triggers) > 0: 311 | task = triggers.pop() 312 | if not task.done(): 313 | task.cancel() 314 | 315 | async def stop_loop(self, channel_id: str): 316 | self.stop_triggers(channel_id) 317 | event = self.channel_events.pop(channel_id, None) 318 | loop = self.channel_loops.pop(channel_id, None) 319 | if event is not None: 320 | event.set() # Gracefully stops the waiter 321 | if loop is not None: 322 | try: 323 | await asyncio.wait_for(loop, self.LOOP_DELETE_TIMEOUT) 324 | except asyncio.TimeoutError: 325 | self.logger.info("Had to forcefully cancel the waiter for {} when deleting".format(channel_id)) 326 | 327 | async def call_later(self, time_to_sleep: float, func): 328 | await asyncio.sleep(time_to_sleep) 329 | func() 330 | 331 | async def temp_send(self, channel: discord.Channel, *args, **kwargs): 332 | """Sends a message with *args **kwargs in `channel` and deletes it after some time 333 | 334 | If sleep_timeout is given as a named parameter (in kwargs), uses it 335 | Else it defaults to TEMP_MESSAGE_TIMEOUT""" 336 | sleep_timeout = kwargs.pop("sleep_timeout", self.TEMP_MESSAGE_TIMEOUT) 337 | message = await self.bot.send_message(channel, *args, **kwargs) 338 | await asyncio.sleep(sleep_timeout) 339 | await self.bot.delete_message(message) 340 | 341 | def create_periodic(self, server_id: str, channel_id: str) -> dict: 342 | return self.config.setdefault(server_id, {}).setdefault(channel_id, {"messages": []}) 343 | 344 | def get_config(self, server_id: str, channel_id: str) -> dict: 345 | return self.config.setdefault(server_id, {}).get(channel_id) 346 | 347 | # Config 348 | def check_configs(self): 349 | self.check_folders() 350 | self.check_files() 351 | 352 | def check_folders(self): 353 | self.check_folder(self.DATA_FOLDER) 354 | 355 | def check_folder(self, name: str): 356 | if not os.path.exists(name): 357 | self.logger.debug("Creating " + name + " folder...") 358 | os.makedirs(name, exist_ok=True) 359 | 360 | def check_files(self): 361 | self.check_file(self.CONFIG_FILE_PATH, self.CONFIG_DEFAULT) 362 | 363 | def check_file(self, file: str, default: dict): 364 | if not dataIO.is_valid_json(file): 365 | self.logger.debug("Creating empty " + file + "...") 366 | dataIO.save_json(file, default) 367 | 368 | def load_data(self): 369 | self.config = dataIO.load_json(self.CONFIG_FILE_PATH) 370 | 371 | def save_data(self): 372 | dataIO.save_json(self.CONFIG_FILE_PATH, self.config) 373 | 374 | 375 | def setup(bot): 376 | bot.add_cog(Periodic(bot)) 377 | -------------------------------------------------------------------------------- /react_roles/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Thanks for installing my role reactions cog and enjoy!\nTo get a message's ID you can right click a message and click Copy ID with the developer mode enabled.\nTo enable the developer go to Settings > Appearance > Advanced > Developer Mode", 4 | "NAME" : "ReactRole", 5 | "SHORT" : "Associate reactions on messages with a role", 6 | "DESCRIPTION" : "Associate reactions on messages with a role to give users a role when clicking the reaction or removing the role when they click again.\nImportant note: this requires my ClientModification cog (can be found on my repo) to work properly.", 7 | "TAGS" : ["reaction", "role", "utility"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /react_roles_bundled/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Thanks for installing my role reactions cog and enjoy!\nTo get a message's ID you can right click a message and click Copy ID with the developer mode enabled.\nTo enable the developer go to Settings > Appearance > Advanced > Developer Mode", 4 | "NAME" : "ReactRole", 5 | "SHORT" : "Associate reactions on messages with a role (bundled)", 6 | "DESCRIPTION" : "Associate reactions on messages with a role to give users a role when clicking the reaction or removing the role when they click again.\nImportant note: this does not require client_modification, but may break if other cogs modify discord.py's message cache (like this one). This cog may not always be up to date with the real one.", 7 | "TAGS" : ["reaction", "role", "utility"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /react_roles_bundled/react_roles_bundled.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import copy 3 | import discord 4 | import os.path 5 | import os 6 | import math 7 | import traceback 8 | import re 9 | import logging 10 | import itertools 11 | import contextlib 12 | 13 | from discord.ext import commands 14 | from .utils import checks 15 | from .utils.dataIO import dataIO 16 | 17 | 18 | class ReactRoles: 19 | """Associate emojis on messages with roles to gain/lose roles when clicking on reactions""" 20 | 21 | # File related constants 22 | DATA_FOLDER = "data/react_roles" 23 | CONFIG_FILE_PATH = DATA_FOLDER + "/config.json" 24 | 25 | # Configuration defaults 26 | SERVER_DEFAULT = {} 27 | CONFIG_DEFAULT = {} 28 | """ 29 | { 30 | server.id: { 31 | channel.id: { 32 | message.id: { 33 | emoji.id or str: role.id 34 | } 35 | } 36 | links: { 37 | name: [channel.id + "_" + message.id] 38 | } 39 | } 40 | }""" 41 | 42 | # Behavior related constants 43 | MAXIMUM_PROCESSED_PER_SECOND = 5 44 | EMOTE_REGEX = re.compile("") 45 | LINKS_ENTRY = "links" 46 | 47 | # Message constants 48 | PROGRESS_FORMAT = "Checked {c} out of {r} reactions out of {t} emojis." 49 | PROGRESS_COMPLETE_FORMAT = """:white_check_mark: Completed! Checked a total of {c} reactions. 50 | Gave a total of {g} roles.""" 51 | MESSAGE_NOT_FOUND = ":x: Message not found." 52 | ALREADY_BOUND = ":x: The emoji is already bound on that message." 53 | NOT_IN_SERVER = ":x: The channel must be in a server." 54 | ROLE_NOT_FOUND = ":x: Role not found on the given channel's server." 55 | EMOJI_NOT_FOUND = ":x: Emoji not found in any of my servers or in unicode emojis." 56 | CANT_ADD_REACTIONS = ":x: I don't have the permission to add reactions in that channel." 57 | CANT_MANAGE_ROLES = ":x: I don't have the permission to manage users' roles in the channel's server." 58 | ROLE_SUCCESSFULLY_BOUND = ":white_check_mark: The role has been bound to {} on the message in {}." 59 | ROLE_NOT_BOUND = ":x: The role is not bound to that message." 60 | ROLE_UNBOUND = ":put_litter_in_its_place: Unbound the role on the message.\n" 61 | REACTION_CLEAN_START = ROLE_UNBOUND + "Removing linked reactions..." 62 | PROGRESS_REMOVED = ROLE_UNBOUND + "Removed **{} / {}** reactions..." 63 | REACTION_CLEAN_DONE = ROLE_UNBOUND + "Removed **{}** reactions." 64 | LINK_MESSAGE_NOT_FOUND = "The following messages weren't found: {}" 65 | LINK_CHANNEL_NOT_FOUND = "The following channels weren't found: {}" 66 | LINK_PAIR_INVALID = "The following channel-message pairs were invalid: {}" 67 | LINK_FAILED = ":x: Failed to link reactions.\n" 68 | LINK_SUCCESSFUL = ":white_check_mark: Successfully linked the reactions." 69 | LINK_NAME_TAKEN = ":x: That link name is already used in the current server. Remove it before assigning to it." 70 | UNLINK_NOT_FOUND = ":x: Could not find a link with that name in this server." 71 | UNLINK_SUCCESSFUL = ":white_check_mark: The link has been removed from this server." 72 | CANT_CHECK_LINKED = ":x: Cannot run a check on linked messages." 73 | 74 | def __init__(self, bot: discord.Client): 75 | self.bot = bot 76 | self.logger = logging.getLogger("red.ZeCogs.react_roles") 77 | self.check_configs() 78 | self.load_data() 79 | self.cached_messages = {} 80 | self.role_queue = asyncio.Queue() 81 | self.role_map = {} 82 | self.role_cache = {} 83 | self.links = {} # {server.id: {channel.id_message.id: [role]}} 84 | self.processing_wait_time = 0 if self.MAXIMUM_PROCESSED_PER_SECOND == 0 else 1/self.MAXIMUM_PROCESSED_PER_SECOND 85 | asyncio.ensure_future(self._init_bot_manipulation()) 86 | self.role_processor = asyncio.ensure_future(self.process_role_queue()) 87 | 88 | # Events 89 | async def on_reaction_add(self, reaction, user): 90 | try: 91 | await self.check_add_role(reaction, user) 92 | except: # Didn't want the event listener to stop working when a random error happens 93 | traceback.print_exc() 94 | 95 | async def on_reaction_remove(self, reaction, user): 96 | try: 97 | await self.check_remove_role(reaction, user) 98 | except: # Didn't want the event listener to stop working when a random error happens 99 | traceback.print_exc() 100 | 101 | async def on_message_delete(self, message: discord.Message): 102 | # Remove the config too 103 | channel = message.channel 104 | if not channel.is_private: 105 | self.remove_cache_message(message) 106 | server = channel.server 107 | server_conf = self.get_config(server.id) 108 | channel_conf = server_conf.get(channel.id, {}) 109 | if message.id in channel_conf: 110 | del channel_conf[message.id] 111 | # And the cache 112 | self.remove_message_from_cache(server.id, channel.id, message.id) 113 | # And the links 114 | pair = channel.id + "_" + message.id 115 | if pair in self.links.get(server.id, {}): 116 | del self.links[server.id][pair] 117 | server_links = server_conf.get(self.LINKS_ENTRY) 118 | if server_links is not None: 119 | for links in server_links.values(): 120 | if pair in links: 121 | links.remove(pair) 122 | 123 | async def _init_bot_manipulation(self): 124 | await self.bot.wait_until_ready() 125 | # ClientModification bundle 126 | def _get_modified_message(message_id): 127 | message = None 128 | react_roles = self.bot.get_cog("ReactRoles") 129 | if react_roles is not None: 130 | message = react_roles.cached_messages.get(message_id) 131 | return message or self.__og_get_message(message_id) 132 | self.__og_get_message = self.bot.connection._get_message 133 | self.bot.connection._get_message = _get_modified_message 134 | 135 | for server_id, server_conf in self.config.items(): 136 | server = self.bot.get_server(server_id) 137 | if server is not None: 138 | for channel_id, channel_conf in filter(lambda o: o[0] != self.LINKS_ENTRY, server_conf.items()): 139 | channel = server.get_channel(channel_id) 140 | if channel is not None: 141 | for msg_id, msg_conf in channel_conf.items(): 142 | msg = await self.safe_get_message(channel, msg_id) 143 | if msg is not None: 144 | self.add_cache_message(msg) # This is where the magic happens. 145 | for emoji_str, role_id in msg_conf.items(): 146 | role = discord.utils.get(server.roles, id=role_id) 147 | if role is not None: 148 | self.add_to_cache(server_id, channel_id, msg_id, emoji_str, role) 149 | else: 150 | self.logger.warning("Could not find message {} in {}".format(msg_id, channel.mention)) 151 | else: 152 | self.logger.warning("Could not find channel with id {} in server {}".format(channel_id, 153 | server.name)) 154 | link_list = server_conf.get(self.LINKS_ENTRY) 155 | if link_list is not None: 156 | self.parse_links(server_id, link_list.values()) 157 | else: 158 | self.logger.warning("Could not find server with id {}".format(server_id)) 159 | 160 | def __unload(self): 161 | self.bot.connection._get_message = self.__og_get_message 162 | self.role_processor.cancel() 163 | 164 | # Commands 165 | @commands.group(name="roles", pass_context=True, no_pm=True, invoke_without_command=True) 166 | @checks.mod_or_permissions(manage_roles=True) 167 | async def _roles(self, ctx): 168 | """Roles giving configuration""" 169 | await self.bot.send_cmd_help(ctx) 170 | 171 | @_roles.command(name="linklist", pass_context=True, no_pm=True) 172 | @checks.mod_or_permissions(manage_roles=True) 173 | async def _roles_link_list(self, ctx): 174 | """Lists all reaction links in the current server""" 175 | message = ctx.message 176 | server = message.server 177 | server_conf = self.get_config(server.id) 178 | server_links = server_conf.get(self.LINKS_ENTRY, {}) 179 | embed = discord.Embed(title="Role Links", colour=discord.Colour.light_grey()) 180 | for name, pairs in server_links.items(): 181 | value = "" 182 | for channel, messages in itertools.groupby(pairs, key=lambda p: p.split("_")[0]): 183 | value += "<#{}>: ".format(channel) + ", ".join(p.split("_")[1] for p in messages) 184 | if len(value) > 0: 185 | embed.add_field(name=name, value=value) 186 | if len(embed.fields) == 0: 187 | embed.description = "There are no links in this server" 188 | await self.bot.send_message(message.channel, embed=embed) 189 | 190 | @_roles.command(name="unlink", pass_context=True, no_pm=True) 191 | @checks.mod_or_permissions(manage_roles=True) 192 | async def _roles_unlink(self, ctx, name: str): 193 | """Remove a link of messages by its name""" 194 | message = ctx.message 195 | server = message.server 196 | server_conf = self.get_config(server.id) 197 | server_links = server_conf.get(self.LINKS_ENTRY) 198 | name = name.lower() 199 | if server_links is None or name not in server_links: 200 | response = self.UNLINK_NOT_FOUND 201 | else: 202 | self.remove_links(server.id, name) 203 | del server_links[name] 204 | self.save_data() 205 | response = self.UNLINK_SUCCESSFUL 206 | await self.bot.send_message(message.channel, response) 207 | 208 | @_roles.command(name="link", pass_context=True, no_pm=True) 209 | @checks.mod_or_permissions(manage_roles=True) 210 | async def _roles_link(self, ctx, name: str, *linked_messages): 211 | """Link messages together to allow only one role from those messages to be given to a member 212 | 213 | name is the name of the link; used to make removal easier 214 | linked_messages is an arbitrary number of channelid-messageid 215 | You can get those channelid-messageid pairs with a shift right click on messages 216 | Users can only get one role out of all the reactions in the linked messages 217 | The bot will NOT remove the user's other reaction(s) when clicking within linked messages""" 218 | message = ctx.message 219 | server = message.server 220 | pairs = [] 221 | messages_not_found = [] 222 | channels_not_found = [] 223 | invalid_pairs = [] 224 | for pair in linked_messages: 225 | split_pair = pair.split("-", 1) 226 | if len(split_pair) == 2: 227 | channel_id, message_id = split_pair 228 | channel = server.get_channel(channel_id) 229 | if channel is not None: 230 | message = await self.safe_get_message(channel, message_id) 231 | if message is not None: 232 | pairs.append("_".join(split_pair)) 233 | else: 234 | messages_not_found.append(split_pair) 235 | else: 236 | channels_not_found.append(channel_id) 237 | else: 238 | invalid_pairs.append(pair) 239 | confimation_msg = "" 240 | if len(invalid_pairs) > 0: 241 | confimation_msg += self.LINK_PAIR_INVALID.format(", ".join(invalid_pairs)) + "\n" 242 | if len(channels_not_found) > 0: 243 | confimation_msg += self.LINK_CHANNEL_NOT_FOUND.format(", ".join(channels_not_found)) + "\n" 244 | if len(messages_not_found) > 0: 245 | confimation_msg += self.LINK_MESSAGE_NOT_FOUND.format( 246 | ", ".join("{} in <#{}>".format(p[0], p[1]) for p in messages_not_found)) + "\n" 247 | if len(confimation_msg) > 0: 248 | response = self.LINK_FAILED + confimation_msg 249 | else: 250 | server_conf = self.get_config(server.id) 251 | server_links = server_conf.setdefault(self.LINKS_ENTRY, {}) 252 | name = name.lower() 253 | if name in server_links: 254 | response = self.LINK_NAME_TAKEN 255 | else: 256 | server_links[name] = pairs 257 | self.save_data() 258 | self.parse_links(server.id, [pairs]) 259 | response = self.LINK_SUCCESSFUL 260 | await self.bot.send_message(message.channel, response) 261 | 262 | @_roles.command(name="add", pass_context=True, no_pm=True) 263 | @checks.mod_or_permissions(manage_roles=True) 264 | async def _roles_add(self, ctx, message_id, channel: discord.Channel, emoji, *, role: discord.Role): 265 | """Add a role on a message 266 | `message_id` must be found in `channel` 267 | `emoji` can either be a Unicode emoji or a server emote 268 | `role` must be found in the channel's server""" 269 | server = channel.server 270 | message = await self.safe_get_message(channel, message_id) 271 | if message is None: 272 | response = self.MESSAGE_NOT_FOUND 273 | else: 274 | msg_conf = self.get_message_config(server.id, channel.id, message.id) 275 | emoji_match = self.EMOTE_REGEX.fullmatch(emoji) 276 | emoji_id = emoji if emoji_match is None else emoji_match.group(1) 277 | if emoji_id in msg_conf: 278 | response = self.ALREADY_BOUND 279 | elif server is None: 280 | response = self.NOT_IN_SERVER 281 | else: 282 | if role.server != channel.server: 283 | response = self.ROLE_NOT_FOUND 284 | elif channel.server.me.server_permissions.manage_roles is False: 285 | response = self.CANT_MANAGE_ROLES 286 | elif channel.permissions_for(channel.server.me).add_reactions is False: 287 | response = self.CANT_ADD_REACTIONS 288 | else: 289 | emoji = None 290 | for emoji_server in self.bot.servers: 291 | if emoji is None: 292 | emoji = discord.utils.get(emoji_server.emojis, id=emoji_id) 293 | try: 294 | await self.bot.add_reaction(message, emoji or emoji_id) 295 | except discord.HTTPException: # Failed to find the emoji 296 | response = self.EMOJI_NOT_FOUND 297 | else: 298 | self.add_to_cache(server.id, channel.id, message_id, emoji_id, role) 299 | msg_conf[emoji_id] = role.id 300 | self.save_data() 301 | response = self.ROLE_SUCCESSFULLY_BOUND.format(str(emoji or emoji_id), channel.mention) 302 | await self.bot.send_message(ctx.message.channel, response) 303 | 304 | @_roles.command(name="remove", pass_context=True, no_pm=True) 305 | @checks.mod_or_permissions(manage_roles=True) 306 | async def _roles_remove(self, ctx, message_id, channel: discord.Channel, *, role: discord.Role): 307 | """Remove a role from a message 308 | `message_id` must be found in `channel` and be bound to `role`""" 309 | server = channel.server 310 | msg_config = self.get_message_config(server.id, channel.id, message_id) 311 | c = ctx.message.channel 312 | emoji_config = discord.utils.find(lambda o: o[1] == role.id, msg_config.items()) 313 | if emoji_config is None: 314 | await self.bot.send_message(c, self.ROLE_NOT_BOUND) 315 | else: 316 | emoji_str = emoji_config[0] 317 | self.remove_role_from_cache(server.id, channel.id, message_id, emoji_str) 318 | del msg_config[emoji_str] 319 | self.save_data() 320 | msg = await self.safe_get_message(channel, message_id) 321 | if msg is None: 322 | await self.bot.send_message(c, self.MESSAGE_NOT_FOUND) 323 | else: 324 | answer = await self.bot.send_message(c, self.REACTION_CLEAN_START) 325 | reaction = discord.utils.find( 326 | lambda r: r.emoji.id == emoji_str if r.custom_emoji else r.emoji == emoji_str, msg.reactions) 327 | after = None 328 | count = 0 329 | user = None 330 | for page in range(math.ceil(reaction.count / 100)): 331 | for user in await self.bot.get_reaction_users(reaction, after=after): 332 | await self.bot.remove_reaction(msg, reaction.emoji, user) 333 | count += 1 334 | after = user 335 | await self.bot.edit_message(answer, self.PROGRESS_REMOVED.format(count, reaction.count)) 336 | await self.bot.edit_message(answer, self.REACTION_CLEAN_DONE.format(count)) 337 | 338 | @_roles.command(name="check", pass_context=True, no_pm=True) 339 | @checks.mod_or_permissions(manage_roles=True) 340 | async def _roles_check(self, ctx, message_id, channel: discord.Channel): 341 | """Goes through all reactions of a message and gives the roles accordingly 342 | This does NOT work with messages in a link""" 343 | server = channel.server 344 | msg = await self.safe_get_message(channel, message_id) 345 | server_links = self.links.get(server.id, {}) 346 | if channel.id + "_" + message_id in server_links: 347 | await self.bot.send_message(ctx.message.channel, self.CANT_CHECK_LINKED) 348 | elif msg is None: 349 | await self.bot.send_message(ctx.message.channel, self.MESSAGE_NOT_FOUND) 350 | else: 351 | msg_conf = self.get_message_config(server.id, channel.id, msg.id) 352 | if msg_conf is not None: # Something is very wrong if this is False but whatever 353 | progress_msg = await self.bot.send_message(ctx.message.channel, "Initializing...") 354 | given_roles = 0 355 | checked_count = 0 356 | total_count = sum(map(lambda r: r.count, msg.reactions)) - len(msg.reactions) # Remove the bot's 357 | total_reactions = 0 358 | for react in msg.reactions: # Go through all reactions on the message and add the roles if needed 359 | total_reactions += 1 360 | emoji_str = react.emoji.id if react.custom_emoji else react.emoji 361 | role = self.get_from_cache(server.id, channel.id, msg.id, emoji_str) 362 | if role is not None: 363 | before = 0 364 | after = None 365 | user = None 366 | while before != after: 367 | before = after 368 | for user in await self.bot.get_reaction_users(react, after=after): 369 | member = server.get_member(user.id) 370 | if member is not None and member != self.bot.user and \ 371 | discord.utils.get(member.roles, id=role.id) is None: 372 | await self.bot.add_roles(member, role) 373 | given_roles += 1 374 | checked_count += 1 375 | after = user 376 | await self.bot.edit_message(progress_msg, self.PROGRESS_FORMAT.format( 377 | c=checked_count, r=total_count, t=total_reactions)) 378 | else: 379 | checked_count += react.count 380 | await self.bot.edit_message(progress_msg, self.PROGRESS_FORMAT.format( 381 | c=checked_count, r=total_count, t=total_reactions)) 382 | await self.bot.edit_message(progress_msg, self.PROGRESS_COMPLETE_FORMAT.format(c=checked_count, 383 | g=given_roles)) 384 | 385 | # Utilities 386 | async def check_add_role(self, reaction, member): 387 | message = reaction.message 388 | channel = message.channel 389 | if isinstance(member, discord.Member) and member != self.bot.user: 390 | # Check whether or not the reaction happened on a server and prevent the bot from giving itself the role 391 | server = channel.server 392 | emoji_str = reaction.emoji.id if reaction.custom_emoji else reaction.emoji 393 | role = self.get_from_cache(server.id, channel.id, message.id, emoji_str) 394 | if role is not None: 395 | await self.add_role_queue(member, role, True, 396 | linked_roles=self.get_link(server.id, channel.id, message.id)) 397 | 398 | async def check_remove_role(self, reaction, member): 399 | message = reaction.message 400 | channel = message.channel 401 | if isinstance(member, discord.Member): # Check whether or not the reaction happened on a server 402 | server = channel.server 403 | emoji_str = reaction.emoji.id if reaction.custom_emoji else reaction.emoji 404 | if member == self.bot.user: # Safeguard in case a mod removes the bot's reaction by accident 405 | msg_conf = self.get_message_config(server.id, channel.id, message.id) 406 | if emoji_str in msg_conf: 407 | await self.bot.add_reaction(message, reaction.emoji) 408 | else: 409 | role = self.get_from_cache(server.id, channel.id, message.id, emoji_str) 410 | if role is not None: 411 | await self.add_role_queue(member, role, False) 412 | 413 | async def add_role_queue(self, member, role, add_bool, *, linked_roles=set()): 414 | key = "_".join((member.server.id, member.id)) # Doing it this way here to make it simpler a bit 415 | q = self.role_map.get(key) 416 | if q is None: # True --> add False --> remove 417 | q = {True: set(), False: {member.server.default_role}, "mem": member} 418 | # Always remove the @everyone role to prevent the bot from trying to give it to members 419 | await self.role_queue.put(key) 420 | q[True].difference_update(linked_roles) # Remove the linked roles from the roles to add 421 | q[False].update(linked_roles) # Add the linked roles to remove them if the user has any of them 422 | q[not add_bool] -= {role} 423 | q[add_bool] |= {role} 424 | self.role_map[key] = q 425 | 426 | async def process_role_queue(self): # This exists to update multiple roles at once when possible 427 | """Loops until the cog is unloaded and processes the role assignments when it can""" 428 | await self.bot.wait_until_ready() 429 | with contextlib.suppress(RuntimeError, asyncio.CancelledError): # Suppress the "Event loop is closed" error 430 | while self == self.bot.get_cog(self.__class__.__name__): 431 | key = await self.role_queue.get() 432 | q = self.role_map.pop(key) 433 | if q is not None and q.get("mem") is not None: 434 | mem = q["mem"] 435 | all_roles = set(mem.roles) 436 | add_set = q.get(True, set()) 437 | del_set = q.get(False, {mem.server.default_role}) 438 | try: 439 | await self.bot.replace_roles(mem, *((all_roles | add_set) - del_set)) 440 | # Basically, the user's roles + the added - the removed 441 | except (discord.Forbidden, discord.HTTPException): 442 | self.role_map[key] = q # Try again when it fails 443 | await self.role_queue.put(key) 444 | else: 445 | self.role_queue.task_done() 446 | finally: 447 | await asyncio.sleep(self.processing_wait_time) 448 | self.logger.debug("The processing loop has ended.") 449 | 450 | async def safe_get_message(self, channel, message_id): 451 | try: 452 | result = await self.bot.get_message(channel, message_id) 453 | except discord.errors.NotFound: 454 | result = None 455 | return result 456 | 457 | def get_link(self, server_id, channel_id, message_id): 458 | return self.links.get(server_id, {}).get(channel_id + "_" + message_id, set()) 459 | 460 | def parse_links(self, server_id, links_list): 461 | """Parses the links of a server into self.links 462 | links_list is a list of links each link being a list of channel.id_message.id linked together""" 463 | link_dict = {} 464 | for link in links_list: 465 | role_list = set() 466 | for entry in link: 467 | channel_id, message_id = entry.split("_", 1) 468 | role_list.update(self.get_all_roles_from_message(server_id, channel_id, message_id)) 469 | for entry in link: 470 | link_dict.setdefault(entry, set()).update(role_list) 471 | self.links[server_id] = link_dict 472 | 473 | def remove_links(self, server_id, name): 474 | entry_list = self.get_config(server_id).get(self.LINKS_ENTRY, {}).get(name, []) 475 | link_dict = self.links.get(server_id, {}) 476 | for entry in entry_list: 477 | if entry in link_dict: 478 | channel_id, message_id = entry.split("_", 1) 479 | role_list = set() 480 | role_list.update(self.get_all_roles_from_message(server_id, channel_id, message_id)) 481 | link_dict[entry].difference_update(role_list) 482 | if len(link_dict[entry]) == 0: 483 | del link_dict[entry] 484 | 485 | # Cache -- Needed to keep the actual role object in cache instead of looking for it every time in the server's roles 486 | def add_to_cache(self, server_id, channel_id, message_id, emoji_str, role): 487 | """Adds an entry to the role cache""" 488 | server_conf = self.role_cache.setdefault(server_id, {}) 489 | channel_conf = server_conf.setdefault(channel_id, {}) 490 | message_conf = channel_conf.setdefault(message_id, {}) 491 | message_conf[emoji_str] = role 492 | 493 | def get_all_roles_from_message(self, server_id, channel_id, message_id): 494 | """Fetches all roles from a given message returns an iterable""" 495 | return self.role_cache.get(server_id, {}).get(channel_id, {}).get(message_id, {}).values() 496 | 497 | def get_from_cache(self, server_id, channel_id, message_id, emoji_str): 498 | """Fetches the role associated with an emoji on the given message""" 499 | return self.role_cache.get(server_id, {}).get(channel_id, {}).get(message_id, {}).get(emoji_str) 500 | 501 | def remove_role_from_cache(self, server_id, channel_id, message_id, emoji_str): 502 | """Removes an entry from the role cache""" 503 | server_conf = self.role_cache.get(server_id) 504 | if server_conf is not None: 505 | channel_conf = server_conf.get(channel_id) 506 | if channel_conf is not None: 507 | message_conf = channel_conf.get(message_id) 508 | if message_conf is not None and emoji_str in message_conf: 509 | del message_conf[emoji_str] 510 | 511 | def remove_message_from_cache(self, server_id, channel_id, message_id): 512 | """Removes a message from the role cache""" 513 | server_conf = self.role_cache.get(server_id) 514 | if server_conf is not None: 515 | channel_conf = server_conf.get(channel_id) 516 | if channel_conf is not None and message_id in channel_conf: 517 | del channel_conf[message_id] 518 | 519 | # Client Modification Proxy 520 | def add_cache_message(self, message): 521 | if isinstance(message, discord.Message): 522 | self.cached_messages[message.id] = message 523 | 524 | def remove_cache_message(self, message): 525 | if isinstance(message, discord.Message): 526 | if message.id in self.cached_messages: 527 | del self.cached_messages[message.id] 528 | elif isinstance(message, str): 529 | if message in self.cached_messages: 530 | del self.cached_messages[message] 531 | 532 | # Config 533 | def get_message_config(self, server_id, channel_id, message_id): 534 | return self.get_config(server_id).setdefault(channel_id, {}).setdefault(message_id, {}) 535 | 536 | def get_config(self, server_id): 537 | config = self.config.get(server_id) 538 | if config is None: 539 | self.config[server_id] = copy.deepcopy(self.SERVER_DEFAULT) 540 | return self.config.get(server_id) 541 | 542 | def check_configs(self): 543 | self.check_folders() 544 | self.check_files() 545 | 546 | def check_folders(self): 547 | if not os.path.exists(self.DATA_FOLDER): 548 | print("Creating data folder...") 549 | os.makedirs(self.DATA_FOLDER, exist_ok=True) 550 | 551 | def check_files(self): 552 | self.check_file(self.CONFIG_FILE_PATH, self.CONFIG_DEFAULT) 553 | 554 | def check_file(self, file, default): 555 | if not dataIO.is_valid_json(file): 556 | print("Creating empty " + file + "...") 557 | dataIO.save_json(file, default) 558 | 559 | def load_data(self): 560 | self.config = dataIO.load_json(self.CONFIG_FILE_PATH) 561 | 562 | def save_data(self): 563 | dataIO.save_json(self.CONFIG_FILE_PATH, self.config) 564 | 565 | 566 | def setup(bot): 567 | # Creating the cog 568 | c = ReactRoles(bot) 569 | # Finally, add the cog to the bot. 570 | bot.add_cog(c) 571 | -------------------------------------------------------------------------------- /reminder/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Enjoy reminding yourself of whatever you wanna remind yourself of!", 4 | "NAME" : "Reminder", 5 | "SHORT" : "Allows user to remind themselves about anything they want", 6 | "DESCRIPTION" : "Lets user tell the bot to remind them about anything they want. There is no limit to how many reminders one user asks for.", 7 | "TAGS" : ["remind"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /reminder/reminder.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import os.path 4 | import os 5 | import re 6 | import collections 7 | import datetime 8 | 9 | from discord.ext import commands 10 | from .utils.dataIO import dataIO 11 | 12 | 13 | class Reminder: 14 | """Utilities to remind yourself of whatever you want""" 15 | 16 | # File constants 17 | DATA_FOLDER = "data/reminder" 18 | DATA_FILE_PATH = DATA_FOLDER + "/reminders.json" 19 | 20 | # Configuration default 21 | CONFIG_DEFAULT = [] 22 | 23 | # Behavior constants 24 | TIME_AMNT_REGEX = re.compile("([1-9][0-9]*)([a-z]+)", re.IGNORECASE) 25 | TIME_QUANTITIES = collections.OrderedDict([("seconds", 1), ("minutes", 60), 26 | ("hours", 3600), ("days", 86400), 27 | ("weeks", 604800), ("months", 2.628e+6), 28 | ("years", 3.154e+7)]) # (amount in seconds, max amount) 29 | MAX_SECONDS = TIME_QUANTITIES["years"] * 2 30 | 31 | # Message constants 32 | INVALID_TIME_FORMAT = ":x: Invalid time format." 33 | TOO_MUCH_TIME = ":x: Too long amount of time. Maximum: {} total seconds" 34 | WILL_REMIND = ":white_check_mark: I will remind you in {} seconds." 35 | 36 | def __init__(self, bot): 37 | self.bot = bot 38 | self.check_configs() 39 | self.load_data() 40 | self.futures = [] 41 | asyncio.ensure_future(self.start_saved_reminders()) 42 | 43 | # Events 44 | def __unload(self): 45 | for future in self.futures: 46 | future.cancel() 47 | 48 | # Commands 49 | @commands.command(pass_context=True) 50 | async def remind(self, ctx, time, *, text): 51 | """Remind yourself of something in a specific amount of time 52 | Examples for time: `5d`, `10m`, `10m30s`, `1h`, `1y1mo2w5d10h30m15s` 53 | Abbreviations: s for seconds, m for minutes, h for hours, d for days, w for weeks, mo for months, y for years 54 | Any longer abbreviation is accepted. `m` assumes minutes instead of months. 55 | One month is counted as exact 365/12 days. 56 | Ignores all invalid abbreviations.""" 57 | message = ctx.message 58 | seconds = self.get_seconds(time) 59 | if seconds is None: 60 | response = self.INVALID_TIME_FORMAT 61 | elif seconds >= self.MAX_SECONDS: 62 | response = self.TOO_MUCH_TIME.format(round(self.MAX_SECONDS)) 63 | else: 64 | user = message.author 65 | time_now = datetime.datetime.utcnow() 66 | days, secs = divmod(seconds, 3600*24) 67 | end_time = time_now + datetime.timedelta(days=days, seconds=secs) 68 | reminder = {"user": user.id, "content": text, 69 | "start_time": time_now.timestamp(), "end_time": end_time.timestamp()} 70 | self.config.append(reminder) 71 | self.save_data() 72 | self.futures.append(asyncio.ensure_future(self.remind_later(user, seconds, text, reminder))) 73 | response = self.WILL_REMIND.format(seconds) 74 | await self.bot.send_message(message.channel, response) 75 | 76 | # Utilities 77 | async def start_saved_reminders(self): 78 | await self.bot.wait_until_ready() 79 | for reminder in list(self.config): # Making a copy 80 | user_id = reminder["user"] 81 | user = None 82 | for server in self.bot.servers: 83 | user = user or server.get_member(user_id) 84 | if user is None: 85 | self.config.remove(reminder) # Delete the reminder if the user doesn't have a mutual server anymore 86 | else: 87 | time_diff = datetime.datetime.fromtimestamp(reminder["end_time"]) - datetime.datetime.utcnow() 88 | time = max(0, time_diff.total_seconds()) 89 | self.futures.append(asyncio.ensure_future(self.remind_later(user, time, reminder["content"], reminder))) 90 | 91 | async def remind_later(self, user: discord.User, time: float, content: str, reminder): 92 | """Reminds the `user` in `time` seconds with a message containing `content`""" 93 | await asyncio.sleep(time) 94 | embed = discord.Embed(title="Reminder", description=content, color=discord.Colour.blue()) 95 | await self.bot.send_message(user, embed=embed) 96 | self.config.remove(reminder) 97 | self.save_data() 98 | 99 | def get_seconds(self, time): 100 | """Returns the amount of converted time or None if invalid""" 101 | seconds = 0 102 | for time_match in self.TIME_AMNT_REGEX.finditer(time): 103 | time_amnt = int(time_match.group(1)) 104 | time_abbrev = time_match.group(2) 105 | time_quantity = discord.utils.find(lambda t: t[0].startswith(time_abbrev), self.TIME_QUANTITIES.items()) 106 | if time_quantity is not None: 107 | seconds += time_amnt * time_quantity[1] 108 | return None if seconds == 0 else seconds 109 | 110 | # Config 111 | def check_configs(self): 112 | self.check_folders() 113 | self.check_files() 114 | 115 | def check_folders(self): 116 | if not os.path.exists(self.DATA_FOLDER): 117 | print("Creating data folder...") 118 | os.makedirs(self.DATA_FOLDER, exist_ok=True) 119 | 120 | def check_files(self): 121 | self.check_file(self.DATA_FILE_PATH, self.CONFIG_DEFAULT) 122 | 123 | def check_file(self, file, default): 124 | if not dataIO.is_valid_json(file): 125 | print("Creating empty " + file + "...") 126 | dataIO.save_json(file, default) 127 | 128 | def load_data(self): 129 | self.config = dataIO.load_json(self.DATA_FILE_PATH) 130 | 131 | def save_data(self): 132 | dataIO.save_json(self.DATA_FILE_PATH, self.config) 133 | 134 | 135 | def setup(bot): 136 | bot.add_cog(Reminder(bot)) 137 | -------------------------------------------------------------------------------- /slowmode/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Enjoy slowing down your ~~dirty dirty spammers~~ users! Remember that [p]check_slow exists if some people get stuck muted (although it shouldn't happen)", 4 | "NAME" : "SlowMode", 5 | "SHORT" : "Prevent users from sending messages too fast", 6 | "DESCRIPTION" : "Specify how fast people can send messages in any channel. You can also allow them to speak again if they aren't unmuted fast enough.", 7 | "TAGS" : ["slowmode", "moderation"], 8 | "REQUIREMENTS" : [], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /timezone_conversion/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTHOR" : "ZeLarpMaster#0818", 3 | "INSTALL_MSG" : "Enjoy converting between timezones!\nYou can add aliases for timezones which aren't official if needed.", 4 | "NAME" : "TimezoneConversion", 5 | "SHORT" : "Convert time between timezones or get the current time in a timezone", 6 | "DESCRIPTION" : "Convert time between timezones or get the current time in a timezone. Allows the creation of aliases for timezones which aren't official", 7 | "TAGS" : ["timezone", "conversion"], 8 | "REQUIREMENTS" : ["pytz"], 9 | "HIDDEN" : false 10 | } -------------------------------------------------------------------------------- /timezone_conversion/timezone_conversion.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import discord 4 | import os.path 5 | import math 6 | 7 | import pytz # pip install pytz 8 | 9 | from discord.ext import commands 10 | from .utils import checks 11 | from .utils.dataIO import dataIO 12 | 13 | 14 | class TimezoneConversion: 15 | """Timezone conversion tools""" 16 | 17 | # Config paths 18 | DATA_FOLDER = "data/timezones/" 19 | ALIASES_FILE = DATA_FOLDER + "aliases.json" 20 | 21 | # Behavior constants 22 | ALIASES_DEFAULT = {} 23 | TIME_REGEX = re.compile("(now|((1?[0-9])([ap]m))|(([0-9]{1,2}):([0-9]{2})))") 24 | 25 | # Message constants 26 | TIME_USAGE = """:x: Invalid command. 27 | Usage: `{prefix}time