├── .gitignore ├── bot.py ├── cogs ├── admin.py ├── data │ └── items.py ├── utils │ ├── context.py │ ├── formats.py │ └── storage.py └── virus.py ├── poetry.lock └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | config.py 2 | venv/* 3 | *.pyc 4 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import config 3 | import logging 4 | import datetime 5 | import sys 6 | import discord 7 | import traceback 8 | 9 | from cogs.utils import context 10 | 11 | # This help command is simple so... 12 | class HelpCommand(commands.HelpCommand): 13 | async def send_bot_help(self, mapping): 14 | e = discord.Embed(colour=0xFD8063) 15 | filtered = await self.filter_commands(self.context.bot.commands, sort=True) 16 | for command in filtered: 17 | e.add_field(name=f'{command.name} {command.signature}', value=command.short_doc, inline=False) 18 | 19 | await self.get_destination().send(embed=e) 20 | 21 | async def send_command_help(self, command): 22 | e = discord.Embed(title=f'{command.qualified_name} {command.signature}', colour=0xFD8063) 23 | e.description = command.help 24 | if isinstance(command, commands.Group): 25 | filtered = await self.filter_commands(command.commands, sort=True) 26 | for child in filtered: 27 | e.add_field(name=f'{child.qualified_name} {child.signature}', value=child.short_doc, inline=False) 28 | 29 | await self.get_destination().send(embed=e) 30 | 31 | send_group_help = send_command_help 32 | 33 | class EventBot(commands.Bot): 34 | def __init__(self): 35 | super().__init__(help_command=HelpCommand(), command_prefix=commands.when_mentioned_or('e!')) 36 | self.uptime = None 37 | self.load_extension('cogs.admin') 38 | self.load_extension('cogs.virus') 39 | 40 | def run(self): 41 | super().run(config.token) 42 | 43 | async def on_ready(self): 44 | if self.uptime is None: 45 | self.uptime = datetime.datetime.utcnow() 46 | 47 | print(f'Logged on as {self.user} (ID: {self.user.id})') 48 | 49 | async def on_resumed(self): 50 | print(f'Resumed connection...') 51 | 52 | async def on_message(self, message): 53 | ctx = await self.get_context(message, cls=context.Context) 54 | if ctx.valid: 55 | await self.invoke(ctx) 56 | else: 57 | self.dispatch('regular_message', message) 58 | 59 | async def on_command_error(self, ctx, error): 60 | if isinstance(error, commands.NoPrivateMessage): 61 | await ctx.author.send('This command cannot be used in private messages.') 62 | elif isinstance(error, commands.DisabledCommand): 63 | await ctx.author.send('Sorry. This command is disabled and cannot be used.') 64 | elif isinstance(error, commands.CommandInvokeError): 65 | original = error.original 66 | if not isinstance(original, discord.HTTPException): 67 | print(f'In {ctx.command.qualified_name}:', file=sys.stderr) 68 | traceback.print_tb(original.__traceback__) 69 | print(f'{original.__class__.__name__}: {original}', file=sys.stderr) 70 | elif isinstance(error, commands.ArgumentParsingError): 71 | await ctx.send(error) 72 | 73 | def main(): 74 | logging.getLogger('discord').setLevel(logging.INFO) 75 | logging.getLogger('discord.http').setLevel(logging.WARNING) 76 | 77 | log = logging.getLogger() 78 | log.setLevel(logging.INFO) 79 | handler = logging.FileHandler(filename='bot.log', encoding='utf-8', mode='w') 80 | dt_fmt = '%Y-%m-%d %H:%M:%S' 81 | fmt = logging.Formatter('[{asctime}] [{levelname:<7}] {name}: {message}', dt_fmt, style='{') 82 | handler.setFormatter(fmt) 83 | log.addHandler(handler) 84 | 85 | bot = EventBot() 86 | bot.run() 87 | 88 | if __name__ == '__main__': 89 | main() 90 | -------------------------------------------------------------------------------- /cogs/admin.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import asyncio 3 | import traceback 4 | import discord 5 | import inspect 6 | import textwrap 7 | import importlib 8 | import pathlib 9 | from contextlib import redirect_stdout 10 | import io 11 | import os 12 | import re 13 | import sys 14 | import copy 15 | import time 16 | import subprocess 17 | from typing import Union 18 | 19 | # to expose to the eval command 20 | import datetime 21 | from collections import Counter 22 | 23 | class Admin(commands.Cog): 24 | """Admin-only commands that make the bot dynamic.""" 25 | 26 | def __init__(self, bot): 27 | self.bot = bot 28 | self._last_result = None 29 | self.sessions = set() 30 | 31 | async def run_process(self, command): 32 | try: 33 | process = await asyncio.create_subprocess_shell(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 34 | result = await process.communicate() 35 | except NotImplementedError: 36 | process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 37 | result = await self.bot.loop.run_in_executor(None, process.communicate) 38 | 39 | return [output.decode() for output in result] 40 | 41 | def cleanup_code(self, content): 42 | """Automatically removes code blocks from the code.""" 43 | # remove ```py\n``` 44 | if content.startswith('```') and content.endswith('```'): 45 | return '\n'.join(content.split('\n')[1:-1]) 46 | 47 | # remove `foo` 48 | return content.strip('` \n') 49 | 50 | async def cog_check(self, ctx): 51 | return await self.bot.is_owner(ctx.author) 52 | 53 | def get_syntax_error(self, e): 54 | if e.text is None: 55 | return f'```py\n{e.__class__.__name__}: {e}\n```' 56 | return f'```py\n{e.text}{"^":>{e.offset}}\n{e.__class__.__name__}: {e}```' 57 | 58 | @commands.command(hidden=True) 59 | async def load(self, ctx, *, module): 60 | """Loads a module.""" 61 | try: 62 | self.bot.load_extension(module) 63 | except commands.ExtensionError as e: 64 | await ctx.send(f'{e.__class__.__name__}: {e}') 65 | else: 66 | await ctx.send('\N{OK HAND SIGN}') 67 | 68 | @commands.command(hidden=True) 69 | async def loaded(self, ctx): 70 | """Shows loaded modules.""" 71 | fmt = [] 72 | for path in pathlib.Path('cogs').glob('*.py'): 73 | key = f'cogs.{path.stem}' 74 | fmt.append(f'{ctx.tick(key in self.bot.extensions)} {key}') 75 | await ctx.send('\n'.join(fmt)) 76 | 77 | @commands.command(hidden=True) 78 | async def unload(self, ctx, *, module): 79 | """Unloads a module.""" 80 | try: 81 | self.bot.unload_extension(module) 82 | except commands.ExtensionError as e: 83 | await ctx.send(f'{e.__class__.__name__}: {e}') 84 | else: 85 | await ctx.send('\N{OK HAND SIGN}') 86 | 87 | def get_submodules_from_extension(self, module): 88 | submodules = set() 89 | for name, obj in inspect.getmembers(module): 90 | if inspect.ismodule(obj): 91 | submodules.add(obj.__name__) 92 | else: 93 | try: 94 | mod = obj.__module__ 95 | except AttributeError: 96 | pass 97 | else: 98 | submodules.add(mod) 99 | 100 | submodules.discard(module.__name__) 101 | return submodules 102 | 103 | @commands.group(name='reload', hidden=True, invoke_without_command=True) 104 | async def _reload(self, ctx, *, module): 105 | """Reloads a module.""" 106 | 107 | try: 108 | ext = self.bot.extensions[module] 109 | except KeyError: 110 | return await ctx.invoke(self.load, module=module) 111 | 112 | submodules = self.get_submodules_from_extension(ext) 113 | for mod in submodules: 114 | if not mod.startswith('cogs.utils.'): 115 | continue 116 | 117 | try: 118 | importlib.reload(sys.modules[mod]) 119 | except KeyError: 120 | pass 121 | 122 | try: 123 | self.bot.reload_extension(module) 124 | except commands.ExtensionError as e: 125 | await ctx.send(f'{e.__class__.__name__}: {e}') 126 | else: 127 | await ctx.send('\N{OK HAND SIGN}') 128 | 129 | _GIT_PULL_REGEX = re.compile(r'\s*(?P.+?)\s*\|\s*[0-9]+\s*[+-]+') 130 | 131 | def find_modules_from_git(self, output): 132 | files = self._GIT_PULL_REGEX.findall(output) 133 | ret = [] 134 | for file in files: 135 | root, ext = os.path.splitext(file) 136 | if ext != '.py': 137 | continue 138 | 139 | if root.startswith('cogs/'): 140 | # A submodule is a directory inside the main cog directory for 141 | # my purposes 142 | ret.append((root.count('/') - 1, root.replace('/', '.'))) 143 | 144 | # For reload order, the submodules should be reloaded first 145 | ret.sort(reverse=True) 146 | return ret 147 | 148 | def reload_or_load_extension(self, module): 149 | try: 150 | self.bot.reload_extension(module) 151 | except commands.ExtensionNotLoaded: 152 | self.bot.load_extension(module) 153 | 154 | @_reload.command(name='all', hidden=True) 155 | async def _reload_all(self, ctx): 156 | """Reloads all modules, while pulling from git.""" 157 | 158 | async with ctx.typing(): 159 | stdout, stderr = await self.run_process('git pull') 160 | 161 | # progress and stuff is redirected to stderr in git pull 162 | # however, things like "fast forward" and files 163 | # along with the text "already up-to-date" are in stdout 164 | 165 | if stdout.startswith('Already up-to-date.'): 166 | return await ctx.send(stdout) 167 | 168 | modules = self.find_modules_from_git(stdout) 169 | mods_text = '\n'.join(f'{index}. `{module}`' for index, (_, module) in enumerate(modules, start=1)) 170 | prompt_text = f'This will update the following modules, are you sure?\n{mods_text}' 171 | confirm = await ctx.prompt(prompt_text) 172 | if not confirm: 173 | return await ctx.send('Aborting.') 174 | 175 | statuses = [] 176 | for is_submodule, module in modules: 177 | if is_submodule: 178 | try: 179 | actual_module = sys.modules[module] 180 | except KeyError: 181 | statuses.append((ctx.tick(None), module)) 182 | else: 183 | try: 184 | importlib.reload(actual_module) 185 | except Exception as e: 186 | statuses.append((ctx.tick(False), module)) 187 | else: 188 | statuses.append((ctx.tick(True), module)) 189 | else: 190 | try: 191 | self.reload_or_load_extension(module) 192 | except commands.ExtensionError: 193 | statuses.append((ctx.tick(False), module)) 194 | else: 195 | statuses.append((ctx.tick(True), module)) 196 | 197 | await ctx.send('\n'.join(f'{status}: `{module}`' for status, module in statuses)) 198 | 199 | @commands.command(name='eval', hidden=True) 200 | async def _eval(self, ctx, *, body: str): 201 | """Evaluates a code""" 202 | 203 | env = { 204 | 'bot': self.bot, 205 | 'ctx': ctx, 206 | 'channel': ctx.channel, 207 | 'author': ctx.author, 208 | 'guild': ctx.guild, 209 | 'message': ctx.message, 210 | '_': self._last_result 211 | } 212 | 213 | env.update(globals()) 214 | 215 | body = self.cleanup_code(body) 216 | stdout = io.StringIO() 217 | 218 | to_compile = f'async def func():\n{textwrap.indent(body, " ")}' 219 | 220 | try: 221 | exec(to_compile, env) 222 | except Exception as e: 223 | return await ctx.send(f'```py\n{e.__class__.__name__}: {e}\n```') 224 | 225 | func = env['func'] 226 | try: 227 | with redirect_stdout(stdout): 228 | ret = await func() 229 | except Exception as e: 230 | value = stdout.getvalue() 231 | await ctx.send(f'```py\n{value}{traceback.format_exc()}\n```') 232 | else: 233 | value = stdout.getvalue() 234 | try: 235 | await ctx.message.add_reaction('\u2705') 236 | except: 237 | pass 238 | 239 | if ret is None: 240 | if value: 241 | await ctx.send(f'```py\n{value}\n```') 242 | else: 243 | self._last_result = ret 244 | await ctx.send(f'```py\n{value}{ret}\n```') 245 | 246 | @commands.command(pass_context=True, hidden=True) 247 | async def repl(self, ctx): 248 | """Launches an interactive REPL session.""" 249 | variables = { 250 | 'ctx': ctx, 251 | 'bot': self.bot, 252 | 'message': ctx.message, 253 | 'guild': ctx.guild, 254 | 'channel': ctx.channel, 255 | 'author': ctx.author, 256 | '_': None, 257 | } 258 | 259 | if ctx.channel.id in self.sessions: 260 | await ctx.send('Already running a REPL session in this channel. Exit it with `quit`.') 261 | return 262 | 263 | self.sessions.add(ctx.channel.id) 264 | await ctx.send('Enter code to execute or evaluate. `exit()` or `quit` to exit.') 265 | 266 | def check(m): 267 | return m.author.id == ctx.author.id and \ 268 | m.channel.id == ctx.channel.id and \ 269 | m.content.startswith('`') 270 | 271 | while True: 272 | try: 273 | response = await self.bot.wait_for('message', check=check, timeout=10.0 * 60.0) 274 | except asyncio.TimeoutError: 275 | await ctx.send('Exiting REPL session.') 276 | self.sessions.remove(ctx.channel.id) 277 | break 278 | 279 | cleaned = self.cleanup_code(response.content) 280 | 281 | if cleaned in ('quit', 'exit', 'exit()'): 282 | await ctx.send('Exiting.') 283 | self.sessions.remove(ctx.channel.id) 284 | return 285 | 286 | executor = exec 287 | if cleaned.count('\n') == 0: 288 | # single statement, potentially 'eval' 289 | try: 290 | code = compile(cleaned, '', 'eval') 291 | except SyntaxError: 292 | pass 293 | else: 294 | executor = eval 295 | 296 | if executor is exec: 297 | try: 298 | code = compile(cleaned, '', 'exec') 299 | except SyntaxError as e: 300 | await ctx.send(self.get_syntax_error(e)) 301 | continue 302 | 303 | variables['message'] = response 304 | 305 | fmt = None 306 | stdout = io.StringIO() 307 | 308 | try: 309 | with redirect_stdout(stdout): 310 | result = executor(code, variables) 311 | if inspect.isawaitable(result): 312 | result = await result 313 | except Exception as e: 314 | value = stdout.getvalue() 315 | fmt = f'```py\n{value}{traceback.format_exc()}\n```' 316 | else: 317 | value = stdout.getvalue() 318 | if result is not None: 319 | fmt = f'```py\n{value}{result}\n```' 320 | variables['_'] = result 321 | elif value: 322 | fmt = f'```py\n{value}\n```' 323 | 324 | try: 325 | if fmt is not None: 326 | if len(fmt) > 2000: 327 | await ctx.send('Content too big to be printed.') 328 | else: 329 | await ctx.send(fmt) 330 | except discord.Forbidden: 331 | pass 332 | except discord.HTTPException as e: 333 | await ctx.send(f'Unexpected error: `{e}`') 334 | 335 | @commands.command(hidden=True) 336 | async def sudo(self, ctx, who: Union[discord.Member, discord.User], *, command: str): 337 | """Run a command as another user.""" 338 | msg = copy.copy(ctx.message) 339 | msg.author = who 340 | msg.content = ctx.prefix + command 341 | new_ctx = await self.bot.get_context(msg, cls=type(ctx)) 342 | new_ctx._db = ctx._db 343 | await self.bot.invoke(new_ctx) 344 | 345 | @commands.command(hidden=True) 346 | async def repeat(self, ctx, times: int, *, command): 347 | """Repeats a command a specified number of times.""" 348 | msg = copy.copy(ctx.message) 349 | msg.content = ctx.prefix + command 350 | 351 | new_ctx = await self.bot.get_context(msg, cls=type(ctx)) 352 | new_ctx._db = ctx._db 353 | 354 | for i in range(times): 355 | await new_ctx.reinvoke() 356 | 357 | def setup(bot): 358 | bot.add_cog(Admin(bot)) 359 | -------------------------------------------------------------------------------- /cogs/data/items.py: -------------------------------------------------------------------------------- 1 | # The item store is a bit odd. 2 | # Every person can only buy an item once (and it's put in their backpack). 3 | # An item can only be bought once and they have limited uses. 4 | # Items are free and on a first come first served basis. They get restocked every so often. 5 | # The item shop only has a few items unlocked per day. They might rotate as well. 6 | 7 | from textwrap import dedent 8 | 9 | # An easier way to alias emoji items 10 | class Emoji: 11 | mask = '\U0001f637' 12 | bed = '\U0001f6cf\ufe0f' 13 | soap = '\U0001f9fc' 14 | handsanitizer = '\U0001f9f4' 15 | dna = '\U0001f9ec' 16 | microbe = '\U0001f9a0' 17 | petri_dish = '\U0001f9eb' 18 | test_tube = '\U0001f9ea' 19 | pill = '\N{PILL}' 20 | syringe = '\N{SYRINGE}' 21 | shower = '\N{SHOWER}' 22 | microscope = '\N{MICROSCOPE}' 23 | potato = '\N{POTATO}' 24 | herb = '\N{HERB}' 25 | books = '\N{BOOKS}' 26 | love_letter = '\N{LOVE LETTER}' 27 | present = '\N{WRAPPED PRESENT}' 28 | airplane = '\N{AIRPLANE}\ufe0f' 29 | bell = '\N{BELLHOP BELL}\ufe0f' 30 | meditate = '\U0001f9d8' 31 | toilet_paper = '\U0001f9fb' 32 | gun = '\N{PISTOL}' 33 | dagger = '\N{DAGGER KNIFE}\ufe0f' 34 | 35 | raw = [ 36 | { 37 | 'emoji': Emoji.mask, 38 | 'name': 'Mask', 39 | 'total': 5, 40 | 'description': 'Helps prevent the spread of the disease!', 41 | 'code': 'user.masked = True', 42 | }, 43 | { 44 | 'emoji': Emoji.bed, 45 | 'name': 'Rest', 46 | 'total': 20, 47 | 'uses': 3, 48 | 'description': 'A good night\'s rest.', 49 | 'code': dedent(""" 50 | if user.infected: 51 | return user.add_sickness(-3) 52 | """), 53 | }, 54 | { 55 | 'emoji': Emoji.soap, 56 | 'name': 'Soap', 57 | 'total': 20, 58 | 'uses': 3, 59 | 'description': 'Washing your hands is always good.', 60 | 'code': dedent(""" 61 | if user.infected: 62 | user.sickness = max(user.sickness - 5, 5) 63 | """), 64 | }, 65 | { 66 | 'emoji': Emoji.handsanitizer, 67 | 'name': 'Hand Sanitizer', 68 | 'description': 'One of the best ways to prevent infection', 69 | 'total': 10, 70 | 'uses': 3, 71 | 'code': dedent(""" 72 | if user.infected: 73 | user.sickness = max(user.sickness - random.randint(10, 15), 5) 74 | """) 75 | }, 76 | { 77 | 'emoji': Emoji.dna, 78 | 'name': 'Research Item', 79 | 'description': 'Will we ever figure out the cause of this?', 80 | 'total': 5, 81 | 'uses': 0, 82 | 'code': 'pass', 83 | 'predicate': 'return user.healer', 84 | }, 85 | { 86 | 'emoji': Emoji.microbe, 87 | 'name': 'Research Item', 88 | 'description': 'Who did this?', 89 | 'total': 5, 90 | 'uses': 0, 91 | 'code': 'pass', 92 | 'predicate': 'return user.healer', 93 | }, 94 | { 95 | 'emoji': Emoji.petri_dish, 96 | 'name': 'Research Item', 97 | 'description': 'If we don\'t try then how will we know?', 98 | 'total': 5, 99 | 'uses': 0, 100 | 'code': 'pass', 101 | 'predicate': 'return user.healer', 102 | }, 103 | { 104 | 'emoji': Emoji.test_tube, 105 | 'name': 'Research Item', 106 | 'description': 'A cure must be possible, surely', 107 | 'total': 5, 108 | 'uses': 0, 109 | 'code': 'pass', 110 | 'predicate': 'return user.healer', 111 | }, 112 | { 113 | 'emoji': Emoji.microscope, 114 | 'name': 'Research Item', 115 | 'description': 'Research is necessary', 116 | 'total': 5, 117 | 'uses': 0, 118 | 'code': 'pass', 119 | 'predicate': 'return user.healer', 120 | }, 121 | { 122 | 'emoji': Emoji.syringe, 123 | 'name': 'Vaccine', 124 | 'description': 'A cure', 125 | 'total': 10, 126 | 'code': dedent(""" 127 | await ctx.cog.vaccinate(user) 128 | """) 129 | }, 130 | { 131 | 'emoji': Emoji.shower, 132 | 'name': 'Shower', 133 | 'description': 'You do shower right?', 134 | 'total': 25, 135 | 'uses': 5, 136 | 'code': dedent(""" 137 | if user.infected: 138 | if user.sickness >= 30: 139 | user.sickness -= random.randint(8, 16) 140 | elif user.sickness >= 10: 141 | user.sickness = max(user.sickness - 3, 10) 142 | """), 143 | }, 144 | { 145 | 'emoji': Emoji.pill, 146 | 'name': 'Medicine', 147 | 'description': 'Experimental medicine that might help', 148 | 'total': 10, 149 | 'code': dedent(""" 150 | roll = random.random() 151 | if roll < 0.1: 152 | user.sickness = 0 153 | return State.cured 154 | return user.add_sickness(random.randint(-20, -5)) 155 | """), 156 | 'predicate': 'return user.is_infectious()', 157 | }, 158 | { 159 | 'emoji': Emoji.potato, 160 | 'name': 'Potato', 161 | 'unlocked': True, 162 | 'total': 100, 163 | 'description': '"Some people like potatoes. Me? I love potatoes." — Whoever buys this', 164 | 'uses': 5, 165 | 'code': 'user.sickness = max(user.sickness - 1, 1)', 166 | 'predicate': 'return user.is_infectious()', 167 | }, 168 | { 169 | 'emoji': Emoji.herb, 170 | 'name': 'Chinese medicine', 171 | 'total': 20, 172 | 'uses': 5, 173 | 'description': 'Who needs western medicine?', 174 | 'code': 'return user.add_sickness(random.randint(-2, 2))', 175 | 'predicate': 'return user.is_infectious()', 176 | }, 177 | { 178 | 'emoji': Emoji.books, 179 | 'name': 'Education', 180 | 'description': 'The most important thing in a society', 181 | 'total': 100, 182 | 'code': dedent(""" 183 | roll = random.random() 184 | if roll < 0.05: 185 | user.healer = True 186 | return State.become_healer 187 | """), 188 | 'predicate': 'return not user.healer' 189 | }, 190 | { 191 | 'emoji': Emoji.love_letter, 192 | 'name': 'Love Letter', 193 | 'description': 'Send a love letter to someone in your dying breath.', 194 | 'total': 5, 195 | 'code': dedent(f""" 196 | member = await ctx.request('Who do you want to send this letter to?') 197 | if member is ...: 198 | raise VirusError("Timed out") 199 | elif member is None: 200 | raise VirusError("I don't know this member.") 201 | 202 | if member.id == user.member_id: 203 | raise VirusError("Uh, you probably want to send that to someone else.") 204 | 205 | participant = await ctx.cog.get_participant(member.id) 206 | participant.backpack['{Emoji.love_letter}'] = 1 207 | chances = [(10, 'infect'), (89, 'nothing'), (1, 'kill')] 208 | value = weighted_random(chances) 209 | if value == 'infect': 210 | await ctx.cog.reinfect(participant) 211 | elif value == 'kill': 212 | await ctx.silent_react('\N{COLLISION SYMBOL}') 213 | return State.dead 214 | """), 215 | 'predicate': 'return user.sickness >= 70' 216 | }, 217 | { 218 | 'emoji': Emoji.present, 219 | 'name': 'Mystery Gift', 220 | 'description': "I wonder what's inside", 221 | 'total': 100, 222 | 'code': dedent(""" 223 | if user.is_susceptible(): 224 | roll = random.random() 225 | if roll < 0.25: 226 | await ctx.cog.infect(user) 227 | elif user.infected: 228 | roll = weighted_random([(1, 'a'), (3, 'b'), (6, 'c')]) 229 | if roll == 'a': 230 | await ctx.silent_react('\N{COLLISION SYMBOL}') 231 | return State.dead 232 | elif roll == 'b': 233 | return user.add_sickness(-10) 234 | else: 235 | return user.add_sickness(random.randint(10, 20)) 236 | """) 237 | }, 238 | { 239 | 'emoji': Emoji.airplane, 240 | 'name': 'Fly', 241 | 'description': "Go somewhere else, the sky's the limit", 242 | 'total': 10, 243 | 'code': dedent(""" 244 | channel = await ctx.request('What channel should we fly to?', commands.TextChannelConverter()) 245 | if channel is ...: 246 | raise VirusError('Timed out') 247 | elif channel is None: 248 | raise VirusError('Invalid channel') 249 | 250 | if user.healer: 251 | # Healers have a higher chance of healing 252 | rates = [(20, -20), (65, -5), (5, 20), (10, 15)] 253 | elif user.infected: 254 | rates = [(65, 20), (5, -5), (20, 10), (10, 15)] 255 | else: 256 | rates = [(90, 0), (5, -5), (5, 5)] 257 | 258 | sickness = weighted_random(rates) 259 | await ctx.cog.apply_sickness_to_all(channel, sickness, cause=user) 260 | """) 261 | }, 262 | { 263 | 'emoji': Emoji.bell, 264 | 'name': "Evangelist's Bell", 265 | 'description': "Probably not a good idea to touch this one...", 266 | 'total': 25, 267 | 'code': dedent(""" 268 | rates = [(75, 'infect'), (2, 'cure'), (3, 'die'), (20, 'healer')] 269 | roll = weighted_random(rates) 270 | if roll == 'infect': 271 | await ctx.cog.reinfect(user) 272 | elif roll == 'cure': 273 | await ctx.cog.cure(user) 274 | elif roll == 'die': 275 | await ctx.cog.kill(user) 276 | elif roll == 'healer': 277 | return State.become_healer 278 | """) 279 | }, 280 | { 281 | 'emoji': Emoji.gun, 282 | 'name': 'Water Gun', 283 | 'description': 'Have some fun and shoot someone with a little water', 284 | 'total': 20, 285 | 'uses': 6, 286 | 'code': dedent(""" 287 | member = await ctx.request('Who do you want to shoot?') 288 | if member is ...: 289 | raise VirusError("Timed out") 290 | elif member is None: 291 | raise VirusError("I don't know this member.") 292 | 293 | participant = await ctx.cog.get_participant(member.id) 294 | if participant.death is not None: 295 | raise VirusError("It's rude to play with the dead.") 296 | 297 | chance = [(1, 'die'), (5, 'dud')] 298 | if weighted_random(chance) == 'die': 299 | return await ctx.cog.process_state(State.dead, participant, cause=user) 300 | 301 | if participant.is_cured(): 302 | if random.randint(0, 5) == 5: 303 | await ctx.cog.process_state(State.reinfect, participant, cause=user) 304 | return 305 | 306 | if participant.is_susceptible(): 307 | await ctx.cog.infect(participant) 308 | return 309 | 310 | if user.infected: 311 | sickness = random.randint(5, 20) 312 | else: 313 | sickness = random.randint(-10, 10) 314 | 315 | state = participant.add_sickness(sickness) 316 | await ctx.cog.process_state(state, participant, cause=user) 317 | """) 318 | }, 319 | { 320 | 'emoji': Emoji.dagger, 321 | 'name': 'Dagger', 322 | 'description': 'Found this laying around somewhere', 323 | 'total': 20, 324 | 'uses': 2, 325 | 'code': dedent(""" 326 | if user.is_infectious(): 327 | return user.add_sickness(random.randint(10, 50)) 328 | 329 | if user.is_susceptible(): 330 | if random.randint(0, 1) == 0: 331 | await ctx.cog.infect(user) 332 | 333 | if user.healer: 334 | if random.randint(0, 1) == 0: 335 | return State.lose_healer 336 | """) 337 | } 338 | ] 339 | -------------------------------------------------------------------------------- /cogs/utils/context.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import discord 3 | import asyncio 4 | import io 5 | 6 | class Context(commands.Context): 7 | def tick(self, opt, label=None): 8 | lookup = { 9 | True: '<:greenTick:596576670815879169>', 10 | False: '<:redTick:596576672149667840>', 11 | None: '<:greyTick:596576672900186113>', 12 | } 13 | emoji = lookup.get(opt, '<:redTick:596576672149667840>') 14 | if label is not None: 15 | return f'{emoji}: {label}' 16 | return emoji 17 | 18 | async def react_tick(self, opt): 19 | try: 20 | await self.message.add_reaction(self.tick(opt)) 21 | except discord.HTTPException: 22 | pass 23 | 24 | async def safe_send(self, content, *, escape_mentions=True, **kwargs): 25 | """Same as send except with some safe guards. 26 | 27 | 1) If the message is too long then it sends a file with the results instead. 28 | 2) If ``escape_mentions`` is ``True`` then it escapes mentions. 29 | """ 30 | if escape_mentions: 31 | content = discord.utils.escape_mentions(content) 32 | 33 | if len(content) > 2000: 34 | fp = io.BytesIO(content.encode()) 35 | kwargs.pop('file', None) 36 | return await self.send(file=discord.File(fp, filename='message_too_long.txt'), **kwargs) 37 | else: 38 | return await self.send(content) 39 | 40 | async def prompt(self, message, *, timeout=60.0, delete_after=True): 41 | assert self.channel.permissions_for(self.me).add_reactions 42 | 43 | msg = await self.send(message) 44 | author_id = self.author.id 45 | confirm = None 46 | 47 | def check(payload): 48 | nonlocal confirm 49 | 50 | if payload.message_id != msg.id or payload.user_id != author_id: 51 | return False 52 | 53 | codepoint = str(payload.emoji) 54 | 55 | if codepoint == '\N{WHITE HEAVY CHECK MARK}': 56 | confirm = True 57 | return True 58 | elif codepoint == '\N{CROSS MARK}': 59 | confirm = False 60 | return True 61 | 62 | return False 63 | 64 | for emoji in ('\N{WHITE HEAVY CHECK MARK}', '\N{CROSS MARK}'): 65 | await msg.add_reaction(emoji) 66 | 67 | try: 68 | await self.bot.wait_for('raw_reaction_add', check=check, timeout=timeout) 69 | except asyncio.TimeoutError: 70 | confirm = None 71 | 72 | try: 73 | if delete_after: 74 | await msg.delete() 75 | except discord.HTTPException: 76 | pass 77 | finally: 78 | return confirm 79 | 80 | async def request(self, message, converter=commands.MemberConverter(), *, timeout=60.0, delete_after=True): 81 | """Request information from the user interactively. 82 | 83 | Parameters 84 | ----------- 85 | message: str 86 | The message to show along with the prompt. 87 | converter: :class:`Converter` 88 | The converter to convert the requested data from. 89 | timeout: float 90 | How long to wait before returning. 91 | delete_after: bool 92 | Whether to delete the confirmation message after we're done. 93 | 94 | Returns 95 | -------- 96 | Optional[Any] 97 | The result of the converter conversion applied to the message. 98 | If conversion fails then ``None`` is returned. 99 | If there's a timeout then ``...`` is returned. 100 | """ 101 | 102 | await self.send(message) 103 | author_id = self.author.id 104 | channel_id = self.channel.id 105 | 106 | def check(m): 107 | return m.author.id == author_id and m.channel.id == channel_id 108 | 109 | try: 110 | msg = await self.bot.wait_for('message', check=check, timeout=timeout) 111 | except asyncio.TimeoutError: 112 | result = ... 113 | else: 114 | try: 115 | result = await converter.convert(self, msg.content) 116 | except Exception: 117 | result = None 118 | 119 | try: 120 | if delete_after: 121 | await msg.delete() 122 | except discord.HTTPException: 123 | pass 124 | finally: 125 | return result 126 | 127 | async def silent_react(self, emoji): 128 | try: 129 | await self.message.add_reaction(emoji) 130 | except discord.HTTPException: 131 | pass 132 | -------------------------------------------------------------------------------- /cogs/utils/formats.py: -------------------------------------------------------------------------------- 1 | def human_join(seq, delim=', ', final='and'): 2 | size = len(seq) 3 | if size == 0: 4 | return '' 5 | 6 | if size == 1: 7 | return seq[0] 8 | 9 | if size == 2: 10 | return f'{seq[0]} {final} {seq[1]}' 11 | 12 | return delim.join(seq[:-1]) + f' {final} {seq[-1]}' 13 | -------------------------------------------------------------------------------- /cogs/utils/storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import uuid 4 | import asyncio 5 | import datetime 6 | 7 | class StorageHook(json.JSONEncoder): 8 | def default(self, o): 9 | if hasattr(o, 'to_json'): 10 | return o.to_json() 11 | if isinstance(o, datetime.datetime): 12 | return {'__date__': o.isoformat()} 13 | return super().default(o) 14 | 15 | @classmethod 16 | def object_hook(cls, data): 17 | if '__date__' in data: 18 | return datetime.datetime.fromisoformat(data['__date__']) 19 | if cls.from_json is not StorageHook.from_json: 20 | return cls.from_json(data) 21 | return data 22 | 23 | @classmethod 24 | def from_json(cls, data): 25 | return data 26 | 27 | class Storage: 28 | """The "database" object. Internally based on ``json``. 29 | 30 | You can pass a hook keyword argument to denote a class that is 31 | used to hook into the (de)serialization. 32 | 33 | Has built-in support for datetime types. 34 | 35 | It must subclass StorageHook and can provide a from_json 36 | classmethod. 37 | """ 38 | 39 | def __init__(self, name, *, hook=StorageHook, init=None): 40 | self.name = name 41 | if not issubclass(hook, StorageHook): 42 | raise TypeError('hook has to subclass StorageHook') 43 | 44 | self.object_hook = hook.object_hook 45 | self.encoder = hook 46 | self.loop = asyncio.get_event_loop() 47 | self.lock = asyncio.Lock() 48 | self.init = init 49 | self.load_from_file() 50 | 51 | def load_from_file(self): 52 | try: 53 | with open(self.name, 'r') as f: 54 | self._db = json.load(f, object_hook=self.object_hook) 55 | except FileNotFoundError: 56 | if self.init is not None: 57 | self._db = self.init() 58 | else: 59 | self._db = {} 60 | 61 | async def load(self): 62 | async with self.lock: 63 | await self.loop.run_in_executor(None, self.load_from_file) 64 | 65 | def _dump(self): 66 | temp = '%s-%s.tmp' % (uuid.uuid4(), self.name) 67 | with open(temp, 'w', encoding='utf-8') as tmp: 68 | json.dump(self._db.copy(), tmp, ensure_ascii=True, cls=self.encoder, separators=(',', ':')) 69 | 70 | # atomically move the file 71 | os.replace(temp, self.name) 72 | 73 | async def save(self): 74 | async with self.lock: 75 | await self.loop.run_in_executor(None, self._dump) 76 | 77 | def get(self, key, *args): 78 | """Retrieves a config entry.""" 79 | return self._db.get(str(key), *args) 80 | 81 | async def put(self, key, value, *args): 82 | """Edits a config entry.""" 83 | self._db[str(key)] = value 84 | await self.save() 85 | 86 | async def remove(self, key): 87 | """Removes a config entry.""" 88 | del self._db[str(key)] 89 | await self.save() 90 | 91 | def __contains__(self, item): 92 | return str(item) in self._db 93 | 94 | def __getitem__(self, item): 95 | return self._db[str(item)] 96 | 97 | def __len__(self): 98 | return len(self._db) 99 | 100 | def all(self): 101 | return self._db 102 | -------------------------------------------------------------------------------- /cogs/virus.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | from collections import deque, defaultdict, Counter 3 | from collections.abc import Sequence 4 | from bisect import bisect_left 5 | 6 | import dataclasses 7 | import discord 8 | import random 9 | import datetime 10 | import typing 11 | import itertools 12 | import asyncio 13 | import textwrap 14 | import enum 15 | 16 | from .utils import storage, formats 17 | 18 | GENERAL_ID = 336642776609456130 19 | SNAKE_PIT_ID = 448285120634421278 20 | TESTING_ID = 381963689470984203 21 | EVENT_ID = 674833398744743936 22 | INFECTED_ROLE_ID = 674811235190964235 23 | HEALER_ROLE_ID = 674838998736437248 24 | DISCORD_PY = 336642139381301249 25 | MOD_TESTING_ID = 568662293190148106 26 | MAX_ALLOWED_HEALS = 3 27 | MAX_VACCINE = 25 28 | VACCINE_MILESTONES = (5, 10, 15, 20, MAX_VACCINE) 29 | 30 | # GENERAL_ID = 182325885867786241 31 | # SNAKE_PIT_ID = 182328316676538369 32 | # TESTING_ID = 182328141862273024 33 | # EVENT_ID = 182332539975892992 34 | # INFECTED_ROLE_ID = 674854333577297930 35 | # HEALER_ROLE_ID = 674854310655557633 36 | # DISCORD_PY = 182325885867786241 37 | 38 | def weighted_random(pairs): 39 | total = sum(weight for weight, _ in pairs) 40 | rand = random.randint(1, total) 41 | for weight, value in pairs: 42 | rand -= weight 43 | if rand <= 0: 44 | return value 45 | 46 | def tomorrow_date(relative=None): 47 | now = relative or datetime.datetime.utcnow() 48 | return datetime.datetime.combine(now.date(), datetime.time()) + datetime.timedelta(days=1) 49 | 50 | class VirusError(commands.CommandError): 51 | pass 52 | 53 | class UniqueCappedList(Sequence): 54 | def __init__(self, maxlen): 55 | self.data = deque(maxlen=maxlen) 56 | 57 | def __getitem__(self, idx): 58 | return self.data[idx] 59 | 60 | def __len__(self): 61 | return len(self.data) 62 | 63 | def __contains__(self, item): 64 | return item in self.data 65 | 66 | def __iter__(self): 67 | return iter(self.data) 68 | 69 | def __reversed__(self): 70 | return reversed(self.data) 71 | 72 | def index(self, value, *args, **kwargs): 73 | return self.data.index(value, *args, **kwargs) 74 | 75 | def count(self, value): 76 | return self.data.count(value) 77 | 78 | def append(self, item): 79 | if item not in self.data: 80 | self.data.append(item) 81 | 82 | class State(enum.Enum): 83 | alive = 0 84 | dead = 1 85 | already_dead = 2 86 | cured = 3 87 | become_healer = 4 88 | reinfect = 5 89 | lose_healer = 6 90 | 91 | @dataclasses.dataclass 92 | class Participant: 93 | member_id: int 94 | infected: bool = False 95 | healer: bool = False 96 | masked: bool = False 97 | immunocompromised: typing.Optional[bool] = None 98 | infected_since: typing.Optional[datetime.datetime] = None 99 | death: typing.Optional[datetime.datetime] = None 100 | sickness: int = 0 101 | backpack: typing.Dict[str, bool] = dataclasses.field(default_factory=dict) 102 | healed: typing.List[int] = dataclasses.field(default_factory=list) 103 | last_heal: typing.Optional[datetime.datetime] = None 104 | immune_until: typing.Optional[datetime.datetime] = None 105 | pda_cooldown: typing.Optional[datetime.datetime] = None 106 | 107 | data_type: dataclasses.InitVar[int] = 1 108 | 109 | def __lt__(self, other): 110 | return isinstance(other, Participant) and self.member_id < other.member_id 111 | 112 | def __post_init__(self, data_type): 113 | # This is pretty hard to model realistically since the definition 114 | # of immunocompromised depends on what type, we have 115 | # old age, HIV, AIDS, cancer, transplant recipients, etc. 116 | # There are over 400 primary immunodeficiency disorders 117 | # So it's hard to pick a certain rate here. 118 | # For the sake of simplicity and to make the game more fun than realistic 119 | # an immunodeficiency rate of ~15% was chosen. 120 | if self.immunocompromised is None: 121 | self.immunocompromised = random.random() < 0.15 122 | 123 | def is_dead(self): 124 | return self.sickness >= 100 125 | 126 | def is_cured(self): 127 | return self.infected and self.sickness == 0 128 | 129 | def is_vaccinated(self): 130 | return '\N{SYRINGE}' in self.backpack 131 | 132 | def is_susceptible(self): 133 | return not self.infected and not self.healer 134 | 135 | def is_infectious(self): 136 | return self.infected and self.sickness not in (0, 100) 137 | 138 | def can_be_touched(self, now): 139 | return self.pda_cooldown is None or now > self.pda_cooldown 140 | 141 | def missing_research_items(self): 142 | items = { 143 | '\U0001f9ec', 144 | '\U0001f9a0', 145 | '\U0001f9eb', 146 | '\U0001f9ea', 147 | '\N{MICROSCOPE}', 148 | } 149 | 150 | return items - set(self.backpack) 151 | 152 | def infect(self, *, force=False): 153 | if self.infected and not force: 154 | return False 155 | 156 | self.infected = True 157 | self.sickness = 15 158 | self.infected_since = datetime.datetime.utcnow() 159 | return True 160 | 161 | def kill(self): 162 | if self.death: 163 | return False 164 | 165 | self.sickness = 100 166 | self.death = datetime.datetime.utcnow() 167 | return True 168 | 169 | def add_sickness(self, number=None): 170 | """Increases sickness. If no number is passed then it randomly increases it. 171 | 172 | Returns None if already dead, False if not dead, True if became dead. 173 | """ 174 | 175 | if self.is_dead(): 176 | return State.already_dead 177 | 178 | if self.is_vaccinated() or self.is_cured(): 179 | return State.alive 180 | 181 | if number is None: 182 | # 1% chance of gaining +3 183 | # 5% chance of gaining +1 184 | roll = weighted_random([(1, 'a'), (5, 'b'), (94, None)]) 185 | if roll == 'a': 186 | self.sickness += 5 if self.immunocompromised else 3 187 | elif roll == 'b': 188 | self.sickness += 2 if self.immunocompromised else 1 189 | else: 190 | self.sickness += number 191 | 192 | if self.sickness <= 0: 193 | self.sickness = 0 194 | return State.cured 195 | 196 | # RIP 197 | if self.is_dead(): 198 | return State.dead 199 | return State.alive 200 | 201 | @property 202 | def sickness_rate(self): 203 | # The sickness rate is the adjusted sickness 204 | # when accounting for certain properties like wearing 205 | # a mask or being a healer 206 | # Masks are 35% effective in this model 207 | base = self.sickness * 0.65 if self.masked else self.sickness 208 | if self.healer: 209 | base /= 2.0 210 | 211 | return min(base * 1.5, 100.0) if self.immunocompromised else base 212 | 213 | @property 214 | def base_healing(self): 215 | # The base healing is an inverse of the sickness 216 | # ergo, the less sick you are the better your healing prowess. 217 | base = 5 * (self.sickness / 1000.0) 218 | return base / 2.0 if self.immunocompromised else base 219 | 220 | def buy(self, item): 221 | item.in_stock -= 1 222 | self.backpack[item.emoji] = item.uses 223 | 224 | async def use(self, ctx, item): 225 | state = await item.use(ctx, self) 226 | self.backpack[item.emoji] -= 1 227 | return state 228 | 229 | def heal(self, other): 230 | if self.is_dead(): 231 | raise VirusError('<:rooThink:596576798351949847>') 232 | 233 | if other.is_dead(): 234 | raise VirusError("I'm afraid they're already dead.") 235 | 236 | if other.is_cured(): 237 | raise VirusError("You can't heal those who are already cured.") 238 | 239 | if not other.infected: 240 | raise VirusError("No point in healing those who aren't sick") 241 | 242 | if other.healer: 243 | raise VirusError("I'm sure they know how to treat themselves, we've got others to worry about for now.") 244 | 245 | now = datetime.datetime.utcnow() 246 | if other.immune_until and other.immune_until >= now: 247 | raise VirusError("This person seems to have already been treated, let them go for now.") 248 | 249 | tomorrow = tomorrow_date(self.last_heal or now) 250 | if now >= tomorrow: 251 | self.healed = [] 252 | 253 | if len(self.healed) >= MAX_ALLOWED_HEALS: 254 | raise VirusError("Don't push yourself too hard, you've already done enough.") 255 | 256 | if other.member_id in self.healed: 257 | raise VirusError("This person's already been treated today, other people need to be treated too.") 258 | 259 | self.last_heal = now 260 | self.healed.append(other.member_id) 261 | other.immune_until = now + datetime.timedelta(hours=4) 262 | if other.sickness != 0: 263 | return other.add_sickness(random.randint(-20, -10)) 264 | 265 | def hug(self, other): 266 | if other.is_infectious(): 267 | if self.is_cured(): 268 | roll = random.random() 269 | if roll < 0.95: 270 | return State.alive 271 | return State.reinfect 272 | elif self.is_susceptible(): 273 | roll = random.random() 274 | if roll < 0.8: 275 | return State.alive 276 | return State.reinfect 277 | elif self.healer: 278 | roll = random.random() 279 | if roll < 0.25: 280 | return State.reinfect 281 | return State.alive 282 | 283 | return self.add_sickness(random.randint(5, 10)) 284 | 285 | if not self.is_cured() and other.healer: 286 | return self.add_sickness(random.randint(-10, 5)) 287 | 288 | return State.alive 289 | 290 | def to_json(self): 291 | o = dataclasses.asdict(self) 292 | o['data_type'] = 1 293 | return o 294 | 295 | 296 | @dataclasses.dataclass 297 | class Item: 298 | emoji: str 299 | name: str 300 | description: str 301 | total: int 302 | code: str 303 | predicate: typing.Optional[str] = None 304 | in_stock: typing.Optional[int] = None 305 | unlocked: bool = False 306 | uses: int = 1 307 | 308 | data_type: dataclasses.InitVar[int] = 2 309 | 310 | def __post_init__(self, data_type): 311 | if self.in_stock is None: 312 | self.in_stock = self.total 313 | 314 | to_compile = f'async def func(self, ctx, user):\n{textwrap.indent(self.code, " ")}' 315 | if self.predicate is None: 316 | to_compile = f'{to_compile}\n\ndef pred(self, user):\n return True' 317 | else: 318 | to_compile = f'{to_compile}\n\ndef pred(self, user):\n{textwrap.indent(self.predicate, " ")}' 319 | 320 | env = globals() 321 | env['weighted_random'] = weighted_random 322 | 323 | try: 324 | exec(to_compile, env) 325 | except Exception as e: 326 | raise RuntimeError(f'Could not compile source for item {self.emoji}: {e.__class__.__name__}: {e}') from e 327 | 328 | self._caller = env['func'] 329 | self._pred = env['pred'] 330 | 331 | def to_json(self): 332 | o = dataclasses.asdict(self) 333 | o['data_type'] = 2 334 | return o 335 | 336 | async def use(self, ctx, user): 337 | return await self._caller(self, ctx, user) 338 | 339 | def usable_by(self, user): 340 | return not user.is_dead() and not user.is_cured() and self._pred(self, user) 341 | 342 | def is_buyable_for(self, user): 343 | return (not user.is_dead() and 344 | not user.is_cured() and 345 | self.in_stock and 346 | self.unlocked and 347 | self._pred(self, user) and 348 | self.emoji not in user.backpack) 349 | 350 | @dataclasses.dataclass 351 | class Stats: 352 | infected: int = 0 353 | healers: int = 0 354 | dead: int = 0 355 | cured: int = 0 356 | vaccinated: int = 0 357 | 358 | people_cured: typing.Dict[str, int] = dataclasses.field(default_factory=dict) 359 | people_infected: typing.Dict[str, int] = dataclasses.field(default_factory=dict) 360 | people_killed: typing.Dict[str, int] = dataclasses.field(default_factory=dict) 361 | 362 | data_type: dataclasses.InitVar[int] = 3 363 | 364 | def to_json(self): 365 | o = dataclasses.asdict(self) 366 | o['data_type'] = 3 367 | return o 368 | 369 | class VirusStorageHook(storage.StorageHook): 370 | @classmethod 371 | def from_json(cls, data): 372 | try: 373 | data_type = data['data_type'] 374 | except KeyError: 375 | return storage.StorageHook.from_json(data) 376 | 377 | if data_type == 1: 378 | return Participant(**data) 379 | elif data_type == 2: 380 | return Item(**data) 381 | elif data_type == 3: 382 | return Stats(**data) 383 | 384 | class Virus(commands.Cog): 385 | """The discord.py virus has spread and needs to be contained \N{FACE SCREAMING IN FEAR}""" 386 | 387 | def __init__(self, bot): 388 | self.bot = bot 389 | self.storage = storage.Storage('virus.json', hook=VirusStorageHook, init=self.init_storage) 390 | # last 5 (unique) authors of a message 391 | # these are Participant instances 392 | self._authors = defaultdict(lambda: UniqueCappedList(maxlen=5)) 393 | self._shop_restocking = False 394 | self._timer_has_data = asyncio.Event() 395 | self._task = bot.loop.create_task(self.day_cycle()) 396 | 397 | def cog_unload(self): 398 | self._task.cancel() 399 | 400 | def init_storage(self): 401 | from .data import items 402 | return { 403 | 'participants': {}, 404 | 'stats': Stats(), 405 | 'store': [ 406 | Item(**data) 407 | for data in items.raw 408 | ], 409 | 'next_cycle': None, 410 | 'event_started': None, 411 | } 412 | 413 | def cog_check(self, ctx): 414 | return not self.is_over() and ctx.channel.id in (TESTING_ID, SNAKE_PIT_ID, MOD_TESTING_ID) 415 | 416 | async def cog_command_error(self, ctx, error): 417 | if isinstance(error, VirusError): 418 | await ctx.send(error) 419 | 420 | def is_over(self): 421 | return self.storage['stats'].vaccinated >= MAX_VACCINE 422 | 423 | @staticmethod 424 | def get_unique(number, elements, already_seen): 425 | diff = list(elements - already_seen) 426 | if len(diff) <= number: 427 | return diff 428 | 429 | elements = [] 430 | while number: 431 | index = random.randrange(len(diff)) 432 | elements.append(diff[index]) 433 | del diff[index] 434 | number -= 1 435 | return elements 436 | 437 | async def get_participant(self, member_id): 438 | participants = self.storage['participants'] 439 | string_id = str(member_id) 440 | try: 441 | return participants[string_id] 442 | except KeyError: 443 | if string_id == str(self.bot.user.id): 444 | raise VirusError('The evangelist cannot participate') 445 | 446 | participants[string_id] = participant = Participant(member_id=member_id) 447 | await self.storage.put('participants', participants) 448 | return participant 449 | 450 | async def day_cycle(self): 451 | if self.storage.get('next_cycle') is None: 452 | await self._timer_has_data.wait() 453 | 454 | await self.bot.wait_until_ready() 455 | while True: 456 | next_cycle = self.storage.get('next_cycle') 457 | await discord.utils.sleep_until(next_cycle) 458 | await self.continue_virus() 459 | await self.storage.put('next_cycle', next_cycle + datetime.timedelta(days=1)) 460 | 461 | @commands.group() 462 | @commands.is_owner() 463 | async def virus(self, ctx): 464 | """Manages the virus""" 465 | pass 466 | 467 | async def new_virus_day(self, guild, infected=None, healers=None, new_infected=5, new_healers=2): 468 | infected = infected or set() 469 | healers = healers or set() 470 | 471 | infected_ret = set() 472 | healers_ret = set() 473 | for channel_id in (GENERAL_ID, SNAKE_PIT_ID, TESTING_ID): 474 | channel = guild.get_channel(channel_id) 475 | authors = { 476 | m.author 477 | async for m in channel.history(limit=500) 478 | if not m.author.bot and isinstance(m.author, discord.Member) 479 | } 480 | infected_ret.update(self.get_unique(new_infected, authors, infected | healers | healers_ret | infected_ret)) 481 | healers_ret.update(self.get_unique(new_healers, authors, infected | healers | healers_ret | infected_ret)) 482 | 483 | to_send = [] 484 | 485 | # Initialize and fill the storage 486 | stats = self.storage['stats'] 487 | stats.infected += len(infected_ret) 488 | stats.healers += len(healers_ret) 489 | 490 | participants = self.storage['participants'] 491 | 492 | for member in infected_ret: 493 | try: 494 | await member.add_roles(discord.Object(id=INFECTED_ROLE_ID)) 495 | except discord.HTTPException: 496 | to_send.append(f'\N{CROSS MARK} Could not infect {member.mention}') 497 | else: 498 | to_send.append(f'\N{WHITE HEAVY CHECK MARK} Infected {member.mention}') 499 | finally: 500 | participants[str(member.id)] = p = Participant(member_id=member.id) 501 | p.infect() 502 | 503 | for member in healers_ret: 504 | try: 505 | await member.add_roles(discord.Object(id=HEALER_ROLE_ID)) 506 | except discord.HTTPException: 507 | to_send.append(f'\N{CROSS MARK} Could not make {member.mention} healer') 508 | else: 509 | to_send.append(f'\N{WHITE HEAVY CHECK MARK} Made {member.mention} healer') 510 | finally: 511 | participants[str(member.id)] = p = Participant(member_id=member.id, healer=True) 512 | 513 | await self.storage.save() 514 | 515 | infected_mentions = [str(m) for m in infected_ret] 516 | healer_mentions = [str(m) for m in healers_ret] 517 | 518 | if infected_mentions: 519 | await self.log_channel.send(f'{formats.human_join(infected_mentions)} are suddenly infected.') 520 | if healer_mentions: 521 | await self.log_channel.send(f'{formats.human_join(healer_mentions)} are suddenly healers...?') 522 | 523 | return '\n'.join(to_send) 524 | 525 | @virus.command(name='start') 526 | async def virus_start(self, ctx): 527 | """Starts the virus infection.""" 528 | to_send = await self.new_virus_day(ctx.guild) 529 | if to_send: 530 | await ctx.send(to_send) 531 | 532 | now = ctx.message.created_at 533 | next_cycle = datetime.datetime.combine(now.date(), datetime.time()) + datetime.timedelta(days=1) 534 | await self.storage.put('event_started', now) 535 | await self.storage.put('next_cycle', next_cycle) 536 | self._timer_has_data.set() 537 | 538 | async def continue_virus(self): 539 | guild = self.bot.get_guild(DISCORD_PY) 540 | infected = set(guild.get_role(INFECTED_ROLE_ID).members) 541 | healers = set(guild.get_role(HEALER_ROLE_ID).members) 542 | 543 | try: 544 | await self.new_virus_day(guild, infected, healers, 1, 1) 545 | except discord.HTTPException: 546 | pass 547 | 548 | # Infect everyone who is currently infected: 549 | participants = self.storage['participants'] 550 | for member in infected: 551 | try: 552 | user = participants[str(member.id)] 553 | except KeyError: 554 | continue 555 | 556 | if user.is_infectious(): 557 | died = user.add_sickness(15) 558 | if died is State.dead: 559 | await self.kill(user) 560 | 561 | await self.storage.save() 562 | 563 | def get_member(self, member_id): 564 | guild = self.bot.get_guild(DISCORD_PY) 565 | return guild.get_member(member_id) 566 | 567 | @property 568 | def log_channel(self): 569 | return self.bot.get_guild(DISCORD_PY).get_channel(EVENT_ID) 570 | 571 | async def infect(self, user): 572 | self.storage['stats'].infected += user.infect() 573 | await self.storage.save() 574 | 575 | member = self.get_member(user.member_id) 576 | if member is not None: 577 | await member.add_roles(discord.Object(id=INFECTED_ROLE_ID)) 578 | 579 | await self.send_infect_message(user) 580 | 581 | async def reinfect(self, user): 582 | # Causes a previously cured user to be reinfected 583 | if user.is_cured(): 584 | self.storage['stats'].cured -= 1 585 | elif user.is_susceptible(): 586 | return await self.infect(user) 587 | elif user.is_infectious(): 588 | state = user.add_sickness(15) 589 | return await self.process_state(state, user) 590 | 591 | user.infect(force=True) 592 | await self.storage.save() 593 | await self.send_reinfect_message(user) 594 | 595 | async def kill(self, user): 596 | self.storage['stats'].dead += user.kill() 597 | await self.storage.save() 598 | await self.send_dead_message(user) 599 | 600 | async def cure(self, user): 601 | user.sickness = 0 602 | self.storage['stats'].cured += 1 603 | await self.storage.save() 604 | await self.send_cured_message(user) 605 | 606 | async def potentially_infect(self, channel_id, participant): 607 | # Infection rate is calculated using the last 5 people who sent messages in the channel. 608 | # We can consider it equivalent to the 5 people in the room. 609 | # Everyone has a sickness value assigned to them 610 | participants = self._authors[channel_id] 611 | if len(participants) == 0: 612 | return 613 | 614 | total_sickness = sum(p.sickness_rate for p in participants) / len(participants) 615 | 616 | # 0 sickness -> 0% chance, 100 sickness -> 10% chance 617 | # of getting infected 618 | cutoff = total_sickness / 1000 619 | 620 | # If we're masked we get *another* bonus 621 | if participant.masked: 622 | cutoff *= 0.65 623 | 624 | if random.random() < cutoff: 625 | # got infected 626 | await self.infect(participant) 627 | 628 | async def surround_healing(self, channel_id, healer): 629 | participants = self._authors[channel_id] 630 | if len(participants) == 0: 631 | return 632 | 633 | # The surround healing algorithm is based on a few things 634 | # 1) a base healing rate which is inversed of the sickness 635 | # 2) player based modifiers 636 | # 3) an actual roll saying it's possible 637 | base = healer.base_healing 638 | for p in participants: 639 | if p.is_infectious(): 640 | roll = random.random() 641 | if roll < 0.1: 642 | state = p.add_sickness(int(-(base * (1 - p.sickness_rate / 100)))) 643 | await self.process_state(state, p, cause=healer) 644 | 645 | await self.storage.save() 646 | 647 | async def apply_sickness_to_all(self, channel, sickness, *, cause=None): 648 | # A helper function to help apply a sickness to all 649 | # recent people in a channel (i.e. an area) 650 | 651 | if not channel.permissions_for(channel.guild.me).send_messages or channel.id == EVENT_ID: 652 | raise VirusError("I don't know what this channel is about") 653 | 654 | authors = {m.author async for m in channel.history(limit=100)} 655 | for author in authors: 656 | participant = await self.get_participant(author.id) 657 | if participant.is_infectious(): 658 | state = participant.add_sickness(sickness) 659 | await self.process_state(state, participant, cause=cause) 660 | 661 | await self.storage.save() 662 | 663 | async def send_dead_message(self, participant): 664 | total = self.storage['stats'].dead 665 | 666 | try: 667 | ping = self.bot.get_user(participant.member_id) or await self.bot.fetch_user(participant.member_id) 668 | except discord.HTTPException: 669 | return 670 | 671 | dialogue = [ 672 | f'A moment silence for {ping} <:rooBless:597589960270544916> (by the way, {total} dead so far)', 673 | f"Some people die, some people live, others just disappear altogether. One thing is certain: {ping} isn't alive. Oh also, {total} dead so far.", 674 | f"Got a letter saying that {ping} isn't with us anymore. Can you believe we have {total} dead to this thing?", 675 | f"Uh.. {ping} died? I don't even know who {ping} is lol. Well anyway that's {total} dead.", 676 | f"Someone that goes by {ping} died. RIP. Kinda forgot what's supposed to go here. Oh yeah {total} dead so far.", 677 | f'\N{SKULL} {ping} has died. {total} dead so far.', 678 | ] 679 | 680 | try: 681 | await self.log_channel.send(random.choice(dialogue)) 682 | except discord.HTTPException: 683 | pass 684 | 685 | async def send_infect_message(self, participant): 686 | total = self.storage['stats'].infected 687 | 688 | try: 689 | ping = self.bot.get_user(participant.member_id) or await self.bot.fetch_user(participant.member_id) 690 | except discord.HTTPException: 691 | return 692 | 693 | dialogue = [ 694 | f'{ping} has been infected. {total} infected so far...', 695 | f'{ping} is officially infected. Feel free to stay away from them and {total-1} more.', 696 | f"Ya know, shaming someone for being sick isn't very nice. Protect {ping} and their {total-1} friends.", 697 | f"Unfortunately {ping} has fallen ill. Get well soon. Oh and {total} infected so far.", 698 | f'"from:{ping} infected" might bring up some interesting results <:rooThink:596576798351949847> ({total} infected)', 699 | ] 700 | 701 | try: 702 | await self.log_channel.send(random.choice(dialogue)) 703 | except discord.HTTPException: 704 | pass 705 | 706 | async def send_cured_message(self, participant): 707 | total = self.storage['stats'].cured 708 | 709 | try: 710 | ping = self.bot.get_user(participant.member_id) or await self.bot.fetch_user(participant.member_id) 711 | except discord.HTTPException: 712 | return 713 | 714 | 715 | try: 716 | await self.log_channel.send(f'{ping} has been cured! Amazing. {total} cured so far.') 717 | except discord.HTTPException: 718 | pass 719 | 720 | async def send_healer_message(self, participant): 721 | total = self.storage['stats'].healers 722 | 723 | try: 724 | ping = self.bot.get_user(participant.member_id) or await self.bot.fetch_user(participant.member_id) 725 | except discord.HTTPException: 726 | return 727 | 728 | try: 729 | await self.log_channel.send(f'{ping} is now a healer...? Wonder what that means. Rather rare, only {total} of them.') 730 | except discord.HTTPException: 731 | pass 732 | 733 | async def send_healer_remove_message(self, participant): 734 | total = self.storage['stats'].healers 735 | 736 | try: 737 | ping = self.bot.get_user(participant.member_id) or await self.bot.fetch_user(participant.member_id) 738 | except discord.HTTPException: 739 | return 740 | 741 | try: 742 | await self.log_channel.send(f'{ping} is no longer a healer due to a fatal accident. Only {total} remain now.') 743 | except discord.HTTPException: 744 | pass 745 | 746 | async def send_reinfect_message(self, participant): 747 | try: 748 | ping = self.bot.get_user(participant.member_id) or await self.bot.fetch_user(participant.member_id) 749 | except discord.HTTPException: 750 | return 751 | 752 | stats = self.storage['stats'] 753 | 754 | try: 755 | await self.log_channel.send(f'{ping} has gotten reinfected... Cured count is down to {stats.cured}.') 756 | except discord.HTTPException: 757 | return 758 | 759 | async def vaccinate(self, user): 760 | user.sickness = 0 761 | self.storage['stats'].vaccinated += 1 762 | await self.storage.save() 763 | vaccinated = self.storage['stats'].vaccinated 764 | 765 | if vaccinated in VACCINE_MILESTONES: 766 | if self.is_over(): 767 | msg = f"\N{CHEERING MEGAPHONE} It seems this virus has finally been eradicated \N{PARTY POPPER}" 768 | else: 769 | msg = f'\N{CHEERING MEGAPHONE} It seems we have {vaccinated} vaccinated now.' 770 | 771 | await self.log_channel.send(msg) 772 | 773 | @commands.Cog.listener() 774 | async def on_regular_message(self, message): 775 | if message.guild is None or message.guild.id != DISCORD_PY: 776 | return 777 | 778 | if message.author.id == self.bot.user.id: 779 | return 780 | 781 | if self.is_over(): 782 | return 783 | 784 | user = await self.get_participant(message.author.id) 785 | if user.healer: 786 | # This is an if block because healers can also be infected 787 | # which means that they should both do their passive heal and also get 788 | # increasingly sick 789 | await self.surround_healing(message.channel.id, user) 790 | 791 | if user.is_susceptible(): 792 | if user.immune_until is None or user.immune_until < message.created_at: 793 | await self.potentially_infect(message.channel.id, user) 794 | elif user.is_infectious(): 795 | if user.immune_until is None or user.immune_until < message.created_at: 796 | state = user.add_sickness() 797 | await self.process_state(state, user) 798 | 799 | self._authors[message.channel.id].append(user) 800 | 801 | @commands.group(invoke_without_command=True, aliases=['store']) 802 | async def shop(self, ctx): 803 | """The item shop!""" 804 | user = await self.get_participant(ctx.author.id) 805 | buyable = [item for item in self.storage['store'] if item.is_buyable_for(user)] 806 | 807 | embed = discord.Embed(title='Item Shop') 808 | embed.set_author(name=str(ctx.author), icon_url=ctx.author.avatar_url_as(format='png')) 809 | if len(buyable) == 0: 810 | embed.description = 'Nothing to see here' 811 | return await ctx.send(embed=embed) 812 | 813 | for item in buyable: 814 | embed.add_field(name=item.emoji, value=f'{item.in_stock} in stock!\n{item.name}\n{item.description}') 815 | 816 | await ctx.send(embed=embed) 817 | 818 | @shop.command(name='buy') 819 | @commands.max_concurrency(1, commands.BucketType.guild, wait=True) 820 | @commands.check(lambda ctx: not ctx.cog._shop_restocking) 821 | async def shop_buy(self, ctx, *, emoji: str): 822 | """Buy an item from the shop 823 | 824 | You have to use the emoji to buy it. 825 | 826 | Items are free and on a first come, first served basis. 827 | You can only buy an item once. 828 | """ 829 | 830 | item = discord.utils.get(self.storage['store'], emoji=emoji) 831 | if item is None: 832 | dialogue = [ 833 | "It's {current_year} and we still don't know how to use emoji lol", 834 | "Hmm.. what are you buying? <:rooThink:596576798351949847>", 835 | "You look around the room. Countless people staring at your mistake. You leave in silence.", 836 | ] 837 | return await ctx.send(random.choice(dialogue)) 838 | 839 | user = await self.get_participant(ctx.author.id) 840 | if not item.is_buyable_for(user): 841 | dialogue = [ 842 | "Hm.. doesn't seem like you can buy that one bub.", 843 | "For some reason the cosmic rays are telling me that this isn't allowed to be purchased.", 844 | "Whoops, the item fell and I need to go look for it. Maybe it doesn't like you?", 845 | "Nothing to see here friend \N{SLEUTH OR SPY}\ufe0f", 846 | ] 847 | return await ctx.send(random.choice(dialogue)) 848 | 849 | user.buy(item) 850 | await self.storage.save() 851 | await ctx.send(f'Alright {ctx.author.mention}, you bought {item.emoji}. Check your backpack.') 852 | 853 | @shop_buy.error 854 | async def shop_buy_error(self, ctx, error): 855 | if isinstance(error, commands.CheckFailure) and self._shop_restocking: 856 | await ctx.send('Sorry, the store is being re-stocked right now...') 857 | 858 | @shop.command(name='restock', aliases=['unlock', 'lock']) 859 | @commands.is_owner() 860 | async def shop_restock(self, ctx, *items: str): 861 | """Control the shop.""" 862 | status = [] 863 | store = self.storage['store'] 864 | getter = discord.utils.get 865 | 866 | for emoji in items: 867 | item = getter(store, emoji=emoji) 868 | if item is None: 869 | status.append(f'{emoji}: {ctx.tick(False)}') 870 | else: 871 | status.append(f'{emoji}: {ctx.tick(True)}') 872 | item.in_stock = item.total 873 | if ctx.invoked_with == 'unlock': 874 | item.unlocked = True 875 | elif ctx.invoked_with == 'lock': 876 | item.unlocked = False 877 | 878 | await self.storage.save() 879 | await ctx.send('\n'.join(status)) 880 | 881 | @shop.command(name='refresh') 882 | @commands.is_owner() 883 | async def shop_refresh(self, ctx): 884 | """Refresh the shop with new data.""" 885 | 886 | # This is hacky but YOLO 887 | from .data import items 888 | import importlib 889 | importlib.reload(items) 890 | 891 | pre_existing = self.storage['store'] 892 | new_items = [Item(**data) for data in items.raw] 893 | tally = [] 894 | 895 | # Items are only going to be added at the end 896 | # Items won't be removed (they can just be locked) 897 | # This simplifies the code checking for this 898 | diff_attributes = ('code', 'predicate', 'total', 'name', 'uses', 'description') 899 | for new, old in itertools.zip_longest(new_items, pre_existing): 900 | if old is None: 901 | if new is not None: 902 | tally.append(new) 903 | continue 904 | 905 | if any(getattr(old, attr) != getattr(new, attr) for attr in diff_attributes): 906 | # These are the two attributes that really rely on storage 907 | new.unlocked = old.unlocked 908 | new.in_stock = old.in_stock 909 | tally.append(new) 910 | else: 911 | tally.append(old) 912 | 913 | await self.storage.put('store', tally) 914 | await ctx.send(ctx.tick(True)) 915 | 916 | @shop_restock.before_invoke 917 | @shop_refresh.before_invoke 918 | async def shop_restock_before(self, ctx): 919 | self._shop_restocking = True 920 | 921 | @shop_restock.after_invoke 922 | @shop_refresh.after_invoke 923 | async def shop_restock_after(self, ctx): 924 | self._shop_restocking = False 925 | 926 | @commands.command() 927 | @commands.is_owner() 928 | async def announce(self, ctx, *, message): 929 | """Announces something via the bot.""" 930 | await self.log_channel.send(f'\N{CHEERING MEGAPHONE} {message}') 931 | 932 | @commands.group(invoke_without_command=True, aliases=['bp']) 933 | async def backpack(self, ctx): 934 | """Check your backpack.""" 935 | 936 | user = await self.get_participant(ctx.author.id) 937 | embed = discord.Embed(title='Backpack') 938 | embed.set_author(name=str(ctx.author), icon_url=ctx.author.avatar_url_as(format='png')) 939 | 940 | if not user.backpack: 941 | embed.description = 'Empty...' 942 | return await ctx.send(embed=embed) 943 | 944 | store = self.storage['store'] 945 | for item in store: 946 | try: 947 | uses = user.backpack[item.emoji] 948 | except KeyError: 949 | continue 950 | 951 | if item.uses == 0: 952 | prefix = 'Special Item' 953 | elif uses == 0: 954 | continue 955 | else: 956 | prefix = f'uses: {uses}/{item.uses}' 957 | 958 | embed.add_field(name=item.emoji, value=f'`{prefix}`\n{item.description}', inline=False) 959 | 960 | await ctx.send(embed=embed) 961 | 962 | async def process_state(self, state, user, *, member=None, cause=None): 963 | if state is State.dead: 964 | if cause is not None: 965 | data = self.storage['stats'].people_killed 966 | try: 967 | data[str(cause.member_id)] += 1 968 | except KeyError: 969 | data[str(cause.member_id)] = 1 970 | 971 | await self.kill(user) 972 | elif state is State.cured: 973 | if cause is not None: 974 | data = self.storage['stats'].people_cured 975 | try: 976 | data[str(cause.member_id)] += 1 977 | except KeyError: 978 | data[str(cause.member_id)] = 1 979 | 980 | await self.cure(user) 981 | elif state is State.become_healer: 982 | self.storage['stats'].healers += 1 983 | user.healer = True 984 | try: 985 | await member.add_roles(discord.Object(id=HEALER_ROLE_ID)) 986 | except discord.HTTPException: 987 | pass 988 | finally: 989 | await self.storage.save() 990 | await self.send_healer_message(user) 991 | elif state is State.reinfect: 992 | if cause is not None: 993 | data = self.storage['stats'].people_infected 994 | try: 995 | data[str(cause.member_id)] += 1 996 | except KeyError: 997 | data[str(cause.member_id)] = 1 998 | 999 | await self.reinfect(user) 1000 | elif state is State.lose_healer: 1001 | self.storage['stats'].healers -= 1 1002 | user.healer = False 1003 | try: 1004 | await member.remove_roles(discord.Object(id=HEALER_ROLE_ID)) 1005 | except discord.HTTPException: 1006 | pass 1007 | finally: 1008 | await self.storage.save() 1009 | await self.send_healer_remove_message(user) 1010 | 1011 | @backpack.command(name='use') 1012 | async def backpack_use(self, ctx, *, emoji: str): 1013 | """Use an item from your backpack. 1014 | 1015 | Similar to the store, you need to pass in the emoji 1016 | of the item you want to use. 1017 | """ 1018 | 1019 | user = await self.get_participant(ctx.author.id) 1020 | 1021 | try: 1022 | uses = user.backpack[emoji] 1023 | except KeyError: 1024 | dialogue = [ 1025 | "Buddy ya think this item exists or you have it? <:notlikeblob:597590860623773696>", 1026 | "I don't know where you've heard of such items <:whenyahomiesaysomewildshit:596577153135673344>", 1027 | "They told me this item doesn't exist bro. Run along now <:blobsweats:596577181518266378>", 1028 | f"Yeah right, as if {discord.utils.escape_mentions(emoji)} isn't a figment of your imagination" 1029 | ] 1030 | return await ctx.send(random.choice(dialogue)) 1031 | 1032 | if uses == 0: 1033 | return await ctx.send("Uh I don't think this item can be used...") 1034 | 1035 | item = discord.utils.get(self.storage['store'], emoji=emoji) 1036 | if item is None: 1037 | return await ctx.send('Uh... if this happens tell Danny since this is pretty weird.') 1038 | 1039 | if not item.usable_by(user): 1040 | return await ctx.send("Can't let you do that chief.") 1041 | 1042 | state = await user.use(ctx, item) 1043 | await self.storage.save() 1044 | 1045 | if state is State.already_dead: 1046 | return await ctx.send("The dead can't use items...") 1047 | elif state is not None: 1048 | await self.process_state(state, user, member=ctx.author) 1049 | 1050 | await ctx.send('The item was used... I wonder what happened?') 1051 | 1052 | @commands.command() 1053 | async def info(self, ctx, *, member: discord.Member = None): 1054 | """Shows you info about yourself, or someone else.""" 1055 | member = member or ctx.author 1056 | embed = discord.Embed(title='Info') 1057 | embed.set_author(name=str(member), icon_url=member.avatar_url_as(format='png')) 1058 | 1059 | user = await self.get_participant(member.id) 1060 | 1061 | badges = [] 1062 | if user.death is not None: 1063 | badges.append('\N{COFFIN}') 1064 | embed.set_footer(text='Dead since').timestamp = user.death 1065 | if user.masked: 1066 | badges.append('\N{FACE WITH MEDICAL MASK}') 1067 | if user.is_infectious(): 1068 | badges.append('\N{BIOHAZARD SIGN}\ufe0f') 1069 | if user.healer: 1070 | badges.append('\N{STAFF OF AESCULAPIUS}\ufe0f') 1071 | if user.immunocompromised: 1072 | badges.append('\U0001fa78') 1073 | if user.immune_until and user.immune_until > ctx.message.created_at: 1074 | badges.append('\N{FLEXED BICEPS}') 1075 | if user.pda_cooldown and user.pda_cooldown > ctx.message.created_at: 1076 | badges.append('\N{HUGGING FACE}') 1077 | 1078 | embed.description = f'Sickness: [{user.sickness}/100]' 1079 | embed.add_field(name='Badges', value=' '.join(badges) or 'None') 1080 | embed.add_field(name='Backpack', value=' '.join(user.backpack) or 'Empty', inline=False) 1081 | if user.infected_since and not user.death: 1082 | embed.set_footer(text='Infected since').timestamp = user.infected_since 1083 | 1084 | await ctx.send(embed=embed) 1085 | 1086 | @commands.group() 1087 | @commands.is_owner() 1088 | async def gm(self, ctx): 1089 | """Game master commands.""" 1090 | pass 1091 | 1092 | @gm.command(name='infect', aliases=['healer', 'kill']) 1093 | async def gm_infect(self, ctx, *, member: discord.Member): 1094 | """Infect or make a user a healer.""" 1095 | 1096 | user = await self.get_participant(member.id) 1097 | if ctx.invoked_with == 'infect': 1098 | await self.infect(user) 1099 | elif ctx.invoked_with == 'healer': 1100 | user.healer = True 1101 | self.storage['stats'].healers += 1 1102 | try: 1103 | await member.add_roles(discord.Object(id=HEALER_ROLE_ID)) 1104 | except discord.HTTPException: 1105 | pass 1106 | finally: 1107 | await self.storage.save() 1108 | await self.send_healer_message(user) 1109 | elif ctx.invoked_with == 'kill': 1110 | await self.kill(user) 1111 | 1112 | @gm.command(name='items') 1113 | async def gm_items(self, ctx): 1114 | """Shows current item metadata.""" 1115 | 1116 | store = self.storage['store'] 1117 | to_send = [ 1118 | f'{i.emoji}: ``' 1119 | for i in store 1120 | ] 1121 | await ctx.send('\n'.join(to_send)) 1122 | 1123 | @gm.command(name='rates') 1124 | async def gm_rates(self, ctx): 1125 | to_send = [] 1126 | for channel_id, authors in self._authors.items(): 1127 | if len(authors) == 0: 1128 | to_send.append(f'<#{channel_id}>: 0') 1129 | else: 1130 | total = sum(p.sickness_rate for p in authors) / len(authors) 1131 | to_send.append(f'<#{channel_id}>: {total/1000:.3%}') 1132 | 1133 | await ctx.send('\n'.join(to_send)) 1134 | 1135 | @commands.command(name='stats') 1136 | async def _stats(self, ctx): 1137 | """Stats on the outbreak.""" 1138 | 1139 | stats = self.storage['stats'] 1140 | participants = self.storage['participants'].values() 1141 | msg = f'Total Participants: {len(participants)}\nDead: {stats.dead}\n' \ 1142 | f'Infected: {stats.infected - stats.cured - stats.dead}\nHealers: {stats.healers}\nCured: {stats.cured}\n' \ 1143 | f'Vaccinated: {stats.vaccinated}' 1144 | 1145 | e = discord.Embed(title='Stats') 1146 | e.description = msg 1147 | 1148 | infected = sorted((p for p in participants if p.is_infectious()), key=lambda p: p.sickness, reverse=True) 1149 | most_sick = '\n'.join(f'{i + 1}) <@{p.member_id}> [{p.sickness}]' for i, p in enumerate(infected[:5])) 1150 | least_sick = '\n'.join(f'{i + 1}) <@{p.member_id}> [{p.sickness}]' for i, p in enumerate(reversed(infected[-5:]))) 1151 | 1152 | e.add_field(name='Most Sick', value=most_sick or 'No one') 1153 | e.add_field(name='Least Sick', value=least_sick or 'No one') 1154 | 1155 | most_cured = Counter(stats.people_cured) 1156 | most_cured = '\n'.join(f'{i + 1}) <@{m}> {total} cured' for (i, (m, total)) in enumerate(most_cured.most_common(5))) 1157 | e.add_field(name='Top Curers', value=most_cured or 'No one', inline=False) 1158 | 1159 | most_infected = Counter(stats.people_infected) 1160 | most_infected = '\n'.join(f'{i + 1}) <@{m}> {total} infected' for (i, (m, total)) in enumerate(most_infected.most_common(5))) 1161 | e.add_field(name='Most Contagious', value=most_infected or 'No one') 1162 | 1163 | top_killers = Counter(stats.people_killed) 1164 | top_killers = '\n'.join(f'{i + 1}) <@{m}> {total} killed' for (i, (m, total)) in enumerate(top_killers.most_common(5))) 1165 | e.add_field(name='Top Killers', value=top_killers or 'No one') 1166 | 1167 | await ctx.send(embed=e) 1168 | 1169 | @commands.command() 1170 | async def heal(self, ctx, *, member: discord.Member): 1171 | """Tries to treat a member?""" 1172 | 1173 | user = await self.get_participant(ctx.author.id) 1174 | if not user.healer: 1175 | dialogue = [ 1176 | "Uh, you sure you're qualified to do that?", 1177 | "You know some people go to school for years before being able to do anything close to what you're implying.", 1178 | "Ah yeah... that didn't work. I remember those days.", 1179 | "I'm afraid I'm gonna need to see some papers buddy.", 1180 | ] 1181 | return await ctx.send(random.choice(dialogue)) 1182 | 1183 | other = await self.get_participant(member.id) 1184 | state = user.heal(other) 1185 | await self.process_state(state, other, member=member, cause=user) 1186 | dialogue = [ 1187 | (2, "*some sound effect*"), 1188 | (7, "Congrats, seems like this might have done something"), 1189 | (1, "Uh... let's just hope whatever you did worked."), 1190 | ] 1191 | await ctx.send(weighted_random(dialogue)) 1192 | 1193 | @commands.command() 1194 | async def hug(self, ctx, *, member: discord.Member): 1195 | """Hugs a member.""" 1196 | 1197 | if ctx.author.id == member.id or member.id == ctx.me.id: 1198 | return await ctx.send('<:rooThink:596576798351949847>') 1199 | 1200 | user = await self.get_participant(ctx.author.id) 1201 | other = await self.get_participant(member.id) 1202 | dt = ctx.message.created_at 1203 | 1204 | if user.is_dead(): 1205 | return await ctx.send('How do you expect to hug someone as a rotting corpse?') 1206 | 1207 | if other.is_dead(): 1208 | dialogue = [ 1209 | "Let's leave the dead body alone...", 1210 | "Hugging a lifeless corpse isn't a good look you know", 1211 | "You'll get in trouble if you keep playing with the dead." 1212 | ] 1213 | return await ctx.send(random.choice(dialogue)) 1214 | 1215 | if not other.can_be_touched(dt): 1216 | dialogue = [ 1217 | "Uh, I don't think they want to be touched right now.", 1218 | "You shouldn't keep hugging people, it'll spread disease." 1219 | ] 1220 | return await ctx.send(random.choice(dialogue)) 1221 | 1222 | if not user.can_be_touched(dt): 1223 | return await ctx.send("Probably shouldn't be hugging people right now.") 1224 | 1225 | await self.process_state(user.hug(other), user, cause=other) 1226 | await self.process_state(other.hug(user), other, cause=user) 1227 | user.pda_cooldown = dt + datetime.timedelta(hours=1) 1228 | 1229 | await self.storage.save() 1230 | 1231 | dialogue = [ 1232 | (2, "Aw isn't that cute. You hugged someone!"), 1233 | (4, "Alright alright you got your hug now scram"), 1234 | (1, "*shudders*"), 1235 | (3, "<:pepoS:596577130893279272>"), 1236 | ] 1237 | await ctx.send(weighted_random(dialogue)) 1238 | 1239 | @commands.command() 1240 | async def research(self, ctx): 1241 | """Research a cure""" 1242 | 1243 | user = await self.get_participant(ctx.author.id) 1244 | if user.is_dead(): 1245 | return await ctx.send("Uh... you're dead, can't do research if you're dead.") 1246 | 1247 | items = { 1248 | '\U0001f9ec', 1249 | '\U0001f9a0', 1250 | '\U0001f9eb', 1251 | '\U0001f9ea', 1252 | '\N{MICROSCOPE}', 1253 | } 1254 | 1255 | if len(items - set(user.backpack)) != 0: 1256 | return await ctx.send('You do not have the requirements to do this.') 1257 | 1258 | item = discord.utils.get(self.storage['store'], emoji='\N{SYRINGE}') 1259 | if item is None: 1260 | return await ctx.send('Tell Danny this happened?') 1261 | 1262 | if item.in_stock: 1263 | return await ctx.send('Hey, we already have some vaccines in stock right now.') 1264 | 1265 | for x in items: 1266 | del user.backpack[x] 1267 | 1268 | item.in_stock = 10 1269 | item.total = 10 1270 | item.unlocked = True 1271 | await self.storage.save() 1272 | await self.log_channel.send(f'\N{CHEERING MEGAPHONE} {ctx.author.mention} seems to have found a cure? Check the store') 1273 | 1274 | def setup(bot): 1275 | bot.add_cog(Virus(bot)) 1276 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "main" 3 | description = "Async http client/server framework (asyncio)" 4 | name = "aiohttp" 5 | optional = false 6 | python-versions = ">=3.5.3" 7 | version = "3.6.2" 8 | 9 | [package.dependencies] 10 | async-timeout = ">=3.0,<4.0" 11 | attrs = ">=17.3.0" 12 | chardet = ">=2.0,<4.0" 13 | multidict = ">=4.5,<5.0" 14 | yarl = ">=1.0,<2.0" 15 | 16 | [package.extras] 17 | speedups = ["aiodns", "brotlipy", "cchardet"] 18 | 19 | [[package]] 20 | category = "main" 21 | description = "Timeout context manager for asyncio programs" 22 | name = "async-timeout" 23 | optional = false 24 | python-versions = ">=3.5.3" 25 | version = "3.0.1" 26 | 27 | [[package]] 28 | category = "main" 29 | description = "Classes Without Boilerplate" 30 | name = "attrs" 31 | optional = false 32 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 33 | version = "19.3.0" 34 | 35 | [package.extras] 36 | azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] 37 | dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] 38 | docs = ["sphinx", "zope.interface"] 39 | tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 40 | 41 | [[package]] 42 | category = "main" 43 | description = "Universal encoding detector for Python 2 and 3" 44 | name = "chardet" 45 | optional = false 46 | python-versions = "*" 47 | version = "3.0.4" 48 | 49 | [[package]] 50 | category = "main" 51 | description = "A python wrapper for the Discord API" 52 | name = "discord.py" 53 | optional = false 54 | python-versions = ">=3.5.3" 55 | version = "1.3.1" 56 | 57 | [package.dependencies] 58 | aiohttp = ">=3.6.0,<3.7.0" 59 | websockets = ">=6.0,<7.0 || >7.0,<8.0 || >8.0,<8.0.1 || >8.0.1,<9.0" 60 | 61 | [package.extras] 62 | docs = ["sphinx (1.8.5)", "sphinxcontrib-trio (1.1.0)", "sphinxcontrib-websupport"] 63 | voice = ["PyNaCl (1.3.0)"] 64 | 65 | [[package]] 66 | category = "main" 67 | description = "Internationalized Domain Names in Applications (IDNA)" 68 | name = "idna" 69 | optional = false 70 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 71 | version = "2.8" 72 | 73 | [[package]] 74 | category = "main" 75 | description = "multidict implementation" 76 | name = "multidict" 77 | optional = false 78 | python-versions = ">=3.5" 79 | version = "4.7.4" 80 | 81 | [[package]] 82 | category = "main" 83 | description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" 84 | name = "websockets" 85 | optional = false 86 | python-versions = ">=3.6.1" 87 | version = "8.1" 88 | 89 | [[package]] 90 | category = "main" 91 | description = "Yet another URL library" 92 | name = "yarl" 93 | optional = false 94 | python-versions = ">=3.5" 95 | version = "1.4.2" 96 | 97 | [package.dependencies] 98 | idna = ">=2.0" 99 | multidict = ">=4.0" 100 | 101 | [metadata] 102 | content-hash = "c2518487f65cd921148368ac6b425a2526a3aee123203c4137d1facd325cb15f" 103 | python-versions = "^3.7" 104 | 105 | [metadata.files] 106 | aiohttp = [ 107 | {file = "aiohttp-3.6.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e"}, 108 | {file = "aiohttp-3.6.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec"}, 109 | {file = "aiohttp-3.6.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48"}, 110 | {file = "aiohttp-3.6.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59"}, 111 | {file = "aiohttp-3.6.2-cp36-cp36m-win32.whl", hash = "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a"}, 112 | {file = "aiohttp-3.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17"}, 113 | {file = "aiohttp-3.6.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a"}, 114 | {file = "aiohttp-3.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd"}, 115 | {file = "aiohttp-3.6.2-cp37-cp37m-win32.whl", hash = "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"}, 116 | {file = "aiohttp-3.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654"}, 117 | {file = "aiohttp-3.6.2-py3-none-any.whl", hash = "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4"}, 118 | {file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"}, 119 | ] 120 | async-timeout = [ 121 | {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, 122 | {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, 123 | ] 124 | attrs = [ 125 | {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, 126 | {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, 127 | ] 128 | chardet = [ 129 | {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, 130 | {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, 131 | ] 132 | "discord.py" = [ 133 | {file = "discord.py-1.3.1-py3-none-any.whl", hash = "sha256:8bfe5628d31771744000f19135c386c74ac337479d7282c26cc1627b9d31f360"}, 134 | ] 135 | idna = [ 136 | {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, 137 | {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, 138 | ] 139 | multidict = [ 140 | {file = "multidict-4.7.4-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:93166e0f5379cf6cd29746989f8a594fa7204dcae2e9335ddba39c870a287e1c"}, 141 | {file = "multidict-4.7.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a8ed33e8f9b67e3b592c56567135bb42e7e0e97417a4b6a771e60898dfd5182b"}, 142 | {file = "multidict-4.7.4-cp35-cp35m-win32.whl", hash = "sha256:a38baa3046cce174a07a59952c9f876ae8875ef3559709639c17fdf21f7b30dd"}, 143 | {file = "multidict-4.7.4-cp35-cp35m-win_amd64.whl", hash = "sha256:9a7b115ee0b9b92d10ebc246811d8f55d0c57e82dbb6a26b23c9a9a6ad40ce0c"}, 144 | {file = "multidict-4.7.4-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:dcfed56aa085b89d644af17442cdc2debaa73388feba4b8026446d168ca8dad7"}, 145 | {file = "multidict-4.7.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f29b885e4903bd57a7789f09fe9d60b6475a6c1a4c0eca874d8558f00f9d4b51"}, 146 | {file = "multidict-4.7.4-cp36-cp36m-win32.whl", hash = "sha256:13f3ebdb5693944f52faa7b2065b751cb7e578b8dd0a5bb8e4ab05ad0188b85e"}, 147 | {file = "multidict-4.7.4-cp36-cp36m-win_amd64.whl", hash = "sha256:4fba5204d32d5c52439f88437d33ad14b5f228e25072a192453f658bddfe45a7"}, 148 | {file = "multidict-4.7.4-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:a6d219f49821f4b2c85c6d426346a5d84dab6daa6f85ca3da6c00ed05b54022d"}, 149 | {file = "multidict-4.7.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:63810343ea07f5cd86ba66ab66706243a6f5af075eea50c01e39b4ad6bc3c57a"}, 150 | {file = "multidict-4.7.4-cp37-cp37m-win32.whl", hash = "sha256:26502cefa86d79b86752e96639352c7247846515c864d7c2eb85d036752b643c"}, 151 | {file = "multidict-4.7.4-cp37-cp37m-win_amd64.whl", hash = "sha256:5eee66f882ab35674944dfa0d28b57fa51e160b4dce0ce19e47f495fdae70703"}, 152 | {file = "multidict-4.7.4-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:527124ef435f39a37b279653ad0238ff606b58328ca7989a6df372fd75d7fe26"}, 153 | {file = "multidict-4.7.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:83c6ddf0add57c6b8a7de0bc7e2d656be3eefeff7c922af9a9aae7e49f225625"}, 154 | {file = "multidict-4.7.4-cp38-cp38-win32.whl", hash = "sha256:6bd10adf9f0d6a98ccc792ab6f83d18674775986ba9bacd376b643fe35633357"}, 155 | {file = "multidict-4.7.4-cp38-cp38-win_amd64.whl", hash = "sha256:5414f388ffd78c57e77bd253cf829373721f450613de53dc85a08e34d806e8eb"}, 156 | {file = "multidict-4.7.4.tar.gz", hash = "sha256:d7d428488c67b09b26928950a395e41cc72bb9c3d5abfe9f0521940ee4f796d4"}, 157 | ] 158 | websockets = [ 159 | {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, 160 | {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"}, 161 | {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, 162 | {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"}, 163 | {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"}, 164 | {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"}, 165 | {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, 166 | {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"}, 167 | {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"}, 168 | {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, 169 | {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"}, 170 | {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"}, 171 | {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"}, 172 | {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, 173 | {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"}, 174 | {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"}, 175 | {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"}, 176 | {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"}, 177 | {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"}, 178 | {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"}, 179 | {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, 180 | {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, 181 | ] 182 | yarl = [ 183 | {file = "yarl-1.4.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b"}, 184 | {file = "yarl-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1"}, 185 | {file = "yarl-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080"}, 186 | {file = "yarl-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a"}, 187 | {file = "yarl-1.4.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f"}, 188 | {file = "yarl-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea"}, 189 | {file = "yarl-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb"}, 190 | {file = "yarl-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70"}, 191 | {file = "yarl-1.4.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d"}, 192 | {file = "yarl-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce"}, 193 | {file = "yarl-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"}, 194 | {file = "yarl-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce"}, 195 | {file = "yarl-1.4.2-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b"}, 196 | {file = "yarl-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae"}, 197 | {file = "yarl-1.4.2-cp38-cp38-win32.whl", hash = "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462"}, 198 | {file = "yarl-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6"}, 199 | {file = "yarl-1.4.2.tar.gz", hash = "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b"}, 200 | ] 201 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "discord-event-bot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Rapptz "] 6 | license = "Apache-2.0" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.7" 10 | "discord.py" = "^1.3.1" 11 | 12 | [tool.poetry.dev-dependencies] 13 | 14 | [build-system] 15 | requires = ["poetry>=0.12"] 16 | build-backend = "poetry.masonry.api" 17 | --------------------------------------------------------------------------------