diff options
-rw-r--r-- | Pipfile | 4 | ||||
-rw-r--r-- | Pipfile.lock | 78 | ||||
-rw-r--r-- | bot/__init__.py | 5 | ||||
-rw-r--r-- | bot/__main__.py | 33 | ||||
-rw-r--r-- | bot/bot.py | 42 | ||||
-rw-r--r-- | bot/cogs/gif.py | 32 | ||||
-rw-r--r-- | bot/cogs/hacktober/__init__.py | 0 | ||||
-rw-r--r-- | bot/cogs/template.py | 39 | ||||
-rw-r--r-- | bot/constants.py | 97 | ||||
-rw-r--r-- | bot/decorators.py | 48 | ||||
-rw-r--r-- | bot/resources/advent_of_code/about.json | 27 | ||||
-rw-r--r-- | bot/resources/avatars/christmas.png | bin | 0 -> 44843 bytes | |||
-rw-r--r-- | bot/resources/avatars/spooky.png | bin | 0 -> 37202 bytes | |||
-rw-r--r-- | bot/resources/avatars/standard.png | bin | 0 -> 52156 bytes | |||
-rw-r--r-- | bot/resources/halloween/github_links.json (renamed from bot/cogs/__init__.py) | 0 | ||||
-rw-r--r-- | bot/resources/halloween/monstersurvey.json | 5 | ||||
-rw-r--r-- | bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 (renamed from bot/resources/spookysounds/109710__tomlija__horror-gate.mp3) | bin | 118125 -> 118125 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 (renamed from bot/resources/spookysounds/126113__klankbeeld__laugh.mp3) | bin | 112365 -> 112365 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 (renamed from bot/resources/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3) | bin | 137385 -> 137385 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 (renamed from bot/resources/spookysounds/14570__oscillator__ghost-fx.mp3) | bin | 135405 -> 135405 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 (renamed from bot/resources/spookysounds/168650__0xmusex0__doorcreak.mp3) | bin | 162421 -> 162421 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 (renamed from bot/resources/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3) | bin | 131625 -> 131625 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 (renamed from bot/resources/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3) | bin | 163257 -> 163257 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 (renamed from bot/resources/spookysounds/237282__devilfish101__frantic-violin-screech.mp3) | bin | 131566 -> 131566 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 (renamed from bot/resources/spookysounds/249686__cylon8472__cthulhu-growl.mp3) | bin | 153226 -> 153226 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 (renamed from bot/resources/spookysounds/35716__analogchill__scream.mp3) | bin | 114773 -> 114773 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 (renamed from bot/resources/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3) | bin | 298717 -> 298717 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 (renamed from bot/resources/spookysounds/60571__gabemiller74__breathofdeath.mp3) | bin | 177049 -> 177049 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 (renamed from bot/resources/spookysounds/Female_Monster_Growls_.mp3) | bin | 148276 -> 148276 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 (renamed from bot/resources/spookysounds/Male_Zombie_Roar_.mp3) | bin | 62171 -> 62171 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 (renamed from bot/resources/spookysounds/Monster_Alien_Growl_Calm_.mp3) | bin | 133651 -> 133651 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 (renamed from bot/resources/spookysounds/Monster_Alien_Grunt_Hiss_.mp3) | bin | 74718 -> 74718 bytes | |||
-rw-r--r-- | bot/resources/halloween/spookysounds/sources.txt (renamed from bot/resources/spookysounds/sources.txt) | 0 | ||||
-rw-r--r-- | bot/seasons/__init__.py | 12 | ||||
-rw-r--r-- | bot/seasons/christmas/__init__.py | 16 | ||||
-rw-r--r-- | bot/seasons/christmas/adventofcode.py | 622 | ||||
-rw-r--r-- | bot/seasons/evergreen/__init__.py | 13 | ||||
-rw-r--r-- | bot/seasons/evergreen/error_handler.py (renamed from bot/cogs/error_handler.py) | 29 | ||||
-rw-r--r-- | bot/seasons/evergreen/uptime.py (renamed from bot/cogs/evergreen/uptime.py) | 7 | ||||
-rw-r--r-- | bot/seasons/halloween/__init__.py | 16 | ||||
-rw-r--r-- | bot/seasons/halloween/candy_collection.py (renamed from bot/cogs/hacktober/candy_collection.py) | 15 | ||||
-rw-r--r-- | bot/seasons/halloween/hacktoberstats.py (renamed from bot/cogs/hacktober/hacktoberstats.py) | 30 | ||||
-rw-r--r-- | bot/seasons/halloween/halloween_facts.py (renamed from bot/cogs/hacktober/halloween_facts.py) | 38 | ||||
-rw-r--r-- | bot/seasons/halloween/halloweenify.py (renamed from bot/cogs/hacktober/halloweenify.py) | 4 | ||||
-rw-r--r-- | bot/seasons/halloween/monstersurvey.py (renamed from bot/cogs/hacktober/monstersurvey.py) | 6 | ||||
-rw-r--r-- | bot/seasons/halloween/scarymovie.py (renamed from bot/cogs/hacktober/scarymovie.py) | 6 | ||||
-rw-r--r-- | bot/seasons/halloween/spookyavatar.py (renamed from bot/cogs/hacktober/spookyavatar.py) | 8 | ||||
-rw-r--r-- | bot/seasons/halloween/spookygif.py (renamed from bot/cogs/hacktober/spookygif.py) | 12 | ||||
-rw-r--r-- | bot/seasons/halloween/spookyreact.py (renamed from bot/cogs/hacktober/spookyreact.py) | 7 | ||||
-rw-r--r-- | bot/seasons/halloween/spookysound.py (renamed from bot/cogs/hacktober/spookysound.py) | 10 | ||||
-rw-r--r-- | bot/seasons/season.py | 180 | ||||
-rw-r--r-- | bot/utils/halloween/__init__.py (renamed from bot/cogs/evergreen/__init__.py) | 0 | ||||
-rw-r--r-- | bot/utils/halloween/spookifications.py (renamed from bot/utils/spookifications.py) | 0 | ||||
-rw-r--r-- | docker/Dockerfile | 2 |
54 files changed, 1240 insertions, 203 deletions
@@ -6,8 +6,10 @@ name = "pypi" [packages] "discord-py" = {ref = "rewrite", git = "https://github.com/Rapptz/discord.py"} arrow = "*" +beautifulsoup4 = "*" +aiodns = "*" pillow = "*" - +pytz = "*" [dev-packages] "flake8" = "*" diff --git a/Pipfile.lock b/Pipfile.lock index be72165f..1f693364 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "577eb73d927cf69687453acf1d06c40b81aedab40a238402f14d5a9385413bc0" + "sha256": "37690a44eef1762372759be11694f873af63f032de0264280ad1762d917c2b89" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,14 @@ ] }, "default": { + "aiodns": { + "hashes": [ + "sha256:99d0652f2c02f73bfa646bf44af82705260a523014576647d7959e664830b26b", + "sha256:d8677adc679ce8d0ef706c14d9c3d2f27a0e0cc11d59730cdbaf218ad52dd9ea" + ], + "index": "pypi", + "version": "==1.1.1" + }, "arrow": { "hashes": [ "sha256:a558d3b7b6ce7ffc74206a86c147052de23d3d4ef0e17c210dd478c53575c4cd" @@ -23,31 +31,30 @@ "index": "pypi", "version": "==0.12.1" }, + "beautifulsoup4": { + "hashes": [ + "sha256:194ec62a25438adcb3fdb06378b26559eda1ea8a747367d34c33cef9c7f48d57", + "sha256:90f8e61121d6ae58362ce3bed8cd997efb00c914eae0ff3d363c32f9a9822d10", + "sha256:f0abd31228055d698bb392a826528ea08ebb9959e6bea17c606fd9c9009db938" + ], + "index": "pypi", + "version": "==4.6.3" + }, "discord-py": { "git": "https://github.com/Rapptz/discord.py", - "ref": "rewrite" + "ref": "66e5033785259400d340b8c00eaa8ad60fbbb82a" }, "pillow": { "hashes": [ "sha256:00203f406818c3f45d47bb8fe7e67d3feddb8dcbbd45a289a1de7dd789226360", "sha256:0616f800f348664e694dddb0b0c88d26761dd5e9f34e1ed7b7a7d2da14b40cb7", - "sha256:091136f2a37e9ed6bd8ce96fbf5269199ba6edee490d64de7ac934316f31ecca", - "sha256:0d67ae9a5937b1348fa1d97c7dcb6b56aaef828ca6655298e96f2f3114ad829d", - "sha256:0e1aaddd00ee9014fe7a61b9da61427233fcd7c7f193b5efd6689e0ec36bc42f", "sha256:1f7908aab90c92ad85af9d2fec5fc79456a89b3adcc26314d2cde0e238bd789e", "sha256:2ea3517cd5779843de8a759c2349a3cd8d3893e03ab47053b66d5ec6f8bc4f93", - "sha256:39b662f65a067709a62943003c1e807d140e7fcf631fcfc66ebe905f8149b9f4", - "sha256:3ddc19447cf42ef3ec564ab7ebbd4f67838ba9816d739befe29dd70149c775bd", "sha256:48a9f0538c91fc136b3a576bee0e7cd174773dc9920b310c21dcb5519722e82c", "sha256:5280ebc42641a1283b7b1f2c20e5b936692198b9dd9995527c18b794850be1a8", - "sha256:576a8a7a57065dab968d9d18befa2594a7673dcdab78c9b1f34248410cc6118f", - "sha256:5e334a23c8f7cb6079987a2ed9978821a42b4323a3a3bdbc132945348737f9a9", "sha256:5e34e4b5764af65551647f5cc67cf5198c1d05621781d5173b342e5e55bf023b", "sha256:63b120421ab85cad909792583f83b6ca3584610c2fe70751e23f606a3c2e87f0", "sha256:696b5e0109fe368d0057f484e2e91717b49a03f1e310f857f133a4acec9f91dd", - "sha256:6cb528de694f503ea164541c151da6c18267727a7558e0c9716cc0383d89658a", - "sha256:7306d851d5a0cfac9ea07f1177783836f4b37292e5f224a534a52111cb6a6451", - "sha256:7e3e32346d991f1788026917d0a9c182d6d32dc757163eee7ca990f1f831499e", "sha256:870ed021a42b1b02b5fe4a739ea735f671a84128c0a666c705db2cb9abd528eb", "sha256:916da1c19e4012d06a372127d7140dae894806fad67ef44330e5600d77833581", "sha256:9303a289fa0811e1c6abd9ddebfc770556d7c3311cb2b32eff72164ddc49bc64", @@ -55,34 +62,50 @@ "sha256:987e1c94a33c93d9b209315bfda9faa54b8edfce6438a1e93ae866ba20de5956", "sha256:99a3bbdbb844f4fb5d6dd59fac836a40749781c1fa63c563bc216c27aef63f60", "sha256:99db8dc3097ceafbcff9cb2bff384b974795edeb11d167d391a02c7bfeeb6e16", - "sha256:a379526415f54f9462bc65a4da76fb0acc05e3b2a21717dde79621cf4377e0e6", "sha256:a5a96cf49eb580756a44ecf12949e52f211e20bffbf5a95760ac14b1e499cd37", - "sha256:a844b5d8120f99fb7cd276ff544ac5bd562b0c053760d59694e6bf747c6ca7f5", - "sha256:a9284368e81a67a7f47d5ef1ef7e4f11a4f688485879f44cf5f9090bba1f9d94", "sha256:aa6ca3eb56704cdc0d876fc6047ffd5ee960caad52452fbee0f99908a141a0ae", "sha256:aade5e66795c94e4a2b2624affeea8979648d1b0ae3fcee17e74e2c647fc4a8a", "sha256:b78905860336c1d292409e3df6ad39cc1f1c7f0964e66844bbc2ebfca434d073", "sha256:b92f521cdc4e4a3041cc343625b699f20b0b5f976793fb45681aac1efda565f8", - "sha256:bb2baf44e97811687893873eab8cf9f18b40321cc15d15ff9f91dc031e30631f", "sha256:bfde84bbd6ae5f782206d454b67b7ee8f7f818c29b99fd02bf022fd33bab14cb", "sha256:c2b62d3df80e694c0e4a0ed47754c9480521e25642251b3ab1dff050a4e60409", - "sha256:c55d348c1c65896c1bd804527de4880d251ae832acf90d74ad525bb79e77d55c", "sha256:c5e2be6c263b64f6f7656e23e18a4a9980cffc671442795682e8c4e4f815dd9f", "sha256:c99aa3c63104e0818ec566f8ff3942fb7c7a8f35f9912cb63fd8e12318b214b2", "sha256:dae06620d3978da346375ebf88b9e2dd7d151335ba668c995aea9ed07af7add4", "sha256:db5499d0710823fa4fb88206050d46544e8f0e0136a9a5f5570b026584c8fd74", - "sha256:dcd3cd17d291e01e47636101c4a6638ffb44c842d009973e3b5c1b67ff718c58", - "sha256:f12df6b45abc18f27f6e21ce26f7cbf7aa19820911462e46536e22085658ca1e", "sha256:f36baafd82119c4a114b9518202f2a983819101dcc14b26e43fc12cbefdce00e", "sha256:f52b79c8796d81391ab295b04e520bda6feed54d54931708872e8f9ae9db0ea1", - "sha256:fa2a50f762d06d84125db0b95d0121e9c640afa7edc23fc0848896760a390f8e", - "sha256:fa49bb60792b542b95ca93a39041e7113843093ce3cfd216870118eb3798fcc9", - "sha256:ff8cff01582fa1a7e533cb97f628531c4014af4b5f38e33cdcfe5eec29b6d888", - "sha256:ffbccfe1c077b5f41738bd719518213c217be7a7a12a7e74113d05a0d6617390" + "sha256:ff8cff01582fa1a7e533cb97f628531c4014af4b5f38e33cdcfe5eec29b6d888" ], "index": "pypi", "version": "==5.3.0" }, + "pycares": { + "hashes": [ + "sha256:0e81c971236bb0767354f1456e67ab6ae305f248565ce77cd413a311f9572bf5", + "sha256:11c0ff3ccdb5a838cbd59a4e59df35d31355a80a61393bca786ca3b44569ba10", + "sha256:170d62bd300999227e64da4fa85459728cc96e62e44780bbc86a915fdae01f78", + "sha256:36f4c03df57c41a87eb3d642201684eb5a8bc194f4bafaa9f60ee6dc0aef8e40", + "sha256:371ce688776da984c4105c8ca760cc60944b9b49ccf8335c71dc7669335e6173", + "sha256:3a2234516f7db495083d8bba0ccdaabae587e62cfcd1b8154d5d0b09d3a48dfc", + "sha256:3f288586592c697109b2b06e3988b7e17d9765887b5fc367010ee8500cbddc86", + "sha256:40134cee03c8bbfbc644d4c0bc81796e12dd012a5257fb146c5a5417812ee5f7", + "sha256:722f5d2c5f78d47b13b0112f6daff43ce4e08e8152319524d14f1f917cc5125e", + "sha256:7b18fab0ed534a898552df91bc804bd62bb3a2646c11e054baca14d23663e1d6", + "sha256:8a39d03bd99ea191f86b990ef67ecce878d6bf6518c5cde9173fb34fb36beb5e", + "sha256:8ea263de8bf1a30b0d87150b4aa0e3203cf93bc1723ea3e7408a7d25e1299217", + "sha256:943e2dc67ff45ab4c81d628c959837d01561d7e185080ab7a276b8ca67573fb5", + "sha256:9d56a54c93e64b30c0d31f394d9890f175edec029cd846221728f99263cdee82", + "sha256:b95b339c11d824f0bb789d31b91c8534916fcbdce248cccce216fa2630bb8a90", + "sha256:bbfd9aba1e172cd2ab7b7142d49b28cf44d6451c4a66a870aff1dc3cb84849c7", + "sha256:d8637bcc2f901aa61ec1d754abc862f9f145cb0346a0249360df4c159377018e", + "sha256:e2446577eeea79d2179c9469d9d4ce3ab8a07d7985465c3cb91e7d74abc329b6", + "sha256:e72fa163f37ae3b09f143cc6690a36f012d13e905d142e1beed4ec0e593ff657", + "sha256:f32b7c63094749fbc0c1106c9a785666ec8afd49ecfe7002a30bb7c42e62b47c", + "sha256:f50be4dd53f009cfb4b98c3c6b240e18ff9b17e3f1c320bd594bb83eddabfcb2" + ], + "version": "==2.3.0" + }, "python-dateutil": { "hashes": [ "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", @@ -90,6 +113,14 @@ ], "version": "==2.7.5" }, + "pytz": { + "hashes": [ + "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca", + "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6" + ], + "index": "pypi", + "version": "==2018.7" + }, "six": { "hashes": [ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", @@ -162,7 +193,6 @@ }, "pycodestyle": { "hashes": [ - "sha256:74abc4e221d393ea5ce1f129ea6903209940c1ecd29e002e8c6933c2b21026e0", "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" ], diff --git a/bot/__init__.py b/bot/__init__.py index 6b3a2a6f..dc97df3d 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -4,6 +4,8 @@ from pathlib import Path import arrow +from bot.constants import Client + # start datetime start_time = arrow.utcnow() @@ -19,7 +21,8 @@ file_handler.setLevel(logging.DEBUG) # console handler prints to terminal console_handler = logging.StreamHandler() -console_handler.setLevel(logging.INFO) +level = logging.DEBUG if Client.debug else logging.INFO +console_handler.setLevel(level) # remove old loggers if any root = logging.getLogger() diff --git a/bot/__main__.py b/bot/__main__.py index b74e4f54..a3b68ec1 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,33 +1,8 @@ import logging -from os import environ -from pathlib import Path -from traceback import format_exc -from discord.ext import commands +from bot.constants import Client, bot -SEASONALBOT_TOKEN = environ.get('SEASONALBOT_TOKEN') -log = logging.getLogger() +log = logging.getLogger(__name__) -if SEASONALBOT_TOKEN: - token_dl = len(SEASONALBOT_TOKEN) // 8 - log.info(f'Bot token loaded: {SEASONALBOT_TOKEN[:token_dl]}...{SEASONALBOT_TOKEN[-token_dl:]}') -else: - log.error(f'Bot token not found: {SEASONALBOT_TOKEN}') - -ghost_unicode = "\N{GHOST}" -bot = commands.Bot(command_prefix=commands.when_mentioned_or(".", f"{ghost_unicode} ", ghost_unicode)) - -log.info('Start loading extensions from ./bot/cogs/halloween/') - - -if __name__ == '__main__': - # Scan for files in the /cogs/ directory and make a list of the file names. - cogs = [file.stem for file in Path('bot', 'cogs', 'hacktober').glob('*.py') if not file.stem.startswith("__")] - for extension in cogs: - try: - bot.load_extension(f'bot.cogs.hacktober.{extension}') - log.info(f'Successfully loaded extension: {extension}') - except Exception as e: - log.error(f'Failed to load extension {extension}: {repr(e)} {format_exc()}') - -bot.run(SEASONALBOT_TOKEN) +bot.load_extension("bot.seasons") +bot.run(Client.token) diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 00000000..24d099ad --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,42 @@ +import logging +import socket +from traceback import format_exc +from typing import List + +from aiohttp import AsyncResolver, ClientSession, TCPConnector +from discord.ext.commands import Bot + +log = logging.getLogger(__name__) + +__all__ = ('SeasonalBot',) + + +class SeasonalBot(Bot): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.http_session = ClientSession( + connector=TCPConnector( + resolver=AsyncResolver(), + family=socket.AF_INET, + ) + ) + + def load_extensions(self, exts: List[str]): + """ + Unload all current cogs, then load in the ones passed into `cogs` + """ + + # Unload all cogs + extensions = list(self.extensions.keys()) + for extension in extensions: + if extension != "bot.seasons": # We shouldn't unload the manager. + self.unload_extension(extension) + + # Load in the list of cogs that was passed in here + for extension in exts: + cog = extension.split(".")[-1] + try: + self.load_extension(extension) + log.info(f'Successfully loaded extension: {cog}') + except Exception as e: + log.error(f'Failed to load extension {cog}: {repr(e)} {format_exc()}') diff --git a/bot/cogs/gif.py b/bot/cogs/gif.py deleted file mode 100644 index cacb77ce..00000000 --- a/bot/cogs/gif.py +++ /dev/null @@ -1,32 +0,0 @@ -from os import environ - -import aiohttp -from discord.ext import commands - - -class SpookyGif: - """ - A cog to fetch a random spooky gif from the web! - """ - - def __init__(self, bot): - self.bot = bot - self.GIPHY_TOKEN = environ.get('GIPHY_TOKEN') - - @commands.command() - async def gif(self, ctx): - """ - Fetches a random gif from the GIPHY API and responds with it. - """ - - async with aiohttp.ClientSession() as session: - params = {'api_key': self.GIPHY_TOKEN, 'tag': 'halloween', 'rating': 'g'} - # Make a GET request to the Giphy API to get a random halloween gif. - async with session.get('http://api.giphy.com/v1/gifs/random', params=params) as resp: - data = await resp.json() - url = data['data']['url'] - await ctx.send(url) - - -def setup(bot): - bot.add_cog(SpookyGif(bot)) diff --git a/bot/cogs/hacktober/__init__.py b/bot/cogs/hacktober/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/bot/cogs/hacktober/__init__.py +++ /dev/null diff --git a/bot/cogs/template.py b/bot/cogs/template.py deleted file mode 100644 index e1b646e3..00000000 --- a/bot/cogs/template.py +++ /dev/null @@ -1,39 +0,0 @@ -from discord.ext import commands - - -class Template: - - """ - A template cog that contains examples of commands and command groups. - """ - - def __init__(self, bot): - self.bot = bot - - @commands.command(name='repo', aliases=['repository', 'project'], brief='A link to the repository of this bot.') - async def repository(self, ctx): - """ - A command to send the seasonalbot github project - """ - await ctx.send('https://github.com/python-discord/seasonalbot') - - @commands.group(name='git', invoke_without_command=True, brief="A link to resources for learning Git") - async def github(self, ctx): - """ - A command group with the name git. You can now create sub-commands such as git commit. - """ - - await ctx.send('Resources to learn **Git**: https://try.github.io/.') - - @github.command() - async def commit(self, ctx): - """ - A command that belongs to the git command group. Invoked using git commit. - """ - - await ctx.send('`git commit -m "First commit"` commits tracked changes.') - - -# Required in order to load the cog, use the class name in the add_cog function. -def setup(bot): - bot.add_cog(Template(bot)) diff --git a/bot/constants.py b/bot/constants.py index 7c2561a7..1294912a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,5 +1,94 @@ -import os +import logging +from os import environ +from typing import NamedTuple -HACKTOBER_CHANNEL_ID = 414574275865870337 -HACKTOBER_VOICE_CHANNEL_ID = 514420006474219521 -GIPHY_TOKEN = os.environ.get("GIPHY_TOKEN") +from bot.bot import SeasonalBot + +__all__ = ('Client', 'Roles', 'bot') + +log = logging.getLogger(__name__) + + +class Channels(NamedTuple): + admins = 365960823622991872 + announcements = 354619224620138496 + big_brother_logs = 468507907357409333 + bot = 267659945086812160 + checkpoint_test = 422077681434099723 + devalerts = 460181980097675264 + devlog = 409308876241108992 + devtest = 414574275865870337 + help_0 = 303906576991780866 + help_1 = 303906556754395136 + help_2 = 303906514266226689 + help_3 = 439702951246692352 + help_4 = 451312046647148554 + help_5 = 454941769734422538 + helpers = 385474242440986624 + message_log = 467752170159079424 + mod_alerts = 473092532147060736 + modlog = 282638479504965634 + off_topic_0 = 291284109232308226 + off_topic_1 = 463035241142026251 + off_topic_2 = 463035268514185226 + python = 267624335836053506 + reddit = 458224812528238616 + staff_lounge = 464905259261755392 + verification = 352442727016693763 + + +class Client(NamedTuple): + guild = int(environ.get('SEASONALBOT_GUILD', 267624335836053506)) + prefix = "." + token = environ.get('SEASONALBOT_TOKEN') + debug = environ.get('SEASONALBOT_DEBUG', '').lower() == 'true' + season_override = environ.get('SEASON_OVERRIDE') + + +class Hacktoberfest(NamedTuple): + channel_id = 498804484324196362 + voice_id = 514420006474219521 + + +class Roles(NamedTuple): + admin = 267628507062992896 + announcements = 463658397560995840 + champion = 430492892331769857 + contributor = 295488872404484098 + developer = 352427296948486144 + devops = 409416496733880320 + jammer = 423054537079783434 + moderator = 267629731250176001 + muted = 277914926603829249 + owner = 267627879762755584 + verified = 352427296948486144 + helpers = 267630620367257601 + rockstars = 458226413825294336 + + +class Colours: + soft_red = 0xcd6d6d + soft_green = 0x68c290 + + +class Emojis: + star = "\u2B50" + christmas_tree = u"\U0001F384" + + +class Tokens(NamedTuple): + giphy = environ.get("GIPHY_TOKEN") + aoc_session_cookie = environ.get("AOC_SESSION_COOKIE") + + +class AdventOfCode: + leaderboard_cache_age_threshold_seconds = 3600 + leaderboard_id = 363275 + leaderboard_join_code = "363275-442b6939" + leaderboard_max_displayed_members = 10 + year = 2018 + channel_id = int(environ.get("AOC_CHANNEL_ID", 517745814039166986)) + role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) + + +bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/decorators.py b/bot/decorators.py new file mode 100644 index 00000000..b84b2c36 --- /dev/null +++ b/bot/decorators.py @@ -0,0 +1,48 @@ +import logging + +from discord.ext import commands +from discord.ext.commands import Context + +log = logging.getLogger(__name__) + + +def with_role(*role_ids: int): + async def predicate(ctx: Context): + if not ctx.guild: # Return False in a DM + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request.") + return False + + for role in ctx.author.roles: + if role.id in role_ids: + log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") + return True + + log.debug(f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected.") + return False + return commands.check(predicate) + + +def without_role(*role_ids: int): + async def predicate(ctx: Context): + if not ctx.guild: # Return False in a DM + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request.") + return False + + author_roles = [role.id for role in ctx.author.roles] + check = all(role not in author_roles for role in role_ids) + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}.") + return check + return commands.check(predicate) + + +def in_channel(channel_id): + async def predicate(ctx: Context): + check = ctx.channel.id == channel_id + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the in_channel check was {check}.") + return check + return commands.check(predicate) diff --git a/bot/resources/advent_of_code/about.json b/bot/resources/advent_of_code/about.json new file mode 100644 index 00000000..3fe6fcc9 --- /dev/null +++ b/bot/resources/advent_of_code/about.json @@ -0,0 +1,27 @@ +[ + { + "name": "What is Advent of Code?", + "value": "Advent of Code (AoC) is a series of small programming puzzles for a variety of skill levels, run every year during the month of December.\n\nThey are self-contained and are just as appropriate for an expert who wants to stay sharp as they are for a beginner who is just learning to code. Each puzzle calls upon different skills and has two parts that build on a theme.", + "inline": false + }, + { + "name": "How do I sign up?", + "value": "AoC utilizes the following services' OAuth:", + "inline": true + }, + { + "name": "Service", + "value": "GitHub\nGoogle\nTwitter\nReddit", + "inline": true + }, + { + "name": "How does scoring work?", + "value": "Getting a star first is worth 100 points, second is 99, and so on down to 1 point at 100th place.\n\nCheck out AoC's [global leaderboard](https://adventofcode.com/2018/leaderboard) to see who's leading this year's event!", + "inline": false + }, + { + "name": "Join our private leaderboard!", + "value": "In addition to the global leaderboard, AoC also offers private leaderboards, where you can compete against a smaller group of friends!\n\nHead over to AoC's [private leaderboard page](https://adventofcode.com/leaderboard/private) and enter code `363275-442b6939` to join the PyDis private leaderboard!", + "inline": false + } +]
\ No newline at end of file diff --git a/bot/resources/avatars/christmas.png b/bot/resources/avatars/christmas.png Binary files differnew file mode 100644 index 00000000..55b72fac --- /dev/null +++ b/bot/resources/avatars/christmas.png diff --git a/bot/resources/avatars/spooky.png b/bot/resources/avatars/spooky.png Binary files differnew file mode 100644 index 00000000..4ab33188 --- /dev/null +++ b/bot/resources/avatars/spooky.png diff --git a/bot/resources/avatars/standard.png b/bot/resources/avatars/standard.png Binary files differnew file mode 100644 index 00000000..c14ff42a --- /dev/null +++ b/bot/resources/avatars/standard.png diff --git a/bot/cogs/__init__.py b/bot/resources/halloween/github_links.json index e69de29b..e69de29b 100644 --- a/bot/cogs/__init__.py +++ b/bot/resources/halloween/github_links.json diff --git a/bot/resources/halloween/monstersurvey.json b/bot/resources/halloween/monstersurvey.json index b430b6c0..d8cc72e7 100644 --- a/bot/resources/halloween/monstersurvey.json +++ b/bot/resources/halloween/monstersurvey.json @@ -10,7 +10,6 @@ "summary": "Count Dracula is an undead, centuries-old vampire, and a Transylvanian nobleman who claims to be a Sz\u00c3\u00a9kely descended from Attila the Hun. He inhabits a decaying castle in the Carpathian Mountains near the Borgo Pass. Unlike the vampires of Eastern European folklore, which are portrayed as repulsive, corpse-like creatures, Dracula wears a veneer of aristocratic charm. In his conversations with Jonathan Harker, he reveals himself as deeply proud of his boyar heritage and nostalgic for the past, which he admits have become only a memory of heroism, honour and valour in modern times.", "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg/250px-Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg", "votes": [ - 224734305581137921 ] }, "goofy": { @@ -24,8 +23,6 @@ "summary": "Who let this guy write this? That's who the real monster is.", "image": "https://avatars0.githubusercontent.com/u/24819750?s=460&v=4", "votes": [ - 95872159741644800, - 129606635545952258 ] } -}
\ No newline at end of file +} diff --git a/bot/resources/spookysounds/109710__tomlija__horror-gate.mp3 b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 Binary files differindex 495f2bd1..495f2bd1 100644 --- a/bot/resources/spookysounds/109710__tomlija__horror-gate.mp3 +++ b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 diff --git a/bot/resources/spookysounds/126113__klankbeeld__laugh.mp3 b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 Binary files differindex 538feabc..538feabc 100644 --- a/bot/resources/spookysounds/126113__klankbeeld__laugh.mp3 +++ b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 diff --git a/bot/resources/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 Binary files differindex 17f66698..17f66698 100644 --- a/bot/resources/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 +++ b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 diff --git a/bot/resources/spookysounds/14570__oscillator__ghost-fx.mp3 b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 Binary files differindex 5670657c..5670657c 100644 --- a/bot/resources/spookysounds/14570__oscillator__ghost-fx.mp3 +++ b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 diff --git a/bot/resources/spookysounds/168650__0xmusex0__doorcreak.mp3 b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 Binary files differindex 42f9e9fd..42f9e9fd 100644 --- a/bot/resources/spookysounds/168650__0xmusex0__doorcreak.mp3 +++ b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 diff --git a/bot/resources/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 Binary files differindex 1cdb0f4d..1cdb0f4d 100644 --- a/bot/resources/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 +++ b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 diff --git a/bot/resources/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 Binary files differindex 89150d57..89150d57 100644 --- a/bot/resources/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 +++ b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 diff --git a/bot/resources/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 Binary files differindex b5f85f8d..b5f85f8d 100644 --- a/bot/resources/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 +++ b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 diff --git a/bot/resources/spookysounds/249686__cylon8472__cthulhu-growl.mp3 b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 Binary files differindex d141f68e..d141f68e 100644 --- a/bot/resources/spookysounds/249686__cylon8472__cthulhu-growl.mp3 +++ b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 diff --git a/bot/resources/spookysounds/35716__analogchill__scream.mp3 b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 Binary files differindex a0614b53..a0614b53 100644 --- a/bot/resources/spookysounds/35716__analogchill__scream.mp3 +++ b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 diff --git a/bot/resources/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 Binary files differindex 38374316..38374316 100644 --- a/bot/resources/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 +++ b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 diff --git a/bot/resources/spookysounds/60571__gabemiller74__breathofdeath.mp3 b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 Binary files differindex f769d9d8..f769d9d8 100644 --- a/bot/resources/spookysounds/60571__gabemiller74__breathofdeath.mp3 +++ b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 diff --git a/bot/resources/spookysounds/Female_Monster_Growls_.mp3 b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 Binary files differindex 8b04f0f5..8b04f0f5 100644 --- a/bot/resources/spookysounds/Female_Monster_Growls_.mp3 +++ b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 diff --git a/bot/resources/spookysounds/Male_Zombie_Roar_.mp3 b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 Binary files differindex 964d685e..964d685e 100644 --- a/bot/resources/spookysounds/Male_Zombie_Roar_.mp3 +++ b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 diff --git a/bot/resources/spookysounds/Monster_Alien_Growl_Calm_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 Binary files differindex 9e643773..9e643773 100644 --- a/bot/resources/spookysounds/Monster_Alien_Growl_Calm_.mp3 +++ b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 diff --git a/bot/resources/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 Binary files differindex ad99cf76..ad99cf76 100644 --- a/bot/resources/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 +++ b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 diff --git a/bot/resources/spookysounds/sources.txt b/bot/resources/halloween/spookysounds/sources.txt index 7df03c2e..7df03c2e 100644 --- a/bot/resources/spookysounds/sources.txt +++ b/bot/resources/halloween/spookysounds/sources.txt diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py new file mode 100644 index 00000000..c43334a4 --- /dev/null +++ b/bot/seasons/__init__.py @@ -0,0 +1,12 @@ +import logging + +from bot.seasons.season import SeasonBase, SeasonManager, get_season + +__all__ = ("SeasonBase", "get_season") + +log = logging.getLogger(__name__) + + +def setup(bot): + bot.add_cog(SeasonManager(bot)) + log.debug("SeasonManager cog loaded") diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py new file mode 100644 index 00000000..cd5ce307 --- /dev/null +++ b/bot/seasons/christmas/__init__.py @@ -0,0 +1,16 @@ +from bot.seasons import SeasonBase + + +class Christmas(SeasonBase): + name = "christmas" + start_date = "01/12" + end_date = "31/12" + bot_name = "Santabot" + + def __init__(self, bot): + self.bot = bot + + @property + def bot_avatar(self): + with open(self.avatar_path("christmas.png"), "rb") as avatar: + return bytearray(avatar.read()) diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py new file mode 100644 index 00000000..4c766703 --- /dev/null +++ b/bot/seasons/christmas/adventofcode.py @@ -0,0 +1,622 @@ +import asyncio +import json +import logging +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import List + +import aiohttp +import discord +from bs4 import BeautifulSoup +from discord.ext import commands +from pytz import timezone + +from bot.constants import AdventOfCode as AocConfig +from bot.constants import Colours, Emojis, Tokens + +log = logging.getLogger(__name__) + +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} +AOC_SESSION_COOKIE = {"session": Tokens.aoc_session_cookie} + +EST = timezone("EST") + + +def is_in_advent() -> bool: + """ + Utility function to check if we are between December 1st + and December 25th. + """ + return datetime.now(EST).day in range(1, 26) and datetime.now(EST).month == 12 + + +def time_left_to_aoc_midnight() -> timedelta: + """ + This calculates the amount of time left until midnight in + UTC-5 (Advent of Code maintainer timezone). + """ + # Change all time properties back to 00:00 + todays_midnight = datetime.now(EST).replace(microsecond=0, + second=0, + minute=0, + hour=0) + + # We want tomorrow so add a day on + tomorrow = todays_midnight + timedelta(days=1) + + # Calculate the timedelta between the current time and midnight + return tomorrow, tomorrow - datetime.now(EST) + + +async def countdown_status(bot: commands.Bot): + """ + Every 2 minutes set the playing status of the bot to + the number of minutes & hours left until the next day + release. + """ + while is_in_advent(): + _, time_left = time_left_to_aoc_midnight() + + hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 + + if hours == 0: + game = discord.Game(f"in {minutes} minutes") + else: + game = discord.Game(f"in {hours} hours and {minutes} minutes") + + # Status will look like "Playing in 5 hours and 30 minutes" + await bot.change_presence(activity=game) + + # Sleep 2 minutes + await asyncio.sleep(120) + + +async def day_countdown(bot: commands.Bot): + """ + Calculate the number of seconds left until the next day of advent. Once + we have calculated this we should then sleep that number and when the time + is reached ping the advent of code role notifying them that the new task is + ready. + """ + while is_in_advent(): + tomorrow, time_left = time_left_to_aoc_midnight() + + await asyncio.sleep(time_left.seconds) + + channel = bot.get_channel(AocConfig.channel_id) + + if not channel: + log.error("Could not find the AoC channel to send notification in") + break + + await channel.send(f"<@&{AocConfig.role_id}> Good morning! Day {tomorrow.day} is ready to be attempted. " + f"View it online now at https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" + f" (this link could take a few minutes to start working). Good luck!") + + # Wait a couple minutes so that if our sleep didn't sleep enough + # time we don't end up announcing twice. + await asyncio.sleep(120) + + +class AdventOfCode: + def __init__(self, bot: commands.Bot): + self.bot = bot + + self._base_url = f"https://adventofcode.com/{AocConfig.year}" + self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" + self.private_leaderboard_url = f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_id}" + + self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") + self.cached_about_aoc = self._build_about_embed() + + self.cached_global_leaderboard = None + self.cached_private_leaderboard = None + + self.countdown_task = None + self.status_task = None + + countdown_coro = day_countdown(self.bot) + self.countdown_task = asyncio.ensure_future(self.bot.loop.create_task(countdown_coro)) + + status_coro = countdown_status(self.bot) + self.status_task = asyncio.ensure_future(self.bot.loop.create_task(status_coro)) + + @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) + async def adventofcode_group(self, ctx: commands.Context): + """ + Advent of Code festivities! Ho Ho Ho! + """ + + await ctx.invoke(self.bot.get_command("help"), "adventofcode") + + @adventofcode_group.command(name="notifications", aliases=("notify", "notifs"), brief="Notifications for new days") + async def aoc_notifications(self, ctx: commands.Context): + """ + Assign the role for notifications about new days being ready. + + Call the same command again to end notifications and remove the role. + """ + role = ctx.guild.get_role(AocConfig.role_id) + + if role in ctx.author.roles: + await ctx.author.remove_roles(role) + await ctx.send("Okay! You have been unsubscribed from notifications. If in future you want to" + " resubscribe just run this command again.") + else: + await ctx.author.add_roles(role) + await ctx.send("Okay! You have been subscribed to notifications about new Advent of Code tasks." + " To unsubscribe in future run the same command again.") + + @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") + async def aoc_countdown(self, ctx: commands.Context): + """ + Return time left until next day + """ + tomorrow, time_left = time_left_to_aoc_midnight() + + hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 + + await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") + + @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") + async def about_aoc(self, ctx: commands.Context): + """ + Respond with an explanation of all things Advent of Code + """ + + await ctx.send("", embed=self.cached_about_aoc) + + @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join PyDis' private AoC leaderboard") + async def join_leaderboard(self, ctx: commands.Context): + """ + Retrieve the link to join the PyDis AoC private leaderboard + """ + + info_str = ( + "Head over to https://adventofcode.com/leaderboard/private " + f"with code `{AocConfig.leaderboard_join_code}` to join the PyDis private leaderboard!" + ) + await ctx.send(info_str) + + @adventofcode_group.command( + name="leaderboard", + aliases=("board", "stats", "lb"), + brief="Get a snapshot of the PyDis private AoC leaderboard", + ) + async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10): + """ + Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed + + For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the + Advent of Code section of the bot constants. number_of_people_to_display values greater than this + limit will default to this maximum and provide feedback to the user. + """ + + async with ctx.typing(): + await self._check_leaderboard_cache(ctx) + + if not self.cached_private_leaderboard: + # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() + # Short circuit here if there's an issue + return + + number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) + + # Generate leaderboard table for embed + members_to_print = self.cached_private_leaderboard.top_n(number_of_people_to_display) + table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) + + # Build embed + aoc_embed = discord.Embed( + description=f"Total members: {len(self.cached_private_leaderboard.members)}", + colour=Colours.soft_green, + timestamp=self.cached_private_leaderboard.last_updated + ) + aoc_embed.set_author(name="Advent of Code", url=self.private_leaderboard_url) + aoc_embed.set_footer(text="Last Updated") + + await ctx.send( + content=f"Here's the current Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", + embed=aoc_embed, + ) + + @adventofcode_group.command( + name="global", + aliases=("globalstats", "globalboard", "gb"), + brief="Get a snapshot of the global AoC leaderboard", + ) + async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10): + """ + Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed + + For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the + Advent of Code section of the bot constants. number_of_people_to_display values greater than this + limit will default to this maximum and provide feedback to the user. + """ + + async with ctx.typing(): + await self._check_leaderboard_cache(ctx, global_board=True) + + if not self.cached_global_leaderboard: + # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() + # Short circuit here if there's an issue + return + + number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) + + # Generate leaderboard table for embed + members_to_print = self.cached_global_leaderboard.top_n(number_of_people_to_display) + table = AocGlobalLeaderboard.build_leaderboard_embed(members_to_print) + + # Build embed + aoc_embed = discord.Embed(colour=Colours.soft_green, timestamp=self.cached_global_leaderboard.last_updated) + aoc_embed.set_author(name="Advent of Code", url=self._base_url) + aoc_embed.set_footer(text="Last Updated") + + await ctx.send( + content=f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", # noqa + embed=aoc_embed, + ) + + async def _check_leaderboard_cache(self, ctx, global_board: bool = False): + """ + Check age of current leaderboard & pull a new one if the board is too old + + global_board is a boolean to toggle between the global board and the Pydis private board + """ + + # Toggle between global & private leaderboards + if global_board: + log.debug("Checking global leaderboard cache") + leaderboard_str = "cached_global_leaderboard" + _shortstr = "global" + else: + log.debug("Checking private leaderboard cache") + leaderboard_str = "cached_private_leaderboard" + _shortstr = "private" + + leaderboard = getattr(self, leaderboard_str) + if not leaderboard: + log.debug(f"No cached {_shortstr} leaderboard found") + await self._boardgetter(global_board) + else: + leaderboard_age = datetime.utcnow() - leaderboard.last_updated + age_seconds = leaderboard_age.total_seconds() + if age_seconds < AocConfig.leaderboard_cache_age_threshold_seconds: + log.debug(f"Cached {_shortstr} leaderboard age less than threshold ({age_seconds} seconds old)") + else: + log.debug(f"Cached {_shortstr} leaderboard age greater than threshold ({age_seconds} seconds old)") + await self._boardgetter(global_board) + + leaderboard = getattr(self, leaderboard_str) + if not leaderboard: + await ctx.send( + "", + embed=_error_embed_helper( + title=f"Something's gone wrong and there's no cached {_shortstr} leaderboard!", + description="Please check in with a staff member.", + ), + ) + + async def _check_n_entries(self, ctx: commands.Context, number_of_people_to_display: int) -> int: + # Check for n > max_entries and n <= 0 + max_entries = AocConfig.leaderboard_max_displayed_members + author = ctx.message.author + if not 0 <= number_of_people_to_display <= max_entries: + log.debug( + f"{author.name} ({author.id}) attempted to fetch an invalid number " + f" of entries from the AoC leaderboard ({number_of_people_to_display})" + ) + await ctx.send( + f":x: {author.mention}, number of entries to display must be a positive " + f"integer less than or equal to {max_entries}\n\n" + f"Head to {self.private_leaderboard_url} to view the entire leaderboard" + ) + number_of_people_to_display = max_entries + + return number_of_people_to_display + + def _build_about_embed(self) -> discord.Embed: + """ + Build and return the informational "About AoC" embed from the resources file + """ + + with self.about_aoc_filepath.open("r") as f: + embed_fields = json.load(f) + + about_embed = discord.Embed(title=self._base_url, colour=Colours.soft_green, url=self._base_url) + about_embed.set_author(name="Advent of Code", url=self._base_url) + for field in embed_fields: + about_embed.add_field(**field) + + about_embed.set_footer(text=f"Last Updated (UTC): {datetime.utcnow()}") + + return about_embed + + async def _boardgetter(self, global_board: bool): + """ + Invoke the proper leaderboard getter based on the global_board boolean + """ + if global_board: + self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() + else: + self.cached_private_leaderboard = await AocPrivateLeaderboard.from_url() + + +class AocMember: + def __init__(self, name: str, aoc_id: int, stars: int, starboard: list, local_score: int, global_score: int): + self.name = name + self.aoc_id = aoc_id + self.stars = stars + self.starboard = starboard + self.local_score = local_score + self.global_score = global_score + self.completions = self._completions_from_starboard(self.starboard) + + def __repr__(self): + return f"<{self.name} ({self.aoc_id}): {self.local_score}>" + + @classmethod + def member_from_json(cls, injson: dict) -> "AocMember": + """ + Generate an AocMember from AoC's private leaderboard API JSON + + injson is expected to be the dict contained in: + + AoC_APIjson['members'][<member id>:str] + + Returns an AocMember object + """ + + return cls( + name=injson["name"] if injson["name"] else "Anonymous User", + aoc_id=int(injson["id"]), + stars=injson["stars"], + starboard=cls._starboard_from_json(injson["completion_day_level"]), + local_score=injson["local_score"], + global_score=injson["global_score"], + ) + + @staticmethod + def _starboard_from_json(injson: dict) -> list: + """ + Generate starboard from AoC's private leaderboard API JSON + + injson is expected to be the dict contained in: + + AoC_APIjson['members'][<member id>:str]['completion_day_level'] + + Returns a list of 25 lists, where each nested list contains a pair of booleans representing + the code challenge completion status for that day + """ + + # Basic input validation + if not isinstance(injson, dict): + raise ValueError + + # Initialize starboard + starboard = [] + for _i in range(25): + starboard.append([False, False]) + + # Iterate over days, which are the keys of injson (as str) + for day in injson: + idx = int(day) - 1 + # If there is a second star, the first star must be completed + if "2" in injson[day].keys(): + starboard[idx] = [True, True] + # If the day exists in injson, then at least the first star is completed + else: + starboard[idx] = [True, False] + + return starboard + + @staticmethod + def _completions_from_starboard(starboard: list) -> tuple: + """ + Return days completed, as a (1 star, 2 star) tuple, from starboard + """ + + completions = [0, 0] + for day in starboard: + if day[0]: + completions[0] += 1 + if day[1]: + completions[1] += 1 + + return tuple(completions) + + +class AocPrivateLeaderboard: + def __init__(self, members: list, owner_id: int, event_year: int): + self.members = members + self._owner_id = owner_id + self._event_year = event_year + self.last_updated = datetime.utcnow() + + def top_n(self, n: int = 10) -> dict: + """ + Return the top n participants on the leaderboard. + + If n is not specified, default to the top 10 + """ + + return self.members[:n] + + @staticmethod + async def json_from_url( + leaderboard_id: int = AocConfig.leaderboard_id, year: int = AocConfig.year + ) -> "AocPrivateLeaderboard": + """ + Request the API JSON from Advent of Code for leaderboard_id for the specified year's event + + If no year is input, year defaults to the current year + """ + + api_url = f"https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" + + log.debug("Querying Advent of Code Private Leaderboard API") + async with aiohttp.ClientSession(cookies=AOC_SESSION_COOKIE, headers=AOC_REQUEST_HEADER) as session: + async with session.get(api_url) as resp: + if resp.status == 200: + raw_dict = await resp.json() + else: + log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") + resp.raise_for_status() + + return raw_dict + + @classmethod + def from_json(cls, injson: dict) -> "AocPrivateLeaderboard": + """ + Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON + """ + + return cls( + members=cls._sorted_members(injson["members"]), owner_id=injson["owner_id"], event_year=injson["event"] + ) + + @classmethod + async def from_url(cls) -> "AocPrivateLeaderboard": + """ + Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json + """ + + api_json = await cls.json_from_url() + return cls.from_json(api_json) + + @staticmethod + def _sorted_members(injson: dict) -> list: + """ + Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON + + Output list is sorted based on the AocMember.local_score + """ + + members = [AocMember.member_from_json(injson[member]) for member in injson] + members.sort(key=lambda x: x.local_score, reverse=True) + + return members + + @staticmethod + def build_leaderboard_embed(members_to_print: List[AocMember]) -> str: + """ + Build a text table from members_to_print, a list of AocMember objects + + Returns a string to be used as the content of the bot's leaderboard response + """ + + stargroup = f"{Emojis.star}, {Emojis.star*2}" + header = f"{' '*3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-'*44}" + table = "" + for i, member in enumerate(members_to_print): + if member.name == "Anonymous User": + name = f"{member.name} #{member.aoc_id}" + else: + name = member.name + + table += ( + f"{i+1:2}) {member.local_score:4} {name:25.25} " + f"({member.completions[0]:2}, {member.completions[1]:2})\n" + ) + else: + table = f"```{header}\n{table}```" + + return table + + +class AocGlobalLeaderboard: + def __init__(self, members: List[tuple]): + self.members = members + self.last_updated = datetime.utcnow() + + def top_n(self, n: int = 10) -> dict: + """ + Return the top n participants on the leaderboard. + + If n is not specified, default to the top 10 + """ + + return self.members[:n] + + @classmethod + async def from_url(cls) -> "AocGlobalLeaderboard": + """ + Generate an list of tuples for the entries on AoC's global leaderboard + + Because there is no API for this, web scraping needs to be used + """ + + aoc_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" + + async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER) as session: + async with session.get(aoc_url) as resp: + if resp.status == 200: + raw_html = await resp.text() + else: + log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") + resp.raise_for_status() + + soup = BeautifulSoup(raw_html, "html.parser") + ele = soup.find_all("div", class_="leaderboard-entry") + + exp = r"(?:[ ]{,2}(\d+)\))?[ ]+(\d+)\s+([\w\(\)#\-\d ]+)" + + lb_list = [] + for entry in ele: + # Strip off the AoC++ decorator + raw_str = entry.text.replace("(AoC++)", "").rstrip() + + # Use a regex to extract the info from the string to unify formatting + # Group 1: Rank + # Group 2: Global Score + # Group 3: Member string + r = re.match(exp, raw_str) + + rank = int(r.group(1)) if r.group(1) else None + global_score = int(r.group(2)) + + member = r.group(3) + if member.lower().startswith("(anonymous"): + # Normalize anonymous user string by stripping () and title casing + member = re.sub(r"[\(\)]", "", member).title() + + lb_list.append((rank, global_score, member)) + + return cls(lb_list) + + @staticmethod + def build_leaderboard_embed(members_to_print: List[tuple]) -> str: + """ + Build a text table from members_to_print, a list of tuples + + Returns a string to be used as the content of the bot's leaderboard response + """ + + header = f"{' '*4}{'Score'} {'Name':^25}\n{'-'*36}" + table = "" + for member in members_to_print: + # In the event of a tie, rank is None + if member[0]: + rank = f"{member[0]:3})" + else: + rank = f"{' ':4}" + table += f"{rank} {member[1]:4} {member[2]:25.25}\n" + else: + table = f"```{header}\n{table}```" + + return table + + +def _error_embed_helper(title: str, description: str) -> discord.Embed: + """ + Return a red-colored Embed with the given title and description + """ + + return discord.Embed(title=title, description=description, colour=discord.Colour.red()) + + +def setup(bot: commands.Bot) -> None: + bot.add_cog(AdventOfCode(bot)) + log.info("Cog loaded: adventofcode") diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py new file mode 100644 index 00000000..e4367aaa --- /dev/null +++ b/bot/seasons/evergreen/__init__.py @@ -0,0 +1,13 @@ +from bot.seasons import SeasonBase + + +class Evergreen(SeasonBase): + bot_name = "SeasonalBot" + + def __init__(self, bot): + self.bot = bot + + @property + def bot_avatar(self): + with open(self.avatar_path("standard.png"), "rb") as avatar: + return bytearray(avatar.read()) diff --git a/bot/cogs/error_handler.py b/bot/seasons/evergreen/error_handler.py index 79780251..6de35e60 100644 --- a/bot/cogs/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -5,6 +5,8 @@ import traceback from discord.ext import commands
+log = logging.getLogger(__name__)
+
class CommandErrorHandler:
"""A error handler for the PythonDiscord server!"""
@@ -17,36 +19,36 @@ class CommandErrorHandler: if hasattr(ctx.command, 'on_error'):
return logging.debug(
- "A command error occured but " +
+ "A command error occured but "
"the command had it's own error handler"
)
error = getattr(error, 'original', error)
if isinstance(error, commands.CommandNotFound):
return logging.debug(
- f"{ctx.author} called '{ctx.message.content}' " +
+ f"{ctx.author} called '{ctx.message.content}' "
"but no command was found"
)
if isinstance(error, commands.UserInputError):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' " +
+ f"{ctx.author} called the command '{ctx.command}' "
"but entered invalid input!"
)
return await ctx.send(
- ":no_entry: The command you specified failed to run." +
+ ":no_entry: The command you specified failed to run."
"This is because the arguments you provided were invalid."
)
if isinstance(error, commands.CommandOnCooldown):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' " +
+ f"{ctx.author} called the command '{ctx.command}' "
"but they were on cooldown!"
)
return await ctx.send(
- "This command is on cooldown," +
+ "This command is on cooldown,"
f" please retry in {math.ceil(error.retry_after)}s."
)
if isinstance(error, commands.DisabledCommand):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' " +
+ f"{ctx.author} called the command '{ctx.command}' "
"but the command was disabled!"
)
return await ctx.send(
@@ -54,7 +56,7 @@ class CommandErrorHandler: )
if isinstance(error, commands.NoPrivateMessage):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' " +
+ f"{ctx.author} called the command '{ctx.command}' "
"in a private message however the command was guild only!"
)
return await ctx.author.send(
@@ -63,7 +65,7 @@ class CommandErrorHandler: if isinstance(error, commands.BadArgument):
if ctx.command.qualified_name == 'tag list':
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' " +
+ f"{ctx.author} called the command '{ctx.command}' "
"but entered an invalid user!"
)
return await ctx.send(
@@ -71,7 +73,7 @@ class CommandErrorHandler: )
else:
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' " +
+ f"{ctx.author} called the command '{ctx.command}' "
"but entered a bad argument!"
)
return await ctx.send(
@@ -79,7 +81,7 @@ class CommandErrorHandler: )
if isinstance(error, commands.CheckFailure):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' " +
+ f"{ctx.author} called the command '{ctx.command}' "
"but the checks failed!"
)
return await ctx.send(
@@ -90,8 +92,8 @@ class CommandErrorHandler: file=sys.stderr
)
logging.warning(
- f"{ctx.author} called the command '{ctx.command}' " +
- "however the command failed to run with the error:" +
+ f"{ctx.author} called the command '{ctx.command}' "
+ "however the command failed to run with the error:"
f"-------------\n{error}"
)
traceback.print_exception(
@@ -104,3 +106,4 @@ class CommandErrorHandler: def setup(bot):
bot.add_cog(CommandErrorHandler(bot))
+ log.debug("CommandErrorHandler cog loaded")
diff --git a/bot/cogs/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py index ec4a3083..1321da19 100644 --- a/bot/cogs/evergreen/uptime.py +++ b/bot/seasons/evergreen/uptime.py @@ -1,9 +1,13 @@ +import logging + import arrow from dateutil.relativedelta import relativedelta from discord.ext import commands from bot import start_time +log = logging.getLogger(__name__) + class Uptime: """ @@ -13,7 +17,7 @@ class Uptime: def __init__(self, bot): self.bot = bot - @commands.command(name='uptime') + @commands.command(name="uptime") async def uptime(self, ctx): """ Returns the uptime of the bot. @@ -31,3 +35,4 @@ class Uptime: # Required in order to load the cog, use the class name in the add_cog function. def setup(bot): bot.add_cog(Uptime(bot)) + log.debug("Uptime cog loaded") diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py new file mode 100644 index 00000000..40b9ce90 --- /dev/null +++ b/bot/seasons/halloween/__init__.py @@ -0,0 +1,16 @@ +from bot.seasons import SeasonBase + + +class Halloween(SeasonBase): + name = "halloween" + start_date = "01/10" + end_date = "31/10" + bot_name = "Spookybot" + + def __init__(self, bot): + self.bot = bot + + @property + def bot_avatar(self): + with open(self.avatar_path("spooky.png"), "rb") as avatar: + return bytearray(avatar.read()) diff --git a/bot/cogs/hacktober/candy_collection.py b/bot/seasons/halloween/candy_collection.py index f5f17abb..80f30a1b 100644 --- a/bot/cogs/hacktober/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -1,12 +1,15 @@ import functools import json +import logging import os import random import discord from discord.ext import commands -from bot.constants import HACKTOBER_CHANNEL_ID +from bot.constants import Hacktoberfest + +log = logging.getLogger(__name__) json_location = os.path.join("bot", "resources", "halloween", "candy_collection.json") @@ -37,7 +40,7 @@ class CandyCollection: if message.author.bot: return # ensure it's hacktober channel - if message.channel.id != HACKTOBER_CHANNEL_ID: + if message.channel.id != Hacktoberfest.channel_id: return # do random check for skull first as it has the lower chance @@ -60,8 +63,9 @@ class CandyCollection: # check to ensure the reactor is human if user.bot: return + # check to ensure it is in correct channel - if message.channel.id != HACKTOBER_CHANNEL_ID: + if message.channel.id != Hacktoberfest.channel_id: return # if its not a candy or skull, and it is one of 10 most recent messages, @@ -120,7 +124,7 @@ class CandyCollection: ten_recent = [] recent_msg = max(message.id for message in self.bot._connection._messages - if message.channel.id == self.HACKTOBER_CHANNEL_ID) + if message.channel.id == Hacktoberfest.channel_id) channel = await self.hacktober_channel() ten_recent.append(recent_msg.id) @@ -155,7 +159,7 @@ class CandyCollection: """ Get #hacktoberbot channel from it's id """ - return self.bot.get_channel(id=HACKTOBER_CHANNEL_ID) + return self.bot.get_channel(id=Hacktoberfest.channel_id) async def remove_reactions(self, reaction): """ @@ -227,3 +231,4 @@ class CandyCollection: def setup(bot): bot.add_cog(CandyCollection(bot)) + log.debug("CandyCollection cog loaded") diff --git a/bot/cogs/hacktober/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index c473d3d0..41cf10ee 100644 --- a/bot/cogs/hacktober/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -10,16 +10,18 @@ import aiohttp import discord from discord.ext import commands +log = logging.getLogger(__name__) -class Stats: + +class HacktoberStats: def __init__(self, bot): self.bot = bot - self.link_json = Path('./bot/resources', 'github_links.json') + self.link_json = Path("bot", "resources", "github_links.json") self.linked_accounts = self.load_linked_users() @commands.group( - name='stats', - aliases=('hacktoberstats', 'getstats', 'userstats'), + name='hacktoberstats', + aliases=('hackstats',), invoke_without_command=True ) async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None): @@ -30,7 +32,7 @@ class Stats: If invoked with a github_username, get that user's contributions """ if not github_username: - author_id, author_mention = Stats._author_mention_from_context(ctx) + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) if str(author_id) in self.linked_accounts.keys(): github_username = self.linked_accounts[author_id]["github_username"] @@ -59,7 +61,7 @@ class Stats: } } """ - author_id, author_mention = Stats._author_mention_from_context(ctx) + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) if github_username: if str(author_id) in self.linked_accounts.keys(): old_username = self.linked_accounts[author_id]["github_username"] @@ -84,7 +86,7 @@ class Stats: """ Remove the invoking user's account link from the log """ - author_id, author_mention = Stats._author_mention_from_context(ctx) + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) stored_user = self.linked_accounts.pop(author_id, None) if stored_user: @@ -175,7 +177,12 @@ class Stats: stats_embed = discord.Embed( title=f"{github_username}'s Hacktoberfest", color=discord.Color(0x9c4af7), - description=f"{github_username} has made {n} {Stats._contributionator(n)} in October\n\n{shirtstr}\n\n" + description=( + f"{github_username} has made {n} " + f"{HacktoberStats._contributionator(n)} in " + f"October\n\n" + f"{shirtstr}\n\n" + ) ) stats_embed.set_thumbnail(url=f"https://www.github.com/{github_username}.png") @@ -244,7 +251,7 @@ class Stats: logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") outlist = [] for item in jsonresp["items"]: - shortname = Stats._get_shortname(item["repository_url"]) + shortname = HacktoberStats._get_shortname(item["repository_url"]) itemdict = { "repo_url": f"https://www.github.com/{shortname}", "repo_shortname": shortname, @@ -298,7 +305,7 @@ class Stats: contributionstrs = [] for repo in stats['top5']: n = repo[1] - contributionstrs.append(f"{n} {Stats._contributionator(n)} to [{repo[0]}]({baseURL}{repo[0]})") + contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({baseURL}{repo[0]})") return "\n".join(contributionstrs) @@ -324,4 +331,5 @@ class Stats: def setup(bot): - bot.add_cog(Stats(bot)) + bot.add_cog(HacktoberStats(bot)) + log.debug("HacktoberStats cog loaded") diff --git a/bot/cogs/hacktober/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index 7b5b866b..098ee432 100644 --- a/bot/cogs/hacktober/halloween_facts.py +++ b/bot/seasons/halloween/halloween_facts.py @@ -1,5 +1,5 @@ -import asyncio import json +import logging import random from datetime import timedelta from pathlib import Path @@ -7,7 +7,9 @@ from pathlib import Path import discord from discord.ext import commands -from bot.constants import HACKTOBER_CHANNEL_ID +from bot.constants import Hacktoberfest + +log = logging.getLogger(__name__) SPOOKY_EMOJIS = [ "\N{BAT}", @@ -30,37 +32,24 @@ class HalloweenFacts: with open(Path("bot", "resources", "halloween", "halloween_facts.json"), "r") as file: self.halloween_facts = json.load(file) self.channel = None - self.last_fact = None + self.facts = list(enumerate(self.halloween_facts)) + random.shuffle(self.facts) async def on_ready(self): - self.channel = self.bot.get_channel(HACKTOBER_CHANNEL_ID) + self.channel = self.bot.get_channel(Hacktoberfest.channel_id) self.bot.loop.create_task(self._fact_publisher_task()) - async def _fact_publisher_task(self): - """ - A background task that runs forever, sending Halloween facts at random to the Discord channel with id equal to - HACKTOBER_CHANNEL_ID every INTERVAL seconds. - """ - facts = list(enumerate(self.halloween_facts)) - while True: - # Avoid choosing each fact at random to reduce chances of facts being reposted soon. - random.shuffle(facts) - for index, fact in facts: - embed = self._build_embed(index, fact) - await self.channel.send("Your regular serving of random Halloween facts", embed=embed) - self.last_fact = (index, fact) - await asyncio.sleep(INTERVAL) + def random_fact(self): + return random.choice(self.facts) - @commands.command(name="hallofact", aliases=["hallofacts"], brief="Get the most recent Halloween fact") - async def get_last_fact(self, ctx): + @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") + async def get_random_fact(self, ctx): """ Reply with the most recent Halloween fact. """ - if ctx.channel != self.channel: - return - index, fact = self.last_fact + index, fact = self.random_fact() embed = self._build_embed(index, fact) - await ctx.send("Halloween fact recap", embed=embed) + await ctx.send(embed=embed) @staticmethod def _build_embed(index, fact): @@ -74,3 +63,4 @@ class HalloweenFacts: def setup(bot): bot.add_cog(HalloweenFacts(bot)) + log.debug("HalloweenFacts cog loaded") diff --git a/bot/cogs/hacktober/halloweenify.py b/bot/seasons/halloween/halloweenify.py index 5d270974..cda07472 100644 --- a/bot/cogs/hacktober/halloweenify.py +++ b/bot/seasons/halloween/halloweenify.py @@ -1,3 +1,4 @@ +import logging from json import load from pathlib import Path from random import choice @@ -6,6 +7,8 @@ import discord from discord.ext import commands from discord.ext.commands.cooldowns import BucketType +log = logging.getLogger(__name__) + class Halloweenify: """ @@ -49,3 +52,4 @@ class Halloweenify: def setup(bot): bot.add_cog(Halloweenify(bot)) + log.debug("Halloweenify cog loaded") diff --git a/bot/cogs/hacktober/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py index 2b78abc6..08873f24 100644 --- a/bot/cogs/hacktober/monstersurvey.py +++ b/bot/seasons/halloween/monstersurvey.py @@ -58,7 +58,7 @@ class MonsterSurvey: @commands.group( name='monster', - aliases=['ms'] + aliases=('ms',) ) async def monster_group(self, ctx: Context): """ @@ -180,7 +180,7 @@ class MonsterSurvey: @monster_group.command( name='leaderboard', - aliases=['lb'] + aliases=('lb',) ) async def monster_leaderboard(self, ctx: Context): """ @@ -215,4 +215,4 @@ class MonsterSurvey: def setup(bot): bot.add_cog(MonsterSurvey(bot)) - log.debug("MonsterSurvey COG Loaded") + log.debug("MonsterSurvey cog loaded") diff --git a/bot/cogs/hacktober/scarymovie.py b/bot/seasons/halloween/scarymovie.py index c2298c65..b280781e 100644 --- a/bot/cogs/hacktober/scarymovie.py +++ b/bot/seasons/halloween/scarymovie.py @@ -1,3 +1,4 @@ +import logging import random from os import environ @@ -5,6 +6,8 @@ import aiohttp from discord import Embed from discord.ext import commands +log = logging.getLogger(__name__) + TMDB_API_KEY = environ.get('TMDB_API_KEY') TMDB_TOKEN = environ.get('TMDB_TOKEN') @@ -18,7 +21,7 @@ class ScaryMovie: def __init__(self, bot): self.bot = bot - @commands.command(name='movie', alias=['tmdb']) + @commands.command(name='scarymovie', alias=['smovie']) async def random_movie(self, ctx): """ Randomly select a scary movie and display information about it. @@ -135,3 +138,4 @@ class ScaryMovie: def setup(bot): bot.add_cog(ScaryMovie(bot)) + log.debug("ScaryMovie cog loaded") diff --git a/bot/cogs/hacktober/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py index 6ce4471c..b37a03f9 100644 --- a/bot/cogs/hacktober/spookyavatar.py +++ b/bot/seasons/halloween/spookyavatar.py @@ -1,3 +1,4 @@ +import logging import os from io import BytesIO @@ -6,7 +7,9 @@ import discord from discord.ext import commands from PIL import Image -from bot.utils import spookifications +from bot.utils.halloween import spookifications + +log = logging.getLogger(__name__) class SpookyAvatar: @@ -26,7 +29,7 @@ class SpookyAvatar: async with session.get(url) as resp: return await resp.read() - @commands.command(name='savatar', aliases=['spookyavatar', 'spookify'], + @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), brief='Spookify an user\'s avatar.') async def spooky_avatar(self, ctx, user: discord.Member = None): """ @@ -52,3 +55,4 @@ class SpookyAvatar: def setup(bot): bot.add_cog(SpookyAvatar(bot)) + log.debug("SpookyAvatar cog loaded") diff --git a/bot/cogs/hacktober/spookygif.py b/bot/seasons/halloween/spookygif.py index 98a411f6..1233773b 100644 --- a/bot/cogs/hacktober/spookygif.py +++ b/bot/seasons/halloween/spookygif.py @@ -1,8 +1,12 @@ +import logging + import aiohttp import discord from discord.ext import commands -from bot.constants import GIPHY_TOKEN +from bot.constants import Tokens + +log = logging.getLogger(__name__) class SpookyGif: @@ -13,14 +17,15 @@ class SpookyGif: def __init__(self, bot): self.bot = bot - @commands.command(name="spookygif", aliases=["sgif", "scarygif"]) + @commands.command(name="spookygif", aliases=("sgif", "scarygif")) async def spookygif(self, ctx): """ Fetches a random gif from the GIPHY API and responds with it. """ + async with ctx.typing(): async with aiohttp.ClientSession() as session: - params = {'api_key': GIPHY_TOKEN, 'tag': 'halloween', 'rating': 'g'} + params = {'api_key': Tokens.giphy, 'tag': 'halloween', 'rating': 'g'} # Make a GET request to the Giphy API to get a random halloween gif. async with session.get('http://api.giphy.com/v1/gifs/random', params=params) as resp: data = await resp.json() @@ -35,3 +40,4 @@ class SpookyGif: def setup(bot): bot.add_cog(SpookyGif(bot)) + log.debug("SpookyGif cog loaded") diff --git a/bot/cogs/hacktober/spookyreact.py b/bot/seasons/halloween/spookyreact.py index 8e9e8db6..f63cd7e5 100644 --- a/bot/cogs/hacktober/spookyreact.py +++ b/bot/seasons/halloween/spookyreact.py @@ -3,6 +3,8 @@ import re import discord +log = logging.getLogger(__name__) + SPOOKY_TRIGGERS = { 'spooky': (r"\bspo{2,}ky\b", "\U0001F47B"), 'skeleton': (r"\bskeleton\b", "\U0001F480"), @@ -52,14 +54,14 @@ class SpookyReact: """ # Check for self reaction if ctx.author == self.bot.user: - logging.info(f"Ignoring reactions on self message. Message ID: {ctx.id}") + logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}") return True # Check for command invocation # Because on_message doesn't give a full Context object, generate one first tmp_ctx = await self.bot.get_context(ctx) if tmp_ctx.prefix: - logging.info(f"Ignoring reactions on command invocation. Message ID: {ctx.id}") + logging.debug(f"Ignoring reactions on command invocation. Message ID: {ctx.id}") return True return False @@ -67,3 +69,4 @@ class SpookyReact: def setup(bot): bot.add_cog(SpookyReact(bot)) + log.debug("SpookyReact cog loaded") diff --git a/bot/cogs/hacktober/spookysound.py b/bot/seasons/halloween/spookysound.py index e1598517..4cab1239 100644 --- a/bot/cogs/hacktober/spookysound.py +++ b/bot/seasons/halloween/spookysound.py @@ -1,10 +1,13 @@ +import logging import random from pathlib import Path import discord from discord.ext import commands -from bot.constants import HACKTOBER_VOICE_CHANNEL_ID +from bot.constants import Hacktoberfest + +log = logging.getLogger(__name__) class SpookySound: @@ -14,7 +17,7 @@ class SpookySound: def __init__(self, bot): self.bot = bot - self.sound_files = list(Path("./bot/resources/spookysounds").glob("*.mp3")) + self.sound_files = list(Path("bot", "resources", "halloween", "spookysounds").glob("*.mp3")) self.channel = None @commands.cooldown(rate=1, per=1) @@ -26,7 +29,7 @@ class SpookySound: """ if not self.channel: await self.bot.wait_until_ready() - self.channel = self.bot.get_channel(HACKTOBER_VOICE_CHANNEL_ID) + self.channel = self.bot.get_channel(Hacktoberfest.voice_id) await ctx.send("Initiating spooky sound...") file_path = random.choice(self.sound_files) @@ -44,3 +47,4 @@ class SpookySound: def setup(bot): bot.add_cog(SpookySound(bot)) + log.debug("SpookySound cog loaded") diff --git a/bot/seasons/season.py b/bot/seasons/season.py new file mode 100644 index 00000000..591bbc2d --- /dev/null +++ b/bot/seasons/season.py @@ -0,0 +1,180 @@ +import asyncio +import datetime +import importlib +import logging +import pkgutil +from pathlib import Path + +from discord.ext import commands + +from bot.constants import Client, Roles +from bot.decorators import with_role + +log = logging.getLogger(__name__) + + +def get_seasons(): + """ + Returns all the Season objects + located in bot/seasons/ + """ + seasons = [] + + for module in pkgutil.iter_modules([Path('bot', 'seasons')]): + if module.ispkg: + seasons.append(module[1]) + + return seasons + + +def get_season_class(season_name): + season_lib = importlib.import_module(f'bot.seasons.{season_name}') + return getattr(season_lib, season_name.capitalize()) + + +def get_season(bot, season_name: str = None, date: datetime.date = None): + """ + Returns a Season object based on either a string or a date. + """ + + # If either both or neither are set, raise an error. + if not bool(season_name) ^ bool(date): + raise UserWarning("This function requires either a season or a date in order to run.") + + seasons = get_seasons() + + # Use season override if season name not provided + if not season_name and Client.season_override: + log.debug(f"Season override found: {Client.season_override}") + season_name = Client.season_override + + # If name provided grab the specified class or fallback to evergreen. + if season_name: + season_name = season_name.lower() + if season_name not in seasons: + season_name = 'evergreen' + season_class = get_season_class(season_name) + return season_class(bot) + + # If not, we have to figure out if the date matches any of the seasons. + seasons.remove('evergreen') + for season_name in seasons: + season_class = get_season_class(season_name) + # check if date matches before returning an instance + if season_class.start() <= date <= season_class.end(): + return season_class(bot) + else: + evergreen_class = get_season_class('evergreen') + return evergreen_class(bot) + + +class SeasonBase: + name = None + date_format = "%d/%m-%Y" + + @staticmethod + def current_year(): + return datetime.date.today().year + + @classmethod + def start(cls): + return datetime.datetime.strptime(f"{cls.start_date}-{cls.current_year()}", cls.date_format).date() + + @classmethod + def end(cls): + return datetime.datetime.strptime(f"{cls.end_date}-{cls.current_year()}", cls.date_format).date() + + @staticmethod + def avatar_path(*path_segments): + return Path('bot', 'resources', 'avatars', *path_segments) + + async def load(self): + """ + Loads in the bot name, the bot avatar, + and the extensions that are relevant to that season. + """ + + guild = self.bot.get_guild(Client.guild) + + # Change only nickname if in debug mode due to ratelimits for user edits + if Client.debug: + if guild.me.display_name != self.bot_name: + log.debug(f"Changing nickname to {self.bot_name}") + await guild.me.edit(nick=self.bot_name) + else: + if self.bot.user.name != self.bot_name: + # attempt to change user details + log.debug(f"Changing username to {self.bot_name}") + await self.bot.user.edit(name=self.bot_name, avatar=self.bot_avatar) + + # fallback on nickname if failed due to ratelimit + if self.bot.user.name != self.bot_name: + log.info(f"User details failed to change: Changing nickname to {self.bot_name}") + await guild.me.edit(nick=self.bot_name) + + # remove nickname if an old one exists + if guild.me.nick and guild.me.nick != self.bot_name: + log.debug(f"Clearing old nickname of {guild.me.nick}") + await guild.me.edit(nick=None) + + # Prepare all the seasonal cogs, and then the evergreen ones. + extensions = [] + for ext_folder in {self.name, "evergreen"}: + if ext_folder: + log.info(f'Start loading extensions from seasons/{ext_folder}/') + path = Path('bot', 'seasons', ext_folder) + for ext_name in [i[1] for i in pkgutil.iter_modules([path])]: + extensions.append(f"bot.seasons.{ext_folder}.{ext_name}") + + # Finally we can load all the cogs we've prepared. + self.bot.load_extensions(extensions) + + +class SeasonManager: + """ + A cog for managing seasons. + """ + + def __init__(self, bot): + self.bot = bot + self.season = get_season(bot, date=datetime.date.today()) + self.season_task = bot.loop.create_task(self.load_seasons()) + + # Figure out number of seconds until a minute past midnight + tomorrow = datetime.datetime.now() + datetime.timedelta(1) + midnight = datetime.datetime( + year=tomorrow.year, + month=tomorrow.month, + day=tomorrow.day, + hour=0, + minute=0, + second=0 + ) + self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60 + + async def load_seasons(self): + await self.bot.wait_until_ready() + await self.season.load() + + while True: + await asyncio.sleep(self.sleep_time) # sleep until midnight + self.sleep_time = 86400 # next time, sleep for 24 hours. + + # If the season has changed, load it. + new_season = get_season(self.bot, date=datetime.date.today()) + if new_season != self.season: + await self.season.load() + + @with_role(Roles.moderator, Roles.admin, Roles.owner) + @commands.command(name='season') + async def change_season(self, ctx, new_season: str): + """ + Changes the currently active season on the bot. + """ + + self.season = get_season(self.bot, season_name=new_season) + await self.season.load() + await ctx.send(f"Season changed to {new_season}.") + + def __unload(self): + self.season_task.cancel() diff --git a/bot/cogs/evergreen/__init__.py b/bot/utils/halloween/__init__.py index e69de29b..e69de29b 100644 --- a/bot/cogs/evergreen/__init__.py +++ b/bot/utils/halloween/__init__.py diff --git a/bot/utils/spookifications.py b/bot/utils/halloween/spookifications.py index 5f2369ae..5f2369ae 100644 --- a/bot/utils/spookifications.py +++ b/bot/utils/halloween/spookifications.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 9c4406bf..1613261c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM python:3.6-alpine3.7 -RUN apk add --update tini git +RUN apk add --update tini build-base git jpeg-dev zlib zlib-dev RUN mkdir /bot COPY . /bot |