diff options
author | 2019-05-11 04:51:30 +1000 | |
---|---|---|
committer | 2019-05-11 04:51:30 +1000 | |
commit | 0e2c6a1b0daef5b569da7652801b5725bf1ed95b (patch) | |
tree | c3d57e50f2ba07cb2d9e7476d3b9441c1f671bd9 | |
parent | not importing aiohttp now (diff) | |
parent | Merge pull request #198 from Suhail6inkling/constants_fix (diff) |
Merge branch 'master' into hanukkah_embed_iceman
79 files changed, 3561 insertions, 569 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4161715e..e2e48bd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,10 +4,12 @@ Seasonalbot is a community project for the Python Discord community over at http Our projects are open-source and are automatically deployed whenever commits are pushed to the `master` branch on each repository, so we've created a set of guidelines in order to keep everything clean and in working order. +Note that contributions may be rejected on the basis of a contributor failing to follow these guidelines. + ## Rules 1. You must be a member of [our Discord community](https://discord.gg/python) in order to contribute to this project. -2. Your pull request must solve an issue created by or approved a staff member or event handler. These will be labeled with the `approved` label. Feel free to suggest issues of your own, which staff can choose to approve. +2. Your pull request must solve an issue created or approved by a staff member. These will be labeled with the `approved` label. Feel free to suggest issues of your own, which staff can review for approval. 3. **No force-pushes** or modifying the Git history in any way. 4. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there. * It's common practice for a repository to reject direct pushes to `master`, so make branching a habit! @@ -21,10 +23,13 @@ Our projects are open-source and are automatically deployed whenever commits are 7. **Avoid frequent pushes to the main repository**. This goes for PRs opened against your fork as well. Our test build pipelines are triggered every time a push to the repository (or PR) is made. Try to batch your commits until you've finished working for that session, or you've reached a point where collaborators need your commits to continue their own work. This also provides you the opportunity to amend commits for minor changes rather than having to commit them on their own because you've already pushed. * This includes merging master into your branch. Try to leave merging from master for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to master for your branch, or something was pushed to master that could potentionally affect the functionality of what you're writing. 8. **Don't fight the framework**. Every framework has its flaws, but the frameworks we've picked out have been carefully chosen for their particular merits. If you can avoid it, please resist reimplementing swathes of framework logic - the work has already been done for you! -9. If someone is working on a pull request, **do not open your own pull request for the same task**. Instead, collaborate with the author(s) of the existing pull request. Communication is key, and there's no point in two separate implementations of the same thing. +9. If someone is working on an issue or pull request, **do not open your own pull request for the same task**. Instead, collaborate with the author(s) of the existing pull request. Duplicate PRs opened without communicating with the other author(s) and/or PyDis staff will be closed. Communication is key, and there's no point in two separate implementations of the same thing. * One option is to fork the other contributor's repository and submit your changes to their branch with your own pull request. We suggest following these guidelines when interacting with their repository as well. -10. **Work as a team** and collaborate whereever possible. Keep things friendly and help each other out - these are shared projects and nobody likes to have their feet trodden on. + * The author(s) of inactive PRs and claimed issues will be be pinged after a week of inactivity for an update. Continued inactivity may result in the issue being released back to the community and/or PR closure. +10. **Work as a team** and collaborate wherever possible. Keep things friendly and help each other out - these are shared projects and nobody likes to have their feet trodden on. 11. **Internal projects are internal**. As a contributor, you have access to information that the rest of the server does not. With this trust comes responsibility - do not release any information you have learned as a result of your contributor position. We are very strict about announcing things at specific times, and many staff members will not appreciate a disruption of the announcement schedule. +12. All static content, such as images or audio, **must be licensed for open public use**. + * Static content must be hosted by a service designed to do so. Failing to do so is known as "leeching" and is frowned upon, as it generates extra bandwidth costs to the host without providing benefit. It would be best if appropriately licensed content is added to the repository itself so it can be served by PyDis' infrastructure. Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role, especially in relation to Rule 7. @@ -34,7 +39,56 @@ All projects evolve over time, and this contribution guide is no different. This ## Supplemental Information ### Developer Environment -Seasonalbot utilizes [Pipenv](https://pipenv.readthedocs.io/en/latest/) for installation and dependency management. For users unfamiliar with the Pipenv workflow, Pipenv's documentation provides a [Basic Usage](https://pipenv.readthedocs.io/en/latest/basics/) tutorial, along with some of the more advanced workflows. +Seasonalbot utilizes [Pipenv](https://pipenv.readthedocs.io/en/latest/) for installation and dependency management. For users unfamiliar with the Pipenv workflow, Pipenv's documentation provides a [Basic Usage](https://pipenv.readthedocs.io/en/latest/basics/) tutorial, along with some of the more advanced workflows. A project-specific installation guide can be found in [Seasonalbot's README](https://github.com/python-discord/seasonalbot/blob/master/README.md). + +When pulling down changes from GitHub, remember to sync your environment using `pipenv sync --dev` to ensure you're using the most up-to-date versions the project's dependencies. + +### Type Hinting +[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types. + +For example: + +```py +def foo(input_1: int, input_2: dict) -> bool: +``` + +Tells us that `foo` accepts an `int` and a `dict` and returns a `bool`. + +All function declarations should be type hinted in code contributed to the PyDis organization. + +For more information, see *[PEP 483](https://www.python.org/dev/peps/pep-0483/) - The Theory of Type Hints* and Python's documentation for the [`typing`](https://docs.python.org/3/library/typing.html) module. + +### AutoDoc Formatting Directives +Many documentation packages provide support for automatic documentation generation from the codebase's docstrings. These tools utilize special formatting directives to enable richer formatting in the generated documentation. + +For example: + +```py +def foo(bar: int, baz: dict=None) -> bool: + """ + Does some things with some stuff. + + :param bar: Some input + :param baz: Optional, some other input + + :return: Some boolean + """ +``` + +Since PyDis does not utilize automatic documentation generation, use of this syntax should not be used in code contributed to the organization. Should the purpose and type of the input variables not be easily discernable from the variable name and type annotation, a prose explanation can be used. Explicit references to variables, functions, classes, etc. should be wrapped with backticks (`` ` ``). + +For example, the above docstring would become: + +```py +def foo(bar: int, baz: dict=None) -> bool: + """ + Does some things with some stuff. + + This function takes an index, `bar` and checks for its presence in the database `baz`, passed as a dictionary. + + Returns `False` if `baz` is not passed. + """ +``` ### Logging levels The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows: @@ -45,6 +99,11 @@ The project currently defines [`logging`](https://docs.python.org/3/library/logg * **ERROR:** An error that affects the specific part that is being interacted with * **CRITICAL:** An error that affects the whole application. +### Work in Progress (WIP) PRs +Github [has introduced a new PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. + +This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. + ## Footnotes This document was inspired by the [Glowstone contribution guidelines](https://github.com/GlowstoneMC/Glowstone/blob/dev/docs/CONTRIBUTING.md). @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -discord-py = {ref = "42a7c4f",git = "https://github.com/Rapptz/discord.py",editable = true} +discord-py = {ref = "43b4475",git = "https://github.com/Rapptz/discord.py",editable = true} arrow = "*" beautifulsoup4 = "*" aiodns = "*" @@ -20,6 +20,7 @@ fuzzywuzzy = "*" "flake8-todo" = "*" "flake8-string-format" = "*" pre-commit = "*" +flake8-docstrings = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 27318993..d4a2183c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d8e3a7f796632bf80bc0c0b8ed2162d3f39aa91e8afa424d3ae85f2210a71923" + "sha256": "d3f50052000b1e8bda6997dbdace86d5218aa19d240983f2586d990b6d18f36c" }, "pipfile-spec": 6, "requires": { @@ -125,7 +125,7 @@ "discord-py": { "editable": true, "git": "https://github.com/Rapptz/discord.py", - "ref": "42a7c4f7e5caa7baf555e86b52249419ce33acfc" + "ref": "43b44751af647ecfcfb17868962972d543eb69a9" }, "fuzzywuzzy": { "hashes": [ @@ -260,36 +260,36 @@ }, "soupsieve": { "hashes": [ - "sha256:afa56bf14907bb09403e5d15fbed6275caa4174d36b975226e3b67a3bb6e2c4b", - "sha256:eaed742b48b1f3e2d45ba6f79401b2ed5dc33b2123dfe216adb90d4bfa0ade26" + "sha256:3aef141566afd07201b525c17bfaadd07580a8066f82b57f7c9417f26adbd0a3", + "sha256:e41a65e99bd125972d84221022beb1e4b5cfc68fa12c170c39834ce32d1b294c" ], - "version": "==1.8" + "version": "==1.9" }, "websockets": { "hashes": [ - "sha256:04b42a1b57096ffa5627d6a78ea1ff7fad3bc2c0331ffc17bc32a4024da7fea0", - "sha256:08e3c3e0535befa4f0c4443824496c03ecc25062debbcf895874f8a0b4c97c9f", - "sha256:10d89d4326045bf5e15e83e9867c85d686b612822e4d8f149cf4840aab5f46e0", - "sha256:232fac8a1978fc1dead4b1c2fa27c7756750fb393eb4ac52f6bc87ba7242b2fa", - "sha256:4bf4c8097440eff22bc78ec76fe2a865a6e658b6977a504679aaf08f02c121da", - "sha256:51642ea3a00772d1e48fb0c492f0d3ae3b6474f34d20eca005a83f8c9c06c561", - "sha256:55d86102282a636e195dad68aaaf85b81d0bef449d7e2ef2ff79ac450bb25d53", - "sha256:564d2675682bd497b59907d2205031acbf7d3fadf8c763b689b9ede20300b215", - "sha256:5d13bf5197a92149dc0badcc2b699267ff65a867029f465accfca8abab95f412", - "sha256:5eda665f6789edb9b57b57a159b9c55482cbe5b046d7db458948370554b16439", - "sha256:5edb2524d4032be4564c65dc4f9d01e79fe8fad5f966e5b552f4e5164fef0885", - "sha256:79691794288bc51e2a3b8de2bc0272ca8355d0b8503077ea57c0716e840ebaef", - "sha256:7fcc8681e9981b9b511cdee7c580d5b005f3bb86b65bde2188e04a29f1d63317", - "sha256:8e447e05ec88b1b408a4c9cde85aa6f4b04f06aa874b9f0b8e8319faf51b1fee", - "sha256:90ea6b3e7787620bb295a4ae050d2811c807d65b1486749414f78cfd6fb61489", - "sha256:9e13239952694b8b831088431d15f771beace10edfcf9ef230cefea14f18508f", - "sha256:d40f081187f7b54d7a99d8a5c782eaa4edc335a057aa54c85059272ed826dc09", - "sha256:e1df1a58ed2468c7b7ce9a2f9752a32ad08eac2bcd56318625c3647c2cd2da6f", - "sha256:e98d0cec437097f09c7834a11c69d79fe6241729b23f656cfc227e93294fc242", - "sha256:f8d59627702d2ff27cb495ca1abdea8bd8d581de425c56e93bff6517134e0a9b", - "sha256:fc30cdf2e949a2225b012a7911d1d031df3d23e99b7eda7dfc982dc4a860dae9" - ], - "version": "==7.0" + "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136", + "sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6", + "sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1", + "sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538", + "sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4", + "sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908", + "sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0", + "sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d", + "sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c", + "sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d", + "sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c", + "sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb", + "sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf", + "sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e", + "sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96", + "sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584", + "sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484", + "sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d", + "sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559", + "sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff", + "sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454" + ], + "version": "==6.0" }, "yarl": { "hashes": [ @@ -347,11 +347,19 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:07b6e769d7f4e168d590f7088eae40f6ddd9fa4952bed31602def65842682c83", - "sha256:0ccf56975f4db1d69dc1cf3598c99d768ebf95d0cad27d76087954aa399b515a" + "sha256:5070774b668be92c4312e5ca82748ddf4ecaa7a24ff062662681bb745c7896eb", + "sha256:fef9c9826d14ec23187ae1edeb3c6513c4e46bf0e70d86bac38f7d9aabae113d" ], "index": "pypi", - "version": "==18.8.0" + "version": "==19.3.0" + }, + "flake8-docstrings": { + "hashes": [ + "sha256:4e0ce1476b64e6291520e5570cf12b05016dd4e8ae454b8a8a9a48bc5f84e1cd", + "sha256:8436396b5ecad51a122a2c99ba26e5b4e623bf6e913b0fea0cb6c2c4050f91eb" + ], + "index": "pypi", + "version": "==1.3.0" }, "flake8-import-order": { "hashes": [ @@ -361,6 +369,13 @@ "index": "pypi", "version": "==0.18.1" }, + "flake8-polyfill": { + "hashes": [ + "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9", + "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda" + ], + "version": "==1.0.2" + }, "flake8-string-format": { "hashes": [ "sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2", @@ -386,10 +401,10 @@ }, "identify": { "hashes": [ - "sha256:407cbb36e8b72b45cfa96a97ae13ccabca4c36557e03616958bd895dfcd3f77d", - "sha256:721abbbb1269fa1172799119981c22c5ace022544ce82eedc29b1b0d753baaa5" + "sha256:244e7864ef59f0c7c50c6db73f58564151d91345cd9b76ed793458953578cadd", + "sha256:8ff062f90ad4b09cfe79b5dfb7a12e40f19d2e68a5c9598a49be45f16aba7171" ], - "version": "==1.4.0" + "version": "==1.4.1" }, "importlib-metadata": { "hashes": [ @@ -426,6 +441,14 @@ ], "version": "==2.5.0" }, + "pydocstyle": { + "hashes": [ + "sha256:2258f9b0df68b97bf3a6c29003edc5238ff8879f1efb6f1999988d934e432bd8", + "sha256:5741c85e408f9e0ddf873611085e819b809fca90b619f5fd7f34bd4959da3dd4", + "sha256:ed79d4ec5e92655eccc21eb0c6cf512e69512b4a97d215ace46d17e4990f2039" + ], + "version": "==3.0.0" + }, "pyflakes": { "hashes": [ "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", @@ -435,19 +458,19 @@ }, "pyyaml": { "hashes": [ - "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", - "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", - "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", - "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", - "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", - "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", - "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", - "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", - "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", - "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", - "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" + "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", + "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", + "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", + "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", + "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", + "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", + "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", + "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", + "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", + "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", + "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" ], - "version": "==3.13" + "version": "==5.1" }, "six": { "hashes": [ @@ -456,6 +479,13 @@ ], "version": "==1.12.0" }, + "snowballstemmer": { + "hashes": [ + "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", + "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" + ], + "version": "==1.2.1" + }, "toml": { "hashes": [ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", diff --git a/bot/__init__.py b/bot/__init__.py index 54b242ee..7c564178 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -16,10 +16,11 @@ logging.addLevelName(logging.TRACE, "TRACE") def monkeypatch_trace(self, msg, *args, **kwargs): """ Log 'msg % args' with severity 'TRACE'. - To pass exception information, use the keyword argument exc_info with - a true value, e.g. + + To pass exception information, use the keyword argument exc_info with a true value, e.g. logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) """ + if self.isEnabledFor(logging.TRACE): self._log(logging.TRACE, msg, args, **kwargs) @@ -53,6 +54,7 @@ if root.handlers: # Silence irrelevant loggers logging.getLogger("discord").setLevel(logging.ERROR) logging.getLogger("websockets").setLevel(logging.ERROR) +logging.getLogger("PIL").setLevel(logging.ERROR) # Setup new logging configuration logging.basicConfig( @@ -5,7 +5,7 @@ from typing import List from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import Embed -from discord.ext.commands import Bot +from discord.ext import commands from bot import constants @@ -14,7 +14,9 @@ log = logging.getLogger(__name__) __all__ = ('SeasonalBot',) -class SeasonalBot(Bot): +class SeasonalBot(commands.Bot): + """Base bot instance.""" + def __init__(self, **kwargs): super().__init__(**kwargs) self.http_session = ClientSession( @@ -22,9 +24,7 @@ class SeasonalBot(Bot): ) def load_extensions(self, exts: List[str]): - """ - Unload all current cogs, then load in the ones passed into `cogs` - """ + """Unload all current extensions, then load the given extensions.""" # Unload all cogs extensions = list(self.extensions.keys()) @@ -42,9 +42,7 @@ class SeasonalBot(Bot): log.error(f'Failed to load extension {cog}: {repr(e)} {format_exc()}') async def send_log(self, title: str, details: str = None, *, icon: str = None): - """ - Send an embed message to the devlog channel - """ + """Send an embed message to the devlog channel.""" devlog = self.get_channel(constants.Channels.devlog) @@ -59,3 +57,11 @@ class SeasonalBot(Bot): embed.set_author(name=title, icon_url=icon) await devlog.send(embed=embed) + + async def on_command_error(self, context, exception): + """Check command errors for UserInputError and reset the cooldown if thrown.""" + + if isinstance(exception, commands.UserInputError): + context.command.reset_cooldown(context) + else: + await super().on_command_error(context, exception) diff --git a/bot/constants.py b/bot/constants.py index 993a0f81..bf542daf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -18,7 +18,6 @@ class AdventOfCode: leaderboard_join_code = str(environ.get("AOC_JOIN_CODE", None)) 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)) @@ -29,7 +28,7 @@ class Channels(NamedTuple): bot = 267659945086812160 checkpoint_test = 422077681434099723 devalerts = 460181980097675264 - devlog = int(environ.get('CHANNEL_DEVLOG', 409308876241108992)) + devlog = int(environ.get('CHANNEL_DEVLOG', 548438471685963776)) devtest = 414574275865870337 help_0 = 303906576991780866 help_1 = 303906556754395136 @@ -46,13 +45,15 @@ class Channels(NamedTuple): off_topic_2 = 463035268514185226 python = 267624335836053506 reddit = 458224812528238616 + seasonalbot_chat = int(environ.get('CHANNEL_SEASONALBOT_CHAT', 542272993192050698)) staff_lounge = 464905259261755392 verification = 352442727016693763 + python_discussion = 267624335836053506 class Client(NamedTuple): guild = int(environ.get('SEASONALBOT_GUILD', 267624335836053506)) - prefix = "." + prefix = environ.get("PREFIX", ".") token = environ.get('SEASONALBOT_TOKEN') debug = environ.get('SEASONALBOT_DEBUG', '').lower() == 'true' season_override = environ.get('SEASON_OVERRIDE') @@ -60,12 +61,13 @@ class Client(NamedTuple): class Colours: blue = 0x0279fd - soft_red = 0xcd6d6d - soft_green = 0x68c290 bright_green = 0x01d277 dark_green = 0x1f8b4c orange = 0xe67e22 pink = 0xcf84e0 + soft_green = 0x68c290 + soft_red = 0xcd6d6d + yellow = 0xf9f586 class Emojis: @@ -82,12 +84,10 @@ class Emojis: class Lovefest: - channel_id = int(environ.get("LOVEFEST_CHANNEL_ID", 542272993192050698)) role_id = int(environ.get("LOVEFEST_ROLE_ID", 542431903886606399)) class Hacktoberfest(NamedTuple): - channel_id = 498804484324196362 voice_id = 514420006474219521 diff --git a/bot/decorators.py b/bot/decorators.py index f5ffadf4..15f7fed2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -14,6 +14,8 @@ log = logging.getLogger(__name__) def with_role(*role_ids: int): + """Check to see whether the invoking user has any of the roles specified in role_ids.""" + 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. " @@ -32,6 +34,8 @@ def with_role(*role_ids: int): def without_role(*role_ids: int): + """Check whether the invoking user does not have all of the roles specified in role_ids.""" + 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. " @@ -47,6 +51,8 @@ def without_role(*role_ids: int): def in_channel(channel_id): + """Check that the command invocation is in the channel specified by 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. " @@ -58,8 +64,8 @@ def in_channel(channel_id): def locked(): """ Allows the user to only run one instance of the decorated command at a time. - Subsequent calls to the command from the same author are - ignored until the command has completed invocation. + + Subsequent calls to the command from the same author are ignored until the command has completed invocation. This decorator has to go before (below) the `command` decorator. """ diff --git a/bot/pagination.py b/bot/pagination.py index 0ad5b81f..1091878a 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) class EmptyPaginatorEmbed(Exception): - pass + """Base Exception class for an empty paginator embed.""" class LinePaginator(Paginator): @@ -37,14 +37,13 @@ class LinePaginator(Paginator): The maximum amount of lines allowed in a page. """ - def __init__(self, prefix='```', suffix='```', - max_size=2000, max_lines=None): + def __init__(self, prefix='```', suffix='```', max_size=2000, max_lines=None): """ - This function overrides the Paginator.__init__ - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + Overrides the Paginator.__init__ from inside discord.ext.commands. + + Allows for configuration of the maximum number of lines per page. """ + self.prefix = prefix self.suffix = suffix self.max_size = max_size - len(suffix) @@ -55,15 +54,13 @@ class LinePaginator(Paginator): self._pages = [] def add_line(self, line='', *, empty=False): - """Adds a line to the current page. + """ + Adds a line to the current page. - If the line exceeds the :attr:`max_size` then an exception - is raised. + If the line exceeds the `max_size` then an exception is raised. - This function overrides the Paginator.add_line - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + Overrides the Paginator.add_line from inside discord.ext.commands in order to allow + configuration of the maximum number of lines per page. Parameters ----------- @@ -75,8 +72,9 @@ class LinePaginator(Paginator): Raises ------ RuntimeError - The line was too big for the current :attr:`max_size`. + The line was too big for the current `max_size`. """ + if len(line) > self.max_size - len(self.prefix) - 2: raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) @@ -98,21 +96,26 @@ class LinePaginator(Paginator): @classmethod async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, - empty: bool = True, restrict_to_user: User = None, timeout: int = 300, - footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False): + prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, + max_size: int = 500, empty: bool = True, restrict_to_user: User = None, + timeout: int = 300, footer_text: str = None, url: str = None, + exception_on_empty_embed: bool = False): """ - Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to - switch page, or to finish with pagination. - When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. Pagination will also be removed automatically - if no reaction is added for five minutes (300 seconds). + Use a paginator and set of reactions to provide pagination over a set of lines. + + The reactions are used to switch page, or to finish with pagination. + + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. + These reactions may be used to change page, or to remove pagination from the message. + Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) >>> await LinePaginator.paginate( ... (line for line in lines), ... ctx, embed ... ) + :param lines: The lines to be paginated :param ctx: Current context object :param embed: A pre-configured embed to be used as a template for each page @@ -129,9 +132,7 @@ class LinePaginator(Paginator): """ def event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" no_restrictions = ( # Pagination is not restricted @@ -301,6 +302,7 @@ class LinePaginator(Paginator): class ImagePaginator(Paginator): """ Helper class that paginates images for embeds in messages. + Close resemblance to LinePaginator, except focuses on images over text. Refer to ImagePaginator.paginate for documentation on how to use. @@ -314,7 +316,8 @@ class ImagePaginator(Paginator): def add_line(self, line: str = '', *, empty: bool = False) -> None: """ - Adds a line to each page, usually just 1 line in this context + Adds a line to each page, usually just 1 line in this context. + :param line: str to be page content / title :param empty: if there should be new lines between entries """ @@ -328,7 +331,8 @@ class ImagePaginator(Paginator): def add_image(self, image: str = None) -> None: """ - Adds an image to a page + Adds an image to a page. + :param image: image url to be appended """ @@ -339,16 +343,14 @@ class ImagePaginator(Paginator): prefix: str = "", suffix: str = "", timeout: int = 300, exception_on_empty_embed: bool = False): """ - Use a paginator and set of reactions to provide - pagination over a set of title/image pairs.The reactions are - used to switch page, or to finish with pagination. + Use a paginator and set of reactions to provide pagination over a set of title/image pairs. - When used, this will send a message using `ctx.send()` and - apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. + The reactions are used to switch page, or to finish with pagination. - Note: Pagination will be removed automatically - if no reaction is added for five minutes (300 seconds). + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. + These reactions may be used to change page, or to remove pagination from the message. + + Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -366,7 +368,8 @@ class ImagePaginator(Paginator): def check_event(reaction_: Reaction, member: Member) -> bool: """ - Checks each reaction added, if it matches our conditions pass the wait_for + Checks each reaction added, if it matches our conditions pass the wait_for. + :param reaction_: reaction added :param member: reaction added by member """ diff --git a/bot/resources/easter/april_fools_vids.json b/bot/resources/easter/april_fools_vids.json new file mode 100644 index 00000000..dfc01b7b --- /dev/null +++ b/bot/resources/easter/april_fools_vids.json @@ -0,0 +1,125 @@ +{ + "google": [ + { + "title": "Introducing Bad Joke Detector", + "link": "https://youtu.be/OYcv406J_J4" + }, + { + "title": "Introducing Google Cloud Hummus API - Find your Hummus!", + "link": "https://youtu.be/0_5X6N6DHyk" + }, + { + "title": "Introducing Google Play for Pets", + "link": "https://youtu.be/UmJ2NBHXTqo" + }, + { + "title": "Haptic Helpers: bringing you to your senses", + "link": "https://youtu.be/3MA6_21nka8" + }, + { + "title": "Introducing Google Gnome", + "link": "https://youtu.be/vNOllWX-2aE" + }, + { + "title": "Introducing Google Wind", + "link": "https://youtu.be/QAwL0O5nXe0" + }, + { + "title": "Experience YouTube in #SnoopaVision", + "link": "https://youtu.be/DPEJB-FCItk" + }, + { + "title": "Introducing the self-driving bicycle in the Netherlands", + "link": "https://youtu.be/LSZPNwZex9s" + }, + { + "title": "Android Developer Story: The Guardian goes galactic with Android and Google Play", + "link": "https://youtu.be/dFrgNiweQDk" + }, + { + "title": "Introducing new delivery technology from Google Express", + "link": "https://youtu.be/F0F6SnbqUcE" + }, + { + "title": "Google Cardboard Plastic", + "link": "https://youtu.be/VkOuShXpoKc" + }, + { + "title": "Google Photos: Search your photos by emoji", + "link": "https://youtu.be/HQtGFBbwKEk" + }, + { + "title": "Introducing Google Actual Cloud Platform", + "link": "https://youtu.be/Cp10_PygJ4o" + }, + { + "title": "Introducing Dial-Up mode", + "link": "https://youtu.be/XTTtkisylQw" + }, + { + "title": "Smartbox by Inbox: the mailbox of tomorrow, today", + "link": "https://youtu.be/hydLZJXG3Tk" + }, + { + "title": "Introducing Coffee to the Home", + "link": "https://youtu.be/U2JBFlW--UU" + }, + { + "title": "Chrome for Android and iOS: Emojify the Web", + "link": "https://youtu.be/G3NXNnoGr3Y" + }, + { + "title": "Google Maps: Pokémon Challenge", + "link": "https://youtu.be/4YMD6xELI_k" + }, + { + "title": "Introducing Google Fiber to the Pole", + "link": "https://youtu.be/qcgWRpQP6ds" + }, + { + "title": "Introducing Gmail Blue", + "link": "https://youtu.be/Zr4JwPb99qU" + }, + { + "title": "Introducing Google Nose", + "link": "https://youtu.be/VFbYadm_mrw" + }, + { + "title": "Explore Treasure Mode with Google Maps", + "link": "https://youtu.be/_qFFHC0eIUc" + }, + { + "title": "YouTube's ready to select a winner", + "link": "https://youtu.be/H542nLTTbu0" + }, + { + "title": "A word about Gmail Tap", + "link": "https://youtu.be/Je7Xq9tdCJc" + }, + { + "title": "Introducing the Google Fiber Bar", + "link": "https://youtu.be/re0VRK6ouwI" + }, + { + "title": "Introducing Gmail Tap", + "link": "https://youtu.be/1KhZKNZO8mQ" + }, + { + "title": "Chrome Multitask Mode", + "link": "https://youtu.be/UiLSiqyDf4Y" + }, + { + "title": "Google Maps 8-bit for NES", + "link": "https://youtu.be/rznYifPHxDg" + }, + { + "title": "Being a Google Autocompleter", + "link": "https://youtu.be/blB_X38YSxQ" + }, + { + "title": "Introducing Gmail Motion", + "link": "https://youtu.be/Bu927_ul_X0" + } + ] + +}
\ No newline at end of file diff --git a/bot/resources/easter/chocolate_bunny.png b/bot/resources/easter/chocolate_bunny.png Binary files differnew file mode 100644 index 00000000..6b25aa5a --- /dev/null +++ b/bot/resources/easter/chocolate_bunny.png diff --git a/bot/resources/easter/easter_eggs/design1.png b/bot/resources/easter/easter_eggs/design1.png Binary files differnew file mode 100644 index 00000000..d887c590 --- /dev/null +++ b/bot/resources/easter/easter_eggs/design1.png diff --git a/bot/resources/easter/easter_eggs/design2.png b/bot/resources/easter/easter_eggs/design2.png Binary files differnew file mode 100644 index 00000000..c4fff644 --- /dev/null +++ b/bot/resources/easter/easter_eggs/design2.png diff --git a/bot/resources/easter/easter_eggs/design3.png b/bot/resources/easter/easter_eggs/design3.png Binary files differnew file mode 100644 index 00000000..803bc1e3 --- /dev/null +++ b/bot/resources/easter/easter_eggs/design3.png diff --git a/bot/resources/easter/easter_eggs/design4.png b/bot/resources/easter/easter_eggs/design4.png Binary files differnew file mode 100644 index 00000000..38e6a83f --- /dev/null +++ b/bot/resources/easter/easter_eggs/design4.png diff --git a/bot/resources/easter/easter_eggs/design5.png b/bot/resources/easter/easter_eggs/design5.png Binary files differnew file mode 100644 index 00000000..56662c26 --- /dev/null +++ b/bot/resources/easter/easter_eggs/design5.png diff --git a/bot/resources/easter/easter_eggs/design6.png b/bot/resources/easter/easter_eggs/design6.png Binary files differnew file mode 100644 index 00000000..5372439a --- /dev/null +++ b/bot/resources/easter/easter_eggs/design6.png diff --git a/bot/resources/easter/egghead_questions.json b/bot/resources/easter/egghead_questions.json new file mode 100644 index 00000000..e4e21ebe --- /dev/null +++ b/bot/resources/easter/egghead_questions.json @@ -0,0 +1,181 @@ +[ + { + "question": "Where did the idea of the Easter Bunny originate?", + "answers": [ + "Russia", + "The United States", + "The UK", + "Germany" + ], + "correct_answer": 3 + }, + { + "question": "The Easter Bunny was originally going to be a...", + "answers": [ + "hare", + "possum", + "cat", + "dove" + ], + "correct_answer": 0 + }, + { + "question": "Which of the following is NOT a movie about Easter?", + "answers": [ + "Winnie the Pooh - Springtime with Roo", + "It's a Wonderful Life", + "The Passion of the Christ", + "Here Comes Peter Cottontail" + ], + "correct_answer": 1 + }, + { + "question": "In Australia, what animal is used instead of the Easter Bunny?", + "answers": [ + "kangaroo", + "wombat", + "koala", + "bilby" + ], + "correct_answer": 3 + }, + { + "question": "When was the first Earth Day?", + "answers": [ + "1982", + "2003", + "1999", + "1970" + ], + "correct_answer": 2 + }, + { + "question": "Who is considered to be the founder of Earth Day?", + "answers": [ + "President Jimmy Carter", + "President John F. Kennedy", + "Vice President Al Gore", + "Senator Gaylord Nelson" + ], + "correct_answer": 3 + }, + { + "question": "Approximately how many countries participated in Earth Day 2000?", + "answers": [ + "60", + "140", + "180", + "240" + ], + "correct_answer": 2 + }, + { + "question": "As Earth Day is this month, how old is the Earth?", + "answers": [ + "4.5 billion years old", + "5 million years old", + "10 billion years old", + "6.7 billion years old" + ], + "correct_answer": 0 + }, + { + "question": "As a celebration of Earth Day, what is the percentage of Oxygen in the Earth's atmosphere?", + "answers": [ + "18%", + "21%", + "25%", + "31%" + ], + "correct_answer": 1 + }, + { + "question": "In what year did Google begin its tradition of April Fools Jokes?", + "answers": [ + "1997", + "2000", + "2003", + "2007" + ], + "correct_answer": 1 + }, + { + "question": "Which type of chocolate is the most healthy?", + "answers": [ + "Dark", + "White", + "Milk" + ], + "correct_answer": 0 + }, + { + "question": "How many bars of milk chocolate would you have to eat to get the same amount of caffeine as in one cup of coffee?", + "answers": [ + "3", + "9", + "14", + "20" + ], + "correct_answer": 2 + }, + { + "question": "Aztecs used to use one of the ingedients of chocolate, cocoa beans, as...", + "answers": [ + "currency", + "medicine", + "dye", + "fertilizer" + ], + "correct_answer": 0 + }, + { + "question": "Which European country was the first to enjoy chocolate?", + "answers": [ + "France", + "Spain", + "England", + "Switzerland" + ], + "correct_answer": 1 + }, + { + "question": "The first European Chocolate Shop opened in what city in 1657?", + "answers": [ + "Paris, France", + "Madrid, Spain", + "Zürich, Switzerland", + "London, England" + ], + "correct_answer": 3 + }, + { + "question": "On average, how many eggs does a hen lay in a year?", + "answers": [ + "Between 200-230", + "Between 250-270", + "Between 300-330", + "Between 370-400" + ], + "correct_answer": 1 + }, + { + "question": "What determines the colour of an egg yolk?", + "answers": [ + "The size of the hen", + "The age of a hen", + "The diet of a hen", + "The colour of a hen's feathers" + ], + "correct_answer": 2 + }, + { + "question": "What country produces the most eggs in a year?", + "answers": [ + "China", + "India", + "The United States", + "Japan" + ], + "correct_answer": 0 + } +]
\ No newline at end of file diff --git a/bot/resources/easter/starter.json b/bot/resources/easter/starter.json new file mode 100644 index 00000000..31e2cbc9 --- /dev/null +++ b/bot/resources/easter/starter.json @@ -0,0 +1,24 @@ +{ + "starters": [ + "What is your favourite Easter candy or treat?", + "What is your earliest memory of Easter?", + "What is the title of the last book you read?", + "What is better: Milk, Dark or White chocolate?", + "What is your favourite holiday?", + "If you could have any superpower, what would it be?", + "Name one thing you like about a person to your right.", + "If you could be anyone else for one day, who would it be?", + "What Easter tradition do you enjoy most?", + "What is the best gift you've been given?", + "Name one famous person you would like to have at your easter dinner.", + "What was the last movie you saw in a cinema?", + "What is your favourite food?", + "If you could travel anywhere in the world, where would you go?", + "Tell us 5 things you do well.", + "What is your favourite place that you have visited?", + "What is your favourite color?", + "If you had $100 bill in your Easter Basket, what would you do with it?", + "What would you do if you know you could succeed at anything you chose to do?", + "If you could take only three things from your house, what would they be?" + ] +} diff --git a/bot/resources/easter/traditions.json b/bot/resources/easter/traditions.json new file mode 100644 index 00000000..f9dd6d81 --- /dev/null +++ b/bot/resources/easter/traditions.json @@ -0,0 +1,13 @@ +{"England": "Easter in England is celebrated through the exchange of Easter Eggs and other gifts like clothes, chocolates or holidays packages. Easter bonnets or baskets are also made that have fillings like daffodils in them.", +"Haiti": "In Haiti, kids have the freedom to spend Good Friday playing outdoors. On this day colourful kites fill the sky and children run long distances, often barefoot, trying to get their kite higher than their friends.", +"Indonesia": "Slightly unconventional, but kids in Indonesia celebrate Easter with a tooth brushing competition!", +"Ethipoia": "In Ethiopia, Easter is called Fasika and marks the end of a 55-day fast during which Christians have only eaten one vegetarian meal a day. Ethiopians will often break their fast after church by eating injera (a type of bread) or teff pancakes, made from grass flour.", +"El Salvador": "On Good Friday communities make rug-like paintings on the streets with sand and sawdust. These later become the path for processions and main avenues and streets are closed", +"Ghana": "Ghanaians dress in certain colours to mark the different days of Easter. On Good Friday, depending on the church denomination, men and women will either dress in dark mourning clothes or bright colours. On Easter Sunday everyone wears white.", +"Kenya": "On Easter Sunday, kids in Kenya look forward to a sumptuous Easter meal after church (Easter services are known to last for three hours!). Children share Nyama Choma (roasted meat) and have a soft drink with their meal!", +"Guatemala": "In Guatemala, Easter customs include a large, colourful celebration marked by countless processions. The main roads are closed, and the sound of music rings through the streets. Special food is prepared such as curtido (a diced vegetable mix which is cooked in vinegar to achieve a sour taste), fish, eggs, chickpeas, fruit mix, pumpkin, pacaya palm and spondias fruit (a Spanish version of a plum.)", +"Germany": "In Germany, Easter is known by the name of Ostern. Easter holidays for children last for about three weeks. Good Friday, Easter Saturday and Easter Sunday are the days when people do not work at all.", +"Mexico": "Semana Santa and Pascua (two separate observances) form a part of Easter celebrations in Mexico. Semana Santa stands for the entire Holy Week, from Palm Sunday to Easter Saturday, whereas the Pascua is the observance of the period from the Resurrection Sunday to the following Saturday.", +"Poland": "They shape the Easter Butter Lamb (Baranek Wielkanocyny) from a chunk of butter. They attempt to make it look like a fluffy lamb!", +"Greece": "They burn an effigy of Judas Iscariot, the betrayer of Jesus, sometimes is done as part of a Passion Play! It is hung by the neck and then burnt.", +"Philippines": "Some Christians put themselves through the same pain that Christ endured, they have someone naile them to a cross and put a crown of thornes on their head."} diff --git a/bot/resources/evergreen/html_colours.json b/bot/resources/evergreen/html_colours.json new file mode 100644 index 00000000..086083d6 --- /dev/null +++ b/bot/resources/evergreen/html_colours.json @@ -0,0 +1,150 @@ +{ + "aliceblue": "0xf0f8ff", + "antiquewhite": "0xfaebd7", + "aqua": "0x00ffff", + "aquamarine": "0x7fffd4", + "azure": "0xf0ffff", + "beige": "0xf5f5dc", + "bisque": "0xffe4c4", + "black": "0x000000", + "blanchedalmond": "0xffebcd", + "blue": "0x0000ff", + "blueviolet": "0x8a2be2", + "brown": "0xa52a2a", + "burlywood": "0xdeb887", + "cadetblue": "0x5f9ea0", + "chartreuse": "0x7fff00", + "chocolate": "0xd2691e", + "coral": "0xff7f50", + "cornflowerblue": "0x6495ed", + "cornsilk": "0xfff8dc", + "crimson": "0xdc143c", + "cyan": "0x00ffff", + "darkblue": "0x00008b", + "darkcyan": "0x008b8b", + "darkgoldenrod": "0xb8860b", + "darkgray": "0xa9a9a9", + "darkgreen": "0x006400", + "darkgrey": "0xa9a9a9", + "darkkhaki": "0xbdb76b", + "darkmagenta": "0x8b008b", + "darkolivegreen": "0x556b2f", + "darkorange": "0xff8c00", + "darkorchid": "0x9932cc", + "darkred": "0x8b0000", + "darksalmon": "0xe9967a", + "darkseagreen": "0x8fbc8f", + "darkslateblue": "0x483d8b", + "darkslategray": "0x2f4f4f", + "darkslategrey": "0x2f4f4f", + "darkturquoise": "0x00ced1", + "darkviolet": "0x9400d3", + "deeppink": "0xff1493", + "deepskyblue": "0x00bfff", + "dimgray": "0x696969", + "dimgrey": "0x696969", + "dodgerblue": "0x1e90ff", + "firebrick": "0xb22222", + "floralwhite": "0xfffaf0", + "forestgreen": "0x228b22", + "fuchsia": "0xff00ff", + "gainsboro": "0xdcdcdc", + "ghostwhite": "0xf8f8ff", + "goldenrod": "0xdaa520", + "gold": "0xffd700", + "gray": "0x808080", + "green": "0x008000", + "greenyellow": "0xadff2f", + "grey": "0x808080", + "honeydew": "0xf0fff0", + "hotpink": "0xff69b4", + "indianred": "0xcd5c5c", + "indigo": "0x4b0082", + "ivory": "0xfffff0", + "khaki": "0xf0e68c", + "lavenderblush": "0xfff0f5", + "lavender": "0xe6e6fa", + "lawngreen": "0x7cfc00", + "lemonchiffon": "0xfffacd", + "lightblue": "0xadd8e6", + "lightcoral": "0xf08080", + "lightcyan": "0xe0ffff", + "lightgoldenrodyellow": "0xfafad2", + "lightgray": "0xd3d3d3", + "lightgreen": "0x90ee90", + "lightgrey": "0xd3d3d3", + "lightpink": "0xffb6c1", + "lightsalmon": "0xffa07a", + "lightseagreen": "0x20b2aa", + "lightskyblue": "0x87cefa", + "lightslategray": "0x778899", + "lightslategrey": "0x778899", + "lightsteelblue": "0xb0c4de", + "lightyellow": "0xffffe0", + "lime": "0x00ff00", + "limegreen": "0x32cd32", + "linen": "0xfaf0e6", + "magenta": "0xff00ff", + "maroon": "0x800000", + "mediumaquamarine": "0x66cdaa", + "mediumblue": "0x0000cd", + "mediumorchid": "0xba55d3", + "mediumpurple": "0x9370db", + "mediumseagreen": "0x3cb371", + "mediumslateblue": "0x7b68ee", + "mediumspringgreen": "0x00fa9a", + "mediumturquoise": "0x48d1cc", + "mediumvioletred": "0xc71585", + "midnightblue": "0x191970", + "mintcream": "0xf5fffa", + "mistyrose": "0xffe4e1", + "moccasin": "0xffe4b5", + "navajowhite": "0xffdead", + "navy": "0x000080", + "oldlace": "0xfdf5e6", + "olive": "0x808000", + "olivedrab": "0x6b8e23", + "orange": "0xffa500", + "orangered": "0xff4500", + "orchid": "0xda70d6", + "palegoldenrod": "0xeee8aa", + "palegreen": "0x98fb98", + "paleturquoise": "0xafeeee", + "palevioletred": "0xdb7093", + "papayawhip": "0xffefd5", + "peachpuff": "0xffdab9", + "peru": "0xcd853f", + "pink": "0xffc0cb", + "plum": "0xdda0dd", + "powderblue": "0xb0e0e6", + "purple": "0x800080", + "rebeccapurple": "0x663399", + "red": "0xff0000", + "rosybrown": "0xbc8f8f", + "royalblue": "0x4169e1", + "saddlebrown": "0x8b4513", + "salmon": "0xfa8072", + "sandybrown": "0xf4a460", + "seagreen": "0x2e8b57", + "seashell": "0xfff5ee", + "sienna": "0xa0522d", + "silver": "0xc0c0c0", + "skyblue": "0x87ceeb", + "slateblue": "0x6a5acd", + "slategray": "0x708090", + "slategrey": "0x708090", + "snow": "0xfffafa", + "springgreen": "0x00ff7f", + "steelblue": "0x4682b4", + "tan": "0xd2b48c", + "teal": "0x008080", + "thistle": "0xd8bfd8", + "tomato": "0xff6347", + "turquoise": "0x40e0d0", + "violet": "0xee82ee", + "wheat": "0xf5deb3", + "white": "0xffffff", + "whitesmoke": "0xf5f5f5", + "yellow": "0xffff00", + "yellowgreen": "0x9acd32" +} diff --git a/bot/resources/evergreen/xkcd_colours.json b/bot/resources/evergreen/xkcd_colours.json new file mode 100644 index 00000000..3feeb639 --- /dev/null +++ b/bot/resources/evergreen/xkcd_colours.json @@ -0,0 +1,951 @@ +{ + "cloudy blue": "0xacc2d9", + "dark pastel green": "0x56ae57", + "dust": "0xb2996e", + "electric lime": "0xa8ff04", + "fresh green": "0x69d84f", + "light eggplant": "0x894585", + "nasty green": "0x70b23f", + "really light blue": "0xd4ffff", + "tea": "0x65ab7c", + "warm purple": "0x952e8f", + "yellowish tan": "0xfcfc81", + "cement": "0xa5a391", + "dark grass green": "0x388004", + "dusty teal": "0x4c9085", + "grey teal": "0x5e9b8a", + "macaroni and cheese": "0xefb435", + "pinkish tan": "0xd99b82", + "spruce": "0x0a5f38", + "strong blue": "0x0c06f7", + "toxic green": "0x61de2a", + "windows blue": "0x3778bf", + "blue blue": "0x2242c7", + "blue with a hint of purple": "0x533cc6", + "booger": "0x9bb53c", + "bright sea green": "0x05ffa6", + "dark green blue": "0x1f6357", + "deep turquoise": "0x017374", + "green teal": "0x0cb577", + "strong pink": "0xff0789", + "bland": "0xafa88b", + "deep aqua": "0x08787f", + "lavender pink": "0xdd85d7", + "light moss green": "0xa6c875", + "light seafoam green": "0xa7ffb5", + "olive yellow": "0xc2b709", + "pig pink": "0xe78ea5", + "deep lilac": "0x966ebd", + "desert": "0xccad60", + "dusty lavender": "0xac86a8", + "purpley grey": "0x947e94", + "purply": "0x983fb2", + "candy pink": "0xff63e9", + "light pastel green": "0xb2fba5", + "boring green": "0x63b365", + "kiwi green": "0x8ee53f", + "light grey green": "0xb7e1a1", + "orange pink": "0xff6f52", + "tea green": "0xbdf8a3", + "very light brown": "0xd3b683", + "egg shell": "0xfffcc4", + "eggplant purple": "0x430541", + "powder pink": "0xffb2d0", + "reddish grey": "0x997570", + "baby shit brown": "0xad900d", + "liliac": "0xc48efd", + "stormy blue": "0x507b9c", + "ugly brown": "0x7d7103", + "custard": "0xfffd78", + "darkish pink": "0xda467d", + "deep brown": "0x410200", + "greenish beige": "0xc9d179", + "manilla": "0xfffa86", + "off blue": "0x5684ae", + "battleship grey": "0x6b7c85", + "browny green": "0x6f6c0a", + "bruise": "0x7e4071", + "kelley green": "0x009337", + "sickly yellow": "0xd0e429", + "sunny yellow": "0xfff917", + "azul": "0x1d5dec", + "darkgreen": "0x054907", + "green/yellow": "0xb5ce08", + "lichen": "0x8fb67b", + "light light green": "0xc8ffb0", + "pale gold": "0xfdde6c", + "sun yellow": "0xffdf22", + "tan green": "0xa9be70", + "burple": "0x6832e3", + "butterscotch": "0xfdb147", + "toupe": "0xc7ac7d", + "dark cream": "0xfff39a", + "indian red": "0x850e04", + "light lavendar": "0xefc0fe", + "poison green": "0x40fd14", + "baby puke green": "0xb6c406", + "bright yellow green": "0x9dff00", + "charcoal grey": "0x3c4142", + "squash": "0xf2ab15", + "cinnamon": "0xac4f06", + "light pea green": "0xc4fe82", + "radioactive green": "0x2cfa1f", + "raw sienna": "0x9a6200", + "baby purple": "0xca9bf7", + "cocoa": "0x875f42", + "light royal blue": "0x3a2efe", + "orangeish": "0xfd8d49", + "rust brown": "0x8b3103", + "sand brown": "0xcba560", + "swamp": "0x698339", + "tealish green": "0x0cdc73", + "burnt siena": "0xb75203", + "camo": "0x7f8f4e", + "dusk blue": "0x26538d", + "fern": "0x63a950", + "old rose": "0xc87f89", + "pale light green": "0xb1fc99", + "peachy pink": "0xff9a8a", + "rosy pink": "0xf6688e", + "light bluish green": "0x76fda8", + "light bright green": "0x53fe5c", + "light neon green": "0x4efd54", + "light seafoam": "0xa0febf", + "tiffany blue": "0x7bf2da", + "washed out green": "0xbcf5a6", + "browny orange": "0xca6b02", + "nice blue": "0x107ab0", + "sapphire": "0x2138ab", + "greyish teal": "0x719f91", + "orangey yellow": "0xfdb915", + "parchment": "0xfefcaf", + "straw": "0xfcf679", + "very dark brown": "0x1d0200", + "terracota": "0xcb6843", + "ugly blue": "0x31668a", + "clear blue": "0x247afd", + "creme": "0xffffb6", + "foam green": "0x90fda9", + "grey/green": "0x86a17d", + "light gold": "0xfddc5c", + "seafoam blue": "0x78d1b6", + "topaz": "0x13bbaf", + "violet pink": "0xfb5ffc", + "wintergreen": "0x20f986", + "yellow tan": "0xffe36e", + "dark fuchsia": "0x9d0759", + "indigo blue": "0x3a18b1", + "light yellowish green": "0xc2ff89", + "pale magenta": "0xd767ad", + "rich purple": "0x720058", + "sunflower yellow": "0xffda03", + "green/blue": "0x01c08d", + "leather": "0xac7434", + "racing green": "0x014600", + "vivid purple": "0x9900fa", + "dark royal blue": "0x02066f", + "hazel": "0x8e7618", + "muted pink": "0xd1768f", + "booger green": "0x96b403", + "canary": "0xfdff63", + "cool grey": "0x95a3a6", + "dark taupe": "0x7f684e", + "darkish purple": "0x751973", + "true green": "0x089404", + "coral pink": "0xff6163", + "dark sage": "0x598556", + "dark slate blue": "0x214761", + "flat blue": "0x3c73a8", + "mushroom": "0xba9e88", + "rich blue": "0x021bf9", + "dirty purple": "0x734a65", + "greenblue": "0x23c48b", + "icky green": "0x8fae22", + "light khaki": "0xe6f2a2", + "warm blue": "0x4b57db", + "dark hot pink": "0xd90166", + "deep sea blue": "0x015482", + "carmine": "0x9d0216", + "dark yellow green": "0x728f02", + "pale peach": "0xffe5ad", + "plum purple": "0x4e0550", + "golden rod": "0xf9bc08", + "neon red": "0xff073a", + "old pink": "0xc77986", + "very pale blue": "0xd6fffe", + "blood orange": "0xfe4b03", + "grapefruit": "0xfd5956", + "sand yellow": "0xfce166", + "clay brown": "0xb2713d", + "dark blue grey": "0x1f3b4d", + "flat green": "0x699d4c", + "light green blue": "0x56fca2", + "warm pink": "0xfb5581", + "dodger blue": "0x3e82fc", + "gross green": "0xa0bf16", + "ice": "0xd6fffa", + "metallic blue": "0x4f738e", + "pale salmon": "0xffb19a", + "sap green": "0x5c8b15", + "algae": "0x54ac68", + "bluey grey": "0x89a0b0", + "greeny grey": "0x7ea07a", + "highlighter green": "0x1bfc06", + "light light blue": "0xcafffb", + "light mint": "0xb6ffbb", + "raw umber": "0xa75e09", + "vivid blue": "0x152eff", + "deep lavender": "0x8d5eb7", + "dull teal": "0x5f9e8f", + "light greenish blue": "0x63f7b4", + "mud green": "0x606602", + "pinky": "0xfc86aa", + "red wine": "0x8c0034", + "shit green": "0x758000", + "tan brown": "0xab7e4c", + "darkblue": "0x030764", + "rosa": "0xfe86a4", + "lipstick": "0xd5174e", + "pale mauve": "0xfed0fc", + "claret": "0x680018", + "dandelion": "0xfedf08", + "orangered": "0xfe420f", + "poop green": "0x6f7c00", + "ruby": "0xca0147", + "dark": "0x1b2431", + "greenish turquoise": "0x00fbb0", + "pastel red": "0xdb5856", + "piss yellow": "0xddd618", + "bright cyan": "0x41fdfe", + "dark coral": "0xcf524e", + "algae green": "0x21c36f", + "darkish red": "0xa90308", + "reddy brown": "0x6e1005", + "blush pink": "0xfe828c", + "camouflage green": "0x4b6113", + "lawn green": "0x4da409", + "putty": "0xbeae8a", + "vibrant blue": "0x0339f8", + "dark sand": "0xa88f59", + "purple/blue": "0x5d21d0", + "saffron": "0xfeb209", + "twilight": "0x4e518b", + "warm brown": "0x964e02", + "bluegrey": "0x85a3b2", + "bubble gum pink": "0xff69af", + "duck egg blue": "0xc3fbf4", + "greenish cyan": "0x2afeb7", + "petrol": "0x005f6a", + "royal": "0x0c1793", + "butter": "0xffff81", + "dusty orange": "0xf0833a", + "off yellow": "0xf1f33f", + "pale olive green": "0xb1d27b", + "orangish": "0xfc824a", + "leaf": "0x71aa34", + "light blue grey": "0xb7c9e2", + "dried blood": "0x4b0101", + "lightish purple": "0xa552e6", + "rusty red": "0xaf2f0d", + "lavender blue": "0x8b88f8", + "light grass green": "0x9af764", + "light mint green": "0xa6fbb2", + "sunflower": "0xffc512", + "velvet": "0x750851", + "brick orange": "0xc14a09", + "lightish red": "0xfe2f4a", + "pure blue": "0x0203e2", + "twilight blue": "0x0a437a", + "violet red": "0xa50055", + "yellowy brown": "0xae8b0c", + "carnation": "0xfd798f", + "muddy yellow": "0xbfac05", + "dark seafoam green": "0x3eaf76", + "deep rose": "0xc74767", + "dusty red": "0xb9484e", + "grey/blue": "0x647d8e", + "lemon lime": "0xbffe28", + "purple/pink": "0xd725de", + "brown yellow": "0xb29705", + "purple brown": "0x673a3f", + "wisteria": "0xa87dc2", + "banana yellow": "0xfafe4b", + "lipstick red": "0xc0022f", + "water blue": "0x0e87cc", + "brown grey": "0x8d8468", + "vibrant purple": "0xad03de", + "baby green": "0x8cff9e", + "barf green": "0x94ac02", + "eggshell blue": "0xc4fff7", + "sandy yellow": "0xfdee73", + "cool green": "0x33b864", + "pale": "0xfff9d0", + "blue/grey": "0x758da3", + "hot magenta": "0xf504c9", + "greyblue": "0x77a1b5", + "purpley": "0x8756e4", + "baby shit green": "0x889717", + "brownish pink": "0xc27e79", + "dark aquamarine": "0x017371", + "diarrhea": "0x9f8303", + "light mustard": "0xf7d560", + "pale sky blue": "0xbdf6fe", + "turtle green": "0x75b84f", + "bright olive": "0x9cbb04", + "dark grey blue": "0x29465b", + "greeny brown": "0x696006", + "lemon green": "0xadf802", + "light periwinkle": "0xc1c6fc", + "seaweed green": "0x35ad6b", + "sunshine yellow": "0xfffd37", + "ugly purple": "0xa442a0", + "medium pink": "0xf36196", + "puke brown": "0x947706", + "very light pink": "0xfff4f2", + "viridian": "0x1e9167", + "bile": "0xb5c306", + "faded yellow": "0xfeff7f", + "very pale green": "0xcffdbc", + "vibrant green": "0x0add08", + "bright lime": "0x87fd05", + "spearmint": "0x1ef876", + "light aquamarine": "0x7bfdc7", + "light sage": "0xbcecac", + "yellowgreen": "0xbbf90f", + "baby poo": "0xab9004", + "dark seafoam": "0x1fb57a", + "deep teal": "0x00555a", + "heather": "0xa484ac", + "rust orange": "0xc45508", + "dirty blue": "0x3f829d", + "fern green": "0x548d44", + "bright lilac": "0xc95efb", + "weird green": "0x3ae57f", + "peacock blue": "0x016795", + "avocado green": "0x87a922", + "faded orange": "0xf0944d", + "grape purple": "0x5d1451", + "hot green": "0x25ff29", + "lime yellow": "0xd0fe1d", + "mango": "0xffa62b", + "shamrock": "0x01b44c", + "bubblegum": "0xff6cb5", + "purplish brown": "0x6b4247", + "vomit yellow": "0xc7c10c", + "pale cyan": "0xb7fffa", + "key lime": "0xaeff6e", + "tomato red": "0xec2d01", + "lightgreen": "0x76ff7b", + "merlot": "0x730039", + "night blue": "0x040348", + "purpleish pink": "0xdf4ec8", + "apple": "0x6ecb3c", + "baby poop green": "0x8f9805", + "green apple": "0x5edc1f", + "heliotrope": "0xd94ff5", + "yellow/green": "0xc8fd3d", + "almost black": "0x070d0d", + "cool blue": "0x4984b8", + "leafy green": "0x51b73b", + "mustard brown": "0xac7e04", + "dusk": "0x4e5481", + "dull brown": "0x876e4b", + "frog green": "0x58bc08", + "vivid green": "0x2fef10", + "bright light green": "0x2dfe54", + "fluro green": "0x0aff02", + "kiwi": "0x9cef43", + "seaweed": "0x18d17b", + "navy green": "0x35530a", + "ultramarine blue": "0x1805db", + "iris": "0x6258c4", + "pastel orange": "0xff964f", + "yellowish orange": "0xffab0f", + "perrywinkle": "0x8f8ce7", + "tealish": "0x24bca8", + "dark plum": "0x3f012c", + "pear": "0xcbf85f", + "pinkish orange": "0xff724c", + "midnight purple": "0x280137", + "light urple": "0xb36ff6", + "dark mint": "0x48c072", + "greenish tan": "0xbccb7a", + "light burgundy": "0xa8415b", + "turquoise blue": "0x06b1c4", + "ugly pink": "0xcd7584", + "sandy": "0xf1da7a", + "electric pink": "0xff0490", + "muted purple": "0x805b87", + "mid green": "0x50a747", + "greyish": "0xa8a495", + "neon yellow": "0xcfff04", + "banana": "0xffff7e", + "carnation pink": "0xff7fa7", + "tomato": "0xef4026", + "sea": "0x3c9992", + "muddy brown": "0x886806", + "turquoise green": "0x04f489", + "buff": "0xfef69e", + "fawn": "0xcfaf7b", + "muted blue": "0x3b719f", + "pale rose": "0xfdc1c5", + "dark mint green": "0x20c073", + "amethyst": "0x9b5fc0", + "blue/green": "0x0f9b8e", + "chestnut": "0x742802", + "sick green": "0x9db92c", + "pea": "0xa4bf20", + "rusty orange": "0xcd5909", + "stone": "0xada587", + "rose red": "0xbe013c", + "pale aqua": "0xb8ffeb", + "deep orange": "0xdc4d01", + "earth": "0xa2653e", + "mossy green": "0x638b27", + "grassy green": "0x419c03", + "pale lime green": "0xb1ff65", + "light grey blue": "0x9dbcd4", + "pale grey": "0xfdfdfe", + "asparagus": "0x77ab56", + "blueberry": "0x464196", + "purple red": "0x990147", + "pale lime": "0xbefd73", + "greenish teal": "0x32bf84", + "caramel": "0xaf6f09", + "deep magenta": "0xa0025c", + "light peach": "0xffd8b1", + "milk chocolate": "0x7f4e1e", + "ocher": "0xbf9b0c", + "off green": "0x6ba353", + "purply pink": "0xf075e6", + "lightblue": "0x7bc8f6", + "dusky blue": "0x475f94", + "golden": "0xf5bf03", + "light beige": "0xfffeb6", + "butter yellow": "0xfffd74", + "dusky purple": "0x895b7b", + "french blue": "0x436bad", + "ugly yellow": "0xd0c101", + "greeny yellow": "0xc6f808", + "orangish red": "0xf43605", + "shamrock green": "0x02c14d", + "orangish brown": "0xb25f03", + "tree green": "0x2a7e19", + "deep violet": "0x490648", + "gunmetal": "0x536267", + "blue/purple": "0x5a06ef", + "cherry": "0xcf0234", + "sandy brown": "0xc4a661", + "warm grey": "0x978a84", + "dark indigo": "0x1f0954", + "midnight": "0x03012d", + "bluey green": "0x2bb179", + "grey pink": "0xc3909b", + "soft purple": "0xa66fb5", + "blood": "0x770001", + "brown red": "0x922b05", + "medium grey": "0x7d7f7c", + "berry": "0x990f4b", + "poo": "0x8f7303", + "purpley pink": "0xc83cb9", + "light salmon": "0xfea993", + "snot": "0xacbb0d", + "easter purple": "0xc071fe", + "light yellow green": "0xccfd7f", + "dark navy blue": "0x00022e", + "drab": "0x828344", + "light rose": "0xffc5cb", + "rouge": "0xab1239", + "purplish red": "0xb0054b", + "slime green": "0x99cc04", + "baby poop": "0x937c00", + "irish green": "0x019529", + "pink/purple": "0xef1de7", + "dark navy": "0x000435", + "greeny blue": "0x42b395", + "light plum": "0x9d5783", + "pinkish grey": "0xc8aca9", + "dirty orange": "0xc87606", + "rust red": "0xaa2704", + "pale lilac": "0xe4cbff", + "orangey red": "0xfa4224", + "primary blue": "0x0804f9", + "kermit green": "0x5cb200", + "brownish purple": "0x76424e", + "murky green": "0x6c7a0e", + "wheat": "0xfbdd7e", + "very dark purple": "0x2a0134", + "bottle green": "0x044a05", + "watermelon": "0xfd4659", + "deep sky blue": "0x0d75f8", + "fire engine red": "0xfe0002", + "yellow ochre": "0xcb9d06", + "pumpkin orange": "0xfb7d07", + "pale olive": "0xb9cc81", + "light lilac": "0xedc8ff", + "lightish green": "0x61e160", + "carolina blue": "0x8ab8fe", + "mulberry": "0x920a4e", + "shocking pink": "0xfe02a2", + "auburn": "0x9a3001", + "bright lime green": "0x65fe08", + "celadon": "0xbefdb7", + "pinkish brown": "0xb17261", + "poo brown": "0x885f01", + "bright sky blue": "0x02ccfe", + "celery": "0xc1fd95", + "dirt brown": "0x836539", + "strawberry": "0xfb2943", + "dark lime": "0x84b701", + "copper": "0xb66325", + "medium brown": "0x7f5112", + "muted green": "0x5fa052", + "robin's egg": "0x6dedfd", + "bright aqua": "0x0bf9ea", + "bright lavender": "0xc760ff", + "ivory": "0xffffcb", + "very light purple": "0xf6cefc", + "light navy": "0x155084", + "pink red": "0xf5054f", + "olive brown": "0x645403", + "poop brown": "0x7a5901", + "mustard green": "0xa8b504", + "ocean green": "0x3d9973", + "very dark blue": "0x000133", + "dusty green": "0x76a973", + "light navy blue": "0x2e5a88", + "minty green": "0x0bf77d", + "adobe": "0xbd6c48", + "barney": "0xac1db8", + "jade green": "0x2baf6a", + "bright light blue": "0x26f7fd", + "light lime": "0xaefd6c", + "dark khaki": "0x9b8f55", + "orange yellow": "0xffad01", + "ocre": "0xc69c04", + "maize": "0xf4d054", + "faded pink": "0xde9dac", + "british racing green": "0x05480d", + "sandstone": "0xc9ae74", + "mud brown": "0x60460f", + "light sea green": "0x98f6b0", + "robin egg blue": "0x8af1fe", + "aqua marine": "0x2ee8bb", + "dark sea green": "0x11875d", + "soft pink": "0xfdb0c0", + "orangey brown": "0xb16002", + "cherry red": "0xf7022a", + "burnt yellow": "0xd5ab09", + "brownish grey": "0x86775f", + "camel": "0xc69f59", + "purplish grey": "0x7a687f", + "marine": "0x042e60", + "greyish pink": "0xc88d94", + "pale turquoise": "0xa5fbd5", + "pastel yellow": "0xfffe71", + "bluey purple": "0x6241c7", + "canary yellow": "0xfffe40", + "faded red": "0xd3494e", + "sepia": "0x985e2b", + "coffee": "0xa6814c", + "bright magenta": "0xff08e8", + "mocha": "0x9d7651", + "ecru": "0xfeffca", + "purpleish": "0x98568d", + "cranberry": "0x9e003a", + "darkish green": "0x287c37", + "brown orange": "0xb96902", + "dusky rose": "0xba6873", + "melon": "0xff7855", + "sickly green": "0x94b21c", + "silver": "0xc5c9c7", + "purply blue": "0x661aee", + "purpleish blue": "0x6140ef", + "hospital green": "0x9be5aa", + "shit brown": "0x7b5804", + "mid blue": "0x276ab3", + "amber": "0xfeb308", + "easter green": "0x8cfd7e", + "soft blue": "0x6488ea", + "cerulean blue": "0x056eee", + "golden brown": "0xb27a01", + "bright turquoise": "0x0ffef9", + "red pink": "0xfa2a55", + "red purple": "0x820747", + "greyish brown": "0x7a6a4f", + "vermillion": "0xf4320c", + "russet": "0xa13905", + "steel grey": "0x6f828a", + "lighter purple": "0xa55af4", + "bright violet": "0xad0afd", + "prussian blue": "0x004577", + "slate green": "0x658d6d", + "dirty pink": "0xca7b80", + "dark blue green": "0x005249", + "pine": "0x2b5d34", + "yellowy green": "0xbff128", + "dark gold": "0xb59410", + "bluish": "0x2976bb", + "darkish blue": "0x014182", + "dull red": "0xbb3f3f", + "pinky red": "0xfc2647", + "bronze": "0xa87900", + "pale teal": "0x82cbb2", + "military green": "0x667c3e", + "barbie pink": "0xfe46a5", + "bubblegum pink": "0xfe83cc", + "pea soup green": "0x94a617", + "dark mustard": "0xa88905", + "shit": "0x7f5f00", + "medium purple": "0x9e43a2", + "very dark green": "0x062e03", + "dirt": "0x8a6e45", + "dusky pink": "0xcc7a8b", + "red violet": "0x9e0168", + "lemon yellow": "0xfdff38", + "pistachio": "0xc0fa8b", + "dull yellow": "0xeedc5b", + "dark lime green": "0x7ebd01", + "denim blue": "0x3b5b92", + "teal blue": "0x01889f", + "lightish blue": "0x3d7afd", + "purpley blue": "0x5f34e7", + "light indigo": "0x6d5acf", + "swamp green": "0x748500", + "brown green": "0x706c11", + "dark maroon": "0x3c0008", + "hot purple": "0xcb00f5", + "dark forest green": "0x002d04", + "faded blue": "0x658cbb", + "drab green": "0x749551", + "light lime green": "0xb9ff66", + "snot green": "0x9dc100", + "yellowish": "0xfaee66", + "light blue green": "0x7efbb3", + "bordeaux": "0x7b002c", + "light mauve": "0xc292a1", + "ocean": "0x017b92", + "marigold": "0xfcc006", + "muddy green": "0x657432", + "dull orange": "0xd8863b", + "steel": "0x738595", + "electric purple": "0xaa23ff", + "fluorescent green": "0x08ff08", + "yellowish brown": "0x9b7a01", + "blush": "0xf29e8e", + "soft green": "0x6fc276", + "bright orange": "0xff5b00", + "lemon": "0xfdff52", + "purple grey": "0x866f85", + "acid green": "0x8ffe09", + "pale lavender": "0xeecffe", + "violet blue": "0x510ac9", + "light forest green": "0x4f9153", + "burnt red": "0x9f2305", + "khaki green": "0x728639", + "cerise": "0xde0c62", + "faded purple": "0x916e99", + "apricot": "0xffb16d", + "dark olive green": "0x3c4d03", + "grey brown": "0x7f7053", + "green grey": "0x77926f", + "true blue": "0x010fcc", + "pale violet": "0xceaefa", + "periwinkle blue": "0x8f99fb", + "light sky blue": "0xc6fcff", + "blurple": "0x5539cc", + "green brown": "0x544e03", + "bluegreen": "0x017a79", + "bright teal": "0x01f9c6", + "brownish yellow": "0xc9b003", + "pea soup": "0x929901", + "forest": "0x0b5509", + "barney purple": "0xa00498", + "ultramarine": "0x2000b1", + "purplish": "0x94568c", + "puke yellow": "0xc2be0e", + "bluish grey": "0x748b97", + "dark periwinkle": "0x665fd1", + "dark lilac": "0x9c6da5", + "reddish": "0xc44240", + "light maroon": "0xa24857", + "dusty purple": "0x825f87", + "terra cotta": "0xc9643b", + "avocado": "0x90b134", + "marine blue": "0x01386a", + "teal green": "0x25a36f", + "slate grey": "0x59656d", + "lighter green": "0x75fd63", + "electric green": "0x21fc0d", + "dusty blue": "0x5a86ad", + "golden yellow": "0xfec615", + "bright yellow": "0xfffd01", + "light lavender": "0xdfc5fe", + "umber": "0xb26400", + "poop": "0x7f5e00", + "dark peach": "0xde7e5d", + "jungle green": "0x048243", + "eggshell": "0xffffd4", + "denim": "0x3b638c", + "yellow brown": "0xb79400", + "dull purple": "0x84597e", + "chocolate brown": "0x411900", + "wine red": "0x7b0323", + "neon blue": "0x04d9ff", + "dirty green": "0x667e2c", + "light tan": "0xfbeeac", + "ice blue": "0xd7fffe", + "cadet blue": "0x4e7496", + "dark mauve": "0x874c62", + "very light blue": "0xd5ffff", + "grey purple": "0x826d8c", + "pastel pink": "0xffbacd", + "very light green": "0xd1ffbd", + "dark sky blue": "0x448ee4", + "evergreen": "0x05472a", + "dull pink": "0xd5869d", + "aubergine": "0x3d0734", + "mahogany": "0x4a0100", + "reddish orange": "0xf8481c", + "deep green": "0x02590f", + "vomit green": "0x89a203", + "purple pink": "0xe03fd8", + "dusty pink": "0xd58a94", + "faded green": "0x7bb274", + "camo green": "0x526525", + "pinky purple": "0xc94cbe", + "pink purple": "0xdb4bda", + "brownish red": "0x9e3623", + "dark rose": "0xb5485d", + "mud": "0x735c12", + "brownish": "0x9c6d57", + "emerald green": "0x028f1e", + "pale brown": "0xb1916e", + "dull blue": "0x49759c", + "burnt umber": "0xa0450e", + "medium green": "0x39ad48", + "clay": "0xb66a50", + "light aqua": "0x8cffdb", + "light olive green": "0xa4be5c", + "brownish orange": "0xcb7723", + "dark aqua": "0x05696b", + "purplish pink": "0xce5dae", + "dark salmon": "0xc85a53", + "greenish grey": "0x96ae8d", + "jade": "0x1fa774", + "ugly green": "0x7a9703", + "dark beige": "0xac9362", + "emerald": "0x01a049", + "pale red": "0xd9544d", + "light magenta": "0xfa5ff7", + "sky": "0x82cafc", + "light cyan": "0xacfffc", + "yellow orange": "0xfcb001", + "reddish purple": "0x910951", + "reddish pink": "0xfe2c54", + "orchid": "0xc875c4", + "dirty yellow": "0xcdc50a", + "orange red": "0xfd411e", + "deep red": "0x9a0200", + "orange brown": "0xbe6400", + "cobalt blue": "0x030aa7", + "neon pink": "0xfe019a", + "rose pink": "0xf7879a", + "greyish purple": "0x887191", + "raspberry": "0xb00149", + "aqua green": "0x12e193", + "salmon pink": "0xfe7b7c", + "tangerine": "0xff9408", + "brownish green": "0x6a6e09", + "red brown": "0x8b2e16", + "greenish brown": "0x696112", + "pumpkin": "0xe17701", + "pine green": "0x0a481e", + "charcoal": "0x343837", + "baby pink": "0xffb7ce", + "cornflower": "0x6a79f7", + "blue violet": "0x5d06e9", + "chocolate": "0x3d1c02", + "greyish green": "0x82a67d", + "scarlet": "0xbe0119", + "green yellow": "0xc9ff27", + "dark olive": "0x373e02", + "sienna": "0xa9561e", + "pastel purple": "0xcaa0ff", + "terracotta": "0xca6641", + "aqua blue": "0x02d8e9", + "sage green": "0x88b378", + "blood red": "0x980002", + "deep pink": "0xcb0162", + "grass": "0x5cac2d", + "moss": "0x769958", + "pastel blue": "0xa2bffe", + "bluish green": "0x10a674", + "green blue": "0x06b48b", + "dark tan": "0xaf884a", + "greenish blue": "0x0b8b87", + "pale orange": "0xffa756", + "vomit": "0xa2a415", + "forrest green": "0x154406", + "dark lavender": "0x856798", + "dark violet": "0x34013f", + "purple blue": "0x632de9", + "dark cyan": "0x0a888a", + "olive drab": "0x6f7632", + "pinkish": "0xd46a7e", + "cobalt": "0x1e488f", + "neon purple": "0xbc13fe", + "light turquoise": "0x7ef4cc", + "apple green": "0x76cd26", + "dull green": "0x74a662", + "wine": "0x80013f", + "powder blue": "0xb1d1fc", + "off white": "0xffffe4", + "electric blue": "0x0652ff", + "dark turquoise": "0x045c5a", + "blue purple": "0x5729ce", + "azure": "0x069af3", + "bright red": "0xff000d", + "pinkish red": "0xf10c45", + "cornflower blue": "0x5170d7", + "light olive": "0xacbf69", + "grape": "0x6c3461", + "greyish blue": "0x5e819d", + "purplish blue": "0x601ef9", + "yellowish green": "0xb0dd16", + "greenish yellow": "0xcdfd02", + "medium blue": "0x2c6fbb", + "dusty rose": "0xc0737a", + "light violet": "0xd6b4fc", + "midnight blue": "0x020035", + "bluish purple": "0x703be7", + "red orange": "0xfd3c06", + "dark magenta": "0x960056", + "greenish": "0x40a368", + "ocean blue": "0x03719c", + "coral": "0xfc5a50", + "cream": "0xffffc2", + "reddish brown": "0x7f2b0a", + "burnt sienna": "0xb04e0f", + "brick": "0xa03623", + "sage": "0x87ae73", + "grey green": "0x789b73", + "white": "0xffffff", + "robin's egg blue": "0x98eff9", + "moss green": "0x658b38", + "steel blue": "0x5a7d9a", + "eggplant": "0x380835", + "light yellow": "0xfffe7a", + "leaf green": "0x5ca904", + "light grey": "0xd8dcd6", + "puke": "0xa5a502", + "pinkish purple": "0xd648d7", + "sea blue": "0x047495", + "pale purple": "0xb790d4", + "slate blue": "0x5b7c99", + "blue grey": "0x607c8e", + "hunter green": "0x0b4008", + "fuchsia": "0xed0dd9", + "crimson": "0x8c000f", + "pale yellow": "0xffff84", + "ochre": "0xbf9005", + "mustard yellow": "0xd2bd0a", + "light red": "0xff474c", + "cerulean": "0x0485d1", + "pale pink": "0xffcfdc", + "deep blue": "0x040273", + "rust": "0xa83c09", + "light teal": "0x90e4c1", + "slate": "0x516572", + "goldenrod": "0xfac205", + "dark yellow": "0xd5b60a", + "dark grey": "0x363737", + "army green": "0x4b5d16", + "grey blue": "0x6b8ba4", + "seafoam": "0x80f9ad", + "puce": "0xa57e52", + "spring green": "0xa9f971", + "dark orange": "0xc65102", + "sand": "0xe2ca76", + "pastel green": "0xb0ff9d", + "mint": "0x9ffeb0", + "light orange": "0xfdaa48", + "bright pink": "0xfe01b1", + "chartreuse": "0xc1f80a", + "deep purple": "0x36013f", + "dark brown": "0x341c02", + "taupe": "0xb9a281", + "pea green": "0x8eab12", + "puke green": "0x9aae07", + "kelly green": "0x02ab2e", + "seafoam green": "0x7af9ab", + "blue green": "0x137e6d", + "khaki": "0xaaa662", + "burgundy": "0x610023", + "dark teal": "0x014d4e", + "brick red": "0x8f1402", + "royal purple": "0x4b006e", + "plum": "0x580f41", + "mint green": "0x8fff9f", + "gold": "0xdbb40c", + "baby blue": "0xa2cffe", + "yellow green": "0xc0fb2d", + "bright purple": "0xbe03fd", + "dark red": "0x840000", + "pale blue": "0xd0fefe", + "grass green": "0x3f9b0b", + "navy": "0x01153e", + "aquamarine": "0x04d8b2", + "burnt orange": "0xc04e01", + "neon green": "0x0cff0c", + "bright blue": "0x0165fc", + "rose": "0xcf6275", + "light pink": "0xffd1df", + "mustard": "0xceb301", + "indigo": "0x380282", + "lime": "0xaaff32", + "sea green": "0x53fca1", + "periwinkle": "0x8e82fe", + "dark pink": "0xcb416b", + "olive green": "0x677a04", + "peach": "0xffb07c", + "pale green": "0xc7fdb5", + "light brown": "0xad8150", + "hot pink": "0xff028d", + "black": "0x000000", + "lilac": "0xcea2fd", + "navy blue": "0x001146", + "royal blue": "0x0504aa", + "beige": "0xe6daa6", + "salmon": "0xff796c", + "olive": "0x6e750e", + "maroon": "0x650021", + "bright green": "0x01ff07", + "dark purple": "0x35063e", + "mauve": "0xae7181", + "forest green": "0x06470c", + "aqua": "0x13eac9", + "cyan": "0x00ffff", + "tan": "0xd1b26f", + "dark blue": "0x00035b", + "lavender": "0xc79fef", + "turquoise": "0x06c2ac", + "dark green": "0x033500", + "violet": "0x9a0eea", + "light purple": "0xbf77f6", + "lime green": "0x89fe05", + "grey": "0x929591", + "sky blue": "0x75bbfd", + "yellow": "0xffff14", + "magenta": "0xc20078", + "light green": "0x96f97b", + "orange": "0xf97306", + "teal": "0x029386", + "light blue": "0x95d0fc", + "red": "0xe50000", + "brown": "0x653700", + "pink": "0xff81c0", + "blue": "0x0343df", + "green": "0x15b01a", + "purple": "0x7e1e9c" +} diff --git a/bot/resources/halloween/spooky_rating.json b/bot/resources/halloween/spooky_rating.json new file mode 100644 index 00000000..1815befc --- /dev/null +++ b/bot/resources/halloween/spooky_rating.json @@ -0,0 +1,47 @@ +{ + "-1": { + "title": "\uD83D\uDD6F You're not scarin' anyone \uD83D\uDD6F", + "text": "No matter what you say or do, nobody even flinches when you try to scare them. Was your costume this year only a white sheet with holes for eyes? Or did you even bother with a costume at all? Either way, don't expect too many treats when going from door-to-door.", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/candle.jpeg" + }, + "5": { + "title": "\uD83D\uDC76 Like taking candy from a baby \uD83D\uDC76", + "text": "Your scaring will probably make a baby cry... but that's the limit on your frightening powers. Be careful not to get to the point where everyone's running away from you because they don't like you, not because they're scared of you.", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/baby.jpeg" + }, + "20": { + "title": "\uD83C\uDFDA You're skills are forming... \uD83C\uDFDA", + "text": "As you become the Devil's apprentice, you begin to make people jump every time you sneak up on them. A good start, but you have to learn not to wear the same costume every year until it doesn't fit you. People will notice you and your prowess will decrease.", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/tiger.jpeg" + }, + "30": { + "title": "\uD83D\uDC80 Picture Perfect... \uD83D\uDC80", + "text": "You've nailed the costume this year! You look suuuper scary! Now make sure to play the part and act out your costume and you'll be sure to give a few people a massive fright!", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/costume.jpeg" + }, + "50": { + "title": "\uD83D\uDC7B Uhm... are you human \uD83D\uDC7B", + "text": "Uhm... you're too good to be human and now you're beginning to sound like a ghost. You're almost invisible when haunting and nobody truly knows where you are at any given time. But they will always scream at the sound of a ghost...", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/ghost.jpeg" + }, + "65": { + "title": "\uD83C\uDF83 That potion can't be real \uD83C\uDF83", + "text": "You're carrying... some... unknown liquids and no one knows who they are but yourself. Be careful on who you use these powerful spells on, because no Mage has the power to do any irreversible enchantments because even you won't know what will happen to these mortals.", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/necromancer.jepg" + }, + "80": { + "title": "\uD83E\uDD21 The most sinister face \uD83E\uDD21", + "text": "Who knew something intended to be playful could be so menacing... Especially other people seeing you in their nightmares, continuing to haunt them day by day, stuck in their head throughout the entire year. Make sure to pull a face they will never forget.", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/clown.jpeg" + }, + "95": { + "title": "\uD83D\uDE08 The Devil's Accomplice \uD83D\uDE08", + "text": "Imagine being allies with the most evil character with an aim to scare people to death. Force people to suffer as they proceed straight to hell to meet your boss and best friend. Not even you know the power He has...", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/jackolantern.jpg" + }, + "100": { + "title":"\uD83D\uDC7F The Devil Himself \uD83D\uDC7F", + "text": "You are the evillest creature in existence to scare anyone and everyone humanly possible. The reason your underlings are called mortals is that they die. With your help, they die a lot quicker. With all the evil power in the universe, you know what to do.", + "image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/devil.jpeg" + } +}
\ No newline at end of file diff --git a/bot/resources/halloween/spookyrating/baby.jpeg b/bot/resources/halloween/spookyrating/baby.jpeg Binary files differnew file mode 100644 index 00000000..199f8bca --- /dev/null +++ b/bot/resources/halloween/spookyrating/baby.jpeg diff --git a/bot/resources/halloween/spookyrating/candle.jpeg b/bot/resources/halloween/spookyrating/candle.jpeg Binary files differnew file mode 100644 index 00000000..9913752b --- /dev/null +++ b/bot/resources/halloween/spookyrating/candle.jpeg diff --git a/bot/resources/halloween/spookyrating/clown.jpeg b/bot/resources/halloween/spookyrating/clown.jpeg Binary files differnew file mode 100644 index 00000000..f23c4f70 --- /dev/null +++ b/bot/resources/halloween/spookyrating/clown.jpeg diff --git a/bot/resources/halloween/spookyrating/costume.jpeg b/bot/resources/halloween/spookyrating/costume.jpeg Binary files differnew file mode 100644 index 00000000..b3c21af0 --- /dev/null +++ b/bot/resources/halloween/spookyrating/costume.jpeg diff --git a/bot/resources/halloween/spookyrating/devil.jpeg b/bot/resources/halloween/spookyrating/devil.jpeg Binary files differnew file mode 100644 index 00000000..4f45aaa7 --- /dev/null +++ b/bot/resources/halloween/spookyrating/devil.jpeg diff --git a/bot/resources/halloween/spookyrating/ghost.jpeg b/bot/resources/halloween/spookyrating/ghost.jpeg Binary files differnew file mode 100644 index 00000000..0cb13346 --- /dev/null +++ b/bot/resources/halloween/spookyrating/ghost.jpeg diff --git a/bot/resources/halloween/spookyrating/jackolantern.jpeg b/bot/resources/halloween/spookyrating/jackolantern.jpeg Binary files differnew file mode 100644 index 00000000..d7cf3d08 --- /dev/null +++ b/bot/resources/halloween/spookyrating/jackolantern.jpeg diff --git a/bot/resources/halloween/spookyrating/necromancer.jpeg b/bot/resources/halloween/spookyrating/necromancer.jpeg Binary files differnew file mode 100644 index 00000000..60b1e689 --- /dev/null +++ b/bot/resources/halloween/spookyrating/necromancer.jpeg diff --git a/bot/resources/halloween/spookyrating/tiger.jpeg b/bot/resources/halloween/spookyrating/tiger.jpeg Binary files differnew file mode 100644 index 00000000..0419f5df --- /dev/null +++ b/bot/resources/halloween/spookyrating/tiger.jpeg diff --git a/bot/resources/persist/egg_hunt.sqlite b/bot/resources/persist/egg_hunt.sqlite Binary files differnew file mode 100644 index 00000000..6a7ae32d --- /dev/null +++ b/bot/resources/persist/egg_hunt.sqlite diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py index 99d81b0c..f0a7c2c6 100644 --- a/bot/seasons/christmas/__init__.py +++ b/bot/seasons/christmas/__init__.py @@ -4,12 +4,15 @@ from bot.seasons import SeasonBase class Christmas(SeasonBase): """ - We are getting into the festive spirit with a new server icon, new - bot name and avatar, and some new commands for you to check out! + Christmas seasonal event attributes. - No matter who you are, where you are or what beliefs you may follow, - we hope every one of you enjoy this festive season! + We are getting into the festive spirit with a new server icon, new bot name and avatar, and some + new commands for you to check out! + + No matter who you are, where you are or what beliefs you may follow, we hope every one of you + enjoy this festive season! """ + name = "christmas" bot_name = "Merrybot" greeting = "Happy Holidays!" diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 2995c3fd..32858673 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -13,7 +13,7 @@ from bs4 import BeautifulSoup from discord.ext import commands from pytz import timezone -from bot.constants import AdventOfCode as AocConfig, Colours, Emojis, Tokens +from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens log = logging.getLogger(__name__) @@ -25,19 +25,15 @@ COUNTDOWN_STEP = 60 * 5 def is_in_advent() -> bool: - """ - Utility function to check if we are between December 1st - and December 25th. - """ + """Utility function to check if we are between December 1st and December 25th.""" + # Run the code from the 1st to the 24th return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: - """ - This calculates the amount of time left until midnight in - UTC-5 (Advent of Code maintainer timezone). - """ + """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, @@ -52,10 +48,8 @@ def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: async def countdown_status(bot: commands.Bot): - """ - Every `COUNTDOWN_STEP` seconds set the playing status of the bot to - the number of minutes & hours left until the next day's release. - """ + """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" + while is_in_advent(): _, time_left = time_left_to_aoc_midnight() @@ -83,17 +77,18 @@ async def countdown_status(bot: commands.Bot): 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. + 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 challenge 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) + channel = bot.get_channel(Channels.seasonalbot_chat) if not channel: log.error("Could not find the AoC channel to send notification in") @@ -109,9 +104,8 @@ async def day_countdown(bot: commands.Bot): class AdventOfCode(commands.Cog): - """ - Advent of Code festivities! Ho Ho Ho! - """ + """Advent of Code festivities! Ho Ho Ho.""" + def __init__(self, bot: commands.Bot): self.bot = bot @@ -136,9 +130,7 @@ class AdventOfCode(commands.Cog): @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) async def adventofcode_group(self, ctx: commands.Context): - """ - All of the Advent of Code commands - """ + """All of the Advent of Code commands.""" await ctx.invoke(self.bot.get_command("help"), "adventofcode") @@ -148,9 +140,8 @@ class AdventOfCode(commands.Cog): brief="Notifications for new days" ) async def aoc_subscribe(self, ctx: commands.Context): - """ - Assign the role for notifications about new days being ready. - """ + """Assign the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" @@ -164,9 +155,8 @@ class AdventOfCode(commands.Cog): @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") async def aoc_unsubscribe(self, ctx: commands.Context): - """ - Remove the role for notifications about new days being ready. - """ + """Remove the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) if role in ctx.author.roles: @@ -177,9 +167,8 @@ class AdventOfCode(commands.Cog): @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 - """ + """Return time left until next day.""" + if not is_in_advent(): datetime_now = datetime.now(EST) december_first = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) @@ -196,17 +185,13 @@ class AdventOfCode(commands.Cog): @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 - """ + """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): - """ - DM the user the information for joining the PyDis AoC private leaderboard - """ + """DM the user the information for joining the PyDis AoC private leaderboard.""" author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") @@ -228,7 +213,7 @@ class AdventOfCode(commands.Cog): ) 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 + 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 @@ -270,7 +255,7 @@ class AdventOfCode(commands.Cog): ) async def private_leaderboard_daily_stats(self, ctx: commands.Context): """ - Respond with a table of the daily completion statistics for the PyDis private leaderboard + Respond with a table of the daily completion statistics for the PyDis private leaderboard. Embed will display the total members and the number of users who have completed each day's puzzle """ @@ -314,7 +299,7 @@ class AdventOfCode(commands.Cog): ) 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 + 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 @@ -347,7 +332,7 @@ class AdventOfCode(commands.Cog): 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 + 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 """ @@ -404,9 +389,7 @@ class AdventOfCode(commands.Cog): 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 - """ + """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) @@ -421,9 +404,8 @@ class AdventOfCode(commands.Cog): return about_embed async def _boardgetter(self, global_board: bool): - """ - Invoke the proper leaderboard getter based on the global_board boolean - """ + """Invoke the proper leaderboard getter based on the global_board boolean.""" + if global_board: self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() else: @@ -431,6 +413,8 @@ class AdventOfCode(commands.Cog): class AocMember: + """Object representing the Advent of Code user.""" + 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 @@ -441,12 +425,14 @@ class AocMember: self.completions = self._completions_from_starboard(self.starboard) def __repr__(self): + """Generate a user-friendly representation of the AocMember & their score.""" + 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 + Generate an AocMember from AoC's private leaderboard API JSON. injson is expected to be the dict contained in: @@ -467,7 +453,7 @@ class AocMember: @staticmethod def _starboard_from_json(injson: dict) -> list: """ - Generate starboard from AoC's private leaderboard API JSON + Generate starboard from AoC's private leaderboard API JSON. injson is expected to be the dict contained in: @@ -500,9 +486,7 @@ class AocMember: @staticmethod def _completions_from_starboard(starboard: list) -> tuple: - """ - Return days completed, as a (1 star, 2 star) tuple, from starboard - """ + """Return days completed, as a (1 star, 2 star) tuple, from starboard.""" completions = [0, 0] for day in starboard: @@ -515,6 +499,8 @@ class AocMember: class AocPrivateLeaderboard: + """Object representing the Advent of Code private leaderboard.""" + def __init__(self, members: list, owner_id: int, event_year: int): self.members = members self._owner_id = owner_id @@ -534,7 +520,7 @@ class AocPrivateLeaderboard: def calculate_daily_completion(self) -> List[tuple]: """ - Calculate member completion rates by day + Calculate member completion rates by day. Return a list of tuples for each day containing the number of users who completed each part of the challenge @@ -560,7 +546,7 @@ class AocPrivateLeaderboard: 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 + 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 """ @@ -580,9 +566,7 @@ class AocPrivateLeaderboard: @classmethod def from_json(cls, injson: dict) -> "AocPrivateLeaderboard": - """ - Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON - """ + """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"] @@ -590,9 +574,7 @@ class AocPrivateLeaderboard: @classmethod async def from_url(cls) -> "AocPrivateLeaderboard": - """ - Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json - """ + """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" api_json = await cls.json_from_url() return cls.from_json(api_json) @@ -600,7 +582,7 @@ class AocPrivateLeaderboard: @staticmethod def _sorted_members(injson: dict) -> list: """ - Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON + Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON. Output list is sorted based on the AocMember.local_score """ @@ -613,7 +595,7 @@ class AocPrivateLeaderboard: @staticmethod def build_leaderboard_embed(members_to_print: List[AocMember]) -> str: """ - Build a text table from members_to_print, a list of AocMember objects + 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 """ @@ -638,6 +620,8 @@ class AocPrivateLeaderboard: class AocGlobalLeaderboard: + """Object representing the Advent of Code global leaderboard.""" + def __init__(self, members: List[tuple]): self.members = members self.last_updated = datetime.utcnow() @@ -654,7 +638,7 @@ class AocGlobalLeaderboard: @classmethod async def from_url(cls) -> "AocGlobalLeaderboard": """ - Generate an list of tuples for the entries on AoC's global leaderboard + 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 """ @@ -700,7 +684,7 @@ class AocGlobalLeaderboard: @staticmethod def build_leaderboard_embed(members_to_print: List[tuple]) -> str: """ - Build a text table from members_to_print, a list of tuples + 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 """ @@ -721,13 +705,13 @@ class AocGlobalLeaderboard: def _error_embed_helper(title: str, description: str) -> discord.Embed: - """ - Return a red-colored Embed with the given title and description - """ + """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: + """Advent of Code Cog load.""" + bot.add_cog(AdventOfCode(bot)) log.info("AdventOfCode cog loaded") diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py index bfad772d..83d12ead 100644 --- a/bot/seasons/easter/__init__.py +++ b/bot/seasons/easter/__init__.py @@ -1,17 +1,33 @@ +from bot.constants import Colours from bot.seasons import SeasonBase class Easter(SeasonBase): """ - Easter is a beautiful time of the year often celebrated after the first Full Moon of the new spring season. - This time is quite beautiful due to the colorful flowers coming out to greet us. So. let's greet Spring - in an Easter celebration of contributions. + Here at Python Discord, we celebrate our version of Easter during the entire month of April. + + While this celebration takes place, you'll notice a few changes: + + • The server icon has changed to our Easter icon. Thanks to <@140605665772175361> for the + design! + + • [Easter issues now available for SeasonalBot on the repo](https://git.io/fjkvQ). + + • You may see stuff like an Easter themed esoteric challenge, a celebration of Earth Day, or + Easter-related micro-events for you to join. Stay tuned! + + If you'd like to contribute, head on over to <#542272993192050698> and we will help you get + started. It doesn't matter if you're new to open source or Python, if you'd like to help, we + will find you a task and teach you what you need to know. """ name = "easter" bot_name = "BunnyBot" - greeting = "Happy Easter to us all!" + greeting = "Happy Easter!" # Duration of season - start_date = "01/04" + start_date = "02/04" end_date = "30/04" + + colour = Colours.pink + icon = "/logos/logo_seasonal/easter/easter.png" diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/seasons/easter/april_fools_vids.py new file mode 100644 index 00000000..5dae8485 --- /dev/null +++ b/bot/seasons/easter/april_fools_vids.py @@ -0,0 +1,38 @@ +import logging +import random +from json import load +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class AprilFoolVideos(commands.Cog): + """A cog for april fools that gets a random april fools video from youtube.""" + def __init__(self, bot): + self.bot = bot + self.yt_vids = self.load_json() + self.youtubers = ['google'] # will add more in future + + @staticmethod + def load_json(): + """A function to load json data.""" + p = Path('bot', 'resources', 'easter', 'april_fools_vids.json') + with p.open() as json_file: + all_vids = load(json_file) + return all_vids + + @commands.command(name='fool') + async def aprial_fools(self, ctx): + """Gets a random april fools video from youtube.""" + random_youtuber = random.choice(self.youtubers) + category = self.yt_vids[random_youtuber] + random_vid = random.choice(category) + await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}") + + +def setup(bot): + """A function to add the cog.""" + bot.add_cog(AprilFoolVideos(bot)) + log.info('April Fools videos cog loaded!') diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/seasons/easter/avatar_easterifier.py new file mode 100644 index 00000000..9f7ea271 --- /dev/null +++ b/bot/seasons/easter/avatar_easterifier.py @@ -0,0 +1,133 @@ +import asyncio +import logging +from io import BytesIO +from pathlib import Path +from typing import Union + +import discord +from PIL import Image +from PIL.ImageOps import posterize +from discord.ext import commands + +log = logging.getLogger(__name__) + +COLOURS = [ + (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), + (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), + (135, 206, 235), (0, 204, 204), (64, 224, 208) +] # Pastel colours - Easter-like + + +class AvatarEasterifier(commands.Cog): + """Put an Easter spin on your avatar or image!""" + + def __init__(self, bot): + self.bot = bot + + @staticmethod + def closest(x): + """ + Finds the closest easter colour to a given pixel. + + Returns a merge between the original colour and the closest colour + """ + + r1, g1, b1 = x + + def distance(point): + """Finds the difference between a pastel colour and the original pixel colour""" + + r2, g2, b2 = point + return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2) + + closest_colours = sorted(COLOURS, key=lambda point: distance(point)) + r2, g2, b2 = closest_colours[0] + r = (r1 + r2) // 2 + g = (g1 + g2) // 2 + b = (b1 + b2) // 2 + return (r, g, b) + + @commands.command(pass_context=True, aliases=["easterify"]) + async def avatareasterify(self, ctx, *colours: Union[discord.Colour, str]): + """ + This "Easterifies" the user's avatar. + + Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. + If colours are not given, a nice little chocolate bunny will sit in the corner. + Colours are split by spaces, unless you wrap the colour name in double quotes. + Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. + """ + + async def send(*args, **kwargs): + """ + This replaces the original ctx.send. + + When invoking the egg decorating command, the egg itself doesn't print to to the channel. + Returns the message content so that if any errors occur, the error message can be output. + """ + if args: + return args[0] + + async with ctx.typing(): + + # Grabs image of avatar + async with self.bot.http_session.get(ctx.author.avatar_url_as(size=256)) as resp: + image_bytes = await resp.read() + + old = Image.open(BytesIO(image_bytes)) + old = old.convert("RGBA") + + # Grabs alpha channel since posterize can't be used with an RGBA image. + alpha = old.getchannel("A").getdata() + old = old.convert("RGB") + old = posterize(old, 6) + + data = old.getdata() + setted_data = set(data) + new_d = {} + + for x in setted_data: + new_d[x] = self.closest(x) + await asyncio.sleep(0) # Ensures discord doesn't break in the background. + new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] + + im = Image.new("RGBA", old.size) + im.putdata(new_data) + + if colours: + send_message = ctx.send + ctx.send = send # Assigns ctx.send to a fake send + egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) + if isinstance(egg, str): # When an error message occurs in eggdecorate. + return await send_message(egg) + + ratio = 64 / egg.height + egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) + egg = egg.convert("RGBA") + im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre. + ctx.send = send_message # Reassigns ctx.send + else: + bunny = Image.open(Path("bot", "resources", "easter", "chocolate_bunny.png")) + im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre. + + bufferedio = BytesIO() + im.save(bufferedio, format="PNG") + + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed + embed = discord.Embed( + name="Your Lovely Easterified Avatar", + description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" + ) + embed.set_image(url="attachment://easterified_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + +def setup(bot): + """Cog load.""" + + bot.add_cog(AvatarEasterifier(bot)) + log.info("AvatarEasterifier cog loaded") diff --git a/bot/seasons/easter/conversationstarters.py b/bot/seasons/easter/conversationstarters.py new file mode 100644 index 00000000..b479406b --- /dev/null +++ b/bot/seasons/easter/conversationstarters.py @@ -0,0 +1,31 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + +with open(Path('bot', 'resources', 'easter', 'starter.json'), 'r', encoding="utf8") as f: + starters = json.load(f) + + +class ConvoStarters(commands.Cog): + """Easter conversation topics.""" + + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def topic(self, ctx): + """Responds with a random topic to start a conversation.""" + + await ctx.send(random.choice(starters['starters'])) + + +def setup(bot): + """Conversation starters Cog load.""" + + bot.add_cog(ConvoStarters(bot)) + log.info("ConvoStarters cog loaded") diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py new file mode 100644 index 00000000..d283e42a --- /dev/null +++ b/bot/seasons/easter/egg_decorating.py @@ -0,0 +1,119 @@ +import json +import logging +import random +from contextlib import suppress +from io import BytesIO +from pathlib import Path +from typing import Union + +import discord +from PIL import Image +from discord.ext import commands + +log = logging.getLogger(__name__) + +with open(Path("bot", "resources", "evergreen", "html_colours.json")) as f: + HTML_COLOURS = json.load(f) + +with open(Path("bot", "resources", "evergreen", "xkcd_colours.json")) as f: + XKCD_COLOURS = json.load(f) + +COLOURS = [ + (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255), + (0, 255, 255, 255), (0, 0, 255, 255), (255, 0, 255, 255), (128, 0, 128, 255) +] # Colours to be replaced - Red, Orange, Yellow, Green, Light Blue, Dark Blue, Pink, Purple + +IRREPLACEABLE = [ + (0, 0, 0, 0), (0, 0, 0, 255) +] # Colours that are meant to stay the same - Transparent and Black + + +class EggDecorating(commands.Cog): + """Decorate some easter eggs!""" + + def __init__(self, bot): + self.bot = bot + + @staticmethod + def replace_invalid(colour: str): + """Attempts to match with HTML or XKCD colour names, returning the int value.""" + with suppress(KeyError): + return int(HTML_COLOURS[colour], 16) + with suppress(KeyError): + return int(XKCD_COLOURS[colour], 16) + return None + + @commands.command(aliases=["decorateegg"]) + async def eggdecorate(self, ctx, *colours: Union[discord.Colour, str]): + """ + Picks a random egg design and decorates it using the given colours. + + Colours are split by spaces, unless you wrap the colour name in double quotes. + Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. + """ + + if len(colours) < 2: + return await ctx.send("You must include at least 2 colours!") + + invalid = [] + colours = list(colours) + for idx, colour in enumerate(colours): + if isinstance(colour, discord.Colour): + continue + value = self.replace_invalid(colour) + if value: + colours[idx] = discord.Colour(value) + else: + invalid.append(colour) + + if len(invalid) > 1: + return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") + elif len(invalid) == 1: + return await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") + + async with ctx.typing(): + # expand list to 8 colours + colours_n = len(colours) + if colours_n < 8: + q, r = divmod(8, colours_n) + colours = colours * q + colours[:r] + num = random.randint(1, 6) + im = Image.open(Path("bot", "resources", "easter", "easter_eggs", f"design{num}.png")) + data = list(im.getdata()) + + replaceable = {x for x in data if x not in IRREPLACEABLE} + replaceable = sorted(replaceable, key=COLOURS.index) + + replacing_colours = {colour: colours[i] for i, colour in enumerate(replaceable)} + new_data = [] + for x in data: + if x in replacing_colours: + new_data.append((*replacing_colours[x].to_rgb(), 255)) + # Also ensures that the alpha channel has a value + else: + new_data.append(x) + new_im = Image.new(im.mode, im.size) + new_im.putdata(new_data) + + bufferedio = BytesIO() + new_im.save(bufferedio, format="PNG") + + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="egg.png") # Creates file to be used in embed + embed = discord.Embed( + title="Your Colourful Easter Egg", + description="Here is your pretty little egg. Hope you like it!" + ) + embed.set_image(url="attachment://egg.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + return new_im + + +def setup(bot): + """Cog load.""" + + bot.add_cog(EggDecorating(bot)) + log.info("EggDecorating cog loaded.") diff --git a/bot/seasons/easter/egg_hunt/__init__.py b/bot/seasons/easter/egg_hunt/__init__.py new file mode 100644 index 00000000..43bda223 --- /dev/null +++ b/bot/seasons/easter/egg_hunt/__init__.py @@ -0,0 +1,12 @@ +import logging + +from .cog import EggHunt + +log = logging.getLogger(__name__) + + +def setup(bot): + """Easter Egg Hunt Cog load.""" + + bot.add_cog(EggHunt()) + log.info("EggHunt cog loaded") diff --git a/bot/seasons/easter/egg_hunt/cog.py b/bot/seasons/easter/egg_hunt/cog.py new file mode 100644 index 00000000..c9e2dc18 --- /dev/null +++ b/bot/seasons/easter/egg_hunt/cog.py @@ -0,0 +1,638 @@ +import asyncio +import contextlib +import logging +import random +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Channels, Client, Roles as MainRoles, bot +from bot.decorators import with_role +from .constants import Colours, EggHuntSettings, Emoji, Roles + +log = logging.getLogger(__name__) + +DB_PATH = Path("bot", "resources", "persist", "egg_hunt.sqlite") + +TEAM_MAP = { + Roles.white: Emoji.egg_white, + Roles.blurple: Emoji.egg_blurple, + Emoji.egg_white: Roles.white, + Emoji.egg_blurple: Roles.blurple +} + +GUILD = bot.get_guild(Client.guild) + +MUTED = GUILD.get_role(MainRoles.muted) + + +def get_team_role(user: discord.Member) -> discord.Role: + """Helper function to get the team role for a member.""" + + if Roles.white in user.roles: + return Roles.white + if Roles.blurple in user.roles: + return Roles.blurple + + +async def assign_team(user: discord.Member) -> discord.Member: + """Helper function to assign a new team role for a member.""" + + db = sqlite3.connect(DB_PATH) + c = db.cursor() + c.execute(f"SELECT team FROM user_scores WHERE user_id = {user.id}") + result = c.fetchone() + if not result: + c.execute( + "SELECT team, COUNT(*) AS count FROM user_scores " + "GROUP BY team ORDER BY count ASC LIMIT 1;" + ) + result = c.fetchone() + result = result[0] if result else "WHITE" + + if result[0] == "WHITE": + new_team = Roles.white + else: + new_team = Roles.blurple + + db.close() + + log.debug(f"Assigned role {new_team} to {user}.") + + await user.add_roles(new_team) + return GUILD.get_member(user.id) + + +class EggMessage: + """Handles a single egg reaction drop session.""" + + def __init__(self, message: discord.Message, egg: discord.Emoji): + self.message = message + self.egg = egg + self.first = None + self.users = set() + self.teams = {Roles.white: "WHITE", Roles.blurple: "BLURPLE"} + self.new_team_assignments = {} + self.timeout_task = None + + @staticmethod + def add_user_score_sql(user_id: int, team: str, score: int) -> str: + """Builds the SQL for adding a score to a user in the database.""" + + return ( + "INSERT INTO user_scores(user_id, team, score)" + f"VALUES({user_id}, '{team}', {score})" + f"ON CONFLICT (user_id) DO UPDATE SET score=score+{score}" + ) + + @staticmethod + def add_team_score_sql(team_name: str, score: int) -> str: + """Builds the SQL for adding a score to a team in the database.""" + + return f"UPDATE team_scores SET team_score=team_score+{score} WHERE team_id='{team_name}'" + + def finalise_score(self): + """Sums and actions scoring for this egg drop session.""" + + db = sqlite3.connect(DB_PATH) + c = db.cursor() + + team_scores = {"WHITE": 0, "BLURPLE": 0} + + first_team = get_team_role(self.first) + if not first_team: + log.debug("User without team role!") + db.close() + return + + score = 3 if first_team == TEAM_MAP[first_team] else 2 + + c.execute(self.add_user_score_sql(self.first.id, self.teams[first_team], score)) + team_scores[self.teams[first_team]] += score + + for user in self.users: + team = get_team_role(user) + if not team: + log.debug("User without team role!") + continue + + team_name = self.teams[team] + team_scores[team_name] += 1 + score = 2 if team == first_team else 1 + c.execute(self.add_user_score_sql(user.id, team_name, score)) + + for team_name, score in team_scores.items(): + if not score: + continue + c.execute(self.add_team_score_sql(team_name, score)) + + db.commit() + db.close() + + log.debug( + f"EggHunt session finalising: ID({self.message.id}) " + f"FIRST({self.first}) REST({self.users})." + ) + + async def start_timeout(self, seconds: int = 5): + """Begins a task that will sleep until the given seconds before finalizing the session.""" + + if self.timeout_task: + self.timeout_task.cancel() + self.timeout_task = None + + await asyncio.sleep(seconds) + + bot.remove_listener(self.collect_reacts, name="on_reaction_add") + + with contextlib.suppress(discord.Forbidden): + await self.message.clear_reactions() + + if self.first: + self.finalise_score() + + def is_valid_react(self, reaction: discord.Reaction, user: discord.Member) -> bool: + """Validates a reaction event was meant for this session.""" + + if user.bot: + return False + if reaction.message.id != self.message.id: + return False + if reaction.emoji != self.egg: + return False + + # ignore the pushished + if MUTED in user.roles: + return False + + return True + + async def collect_reacts(self, reaction: discord.Reaction, user: discord.Member): + """Handles emitted reaction_add events via listener.""" + + if not self.is_valid_react(reaction, user): + return + + team = get_team_role(user) + if not team: + log.debug(f"Assigning a team for {user}.") + user = await assign_team(user) + + if not self.first: + log.debug(f"{user} was first to react to egg on {self.message.id}.") + self.first = user + await self.start_timeout() + else: + if user != self.first: + self.users.add(user) + + async def start(self): + """Starts the egg drop session.""" + + log.debug(f"EggHunt session started for message {self.message.id}.") + bot.add_listener(self.collect_reacts, name="on_reaction_add") + with contextlib.suppress(discord.Forbidden): + await self.message.add_reaction(self.egg) + self.timeout_task = asyncio.create_task(self.start_timeout(300)) + while True: + if not self.timeout_task: + break + if not self.timeout_task.done(): + await self.timeout_task + else: + # make sure any exceptions raise if necessary + self.timeout_task.result() + break + + +class SuperEggMessage(EggMessage): + """Handles a super egg session.""" + + def __init__(self, message: discord.Message, egg: discord.Emoji, window: int): + super().__init__(message, egg) + self.window = window + + async def finalise_score(self): + """Sums and actions scoring for this super egg session.""" + try: + message = await self.message.channel.fetch_message(self.message.id) + except discord.NotFound: + return + + count = 0 + white = 0 + blurple = 0 + react_users = [] + for reaction in message.reactions: + if reaction.emoji == self.egg: + react_users = await reaction.users().flatten() + for user in react_users: + team = get_team_role(user) + if team == Roles.white: + white += 1 + elif team == Roles.blurple: + blurple += 1 + count = reaction.count - 1 + break + + score = 50 if self.egg == Emoji.egg_gold else 100 + if white == blurple: + log.debug("Tied SuperEgg Result.") + team = None + score /= 2 + elif white > blurple: + team = Roles.white + else: + team = Roles.blurple + + embed = self.message.embeds[0] + + db = sqlite3.connect(DB_PATH) + c = db.cursor() + + user_bonus = 5 if self.egg == Emoji.egg_gold else 10 + for user in react_users: + if user.bot: + continue + role = get_team_role(user) + if not role: + print("issue") + user_score = 1 if user != self.first else user_bonus + c.execute(self.add_user_score_sql(user.id, self.teams[role], user_score)) + + if not team: + embed.description = f"{embed.description}\n\nA Tie!\nBoth got {score} points!" + c.execute(self.add_team_score_sql(self.teams[Roles.white], score)) + c.execute(self.add_team_score_sql(self.teams[Roles.blurple], score)) + team_name = "TIE" + else: + team_name = self.teams[team] + embed.description = ( + f"{embed.description}\n\nTeam {team_name.capitalize()} won the points!" + ) + c.execute(self.add_team_score_sql(team_name, score)) + + c.execute( + "INSERT INTO super_eggs (message_id, egg_type, team, window) " + f"VALUES ({self.message.id}, '{self.egg.name}', '{team_name}', {self.window});" + ) + + log.debug("Committing Super Egg scores.") + db.commit() + db.close() + + embed.set_footer(text=f"Finished with {count} total reacts.") + with contextlib.suppress(discord.HTTPException): + await self.message.edit(embed=embed) + + async def start_timeout(self, seconds=None): + """Starts the super egg session.""" + + if not seconds: + return + count = 4 + for _ in range(count): + await asyncio.sleep(60) + embed = self.message.embeds[0] + embed.set_footer(text=f"Finishing in {count} minutes.") + try: + await self.message.edit(embed=embed) + except discord.HTTPException: + break + count -= 1 + bot.remove_listener(self.collect_reacts, name="on_reaction_add") + await self.finalise_score() + + +class EggHunt(commands.Cog): + """Easter Egg Hunt Event.""" + + def __init__(self): + self.event_channel = GUILD.get_channel(Channels.seasonalbot_chat) + self.super_egg_buffer = 60*60 + self.tables = { + "super_eggs": ( + "CREATE TABLE super_eggs (" + "message_id INTEGER NOT NULL " + " CONSTRAINT super_eggs_pk PRIMARY KEY, " + "egg_type TEXT NOT NULL, " + "team TEXT NOT NULL, " + "window INTEGER);" + ), + "team_scores": ( + "CREATE TABLE team_scores (" + "team_id TEXT, " + "team_score INTEGER DEFAULT 0);" + ), + "user_scores": ( + "CREATE TABLE user_scores(" + "user_id INTEGER NOT NULL " + " CONSTRAINT user_scores_pk PRIMARY KEY, " + "team TEXT NOT NULL, " + "score INTEGER DEFAULT 0 NOT NULL);" + ), + "react_logs": ( + "CREATE TABLE react_logs(" + "member_id INTEGER NOT NULL, " + "message_id INTEGER NOT NULL, " + "reaction_id TEXT NOT NULL, " + "react_timestamp REAL NOT NULL);" + ) + } + self.prepare_db() + self.task = asyncio.create_task(self.super_egg()) + self.task.add_done_callback(self.task_cleanup) + + def prepare_db(self): + """Ensures database tables all exist and if not, creates them.""" + + db = sqlite3.connect(DB_PATH) + c = db.cursor() + + exists_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';" + + missing_tables = [] + for table in self.tables: + c.execute(exists_sql.format(table_name=table)) + result = c.fetchone() + if not result: + missing_tables.append(table) + + for table in missing_tables: + log.info(f"Table {table} is missing, building new one.") + c.execute(self.tables[table]) + + db.commit() + db.close() + + def task_cleanup(self, task): + """Returns task result and restarts. Used as a done callback to show raised exceptions.""" + + task.result() + self.task = asyncio.create_task(self.super_egg()) + + @staticmethod + def current_timestamp() -> float: + """Returns a timestamp of the current UTC time.""" + + return datetime.utcnow().replace(tzinfo=timezone.utc).timestamp() + + async def super_egg(self): + """Manages the timing of super egg drops.""" + + while True: + now = int(self.current_timestamp()) + + if now > EggHuntSettings.end_time: + log.debug("Hunt ended. Ending task.") + break + + if now < EggHuntSettings.start_time: + remaining = EggHuntSettings.start_time - now + log.debug(f"Hunt not started yet. Sleeping for {remaining}.") + await asyncio.sleep(remaining) + + log.debug(f"Hunt started.") + + db = sqlite3.connect(DB_PATH) + c = db.cursor() + + current_window = None + next_window = None + windows = EggHuntSettings.windows.copy() + windows.insert(0, EggHuntSettings.start_time) + for i, window in enumerate(windows): + c.execute(f"SELECT COUNT(*) FROM super_eggs WHERE window={window}") + already_dropped = c.fetchone()[0] + + if already_dropped: + log.debug(f"Window {window} already dropped, checking next one.") + continue + + if now < window: + log.debug("Drop windows up to date, sleeping until next one.") + await asyncio.sleep(window-now) + now = int(self.current_timestamp()) + + current_window = window + next_window = windows[i+1] + break + + count = c.fetchone() + db.close() + + if not current_window: + log.debug("No drop windows left, ending task.") + break + + log.debug(f"Current Window: {current_window}. Next Window {next_window}") + + if not count: + if next_window < now: + log.debug("An Egg Drop Window was missed, dropping one now.") + next_drop = 0 + else: + next_drop = random.randrange(now, next_window) + + if next_drop: + log.debug(f"Sleeping until next super egg drop: {next_drop}.") + await asyncio.sleep(next_drop) + + if random.randrange(10) <= 2: + egg = Emoji.egg_diamond + egg_type = "Diamond" + score = "100" + colour = Colours.diamond + else: + egg = Emoji.egg_gold + egg_type = "Gold" + score = "50" + colour = Colours.gold + + embed = discord.Embed( + title=f"A {egg_type} Egg Has Appeared!", + description=f"**Worth {score} team points!**\n\n" + "The team with the most reactions after 5 minutes wins!", + colour=colour + ) + embed.set_thumbnail(url=egg.url) + embed.set_footer(text="Finishing in 5 minutes.") + msg = await self.event_channel.send(embed=embed) + await SuperEggMessage(msg, egg, current_window).start() + + log.debug("Sleeping until next window.") + next_loop = max(next_window - int(self.current_timestamp()), self.super_egg_buffer) + await asyncio.sleep(next_loop) + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload): + """Reaction event listener for reaction logging for later anti-cheat analysis.""" + + if payload.channel_id not in EggHuntSettings.allowed_channels: + return + + now = self.current_timestamp() + db = sqlite3.connect(DB_PATH) + c = db.cursor() + c.execute( + "INSERT INTO react_logs(member_id, message_id, reaction_id, react_timestamp) " + f"VALUES({payload.user_id}, {payload.message_id}, '{payload.emoji}', {now})" + ) + db.commit() + db.close() + + @commands.Cog.listener() + async def on_message(self, message): + """Message event listener for random egg drops.""" + + if self.current_timestamp() < EggHuntSettings.start_time: + return + + if message.channel.id not in EggHuntSettings.allowed_channels: + log.debug("Message not in Egg Hunt channel; ignored.") + return + + if message.author.bot: + return + + if random.randrange(100) <= 5: + await EggMessage(message, random.choice([Emoji.egg_white, Emoji.egg_blurple])).start() + + @commands.group(invoke_without_command=True) + async def hunt(self, ctx): + """ + For 48 hours, hunt down as many eggs randomly appearing as possible. + + Standard Eggs + -------------- + Egg React: +1pt + Team Bonus for Claimed Egg: +1pt + First React on Other Team Egg: +1pt + First React on Your Team Egg: +2pt + + If you get first react, you will claim that egg for your team, allowing + your team to get the Team Bonus point, but be quick, as the egg will + disappear after 5 seconds of the first react. + + Super Eggs + ----------- + Gold Egg: 50 team pts, 5pts to first react + Diamond Egg: 100 team pts, 10pts to first react + + Super Eggs only appear in #seasonalbot-chat so be sure to keep an eye + out. They stay around for 5 minutes and the team with the most reacts + wins the points. + """ + + await ctx.invoke(bot.get_command("help"), command="hunt") + + @hunt.command() + async def countdown(self, ctx): + """Show the time status of the Egg Hunt event.""" + + now = self.current_timestamp() + if now > EggHuntSettings.end_time: + return await ctx.send("The Hunt has ended.") + + difference = EggHuntSettings.start_time - now + if difference < 0: + difference = EggHuntSettings.end_time - now + msg = "The Egg Hunt will end in" + else: + msg = "The Egg Hunt will start in" + + hours, r = divmod(difference, 3600) + minutes, r = divmod(r, 60) + await ctx.send(f"{msg} {hours:.0f}hrs, {minutes:.0f}mins & {r:.0f}secs") + + @hunt.command() + async def leaderboard(self, ctx): + """Show the Egg Hunt Leaderboards.""" + + db = sqlite3.connect(DB_PATH) + c = db.cursor() + c.execute(f"SELECT *, RANK() OVER(ORDER BY score DESC) AS rank FROM user_scores LIMIT 10") + user_result = c.fetchall() + c.execute(f"SELECT * FROM team_scores ORDER BY team_score DESC") + team_result = c.fetchall() + db.close() + output = [] + if user_result: + # Get the alignment needed for the score + score_lengths = [] + for result in user_result: + length = len(str(result[2])) + score_lengths.append(length) + + score_length = max(score_lengths) + for user_id, team, score, rank in user_result: + user = GUILD.get_member(user_id) or user_id + team = team.capitalize() + score = f"{score}pts" + output.append(f"{rank:>2}. {score:>{score_length+3}} - {user} ({team})") + user_board = "\n".join(output) + else: + user_board = "No entries." + if team_result: + output = [] + for team, score in team_result: + output.append(f"{team:<7}: {score}") + team_board = "\n".join(output) + else: + team_board = "No entries." + embed = discord.Embed( + title="Egg Hunt Leaderboards", + description=f"**Team Scores**\n```\n{team_board}\n```\n" + f"**Top 10 Members**\n```\n{user_board}\n```" + ) + await ctx.send(embed=embed) + + @hunt.command() + async def rank(self, ctx, *, member: discord.Member = None): + """Get your ranking in the Egg Hunt Leaderboard.""" + + member = member or ctx.author + db = sqlite3.connect(DB_PATH) + c = db.cursor() + c.execute( + "SELECT rank FROM " + "(SELECT RANK() OVER(ORDER BY score DESC) AS rank, user_id FROM user_scores)" + f"WHERE user_id = {member.id};" + ) + result = c.fetchone() + db.close() + if not result: + embed = discord.Embed().set_author(name=f"Egg Hunt - No Ranking") + else: + embed = discord.Embed().set_author(name=f"Egg Hunt - Rank #{result[0]}") + await ctx.send(embed=embed) + + @with_role(MainRoles.admin) + @hunt.command() + async def clear_db(self, ctx): + """Resets the database to it's initial state.""" + + def check(msg): + if msg.author != ctx.author: + return False + if msg.channel != ctx.channel: + return False + return True + await ctx.send( + "WARNING: This will delete all current event data.\n" + "Please verify this action by replying with 'Yes, I want to delete all data.'" + ) + reply_msg = await bot.wait_for('message', check=check) + if reply_msg.content != "Yes, I want to delete all data.": + return await ctx.send("Reply did not match. Aborting database deletion.") + db = sqlite3.connect(DB_PATH) + c = db.cursor() + c.execute("DELETE FROM super_eggs;") + c.execute("DELETE FROM user_scores;") + c.execute("UPDATE team_scores SET team_score=0") + db.commit() + db.close() + await ctx.send("Database successfully cleared.") diff --git a/bot/seasons/easter/egg_hunt/constants.py b/bot/seasons/easter/egg_hunt/constants.py new file mode 100644 index 00000000..c7d9818b --- /dev/null +++ b/bot/seasons/easter/egg_hunt/constants.py @@ -0,0 +1,39 @@ +import os + +from discord import Colour + +from bot.constants import Channels, Client, bot + + +GUILD = bot.get_guild(Client.guild) + + +class EggHuntSettings: + start_time = int(os.environ["HUNT_START"]) + end_time = start_time + 172800 # 48 hrs later + windows = [int(w) for w in os.environ.get("HUNT_WINDOWS").split(',')] or [] + allowed_channels = [ + Channels.seasonalbot_chat, + Channels.off_topic_0, + Channels.off_topic_1, + Channels.off_topic_2, + ] + + +class Roles: + white = GUILD.get_role(569304397054607363) + blurple = GUILD.get_role(569304472820514816) + + +class Emoji: + egg_white = bot.get_emoji(569266762428841989) + egg_blurple = bot.get_emoji(569266666094067819) + egg_gold = bot.get_emoji(569266900106739712) + egg_diamond = bot.get_emoji(569266839738384384) + + +class Colours: + white = Colour(0xFFFFFF) + blurple = Colour(0x7289DA) + gold = Colour(0xE4E415) + diamond = Colour(0xECF5FF) diff --git a/bot/seasons/easter/egghead_quiz.py b/bot/seasons/easter/egghead_quiz.py new file mode 100644 index 00000000..8dd2c21d --- /dev/null +++ b/bot/seasons/easter/egghead_quiz.py @@ -0,0 +1,121 @@ +import asyncio +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path('bot', 'resources', 'easter', 'egghead_questions.json'), 'r', encoding="utf8") as f: + EGGHEAD_QUESTIONS = load(f) + + +EMOJIS = [ + '\U0001f1e6', '\U0001f1e7', '\U0001f1e8', '\U0001f1e9', '\U0001f1ea', + '\U0001f1eb', '\U0001f1ec', '\U0001f1ed', '\U0001f1ee', '\U0001f1ef', + '\U0001f1f0', '\U0001f1f1', '\U0001f1f2', '\U0001f1f3', '\U0001f1f4', + '\U0001f1f5', '\U0001f1f6', '\U0001f1f7', '\U0001f1f8', '\U0001f1f9', + '\U0001f1fa', '\U0001f1fb', '\U0001f1fc', '\U0001f1fd', '\U0001f1fe', + '\U0001f1ff' +] # Regional Indicators A-Z (used for voting) + +TIMELIMIT = 30 + + +class EggheadQuiz(commands.Cog): + """This cog contains the command for the Easter quiz!""" + + def __init__(self, bot): + self.bot = bot + self.quiz_messages = {} + + @commands.command(aliases=["eggheadquiz", "easterquiz"]) + async def eggquiz(self, ctx): + """ + Gives a random quiz question, waits 30 seconds and then outputs the answer + + Also informs of the percentages and votes of each option + """ + + random_question = random.choice(EGGHEAD_QUESTIONS) + question, answers = random_question["question"], random_question["answers"] + answers = [(EMOJIS[i], a) for i, a in enumerate(answers)] + correct = EMOJIS[random_question["correct_answer"]] + + valid_emojis = [emoji for emoji, _ in answers] + + description = f"You have {TIMELIMIT} seconds to vote.\n\n" + description += "\n".join([f"{emoji} -> **{answer}**" for emoji, answer in answers]) + + q_embed = discord.Embed(title=question, description=description, colour=Colours.pink) + + msg = await ctx.send(embed=q_embed) + for emoji in valid_emojis: + await msg.add_reaction(emoji) + + self.quiz_messages[msg.id] = valid_emojis + + await asyncio.sleep(TIMELIMIT) + + del self.quiz_messages[msg.id] + + msg = await ctx.channel.fetch_message(msg.id) # Refreshes message + + total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions + + if total_no == 0: + return await msg.delete() # to avoid ZeroDivisionError if nobody reacts + + results = ["**VOTES:**"] + for emoji, _ in answers: + num = [len(await r.users().flatten()) for r in msg.reactions if str(r.emoji) == emoji][0] - 1 + percent = round(100 * num / total_no) + s = "" if num == 1 else "s" + string = f"{emoji} - {num} vote{s} ({percent}%)" + results.append(string) + + mentions = " ".join([ + u.mention for u in [ + await r.users().flatten() for r in msg.reactions if str(r.emoji) == correct + ][0] if not u.bot + ]) + + content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..." + + a_embed = discord.Embed( + title=f"The correct answer was {correct}!", + description="\n".join(results), + colour=Colours.pink + ) + + await ctx.send(content, embed=a_embed) + + @staticmethod + async def already_reacted(message, user): + """Returns whether a given user has reacted more than once to a given message""" + users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction] + return users.count(user.id) > 1 # Old reaction plus new reaction + + @commands.Cog.listener() + async def on_reaction_add(self, reaction, user): + """Listener to listen specifically for reactions of quiz messages""" + if user.bot: + return + if reaction.message.id not in self.quiz_messages: + return + if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]: + return await reaction.message.remove_reaction(reaction, user) + if await self.already_reacted(reaction.message, user): + return await reaction.message.remove_reaction(reaction, user) + + +def setup(bot): + """Cog load.""" + + bot.add_cog(EggheadQuiz(bot)) + log.info("EggheadQuiz bot loaded") diff --git a/bot/seasons/easter/traditions.py b/bot/seasons/easter/traditions.py new file mode 100644 index 00000000..05cd79f3 --- /dev/null +++ b/bot/seasons/easter/traditions.py @@ -0,0 +1,33 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + +with open(Path('bot', 'resources', 'easter', 'traditions.json'), 'r', encoding="utf8") as f: + traditions = json.load(f) + + +class Traditions(commands.Cog): + """A cog which allows users to get a random easter tradition or custom from a random country.""" + + def __init__(self, bot): + self.bot = bot + + @commands.command(aliases=('eastercustoms',)) + async def easter_tradition(self, ctx): + """Responds with a random tradition or custom""" + + random_country = random.choice(list(traditions)) + + await ctx.send(f"{random_country}:\n{traditions[random_country]}") + + +def setup(bot): + """Traditions Cog load.""" + + bot.add_cog(Traditions(bot)) + log.info("Traditions cog loaded") diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index db610e7c..ac32c199 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -2,4 +2,6 @@ from bot.seasons import SeasonBase class Evergreen(SeasonBase): + """Evergreen Seasonal event attributes.""" + bot_icon = "/logos/logo_seasonal/evergreen/logo_evergreen.png" diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index 7774f06e..26afe814 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -9,7 +9,7 @@ log = logging.getLogger(__name__) class CommandErrorHandler(commands.Cog):
- """A error handler for the PythonDiscord server!"""
+ """A error handler for the PythonDiscord server."""
def __init__(self, bot):
self.bot = bot
@@ -26,7 +26,7 @@ class CommandErrorHandler(commands.Cog): @commands.Cog.listener()
async def on_command_error(self, ctx, error):
- """Activates when a command opens an error"""
+ """Activates when a command opens an error."""
if hasattr(ctx.command, 'on_error'):
return logging.debug(
@@ -100,5 +100,7 @@ class CommandErrorHandler(commands.Cog): def setup(bot):
+ """Error handler Cog load."""
+
bot.add_cog(CommandErrorHandler(bot))
log.info("CommandErrorHandler cog loaded")
diff --git a/bot/seasons/evergreen/fun.py b/bot/seasons/evergreen/fun.py index 9ef47331..05cf504e 100644 --- a/bot/seasons/evergreen/fun.py +++ b/bot/seasons/evergreen/fun.py @@ -9,18 +9,15 @@ log = logging.getLogger(__name__) class Fun(commands.Cog): - """ - A collection of general commands for fun. - """ + """A collection of general commands for fun.""" def __init__(self, bot): self.bot = bot @commands.command() async def roll(self, ctx, num_rolls: int = 1): - """ - Outputs a number of random dice emotes (up to 6) - """ + """Outputs a number of random dice emotes (up to 6).""" + output = "" if num_rolls > 6: num_rolls = 6 @@ -32,7 +29,8 @@ class Fun(commands.Cog): await ctx.send(output) -# Required in order to load the cog, use the class name in the add_cog function. def setup(bot): + """Fun Cog load.""" + bot.add_cog(Fun(bot)) log.info("Fun cog loaded") diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/seasons/evergreen/magic_8ball.py index 88c9fd26..0b4eeb62 100644 --- a/bot/seasons/evergreen/magic_8ball.py +++ b/bot/seasons/evergreen/magic_8ball.py @@ -8,10 +8,8 @@ from discord.ext import commands log = logging.getLogger(__name__) -class Magic8ball: - """ - A Magic 8ball command to respond to a users question. - """ +class Magic8ball(commands.Cog): + """A Magic 8ball command to respond to a user's question.""" def __init__(self, bot): self.bot = bot @@ -20,9 +18,7 @@ class Magic8ball: @commands.command(name="8ball") async def output_answer(self, ctx, *, question): - """ - Return a magic 8 ball answer from answers list. - """ + """Return a magic 8 ball answer from answers list.""" if len(question.split()) >= 3: answer = random.choice(self.answers) await ctx.send(answer) @@ -30,7 +26,8 @@ class Magic8ball: await ctx.send("Usage: .8ball <question> (minimum length of 3 eg: `will I win?`)") -# Required in order to load the cog, use the class name in the add_cog function. def setup(bot): + """Magic 8ball cog load.""" + bot.add_cog(Magic8ball(bot)) - log.info("Magic 8ball cog loaded") + log.info("Magic8ball cog loaded") diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py index 6fb1f673..5188200e 100644 --- a/bot/seasons/evergreen/snakes/__init__.py +++ b/bot/seasons/evergreen/snakes/__init__.py @@ -6,5 +6,7 @@ log = logging.getLogger(__name__) def setup(bot): + """Snakes Cog load.""" + bot.add_cog(Snakes(bot)) log.info("Snakes cog loaded") diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py index c091d9c1..ec9c9870 100644 --- a/bot/seasons/evergreen/snakes/converter.py +++ b/bot/seasons/evergreen/snakes/converter.py @@ -13,10 +13,14 @@ log = logging.getLogger(__name__) class Snake(Converter): + """Snake converter for the Snakes Cog.""" + snakes = None special_cases = None async def convert(self, ctx, name): + """Convert the input snake name to the closest matching Snake object.""" + await self.build_list() name = name.lower() @@ -56,6 +60,8 @@ class Snake(Converter): @classmethod async def build_list(cls): + """Build list of snakes from the static snake resources.""" + # Get all the snakes if cls.snakes is None: with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: @@ -70,11 +76,14 @@ class Snake(Converter): @classmethod async def random(cls): """ - This is stupid. We should find a way to - somehow get the global session into a - global context, so I can get it from here. + Get a random Snake from the loaded resources. + + This is stupid. We should find a way to somehow get the global session into a global context, + so I can get it from here. + :return: """ + await cls.build_list() names = [snake['scientific'] for snake in cls.snakes] return random.choice(names) diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py index 74d2ab4f..3ffdf1bf 100644 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -134,12 +134,11 @@ CARD = { class Snakes(Cog): """ - Commands related to snakes. These were created by our - community during the first code jam. + Commands related to snakes, created by our community during the first code jam. More information can be found in the code-jam-1 repo. - https://gitlab_bot_repo.com/discord-python/code-jams/code-jam-1 + https://github.com/python-discord/code-jam-1 """ wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) @@ -156,9 +155,8 @@ class Snakes(Cog): # region: Helper methods @staticmethod def _beautiful_pastel(hue): - """ - Returns random bright pastels. - """ + """Returns random bright pastels.""" + light = random.uniform(0.7, 0.85) saturation = 1 @@ -178,6 +176,7 @@ class Snakes(Cog): Written by juan and Someone during the first code jam. """ + snake = Image.open(buffer) # Get the size of the snake icon, configure the height of the image box (yes, it changes) @@ -254,9 +253,8 @@ class Snakes(Cog): @staticmethod def _snakify(message): - """ - Sssnakifffiesss a sstring. - """ + """Sssnakifffiesss a sstring.""" + # Replace fricatives with exaggerated snake fricatives. simple_fricatives = [ "f", "s", "z", "h", @@ -278,9 +276,8 @@ class Snakes(Cog): return message async def _fetch(self, session, url, params=None): - """ - Asyncronous web request helper method. - """ + """Asynchronous web request helper method.""" + if params is None: params = {} @@ -290,11 +287,11 @@ class Snakes(Cog): def _get_random_long_message(self, messages, retries=10): """ - Fetch a message that's at least 3 words long, - but only if it is possible to do so in retries - attempts. Else, just return whatever the last - message is. + Fetch a message that's at least 3 words long, if possible to do so in retries attempts. + + Else, just return whatever the last message is. """ + long_message = random.choice(messages) if len(long_message.split()) < 3 and retries > 0: return self._get_random_long_message( @@ -306,14 +303,16 @@ class Snakes(Cog): async def _get_snek(self, name: str) -> Dict[str, Any]: """ - Goes online and fetches all the data from a wikipedia article - about a snake. Builds a dict that the .get() method can use. + Fetches all the data from a wikipedia article about a snake. + + Builds a dict that the .get() method can use. Created by Ava and eivl. :param name: The name of the snake to get information for - omit for a random snake :return: A dict containing information on a snake """ + snake_info = {} async with aiohttp.ClientSession() as session: @@ -412,20 +411,21 @@ class Snakes(Cog): async def _get_snake_name(self) -> Dict[str, str]: """ Gets a random snake name. + :return: A random snake name, as a string. """ return random.choice(self.snake_names) async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list): """ - Validate the answer using a reaction event loop + Validate the answer using a reaction event loop. + :return: """ def predicate(reaction, user): - """ - Test if the the answer is valid and can be evaluated. - """ + """Test if the the answer is valid and can be evaluated.""" + return ( reaction.message.id == message.id # The reaction is attached to the question we asked. and user == ctx.author # It's the user who triggered the quiz. @@ -465,7 +465,7 @@ class Snakes(Cog): @locked() async def antidote_command(self, ctx: Context): """ - Antidote - Can you create the antivenom before the patient dies? + Antidote! Can you create the antivenom before the patient dies? Rules: You have 4 ingredients for each antidote, you only have 10 attempts Once you synthesize the antidote, you will be presented with 4 markers @@ -480,9 +480,7 @@ class Snakes(Cog): """ def predicate(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" return ( all(( @@ -610,7 +608,7 @@ class Snakes(Cog): @snakes_group.command(name='draw') async def draw_command(self, ctx: Context): """ - Draws a random snek using Perlin noise + Draws a random snek using Perlin noise. Written by Momo and kel. Modified by juan and lemon. @@ -652,12 +650,14 @@ class Snakes(Cog): async def get_command(self, ctx: Context, *, name: Snake = None): """ Fetches information about a snake from Wikipedia. + :param ctx: Context object passed from discord.py :param name: Optional, the name of the snake to get information for - omit for a random snake Created by Ava and eivl. """ + with ctx.typing(): if name is None: name = await Snake.random() @@ -702,11 +702,12 @@ class Snakes(Cog): @locked() async def guess_command(self, ctx): """ - Snake identifying game! + Snake identifying game. Made by Ava and eivl. Modified by lemon. """ + with ctx.typing(): image = None @@ -736,10 +737,11 @@ class Snakes(Cog): @snakes_group.command(name='hatch') async def hatch_command(self, ctx: Context): """ - Hatches your personal snake + Hatches your personal snake. Written by Momo and kel. """ + # Pick a random snake to hatch. snake_name = random.choice(list(utils.snakes.keys())) snake_image = utils.snakes[snake_name] @@ -772,6 +774,7 @@ class Snakes(Cog): Written by Samuel. Modified by gdude. """ + url = "http://www.omdbapi.com/" page = random.randint(1, 27) @@ -842,6 +845,7 @@ class Snakes(Cog): This was created by Mushy and Cardium, and modified by Urthas and lemon. """ + # Prepare a question. question = random.choice(self.snake_quizzes) answer = question["answerkey"] @@ -862,6 +866,8 @@ class Snakes(Cog): @snakes_group.command(name='name', aliases=('name_gen',)) async def name_command(self, ctx: Context, *, name: str = None): """ + Snakifies a username. + Slices the users name at the last vowel (or second last if the name ends with a vowel), and then combines it with a random snake name, which is sliced at the first vowel (or second if the name starts with @@ -880,6 +886,7 @@ class Snakes(Cog): This was written by Iceman, and modified for inclusion into the bot by lemon. """ + snake_name = await self._get_snake_name() snake_name = snake_name['name'] snake_prefix = "" @@ -932,11 +939,12 @@ class Snakes(Cog): @locked() async def sal_command(self, ctx: Context): """ - Play a game of Snakes and Ladders! + Play a game of Snakes and Ladders. Written by Momo and kel. Modified by lemon. """ + # check if there is already a game in this channel if ctx.channel in self.active_sal: await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") @@ -949,10 +957,8 @@ class Snakes(Cog): @snakes_group.command(name='about') async def about_command(self, ctx: Context): - """ - A command that shows an embed with information about the event, - it's participants, and its winners. - """ + """Show an embed with information about the event, its participants, and its winners.""" + contributors = [ "<@!245270749919576066>", "<@!396290259907903491>", @@ -996,10 +1002,11 @@ class Snakes(Cog): @snakes_group.command(name='card') async def card_command(self, ctx: Context, *, name: Snake = None): """ - Create an interesting little card from a snake! + Create an interesting little card from a snake. Created by juan and Someone during the first code jam. """ + # Get the snake data we need if not name: name_obj = await self._get_snake_name() @@ -1034,11 +1041,12 @@ class Snakes(Cog): @snakes_group.command(name='fact') async def fact_command(self, ctx: Context): """ - Gets a snake-related fact + Gets a snake-related fact. Written by Andrew and Prithaj. Modified by lemon. """ + question = random.choice(self.snake_facts)["fact"] embed = Embed( title="Snake fact", @@ -1049,9 +1057,8 @@ class Snakes(Cog): @snakes_group.command(name='help') async def help_command(self, ctx: Context): - """ - This just invokes the help command on this cog. - """ + """Invokes the help command for the Snakes Cog.""" + log.debug(f"{ctx.author} requested info about the snakes cog") return await ctx.invoke(self.bot.get_command("help"), "Snakes") @@ -1059,6 +1066,7 @@ class Snakes(Cog): async def snakify_command(self, ctx: Context, *, message: str = None): """ How would I talk if I were a snake? + :param ctx: context :param message: If this is passed, it will snakify the message. If not, it will snakify a random message from @@ -1067,6 +1075,7 @@ class Snakes(Cog): Written by Momo and kel. Modified by lemon. """ + with ctx.typing(): embed = Embed() user = ctx.message.author @@ -1100,13 +1109,14 @@ class Snakes(Cog): @snakes_group.command(name='video', aliases=('get_video',)) async def video_command(self, ctx: Context, *, search: str = None): """ - Gets a YouTube video about snakes + Gets a YouTube video about snakes. :param ctx: Context object passed from discord.py :param search: Optional, a name of a snake. Used to search for videos with that name Written by Andrew and Prithaj. """ + # Are we searching for anything specific? if search: query = search + ' snake' @@ -1141,12 +1151,12 @@ class Snakes(Cog): @snakes_group.command(name='zen') async def zen_command(self, ctx: Context): """ - Gets a random quote from the Zen of Python, - except as if spoken by a snake. + Gets a random quote from the Zen of Python, except as if spoken by a snake. Written by Prithaj and Andrew. Modified by lemon. """ + embed = Embed( title="Zzzen of Pythhon", color=SNAKE_COLOR @@ -1168,6 +1178,7 @@ class Snakes(Cog): @card_command.error @video_command.error async def command_error(self, ctx, error): + """Local error handler for the Snake Cog.""" embed = Embed() embed.colour = Colour.red() diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py index ec280223..e2ed60bd 100644 --- a/bot/seasons/evergreen/snakes/utils.py +++ b/bot/seasons/evergreen/snakes/utils.py @@ -1,8 +1,3 @@ -""" -Perlin noise implementation. -Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 -Licensed under ISC -""" import asyncio import io import json @@ -117,43 +112,54 @@ ANGLE_RANGE = math.pi * 2 def get_resource(file: str) -> List[dict]: + """Load Snake resources JSON.""" + with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile: return json.load(snakefile) def smoothstep(t): - """Smooth curve with a zero derivative at 0 and 1, making it useful for - interpolating. - """ + """Smooth curve with a zero derivative at 0 and 1, making it useful for interpolating.""" + return t * t * (3. - 2. * t) def lerp(t, a, b): """Linear interpolation between a and b, given a fraction t.""" + return a + t * (b - a) class PerlinNoiseFactory(object): - """Callable that produces Perlin noise for an arbitrary point in an - arbitrary number of dimensions. The underlying grid is aligned with the - integers. - There is no limit to the coordinates used; new gradients are generated on - the fly as necessary. + """ + Callable that produces Perlin noise for an arbitrary point in an arbitrary number of dimensions. + + The underlying grid is aligned with the integers. + + There is no limit to the coordinates used; new gradients are generated on the fly as necessary. + + Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 + Licensed under ISC """ def __init__(self, dimension, octaves=1, tile=(), unbias=False): - """Create a new Perlin noise factory in the given number of dimensions, - which should be an integer and at least 1. - More octaves create a foggier and more-detailed noise pattern. More - than 4 octaves is rather excessive. - ``tile`` can be used to make a seamlessly tiling pattern. For example: + """ + Create a new Perlin noise factory in the given number of dimensions. + + dimension should be an integer and at least 1. + + More octaves create a foggier and more-detailed noise pattern. More than 4 octaves is rather excessive. + + ``tile`` can be used to make a seamlessly tiling pattern. + For example: pnf = PerlinNoiseFactory(2, tile=(0, 3)) - This will produce noise that tiles every 3 units vertically, but never - tiles horizontally. - If ``unbias`` is true, the smoothstep function will be applied to the - output before returning it, to counteract some of Perlin noise's - significant bias towards the center of its output range. + + This will produce noise that tiles every 3 units vertically, but never tiles horizontally. + + If ``unbias`` is true, the smoothstep function will be applied to the output before returning + it, to counteract some of Perlin noise's significant bias towards the center of its output range. """ + self.dimension = dimension self.octaves = octaves self.tile = tile + (0,) * dimension @@ -166,8 +172,11 @@ class PerlinNoiseFactory(object): self.gradient = {} def _generate_gradient(self): - # Generate a random unit vector at each grid point -- this is the - # "gradient" vector, in that the grid tile slopes towards it + """ + Generate a random unit vector at each grid point. + + This is the "gradient" vector, in that the grid tile slopes towards it + """ # 1 dimension is special, since the only unit vector is trivial; # instead, use a slope between -1 and 1 @@ -184,9 +193,8 @@ class PerlinNoiseFactory(object): return tuple(coord * scale for coord in random_point) def get_plain_noise(self, *point): - """Get plain noise for a single point, without taking into account - either octaves or tiling. - """ + """Get plain noise for a single point, without taking into account either octaves or tiling.""" + if len(point) != self.dimension: raise ValueError("Expected {0} values, got {1}".format( self.dimension, len(point))) @@ -234,9 +242,12 @@ class PerlinNoiseFactory(object): return dots[0] * self.scale_factor def __call__(self, *point): - """Get the value of this Perlin noise function at the given point. The - number of values given should match the number of dimensions. """ + Get the value of this Perlin noise function at the given point. + + The number of values given should match the number of dimensions. + """ + ret = 0 for o in range(self.octaves): o2 = 1 << o @@ -281,6 +292,7 @@ def create_snek_frame( ) -> Image: """ Creates a single random snek frame using Perlin noise. + :param perlin_factory: the perlin noise factory used. Required. :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame :param image_dimensions: the size of the output image. @@ -288,14 +300,15 @@ def create_snek_frame( :param snake_length: the length of the snake, in segments. :param snake_color: the color of the snake. :param bg_color: the background color. - :param segment_length_range: the range of the segment length. Values will be generated inside this range, including - the bounds. + :param segment_length_range: the range of the segment length. Values will be generated inside + this range, including the bounds. :param snake_width: the width of the snek, in pixels. :param text: the text to display with the snek. Set to None for no text. :param text_position: the position of the text. :param text_color: the color of the text. :return: a PIL image, representing a single frame. """ + start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) points = [(start_x, start_y)] @@ -349,6 +362,8 @@ def create_snek_frame( def frame_to_png_bytes(image: Image): + """Convert image to byte stream.""" + stream = io.BytesIO() image.save(stream, format='PNG') return stream.getvalue() @@ -371,6 +386,8 @@ GAME_SCREEN_EMOJI = [ class SnakeAndLaddersGame: + """Snakes and Ladders game Cog.""" + def __init__(self, snakes, context: Context): self.snakes = snakes self.ctx = context @@ -393,10 +410,10 @@ class SnakeAndLaddersGame: Listen for reactions until players have joined, and the game has been started. """ + def startup_event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" + return ( all(( reaction_.message.id == startup.id, # Reaction is on startup message @@ -471,6 +488,13 @@ class SnakeAndLaddersGame: self.avatar_images[user.id] = im async def player_join(self, user: Member): + """ + Handle players joining the game. + + Prevent player joining if they have already joined, if the game is full, or if the game is + in a waiting state. + """ + for p in self.players: if user == p: await self.channel.send(user.mention + " You are already in the game.", delete_after=10) @@ -491,6 +515,13 @@ class SnakeAndLaddersGame: ) async def player_leave(self, user: Member): + """ + Handle players leaving the game. + + Leaving is prevented if the user initiated the game or if they weren't part of it in the + first place. + """ + if user == self.author: await self.channel.send( user.mention + " You are the author, and cannot leave the game. Execute " @@ -515,6 +546,8 @@ class SnakeAndLaddersGame: await self.channel.send(user.mention + " You are not in the match.", delete_after=10) async def cancel_game(self, user: Member): + """Allow the game author to cancel the running game.""" + if not user == self.author: await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10) return @@ -522,6 +555,13 @@ class SnakeAndLaddersGame: self._destruct() async def start_game(self, user: Member): + """ + Allow the game author to begin the game. + + The game cannot be started if there aren't enough players joined or if the game is in a + waiting state. + """ + if not user == self.author: await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) return @@ -540,10 +580,11 @@ class SnakeAndLaddersGame: await self.start_round() async def start_round(self): + """Begin the round.""" + def game_event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" + return ( all(( reaction_.message.id == self.positions.id, # Reaction is on positions message @@ -634,6 +675,8 @@ class SnakeAndLaddersGame: await self._complete_round() async def player_roll(self, user: Member): + """Handle the player's roll.""" + if user.id not in self.player_tiles: await self.channel.send(user.mention + " You are not in the match.", delete_after=10) return diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py index 3d2c7d03..32c2b59d 100644 --- a/bot/seasons/evergreen/uptime.py +++ b/bot/seasons/evergreen/uptime.py @@ -10,18 +10,15 @@ log = logging.getLogger(__name__) class Uptime(commands.Cog): - """ - A cog for posting the bots uptime. - """ + """A cog for posting the bot's uptime.""" def __init__(self, bot): self.bot = bot @commands.command(name="uptime") async def uptime(self, ctx): - """ - Returns the uptime of the bot. - """ + """Responds with the uptime of the bot.""" + difference = relativedelta(start_time - arrow.utcnow()) uptime_string = start_time.shift( seconds=-difference.seconds, @@ -32,7 +29,8 @@ class Uptime(commands.Cog): await ctx.send(f"I started up {uptime_string}.") -# Required in order to load the cog, use the class name in the add_cog function. def setup(bot): + """Uptime Cog load.""" + bot.add_cog(Uptime(bot)) log.info("Uptime cog loaded") diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py index 4b371f14..74c962ed 100644 --- a/bot/seasons/halloween/__init__.py +++ b/bot/seasons/halloween/__init__.py @@ -3,6 +3,8 @@ from bot.seasons import SeasonBase class Halloween(SeasonBase): + """Halloween Seasonal event attributes.""" + name = "halloween" bot_name = "Spookybot" greeting = "Happy Halloween!" diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py index 6932097c..f8ab4c60 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -7,7 +7,7 @@ import random import discord from discord.ext import commands -from bot.constants import Hacktoberfest +from bot.constants import Channels log = logging.getLogger(__name__) @@ -21,6 +21,8 @@ ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% class CandyCollection(commands.Cog): + """Candy collection game Cog.""" + def __init__(self, bot): self.bot = bot with open(json_location) as candy: @@ -33,15 +35,13 @@ class CandyCollection(commands.Cog): @commands.Cog.listener() async def on_message(self, message): - """ - Randomly adds candy or skull to certain messages - """ + """Randomly adds candy or skull reaction to non-bot messages in the Event channel.""" # make sure its a human message if message.author.bot: return # ensure it's hacktober channel - if message.channel.id != Hacktoberfest.channel_id: + if message.channel.id != Channels.seasonalbot_chat: return # do random check for skull first as it has the lower chance @@ -57,9 +57,7 @@ class CandyCollection(commands.Cog): @commands.Cog.listener() async def on_reaction_add(self, reaction, user): - """ - Add/remove candies from a person if the reaction satisfies criteria - """ + """Add/remove candies from a person if the reaction satisfies criteria.""" message = reaction.message # check to ensure the reactor is human @@ -67,7 +65,7 @@ class CandyCollection(commands.Cog): return # check to ensure it is in correct channel - if message.channel.id != Hacktoberfest.channel_id: + if message.channel.id != Channels.seasonalbot_chat: return # if its not a candy or skull, and it is one of 10 most recent messages, @@ -107,8 +105,10 @@ class CandyCollection(commands.Cog): async def reacted_msg_chance(self, message): """ - Randomly add a skull or candy to a message if there is a reaction there already - (higher probability) + Randomly add a skull or candy reaction to a message if there is a reaction there already. + + This event has a higher probability of occurring than a reaction add to a message without an + existing reaction. """ if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1: @@ -122,11 +122,12 @@ class CandyCollection(commands.Cog): return await message.add_reaction('\N{CANDY}') async def ten_recent_msg(self): - """Get the last 10 messages sent in the channel""" + """Get the last 10 messages sent in the channel.""" + ten_recent = [] recent_msg = max(message.id for message in self.bot._connection._messages - if message.channel.id == Hacktoberfest.channel_id) + if message.channel.id == Channels.seasonalbot_chat) channel = await self.hacktober_channel() ten_recent.append(recent_msg.id) @@ -139,9 +140,7 @@ class CandyCollection(commands.Cog): return ten_recent async def get_message(self, msg_id): - """ - Get the message from it's ID. - """ + """Get the message from its ID.""" try: o = discord.Object(id=msg_id + 1) @@ -158,15 +157,12 @@ class CandyCollection(commands.Cog): return None async def hacktober_channel(self): - """ - Get #hacktoberbot channel from it's id - """ - return self.bot.get_channel(id=Hacktoberfest.channel_id) + """Get #hacktoberbot channel from its ID.""" + + return self.bot.get_channel(id=Channels.seasonalbot_chat) async def remove_reactions(self, reaction): - """ - Remove all candy/skull reactions - """ + """Remove all candy/skull reactions.""" try: async for user in reaction.users(): @@ -176,26 +172,22 @@ class CandyCollection(commands.Cog): pass async def send_spook_msg(self, author, channel, candies): - """ - Send a spooky message - """ + """Send a spooky message.""" + e = discord.Embed(colour=author.colour) e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; " f"I took {candies} candies and quickly took flight.") await channel.send(embed=e) def save_to_json(self): - """ - Save json to the file. - """ + """Save JSON to a local file.""" + with open(json_location, 'w') as outfile: json.dump(self.candy_json, outfile) @commands.command() async def candy(self, ctx): - """ - Get the candy leaderboard and save to json when this is called - """ + """Get the candy leaderboard and save to JSON.""" # use run_in_executor to prevent blocking thing = functools.partial(self.save_to_json) @@ -232,5 +224,7 @@ class CandyCollection(commands.Cog): def setup(bot): + """Candy Collection game Cog load.""" + bot.add_cog(CandyCollection(bot)) log.info("CandyCollection cog loaded") diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index 81f11455..42623669 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -14,6 +14,8 @@ log = logging.getLogger(__name__) class HacktoberStats(commands.Cog): + """Hacktoberfest statistics Cog.""" + def __init__(self, bot): self.bot = bot self.link_json = Path("bot", "resources", "github_links.json") @@ -26,11 +28,13 @@ class HacktoberStats(commands.Cog): ) async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None): """ - If invoked without a subcommand or github_username, get the invoking user's stats if - they've linked their Discord name to GitHub using .stats link + Display an embed for a user's Hacktoberfest contributions. - If invoked with a github_username, get that user's contributions + If invoked without a subcommand or github_username, get the invoking user's stats if they've + linked their Discord name to GitHub using .stats link. If invoked with a github_username, + get that user's contributions """ + if not github_username: author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) @@ -51,7 +55,7 @@ class HacktoberStats(commands.Cog): @hacktoberstats_group.command(name="link") async def link_user(self, ctx: commands.Context, github_username: str = None): """ - Link the invoking user's Github github_username to their Discord ID + Link the invoking user's Github github_username to their Discord ID. Linked users are stored as a nested dict: { @@ -61,6 +65,7 @@ class HacktoberStats(commands.Cog): } } """ + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) if github_username: if str(author_id) in self.linked_accounts.keys(): @@ -83,9 +88,8 @@ class HacktoberStats(commands.Cog): @hacktoberstats_group.command(name="unlink") async def unlink_user(self, ctx: commands.Context): - """ - Remove the invoking user's account link from the log - """ + """Remove the invoking user's account link from the log.""" + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) stored_user = self.linked_accounts.pop(author_id, None) @@ -100,7 +104,7 @@ class HacktoberStats(commands.Cog): def load_linked_users(self) -> typing.Dict: """ - Load list of linked users from local JSON file + Load list of linked users from local JSON file. Linked users are stored as a nested dict: { @@ -110,6 +114,7 @@ class HacktoberStats(commands.Cog): } } """ + if self.link_json.exists(): logging.info(f"Loading linked GitHub accounts from '{self.link_json}'") with open(self.link_json, 'r') as fID: @@ -123,7 +128,7 @@ class HacktoberStats(commands.Cog): def save_linked_users(self): """ - Save list of linked users to local JSON file + Save list of linked users to local JSON file. Linked users are stored as a nested dict: { @@ -133,6 +138,7 @@ class HacktoberStats(commands.Cog): } } """ + logging.info(f"Saving linked_accounts to '{self.link_json}'") with open(self.link_json, 'w') as fID: json.dump(self.linked_accounts, fID, default=str) @@ -140,16 +146,15 @@ class HacktoberStats(commands.Cog): async def get_stats(self, ctx: commands.Context, github_username: str): """ - Query GitHub's API for PRs created by a GitHub user during the month of October that - do not have an 'invalid' tag + Query GitHub's API for PRs created by a GitHub user during the month of October. - For example: - !getstats heavysaturn + PRs with the 'invalid' tag are ignored If a valid github_username is provided, an embed is generated and posted to the channel Otherwise, post a helpful error message """ + async with ctx.typing(): prs = await self.get_october_prs(github_username) @@ -160,9 +165,8 @@ class HacktoberStats(commands.Cog): await ctx.send(f"No October GitHub contributions found for '{github_username}'") def build_embed(self, github_username: str, prs: typing.List[dict]) -> discord.Embed: - """ - Return a stats embed built from github_username's PRs - """ + """Return a stats embed built from github_username's PRs.""" + logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'") pr_stats = self._summarize_prs(prs) @@ -202,8 +206,9 @@ class HacktoberStats(commands.Cog): @staticmethod async def get_october_prs(github_username: str) -> typing.List[dict]: """ - Query GitHub's API for PRs created during the month of October by github_username - that do not have an 'invalid' tag + Query GitHub's API for PRs created during the month of October by github_username. + + PRs with an 'invalid' tag are ignored If PRs are found, return a list of dicts with basic PR information @@ -216,6 +221,7 @@ class HacktoberStats(commands.Cog): Otherwise, return None """ + logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'") base_url = "https://api.github.com/search/issues?q=" not_label = "invalid" @@ -265,20 +271,21 @@ class HacktoberStats(commands.Cog): @staticmethod def _get_shortname(in_url: str) -> str: """ - Extract shortname from https://api.github.com/repos/* URL + Extract shortname from https://api.github.com/repos/* URL. e.g. "https://api.github.com/repos/python-discord/seasonalbot" | V "python-discord/seasonalbot" """ + exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)" return re.findall(exp, in_url)[0] @staticmethod def _summarize_prs(prs: typing.List[dict]) -> typing.Dict: """ - Generate statistics from an input list of PR dictionaries, as output by get_october_prs + Generate statistics from an input list of PR dictionaries, as output by get_october_prs. Return a dictionary containing: { @@ -286,13 +293,14 @@ class HacktoberStats(commands.Cog): "top5": [(repo_shortname, ncontributions), ...] } """ + contributed_repos = [pr["repo_shortname"] for pr in prs] return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)} @staticmethod def _build_top5str(stats: typing.List[tuple]) -> str: """ - Build a string from the Top 5 contributions that is compatible with a discord.Embed field + Build a string from the Top 5 contributions that is compatible with a discord.Embed field. Top 5 contributions should be a list of tuples, as output in the stats dictionary by _summarize_prs @@ -301,6 +309,7 @@ class HacktoberStats(commands.Cog): n contribution(s) to [shortname](url) ... """ + baseURL = "https://www.github.com/" contributionstrs = [] for repo in stats['top5']: @@ -311,9 +320,8 @@ class HacktoberStats(commands.Cog): @staticmethod def _contributionator(n: int) -> str: - """ - Return "contribution" or "contributions" based on the value of n - """ + """Return "contribution" or "contributions" based on the value of n.""" + if n == 1: return "contribution" else: @@ -321,9 +329,8 @@ class HacktoberStats(commands.Cog): @staticmethod def _author_mention_from_context(ctx: commands.Context) -> typing.Tuple: - """ - Return stringified Message author ID and mentionable string from commands.Context - """ + """Return stringified Message author ID and mentionable string from commands.Context.""" + author_id = str(ctx.message.author.id) author_mention = ctx.message.author.mention @@ -331,5 +338,7 @@ class HacktoberStats(commands.Cog): def setup(bot): + """Hacktoberstats Cog load.""" + bot.add_cog(HacktoberStats(bot)) log.info("HacktoberStats cog loaded") diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index 9224cc57..ad9aa716 100644 --- a/bot/seasons/halloween/halloween_facts.py +++ b/bot/seasons/halloween/halloween_facts.py @@ -7,7 +7,7 @@ from pathlib import Path import discord from discord.ext import commands -from bot.constants import Hacktoberfest +from bot.constants import Channels log = logging.getLogger(__name__) @@ -26,6 +26,7 @@ INTERVAL = timedelta(hours=6).total_seconds() class HalloweenFacts(commands.Cog): + """A Cog for displaying interesting facts about Halloween.""" def __init__(self, bot): self.bot = bot @@ -37,31 +38,35 @@ class HalloweenFacts(commands.Cog): @commands.Cog.listener() async def on_ready(self): - self.channel = self.bot.get_channel(Hacktoberfest.channel_id) + """Get event Channel object and initialize fact task loop.""" + + self.channel = self.bot.get_channel(Channels.seasonalbot_chat) self.bot.loop.create_task(self._fact_publisher_task()) def random_fact(self): + """Return a random fact from the loaded facts.""" + return random.choice(self.facts) @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. - """ + """Reply with the most recent Halloween fact.""" + index, fact = self.random_fact() embed = self._build_embed(index, fact) await ctx.send(embed=embed) @staticmethod def _build_embed(index, fact): - """ - Builds a Discord embed from the given fact and its index. - """ + """Builds a Discord embed from the given fact and its index.""" + emoji = random.choice(SPOOKY_EMOJIS) title = f"{emoji} Halloween Fact #{index + 1}" return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) def setup(bot): + """Halloween facts Cog load.""" + bot.add_cog(HalloweenFacts(bot)) log.info("HalloweenFacts cog loaded") diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py index 0d6964a5..ce057889 100644 --- a/bot/seasons/halloween/halloweenify.py +++ b/bot/seasons/halloween/halloweenify.py @@ -11,9 +11,7 @@ log = logging.getLogger(__name__) class Halloweenify(commands.Cog): - """ - A cog to change a invokers nickname to a spooky one! - """ + """A cog to change a invokers nickname to a spooky one!""" def __init__(self, bot): self.bot = bot @@ -21,9 +19,8 @@ class Halloweenify(commands.Cog): @commands.cooldown(1, 300, BucketType.user) @commands.command() async def halloweenify(self, ctx): - """ - Change your nickname into a much spookier one! - """ + """Change your nickname into a much spookier one.""" + async with ctx.typing(): with open(Path('bot', 'resources', 'halloween', 'halloweenify.json'), 'r') as f: data = load(f) @@ -51,5 +48,7 @@ class Halloweenify(commands.Cog): def setup(bot): + """Halloweenify Cog load.""" + bot.add_cog(Halloweenify(bot)) log.info("Halloweenify cog loaded") diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py index 2b251b90..2ae98f6e 100644 --- a/bot/seasons/halloween/monstersurvey.py +++ b/bot/seasons/halloween/monstersurvey.py @@ -16,8 +16,10 @@ EMOJIS = { class MonsterSurvey(Cog): """ - Vote for your favorite monster! - This command allows users to vote for their favorite listed monster. + Vote for your favorite monster. + + This Cog allows users to vote for their favorite listed monster. + Users may change their vote, but only their current vote will be counted. """ @@ -30,12 +32,18 @@ class MonsterSurvey(Cog): self.voter_registry = json.load(jason) def json_write(self): + """Write voting results to a local JSON file.""" + log.info("Saved Monster Survey Results") with open(self.registry_location, 'w') as jason: json.dump(self.voter_registry, jason, indent=2) def cast_vote(self, id: int, monster: str): """ + Cast a user's vote for the specified monster. + + If the user has already voted, their existing vote is removed. + :param id: The id of the person voting :param monster: the string key of the json that represents a monster :return: None @@ -50,6 +58,8 @@ class MonsterSurvey(Cog): vr[m]['votes'].remove(id) def get_name_by_leaderboard_index(self, n): + """Return the monster at the specified leaderboard index.""" + n = n - 1 vr = self.voter_registry top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) @@ -61,9 +71,7 @@ class MonsterSurvey(Cog): aliases=('ms',) ) async def monster_group(self, ctx: Context): - """ - The base voting command. If nothing is called, then it will return an embed. - """ + """The base voting command. If nothing is called, then it will return an embed.""" if ctx.invoked_subcommand is None: async with ctx.typing(): @@ -95,8 +103,9 @@ class MonsterSurvey(Cog): ) async def monster_vote(self, ctx: Context, name=None): """ - Casts a vote for a particular monster, or displays a list of monsters that can be voted for - if one is not given. + Cast a vote for a particular monster. + + Displays a list of monsters that can be voted for if one is not specified. """ if name is None: @@ -185,6 +194,7 @@ class MonsterSurvey(Cog): async def monster_leaderboard(self, ctx: Context): """ Shows the current standings. + :param ctx: :return: """ @@ -214,5 +224,7 @@ class MonsterSurvey(Cog): def setup(bot): + """Monster survey Cog load.""" + bot.add_cog(MonsterSurvey(bot)) log.info("MonsterSurvey cog loaded") diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py index dcff4f58..3878ef7f 100644 --- a/bot/seasons/halloween/scarymovie.py +++ b/bot/seasons/halloween/scarymovie.py @@ -14,18 +14,15 @@ TMDB_TOKEN = environ.get('TMDB_TOKEN') class ScaryMovie(commands.Cog): - """ - Selects a random scary movie and embeds info into discord chat - """ + """Selects a random scary movie and embeds info into Discord chat.""" def __init__(self, bot): self.bot = bot @commands.command(name='scarymovie', alias=['smovie']) async def random_movie(self, ctx): - """ - Randomly select a scary movie and display information about it. - """ + """Randomly select a scary movie and display information about it.""" + async with ctx.typing(): selection = await self.select_movie() movie_details = await self.format_metadata(selection) @@ -34,9 +31,7 @@ class ScaryMovie(commands.Cog): @staticmethod async def select_movie(): - """ - Selects a random movie and returns a json of movie details from TMDb - """ + """Selects a random movie and returns a json of movie details from TMDb.""" url = 'https://api.themoviedb.org/4/discover/movie' params = { @@ -70,9 +65,7 @@ class ScaryMovie(commands.Cog): @staticmethod async def format_metadata(movie): - """ - Formats raw TMDb data to be embedded in discord chat - """ + """Formats raw TMDb data to be embedded in discord chat.""" # Build the relevant URLs. movie_id = movie.get("id") @@ -137,5 +130,7 @@ class ScaryMovie(commands.Cog): def setup(bot): + """Scary movie Cog load.""" + bot.add_cog(ScaryMovie(bot)) log.info("ScaryMovie cog loaded") diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py index 032ad352..15c7c431 100644 --- a/bot/seasons/halloween/spookyavatar.py +++ b/bot/seasons/halloween/spookyavatar.py @@ -13,18 +13,14 @@ log = logging.getLogger(__name__) class SpookyAvatar(commands.Cog): - - """ - A cog that spookifies an avatar. - """ + """A cog that spookifies an avatar.""" def __init__(self, bot): self.bot = bot async def get(self, url): - """ - Returns the contents of the supplied url. - """ + """Returns the contents of the supplied url.""" + async with aiohttp.ClientSession() as session: async with session.get(url) as resp: return await resp.read() @@ -32,9 +28,8 @@ class SpookyAvatar(commands.Cog): @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), brief='Spookify an user\'s avatar.') async def spooky_avatar(self, ctx, user: discord.Member = None): - """ - A command to print the user's spookified avatar. - """ + """A command to print the user's spookified avatar.""" + if user is None: user = ctx.message.author @@ -54,5 +49,7 @@ class SpookyAvatar(commands.Cog): def setup(bot): + """Spooky avatar Cog load.""" + bot.add_cog(SpookyAvatar(bot)) log.info("SpookyAvatar cog loaded") diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py index c11d5ecb..37d46c01 100644 --- a/bot/seasons/halloween/spookygif.py +++ b/bot/seasons/halloween/spookygif.py @@ -10,18 +10,14 @@ log = logging.getLogger(__name__) class SpookyGif(commands.Cog): - """ - A cog to fetch a random spooky gif from the web! - """ + """A cog to fetch a random spooky gif from the web!""" def __init__(self, bot): self.bot = bot @commands.command(name="spookygif", aliases=("sgif", "scarygif")) async def spookygif(self, ctx): - """ - Fetches a random gif from the GIPHY API and responds with it. - """ + """Fetches a random gif from the GIPHY API and responds with it.""" async with ctx.typing(): async with aiohttp.ClientSession() as session: @@ -39,5 +35,7 @@ class SpookyGif(commands.Cog): def setup(bot): + """Spooky GIF Cog load.""" + bot.add_cog(SpookyGif(bot)) log.info("SpookyGif cog loaded") diff --git a/bot/seasons/halloween/spookyrating.py b/bot/seasons/halloween/spookyrating.py new file mode 100644 index 00000000..a9cfda9b --- /dev/null +++ b/bot/seasons/halloween/spookyrating.py @@ -0,0 +1,68 @@ +import bisect +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with Path('bot', 'resources', 'halloween', 'spooky_rating.json').open() as file: + SPOOKY_DATA = json.load(file) + SPOOKY_DATA = sorted((int(key), value) for key, value in SPOOKY_DATA.items()) + + +class SpookyRating(commands.Cog): + """A cog for calculating one's spooky rating""" + + def __init__(self, bot): + self.bot = bot + self.local_random = random.Random() + + @commands.command() + @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) + async def spookyrating(self, ctx, who: discord.Member = None): + """ + Calculates the spooky rating of someone. + + Any user will always yield the same result, no matter who calls the command + """ + + if who is None: + who = ctx.author + + # This ensures that the same result over multiple runtimes + self.local_random.seed(who.id) + spooky_percent = self.local_random.randint(1, 101) + + # We need the -1 due to how bisect returns the point + # see the documentation for further detail + # https://docs.python.org/3/library/bisect.html#bisect.bisect + index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1 + + _, data = SPOOKY_DATA[index] + + embed = discord.Embed( + title=data['title'], + description=f'{who} scored {spooky_percent}%!', + color=Colours.orange + ) + embed.add_field( + name='A whisper from Satan', + value=data['text'] + ) + embed.set_thumbnail( + url=data['image'] + ) + + await ctx.send(embed=embed) + + +def setup(bot): + """Cog load.""" + bot.add_cog(SpookyRating(bot)) + log.info("SpookyRating cog loaded") diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py index 3b4e3fdf..9b14507a 100644 --- a/bot/seasons/halloween/spookyreact.py +++ b/bot/seasons/halloween/spookyreact.py @@ -18,10 +18,7 @@ SPOOKY_TRIGGERS = { class SpookyReact(Cog): - - """ - A cog that makes the bot react to message triggers. - """ + """A cog that makes the bot react to message triggers.""" def __init__(self, bot): self.bot = bot @@ -29,12 +26,13 @@ class SpookyReact(Cog): @Cog.listener() async def on_message(self, ctx: discord.Message): """ - A command to send the seasonalbot github project + A command to send the seasonalbot github project. Lines that begin with the bot's command prefix are ignored Seasonalbot's own messages are ignored """ + for trigger in SPOOKY_TRIGGERS.keys(): trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower()) if trigger_test: @@ -54,6 +52,7 @@ class SpookyReact(Cog): * author is the bot * prefix is not None """ + # Check for self reaction if ctx.author == self.bot.user: logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}") @@ -70,5 +69,7 @@ class SpookyReact(Cog): def setup(bot): + """Spooky reaction Cog load.""" + bot.add_cog(SpookyReact(bot)) log.info("SpookyReact cog loaded") diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py index 1e430dab..7c4d8113 100644 --- a/bot/seasons/halloween/spookysound.py +++ b/bot/seasons/halloween/spookysound.py @@ -11,9 +11,7 @@ log = logging.getLogger(__name__) class SpookySound(commands.Cog): - """ - A cog that plays a spooky sound in a voice channel on command. - """ + """A cog that plays a spooky sound in a voice channel on command.""" def __init__(self, bot): self.bot = bot @@ -24,9 +22,11 @@ class SpookySound(commands.Cog): @commands.command(brief="Play a spooky sound, restricted to once per 2 mins") async def spookysound(self, ctx): """ - Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. Cannot be used more than - once in 2 minutes. + Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. + + Cannot be used more than once in 2 minutes. """ + if not self.channel: await self.bot.wait_until_ready() self.channel = self.bot.get_channel(Hacktoberfest.voice_id) @@ -39,12 +39,12 @@ class SpookySound(commands.Cog): @staticmethod async def disconnect(voice): - """ - Helper method to disconnect a given voice client. - """ + """Helper method to disconnect a given voice client.""" await voice.disconnect() def setup(bot): + """Spooky sound Cog load.""" + bot.add_cog(SpookySound(bot)) log.info("SpookySound cog loaded") diff --git a/bot/seasons/halloween/timeleft.py b/bot/seasons/halloween/timeleft.py new file mode 100644 index 00000000..3ea2d9ad --- /dev/null +++ b/bot/seasons/halloween/timeleft.py @@ -0,0 +1,63 @@ +import logging +from datetime import datetime + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class TimeLeft(commands.Cog): + """A Cog that tells you how long left until Hacktober is over!""" + + def __init__(self, bot): + self.bot = bot + + @staticmethod + def in_october(): + """Return True if the current month is October.""" + + return datetime.utcnow().month == 10 + + @staticmethod + def load_date(): + """Return of a tuple of the current time and the end and start times of the next October.""" + + now = datetime.utcnow() + year = now.year + if now.month > 10: + year += 1 + end = datetime(year, 10, 31, 11, 59, 59) + start = datetime(year, 10, 1) + return now, end, start + + @commands.command() + async def timeleft(self, ctx): + """ + Calculates the time left until the end of Hacktober. + + Whilst in October, displays the days, hours and minutes left. + Only displays the days left until the beginning and end whilst in a different month + """ + + now, end, start = self.load_date() + diff = end - now + days, seconds = diff.days, diff.seconds + if self.in_october(): + minutes = seconds // 60 + hours, minutes = divmod(minutes, 60) + await ctx.send(f"There is currently only {days} days, {hours} hours and {minutes}" + "minutes left until the end of Hacktober.") + else: + start_diff = start - now + start_days = start_diff.days + await ctx.send( + f"It is not currently Hacktober. However, the next one will start in {start_days} days " + f"and will finish in {days} days." + ) + + +def setup(bot): + """Cog load.""" + + bot.add_cog(TimeLeft(bot)) + log.info("TimeLeft cog loaded") diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py index d8a7e34b..cbd21ee2 100644 --- a/bot/seasons/pride/__init__.py +++ b/bot/seasons/pride/__init__.py @@ -4,8 +4,8 @@ from bot.seasons import SeasonBase class Pride(SeasonBase): """ No matter your origin, identity or sexuality, we come together to celebrate each and everyone's individuality. + Feature contributions to ProudBot is encouraged to commemorate the history and challenges of the LGBTQ+ community. - Happy Pride Month """ name = "pride" diff --git a/bot/seasons/season.py b/bot/seasons/season.py index b7892606..6d992276 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -17,11 +17,11 @@ from bot.decorators import with_role log = logging.getLogger(__name__) +ICON_BASE_URL = "https://raw.githubusercontent.com/python-discord/branding/master" + def get_seasons() -> List[str]: - """ - Returns all the Season objects located in bot/seasons/ - """ + """Returns all the Season objects located in /bot/seasons/.""" seasons = [] @@ -32,9 +32,7 @@ def get_seasons() -> List[str]: def get_season_class(season_name: str) -> Type["SeasonBase"]: - """ - Get's the season class of the season module. - """ + """Gets the season class of the season module.""" season_lib = importlib.import_module(f"bot.seasons.{season_name}") class_name = season_name.replace("_", " ").title().replace(" ", "") @@ -42,9 +40,7 @@ def get_season_class(season_name: str) -> Type["SeasonBase"]: def get_season(season_name: str = None, date: datetime.datetime = None) -> "SeasonBase": - """ - Returns a Season object based on either a string or a date. - """ + """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): @@ -78,9 +74,7 @@ def get_season(season_name: str = None, date: datetime.datetime = None) -> "Seas class SeasonBase: - """ - Base class for Seasonal classes. - """ + """Base class for Seasonal classes.""" name: Optional[str] = "evergreen" bot_name: str = "SeasonalBot" @@ -96,9 +90,7 @@ class SeasonBase: @staticmethod def current_year() -> int: - """ - Returns the current year. - """ + """Returns the current year.""" return datetime.date.today().year @@ -107,8 +99,7 @@ class SeasonBase: """ Returns the start date using current year and start_date attribute. - If no start_date was defined, returns the minimum datetime to ensure - it's always below checked dates. + If no start_date was defined, returns the minimum datetime to ensure it's always below checked dates. """ if not cls.start_date: @@ -120,8 +111,7 @@ class SeasonBase: """ Returns the start date using current year and end_date attribute. - If no end_date was defined, returns the minimum datetime to ensure - it's always above checked dates. + If no end_date was defined, returns the minimum datetime to ensure it's always above checked dates. """ if not cls.end_date: @@ -130,53 +120,52 @@ class SeasonBase: @classmethod def is_between_dates(cls, date: datetime.datetime) -> bool: - """ - Determines if the given date falls between the season's date range. - """ + """Determines if the given date falls between the season's date range.""" return cls.start() <= date <= cls.end() @property def name_clean(self) -> str: + """Return the Season's name with underscores replaced by whitespace.""" + return self.name.replace("_", " ").title() @property def greeting(self) -> str: """ - Provides a default greeting based on the season name if one wasn't - defined in the season class. + Provides a default greeting based on the season name if one wasn't defined in the season class. - It's recommended to define one in most cases by overwriting this as a - normal attribute in the inhertiting class. + It's recommended to define one in most cases by overwriting this as a normal attribute in the + inheriting class. """ return f"New Season, {self.name_clean}!" async def get_icon(self, avatar: bool = False) -> bytes: """ - Retrieves the icon image from the branding repository, using the - defined icon attribute for the season. If `avatar` is True, uses - optional bot-only avatar icon if present. + Retrieve the season's icon from the branding repository using the Season's icon attribute. - The icon attribute must provide the url path, starting from the master - branch base url, including the starting slash: - `https://raw.githubusercontent.com/python-discord/branding/master` + If `avatar` is True, uses optional bot-only avatar icon if present. + + The icon attribute must provide the url path, starting from the master branch base url, + including the starting slash. + e.g. `/logos/logo_seasonal/valentines/loved_up.png` """ - base_url = "https://raw.githubusercontent.com/python-discord/branding/master" if avatar: icon = self.bot_icon or self.icon else: icon = self.icon - full_url = base_url + icon + full_url = ICON_BASE_URL + icon log.debug(f"Getting icon from: {full_url}") async with bot.http_session.get(full_url) as resp: return await resp.read() async def apply_username(self, *, debug: bool = False) -> Union[bool, None]: """ - Applies the username for the current season. Only changes nickname if - `bool` is False, otherwise only changes the nickname. + Applies the username for the current season. + + Only changes nickname if `bool` is False, otherwise only changes the nickname. Returns True if it successfully changed the username. Returns False if it failed to change the username, falling back to nick. @@ -216,7 +205,9 @@ class SeasonBase: async def apply_avatar(self) -> bool: """ - Applies the avatar for the current season. Returns if it was successful. + Applies the avatar for the current season. + + Returns True if successful. """ # track old avatar hash for later comparison @@ -238,7 +229,9 @@ class SeasonBase: async def apply_server_icon(self) -> bool: """ - Applies the server icon for the current season. Returns if it was successful. + Applies the server icon for the current season. + + Returns True if was successful. """ guild = bot.get_guild(Client.guild) @@ -265,8 +258,7 @@ class SeasonBase: """ Announces a change in season in the announcement channel. - It will skip the announcement if the current active season is the - "evergreen" default season. + It will skip the announcement if the current active season is the "evergreen" default season. """ # don't actually announce if reverting to normal season @@ -278,7 +270,21 @@ class SeasonBase: channel = guild.get_channel(Channels.announcements) mention = f"<@&{Roles.announcements}>" - # collect seasonal cogs + # build cog info output + doc = inspect.getdoc(self) + announce = "\n\n".join(l.replace("\n", " ") for l in doc.split("\n\n")) + + # no announcement message found + if not doc: + return + + embed = discord.Embed(description=f"{announce}\n\n", colour=self.colour or guild.me.colour) + embed.set_author(name=self.greeting) + + if self.icon: + embed.set_image(url=ICON_BASE_URL+self.icon) + + # find any seasonal commands cogs = [] for cog in bot.cogs.values(): if "evergreen" in cog.__module__: @@ -287,30 +293,21 @@ class SeasonBase: if cog_name != "SeasonManager": cogs.append(cog_name) - # no cogs, so no seasonal commands - if not cogs: - return - - # build cog info output - doc = inspect.getdoc(self) - announce_text = doc + "\n\n" if doc else "" + if cogs: + def cog_name(cog): + return type(cog).__name__ - def cog_name(cog): - return type(cog).__name__ - - cog_info = [] - for cog in sorted(cogs, key=cog_name): - doc = inspect.getdoc(bot.get_cog(cog)) - if doc: - cog_info.append(f"**{cog}**\n*{doc}*") - else: - cog_info.append(f"**{cog}**") + cog_info = [] + for cog in sorted(cogs, key=cog_name): + doc = inspect.getdoc(bot.get_cog(cog)) + if doc: + cog_info.append(f"**{cog}**\n*{doc}*") + else: + cog_info.append(f"**{cog}**") - embed = discord.Embed(description=announce_text, colour=self.colour or guild.me.colour) - embed.set_author(name=self.greeting) - cogs_text = "\n".join(cog_info) - embed.add_field(name="New Command Categories", value=cogs_text) - embed.set_footer(text="To see the new commands, use .help Category") + cogs_text = "\n".join(cog_info) + embed.add_field(name="New Command Categories", value=cogs_text) + embed.set_footer(text="To see the new commands, use .help Category") await channel.send(mention, embed=embed) @@ -353,9 +350,7 @@ class SeasonBase: class SeasonManager(commands.Cog): - """ - A cog for managing seasons. - """ + """A cog for managing seasons.""" def __init__(self, bot): self.bot = bot @@ -375,6 +370,8 @@ class SeasonManager(commands.Cog): self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60 async def load_seasons(self): + """Asynchronous timer loop to check for a new season every midnight.""" + await self.bot.wait_until_ready() await self.season.load() @@ -390,9 +387,7 @@ class SeasonManager(commands.Cog): @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. - """ + """Changes the currently active season on the bot.""" self.season = get_season(season_name=new_season) await self.season.load() @@ -401,9 +396,7 @@ class SeasonManager(commands.Cog): @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.command(name="seasons") async def show_seasons(self, ctx): - """ - Shows the available seasons and their dates. - """ + """Shows the available seasons and their dates.""" # sort by start order, followed by lower duration def season_key(season_class: Type[SeasonBase]): @@ -447,17 +440,13 @@ class SeasonManager(commands.Cog): @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.group() async def refresh(self, ctx): - """ - Refreshes certain seasonal elements without reloading seasons. - """ + """Refreshes certain seasonal elements without reloading seasons.""" if not ctx.invoked_subcommand: await ctx.invoke(bot.get_command("help"), "refresh") @refresh.command(name="avatar") async def refresh_avatar(self, ctx): - """ - Re-applies the bot avatar for the currently loaded season. - """ + """Re-applies the bot avatar for the currently loaded season.""" # attempt the change is_changed = await self.season.apply_avatar() @@ -481,9 +470,7 @@ class SeasonManager(commands.Cog): @refresh.command(name="icon") async def refresh_server_icon(self, ctx): - """ - Re-applies the server icon for the currently loaded season. - """ + """Re-applies the server icon for the currently loaded season.""" # attempt the change is_changed = await self.season.apply_server_icon() @@ -507,9 +494,7 @@ class SeasonManager(commands.Cog): @refresh.command(name="username", aliases=("name",)) async def refresh_username(self, ctx): - """ - Re-applies the bot username for the currently loaded season. - """ + """Re-applies the bot username for the currently loaded season.""" old_username = str(bot.user) old_display_name = ctx.guild.me.display_name @@ -549,10 +534,11 @@ class SeasonManager(commands.Cog): @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.command() async def announce(self, ctx): - """ - Announces the currently loaded season. - """ + """Announces the currently loaded season.""" + await self.season.announce_season() - def __unload(self): + def cog_unload(self): + """Cancel season-related tasks on cog unload.""" + self.season_task.cancel() diff --git a/bot/seasons/valentines/__init__.py b/bot/seasons/valentines/__init__.py index f1489cf9..e3e04421 100644 --- a/bot/seasons/valentines/__init__.py +++ b/bot/seasons/valentines/__init__.py @@ -8,6 +8,7 @@ class Valentines(SeasonBase): Get yourself into the bot-commands channel and check out the new features! """ + name = "valentines" bot_name = "Tenderbot" greeting = "Get loved-up!" diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py index 4e2182c3..19788577 100644 --- a/bot/seasons/valentines/be_my_valentine.py +++ b/bot/seasons/valentines/be_my_valentine.py @@ -8,7 +8,7 @@ import discord from discord.ext import commands from discord.ext.commands.cooldowns import BucketType -from bot.constants import Client, Colours, Lovefest +from bot.constants import Channels, Client, Colours, Lovefest log = logging.getLogger(__name__) @@ -16,9 +16,7 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea class BeMyValentine(commands.Cog): - """ - A cog that sends valentines to other users ! - """ + """A cog that sends Valentines to other users!""" def __init__(self, bot): self.bot = bot @@ -26,6 +24,8 @@ class BeMyValentine(commands.Cog): @staticmethod def load_json(): + """Load Valentines messages from the static resources.""" + p = Path('bot', 'resources', 'valentines', 'bemyvalentine_valentines.json') with p.open() as json_data: valentines = load(json_data) @@ -34,19 +34,20 @@ class BeMyValentine(commands.Cog): @commands.group(name="lovefest", invoke_without_command=True) async def lovefest_role(self, ctx): """ - You can have yourself the lovefest role or remove it. + Subscribe or unsubscribe from the lovefest role. + The lovefest role makes you eligible to receive anonymous valentines from other users. 1) use the command \".lovefest sub\" to get the lovefest role. 2) use the command \".lovefest unsub\" to get rid of the lovefest role. """ + await ctx.invoke(self.bot.get_command("help"), "lovefest") @lovefest_role.command(name="sub") async def add_role(self, ctx): - """ - This command adds the lovefest role. - """ + """Adds the lovefest role.""" + user = ctx.author role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: @@ -57,9 +58,8 @@ class BeMyValentine(commands.Cog): @lovefest_role.command(name="unsub") async def remove_role(self, ctx): - """ - This command removes the lovefest role. - """ + """Removes the lovefest role.""" + user = ctx.author role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: @@ -72,7 +72,7 @@ class BeMyValentine(commands.Cog): @commands.group(name='bemyvalentine', invoke_without_command=True) async def send_valentine(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): """ - This command sends valentine to user if specified or a random user having lovefest role. + Send a valentine to user, if specified, or to a random user with the lovefest role. syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message] (optional) @@ -99,7 +99,7 @@ class BeMyValentine(commands.Cog): emoji_1, emoji_2 = self.random_emoji() lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) - channel = self.bot.get_channel(Lovefest.channel_id) + channel = self.bot.get_channel(Channels.seasonalbot_chat) valentine, title = self.valentine_check(valentine_type) if user is None: @@ -119,7 +119,7 @@ class BeMyValentine(commands.Cog): @send_valentine.command(name='secret') async def anonymous(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): """ - This command DMs a valentine to be given anonymous to a user if specified or a random user having lovefest role. + Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role. **This command should be DMed to the bot.** @@ -171,6 +171,8 @@ class BeMyValentine(commands.Cog): await ctx.author.send(f"Your message has been sent to {user}") def valentine_check(self, valentine_type): + """Return the appropriate Valentine type & title based on the invoking user's input.""" + if valentine_type is None: valentine, title = self.random_valentine() @@ -191,12 +193,14 @@ class BeMyValentine(commands.Cog): @staticmethod def random_user(author, members): """ - Picks a random member from the list provided in `members`, ensuring - the author is not one of the options. + Picks a random member from the list provided in `members`. + + The invoking author is ignored. :param author: member who invoked the command :param members: list of discord.Member objects """ + if author in members: members.remove(author) @@ -204,14 +208,15 @@ class BeMyValentine(commands.Cog): @staticmethod def random_emoji(): + """Return two random emoji from the module-defined constants.""" + EMOJI_1 = random.choice(HEART_EMOJIS) EMOJI_2 = random.choice(HEART_EMOJIS) return EMOJI_1, EMOJI_2 def random_valentine(self): - """ - Grabs a random poem or a compliment (any message). - """ + """Grabs a random poem or a compliment (any message).""" + valentine_poem = random.choice(self.valentines['valentine_poems']) valentine_compliment = random.choice(self.valentines['valentine_compliments']) random_valentine = random.choice([valentine_compliment, valentine_poem]) @@ -222,20 +227,20 @@ class BeMyValentine(commands.Cog): return random_valentine, title def valentine_poem(self): - """ - Grabs a random poem. - """ + """Grabs a random poem.""" + valentine_poem = random.choice(self.valentines['valentine_poems']) return valentine_poem def valentine_compliment(self): - """ - Grabs a random compliment. - """ + """Grabs a random compliment.""" + valentine_compliment = random.choice(self.valentines['valentine_compliments']) return valentine_compliment def setup(bot): + """Be my Valentine Cog load.""" + bot.add_cog(BeMyValentine(bot)) log.info("BeMyValentine cog loaded") diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/seasons/valentines/lovecalculator.py index 0662cf5b..cd684f9d 100644 --- a/bot/seasons/valentines/lovecalculator.py +++ b/bot/seasons/valentines/lovecalculator.py @@ -21,9 +21,7 @@ with Path('bot', 'resources', 'valentines', 'love_matches.json').open() as file: class LoveCalculator(Cog): - """ - A cog for calculating the love between two people - """ + """A cog for calculating the love between two people.""" def __init__(self, bot): self.bot = bot @@ -103,5 +101,7 @@ class LoveCalculator(Cog): def setup(bot): + """Love calculator Cog load.""" + bot.add_cog(LoveCalculator(bot)) log.info("LoveCalculator cog loaded") diff --git a/bot/seasons/valentines/movie_generator.py b/bot/seasons/valentines/movie_generator.py index 8fce011b..a09a563f 100644 --- a/bot/seasons/valentines/movie_generator.py +++ b/bot/seasons/valentines/movie_generator.py @@ -12,18 +12,15 @@ log = logging.getLogger(__name__) class RomanceMovieFinder(commands.Cog): - """ - A cog that returns a random romance movie suggestion to a user - """ + """A cog that returns a random romance movie suggestion to a user.""" def __init__(self, bot): self.bot = bot @commands.command(name="romancemovie") async def romance_movie(self, ctx): - """ - Randomly selects a romance movie and displays information about it - """ + """Randomly selects a romance movie and displays information about it.""" + # selecting a random int to parse it to the page parameter random_page = random.randint(0, 20) # TMDB api params @@ -62,5 +59,7 @@ class RomanceMovieFinder(commands.Cog): def setup(bot): + """Romance movie Cog load.""" + bot.add_cog(RomanceMovieFinder(bot)) log.info("RomanceMovieFinder cog loaded") diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/seasons/valentines/myvalenstate.py index 7d9f3a59..344f52f6 100644 --- a/bot/seasons/valentines/myvalenstate.py +++ b/bot/seasons/valentines/myvalenstate.py @@ -16,13 +16,14 @@ with open(Path('bot', 'resources', 'valentines', 'valenstates.json'), 'r') as fi class MyValenstate(commands.Cog): + """A Cog to find your most likely Valentine's vacation destination.""" + def __init__(self, bot): self.bot = bot def levenshtein(self, source, goal): - """ - Calculates the Levenshtein Distance between source and goal. - """ + """Calculates the Levenshtein Distance between source and goal.""" + if len(source) < len(goal): return self.levenshtein(goal, source) if len(source) == 0: @@ -43,6 +44,8 @@ class MyValenstate(commands.Cog): @commands.command() async def myvalenstate(self, ctx, *, name=None): + """Find the vacation spot(s) with the most matching characters to the invoking user.""" + eq_chars = collections.defaultdict(int) if name is None: author = ctx.message.author.name.lower().replace(' ', '') @@ -81,5 +84,7 @@ class MyValenstate(commands.Cog): def setup(bot): + """Valenstate Cog load.""" + bot.add_cog(MyValenstate(bot)) log.info("MyValenstate cog loaded") diff --git a/bot/seasons/valentines/pickuplines.py b/bot/seasons/valentines/pickuplines.py index e1abb4e5..ad75c93f 100644 --- a/bot/seasons/valentines/pickuplines.py +++ b/bot/seasons/valentines/pickuplines.py @@ -15,9 +15,7 @@ with open(Path('bot', 'resources', 'valentines', 'pickup_lines.json'), 'r', enco class PickupLine(commands.Cog): - """ - A cog that gives random cheesy pickup lines. - """ + """A cog that gives random cheesy pickup lines.""" def __init__(self, bot): self.bot = bot @@ -25,8 +23,11 @@ class PickupLine(commands.Cog): @commands.command() async def pickupline(self, ctx): """ - Gives you a random pickup line. Note that most of them are very cheesy! + Gives you a random pickup line. + + Note that most of them are very cheesy. """ + random_line = random.choice(pickup_lines['lines']) embed = discord.Embed( title=':cheese: Your pickup line :cheese:', @@ -40,5 +41,7 @@ class PickupLine(commands.Cog): def setup(bot): + """Pickup lines Cog load.""" + bot.add_cog(PickupLine(bot)) log.info('PickupLine cog loaded') diff --git a/bot/seasons/valentines/savethedate.py b/bot/seasons/valentines/savethedate.py index fbc9eb82..281625a4 100644 --- a/bot/seasons/valentines/savethedate.py +++ b/bot/seasons/valentines/savethedate.py @@ -17,18 +17,15 @@ with open(Path('bot', 'resources', 'valentines', 'date_ideas.json'), 'r', encodi class SaveTheDate(commands.Cog): - """ - A cog that gives random suggestion, for a valentines date ! - """ + """A cog that gives random suggestion for a Valentine's date.""" def __init__(self, bot): self.bot = bot @commands.command() async def savethedate(self, ctx): - """ - Gives you ideas for what to do on a date with your valentine. - """ + """Gives you ideas for what to do on a date with your valentine.""" + random_date = random.choice(VALENTINES_DATES['ideas']) emoji_1 = random.choice(HEART_EMOJIS) emoji_2 = random.choice(HEART_EMOJIS) @@ -41,5 +38,7 @@ class SaveTheDate(commands.Cog): def setup(bot): + """Save the date Cog Load.""" + bot.add_cog(SaveTheDate(bot)) log.info("SaveTheDate cog loaded") diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/seasons/valentines/valentine_zodiac.py index 33fc739a..1700260e 100644 --- a/bot/seasons/valentines/valentine_zodiac.py +++ b/bot/seasons/valentines/valentine_zodiac.py @@ -15,15 +15,16 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea class ValentineZodiac(commands.Cog): - """ - A cog that returns a counter compatible zodiac sign to the given user's zodiac sign. - """ + """A cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" + def __init__(self, bot): self.bot = bot self.zodiacs = self.load_json() @staticmethod def load_json(): + """Load Zodiac compatibility from static JSON resource.""" + p = Path('bot', 'resources', 'valentines', 'zodiac_compatibility.json') with p.open() as json_data: zodiacs = load(json_data) @@ -31,9 +32,8 @@ class ValentineZodiac(commands.Cog): @commands.command(name="partnerzodiac") async def counter_zodiac(self, ctx, zodiac_sign): - """ - Provides a counter compatible zodiac sign to the given user's zodiac sign. - """ + """Provides a counter compatible zodiac sign to the given user's zodiac sign.""" + try: compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.lower()]) except KeyError: @@ -55,5 +55,7 @@ class ValentineZodiac(commands.Cog): def setup(bot): + """Valentine Zodiac Cog load.""" + bot.add_cog(ValentineZodiac(bot)) log.info("ValentineZodiac cog loaded") diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/seasons/valentines/whoisvalentine.py index 59a13ca3..96d97e22 100644 --- a/bot/seasons/valentines/whoisvalentine.py +++ b/bot/seasons/valentines/whoisvalentine.py @@ -15,14 +15,15 @@ with open(Path("bot", "resources", "valentines", "valentine_facts.json"), "r") a class ValentineFacts(commands.Cog): + """A Cog for displaying facts about Saint Valentine.""" + def __init__(self, bot): self.bot = bot @commands.command(aliases=('whoisvalentine', 'saint_valentine')) async def who_is_valentine(self, ctx): - """ - Displays info about Saint Valentine. - """ + """Displays info about Saint Valentine.""" + embed = discord.Embed( title="Who is Saint Valentine?", description=FACTS['whois'], @@ -37,9 +38,8 @@ class ValentineFacts(commands.Cog): @commands.command() async def valentine_fact(self, ctx): - """ - Shows a random fact about Valentine's Day. - """ + """Shows a random fact about Valentine's Day.""" + embed = discord.Embed( title=choice(FACTS['titles']), description=choice(FACTS['text']), @@ -50,5 +50,7 @@ class ValentineFacts(commands.Cog): def setup(bot): + """Who is Valentine Cog load.""" + bot.add_cog(ValentineFacts(bot)) log.info("ValentineFacts cog loaded") diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py index 5f2369ae..390cfa49 100644 --- a/bot/utils/halloween/spookifications.py +++ b/bot/utils/halloween/spookifications.py @@ -8,17 +8,20 @@ log = logging.getLogger() def inversion(im): - """Inverts an image. + """ + Inverts the image. Returns an inverted image when supplied with an Image object. """ + im = im.convert('RGB') inv = ImageOps.invert(im) return inv def pentagram(im): - """Adds pentagram to image.""" + """Adds pentagram to the image.""" + im = im.convert('RGB') wt, ht = im.size penta = Image.open('bot/resources/halloween/bloody-pentagram.png') @@ -28,10 +31,13 @@ def pentagram(im): def bat(im): - """Adds a bat silhoutte to the image. + """ + Adds a bat silhoutte to the image. + + The bat silhoutte is of a size at least one-fifths that of the original image and may be rotated + up to 90 degrees anti-clockwise. + """ - The bat silhoutte is of a size at least one-fifths that of the original - image and may be rotated upto 90 degrees anti-clockwise.""" im = im.convert('RGB') wt, ht = im.size bat = Image.open('bot/resources/halloween/bat-clipart.png') @@ -49,6 +55,7 @@ def bat(im): def get_random_effect(im): """Randomly selects and applies an effect.""" + effects = [inversion, pentagram, bat] effect = choice(effects) log.info("Spookyavatar's chosen effect: " + effect.__name__) @@ -1,6 +1,19 @@ [flake8] max-line-length=120 application_import_names=bot -ignore=P102,B311,W503,E226,S311 -exclude=__pycache__,venv,.venv,tests,.cache +ignore= + P102,B311,W503,E226,S311, + # Missing Docstrings + D100,D104,D107, + # Docstring Whitespace + D202,D203,D204,D212,D214,D215, + # Docstring Quotes + D301,D302, + # Docstring Content + D400,D401,D402,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414 +exclude= + __pycache__,.cache, + venv,.venv, + tests, + constants.py import-order-style=pycharm |