diff --git a/.codecov.yml b/.codecov.yml index c964e104..9e4bea5e 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -35,7 +35,8 @@ coverage: # advanced branches: - main - if_ci_failed: error #success, failure, error, ignore + if_ci_failed: ignore #success, failure, error, ignore + informational: true only_pulls: true diff --git a/.env.example b/.env.example deleted file mode 100644 index dcdac082..00000000 --- a/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -TOKEN="MyBotToken" -GUILD_ID=1234567890 -OWNERS="hello world" - -MODMAIL_GUILD_ID=1234567890 - -# DATABASE -CONNECTION_URI="mongodb+srv://mongodburi" - -# DISCORD LOGIN -CLIENT_ID = "YOUR APP'S CLIENT ID HERE" -CLIENT_SECRET = "YOUR APP'S CLIENT SECRET HERE" -REDIRECT_URI = "https://yourdomain.com/callback" diff --git a/.env.template b/.env.template new file mode 100644 index 00000000..79023bad --- /dev/null +++ b/.env.template @@ -0,0 +1 @@ +TOKEN="MyBotToken" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c8824a37..7d1d6563 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,4 +7,5 @@ We highly recommend linking to an issue that has been approved by a maintainer, ## Description + diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 00000000..473418ca --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,22 @@ +# Github Action Workflow enforcing our changelog style of enforcing PR number. +# credit: https://github.com/psf/black/blob/main/.github/workflows/changelog.yml + +name: Changelog Entry Check + +on: + pull_request: + types: [opened, synchronize, labeled, unlabeled, reopened] + +jobs: + build: + name: Changelog Entry Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Grep CHANGES.md for PR number + if: contains(github.event.pull_request.labels.*.name, 'skip changelog') != true + run: | + grep -Pz "\((\n\s*)?#${{ github.event.pull_request.number }}(\n\s*)?\)" CHANGELOG.md || \ + (echo "Please add '(#${{ github.event.pull_request.number }})' change line to CHANGELOG.md" && \ + exit 1) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb5a22dc..fb152f56 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,16 @@ repos: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] + - repo: https://github.com/executablebooks/mdformat + rev: 0.7.8 + hooks: + - id: mdformat + files: '\.md$' + exclude: 'tests/docs.md' + additional_dependencies: + - mdformat-gfm + - mdformat-black + - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.9.0 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..8dafbd12 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2021-08-13 + +### Added + +- Bot modes to determine behavior. Multiple can be applied at once. (#43) + - `PROD`: the default mode, no dev extensions or dev plugins load + - `DEVELOP`: the bot developer mode, most useful for people adding features to modmail + - Enables the extension_manager extension. + - `PLUGIN_DEV`: the plugin developer mode, useful for enabling plugin specific features + - This is not used yet. +- Extension loading system (#43) + - scans for extensions in the `modmail/extensions` folder and loads them if they are of the right format. + - all extensions must be loadable as a module, which means they must have `__init__.py` files in their directories. +- Plugin loading system (#43) + - scans for plugins in the `modmail/plugins` folder and loads them. + - Unlike extensions, plugins and their respective folders do not need to have `__init__.py` files and are allowed to be symlinks. +- Extension management commands (#43) + - load, reload, unload, list, refresh commands for dealing with extensions + - Run the `ext` command for more details when bot is in `DEVELOP` mode. +- Plugin management commands (#43) + - load, reload, unload, list, refresh commands for dealing with plugins + - Run the `plugins` command for more details. +- Extension metadata (#43) + - used to determine if a cog should load or not depending on the bot mode +- Plugin helper file (#43) + - `modmail/plugin_helpers.py` contains several helpers for making plugins + - `PluginCog` + - `ModmailBot`, imported from `modmail.bot` + - `ModmailLogger`, imported from `modmail.log` +- Meta Cog (#43) + - **NOTE**: The commands in this cog are not stabilized yet and should not be relied upon. + - Prefix command for getting the set prefix. Most useful by mentioning the bot. + - Uptime command which tells the end user how long the bot has been online. + - Ping command to see the bot latency. +- Guide on how to contribute to modmail, see [CONTRIBUTING.md](./CONTRIBUTING.md) +- Start a Changelog + +### Fixed + +- Make the bot http_session within an event loop. + +[0.1.0]: https://github.com/discord-modmail/modmail/releases/tag/v0.1.0 +[unreleased]: https://github.com/discord-modmail/modmail/compare/v0.1.0...main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2a90f8dc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,243 @@ +# Contributing Guidelines + +Thank you so much for your interest in contributing!. All types of contributions are encouraged and valued. See below for different ways to help, and details about how this project handles them! + +Please make sure to read the relevant section before making your contribution! It will make it a lot easier for us maintainers to make the most of it and smooth out the experience for all involved. πŸ’š + +> **NOTE**: failing to comply with our guidelines may lead to a rejection of the contribution. +> However, most features that are rejected can be written as a plugin, and used on your +> modmail, without blocking you from getting updates. + +If you are confused by any of these rules, feel free to ask us in the `#support` channel in our [Discord server.][modmail-discord] + +## How do I... + +- Ask or Say Something πŸ€”πŸ›πŸ˜± + - [Request Support](#request-support) + - [Report an Error or Bug](#report-an-error-or-bug) + - [Request a Feature](#request-a-feature) +- Make Something πŸ€“πŸ‘©πŸ½β€πŸ’»πŸ“œπŸ³ + - [Project Setup](#project-setup) + - [Contribute Code](#contribute-code) +- Style Guides βœ…πŸ™†πŸΌπŸ’ƒπŸ‘” + - [Git Commit Messages](#git-commit-messages) + - [Python Styleguide](#python-styleguide) + - [Changelog Requirement](#changelog-requirement) + +## Request Support + +- You can either ask your question as issue by opening one at [discord-modmail/modmail/issues][modmail-issues]. + +- [Join the Modmail Discord Server][modmail-discord] + + - Even though Discord is a chat service, sometimes it takes several hours for community members to respond β€” please be patient! + - Use the `#support` channel for questions or discussion about writing or contributing to Discord Modmail bot. + - There are many other channels available, check the channel list + +## Report an Error or Bug + +If you run into an error or bug with the project: + +> **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. + +- Open an Issue at [discord-modmail/modmail/issues][modmail-issues]. +- Explain the problem and include additional details to help maintainers reproduce the problem: + - **Use a clear and descriptive title** for the issue to identify the problem. + - **Describe the exact steps which reproduce the problem** in as many details as possible. When listing steps, **don't just say what you did but explain how you did it**. + - **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/paste-able snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). + - **Describe the behaviour you observed after following the steps** and point out what exactly is the problem with that behaviour. + - **Explain which behaviour you expected to see instead and why.** + - **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. If you use the keyboard while following the steps, **record the GIF with the [Keybinding Resolver](https://github.com/atom/keybinding-resolver) shown**. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) on Linux (of course there are plenty more). + +## Request a Feature + +If the project doesn't do something you need or want it to do: + +- Open an Issue at [discord-modmail/modmail/issues][modmail-issues]. +- Provide as much context as you can about what you're running into. + - **Use a clear and descriptive title** for the issue to identify the suggestion. + - **Provide a step-by-step description of the suggested enhancement** in as many details as possible. + - **Provide specific examples to demonstrate the steps**. Include copy/paste-able snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). + - **Explain why this enhancement would be useful** to Modmail, and would benefit the community members. +- Please try and be clear about why existing features and alternatives would not work for you. + +Once it's filed: + +- The Maintainers will [label the issue](#label-issues). +- The Maintainers will evaluate the feature request, possibly asking you more questions to understand its purpose and any relevant requirements. If the issue is closed, the team will convey their reasoning and suggest an alternative path forward. +- If the feature request is accepted, it will be marked for implementation with `status: approved`, which can then be done either by a core team member or by anyone in the community who wants to contribute code. + +> **Note**: The team is unlikely to be able to accept every single feature request that is filed. Please understand if they need to say no. However for most features requested, you can always write a plugin for your modmail bot. + +## Project Setup + +So you want to contribute some code! That's great! This project uses GitHub Pull Requests to manage contributions, so [read up on how to fork a GitHub project and file a PR](https://guides.github.com/activities/forking) if you've never done it before. + +### Test Server and Bot Account + +You will need your own test server and bot account on Discord to test your changes to the bot. + +1. Create a test server. +1. Create a bot account and invite it to the server you just created. + + + +Note down the IDs for your server. +Learn how to obtain the ID of a server, channel or role **[here](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-).** + +### Fork the Project + +You will need your own remote (online) copy of the project repository, known as a fork. +You will do all your work in the fork rather than directly in the main repository. + +You can click [here to fork][fork] + +And you should be ready to go! + +Once you have your fork you will need to clone the repo to your computer. + +```sh +$ git clone https://github.com//modmail +... +$ cd modmail +``` + +or using the [github cli](https://github.com/cli/cli): + +```sh +$ gh repo clone /modmail +... +$ cd modmail +``` + +> Tip: You can use the github cli to fork the repo as well, just use `gh repo fork discord-modmail/modmail` and it will allow you to clone it directly. + +### Install development dependencies + +Make sure you are in the project directory. + +```sh +# This will install the development and project dependencies. +poetry install +# This will install the pre-commit hooks. +poetry run task precommit +``` + +### Set up environment variables + +1. Create a text file named `.env` in your project root (that's the base folder of your repository): + - You can also copy the `.env.template` file to `.env` + +> Note: The entire file name is literally `.env` + +1. Open the file with any text editor. +1. Each environment variable is on its own line, with the variable and the value separated by a `=` sign. + +#### The following variables are needed for running Modmail: + +| Required | ENV VARIABLE NAME | TYPE | WHAT IS IT? | +| -------- | ----------------- | ------- | ------------------------------------------------ | +| True | `TOKEN` | String | Bot Token from the Discord developer portal | +| False | `DEVELOP` | Boolean | Enables the developer bot extensions | +| False | `PLUGIN_DEV` | Boolean | Enables plugin-developer friendly bot extensions | + +The rest of them can be viewed in our [.env.template](./.env.template) + +### Run The Project + +To run the project, use the (below) in the project root. + +```shell +$ poetry run task start +``` + +## Contribute Code + +We like code commits a lot! They're super handy, and they keep the project going and doing the work it needs to do to be useful to others. + +Code contributions of just about any size are acceptable! + +To contribute code: + +- [Set up the project](#project-setup). +- Make any necessary changes to the source code. +- Write clear, concise commit message(s). + - A more in-depth guide to writing great commit messages can be found in Chris Beam's [*How to Write a Git Commit Message*](https://chris.beams.io/posts/git-commit/). +- Run `flake8`, `black` and `pre-commit` against your code **before** you push. Your commit will be rejected by the build server if it fails to lint. You can run the lint by executing `poetry run task lint` in your command line. +- Go to [discord-modmail/modmail/pulls][modmail-pulls] and open a new pull request with your changes. +- If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This permits maintainers to commit changes directly to your fork, speeding up the review process. +- If your PR is connected to an open issue, add a line in your PR's description that says `Closes #123`, where `#123` is the number of the issue you're fixing. This will make github link your issue, and make it easier for us (and other contributers) to find and understand the context behind your PR. + +> Pull requests (or PRs for short) are the primary mechanism we use to change modmail. GitHub itself has some [great documentation][about-pull-requests] on using the Pull Request feature. We use the "fork and pull" model [described here][development-models], where contributors push changes to their personal fork and create pull requests to bring those changes into the source repository. + +Once you've filed the PR: + +- Barring special circumstances, maintainers will not review PRs until lint checks pass (`poetry run task lint`). +- One or more contributors will use GitHub's review feature to review your PR. +- If the maintainer asks for any changes, edit your changes, push, and ask for another review. +- If the maintainer decides to pass on your PR, they will thank you for the contribution and explain why they won't be accepting the changes. That's ok! We still really appreciate you taking the time to do it, and we don't take that lightly. πŸ’š +- If your PR gets accepted, it will be marked as such, and merged into the `main` branch soon after. + +## Git Commit Messages + +Commit messages must start with a short summary (max. 50 chars) +written in the imperative, followed by an optional, more detailed explanatory +text which is separated from the summary by an empty line. + +Commit messages should follow best practices, including explaining the context +of the problem and how it was solved, including caveats or follow up changes +required. They should tell the story of the change and provide readers +understanding of what led to it. + +Check out [Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) for more information. + +If you're lost about what this even means, please see [How to Write a Git +Commit Message](http://chris.beams.io/posts/git-commit/) for a start. + +In practice, the best approach to maintaining a nice commit message is to +leverage a `git add -p` and `git commit --amend` to formulate a solid +changeset. This allows one to piece together a change, as information becomes +available. + +If you squash a series of commits, don't just submit that. Re-write the commit +message, as if the series of commits was a single stroke of brilliance. + +That said, there is no requirement to have a single commit for a PR, as long as +each commit tells the story. For example, if there is a feature that requires a +package, it might make sense to have the package in a separate commit then have +a subsequent commit that uses it. + +Remember, you're telling part of the story with the commit message. Don't make +your chapter weird. + +## Python Styleguide + + + +## Changelog Requirement + +Modmail has CI that will check for an entry corresponding to your PR in `CHANGES.md`. +If you feel this PR does not require a changelog entry please state that in a comment +and a maintainer can add a `skip changelog` label to make the CI pass. Otherwise, +please ensure you have a line in the following format: + +``` +- `Modmail` is now more awesome (#X) +``` + +Note that X should be your PR number, not issue number! This is not perfect but +saves a lot of release overhead as now the releaser does not need to go back and +workout what to add to the `CHANGES.md` for each release. + +## Attribution + +This contributing guide is inspired by the [Moby's](https://github.com/moby/moby) and [Atom Text Editor's](https://github.com/atom/atom) contributing guide. + +[about-pull-requests]: https://help.github.com/articles/about-pull-requests/ +[development-models]: https://help.github.com/articles/about-collaborative-development-models/ +[fork]: https://github.com/discord-modmail/modmail/fork +[modmail-discord]: https://discord.gg/ERteGkedDW +[modmail-issues]: https://github.com/discord-modmail/modmail/issues +[modmail-pulls]: https://github.com/discord-modmail/modmail/pulls diff --git a/Procfile b/Procfile index 8faf34e1..135be655 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -worker: python -m modmail +web: python -m modmail diff --git a/README.md b/README.md index 72b83623..41a0ec1f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # modmail + [![Lint & Test](https://img.shields.io/github/workflow/status/discord-modmail/modmail/Lint%20&%20Test/main?label=Lint+%26+Test&logo=github&style=flat)](https://github.com/discord-modmail/modmail/actions/workflows/lint_test.yml "Lint and Test") [![Code Coverage](https://img.shields.io/codecov/c/gh/discord-modmail/modmail/main?logo=codecov&style=flat&label=Code+Coverage)](https://app.codecov.io/gh/discord-modmail/modmail "Code Coverage") [![Codacy Grade](https://img.shields.io/codacy/grade/78be21a49835484595aea556d5920638?logo=codacy&style=flat&label=Code+Quality)](https://www.codacy.com/gh/discord-modmail/modmail/dashboard "Codacy Grade") -[![Python](https://img.shields.io/static/v1?label=Python&message=3.8+|+3.9&color=blue&logo=Python&style=flat)](https://www.python.org/downloads/ "Python 3.8 | 3.9") +[![Python](https://img.shields.io/static/v1?label=Python&message=3.8+%7C+3.9&color=blue&logo=Python&style=flat)](https://www.python.org/downloads/ "Python 3.8 | 3.9") [![License](https://img.shields.io/github/license/discord-modmail/modmail?style=flat&label=License)](./LICENSE "License file") [![Code Style](https://img.shields.io/static/v1?label=Code%20Style&message=black&color=000000&style=flat)](https://github.com/psf/black "The uncompromising python formatter") diff --git a/SECURITY.md b/SECURITY.md index 0413dc18..4084290a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ versions long, as that may make contributing difficult. | Version | Supported | | ------- | ------------------ | -| < 1.0 | :white_check_mark: | +| \< 1.0 | :white_check_mark: | | >= 1.0 | Unreleased | ## Reporting a Vulnerability diff --git a/modmail/__init__.py b/modmail/__init__.py index 6cf37526..14cd73a5 100644 --- a/modmail/__init__.py +++ b/modmail/__init__.py @@ -4,48 +4,52 @@ import coloredlogs -from .log import ModmailLogger +from modmail.log import ModmailLogger logging.TRACE = 5 logging.NOTICE = 25 logging.addLevelName(logging.TRACE, "TRACE") logging.addLevelName(logging.NOTICE, "NOTICE") -LOG_LEVEL = 20 -fmt = "%(asctime)s %(levelname)10s %(name)15s - [%(lineno)5d]: %(message)s" -datefmt = "%Y/%m/%d %H:%M:%S" + +LOG_FILE_SIZE = 8 * (2 ** 10) ** 2 # 8MB, discord upload limit + +# this logging level is set to logging.TRACE because if it is not set to the lowest level, +# the child level will be limited to the lowest level this is set to. +ROOT_LOG_LEVEL = logging.TRACE +FMT = "%(asctime)s %(levelname)10s %(name)15s - [%(lineno)5d]: %(message)s" +DATEFMT = "%Y/%m/%d %H:%M:%S" logging.setLoggerClass(ModmailLogger) # Set up file logging -log_file = Path("./logs/bot.log") +log_file = Path("logs", "bot.log") log_file.parent.mkdir(parents=True, exist_ok=True) # file handler file_handler = logging.handlers.RotatingFileHandler( log_file, - maxBytes=5 * (2 ** 12), - backupCount=5, + maxBytes=LOG_FILE_SIZE, + backupCount=7, encoding="utf-8", ) file_handler.setFormatter( logging.Formatter( - fmt=fmt, - datefmt=datefmt, + fmt=FMT, + datefmt=DATEFMT, ) ) file_handler.setLevel(logging.TRACE) -coloredlogs.install( - level=LOG_LEVEL, - fmt=fmt, - datefmt=datefmt, -) +coloredlogs.DEFAULT_LEVEL_STYLES["trace"] = coloredlogs.DEFAULT_LEVEL_STYLES["spam"] + +coloredlogs.install(level=logging.TRACE, fmt=FMT, datefmt=DATEFMT) # Create root logger root: ModmailLogger = logging.getLogger() +root.setLevel(ROOT_LOG_LEVEL) root.addHandler(file_handler) # Silence irrelevant loggers @@ -53,5 +57,3 @@ logging.getLogger("websockets").setLevel(logging.ERROR) # Set asyncio logging back to the default of INFO even if asyncio's debug mode is enabled. logging.getLogger("asyncio").setLevel(logging.INFO) - -root.debug("Logging initialization complete") diff --git a/modmail/__main__.py b/modmail/__main__.py index ae68d2dd..4b4aaa84 100644 --- a/modmail/__main__.py +++ b/modmail/__main__.py @@ -1,5 +1,8 @@ import logging +from modmail.bot import ModmailBot +from modmail.log import ModmailLogger + try: # noinspection PyUnresolvedReferences from colorama import init @@ -8,15 +11,16 @@ except ImportError: pass -from .bot import ModmailBot -log = logging.getLogger(__name__) +log: ModmailLogger = logging.getLogger(__name__) def main() -> None: """Run the bot.""" bot = ModmailBot() - log.notice("running bot") + bot.load_extensions() + bot.load_plugins() + log.notice("Running the bot.") bot.run(bot.config.bot.token) diff --git a/modmail/bot.py b/modmail/bot.py index 3284b89f..deb66464 100644 --- a/modmail/bot.py +++ b/modmail/bot.py @@ -2,13 +2,14 @@ import logging import typing as t -import discord +import arrow from aiohttp import ClientSession from discord.ext import commands -from .config import CONFIG, INTERNAL - -log = logging.getLogger(__name__) +from modmail.config import CONFIG +from modmail.log import ModmailLogger +from modmail.utils.extensions import EXTENSIONS, NO_UNLOAD, walk_extensions +from modmail.utils.plugins import PLUGINS, walk_plugins class ModmailBot(commands.Bot): @@ -19,51 +20,107 @@ class ModmailBot(commands.Bot): """ main_task: asyncio.Task + logger: ModmailLogger = logging.getLogger(__name__) def __init__(self, **kwargs): self.config = CONFIG - self.internal = INTERNAL - self.http_session: ClientSession = None - super().__init__(command_prefix=self.get_prefix, **kwargs) + self.http_session: t.Optional[ClientSession] = None + self.start_time = arrow.utcnow() + super().__init__(command_prefix=commands.when_mentioned_or(self.config.bot.prefix), **kwargs) async def create_session(self) -> None: """Create an aiohttp client session.""" self.http_session = ClientSession() - async def get_prefix(self, message: discord.Message = None) -> t.List[str]: - """Returns the bot prefix, but also allows the bot to work with user mentions.""" - return [self.config.bot.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] + async def start(self, *args, **kwargs) -> None: + """ + Start the bot. + + This just serves to create the http session. + """ + await self.create_session() + await super().start(*args, **kwargs) async def close(self) -> None: - """Safely close HTTP session and extensions when bot is shutting down.""" - await super().close() + """Safely close HTTP session, unload plugins and extensions when the bot is shutting down.""" + plugins = self.extensions & PLUGINS.keys() + for plug in list(plugins): + try: + self.unload_extension(plug) + except Exception: + self.logger.error(f"Exception occured while unloading plugin {plug.name}", exc_info=True) for ext in list(self.extensions): try: self.unload_extension(ext) except Exception: - log.error(f"Exception occured while unloading {ext.name}", exc_info=1) + self.logger.error(f"Exception occured while unloading {ext.name}", exc_info=True) for cog in list(self.cogs): try: self.remove_cog(cog) except Exception: - log.error(f"Exception occured while removing cog {cog.name}", exc_info=1) - - await super().close() + self.logger.error(f"Exception occured while removing cog {cog.name}", exc_info=True) if self.http_session: await self.http_session.close() - def add_cog(self, cog: commands.Cog) -> None: + await super().close() + + def load_extensions(self) -> None: + """Load all enabled extensions.""" + EXTENSIONS.update(walk_extensions()) + + # set up no_unload global too + for ext, value in EXTENSIONS.items(): + if value[1]: + NO_UNLOAD.append(ext) + + for extension, value in EXTENSIONS.items(): + if value[0]: + self.logger.debug(f"Loading extension {extension}") + self.load_extension(extension) + + def load_plugins(self) -> None: + """Load all enabled plugins.""" + PLUGINS.update(walk_plugins()) + + for plugin, should_load in PLUGINS.items(): + if should_load: + self.logger.debug(f"Loading plugin {plugin}") + try: + # since we're loading user generated content, + # any errors here will take down the entire bot + self.load_extension(plugin) + except Exception: + self.logger.error("Failed to load plugin {0}".format(plugin), exc_info=True) + + def add_cog(self, cog: commands.Cog, *, override: bool = False) -> None: + """ + Load a given cog. + + Utilizes the default discord.py loader beneath, but also checks so we can warn when we're + loading a non-ModmailCog cog. + """ + from modmail.utils.cogs import ModmailCog + + if not isinstance(cog, ModmailCog): + self.logger.warning( + f"Cog {cog.name} is not a ModmailCog. All loaded cogs should always be" + f" instances of ModmailCog." + ) + super().add_cog(cog, override=override) + self.logger.info(f"Cog loaded: {cog.qualified_name}") + + def remove_cog(self, cog: str) -> None: """ - Delegate to super to register `cog`. + Delegate to super to unregister `cog`. This only serves to make the info log, so that extensions don't have to. """ - super().add_cog(cog) - log.info(f"Cog loaded: {cog.qualified_name}") + super().remove_cog(cog) + self.logger.info(f"Cog unloaded: {cog}") async def on_ready(self) -> None: """Send basic login success message.""" - log.info("Logged in as %s", self.user) + self.logger.info("Logged in as %s", self.user) diff --git a/modmail/config-default.toml b/modmail/config-default.toml index 7885da20..db98e228 100644 --- a/modmail/config-default.toml +++ b/modmail/config-default.toml @@ -1,116 +1,8 @@ [bot] prefix = "?" -log_url_prefix = '/' -database_type = 'mongodb' -owners = '' -enable_plugins = true -enable_eval = true -data_collection = true -multi_bot = false - -[bot.activity] -twitch_url = "https://www.twitch.tv/discordmodmail/" - -[colors] -main_color = "#7289da" -error_color = "#e74c3c" -recipient_color = "#f1c40f" -mod_color = '#2ecc71' - -[channels] -main_category_id = "Modmail" -fallback_category_id = "Fallback Modmail" -log_channel_id = "bot-logs" -mention_channel_id = '' -update_channel_id = "0" [dev] log_level = 25 # "NOTICE" -[emoji] -# sent_emoji = "white_heavy_check_mark" -# blocked_emoji = "\\N{NO ENTRY SIGN}" - -[internal] - -[mention] -alert_on_mention = false -silent_alert_on_mention = false -mention_channel_id = '' - -[snippets] -anonymous_snippets = false -use_regex_autotrigger = false - -[thread] -anon_reply_without_command = false -reply_without_command = false -plain_reply_without_command = false -mention = "@here" -user_typing = true -mod_typing = false -transfer_reactions = true -contact_silently = false -use_user_id_channel_name = false -account_age = -1 -guild_age = -1 -mod_tag = '' -show_timestamp = true - - -[thread.anon] -username = 'Staff Team' -tag = 'Response' - -[thread.auto_close] -time = 0 -silently = false -response = "This thread has been closed automatically due to inactivity after {timeout}." - -[thread.close] -footer = "Replying will create a new thread" -title = "Thread Closed" -response = "{closer.mention} has closed this Modmail thread." -on_leave = false -on_leave_reason = "The recipient has left the server." -self_close_response = "You have closed this Modmail thread." - -[thread.confirm_creation] -enabled = false -title = "Confirm thread creation" -response = "React to confirm thread creation which will directly contact the moderators" -accept_emoji = "\\N{WHITE HEAVY CHECK MARK}" -deny_emoji = "\\N{NO ENTRY SIGN}" - -[thread.cooldown] -time = 0 -embed_title = "Message not sent!" -cooldown_thread_response = "You must wait for {delta} before you can contact me again." - -[thread.creation] -response = "The staff team will get back to you as soon as possible." -footer = "Your message has been sent" -title = "Thread Created" - -[thread.disabled] -new_title = "Not Delivered" -new_response = "We are not accepting new threads." -new_footer = "Please try again later..." -current_title = "Not Delivered" -current_response = "We are not accepting any messages." -current_footer = "Please try again later..." - -[thread.move] -title = "Thread Moved" -notify = false -notify_mods = false -response = "This thread has been moved." - -[thread.self_closable] -creation_footer = "Click the lock to close the thread" -enabled = false -lock_emoji = "\\N{LOCK}" - -[updates] -disable_autoupdates = false -update_notifications = true +[dev.mode] +production = true diff --git a/modmail/config.py b/modmail/config.py index 2dfac8a8..7345d25b 100644 --- a/modmail/config.py +++ b/modmail/config.py @@ -92,57 +92,25 @@ def customise_sources( ) -class ThreadBaseSettings(BaseSettings): - class Config: - env_prefix = "thread." - - # @classmethod - # def alias_generator(cls, string: str) -> str: - # return f"thread.{super.__name__}.{string}" - - -class BotActivityConfig(BaseSettings): - twitch_url: str = "https://www.twitch.tv/discordmodmail/" - - class BotConfig(BaseSettings): prefix: str = "?" - activity: BotActivityConfig token: str = None - modmail_guild_id: str = None - guild_id: str = None - multi_bot: bool = False - log_url: str = None - log_url_prefix = "/" - github_token: SecretStr = None - database_type: str = "mongodb" # TODO limit to specific strings - enable_plugins: bool = True - enable_eval: bool = True - data_collection = True - owners: str = 1 - connection_uri: str = None - level_permissions: dict = None class Config: # env_prefix = "bot." allow_mutation = False -class ColorsConfig(BaseSettings): - main_color: str = str(discord.Colour.blurple()) - error_color: str = str(discord.Colour.red()) - recipient_color: str = str(discord.Colour.green()) - mod_color: str = str(discord.Colour.blue()) +class BotMode(BaseSettings): + """ + Bot mode. + Used to determine when the bot will run. + """ -class ChannelConfig(BaseSettings): - # all of the below should be validated to channels - # either by name or by int - main_category: str = None - fallback_category: str = None - log_channel: str = None - mention_channel: str = None - update_channel: str = None + production: bool = True + plugin_dev: bool = False + develop: bool = False class DevConfig(BaseSettings): @@ -152,165 +120,12 @@ class DevConfig(BaseSettings): """ log_level: conint(ge=0, le=50) = getattr(logging, "NOTICE", 25) - - -class EmojiConfig(BaseSettings): - """ - Standard emojis that the bot uses when a specific emoji is not defined for a specific use. - """ - - sent_emoji: str = "\\N{WHITE HEAVY CHECK MARK}" # TODO type as a discord emoji - blocked_emoji: str = "\\N{NO ENTRY SIGN}" # TODO type as a discord emoji - - -class InternalConfig(BaseModel): - # do NOT set these yourself. The bot will handle these - activity_message: str = None - activity_type: None = None - status: None = None - dm_disabled: int = 0 - # moderation - blocked: dict = dict() - blocked_roles: dict = dict() - blocked_whitelist: list = dict() - command_permissions: dict = dict() - level_permissions: dict = dict() - override_command_level: dict = dict() - # threads - snippets: dict = dict() - notifications: dict = dict() - subscriptions: dict = dict() - closures: dict = dict() - # misc - plugins: list = list() - aliases: dict = dict() - auto_triggers: dict = dict() - command_permissions: dict = dict() - level_permissions: dict = dict() - - class Config: - arbitrary_types_allowed = True - - -class MentionConfig(BaseSettings): - alert_on_mention: bool = False - silent_alert_on_mention: bool = False - mention_channel: int = None - - -class SnippetConfig(BaseSettings): - anonmous_snippets: bool = False - use_regex_autotrigger: bool = False - - -class ThreadAnonConfig(ThreadBaseSettings): - username: str = "Response" - footer: str = "Staff Team" - - -class ThreadAutoCloseConfig(ThreadBaseSettings): - time: datetime.timedelta = 0 - silently: bool = False - response: str = "This thread has been closed automatically due to inactivity after {timeout}." - - -class ThreadCloseConfig(ThreadBaseSettings): - footer: str = "Replying will create a new thread" - title: str = "Thread Closed" - response: str = "{closer.mention} has closed this Modmail thread." - on_leave: bool = False - on_leave_reason: str = "The recipient has left the server." - self_close_response: str = "You have closed this Modmail thread." - - -class ThreadConfirmCreationConfig(ThreadBaseSettings): - enabled: bool = False - title: str = "Confirm thread creation" - response: str = "React to confirm thread creation which will directly contact the moderators" - accept_emoji: str = "\N{WHITE HEAVY CHECK MARK}" # TODO type as a discord emoji - deny_emoji: str = "\N{NO ENTRY SIGN}" # TODO type as a discord emoji - - -class ThreadCooldownConfig(ThreadBaseSettings): - time: datetime.timedelta = 0 - embed_title: str = "Message not sent!" - response: str = "You must wait for {delta} before you can contact me again." - - -class ThreadCreationConfig(ThreadBaseSettings): - response: str = "The staff team will get back to you as soon as possible." - footer: str = "Your message has been sent" - title: str = "Thread Created" - - -class ThreadDisabledConfig(ThreadBaseSettings): - new_title: str = "Not Delivered" - new_response: str = "We are not accepting new threads." - new_footer: str = "Please try again later..." - current_title: str = "Not Delivered" - current_response: str = "We are not accepting any messages." - current_footer: str = "Please try again later..." - - -class ThreadMoveConfig(ThreadBaseSettings): - title: str = "Thread Moved" - notify: bool = False - notify_mods: bool = False - response: str = "This thread has been moved." - - -class ThreadSelfClosableConfig(ThreadBaseSettings): - enabled: bool = False - lock_emoji: str = "\N{LOCK}" - creation_footer: str = "Click the lock to close the thread" - - -class ThreadConfig(BaseSettings): - anon_reply_without_command: bool = False - reply_without_command: bool = False - plain_reply_without_command: bool = False - mention: str = "@here" - user_typing: bool = False - mod_typing: bool = False - transfer_reactions: bool = True - contact_silently: bool = False - account_age: datetime.timedelta = 0 - guild_age: datetime.timedelta = 0 - mod_tag: str = "" - show_timestamp: bool = True - - anon: ThreadAnonConfig - auto_close: ThreadAutoCloseConfig - close: ThreadCloseConfig - confirm_creation: ThreadConfirmCreationConfig - cooldown: ThreadCooldownConfig - creation: ThreadCreationConfig - disabled: ThreadDisabledConfig - move: ThreadMoveConfig - self_closable: ThreadSelfClosableConfig - - -class UpdateConfig(BaseSettings): - disable_autoupdates: bool = False - update_notifications: bool = True - - class Config: - allow_mutation = False - env_prefix = "updates." + mode: BotMode class ModmailConfig(BaseSettings): bot: BotConfig - colors: ColorsConfig - channels: ChannelConfig dev: DevConfig - emoji: EmojiConfig - mention: MentionConfig - snippets: SnippetConfig - thread: ThreadConfig - updates: UpdateConfig - shell: str = None CONFIG = ModmailConfig() -INTERNAL = InternalConfig() diff --git a/modmail/extensions/__init__.py b/modmail/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py new file mode 100644 index 00000000..2f5c5a41 --- /dev/null +++ b/modmail/extensions/extension_manager.py @@ -0,0 +1,303 @@ +# original source: +# https://github.com/python-discord/bot/blob/a8869b4d60512b173871c886321b261cbc4acca9/bot/exts/utils/extensions.py # noqa: E501 +# MIT License 2021 Python Discord +import functools +import logging +import typing as t +from collections import defaultdict +from enum import Enum + +from discord import AllowedMentions, Colour, Embed +from discord.ext import commands +from discord.ext.commands import Context + +from modmail.bot import ModmailBot +from modmail.log import ModmailLogger +from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog +from modmail.utils.extensions import EXTENSIONS, NO_UNLOAD, unqualify, walk_extensions + +log: ModmailLogger = logging.getLogger(__name__) + + +BASE_PATH_LEN = __name__.count(".") + +EXT_METADATA = ExtMetadata(load_if_mode=BotModes.DEVELOP, no_unload=True) + + +class Action(Enum): + """Represents an action to perform on an extension.""" + + # Need to be partial otherwise they are considered to be function definitions. + LOAD = functools.partial(ModmailBot.load_extension) + UNLOAD = functools.partial(ModmailBot.unload_extension) + RELOAD = functools.partial(ModmailBot.reload_extension) + + +class ExtensionConverter(commands.Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * value bypasses this when used with the an extension manger command. + """ + + source_list = EXTENSIONS + type = "extension" + + async def convert(self, _: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*": + return argument + + argument = argument.lower() + + if argument in self.source_list: + return argument + + qualified_arg = f"modmail.{self.type}s.{argument}" + if qualified_arg in self.source_list: + return qualified_arg + + matches = [] + for ext in self.source_list: + if argument == unqualify(ext): + matches.append(ext) + + if not matches: + raise commands.BadArgument(f":x: Could not find the {self.type} `{argument}`.") + + if len(matches) > 1: + names = "\n".join(sorted(matches)) + raise commands.BadArgument( + f":x: `{argument}` is an ambiguous {self.type} name. " + f"Please use one of the following fully-qualified names.```\n{names}```" + ) + + return matches[0] + + +class ExtensionManager(ModmailCog, name="Extension Manager"): + """ + Extension management. + + Commands to load, reload, unload, and list extensions. + """ + + type = "extension" + + def __init__(self, bot: ModmailBot): + self.bot = bot + self.all_extensions = EXTENSIONS + self.refresh_method = walk_extensions + + def get_black_listed_extensions(self) -> list: + """Returns a list of all unload blacklisted extensions.""" + return NO_UNLOAD + + @commands.group("ext", aliases=("extensions", "exts"), invoke_without_command=True) + async def extensions_group(self, ctx: Context) -> None: + """Load, unload, reload, and list loaded extensions.""" + await ctx.send_help(ctx.command) + + @extensions_group.command(name="load", aliases=("l",)) + async def load_extensions(self, ctx: Context, *extensions: ExtensionConverter) -> None: + """ + Load extensions given their fully qualified or unqualified names. + + If '\*' is given as the name, all unloaded extensions will be loaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "*" in extensions: + extensions = sorted(ext for ext in self.all_extensions if ext not in self.bot.extensions.keys()) + + msg = self.batch_manage(Action.LOAD, *extensions) + await ctx.send(msg) + + @extensions_group.command(name="unload", aliases=("ul",)) + async def unload_extensions(self, ctx: Context, *extensions: ExtensionConverter) -> None: + """ + Unload currently loaded extensions given their fully qualified or unqualified names. + + If '\*' is given as the name, all loaded extensions will be unloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + blacklisted = [ext for ext in self.get_black_listed_extensions() if ext in extensions] + + if blacklisted: + bl_msg = "\n".join(blacklisted) + await ctx.send(f":x: The following {self.type}(s) may not be unloaded:```\n{bl_msg}```") + return + + if "*" in extensions: + extensions = sorted( + ext + for ext in self.bot.extensions.keys() & self.all_extensions + if ext not in (self.get_black_listed_extensions()) + ) + + await ctx.send(self.batch_manage(Action.UNLOAD, *extensions)) + + @extensions_group.command(name="reload", aliases=("r", "rl")) + async def reload_extensions(self, ctx: Context, *extensions: ExtensionConverter) -> None: + """ + Reload extensions given their fully qualified or unqualified names. + + If an extension fails to be reloaded, it will be rolled-back to the prior working state. + + If '\*' is given as the name, all currently loaded extensions will be reloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "*" in extensions: + extensions = self.bot.extensions.keys() & self.all_extensions.keys() + + await ctx.send(self.batch_manage(Action.RELOAD, *extensions)) + + @extensions_group.command(name="list", aliases=("all", "ls")) + async def list_extensions(self, ctx: Context) -> None: + """ + Get a list of all extensions, including their loaded status. + + Red indicates that the extension is unloaded. + Green indicates that the extension is currently loaded. + """ + embed = Embed(colour=Colour.blurple()) + embed.set_author( + name=f"{self.type.capitalize()} List", + ) + + lines = [] + categories = self.group_extension_statuses() + for category, extensions in sorted(categories.items()): + # Treat each category as a single line by concatenating everything. + # This ensures the paginator will not cut off a page in the middle of a category. + category = category.replace("_", " ").title() + extensions = "\n".join(sorted(extensions)) + lines.append(f"**{category}**\n{extensions}\n") + + log.debug(f"{ctx.author} requested a list of all {self.type}s. " "Returning a paginated list.") + + # TODO: since we currently don't have a paginator. + await ctx.send("".join(lines) or f"There are no {self.type}s installed.") + + @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) + async def resync_extensions(self, ctx: Context) -> None: + """ + Refreshes the list of extensions from disk, but do not unload any currently active. + + Typical use case is in the event that the existing extensions have changed while the bot is running. + """ + log.debug(f"Refreshing list of {self.type}s.") + + # make sure the new walk contains all currently loaded extensions, so they can be unloaded + loaded_extensions = {} + for name, should_load in self.all_extensions.items(): + if name in self.bot.extensions: + loaded_extensions[name] = should_load + + # now that we know what the list was, we can clear it + self.all_extensions.clear() + # put the loaded extensions back in + self.all_extensions.update(loaded_extensions) + # now we can re-walk the extensions + self.all_extensions.update(self.refresh_method()) + await ctx.send(f":ok_hand: Refreshed list of {self.type}s.") + + def group_extension_statuses(self) -> t.Mapping[str, str]: + """Return a mapping of extension names and statuses to their categories.""" + categories = defaultdict(list) + + for ext in self.all_extensions: + if ext in self.bot.extensions: + status = ":green_circle:" + else: + status = ":red_circle:" + + root, name = ext.rsplit(".", 1) + category = " - ".join(root.split(".")) + categories[category].append(f"{status} {name}") + + return dict(categories) + + def batch_manage(self, action: Action, *extensions: str) -> str: + """ + Apply an action to multiple extensions and return a message with the results. + + If only one extension is given, it is deferred to `manage()`. + """ + if len(extensions) == 1: + msg, _ = self.manage(action, extensions[0]) + return msg + + verb = action.name.lower() + failures = {} + + for extension in sorted(extensions): + _, error = self.manage(action, extension) + if error: + failures[extension] = error + + emoji = ":x:" if failures else ":thumbsup:" + msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} {self.type}s {verb}ed." + + if failures: + failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) + msg += f"\nFailures:```\n{failures}```" + + log.debug(f"Batch {verb}ed {self.type}s.") + + return msg + + def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: + """Apply an action to an extension and return the status message and any error message.""" + verb = action.name.lower() + error_msg = None + + try: + action.value(self.bot, ext) + except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): + if action is Action.RELOAD: + # When reloading, have a special error. + msg = f":x: {self.type.capitalize()} `{ext}` is not loaded, so it was not {verb}ed." + else: + msg = f":x: {self.type.capitalize()} `{ext}` is already {verb}ed." + except Exception as e: + if hasattr(e, "original"): + # If original exception is present, then utilize it + e = e.original + + log.exception(f"{self.type.capitalize()} '{ext}' failed to {verb}.") + + error_msg = f"{e.__class__.__name__}: {e}" + msg = f":x: Failed to {verb} {self.type} `{ext}`:\n```\n{error_msg}```" + else: + msg = f":thumbsup: {self.type.capitalize()} successfully {verb}ed: `{ext}`." + + log.debug(error_msg or msg) + return msg, error_msg + + # This cannot be static (must have a __func__ attribute). + async def cog_check(self, ctx: Context) -> bool: + """Only allow bot owners to invoke the commands in this cog.""" + # TODO: Change to allow other users to invoke this too. + return await self.bot.is_owner(ctx.author) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle BadArgument errors locally to prevent the help command from showing.""" + if isinstance(error, commands.BadArgument): + await ctx.send(str(error), allowed_mentions=AllowedMentions.none()) + error.handled = True + + +def setup(bot: ModmailBot) -> None: + """Load the Extension Manager cog.""" + bot.add_cog(ExtensionManager(bot)) diff --git a/modmail/extensions/meta.py b/modmail/extensions/meta.py new file mode 100644 index 00000000..d90b0146 --- /dev/null +++ b/modmail/extensions/meta.py @@ -0,0 +1,39 @@ +import logging + +from discord.ext import commands + +from modmail.bot import ModmailBot +from modmail.log import ModmailLogger +from modmail.utils.cogs import ExtMetadata, ModmailCog + +log: ModmailLogger = logging.getLogger(__name__) + +EXT_METADATA = ExtMetadata() + + +class Meta(ModmailCog): + """Meta commands to get info about the bot itself.""" + + def __init__(self, bot: ModmailBot): + self.bot = bot + + @commands.command(name="ping", aliases=("pong",)) + async def ping(self, ctx: commands.Context) -> None: + """Ping the bot to see its latency and state.""" + await ctx.send(f"{round(self.bot.latency * 1000)}ms") + + @commands.command(name="uptime") + async def uptime(self, ctx: commands.Context) -> None: + """Get the current uptime of the bot.""" + timestamp = round(float(self.bot.start_time.format("X"))) + await ctx.send(f"Start time: ") + + @commands.command(name="prefix") + async def prefix(self, ctx: commands.Context) -> None: + """Return the configured prefix.""" + await ctx.send(f"My current prefix is `{self.bot.config.bot.prefix}`") + + +def setup(bot: ModmailBot) -> None: + """Load the Meta cog.""" + bot.add_cog(Meta(bot)) diff --git a/modmail/extensions/plugin_manager.py b/modmail/extensions/plugin_manager.py new file mode 100644 index 00000000..4661a7ae --- /dev/null +++ b/modmail/extensions/plugin_manager.py @@ -0,0 +1,112 @@ +from discord.ext import commands +from discord.ext.commands import Context + +from modmail.bot import ModmailBot +from modmail.extensions.extension_manager import ExtensionConverter, ExtensionManager +from modmail.utils.cogs import BotModes, ExtMetadata +from modmail.utils.plugins import PLUGINS, walk_plugins + +EXT_METADATA = ExtMetadata(load_if_mode=BotModes.PRODUCTION) + + +class PluginConverter(ExtensionConverter): + """ + Fully qualify the name of a plugin and ensure it exists. + + The * value bypasses this when used with the a plugin manger command. + """ # noqa: W605 + + source_list = PLUGINS + type = "plugin" + NO_UNLOAD = None + + +class PluginManager(ExtensionManager, name="Plugin Manager"): + """Plugin management commands.""" + + type = "plugin" + + def __init__(self, bot: ModmailBot) -> None: + super().__init__(bot) + self.all_extensions = PLUGINS + self.refresh_method = walk_plugins + + def get_black_listed_extensions(self) -> list: + """ + Returns a list of all unload blacklisted plugins. + + This method exists to override the one in extensions manager, + due to the fact that blacklisting plugins is not supported. + """ + return [] + + @commands.group("plugins", aliases=("plug", "plugs"), invoke_without_command=True) + async def plugins_group(self, ctx: Context) -> None: + """Install, uninstall, disable, update, and enable installed plugins.""" + await ctx.send_help(ctx.command) + + @plugins_group.command(name="load", aliases=("l",)) + async def load_plugin(self, ctx: Context, *plugins: PluginConverter) -> None: + """ + Load plugins given their fully qualified or unqualified names. + + If '\*' is given as the name, all unloaded plugins will be loaded. + """ # noqa: W605 + await self.load_extensions.callback(self, ctx, *plugins) + + @plugins_group.command(name="unload", aliases=("ul",)) + async def unload_plugins(self, ctx: Context, *plugins: PluginConverter) -> None: + """ + Unload currently loaded plugins given their fully qualified or unqualified names. + + If '\*' is given as the name, all loaded plugins will be unloaded. + """ # noqa: W605 + await self.unload_extensions.callback(self, ctx, *plugins) + + @plugins_group.command(name="reload", aliases=("r", "rl")) + async def reload_plugins(self, ctx: Context, *plugins: PluginConverter) -> None: + """ + Reload plugins given their fully qualified or unqualified names. + + If an plugin fails to be reloaded, it will be rolled-back to the prior working state. + + If '\*' is given as the name, all currently loaded plugins will be reloaded. + """ # noqa: W605 + await self.reload_extensions.callback(self, ctx, *plugins) + + @plugins_group.command(name="list", aliases=("all", "ls")) + async def list_plugins(self, ctx: Context) -> None: + """ + Get a list of all plugins, including their loaded status. + + Red indicates that the plugin is unloaded. + Green indicates that the plugin is currently loaded. + """ + await self.list_extensions.callback(self, ctx) + + @plugins_group.command(name="refresh", aliases=("rewalk", "rescan")) + async def resync_plugins(self, ctx: Context) -> None: + """Refreshes the list of plugins from disk, but do not unload any currently active.""" + await self.resync_extensions.callback(self, ctx) + + # TODO: Implement install/enable/disable/etc + + # This cannot be static (must have a __func__ attribute). + async def cog_check(self, ctx: Context) -> bool: + """Only allow server admins and bot owners to invoke the commands in this cog.""" + if ctx.guild is None: + return await self.bot.is_owner(ctx.author) + else: + return ctx.author.guild_permissions.administrator or await self.bot.is_owner(ctx.author) + + +# HACK: Delete the commands from ExtensionManager that PluginManager has inherited +# before discord.py tries to re-register them +for command in ExtensionManager.__cog_commands__: + PluginManager.__cog_commands__.remove(command) + + +def setup(bot: ModmailBot) -> None: + """Load the Plugins Manager cog.""" + # PluginManager includes the ExtensionManager + bot.add_cog(PluginManager(bot)) diff --git a/modmail/plugin_helpers.py b/modmail/plugin_helpers.py new file mode 100644 index 00000000..d20abf8e --- /dev/null +++ b/modmail/plugin_helpers.py @@ -0,0 +1,20 @@ +from modmail.bot import ModmailBot +from modmail.log import ModmailLogger +from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog + +__all__ = ["PluginCog", ModmailBot, ModmailLogger, BotModes, ExtMetadata] + + +class PluginCog(ModmailCog): + """ + The base class that all cogs must inherit from. + + A cog is a collection of commands, listeners, and optional state to + help group commands together. More information on them can be found on + the :ref:`ext_commands_cogs` page. + + When inheriting from this class, the options shown in :class:`CogMeta` + are equally valid here. + """ + + pass diff --git a/modmail/plugins/.gitignore b/modmail/plugins/.gitignore new file mode 100644 index 00000000..4be6c40b --- /dev/null +++ b/modmail/plugins/.gitignore @@ -0,0 +1,12 @@ +# start by ignoring all files +/* +# don't ignore this file +!/.gitignore + +# ignore the local folder, but not the readme +local/** +!local/ +!local/README.md + +# ensure this file is uploaded so `plugins` is considered a module +!/__init__.py diff --git a/modmail/plugins/__init__.py b/modmail/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modmail/plugins/local/README.md b/modmail/plugins/local/README.md new file mode 100644 index 00000000..817f6aba --- /dev/null +++ b/modmail/plugins/local/README.md @@ -0,0 +1,12 @@ +# Plugins + +This folder is where local plugins can be put for developing. + +Plugins should be like normal discord cogs, but should subclass `PluginCog` from `modmail.plugin_helpers` + +```py +from modmail.plugin_helpers import PluginCog + +class MyPlugin(PluginCog): + pass +``` diff --git a/modmail/utils/cogs.py b/modmail/utils/cogs.py new file mode 100644 index 00000000..5d5a92b8 --- /dev/null +++ b/modmail/utils/cogs.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from enum import IntEnum, auto + +from discord.ext import commands + + +class BitwiseAutoEnum(IntEnum): + """Enum class which generates binary value for each item.""" + + def _generate_next_value_(name, start, count, last_values) -> int: # noqa: ANN001 N805 + """Override default enum auto() counter to return increasing powers of 2, 4, 8...""" + return 1 << count + + +class BotModes(BitwiseAutoEnum): + """ + Valid modes for the bot. + + These values affect logging levels, which extensions are loaded, and so forth. + """ + + PRODUCTION = auto() + DEVELOP = auto() + PLUGIN_DEV = auto() + + +BOT_MODES = BotModes + + +@dataclass() +class ExtMetadata: + """Ext metadata class to determine if extension should load at runtime depending on bot configuration.""" + + load_if_mode: int = BotModes.PRODUCTION + # this is to determine if the cog is allowed to be unloaded. + no_unload: bool = False + + def __init__(self, load_if_mode: int = BotModes.PRODUCTION, no_unload: bool = False) -> "ExtMetadata": + self.load_if_mode = load_if_mode + self.no_unload = no_unload + + +class ModmailCog(commands.Cog): + """ + The base class that all cogs must inherit from. + + A cog is a collection of commands, listeners, and optional state to + help group commands together. More information on them can be found on + the :ref:`ext_commands_cogs` page. + + When inheriting from this class, the options shown in :class:`CogMeta` + are equally valid here. + """ + + pass diff --git a/modmail/utils/extensions.py b/modmail/utils/extensions.py new file mode 100644 index 00000000..2734bfa0 --- /dev/null +++ b/modmail/utils/extensions.py @@ -0,0 +1,79 @@ +# original source: +# https://github.com/python-discord/bot/blob/a8869b4d60512b173871c886321b261cbc4acca9/bot/utils/extensions.py +# MIT License 2021 Python Discord +import importlib +import inspect +import logging +import pkgutil +import typing as t + +from modmail import extensions +from modmail.config import CONFIG +from modmail.log import ModmailLogger +from modmail.utils.cogs import BOT_MODES, BotModes, ExtMetadata + +log: ModmailLogger = logging.getLogger(__name__) + +EXT_METADATA = ExtMetadata + + +EXTENSIONS: t.Dict[str, t.Tuple[bool, bool]] = dict() +NO_UNLOAD: t.List[str] = list() + + +def unqualify(name: str) -> str: + """Return an unqualified name given a qualified module/package `name`.""" + return name.rsplit(".", maxsplit=1)[-1] + + +def determine_bot_mode() -> int: + """ + Figure out the bot mode from the configuration system. + + The configuration system uses true/false values, so we need to turn them into an integer for bitwise. + """ + bot_mode = 0 + for mode in BotModes: + if getattr(CONFIG.dev.mode, unqualify(str(mode)).lower(), True): + bot_mode += mode.value + return bot_mode + + +BOT_MODE = determine_bot_mode() + + +log.trace(f"BOT_MODE value: {BOT_MODE}") +log.debug(f"Dev mode status: {bool(BOT_MODE & BOT_MODES.DEVELOP)}") +log.debug(f"Plugin dev mode status: {bool(BOT_MODE & BOT_MODES.PLUGIN_DEV)}") + + +def walk_extensions() -> t.Iterator[t.Tuple[str, t.Tuple[bool, bool]]]: + """Yield extension names from the modmail.exts subpackage.""" + + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) # pragma: no cover + + for module in pkgutil.walk_packages(extensions.__path__, f"{extensions.__name__}.", onerror=on_error): + if unqualify(module.name).startswith("_"): + # Ignore module/package names starting with an underscore. + continue + + imported = importlib.import_module(module.name) + if module.ispkg: + if not inspect.isfunction(getattr(imported, "setup", None)): + # If it lacks a setup function, it's not an extension. + continue + + ext_metadata: ExtMetadata = getattr(imported, "EXT_METADATA", None) + if ext_metadata is not None: + # check if this cog is dev only or plugin dev only + load_cog = bool(int(ext_metadata.load_if_mode) & BOT_MODE) + log.trace(f"Load cog {module.name!r}?: {load_cog}") + no_unload = ext_metadata.no_unload + yield module.name, (load_cog, no_unload) + continue + + log.notice(f"Cog {module.name!r} is missing an EXT_METADATA variable. Assuming its a normal cog.") + + # Presume Production Mode/Metadata defaults if metadata var does not exist. + yield module.name, (ExtMetadata.load_if_mode, ExtMetadata.no_unload) diff --git a/modmail/utils/plugins.py b/modmail/utils/plugins.py new file mode 100644 index 00000000..e6f681ac --- /dev/null +++ b/modmail/utils/plugins.py @@ -0,0 +1,88 @@ +# original source: +# https://github.com/python-discord/bot/blob/a8869b4d60512b173871c886321b261cbc4acca9/bot/utils/extensions.py +# MIT License 2021 Python Discord +""" +Helper utililites for managing plugins. + +TODO: Expand file to download plugins from github and gitlab from a list that is passed. +""" + + +import glob +import importlib +import importlib.util +import inspect +import logging +import typing as t +from pathlib import Path + +from modmail import plugins +from modmail.log import ModmailLogger +from modmail.utils.cogs import ExtMetadata +from modmail.utils.extensions import BOT_MODE, unqualify + +log: ModmailLogger = logging.getLogger(__name__) + + +BASE_PATH = Path(plugins.__file__).parent.resolve() +PLUGIN_MODULE = "modmail.plugins" +PLUGINS: t.Dict[str, t.Tuple[bool, bool]] = dict() + + +def walk_plugins() -> t.Iterator[t.Tuple[str, bool]]: + """Yield plugin names from the modmail.plugins subpackage.""" + # walk all files in the plugins folder + # this is to ensure folder symlinks are supported, + # which are important for ease of development. + # NOTE: We are not using Pathlib's glob utility as it doesn't + # support following symlinks, see: https://bugs.python.org/issue33428 + for path in glob.iglob(f"{BASE_PATH}/**/*.py", recursive=True): + + log.trace("Path: {0}".format(path)) + + # calculate the module name, dervived from the relative path + relative_path = Path(path).relative_to(BASE_PATH) + name = relative_path.__str__().rstrip(".py").replace("/", ".") + name = PLUGIN_MODULE + "." + name + log.trace("Module name: {0}".format(name)) + + if unqualify(name.split(".")[-1]).startswith("_"): + # Ignore module/package names starting with an underscore. + continue + + # due to the fact that plugins are user generated and may not have gone through + # the testing that the bot has, we want to ensure we try/except any plugins + # that fail to import. + try: + # load the plugins using importlib + # this needs to be done like this, due to the fact that + # its possible a plugin will not have an __init__.py file + spec = importlib.util.spec_from_file_location(name, path) + imported = importlib.util.module_from_spec(spec) + spec.loader.exec_module(imported) + except Exception: + log.error( + "Failed to import {0}. As a result, this plugin is not considered installed.".format(name), + exc_info=True, + ) + continue + + if not inspect.isfunction(getattr(imported, "setup", None)): + # If it lacks a setup function, it's not a plugin. This is enforced by dpy. + log.trace("{0} does not have a setup function. Skipping.".format(name)) + continue + + ext_metadata: ExtMetadata = getattr(imported, "EXT_METADATA", None) + if ext_metadata is not None: + # check if this plugin is dev only or plugin dev only + load_cog = bool(int(ext_metadata.load_if_mode) & BOT_MODE) + log.trace(f"Load plugin {imported.__name__!r}?: {load_cog}") + yield imported.__name__, load_cog + continue + + log.info( + f"Plugin {imported.__name__!r} is missing a EXT_METADATA variable. Assuming its a normal plugin." + ) + + # Presume Production Mode/Metadata defaults if metadata var does not exist. + yield imported.__name__, ExtMetadata.load_if_mode diff --git a/poetry.lock b/poetry.lock index 1c609571..520ef224 100644 --- a/poetry.lock +++ b/poetry.lock @@ -39,6 +39,17 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "arrow" +version = "1.1.1" +description = "Better dates & times for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +python-dateutil = ">=2.7.0" + [[package]] name = "async-timeout" version = "3.0.1" @@ -244,7 +255,7 @@ toml = ["toml"] [[package]] name = "discord.py" -version = "2.0.0a3453+g58ca9e99" +version = "2.0.0a3467+g08a4db39" description = "A Python wrapper for the Discord API" category = "main" optional = false @@ -263,7 +274,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] type = "git" url = "https://github.com/Rapptz/discord.py.git" reference = "master" -resolved_reference = "58ca9e99325ac2678a51cd63afd7ae917f14bc8d" +resolved_reference = "08a4db396118aeda6205ff56c8c8fc565fc338fc" [[package]] name = "distlib" @@ -273,6 +284,24 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "environs" +version = "9.3.3" +description = "simplified environment variable parsing" +category = "main" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +marshmallow = ">=3.0.0" +python-dotenv = "*" + +[package.extras] +dev = ["pytest", "dj-database-url", "dj-email-url", "django-cache-url", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)", "tox"] +django = ["dj-database-url", "dj-email-url", "django-cache-url"] +lint = ["flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "dj-database-url", "dj-email-url", "django-cache-url"] + [[package]] name = "execnet" version = "1.9.0" @@ -453,7 +482,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.11" +version = "2.2.13" description = "File identification library for Python" category = "dev" optional = false @@ -492,6 +521,20 @@ requirements_deprecated_finder = ["pipreqs", "pip-api"] colors = ["colorama (>=0.4.3,<0.5.0)"] plugins = ["setuptools"] +[[package]] +name = "marshmallow" +version = "3.13.0" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +category = "main" +optional = true +python-versions = ">=3.5" + +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (==0.910)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["sphinx (==4.1.1)", "sphinx-issues (==1.2.0)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.6)"] +lint = ["mypy (==0.910)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "pytz", "simplejson"] + [[package]] name = "mccabe" version = "0.6.1" @@ -561,7 +604,7 @@ python-versions = ">=2.6" [[package]] name = "pep8-naming" -version = "0.12.0" +version = "0.12.1" description = "Check PEP-8 naming conventions, plugin for flake8" category = "dev" optional = false @@ -596,7 +639,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.13.0" +version = "2.14.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -668,6 +711,7 @@ optional = false python-versions = ">=3.6.1" [package.dependencies] +python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""} typing-extensions = ">=3.7.4.3" [package.extras] @@ -815,13 +859,24 @@ pytest-forked = "*" psutil = ["psutil (>=3.0)"] testing = ["filelock"] +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" -version = "0.17.1" +version = "0.19.0" description = "Read key-value pairs from a .env file and set them as environment variables" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.5" [package.extras] cli = ["click (>=5.0)"] @@ -836,7 +891,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "regex" -version = "2021.7.6" +version = "2021.8.3" description = "Alternative regular expression module, to replace re." category = "dev" optional = false @@ -864,7 +919,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" @@ -932,7 +987,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tomli" -version = "1.2.0" +version = "1.2.1" description = "A lil' TOML parser" category = "dev" optional = false @@ -961,7 +1016,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.7.0" +version = "20.7.2" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -990,10 +1045,13 @@ python-versions = ">=3.6" idna = ">=2.0" multidict = ">=4.0" +[extras] +environs = ["environs"] + [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "c7a0c52a052d6ecf36dcae9afdfdeee0c3bb98c15af1d72185254766d7f33028" +content-hash = "ffc92d956a0a41f4495c9f72484a26d0d261836d15b984d686d8e44276bd6244" [metadata.files] aiodns = [ @@ -1043,6 +1101,10 @@ appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +arrow = [ + {file = "arrow-1.1.1-py3-none-any.whl", hash = "sha256:77a60a4db5766d900a2085ce9074c5c7b8e2c99afeaa98ad627637ff6f292510"}, + {file = "arrow-1.1.1.tar.gz", hash = "sha256:dee7602f6c60e3ec510095b5e301441bc56288cb8f51def14dcb3079f623823a"}, +] async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, @@ -1276,6 +1338,10 @@ distlib = [ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] +environs = [ + {file = "environs-9.3.3-py2.py3-none-any.whl", hash = "sha256:ee5466156b50fe03aa9fec6e720feea577b5bf515d7f21b2c46608272557ba26"}, + {file = "environs-9.3.3.tar.gz", hash = "sha256:72b867ff7b553076cdd90f3ee01ecc1cf854987639c9c459f0ed0d3d44ae490c"}, +] execnet = [ {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"}, {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, @@ -1335,8 +1401,8 @@ humanfriendly = [ {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, ] identify = [ - {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, - {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, + {file = "identify-2.2.13-py2.py3-none-any.whl", hash = "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c"}, + {file = "identify-2.2.13.tar.gz", hash = "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79"}, ] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, @@ -1350,6 +1416,10 @@ isort = [ {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, ] +marshmallow = [ + {file = "marshmallow-3.13.0-py2.py3-none-any.whl", hash = "sha256:dd4724335d3c2b870b641ffe4a2f8728a1380cd2e7e2312756715ffeaa82b842"}, + {file = "marshmallow-3.13.0.tar.gz", hash = "sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e"}, +] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, @@ -1418,8 +1488,8 @@ pbr = [ {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"}, ] pep8-naming = [ - {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, - {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, + {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"}, + {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, ] platformdirs = [ {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, @@ -1430,8 +1500,8 @@ pluggy = [ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, - {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, + {file = "pre_commit-2.14.0-py2.py3-none-any.whl", hash = "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4"}, + {file = "pre_commit-2.14.0.tar.gz", hash = "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1578,9 +1648,13 @@ pytest-xdist = [ {file = "pytest-xdist-2.3.0.tar.gz", hash = "sha256:e8ecde2f85d88fbcadb7d28cb33da0fa29bca5cf7d5967fa89fc0e97e5299ea5"}, {file = "pytest_xdist-2.3.0-py3-none-any.whl", hash = "sha256:ed3d7da961070fce2a01818b51f6888327fb88df4379edeb6b9d990e789d9c8d"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] python-dotenv = [ - {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, - {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"}, + {file = "python-dotenv-0.19.0.tar.gz", hash = "sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172"}, + {file = "python_dotenv-0.19.0-py2.py3-none-any.whl", hash = "sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1"}, ] pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, @@ -1614,47 +1688,20 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] regex = [ - {file = "regex-2021.7.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407"}, - {file = "regex-2021.7.6-cp36-cp36m-win32.whl", hash = "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b"}, - {file = "regex-2021.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb"}, - {file = "regex-2021.7.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895"}, - {file = "regex-2021.7.6-cp37-cp37m-win32.whl", hash = "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5"}, - {file = "regex-2021.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f"}, - {file = "regex-2021.7.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068"}, - {file = "regex-2021.7.6-cp38-cp38-win32.whl", hash = "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0"}, - {file = "regex-2021.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4"}, - {file = "regex-2021.7.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3"}, - {file = "regex-2021.7.6-cp39-cp39-win32.whl", hash = "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035"}, - {file = "regex-2021.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c"}, - {file = "regex-2021.7.6.tar.gz", hash = "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4551728b767f35f86b8e5ec19a363df87450c7376d7419c3cac5b9ceb4bce576"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:577737ec3d4c195c4aef01b757905779a9e9aee608fa1cf0aec16b5576c893d3"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c856ec9b42e5af4fe2d8e75970fcc3a2c15925cbcc6e7a9bcb44583b10b95e80"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3835de96524a7b6869a6c710b26c90e94558c31006e96ca3cf6af6751b27dca1"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cea56288eeda8b7511d507bbe7790d89ae7049daa5f51ae31a35ae3c05408531"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff4a8ad9638b7ca52313d8732f37ecd5fd3c8e3aff10a8ccb93176fd5b3812f6"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b63e3571b24a7959017573b6455e05b675050bbbea69408f35f3cb984ec54363"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fbc20975eee093efa2071de80df7f972b7b35e560b213aafabcec7c0bd00bd8c"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb350eb1060591d8e89d6bac4713d41006cd4d479f5e11db334a48ff8999512f"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3905c86cc4ab6d71635d6419a6f8d972cab7c634539bba6053c47354fd04452c"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:28e8af338240b6f39713a34e337c3813047896ace09d51593d6907c66c0708ba"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:85f568892422a0e96235eb8ea6c5a41c8ccbf55576a2260c0160800dbd7c4f20"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9569da9e78f0947b249370cb8fadf1015a193c359e7e442ac9ecc585d937f08d"}, + {file = "regex-2021.8.3.tar.gz", hash = "sha256:8935937dad2c9b369c3d932b0edbc52a62647c2afb2fafc0c280f14a8bf56a6a"}, ] requests = [ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, @@ -1689,8 +1736,8 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tomli = [ - {file = "tomli-1.2.0-py3-none-any.whl", hash = "sha256:056f0376bf5a6b182c513f9582c1e5b0487265eb6c48842b69aa9ca1cd5f640a"}, - {file = "tomli-1.2.0.tar.gz", hash = "sha256:d60e681734099207a6add7a10326bc2ddd1fdc36c1b0f547d00ef73ac63739c2"}, + {file = "tomli-1.2.1-py3-none-any.whl", hash = "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f"}, + {file = "tomli-1.2.1.tar.gz", hash = "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442"}, ] typing-extensions = [ {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, @@ -1702,8 +1749,8 @@ urllib3 = [ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, ] virtualenv = [ - {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, - {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, + {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"}, + {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index 89da5099..c1122012 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,34 +1,37 @@ [tool.poetry] name = "Modmail" -version = "0.0.1" +version = "0.1.0" description = "A modmail bot for Discord. Python 3.8+ compatiable" license = "MIT" - authors = ["aru ", "bast "] - readme = "README.md" repository = "https://github.com/discord-modmail/modmail" homepage = "https://github.com/discord-modmail/modmail" keywords = ["discord", "modmail"] - +include = ["CHANGELOG.md", "env.template"] packages = [{ include = "modmail" }] [tool.poetry.dependencies] python = "^3.8" aiohttp = { extras = ["speedups"], version = "^3.7.4" } +arrow = "^1.1.1" colorama = "^0.4.3" coloredlogs = "^15.0" -"discord.py" = {git = "https://github.com/Rapptz/discord.py.git", rev = "master"} -pydantic = "^1.8.2" -python-dotenv = "~=0.17.1" +"discord.py" = { git = "https://github.com/Rapptz/discord.py.git", rev = "master" } +environs = { version = "~=9.3.3", optional = true } +pydantic = { version = "^1.8.2", extras = ["dotenv"] } toml = "^0.10.2" +[tool.poetry.extras] +environs = ["environs"] + [tool.poetry.dev-dependencies] +# always needed +pre-commit = "~=2.1" +taskipy = "^1.6.0" +# linting black = "^21.7b0" -codecov = "^2.1.11" -coverage = { extras = ["toml"], version = "^5.5" } flake8 = "~=3.8" flake8-annotations = "~=2.3" flake8-bandit = "^2.1.2" @@ -40,19 +43,35 @@ flake8-tidy-imports = "~=4.1" flake8-todo = "~=0.7" isort = "^5.9.2" pep8-naming = "~=0.11" -pre-commit = "~=2.1" +# testing +codecov = "^2.1.11" +coverage = { extras = ["toml"], version = "^5.5" } pytest = "^6.2.4" pytest-asyncio = "^0.15.1" pytest-cov = "^2.12.1" pytest-dependency = "^0.5.1" pytest-docs = "^0.1.0" pytest-xdist = { version = "^2.3.0", extras = ["psutil"] } -taskipy = "^1.6.0" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" +[tool.coverage.run] +branch = true +source_pkgs = ["modmail"] +omit = ["modmail/plugins/**.*"] + +[tool.pytest.ini_options] +addopts = "--cov -ra" +minversion = "6.0" +testpaths = ["tests"] + +[tool.black] +line-length = 110 +target-version = ['py38'] +include = '\.pyi?$' + [tool.taskipy.tasks.export] cmd = """ echo 'Exporting installed packages to requirements.txt.\n\ @@ -67,27 +86,12 @@ help = "Export installed packages in requirements.txt format" [tool.taskipy.tasks] black = { cmd = "black --check .", help = "dry run of black" } -codecov-validate = { cmd= "curl --data-binary @.codecov.yml https://codecov.io/validate", help = "Validate `.codecov.yml` with their api." } +codecov-validate = { cmd = "curl --data-binary @.codecov.yml https://codecov.io/validate", help = "Validate `.codecov.yml` with their api." } flake8 = { cmd = "python -m flake8", help = "Lints code with flake8" } lint = { cmd = "pre-commit run --all-files", help = "Checks all files for CI errors" } precommit = { cmd = "pre-commit install --install-hooks", help = "Installs the precommit hook" } -pytest-docs = { cmd = "pytest --no-cov --docs tests/docs.md", help = "Create docs for tests using pytest-docs."} +pytest-docs = { cmd = "pytest --no-cov --docs tests/docs.md", help = "Create docs for tests using pytest-docs." } report = { cmd = "coverage report", help = "Show coverage report from previously run tests." } -run = { cmd = "python -m modmail", help = "Run bot" } +start = { cmd = "python -m modmail", help = "Run bot" } test = { cmd = "pytest -n auto --dist loadfile --cov-report= --cov= --docs tests/docs.md", help = "Runs tests and save results to a coverage report" } test-nocov = { cmd = "pytest --no-cov", help = "Runs tests without creating a coverage report" } - - -[tool.coverage.run] -branch = true -source_pkgs = ["modmail"] - -[tool.pytest.ini_options] -addopts = "--cov -ra" -minversion = "6.0" -testpaths = ["tests"] - -[tool.black] -line-length = 110 -target-version = ['py38'] -include = '\.pyi?$' diff --git a/requirements.txt b/requirements.txt index 5ae4032a..b53d19e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,28 @@ # Do not manually edit. # Generate with "poetry run task export" -aiodns==3.0.0; python_version >= "3.6" and python_full_version >= "3.8.0" +aiodns==3.0.0; python_version >= "3.6" or python_version >= "3.6" and python_full_version >= "3.8.0" aiohttp==3.7.4.post0; python_version >= "3.6" -async-timeout==3.0.1; python_full_version >= "3.8.0" and python_version >= "3.6" -attrs==21.2.0; python_full_version >= "3.8.0" and python_version >= "3.6" -brotlipy==0.7.0; python_version >= "3.6" and python_full_version >= "3.8.0" -cchardet==2.1.7; python_version >= "3.6" and python_full_version >= "3.8.0" +arrow==1.1.1; python_version >= "3.6" +async-timeout==3.0.1; python_full_version >= "3.5.3" and python_version >= "3.6" or python_version >= "3.6" and python_full_version >= "3.8.0" +attrs==21.2.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" or python_version >= "3.6" and python_full_version >= "3.8.0" +brotlipy==0.7.0; python_version >= "3.6" or python_version >= "3.6" and python_full_version >= "3.8.0" +cchardet==2.1.7; python_version >= "3.6" or python_version >= "3.6" and python_full_version >= "3.8.0" cffi==1.14.6; python_version >= "3.6" -chardet==4.0.0; python_full_version >= "3.8.0" and python_version >= "3.6" +chardet==4.0.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" or python_version >= "3.6" and python_full_version >= "3.8.0" colorama==0.4.4; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") coloredlogs==15.0.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") discord.py @ git+https://github.com/Rapptz/discord.py.git@master ; python_full_version >= "3.8.0" humanfriendly==9.2; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" idna==3.2; python_version >= "3.6" -multidict==5.1.0; python_version >= "3.6" and python_full_version >= "3.8.0" +multidict==5.1.0; python_version >= "3.6" and python_full_version >= "3.8.0" or python_version >= "3.6" pycares==4.0.0; python_version >= "3.6" pycparser==2.20; python_version >= "3.6" and python_full_version < "3.0.0" or python_version >= "3.6" and python_full_version >= "3.4.0" pydantic==1.8.2; python_full_version >= "3.6.1" pyreadline==2.1; python_version >= "2.7" and python_full_version < "3.0.0" and sys_platform == "win32" or python_full_version >= "3.5.0" and sys_platform == "win32" -python-dotenv==0.17.1 +python-dateutil==2.8.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6" +python-dotenv==0.19.0; python_full_version >= "3.6.1" and python_version >= "3.5" +six==1.16.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6" toml==0.10.2; (python_version >= "2.6" and python_full_version < "3.0.0") or (python_full_version >= "3.3.0") -typing-extensions==3.10.0.0; python_version >= "3.6" and python_full_version >= "3.8.0" -yarl==1.6.3; python_version >= "3.6" and python_full_version >= "3.8.0" +typing-extensions==3.10.0.0; python_version >= "3.6" or python_full_version >= "3.6.1" or python_version >= "3.6" and python_full_version >= "3.8.0" +yarl==1.6.3; python_version >= "3.6" and python_full_version >= "3.8.0" or python_version >= "3.6" diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 00000000..9bff0e00 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.9.6