├── .gitattributes ├── runtime.txt ├── Procfile ├── plugins ├── @local │ └── .gitignore └── registry.json ├── .github ├── FUNDING.yml ├── pull.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── lints.yml │ └── docker-image.yml ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── modmail.sh ├── .env.example ├── docker-compose.yml ├── pyproject.toml ├── Pipfile ├── Dockerfile ├── .gitignore ├── app.json ├── requirements.txt ├── .dockerignore ├── core ├── checks.py ├── changelog.py ├── paginator.py ├── time.py ├── models.py ├── config.py ├── utils.py └── clients.py ├── PRIVACY.md ├── SPONSORS.json ├── README.md └── .bandit_baseline.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.10.7 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: python bot.py 2 | -------------------------------------------------------------------------------- /plugins/@local/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: modmaildev 2 | -------------------------------------------------------------------------------- /modmail.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pipenv run python3 bot.py -------------------------------------------------------------------------------- /.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 | DISABLE_AUTOUPDATES=true -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.2.1' 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 ', 29 | '4jr ', 30 | 'Taki ' 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | tomli = "==2.2.1" # Needed for black on Python < 3.11 11 | 12 | [packages] 13 | aiohttp = "==3.13.2" 14 | async-timeout = {version = "==5.0.1", markers = "python_version < '3.11'"} # Required by aiohttp 15 | typing-extensions = ">=4.12.2" # Required by aiohttp 16 | colorama = "==0.4.6" 17 | "discord.py" = {version = "==2.6.3", extras = ["speed"]} 18 | emoji = "==2.8.0" 19 | isodate = "==0.6.1" 20 | motor = "==3.7.1" 21 | natural = "==0.2.0" # Why is this needed? 22 | packaging = "==23.2" 23 | parsedatetime = "==2.6" 24 | dnspython = ">=2.8,<3" # Required by pymongo 25 | pymongo = ">=4.9,<5" # Required by motor 26 | python-dateutil = "==2.8.2" 27 | python-dotenv = "==1.0.0" 28 | uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} 29 | lottie = {version = "==0.7.2", extras = ["pdf"]} 30 | setuptools = "*" # Needed for lottie 31 | requests = "==2.31.0" 32 | 33 | [scripts] 34 | bot = "python bot.py" 35 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### Modmail 2 | # Plugins 3 | plugins/* 4 | !plugins/registry.json 5 | !plugins/@local 6 | 7 | # Config files 8 | config.json 9 | .env 10 | 11 | # Data 12 | temp/ 13 | 14 | ### Python 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # Installer logs 21 | pip-log.txt 22 | pip-delete-this-directory.txt 23 | 24 | # pyenv 25 | .python-version 26 | 27 | # Environments 28 | .env 29 | .venv 30 | env/ 31 | venv/ 32 | venv2/ 33 | ENV/ 34 | env.bak/ 35 | venv.bak/ 36 | 37 | #### PyCharm 38 | .idea/ 39 | 40 | #### VS Code 41 | .vscode/ 42 | 43 | ### Windows 44 | # Windows thumbnail cache files 45 | Thumbs.db 46 | Thumbs.db:encryptable 47 | ehthumbs.db 48 | ehthumbs_vista.db 49 | 50 | # Dump file 51 | *.stackdump 52 | 53 | # Folder config file 54 | [Dd]esktop.ini 55 | 56 | # Recycle Bin used on file shares 57 | $RECYCLE.BIN/ 58 | 59 | # Windows Installer files 60 | *.cab 61 | *.msi 62 | *.msix 63 | *.msm 64 | *.msp 65 | 66 | # Windows shortcuts 67 | *.lnk 68 | 69 | ### macOS 70 | # General 71 | .DS_Store 72 | .AppleDouble 73 | .LSOverride 74 | 75 | # Icon must end with two \r 76 | Icon 77 | 78 | # Thumbnails 79 | ._* 80 | 81 | # Files that might appear in the root of a volume 82 | .DocumentRevisions-V100 83 | .fseventsd 84 | .Spotlight-V100 85 | .TemporaryItems 86 | .Trashes 87 | .VolumeIcon.icns 88 | .com.apple.timemachine.donotpresent 89 | 90 | # Directories potentially created on remote AFP share 91 | .AppleDB 92 | .AppleDesktop 93 | Network Trash Folder 94 | Temporary Items 95 | .apdisk 96 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/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 | - Buy Me A Coffee / 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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | aiodns==3.5.0; python_version >= '3.9' 3 | aiohappyeyeballs==2.6.1; python_version >= '3.9' 4 | aiohttp==3.13.2; python_version >= '3.9' 5 | aiosignal==1.4.0; python_version >= '3.9' 6 | async-timeout==5.0.1; python_version < '3.11' 7 | attrs==25.4.0; python_version >= '3.9' 8 | audioop-lts==0.2.2; python_version >= '3.13' 9 | brotli==1.2.0 10 | cairocffi==1.7.1; python_version >= '3.8' 11 | cairosvg==2.8.2; python_version >= '3.9' 12 | certifi==2025.10.5; python_version >= '3.7' 13 | cffi==2.0.0; python_version >= '3.9' 14 | charset-normalizer==3.4.4; python_version >= '3.7' 15 | 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' 16 | cssselect2==0.8.0; python_version >= '3.9' 17 | defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 18 | discord.py[speed]==2.6.3; python_version >= '3.8' 19 | dnspython==2.8.0; python_version >= '3.10' 20 | emoji==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 21 | frozenlist==1.8.0; python_version >= '3.9' 22 | idna==3.11; python_version >= '3.8' 23 | isodate==0.6.1 24 | lottie[pdf]==0.7.2; python_version >= '3' 25 | motor==3.7.1; python_version >= '3.9' 26 | multidict==6.7.0; python_version >= '3.9' 27 | natural==0.2.0 28 | orjson==3.11.4; python_version >= '3.9' 29 | packaging==23.2; python_version >= '3.7' 30 | parsedatetime==2.6 31 | pillow==12.0.0; python_version >= '3.10' 32 | propcache==0.4.1; python_version >= '3.9' 33 | pycares==4.11.0; python_version >= '3.9' 34 | pycparser==2.23; python_version >= '3.8' 35 | pymongo==4.15.3; python_version >= '3.9' 36 | python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' 37 | python-dotenv==1.0.0; python_version >= '3.8' 38 | requests==2.31.0; python_version >= '3.7' 39 | setuptools==80.9.0; python_version >= '3.9' 40 | six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' 41 | tinycss2==1.4.0; python_version >= '3.8' 42 | typing-extensions==4.15.0; python_version >= '3.9' 43 | urllib3==2.5.0; python_version >= '3.9' 44 | uvloop==0.22.1; sys_platform != 'win32' 45 | webencodings==0.5.1 46 | yarl==1.22.0; python_version >= '3.9' 47 | zstandard==0.25.0; python_version >= '3.9' 48 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 if self.bot.user.display_avatar else None, 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 if self.bot.user.display_avatar else None) 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "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)**", 118 | "author": { 119 | "name": "New York Roleplay", 120 | "icon_url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" 121 | }, 122 | "color": 431075, 123 | "thumbnail": { 124 | "url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" 125 | } 126 | } 127 | }, 128 | { 129 | "embed": { 130 | "title": "CityStore PLC", 131 | "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! 🎉", 132 | "color": 15523550 133 | } 134 | }, 135 | { 136 | "embed": { 137 | "description": "✨ *\"Let's bake it!\"* ✨ \n\nKistó is a very successful and well-known **Bakery Group** on the platform. Its goal is to give every guest the **ultimate bakery experience**. \n\nWe have a wide variety of hand-made treats, from our rich **drinks** to our freshly baked **pastries**. Every item is made with care by our **skilled and passionate team**, redefining what it means to be a modern bakery. \n\n💖 Come visit us today and taste the *sweet side of perfection* at **Kistó Bakery.** \n\n**Roblox Group:** [Click here!](https://www.roblox.com/communities/9318596/Kist#!/about) \n**Discord Server:** [Click here!](https://discord.gg/aGt8Wv3gP9)", 138 | "color": 16736255, 139 | "author": { 140 | "name": "Kistó Bakery", 141 | "icon_url": "https://cdn.discordapp.com/attachments/1413609998797242522/1436882475883298887/noFilter_10_1.png" 142 | }, 143 | "footer": { 144 | "text": "Proudly serving since 2021" 145 | }, 146 | "image": { 147 | "url": "https://cdn.discordapp.com/attachments/1413609998797242522/1436882475543429260/0904.png" 148 | } 149 | } 150 | } 151 | ] 152 | -------------------------------------------------------------------------------- /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 | Buy Me A Coffee 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 **[Buy Me A Coffee](https://buymeacoffee.com/modmaildev)**, 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 feature](https://buymeacoffee.com/modmaildev/membership)). 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 | ### Paid 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 [**Buy Me A Coffee**](https://buymeacoffee.com/modmaildev/membership). 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 [**Buy Me A Coffee**](https://buymeacoffee.com/modmaildev/membership). 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 | Discord Advice Center: 151 |
152 | 153 | 154 | 155 |
156 |
157 | Kistó Bakery: 158 |
159 | 160 | 161 | 162 | 163 | Become a sponsor on [Buy Me A Coffee](https://buymeacoffee.com/modmaildev/membership). 164 | 165 | ## Contributing 166 | 167 | 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. 168 | 169 | If you like this project and would like to show your appreciation, support us on **[Buy Me A Coffee](https://buymeacoffee.com/modmaildev)**! 170 | 171 | ## Beta Testing 172 | 173 | 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. 174 | 175 | 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). 176 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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) -> None: 153 | """ 154 | Starts the pagination session. 155 | """ 156 | if not self.running: 157 | await self.show_page(self.current) 158 | 159 | # Don't block command execution while waiting for the View timeout. 160 | # Schedule the wait-and-close sequence in the background so the command 161 | # returns immediately (prevents typing indicator from hanging). 162 | if self.view is not None: 163 | 164 | async def _wait_and_close(): 165 | try: 166 | await self.view.wait() 167 | finally: 168 | await self.close(delete=False) 169 | 170 | # Fire and forget 171 | self.ctx.bot.loop.create_task(_wait_and_close()) 172 | else: 173 | await self.close(delete=False) 174 | 175 | async def close( 176 | self, delete: bool = True, *, interaction: Interaction = None 177 | ) -> typing.Optional[Message]: 178 | """ 179 | Closes the pagination session. 180 | 181 | Parameters 182 | ---------- 183 | delete : bool, optional 184 | Whether or delete the message upon closure. 185 | Defaults to `True`. 186 | 187 | Returns 188 | ------- 189 | Optional[Message] 190 | If `delete` is `True`. 191 | """ 192 | if self.running: 193 | sent_emoji, _ = await self.ctx.bot.retrieve_emoji() 194 | await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) 195 | 196 | if interaction: 197 | message = interaction.message 198 | else: 199 | message = self.base 200 | 201 | self.running = False 202 | 203 | if self.view is not None: 204 | self.view.stop() 205 | if delete: 206 | await message.delete() 207 | else: 208 | self.view.clear_items() 209 | await message.edit(view=self.view) 210 | 211 | 212 | class PaginatorView(View): 213 | """ 214 | View that is used for pagination. 215 | 216 | Parameters 217 | ---------- 218 | handler : PaginatorSession 219 | The paginator session that spawned this view. 220 | timeout : float 221 | How long to wait for before the session closes. 222 | 223 | Attributes 224 | ---------- 225 | handler : PaginatorSession 226 | The paginator session that spawned this view. 227 | timeout : float 228 | How long to wait for before the session closes. 229 | """ 230 | 231 | def __init__(self, handler: PaginatorSession, *args, **kwargs): 232 | super().__init__(*args, **kwargs) 233 | self.handler = handler 234 | self.clear_items() # clear first so we can control the order 235 | self.fill_items() 236 | 237 | async def stop_callback(self, interaction: Interaction): 238 | await self.handler.close(interaction=interaction) 239 | 240 | def fill_items(self): 241 | if self.handler.select_menu is not None: 242 | self.add_item(self.handler.select_menu) 243 | 244 | for label, callback in self.handler.callback_map.items(): 245 | if len(self.handler.pages) == 2 and label in ("<<", ">>"): 246 | continue 247 | 248 | if label in ("<<", ">>"): 249 | style = ButtonStyle.secondary 250 | else: 251 | style = ButtonStyle.primary 252 | 253 | button = PageButton(self.handler, callback, label=label, style=style) 254 | 255 | self.handler._buttons_map[label] = button 256 | self.add_item(button) 257 | 258 | stop_button = Button(label="Stop", style=ButtonStyle.danger) 259 | stop_button.callback = self.stop_callback 260 | self.add_item(stop_button) 261 | 262 | async def interaction_check(self, interaction: Interaction): 263 | """Only allow the message author to interact""" 264 | if interaction.user != self.handler.ctx.author: 265 | await interaction.response.send_message( 266 | "Only the original author can control this!", ephemeral=True 267 | ) 268 | return False 269 | return True 270 | 271 | 272 | class PageButton(Button): 273 | """ 274 | A button that has a callback to jump to the next page 275 | 276 | Parameters 277 | ---------- 278 | handler : PaginatorSession 279 | The paginator session that spawned this view. 280 | page_callback : Callable 281 | A callable that returns an int of the page to go to. 282 | 283 | Attributes 284 | ---------- 285 | handler : PaginatorSession 286 | The paginator session that spawned this view. 287 | page_callback : Callable 288 | A callable that returns an int of the page to go to. 289 | """ 290 | 291 | def __init__(self, handler, page_callback, **kwargs): 292 | super().__init__(**kwargs) 293 | self.handler = handler 294 | self.page_callback = page_callback 295 | 296 | async def callback(self, interaction: Interaction): 297 | kwargs = await self.handler.show_page(self.page_callback()) 298 | await interaction.response.edit_message(**kwargs, view=self.view) 299 | 300 | 301 | class PageSelect(Select): 302 | def __init__(self, handler: PaginatorSession, pages: typing.List[typing.Tuple[str]]): 303 | self.handler = handler 304 | options = [] 305 | for n, (label, description) in enumerate(pages): 306 | options.append(discord.SelectOption(label=label, description=description, value=str(n))) 307 | 308 | options = options[:25] # max 25 options 309 | super().__init__(placeholder="Select a page", min_values=1, max_values=1, options=options) 310 | 311 | async def callback(self, interaction: Interaction): 312 | page = int(self.values[0]) 313 | kwargs = await self.handler.show_page(page) 314 | await interaction.response.edit_message(**kwargs, view=self.view) 315 | 316 | 317 | class EmbedPaginatorSession(PaginatorSession): 318 | def __init__(self, ctx: commands.Context, *embeds, **options): 319 | super().__init__(ctx, *embeds, **options) 320 | 321 | if len(self.pages) > 1: 322 | select_options = [] 323 | create_select = True 324 | for i, embed in enumerate(self.pages): 325 | footer_text = f"Page {i + 1} of {len(self.pages)}" 326 | if embed.footer.text: 327 | footer_text = footer_text + " • " + embed.footer.text 328 | 329 | if embed.footer.icon: 330 | icon_url = embed.footer.icon.url if embed.footer.icon else None 331 | else: 332 | icon_url = None 333 | embed.set_footer(text=footer_text, icon_url=icon_url) 334 | 335 | # select menu 336 | if embed.author.name: 337 | title = embed.author.name[:30].strip() 338 | if len(embed.author.name) > 30: 339 | title += "..." 340 | else: 341 | title = embed.title[:30].strip() 342 | if len(embed.title) > 30: 343 | title += "..." 344 | if not title: 345 | create_select = False 346 | 347 | if embed.description: 348 | description = embed.description[:40].replace("*", "").replace("`", "").strip() 349 | if len(embed.description) > 40: 350 | description += "..." 351 | else: 352 | description = "" 353 | select_options.append((title, description)) 354 | 355 | if create_select: 356 | if len(set(x[0] for x in select_options)) != 1: # must have unique authors 357 | self.select_menu = PageSelect(self, select_options) 358 | 359 | def add_page(self, item: Embed) -> None: 360 | if isinstance(item, Embed): 361 | self.pages.append(item) 362 | else: 363 | raise TypeError("Page must be an Embed object.") 364 | 365 | async def _create_base(self, item: Embed, view: View) -> None: 366 | self.base = await self.destination.send(embed=item, view=view) 367 | 368 | def _show_page(self, page): 369 | return dict(embed=page) 370 | 371 | 372 | class MessagePaginatorSession(PaginatorSession): 373 | def __init__(self, ctx: commands.Context, *messages, embed: Embed = None, **options): 374 | self.embed = embed 375 | self.footer_text = self.embed.footer.text if embed is not None else None 376 | super().__init__(ctx, *messages, **options) 377 | 378 | def add_page(self, item: str) -> None: 379 | if isinstance(item, str): 380 | self.pages.append(item) 381 | else: 382 | raise TypeError("Page must be a str object.") 383 | 384 | def _set_footer(self): 385 | if self.embed is not None: 386 | footer_text = f"Page {self.current + 1} of {len(self.pages)}" 387 | if self.footer_text: 388 | footer_text = footer_text + " • " + self.footer_text 389 | 390 | if self.embed.footer.icon: 391 | icon_url = self.embed.footer.icon.url if self.embed.footer.icon else None 392 | else: 393 | icon_url = None 394 | 395 | self.embed.set_footer(text=footer_text, icon_url=icon_url) 396 | 397 | async def _create_base(self, item: str, view: View) -> None: 398 | self._set_footer() 399 | self.base = await self.ctx.send(content=item, embed=self.embed, view=view) 400 | 401 | def _show_page(self, page) -> typing.Dict: 402 | self._set_footer() 403 | return dict(content=page, embed=self.embed) 404 | -------------------------------------------------------------------------------- /core/time.py: -------------------------------------------------------------------------------- 1 | """ 2 | UserFriendlyTime by Rapptz 3 | Source: 4 | https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/time.py 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import datetime 10 | import discord 11 | from typing import TYPE_CHECKING, Any, Optional, Union 12 | import parsedatetime as pdt 13 | from dateutil.relativedelta import relativedelta 14 | from .utils import human_join 15 | from discord.ext import commands 16 | from discord import app_commands 17 | import re 18 | 19 | # Monkey patch mins and secs into the units 20 | units = pdt.pdtLocales["en_US"].units 21 | units["minutes"].append("mins") 22 | units["seconds"].append("secs") 23 | 24 | if TYPE_CHECKING: 25 | from discord.ext.commands import Context 26 | from typing_extensions import Self 27 | 28 | 29 | class plural: 30 | """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L8-L18""" 31 | 32 | def __init__(self, value: int): 33 | self.value: int = value 34 | 35 | def __format__(self, format_spec: str) -> str: 36 | v = self.value 37 | singular, sep, plural = format_spec.partition("|") 38 | plural = plural or f"{singular}s" 39 | if abs(v) != 1: 40 | return f"{v} {plural}" 41 | return f"{v} {singular}" 42 | 43 | 44 | class ShortTime: 45 | compiled = re.compile( 46 | """ 47 | (?:(?P[0-9])(?:years?|y))? # e.g. 2y 48 | (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months 49 | (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w 50 | (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d 51 | (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h 52 | (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m 53 | (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s 54 | """, 55 | re.VERBOSE, 56 | ) 57 | 58 | discord_fmt = re.compile(r"[0-9]+)(?:\:?[RFfDdTt])?>") 59 | 60 | dt: datetime.datetime 61 | 62 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 63 | match = self.compiled.fullmatch(argument) 64 | if match is None or not match.group(0): 65 | match = self.discord_fmt.fullmatch(argument) 66 | if match is not None: 67 | self.dt = datetime.datetime.utcfromtimestamp(int(match.group("ts")), tz=datetime.timezone.utc) 68 | return 69 | else: 70 | raise commands.BadArgument("invalid time provided") 71 | 72 | data = {k: int(v) for k, v in match.groupdict(default=0).items()} 73 | now = now or datetime.datetime.now(datetime.timezone.utc) 74 | self.dt = now + relativedelta(**data) 75 | 76 | @classmethod 77 | async def convert(cls, ctx: Context, argument: str) -> Self: 78 | return cls(argument, now=ctx.message.created_at) 79 | 80 | 81 | class HumanTime: 82 | calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) 83 | 84 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 85 | now = now or datetime.datetime.utcnow() 86 | dt, status = self.calendar.parseDT(argument, sourceTime=now) 87 | if not status.hasDateOrTime: 88 | raise commands.BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') 89 | 90 | if not status.hasTime: 91 | # replace it with the current time 92 | dt = dt.replace( 93 | hour=now.hour, 94 | minute=now.minute, 95 | second=now.second, 96 | microsecond=now.microsecond, 97 | ) 98 | 99 | self.dt: datetime.datetime = dt 100 | self._past: bool = dt < now 101 | 102 | @classmethod 103 | async def convert(cls, ctx: Context, argument: str) -> Self: 104 | return cls(argument, now=ctx.message.created_at) 105 | 106 | 107 | class Time(HumanTime): 108 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 109 | try: 110 | o = ShortTime(argument, now=now) 111 | except Exception: 112 | super().__init__(argument) 113 | else: 114 | self.dt = o.dt 115 | self._past = False 116 | 117 | 118 | class FutureTime(Time): 119 | def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): 120 | super().__init__(argument, now=now) 121 | 122 | if self._past: 123 | raise commands.BadArgument("this time is in the past") 124 | 125 | 126 | class BadTimeTransform(app_commands.AppCommandError): 127 | pass 128 | 129 | 130 | class TimeTransformer(app_commands.Transformer): 131 | async def transform(self, interaction, value: str) -> datetime.datetime: 132 | now = interaction.created_at 133 | try: 134 | short = ShortTime(value, now=now) 135 | except commands.BadArgument: 136 | try: 137 | human = FutureTime(value, now=now) 138 | except commands.BadArgument as e: 139 | raise BadTimeTransform(str(e)) from None 140 | else: 141 | return human.dt 142 | else: 143 | return short.dt 144 | 145 | 146 | # CHANGE: Added now 147 | class FriendlyTimeResult: 148 | dt: datetime.datetime 149 | now: datetime.datetime 150 | arg: str 151 | 152 | __slots__ = ("dt", "arg", "now") 153 | 154 | def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): 155 | self.dt = dt 156 | self.now = now 157 | 158 | if now is None: 159 | self.now = dt 160 | else: 161 | self.now = now 162 | 163 | self.arg = "" 164 | 165 | async def ensure_constraints( 166 | self, 167 | ctx: Context, 168 | uft: UserFriendlyTime, 169 | now: datetime.datetime, 170 | remaining: str, 171 | ) -> None: 172 | # Strip stray connector words like "in", "to", or "at" that may 173 | # remain when the natural language parser isolates the time token 174 | # positioned at the end (e.g. "in 10m" leaves "in" before the token). 175 | if isinstance(remaining, str): 176 | cleaned = remaining.strip(" ,.!") 177 | stray_tokens = { 178 | "in", 179 | "to", 180 | "at", 181 | "me", 182 | # also treat vague times of day as stray tokens when they are the only leftover word 183 | "evening", 184 | "night", 185 | "midnight", 186 | "morning", 187 | "afternoon", 188 | "tonight", 189 | "noon", 190 | "today", 191 | "tomorrow", 192 | } 193 | if cleaned.lower() in stray_tokens: 194 | remaining = "" 195 | 196 | if self.dt < now: 197 | raise commands.BadArgument("This time is in the past.") 198 | 199 | # CHANGE 200 | # if not remaining: 201 | # if uft.default is None: 202 | # raise commands.BadArgument("Missing argument after the time.") 203 | # remaining = uft.default 204 | 205 | if uft.converter is not None: 206 | self.arg = await uft.converter.convert(ctx, remaining) 207 | else: 208 | self.arg = remaining 209 | 210 | 211 | class UserFriendlyTime(commands.Converter): 212 | """That way quotes aren't absolutely necessary.""" 213 | 214 | def __init__( 215 | self, 216 | converter: Optional[Union[type[commands.Converter], commands.Converter]] = None, 217 | *, 218 | default: Any = None, 219 | ): 220 | if isinstance(converter, type) and issubclass(converter, commands.Converter): 221 | converter = converter() 222 | 223 | if converter is not None and not isinstance(converter, commands.Converter): 224 | raise TypeError("commands.Converter subclass necessary.") 225 | 226 | self.converter: commands.Converter = converter # type: ignore # It doesn't understand this narrowing 227 | self.default: Any = default 228 | 229 | async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTimeResult: 230 | calendar = HumanTime.calendar 231 | regex = ShortTime.compiled 232 | if now is None: 233 | now = ctx.message.created_at 234 | 235 | # Heuristic: If the user provides only certain single words that are commonly 236 | # used as salutations or vague times of day, interpret them as a message 237 | # rather than a schedule. This avoids accidental scheduling when the intent 238 | # is a short message (e.g. '?close evening'). Explicit scheduling still works 239 | # via 'in 2h', '2m30s', 'at 8pm', etc. 240 | if argument.strip().lower() in { 241 | "evening", 242 | "night", 243 | "midnight", 244 | "morning", 245 | "afternoon", 246 | "tonight", 247 | "noon", 248 | "today", 249 | "tomorrow", 250 | }: 251 | result = FriendlyTimeResult(now) 252 | await result.ensure_constraints(ctx, self, now, argument) 253 | return result 254 | 255 | match = regex.match(argument) 256 | if match is not None and match.group(0): 257 | data = {k: int(v) for k, v in match.groupdict(default=0).items()} 258 | remaining = argument[match.end() :].strip() 259 | result = FriendlyTimeResult(now + relativedelta(**data), now) 260 | await result.ensure_constraints(ctx, self, now, remaining) 261 | return result 262 | 263 | if match is None or not match.group(0): 264 | match = ShortTime.discord_fmt.match(argument) 265 | if match is not None: 266 | result = FriendlyTimeResult( 267 | datetime.datetime.utcfromtimestamp(int(match.group("ts")), now, tz=datetime.timezone.utc) 268 | ) 269 | remaining = argument[match.end() :].strip() 270 | await result.ensure_constraints(ctx, self, now, remaining) 271 | return result 272 | 273 | # apparently nlp does not like "from now" 274 | # it likes "from x" in other cases though so let me handle the 'now' case 275 | if argument.endswith("from now"): 276 | argument = argument[:-8].strip() 277 | 278 | if argument[0:2] == "me": 279 | # starts with "me to", "me in", or "me at " 280 | if argument[0:6] in ("me to ", "me in ", "me at "): 281 | argument = argument[6:] 282 | 283 | elements = calendar.nlp(argument, sourceTime=now) 284 | if elements is None or len(elements) == 0: 285 | # CHANGE 286 | result = FriendlyTimeResult(now) 287 | await result.ensure_constraints(ctx, self, now, argument) 288 | return result 289 | 290 | # handle the following cases: 291 | # "date time" foo 292 | # date time foo 293 | # foo date time 294 | 295 | # first the first two cases: 296 | dt, status, begin, end, dt_string = elements[0] 297 | 298 | if not status.hasDateOrTime: 299 | raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') 300 | 301 | # If the parsed time token is embedded in the text but only followed by 302 | # trailing punctuation/whitespace, treat it as if it's positioned at the end. 303 | trailing = argument[end:].strip(" ,.!") 304 | if begin not in (0, 1) and trailing != "": 305 | raise commands.BadArgument( 306 | "Time is either in an inappropriate location, which " 307 | "must be either at the end or beginning of your input, " 308 | "or I just flat out did not understand what you meant. Sorry." 309 | ) 310 | 311 | if not status.hasTime: 312 | # replace it with the current time 313 | dt = dt.replace( 314 | hour=now.hour, 315 | minute=now.minute, 316 | second=now.second, 317 | microsecond=now.microsecond, 318 | ) 319 | 320 | # if midnight is provided, just default to next day 321 | if status.accuracy == pdt.pdtContext.ACU_HALFDAY: 322 | dt = dt.replace(day=now.day + 1) 323 | 324 | # Heuristic: If the matched time string is a vague time-of-day (e.g., 325 | # 'evening', 'morning', 'afternoon', 'night') and there's additional 326 | # non-punctuation text besides that token, assume the user intended a 327 | # closing message rather than scheduling. This avoids cases like 328 | # '?close Have a good evening!' being treated as a scheduled close. 329 | vague_tod = {"evening", "morning", "afternoon", "night"} 330 | matched_text = dt_string.strip().strip('"').rstrip(" ,.!").lower() 331 | pre_text = argument[:begin].strip(" ,.!") 332 | post_text = argument[end:].strip(" ,.!") 333 | if matched_text in vague_tod and (pre_text or post_text): 334 | result = FriendlyTimeResult(now) 335 | await result.ensure_constraints(ctx, self, now, argument) 336 | return result 337 | 338 | result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) 339 | remaining = "" 340 | 341 | if begin in (0, 1): 342 | if begin == 1: 343 | # check if it's quoted: 344 | if argument[0] != '"': 345 | raise commands.BadArgument("Expected quote before time input...") 346 | 347 | if not (end < len(argument) and argument[end] == '"'): 348 | raise commands.BadArgument("If the time is quoted, you must unquote it.") 349 | 350 | remaining = argument[end + 1 :].lstrip(" ,.!") 351 | else: 352 | remaining = argument[end:].lstrip(" ,.!") 353 | elif len(argument) == end: 354 | remaining = argument[:begin].strip() 355 | 356 | await result.ensure_constraints(ctx, self, now, remaining) 357 | return result 358 | 359 | 360 | def human_timedelta( 361 | dt: datetime.datetime, 362 | *, 363 | source: Optional[datetime.datetime] = None, 364 | accuracy: Optional[int] = 3, 365 | brief: bool = False, 366 | suffix: bool = True, 367 | ) -> str: 368 | now = source or datetime.datetime.now(datetime.timezone.utc) 369 | if dt.tzinfo is None: 370 | dt = dt.replace(tzinfo=datetime.timezone.utc) 371 | 372 | if now.tzinfo is None: 373 | now = now.replace(tzinfo=datetime.timezone.utc) 374 | 375 | # Microsecond free zone 376 | now = now.replace(microsecond=0) 377 | dt = dt.replace(microsecond=0) 378 | 379 | # This implementation uses relativedelta instead of the much more obvious 380 | # divmod approach with seconds because the seconds approach is not entirely 381 | # accurate once you go over 1 week in terms of accuracy since you have to 382 | # hardcode a month as 30 or 31 days. 383 | # A query like "11 months" can be interpreted as "!1 months and 6 days" 384 | if dt > now: 385 | delta = relativedelta(dt, now) 386 | output_suffix = "" 387 | else: 388 | delta = relativedelta(now, dt) 389 | output_suffix = " ago" if suffix else "" 390 | 391 | attrs = [ 392 | ("year", "y"), 393 | ("month", "mo"), 394 | ("day", "d"), 395 | ("hour", "h"), 396 | ("minute", "m"), 397 | ("second", "s"), 398 | ] 399 | 400 | output = [] 401 | for attr, brief_attr in attrs: 402 | elem = getattr(delta, attr + "s") 403 | if not elem: 404 | continue 405 | 406 | if attr == "day": 407 | weeks = delta.weeks 408 | if weeks: 409 | elem -= weeks * 7 410 | if not brief: 411 | output.append(format(plural(weeks), "week")) 412 | else: 413 | output.append(f"{weeks}w") 414 | 415 | if elem <= 0: 416 | continue 417 | 418 | if brief: 419 | output.append(f"{elem}{brief_attr}") 420 | else: 421 | output.append(format(plural(elem), attr)) 422 | 423 | if accuracy is not None: 424 | output = output[:accuracy] 425 | 426 | if len(output) == 0: 427 | return "now" 428 | else: 429 | if not brief: 430 | return human_join(output, final="and") + output_suffix 431 | else: 432 | return " ".join(output) + output_suffix 433 | 434 | 435 | def format_relative(dt: datetime.datetime) -> str: 436 | return discord.utils.format_dt(dt, "R") 437 | -------------------------------------------------------------------------------- /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", 153 | datefmt="%m/%d/%y %H:%M:%S", 154 | ) 155 | 156 | log_file_formatter = FileFormatter( 157 | "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", 158 | datefmt="%Y-%m-%d %H:%M:%S", 159 | ) 160 | 161 | json_formatter = JsonFormatter( 162 | { 163 | "level": "levelname", 164 | "message": "message", 165 | "loggerName": "name", 166 | "processName": "processName", 167 | "processID": "process", 168 | "threadName": "threadName", 169 | "threadID": "thread", 170 | "timestamp": "asctime", 171 | } 172 | ) 173 | 174 | 175 | def create_log_handler( 176 | filename: Optional[str] = None, 177 | *, 178 | rotating: bool = False, 179 | level: int = logging.DEBUG, 180 | mode: str = "a+", 181 | encoding: str = "utf-8", 182 | format: str = "plain", 183 | maxBytes: int = 28000000, 184 | backupCount: int = 1, 185 | **kwargs, 186 | ) -> Handler: 187 | """ 188 | Creates a pre-configured log handler. This function is made for consistency's sake with 189 | pre-defined default values for parameters and formatters to pass to handler class. 190 | Additional keyword arguments also can be specified, just in case. 191 | 192 | Plugin developers should not use this and use `models.getLogger` instead. 193 | 194 | Parameters 195 | ---------- 196 | filename : Optional[Path] 197 | Specifies that a `FileHandler` or `RotatingFileHandler` be created, using the specified filename, 198 | rather than a `StreamHandler`. Defaults to `None`. 199 | rotating : bool 200 | Whether the file handler should be the `RotatingFileHandler`. Defaults to `False`. Note, this 201 | argument only compatible if the `filename` is specified, otherwise `ValueError` will be raised. 202 | level : int 203 | The root logger level for the handler. Defaults to `logging.DEBUG`. 204 | mode : str 205 | If filename is specified, open the file in this mode. Defaults to 'a+'. 206 | encoding : str 207 | If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created, 208 | and thus used when opening the output file. Defaults to 'utf-8'. 209 | format : str 210 | The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created, 211 | based on other conditional logic. 212 | maxBytes : int 213 | The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current 214 | log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero, 215 | rollover never occurs, so you generally want to set `backupCount` to at least 1. 216 | backupCount : int 217 | Max number of backup files. Defaults to 1. If this is set to zero, rollover will never occur. 218 | 219 | Returns 220 | ------- 221 | `StreamHandler` when `filename` is `None`, otherwise `FileHandler` or `RotatingFileHandler` 222 | depending on the `rotating` value. 223 | """ 224 | if filename is None and rotating: 225 | raise ValueError("`filename` must be set to instantiate a `RotatingFileHandler`.") 226 | 227 | if filename is None: 228 | handler = StreamHandler(stream=sys.stdout, **kwargs) 229 | formatter = log_stream_formatter 230 | elif not rotating: 231 | handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs) 232 | formatter = log_file_formatter 233 | else: 234 | handler = RotatingFileHandler( 235 | filename, 236 | mode=mode, 237 | encoding=encoding, 238 | maxBytes=maxBytes, 239 | backupCount=backupCount, 240 | **kwargs, 241 | ) 242 | formatter = log_file_formatter 243 | 244 | if format == "json": 245 | formatter = json_formatter 246 | 247 | handler.setLevel(level) 248 | handler.setFormatter(formatter) 249 | return handler 250 | 251 | 252 | logging.setLoggerClass(ModmailLogger) 253 | log_level = logging.INFO 254 | loggers = set() 255 | 256 | ch = create_log_handler(level=log_level) 257 | ch_debug: Optional[RotatingFileHandler] = None 258 | 259 | 260 | def getLogger(name=None) -> ModmailLogger: 261 | logger = logging.getLogger(name) 262 | logger.setLevel(log_level) 263 | logger.addHandler(ch) 264 | if ch_debug is not None: 265 | logger.addHandler(ch_debug) 266 | loggers.add(logger) 267 | return logger 268 | 269 | 270 | def configure_logging(bot) -> None: 271 | global ch_debug, log_level, ch 272 | 273 | stream_log_format, file_log_format = ( 274 | bot.config["stream_log_format"], 275 | bot.config["file_log_format"], 276 | ) 277 | if stream_log_format == "json": 278 | ch.setFormatter(json_formatter) 279 | 280 | logger = getLogger(__name__) 281 | level_text = bot.config["log_level"].upper() 282 | logging_levels = { 283 | "CRITICAL": logging.CRITICAL, 284 | "ERROR": logging.ERROR, 285 | "WARNING": logging.WARNING, 286 | "INFO": logging.INFO, 287 | "DEBUG": logging.DEBUG, 288 | } 289 | logger.line() 290 | 291 | level = logging_levels.get(level_text) 292 | if level is None: 293 | level = bot.config.remove("log_level") 294 | logger.warning("Invalid logging level set: %s.", level_text) 295 | logger.warning("Using default logging level: %s.", level) 296 | level = logging_levels[level] 297 | else: 298 | logger.info("Logging level: %s", level_text) 299 | log_level = level 300 | 301 | logger.info("Log file: %s", bot.log_file_path) 302 | ch_debug = create_log_handler(bot.log_file_path, rotating=True) 303 | 304 | if file_log_format == "json": 305 | ch_debug.setFormatter(json_formatter) 306 | 307 | ch.setLevel(log_level) 308 | 309 | logger.info("Stream log format: %s", stream_log_format) 310 | logger.info("File log format: %s", file_log_format) 311 | 312 | for log in loggers: 313 | log.setLevel(log_level) 314 | log.addHandler(ch_debug) 315 | 316 | # Set up discord.py logging 317 | d_level_text = bot.config["discord_log_level"].upper() 318 | d_level = logging_levels.get(d_level_text) 319 | if d_level is None: 320 | d_level = bot.config.remove("discord_log_level") 321 | logger.warning("Invalid discord logging level set: %s.", d_level_text) 322 | logger.warning("Using default discord logging level: %s.", d_level) 323 | d_level = logging_levels[d_level] 324 | d_logger = logging.getLogger("discord") 325 | d_logger.setLevel(d_level) 326 | 327 | non_verbose_log_level = max(d_level, logging.INFO) 328 | stream_handler = create_log_handler(level=non_verbose_log_level) 329 | if non_verbose_log_level != d_level: 330 | logger.info( 331 | "Discord logging level (stdout): %s.", 332 | logging.getLevelName(non_verbose_log_level), 333 | ) 334 | logger.info("Discord logging level (logfile): %s.", logging.getLevelName(d_level)) 335 | else: 336 | logger.info("Discord logging level: %s.", logging.getLevelName(d_level)) 337 | d_logger.addHandler(stream_handler) 338 | d_logger.addHandler(ch_debug) 339 | 340 | logger.debug("Successfully configured logging.") 341 | 342 | 343 | class InvalidConfigError(commands.BadArgument): 344 | def __init__(self, msg, *args): 345 | super().__init__(msg, *args) 346 | self.msg = msg 347 | 348 | @property 349 | def embed(self): 350 | # Single reference of Color.red() 351 | return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) 352 | 353 | 354 | class _Default: 355 | pass 356 | 357 | 358 | Default = _Default() 359 | 360 | 361 | class SafeFormatter(Formatter): 362 | def get_field(self, field_name, args, kwargs): 363 | first, rest = _string.formatter_field_name_split(field_name) 364 | 365 | try: 366 | obj = self.get_value(first, args, kwargs) 367 | except (IndexError, KeyError): 368 | return "", first 369 | 370 | # loop through the rest of the field_name, doing 371 | # getattr or getitem as needed 372 | # stops when reaches the depth of 2 or starts with _. 373 | try: 374 | for n, (is_attr, i) in enumerate(rest): 375 | if n >= 2: 376 | break 377 | if is_attr: 378 | if str(i).startswith("_"): 379 | break 380 | obj = getattr(obj, i) 381 | else: 382 | obj = obj[i] 383 | else: 384 | return obj, first 385 | except (IndexError, KeyError): 386 | pass 387 | return "", first 388 | 389 | 390 | class UnseenFormatter(Formatter): 391 | def get_value(self, key, args, kwds): 392 | if isinstance(key, str): 393 | try: 394 | return kwds[key] 395 | except KeyError: 396 | return "{" + key + "}" 397 | else: 398 | return super().get_value(key, args, kwds) 399 | 400 | 401 | class SimilarCategoryConverter(commands.CategoryChannelConverter): 402 | async def convert(self, ctx, argument): 403 | bot = ctx.bot 404 | guild = ctx.guild 405 | 406 | try: 407 | return await super().convert(ctx, argument) 408 | except commands.ChannelNotFound: 409 | if guild: 410 | categories = {c.name.casefold(): c for c in guild.categories} 411 | else: 412 | categories = { 413 | c.name.casefold(): c 414 | for c in bot.get_all_channels() 415 | if isinstance(c, discord.CategoryChannel) 416 | } 417 | 418 | result = get_close_matches(argument.casefold(), categories.keys(), n=1, cutoff=0.75) 419 | if result: 420 | result = categories[result[0]] 421 | 422 | if not isinstance(result, discord.CategoryChannel): 423 | raise commands.ChannelNotFound(argument) 424 | 425 | return result 426 | 427 | 428 | class DummyMessage: 429 | """ 430 | A class mimicking the original :class:discord.Message 431 | where all functions that require an actual message to exist 432 | is replaced with a dummy function 433 | """ 434 | 435 | def __init__(self, message): 436 | if message: 437 | message.attachments = [] 438 | self._message = message 439 | 440 | def __getattr__(self, name: str): 441 | return getattr(self._message, name) 442 | 443 | def __bool__(self): 444 | return bool(self._message) 445 | 446 | async def delete(self, *, delay=None): 447 | return 448 | 449 | async def edit(self, **fields): 450 | return 451 | 452 | async def add_reaction(self, emoji): 453 | return 454 | 455 | async def remove_reaction(self, emoji): 456 | return 457 | 458 | async def clear_reaction(self, emoji): 459 | return 460 | 461 | async def clear_reactions(self): 462 | return 463 | 464 | async def pin(self, *, reason=None): 465 | return 466 | 467 | async def unpin(self, *, reason=None): 468 | return 469 | 470 | async def publish(self): 471 | return 472 | 473 | async def ack(self): 474 | return 475 | 476 | 477 | class PermissionLevel(IntEnum): 478 | OWNER = 5 479 | ADMINISTRATOR = 4 480 | ADMIN = 4 481 | MODERATOR = 3 482 | MOD = 3 483 | SUPPORTER = 2 484 | RESPONDER = 2 485 | REGULAR = 1 486 | INVALID = -1 487 | 488 | 489 | class DMDisabled(IntEnum): 490 | NONE = 0 491 | NEW_THREADS = 1 492 | ALL_THREADS = 2 493 | 494 | 495 | class HostingMethod(IntEnum): 496 | HEROKU = 0 497 | PM2 = 1 498 | SYSTEMD = 2 499 | SCREEN = 3 500 | DOCKER = 4 501 | OTHER = 5 502 | -------------------------------------------------------------------------------- /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_creation_send_dm_embed": True, 74 | "thread_close_footer": "Replying will create a new thread", 75 | "thread_close_title": "Thread Closed", 76 | "thread_close_response": "{closer.mention} has closed this Modmail thread.", 77 | "thread_self_close_response": "You have closed this Modmail thread.", 78 | "thread_move_title": "Thread Moved", 79 | "thread_move_notify": False, 80 | "thread_move_notify_mods": False, 81 | "thread_move_response": "This thread has been moved.", 82 | "cooldown_thread_title": "Message not sent!", 83 | "cooldown_thread_response": "Your cooldown ends {delta}. Try contacting me then.", 84 | "disabled_new_thread_title": "Not Delivered", 85 | "disabled_new_thread_response": "We are not accepting new threads.", 86 | "disabled_new_thread_footer": "Please try again later...", 87 | "disabled_current_thread_title": "Not Delivered", 88 | "disabled_current_thread_response": "We are not accepting any messages.", 89 | "disabled_current_thread_footer": "Please try again later...", 90 | "transfer_reactions": True, 91 | "close_on_leave": False, 92 | "close_on_leave_reason": "The recipient has left the server.", 93 | "alert_on_mention": False, 94 | "silent_alert_on_mention": False, 95 | "show_timestamp": True, 96 | "anonymous_snippets": False, 97 | "plain_snippets": False, 98 | "require_close_reason": False, 99 | "show_log_url_button": False, 100 | # group conversations 101 | "private_added_to_group_title": "New Thread (Group)", 102 | "private_added_to_group_response": "{moderator.name} has added you to a Modmail thread.", 103 | "private_added_to_group_description_anon": "A moderator has added you to a Modmail thread.", 104 | "public_added_to_group_title": "New User", 105 | "public_added_to_group_response": "{moderator.name} has added {users} to the Modmail thread.", 106 | "public_added_to_group_description_anon": "A moderator has added {users} to the Modmail thread.", 107 | "private_removed_from_group_title": "Removed From Thread (Group)", 108 | "private_removed_from_group_response": "{moderator.name} has removed you from the Modmail thread.", 109 | "private_removed_from_group_description_anon": "A moderator has removed you from the Modmail thread.", 110 | "public_removed_from_group_title": "User Removed", 111 | "public_removed_from_group_response": "{moderator.name} has removed {users} from the Modmail thread.", 112 | "public_removed_from_group_description_anon": "A moderator has removed {users} from the Modmail thread.", 113 | # moderation 114 | "recipient_color": str(discord.Color.gold()), 115 | "mod_color": str(discord.Color.green()), 116 | "mod_tag": None, 117 | # anonymous message 118 | "anon_username": None, 119 | "anon_avatar_url": None, 120 | "anon_tag": "Response", 121 | # react to contact 122 | "react_to_contact_message": None, 123 | "react_to_contact_emoji": "\N{WHITE HEAVY CHECK MARK}", 124 | # confirm thread creation 125 | "confirm_thread_creation": False, 126 | "confirm_thread_creation_title": "Confirm thread creation", 127 | "confirm_thread_response": "Click the button to confirm thread creation which will directly contact the moderators.", 128 | "confirm_thread_creation_accept": "\N{WHITE HEAVY CHECK MARK}", 129 | "confirm_thread_creation_deny": "\N{NO ENTRY SIGN}", 130 | # regex 131 | "use_regex_autotrigger": False, 132 | "use_hoisted_top_role": True, 133 | # Minimum characters for thread creation 134 | "thread_min_characters": 0, 135 | "thread_min_characters_title": "Message too short", 136 | "thread_min_characters_response": "Your message is too short to create a thread. Please provide more details.", 137 | "thread_min_characters_footer": "Minimum {min_characters} characters required.", 138 | # --- SNOOZE FEATURE CONFIG --- 139 | "snooze_default_duration": 604800, # in seconds, default 7 days 140 | "snooze_title": "Thread Snoozed", 141 | "snooze_text": "This thread has been snoozed. The channel will be restored when the user replies or a moderator unsnoozes it.", 142 | "unsnooze_text": "This thread has been unsnoozed and restored.", 143 | "unsnooze_notify_channel": "thread", # Can be a channel ID or 'thread' for the thread's own channel 144 | # snooze behavior 145 | "snooze_behavior": "delete", # 'delete' to delete channel, 'move' to move channel to snoozed_category_id 146 | "snoozed_category_id": None, # Category ID to move snoozed channels into when snooze_behavior == 'move' 147 | # attachments persistence for delete-behavior snooze 148 | "snooze_store_attachments": False, # when True, store image attachments as base64 in snooze_data 149 | "snooze_attachment_max_bytes": 4_194_304, # 4 MiB per attachment cap to avoid Mongo 16MB limit 150 | "unsnooze_history_limit": None, # Limit number of messages replayed when unsnoozing (None = all messages) 151 | # --- THREAD CREATION MENU --- 152 | "thread_creation_menu_timeout": 30, # Default interaction timeout for the thread-creation menu (in seconds) 153 | "thread_creation_menu_close_on_timeout": False, 154 | "thread_creation_menu_anonymous_menu": False, 155 | "thread_creation_menu_embed_text": "Please select an option.", 156 | "thread_creation_menu_dropdown_placeholder": "Select an option to contact the staff team.", 157 | "thread_creation_menu_selection_log": True, # log selected option in newly created thread channel 158 | "thread_creation_menu_precreate_channel": False, 159 | # thread-creation menu embed customization 160 | "thread_creation_menu_embed_title": None, 161 | "thread_creation_menu_embed_footer": None, 162 | "thread_creation_menu_embed_thumbnail_url": None, 163 | "thread_creation_menu_embed_image_url": None, 164 | "thread_creation_menu_embed_large_image": False, 165 | "thread_creation_menu_embed_footer_icon_url": None, 166 | "thread_creation_menu_embed_color": str(discord.Color.green()), 167 | } 168 | 169 | private_keys = { 170 | # bot presence 171 | "activity_message": "", 172 | "activity_type": None, 173 | "status": None, 174 | "dm_disabled": DMDisabled.NONE, 175 | "oauth_whitelist": [], 176 | # moderation 177 | "blocked": {}, 178 | "blocked_roles": {}, 179 | "blocked_whitelist": [], 180 | "command_permissions": {}, 181 | "level_permissions": {}, 182 | "override_command_level": {}, 183 | # threads 184 | "snippets": {}, 185 | "notification_squad": {}, 186 | "subscriptions": {}, 187 | "closures": {}, 188 | # Thread creation menu 189 | "thread_creation_menu_enabled": False, 190 | "thread_creation_menu_options": {}, # main menu options mapping key -> {label, description, emoji, type, callback} 191 | "thread_creation_menu_submenus": {}, # submenu name -> submenu options (same structure as options) 192 | # misc 193 | "plugins": [], 194 | "aliases": {}, 195 | "auto_triggers": {}, 196 | } 197 | 198 | protected_keys = { 199 | # Modmail 200 | "modmail_guild_id": None, 201 | "guild_id": None, 202 | "log_url": "https://example.com/", 203 | "log_url_prefix": "/logs", 204 | "mongo_uri": None, 205 | "database_type": "mongodb", 206 | "connection_uri": None, # replace mongo uri in the future 207 | "owners": None, 208 | "enable_presence_intent": False, 209 | "registry_plugins_only": False, 210 | # bot 211 | "token": None, 212 | "enable_plugins": True, 213 | "enable_eval": True, 214 | # github access token for private repositories 215 | "github_token": None, 216 | "disable_autoupdates": False, 217 | "disable_updates": False, 218 | # Logging 219 | "log_level": "INFO", 220 | "stream_log_format": "plain", 221 | "file_log_format": "plain", 222 | "discord_log_level": "INFO", 223 | # data collection 224 | "data_collection": True, 225 | } 226 | 227 | colors = { 228 | "mod_color", 229 | "recipient_color", 230 | "main_color", 231 | "error_color", 232 | "thread_creation_menu_embed_color", 233 | } 234 | 235 | time_deltas = { 236 | "account_age", 237 | "guild_age", 238 | "thread_auto_close", 239 | "thread_cooldown", 240 | "log_expiration", 241 | } 242 | 243 | duration_seconds = {"snooze_default_duration"} 244 | 245 | booleans = { 246 | "use_user_id_channel_name", 247 | "use_timestamp_channel_name", 248 | "use_nickname_channel_name", 249 | "use_random_channel_name", 250 | "user_typing", 251 | "mod_typing", 252 | "reply_without_command", 253 | "anon_reply_without_command", 254 | "plain_reply_without_command", 255 | "show_log_url_button", 256 | "recipient_thread_close", 257 | "thread_auto_close_silently", 258 | "thread_move_notify", 259 | "thread_move_notify_mods", 260 | "transfer_reactions", 261 | "close_on_leave", 262 | "alert_on_mention", 263 | "silent_alert_on_mention", 264 | "show_timestamp", 265 | "confirm_thread_creation", 266 | "use_regex_autotrigger", 267 | "enable_plugins", 268 | "data_collection", 269 | "enable_eval", 270 | "disable_autoupdates", 271 | "disable_updates", 272 | "update_notifications", 273 | "thread_contact_silently", 274 | "anonymous_snippets", 275 | "plain_snippets", 276 | "require_close_reason", 277 | "recipient_thread_close", 278 | "thread_show_roles", 279 | "thread_show_account_age", 280 | "thread_show_join_age", 281 | "use_hoisted_top_role", 282 | "enable_presence_intent", 283 | "registry_plugins_only", 284 | # snooze 285 | "snooze_store_attachments", 286 | # thread creation menu booleans 287 | "thread_creation_send_dm_embed", 288 | "thread_creation_menu_enabled", 289 | "thread_creation_menu_close_on_timeout", 290 | "thread_creation_menu_anonymous_menu", 291 | "thread_creation_menu_selection_log", 292 | "thread_creation_menu_precreate_channel", 293 | "thread_creation_menu_embed_large_image", 294 | } 295 | 296 | enums = { 297 | "dm_disabled": DMDisabled, 298 | "status": discord.Status, 299 | "activity_type": discord.ActivityType, 300 | } 301 | 302 | force_str = {"command_permissions", "level_permissions"} 303 | 304 | defaults = {**public_keys, **private_keys, **protected_keys} 305 | all_keys = set(defaults.keys()) 306 | 307 | def __init__(self, bot): 308 | self.bot = bot 309 | self._cache = {} 310 | self.ready_event = asyncio.Event() 311 | self.config_help = {} 312 | 313 | def __repr__(self): 314 | return repr(self._cache) 315 | 316 | def populate_cache(self) -> dict: 317 | data = deepcopy(self.defaults) 318 | 319 | # populate from env var and .env file 320 | data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}) 321 | config_json = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json") 322 | if os.path.exists(config_json): 323 | logger.debug("Loading envs from config.json.") 324 | with open(config_json, "r", encoding="utf-8") as f: 325 | # Config json should override env vars 326 | try: 327 | data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys}) 328 | except json.JSONDecodeError: 329 | logger.critical("Failed to load config.json env values.", exc_info=True) 330 | 331 | self._cache = data 332 | 333 | config_help_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config_help.json") 334 | with open(config_help_json, "r", encoding="utf-8") as f: 335 | self.config_help = dict(sorted(json.load(f).items())) 336 | 337 | return self._cache 338 | 339 | async def update(self): 340 | """Updates the config with data from the cache""" 341 | await self.bot.api.update_config(self.filter_default(self._cache)) 342 | 343 | async def refresh(self) -> dict: 344 | """Refreshes internal cache with data from database""" 345 | for k, v in (await self.bot.api.get_config()).items(): 346 | k = k.lower() 347 | if k in self.all_keys: 348 | self._cache[k] = v 349 | if not self.ready_event.is_set(): 350 | self.ready_event.set() 351 | logger.debug("Successfully fetched configurations from database.") 352 | return self._cache 353 | 354 | async def wait_until_ready(self) -> None: 355 | await self.ready_event.wait() 356 | 357 | def __setitem__(self, key: str, item: typing.Any) -> None: 358 | key = key.lower() 359 | logger.info("Setting %s.", key) 360 | if key not in self.all_keys: 361 | raise InvalidConfigError(f'Configuration "{key}" is invalid.') 362 | self._cache[key] = item 363 | 364 | def __getitem__(self, key: str) -> typing.Any: 365 | # make use of the custom methods in func:get: 366 | return self.get(key) 367 | 368 | def __delitem__(self, key: str) -> None: 369 | return self.remove(key) 370 | 371 | def get(self, key: str, *, convert: bool = True) -> typing.Any: 372 | key = key.lower() 373 | if key not in self.all_keys: 374 | raise InvalidConfigError(f'Configuration "{key}" is invalid.') 375 | if key not in self._cache: 376 | self._cache[key] = deepcopy(self.defaults[key]) 377 | value = self._cache[key] 378 | 379 | if not convert: 380 | return value 381 | 382 | if key in self.colors: 383 | try: 384 | return int(value.lstrip("#"), base=16) 385 | except ValueError: 386 | logger.error("Invalid %s provided.", key) 387 | value = int(self.remove(key).lstrip("#"), base=16) 388 | 389 | elif key in self.time_deltas: 390 | if not isinstance(value, isodate.Duration): 391 | try: 392 | value = isodate.parse_duration(value) 393 | except isodate.ISO8601Error: 394 | logger.warning( 395 | "The {account} age limit needs to be a " 396 | 'ISO-8601 duration formatted duration, not "%s".', 397 | value, 398 | ) 399 | value = self.remove(key) 400 | 401 | elif key in self.booleans: 402 | try: 403 | value = strtobool(value) 404 | except ValueError: 405 | value = self.remove(key) 406 | 407 | elif key in self.enums: 408 | if value is None: 409 | return None 410 | try: 411 | value = self.enums[key](value) 412 | except ValueError: 413 | logger.warning("Invalid %s %s.", key, value) 414 | value = self.remove(key) 415 | 416 | elif key in self.duration_seconds: 417 | if not isinstance(value, int): 418 | try: 419 | value = int(value) 420 | except (ValueError, TypeError): 421 | logger.warning("Invalid %s %s.", key, value) 422 | value = self.remove(key) 423 | 424 | elif key in self.force_str: 425 | # Temporary: as we saved in int previously, leading to int32 overflow, 426 | # this is transitioning IDs to strings 427 | new_value = {} 428 | changed = False 429 | for k, v in value.items(): 430 | new_v = v 431 | if isinstance(v, list): 432 | new_v = [] 433 | for n in v: 434 | if n != -1 and not isinstance(n, str): 435 | changed = True 436 | n = str(n) 437 | new_v.append(n) 438 | new_value[k] = new_v 439 | 440 | if changed: 441 | # transition the database as well 442 | self.set(key, new_value) 443 | 444 | value = new_value 445 | 446 | return value 447 | 448 | async def set(self, key: str, item: typing.Any, convert=True) -> None: 449 | if not convert: 450 | return self.__setitem__(key, item) 451 | 452 | if "channel" in key or "category" in key: 453 | if isinstance(item, str) and item not in {"thread", "NONE"}: 454 | item = item.strip("<#>") 455 | 456 | if key in self.colors: 457 | try: 458 | hex_ = str(item) 459 | if hex_.startswith("#"): 460 | hex_ = hex_[1:] 461 | if len(hex_) == 3: 462 | hex_ = "".join(s for s in hex_ for _ in range(2)) 463 | if len(hex_) != 6: 464 | raise InvalidConfigError("Invalid color name or hex.") 465 | try: 466 | int(hex_, 16) 467 | except ValueError: 468 | raise InvalidConfigError("Invalid color name or hex.") 469 | 470 | except InvalidConfigError: 471 | name = str(item).lower() 472 | name = re.sub(r"[\-+|. ]+", " ", name) 473 | hex_ = ALL_COLORS.get(name) 474 | if hex_ is None: 475 | name = re.sub(r"[\-+|. ]+", "", name) 476 | hex_ = ALL_COLORS.get(name) 477 | if hex_ is None: 478 | raise 479 | return self.__setitem__(key, "#" + hex_) 480 | 481 | if key in self.time_deltas: 482 | try: 483 | isodate.parse_duration(item) 484 | except isodate.ISO8601Error: 485 | try: 486 | converter = UserFriendlyTime() 487 | time = await converter.convert(None, item, now=discord.utils.utcnow()) 488 | if time.arg: 489 | raise ValueError 490 | except BadArgument as exc: 491 | raise InvalidConfigError(*exc.args) 492 | except Exception as e: 493 | logger.debug(e) 494 | raise InvalidConfigError( 495 | "Unrecognized time, please use ISO-8601 duration format " 496 | 'string or a simpler "human readable" time.' 497 | ) 498 | now = discord.utils.utcnow() 499 | item = isodate.duration_isoformat(time.dt - now) 500 | return self.__setitem__(key, item) 501 | 502 | if key in self.booleans: 503 | try: 504 | return self.__setitem__(key, strtobool(item)) 505 | except ValueError: 506 | raise InvalidConfigError("Must be a yes/no value.") 507 | 508 | elif key in self.duration_seconds: 509 | if isinstance(item, int): 510 | return self.__setitem__(key, item) 511 | try: 512 | converter = UserFriendlyTime() 513 | time = await converter.convert(None, str(item), now=discord.utils.utcnow()) 514 | if time.arg: 515 | raise ValueError 516 | except BadArgument as exc: 517 | raise InvalidConfigError(*exc.args) 518 | except Exception as e: 519 | logger.debug(e) 520 | raise InvalidConfigError( 521 | "Unrecognized time, please use a duration like '5 days' or '2 hours'." 522 | ) 523 | now = discord.utils.utcnow() 524 | duration_seconds = int((time.dt - now).total_seconds()) 525 | return self.__setitem__(key, duration_seconds) 526 | 527 | elif key in self.enums: 528 | if isinstance(item, self.enums[key]): 529 | # value is an enum type 530 | item = item.value 531 | 532 | return self.__setitem__(key, item) 533 | 534 | def remove(self, key: str) -> typing.Any: 535 | key = key.lower() 536 | logger.info("Removing %s.", key) 537 | if key not in self.all_keys: 538 | raise InvalidConfigError(f'Configuration "{key}" is invalid.') 539 | if key in self._cache: 540 | del self._cache[key] 541 | self._cache[key] = deepcopy(self.defaults[key]) 542 | return self._cache[key] 543 | 544 | def items(self) -> typing.Iterable: 545 | return self._cache.items() 546 | 547 | @classmethod 548 | def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: 549 | return { 550 | k.lower(): v 551 | for k, v in data.items() 552 | if k.lower() in cls.public_keys or k.lower() in cls.private_keys 553 | } 554 | 555 | @classmethod 556 | def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: 557 | # TODO: use .get to prevent errors 558 | filtered = {} 559 | for k, v in data.items(): 560 | default = cls.defaults.get(k.lower(), Default) 561 | if default is Default: 562 | logger.error("Unexpected configuration detected: %s.", k) 563 | continue 564 | if v != default: 565 | filtered[k.lower()] = v 566 | return filtered 567 | -------------------------------------------------------------------------------- /core/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import functools 3 | import contextlib 4 | import re 5 | import typing 6 | from datetime import datetime, timezone 7 | from difflib import get_close_matches 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 | "safe_typing", 38 | "escape_code_block", 39 | "tryint", 40 | "get_top_role", 41 | "get_joint_id", 42 | "extract_block_timestamp", 43 | "return_or_truncate", 44 | "AcceptButton", 45 | "DenyButton", 46 | "ConfirmThreadCreationView", 47 | "DummyParam", 48 | "extract_forwarded_content", 49 | ] 50 | 51 | 52 | logger = getLogger(__name__) 53 | 54 | 55 | def strtobool(val): 56 | if isinstance(val, bool): 57 | return val 58 | val = str(val).lower() 59 | if val in ("y", "yes", "on", "1", "true", "t", "enable"): 60 | return 1 61 | if val in ("n", "no", "off", "0", "false", "f", "disable"): 62 | return 0 63 | raise ValueError(f"invalid truth value {val}") 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( 262 | text: str, 263 | ) -> typing.Tuple[typing.Optional[str], int, typing.List[int]]: 264 | """ 265 | A helper to parse channel topics and respectivefully returns all the required values 266 | at once. 267 | 268 | Parameters 269 | ---------- 270 | text : str 271 | The text of channel topic. 272 | 273 | Returns 274 | ------- 275 | Tuple[Optional[str], int, List[int]] 276 | A tuple of title, user ID, and other recipients IDs. 277 | """ 278 | title, user_id, other_ids = None, -1, [] 279 | if isinstance(text, str): 280 | match = TOPIC_REGEX.search(text) 281 | else: 282 | match = None 283 | 284 | if match is not None: 285 | groupdict = match.groupdict() 286 | title = groupdict["title"] 287 | 288 | # user ID string is the required one in regex, so if match is found 289 | # the value of this won't be None 290 | user_id = int(groupdict["user_id"]) 291 | 292 | oth_ids = groupdict["other_ids"] 293 | if oth_ids: 294 | other_ids = list(map(int, oth_ids.split(","))) 295 | 296 | return title, user_id, other_ids 297 | 298 | 299 | def match_title(text: str) -> str: 300 | """ 301 | Matches a title in the format of "Title: XXXX" 302 | 303 | Parameters 304 | ---------- 305 | text : str 306 | The text of the user ID. 307 | 308 | Returns 309 | ------- 310 | Optional[str] 311 | The title if found. 312 | """ 313 | return parse_channel_topic(text)[0] 314 | 315 | 316 | def match_user_id(text: str, any_string: bool = False) -> int: 317 | """ 318 | Matches a user ID in the format of "User ID: 12345". 319 | 320 | Parameters 321 | ---------- 322 | text : str 323 | The text of the user ID. 324 | any_string: bool 325 | Whether to search any string that matches the UID_REGEX, e.g. not from channel topic. 326 | Defaults to False. 327 | 328 | Returns 329 | ------- 330 | int 331 | The user ID if found. Otherwise, -1. 332 | """ 333 | user_id = -1 334 | if any_string: 335 | match = UID_REGEX.search(text) 336 | if match is not None: 337 | user_id = int(match.group(1)) 338 | else: 339 | user_id = parse_channel_topic(text)[1] 340 | 341 | return user_id 342 | 343 | 344 | def match_other_recipients(text: str) -> typing.List[int]: 345 | """ 346 | Matches a title in the format of "Other Recipients: XXXX,XXXX" 347 | 348 | Parameters 349 | ---------- 350 | text : str 351 | The text of the user ID. 352 | 353 | Returns 354 | ------- 355 | List[int] 356 | The list of other recipients IDs. 357 | """ 358 | return parse_channel_topic(text)[2] 359 | 360 | 361 | def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: 362 | # Single reference of Color.red() 363 | embed = discord.Embed( 364 | color=discord.Color.red(), 365 | description=f"**{name.capitalize()} `{word}` cannot be found.**", 366 | ) 367 | val = get_close_matches(word, possibilities, n=n, cutoff=cutoff) 368 | if val: 369 | embed.description += "\nHowever, perhaps you meant...\n" + "\n".join(val) 370 | return embed 371 | 372 | 373 | def parse_alias(alias, *, split=True): 374 | def encode_alias(m): 375 | return "\x1aU" + base64.b64encode(m.group(1).encode()).decode() + "\x1aU" 376 | 377 | def decode_alias(m): 378 | return base64.b64decode(m.group(1).encode()).decode() 379 | 380 | alias = re.sub( 381 | r"(?:(?<=^)(?:\s*(?<!\\)(?:\")\s*)|(?<=&&)(?:\s*(?<!\\)(?:\")\s*))(.+?)" 382 | r"(?:(?:\s*(?<!\\)(?:\")\s*)(?=&&)|(?:\s*(?<!\\)(?:\")\s*)(?=$))", 383 | encode_alias, 384 | alias, 385 | ).strip() 386 | 387 | aliases = [] 388 | if not alias: 389 | return aliases 390 | 391 | if split: 392 | iterate = re.split(r"\s*&&\s*", alias) 393 | else: 394 | iterate = [alias] 395 | 396 | for a in iterate: 397 | a = re.sub(r"\x1AU(.+?)\x1AU", decode_alias, a) 398 | if a[0] == a[-1] == '"': 399 | a = a[1:-1] 400 | aliases.append(a) 401 | 402 | return aliases 403 | 404 | 405 | def normalize_alias(alias, message=""): 406 | aliases = parse_alias(alias) 407 | contents = parse_alias(message, split=False) 408 | 409 | final_aliases = [] 410 | for a, content in zip_longest(aliases, contents): 411 | if a is None: 412 | break 413 | 414 | if content: 415 | final_aliases.append(f"{a} {content}") 416 | else: 417 | final_aliases.append(a) 418 | 419 | return final_aliases 420 | 421 | 422 | def format_description(i, names): 423 | return "\n".join( 424 | ": ".join((str(a + i * 15), b)) 425 | for a, b in enumerate(takewhile(lambda x: x is not None, names), start=1) 426 | ) 427 | 428 | 429 | class _SafeTyping: 430 | """Best-effort typing context manager. 431 | 432 | Suppresses errors from Discord's typing endpoint so core flows continue 433 | when typing is disabled or experiencing outages. 434 | """ 435 | 436 | def __init__(self, target): 437 | # target can be a Context or any Messageable (channel/DM/user) 438 | self._target = target 439 | self._cm = None 440 | 441 | async def __aenter__(self): 442 | try: 443 | self._cm = self._target.typing() 444 | return await self._cm.__aenter__() 445 | except Exception: 446 | # typing is best-effort; ignore any failure 447 | self._cm = None 448 | 449 | async def __aexit__(self, exc_type, exc, tb): 450 | if self._cm is not None: 451 | with contextlib.suppress(Exception): 452 | return await self._cm.__aexit__(exc_type, exc, tb) 453 | 454 | 455 | def safe_typing(target): 456 | return _SafeTyping(target) 457 | 458 | 459 | def trigger_typing(func): 460 | @functools.wraps(func) 461 | async def wrapper(self, ctx: commands.Context, *args, **kwargs): 462 | # Keep typing active for the duration of the command; suppress failures 463 | async with safe_typing(ctx): 464 | return await func(self, ctx, *args, **kwargs) 465 | 466 | return wrapper 467 | 468 | 469 | def escape_code_block(text): 470 | return re.sub(r"```", "`\u200b``", text) 471 | 472 | 473 | def tryint(x): 474 | try: 475 | return int(x) 476 | except (ValueError, TypeError): 477 | return x 478 | 479 | 480 | def get_top_role(member: discord.Member, hoisted=True): 481 | roles = sorted(member.roles, key=lambda r: r.position, reverse=True) 482 | for role in roles: 483 | if not hoisted: 484 | return role 485 | if role.hoist: 486 | return role 487 | 488 | 489 | async def create_thread_channel(bot, recipient, category, overwrites, *, name=None, errors_raised=None): 490 | name = name or bot.format_channel_name(recipient) 491 | errors_raised = errors_raised or [] 492 | 493 | try: 494 | channel = await bot.modmail_guild.create_text_channel( 495 | name=name, 496 | category=category, 497 | overwrites=overwrites, 498 | topic=f"User ID: {recipient.id}", 499 | reason="Creating a thread channel.", 500 | ) 501 | except discord.HTTPException as e: 502 | if (e.text, (category, name)) in errors_raised: 503 | # Just raise the error to prevent infinite recursion after retrying 504 | raise 505 | 506 | errors_raised.append((e.text, (category, name))) 507 | 508 | if "Maximum number of channels in category reached" in e.text: 509 | fallback = None 510 | fallback_id = bot.config["fallback_category_id"] 511 | if fallback_id: 512 | fallback = discord.utils.get(category.guild.categories, id=int(fallback_id)) 513 | if fallback and len(fallback.channels) >= 49: 514 | fallback = None 515 | 516 | if not fallback: 517 | fallback = await category.clone(name="Fallback Modmail") 518 | await bot.config.set("fallback_category_id", str(fallback.id)) 519 | await bot.config.update() 520 | 521 | return await create_thread_channel( 522 | bot, recipient, fallback, overwrites, errors_raised=errors_raised 523 | ) 524 | 525 | if "Contains words not allowed" in e.text: 526 | # try again but null-discrim (name could be banned) 527 | return await create_thread_channel( 528 | bot, 529 | recipient, 530 | category, 531 | overwrites, 532 | name=bot.format_channel_name(recipient, force_null=True), 533 | errors_raised=errors_raised, 534 | ) 535 | 536 | raise 537 | 538 | return channel 539 | 540 | 541 | def get_joint_id(message: discord.Message) -> typing.Optional[int]: 542 | """ 543 | Get the joint ID from `discord.Embed().author.url`. 544 | Parameters 545 | ----------- 546 | message : discord.Message 547 | The discord.Message object. 548 | Returns 549 | ------- 550 | int 551 | The joint ID if found. Otherwise, None. 552 | """ 553 | if message.embeds: 554 | try: 555 | url = getattr(message.embeds[0].author, "url", "") 556 | if url: 557 | return int(url.split("#")[-1]) 558 | except ValueError: 559 | pass 560 | return None 561 | 562 | 563 | def extract_block_timestamp(reason, id_): 564 | # etc "blah blah blah... until <t:XX:f>." 565 | now = discord.utils.utcnow() 566 | end_time = re.search(r"until <t:(\d+):(?:R|f)>.$", reason) 567 | attempts = [ 568 | # backwards compat 569 | re.search(r"until ([^`]+?)\.$", reason), 570 | re.search(r"%([^%]+?)%", reason), 571 | ] 572 | after = None 573 | if end_time is None: 574 | for i in attempts: 575 | if i is not None: 576 | end_time = i 577 | break 578 | 579 | if end_time is not None: 580 | # found a deprecated version 581 | try: 582 | after = ( 583 | datetime.fromisoformat(end_time.group(1)).replace(tzinfo=timezone.utc) - now 584 | ).total_seconds() 585 | except ValueError: 586 | logger.warning( 587 | r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", 588 | id_, 589 | ) 590 | raise 591 | logger.warning( 592 | r"Deprecated time message for user %s, block and unblock again to update.", 593 | id_, 594 | ) 595 | else: 596 | try: 597 | after = ( 598 | datetime.utcfromtimestamp(int(end_time.group(1))).replace(tzinfo=timezone.utc) - now 599 | ).total_seconds() 600 | except ValueError: 601 | logger.warning( 602 | r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", 603 | id_, 604 | ) 605 | raise 606 | 607 | return end_time, after 608 | 609 | 610 | def return_or_truncate(text, max_length): 611 | if len(text) <= max_length: 612 | return text 613 | return text[: max_length - 3] + "..." 614 | 615 | 616 | class AcceptButton(discord.ui.Button): 617 | def __init__(self, custom_id: str, emoji: str): 618 | super().__init__(style=discord.ButtonStyle.gray, emoji=emoji, custom_id=custom_id) 619 | 620 | async def callback(self, interaction: discord.Interaction): 621 | self.view.value = True 622 | await interaction.response.edit_message(view=None) 623 | self.view.stop() 624 | 625 | 626 | class DenyButton(discord.ui.Button): 627 | def __init__(self, custom_id: str, emoji: str): 628 | super().__init__(style=discord.ButtonStyle.gray, emoji=emoji, custom_id=custom_id) 629 | 630 | async def callback(self, interaction: discord.Interaction): 631 | self.view.value = False 632 | await interaction.response.edit_message(view=None) 633 | self.view.stop() 634 | 635 | 636 | class ConfirmThreadCreationView(discord.ui.View): 637 | def __init__(self): 638 | # Match thread_creation_menu_timeout default (30s) for consistency in UX 639 | super().__init__(timeout=30) 640 | self.value = None 641 | 642 | 643 | def extract_forwarded_content(message) -> typing.Optional[str]: 644 | """ 645 | Extract forwarded message content from Discord forwarded messages. 646 | 647 | Parameters 648 | ---------- 649 | message : discord.Message 650 | The message to extract forwarded content from. 651 | 652 | Returns 653 | ------- 654 | Optional[str] 655 | The extracted forwarded content, or None if not a forwarded message. 656 | """ 657 | import discord 658 | 659 | try: 660 | # Handle multi-forward (message_snapshots) 661 | if hasattr(message, "flags") and getattr(message.flags, "has_snapshot", False): 662 | if hasattr(message, "message_snapshots") and message.message_snapshots: 663 | forwarded_parts = [] 664 | for snap in message.message_snapshots: 665 | author = getattr(snap, "author", None) 666 | author_name = getattr(author, "name", "Unknown") if author else "Unknown" 667 | snap_content = getattr(snap, "content", "") 668 | 669 | if snap_content: 670 | # Truncate very long messages to prevent spam 671 | if len(snap_content) > 500: 672 | snap_content = snap_content[:497] + "..." 673 | forwarded_parts.append(f"**{author_name}:** {snap_content}") 674 | elif getattr(snap, "embeds", None): 675 | for embed in snap.embeds: 676 | if hasattr(embed, "description") and embed.description: 677 | embed_desc = embed.description 678 | if len(embed_desc) > 300: 679 | embed_desc = embed_desc[:297] + "..." 680 | forwarded_parts.append(f"**{author_name}:** {embed_desc}") 681 | break 682 | elif getattr(snap, "attachments", None): 683 | attachment_info = ", ".join( 684 | [getattr(a, "filename", "Unknown") for a in snap.attachments[:3]] 685 | ) 686 | if len(snap.attachments) > 3: 687 | attachment_info += f" (+{len(snap.attachments) - 3} more)" 688 | forwarded_parts.append(f"**{author_name}:** [Attachments: {attachment_info}]") 689 | else: 690 | forwarded_parts.append(f"**{author_name}:** [No content]") 691 | 692 | if forwarded_parts: 693 | return "\n".join(forwarded_parts) 694 | 695 | # Handle single-message forward 696 | elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): 697 | ref = getattr(message, "reference", None) 698 | if ( 699 | ref 700 | and hasattr(discord, "MessageReferenceType") 701 | and getattr(ref, "type", None) == getattr(discord.MessageReferenceType, "forward", None) 702 | ): 703 | try: 704 | ref_msg = getattr(ref, "resolved", None) 705 | if ref_msg: 706 | ref_author = getattr(ref_msg, "author", None) 707 | ref_author_name = getattr(ref_author, "name", "Unknown") if ref_author else "Unknown" 708 | ref_content = getattr(ref_msg, "content", "") 709 | 710 | if ref_content: 711 | if len(ref_content) > 500: 712 | ref_content = ref_content[:497] + "..." 713 | return f"**{ref_author_name}:** {ref_content}" 714 | elif getattr(ref_msg, "embeds", None): 715 | for embed in ref_msg.embeds: 716 | if hasattr(embed, "description") and embed.description: 717 | embed_desc = embed.description 718 | if len(embed_desc) > 300: 719 | embed_desc = embed_desc[:297] + "..." 720 | return f"**{ref_author_name}:** {embed_desc}" 721 | elif getattr(ref_msg, "attachments", None): 722 | attachment_info = ", ".join( 723 | [getattr(a, "filename", "Unknown") for a in ref_msg.attachments[:3]] 724 | ) 725 | if len(ref_msg.attachments) > 3: 726 | attachment_info += f" (+{len(ref_msg.attachments) - 3} more)" 727 | return f"**{ref_author_name}:** [Attachments: {attachment_info}]" 728 | except Exception as e: 729 | # Log and continue; failing to extract a reference preview shouldn't break flow 730 | logger.debug("Failed to extract reference preview: %s", e) 731 | except Exception: 732 | # Silently handle any unexpected errors 733 | pass 734 | 735 | return None 736 | 737 | 738 | class DummyParam: 739 | """ 740 | A dummy parameter that can be used for MissingRequiredArgument. 741 | """ 742 | 743 | def __init__(self, name): 744 | self.name = name 745 | self.displayed_name = name 746 | -------------------------------------------------------------------------------- /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( 137 | response: ClientResponse, 138 | ) -> Union[Dict[str, Any], str]: 139 | """ 140 | Internal method to convert the response data to `dict` if the data is a 141 | json object, or to `str` (raw response) if the data is not a valid json. 142 | """ 143 | try: 144 | return await response.json() 145 | except (JSONDecodeError, ClientResponseError): 146 | return await response.text() 147 | 148 | def filter_valid(self, data) -> Dict[str, Any]: 149 | """ 150 | Filters configuration keys that are accepted. 151 | 152 | Parameters 153 | ---------- 154 | data : Dict[str, Any] 155 | The data that needs to be cleaned. 156 | 157 | Returns 158 | ------- 159 | Dict[str, Any] 160 | Filtered `data` to keep only the accepted pairs. 161 | """ 162 | valid_keys = self.bot.config.valid_keys.difference(self.bot.config.protected_keys) 163 | return {k: v for k, v in data.items() if k in valid_keys} 164 | 165 | async def update_repository(self, sha: str = None) -> Dict[str, Any]: 166 | """ 167 | Update the repository from Modmail main repo. 168 | 169 | Parameters 170 | ---------- 171 | sha : Optional[str] 172 | The commit SHA to update the repository. If `None`, the latest 173 | commit SHA will be fetched. 174 | 175 | Returns 176 | ------- 177 | Dict[str, Any] 178 | A dictionary that contains response data. 179 | """ 180 | if not self.username: 181 | raise commands.CommandInvokeError("Username not found.") 182 | 183 | if sha is None: 184 | resp = await self.request(self.REPO + "/git/refs/heads/" + self.BRANCH) 185 | sha = resp["object"]["sha"] 186 | 187 | payload = {"base": self.BRANCH, "head": sha, "commit_message": "Updating bot"} 188 | 189 | merge_url = self.MERGE_URL.format(username=self.username) 190 | 191 | resp = await self.request( 192 | merge_url, 193 | method="POST", 194 | payload=payload, 195 | return_response=True, 196 | read_before_return=True, 197 | ) 198 | 199 | repo_url = self.BASE + f"/repos/{self.username}/modmail" 200 | status_map = { 201 | 201: "Successful response.", 202 | 204: "Already merged.", 203 | 403: "Forbidden.", 204 | 404: f"Repository '{repo_url}' not found.", 205 | 409: "There is a merge conflict.", 206 | 422: "Validation failed.", 207 | } 208 | # source https://docs.github.com/en/rest/branches/branches#merge-a-branch 209 | 210 | status = resp.status 211 | data = await self._get_response_data(resp) 212 | if status in (201, 204): 213 | return data 214 | 215 | args = (resp.request_info, resp.history) 216 | try: 217 | # try to get the response error message if any 218 | message = data.get("message") 219 | except AttributeError: 220 | message = None 221 | kwargs = { 222 | "status": status, 223 | "message": message if message else status_map.get(status), 224 | } 225 | # just raise 226 | raise ClientResponseError(*args, **kwargs) 227 | 228 | async def fork_repository(self) -> None: 229 | """ 230 | Forks Modmail's repository. 231 | """ 232 | await self.request(self.FORK_URL, method="POST", return_response=True) 233 | 234 | async def has_starred(self) -> bool: 235 | """ 236 | Checks if shared Modmail. 237 | 238 | Returns 239 | ------- 240 | bool 241 | `True`, if Modmail was starred. 242 | Otherwise `False`. 243 | """ 244 | resp = await self.request(self.STAR_URL, return_response=True) 245 | return resp.status == 204 246 | 247 | async def star_repository(self) -> None: 248 | """ 249 | Stars Modmail's repository. 250 | """ 251 | await self.request( 252 | self.STAR_URL, 253 | method="PUT", 254 | headers={"Content-Length": "0"}, 255 | return_response=True, 256 | ) 257 | 258 | @classmethod 259 | async def login(cls, bot) -> "GitHub": 260 | """ 261 | Logs in to GitHub with configuration variable information. 262 | 263 | Parameters 264 | ---------- 265 | bot : Bot 266 | The Modmail bot. 267 | 268 | Returns 269 | ------- 270 | GitHub 271 | The newly created `GitHub` object. 272 | """ 273 | self = cls(bot, bot.config.get("github_token")) 274 | resp: Dict[str, Any] = await self.request(self.BASE + "/user") 275 | if resp.get("login"): 276 | self.username = resp["login"] 277 | self.avatar_url = resp["avatar_url"] 278 | self.url = resp["html_url"] 279 | logger.info(f"GitHub logged in to: {self.username}") 280 | return self 281 | else: 282 | raise InvalidConfigError("Invalid github token") 283 | 284 | 285 | class ApiClient: 286 | """ 287 | This class represents the general request class for all type of clients. 288 | 289 | Parameters 290 | ---------- 291 | bot : Bot 292 | The Modmail bot. 293 | 294 | Attributes 295 | ---------- 296 | bot : Bot 297 | The Modmail bot. 298 | session : ClientSession 299 | The bot's current running `ClientSession`. 300 | """ 301 | 302 | def __init__(self, bot, db): 303 | self.bot = bot 304 | self.db = db 305 | self.session = bot.session 306 | 307 | async def request( 308 | self, 309 | url: str, 310 | method: str = "GET", 311 | payload: dict = None, 312 | return_response: bool = False, 313 | headers: dict = None, 314 | ) -> Union[ClientResponse, dict, str]: 315 | """ 316 | Makes a HTTP request. 317 | 318 | Parameters 319 | ---------- 320 | url : str 321 | The destination URL of the request. 322 | method : str 323 | The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). 324 | payload : Dict[str, Any] 325 | The json payload to be sent along the request. 326 | return_response : bool 327 | Whether the `ClientResponse` object should be returned. 328 | headers : Dict[str, str] 329 | Additional headers to `headers`. 330 | 331 | Returns 332 | ------- 333 | ClientResponse or Dict[str, Any] or List[Any] or str 334 | `ClientResponse` if `return_response` is `True`. 335 | `dict` if the returned data is a json object. 336 | `list` if the returned data is a json list. 337 | `str` if the returned data is not a valid json data, 338 | the raw response. 339 | """ 340 | async with self.session.request(method, url, headers=headers, json=payload) as resp: 341 | if return_response: 342 | return resp 343 | try: 344 | return await resp.json() 345 | except (JSONDecodeError, ClientResponseError): 346 | return await resp.text() 347 | 348 | @property 349 | def logs(self): 350 | return self.db.logs 351 | 352 | async def setup_indexes(self): 353 | return NotImplemented 354 | 355 | async def validate_database_connection(self): 356 | return NotImplemented 357 | 358 | async def get_user_logs(self, user_id: Union[str, int]) -> list: 359 | return NotImplemented 360 | 361 | async def find_log_entry(self, key: str) -> list: 362 | return NotImplemented 363 | 364 | async def get_latest_user_logs(self, user_id: Union[str, int]): 365 | return NotImplemented 366 | 367 | async def get_responded_logs(self, user_id: Union[str, int]) -> list: 368 | return NotImplemented 369 | 370 | async def get_open_logs(self) -> list: 371 | return NotImplemented 372 | 373 | async def get_log(self, channel_id: Union[str, int]) -> dict: 374 | return NotImplemented 375 | 376 | async def get_log_link(self, channel_id: Union[str, int]) -> str: 377 | return NotImplemented 378 | 379 | async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: 380 | return NotImplemented 381 | 382 | async def delete_log_entry(self, key: str) -> bool: 383 | return NotImplemented 384 | 385 | async def get_config(self) -> dict: 386 | return NotImplemented 387 | 388 | async def update_config(self, data: dict): 389 | return NotImplemented 390 | 391 | async def edit_message(self, message_id: Union[int, str], new_content: str): 392 | return NotImplemented 393 | 394 | async def append_log( 395 | self, 396 | message: Message, 397 | *, 398 | message_id: str = "", 399 | channel_id: str = "", 400 | type_: str = "thread_message", 401 | ) -> dict: 402 | return NotImplemented 403 | 404 | async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: 405 | return NotImplemented 406 | 407 | async def search_closed_by(self, user_id: Union[int, str]): 408 | return NotImplemented 409 | 410 | async def search_by_text(self, text: str, limit: Optional[int]): 411 | return NotImplemented 412 | 413 | async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): 414 | return NotImplemented 415 | 416 | async def find_notes(self, recipient: Member): 417 | return NotImplemented 418 | 419 | async def update_note_ids(self, ids: dict): 420 | return NotImplemented 421 | 422 | async def delete_note(self, message_id: Union[int, str]): 423 | return NotImplemented 424 | 425 | async def edit_note(self, message_id: Union[int, str], message: str): 426 | return NotImplemented 427 | 428 | def get_plugin_partition(self, cog): 429 | return NotImplemented 430 | 431 | async def update_repository(self) -> dict: 432 | return NotImplemented 433 | 434 | async def get_user_info(self) -> Optional[dict]: 435 | return NotImplemented 436 | 437 | 438 | class MongoDBClient(ApiClient): 439 | def __init__(self, bot): 440 | mongo_uri = bot.config["connection_uri"] 441 | if mongo_uri is None: 442 | mongo_uri = bot.config["mongo_uri"] 443 | if mongo_uri is not None: 444 | logger.warning( 445 | "You're using the old config MONGO_URI, " 446 | "consider switching to the new CONNECTION_URI config." 447 | ) 448 | else: 449 | logger.critical("A Mongo URI is necessary for the bot to function.") 450 | raise RuntimeError 451 | 452 | try: 453 | db = AsyncIOMotorClient(mongo_uri).modmail_bot 454 | except ConfigurationError as e: 455 | logger.critical( 456 | "Your MongoDB CONNECTION_URI might be copied wrong, try re-copying from the source again. " 457 | "Otherwise noted in the following message:\n%s", 458 | e, 459 | ) 460 | sys.exit(0) 461 | 462 | super().__init__(bot, db) 463 | 464 | async def setup_indexes(self): 465 | """Setup text indexes so we can use the $search operator""" 466 | coll = self.db.logs 467 | index_name = "messages.content_text_messages.author.name_text_key_text" 468 | 469 | index_info = await coll.index_information() 470 | 471 | # Backwards compatibility 472 | old_index = "messages.content_text_messages.author.name_text" 473 | if old_index in index_info: 474 | logger.info("Dropping old index: %s", old_index) 475 | await coll.drop_index(old_index) 476 | 477 | if index_name not in index_info: 478 | logger.info('Creating "text" index for logs collection.') 479 | logger.info("Name: %s", index_name) 480 | await coll.create_index( 481 | [ 482 | ("messages.content", "text"), 483 | ("messages.author.name", "text"), 484 | ("key", "text"), 485 | ] 486 | ) 487 | logger.debug("Successfully configured and verified database indexes.") 488 | 489 | async def validate_database_connection(self, *, ssl_retry=True): 490 | try: 491 | await self.db.command("buildinfo") 492 | except Exception as exc: 493 | logger.critical("Something went wrong while connecting to the database.") 494 | message = f"{type(exc).__name__}: {str(exc)}" 495 | logger.critical(message) 496 | if "CERTIFICATE_VERIFY_FAILED" in message and ssl_retry: 497 | mongo_uri = self.bot.config["connection_uri"] 498 | if mongo_uri is None: 499 | mongo_uri = self.bot.config["mongo_uri"] 500 | for _ in range(3): 501 | logger.warning( 502 | "FAILED TO VERIFY SSL CERTIFICATE, ATTEMPTING TO START WITHOUT SSL (UNSAFE)." 503 | ) 504 | logger.warning( 505 | "To fix this warning, check there's no proxies blocking SSL cert verification, " 506 | 'run "Certificate.command" on MacOS, ' 507 | 'and check certifi is up to date "pip3 install --upgrade certifi".' 508 | ) 509 | self.db = AsyncIOMotorClient(mongo_uri, tlsAllowInvalidCertificates=True).modmail_bot 510 | return await self.validate_database_connection(ssl_retry=False) 511 | if "ServerSelectionTimeoutError" in message: 512 | logger.critical( 513 | "This may have been caused by not whitelisting " 514 | "IPs correctly. Make sure to whitelist all " 515 | "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" 516 | ) 517 | 518 | if "OperationFailure" in message: 519 | logger.critical( 520 | "This is due to having invalid credentials in your MongoDB CONNECTION_URI. " 521 | "Remember you need to substitute `<password>` with your actual password." 522 | ) 523 | logger.critical( 524 | "Be sure to URL encode your username and password (not the entire URL!!), " 525 | "https://www.urlencoder.io/, if this issue persists, try changing your username and password " 526 | "to only include alphanumeric characters, no symbols." 527 | "" 528 | ) 529 | raise 530 | else: 531 | logger.debug("Successfully connected to the database.") 532 | logger.line("debug") 533 | 534 | async def get_user_logs(self, user_id: Union[str, int]) -> list: 535 | query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id)} 536 | projection = {"messages": {"$slice": 5}} 537 | logger.debug("Retrieving user %s logs.", user_id) 538 | 539 | return await self.logs.find(query, projection).to_list(None) 540 | 541 | async def find_log_entry(self, key: str) -> list: 542 | query = {"key": key} 543 | projection = {"messages": {"$slice": 5}} 544 | logger.debug(f"Retrieving log ID {key}.") 545 | 546 | return await self.logs.find(query, projection).to_list(None) 547 | 548 | async def get_latest_user_logs(self, user_id: Union[str, int]): 549 | query = { 550 | "recipient.id": str(user_id), 551 | "guild_id": str(self.bot.guild_id), 552 | "open": False, 553 | } 554 | projection = {"messages": {"$slice": 5}} 555 | logger.debug("Retrieving user %s latest logs.", user_id) 556 | 557 | return await self.logs.find_one(query, projection, limit=1, sort=[("closed_at", -1)]) 558 | 559 | async def get_responded_logs(self, user_id: Union[str, int]) -> list: 560 | query = { 561 | "open": False, 562 | "messages": { 563 | "$elemMatch": { 564 | "author.id": str(user_id), 565 | "author.mod": True, 566 | "type": {"$in": ["anonymous", "thread_message"]}, 567 | } 568 | }, 569 | } 570 | return await self.logs.find(query).to_list(None) 571 | 572 | async def get_open_logs(self) -> list: 573 | query = {"open": True} 574 | return await self.logs.find(query).to_list(None) 575 | 576 | async def get_log(self, channel_id: Union[str, int]) -> dict: 577 | logger.debug("Retrieving channel %s logs.", channel_id) 578 | return await self.logs.find_one({"channel_id": str(channel_id)}) 579 | 580 | async def get_log_link(self, channel_id: Union[str, int]) -> str: 581 | doc = await self.get_log(channel_id) 582 | logger.debug("Retrieving log link for channel %s.", channel_id) 583 | prefix = self.bot.config["log_url_prefix"].strip("/") 584 | if prefix == "NONE": 585 | prefix = "" 586 | return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" 587 | 588 | async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: 589 | key = secrets.token_hex(6) 590 | 591 | await self.logs.insert_one( 592 | { 593 | "_id": key, 594 | "key": key, 595 | "open": True, 596 | "created_at": str(discord.utils.utcnow()), 597 | "closed_at": None, 598 | "channel_id": str(channel.id), 599 | "guild_id": str(self.bot.guild_id), 600 | "bot_id": str(self.bot.user.id), 601 | "recipient": { 602 | "id": str(recipient.id), 603 | "name": recipient.name, 604 | "discriminator": recipient.discriminator, 605 | "avatar_url": recipient.display_avatar.url if recipient.display_avatar else None, 606 | "mod": False, 607 | }, 608 | "creator": { 609 | "id": str(creator.id), 610 | "name": creator.name, 611 | "discriminator": creator.discriminator, 612 | "avatar_url": creator.display_avatar.url if creator.display_avatar else None, 613 | "mod": isinstance(creator, Member), 614 | }, 615 | "closer": None, 616 | "messages": [], 617 | } 618 | ) 619 | logger.debug("Created a log entry, key %s.", key) 620 | prefix = self.bot.config["log_url_prefix"].strip("/") 621 | if prefix == "NONE": 622 | prefix = "" 623 | return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{key}" 624 | 625 | async def delete_log_entry(self, key: str) -> bool: 626 | result = await self.logs.delete_one({"key": key}) 627 | return result.deleted_count == 1 628 | 629 | async def get_config(self) -> dict: 630 | conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) 631 | if conf is None: 632 | logger.debug("Creating a new config entry for bot %s.", self.bot.user.id) 633 | await self.db.config.insert_one({"bot_id": self.bot.user.id}) 634 | return {"bot_id": self.bot.user.id} 635 | return conf 636 | 637 | async def update_config(self, data: dict): 638 | toset = self.bot.config.filter_valid(data) 639 | unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) 640 | 641 | if toset and unset: 642 | return await self.db.config.update_one( 643 | {"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset} 644 | ) 645 | if toset: 646 | return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$set": toset}) 647 | if unset: 648 | return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$unset": unset}) 649 | 650 | async def edit_message(self, message_id: Union[int, str], new_content: str) -> None: 651 | await self.logs.update_one( 652 | {"messages.message_id": str(message_id)}, 653 | {"$set": {"messages.$.content": new_content, "messages.$.edited": True}}, 654 | ) 655 | 656 | async def append_log( 657 | self, 658 | message: Message, 659 | *, 660 | message_id: str = "", 661 | channel_id: str = "", 662 | type_: str = "thread_message", 663 | ) -> dict: 664 | channel_id = str(channel_id) or str(message.channel.id) 665 | message_id = str(message_id) or str(message.id) 666 | 667 | data = { 668 | "timestamp": str(message.created_at), 669 | "message_id": message_id, 670 | "author": { 671 | "id": str(message.author.id), 672 | "name": message.author.name, 673 | "discriminator": message.author.discriminator, 674 | "avatar_url": message.author.display_avatar.url if message.author.display_avatar else None, 675 | "mod": not isinstance(message.channel, DMChannel), 676 | }, 677 | "content": message.content, 678 | "type": type_, 679 | "attachments": [ 680 | { 681 | "id": a.id, 682 | "filename": a.filename, 683 | "is_image": a.width is not None, 684 | "size": a.size, 685 | "url": a.url, 686 | } 687 | for a in message.attachments 688 | ], 689 | } 690 | 691 | return await self.logs.find_one_and_update( 692 | {"channel_id": channel_id}, 693 | {"$push": {"messages": data}}, 694 | return_document=True, 695 | ) 696 | 697 | async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: 698 | return await self.logs.find_one_and_update( 699 | {"channel_id": str(channel_id)}, {"$set": data}, return_document=True 700 | ) 701 | 702 | async def search_closed_by(self, user_id: Union[int, str]): 703 | return await self.logs.find( 704 | { 705 | "guild_id": str(self.bot.guild_id), 706 | "open": False, 707 | "closer.id": str(user_id), 708 | }, 709 | {"messages": {"$slice": 5}}, 710 | ).to_list(None) 711 | 712 | async def search_by_text(self, text: str, limit: Optional[int]): 713 | return await self.bot.db.logs.find( 714 | { 715 | "guild_id": str(self.bot.guild_id), 716 | "open": False, 717 | "$text": {"$search": f'"{text}"'}, 718 | }, 719 | {"messages": {"$slice": 5}}, 720 | ).to_list(limit) 721 | 722 | async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): 723 | await self.db.notes.insert_one( 724 | { 725 | "recipient": str(recipient.id), 726 | "author": { 727 | "id": str(message.author.id), 728 | "name": message.author.name, 729 | "discriminator": message.author.discriminator, 730 | "avatar_url": ( 731 | message.author.display_avatar.url if message.author.display_avatar else None 732 | ), 733 | }, 734 | "message": message.content, 735 | "message_id": str(message_id), 736 | } 737 | ) 738 | 739 | async def find_notes(self, recipient: Member): 740 | return await self.db.notes.find({"recipient": str(recipient.id)}).to_list(None) 741 | 742 | async def update_note_ids(self, ids: dict): 743 | for object_id, message_id in ids.items(): 744 | await self.db.notes.update_one({"_id": object_id}, {"$set": {"message_id": message_id}}) 745 | 746 | async def delete_note(self, message_id: Union[int, str]): 747 | await self.db.notes.delete_one({"message_id": str(message_id)}) 748 | 749 | async def edit_note(self, message_id: Union[int, str], message: str): 750 | await self.db.notes.update_one({"message_id": str(message_id)}, {"$set": {"message": message}}) 751 | 752 | def get_plugin_partition(self, cog): 753 | cls_name = cog.__class__.__name__ 754 | return self.db.plugins[cls_name] 755 | 756 | async def update_repository(self) -> dict: 757 | user = await GitHub.login(self.bot) 758 | data = await user.update_repository() 759 | return { 760 | "data": data, 761 | "user": { 762 | "username": user.username, 763 | "avatar_url": user.avatar_url, 764 | "url": user.url, 765 | }, 766 | } 767 | 768 | async def get_user_info(self) -> Optional[dict]: 769 | try: 770 | user = await GitHub.login(self.bot) 771 | except InvalidConfigError: 772 | return None 773 | else: 774 | return { 775 | "user": { 776 | "username": user.username, 777 | "avatar_url": user.avatar_url, 778 | "url": user.url, 779 | } 780 | } 781 | 782 | 783 | class PluginDatabaseClient: 784 | def __init__(self, bot): 785 | self.bot = bot 786 | 787 | def get_partition(self, cog): 788 | cls_name = cog.__class__.__name__ 789 | return self.bot.api.db.plugins[cls_name] 790 | --------------------------------------------------------------------------------