├── .bandit_baseline.json ├── .dockerignore ├── .env.example ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── pull.yml └── workflows │ ├── docker-image.yml │ └── lints.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── PRIVACY.md ├── Pipfile ├── Pipfile.lock ├── Procfile ├── README.md ├── SPONSORS.json ├── app.json ├── bot.py ├── cogs ├── modmail.py ├── plugins.py └── utility.py ├── core ├── _color_data.py ├── changelog.py ├── checks.py ├── clients.py ├── config.py ├── config_help.json ├── models.py ├── paginator.py ├── thread.py ├── time.py └── utils.py ├── docker-compose.yml ├── modmail.sh ├── plugins ├── @local │ └── .gitignore └── registry.json ├── pyproject.toml ├── requirements.txt └── runtime.txt /.bandit_baseline.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": [], 3 | "generated_at": "2022-09-06T16:19:31Z", 4 | "metrics": { 5 | "./bot.py": { 6 | "CONFIDENCE.HIGH": 1, 7 | "CONFIDENCE.LOW": 0, 8 | "CONFIDENCE.MEDIUM": 0, 9 | "CONFIDENCE.UNDEFINED": 0, 10 | "SEVERITY.HIGH": 0, 11 | "SEVERITY.LOW": 1, 12 | "SEVERITY.MEDIUM": 0, 13 | "SEVERITY.UNDEFINED": 0, 14 | "loc": 1507, 15 | "nosec": 0, 16 | "skipped_tests": 0 17 | }, 18 | "./cogs/modmail.py": { 19 | "CONFIDENCE.HIGH": 0, 20 | "CONFIDENCE.LOW": 0, 21 | "CONFIDENCE.MEDIUM": 0, 22 | "CONFIDENCE.UNDEFINED": 0, 23 | "SEVERITY.HIGH": 0, 24 | "SEVERITY.LOW": 0, 25 | "SEVERITY.MEDIUM": 0, 26 | "SEVERITY.UNDEFINED": 0, 27 | "loc": 1837, 28 | "nosec": 0, 29 | "skipped_tests": 0 30 | }, 31 | "./cogs/plugins.py": { 32 | "CONFIDENCE.HIGH": 1, 33 | "CONFIDENCE.LOW": 0, 34 | "CONFIDENCE.MEDIUM": 0, 35 | "CONFIDENCE.UNDEFINED": 0, 36 | "SEVERITY.HIGH": 0, 37 | "SEVERITY.LOW": 1, 38 | "SEVERITY.MEDIUM": 0, 39 | "SEVERITY.UNDEFINED": 0, 40 | "loc": 597, 41 | "nosec": 0, 42 | "skipped_tests": 0 43 | }, 44 | "./cogs/utility.py": { 45 | "CONFIDENCE.HIGH": 2, 46 | "CONFIDENCE.LOW": 0, 47 | "CONFIDENCE.MEDIUM": 0, 48 | "CONFIDENCE.UNDEFINED": 0, 49 | "SEVERITY.HIGH": 0, 50 | "SEVERITY.LOW": 1, 51 | "SEVERITY.MEDIUM": 1, 52 | "SEVERITY.UNDEFINED": 0, 53 | "loc": 1794, 54 | "nosec": 0, 55 | "skipped_tests": 0 56 | }, 57 | "./core/_color_data.py": { 58 | "CONFIDENCE.HIGH": 0, 59 | "CONFIDENCE.LOW": 0, 60 | "CONFIDENCE.MEDIUM": 0, 61 | "CONFIDENCE.UNDEFINED": 0, 62 | "SEVERITY.HIGH": 0, 63 | "SEVERITY.LOW": 0, 64 | "SEVERITY.MEDIUM": 0, 65 | "SEVERITY.UNDEFINED": 0, 66 | "loc": 1166, 67 | "nosec": 0, 68 | "skipped_tests": 0 69 | }, 70 | "./core/changelog.py": { 71 | "CONFIDENCE.HIGH": 1, 72 | "CONFIDENCE.LOW": 0, 73 | "CONFIDENCE.MEDIUM": 0, 74 | "CONFIDENCE.UNDEFINED": 0, 75 | "SEVERITY.HIGH": 0, 76 | "SEVERITY.LOW": 1, 77 | "SEVERITY.MEDIUM": 0, 78 | "SEVERITY.UNDEFINED": 0, 79 | "loc": 159, 80 | "nosec": 0, 81 | "skipped_tests": 0 82 | }, 83 | "./core/checks.py": { 84 | "CONFIDENCE.HIGH": 0, 85 | "CONFIDENCE.LOW": 0, 86 | "CONFIDENCE.MEDIUM": 0, 87 | "CONFIDENCE.UNDEFINED": 0, 88 | "SEVERITY.HIGH": 0, 89 | "SEVERITY.LOW": 0, 90 | "SEVERITY.MEDIUM": 0, 91 | "SEVERITY.UNDEFINED": 0, 92 | "loc": 105, 93 | "nosec": 0, 94 | "skipped_tests": 0 95 | }, 96 | "./core/clients.py": { 97 | "CONFIDENCE.HIGH": 0, 98 | "CONFIDENCE.LOW": 0, 99 | "CONFIDENCE.MEDIUM": 1, 100 | "CONFIDENCE.UNDEFINED": 0, 101 | "SEVERITY.HIGH": 0, 102 | "SEVERITY.LOW": 1, 103 | "SEVERITY.MEDIUM": 0, 104 | "SEVERITY.UNDEFINED": 0, 105 | "loc": 644, 106 | "nosec": 0, 107 | "skipped_tests": 0 108 | }, 109 | "./core/config.py": { 110 | "CONFIDENCE.HIGH": 0, 111 | "CONFIDENCE.LOW": 0, 112 | "CONFIDENCE.MEDIUM": 0, 113 | "CONFIDENCE.UNDEFINED": 0, 114 | "SEVERITY.HIGH": 0, 115 | "SEVERITY.LOW": 0, 116 | "SEVERITY.MEDIUM": 0, 117 | "SEVERITY.UNDEFINED": 0, 118 | "loc": 388, 119 | "nosec": 0, 120 | "skipped_tests": 0 121 | }, 122 | "./core/models.py": { 123 | "CONFIDENCE.HIGH": 0, 124 | "CONFIDENCE.LOW": 0, 125 | "CONFIDENCE.MEDIUM": 0, 126 | "CONFIDENCE.UNDEFINED": 0, 127 | "SEVERITY.HIGH": 0, 128 | "SEVERITY.LOW": 0, 129 | "SEVERITY.MEDIUM": 0, 130 | "SEVERITY.UNDEFINED": 0, 131 | "loc": 210, 132 | "nosec": 0, 133 | "skipped_tests": 0 134 | }, 135 | "./core/paginator.py": { 136 | "CONFIDENCE.HIGH": 0, 137 | "CONFIDENCE.LOW": 0, 138 | "CONFIDENCE.MEDIUM": 0, 139 | "CONFIDENCE.UNDEFINED": 0, 140 | "SEVERITY.HIGH": 0, 141 | "SEVERITY.LOW": 0, 142 | "SEVERITY.MEDIUM": 0, 143 | "SEVERITY.UNDEFINED": 0, 144 | "loc": 312, 145 | "nosec": 0, 146 | "skipped_tests": 0 147 | }, 148 | "./core/thread.py": { 149 | "CONFIDENCE.HIGH": 0, 150 | "CONFIDENCE.LOW": 0, 151 | "CONFIDENCE.MEDIUM": 0, 152 | "CONFIDENCE.UNDEFINED": 0, 153 | "SEVERITY.HIGH": 0, 154 | "SEVERITY.LOW": 0, 155 | "SEVERITY.MEDIUM": 0, 156 | "SEVERITY.UNDEFINED": 0, 157 | "loc": 1184, 158 | "nosec": 0, 159 | "skipped_tests": 0 160 | }, 161 | "./core/time.py": { 162 | "CONFIDENCE.HIGH": 0, 163 | "CONFIDENCE.LOW": 0, 164 | "CONFIDENCE.MEDIUM": 0, 165 | "CONFIDENCE.UNDEFINED": 0, 166 | "SEVERITY.HIGH": 0, 167 | "SEVERITY.LOW": 0, 168 | "SEVERITY.MEDIUM": 0, 169 | "SEVERITY.UNDEFINED": 0, 170 | "loc": 265, 171 | "nosec": 0, 172 | "skipped_tests": 0 173 | }, 174 | "./core/utils.py": { 175 | "CONFIDENCE.HIGH": 0, 176 | "CONFIDENCE.LOW": 0, 177 | "CONFIDENCE.MEDIUM": 0, 178 | "CONFIDENCE.UNDEFINED": 0, 179 | "SEVERITY.HIGH": 0, 180 | "SEVERITY.LOW": 0, 181 | "SEVERITY.MEDIUM": 0, 182 | "SEVERITY.UNDEFINED": 0, 183 | "loc": 396, 184 | "nosec": 0, 185 | "skipped_tests": 0 186 | }, 187 | "./plugins/Cordila/cord/jishaku-migration/jishaku.py": { 188 | "CONFIDENCE.HIGH": 0, 189 | "CONFIDENCE.LOW": 0, 190 | "CONFIDENCE.MEDIUM": 0, 191 | "CONFIDENCE.UNDEFINED": 0, 192 | "SEVERITY.HIGH": 0, 193 | "SEVERITY.LOW": 0, 194 | "SEVERITY.MEDIUM": 0, 195 | "SEVERITY.UNDEFINED": 0, 196 | "loc": 2, 197 | "nosec": 0, 198 | "skipped_tests": 0 199 | }, 200 | "_totals": { 201 | "CONFIDENCE.HIGH": 5, 202 | "CONFIDENCE.LOW": 0, 203 | "CONFIDENCE.MEDIUM": 1, 204 | "CONFIDENCE.UNDEFINED": 0, 205 | "SEVERITY.HIGH": 0, 206 | "SEVERITY.LOW": 5, 207 | "SEVERITY.MEDIUM": 1, 208 | "SEVERITY.UNDEFINED": 0, 209 | "loc": 10566, 210 | "nosec": 0, 211 | "skipped_tests": 0 212 | } 213 | }, 214 | "results": [ 215 | { 216 | "code": "14 from datetime import datetime, timezone\n15 from subprocess import PIPE\n16 from types import SimpleNamespace\n", 217 | "col_offset": 0, 218 | "filename": "./bot.py", 219 | "issue_confidence": "HIGH", 220 | "issue_cwe": { 221 | "id": 78, 222 | "link": "https://cwe.mitre.org/data/definitions/78.html" 223 | }, 224 | "issue_severity": "LOW", 225 | "issue_text": "Consider possible security implications associated with the subprocess module.", 226 | "line_number": 15, 227 | "line_range": [ 228 | 15 229 | ], 230 | "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", 231 | "test_id": "B404", 232 | "test_name": "blacklist" 233 | }, 234 | { 235 | "code": "13 from site import USER_SITE\n14 from subprocess import PIPE\n15 \n16 import discord\n", 236 | "col_offset": 0, 237 | "filename": "./cogs/plugins.py", 238 | "issue_confidence": "HIGH", 239 | "issue_cwe": { 240 | "id": 78, 241 | "link": "https://cwe.mitre.org/data/definitions/78.html" 242 | }, 243 | "issue_severity": "LOW", 244 | "issue_text": "Consider possible security implications associated with the subprocess module.", 245 | "line_number": 14, 246 | "line_range": [ 247 | 14, 248 | 15 249 | ], 250 | "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", 251 | "test_id": "B404", 252 | "test_name": "blacklist" 253 | }, 254 | { 255 | "code": "11 from json import JSONDecodeError, loads\n12 from subprocess import PIPE\n13 from textwrap import indent\n", 256 | "col_offset": 0, 257 | "filename": "./cogs/utility.py", 258 | "issue_confidence": "HIGH", 259 | "issue_cwe": { 260 | "id": 78, 261 | "link": "https://cwe.mitre.org/data/definitions/78.html" 262 | }, 263 | "issue_severity": "LOW", 264 | "issue_text": "Consider possible security implications associated with the subprocess module.", 265 | "line_number": 12, 266 | "line_range": [ 267 | 12 268 | ], 269 | "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", 270 | "test_id": "B404", 271 | "test_name": "blacklist" 272 | }, 273 | { 274 | "code": "2093 try:\n2094 exec(to_compile, env) # pylint: disable=exec-used\n2095 except Exception as exc:\n", 275 | "col_offset": 12, 276 | "filename": "./cogs/utility.py", 277 | "issue_confidence": "HIGH", 278 | "issue_cwe": { 279 | "id": 78, 280 | "link": "https://cwe.mitre.org/data/definitions/78.html" 281 | }, 282 | "issue_severity": "MEDIUM", 283 | "issue_text": "Use of exec detected.", 284 | "line_number": 2094, 285 | "line_range": [ 286 | 2094 287 | ], 288 | "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b102_exec_used.html", 289 | "test_id": "B102", 290 | "test_name": "exec_used" 291 | }, 292 | { 293 | "code": "2 import re\n3 from subprocess import PIPE\n4 from typing import List\n", 294 | "col_offset": 0, 295 | "filename": "./core/changelog.py", 296 | "issue_confidence": "HIGH", 297 | "issue_cwe": { 298 | "id": 78, 299 | "link": "https://cwe.mitre.org/data/definitions/78.html" 300 | }, 301 | "issue_severity": "LOW", 302 | "issue_text": "Consider possible security implications associated with the subprocess module.", 303 | "line_number": 3, 304 | "line_range": [ 305 | 3 306 | ], 307 | "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", 308 | "test_id": "B404", 309 | "test_name": "blacklist" 310 | }, 311 | { 312 | "code": "70 \n71 def __init__(self, bot, access_token: str = \"\", username: str = \"\", **kwargs):\n72 self.bot = bot\n73 self.session = bot.session\n74 self.headers: Optional[dict] = None\n75 self.access_token = access_token\n76 self.username = username\n77 self.avatar_url: str = kwargs.pop(\"avatar_url\", \"\")\n78 self.url: str = kwargs.pop(\"url\", \"\")\n79 if self.access_token:\n80 self.headers = {\"Authorization\": \"token \" + str(access_token)}\n81 \n82 @property\n83 def BRANCH(self) -> str:\n", 313 | "col_offset": 4, 314 | "filename": "./core/clients.py", 315 | "issue_confidence": "MEDIUM", 316 | "issue_cwe": { 317 | "id": 259, 318 | "link": "https://cwe.mitre.org/data/definitions/259.html" 319 | }, 320 | "issue_severity": "LOW", 321 | "issue_text": "Possible hardcoded password: ''", 322 | "line_number": 71, 323 | "line_range": [ 324 | 71, 325 | 72, 326 | 73, 327 | 74, 328 | 75, 329 | 76, 330 | 77, 331 | 78, 332 | 79, 333 | 80, 334 | 81, 335 | 82 336 | ], 337 | "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b107_hardcoded_password_default.html", 338 | "test_id": "B107", 339 | "test_name": "hardcoded_password_default" 340 | } 341 | ] 342 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | venv2/ 97 | ENV/ 98 | env.bak/ 99 | venv.bak/ 100 | 101 | # Spyder project settings 102 | .spyderproject 103 | .spyproject 104 | 105 | # Rope project settings 106 | .ropeproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | 119 | # PyCharm 120 | .idea/ 121 | 122 | # MacOS 123 | .DS_Store 124 | 125 | # VS Code 126 | .vscode/ 127 | 128 | # Node 129 | package-lock.json 130 | node_modules/ 131 | 132 | # Modmail 133 | config.json 134 | plugins/ 135 | !plugins/registry.json 136 | !plugins/@local/ 137 | temp/ 138 | test.py 139 | 140 | # Other stuff 141 | .dockerignore 142 | .env.example 143 | .git/ 144 | .gitignore 145 | .github/ 146 | app.json 147 | CHANGELOG.md 148 | Dockerfile 149 | docker-compose.yml 150 | Procfile 151 | pyproject.toml 152 | README.md 153 | Pipfile 154 | Pipfile.lock 155 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TOKEN=MyBotToken 2 | LOG_URL=https://logviewername.herokuapp.com/ 3 | GUILD_ID=1234567890 4 | OWNERS=Owner1ID,Owner2ID,Owner3ID 5 | CONNECTION_URI=mongodb+srv://mongodburi 6 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team on [discord](https://discord.gg/etJNHCQ). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Modmail 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Becoming a maintainer 10 | 11 | If you are proposing new features, please discuss them with us in the [development server](https://discord.gg/etJNHCQ) before you start working on them! 12 | 13 | ## We Develop with Github 14 | We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. 15 | 16 | ## We Use [Git Flow](https://atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) 17 | ![Simple Image Of A Git Flow Workflow](https://nvie.com/img/hotfix-branches@2x.png) 18 | When contributing to this project, please make sure you follow this and name your branches appropriately! 19 | 20 | ## All Code Changes Happen Through Pull Requests 21 | Make sure you know how Git Flow works before contributing! 22 | Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: 23 | 24 | 1. Fork the repo and create your branch from `master` or `development` according to Git Flow. 25 | 2. Update the CHANGELOG. 26 | 3. If you've changed `core/*` or `bot.py`, mark changelog as "BREAKING" since plugins may break. 27 | 4. Make sure your code passes the lint checks. 28 | 5. Create Issues and pull requests! 29 | 30 | ## Any contributions you make will be under the GNU Affero General Public License v3.0 31 | In short, when you submit code changes, your submissions are understood to be under the same [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.en.html) that covers the project. Feel free to contact the maintainers if that's a concern. 32 | 33 | ## Report bugs using [Github Issues](https://github.com/modmail-dev/modmail/issues) 34 | We use GitHub issues to track public bugs. Report a bug by [opening a new Issue](https://github.com/modmail-dev/modmail/issues/new); it's that easy! 35 | 36 | ## Find pre-existing issues to tackle 37 | Check out our [unstaged issue tracker](https://github.com/modmail-dev/modmail/issues?q=is%3Aissue+is%3Aopen+-label%3Astaged) and start helping out! 38 | 39 | Ways to help out: 40 | - Help out new members 41 | - Highlight invalid bugs/unsupported use cases 42 | - Code review of pull requests 43 | - Add on new use cases or reproduction steps 44 | - Point out duplicate issues and guide them to the right direction 45 | - Create a pull request to resolve the issue! 46 | 47 | ## Write bug reports with detail, background, and sample code 48 | **Great Bug Reports** tend to have: 49 | 50 | - A quick summary and background 51 | - Steps to reproduce 52 | - Be specific! 53 | - What you expected would happen 54 | - What *actually* happens 55 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 56 | 57 | ## Use a Consistent Coding Style 58 | We use [black](https://github.com/python/black) for a unified code style. 59 | 60 | ## License 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | 63 | ## References 64 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) 65 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: kyber 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[BUG]: your bug report title" 4 | labels: "maybe: bug" 5 | body: 6 | - type: input 7 | id: bot-info-version 8 | attributes: 9 | label: Bot Version 10 | description: Check it with `@modmail about` 11 | placeholder: eg. v3.9.4 12 | validations: 13 | required: true 14 | - type: dropdown 15 | id: bot-info-hosting 16 | attributes: 17 | label: How are you hosting Modmail? 18 | description: You can check it with `@modmail about` if you are unsure 19 | options: 20 | - Heroku 21 | - Systemd 22 | - PM2 23 | - Patreon 24 | - Other 25 | validations: 26 | required: true 27 | - type: input 28 | id: logs 29 | attributes: 30 | label: Error Logs 31 | placeholder: https://hastebin.cc/placeholder 32 | description: 33 | "If your Modmail bot is online, type `@modmail debug hastebin` and include the link here. 34 | 35 | If your Modmail bot is not online or the previous command did not generate a link, do the following: 36 | 37 | 1. Select your *bot* application at https://dashboard.heroku.com 38 | 39 | 2. [Restart your bot](https://i.imgur.com/3FcrlKz.png) 40 | 41 | 3. Reproduce the error to populate the error logs 42 | 43 | 4. [Copy and paste the logs](https://i.imgur.com/TTrhitm.png)" 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: screenshots 48 | attributes: 49 | label: Screenshots 50 | description: "[optional] You may add screenshots to further explain your problem." 51 | - type: textarea 52 | id: additional-info 53 | attributes: 54 | label: Additional Information 55 | description: "[optional] You may provide additional context for us to better understand how this issue occured." 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discord Server 4 | url: https://discord.gg/etJNHCQ 5 | about: Please ask hosting-related questions here before creating an issue. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "your feature request title" 4 | labels: "feature request" 5 | body: 6 | - type: textarea 7 | id: problem-relation 8 | attributes: 9 | label: Is your feature request related to a problem? Please elaborate. 10 | description: A clear and concise description of what the problem is. 11 | placeholder: eg. I'm always frustrated when... 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: solution 16 | attributes: 17 | label: Describe the solution you'd like 18 | description: A clear and concise description of what you want to happen. 19 | validations: 20 | required: true 21 | - type: checkboxes 22 | id: complications 23 | attributes: 24 | label: Does your solution involve any of the following? 25 | options: 26 | - label: Logviewer 27 | - label: New config option 28 | - type: textarea 29 | id: alternatives 30 | attributes: 31 | label: Describe alternatives you've considered 32 | description: A clear and concise description of any alternative solutions or features you've considered. 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: benefit 37 | attributes: 38 | label: Who will this benefit? 39 | description: Does this feature apply to a great portion of users? 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: additional-info 44 | attributes: 45 | label: Additional Information 46 | description: "[optional] You may provide additional context or screenshots for us to better understand the need of the feature." 47 | -------------------------------------------------------------------------------- /.github/pull.yml: -------------------------------------------------------------------------------- 1 | version: "1" 2 | rules: 3 | - base: master 4 | upstream: modmail-dev:master 5 | mergeMethod: hardreset 6 | - base: development 7 | upstream: modmail-dev:development 8 | mergeMethod: hardreset -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Create and publish a Docker image 3 | 4 | on: 5 | push: 6 | branches: ['master'] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Log in to the Container registry 24 | uses: docker/login-action@v2 25 | with: 26 | registry: ${{ env.REGISTRY }} 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@v4 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@v3 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /.github/workflows/lints.yml: -------------------------------------------------------------------------------- 1 | name: Modmail Workflow 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | code-style: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ['3.10', '3.11'] 11 | 12 | name: Python ${{ matrix.python-version }} on ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | architecture: x64 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip pipenv 24 | pipenv install --dev --system 25 | # to refresh: bandit -f json -o .bandit_baseline.json -r . 26 | # - name: Bandit syntax check 27 | # run: bandit -r . -b .bandit_baseline.json 28 | - name: Pylint 29 | run: pylint ./bot.py cogs/*.py core/*.py --exit-zero -r y 30 | continue-on-error: true 31 | - name: Black 32 | run: | 33 | black . --diff --check 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | venv2/ 97 | ENV/ 98 | env.bak/ 99 | venv.bak/ 100 | 101 | # Spyder project settings 102 | .spyderproject 103 | .spyproject 104 | 105 | # Rope project settings 106 | .ropeproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | 119 | # PyCharm 120 | .idea/ 121 | 122 | # MacOS 123 | .DS_Store 124 | 125 | # VS Code 126 | .vscode/ 127 | 128 | # Node 129 | package-lock.json 130 | node_modules/ 131 | 132 | # Modmail 133 | plugins/* 134 | !plugins/registry.json 135 | !plugins/@local 136 | config.json 137 | temp/ 138 | test.py 139 | stack.yml 140 | .github/workflows/build_docker.yml -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-bookworm as base 2 | 3 | RUN apt-get update && \ 4 | apt-get install --no-install-recommends -y \ 5 | # Install CairoSVG dependencies. 6 | libcairo2 && \ 7 | # Cleanup APT. 8 | apt-get clean && \ 9 | rm -rf /var/lib/apt/lists/* && \ 10 | # Create a non-root user. 11 | useradd --shell /usr/sbin/nologin --create-home -d /opt/modmail modmail 12 | 13 | FROM base as builder 14 | 15 | COPY requirements.txt . 16 | 17 | RUN pip install --root-user-action=ignore --no-cache-dir --upgrade pip wheel && \ 18 | python -m venv /opt/modmail/.venv && \ 19 | . /opt/modmail/.venv/bin/activate && \ 20 | pip install --no-cache-dir --upgrade -r requirements.txt 21 | 22 | FROM base 23 | 24 | # Copy the entire venv. 25 | COPY --from=builder --chown=modmail:modmail /opt/modmail/.venv /opt/modmail/.venv 26 | 27 | # Copy repository files. 28 | WORKDIR /opt/modmail 29 | USER modmail:modmail 30 | COPY --chown=modmail:modmail . . 31 | 32 | # This sets some Python runtime variables and disables the internal auto-update. 33 | ENV PYTHONUNBUFFERED=1 \ 34 | PYTHONDONTWRITEBYTECODE=1 \ 35 | PATH=/opt/modmail/.venv/bin:$PATH \ 36 | USING_DOCKER=yes 37 | 38 | CMD ["python", "bot.py"] 39 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Statement 2 | 3 | Hey, we are the lead developers of Modmail bot. This is a look into the data we collect, the data you collect, the data other parties collect, and what can be done about any of this data. 4 | > **Disclaimer**: None of us are lawyers. We are just trying to be more transparent 5 | 6 | ### TL;DR 7 | 8 | Yes, we collect some data to keep us happy. You collect some data to keep the bot functioning. External services also collect some data that is out of our control. 9 | 10 | ## Interpretation 11 | 12 | - Modmail: This application that has been made open-source. 13 | - Modmail Team: Lead developers, namely kyb3r, fourjr and taku. 14 | - Bot: Your instance of the Modmail bot. 15 | - Bot owner: The person managing the bot. 16 | - Guild: A [server](https://discord.com/developers/docs/resources/guild#guild-resource), an isolated collection of users and channels, within Discord 17 | - User: The end user, or server members, that interface with the bot. 18 | - Database: A location where data is stored, hosted by the bot owner. The following types of database are currently supported: [MongoDB](#MongoDB). 19 | - Logviewer: A webserver hosted by the bot owner. 20 | 21 | ## The Data We Collect 22 | 23 | No data is being collected unless someone decides to host the bot and the bot is kept online. 24 | 25 | The Modmail Team collect some metadata to keep us updated on the number of instances that are making use of the bot and know what features we should focus on. The following is a list of data that we collect: 26 | - Bot ID 27 | - Bot username and discriminator 28 | - Bot avatar URL 29 | - Main guild ID 30 | - Main guild name 31 | - Main guild member count 32 | - Bot uptime 33 | - Bot latency 34 | - Bot version 35 | - Whether the bot is selfhosted 36 | 37 | No tokens/passwords/private data is ever being collected or sent to our servers. 38 | 39 | This metadata is sent to our centralised servers every hour that the bot is up and can be viewed in the bot logs when the `log_level` is set to `DEBUG`. 40 | 41 | As our bot is completely open-source, the part that details this behaviour is located in `bot.py > ModmailBot > post_metadata`. 42 | 43 | We assure you that the data is not being sold to anybody. 44 | 45 | ### Opting out 46 | 47 | The bot owner can opt out of this data collection by setting `data_collection` to `off` within the configuration variables or the `.env` file. 48 | 49 | ### Data deletion 50 | 51 | Data can be deleted with a request in a DM to our [support server](https://discord.gg/etJNHCQ)'s Modmail bot. 52 | 53 | ## The Data You Collect 54 | 55 | When using the bot, the bot can collect various bits of user data to ensure that the bot can run smoothly. 56 | This data is stored in a database instance that is hosted by the bot owner (more details below). 57 | 58 | When a thread is created, the bot saves the following data: 59 | - Timestamp 60 | - Log Key 61 | - Channel ID 62 | - Guild ID 63 | - Bot ID 64 | - Recipient ID 65 | - Recipient Username and Discriminator 66 | - Recipient Avatar URL 67 | - Whether the recipient is a moderator 68 | 69 | When a message is sent in a thread, the bot saves the following data: 70 | - Timestamp 71 | - Message ID 72 | - Message author ID 73 | - Message author username and discriminator 74 | - Message author avatar URL 75 | - Whether the message author is a moderator 76 | - Message content 77 | - All attachment urls in the message 78 | 79 | This data is essential to have live logs for the web logviewer to function. 80 | The Modmail team does not track any data by users. 81 | 82 | ### Opting out 83 | 84 | There is no way for users or moderators to opt out from this data collection. 85 | 86 | ### Data deletion 87 | 88 | Logs can be deleted using the `?logs delete ` command. This will remove all data from that specific log entry from the database permenantly. 89 | 90 | ## The Data Other Parties Collect 91 | 92 | Plugins form a large part of the Modmail experience. Although we do not have any control over the data plugins collect, including plugins within our registry, all plugins are open-sourced by design. Some plugin devs may collect data beyond our control, and it is the bot owner's responsibility to check with the various plugin developers involved. 93 | 94 | We recommend 4 external services to be used when setting up the Modmail bot. 95 | We have no control over the data external parties collect and it is up to the bot owner's choice as to which external service they choose to employ when using Modmail. 96 | If you wish to opt out of any of this data collection, please view their own privacy policies and data collection information. We will not provide support for such a procedure. 97 | 98 | ### Discord 99 | 100 | - [Discord Privacy Policy](https://discord.com/privacy) 101 | 102 | ### Heroku 103 | 104 | - [Heroku Security](https://www.heroku.com/policy/security) 105 | - [Salesforce Privacy Policy](https://www.salesforce.com/company/privacy/). 106 | 107 | ### MongoDB 108 | 109 | - [MongoDB Privacy Policy](https://www.mongodb.com/legal/privacy-policy). 110 | 111 | ### Github 112 | 113 | - [Github Privacy Statement](https://docs.github.com/en/free-pro-team@latest/github/site-policy/github-privacy-statement) 114 | 115 | ## Maximum Privacy Setup 116 | 117 | For a maximum privacy setup, we recommend the following hosting procedure. We have included links to various help articles for each relevant step. We will not provide support for such a procedure. 118 | - [Creating a local mongodb instance](https://zellwk.com/blog/local-mongodb/) 119 | - [Hosting Modmail on your personal computer](https://taaku18.github.io/modmail/local-hosting/) 120 | - Ensuring `data_collection` is set to `no` in the `.env` file. 121 | - [Opt out of discord data collection](https://support.discord.com/hc/en-us/articles/360004109911-Data-Privacy-Controls) 122 | - Do not use any plugins, setting `enable_plugins` to `no`. 123 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | bandit = ">=1.7.5" 8 | black = "==23.11.0" 9 | pylint = "==3.0.2" 10 | typing-extensions = "==4.8.0" 11 | 12 | [packages] 13 | aiohttp = "==3.9.0" 14 | colorama = "==0.4.6" 15 | "discord.py" = {version = "==2.3.2", extras = ["speed"]} 16 | emoji = "==2.8.0" 17 | isodate = "==0.6.1" 18 | motor = "==3.3.2" 19 | natural = "==0.2.0" # Why is this needed? 20 | packaging = "==23.2" 21 | parsedatetime = "==2.6" 22 | pymongo = {extras = ["srv"], version = "*"} # Required by motor 23 | python-dateutil = "==2.8.2" 24 | python-dotenv = "==1.0.0" 25 | uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} 26 | lottie = {version = "==0.7.0", extras = ["pdf"]} 27 | requests = "==2.31.0" 28 | 29 | [scripts] 30 | bot = "python bot.py" 31 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: python bot.py 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | A feature-rich Modmail bot for Discord. 5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | Bot instances 20 | 21 | 22 | 23 | Support 24 | 25 | 26 | 27 | Patreon 28 | 29 | 30 | 31 | Made with Python 3.10 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | MIT License 40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 | ## What is Modmail? 48 | 49 | Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way. 50 | 51 | This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Patreon](https://www.patreon.com/kyber)**, cool benefits included! 52 | 53 | For up-to-date setup instructions, please visit our [**documentation**](https://docs.modmail.dev/installation) page. 54 | 55 | ## How does it work? 56 | 57 | When a member sends a direct message to the bot, Modmail will create a channel or "thread" into a designated category. All further DM messages will automatically relay to that channel; any available staff can respond within the channel. 58 | 59 | Our Logviewer will save the threads so you can view previous threads through their corresponding log link. ~~Here is an [**example**](https://logs.modmail.dev/example)~~ (demo not available at the moment). 60 | 61 | ## Features 62 | 63 | * **Highly Customisable:** 64 | * Bot activity, prefix, category, log channel, etc. 65 | * Command permission system. 66 | * Interface elements (color, responses, reactions, etc.). 67 | * Snippets and *command aliases*. 68 | * Minimum duration for accounts to be created before allowed to contact Modmail (`account_age`). 69 | * Minimum length for members to be in the guild before allowed to contact Modmail (`guild_age`). 70 | 71 | * **Advanced Logging Functionality:** 72 | * When you close a thread, Modmail will generate a log link and post it to your log channel. 73 | * Native Discord dark-mode feel. 74 | * Markdown/formatting support. 75 | * Login via Discord to protect your logs ([premium Patreon feature](https://patreon.com/kyber)). 76 | * See past logs of a user with `?logs`. 77 | * Searchable by text queries using `?logs search`. 78 | 79 | * **Robust implementation:** 80 | * Schedule tasks in human time, e.g. `?close in 2 hours silently`. 81 | * Editing and deleting messages are synced. 82 | * Support for the diverse range of message contents (multiple images, files). 83 | * Paginated commands interfaces via reactions. 84 | 85 | This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `?help` command. 86 | 87 | ## Installation 88 | 89 | There are a number of options for hosting your very own dedicated Modmail bot. 90 | 91 | Visit our [**documentation**](https://docs.modmail.dev/installation) page for detailed guidance on how to deploy your Modmail bot. 92 | 93 | ### Patreon Hosting 94 | 95 | If you don't want the trouble of renting and configuring your server to host Modmail, we got a solution for you! We offer hosting and maintenance of your own, private Modmail bot (including a Logviewer) through [**Patreon**](https://patreon.com/kyber). 96 | 97 | ## FAQ 98 | 99 | **Q: Where can I find the Modmail bot invite link?** 100 | 101 | **A:** Unfortunately, due to how this bot functions, it cannot be invited. The lack of an invite link is to ensure an individuality to your server and grant you full control over your bot and data. Nonetheless, you can quickly obtain a free copy of Modmail for your server by following our [**documentation**](https://docs.modmail.dev/installation) steps or subscribe to [**Patreon**](https://patreon.com/kyber). 102 | 103 | **Q: Where can I find out more info about Modmail?** 104 | 105 | **A:** You can find more info about Modmail on our [**documentation**](https://docs.modmail.dev) page. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/cnUpwrnpYb) for help and support. 106 | 107 | ## Plugins 108 | 109 | Modmail supports the use of third-party plugins to extend or add functionalities to the bot. 110 | Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. 111 | 112 | You can find a list of third-party plugins using the `?plugins registry` command or visit the [Unofficial List of Plugins](https://github.com/modmail-dev/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. 113 | 114 | To develop your own, check out the [plugins documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). 115 | 116 | Plugins requests and support are available in our [Modmail Support Server](https://discord.gg/cnUpwrnpYb). 117 | 118 | ## Sponsors 119 | 120 | Special thanks to our sponsors for supporting the project. 121 | 122 | SirReddit: 123 |
124 | 125 | 126 | 127 |
128 |
129 | Prime Servers Inc: 130 |
131 | 132 | 133 | 134 |
135 |
136 | Real Madrid: 137 |
138 | 139 | 140 | 141 |
142 |
143 | Advertise Your Server: 144 |
145 | 146 | 147 | 148 |
149 |
150 | Help Us • Help Other's: 151 |
152 | 153 | 154 | 155 |
156 |
157 | Discord Advice Center: 158 |
159 | 160 | 161 | 162 |
163 |
164 | Blacklight Promotions: 165 |
166 | 167 | 168 | 169 | 170 | 171 | Become a sponsor on [Patreon](https://patreon.com/kyber). 172 | 173 | ## Contributing 174 | 175 | Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our [contributing guidelines](https://github.com/modmail-dev/modmail/blob/master/.github/CONTRIBUTING.md) before you get started. 176 | 177 | If you like this project and would like to show your appreciation, support us on **[Patreon](https://www.patreon.com/kyber)**! 178 | 179 | ## Beta Testing 180 | 181 | Our [development](https://github.com/modmail-dev/modmail/tree/development) branch is where most of our features are tested before public release. Be warned that there could be bugs in various commands so keep it away from any large servers you manage. 182 | 183 | If you wish to test the new features and play around with them, feel free to join our [Public Test Server](https://discord.gg/v5hTjKC). Bugs can be raised within that server or in our Github issues (state that you are using the development branch though). 184 | -------------------------------------------------------------------------------- /SPONSORS.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "embed": { 4 | "description": "This is a youtube channel that provides high quality reddit content. Videos are uploaded regularly so you are never out of funny, interesting or even creepy content!", 5 | "color": 15114050, 6 | "footer": { 7 | "icon_url": "https://i.imgur.com/fvNKUks.png", 8 | "text": "I am a robot, son of Daniel (UK)" 9 | }, 10 | "thumbnail": { 11 | "url": "https://i.imgur.com/WyzaPKY.png" 12 | }, 13 | "author": { 14 | "name": "Sir Reddit", 15 | "url": "https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1", 16 | "icon_url": "https://i.imgur.com/WyzaPKY.png" 17 | }, 18 | "fields": [ 19 | { 20 | "name": "Subscribe!", 21 | "value": "[**Click Here**](https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1)" 22 | }, 23 | { 24 | "name": "Discord Server", 25 | "value": "[**Click Here**](https://discord.gg/V8ErqHb)" 26 | } 27 | ] 28 | } 29 | }, 30 | { 31 | "embed": { 32 | "title": "Berkand Karadere", 33 | "description": "Berkand Karadere is an German Community Manager who integrated new systems into the game industry. He also is hosting and developing web servers and game servers. He also plays American Football for the Dortmund Giants and his journey has just begun.", 34 | "color": 2968248, 35 | "thumbnail": { 36 | "url": "https://i.imgur.com/cs2QEcp.png" 37 | }, 38 | "fields": [ 39 | { 40 | "name": "Discord Server", 41 | "value": "[**Click here**](https://discord.gg/BanCwptMJV)" 42 | } 43 | ] 44 | } 45 | }, 46 | { 47 | "embed": { 48 | "description": "Quality Hosting at Prices You Deserve!", 49 | "color": 3137203, 50 | "footer": { 51 | "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png", 52 | "text": "Prime Servers, Inc." 53 | }, 54 | "thumbnail": { 55 | "url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" 56 | }, 57 | "author": { 58 | "name": "Prime Servers, Inc.", 59 | "url": "https://primeserversinc.com", 60 | "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" 61 | }, 62 | "fields": [ 63 | { 64 | "name": "Twitter", 65 | "value": "[**Click Here**](https://twitter.com/PrimeServersInc)" 66 | }, 67 | { 68 | "name": "Discord Server", 69 | "value": "[**Click Here**](https://discord.gg/cYM6Urn)" 70 | } 71 | ] 72 | } 73 | }, 74 | { 75 | "embed": { 76 | "description": "──── 《𝐃𝐢𝐬𝐜𝐨𝐫𝐝 𝐀𝐝𝐯𝐢𝐜𝐞 𝐂𝐞𝐧𝐭𝐞𝐫 》 ────\n\n◈ We are a server aimed to meet your discord needs. We have tools, tricks and tips to grow your server and advertise your server. We offer professional server reviews and suggestions how to run it successfully as a part of our courtesy. Join the server and get the chance to add our very own BUMP BOT called DAC Advertise where you can advertise your server to other servers!\n", 77 | "color": 53380, 78 | "author": { 79 | "name": "Discord Advice Center", 80 | "url": "https://discord.gg/nkMDQfuK", 81 | "icon_url": "https://i.imgur.com/cjVtRw5.jpg" 82 | }, 83 | "image": { 84 | "url": "https://i.imgur.com/1hrjcHd.png" 85 | }, 86 | "fields": [ 87 | { 88 | "name": "Discord Server", 89 | "value": "[**Click Here**](https://discord.gg/zmwZy5fd9v)" 90 | } 91 | ] 92 | } 93 | }, 94 | { 95 | "embed": { 96 | "footer": { 97 | "text": "Join noch heute!" 98 | }, 99 | "thumbnail": { 100 | "url": "https://i.imgur.com/bp0xfyK.png" 101 | }, 102 | "fields": [ 103 | { 104 | "inline": false, 105 | "name": "Viele Verschiedene Talks", 106 | "value": "Gro\u00dfe Community\nGewinnspiele" 107 | } 108 | ], 109 | "color": 61532, 110 | "description": "Die etwas andere Community", 111 | "url": "https://discord.gg/uncommon", 112 | "title": "uncommon community" 113 | } 114 | }, 115 | { 116 | "embed": { 117 | "author": { 118 | "name": "Help us • Help Others" 119 | }, 120 | "title": "Join Today", 121 | "url": "https://discord.gg/5yQCFzY6HU", 122 | "description": "At Help Us • Help Others, we accept as true with inside the transformative electricity of cooperation and kindness. Each one people has the capability to make a meaningful impact by means of helping and caring for others. Whether you want assistance or want to offer it, this is the right region for you!", 123 | "fields": [ 124 | { 125 | "name": "What we offer:", 126 | "value": "`🎬` - Active community\n`👮` - Active staff around the globe! \n`🛜` - 40+ Advertising channels to grow your socials!\n`💎` - Boosting Perks\n`🎉` - Event's monthly especially bank holiday roles!!\n`🔢` - Unique levelling systems\n`📞` - Multiple voice channels including gaming!\n`🎁` - Exclusive giveaways!" 127 | }, 128 | { 129 | "name": "We Are Hiring", 130 | "value": "`🔵` - Moderators\n`🔵` - Human Resources\n`🔵` - Community Team\n`🔵` - Partnership Manager\n`🔵` - Growth Manager\n`🚀` Much more to come!\n\n\nJoin Today!" 131 | } 132 | ], 133 | "image": { 134 | "url": "https://cdn.discordapp.com/attachments/1218338794416246874/1243635366326567002/AD_animated.gif" 135 | }, 136 | "color": 45300, 137 | "footer": { 138 | "text": "Help Us • Help Others" 139 | } 140 | } 141 | }, 142 | { 143 | "embed": { 144 | "description": "> Be apart of our community as we start to grow! and embark on a long journey.\n——————————————————-\n**What we offer?**\n\n➺〚🖌️〛Custom Liveries \n➺〚❤️〛Friendly and Growing community.\n➺〚🤝〛Partnerships.\n➺〚🎮〛Daily SSUs. \n➺〚🚨〛Great roleplays.\n➺〚💬〛Kind and Professional staff\n➺〚🎉〛Giveaways!!! \n——————————————————-\n**Emergency Services**\n\n➺〚🚔〛NY Police Force\n➺〚🚒〛Fire & Emergency NY\n➺〚🚧〛NY department of transportation \n\n——————————————————-\n**Whitelisted**\nComing soon!\n——————————————————-\n**What are we looking for!**\n\n➺〚💬〛More members\n➺〚⭐〛Staff Members - **WE'RE HIRING!**\n➺〚🤝〛Partnerships\n➺〚💎〛Boosters\n——————————————————\n\n**[Join now](https://discord.com/invite/qt62qSnKVa)**", 145 | "author": { 146 | "name": "New York Roleplay", 147 | "icon_url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" 148 | }, 149 | "color": 431075, 150 | "thumbnail": { 151 | "url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" 152 | } 153 | } 154 | }, 155 | { 156 | "embed": { 157 | "title": "CityStore PLC", 158 | "description": "*Your Retail Journey*\n*\"Better choice and better value in food, fashion & homewares.\"*\n\n\n**------------------------------------------**\n*__About us__*\nSupermarket, CityStore PLC! Attend a training to become staff!\n\nThis game is currently in V3\n\nWe have a training Centre and applications center!\n\n**------------------------------------------**\n\n> *❤️ Don't hesitate! Dive into the excitement today by joining our vibrant community on Discord. Experience our unique perspective and become an integral part of our group. Your **journey** with us promises to be unforgettable no regrets, only great memories await! ❤️*\n\n*We hope to see you. *\n\n*Signed,*\n**CityStore PLC**\n> Discord: https://discord.gg/yjFQb5mrSk\n> Roblox Group: https://www.roblox.com/groups/32819373/CityStore-PLC#!/about\n\nJoin us now and become apart of Citystore PLC community! 🎉", 159 | "color": 15523550 160 | } 161 | } 162 | ] 163 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Modmail", 3 | "description": "An easy to install Modmail bot for Discord - DM to contact mods!", 4 | "repository": "https://github.com/modmail-dev/modmail", 5 | "env": { 6 | "TOKEN": { 7 | "description": "Your discord bot's token.", 8 | "required": true 9 | }, 10 | "GUILD_ID": { 11 | "description": "The id for the server you are hosting this bot for.", 12 | "required": true 13 | }, 14 | "OWNERS": { 15 | "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", 16 | "required": true 17 | }, 18 | "CONNECTION_URI": { 19 | "description": "The connection URI for your database.", 20 | "required": true 21 | }, 22 | "DATABASE_TYPE": { 23 | "description": "The type of your database. There is only one supported database at the moment - MongoDB (default).", 24 | "required": false 25 | }, 26 | "LOG_URL": { 27 | "description": "The url of the log viewer app for viewing self-hosted logs.", 28 | "required": true 29 | }, 30 | "GITHUB_TOKEN": { 31 | "description": "A github personal access token with the repo scope.", 32 | "required": false 33 | }, 34 | "REGISTRY_PLUGINS_ONLY": { 35 | "description": "If set to true, only plugins that are in the registry can be loaded.", 36 | "required": false 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cogs/plugins.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import json 4 | import os 5 | import shutil 6 | import sys 7 | import typing 8 | import zipfile 9 | from difflib import get_close_matches 10 | from importlib import invalidate_caches 11 | from pathlib import Path, PurePath 12 | from re import match 13 | from site import USER_SITE 14 | from subprocess import PIPE 15 | 16 | import discord 17 | from discord.ext import commands 18 | from packaging.version import Version 19 | 20 | from core import checks 21 | from core.models import PermissionLevel, getLogger 22 | from core.paginator import EmbedPaginatorSession 23 | from core.utils import trigger_typing, truncate 24 | 25 | logger = getLogger(__name__) 26 | 27 | 28 | class InvalidPluginError(commands.BadArgument): 29 | pass 30 | 31 | 32 | class Plugin: 33 | def __init__(self, user, repo=None, name=None, branch=None): 34 | if repo is None: 35 | self.user = "@local" 36 | self.repo = "@local" 37 | self.name = user 38 | self.local = True 39 | self.branch = "@local" 40 | self.url = f"@local/{user}" 41 | self.link = f"@local/{user}" 42 | else: 43 | self.user = user 44 | self.repo = repo 45 | self.name = name 46 | self.local = False 47 | self.branch = branch if branch is not None else "master" 48 | self.url = f"https://github.com/{user}/{repo}/archive/{self.branch}.zip" 49 | self.link = f"https://github.com/{user}/{repo}/tree/{self.branch}/{name}" 50 | 51 | @property 52 | def path(self): 53 | if self.local: 54 | return PurePath("plugins") / "@local" / self.name 55 | return PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}" 56 | 57 | @property 58 | def abs_path(self): 59 | return Path(__file__).absolute().parent.parent / self.path 60 | 61 | @property 62 | def cache_path(self): 63 | if self.local: 64 | raise ValueError("No cache path for local plugins!") 65 | return ( 66 | Path(__file__).absolute().parent.parent 67 | / "temp" 68 | / "plugins-cache" 69 | / f"{self.user}-{self.repo}-{self.branch}.zip" 70 | ) 71 | 72 | @property 73 | def ext_string(self): 74 | if self.local: 75 | return f"plugins.@local.{self.name}.{self.name}" 76 | return f"plugins.{self.user}.{self.repo}.{self.name}-{self.branch}.{self.name}" 77 | 78 | def __str__(self): 79 | if self.local: 80 | return f"@local/{self.name}" 81 | return f"{self.user}/{self.repo}/{self.name}@{self.branch}" 82 | 83 | def __lt__(self, other): 84 | return self.name.lower() < other.name.lower() 85 | 86 | @classmethod 87 | def from_string(cls, s, strict=False): 88 | m = match(r"^@?local/(.+)$", s) 89 | if m is None: 90 | if not strict: 91 | m = match(r"^(.+?)/(.+?)/(.+?)(?:@(.+?))?$", s) 92 | else: 93 | m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s) 94 | 95 | if m is not None: 96 | return Plugin(*m.groups()) 97 | raise InvalidPluginError("Cannot decipher %s.", s) # pylint: disable=raising-format-tuple 98 | 99 | def __hash__(self): 100 | return hash((self.user, self.repo, self.name, self.branch)) 101 | 102 | def __repr__(self): 103 | return f"" 104 | 105 | def __eq__(self, other): 106 | return isinstance(other, Plugin) and self.__str__() == other.__str__() 107 | 108 | 109 | class Plugins(commands.Cog): 110 | """ 111 | Plugins expand Modmail functionality by allowing third-party addons. 112 | 113 | These addons could have a range of features from moderation to simply 114 | making your life as a moderator easier! 115 | Learn how to create a plugin yourself here: 116 | https://github.com/modmail-dev/modmail/wiki/Plugins 117 | """ 118 | 119 | def __init__(self, bot): 120 | self.bot = bot 121 | self.registry = {} 122 | self.loaded_plugins = set() 123 | self._ready_event = asyncio.Event() 124 | 125 | async def cog_load(self): 126 | await self.populate_registry() 127 | if self.bot.config.get("enable_plugins"): 128 | await self.initial_load_plugins() 129 | else: 130 | logger.info("Plugins not loaded since ENABLE_PLUGINS=false.") 131 | 132 | async def populate_registry(self): 133 | url = "https://raw.githubusercontent.com/modmail-dev/modmail/master/plugins/registry.json" 134 | try: 135 | async with self.bot.session.get(url) as resp: 136 | self.registry = json.loads(await resp.text()) 137 | except asyncio.TimeoutError: 138 | logger.warning("Failed to fetch registry. Loading with empty registry") 139 | 140 | async def initial_load_plugins(self): 141 | for plugin_name in list(self.bot.config["plugins"]): 142 | try: 143 | plugin = Plugin.from_string(plugin_name, strict=True) 144 | except InvalidPluginError: 145 | self.bot.config["plugins"].remove(plugin_name) 146 | try: 147 | # For backwards compat 148 | plugin = Plugin.from_string(plugin_name) 149 | except InvalidPluginError: 150 | logger.error("Failed to parse plugin name: %s.", plugin_name, exc_info=True) 151 | continue 152 | 153 | logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) 154 | self.bot.config["plugins"].append(str(plugin)) 155 | 156 | try: 157 | await self.download_plugin(plugin) 158 | await self.load_plugin(plugin) 159 | except Exception: 160 | self.bot.config["plugins"].remove(plugin_name) 161 | logger.error( 162 | "Error when loading plugin %s. Plugin removed from config.", 163 | plugin, 164 | exc_info=True, 165 | ) 166 | continue 167 | 168 | logger.debug("Finished loading all plugins.") 169 | 170 | self.bot.dispatch("plugins_ready") 171 | 172 | self._ready_event.set() 173 | await self.bot.config.update() 174 | 175 | async def download_plugin(self, plugin, force=False): 176 | if plugin.abs_path.exists() and (not force or plugin.local): 177 | return 178 | 179 | if plugin.local: 180 | raise InvalidPluginError(f"Local plugin {plugin} not found!") 181 | 182 | plugin.abs_path.mkdir(parents=True, exist_ok=True) 183 | 184 | if plugin.cache_path.exists() and not force: 185 | plugin_io = plugin.cache_path.open("rb") 186 | logger.debug("Loading cached %s.", plugin.cache_path) 187 | else: 188 | headers = {} 189 | github_token = self.bot.config["github_token"] 190 | if github_token is not None: 191 | headers["Authorization"] = f"token {github_token}" 192 | 193 | async with self.bot.session.get(plugin.url, headers=headers) as resp: 194 | logger.debug("Downloading %s.", plugin.url) 195 | raw = await resp.read() 196 | 197 | try: 198 | raw = await resp.text() 199 | except UnicodeDecodeError: 200 | pass 201 | else: 202 | if raw == "Not Found": 203 | raise InvalidPluginError("Plugin not found") 204 | else: 205 | raise InvalidPluginError("Invalid download received, non-bytes object") 206 | 207 | plugin_io = io.BytesIO(raw) 208 | if not plugin.cache_path.parent.exists(): 209 | plugin.cache_path.parent.mkdir(parents=True) 210 | 211 | with plugin.cache_path.open("wb") as f: 212 | f.write(raw) 213 | 214 | with zipfile.ZipFile(plugin_io) as zipf: 215 | for info in zipf.infolist(): 216 | path = PurePath(info.filename) 217 | if len(path.parts) >= 3 and path.parts[1] == plugin.name: 218 | plugin_path = plugin.abs_path / Path(*path.parts[2:]) 219 | if info.is_dir(): 220 | plugin_path.mkdir(parents=True, exist_ok=True) 221 | else: 222 | plugin_path.parent.mkdir(parents=True, exist_ok=True) 223 | with zipf.open(info) as src, plugin_path.open("wb") as dst: 224 | shutil.copyfileobj(src, dst) 225 | 226 | plugin_io.close() 227 | 228 | async def load_plugin(self, plugin): 229 | if not (plugin.abs_path / f"{plugin.name}.py").exists(): 230 | raise InvalidPluginError(f"{plugin.name}.py not found.") 231 | 232 | req_txt = plugin.abs_path / "requirements.txt" 233 | 234 | if req_txt.exists(): 235 | # Install PIP requirements 236 | 237 | venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env 238 | user_install = " --user" if not venv else "" 239 | proc = await asyncio.create_subprocess_shell( 240 | f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q', 241 | stderr=PIPE, 242 | stdout=PIPE, 243 | ) 244 | 245 | logger.debug("Downloading requirements for %s.", plugin.ext_string) 246 | 247 | stdout, stderr = await proc.communicate() 248 | 249 | if stdout: 250 | logger.debug("[stdout]\n%s.", stdout.decode()) 251 | 252 | if stderr: 253 | logger.debug("[stderr]\n%s.", stderr.decode()) 254 | logger.error("Failed to download requirements for %s.", plugin.ext_string, exc_info=True) 255 | raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```") 256 | 257 | if os.path.exists(USER_SITE): 258 | sys.path.insert(0, USER_SITE) 259 | 260 | try: 261 | await self.bot.load_extension(plugin.ext_string) 262 | logger.info("Loaded plugin: %s", plugin.ext_string.split(".")[-1]) 263 | self.loaded_plugins.add(plugin) 264 | 265 | except commands.ExtensionError as exc: 266 | logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) 267 | raise InvalidPluginError("Cannot load extension, plugin invalid.") from exc 268 | 269 | async def unload_plugin(self, plugin: Plugin) -> None: 270 | try: 271 | await self.bot.unload_extension(plugin.ext_string) 272 | except commands.ExtensionError as exc: 273 | raise exc 274 | 275 | ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) 276 | for module in list(sys.modules.keys()): 277 | if module == ext_parent or module.startswith(ext_parent + "."): 278 | del sys.modules[module] 279 | 280 | async def parse_user_input(self, ctx, plugin_name, check_version=False): 281 | if not self.bot.config["enable_plugins"]: 282 | embed = discord.Embed( 283 | description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", 284 | color=self.bot.main_color, 285 | ) 286 | await ctx.send(embed=embed) 287 | return 288 | 289 | if not self._ready_event.is_set(): 290 | embed = discord.Embed( 291 | description="Plugins are still loading, please try again later.", 292 | color=self.bot.main_color, 293 | ) 294 | await ctx.send(embed=embed) 295 | return 296 | 297 | if plugin_name in self.registry: 298 | details = self.registry[plugin_name] 299 | user, repo = details["repository"].split("/", maxsplit=1) 300 | branch = details.get("branch") 301 | 302 | if check_version: 303 | required_version = details.get("bot_version", False) 304 | 305 | if required_version and self.bot.version < Version(required_version): 306 | embed = discord.Embed( 307 | description="Your bot's version is too low. " 308 | f"This plugin requires version `{required_version}`.", 309 | color=self.bot.error_color, 310 | ) 311 | await ctx.send(embed=embed) 312 | return 313 | 314 | plugin = Plugin(user, repo, plugin_name, branch) 315 | 316 | else: 317 | if self.bot.config.get("registry_plugins_only"): 318 | embed = discord.Embed( 319 | description="This plugin is not in the registry. To install this plugin, " 320 | "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", 321 | color=self.bot.error_color, 322 | ) 323 | await ctx.send(embed=embed) 324 | return 325 | try: 326 | plugin = Plugin.from_string(plugin_name) 327 | except InvalidPluginError: 328 | embed = discord.Embed( 329 | description="Invalid plugin name, double check the plugin name " 330 | "or use one of the following formats: " 331 | "username/repo/plugin-name, username/repo/plugin-name@branch, local/plugin-name.", 332 | color=self.bot.error_color, 333 | ) 334 | await ctx.send(embed=embed) 335 | return 336 | return plugin 337 | 338 | @commands.group(aliases=["plugin"], invoke_without_command=True) 339 | @checks.has_permissions(PermissionLevel.OWNER) 340 | async def plugins(self, ctx): 341 | """ 342 | Manage plugins for Modmail. 343 | """ 344 | 345 | await ctx.send_help(ctx.command) 346 | 347 | @plugins.command(name="add", aliases=["install", "load"]) 348 | @checks.has_permissions(PermissionLevel.OWNER) 349 | @trigger_typing 350 | async def plugins_add(self, ctx, *, plugin_name: str): 351 | """ 352 | Install a new plugin for the bot. 353 | 354 | `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, 355 | or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) 356 | or `local/name` for local plugins. 357 | """ 358 | 359 | plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) 360 | if plugin is None: 361 | return 362 | 363 | if str(plugin) in self.bot.config["plugins"]: 364 | embed = discord.Embed(description="This plugin is already installed.", color=self.bot.error_color) 365 | return await ctx.send(embed=embed) 366 | 367 | if plugin.name in self.bot.cogs: 368 | # another class with the same name 369 | embed = discord.Embed( 370 | description="Cannot install this plugin (dupe cog name).", 371 | color=self.bot.error_color, 372 | ) 373 | return await ctx.send(embed=embed) 374 | 375 | if plugin.local: 376 | embed = discord.Embed( 377 | description=f"Starting to load local plugin from {plugin.link}...", 378 | color=self.bot.main_color, 379 | ) 380 | else: 381 | embed = discord.Embed( 382 | description=f"Starting to download plugin from {plugin.link}...", 383 | color=self.bot.main_color, 384 | ) 385 | msg = await ctx.send(embed=embed) 386 | 387 | try: 388 | await self.download_plugin(plugin, force=True) 389 | except Exception as e: 390 | logger.warning("Unable to download plugin %s.", plugin, exc_info=True) 391 | 392 | embed = discord.Embed( 393 | description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", 394 | color=self.bot.error_color, 395 | ) 396 | 397 | return await msg.edit(embed=embed) 398 | 399 | self.bot.config["plugins"].append(str(plugin)) 400 | await self.bot.config.update() 401 | 402 | if self.bot.config.get("enable_plugins"): 403 | invalidate_caches() 404 | 405 | try: 406 | await self.load_plugin(plugin) 407 | except Exception as e: 408 | logger.warning("Unable to load plugin %s.", plugin, exc_info=True) 409 | 410 | embed = discord.Embed( 411 | description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", 412 | color=self.bot.error_color, 413 | ) 414 | 415 | else: 416 | embed = discord.Embed( 417 | description="Successfully installed plugin.\n" 418 | "*Friendly reminder, plugins have absolute control over your bot. " 419 | "Please only install plugins from developers you trust.*", 420 | color=self.bot.main_color, 421 | ) 422 | else: 423 | embed = discord.Embed( 424 | description="Successfully installed plugin.\n" 425 | "*Friendly reminder, plugins have absolute control over your bot. " 426 | "Please only install plugins from developers you trust.*\n\n" 427 | "This plugin is currently not enabled due to `ENABLE_PLUGINS=false`, " 428 | "to re-enable plugins, remove or change `ENABLE_PLUGINS=true` and restart your bot.", 429 | color=self.bot.main_color, 430 | ) 431 | return await msg.edit(embed=embed) 432 | 433 | @plugins.command(name="remove", aliases=["del", "delete"]) 434 | @checks.has_permissions(PermissionLevel.OWNER) 435 | async def plugins_remove(self, ctx, *, plugin_name: str): 436 | """ 437 | Remove an installed plugin of the bot. 438 | 439 | `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference 440 | to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. 441 | """ 442 | plugin = await self.parse_user_input(ctx, plugin_name) 443 | if plugin is None: 444 | return 445 | 446 | if str(plugin) not in self.bot.config["plugins"]: 447 | embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) 448 | return await ctx.send(embed=embed) 449 | 450 | if self.bot.config.get("enable_plugins"): 451 | try: 452 | await self.unload_plugin(plugin) 453 | self.loaded_plugins.remove(plugin) 454 | except (commands.ExtensionNotLoaded, KeyError): 455 | logger.warning("Plugin was never loaded.") 456 | 457 | self.bot.config["plugins"].remove(str(plugin)) 458 | await self.bot.config.update() 459 | if not plugin.local: 460 | shutil.rmtree( 461 | plugin.abs_path, 462 | onerror=lambda *args: logger.warning( 463 | "Failed to remove plugin files %s: %s", plugin, str(args[2]) 464 | ), 465 | ) 466 | try: 467 | plugin.abs_path.parent.rmdir() 468 | plugin.abs_path.parent.parent.rmdir() 469 | except OSError: 470 | pass # dir not empty 471 | 472 | embed = discord.Embed( 473 | description="The plugin is successfully uninstalled.", color=self.bot.main_color 474 | ) 475 | await ctx.send(embed=embed) 476 | 477 | async def update_plugin(self, ctx, plugin_name): 478 | logger.debug("Updating %s.", plugin_name) 479 | plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) 480 | if plugin is None: 481 | return 482 | 483 | if str(plugin) not in self.bot.config["plugins"]: 484 | embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) 485 | return await ctx.send(embed=embed) 486 | 487 | async with ctx.typing(): 488 | embed = discord.Embed( 489 | description=f"Successfully updated {plugin.name}.", color=self.bot.main_color 490 | ) 491 | await self.download_plugin(plugin, force=True) 492 | if self.bot.config.get("enable_plugins"): 493 | try: 494 | await self.unload_plugin(plugin) 495 | except commands.ExtensionError: 496 | logger.warning("Plugin unload fail.", exc_info=True) 497 | 498 | try: 499 | await self.load_plugin(plugin) 500 | except Exception: 501 | embed = discord.Embed( 502 | description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", 503 | color=self.bot.error_color, 504 | ) 505 | self.bot.config["plugins"].remove(str(plugin)) 506 | logger.debug("Failed to update %s. Removed plugin from config.", plugin) 507 | else: 508 | logger.debug("Updated %s.", plugin) 509 | else: 510 | logger.debug("Updated %s.", plugin) 511 | return await ctx.send(embed=embed) 512 | 513 | @plugins.command(name="update") 514 | @checks.has_permissions(PermissionLevel.OWNER) 515 | async def plugins_update(self, ctx, *, plugin_name: str = None): 516 | """ 517 | Update a plugin for the bot. 518 | 519 | `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference 520 | to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. 521 | 522 | To update all plugins, do `{prefix}plugins update`. 523 | """ 524 | 525 | if plugin_name is None: 526 | # pylint: disable=redefined-argument-from-local 527 | for plugin_name in list(self.bot.config["plugins"]): 528 | await self.update_plugin(ctx, plugin_name) 529 | else: 530 | await self.update_plugin(ctx, plugin_name) 531 | 532 | @plugins.command(name="reset") 533 | @checks.has_permissions(PermissionLevel.OWNER) 534 | async def plugins_reset(self, ctx): 535 | """ 536 | Reset all plugins for the bot. 537 | 538 | Deletes all cache and plugins from config and unloads from the bot. 539 | """ 540 | logger.warning("Purging plugins.") 541 | for ext in list(self.bot.extensions): 542 | if not ext.startswith("plugins."): 543 | continue 544 | logger.error("Unloading plugin: %s.", ext) 545 | try: 546 | plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) 547 | if plugin: 548 | await self.unload_plugin(plugin) 549 | self.loaded_plugins.remove(plugin) 550 | else: 551 | await self.bot.unload_extension(ext) 552 | except Exception: 553 | logger.error("Failed to unload plugin: %s.", ext) 554 | 555 | for module in list(sys.modules.keys()): 556 | if module.startswith("plugins."): 557 | del sys.modules[module] 558 | 559 | self.bot.config["plugins"].clear() 560 | await self.bot.config.update() 561 | 562 | cache_path = Path(__file__).absolute().parent.parent / "temp" / "plugins-cache" 563 | if cache_path.exists(): 564 | logger.warning("Removing cache path.") 565 | shutil.rmtree(cache_path) 566 | 567 | for entry in os.scandir(Path(__file__).absolute().parent.parent / "plugins"): 568 | if entry.is_dir() and entry.name != "@local": 569 | shutil.rmtree(entry.path) 570 | logger.warning("Removing %s.", entry.name) 571 | 572 | embed = discord.Embed( 573 | description="Successfully purged all plugins from the bot.", color=self.bot.main_color 574 | ) 575 | return await ctx.send(embed=embed) 576 | 577 | @plugins.command(name="loaded", aliases=["enabled", "installed"]) 578 | @checks.has_permissions(PermissionLevel.OWNER) 579 | async def plugins_loaded(self, ctx): 580 | """ 581 | Show a list of currently loaded plugins. 582 | """ 583 | 584 | if not self.bot.config.get("enable_plugins"): 585 | embed = discord.Embed( 586 | description="No plugins are loaded due to `ENABLE_PLUGINS=false`, " 587 | "to re-enable plugins, remove or set `ENABLE_PLUGINS=true` and restart your bot.", 588 | color=self.bot.error_color, 589 | ) 590 | return await ctx.send(embed=embed) 591 | 592 | if not self._ready_event.is_set(): 593 | embed = discord.Embed( 594 | description="Plugins are still loading, please try again later.", 595 | color=self.bot.main_color, 596 | ) 597 | return await ctx.send(embed=embed) 598 | 599 | if not self.loaded_plugins: 600 | embed = discord.Embed( 601 | description="There are no plugins currently loaded.", color=self.bot.error_color 602 | ) 603 | return await ctx.send(embed=embed) 604 | 605 | loaded_plugins = map(str, sorted(self.loaded_plugins)) 606 | pages = ["```\n"] 607 | for plugin in loaded_plugins: 608 | msg = str(plugin) + "\n" 609 | if len(msg) + len(pages[-1]) + 3 <= 2048: 610 | pages[-1] += msg 611 | else: 612 | pages[-1] += "```" 613 | pages.append(f"```\n{msg}") 614 | 615 | if pages[-1][-3:] != "```": 616 | pages[-1] += "```" 617 | 618 | embeds = [] 619 | for page in pages: 620 | embed = discord.Embed(title="Loaded plugins:", description=page, color=self.bot.main_color) 621 | embeds.append(embed) 622 | paginator = EmbedPaginatorSession(ctx, *embeds) 623 | await paginator.run() 624 | 625 | @plugins.group(invoke_without_command=True, name="registry", aliases=["list", "info"]) 626 | @checks.has_permissions(PermissionLevel.OWNER) 627 | async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = None): 628 | """ 629 | Shows a list of all approved plugins. 630 | 631 | Usage: 632 | `{prefix}plugin registry` Details about all plugins. 633 | `{prefix}plugin registry plugin-name` Details about the indicated plugin. 634 | `{prefix}plugin registry page-number` Jump to a page in the registry. 635 | """ 636 | 637 | await self.populate_registry() 638 | 639 | embeds = [] 640 | 641 | registry = sorted(self.registry.items(), key=lambda elem: elem[0]) 642 | 643 | if not registry: 644 | embed = discord.Embed( 645 | color=self.bot.error_color, 646 | description="Registry is empty. This could be because it failed to load.", 647 | ) 648 | await ctx.send(embed=embed) 649 | return 650 | 651 | if isinstance(plugin_name, int): 652 | index = plugin_name - 1 653 | if index < 0: 654 | index = 0 655 | if index >= len(registry): 656 | index = len(registry) - 1 657 | else: 658 | index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0) 659 | 660 | if not index and plugin_name is not None: 661 | embed = discord.Embed( 662 | color=self.bot.error_color, 663 | description=f'Could not find a plugin with name "{plugin_name}" within the registry.', 664 | ) 665 | 666 | matches = get_close_matches(plugin_name, self.registry.keys()) 667 | 668 | if matches: 669 | embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches)) 670 | 671 | return await ctx.send(embed=embed) 672 | 673 | for name, details in registry: 674 | details = self.registry[name] 675 | user, repo = details["repository"].split("/", maxsplit=1) 676 | branch = details.get("branch") 677 | 678 | plugin = Plugin(user, repo, name, branch) 679 | 680 | embed = discord.Embed( 681 | color=self.bot.main_color, 682 | description=details["description"], 683 | url=plugin.link, 684 | title=details["repository"], 685 | ) 686 | 687 | embed.add_field(name="Installation", value=f"```{self.bot.prefix}plugins add {name}```") 688 | 689 | embed.set_author(name=details["title"], icon_url=details.get("icon_url"), url=plugin.link) 690 | 691 | if details.get("thumbnail_url"): 692 | embed.set_thumbnail(url=details.get("thumbnail_url")) 693 | 694 | if details.get("image_url"): 695 | embed.set_image(url=details.get("image_url")) 696 | 697 | if plugin in self.loaded_plugins: 698 | embed.set_footer(text="This plugin is currently loaded.") 699 | else: 700 | required_version = details.get("bot_version", False) 701 | if required_version and self.bot.version < Version(required_version): 702 | embed.set_footer( 703 | text="Your bot is unable to install this plugin, " 704 | f"minimum required version is v{required_version}." 705 | ) 706 | else: 707 | embed.set_footer(text="Your bot is able to install this plugin.") 708 | 709 | embeds.append(embed) 710 | 711 | paginator = EmbedPaginatorSession(ctx, *embeds) 712 | paginator.current = index 713 | await paginator.run() 714 | 715 | @plugins_registry.command(name="compact", aliases=["slim"]) 716 | @checks.has_permissions(PermissionLevel.OWNER) 717 | async def plugins_registry_compact(self, ctx): 718 | """ 719 | Shows a compact view of all plugins within the registry. 720 | """ 721 | 722 | await self.populate_registry() 723 | 724 | registry = sorted(self.registry.items(), key=lambda elem: elem[0]) 725 | 726 | pages = [""] 727 | 728 | for plugin_name, details in registry: 729 | details = self.registry[plugin_name] 730 | user, repo = details["repository"].split("/", maxsplit=1) 731 | branch = details.get("branch") 732 | 733 | plugin = Plugin(user, repo, plugin_name, branch) 734 | 735 | desc = discord.utils.escape_markdown(details["description"].replace("\n", "")) 736 | 737 | name = f"[`{plugin.name}`]({plugin.link})" 738 | fmt = f"{name} - {desc}" 739 | 740 | if plugin_name in self.loaded_plugins: 741 | limit = 75 - len(plugin_name) - 4 - 8 + len(name) 742 | if limit < 0: 743 | fmt = plugin.name 744 | limit = 75 745 | fmt = truncate(fmt, limit) + "[loaded]\n" 746 | else: 747 | limit = 75 - len(plugin_name) - 4 + len(name) 748 | if limit < 0: 749 | fmt = plugin.name 750 | limit = 75 751 | fmt = truncate(fmt, limit) + "\n" 752 | 753 | if len(fmt) + len(pages[-1]) <= 2048: 754 | pages[-1] += fmt 755 | else: 756 | pages.append(fmt) 757 | 758 | embeds = [] 759 | 760 | for page in pages: 761 | embed = discord.Embed(color=self.bot.main_color, description=page) 762 | embed.set_author(name="Plugin Registry", icon_url=self.bot.user.display_avatar.url) 763 | embeds.append(embed) 764 | 765 | paginator = EmbedPaginatorSession(ctx, *embeds) 766 | await paginator.run() 767 | 768 | 769 | async def setup(bot): 770 | await bot.add_cog(Plugins(bot)) 771 | -------------------------------------------------------------------------------- /core/changelog.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from subprocess import PIPE 4 | from typing import List 5 | 6 | from discord import Embed 7 | 8 | from core.models import getLogger 9 | from core.utils import truncate 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | class Version: 15 | """ 16 | This class represents a single version of Modmail. 17 | 18 | Parameters 19 | ---------- 20 | bot : Bot 21 | The Modmail bot. 22 | version : str 23 | The version string (ie. "v2.12.0"). 24 | lines : str 25 | The lines of changelog messages for this version. 26 | 27 | Attributes 28 | ---------- 29 | bot : Bot 30 | The Modmail bot. 31 | version : str 32 | The version string (ie. "v2.12.0"). 33 | lines : str 34 | A list of lines of changelog messages for this version. 35 | fields : Dict[str, str] 36 | A dict of fields separated by "Fixed", "Changed", etc sections. 37 | description : str 38 | General description of the version. 39 | 40 | Class Attributes 41 | ---------------- 42 | ACTION_REGEX : str 43 | The regex used to parse the actions. 44 | DESCRIPTION_REGEX: str 45 | The regex used to parse the description. 46 | """ 47 | 48 | ACTION_REGEX = r"###\s*(.+?)\s*\n(.*?)(?=###\s*.+?|$)" 49 | DESCRIPTION_REGEX = r"^(.*?)(?=###\s*.+?|$)" 50 | 51 | def __init__(self, bot, branch: str, version: str, lines: str): 52 | self.bot = bot 53 | self.version = version.lstrip("vV") 54 | self.lines = lines.strip() 55 | self.fields = {} 56 | self.changelog_url = f"https://github.com/modmail-dev/modmail/blob/{branch}/CHANGELOG.md" 57 | self.description = "" 58 | self.parse() 59 | 60 | def __repr__(self) -> str: 61 | return f'Version(v{self.version}, description="{self.description}")' 62 | 63 | def parse(self) -> None: 64 | """ 65 | Parse the lines and split them into `description` and `fields`. 66 | """ 67 | self.description = re.match(self.DESCRIPTION_REGEX, self.lines, re.DOTALL) 68 | self.description = self.description.group(1).strip() if self.description is not None else "" 69 | 70 | matches = re.finditer(self.ACTION_REGEX, self.lines, re.DOTALL) 71 | for m in matches: 72 | try: 73 | self.fields[m.group(1).strip()] = m.group(2).strip() 74 | except AttributeError: 75 | logger.error( 76 | "Something went wrong when parsing the changelog for version %s.", 77 | self.version, 78 | exc_info=True, 79 | ) 80 | 81 | @property 82 | def url(self) -> str: 83 | return f"{self.changelog_url}#v{self.version[::2]}" 84 | 85 | @property 86 | def embed(self) -> Embed: 87 | """ 88 | Embed: the formatted `Embed` of this `Version`. 89 | """ 90 | embed = Embed(color=self.bot.main_color, description=self.description) 91 | embed.set_author( 92 | name=f"v{self.version} - Changelog", 93 | icon_url=self.bot.user.display_avatar.url, 94 | url=self.url, 95 | ) 96 | 97 | for name, value in self.fields.items(): 98 | embed.add_field(name=name, value=truncate(value, 1024), inline=False) 99 | embed.set_footer(text=f"Current version: v{self.bot.version}") 100 | 101 | embed.set_thumbnail(url=self.bot.user.display_avatar.url) 102 | return embed 103 | 104 | 105 | class Changelog: 106 | """ 107 | This class represents the complete changelog of Modmail. 108 | 109 | Parameters 110 | ---------- 111 | bot : Bot 112 | The Modmail bot. 113 | text : str 114 | The complete changelog text. 115 | 116 | Attributes 117 | ---------- 118 | bot : Bot 119 | The Modmail bot. 120 | text : str 121 | The complete changelog text. 122 | versions : List[Version] 123 | A list of `Version`'s within the changelog. 124 | 125 | Class Attributes 126 | ---------------- 127 | VERSION_REGEX : re.Pattern 128 | The regex used to parse the versions. 129 | """ 130 | 131 | VERSION_REGEX = re.compile( 132 | r"#\s*([vV]\d+\.\d+(?:\.\d+)?(?:-\w+?)?)\s+(.*?)(?=#\s*[vV]\d+\.\d+(?:\.\d+)(?:-\w+?)?|$)", 133 | flags=re.DOTALL, 134 | ) 135 | 136 | def __init__(self, bot, branch: str, text: str): 137 | self.bot = bot 138 | self.text = text 139 | logger.debug("Fetching changelog from GitHub.") 140 | self.versions = [Version(bot, branch, *m) for m in self.VERSION_REGEX.findall(text)] 141 | 142 | @property 143 | def latest_version(self) -> Version: 144 | """ 145 | Version: The latest `Version` of the `Changelog`. 146 | """ 147 | return self.versions[0] 148 | 149 | @property 150 | def embeds(self) -> List[Embed]: 151 | """ 152 | List[Embed]: A list of `Embed`'s for each of the `Version`. 153 | """ 154 | return [v.embed for v in self.versions] 155 | 156 | @classmethod 157 | async def from_url(cls, bot, url: str = "") -> "Changelog": 158 | """ 159 | Create a `Changelog` from a URL. 160 | 161 | Parameters 162 | ---------- 163 | bot : Bot 164 | The Modmail bot. 165 | url : str, optional 166 | The URL to the changelog. 167 | 168 | Returns 169 | ------- 170 | Changelog 171 | The newly created `Changelog` parsed from the `url`. 172 | """ 173 | # get branch via git cli if available 174 | proc = await asyncio.create_subprocess_shell( 175 | "git branch --show-current", 176 | stderr=PIPE, 177 | stdout=PIPE, 178 | ) 179 | err = await proc.stderr.read() 180 | err = err.decode("utf-8").rstrip() 181 | res = await proc.stdout.read() 182 | branch = res.decode("utf-8").rstrip() 183 | if not branch or err: 184 | branch = "master" if not bot.version.is_prerelease else "development" 185 | 186 | if branch not in ("master", "development"): 187 | branch = "master" 188 | 189 | url = url or f"https://raw.githubusercontent.com/modmail-dev/modmail/{branch}/CHANGELOG.md" 190 | 191 | async with await bot.session.get(url) as resp: 192 | return cls(bot, branch, await resp.text()) 193 | -------------------------------------------------------------------------------- /core/checks.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | 3 | from core.models import HostingMethod, PermissionLevel, getLogger 4 | 5 | logger = getLogger(__name__) 6 | 7 | 8 | def has_permissions_predicate( 9 | permission_level: PermissionLevel = PermissionLevel.REGULAR, 10 | ): 11 | async def predicate(ctx): 12 | return await check_permissions(ctx, ctx.command.qualified_name) 13 | 14 | predicate.permission_level = permission_level 15 | return predicate 16 | 17 | 18 | def has_permissions(permission_level: PermissionLevel = PermissionLevel.REGULAR): 19 | """ 20 | A decorator that checks if the author has the required permissions. 21 | 22 | Parameters 23 | ---------- 24 | 25 | permission_level : PermissionLevel 26 | The lowest level of permission needed to use this command. 27 | Defaults to REGULAR. 28 | 29 | Examples 30 | -------- 31 | :: 32 | @has_permissions(PermissionLevel.OWNER) 33 | async def setup(ctx): 34 | await ctx.send('Success') 35 | """ 36 | 37 | return commands.check(has_permissions_predicate(permission_level)) 38 | 39 | 40 | async def check_permissions(ctx, command_name) -> bool: 41 | """Logic for checking permissions for a command for a user""" 42 | if await ctx.bot.is_owner(ctx.author) or ctx.author.id == ctx.bot.user.id: 43 | # Bot owner(s) (and creator) has absolute power over the bot 44 | return True 45 | 46 | permission_level = ctx.bot.command_perm(command_name) 47 | 48 | if permission_level is PermissionLevel.INVALID: 49 | logger.warning("Invalid permission level for command %s.", command_name) 50 | return True 51 | 52 | if ( 53 | permission_level is not PermissionLevel.OWNER 54 | and ctx.channel.permissions_for(ctx.author).administrator 55 | and ctx.guild == ctx.bot.modmail_guild 56 | ): 57 | # Administrators have permission to all non-owner commands in the Modmail Guild 58 | logger.debug("Allowed due to administrator.") 59 | return True 60 | 61 | command_permissions = ctx.bot.config["command_permissions"] 62 | checkables = {*ctx.author.roles, ctx.author} 63 | 64 | if command_name in command_permissions: 65 | # -1 is for @everyone 66 | if -1 in command_permissions[command_name] or any( 67 | str(check.id) in command_permissions[command_name] for check in checkables 68 | ): 69 | return True 70 | 71 | level_permissions = ctx.bot.config["level_permissions"] 72 | 73 | for level in PermissionLevel: 74 | if level >= permission_level and level.name in level_permissions: 75 | # -1 is for @everyone 76 | if -1 in level_permissions[level.name] or any( 77 | str(check.id) in level_permissions[level.name] for check in checkables 78 | ): 79 | return True 80 | return False 81 | 82 | 83 | def thread_only(): 84 | """ 85 | A decorator that checks if the command 86 | is being ran within a Modmail thread. 87 | """ 88 | 89 | async def predicate(ctx): 90 | """ 91 | Parameters 92 | ---------- 93 | ctx : Context 94 | The current discord.py `Context`. 95 | 96 | Returns 97 | ------- 98 | Bool 99 | `True` if the current `Context` is within a Modmail thread. 100 | Otherwise, `False`. 101 | """ 102 | return ctx.thread is not None 103 | 104 | predicate.fail_msg = "This is not a Modmail thread." 105 | return commands.check(predicate) 106 | 107 | 108 | def github_token_required(ignore_if_not_heroku=False): 109 | """ 110 | A decorator that ensures github token 111 | is set 112 | """ 113 | 114 | async def predicate(ctx): 115 | if ignore_if_not_heroku and ctx.bot.hosting_method != HostingMethod.HEROKU: 116 | return True 117 | else: 118 | return ctx.bot.config.get("github_token") 119 | 120 | predicate.fail_msg = ( 121 | "You can only use this command if you have a " 122 | "configured `GITHUB_TOKEN`. Get a " 123 | "personal access token from developer settings." 124 | ) 125 | return commands.check(predicate) 126 | 127 | 128 | def updates_enabled(): 129 | """ 130 | A decorator that ensures 131 | updates are enabled 132 | """ 133 | 134 | async def predicate(ctx): 135 | return not ctx.bot.config["disable_updates"] 136 | 137 | predicate.fail_msg = ( 138 | "Updates are disabled on this bot instance. " 139 | "View `?config help disable_updates` for " 140 | "more information." 141 | ) 142 | return commands.check(predicate) 143 | -------------------------------------------------------------------------------- /core/clients.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import sys 3 | from json import JSONDecodeError 4 | from typing import Any, Dict, Union, Optional 5 | 6 | import discord 7 | from discord import Member, DMChannel, TextChannel, Message 8 | from discord.ext import commands 9 | 10 | from aiohttp import ClientResponseError, ClientResponse 11 | from motor.motor_asyncio import AsyncIOMotorClient 12 | from pymongo.errors import ConfigurationError 13 | 14 | from core.models import InvalidConfigError, getLogger 15 | 16 | logger = getLogger(__name__) 17 | 18 | 19 | class GitHub: 20 | """ 21 | The client for interacting with GitHub API. 22 | 23 | Parameters 24 | ---------- 25 | bot : Bot 26 | The Modmail bot. 27 | access_token : str, optional 28 | GitHub's access token. 29 | username : str, optional 30 | GitHub username. 31 | avatar_url : str, optional 32 | URL to the avatar in GitHub. 33 | url : str, optional 34 | URL to the GitHub profile. 35 | 36 | Attributes 37 | ---------- 38 | bot : Bot 39 | The Modmail bot. 40 | access_token : str 41 | GitHub's access token. 42 | username : str 43 | GitHub username. 44 | avatar_url : str 45 | URL to the avatar in GitHub. 46 | url : str 47 | URL to the GitHub profile. 48 | 49 | Class Attributes 50 | ---------------- 51 | BASE : str 52 | GitHub API base URL. 53 | REPO : str 54 | Modmail repo URL for GitHub API. 55 | HEAD : str 56 | Modmail HEAD URL for GitHub API. 57 | MERGE_URL : str 58 | URL for merging upstream to master. 59 | FORK_URL : str 60 | URL to fork Modmail. 61 | STAR_URL : str 62 | URL to star Modmail. 63 | """ 64 | 65 | BASE = "https://api.github.com" 66 | REPO = BASE + "/repos/modmail-dev/modmail" 67 | MERGE_URL = BASE + "/repos/{username}/modmail/merges" 68 | FORK_URL = REPO + "/forks" 69 | STAR_URL = BASE + "/user/starred/modmail-dev/modmail" 70 | 71 | def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): 72 | self.bot = bot 73 | self.session = bot.session 74 | self.headers: Optional[dict] = None 75 | self.access_token = access_token 76 | self.username = username 77 | self.avatar_url: str = kwargs.pop("avatar_url", "") 78 | self.url: str = kwargs.pop("url", "") 79 | if self.access_token: 80 | self.headers = {"Authorization": "token " + str(access_token)} 81 | 82 | @property 83 | def BRANCH(self) -> str: 84 | return "master" if not self.bot.version.is_prerelease else "development" 85 | 86 | async def request( 87 | self, 88 | url: str, 89 | method: str = "GET", 90 | payload: dict = None, 91 | headers: dict = None, 92 | return_response: bool = False, 93 | read_before_return: bool = False, 94 | ) -> Union[ClientResponse, Dict[str, Any], str]: 95 | """ 96 | Makes a HTTP request. 97 | 98 | Parameters 99 | ---------- 100 | url : str 101 | The destination URL of the request. 102 | method : str 103 | The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). 104 | payload : Dict[str, Any] 105 | The json payload to be sent along the request. 106 | headers : Dict[str, str] 107 | Additional headers to `headers`. 108 | return_response : bool 109 | Whether the `ClientResponse` object should be returned. 110 | read_before_return : bool 111 | Whether to perform `.read()` method before returning the `ClientResponse` object. 112 | Only valid if `return_response` is set to `True`. 113 | 114 | Returns 115 | ------- 116 | ClientResponse or Dict[str, Any] or List[Any] or str 117 | `ClientResponse` if `return_response` is `True`. 118 | `Dict[str, Any]` if the returned data is a json object. 119 | `List[Any]` if the returned data is a json list. 120 | `str` if the returned data is not a valid json data, 121 | the raw response. 122 | """ 123 | if headers is not None: 124 | headers.update(self.headers) 125 | else: 126 | headers = self.headers 127 | async with self.session.request(method, url, headers=headers, json=payload) as resp: 128 | if return_response: 129 | if read_before_return: 130 | await resp.read() 131 | return resp 132 | 133 | return await self._get_response_data(resp) 134 | 135 | @staticmethod 136 | async def _get_response_data(response: ClientResponse) -> Union[Dict[str, Any], str]: 137 | """ 138 | Internal method to convert the response data to `dict` if the data is a 139 | json object, or to `str` (raw response) if the data is not a valid json. 140 | """ 141 | try: 142 | return await response.json() 143 | except (JSONDecodeError, ClientResponseError): 144 | return await response.text() 145 | 146 | def filter_valid(self, data) -> Dict[str, Any]: 147 | """ 148 | Filters configuration keys that are accepted. 149 | 150 | Parameters 151 | ---------- 152 | data : Dict[str, Any] 153 | The data that needs to be cleaned. 154 | 155 | Returns 156 | ------- 157 | Dict[str, Any] 158 | Filtered `data` to keep only the accepted pairs. 159 | """ 160 | valid_keys = self.bot.config.valid_keys.difference(self.bot.config.protected_keys) 161 | return {k: v for k, v in data.items() if k in valid_keys} 162 | 163 | async def update_repository(self, sha: str = None) -> Dict[str, Any]: 164 | """ 165 | Update the repository from Modmail main repo. 166 | 167 | Parameters 168 | ---------- 169 | sha : Optional[str] 170 | The commit SHA to update the repository. If `None`, the latest 171 | commit SHA will be fetched. 172 | 173 | Returns 174 | ------- 175 | Dict[str, Any] 176 | A dictionary that contains response data. 177 | """ 178 | if not self.username: 179 | raise commands.CommandInvokeError("Username not found.") 180 | 181 | if sha is None: 182 | resp = await self.request(self.REPO + "/git/refs/heads/" + self.BRANCH) 183 | sha = resp["object"]["sha"] 184 | 185 | payload = {"base": self.BRANCH, "head": sha, "commit_message": "Updating bot"} 186 | 187 | merge_url = self.MERGE_URL.format(username=self.username) 188 | 189 | resp = await self.request( 190 | merge_url, 191 | method="POST", 192 | payload=payload, 193 | return_response=True, 194 | read_before_return=True, 195 | ) 196 | 197 | repo_url = self.BASE + f"/repos/{self.username}/modmail" 198 | status_map = { 199 | 201: "Successful response.", 200 | 204: "Already merged.", 201 | 403: "Forbidden.", 202 | 404: f"Repository '{repo_url}' not found.", 203 | 409: "There is a merge conflict.", 204 | 422: "Validation failed.", 205 | } 206 | # source https://docs.github.com/en/rest/branches/branches#merge-a-branch 207 | 208 | status = resp.status 209 | data = await self._get_response_data(resp) 210 | if status in (201, 204): 211 | return data 212 | 213 | args = (resp.request_info, resp.history) 214 | try: 215 | # try to get the response error message if any 216 | message = data.get("message") 217 | except AttributeError: 218 | message = None 219 | kwargs = { 220 | "status": status, 221 | "message": message if message else status_map.get(status), 222 | } 223 | # just raise 224 | raise ClientResponseError(*args, **kwargs) 225 | 226 | async def fork_repository(self) -> None: 227 | """ 228 | Forks Modmail's repository. 229 | """ 230 | await self.request(self.FORK_URL, method="POST", return_response=True) 231 | 232 | async def has_starred(self) -> bool: 233 | """ 234 | Checks if shared Modmail. 235 | 236 | Returns 237 | ------- 238 | bool 239 | `True`, if Modmail was starred. 240 | Otherwise `False`. 241 | """ 242 | resp = await self.request(self.STAR_URL, return_response=True) 243 | return resp.status == 204 244 | 245 | async def star_repository(self) -> None: 246 | """ 247 | Stars Modmail's repository. 248 | """ 249 | await self.request( 250 | self.STAR_URL, 251 | method="PUT", 252 | headers={"Content-Length": "0"}, 253 | return_response=True, 254 | ) 255 | 256 | @classmethod 257 | async def login(cls, bot) -> "GitHub": 258 | """ 259 | Logs in to GitHub with configuration variable information. 260 | 261 | Parameters 262 | ---------- 263 | bot : Bot 264 | The Modmail bot. 265 | 266 | Returns 267 | ------- 268 | GitHub 269 | The newly created `GitHub` object. 270 | """ 271 | self = cls(bot, bot.config.get("github_token")) 272 | resp: Dict[str, Any] = await self.request(self.BASE + "/user") 273 | if resp.get("login"): 274 | self.username = resp["login"] 275 | self.avatar_url = resp["avatar_url"] 276 | self.url = resp["html_url"] 277 | logger.info(f"GitHub logged in to: {self.username}") 278 | return self 279 | else: 280 | raise InvalidConfigError("Invalid github token") 281 | 282 | 283 | class ApiClient: 284 | """ 285 | This class represents the general request class for all type of clients. 286 | 287 | Parameters 288 | ---------- 289 | bot : Bot 290 | The Modmail bot. 291 | 292 | Attributes 293 | ---------- 294 | bot : Bot 295 | The Modmail bot. 296 | session : ClientSession 297 | The bot's current running `ClientSession`. 298 | """ 299 | 300 | def __init__(self, bot, db): 301 | self.bot = bot 302 | self.db = db 303 | self.session = bot.session 304 | 305 | async def request( 306 | self, 307 | url: str, 308 | method: str = "GET", 309 | payload: dict = None, 310 | return_response: bool = False, 311 | headers: dict = None, 312 | ) -> Union[ClientResponse, dict, str]: 313 | """ 314 | Makes a HTTP request. 315 | 316 | Parameters 317 | ---------- 318 | url : str 319 | The destination URL of the request. 320 | method : str 321 | The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). 322 | payload : Dict[str, Any] 323 | The json payload to be sent along the request. 324 | return_response : bool 325 | Whether the `ClientResponse` object should be returned. 326 | headers : Dict[str, str] 327 | Additional headers to `headers`. 328 | 329 | Returns 330 | ------- 331 | ClientResponse or Dict[str, Any] or List[Any] or str 332 | `ClientResponse` if `return_response` is `True`. 333 | `dict` if the returned data is a json object. 334 | `list` if the returned data is a json list. 335 | `str` if the returned data is not a valid json data, 336 | the raw response. 337 | """ 338 | async with self.session.request(method, url, headers=headers, json=payload) as resp: 339 | if return_response: 340 | return resp 341 | try: 342 | return await resp.json() 343 | except (JSONDecodeError, ClientResponseError): 344 | return await resp.text() 345 | 346 | @property 347 | def logs(self): 348 | return self.db.logs 349 | 350 | async def setup_indexes(self): 351 | return NotImplemented 352 | 353 | async def validate_database_connection(self): 354 | return NotImplemented 355 | 356 | async def get_user_logs(self, user_id: Union[str, int]) -> list: 357 | return NotImplemented 358 | 359 | async def find_log_entry(self, key: str) -> list: 360 | return NotImplemented 361 | 362 | async def get_latest_user_logs(self, user_id: Union[str, int]): 363 | return NotImplemented 364 | 365 | async def get_responded_logs(self, user_id: Union[str, int]) -> list: 366 | return NotImplemented 367 | 368 | async def get_open_logs(self) -> list: 369 | return NotImplemented 370 | 371 | async def get_log(self, channel_id: Union[str, int]) -> dict: 372 | return NotImplemented 373 | 374 | async def get_log_link(self, channel_id: Union[str, int]) -> str: 375 | return NotImplemented 376 | 377 | async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: 378 | return NotImplemented 379 | 380 | async def delete_log_entry(self, key: str) -> bool: 381 | return NotImplemented 382 | 383 | async def get_config(self) -> dict: 384 | return NotImplemented 385 | 386 | async def update_config(self, data: dict): 387 | return NotImplemented 388 | 389 | async def edit_message(self, message_id: Union[int, str], new_content: str): 390 | return NotImplemented 391 | 392 | async def append_log( 393 | self, 394 | message: Message, 395 | *, 396 | message_id: str = "", 397 | channel_id: str = "", 398 | type_: str = "thread_message", 399 | ) -> dict: 400 | return NotImplemented 401 | 402 | async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: 403 | return NotImplemented 404 | 405 | async def search_closed_by(self, user_id: Union[int, str]): 406 | return NotImplemented 407 | 408 | async def search_by_text(self, text: str, limit: Optional[int]): 409 | return NotImplemented 410 | 411 | async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): 412 | return NotImplemented 413 | 414 | async def find_notes(self, recipient: Member): 415 | return NotImplemented 416 | 417 | async def update_note_ids(self, ids: dict): 418 | return NotImplemented 419 | 420 | async def delete_note(self, message_id: Union[int, str]): 421 | return NotImplemented 422 | 423 | async def edit_note(self, message_id: Union[int, str], message: str): 424 | return NotImplemented 425 | 426 | def get_plugin_partition(self, cog): 427 | return NotImplemented 428 | 429 | async def update_repository(self) -> dict: 430 | return NotImplemented 431 | 432 | async def get_user_info(self) -> Optional[dict]: 433 | return NotImplemented 434 | 435 | 436 | class MongoDBClient(ApiClient): 437 | def __init__(self, bot): 438 | mongo_uri = bot.config["connection_uri"] 439 | if mongo_uri is None: 440 | mongo_uri = bot.config["mongo_uri"] 441 | if mongo_uri is not None: 442 | logger.warning( 443 | "You're using the old config MONGO_URI, " 444 | "consider switching to the new CONNECTION_URI config." 445 | ) 446 | else: 447 | logger.critical("A Mongo URI is necessary for the bot to function.") 448 | raise RuntimeError 449 | 450 | try: 451 | db = AsyncIOMotorClient(mongo_uri).modmail_bot 452 | except ConfigurationError as e: 453 | logger.critical( 454 | "Your MongoDB CONNECTION_URI might be copied wrong, try re-copying from the source again. " 455 | "Otherwise noted in the following message:\n%s", 456 | e, 457 | ) 458 | sys.exit(0) 459 | 460 | super().__init__(bot, db) 461 | 462 | async def setup_indexes(self): 463 | """Setup text indexes so we can use the $search operator""" 464 | coll = self.db.logs 465 | index_name = "messages.content_text_messages.author.name_text_key_text" 466 | 467 | index_info = await coll.index_information() 468 | 469 | # Backwards compatibility 470 | old_index = "messages.content_text_messages.author.name_text" 471 | if old_index in index_info: 472 | logger.info("Dropping old index: %s", old_index) 473 | await coll.drop_index(old_index) 474 | 475 | if index_name not in index_info: 476 | logger.info('Creating "text" index for logs collection.') 477 | logger.info("Name: %s", index_name) 478 | await coll.create_index( 479 | [("messages.content", "text"), ("messages.author.name", "text"), ("key", "text")] 480 | ) 481 | logger.debug("Successfully configured and verified database indexes.") 482 | 483 | async def validate_database_connection(self, *, ssl_retry=True): 484 | try: 485 | await self.db.command("buildinfo") 486 | except Exception as exc: 487 | logger.critical("Something went wrong while connecting to the database.") 488 | message = f"{type(exc).__name__}: {str(exc)}" 489 | logger.critical(message) 490 | if "CERTIFICATE_VERIFY_FAILED" in message and ssl_retry: 491 | mongo_uri = self.bot.config["connection_uri"] 492 | if mongo_uri is None: 493 | mongo_uri = self.bot.config["mongo_uri"] 494 | for _ in range(3): 495 | logger.warning( 496 | "FAILED TO VERIFY SSL CERTIFICATE, ATTEMPTING TO START WITHOUT SSL (UNSAFE)." 497 | ) 498 | logger.warning( 499 | "To fix this warning, check there's no proxies blocking SSL cert verification, " 500 | 'run "Certificate.command" on MacOS, ' 501 | 'and check certifi is up to date "pip3 install --upgrade certifi".' 502 | ) 503 | self.db = AsyncIOMotorClient(mongo_uri, tlsAllowInvalidCertificates=True).modmail_bot 504 | return await self.validate_database_connection(ssl_retry=False) 505 | if "ServerSelectionTimeoutError" in message: 506 | logger.critical( 507 | "This may have been caused by not whitelisting " 508 | "IPs correctly. Make sure to whitelist all " 509 | "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" 510 | ) 511 | 512 | if "OperationFailure" in message: 513 | logger.critical( 514 | "This is due to having invalid credentials in your MongoDB CONNECTION_URI. " 515 | "Remember you need to substitute `` with your actual password." 516 | ) 517 | logger.critical( 518 | "Be sure to URL encode your username and password (not the entire URL!!), " 519 | "https://www.urlencoder.io/, if this issue persists, try changing your username and password " 520 | "to only include alphanumeric characters, no symbols." 521 | "" 522 | ) 523 | raise 524 | else: 525 | logger.debug("Successfully connected to the database.") 526 | logger.line("debug") 527 | 528 | async def get_user_logs(self, user_id: Union[str, int]) -> list: 529 | query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id)} 530 | projection = {"messages": {"$slice": 5}} 531 | logger.debug("Retrieving user %s logs.", user_id) 532 | 533 | return await self.logs.find(query, projection).to_list(None) 534 | 535 | async def find_log_entry(self, key: str) -> list: 536 | query = {"key": key} 537 | projection = {"messages": {"$slice": 5}} 538 | logger.debug(f"Retrieving log ID {key}.") 539 | 540 | return await self.logs.find(query, projection).to_list(None) 541 | 542 | async def get_latest_user_logs(self, user_id: Union[str, int]): 543 | query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False} 544 | projection = {"messages": {"$slice": 5}} 545 | logger.debug("Retrieving user %s latest logs.", user_id) 546 | 547 | return await self.logs.find_one(query, projection, limit=1, sort=[("closed_at", -1)]) 548 | 549 | async def get_responded_logs(self, user_id: Union[str, int]) -> list: 550 | query = { 551 | "open": False, 552 | "messages": { 553 | "$elemMatch": { 554 | "author.id": str(user_id), 555 | "author.mod": True, 556 | "type": {"$in": ["anonymous", "thread_message"]}, 557 | } 558 | }, 559 | } 560 | return await self.logs.find(query).to_list(None) 561 | 562 | async def get_open_logs(self) -> list: 563 | query = {"open": True} 564 | return await self.logs.find(query).to_list(None) 565 | 566 | async def get_log(self, channel_id: Union[str, int]) -> dict: 567 | logger.debug("Retrieving channel %s logs.", channel_id) 568 | return await self.logs.find_one({"channel_id": str(channel_id)}) 569 | 570 | async def get_log_link(self, channel_id: Union[str, int]) -> str: 571 | doc = await self.get_log(channel_id) 572 | logger.debug("Retrieving log link for channel %s.", channel_id) 573 | prefix = self.bot.config["log_url_prefix"].strip("/") 574 | if prefix == "NONE": 575 | prefix = "" 576 | return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" 577 | 578 | async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: 579 | key = secrets.token_hex(6) 580 | 581 | await self.logs.insert_one( 582 | { 583 | "_id": key, 584 | "key": key, 585 | "open": True, 586 | "created_at": str(discord.utils.utcnow()), 587 | "closed_at": None, 588 | "channel_id": str(channel.id), 589 | "guild_id": str(self.bot.guild_id), 590 | "bot_id": str(self.bot.user.id), 591 | "recipient": { 592 | "id": str(recipient.id), 593 | "name": recipient.name, 594 | "discriminator": recipient.discriminator, 595 | "avatar_url": recipient.display_avatar.url, 596 | "mod": False, 597 | }, 598 | "creator": { 599 | "id": str(creator.id), 600 | "name": creator.name, 601 | "discriminator": creator.discriminator, 602 | "avatar_url": creator.display_avatar.url, 603 | "mod": isinstance(creator, Member), 604 | }, 605 | "closer": None, 606 | "messages": [], 607 | } 608 | ) 609 | logger.debug("Created a log entry, key %s.", key) 610 | prefix = self.bot.config["log_url_prefix"].strip("/") 611 | if prefix == "NONE": 612 | prefix = "" 613 | return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{key}" 614 | 615 | async def delete_log_entry(self, key: str) -> bool: 616 | result = await self.logs.delete_one({"key": key}) 617 | return result.deleted_count == 1 618 | 619 | async def get_config(self) -> dict: 620 | conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) 621 | if conf is None: 622 | logger.debug("Creating a new config entry for bot %s.", self.bot.user.id) 623 | await self.db.config.insert_one({"bot_id": self.bot.user.id}) 624 | return {"bot_id": self.bot.user.id} 625 | return conf 626 | 627 | async def update_config(self, data: dict): 628 | toset = self.bot.config.filter_valid(data) 629 | unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) 630 | 631 | if toset and unset: 632 | return await self.db.config.update_one( 633 | {"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset} 634 | ) 635 | if toset: 636 | return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$set": toset}) 637 | if unset: 638 | return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$unset": unset}) 639 | 640 | async def edit_message(self, message_id: Union[int, str], new_content: str) -> None: 641 | await self.logs.update_one( 642 | {"messages.message_id": str(message_id)}, 643 | {"$set": {"messages.$.content": new_content, "messages.$.edited": True}}, 644 | ) 645 | 646 | async def append_log( 647 | self, 648 | message: Message, 649 | *, 650 | message_id: str = "", 651 | channel_id: str = "", 652 | type_: str = "thread_message", 653 | ) -> dict: 654 | channel_id = str(channel_id) or str(message.channel.id) 655 | message_id = str(message_id) or str(message.id) 656 | 657 | data = { 658 | "timestamp": str(message.created_at), 659 | "message_id": message_id, 660 | "author": { 661 | "id": str(message.author.id), 662 | "name": message.author.name, 663 | "discriminator": message.author.discriminator, 664 | "avatar_url": message.author.display_avatar.url, 665 | "mod": not isinstance(message.channel, DMChannel), 666 | }, 667 | "content": message.content, 668 | "type": type_, 669 | "attachments": [ 670 | { 671 | "id": a.id, 672 | "filename": a.filename, 673 | "is_image": a.width is not None, 674 | "size": a.size, 675 | "url": a.url, 676 | } 677 | for a in message.attachments 678 | ], 679 | } 680 | 681 | return await self.logs.find_one_and_update( 682 | {"channel_id": channel_id}, {"$push": {"messages": data}}, return_document=True 683 | ) 684 | 685 | async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: 686 | return await self.logs.find_one_and_update( 687 | {"channel_id": str(channel_id)}, {"$set": data}, return_document=True 688 | ) 689 | 690 | async def search_closed_by(self, user_id: Union[int, str]): 691 | return await self.logs.find( 692 | {"guild_id": str(self.bot.guild_id), "open": False, "closer.id": str(user_id)}, 693 | {"messages": {"$slice": 5}}, 694 | ).to_list(None) 695 | 696 | async def search_by_text(self, text: str, limit: Optional[int]): 697 | return await self.bot.db.logs.find( 698 | { 699 | "guild_id": str(self.bot.guild_id), 700 | "open": False, 701 | "$text": {"$search": f'"{text}"'}, 702 | }, 703 | {"messages": {"$slice": 5}}, 704 | ).to_list(limit) 705 | 706 | async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): 707 | await self.db.notes.insert_one( 708 | { 709 | "recipient": str(recipient.id), 710 | "author": { 711 | "id": str(message.author.id), 712 | "name": message.author.name, 713 | "discriminator": message.author.discriminator, 714 | "avatar_url": message.author.display_avatar.url, 715 | }, 716 | "message": message.content, 717 | "message_id": str(message_id), 718 | } 719 | ) 720 | 721 | async def find_notes(self, recipient: Member): 722 | return await self.db.notes.find({"recipient": str(recipient.id)}).to_list(None) 723 | 724 | async def update_note_ids(self, ids: dict): 725 | for object_id, message_id in ids.items(): 726 | await self.db.notes.update_one({"_id": object_id}, {"$set": {"message_id": message_id}}) 727 | 728 | async def delete_note(self, message_id: Union[int, str]): 729 | await self.db.notes.delete_one({"message_id": str(message_id)}) 730 | 731 | async def edit_note(self, message_id: Union[int, str], message: str): 732 | await self.db.notes.update_one({"message_id": str(message_id)}, {"$set": {"message": message}}) 733 | 734 | def get_plugin_partition(self, cog): 735 | cls_name = cog.__class__.__name__ 736 | return self.db.plugins[cls_name] 737 | 738 | async def update_repository(self) -> dict: 739 | user = await GitHub.login(self.bot) 740 | data = await user.update_repository() 741 | return { 742 | "data": data, 743 | "user": { 744 | "username": user.username, 745 | "avatar_url": user.avatar_url, 746 | "url": user.url, 747 | }, 748 | } 749 | 750 | async def get_user_info(self) -> Optional[dict]: 751 | try: 752 | user = await GitHub.login(self.bot) 753 | except InvalidConfigError: 754 | return None 755 | else: 756 | return { 757 | "user": { 758 | "username": user.username, 759 | "avatar_url": user.avatar_url, 760 | "url": user.url, 761 | } 762 | } 763 | 764 | 765 | class PluginDatabaseClient: 766 | def __init__(self, bot): 767 | self.bot = bot 768 | 769 | def get_partition(self, cog): 770 | cls_name = cog.__class__.__name__ 771 | return self.bot.api.db.plugins[cls_name] 772 | -------------------------------------------------------------------------------- /core/config.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | import re 5 | import typing 6 | from copy import deepcopy 7 | 8 | from dotenv import load_dotenv 9 | import isodate 10 | 11 | import discord 12 | from discord.ext.commands import BadArgument 13 | 14 | from core._color_data import ALL_COLORS 15 | from core.models import DMDisabled, InvalidConfigError, Default, getLogger 16 | from core.time import UserFriendlyTime 17 | from core.utils import strtobool 18 | 19 | logger = getLogger(__name__) 20 | load_dotenv() 21 | 22 | 23 | class ConfigManager: 24 | public_keys = { 25 | # activity 26 | "twitch_url": "https://www.twitch.tv/discordmodmail/", 27 | # bot settings 28 | "main_category_id": None, 29 | "fallback_category_id": None, 30 | "prefix": "?", 31 | "mention": "@here", 32 | "main_color": str(discord.Color.blurple()), 33 | "error_color": str(discord.Color.red()), 34 | "user_typing": False, 35 | "mod_typing": False, 36 | "account_age": isodate.Duration(), 37 | "guild_age": isodate.Duration(), 38 | "thread_cooldown": isodate.Duration(), 39 | "log_expiration": isodate.Duration(), 40 | "reply_without_command": False, 41 | "anon_reply_without_command": False, 42 | "plain_reply_without_command": False, 43 | # logging 44 | "log_channel_id": None, 45 | "mention_channel_id": None, 46 | "update_channel_id": None, 47 | # updates 48 | "update_notifications": True, 49 | # threads 50 | "sent_emoji": "\N{WHITE HEAVY CHECK MARK}", 51 | "blocked_emoji": "\N{NO ENTRY SIGN}", 52 | "close_emoji": "\N{LOCK}", 53 | "use_user_id_channel_name": False, 54 | "use_timestamp_channel_name": False, 55 | "use_nickname_channel_name": False, 56 | "use_random_channel_name": False, 57 | "recipient_thread_close": False, 58 | "thread_show_roles": True, 59 | "thread_show_account_age": True, 60 | "thread_show_join_age": True, 61 | "thread_cancelled": "Cancelled", 62 | "thread_auto_close_silently": False, 63 | "thread_auto_close": isodate.Duration(), 64 | "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", 65 | "thread_creation_response": "The staff team will get back to you as soon as possible.", 66 | "thread_creation_footer": "Your message has been sent", 67 | "thread_contact_silently": False, 68 | "thread_self_closable_creation_footer": "Click the lock to close the thread", 69 | "thread_creation_contact_title": "New Thread", 70 | "thread_creation_self_contact_response": "You have opened a Modmail thread.", 71 | "thread_creation_contact_response": "{creator.name} has opened a Modmail thread.", 72 | "thread_creation_title": "Thread Created", 73 | "thread_close_footer": "Replying will create a new thread", 74 | "thread_close_title": "Thread Closed", 75 | "thread_close_response": "{closer.mention} has closed this Modmail thread.", 76 | "thread_self_close_response": "You have closed this Modmail thread.", 77 | "thread_move_title": "Thread Moved", 78 | "thread_move_notify": False, 79 | "thread_move_notify_mods": False, 80 | "thread_move_response": "This thread has been moved.", 81 | "cooldown_thread_title": "Message not sent!", 82 | "cooldown_thread_response": "Your cooldown ends {delta}. Try contacting me then.", 83 | "disabled_new_thread_title": "Not Delivered", 84 | "disabled_new_thread_response": "We are not accepting new threads.", 85 | "disabled_new_thread_footer": "Please try again later...", 86 | "disabled_current_thread_title": "Not Delivered", 87 | "disabled_current_thread_response": "We are not accepting any messages.", 88 | "disabled_current_thread_footer": "Please try again later...", 89 | "transfer_reactions": True, 90 | "close_on_leave": False, 91 | "close_on_leave_reason": "The recipient has left the server.", 92 | "alert_on_mention": False, 93 | "silent_alert_on_mention": False, 94 | "show_timestamp": True, 95 | "anonymous_snippets": False, 96 | "plain_snippets": False, 97 | "require_close_reason": False, 98 | "show_log_url_button": False, 99 | # group conversations 100 | "private_added_to_group_title": "New Thread (Group)", 101 | "private_added_to_group_response": "{moderator.name} has added you to a Modmail thread.", 102 | "private_added_to_group_description_anon": "A moderator has added you to a Modmail thread.", 103 | "public_added_to_group_title": "New User", 104 | "public_added_to_group_response": "{moderator.name} has added {users} to the Modmail thread.", 105 | "public_added_to_group_description_anon": "A moderator has added {users} to the Modmail thread.", 106 | "private_removed_from_group_title": "Removed From Thread (Group)", 107 | "private_removed_from_group_response": "{moderator.name} has removed you from the Modmail thread.", 108 | "private_removed_from_group_description_anon": "A moderator has removed you from the Modmail thread.", 109 | "public_removed_from_group_title": "User Removed", 110 | "public_removed_from_group_response": "{moderator.name} has removed {users} from the Modmail thread.", 111 | "public_removed_from_group_description_anon": "A moderator has removed {users} from the Modmail thread.", 112 | # moderation 113 | "recipient_color": str(discord.Color.gold()), 114 | "mod_color": str(discord.Color.green()), 115 | "mod_tag": None, 116 | # anonymous message 117 | "anon_username": None, 118 | "anon_avatar_url": None, 119 | "anon_tag": "Response", 120 | # react to contact 121 | "react_to_contact_message": None, 122 | "react_to_contact_emoji": "\N{WHITE HEAVY CHECK MARK}", 123 | # confirm thread creation 124 | "confirm_thread_creation": False, 125 | "confirm_thread_creation_title": "Confirm thread creation", 126 | "confirm_thread_response": "Click the button to confirm thread creation which will directly contact the moderators.", 127 | "confirm_thread_creation_accept": "\N{WHITE HEAVY CHECK MARK}", 128 | "confirm_thread_creation_deny": "\N{NO ENTRY SIGN}", 129 | # regex 130 | "use_regex_autotrigger": False, 131 | "use_hoisted_top_role": True, 132 | } 133 | 134 | private_keys = { 135 | # bot presence 136 | "activity_message": "", 137 | "activity_type": None, 138 | "status": None, 139 | "dm_disabled": DMDisabled.NONE, 140 | "oauth_whitelist": [], 141 | # moderation 142 | "blocked": {}, 143 | "blocked_roles": {}, 144 | "blocked_whitelist": [], 145 | "command_permissions": {}, 146 | "level_permissions": {}, 147 | "override_command_level": {}, 148 | # threads 149 | "snippets": {}, 150 | "notification_squad": {}, 151 | "subscriptions": {}, 152 | "closures": {}, 153 | # misc 154 | "plugins": [], 155 | "aliases": {}, 156 | "auto_triggers": {}, 157 | } 158 | 159 | protected_keys = { 160 | # Modmail 161 | "modmail_guild_id": None, 162 | "guild_id": None, 163 | "log_url": "https://example.com/", 164 | "log_url_prefix": "/logs", 165 | "mongo_uri": None, 166 | "database_type": "mongodb", 167 | "connection_uri": None, # replace mongo uri in the future 168 | "owners": None, 169 | "enable_presence_intent": False, 170 | "registry_plugins_only": False, 171 | # bot 172 | "token": None, 173 | "enable_plugins": True, 174 | "enable_eval": True, 175 | # github access token for private repositories 176 | "github_token": None, 177 | "disable_autoupdates": False, 178 | "disable_updates": False, 179 | # Logging 180 | "log_level": "INFO", 181 | "stream_log_format": "plain", 182 | "file_log_format": "plain", 183 | "discord_log_level": "INFO", 184 | # data collection 185 | "data_collection": True, 186 | } 187 | 188 | colors = {"mod_color", "recipient_color", "main_color", "error_color"} 189 | 190 | time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown", "log_expiration"} 191 | 192 | booleans = { 193 | "use_user_id_channel_name", 194 | "use_timestamp_channel_name", 195 | "use_nickname_channel_name", 196 | "use_random_channel_name", 197 | "user_typing", 198 | "mod_typing", 199 | "reply_without_command", 200 | "anon_reply_without_command", 201 | "plain_reply_without_command", 202 | "show_log_url_button", 203 | "recipient_thread_close", 204 | "thread_auto_close_silently", 205 | "thread_move_notify", 206 | "thread_move_notify_mods", 207 | "transfer_reactions", 208 | "close_on_leave", 209 | "alert_on_mention", 210 | "silent_alert_on_mention", 211 | "show_timestamp", 212 | "confirm_thread_creation", 213 | "use_regex_autotrigger", 214 | "enable_plugins", 215 | "data_collection", 216 | "enable_eval", 217 | "disable_autoupdates", 218 | "disable_updates", 219 | "update_notifications", 220 | "thread_contact_silently", 221 | "anonymous_snippets", 222 | "plain_snippets", 223 | "require_close_reason", 224 | "recipient_thread_close", 225 | "thread_show_roles", 226 | "thread_show_account_age", 227 | "thread_show_join_age", 228 | "use_hoisted_top_role", 229 | "enable_presence_intent", 230 | "registry_plugins_only", 231 | } 232 | 233 | enums = { 234 | "dm_disabled": DMDisabled, 235 | "status": discord.Status, 236 | "activity_type": discord.ActivityType, 237 | } 238 | 239 | force_str = {"command_permissions", "level_permissions"} 240 | 241 | defaults = {**public_keys, **private_keys, **protected_keys} 242 | all_keys = set(defaults.keys()) 243 | 244 | def __init__(self, bot): 245 | self.bot = bot 246 | self._cache = {} 247 | self.ready_event = asyncio.Event() 248 | self.config_help = {} 249 | 250 | def __repr__(self): 251 | return repr(self._cache) 252 | 253 | def populate_cache(self) -> dict: 254 | data = deepcopy(self.defaults) 255 | 256 | # populate from env var and .env file 257 | data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}) 258 | config_json = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json") 259 | if os.path.exists(config_json): 260 | logger.debug("Loading envs from config.json.") 261 | with open(config_json, "r", encoding="utf-8") as f: 262 | # Config json should override env vars 263 | try: 264 | data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys}) 265 | except json.JSONDecodeError: 266 | logger.critical("Failed to load config.json env values.", exc_info=True) 267 | self._cache = data 268 | 269 | config_help_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config_help.json") 270 | with open(config_help_json, "r", encoding="utf-8") as f: 271 | self.config_help = dict(sorted(json.load(f).items())) 272 | 273 | return self._cache 274 | 275 | async def update(self): 276 | """Updates the config with data from the cache""" 277 | await self.bot.api.update_config(self.filter_default(self._cache)) 278 | 279 | async def refresh(self) -> dict: 280 | """Refreshes internal cache with data from database""" 281 | for k, v in (await self.bot.api.get_config()).items(): 282 | k = k.lower() 283 | if k in self.all_keys: 284 | self._cache[k] = v 285 | if not self.ready_event.is_set(): 286 | self.ready_event.set() 287 | logger.debug("Successfully fetched configurations from database.") 288 | return self._cache 289 | 290 | async def wait_until_ready(self) -> None: 291 | await self.ready_event.wait() 292 | 293 | def __setitem__(self, key: str, item: typing.Any) -> None: 294 | key = key.lower() 295 | logger.info("Setting %s.", key) 296 | if key not in self.all_keys: 297 | raise InvalidConfigError(f'Configuration "{key}" is invalid.') 298 | self._cache[key] = item 299 | 300 | def __getitem__(self, key: str) -> typing.Any: 301 | # make use of the custom methods in func:get: 302 | return self.get(key) 303 | 304 | def __delitem__(self, key: str) -> None: 305 | return self.remove(key) 306 | 307 | def get(self, key: str, *, convert: bool = True) -> typing.Any: 308 | key = key.lower() 309 | if key not in self.all_keys: 310 | raise InvalidConfigError(f'Configuration "{key}" is invalid.') 311 | if key not in self._cache: 312 | self._cache[key] = deepcopy(self.defaults[key]) 313 | value = self._cache[key] 314 | 315 | if not convert: 316 | return value 317 | 318 | if key in self.colors: 319 | try: 320 | return int(value.lstrip("#"), base=16) 321 | except ValueError: 322 | logger.error("Invalid %s provided.", key) 323 | value = int(self.remove(key).lstrip("#"), base=16) 324 | 325 | elif key in self.time_deltas: 326 | if not isinstance(value, isodate.Duration): 327 | try: 328 | value = isodate.parse_duration(value) 329 | except isodate.ISO8601Error: 330 | logger.warning( 331 | "The {account} age limit needs to be a " 332 | 'ISO-8601 duration formatted duration, not "%s".', 333 | value, 334 | ) 335 | value = self.remove(key) 336 | 337 | elif key in self.booleans: 338 | try: 339 | value = strtobool(value) 340 | except ValueError: 341 | value = self.remove(key) 342 | 343 | elif key in self.enums: 344 | if value is None: 345 | return None 346 | try: 347 | value = self.enums[key](value) 348 | except ValueError: 349 | logger.warning("Invalid %s %s.", key, value) 350 | value = self.remove(key) 351 | 352 | elif key in self.force_str: 353 | # Temporary: as we saved in int previously, leading to int32 overflow, 354 | # this is transitioning IDs to strings 355 | new_value = {} 356 | changed = False 357 | for k, v in value.items(): 358 | new_v = v 359 | if isinstance(v, list): 360 | new_v = [] 361 | for n in v: 362 | if n != -1 and not isinstance(n, str): 363 | changed = True 364 | n = str(n) 365 | new_v.append(n) 366 | new_value[k] = new_v 367 | 368 | if changed: 369 | # transition the database as well 370 | self.set(key, new_value) 371 | 372 | value = new_value 373 | 374 | return value 375 | 376 | async def set(self, key: str, item: typing.Any, convert=True) -> None: 377 | if not convert: 378 | return self.__setitem__(key, item) 379 | 380 | if key in self.colors: 381 | try: 382 | hex_ = str(item) 383 | if hex_.startswith("#"): 384 | hex_ = hex_[1:] 385 | if len(hex_) == 3: 386 | hex_ = "".join(s for s in hex_ for _ in range(2)) 387 | if len(hex_) != 6: 388 | raise InvalidConfigError("Invalid color name or hex.") 389 | try: 390 | int(hex_, 16) 391 | except ValueError: 392 | raise InvalidConfigError("Invalid color name or hex.") 393 | 394 | except InvalidConfigError: 395 | name = str(item).lower() 396 | name = re.sub(r"[\-+|. ]+", " ", name) 397 | hex_ = ALL_COLORS.get(name) 398 | if hex_ is None: 399 | name = re.sub(r"[\-+|. ]+", "", name) 400 | hex_ = ALL_COLORS.get(name) 401 | if hex_ is None: 402 | raise 403 | return self.__setitem__(key, "#" + hex_) 404 | 405 | if key in self.time_deltas: 406 | try: 407 | isodate.parse_duration(item) 408 | except isodate.ISO8601Error: 409 | try: 410 | converter = UserFriendlyTime() 411 | time = await converter.convert(None, item, now=discord.utils.utcnow()) 412 | if time.arg: 413 | raise ValueError 414 | except BadArgument as exc: 415 | raise InvalidConfigError(*exc.args) 416 | except Exception as e: 417 | logger.debug(e) 418 | raise InvalidConfigError( 419 | "Unrecognized time, please use ISO-8601 duration format " 420 | 'string or a simpler "human readable" time.' 421 | ) 422 | now = discord.utils.utcnow() 423 | item = isodate.duration_isoformat(time.dt - now) 424 | return self.__setitem__(key, item) 425 | 426 | if key in self.booleans: 427 | try: 428 | return self.__setitem__(key, strtobool(item)) 429 | except ValueError: 430 | raise InvalidConfigError("Must be a yes/no value.") 431 | 432 | elif key in self.enums: 433 | if isinstance(item, self.enums[key]): 434 | # value is an enum type 435 | item = item.value 436 | 437 | return self.__setitem__(key, item) 438 | 439 | def remove(self, key: str) -> typing.Any: 440 | key = key.lower() 441 | logger.info("Removing %s.", key) 442 | if key not in self.all_keys: 443 | raise InvalidConfigError(f'Configuration "{key}" is invalid.') 444 | if key in self._cache: 445 | del self._cache[key] 446 | self._cache[key] = deepcopy(self.defaults[key]) 447 | return self._cache[key] 448 | 449 | def items(self) -> typing.Iterable: 450 | return self._cache.items() 451 | 452 | @classmethod 453 | def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: 454 | return { 455 | k.lower(): v 456 | for k, v in data.items() 457 | if k.lower() in cls.public_keys or k.lower() in cls.private_keys 458 | } 459 | 460 | @classmethod 461 | def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: 462 | # TODO: use .get to prevent errors 463 | filtered = {} 464 | for k, v in data.items(): 465 | default = cls.defaults.get(k.lower(), Default) 466 | if default is Default: 467 | logger.error("Unexpected configuration detected: %s.", k) 468 | continue 469 | if v != default: 470 | filtered[k.lower()] = v 471 | return filtered 472 | -------------------------------------------------------------------------------- /core/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import re 5 | import sys 6 | import _string 7 | 8 | from difflib import get_close_matches 9 | from enum import IntEnum 10 | from logging import FileHandler, StreamHandler, Handler 11 | from logging.handlers import RotatingFileHandler 12 | from string import Formatter 13 | from typing import Dict, Optional 14 | 15 | import discord 16 | from discord.ext import commands 17 | 18 | 19 | try: 20 | from colorama import Fore, Style 21 | except ImportError: 22 | Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() 23 | 24 | 25 | if ".heroku" in os.environ.get("PYTHONHOME", ""): 26 | # heroku 27 | Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() 28 | 29 | 30 | class ModmailLogger(logging.Logger): 31 | @staticmethod 32 | def _debug_(*msgs): 33 | return f'{Fore.CYAN}{" ".join(msgs)}{Style.RESET_ALL}' 34 | 35 | @staticmethod 36 | def _info_(*msgs): 37 | return f'{Fore.LIGHTMAGENTA_EX}{" ".join(msgs)}{Style.RESET_ALL}' 38 | 39 | @staticmethod 40 | def _error_(*msgs): 41 | return f'{Fore.RED}{" ".join(msgs)}{Style.RESET_ALL}' 42 | 43 | def debug(self, msg, *args, **kwargs): 44 | if self.isEnabledFor(logging.DEBUG): 45 | self._log(logging.DEBUG, self._debug_(msg), args, **kwargs) 46 | 47 | def info(self, msg, *args, **kwargs): 48 | if self.isEnabledFor(logging.INFO): 49 | self._log(logging.INFO, self._info_(msg), args, **kwargs) 50 | 51 | def warning(self, msg, *args, **kwargs): 52 | if self.isEnabledFor(logging.WARNING): 53 | self._log(logging.WARNING, self._error_(msg), args, **kwargs) 54 | 55 | def error(self, msg, *args, **kwargs): 56 | if self.isEnabledFor(logging.ERROR): 57 | self._log(logging.ERROR, self._error_(msg), args, **kwargs) 58 | 59 | def critical(self, msg, *args, **kwargs): 60 | if self.isEnabledFor(logging.CRITICAL): 61 | self._log(logging.CRITICAL, self._error_(msg), args, **kwargs) 62 | 63 | def line(self, level="info"): 64 | if level == "info": 65 | level = logging.INFO 66 | elif level == "debug": 67 | level = logging.DEBUG 68 | else: 69 | level = logging.INFO 70 | if self.isEnabledFor(level): 71 | self._log( 72 | level, 73 | Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL, 74 | [], 75 | ) 76 | 77 | 78 | class JsonFormatter(logging.Formatter): 79 | """ 80 | Formatter that outputs JSON strings after parsing the LogRecord. 81 | 82 | Parameters 83 | ---------- 84 | fmt_dict : Optional[Dict[str, str]] 85 | {key: logging format attribute} pairs. Defaults to {"message": "message"}. 86 | time_format: str 87 | time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S" 88 | msec_format: str 89 | Microsecond formatting. Appended at the end. Default: "%s.%03dZ" 90 | """ 91 | 92 | def __init__( 93 | self, 94 | fmt_dict: Optional[Dict[str, str]] = None, 95 | time_format: str = "%Y-%m-%dT%H:%M:%S", 96 | msec_format: str = "%s.%03dZ", 97 | ): 98 | self.fmt_dict: Dict[str, str] = fmt_dict if fmt_dict is not None else {"message": "message"} 99 | self.default_time_format: str = time_format 100 | self.default_msec_format: str = msec_format 101 | self.datefmt: Optional[str] = None 102 | 103 | def usesTime(self) -> bool: 104 | """ 105 | Overwritten to look for the attribute in the format dict values instead of the fmt string. 106 | """ 107 | return "asctime" in self.fmt_dict.values() 108 | 109 | def formatMessage(self, record) -> Dict[str, str]: 110 | """ 111 | Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string. 112 | KeyError is raised if an unknown attribute is provided in the fmt_dict. 113 | """ 114 | return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()} 115 | 116 | def format(self, record) -> str: 117 | """ 118 | Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON 119 | instead of a string. 120 | """ 121 | record.message = record.getMessage() 122 | 123 | if self.usesTime(): 124 | record.asctime = self.formatTime(record, self.datefmt) 125 | 126 | message_dict = self.formatMessage(record) 127 | 128 | if record.exc_info: 129 | # Cache the traceback text to avoid converting it multiple times 130 | # (it's constant anyway) 131 | if not record.exc_text: 132 | record.exc_text = self.formatException(record.exc_info) 133 | 134 | if record.exc_text: 135 | message_dict["exc_info"] = record.exc_text 136 | 137 | if record.stack_info: 138 | message_dict["stack_info"] = self.formatStack(record.stack_info) 139 | 140 | return json.dumps(message_dict, default=str) 141 | 142 | 143 | class FileFormatter(logging.Formatter): 144 | ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") 145 | 146 | def format(self, record): 147 | record.msg = self.ansi_escape.sub("", record.msg) 148 | return super().format(record) 149 | 150 | 151 | log_stream_formatter = logging.Formatter( 152 | "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S" 153 | ) 154 | 155 | log_file_formatter = FileFormatter( 156 | "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", 157 | datefmt="%Y-%m-%d %H:%M:%S", 158 | ) 159 | 160 | json_formatter = JsonFormatter( 161 | { 162 | "level": "levelname", 163 | "message": "message", 164 | "loggerName": "name", 165 | "processName": "processName", 166 | "processID": "process", 167 | "threadName": "threadName", 168 | "threadID": "thread", 169 | "timestamp": "asctime", 170 | } 171 | ) 172 | 173 | 174 | def create_log_handler( 175 | filename: Optional[str] = None, 176 | *, 177 | rotating: bool = False, 178 | level: int = logging.DEBUG, 179 | mode: str = "a+", 180 | encoding: str = "utf-8", 181 | format: str = "plain", 182 | maxBytes: int = 28000000, 183 | backupCount: int = 1, 184 | **kwargs, 185 | ) -> Handler: 186 | """ 187 | Creates a pre-configured log handler. This function is made for consistency's sake with 188 | pre-defined default values for parameters and formatters to pass to handler class. 189 | Additional keyword arguments also can be specified, just in case. 190 | 191 | Plugin developers should not use this and use `models.getLogger` instead. 192 | 193 | Parameters 194 | ---------- 195 | filename : Optional[Path] 196 | Specifies that a `FileHandler` or `RotatingFileHandler` be created, using the specified filename, 197 | rather than a `StreamHandler`. Defaults to `None`. 198 | rotating : bool 199 | Whether the file handler should be the `RotatingFileHandler`. Defaults to `False`. Note, this 200 | argument only compatible if the `filename` is specified, otherwise `ValueError` will be raised. 201 | level : int 202 | The root logger level for the handler. Defaults to `logging.DEBUG`. 203 | mode : str 204 | If filename is specified, open the file in this mode. Defaults to 'a+'. 205 | encoding : str 206 | If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created, 207 | and thus used when opening the output file. Defaults to 'utf-8'. 208 | format : str 209 | The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created, 210 | based on other conditional logic. 211 | maxBytes : int 212 | The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current 213 | log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero, 214 | rollover never occurs, so you generally want to set `backupCount` to at least 1. 215 | backupCount : int 216 | Max number of backup files. Defaults to 1. If this is set to zero, rollover will never occur. 217 | 218 | Returns 219 | ------- 220 | `StreamHandler` when `filename` is `None`, otherwise `FileHandler` or `RotatingFileHandler` 221 | depending on the `rotating` value. 222 | """ 223 | if filename is None and rotating: 224 | raise ValueError("`filename` must be set to instantiate a `RotatingFileHandler`.") 225 | 226 | if filename is None: 227 | handler = StreamHandler(stream=sys.stdout, **kwargs) 228 | formatter = log_stream_formatter 229 | elif not rotating: 230 | handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs) 231 | formatter = log_file_formatter 232 | else: 233 | handler = RotatingFileHandler( 234 | filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs 235 | ) 236 | formatter = log_file_formatter 237 | 238 | if format == "json": 239 | formatter = json_formatter 240 | 241 | handler.setLevel(level) 242 | handler.setFormatter(formatter) 243 | return handler 244 | 245 | 246 | logging.setLoggerClass(ModmailLogger) 247 | log_level = logging.INFO 248 | loggers = set() 249 | 250 | ch = create_log_handler(level=log_level) 251 | ch_debug: Optional[RotatingFileHandler] = None 252 | 253 | 254 | def getLogger(name=None) -> ModmailLogger: 255 | logger = logging.getLogger(name) 256 | logger.setLevel(log_level) 257 | logger.addHandler(ch) 258 | if ch_debug is not None: 259 | logger.addHandler(ch_debug) 260 | loggers.add(logger) 261 | return logger 262 | 263 | 264 | def configure_logging(bot) -> None: 265 | global ch_debug, log_level, ch 266 | 267 | stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"] 268 | if stream_log_format == "json": 269 | ch.setFormatter(json_formatter) 270 | 271 | logger = getLogger(__name__) 272 | level_text = bot.config["log_level"].upper() 273 | logging_levels = { 274 | "CRITICAL": logging.CRITICAL, 275 | "ERROR": logging.ERROR, 276 | "WARNING": logging.WARNING, 277 | "INFO": logging.INFO, 278 | "DEBUG": logging.DEBUG, 279 | } 280 | logger.line() 281 | 282 | level = logging_levels.get(level_text) 283 | if level is None: 284 | level = bot.config.remove("log_level") 285 | logger.warning("Invalid logging level set: %s.", level_text) 286 | logger.warning("Using default logging level: %s.", level) 287 | level = logging_levels[level] 288 | else: 289 | logger.info("Logging level: %s", level_text) 290 | log_level = level 291 | 292 | logger.info("Log file: %s", bot.log_file_path) 293 | ch_debug = create_log_handler(bot.log_file_path, rotating=True) 294 | 295 | if file_log_format == "json": 296 | ch_debug.setFormatter(json_formatter) 297 | 298 | ch.setLevel(log_level) 299 | 300 | logger.info("Stream log format: %s", stream_log_format) 301 | logger.info("File log format: %s", file_log_format) 302 | 303 | for log in loggers: 304 | log.setLevel(log_level) 305 | log.addHandler(ch_debug) 306 | 307 | # Set up discord.py logging 308 | d_level_text = bot.config["discord_log_level"].upper() 309 | d_level = logging_levels.get(d_level_text) 310 | if d_level is None: 311 | d_level = bot.config.remove("discord_log_level") 312 | logger.warning("Invalid discord logging level set: %s.", d_level_text) 313 | logger.warning("Using default discord logging level: %s.", d_level) 314 | d_level = logging_levels[d_level] 315 | d_logger = logging.getLogger("discord") 316 | d_logger.setLevel(d_level) 317 | 318 | non_verbose_log_level = max(d_level, logging.INFO) 319 | stream_handler = create_log_handler(level=non_verbose_log_level) 320 | if non_verbose_log_level != d_level: 321 | logger.info("Discord logging level (stdout): %s.", logging.getLevelName(non_verbose_log_level)) 322 | logger.info("Discord logging level (logfile): %s.", logging.getLevelName(d_level)) 323 | else: 324 | logger.info("Discord logging level: %s.", logging.getLevelName(d_level)) 325 | d_logger.addHandler(stream_handler) 326 | d_logger.addHandler(ch_debug) 327 | 328 | logger.debug("Successfully configured logging.") 329 | 330 | 331 | class InvalidConfigError(commands.BadArgument): 332 | def __init__(self, msg, *args): 333 | super().__init__(msg, *args) 334 | self.msg = msg 335 | 336 | @property 337 | def embed(self): 338 | # Single reference of Color.red() 339 | return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) 340 | 341 | 342 | class _Default: 343 | pass 344 | 345 | 346 | Default = _Default() 347 | 348 | 349 | class SafeFormatter(Formatter): 350 | def get_field(self, field_name, args, kwargs): 351 | first, rest = _string.formatter_field_name_split(field_name) 352 | 353 | try: 354 | obj = self.get_value(first, args, kwargs) 355 | except (IndexError, KeyError): 356 | return "", first 357 | 358 | # loop through the rest of the field_name, doing 359 | # getattr or getitem as needed 360 | # stops when reaches the depth of 2 or starts with _. 361 | try: 362 | for n, (is_attr, i) in enumerate(rest): 363 | if n >= 2: 364 | break 365 | if is_attr: 366 | if str(i).startswith("_"): 367 | break 368 | obj = getattr(obj, i) 369 | else: 370 | obj = obj[i] 371 | else: 372 | return obj, first 373 | except (IndexError, KeyError): 374 | pass 375 | return "", first 376 | 377 | 378 | class UnseenFormatter(Formatter): 379 | def get_value(self, key, args, kwds): 380 | if isinstance(key, str): 381 | try: 382 | return kwds[key] 383 | except KeyError: 384 | return "{" + key + "}" 385 | else: 386 | return super().get_value(key, args, kwds) 387 | 388 | 389 | class SimilarCategoryConverter(commands.CategoryChannelConverter): 390 | async def convert(self, ctx, argument): 391 | bot = ctx.bot 392 | guild = ctx.guild 393 | 394 | try: 395 | return await super().convert(ctx, argument) 396 | except commands.ChannelNotFound: 397 | if guild: 398 | categories = {c.name.casefold(): c for c in guild.categories} 399 | else: 400 | categories = { 401 | c.name.casefold(): c 402 | for c in bot.get_all_channels() 403 | if isinstance(c, discord.CategoryChannel) 404 | } 405 | 406 | result = get_close_matches(argument.casefold(), categories.keys(), n=1, cutoff=0.75) 407 | if result: 408 | result = categories[result[0]] 409 | 410 | if not isinstance(result, discord.CategoryChannel): 411 | raise commands.ChannelNotFound(argument) 412 | 413 | return result 414 | 415 | 416 | class DummyMessage: 417 | """ 418 | A class mimicking the original :class:discord.Message 419 | where all functions that require an actual message to exist 420 | is replaced with a dummy function 421 | """ 422 | 423 | def __init__(self, message): 424 | if message: 425 | message.attachments = [] 426 | self._message = message 427 | 428 | def __getattr__(self, name: str): 429 | return getattr(self._message, name) 430 | 431 | def __bool__(self): 432 | return bool(self._message) 433 | 434 | async def delete(self, *, delay=None): 435 | return 436 | 437 | async def edit(self, **fields): 438 | return 439 | 440 | async def add_reaction(self, emoji): 441 | return 442 | 443 | async def remove_reaction(self, emoji): 444 | return 445 | 446 | async def clear_reaction(self, emoji): 447 | return 448 | 449 | async def clear_reactions(self): 450 | return 451 | 452 | async def pin(self, *, reason=None): 453 | return 454 | 455 | async def unpin(self, *, reason=None): 456 | return 457 | 458 | async def publish(self): 459 | return 460 | 461 | async def ack(self): 462 | return 463 | 464 | 465 | class PermissionLevel(IntEnum): 466 | OWNER = 5 467 | ADMINISTRATOR = 4 468 | ADMIN = 4 469 | MODERATOR = 3 470 | MOD = 3 471 | SUPPORTER = 2 472 | RESPONDER = 2 473 | REGULAR = 1 474 | INVALID = -1 475 | 476 | 477 | class DMDisabled(IntEnum): 478 | NONE = 0 479 | NEW_THREADS = 1 480 | ALL_THREADS = 2 481 | 482 | 483 | class HostingMethod(IntEnum): 484 | HEROKU = 0 485 | PM2 = 1 486 | SYSTEMD = 2 487 | SCREEN = 3 488 | DOCKER = 4 489 | OTHER = 5 490 | -------------------------------------------------------------------------------- /core/paginator.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import discord 4 | from discord import Message, Embed, ButtonStyle, Interaction 5 | from discord.ui import View, Button, Select 6 | from discord.ext import commands 7 | 8 | 9 | class PaginatorSession: 10 | """ 11 | Class that interactively paginates something. 12 | 13 | Parameters 14 | ---------- 15 | ctx : Context 16 | The context of the command. 17 | timeout : float 18 | How long to wait for before the session closes. 19 | pages : List[Any] 20 | A list of entries to paginate. 21 | 22 | Attributes 23 | ---------- 24 | ctx : Context 25 | The context of the command. 26 | timeout : float 27 | How long to wait for before the session closes. 28 | pages : List[Any] 29 | A list of entries to paginate. 30 | running : bool 31 | Whether the paginate session is running. 32 | base : Message 33 | The `Message` of the `Embed`. 34 | current : int 35 | The current page number. 36 | callback_map : Dict[str, method] 37 | A mapping for text to method. 38 | view : PaginatorView 39 | The view that is sent along with the base message. 40 | select_menu : Select 41 | A select menu that will be added to the View. 42 | """ 43 | 44 | def __init__(self, ctx: commands.Context, *pages, **options): 45 | self.ctx = ctx 46 | self.timeout: int = options.get("timeout", 210) 47 | self.running = False 48 | self.base: Message = None 49 | self.current = 0 50 | self.pages = list(pages) 51 | self.destination = options.get("destination", ctx) 52 | self.view = None 53 | self.select_menu = None 54 | 55 | self.callback_map = { 56 | "<<": self.first_page, 57 | "<": self.previous_page, 58 | ">": self.next_page, 59 | ">>": self.last_page, 60 | } 61 | self._buttons_map = {"<<": None, "<": None, ">": None, ">>": None} 62 | 63 | async def show_page(self, index: int) -> typing.Optional[typing.Dict]: 64 | """ 65 | Show a page by page number. 66 | 67 | Parameters 68 | ---------- 69 | index : int 70 | The index of the page. 71 | """ 72 | if not 0 <= index < len(self.pages): 73 | return 74 | 75 | self.current = index 76 | page = self.pages[index] 77 | result = None 78 | 79 | if self.running: 80 | result = self._show_page(page) 81 | else: 82 | await self.create_base(page) 83 | 84 | self.update_disabled_status() 85 | return result 86 | 87 | def update_disabled_status(self): 88 | if self.current == self.first_page(): 89 | # disable << button 90 | if self._buttons_map["<<"] is not None: 91 | self._buttons_map["<<"].disabled = True 92 | 93 | if self._buttons_map["<"] is not None: 94 | self._buttons_map["<"].disabled = True 95 | else: 96 | if self._buttons_map["<<"] is not None: 97 | self._buttons_map["<<"].disabled = False 98 | 99 | if self._buttons_map["<"] is not None: 100 | self._buttons_map["<"].disabled = False 101 | 102 | if self.current == self.last_page(): 103 | # disable >> button 104 | if self._buttons_map[">>"] is not None: 105 | self._buttons_map[">>"].disabled = True 106 | 107 | if self._buttons_map[">"] is not None: 108 | self._buttons_map[">"].disabled = True 109 | else: 110 | if self._buttons_map[">>"] is not None: 111 | self._buttons_map[">>"].disabled = False 112 | 113 | if self._buttons_map[">"] is not None: 114 | self._buttons_map[">"].disabled = False 115 | 116 | async def create_base(self, item) -> None: 117 | """ 118 | Create a base `Message`. 119 | """ 120 | if len(self.pages) == 1: 121 | self.view = None 122 | self.running = False 123 | else: 124 | self.view = PaginatorView(self, timeout=self.timeout) 125 | self.update_disabled_status() 126 | self.running = True 127 | 128 | await self._create_base(item, self.view) 129 | 130 | async def _create_base(self, item, view: View) -> None: 131 | raise NotImplementedError 132 | 133 | def _show_page(self, page): 134 | raise NotImplementedError 135 | 136 | def first_page(self): 137 | """Returns the index of the first page""" 138 | return 0 139 | 140 | def next_page(self): 141 | """Returns the index of the next page""" 142 | return min(self.current + 1, self.last_page()) 143 | 144 | def previous_page(self): 145 | """Returns the index of the previous page""" 146 | return max(self.current - 1, self.first_page()) 147 | 148 | def last_page(self): 149 | """Returns the index of the last page""" 150 | return len(self.pages) - 1 151 | 152 | async def run(self) -> typing.Optional[Message]: 153 | """ 154 | Starts the pagination session. 155 | """ 156 | if not self.running: 157 | await self.show_page(self.current) 158 | 159 | if self.view is not None: 160 | await self.view.wait() 161 | 162 | await self.close(delete=False) 163 | 164 | async def close( 165 | self, delete: bool = True, *, interaction: Interaction = None 166 | ) -> typing.Optional[Message]: 167 | """ 168 | Closes the pagination session. 169 | 170 | Parameters 171 | ---------- 172 | delete : bool, optional 173 | Whether or delete the message upon closure. 174 | Defaults to `True`. 175 | 176 | Returns 177 | ------- 178 | Optional[Message] 179 | If `delete` is `True`. 180 | """ 181 | if self.running: 182 | sent_emoji, _ = await self.ctx.bot.retrieve_emoji() 183 | await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) 184 | 185 | if interaction: 186 | message = interaction.message 187 | else: 188 | message = self.base 189 | 190 | self.running = False 191 | 192 | if self.view is not None: 193 | self.view.stop() 194 | if delete: 195 | await message.delete() 196 | else: 197 | self.view.clear_items() 198 | await message.edit(view=self.view) 199 | 200 | 201 | class PaginatorView(View): 202 | """ 203 | View that is used for pagination. 204 | 205 | Parameters 206 | ---------- 207 | handler : PaginatorSession 208 | The paginator session that spawned this view. 209 | timeout : float 210 | How long to wait for before the session closes. 211 | 212 | Attributes 213 | ---------- 214 | handler : PaginatorSession 215 | The paginator session that spawned this view. 216 | timeout : float 217 | How long to wait for before the session closes. 218 | """ 219 | 220 | def __init__(self, handler: PaginatorSession, *args, **kwargs): 221 | super().__init__(*args, **kwargs) 222 | self.handler = handler 223 | self.clear_items() # clear first so we can control the order 224 | self.fill_items() 225 | 226 | @discord.ui.button(label="Stop", style=ButtonStyle.danger) 227 | async def stop_button(self, interaction: Interaction, button: Button): 228 | await self.handler.close(interaction=interaction) 229 | 230 | def fill_items(self): 231 | if self.handler.select_menu is not None: 232 | self.add_item(self.handler.select_menu) 233 | 234 | for label, callback in self.handler.callback_map.items(): 235 | if len(self.handler.pages) == 2 and label in ("<<", ">>"): 236 | continue 237 | 238 | if label in ("<<", ">>"): 239 | style = ButtonStyle.secondary 240 | else: 241 | style = ButtonStyle.primary 242 | 243 | button = PageButton(self.handler, callback, label=label, style=style) 244 | 245 | self.handler._buttons_map[label] = button 246 | self.add_item(button) 247 | self.add_item(self.stop_button) 248 | 249 | async def interaction_check(self, interaction: Interaction): 250 | """Only allow the message author to interact""" 251 | if interaction.user != self.handler.ctx.author: 252 | await interaction.response.send_message( 253 | "Only the original author can control this!", ephemeral=True 254 | ) 255 | return False 256 | return True 257 | 258 | 259 | class PageButton(Button): 260 | """ 261 | A button that has a callback to jump to the next page 262 | 263 | Parameters 264 | ---------- 265 | handler : PaginatorSession 266 | The paginator session that spawned this view. 267 | page_callback : Callable 268 | A callable that returns an int of the page to go to. 269 | 270 | Attributes 271 | ---------- 272 | handler : PaginatorSession 273 | The paginator session that spawned this view. 274 | page_callback : Callable 275 | A callable that returns an int of the page to go to. 276 | """ 277 | 278 | def __init__(self, handler, page_callback, **kwargs): 279 | super().__init__(**kwargs) 280 | self.handler = handler 281 | self.page_callback = page_callback 282 | 283 | async def callback(self, interaction: Interaction): 284 | kwargs = await self.handler.show_page(self.page_callback()) 285 | await interaction.response.edit_message(**kwargs, view=self.view) 286 | 287 | 288 | class PageSelect(Select): 289 | def __init__(self, handler: PaginatorSession, pages: typing.List[typing.Tuple[str]]): 290 | self.handler = handler 291 | options = [] 292 | for n, (label, description) in enumerate(pages): 293 | options.append(discord.SelectOption(label=label, description=description, value=str(n))) 294 | 295 | options = options[:25] # max 25 options 296 | super().__init__(placeholder="Select a page", min_values=1, max_values=1, options=options) 297 | 298 | async def callback(self, interaction: Interaction): 299 | page = int(self.values[0]) 300 | kwargs = await self.handler.show_page(page) 301 | await interaction.response.edit_message(**kwargs, view=self.view) 302 | 303 | 304 | class EmbedPaginatorSession(PaginatorSession): 305 | def __init__(self, ctx: commands.Context, *embeds, **options): 306 | super().__init__(ctx, *embeds, **options) 307 | 308 | if len(self.pages) > 1: 309 | select_options = [] 310 | create_select = True 311 | for i, embed in enumerate(self.pages): 312 | footer_text = f"Page {i + 1} of {len(self.pages)}" 313 | if embed.footer.text: 314 | footer_text = footer_text + " • " + embed.footer.text 315 | 316 | if embed.footer.icon: 317 | icon_url = embed.footer.icon.url 318 | else: 319 | icon_url = None 320 | embed.set_footer(text=footer_text, icon_url=icon_url) 321 | 322 | # select menu 323 | if embed.author.name: 324 | title = embed.author.name[:30].strip() 325 | if len(embed.author.name) > 30: 326 | title += "..." 327 | else: 328 | title = embed.title[:30].strip() 329 | if len(embed.title) > 30: 330 | title += "..." 331 | if not title: 332 | create_select = False 333 | 334 | if embed.description: 335 | description = embed.description[:40].replace("*", "").replace("`", "").strip() 336 | if len(embed.description) > 40: 337 | description += "..." 338 | else: 339 | description = "" 340 | select_options.append((title, description)) 341 | 342 | if create_select: 343 | if len(set(x[0] for x in select_options)) != 1: # must have unique authors 344 | self.select_menu = PageSelect(self, select_options) 345 | 346 | def add_page(self, item: Embed) -> None: 347 | if isinstance(item, Embed): 348 | self.pages.append(item) 349 | else: 350 | raise TypeError("Page must be an Embed object.") 351 | 352 | async def _create_base(self, item: Embed, view: View) -> None: 353 | self.base = await self.destination.send(embed=item, view=view) 354 | 355 | def _show_page(self, page): 356 | return dict(embed=page) 357 | 358 | 359 | class MessagePaginatorSession(PaginatorSession): 360 | def __init__(self, ctx: commands.Context, *messages, embed: Embed = None, **options): 361 | self.embed = embed 362 | self.footer_text = self.embed.footer.text if embed is not None else None 363 | super().__init__(ctx, *messages, **options) 364 | 365 | def add_page(self, item: str) -> None: 366 | if isinstance(item, str): 367 | self.pages.append(item) 368 | else: 369 | raise TypeError("Page must be a str object.") 370 | 371 | def _set_footer(self): 372 | if self.embed is not None: 373 | footer_text = f"Page {self.current+1} of {len(self.pages)}" 374 | if self.footer_text: 375 | footer_text = footer_text + " • " + self.footer_text 376 | 377 | if self.embed.footer.icon: 378 | icon_url = self.embed.footer.icon.url 379 | else: 380 | icon_url = None 381 | 382 | self.embed.set_footer(text=footer_text, icon_url=icon_url) 383 | 384 | async def _create_base(self, item: str, view: View) -> None: 385 | self._set_footer() 386 | self.base = await self.ctx.send(content=item, embed=self.embed, view=view) 387 | 388 | def _show_page(self, page) -> typing.Dict: 389 | self._set_footer() 390 | return dict(content=page, embed=self.embed) 391 | -------------------------------------------------------------------------------- /core/time.py: -------------------------------------------------------------------------------- 1 | """ 2 | UserFriendlyTime by Rapptz 3 | Source: 4 | https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/time.py 5 | """ 6 | from __future__ import annotations 7 | 8 | import datetime 9 | import discord 10 | from typing import TYPE_CHECKING, Any, Optional, Union 11 | import parsedatetime as pdt 12 | from dateutil.relativedelta import relativedelta 13 | from .utils import human_join 14 | from discord.ext import commands 15 | from discord import app_commands 16 | import re 17 | 18 | # Monkey patch mins and secs into the units 19 | units = pdt.pdtLocales["en_US"].units 20 | units["minutes"].append("mins") 21 | units["seconds"].append("secs") 22 | 23 | if TYPE_CHECKING: 24 | from discord.ext.commands import Context 25 | from typing_extensions import Self 26 | 27 | 28 | class plural: 29 | """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L8-L18""" 30 | 31 | def __init__(self, value: int): 32 | self.value: int = value 33 | 34 | def __format__(self, format_spec: str) -> str: 35 | v = self.value 36 | singular, sep, plural = format_spec.partition("|") 37 | plural = plural or f"{singular}s" 38 | if abs(v) != 1: 39 | return f"{v} {plural}" 40 | return f"{v} {singular}" 41 | 42 | 43 | class ShortTime: 44 | compiled = re.compile( 45 | """ 46 | (?:(?P[0-9])(?:years?|y))? # e.g. 2y 47 | (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months 48 | (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w 49 | (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d 50 | (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h 51 | (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m 52 | (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s 53 | """, 54 | re.VERBOSE, 55 | ) 56 | 57 | discord_fmt = re.compile(r"[0-9]+)(?:\:?[RFfDdTt])?>") 58 | 59 | dt: datetime.datetime 60 | 61 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 62 | match = self.compiled.fullmatch(argument) 63 | if match is None or not match.group(0): 64 | match = self.discord_fmt.fullmatch(argument) 65 | if match is not None: 66 | self.dt = datetime.datetime.utcfromtimestamp(int(match.group("ts")), tz=datetime.timezone.utc) 67 | return 68 | else: 69 | raise commands.BadArgument("invalid time provided") 70 | 71 | data = {k: int(v) for k, v in match.groupdict(default=0).items()} 72 | now = now or datetime.datetime.now(datetime.timezone.utc) 73 | self.dt = now + relativedelta(**data) 74 | 75 | @classmethod 76 | async def convert(cls, ctx: Context, argument: str) -> Self: 77 | return cls(argument, now=ctx.message.created_at) 78 | 79 | 80 | class HumanTime: 81 | calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) 82 | 83 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 84 | now = now or datetime.datetime.utcnow() 85 | dt, status = self.calendar.parseDT(argument, sourceTime=now) 86 | if not status.hasDateOrTime: 87 | raise commands.BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') 88 | 89 | if not status.hasTime: 90 | # replace it with the current time 91 | dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) 92 | 93 | self.dt: datetime.datetime = dt 94 | self._past: bool = dt < now 95 | 96 | @classmethod 97 | async def convert(cls, ctx: Context, argument: str) -> Self: 98 | return cls(argument, now=ctx.message.created_at) 99 | 100 | 101 | class Time(HumanTime): 102 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 103 | try: 104 | o = ShortTime(argument, now=now) 105 | except Exception: 106 | super().__init__(argument) 107 | else: 108 | self.dt = o.dt 109 | self._past = False 110 | 111 | 112 | class FutureTime(Time): 113 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 114 | super().__init__(argument, now=now) 115 | 116 | if self._past: 117 | raise commands.BadArgument("this time is in the past") 118 | 119 | 120 | class BadTimeTransform(app_commands.AppCommandError): 121 | pass 122 | 123 | 124 | class TimeTransformer(app_commands.Transformer): 125 | async def transform(self, interaction, value: str) -> datetime.datetime: 126 | now = interaction.created_at 127 | try: 128 | short = ShortTime(value, now=now) 129 | except commands.BadArgument: 130 | try: 131 | human = FutureTime(value, now=now) 132 | except commands.BadArgument as e: 133 | raise BadTimeTransform(str(e)) from None 134 | else: 135 | return human.dt 136 | else: 137 | return short.dt 138 | 139 | 140 | # CHANGE: Added now 141 | class FriendlyTimeResult: 142 | dt: datetime.datetime 143 | now: datetime.datetime 144 | arg: str 145 | 146 | __slots__ = ("dt", "arg", "now") 147 | 148 | def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): 149 | self.dt = dt 150 | self.now = now 151 | 152 | if now is None: 153 | self.now = dt 154 | else: 155 | self.now = now 156 | 157 | self.arg = "" 158 | 159 | async def ensure_constraints( 160 | self, ctx: Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str 161 | ) -> None: 162 | if self.dt < now: 163 | raise commands.BadArgument("This time is in the past.") 164 | 165 | # CHANGE 166 | # if not remaining: 167 | # if uft.default is None: 168 | # raise commands.BadArgument("Missing argument after the time.") 169 | # remaining = uft.default 170 | 171 | if uft.converter is not None: 172 | self.arg = await uft.converter.convert(ctx, remaining) 173 | else: 174 | self.arg = remaining 175 | 176 | 177 | class UserFriendlyTime(commands.Converter): 178 | """That way quotes aren't absolutely necessary.""" 179 | 180 | def __init__( 181 | self, 182 | converter: Optional[Union[type[commands.Converter], commands.Converter]] = None, 183 | *, 184 | default: Any = None, 185 | ): 186 | if isinstance(converter, type) and issubclass(converter, commands.Converter): 187 | converter = converter() 188 | 189 | if converter is not None and not isinstance(converter, commands.Converter): 190 | raise TypeError("commands.Converter subclass necessary.") 191 | 192 | self.converter: commands.Converter = converter # type: ignore # It doesn't understand this narrowing 193 | self.default: Any = default 194 | 195 | async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTimeResult: 196 | calendar = HumanTime.calendar 197 | regex = ShortTime.compiled 198 | if now is None: 199 | now = ctx.message.created_at 200 | 201 | match = regex.match(argument) 202 | if match is not None and match.group(0): 203 | data = {k: int(v) for k, v in match.groupdict(default=0).items()} 204 | remaining = argument[match.end() :].strip() 205 | result = FriendlyTimeResult(now + relativedelta(**data), now) 206 | await result.ensure_constraints(ctx, self, now, remaining) 207 | return result 208 | 209 | if match is None or not match.group(0): 210 | match = ShortTime.discord_fmt.match(argument) 211 | if match is not None: 212 | result = FriendlyTimeResult( 213 | datetime.datetime.utcfromtimestamp(int(match.group("ts")), now, tz=datetime.timezone.utc) 214 | ) 215 | remaining = argument[match.end() :].strip() 216 | await result.ensure_constraints(ctx, self, now, remaining) 217 | return result 218 | 219 | # apparently nlp does not like "from now" 220 | # it likes "from x" in other cases though so let me handle the 'now' case 221 | if argument.endswith("from now"): 222 | argument = argument[:-8].strip() 223 | 224 | if argument[0:2] == "me": 225 | # starts with "me to", "me in", or "me at " 226 | if argument[0:6] in ("me to ", "me in ", "me at "): 227 | argument = argument[6:] 228 | 229 | elements = calendar.nlp(argument, sourceTime=now) 230 | if elements is None or len(elements) == 0: 231 | # CHANGE 232 | result = FriendlyTimeResult(now) 233 | await result.ensure_constraints(ctx, self, now, argument) 234 | return result 235 | 236 | # handle the following cases: 237 | # "date time" foo 238 | # date time foo 239 | # foo date time 240 | 241 | # first the first two cases: 242 | dt, status, begin, end, dt_string = elements[0] 243 | 244 | if not status.hasDateOrTime: 245 | raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') 246 | 247 | if begin not in (0, 1) and end != len(argument): 248 | raise commands.BadArgument( 249 | "Time is either in an inappropriate location, which " 250 | "must be either at the end or beginning of your input, " 251 | "or I just flat out did not understand what you meant. Sorry." 252 | ) 253 | 254 | if not status.hasTime: 255 | # replace it with the current time 256 | dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) 257 | 258 | # if midnight is provided, just default to next day 259 | if status.accuracy == pdt.pdtContext.ACU_HALFDAY: 260 | dt = dt.replace(day=now.day + 1) 261 | 262 | result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) 263 | remaining = "" 264 | 265 | if begin in (0, 1): 266 | if begin == 1: 267 | # check if it's quoted: 268 | if argument[0] != '"': 269 | raise commands.BadArgument("Expected quote before time input...") 270 | 271 | if not (end < len(argument) and argument[end] == '"'): 272 | raise commands.BadArgument("If the time is quoted, you must unquote it.") 273 | 274 | remaining = argument[end + 1 :].lstrip(" ,.!") 275 | else: 276 | remaining = argument[end:].lstrip(" ,.!") 277 | elif len(argument) == end: 278 | remaining = argument[:begin].strip() 279 | 280 | await result.ensure_constraints(ctx, self, now, remaining) 281 | return result 282 | 283 | 284 | def human_timedelta( 285 | dt: datetime.datetime, 286 | *, 287 | source: Optional[datetime.datetime] = None, 288 | accuracy: Optional[int] = 3, 289 | brief: bool = False, 290 | suffix: bool = True, 291 | ) -> str: 292 | now = source or datetime.datetime.now(datetime.timezone.utc) 293 | if dt.tzinfo is None: 294 | dt = dt.replace(tzinfo=datetime.timezone.utc) 295 | 296 | if now.tzinfo is None: 297 | now = now.replace(tzinfo=datetime.timezone.utc) 298 | 299 | # Microsecond free zone 300 | now = now.replace(microsecond=0) 301 | dt = dt.replace(microsecond=0) 302 | 303 | # This implementation uses relativedelta instead of the much more obvious 304 | # divmod approach with seconds because the seconds approach is not entirely 305 | # accurate once you go over 1 week in terms of accuracy since you have to 306 | # hardcode a month as 30 or 31 days. 307 | # A query like "11 months" can be interpreted as "!1 months and 6 days" 308 | if dt > now: 309 | delta = relativedelta(dt, now) 310 | output_suffix = "" 311 | else: 312 | delta = relativedelta(now, dt) 313 | output_suffix = " ago" if suffix else "" 314 | 315 | attrs = [ 316 | ("year", "y"), 317 | ("month", "mo"), 318 | ("day", "d"), 319 | ("hour", "h"), 320 | ("minute", "m"), 321 | ("second", "s"), 322 | ] 323 | 324 | output = [] 325 | for attr, brief_attr in attrs: 326 | elem = getattr(delta, attr + "s") 327 | if not elem: 328 | continue 329 | 330 | if attr == "day": 331 | weeks = delta.weeks 332 | if weeks: 333 | elem -= weeks * 7 334 | if not brief: 335 | output.append(format(plural(weeks), "week")) 336 | else: 337 | output.append(f"{weeks}w") 338 | 339 | if elem <= 0: 340 | continue 341 | 342 | if brief: 343 | output.append(f"{elem}{brief_attr}") 344 | else: 345 | output.append(format(plural(elem), attr)) 346 | 347 | if accuracy is not None: 348 | output = output[:accuracy] 349 | 350 | if len(output) == 0: 351 | return "now" 352 | else: 353 | if not brief: 354 | return human_join(output, final="and") + output_suffix 355 | else: 356 | return " ".join(output) + output_suffix 357 | 358 | 359 | def format_relative(dt: datetime.datetime) -> str: 360 | return discord.utils.format_dt(dt, "R") 361 | -------------------------------------------------------------------------------- /core/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import functools 3 | import re 4 | import typing 5 | from datetime import datetime, timezone 6 | from difflib import get_close_matches 7 | from distutils.util import strtobool as _stb # pylint: disable=import-error 8 | from itertools import takewhile, zip_longest 9 | from urllib import parse 10 | 11 | import discord 12 | from discord.ext import commands 13 | 14 | from core.models import getLogger 15 | 16 | 17 | __all__ = [ 18 | "strtobool", 19 | "User", 20 | "truncate", 21 | "format_preview", 22 | "is_image_url", 23 | "parse_image_url", 24 | "human_join", 25 | "days", 26 | "cleanup_code", 27 | "parse_channel_topic", 28 | "match_title", 29 | "match_user_id", 30 | "match_other_recipients", 31 | "create_thread_channel", 32 | "create_not_found_embed", 33 | "parse_alias", 34 | "normalize_alias", 35 | "format_description", 36 | "trigger_typing", 37 | "escape_code_block", 38 | "tryint", 39 | "get_top_role", 40 | "get_joint_id", 41 | "extract_block_timestamp", 42 | "AcceptButton", 43 | "DenyButton", 44 | "ConfirmThreadCreationView", 45 | "DummyParam", 46 | ] 47 | 48 | 49 | logger = getLogger(__name__) 50 | 51 | 52 | def strtobool(val): 53 | if isinstance(val, bool): 54 | return val 55 | try: 56 | return _stb(str(val)) 57 | except ValueError: 58 | val = val.lower() 59 | if val == "enable": 60 | return 1 61 | if val == "disable": 62 | return 0 63 | raise 64 | 65 | 66 | class User(commands.MemberConverter): 67 | """ 68 | A custom discord.py `Converter` that 69 | supports `Member`, `User`, and string ID's. 70 | """ 71 | 72 | # noinspection PyCallByClass,PyTypeChecker 73 | async def convert(self, ctx, argument): 74 | try: 75 | return await commands.MemberConverter().convert(ctx, argument) 76 | except commands.BadArgument: 77 | pass 78 | try: 79 | return await commands.UserConverter().convert(ctx, argument) 80 | except commands.BadArgument: 81 | pass 82 | match = self._get_id_match(argument) 83 | if match is None: 84 | raise commands.BadArgument('User "{}" not found'.format(argument)) 85 | return discord.Object(int(match.group(1))) 86 | 87 | 88 | def truncate(text: str, max: int = 50) -> str: # pylint: disable=redefined-builtin 89 | """ 90 | Reduces the string to `max` length, by trimming the message into "...". 91 | 92 | Parameters 93 | ---------- 94 | text : str 95 | The text to trim. 96 | max : int, optional 97 | The max length of the text. 98 | Defaults to 50. 99 | 100 | Returns 101 | ------- 102 | str 103 | The truncated text. 104 | """ 105 | text = text.strip() 106 | return text[: max - 3].strip() + "..." if len(text) > max else text 107 | 108 | 109 | def format_preview(messages: typing.List[typing.Dict[str, typing.Any]]): 110 | """ 111 | Used to format previews. 112 | 113 | Parameters 114 | ---------- 115 | messages : List[Dict[str, Any]] 116 | A list of messages. 117 | 118 | Returns 119 | ------- 120 | str 121 | A formatted string preview. 122 | """ 123 | messages = messages[:3] 124 | out = "" 125 | for message in messages: 126 | if message.get("type") in {"note", "internal"}: 127 | continue 128 | author = message["author"] 129 | content = str(message["content"]).replace("\n", " ") 130 | 131 | name = author["name"] 132 | discriminator = str(author["discriminator"]) 133 | if discriminator != "0": 134 | name += "#" + discriminator 135 | prefix = "[M]" if author["mod"] else "[R]" 136 | out += truncate(f"`{prefix} {name}:` {content}", max=75) + "\n" 137 | 138 | return out or "No Messages" 139 | 140 | 141 | def is_image_url(url: str, **kwargs) -> str: 142 | """ 143 | Check if the URL is pointing to an image. 144 | 145 | Parameters 146 | ---------- 147 | url : str 148 | The URL to check. 149 | 150 | Returns 151 | ------- 152 | bool 153 | Whether the URL is a valid image URL. 154 | """ 155 | try: 156 | result = parse.urlparse(url) 157 | if result.netloc == "gyazo.com" and result.scheme in ["http", "https"]: 158 | # gyazo support 159 | url = re.sub( 160 | r"(https?://)((?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|%[0-9a-fA-F][0-9a-fA-F])+)", 161 | r"\1i.\2.png", 162 | url, 163 | ) 164 | except ValueError: 165 | pass 166 | 167 | return parse_image_url(url, **kwargs) 168 | 169 | 170 | def parse_image_url(url: str, *, convert_size=True) -> str: 171 | """ 172 | Convert the image URL into a sized Discord avatar. 173 | 174 | Parameters 175 | ---------- 176 | url : str 177 | The URL to convert. 178 | 179 | Returns 180 | ------- 181 | str 182 | The converted URL, or '' if the URL isn't in the proper format. 183 | """ 184 | types = [".png", ".jpg", ".gif", ".jpeg", ".webp"] 185 | url = parse.urlsplit(url) 186 | 187 | if any(url.path.lower().endswith(i) for i in types): 188 | if convert_size: 189 | return parse.urlunsplit((*url[:3], "size=128", url[-1])) 190 | else: 191 | return parse.urlunsplit(url) 192 | return "" 193 | 194 | 195 | def human_join(seq: typing.Sequence[str], delim: str = ", ", final: str = "or") -> str: 196 | """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L21-L32""" 197 | size = len(seq) 198 | if size == 0: 199 | return "" 200 | 201 | if size == 1: 202 | return seq[0] 203 | 204 | if size == 2: 205 | return f"{seq[0]} {final} {seq[1]}" 206 | 207 | return delim.join(seq[:-1]) + f" {final} {seq[-1]}" 208 | 209 | 210 | def days(day: typing.Union[str, int]) -> str: 211 | """ 212 | Humanize the number of days. 213 | 214 | Parameters 215 | ---------- 216 | day: Union[int, str] 217 | The number of days passed. 218 | 219 | Returns 220 | ------- 221 | str 222 | A formatted string of the number of days passed. 223 | """ 224 | day = int(day) 225 | if day == 0: 226 | return "**today**" 227 | return f"{day} day ago" if day == 1 else f"{day} days ago" 228 | 229 | 230 | def cleanup_code(content: str) -> str: 231 | """ 232 | Automatically removes code blocks from the code. 233 | 234 | Parameters 235 | ---------- 236 | content : str 237 | The content to be cleaned. 238 | 239 | Returns 240 | ------- 241 | str 242 | The cleaned content. 243 | """ 244 | # remove ```py\n``` 245 | if content.startswith("```") and content.endswith("```"): 246 | return "\n".join(content.split("\n")[1:-1]) 247 | 248 | # remove `foo` 249 | return content.strip("` \n") 250 | 251 | 252 | TOPIC_REGEX = re.compile( 253 | r"(?:\bTitle:\s*(?P.*)\n)?" 254 | r"\bUser ID:\s*(?P<user_id>\d{17,21})\b" 255 | r"(?:\nOther Recipients:\s*(?P<other_ids>\d{17,21}(?:(?:\s*,\s*)\d{17,21})*)\b)?", 256 | flags=re.IGNORECASE | re.DOTALL, 257 | ) 258 | UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) 259 | 260 | 261 | def parse_channel_topic(text: str) -> typing.Tuple[typing.Optional[str], int, typing.List[int]]: 262 | """ 263 | A helper to parse channel topics and respectivefully returns all the required values 264 | at once. 265 | 266 | Parameters 267 | ---------- 268 | text : str 269 | The text of channel topic. 270 | 271 | Returns 272 | ------- 273 | Tuple[Optional[str], int, List[int]] 274 | A tuple of title, user ID, and other recipients IDs. 275 | """ 276 | title, user_id, other_ids = None, -1, [] 277 | if isinstance(text, str): 278 | match = TOPIC_REGEX.search(text) 279 | else: 280 | match = None 281 | 282 | if match is not None: 283 | groupdict = match.groupdict() 284 | title = groupdict["title"] 285 | 286 | # user ID string is the required one in regex, so if match is found 287 | # the value of this won't be None 288 | user_id = int(groupdict["user_id"]) 289 | 290 | oth_ids = groupdict["other_ids"] 291 | if oth_ids: 292 | other_ids = list(map(int, oth_ids.split(","))) 293 | 294 | return title, user_id, other_ids 295 | 296 | 297 | def match_title(text: str) -> str: 298 | """ 299 | Matches a title in the format of "Title: XXXX" 300 | 301 | Parameters 302 | ---------- 303 | text : str 304 | The text of the user ID. 305 | 306 | Returns 307 | ------- 308 | Optional[str] 309 | The title if found. 310 | """ 311 | return parse_channel_topic(text)[0] 312 | 313 | 314 | def match_user_id(text: str, any_string: bool = False) -> int: 315 | """ 316 | Matches a user ID in the format of "User ID: 12345". 317 | 318 | Parameters 319 | ---------- 320 | text : str 321 | The text of the user ID. 322 | any_string: bool 323 | Whether to search any string that matches the UID_REGEX, e.g. not from channel topic. 324 | Defaults to False. 325 | 326 | Returns 327 | ------- 328 | int 329 | The user ID if found. Otherwise, -1. 330 | """ 331 | user_id = -1 332 | if any_string: 333 | match = UID_REGEX.search(text) 334 | if match is not None: 335 | user_id = int(match.group(1)) 336 | else: 337 | user_id = parse_channel_topic(text)[1] 338 | 339 | return user_id 340 | 341 | 342 | def match_other_recipients(text: str) -> typing.List[int]: 343 | """ 344 | Matches a title in the format of "Other Recipients: XXXX,XXXX" 345 | 346 | Parameters 347 | ---------- 348 | text : str 349 | The text of the user ID. 350 | 351 | Returns 352 | ------- 353 | List[int] 354 | The list of other recipients IDs. 355 | """ 356 | return parse_channel_topic(text)[2] 357 | 358 | 359 | def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: 360 | # Single reference of Color.red() 361 | embed = discord.Embed( 362 | color=discord.Color.red(), description=f"**{name.capitalize()} `{word}` cannot be found.**" 363 | ) 364 | val = get_close_matches(word, possibilities, n=n, cutoff=cutoff) 365 | if val: 366 | embed.description += "\nHowever, perhaps you meant...\n" + "\n".join(val) 367 | return embed 368 | 369 | 370 | def parse_alias(alias, *, split=True): 371 | def encode_alias(m): 372 | return "\x1AU" + base64.b64encode(m.group(1).encode()).decode() + "\x1AU" 373 | 374 | def decode_alias(m): 375 | return base64.b64decode(m.group(1).encode()).decode() 376 | 377 | alias = re.sub( 378 | r"(?:(?<=^)(?:\s*(?<!\\)(?:\")\s*)|(?<=&&)(?:\s*(?<!\\)(?:\")\s*))(.+?)" 379 | r"(?:(?:\s*(?<!\\)(?:\")\s*)(?=&&)|(?:\s*(?<!\\)(?:\")\s*)(?=$))", 380 | encode_alias, 381 | alias, 382 | ).strip() 383 | 384 | aliases = [] 385 | if not alias: 386 | return aliases 387 | 388 | if split: 389 | iterate = re.split(r"\s*&&\s*", alias) 390 | else: 391 | iterate = [alias] 392 | 393 | for a in iterate: 394 | a = re.sub("\x1AU(.+?)\x1AU", decode_alias, a) 395 | if a[0] == a[-1] == '"': 396 | a = a[1:-1] 397 | aliases.append(a) 398 | 399 | return aliases 400 | 401 | 402 | def normalize_alias(alias, message=""): 403 | aliases = parse_alias(alias) 404 | contents = parse_alias(message, split=False) 405 | 406 | final_aliases = [] 407 | for a, content in zip_longest(aliases, contents): 408 | if a is None: 409 | break 410 | 411 | if content: 412 | final_aliases.append(f"{a} {content}") 413 | else: 414 | final_aliases.append(a) 415 | 416 | return final_aliases 417 | 418 | 419 | def format_description(i, names): 420 | return "\n".join( 421 | ": ".join((str(a + i * 15), b)) 422 | for a, b in enumerate(takewhile(lambda x: x is not None, names), start=1) 423 | ) 424 | 425 | 426 | def trigger_typing(func): 427 | @functools.wraps(func) 428 | async def wrapper(self, ctx: commands.Context, *args, **kwargs): 429 | await ctx.typing() 430 | return await func(self, ctx, *args, **kwargs) 431 | 432 | return wrapper 433 | 434 | 435 | def escape_code_block(text): 436 | return re.sub(r"```", "`\u200b``", text) 437 | 438 | 439 | def tryint(x): 440 | try: 441 | return int(x) 442 | except (ValueError, TypeError): 443 | return x 444 | 445 | 446 | def get_top_role(member: discord.Member, hoisted=True): 447 | roles = sorted(member.roles, key=lambda r: r.position, reverse=True) 448 | for role in roles: 449 | if not hoisted: 450 | return role 451 | if role.hoist: 452 | return role 453 | 454 | 455 | async def create_thread_channel(bot, recipient, category, overwrites, *, name=None, errors_raised=None): 456 | name = name or bot.format_channel_name(recipient) 457 | errors_raised = errors_raised or [] 458 | 459 | try: 460 | channel = await bot.modmail_guild.create_text_channel( 461 | name=name, 462 | category=category, 463 | overwrites=overwrites, 464 | topic=f"User ID: {recipient.id}", 465 | reason="Creating a thread channel.", 466 | ) 467 | except discord.HTTPException as e: 468 | if (e.text, (category, name)) in errors_raised: 469 | # Just raise the error to prevent infinite recursion after retrying 470 | raise 471 | 472 | errors_raised.append((e.text, (category, name))) 473 | 474 | if "Maximum number of channels in category reached" in e.text: 475 | fallback = None 476 | fallback_id = bot.config["fallback_category_id"] 477 | if fallback_id: 478 | fallback = discord.utils.get(category.guild.categories, id=int(fallback_id)) 479 | if fallback and len(fallback.channels) >= 49: 480 | fallback = None 481 | 482 | if not fallback: 483 | fallback = await category.clone(name="Fallback Modmail") 484 | await bot.config.set("fallback_category_id", str(fallback.id)) 485 | await bot.config.update() 486 | 487 | return await create_thread_channel( 488 | bot, recipient, fallback, overwrites, errors_raised=errors_raised 489 | ) 490 | 491 | if "Contains words not allowed" in e.text: 492 | # try again but null-discrim (name could be banned) 493 | return await create_thread_channel( 494 | bot, 495 | recipient, 496 | category, 497 | overwrites, 498 | name=bot.format_channel_name(recipient, force_null=True), 499 | errors_raised=errors_raised, 500 | ) 501 | 502 | raise 503 | 504 | return channel 505 | 506 | 507 | def get_joint_id(message: discord.Message) -> typing.Optional[int]: 508 | """ 509 | Get the joint ID from `discord.Embed().author.url`. 510 | Parameters 511 | ----------- 512 | message : discord.Message 513 | The discord.Message object. 514 | Returns 515 | ------- 516 | int 517 | The joint ID if found. Otherwise, None. 518 | """ 519 | if message.embeds: 520 | try: 521 | url = getattr(message.embeds[0].author, "url", "") 522 | if url: 523 | return int(url.split("#")[-1]) 524 | except ValueError: 525 | pass 526 | return None 527 | 528 | 529 | def extract_block_timestamp(reason, id_): 530 | # etc "blah blah blah... until <t:XX:f>." 531 | now = discord.utils.utcnow() 532 | end_time = re.search(r"until <t:(\d+):(?:R|f)>.$", reason) 533 | attempts = [ 534 | # backwards compat 535 | re.search(r"until ([^`]+?)\.$", reason), 536 | re.search(r"%([^%]+?)%", reason), 537 | ] 538 | after = None 539 | if end_time is None: 540 | for i in attempts: 541 | if i is not None: 542 | end_time = i 543 | break 544 | 545 | if end_time is not None: 546 | # found a deprecated version 547 | try: 548 | after = ( 549 | datetime.fromisoformat(end_time.group(1)).replace(tzinfo=timezone.utc) - now 550 | ).total_seconds() 551 | except ValueError: 552 | logger.warning( 553 | r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", 554 | id_, 555 | ) 556 | raise 557 | logger.warning( 558 | r"Deprecated time message for user %s, block and unblock again to update.", 559 | id_, 560 | ) 561 | else: 562 | try: 563 | after = ( 564 | datetime.utcfromtimestamp(int(end_time.group(1))).replace(tzinfo=timezone.utc) - now 565 | ).total_seconds() 566 | except ValueError: 567 | logger.warning( 568 | r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", 569 | id_, 570 | ) 571 | raise 572 | 573 | return end_time, after 574 | 575 | 576 | class AcceptButton(discord.ui.Button): 577 | def __init__(self, emoji): 578 | super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) 579 | 580 | async def callback(self, interaction: discord.Interaction): 581 | self.view.value = True 582 | await interaction.response.edit_message(view=None) 583 | self.view.stop() 584 | 585 | 586 | class DenyButton(discord.ui.Button): 587 | def __init__(self, emoji): 588 | super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) 589 | 590 | async def callback(self, interaction: discord.Interaction): 591 | self.view.value = False 592 | await interaction.response.edit_message(view=None) 593 | self.view.stop() 594 | 595 | 596 | class ConfirmThreadCreationView(discord.ui.View): 597 | def __init__(self): 598 | super().__init__(timeout=20) 599 | self.value = None 600 | 601 | 602 | class DummyParam: 603 | """ 604 | A dummy parameter that can be used for MissingRequiredArgument. 605 | """ 606 | 607 | def __init__(self, name): 608 | self.name = name 609 | self.displayed_name = name 610 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | bot: 4 | image: ghcr.io/modmail-dev/modmail:master 5 | restart: always 6 | env_file: 7 | - .env 8 | environment: 9 | - CONNECTION_URI=mongodb://mongo 10 | depends_on: 11 | - mongo 12 | logviewer: 13 | image: ghcr.io/modmail-dev/logviewer:master 14 | restart: always 15 | depends_on: 16 | - mongo 17 | environment: 18 | - MONGO_URI=mongodb://mongo 19 | ports: 20 | - 80:8000 21 | mongo: 22 | image: mongo 23 | restart: always 24 | volumes: 25 | - mongodb:/data/db 26 | 27 | volumes: 28 | mongodb: 29 | -------------------------------------------------------------------------------- /modmail.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pipenv run python3 bot.py -------------------------------------------------------------------------------- /plugins/@local/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /plugins/registry.json: -------------------------------------------------------------------------------- 1 | { 2 | "advanced-menu": { 3 | "repository": "sebkuip/mm-plugins", 4 | "branch": "master", 5 | "description": "Advanced menu plugin using dropdown selectors. Supports submenus (and sub-submenus infinitely).", 6 | "bot_version": "v4.0.0", 7 | "title": "Advanced menu", 8 | "icon_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png", 9 | "thumbnail_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png" 10 | }, 11 | "announcement": { 12 | "repository": "Jerrie-Aries/modmail-plugins", 13 | "branch": "master", 14 | "description": "Create and post announcements. Supports both plain and embed. Also customisable using buttons and dropdown menus.", 15 | "bot_version": "4.0.0", 16 | "title": "Announcement", 17 | "icon_url": "https://github.com/Jerrie-Aries.png", 18 | "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/announcement.jpg" 19 | }, 20 | "autoreact": { 21 | "repository": "martinbndr/kyb3r-modmail-plugins", 22 | "branch": "master", 23 | "description": "Automatically reacts with emojis in certain channels.", 24 | "bot_version": "4.0.0", 25 | "title": "Autoreact", 26 | "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png", 27 | "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png" 28 | }, 29 | "giveaway": { 30 | "repository": "Jerrie-Aries/modmail-plugins", 31 | "branch": "master", 32 | "description": "Host giveaways on your server with this plugin.", 33 | "bot_version": "4.0.0", 34 | "title": "Giveaway", 35 | "icon_url": "https://github.com/Jerrie-Aries.png", 36 | "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/giveaway.jpg" 37 | }, 38 | "suggest": { 39 | "repository": "realcyguy/modmail-plugins", 40 | "branch": "v4", 41 | "description": "Send suggestions to a selected server! It has accepting, denying, and moderation-ing.", 42 | "bot_version": "4.0.0", 43 | "title": "Suggest stuff.", 44 | "icon_url": "https://i.imgur.com/qtE7AH8.png", 45 | "thumbnail_url": "https://i.imgur.com/qtE7AH8.png" 46 | }, 47 | "reminder": { 48 | "repository": "martinbndr/kyb3r-modmail-plugins", 49 | "branch": "master", 50 | "description": "Let´s you create reminders.", 51 | "bot_version": "4.0.0", 52 | "title": "Reminder", 53 | "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png", 54 | "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png" 55 | }, 56 | "welcomer": { 57 | "repository": "fourjr/modmail-plugins", 58 | "branch": "v4", 59 | "description": "Add messages to welcome new members! Allows for embedded messages as well. [Read more](https://github.com/fourjr/modmail-plugins/blob/master/welcomer/README.md)", 60 | "bot_version": "4.0.0", 61 | "title": "New member messages plugin", 62 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 63 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 64 | }, 65 | "countdowns": { 66 | "repository": "fourjr/modmail-plugins", 67 | "branch": "v4", 68 | "description": "Setup a countdown voice channel in your server!", 69 | "bot_version": "4.0.0", 70 | "title": "Countdowns", 71 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 72 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 73 | }, 74 | "claim": { 75 | "repository": "fourjr/modmail-plugins", 76 | "branch": "v4", 77 | "description": "Allows supporters to claim thread by sending ?claim in the thread channel", 78 | "bot_version": "4.0.0", 79 | "title": "Claim Thread", 80 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 81 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 82 | }, 83 | "emote-manager": { 84 | "repository": "fourjr/modmail-plugins", 85 | "branch": "v4", 86 | "description": "Allows managing server emotes via ?emoji", 87 | "bot_version": "4.0.0", 88 | "title": "Emote Manager", 89 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 90 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 91 | }, 92 | "gen-log": { 93 | "repository": "fourjr/modmail-plugins", 94 | "branch": "v4", 95 | "description": "Outputs a text log of a thread in a specified channel", 96 | "bot_version": "4.0.0", 97 | "title": "Log Generator", 98 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 99 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 100 | }, 101 | "media-logger": { 102 | "repository": "fourjr/modmail-plugins", 103 | "branch": "v4", 104 | "description": "Re-posts detected media from all visible channels into a specified logging channel", 105 | "bot_version": "4.0.0", 106 | "title": "Media Logger", 107 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 108 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 109 | }, 110 | "report": { 111 | "repository": "fourjr/modmail-plugins", 112 | "branch": "v4", 113 | "description": "Specify an emoji to react with on messages. Generates a 'report' in specified logging channel upon react.", 114 | "bot_version": "4.0.0", 115 | "title": "Report", 116 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 117 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 118 | }, 119 | "top-supporters": { 120 | "repository": "fourjr/modmail-plugins", 121 | "branch": "v4", 122 | "description": "Gathers and prints the top supporters of handling threads.", 123 | "bot_version": "4.0.0", 124 | "title": "Top Supporters", 125 | "icon_url": "https://i.imgur.com/Mo60CdK.png", 126 | "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" 127 | }, 128 | "rename": { 129 | "repository": "Nicklaus-s/modmail-plugins", 130 | "branch": "master", 131 | "description": "Set a thread channel name.", 132 | "bot_version": "4.0.0", 133 | "title": "Rename", 134 | "icon_url": "https://i.imgur.com/A1auJ95.png", 135 | "thumbnail_url": "https://i.imgur.com/A1auJ95.png" 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = "110" 3 | target-version = ['py310'] 4 | include = '\.pyi?$' 5 | extend-exclude = ''' 6 | ( 7 | /( 8 | \.eggs 9 | | \.git 10 | | \.venv 11 | | venv 12 | | venv2 13 | | _build 14 | | build 15 | | dist 16 | | plugins 17 | | temp 18 | )/ 19 | ) 20 | ''' 21 | 22 | [tool.poetry] 23 | name = 'Modmail' 24 | version = '4.1.2' 25 | description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." 26 | license = 'AGPL-3.0-only' 27 | authors = [ 28 | 'kyb3r <noemail@example.com>', 29 | '4jr <noemail@example.com>', 30 | 'Taki <noemail@example.com>' 31 | ] 32 | readme = 'README.md' 33 | repository = 'https://github.com/modmail-dev/modmail' 34 | homepage = 'https://github.com/modmail-dev/modmail' 35 | keywords = ['discord', 'modmail'] 36 | 37 | [tool.pylint.format] 38 | max-line-length = "110" 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | aiodns==3.1.1 3 | aiohttp==3.9.0; python_version >= '3.8' 4 | aiosignal==1.3.1; python_version >= '3.7' 5 | async-timeout==4.0.3; python_version < '3.11' 6 | attrs==23.1.0; python_version >= '3.7' 7 | brotli==1.1.0 8 | cairocffi==1.6.1; python_version >= '3.7' 9 | cairosvg==2.7.1; python_version >= '3.5' 10 | certifi==2023.11.17; python_version >= '3.6' 11 | cffi==1.16.0; python_version >= '3.8' 12 | charset-normalizer==3.3.2; python_full_version >= '3.7.0' 13 | colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6' 14 | cssselect2==0.7.0; python_version >= '3.7' 15 | defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 16 | discord.py[speed]==2.3.2; python_full_version >= '3.8.0' 17 | dnspython==2.4.2; python_version >= '3.8' and python_version < '4.0' 18 | emoji==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 19 | frozenlist==1.4.0; python_version >= '3.8' 20 | idna==3.4; python_version >= '3.5' 21 | isodate==0.6.1 22 | lottie[pdf]==0.7.0; python_version >= '3' 23 | motor==3.3.2; python_version >= '3.7' 24 | multidict==6.0.4; python_version >= '3.7' 25 | natural==0.2.0 26 | orjson==3.9.10 27 | packaging==23.2; python_version >= '3.7' 28 | parsedatetime==2.6 29 | pillow==10.1.0; python_version >= '3.8' 30 | pycares==4.4.0; python_version >= '3.8' 31 | pycparser==2.21 32 | pymongo[srv]==4.6.0; python_version >= '3.7' 33 | python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 34 | python-dotenv==1.0.0; python_version >= '3.8' 35 | requests==2.31.0; python_version >= '3.7' 36 | six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 37 | tinycss2==1.2.1; python_version >= '3.7' 38 | urllib3==2.1.0; python_version >= '3.8' 39 | uvloop==0.19.0; sys_platform != 'win32' 40 | webencodings==0.5.1 41 | yarl==1.9.3; python_version >= '3.7' 42 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.10.7 2 | --------------------------------------------------------------------------------