├── .dockerignore ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yaml └── workflows │ ├── deploy.yaml │ ├── lint_test_build.yaml │ ├── sentry_release.yaml │ └── status_embed.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── LICENSE_THIRD_PARTY ├── README.md ├── alembic.ini ├── docker-compose.yaml ├── monty ├── __init__.py ├── __main__.py ├── alembic │ ├── README │ ├── __init__.py │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 19e4f2aee642_guild_features.py │ │ ├── 2022_06_28_feature_rollouts.py │ │ ├── 2023_03_17_1_allow_disabling_codesnippets.py │ │ ├── 2023_06_08_1_add_github_comment_linking.py │ │ ├── 50ddfc74e23c_add_per_guild_configuration.py │ │ ├── 6a57a6d8d400_.py │ │ ├── 7d2f79cf061c_add_per_guild_issue_linking_config.py │ │ ├── __init__.py │ │ └── d1f327f1548f_.py ├── bot.py ├── command.py ├── config_metadata.py ├── constants.py ├── database │ ├── __init__.py │ ├── base.py │ ├── feature.py │ ├── guild.py │ ├── guild_config.py │ ├── package.py │ └── rollouts.py ├── errors.py ├── exts │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── realpython.py │ │ ├── stackoverflow.py │ │ └── wikipedia.py │ ├── backend │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── bot_features.py │ │ ├── error_handler.py │ │ ├── extensions.py │ │ ├── global_checks.py │ │ ├── guild_config.py │ │ ├── help.py │ │ ├── logging.py │ │ ├── rollouts.py │ │ └── uptime.py │ ├── eval │ │ └── __init__.py │ ├── filters │ │ ├── __init__.py │ │ ├── token_remover.py │ │ └── webhook_remover.py │ ├── info │ │ ├── __init__.py │ │ ├── _global_source_snekcode.py │ │ ├── codeblock │ │ │ ├── __init__.py │ │ │ ├── _cog.py │ │ │ ├── _instructions.py │ │ │ └── _parsing.py │ │ ├── codeblock_commands.py │ │ ├── codesnippets.py │ │ ├── colour.py │ │ ├── discord.py │ │ ├── docs │ │ │ ├── __init__.py │ │ │ ├── _batch_parser.py │ │ │ ├── _cog.py │ │ │ ├── _html.py │ │ │ └── _redis_cache.py │ │ ├── github_info.py │ │ ├── global_source.py │ │ ├── meta.py │ │ ├── pep.py │ │ ├── pypi.py │ │ ├── python_discourse.py │ │ ├── ruff.py │ │ ├── source.py │ │ ├── utils.py │ │ └── xkcd.py │ └── utils │ │ ├── __init__.py │ │ ├── bookmark.py │ │ ├── delete.py │ │ ├── dev_tools.py │ │ ├── status_codes.py │ │ └── timed.py ├── group.py ├── log.py ├── metadata.py ├── monkey_patches.py ├── resources │ ├── __init__.py │ ├── ruff_rules.json │ └── ryanzec_colours.json ├── statsd.py └── utils │ ├── __init__.py │ ├── caching.py │ ├── converters.py │ ├── extensions.py │ ├── features.py │ ├── function.py │ ├── helpers.py │ ├── html_parsing.py │ ├── inventory_parser.py │ ├── lock.py │ ├── markdown.py │ ├── messages.py │ ├── pagination.py │ ├── responses.py │ ├── rollouts.py │ ├── scheduling.py │ └── services.py ├── poetry.lock ├── pyproject.toml └── task.env /.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude everything 2 | * 3 | 4 | # Make exceptions for what's needed 5 | !alembic.ini 6 | !monty/ 7 | !pyproject.toml 8 | !poetry.lock 9 | !LICENSE 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | monty/resources/ruff_rules.json linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [onerandomusername] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | # Workflow files in .github/workflows will be checked 7 | directory: "/" 8 | schedule: 9 | interval: "daily" 10 | open-pull-requests-limit: 50 11 | 12 | - package-ecosystem: "pip" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | time: "00:00" 17 | timezone: "Etc/GMT+5" 18 | open-pull-requests-limit: 50 19 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Lint, Test, Build"] 6 | branches: 7 | - main 8 | types: 9 | - completed 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | permissions: 16 | actions: read 17 | contents: read 18 | deployments: read 19 | packages: read 20 | 21 | jobs: 22 | build: 23 | environment: production 24 | if: github.event.workflow_run.conclusion == 'success' 25 | name: Deploy to k8s 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Create SHA Container Tag 30 | id: sha_tag 31 | run: | 32 | tag=$(cut -c 1-7 <<< $GITHUB_SHA) 33 | echo "tag=$tag" >> $GITHUB_OUTPUT 34 | - name: Checkout code 35 | uses: actions/checkout@v4 36 | with: 37 | repository: onerandomusername/kubernetes 38 | token: ${{ secrets.REPO_TOKEN }} 39 | 40 | - name: Install kubectl 41 | uses: azure/setup-kubectl@v4 42 | with: 43 | version: 'latest' 44 | 45 | - name: Authenticate with Kubernetes 46 | uses: azure/k8s-set-context@v4 47 | with: 48 | method: kubeconfig 49 | kubeconfig: ${{ secrets.KUBECONFIG }} 50 | 51 | - name: Login to Github Container Registry 52 | uses: docker/login-action@v3 53 | with: 54 | registry: ghcr.io 55 | username: ${{ github.repository_owner }} 56 | password: ${{ secrets.GITHUB_TOKEN }} 57 | 58 | - name: Deploy to Kubernetes 59 | uses: azure/k8s-deploy@v5 60 | with: 61 | manifests: | 62 | monty/deployment.yaml 63 | images: 'ghcr.io/onerandomusername/monty-python:${{ steps.sha_tag.outputs.tag }}' 64 | # annotate-namespace: false 65 | # strategy: 'none' 66 | -------------------------------------------------------------------------------- /.github/workflows/lint_test_build.yaml: -------------------------------------------------------------------------------- 1 | # Github Action Workflow enforcing our code style and running tests. 2 | 3 | # misnomer, but not worth changing it 4 | # still not worth changing imo 5 | name: Lint, Test, Build 6 | 7 | # Trigger the workflow on both push (to the main repository) 8 | # and pull requests (against the main repository, but from any repo). 9 | on: 10 | push: 11 | branches: 12 | - main 13 | pull_request: 14 | 15 | # Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. 16 | # It is useful for pull requests coming from the main repository since both triggers will match. 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.repository }}-${{ github.ref }} 19 | cancel-in-progress: false 20 | 21 | permissions: 22 | read-all 23 | 24 | env: 25 | # Configure pip to cache dependencies and do a user install 26 | PIP_NO_CACHE_DIR: false 27 | PIP_USER: 1 28 | PYTHON_VERSION: '3.10' 29 | 30 | # Specify explicit paths for python dependencies and the pre-commit 31 | # environment so we know which directories to cache 32 | POETRY_CACHE_DIR: ${{ github.workspace }}/.cache/py-user-base 33 | PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base 34 | PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache 35 | 36 | jobs: 37 | lint: 38 | runs-on: ubuntu-latest 39 | strategy: 40 | fail-fast: false 41 | steps: 42 | - name: Add custom PYTHONUSERBASE to PATH 43 | run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH 44 | 45 | # Checks out the repository in the current folder. 46 | - name: Checks out repository 47 | uses: actions/checkout@v4 48 | 49 | # Set up the right version of Python 50 | - name: Set up Python ${{ env.PYTHON_VERSION }} 51 | id: python 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: ${{ env.PYTHON_VERSION }} 55 | 56 | # This step caches our Python dependencies. To make sure we 57 | # only restore a cache when the dependencies, the python version, 58 | # the runner operating system, and the dependency location haven't 59 | # changed, we create a cache key that is a composite of those states. 60 | # 61 | # Only when the context is exactly the same, we will restore the cache. 62 | - name: Python Dependency Caching 63 | uses: actions/cache@v4 64 | id: python_cache 65 | with: 66 | path: ${{ env.PYTHONUSERBASE }} 67 | key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ 68 | ${{ steps.python.outputs.python-version }}-\ 69 | ${{ hashFiles('./pyproject.toml', './poetry.lock') }}" 70 | 71 | # Install our dependencies if we did not restore a dependency cache 72 | - name: Install dependencies using poetry 73 | # if: steps.python_cache.outputs.cache-hit != 'true' 74 | run: | 75 | pip install poetry==1.7.1 poetry-plugin-export==1.6.0 76 | poetry install --no-interaction --no-ansi 77 | 78 | # This step caches our pre-commit environment. To make sure we 79 | # do create a new environment when our pre-commit setup changes, 80 | # we create a cache key based on relevant factors. 81 | - name: Pre-commit Environment Caching 82 | uses: actions/cache@v4 83 | with: 84 | path: ${{ env.PRE_COMMIT_HOME }} 85 | key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ 86 | ${{ steps.python.outputs.python-version }}-\ 87 | ${{ hashFiles('./.pre-commit-config.yaml') }}" 88 | 89 | # We will not run `black` here, as we will use a seperate 90 | # black action. As pre-commit does not support user installs, 91 | # we set PIP_USER=0 to not do a user install. 92 | - name: Run pre-commit hooks 93 | id: pre-commit 94 | run: export PIP_USER=0; SKIP="no-commit-to-branch,black" poetry run pre-commit run --all-files 95 | 96 | # Run black seperately as we don't want to reformat the files 97 | # just error if something isn't formatted correctly. 98 | - name: Check files with black 99 | id: black 100 | if: always() && (steps.pre-commit.outcome == 'success' || steps.pre-commit.outcome == 'failure') 101 | run: poetry run black . --check --diff --color 102 | 103 | build: 104 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 105 | name: Build & Push 106 | needs: [lint] 107 | runs-on: ubuntu-latest 108 | permissions: 109 | packages: write 110 | 111 | steps: 112 | # Create a commit SHA-based tag for the container repositories 113 | - name: Create SHA Container Tag 114 | id: sha_tag 115 | run: | 116 | tag=$(cut -c 1-7 <<< $GITHUB_SHA) 117 | echo "tag=$tag" >> $GITHUB_OUTPUT 118 | # Check out the current repository in the `monty` subdirectory 119 | - name: Checkout code 120 | uses: actions/checkout@v4 121 | with: 122 | path: monty 123 | 124 | - name: Set up Docker Buildx 125 | uses: docker/setup-buildx-action@v3 126 | 127 | - name: Login to Github Container Registry 128 | uses: docker/login-action@v3 129 | with: 130 | registry: ghcr.io 131 | username: ${{ github.repository_owner }} 132 | password: ${{ secrets.GITHUB_TOKEN }} 133 | 134 | # Build and push the container to the GitHub Container 135 | # Repository. The container will be tagged as "latest" 136 | # and with the short SHA of the commit. 137 | - name: Build and push 138 | uses: docker/build-push-action@v6 139 | with: 140 | context: monty/ 141 | file: monty/Dockerfile 142 | push: true 143 | cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/monty-python:latest 144 | cache-to: type=inline 145 | tags: | 146 | ghcr.io/${{ github.repository_owner }}/monty-python:latest 147 | ghcr.io/${{ github.repository_owner }}/monty-python:${{ steps.sha_tag.outputs.tag }} 148 | build-args: | 149 | git_sha=${{ github.sha }} 150 | 151 | 152 | artifact: 153 | name: Generate Artifact 154 | if: always() 155 | runs-on: ubuntu-latest 156 | steps: 157 | # Prepare the Pull Request Payload artifact. If this fails, we 158 | # we fail silently using the `continue-on-error` option. It's 159 | # nice if this succeeds, but if it fails for any reason, it 160 | # does not mean that our lint-test checks failed. 161 | - name: Prepare Pull Request Payload artifact 162 | id: prepare-artifact 163 | if: always() && github.event_name == 'pull_request' 164 | continue-on-error: true 165 | run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json 166 | 167 | # This only makes sense if the previous step succeeded. To 168 | # get the original outcome of the previous step before the 169 | # `continue-on-error` conclusion is applied, we use the 170 | # `.outcome` value. This step also fails silently. 171 | - name: Upload a Build Artifact 172 | if: always() && steps.prepare-artifact.outcome == 'success' 173 | continue-on-error: true 174 | uses: actions/upload-artifact@v4 175 | with: 176 | name: pull-request-payload 177 | path: pull_request_payload.json 178 | -------------------------------------------------------------------------------- /.github/workflows/sentry_release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Sentry release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | create_sentry_release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Create a Sentry.io release 20 | uses: tclindner/sentry-releases-action@v1.3.0 21 | env: 22 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 23 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 24 | SENTRY_PROJECT: monty-python 25 | with: 26 | tagName: ${{ github.sha }} 27 | environment: production 28 | releaseNamePrefix: monty@ 29 | -------------------------------------------------------------------------------- /.github/workflows/status_embed.yaml: -------------------------------------------------------------------------------- 1 | # Sends a status embed to a discord webhook 2 | 3 | name: Status Embed 4 | 5 | on: 6 | workflow_run: 7 | workflows: 8 | - Lint, Test, Build 9 | types: 10 | - completed 11 | 12 | permissions: 13 | read-all 14 | 15 | jobs: 16 | status_embed: 17 | name: Send Status Embed to Discord 18 | runs-on: ubuntu-latest 19 | if: ${{ !endsWith(github.actor, '[bot]') }} 20 | 21 | steps: 22 | # Process the artifact uploaded in the `pull_request`-triggered workflow: 23 | - name: Get Pull Request Information 24 | id: pr_info 25 | if: github.event.workflow_run.event == 'pull_request' 26 | run: | 27 | curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json 28 | DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') 29 | [ -z "$DOWNLOAD_URL" ] && exit 1 30 | curl -sSL -H "Authorization: token $GITHUB_TOKEN" -o pull_request_payload.zip $DOWNLOAD_URL || exit 2 31 | unzip -p pull_request_payload.zip > pull_request_payload.json 32 | [ -s pull_request_payload.json ] || exit 3 33 | echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 34 | echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 35 | echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 36 | echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | # Send an informational status embed to Discord instead of the 41 | # standard embeds that Discord sends. This embed will contain 42 | # more information and we can fine tune when we actually want 43 | # to send an embed. 44 | - name: GitHub Actions Status Embed for Discord 45 | uses: SebastiaanZ/github-status-embed-for-discord@v0.3.0 46 | with: 47 | # Webhook token 48 | webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} 49 | webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} 50 | 51 | # We need to provide the information of the workflow that 52 | # triggered this workflow instead of this workflow. 53 | workflow_name: ${{ github.event.workflow_run.name }} 54 | run_id: ${{ github.event.workflow_run.id }} 55 | run_number: ${{ github.event.workflow_run.run_number }} 56 | status: ${{ github.event.workflow_run.conclusion }} 57 | actor: ${{ github.actor }} 58 | repository: ${{ github.repository }} 59 | ref: ${{ github.ref }} 60 | sha: ${{ github.event.workflow_run.head_sha }} 61 | 62 | # Now we can use the information extracted in the previous step: 63 | pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} 64 | pr_number: ${{ steps.pr_info.outputs.pr_number }} 65 | pr_title: ${{ steps.pr_info.outputs.pr_title }} 66 | pr_source: ${{ steps.pr_info.outputs.pr_source }} 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | .venv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # mypy 100 | .mypy_cache/ 101 | 102 | # PyCharm 103 | .idea/ 104 | 105 | # VSCode 106 | .vscode/ 107 | 108 | # Vagrant 109 | .vagrant 110 | 111 | # Logfiles 112 | log.* 113 | *.log.* 114 | !log.py 115 | 116 | # Custom user configuration 117 | config.yml 118 | docker-compose.override.yml 119 | 120 | # xmlrunner unittest XML reports 121 | TEST-**.xml 122 | 123 | # Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder 124 | .DS_Store 125 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ## Pre-commit setup 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: check-case-conflict 8 | - id: check-json 9 | - id: check-toml 10 | - id: check-yaml 11 | # - id: pretty-format-json 12 | # args: [--indent=4, --autofix] 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | args: [--fix=lf] 16 | - id: trailing-whitespace 17 | args: [--markdown-linebreak-ext=md] 18 | 19 | - repo: https://github.com/pre-commit/pygrep-hooks 20 | rev: v1.10.0 21 | hooks: 22 | - id: python-check-blanket-noqa 23 | - id: python-use-type-annotations 24 | 25 | - repo: https://github.com/PyCQA/isort 26 | rev: 5.13.2 27 | hooks: 28 | - id: isort 29 | 30 | - repo: https://github.com/psf/black 31 | rev: 24.10.0 32 | hooks: 33 | - id: black 34 | language_version: python3 35 | 36 | - repo: https://github.com/charliermarsh/ruff-pre-commit 37 | rev: v0.7.4 38 | hooks: 39 | - id: ruff 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | # Set pip to have cleaner logs and no saved cache 4 | ENV PIP_NO_CACHE_DIR=false 5 | 6 | # Create the working directory 7 | WORKDIR /bot 8 | 9 | # Install project dependencies 10 | 11 | # as we have a git dep, install git 12 | RUN apt update && apt install git -y 13 | 14 | RUN pip install -U pip wheel setuptools 15 | RUN pip install poetry==1.7.1 poetry-plugin-export==1.6.0 16 | 17 | # export requirements after copying req files 18 | COPY pyproject.toml poetry.lock ./ 19 | RUN poetry export --without-hashes > requirements.txt 20 | RUN pip uninstall poetry -y 21 | RUN pip install -Ur requirements.txt 22 | 23 | # Set SHA build argument 24 | ARG git_sha="main" 25 | ENV GIT_SHA=$git_sha 26 | 27 | # Copy the source code in next to last to optimize rebuilding the image 28 | COPY . . 29 | 30 | # install the package using pep 517 31 | RUN pip install . --no-deps 32 | 33 | ENTRYPOINT ["python3", "-m", "monty"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 aru 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE_THIRD_PARTY: -------------------------------------------------------------------------------- 1 | This project was initially based off of two projects by Python Discord. 2 | - https://github.com/python-discord/bot 3 | - https://github.com/python-discord/sir-lancebot 4 | 5 | ------------------------------------------------------------------------ 6 | 7 | MIT License 8 | 9 | Copyright (c) 2018 Python Discord 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monty Python 2 | 3 | Based off of multiple open source projects, Monty is a development tool for Discord servers. See [third party licensing](./LICENSE_THIRD_PARTY) for the original projects. 4 | 5 | ### Primary features 6 | `/docs` View Python documentation from discord \ 7 | `/pep` View PEPs directly within discord \ 8 | `-eval` Evaluate Python code \ 9 | `-black` Blacken Python code 10 | 11 | ### Additional features 12 | - Automatic GitHub issue linking 13 | - Inline Python Docs and Python Code evaluation 14 | - Automatic leaked Discord Webhook deletion 15 | - Missing python codebloc detection 16 | - PyPI commands to interface with packages on the Python Package Index 17 | 18 | Click [here](https://discord.com/oauth2/authorize?client_id=872576125384147005&scope=bot+applications.commands&permissions=395204488384) to invite the public instance of Monty to your Discord server. 19 | 20 | ## Running Locally 21 | 22 | Monty uses quite a few services to run. However, these have been consolidated into the [docker-compose.yaml](./docker-compose.yaml) file. 23 | 24 | To deploy, first clone this repo. 25 | 26 | Minimally, Monty can run with just a bot token, but a few more variables are recommended for a more full experience. 27 | 28 | ```sh 29 | # required 30 | BOT_TOKEN= 31 | 32 | # optional but recommended 33 | GITHUB_TOKEN= # Generate this in github's api token settings. This does not need any special permissions 34 | ``` 35 | 36 | From this point, just run `docker compose up` to start all of the services. Snekbox is optional, and the bot will function without snekbox. 37 | 38 | Some services will not work, but the majority will. 39 | 40 | ## Contact 41 | 42 | For support or to contact the developer, please join the [Support Server](https://discord.gg/mPscM4FjWB). 43 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = monty/alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | [post_write_hooks] 56 | # post_write_hooks defines scripts or Python functions that are run 57 | # on newly generated revision scripts. See the documentation for further 58 | # detail and examples 59 | 60 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 61 | hooks = black 62 | black.type = console_scripts 63 | black.entrypoint = black 64 | black.options = -l 100 REVISION_SCRIPT_FILENAME 65 | 66 | # Logging configuration 67 | [loggers] 68 | keys = root,sqlalchemy,alembic 69 | 70 | [handlers] 71 | keys = console 72 | 73 | [formatters] 74 | keys = generic 75 | 76 | [logger_root] 77 | level = WARN 78 | handlers = console 79 | qualname = 80 | 81 | [logger_sqlalchemy] 82 | level = WARN 83 | handlers = 84 | qualname = sqlalchemy.engine 85 | 86 | [logger_alembic] 87 | level = INFO 88 | handlers = 89 | qualname = alembic 90 | 91 | [handler_console] 92 | class = StreamHandler 93 | args = (sys.stderr,) 94 | level = NOTSET 95 | formatter = generic 96 | 97 | [formatter_generic] 98 | format = %(levelname)-5.5s [%(name)s] %(message)s 99 | datefmt = %H:%M:%S 100 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | x-restart-policy: &restart_policy 2 | restart: unless-stopped 3 | 4 | services: 5 | postgres: 6 | << : *restart_policy 7 | image: postgres:13-alpine 8 | ports: 9 | - "127.0.0.1:5432:5432" 10 | environment: 11 | POSTGRES_DB: monty 12 | POSTGRES_PASSWORD: monty 13 | POSTGRES_USER: monty 14 | healthcheck: 15 | test: ["CMD-SHELL", "pg_isready -U monty -d monty"] 16 | interval: 1s 17 | timeout: 1s 18 | retries: 10 19 | 20 | redis: 21 | << : *restart_policy 22 | image: redis:latest 23 | ports: 24 | - "127.0.0.1:6379:6379" 25 | command: 26 | --save 60 1 27 | volumes: 28 | - redis:/data 29 | healthcheck: 30 | test: ["CMD-SHELL", "[ $$(redis-cli ping) = 'PONG' ]"] 31 | interval: 1s 32 | timeout: 1s 33 | retries: 5 34 | 35 | snekbox: 36 | << : *restart_policy 37 | image: ghcr.io/onerandomusername/snekbox:latest 38 | hostname: snekbox 39 | privileged: true 40 | ports: 41 | - "127.0.0.1:8060:8060" 42 | init: true 43 | ipc: none 44 | environment: 45 | PYTHONDONTWRITEBYTECODE: 1 46 | volumes: 47 | - user-base:/snekbox/user_base 48 | 49 | monty: 50 | << : *restart_policy 51 | container_name: monty-python 52 | image: ghcr.io/onerandomusername/monty-python:latest 53 | build: 54 | context: . 55 | dockerfile: Dockerfile 56 | tty: true 57 | 58 | depends_on: 59 | postgres: 60 | condition: service_healthy 61 | restart: true 62 | redis: 63 | condition: service_healthy 64 | 65 | environment: 66 | - REDIS_URI=redis://redis:6379 67 | - DB_BIND=postgresql+asyncpg://monty:monty@postgres:5432/monty 68 | - USE_FAKEREDIS=false 69 | - SNEKBOX_URL=http://snekbox:8060/ 70 | 71 | env_file: 72 | - .env 73 | 74 | volumes: 75 | - .:/bot 76 | 77 | volumes: 78 | user-base: 79 | redis: 80 | -------------------------------------------------------------------------------- /monty/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import dotenv 3 | except ModuleNotFoundError: 4 | pass 5 | else: 6 | if dotenv.find_dotenv(): 7 | print("Found .env file, loading environment variables from it.") # noqa: T201 8 | dotenv.load_dotenv(override=True) 9 | 10 | 11 | import asyncio 12 | import logging 13 | import os 14 | from functools import partial, partialmethod 15 | 16 | import sentry_sdk 17 | from disnake.ext import commands 18 | from sentry_sdk.integrations.logging import LoggingIntegration 19 | from sentry_sdk.integrations.redis import RedisIntegration 20 | 21 | #################### 22 | # NOTE: do not import any other modules before the `log.setup()` call 23 | #################### 24 | from monty import log 25 | 26 | 27 | sentry_logging = LoggingIntegration( 28 | level=5, # this is the same as logging.TRACE 29 | event_level=logging.WARNING, 30 | ) 31 | 32 | sentry_sdk.init( 33 | dsn=os.environ.get("SENTRY_DSN"), 34 | integrations=[ 35 | sentry_logging, 36 | RedisIntegration(), 37 | ], 38 | release=f"monty@{os.environ.get('GIT_SHA', 'dev')}", 39 | ) 40 | 41 | log.setup() 42 | 43 | 44 | from monty import monkey_patches # noqa: E402 # we need to set up logging before importing anything else 45 | 46 | 47 | # On Windows, the selector event loop is required for aiodns. 48 | if os.name == "nt": 49 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 50 | 51 | # Monkey-patch discord.py decorators to use the both the Command and Group subclasses which supports root aliases. 52 | # Must be patched before any cogs are added. 53 | commands.command = partial(commands.command, cls=monkey_patches.Command) 54 | commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=monkey_patches.Command) # type: ignore 55 | 56 | commands.group = partial(commands.group, cls=monkey_patches.Group) 57 | commands.GroupMixin.group = partialmethod(commands.GroupMixin.group, cls=monkey_patches.Group) # type: ignore 58 | -------------------------------------------------------------------------------- /monty/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import signal 5 | import sys 6 | 7 | import alembic.command 8 | import alembic.config 9 | import cachingutils 10 | import cachingutils.redis 11 | import disnake 12 | import redis 13 | import redis.asyncio 14 | from disnake.ext import commands 15 | from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine 16 | 17 | import monty.alembic 18 | from monty import constants, monkey_patches 19 | from monty.bot import Monty 20 | 21 | 22 | log = logging.getLogger(__name__) 23 | _intents = disnake.Intents.all() 24 | _intents.members = False 25 | _intents.presences = False 26 | _intents.bans = False 27 | _intents.integrations = False 28 | _intents.invites = False 29 | _intents.typing = False 30 | _intents.webhooks = False 31 | _intents.voice_states = False 32 | 33 | 34 | def run_upgrade(connection: AsyncConnection, cfg: alembic.config.Config) -> None: 35 | """Run alembic upgrades.""" 36 | cfg.attributes["connection"] = connection 37 | alembic.command.upgrade(cfg, "head") 38 | 39 | 40 | async def run_async_upgrade(engine: AsyncEngine) -> None: 41 | """Run alembic upgrades but async.""" 42 | alembic_cfg = alembic.config.Config() 43 | alembic_cfg.set_main_option("script_location", os.path.dirname(monty.alembic.__file__)) 44 | async with engine.connect() as conn: 45 | await conn.run_sync(run_upgrade, alembic_cfg) 46 | 47 | 48 | async def run_alembic() -> None: 49 | """Run alembic migrations.""" 50 | engine = create_async_engine(constants.Database.postgres_bind) 51 | await run_async_upgrade(engine) 52 | 53 | 54 | async def main() -> None: 55 | """Create and run the bot.""" 56 | disnake.Embed.set_default_colour(constants.Colours.python_yellow) 57 | monkey_patches.patch_typing() 58 | monkey_patches.patch_inter_send() 59 | 60 | # we make our redis session here and pass it to cachingutils 61 | if constants.RedisConfig.use_fakeredis: 62 | try: 63 | import fakeredis 64 | import fakeredis.aioredis 65 | except ImportError as e: 66 | raise RuntimeError("fakeredis must be installed to use fake redis") from e 67 | redis_session = fakeredis.aioredis.FakeRedis.from_url(constants.RedisConfig.uri) 68 | else: 69 | pool = redis.asyncio.BlockingConnectionPool.from_url( 70 | constants.RedisConfig.uri, 71 | max_connections=20, 72 | timeout=300, 73 | ) 74 | redis_session = redis.asyncio.Redis(connection_pool=pool) 75 | 76 | cachingutils.redis.async_session( 77 | constants.Client.config_prefix, session=redis_session, prefix=constants.RedisConfig.prefix 78 | ) 79 | 80 | # run alembic migrations 81 | if constants.Database.run_migrations: 82 | log.info(f"Running database migrations to target {constants.Database.migration_target}") 83 | await run_alembic() 84 | else: 85 | log.warning("Not running database migrations per environment settings.") 86 | 87 | # ping redis 88 | await redis_session.ping() 89 | 90 | command_sync_flags = commands.CommandSyncFlags( 91 | allow_command_deletion=False, 92 | sync_guild_commands=True, 93 | sync_global_commands=True, 94 | sync_commands_debug=True, 95 | sync_on_cog_actions=True, 96 | ) 97 | 98 | kwargs = {} 99 | if constants.Client.proxy is not None: 100 | kwargs["proxy"] = constants.Client.proxy 101 | 102 | bot = Monty( 103 | redis_session=redis_session, 104 | command_prefix=constants.Client.default_command_prefix, 105 | activity=disnake.Game(name=f"Commands: {constants.Client.default_command_prefix}help"), 106 | allowed_mentions=disnake.AllowedMentions(everyone=False), 107 | intents=_intents, 108 | command_sync_flags=command_sync_flags, 109 | **kwargs, 110 | ) 111 | 112 | try: 113 | bot.load_extensions() 114 | except Exception: 115 | await bot.close() 116 | raise 117 | 118 | loop = asyncio.get_running_loop() 119 | 120 | future: asyncio.Future = asyncio.ensure_future(bot.start(constants.Client.token or ""), loop=loop) 121 | loop.add_signal_handler(signal.SIGINT, lambda: future.cancel()) 122 | loop.add_signal_handler(signal.SIGTERM, lambda: future.cancel()) 123 | try: 124 | await future 125 | except asyncio.CancelledError: 126 | log.info("Received signal to terminate bot and event loop.") 127 | finally: 128 | if not bot.is_closed(): 129 | await bot.close() 130 | 131 | 132 | if __name__ == "__main__": 133 | sys.exit(asyncio.run(main())) 134 | -------------------------------------------------------------------------------- /monty/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /monty/alembic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onerandomusername/monty-python/57f66e7147d659523e11b09402bc495fa8abd517/monty/alembic/__init__.py -------------------------------------------------------------------------------- /monty/alembic/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import pool 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import async_engine_from_config 8 | 9 | from monty import constants 10 | from monty.database.base import Base 11 | 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | if config.config_file_name is not None: 20 | fileConfig(config.config_file_name) 21 | 22 | # add your model's MetaData object here 23 | # for 'autogenerate' support 24 | # from myapp import mymodel 25 | # target_metadata = mymodel.Base.metadata 26 | target_metadata = Base.metadata 27 | 28 | # other values from the config, defined by the needs of env.py, 29 | # can be acquired: 30 | # my_important_option = config.get_main_option("my_important_option") 31 | # ... etc. 32 | config.set_main_option("sqlalchemy.url", constants.Database.postgres_bind) 33 | 34 | 35 | def run_migrations_offline() -> None: 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def do_run_migrations(connection: Connection) -> None: 60 | context.configure(connection=connection, target_metadata=target_metadata) 61 | 62 | with context.begin_transaction(): 63 | context.run_migrations() 64 | 65 | 66 | async def run_async_migrations() -> None: 67 | """Run migrations in 'online' mode. 68 | 69 | In this scenario we need to create an Engine 70 | and associate a connection with the context. 71 | 72 | """ 73 | 74 | connectable = async_engine_from_config( 75 | config.get_section(config.config_ini_section, {}), 76 | prefix="sqlalchemy.", 77 | poolclass=pool.NullPool, 78 | ) 79 | 80 | async with connectable.connect() as connection: 81 | await connection.run_sync(do_run_migrations) 82 | 83 | await connectable.dispose() 84 | 85 | 86 | def run_migrations_online() -> None: 87 | """Run migrations in 'online' mode.""" 88 | connectable = config.attributes.get("connection", None) 89 | 90 | if connectable is None: 91 | asyncio.run(run_async_migrations()) 92 | else: 93 | do_run_migrations(connectable) 94 | 95 | 96 | if context.is_offline_mode(): 97 | run_migrations_offline() 98 | else: 99 | run_migrations_online() 100 | -------------------------------------------------------------------------------- /monty/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /monty/alembic/versions/19e4f2aee642_guild_features.py: -------------------------------------------------------------------------------- 1 | """guild features 2 | 3 | Revision ID: 19e4f2aee642 4 | Revises: 7d2f79cf061c 5 | Create Date: 2022-06-25 02:20:19.273814 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | from sqlalchemy.dialects import postgresql 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "19e4f2aee642" 16 | down_revision = "7d2f79cf061c" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "features", 25 | sa.Column("name", sa.String(length=50), nullable=False), 26 | sa.Column("enabled", sa.Boolean(), nullable=True), 27 | sa.PrimaryKeyConstraint("name"), 28 | ) 29 | op.create_table( 30 | "guilds", 31 | sa.Column("id", sa.BigInteger(), nullable=False), 32 | sa.Column("features", postgresql.ARRAY(sa.String(length=50)), server_default="{}", nullable=False), 33 | sa.PrimaryKeyConstraint("id"), 34 | ) 35 | op.add_column("guild_config", sa.Column("guild", sa.BigInteger(), nullable=True)) 36 | op.create_unique_constraint("guild_config_guild_key", "guild_config", ["guild"]) 37 | op.create_foreign_key("fk_guild_config_guilds_id_guild", "guild_config", "guilds", ["guild"], ["id"]) 38 | # ### end Alembic commands ### 39 | 40 | # we want to migrate to the guilds table, so this means creating guilds data 41 | op.execute("INSERT INTO guilds (id) SELECT id FROM guild_config") 42 | 43 | # then we want to update the guild_config guild column with the guilds we just created 44 | op.execute("UPDATE ONLY guild_config SET guild = (SELECT id FROM guilds WHERE guild_config.id = guilds.id)") 45 | 46 | 47 | def downgrade() -> None: 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_constraint("fk_guild_config_guilds_id_guild", "guild_config", type_="foreignkey") 50 | op.drop_constraint("guild_config_guild_key", "guild_config", type_="unique") 51 | op.drop_column("guild_config", "guild") 52 | op.drop_table("guilds") 53 | op.drop_table("features") 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /monty/alembic/versions/2022_06_28_feature_rollouts.py: -------------------------------------------------------------------------------- 1 | """feature rollouts 2 | 3 | Revision ID: 2022_06_28_1 4 | Revises: 19e4f2aee642 5 | Create Date: 2022-06-28 10:46:16.693218 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "2022_06_28_1" 15 | down_revision = "19e4f2aee642" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "rollouts", 24 | sa.Column("id", sa.Integer(), nullable=False), 25 | sa.Column("name", sa.String(length=100), nullable=False), 26 | sa.Column("active", sa.Boolean(), nullable=False), 27 | sa.Column("rollout_by", sa.DateTime(timezone=True), nullable=True), 28 | sa.Column("rollout_to_percent", sa.SmallInteger(), nullable=False), 29 | sa.Column("rollout_hash_low", sa.SmallInteger(), nullable=False), 30 | sa.Column("rollout_hash_high", sa.SmallInteger(), nullable=False), 31 | sa.Column("update_every", sa.SmallInteger(), nullable=False), 32 | sa.Column( 33 | "hashes_last_updated", 34 | sa.DateTime(timezone=True), 35 | server_default=sa.text("now()"), 36 | nullable=False, 37 | ), 38 | sa.PrimaryKeyConstraint("id"), 39 | sa.UniqueConstraint("name"), 40 | ) 41 | op.add_column("features", sa.Column("rollout", sa.Integer(), nullable=True)) 42 | op.create_foreign_key("fk_features_rollouts_id_rollout", "features", "rollouts", ["rollout"], ["id"]) 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade() -> None: 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | op.drop_constraint("fk_features_rollouts_id_rollout", "features", type_="foreignkey") 49 | op.drop_column("features", "rollout") 50 | op.drop_table("rollouts") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /monty/alembic/versions/2023_03_17_1_allow_disabling_codesnippets.py: -------------------------------------------------------------------------------- 1 | """allow disabling codesnippets 2 | 3 | Revision ID: 2023_03_17_1 4 | Revises: 2022_06_28_1 5 | Create Date: 2023-03-17 21:01:37.638503 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "2023_03_17_1" 15 | down_revision = "2022_06_28_1" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column("guild_config", sa.Column("git_file_expansions", sa.Boolean(), nullable=True)) 23 | op.add_column("guild_config", sa.Column("github_issue_linking", sa.Boolean(), nullable=True)) 24 | # ### end Alembic commands ### 25 | 26 | op.execute("UPDATE guild_config SET git_file_expansions = true") 27 | op.execute("UPDATE guild_config SET github_issue_linking = true") 28 | 29 | op.alter_column("guild_config", "git_file_expansions", nullable=False) 30 | op.alter_column("guild_config", "github_issue_linking", nullable=False) 31 | 32 | 33 | def downgrade() -> None: 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_column("guild_config", "git_file_expansions") 36 | op.drop_column("guild_config", "github_issue_linking") 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /monty/alembic/versions/2023_06_08_1_add_github_comment_linking.py: -------------------------------------------------------------------------------- 1 | """add github comment linking 2 | 3 | Revision ID: 2023_06_08_1 4 | Revises: 2023_03_17_1 5 | Create Date: 2023-06-08 16:08:05.842221 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "2023_06_08_1" 15 | down_revision = "2023_03_17_1" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column("guild_config", sa.Column("github_comment_linking", sa.Boolean(), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | op.execute("UPDATE guild_config SET github_comment_linking = true") 26 | 27 | op.alter_column("guild_config", "github_comment_linking", nullable=False) 28 | 29 | 30 | def downgrade() -> None: 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_column("guild_config", "github_comment_linking") 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /monty/alembic/versions/50ddfc74e23c_add_per_guild_configuration.py: -------------------------------------------------------------------------------- 1 | """add per-guild configuration 2 | 3 | Revision ID: 50ddfc74e23c 4 | Revises: d1f327f1548f 5 | Create Date: 2022-05-22 01:56:44.036037 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "50ddfc74e23c" 15 | down_revision = "d1f327f1548f" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "guild_config", 24 | sa.Column("id", sa.BigInteger(), nullable=False), 25 | sa.Column("prefix", sa.String(length=50), nullable=True), 26 | sa.PrimaryKeyConstraint("id"), 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table("guild_config") 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /monty/alembic/versions/6a57a6d8d400_.py: -------------------------------------------------------------------------------- 1 | """Set up the documentation tables 2 | 3 | Revision ID: 6a57a6d8d400 4 | Revises: 5 | Create Date: 2022-05-20 03:06:27.335042 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | from sqlalchemy.dialects import postgresql 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "6a57a6d8d400" 16 | down_revision = None 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "docs_inventory", 25 | sa.Column("name", sa.String(length=50), nullable=False), 26 | sa.Column("inventory_url", sa.Text(), nullable=False), 27 | sa.Column("base_url", sa.Text(), nullable=True), 28 | sa.Column("guilds_whitelist", postgresql.ARRAY(sa.BigInteger()), nullable=True), 29 | sa.Column("guilds_blacklist", postgresql.ARRAY(sa.BigInteger()), nullable=True), 30 | sa.PrimaryKeyConstraint("name"), 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade() -> None: 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table("docs_inventory") 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /monty/alembic/versions/7d2f79cf061c_add_per_guild_issue_linking_config.py: -------------------------------------------------------------------------------- 1 | """add per-guild issue linking config 2 | 3 | Revision ID: 7d2f79cf061c 4 | Revises: 50ddfc74e23c 5 | Create Date: 2022-05-22 04:25:19.100644 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "7d2f79cf061c" 15 | down_revision = "50ddfc74e23c" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column("guild_config", sa.Column("github_issues_org", sa.String(length=39), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade() -> None: 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column("guild_config", "github_issues_org") 29 | # ### end Alembic commands ### 30 | -------------------------------------------------------------------------------- /monty/alembic/versions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onerandomusername/monty-python/57f66e7147d659523e11b09402bc495fa8abd517/monty/alembic/versions/__init__.py -------------------------------------------------------------------------------- /monty/alembic/versions/d1f327f1548f_.py: -------------------------------------------------------------------------------- 1 | """Add hidden field to docs_inventory 2 | 3 | Revision ID: d1f327f1548f 4 | Revises: 6a57a6d8d400 5 | Create Date: 2022-05-20 04:59:09.975649 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "d1f327f1548f" 15 | down_revision = "6a57a6d8d400" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column("docs_inventory", sa.Column("hidden", sa.Boolean(), server_default="false", nullable=False)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade() -> None: 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column("docs_inventory", "hidden") 29 | # ### end Alembic commands ### 30 | -------------------------------------------------------------------------------- /monty/command.py: -------------------------------------------------------------------------------- 1 | from disnake.ext import commands 2 | 3 | 4 | class Command(commands.Command): 5 | """ 6 | A `discord.ext.commands.Command` subclass which supports root aliases. 7 | 8 | A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as 9 | top-level commands rather than being aliases of the command's group. It's stored as an attribute 10 | also named `root_aliases`. 11 | """ 12 | 13 | def __init__(self, *args, **kwargs) -> None: 14 | super().__init__(*args, **kwargs) 15 | self.root_aliases = kwargs.get("root_aliases", []) 16 | 17 | if not isinstance(self.root_aliases, (list, tuple)): 18 | raise TypeError("Root aliases of a command must be a list or a tuple of strings.") 19 | -------------------------------------------------------------------------------- /monty/config_metadata.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass, field 3 | from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Type, TypeVar, Union 4 | 5 | import aiohttp 6 | import disnake 7 | from disnake import Locale 8 | from disnake.ext import commands 9 | 10 | from monty.constants import Feature 11 | 12 | 13 | if TYPE_CHECKING: 14 | from monty.bot import Monty 15 | 16 | 17 | __all__ = ("METADATA",) 18 | 19 | GITHUB_ORG_REGEX = re.compile(r"[a-zA-Z0-9\-]{1,}") 20 | 21 | VALID_CONFIG_TYPES = Union[str, bool, float, int] 22 | T = TypeVar("T", bound=VALID_CONFIG_TYPES) 23 | AnyContext = Union[disnake.ApplicationCommandInteraction, commands.Context["Monty"]] 24 | 25 | 26 | async def validate_github_org(ctx: AnyContext, arg: T) -> Optional[T]: 27 | """Validate all GitHub orgs meet GitHub's naming requirements.""" 28 | if not arg: 29 | return None 30 | if not GITHUB_ORG_REGEX.fullmatch(arg): 31 | err = f"The GitHub org '{arg}' is not a valid GitHub organisation name." 32 | raise ValueError(err) 33 | 34 | try: 35 | r = await ctx.bot.http_session.head(f"https://github.com/{arg}", raise_for_status=True) 36 | except aiohttp.ClientResponseError: 37 | raise commands.UserInputError( 38 | "Organisation must be a valid GitHub user or organisation. Please check the provided account exists on" 39 | " GitHub and try again." 40 | ) from None 41 | else: 42 | r.close() 43 | return arg 44 | 45 | 46 | @dataclass(kw_only=True) 47 | class StatusMessages: 48 | set_attr_success: str = ( # this also can take an `old_setting` parameter 49 | "Successfully set `{name}` to ``{new_setting}``." 50 | ) 51 | set_attr_fail: str = "Could not change `{name}`: {err}" 52 | view_attr_success: str = "`{name}` is currently set to ``{current_setting}``." 53 | view_attr_success_unset: str = "`{name}` is currently unset." # will take a current_setting parameter if needed 54 | clear_attr_success: str = "`{name}` has successfully been reset." 55 | clear_attr_success_with_default: str = "The `{name}` setting has been reset to ``{default}``." 56 | 57 | 58 | @dataclass(kw_only=True) 59 | class ConfigAttrMetadata: 60 | name: Union[str, dict[Locale, str]] 61 | description: Union[str, dict[Locale, str]] 62 | type: Union[Type[str], Type[int], Type[float], Type[bool]] 63 | requires_bot: bool = True 64 | long_description: Optional[str] = None 65 | depends_on_features: Optional[tuple[str]] = None 66 | validator: Optional[Union[Callable, Callable[Any, Coroutine]]] = None 67 | status_messages: StatusMessages = field(default_factory=StatusMessages) 68 | 69 | def __post_init__(self): 70 | if self.type not in (str, int, float, bool): 71 | raise ValueError("type must be one of str, int, float, or bool") 72 | 73 | 74 | METADATA: dict[str, ConfigAttrMetadata] = dict( # noqa: C408 75 | prefix=ConfigAttrMetadata( 76 | type=str, 77 | name="Command Prefix", 78 | description="The prefix used for text based commands.", 79 | ), 80 | github_issues_org=ConfigAttrMetadata( 81 | type=str, 82 | name={ 83 | Locale.en_US: "GitHub Issue Organization", 84 | Locale.en_GB: "GitHub Issue Organisation", 85 | }, 86 | description={ 87 | Locale.en_US: "A specific organization or user to use as the default org for GitHub related commands.", 88 | Locale.en_GB: "A specific organisation or user to use as the default org for GitHub related commands.", 89 | }, 90 | validator=validate_github_org, 91 | ), 92 | git_file_expansions=ConfigAttrMetadata( 93 | type=bool, 94 | name="GitHub/GitLab/BitBucket File Expansions", 95 | description="BitBucket, GitLab, and GitHub automatic file expansions.", 96 | long_description=( 97 | "Automatically expand links to specific lines for GitHub, GitLab, and BitBucket when possible." 98 | ), 99 | ), 100 | github_issue_linking=ConfigAttrMetadata( 101 | type=bool, 102 | name="GitHub Issue Linking", 103 | description="Automatically link GitHub issues if they match the inline markdown syntax on GitHub.", 104 | long_description=( 105 | "Automatically link GitHub issues if they match the inline markdown syntax on GitHub. " 106 | "For example, `onerandomusername/monty-python#223` will provide a link to issue 223." 107 | ), 108 | ), 109 | github_comment_linking=ConfigAttrMetadata( 110 | type=bool, 111 | name="GitHub Comment Linking", 112 | depends_on_features=(Feature.GITHUB_COMMENT_LINKS,), 113 | description="Automatically expand a GitHub comment link. Requires GitHub Issue Linking to have an effect.", 114 | ), 115 | ) 116 | 117 | 118 | # check the config metadata is valid 119 | def _check_config_metadata(metadata: dict[str, ConfigAttrMetadata]) -> None: 120 | for m in metadata.values(): 121 | assert 0 < len(m.description) < 100 122 | 123 | 124 | _check_config_metadata(METADATA) 125 | -------------------------------------------------------------------------------- /monty/database/__init__.py: -------------------------------------------------------------------------------- 1 | from .feature import Feature 2 | from .guild import Guild 3 | from .guild_config import GuildConfig 4 | from .package import PackageInfo 5 | from .rollouts import Rollout 6 | 7 | 8 | __all__ = ( 9 | "Feature", 10 | "Guild", 11 | "GuildConfig", 12 | "PackageInfo", 13 | "Rollout", 14 | ) 15 | -------------------------------------------------------------------------------- /monty/database/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /monty/database/feature.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | import sqlalchemy as sa 5 | from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship, validates 6 | 7 | from .base import Base 8 | from .rollouts import Rollout 9 | 10 | 11 | NAME_REGEX = re.compile(r"^[A-Z_]+$") 12 | 13 | 14 | class Feature(MappedAsDataclass, Base): 15 | """Represents a bot feature.""" 16 | 17 | __tablename__ = "features" 18 | 19 | name: Mapped[str] = mapped_column(sa.String(length=50), primary_key=True) 20 | enabled: Mapped[Optional[bool]] = mapped_column(default=None, server_default=None, nullable=True) 21 | rollout_id: Mapped[Optional[int]] = mapped_column( 22 | sa.ForeignKey("rollouts.id"), default=None, nullable=True, name="rollout" 23 | ) 24 | rollout: Mapped[Optional[Rollout]] = relationship(Rollout, default=None) 25 | 26 | @validates("name") 27 | def validate_name(self, key: str, name: str) -> str: 28 | """Validate the `name` attribute meets the regex requirement.""" 29 | if not NAME_REGEX.fullmatch(name): 30 | err = f"The provided feature name '{name}' does not match the name regex {str(NAME_REGEX)}" 31 | raise ValueError(err) 32 | return name 33 | -------------------------------------------------------------------------------- /monty/database/guild.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import sqlalchemy as sa 4 | from sqlalchemy.ext.mutable import MutableList 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from .base import Base 8 | 9 | 10 | class Guild(Base): 11 | """Represents a Discord guild's enabled bot features.""" 12 | 13 | __tablename__ = "guilds" 14 | 15 | id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True, autoincrement=False) 16 | # todo: this should be a many to many relationship 17 | feature_ids: Mapped[List[str]] = mapped_column( 18 | MutableList.as_mutable(sa.ARRAY(sa.String(length=50))), 19 | name="features", 20 | nullable=False, 21 | default=[], 22 | server_default=r"{}", # noqa: P103 23 | ) 24 | 25 | # features: Mapped[List[Feature]] = relationship(Feature) 26 | -------------------------------------------------------------------------------- /monty/database/guild_config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import sqlalchemy as sa 4 | from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship 5 | 6 | from monty import constants 7 | 8 | from .base import Base 9 | from .guild import Guild 10 | 11 | 12 | # n.b. make sure the metadata in config_metadata stays synced to this file and vice versa 13 | class GuildConfig(MappedAsDataclass, Base): 14 | """Represents a per-guild config.""" 15 | 16 | __tablename__ = "guild_config" 17 | 18 | id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True, autoincrement=False) 19 | guild_id: Mapped[Optional[int]] = mapped_column(sa.ForeignKey("guilds.id"), name="guild", unique=True) 20 | guild: Mapped[Optional[Guild]] = relationship(Guild, default=None) 21 | prefix: Mapped[Optional[str]] = mapped_column( 22 | sa.String(length=50), nullable=True, default=constants.Client.default_command_prefix 23 | ) 24 | github_issues_org: Mapped[Optional[str]] = mapped_column(sa.String(length=39), nullable=True, default=None) 25 | git_file_expansions: Mapped[bool] = mapped_column(sa.Boolean, default=True) 26 | github_issue_linking: Mapped[bool] = mapped_column(sa.Boolean, default=True) 27 | github_comment_linking: Mapped[bool] = mapped_column(sa.Boolean, default=True) 28 | -------------------------------------------------------------------------------- /monty/database/package.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import TYPE_CHECKING, List, Optional 3 | 4 | import sqlalchemy as sa 5 | from sqlalchemy.ext.mutable import MutableList 6 | from sqlalchemy.orm import Mapped, mapped_column, validates 7 | 8 | from .base import Base 9 | 10 | 11 | NAME_REGEX = re.compile(r"^[a-z0-9_]+$") 12 | 13 | if TYPE_CHECKING: 14 | hybrid_property = property 15 | else: 16 | from sqlalchemy.ext.hybrid import hybrid_property 17 | 18 | 19 | class PackageInfo(Base): 20 | """Represents the package information for a documentation inventory.""" 21 | 22 | __tablename__ = "docs_inventory" 23 | 24 | name: Mapped[str] = mapped_column( 25 | sa.String(length=50), 26 | primary_key=True, 27 | ) 28 | inventory_url: Mapped[str] = mapped_column(sa.Text) 29 | _base_url: Mapped[Optional[str]] = mapped_column(sa.Text, nullable=True, default=None, name="base_url") 30 | hidden: Mapped[bool] = mapped_column(sa.Boolean, default=False, server_default="false", nullable=False) 31 | guilds_whitelist: Mapped[Optional[List[int]]] = mapped_column( 32 | MutableList.as_mutable(sa.ARRAY(sa.BigInteger)), 33 | nullable=True, 34 | default=[], 35 | server_default=sa.text("ARRAY[]::bigint[]"), 36 | ) 37 | guilds_blacklist: Mapped[Optional[List[int]]] = mapped_column( 38 | MutableList.as_mutable(sa.ARRAY(sa.BigInteger)), 39 | nullable=True, 40 | default=[], 41 | server_default=sa.text("ARRAY[]::bigint[]"), 42 | ) 43 | 44 | @hybrid_property 45 | def base_url(self) -> str: # noqa: D102 46 | if self._base_url: 47 | return self._base_url 48 | return self.inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/" 49 | 50 | @base_url.setter 51 | def base_url(self, value: Optional[str]) -> None: 52 | self._base_url = value 53 | 54 | @validates("name") 55 | def validate_name(self, key: str, name: str) -> str: 56 | """Validate all names are of the format of valid python package names.""" 57 | if not NAME_REGEX.fullmatch(name): 58 | err = f"The provided package name '{name}' does not match the name regex {str(NAME_REGEX)}" 59 | raise ValueError(err) 60 | return name 61 | -------------------------------------------------------------------------------- /monty/database/rollouts.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | import sqlalchemy as sa 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from .base import Base 8 | 9 | 10 | if TYPE_CHECKING: 11 | pass 12 | 13 | 14 | class Rollout(Base): 15 | """Represents a feature rollout.""" 16 | 17 | __tablename__ = "rollouts" 18 | 19 | id: Mapped[int] = mapped_column(sa.Integer, autoincrement=True, primary_key=True) 20 | name: Mapped[str] = mapped_column(sa.String(length=100), unique=True) 21 | active: Mapped[bool] = mapped_column(sa.Boolean, default=False, nullable=False) 22 | rollout_by: Mapped[Optional[datetime.datetime]] = mapped_column(sa.DateTime(timezone=True), nullable=True) 23 | rollout_to_percent: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False) 24 | rollout_hash_low: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False) 25 | rollout_hash_high: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False) 26 | update_every: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False, default=15) 27 | hashes_last_updated: Mapped[datetime.datetime] = mapped_column( 28 | sa.DateTime(timezone=True), 29 | nullable=False, 30 | default=datetime.datetime.now, 31 | server_default=sa.func.now(), 32 | ) 33 | -------------------------------------------------------------------------------- /monty/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | from typing import Hashable, Optional 5 | 6 | from disnake.ext import commands 7 | 8 | from monty.constants import NEGATIVE_REPLIES 9 | 10 | 11 | class APIError(commands.CommandError): 12 | """Raised when an external API (eg. Wikipedia) returns an error response.""" 13 | 14 | def __init__(self, api: str, status_code: int, error_msg: Optional[str] = None) -> None: 15 | super().__init__(error_msg) 16 | self.api = api 17 | self.status_code = status_code 18 | self.error_msg = error_msg 19 | 20 | @property 21 | def title(self) -> str: 22 | """Return a title embed.""" 23 | return f"Something went wrong with {self.api}" 24 | 25 | 26 | class BotAccountRequired(commands.CheckFailure): 27 | """Raised when the bot needs to be in the guild.""" 28 | 29 | def __init__(self, msg: str) -> None: 30 | self._error_title = "Bot Account Required" 31 | self.msg = msg 32 | 33 | def __str__(self) -> str: 34 | return self.msg 35 | 36 | 37 | class FeatureDisabled(commands.CheckFailure): 38 | """Raised when a feature is attempted to be used that is currently disabled for that guild.""" 39 | 40 | def __init__(self) -> None: 41 | super().__init__("This feature is currently disabled.") 42 | 43 | 44 | class LockedResourceError(RuntimeError): 45 | """ 46 | Exception raised when an operation is attempted on a locked resource. 47 | 48 | Attributes: 49 | `type` -- name of the locked resource's type 50 | `id` -- ID of the locked resource 51 | """ 52 | 53 | def __init__(self, resource_type: str, resource_id: Hashable) -> None: 54 | self.type = resource_type 55 | self.id = resource_id 56 | 57 | super().__init__( 58 | f"Cannot operate on {self.type.lower()} `{self.id}`; " 59 | "it is currently locked and in use by another operation." 60 | ) 61 | 62 | 63 | class MontyCommandError(commands.CommandError): 64 | def __init__(self, message: str, *, title: str = None): 65 | if not title: 66 | title = random.choice(NEGATIVE_REPLIES) 67 | self.title = title 68 | super().__init__(message) 69 | 70 | 71 | class OpenDMsRequired(commands.UserInputError): 72 | 73 | def __init__(self, message: str = None, *args): 74 | self.title = "Open DMs Required" 75 | if message is None: 76 | message = "I must be able to DM you to run this command. Please open your DMs" 77 | super().__init__(message, *args) 78 | -------------------------------------------------------------------------------- /monty/exts/__init__.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | from typing import Iterator 3 | 4 | from monty.log import get_logger 5 | 6 | 7 | __all__ = ("get_package_names",) 8 | 9 | log = get_logger(__name__) 10 | 11 | 12 | def get_package_names() -> Iterator[str]: 13 | """Iterate names of all packages located in /monty/exts/.""" 14 | for package in pkgutil.iter_modules(__path__): 15 | if package.ispkg: 16 | yield package.name 17 | -------------------------------------------------------------------------------- /monty/exts/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onerandomusername/monty-python/57f66e7147d659523e11b09402bc495fa8abd517/monty/exts/api/__init__.py -------------------------------------------------------------------------------- /monty/exts/api/realpython.py: -------------------------------------------------------------------------------- 1 | from html import unescape 2 | from urllib.parse import quote_plus 3 | 4 | import disnake 5 | from disnake.ext import commands 6 | 7 | from monty import bot 8 | from monty.constants import Colours 9 | from monty.errors import APIError, MontyCommandError 10 | from monty.log import get_logger 11 | 12 | 13 | logger = get_logger(__name__) 14 | 15 | 16 | API_ROOT = "https://realpython.com/search/api/v1/" 17 | ARTICLE_URL = "https://realpython.com{article_url}" 18 | SEARCH_URL = "https://realpython.com/search?q={user_search}" 19 | 20 | 21 | ERROR_EMBED = disnake.Embed( 22 | title="Error while searching Real Python", 23 | description="There was an error while trying to reach Real Python. Please try again shortly.", 24 | color=Colours.soft_red, 25 | ) 26 | 27 | 28 | class RealPython(commands.Cog, name="Real Python", slash_command_attrs={"dm_permission": False}): 29 | """User initiated command to search for a Real Python article.""" 30 | 31 | def __init__(self, bot: bot.Monty) -> None: 32 | self.bot = bot 33 | 34 | @commands.command(aliases=["rp"]) 35 | @commands.cooldown(1, 10, commands.cooldowns.BucketType.user) 36 | async def realpython(self, ctx: commands.Context, *, user_search: str) -> None: 37 | """Send 5 articles that match the user's search terms.""" 38 | params = {"q": user_search, "limit": 5} 39 | async with self.bot.http_session.get(url=API_ROOT, params=params) as response: 40 | if response.status != 200: 41 | logger.error(f"Unexpected status code {response.status} from Real Python") 42 | raise APIError( 43 | "Real Python", 44 | response.status, 45 | "Sorry, there was en error while trying to fetch data from the Stackoverflow website. " 46 | "Please try again in some time. " 47 | "If this issue persists, please report this issue in our support server, see link below.", 48 | ) 49 | 50 | data = await response.json() 51 | 52 | articles = data["results"] 53 | 54 | if len(articles) == 0: 55 | raise MontyCommandError( 56 | title=f"No articles found for '{user_search}'", 57 | message="Try broadening your search to show more results.", 58 | ) 59 | 60 | article_embed = disnake.Embed( 61 | title="Search results - Real Python", 62 | url=SEARCH_URL.format(user_search=quote_plus(user_search)), 63 | description="Here are the top 5 results:", 64 | color=Colours.orange, 65 | ) 66 | 67 | for article in articles: 68 | article_embed.add_field( 69 | name=unescape(article["title"]), 70 | value=ARTICLE_URL.format(article_url=article["url"]), 71 | inline=False, 72 | ) 73 | article_embed.set_footer(text="Click the links to go to the articles.") 74 | 75 | await ctx.send(embed=article_embed) 76 | 77 | 78 | def setup(bot: bot.Monty) -> None: 79 | """Load the Real Python Cog.""" 80 | bot.add_cog(RealPython(bot)) 81 | -------------------------------------------------------------------------------- /monty/exts/api/stackoverflow.py: -------------------------------------------------------------------------------- 1 | from html import unescape 2 | from urllib.parse import quote_plus 3 | 4 | import disnake 5 | from disnake.ext import commands 6 | 7 | from monty import bot 8 | from monty.constants import Colours, Emojis 9 | from monty.errors import APIError, MontyCommandError 10 | from monty.log import get_logger 11 | 12 | 13 | logger = get_logger(__name__) 14 | 15 | BASE_URL = "https://api.stackexchange.com/2.2/search/advanced" 16 | SO_PARAMS = {"order": "desc", "sort": "activity", "site": "stackoverflow"} 17 | SEARCH_URL = "https://stackoverflow.com/search?q={query}" 18 | 19 | 20 | class Stackoverflow(commands.Cog, name="Stack Overflow", slash_command_attrs={"dm_permission": False}): 21 | """Contains command to interact with stackoverflow from disnake.""" 22 | 23 | def __init__(self, bot: bot.Monty) -> None: 24 | self.bot = bot 25 | 26 | @commands.command(aliases=["so"]) 27 | @commands.cooldown(1, 15, commands.cooldowns.BucketType.user) 28 | async def stackoverflow(self, ctx: commands.Context, *, search_query: str) -> None: 29 | """Sends the top 5 results of a search query from stackoverflow.""" 30 | params = SO_PARAMS | {"q": search_query} 31 | async with ctx.typing(): 32 | async with self.bot.http_session.get(url=BASE_URL, params=params) as response: 33 | if response.status == 200: 34 | data = await response.json() 35 | else: 36 | logger.error(f"Status code is not 200, it is {response.status}") 37 | raise APIError( 38 | "Stack Overflow", 39 | response.status, 40 | "Sorry, there was an error while trying to fetch data from the StackOverflow website. " 41 | "Please try again in some time. " 42 | "If this issue persists, please report this issue in our support server, see link below.", 43 | ) 44 | if not data["items"]: 45 | raise MontyCommandError( 46 | title="No results found", 47 | message=f"No search results found for `{search_query}`. " 48 | "Try adjusting your search or searching for fewer terms.", 49 | ) 50 | 51 | top5 = data["items"][:5] 52 | encoded_search_query = quote_plus(search_query) 53 | embed = disnake.Embed( 54 | title="Search results - Stackoverflow", 55 | url=SEARCH_URL.format(query=encoded_search_query), 56 | description=f"Here are the top {len(top5)} results:", 57 | color=Colours.orange, 58 | ) 59 | embed.check_limits() 60 | 61 | for item in top5: 62 | embed.add_field( 63 | name=unescape(item["title"]), 64 | value=( 65 | f"[{Emojis.reddit_upvote} {item['score']} " 66 | f"{Emojis.stackoverflow_views} {item['view_count']} " 67 | f"{Emojis.reddit_comments} {item['answer_count']} " 68 | f"{Emojis.stackoverflow_tag} {', '.join(item['tags'][:3])}]" 69 | f"({item['link']})" 70 | ), 71 | inline=False, 72 | ) 73 | try: 74 | embed.check_limits() 75 | except ValueError: 76 | embed.remove_field(-1) 77 | break 78 | 79 | embed.set_footer(text="View the original link for more results.") 80 | 81 | await ctx.send(embed=embed) 82 | 83 | 84 | def setup(bot: bot.Monty) -> None: 85 | """Load the Stackoverflow Cog.""" 86 | bot.add_cog(Stackoverflow(bot)) 87 | -------------------------------------------------------------------------------- /monty/exts/api/wikipedia.py: -------------------------------------------------------------------------------- 1 | import re 2 | from html import unescape 3 | from typing import List 4 | 5 | import disnake 6 | from disnake.ext import commands 7 | 8 | from monty.bot import Monty 9 | from monty.errors import APIError 10 | from monty.log import get_logger 11 | from monty.utils import LinePaginator 12 | from monty.utils.helpers import utcnow 13 | 14 | 15 | log = get_logger(__name__) 16 | 17 | SEARCH_API = "https://en.wikipedia.org/w/api.php" 18 | WIKI_PARAMS = { 19 | "action": "query", 20 | "list": "search", 21 | "prop": "info", 22 | "inprop": "url", 23 | "utf8": "", 24 | "format": "json", 25 | "origin": "*", 26 | } 27 | WIKI_THUMBNAIL = ( 28 | "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/330px-Wikipedia-logo-v2.svg.png" 29 | ) 30 | WIKI_SNIPPET_REGEX = r"(|<[^>]*>)" 31 | WIKI_SEARCH_RESULT = "**[{name}]({url})**\n{description}\n" 32 | 33 | 34 | class WikipediaSearch(commands.Cog, name="Wikipedia Search", slash_command_attrs={"dm_permission": False}): 35 | """Get info from wikipedia.""" 36 | 37 | def __init__(self, bot: Monty) -> None: 38 | self.bot = bot 39 | 40 | async def wiki_request(self, channel: disnake.abc.Messageable, search: str) -> List[str]: 41 | """Search wikipedia search string and return formatted first 10 pages found.""" 42 | params = WIKI_PARAMS | {"srlimit": 10, "srsearch": search} 43 | async with self.bot.http_session.get(url=SEARCH_API, params=params) as resp: 44 | if resp.status != 200: 45 | log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`") 46 | raise APIError("Wikipedia API", resp.status) 47 | 48 | raw_data = await resp.json() 49 | 50 | if not raw_data.get("query"): 51 | if error := raw_data.get("errors"): 52 | log.error(f"There was an error while communicating with the Wikipedia API: {error}") 53 | raise APIError("Wikipedia API", resp.status, error) 54 | 55 | lines = [] 56 | if raw_data["query"]["searchinfo"]["totalhits"]: 57 | for article in raw_data["query"]["search"]: 58 | line = WIKI_SEARCH_RESULT.format( 59 | name=article["title"], 60 | description=unescape(re.sub(WIKI_SNIPPET_REGEX, "", article["snippet"])), 61 | url=f"https://en.wikipedia.org/?curid={article['pageid']}", 62 | ) 63 | lines.append(line) 64 | 65 | return lines 66 | 67 | @commands.cooldown(1, 10, commands.BucketType.user) 68 | @commands.command(name="wikipedia", aliases=("wiki",)) 69 | async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None: 70 | """Sends paginated top 10 results of Wikipedia search..""" 71 | contents = await self.wiki_request(ctx.channel, search) 72 | 73 | if contents: 74 | embed = disnake.Embed(title="Wikipedia Search Results", colour=disnake.Color.blurple()) 75 | embed.set_thumbnail(url=WIKI_THUMBNAIL) 76 | embed.timestamp = utcnow() 77 | await LinePaginator.paginate(contents, ctx, embed) 78 | else: 79 | await ctx.send("Sorry, we could not find a wikipedia article using that search term.") 80 | 81 | 82 | def setup(bot: Monty) -> None: 83 | """Load the WikipediaSearch cog.""" 84 | bot.add_cog(WikipediaSearch(bot)) 85 | -------------------------------------------------------------------------------- /monty/exts/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onerandomusername/monty-python/57f66e7147d659523e11b09402bc495fa8abd517/monty/exts/backend/__init__.py -------------------------------------------------------------------------------- /monty/exts/backend/global_checks.py: -------------------------------------------------------------------------------- 1 | import disnake 2 | from disnake.ext import commands 3 | 4 | from monty.bot import Monty 5 | from monty.errors import BotAccountRequired 6 | from monty.log import get_logger 7 | 8 | 9 | logger = get_logger(__name__) 10 | 11 | 12 | class GlobalCheck(commands.Cog, slash_command_attrs={"dm_permission": False}): 13 | """Global checks for monty.""" 14 | 15 | def __init__(self, bot: Monty) -> None: 16 | self.bot = bot 17 | self._bot_invite_link: str = "" 18 | 19 | async def cog_load(self) -> None: 20 | """Run set_invite_link after the bot is ready.""" 21 | await self.bot.wait_until_ready() 22 | await self.set_invite_link() 23 | 24 | async def set_invite_link(self) -> None: 25 | """Set the invite link for the bot.""" 26 | if self._bot_invite_link: 27 | return 28 | 29 | # todo: don't require a fake guild object 30 | class FakeGuild: 31 | id: str = "{guild_id}" 32 | 33 | guild = FakeGuild 34 | self._bot_invite_link = disnake.utils.oauth_url( 35 | self.bot.user.id, 36 | disable_guild_select=True, 37 | guild=guild, # type: ignore # this is totally wrong 38 | scopes={"applications.commands", "bot"}, 39 | permissions=self.bot.invite_permissions, 40 | ) 41 | 42 | def bot_slash_command_check(self, inter: disnake.CommandInteraction) -> bool: 43 | """ 44 | Require all commands in guilds have the bot scope. 45 | 46 | This essentially prevents commands from running when the Bot is not in a guild. 47 | 48 | However, this does allow slash commands in DMs as those are now controlled via 49 | the dm_permisions attribute on each app command. 50 | """ 51 | if inter.guild or not inter.guild_id: 52 | return True 53 | 54 | invite = self._bot_invite_link.format(guild_id=inter.guild_id) 55 | if inter.permissions.manage_guild: 56 | msg = ( 57 | "The bot scope is required to perform any actions. " 58 | f"You can invite the full bot by [clicking here](<{invite}>)." 59 | ) 60 | else: 61 | msg = ( 62 | "The bot scope is required to perform any actions. " 63 | f"Please ask a server manager to [invite the full bot](<{invite}>)." 64 | ) 65 | raise BotAccountRequired(msg) 66 | 67 | bot_user_command_check = bot_slash_command_check 68 | bot_message_command_check = bot_slash_command_check 69 | 70 | async def bot_check_once(self, ctx: commands.Context) -> bool: 71 | """Require all commands be in guild.""" 72 | if ctx.guild: 73 | return True 74 | raise commands.NoPrivateMessage() 75 | 76 | 77 | def setup(bot: Monty) -> None: 78 | """Add the global checks to the bot.""" 79 | bot.add_cog(GlobalCheck(bot)) 80 | -------------------------------------------------------------------------------- /monty/exts/backend/logging.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from typing import Any 3 | 4 | import disnake 5 | from disnake.ext import commands 6 | 7 | from monty.bot import Monty 8 | from monty.log import get_logger 9 | from monty.metadata import ExtMetadata 10 | 11 | 12 | EXT_METADATA = ExtMetadata(core=True) 13 | logger = get_logger(__name__) 14 | 15 | 16 | class InternalLogger(commands.Cog, slash_command_attrs={"dm_permission": False}): 17 | """Internal logging for debug and abuse handling.""" 18 | 19 | def __init__(self, bot: Monty) -> None: 20 | self.bot = bot 21 | 22 | @commands.Cog.listener() 23 | async def on_command(self, ctx: commands.Context, command: Any = None, spl: str = None) -> None: 24 | """Log a command invoke.""" 25 | if not spl: 26 | spl = ctx.message.content 27 | spl = spl.split("\n") 28 | if command is None: 29 | command: commands.Command = ctx.command 30 | qualname = command.qualified_name 31 | self.bot.stats.incr("prefix_commands." + qualname.replace(".", "_") + ".uses") 32 | logger.info( 33 | "command %s by %s (%s) in channel %s (%s) in guild %s: %s", 34 | qualname, 35 | ctx.author, 36 | ctx.author.id, 37 | ctx.channel, 38 | ctx.channel.id, 39 | ctx.guild.id, 40 | spl[0] + (" ..." if len(spl) > 1 else ""), 41 | ) 42 | 43 | @commands.Cog.listener() 44 | async def on_error(self, event_method: Any, *args, **kwargs) -> None: 45 | """Log all errors without other listeners.""" 46 | logger.error(f"Ignoring exception in {event_method}:\n{traceback.format_exc()}") 47 | 48 | @commands.Cog.listener() 49 | async def on_command_completion(self, ctx: commands.Context) -> None: 50 | """Log a successful command completion.""" 51 | qualname = ctx.command.qualified_name 52 | logger.info( 53 | "command %s by %s (%s) in channel %s (%s) in guild %s has completed!", 54 | qualname, 55 | ctx.author, 56 | ctx.author.id, 57 | ctx.channel, 58 | ctx.channel.id, 59 | ctx.guild.id, 60 | ) 61 | 62 | self.bot.stats.incr("prefix_commands." + qualname.replace(".", "_") + ".completion") 63 | 64 | @commands.Cog.listener() 65 | async def on_slash_command(self, inter: disnake.ApplicationCommandInteraction) -> None: 66 | """Log the start of a slash command.""" 67 | spl = str(inter.filled_options).replace("\n", " ") 68 | spl = spl.split("\n") 69 | # todo: fix this in disnake 70 | if inter.application_command is disnake.utils.MISSING: 71 | return 72 | qualname = inter.application_command.qualified_name 73 | self.bot.stats.incr("slash_commands." + qualname.replace(".", "_") + ".uses") 74 | 75 | logger.info( 76 | "slash command `%s` by %s (%s) in channel %s (%s) in guild %s: %s", 77 | inter.application_command.qualified_name, 78 | inter.author, 79 | inter.author.id, 80 | inter.channel, 81 | inter.channel_id, 82 | inter.guild_id, 83 | spl[0] + (" ..." if len(spl) > 1 else ""), 84 | ) 85 | 86 | @commands.Cog.listener() 87 | async def on_slash_command_completion(self, inter: disnake.ApplicationCommandInteraction) -> None: 88 | """Log slash command completion.""" 89 | qualname = inter.application_command.qualified_name 90 | self.bot.stats.incr("slash_commands." + qualname.replace(".", "_") + ".completion") 91 | logger.info( 92 | "slash command `%s` by %s (%s) in channel %s (%s) in guild %s has completed!", 93 | qualname, 94 | inter.author, 95 | inter.author.id, 96 | inter.channel, 97 | inter.channel_id, 98 | inter.guild_id, 99 | ) 100 | 101 | 102 | def setup(bot: Monty) -> None: 103 | """Add the internal logger cog to the bot.""" 104 | bot.add_cog(InternalLogger(bot)) 105 | -------------------------------------------------------------------------------- /monty/exts/backend/uptime.py: -------------------------------------------------------------------------------- 1 | import yarl 2 | from disnake.ext import commands, tasks 3 | 4 | from monty.bot import Monty 5 | from monty.constants import UptimeMonitoring 6 | from monty.log import get_logger 7 | from monty.metadata import ExtMetadata 8 | 9 | 10 | EXT_METADATA = ExtMetadata(core=True) 11 | logger = get_logger(__name__) 12 | 13 | 14 | class UptimeMonitor(commands.Cog, slash_command_attrs={"dm_permission": False}): 15 | """Pong a remote server for uptime monitoring.""" 16 | 17 | def __init__(self, bot: Monty) -> None: 18 | self.bot = bot 19 | self._url = yarl.URL(UptimeMonitoring.private_url) 20 | if UptimeMonitoring.enabled: 21 | self.uptime_monitor.start() 22 | 23 | def cog_unload(self) -> None: 24 | """Stop existing tasks on cog unload.""" 25 | self.uptime_monitor.cancel() 26 | 27 | def get_url(self) -> str: 28 | """Get the uptime URL with proper formatting. The result of this method should not be cached.""" 29 | queries = {} 30 | for param, value in UptimeMonitoring.query_params.items(): 31 | if callable(value): 32 | value = value(self.bot) 33 | queries[param] = value 34 | 35 | return str(self._url.update_query(**queries)) 36 | 37 | @tasks.loop(seconds=UptimeMonitoring.interval) 38 | async def uptime_monitor(self) -> None: 39 | """Send an uptime ack if uptime monitoring is enabled.""" 40 | url = self.get_url() 41 | async with self.bot.http_session.get(url, use_cache=False): 42 | pass 43 | 44 | @uptime_monitor.before_loop 45 | async def before_uptime_monitor(self) -> None: 46 | """Wait until the bot is ready to send an uptime ack.""" 47 | await self.bot.wait_until_ready() 48 | 49 | 50 | def setup(bot: Monty) -> None: 51 | """Add the uptime monitoring cog to the bot.""" 52 | bot.add_cog(UptimeMonitor(bot)) 53 | -------------------------------------------------------------------------------- /monty/exts/filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onerandomusername/monty-python/57f66e7147d659523e11b09402bc495fa8abd517/monty/exts/filters/__init__.py -------------------------------------------------------------------------------- /monty/exts/filters/webhook_remover.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import disnake 4 | from disnake.ext import commands 5 | 6 | from monty.bot import Monty 7 | from monty.constants import Feature 8 | from monty.log import get_logger 9 | 10 | 11 | WEBHOOK_URL_RE = re.compile( 12 | r"((?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/)\S+\/?", re.IGNORECASE 13 | ) 14 | 15 | ALERT_MESSAGE_TEMPLATE = ( 16 | "{user}, looks like you posted a Discord webhook URL. Therefore " 17 | "your webhook has been deleted. " 18 | "You can re-create it if you wish to. If you believe this was a " 19 | "mistake, please let us know." 20 | ) 21 | 22 | 23 | log = get_logger(__name__) 24 | 25 | 26 | class WebhookRemover(commands.Cog, name="Webhook Remover", slash_command_attrs={"dm_permission": False}): 27 | """Scan messages to detect Discord webhooks links.""" 28 | 29 | def __init__(self, bot: Monty) -> None: 30 | self.bot = bot 31 | 32 | async def maybe_delete(self, msg: disnake.Message) -> bool: 33 | """ 34 | Maybe delete a message, if we have perms. 35 | 36 | Returns True on success. 37 | """ 38 | if not msg.guild: 39 | return False 40 | can_delete = msg.author == msg.guild.me or msg.channel.permissions_for(msg.guild.me).manage_messages 41 | if not can_delete: 42 | return False 43 | 44 | await msg.delete() 45 | return True 46 | 47 | async def delete_and_respond(self, msg: disnake.Message, redacted_url: str, *, webhook_deleted: bool) -> None: 48 | """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" 49 | if webhook_deleted: 50 | await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) 51 | delete_state = "The webhook was successfully deleted." 52 | else: 53 | delete_state = "There was an error when deleting the webhook, it might have already been removed." 54 | message = ( 55 | f"{msg.author} ({msg.author.id!s}) posted a Discord webhook URL to {msg.channel.id}. {delete_state} " 56 | f"Webhook URL was `{redacted_url}`" 57 | ) 58 | log.debug(message) 59 | 60 | @commands.Cog.listener() 61 | async def on_message(self, msg: disnake.Message) -> None: 62 | """Check if a Discord webhook URL is in `message`.""" 63 | # Ignore DMs; can't delete messages in there anyway. 64 | if not msg.guild or msg.author.bot: 65 | return 66 | if not await self.bot.guild_has_feature(msg.guild, Feature.DISCORD_WEBHOOK_REMOVER): 67 | return 68 | 69 | matches = WEBHOOK_URL_RE.search(msg.content) 70 | if matches: 71 | async with self.bot.http_session.delete(matches[0]) as resp: 72 | # The Discord API Returns a 204 NO CONTENT response on success. 73 | deleted_successfully = resp.status == 204 74 | await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully) 75 | 76 | @commands.Cog.listener() 77 | async def on_message_edit(self, before: disnake.Message, after: disnake.Message) -> None: 78 | """Check if a Discord webhook URL is in the edited message `after`.""" 79 | if before.content == after.content: 80 | return 81 | 82 | await self.on_message(after) 83 | 84 | 85 | def setup(bot: Monty) -> None: 86 | """Load `WebhookRemover` cog.""" 87 | bot.add_cog(WebhookRemover(bot)) 88 | -------------------------------------------------------------------------------- /monty/exts/info/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onerandomusername/monty-python/57f66e7147d659523e11b09402bc495fa8abd517/monty/exts/info/__init__.py -------------------------------------------------------------------------------- /monty/exts/info/_global_source_snekcode.py: -------------------------------------------------------------------------------- 1 | """ 2 | Do not import this file. 3 | 4 | NOTE: THIS RUNS ON PYTHON 3.11 5 | """ 6 | 7 | # exit codes: 8 | # 0: success 9 | # 1: indeterminate error 10 | # 2: module not resolvable 11 | # 3: attribute does not exist 12 | # 4: invalid characters, not a valid object path 13 | # 5: dynamically created object 14 | # 6: is a builtin object, prints module name 15 | # 7: invalid metadata 16 | # 8: unsupported package (does not use github) 17 | # 9: module found but cannot find class definition 18 | 19 | if __name__ == "__main__": 20 | import importlib 21 | import importlib.metadata 22 | import importlib.util 23 | import inspect 24 | import pathlib 25 | import pkgutil 26 | import sys 27 | import tracemalloc 28 | import types 29 | from typing import Any 30 | 31 | # establish the object itself 32 | object_name = """REPLACE_THIS_STRING_WITH_THE_OBJECT_NAME""" 33 | 34 | tracemalloc.start() 35 | try: 36 | src: Any = pkgutil.resolve_name(object_name) 37 | except ModuleNotFoundError: 38 | sys.exit(2) 39 | except AttributeError: 40 | sys.exit(3) 41 | except ValueError: 42 | sys.exit(4) 43 | except Exception: 44 | raise 45 | 46 | try: 47 | unwrapped = inspect.unwrap(src) 48 | if isinstance(unwrapped, property) and unwrapped.fget: 49 | unwrapped = inspect.unwrap(unwrapped.fget) 50 | except Exception: 51 | # continue with possibly wrapped src object in case of error 52 | pass 53 | else: 54 | src = unwrapped 55 | 56 | trace = tracemalloc.get_object_traceback(src) 57 | tracemalloc.stop() 58 | 59 | # get the source of the object 60 | 61 | try: 62 | filename = inspect.getsourcefile(src) 63 | except TypeError: 64 | if isinstance(src, types.BuiltinFunctionType): 65 | sys.exit(6) 66 | # if we have to use tracemalloc we have a bit of a problem 67 | # the code is dynamically created 68 | # this means that we need to establish the file, where the def starts, and where it ends 69 | if not trace: 70 | sys.exit(5) 71 | frame = trace[-1] 72 | filename = frame.filename 73 | first_lineno = frame.lineno 74 | lines_extension = f"#L{frame.lineno}" 75 | parents: list[str] = [] 76 | try: 77 | name: str = src.__qualname__ 78 | except AttributeError: 79 | name = object_name.rsplit(".", 1)[-1] 80 | else: 81 | if "." in name: 82 | parents_str, name = name.rsplit(".", 1) 83 | parents = parents_str.split(".") 84 | try: 85 | with open(filename) as f: 86 | sourcecode = f.read() 87 | except FileNotFoundError: 88 | sys.exit(5) 89 | 90 | # Once we know where the definition starts, we can hopefully use ast for parsing the file 91 | import ast 92 | 93 | parsed = ast.parse(sourcecode, filename=filename) 94 | _endlines: set[tuple[int, int]] = set() 95 | for node in ast.walk(parsed): 96 | if not hasattr(node, "lineno"): 97 | continue 98 | if node.lineno < first_lineno: 99 | continue 100 | if isinstance(node, ast.Assign): 101 | target = node.targets[0] 102 | elif isinstance(node, ast.AnnAssign): 103 | target = node.target 104 | else: 105 | continue 106 | if parents: 107 | if getattr(target, "attr", None) != name: 108 | continue 109 | elif getattr(target, "id", None) != name: 110 | continue 111 | if node.end_lineno: 112 | end_lineno = node.end_lineno 113 | else: 114 | end_lineno = node.lineno 115 | _endlines.add((node.lineno, end_lineno)) 116 | 117 | if _endlines: 118 | lineno, end_lineno = sorted(_endlines, key=lambda i: i[0])[0] 119 | lines_extension = f"#L{lineno}" 120 | if end_lineno > lineno: 121 | lines_extension += f"-L{end_lineno}" 122 | 123 | module_name = object_name.split(":", 1)[0] if ":" in object_name else object_name.rsplit(".", 1)[0] 124 | else: 125 | if not inspect.ismodule(src): 126 | try: 127 | lines, first_lineno = inspect.getsourcelines(src) 128 | except OSError: 129 | print(filename) 130 | sys.exit(9) 131 | lines_extension = f"#L{first_lineno}-L{first_lineno+len(lines)-1}" 132 | else: 133 | lines_extension = "" 134 | module_name = "" 135 | if not filename: 136 | sys.exit(6) 137 | 138 | if not module_name: 139 | module = inspect.getmodule(src) 140 | if not module: 141 | sys.exit(4) 142 | module_name = module.__name__ 143 | top_module_name = module_name.split(".", 1)[0] 144 | 145 | # determine the actual file name 146 | try: 147 | file = inspect.getsourcefile(importlib.import_module(top_module_name)) 148 | if file is None: 149 | raise ValueError 150 | filename = str(pathlib.Path(filename).relative_to(pathlib.Path(file).parent.parent)) 151 | filename = filename.removeprefix("site-packages/") 152 | except ValueError: 153 | sys.exit(5) 154 | 155 | # get the version and link to the source of the module 156 | if top_module_name in sys.stdlib_module_names: # type: ignore # this code runs on py3.10 157 | if top_module_name in sys.builtin_module_names: 158 | sys.exit(6) 159 | # handle the object being part of the stdlib 160 | import platform 161 | 162 | python_version = f"python{platform.python_version().rsplit('.', 1)[0]}/" 163 | if filename.startswith(python_version): 164 | filename = filename.split("/", 1)[-1] 165 | url = f"https://github.com/python/cpython/blob/v{platform.python_version()}/Lib/{filename}{lines_extension}" 166 | else: 167 | # assume that the source is github 168 | try: 169 | metadata = importlib.metadata.metadata(top_module_name) 170 | except importlib.metadata.PackageNotFoundError: 171 | print(f"Sorry, I can't find the metadata for `{object_name}`.") 172 | sys.exit(7) 173 | # print(metadata.keys()) 174 | version = metadata["Version"] 175 | for url in [metadata.get("Home-page"), *metadata.json.get("project_url", [])]: # type: ignore # runs on py3.10 176 | if not url: 177 | continue 178 | url = url.split(",", 1)[-1].strip().rstrip("/") 179 | # there are 4 `/` in a github link 180 | if url.startswith(("https://github.com/", "http://github.com/")) and url.count("/") == 4: 181 | break 182 | else: 183 | print("This package isn't supported right now.") 184 | sys.exit(8) 185 | # I ideally want to use the database for this and run that locally by sending a pickled result. 186 | if top_module_name not in ("arrow", "databases", "ormar", "typing_extensions"): 187 | version = f"v{version}" 188 | if top_module_name in ("typing_extensions",): 189 | filename = f"src/{filename}" 190 | url += f"/blob/{version}/{filename}{lines_extension}" 191 | # used to be able to slice code to ignore import side-effects 192 | print("#" * 80) 193 | print(url) 194 | -------------------------------------------------------------------------------- /monty/exts/info/codeblock/__init__.py: -------------------------------------------------------------------------------- 1 | from monty.bot import Monty 2 | 3 | 4 | def setup(bot: Monty) -> None: 5 | """Load the CodeBlockCog cog.""" 6 | # Defer import to reduce side effects from importing the codeblock package. 7 | from monty.exts.info.codeblock._cog import CodeBlockCog 8 | 9 | bot.add_cog(CodeBlockCog(bot)) 10 | -------------------------------------------------------------------------------- /monty/exts/info/docs/__init__.py: -------------------------------------------------------------------------------- 1 | import cachingutils.redis 2 | 3 | from monty import constants 4 | from monty.bot import Monty 5 | 6 | from ._redis_cache import DocRedisCache 7 | 8 | 9 | MAX_SIGNATURE_AMOUNT = 3 10 | PRIORITY_PACKAGES = ("python",) 11 | NAMESPACE = "doc" 12 | 13 | _cache = cachingutils.redis.async_session(constants.Client.config_prefix) 14 | doc_cache = DocRedisCache(prefix=_cache._prefix + "docs", session=_cache._redis) 15 | 16 | 17 | def setup(bot: Monty) -> None: 18 | """Load the Doc cog.""" 19 | from ._cog import DocCog 20 | 21 | bot.add_cog(DocCog(bot)) 22 | -------------------------------------------------------------------------------- /monty/exts/info/docs/_html.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import partial 3 | from typing import Callable, Container, Iterable, List, Union 4 | 5 | from bs4 import BeautifulSoup 6 | from bs4.element import NavigableString, PageElement, SoupStrainer, Tag 7 | 8 | from monty.log import get_logger 9 | 10 | from . import MAX_SIGNATURE_AMOUNT 11 | 12 | 13 | log = get_logger(__name__) 14 | 15 | _UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") 16 | _SEARCH_END_TAG_ATTRS = ( 17 | "data", 18 | "function", 19 | "class", 20 | "exception", 21 | "seealso", 22 | "section", 23 | "rubric", 24 | "sphinxsidebar", 25 | ) 26 | 27 | 28 | class Strainer(SoupStrainer): 29 | """Subclass of SoupStrainer to allow matching of both `Tag`s and `NavigableString`s.""" 30 | 31 | def __init__(self, *, include_strings: bool, **kwargs) -> None: 32 | self.include_strings = include_strings 33 | passed_text = kwargs.pop("text", None) 34 | if passed_text is not None: 35 | log.warning("`text` is not a supported kwarg in the custom strainer.") 36 | super().__init__(**kwargs) 37 | 38 | Markup = Union[PageElement, List["Markup"]] 39 | 40 | def search(self, markup: Markup) -> Union[PageElement, str]: 41 | """Extend default SoupStrainer behaviour to allow matching both `Tag`s` and `NavigableString`s.""" 42 | if isinstance(markup, str): 43 | # Let everything through the text filter if we're including strings and tags. 44 | if not self.name and not self.attrs and self.include_strings: 45 | return markup 46 | else: 47 | return super().search(markup) 48 | 49 | 50 | def _find_elements_until_tag( 51 | start_element: PageElement, 52 | end_tag_filter: Union[Container[str], Callable[[Tag], bool]], 53 | *, 54 | func: Callable, 55 | include_strings: bool = False, 56 | limit: int = None, 57 | ) -> List[Union[Tag, NavigableString]]: 58 | """ 59 | Get all elements up to `limit` or until a tag matching `end_tag_filter` is found. 60 | 61 | `end_tag_filter` can be either a container of string names to check against, 62 | or a filtering callable that's applied to tags. 63 | 64 | When `include_strings` is True, `NavigableString`s from the document will be included in the result along `Tag`s. 65 | 66 | `func` takes in a BeautifulSoup unbound method for finding multiple elements, such as `BeautifulSoup.find_all`. 67 | The method is then iterated over and all elements until the matching tag or the limit are added to the return list. 68 | """ 69 | use_container_filter = not callable(end_tag_filter) 70 | elements = [] 71 | 72 | for element in func(start_element, name=Strainer(include_strings=include_strings), limit=limit): 73 | if isinstance(element, Tag): 74 | if use_container_filter: 75 | if element.name in end_tag_filter: 76 | break 77 | elif end_tag_filter(element): 78 | break 79 | elements.append(element) 80 | 81 | return elements 82 | 83 | 84 | _find_next_children_until_tag = partial(_find_elements_until_tag, func=partial(BeautifulSoup.find_all, recursive=False)) 85 | _find_recursive_children_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_all) 86 | _find_next_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_next_siblings) 87 | _find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings) 88 | 89 | 90 | def _class_filter_factory(class_names: Iterable[str]) -> Callable[[Tag], bool]: 91 | """Create callable that returns True when the passed in tag's class is in `class_names` or when it's a table.""" 92 | 93 | def match_tag(tag: Tag) -> bool: 94 | for attr in class_names: 95 | if attr in tag.get("class", ()): 96 | return True 97 | return tag.name == "table" 98 | 99 | return match_tag 100 | 101 | 102 | def get_general_description(start_element: PageElement) -> List[Union[Tag, NavigableString]]: 103 | """ 104 | Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`. 105 | 106 | A headerlink tag is attempted to be found to skip repeating the symbol information in the description. 107 | If it's found it's used as the tag to start the search from instead of the `start_element`. 108 | """ 109 | child_tags = _find_recursive_children_until_tag(start_element, _class_filter_factory(["section"]), limit=100) 110 | header = next(filter(_class_filter_factory(["headerlink"]), child_tags), None) 111 | start_tag = header.parent if header is not None else start_element 112 | return _find_next_siblings_until_tag(start_tag, _class_filter_factory(_SEARCH_END_TAG_ATTRS), include_strings=True) 113 | 114 | 115 | def get_dd_description(symbol: PageElement) -> List[Union[Tag, NavigableString]]: 116 | """Get the contents of the next dd tag, up to a dt or a dl tag.""" 117 | description_tag = symbol.find_next("dd") 118 | return _find_next_children_until_tag(description_tag, ("dt", "dl"), include_strings=True) 119 | 120 | 121 | def get_signatures(start_signature: PageElement) -> List[str]: 122 | """ 123 | Collect up to `_MAX_SIGNATURE_AMOUNT` signatures from dt tags around the `start_signature` dt tag. 124 | 125 | First the signatures under the `start_signature` are included; 126 | if less than 2 are found, tags above the start signature are added to the result if any are present. 127 | """ 128 | signatures = [] 129 | for element in ( 130 | *reversed(_find_previous_siblings_until_tag(start_signature, ("dd",), limit=2)), 131 | start_signature, 132 | *_find_next_siblings_until_tag(start_signature, ("dd",), limit=2), 133 | )[-MAX_SIGNATURE_AMOUNT:]: 134 | signature = _UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) 135 | 136 | if signature: 137 | signatures.append(signature) 138 | 139 | return signatures 140 | -------------------------------------------------------------------------------- /monty/exts/info/docs/_redis_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from typing import TYPE_CHECKING, Any, Optional 5 | 6 | import cachingutils 7 | import cachingutils.redis 8 | import redis.asyncio 9 | 10 | 11 | if TYPE_CHECKING: 12 | from ._cog import DocItem 13 | 14 | WEEK_SECONDS = datetime.timedelta(weeks=1) 15 | 16 | 17 | def item_key(item: DocItem) -> str: 18 | """Get the redis redis key string from `item`.""" 19 | return f"{item.package}:{item.relative_url_path.removesuffix('.html')}" 20 | 21 | 22 | class DocRedisCache(cachingutils.redis.AsyncRedisCache): 23 | """Interface for redis functionality needed by the Doc cog.""" 24 | 25 | def __init__(self, *args, **kwargs) -> None: 26 | super().__init__(*args, **kwargs) 27 | self._set_expires = set() 28 | self.namespace = self._prefix 29 | self._redis: redis.asyncio.Redis 30 | 31 | async def set(self, item: DocItem, value: str) -> None: 32 | """ 33 | Set the Markdown `value` for the symbol `item`. 34 | 35 | All keys from a single page are stored together, expiring a week after the first set. 36 | """ 37 | redis_key = f"{self.namespace}:{item_key(item)}" 38 | needs_expire = False 39 | if redis_key not in self._set_expires: 40 | # An expire is only set if the key didn't exist before. 41 | # If this is the first time setting values for this key check if it exists and add it to 42 | # `_set_expires` to prevent redundant checks for subsequent uses with items from the same page. 43 | self._set_expires.add(redis_key) 44 | needs_expire = not await self._redis.exists(redis_key) 45 | 46 | await self._redis.hset(redis_key, item.symbol_id, value) 47 | if needs_expire: 48 | await self._redis.expire(redis_key, WEEK_SECONDS) 49 | 50 | async def get(self, item: DocItem, default: Any = None) -> Optional[str]: 51 | """Return the Markdown content of the symbol `item` if it exists.""" 52 | res = await self._redis.hget(f"{self.namespace}:{item_key(item)}", item.symbol_id) 53 | if res: 54 | return res.decode() 55 | return default 56 | 57 | async def delete(self, package: str) -> bool: 58 | """Remove all values for `package`; return True if at least one key was deleted, False otherwise.""" 59 | connection = self._redis 60 | package_keys = [ 61 | package_key async for package_key in connection.scan_iter(match=f"{self.namespace}:{package}:*") 62 | ] 63 | if package_keys: 64 | await connection.delete(*package_keys) 65 | return True 66 | return False 67 | 68 | 69 | class StaleItemCounter(DocRedisCache): 70 | """Manage increment counters for stale `DocItem`s.""" 71 | 72 | async def increment_for(self, item: DocItem) -> int: 73 | """ 74 | Increment the counter for `item` by 1, set it to expire in 3 weeks and return the new value. 75 | 76 | If the counter didn't exist, initialize it with 1. 77 | """ 78 | key = f"{self.namespace}:{item_key(item)}:{item.symbol_id}" 79 | connection = self._redis 80 | await connection.expire(key, WEEK_SECONDS * 3) 81 | return int(await connection.incr(key)) 82 | 83 | async def delete(self, package: str) -> bool: 84 | """Remove all values for `package`; return True if at least one key was deleted, False otherwise.""" 85 | connection = self._redis 86 | package_keys = [ 87 | package_key async for package_key in connection.scan_iter(match=f"{self.namespace}:{package}:*") 88 | ] 89 | if package_keys: 90 | await connection.delete(*package_keys) 91 | return True 92 | return False 93 | -------------------------------------------------------------------------------- /monty/exts/info/global_source.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import TYPE_CHECKING, Final, List 5 | from urllib.parse import urldefrag 6 | 7 | import disnake 8 | from disnake.ext import commands, tasks 9 | 10 | from monty.constants import Feature 11 | from monty.log import get_logger 12 | from monty.utils.features import require_feature 13 | from monty.utils.helpers import encode_github_link 14 | from monty.utils.messages import DeleteButton 15 | 16 | 17 | if TYPE_CHECKING: 18 | from monty.bot import Monty 19 | from monty.exts.eval import Snekbox 20 | 21 | logger = get_logger(__name__) 22 | CODE_FILE = os.path.dirname(__file__) + "/_global_source_snekcode.py" 23 | 24 | 25 | class GlobalSource(commands.Cog, name="Global Source"): 26 | """Global source for python objects.""" 27 | 28 | def __init__(self, bot: Monty) -> None: 29 | self.bot = bot 30 | with open(CODE_FILE, "r") as f: 31 | # this is declared as final as we should *not* be writing to it 32 | self.code: Final[str] = f.read() 33 | 34 | def cog_unload(self) -> None: 35 | """Stop the running task on unload if it is running.""" 36 | self.refresh_code.stop() 37 | 38 | @property 39 | def snekbox(self) -> Snekbox: 40 | """Return the snekbox cog where the code is ran.""" 41 | snekbox: Snekbox 42 | if snekbox := self.bot.get_cog("Snekbox"): # type: ignore # this will always be a Snekbox instance 43 | return snekbox 44 | raise RuntimeError("Snekbox is not loaded") 45 | 46 | @require_feature(Feature.GLOBAL_SOURCE) 47 | @commands.command(name="globalsource", aliases=("gs",), hidden=True) 48 | async def globalsource(self, ctx: commands.Context, object: str) -> None: 49 | """Get the source of a python object.""" 50 | object = object.strip("`") 51 | async with ctx.typing(): 52 | result = await self.snekbox.post_eval( 53 | self.code.replace("REPLACE_THIS_STRING_WITH_THE_OBJECT_NAME", object), 54 | # for `-X frozen_modules=off`, see https://github.com/python/cpython/issues/89183 55 | args=["-X", "frozen_modules=off", "-c"], 56 | ) 57 | 58 | # exit codes: 59 | # 0: success 60 | # 1: indeterminate error 61 | # 2: module not resolvable 62 | # 3: attribute does not exist 63 | # 4: invalid characters, not a valid object path 64 | # 5: dynamically created object 65 | # 6: is a builtin object, prints module name 66 | # 7: invalid metadata 67 | # 8: unsupported package (does not use github) 68 | # 9: module found but cannot find class definition 69 | 70 | text = result["stdout"].strip() 71 | if self.refresh_code.is_running(): 72 | logger.debug(text) 73 | returncode = result["returncode"] 74 | link = "" 75 | if returncode == 0: 76 | link = text.rsplit("#" * 80)[-1].strip() 77 | text = f"Source of `{object}`:\n<{link}>" 78 | elif returncode == 1: 79 | # generic exception occured 80 | logger.exception(result["stdout"]) 81 | raise Exception("Snekbox returned an error.") 82 | elif returncode == 2: 83 | text = "The module you provided was not resolvable to an installed module." 84 | elif returncode == 3: 85 | text = "The attribute you are looking for does not exist. Check for misspellings and try again." 86 | elif returncode == 4: 87 | text = "The object path you provided is invalid." 88 | elif returncode == 5: 89 | text = "That object exists, but is dynamically created." 90 | elif returncode == 6: 91 | text = ( 92 | f"`{object}` is a builtin object/implemented in C. " 93 | "It is not currently possible to get source of those objects." 94 | ) 95 | elif returncode == 7: 96 | text = "The metadata for the provided module is invalid." 97 | elif returncode == 8: 98 | text = "The provided module is not supported." 99 | elif returncode == 9: 100 | text = "The definition could not be found." 101 | else: 102 | text = "Something went wrong." 103 | 104 | components: List[disnake.ui.action_row.Components] = [] 105 | if isinstance(ctx, commands.Context): 106 | components.append(DeleteButton(ctx.author, initial_message=ctx.message)) 107 | else: 108 | components.append(DeleteButton(ctx.author)) 109 | 110 | if link: 111 | components.append(disnake.ui.Button(style=disnake.ButtonStyle.link, url=link, label="Go to Github")) 112 | custom_id = encode_github_link(link) 113 | if frag := (urldefrag(link)[1]): 114 | frag = frag.replace("#", "").replace("L", "") 115 | 116 | if "-" in frag: 117 | num1, num2 = frag.split("-") 118 | show_source = int(num2) - int(num1) <= 21 119 | else: 120 | show_source = True 121 | 122 | if show_source: 123 | components.append( 124 | disnake.ui.Button(style=disnake.ButtonStyle.blurple, label="Expand", custom_id=custom_id) 125 | ) 126 | 127 | await ctx.reply( 128 | text, 129 | allowed_mentions=disnake.AllowedMentions(everyone=False, users=False, roles=False, replied_user=True), 130 | components=components, 131 | ) 132 | 133 | @tasks.loop(seconds=1) 134 | async def refresh_code(self, ctx: commands.Context, query: str) -> None: 135 | """Refresh the internal code every second.""" 136 | modified = os.stat(CODE_FILE).st_mtime 137 | if modified <= self.last_modified: 138 | return 139 | self.last_modified = modified 140 | with open(CODE_FILE, "r") as f: 141 | self.code = f.read() # type: ignore # this is the one time we can write to the code 142 | logger.debug("Updated global_source code") 143 | 144 | try: 145 | await self.globalsource(ctx, query) 146 | except Exception as e: 147 | self.bot.dispatch("command_error", ctx, e) 148 | 149 | @refresh_code.before_loop 150 | async def before_refresh_code(self) -> None: 151 | """Set the current last_modified stat to zero starting the task.""" 152 | self.last_modified = 0 153 | 154 | @commands.command("globalsourcedebug", hidden=True) 155 | @commands.is_owner() 156 | async def globalsourcedebug(self, ctx: commands.Context, query: str = None) -> None: 157 | """Refresh the existing code and reinvoke it continually until the command is run again.""" 158 | if self.refresh_code.is_running(): 159 | if query: 160 | self.refresh_code.restart(ctx, query) 161 | await ctx.send("Restarted the global source debug task.") 162 | else: 163 | self.refresh_code.stop() 164 | await ctx.send("Cancelled the internal global source debug task.") 165 | return 166 | if not query: 167 | 168 | class FakeParam: 169 | name = "query" 170 | 171 | raise commands.MissingRequiredArgument(FakeParam) # type: ignore # we don't need an entire Parameter obj 172 | await ctx.send("Starting the global source debug task.") 173 | self.refresh_code.start(ctx, query) 174 | 175 | 176 | def setup(bot: Monty) -> None: 177 | """Add the global source cog to the bot.""" 178 | bot.add_cog(GlobalSource(bot)) 179 | -------------------------------------------------------------------------------- /monty/exts/info/python_discourse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from datetime import timedelta 6 | from typing import Any, Optional, Union 7 | from urllib.parse import urljoin 8 | 9 | import aiohttp 10 | import disnake 11 | from cachingutils import LRUMemoryCache, async_cached 12 | from disnake.ext import commands 13 | 14 | from monty.bot import Monty 15 | from monty.constants import Feature, Icons 16 | from monty.log import get_logger 17 | from monty.utils import scheduling 18 | from monty.utils.helpers import fromisoformat, get_num_suffix 19 | from monty.utils.messages import DeleteButton, extract_urls, suppress_embeds 20 | 21 | 22 | DOMAIN = "https://discuss.python.org" 23 | TOPIC_REGEX = re.compile(r"https?:\/\/discuss\.python\.org\/t\/(?:[^\s\/]*\/)*?(?P\d+)(?:\/(?P\d+))?[^\s]*") 24 | # https://docs.discourse.org/#tag/Posts 25 | TOPIC_API_URL = f"{DOMAIN}/t/{{id}}.json" 26 | # https://docs.discourse.org/#tag/Topics 27 | POST_API_URL = f"{DOMAIN}/posts/{{id}}.json" 28 | 29 | 30 | logger = get_logger(__name__) 31 | 32 | 33 | @dataclass 34 | class DiscussionTopic: 35 | id: int 36 | url: str 37 | reply_id: Optional[int] = None 38 | 39 | def __init__(self, id: Union[int, str], url: str, reply: Optional[Union[str, int]] = None) -> None: 40 | self.id = int(id) 41 | self.url = url 42 | self.reply_id = int(reply) if reply is not None else None 43 | 44 | def __hash__(self) -> int: 45 | return hash((self.id, self.reply_id)) 46 | 47 | 48 | @dataclass 49 | class TopicInfo: 50 | title: str 51 | url: str 52 | 53 | 54 | class PythonDiscourse(commands.Cog): 55 | """Autolink discuss.python.org discussions.""" 56 | 57 | def __init__(self, bot: Monty) -> None: 58 | self.bot = bot 59 | 60 | @async_cached(cache=LRUMemoryCache(50, timeout=int(timedelta(minutes=10).total_seconds()))) 61 | async def fetch_data(self, url: str) -> dict[str, Any]: 62 | """Fetch the url. Results are cached.""" 63 | async with self.bot.http_session.get(url, raise_for_status=True) as r: 64 | return await r.json() 65 | 66 | async def fetch_post(self, topic: DiscussionTopic) -> tuple[dict[str, Any], TopicInfo]: 67 | """Fetch a python discourse post knowing the topic and reply id.""" 68 | if topic.reply_id is not None: 69 | index = topic.reply_id 70 | url = TOPIC_API_URL.format(id=f"{topic.id}/{index}") 71 | data = await self.fetch_data(url) # type: ignore 72 | else: 73 | url = TOPIC_API_URL.format(id=topic.id) 74 | data = await self.fetch_data(url) # type: ignore 75 | index = 1 76 | posts = data["post_stream"]["posts"] 77 | post_id = next(filter(lambda p: p["post_number"] == index, posts))["id"] 78 | 79 | topic_info = TopicInfo(title=data["title"], url=f"{DOMAIN}/t/{data['slug']}/{data['id']}") 80 | 81 | data = await self.fetch_data(POST_API_URL.format(id=post_id)) # type: ignore 82 | return data, topic_info 83 | 84 | def make_post_embed(self, data: dict[str, Any], topic_info: TopicInfo = None) -> disnake.Embed: 85 | """Return an embed representing the provided post and topic information.""" 86 | # consider parsing this into markdown 87 | limit = 2700 88 | body: str = data["raw"] 89 | 90 | if len(body) > limit: 91 | body = body[: limit - 3] + "..." 92 | e = disnake.Embed(description=body) 93 | 94 | is_reply = data["post_number"] > 1 95 | if topic_info and topic_info.title: 96 | if is_reply: 97 | e.title = "comment on " + topic_info.title 98 | else: 99 | e.title = topic_info.title 100 | 101 | url = f"{DOMAIN}/t/{data['topic_slug']}/{data['topic_id']}" 102 | if is_reply: 103 | url += f"/{data['post_number']}" 104 | e.url = url 105 | 106 | author_url = urljoin(DOMAIN, data["avatar_template"].format(size="256")) 107 | e.set_author( 108 | name=data["name"], 109 | icon_url=author_url, 110 | url=f"{DOMAIN}/u/{data['username']}", 111 | ) 112 | 113 | e.timestamp = fromisoformat(data["created_at"]) 114 | e.set_footer(text="Posted at", icon_url=Icons.python_discourse) 115 | return e 116 | 117 | def extract_topic_urls(self, content: str) -> list[DiscussionTopic]: 118 | """Extract python discourse urls from the provided content.""" 119 | posts = [] 120 | for match in filter(None, map(TOPIC_REGEX.fullmatch, extract_urls(content))): 121 | posts.append( 122 | DiscussionTopic( 123 | id=match.group("num"), 124 | url=match[0], 125 | reply=match.group("reply"), 126 | ) 127 | ) 128 | return posts 129 | 130 | @commands.Cog.listener("on_message") 131 | async def on_message(self, message: disnake.Message) -> None: 132 | """Automatically link python discourse urls.""" 133 | if message.author.bot: 134 | return 135 | 136 | if not message.guild: 137 | return 138 | 139 | if not message.content: 140 | return 141 | 142 | if not await self.bot.guild_has_feature(message.guild, Feature.PYTHON_DISCOURSE_AUTOLINK): 143 | return 144 | 145 | posts = self.extract_topic_urls(message.content) 146 | 147 | if not posts: 148 | return 149 | 150 | posts = list(dict.fromkeys(posts, None)) 151 | my_perms = message.channel.permissions_for(message.guild.me) 152 | 153 | if len(posts) > 4: 154 | if my_perms.add_reactions: 155 | await message.add_reaction(":x:") 156 | await message.reply( 157 | "I can only link 4 discussion urls at a time!.", 158 | components=DeleteButton(message.author), 159 | allowed_mentions=disnake.AllowedMentions(replied_user=False), 160 | fail_if_not_exists=True, 161 | ) 162 | return 163 | 164 | embeds = [] 165 | components: list[disnake.ui.Button] = [] 166 | chars = 0 167 | for post in posts: 168 | try: 169 | data = await self.fetch_post(post) 170 | except aiohttp.ClientResponseError: 171 | continue 172 | 173 | embed = self.make_post_embed(*data) 174 | chars += len(embed) 175 | if chars > 6000: 176 | break 177 | 178 | embeds.append(embed) 179 | components.append(disnake.ui.Button(url=embed.url, label="View comment")) 180 | 181 | if len(components) > 1: 182 | for num, component in enumerate(components, 1): 183 | suffix = get_num_suffix(num) 184 | component.label = f"View {num}{suffix} comment" 185 | 186 | components.insert(0, DeleteButton(message.author)) 187 | 188 | if embeds: 189 | if my_perms.manage_messages: 190 | scheduling.create_task(suppress_embeds(self.bot, message)) 191 | await message.reply(embeds=embeds, components=components) 192 | 193 | 194 | def setup(bot: Monty) -> None: 195 | """Add the Python Discourse cog to the bot.""" 196 | bot.add_cog(PythonDiscourse(bot)) 197 | -------------------------------------------------------------------------------- /monty/exts/info/ruff.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import itertools 4 | import json 5 | import pathlib 6 | import random 7 | from typing import Any, Optional 8 | 9 | import attrs 10 | import disnake 11 | import rapidfuzz.fuzz 12 | import rapidfuzz.process 13 | from disnake.ext import commands, tasks 14 | 15 | import monty.resources 16 | from monty.bot import Monty 17 | from monty.log import get_logger 18 | from monty.utils.helpers import utcnow 19 | from monty.utils.messages import DeleteButton 20 | 21 | 22 | logger = get_logger(__name__) 23 | 24 | 25 | RUFF_RULES = monty.resources.folder / "ruff_rules.json" 26 | 27 | RUFF_RULES_BASE_URL = "https://docs.astral.sh/ruff/rules" 28 | 29 | RUFF_COLOUR_CYCLE = itertools.cycle((0xD7FF66, 0x30173D)) 30 | 31 | 32 | @attrs.define(hash=True, frozen=True) 33 | class Rule: 34 | name: str 35 | code: str 36 | linter: str 37 | summary: str 38 | message_formats: tuple[str] = attrs.field(converter=tuple) # type: ignore 39 | fix: str 40 | explanation: str 41 | preview: bool 42 | 43 | @property 44 | def title(self) -> str: 45 | """Return a human-readable title.""" 46 | return self.code + ": " + self.name 47 | 48 | 49 | class Ruff(commands.Cog): 50 | """Cog for getting information about Ruff and other rules.""" 51 | 52 | def __init__(self, bot: Monty) -> None: 53 | self.bot = bot 54 | self.fetch_lock = asyncio.Lock() 55 | 56 | self.rules: dict[str, Rule] = {} 57 | 58 | self.last_fetched: Optional[datetime.datetime] = None 59 | 60 | async def cog_load(self) -> None: 61 | """Load the rules on cog load.""" 62 | # start the task 63 | self.update_rules.start() 64 | # pre-fill the autocomplete once 65 | await self.update_rules() 66 | 67 | def cog_unload(self) -> None: 68 | """Remove the autocomplete task on cog unload.""" 69 | self.update_rules.cancel() 70 | 71 | async def _fetch_rules(self) -> Any: 72 | if isinstance(RUFF_RULES, pathlib.Path): 73 | with open(RUFF_RULES, "r") as f: 74 | return json.load(f) 75 | async with self.bot.http_session.get(RUFF_RULES) as response: 76 | if response.status == 200 and response.content_type == "application/json": 77 | return await response.json() 78 | return None 79 | 80 | @tasks.loop(hours=1) 81 | # @async_cached(cache=LRUMemoryCache(25, timeout=int(datetime.timedelta(hours=2).total_seconds()))) 82 | async def update_rules(self) -> Optional[dict[str, Any]]: 83 | """Fetch Ruff rules.""" 84 | raw_rules = await self._fetch_rules() 85 | new_rules = dict[str, Rule]() 86 | if not raw_rules: 87 | logger.error("Failed to fetch rules, something went wrong") 88 | return 89 | for unparsed_rule in raw_rules: 90 | parsed_rule = Rule(**unparsed_rule) 91 | new_rules[parsed_rule.code] = parsed_rule 92 | 93 | self.rules.clear() 94 | self.rules.update(new_rules) 95 | 96 | logger.info("Successfully loaded all ruff rules!") 97 | self.last_fetched = utcnow() 98 | 99 | @commands.slash_command(name="ruff") 100 | async def ruff(self, inter: disnake.ApplicationCommandInteraction) -> None: 101 | """Ruff.""" 102 | pass 103 | 104 | @ruff.sub_command(name="rule") 105 | async def ruff_rules(self, inter: disnake.ApplicationCommandInteraction, rule: str) -> None: 106 | """ 107 | Provide information about a specific rule from ruff. 108 | 109 | Parameters 110 | ---------- 111 | rule: The rule to get information about 112 | """ 113 | ruleCheck = rule.upper().strip() 114 | if ruleCheck not in self.rules: 115 | raise commands.BadArgument(f"'rule' must be a valid ruff rule. The rule {rule} does not exist.") 116 | rule = ruleCheck 117 | del ruleCheck 118 | 119 | ruleObj = self.rules[rule] 120 | embed = disnake.Embed(colour=disnake.Colour(next(RUFF_COLOUR_CYCLE))) 121 | 122 | embed.set_footer( 123 | text=f"original linter: {ruleObj.linter}", 124 | icon_url="https://avatars.githubusercontent.com/u/115962839?s=200&v=4", 125 | ) 126 | embed.set_author( 127 | name="ruff rules", icon_url="https://cdn.discordapp.com/emojis/1122704477334548560.webp?size=256" 128 | ) 129 | # embed.timestamp = self.last_fetched 130 | embed.title = "" 131 | if ruleObj.preview: 132 | embed.title = "🧪 " 133 | embed.title += ruleObj.title 134 | 135 | try: 136 | embed.description = ruleObj.explanation.split("## What it does\n", 1)[-1].split("## Why is this bad?")[0] 137 | except Exception as err: 138 | logger.error("Something went wrong trying to get the summary from the description", exc_info=err) 139 | 140 | url = f"{RUFF_RULES_BASE_URL}/{ruleObj.name}/" 141 | embed.url = url 142 | 143 | if ruleObj.fix in {"Fix is sometimes available.", "Fix is always available."}: 144 | embed.add_field( 145 | "Fixable status", 146 | ruleObj.fix, 147 | inline=False, 148 | ) 149 | 150 | # check if rule has been deprecated 151 | if "deprecated" in ruleObj.explanation.split("/n")[0].lower(): 152 | embed.add_field( 153 | "WARNING", 154 | "This rule may have been deprecated. Please check the docs for more information.", 155 | inline=False, 156 | ) 157 | 158 | if ruleObj.preview: 159 | embed.add_field( 160 | "Preview", 161 | "This rule is still in preview, and may be subject to change.", 162 | inline=False, 163 | ) 164 | 165 | await inter.response.send_message( 166 | embed=embed, 167 | components=[ 168 | DeleteButton(inter.author), 169 | disnake.ui.Button(label="View More", style=disnake.ButtonStyle.url, url=url), 170 | ], 171 | ) 172 | 173 | @ruff_rules.autocomplete("rule") 174 | async def ruff_rule_autocomplete(self, inter: disnake.ApplicationCommandInteraction, option: str) -> dict[str, str]: 175 | """Provide autocomplete for ruff rules.""" 176 | # return dict(sorted([[code, code] for code, rule in self.rules.items()])[:25]) 177 | option = option.upper().strip() 178 | 179 | if not option: 180 | return {rule.title: rule.code for rule in random.choices(list(self.rules.values()), k=12)} 181 | 182 | class Fake: 183 | title = option 184 | 185 | # score twice, once on name, and once on the full name with the code 186 | results = rapidfuzz.process.extract( 187 | (option, Fake), # must be a nested sequence because of the preprocessor 188 | self.rules.items(), 189 | scorer=rapidfuzz.fuzz.WRatio, 190 | limit=20, 191 | processor=lambda x: x[0], 192 | score_cutoff=0.6, 193 | ) 194 | results2 = rapidfuzz.process.extract( 195 | (option, Fake), # must be a nested sequence because of the preprocessor 196 | self.rules.items(), 197 | scorer=rapidfuzz.fuzz.WRatio, 198 | limit=20, 199 | processor=lambda x: x[1].title, 200 | score_cutoff=0.6, 201 | ) 202 | 203 | # get the best matches from both 204 | matches: dict[str, str] = {} 205 | for _ in range(20): 206 | if results[0][1] > results2[0][1]: 207 | code, rule = results.pop(0)[0] 208 | else: 209 | code, rule = results2.pop(0)[0] 210 | matches[rule.title] = code 211 | 212 | return matches 213 | 214 | 215 | def setup(bot: Monty) -> None: 216 | """Load the Ruff cog.""" 217 | bot.add_cog(Ruff(bot)) 218 | -------------------------------------------------------------------------------- /monty/exts/info/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import unicodedata 5 | from typing import Tuple 6 | 7 | import disnake 8 | from disnake.ext import commands 9 | 10 | from monty.bot import Monty 11 | from monty.log import get_logger 12 | from monty.utils.messages import DeleteButton 13 | from monty.utils.pagination import LinePaginator 14 | 15 | 16 | log = get_logger(__name__) 17 | 18 | 19 | class Utils(commands.Cog, slash_command_attrs={"dm_permission": False}): 20 | """A selection of utilities which don't have a clear category.""" 21 | 22 | def __init__(self, bot: Monty) -> None: 23 | self.bot = bot 24 | 25 | def _format_snowflake(self, snowflake: disnake.Object) -> str: 26 | """Return a formatted Snowflake form.""" 27 | timestamp = int(snowflake.created_at.timestamp()) 28 | out = ( 29 | f"**{snowflake.id}** ({timestamp})\n" 30 | f" ()." 31 | f"`{snowflake.created_at.isoformat().replace('+00:00', 'Z')}`\n" 32 | ) 33 | return out 34 | 35 | @commands.slash_command(name="char-info") 36 | async def charinfo( 37 | self, ctx: disnake.ApplicationCommandInteraction, characters: commands.String[str, ..., 50] 38 | ) -> None: 39 | """ 40 | Shows you information on up to 50 unicode characters. 41 | 42 | Parameters 43 | ---------- 44 | characters: The characters to display information on. 45 | """ 46 | match = re.match(r"<(a?):(\w+):(\d+)>", characters) 47 | if match: 48 | await ctx.send( 49 | "**Non-Character Detected**\n" 50 | "Only unicode characters can be processed, but a custom Discord emoji " 51 | "was found. Please remove it and try again." 52 | ) 53 | return 54 | 55 | if len(characters) > 50: 56 | await ctx.send(f"Too many characters ({len(characters)}/50)") 57 | return 58 | 59 | def get_info(char: str) -> Tuple[str, str]: 60 | digit = f"{ord(char):x}" 61 | if len(digit) <= 4: 62 | u_code = f"\\u{digit:>04}" 63 | else: 64 | u_code = f"\\U{digit:>08}" 65 | url = f"https://www.compart.com/en/unicode/U+{digit:>04}" 66 | name = f"[{unicodedata.name(char, '')}]({url})" 67 | info = f"`{u_code.ljust(10)}`: {name} - {disnake.utils.escape_markdown(char)}" 68 | return (info, u_code) 69 | 70 | (char_list, raw_list) = zip(*(get_info(c) for c in characters)) 71 | embed = disnake.Embed().set_author(name="Character Info") 72 | 73 | if len(characters) > 1: 74 | # Maximum length possible is 502 out of 1024, so there's no need to truncate. 75 | embed.add_field(name="Full Raw Text", value=f"`{''.join(raw_list)}`", inline=False) 76 | embed.description = "\n".join(char_list) 77 | await ctx.send(embed=embed, components=DeleteButton(ctx.author)) 78 | 79 | @commands.command(aliases=("snf", "snfl", "sf")) 80 | async def snowflake(self, ctx: commands.Context, *snowflakes: disnake.Object) -> None: 81 | """Get Discord snowflake creation time.""" 82 | if not snowflakes: 83 | raise commands.BadArgument("At least one snowflake must be provided.") 84 | 85 | # clear any duplicated keys 86 | snowflakes = tuple(set(snowflakes)) 87 | 88 | embed = disnake.Embed(colour=disnake.Colour.blue()) 89 | embed.set_author( 90 | name=f"Snowflake{'s'[:len(snowflakes)^1]}", # Deals with pluralisation 91 | icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true", 92 | ) 93 | 94 | lines = [] 95 | for snowflake in snowflakes: 96 | lines.append(self._format_snowflake(snowflake)) 97 | 98 | await LinePaginator.paginate(lines, ctx=ctx, embed=embed, max_lines=5, max_size=1000) 99 | 100 | @commands.slash_command(name="snowflake") 101 | async def slash_snowflake( 102 | self, 103 | inter: disnake.AppCommandInteraction, 104 | snowflake: disnake.Object, 105 | ) -> None: 106 | """ 107 | [BETA] Get creation date of a snowflake. 108 | 109 | Parameters 110 | ---------- 111 | snowflake: The snowflake. 112 | """ 113 | embed = disnake.Embed(colour=disnake.Colour.blue()) 114 | embed.set_author( 115 | name="Snowflake", 116 | icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true", 117 | ) 118 | 119 | embed.description = self._format_snowflake(snowflake) 120 | components = DeleteButton(inter.author) 121 | await inter.send(embed=embed, components=components) 122 | 123 | 124 | def setup(bot: Monty) -> None: 125 | """Load the Utils cog.""" 126 | bot.add_cog(Utils(bot)) 127 | -------------------------------------------------------------------------------- /monty/exts/info/xkcd.py: -------------------------------------------------------------------------------- 1 | import re 2 | from random import randint 3 | from typing import Dict, Optional, Union 4 | 5 | import disnake 6 | from disnake.ext import commands, tasks 7 | 8 | from monty.bot import Monty 9 | from monty.constants import Colours 10 | from monty.errors import APIError 11 | from monty.log import get_logger 12 | from monty.utils.messages import DeleteButton 13 | 14 | 15 | log = get_logger(__name__) 16 | 17 | COMIC_FORMAT = re.compile(r"latest|[0-9]+") 18 | BASE_URL = "https://xkcd.com" 19 | 20 | 21 | class XKCD(commands.Cog, slash_command_attrs={"dm_permission": False}): 22 | """Retrieving XKCD comics.""" 23 | 24 | def __init__(self, bot: Monty) -> None: 25 | self.bot = bot 26 | self.latest_comic_info: Dict[str, Union[str, int]] = {} 27 | self.get_latest_comic_info.start() 28 | 29 | def cog_unload(self) -> None: 30 | """Cancels refreshing of the task for refreshing the most recent comic info.""" 31 | self.get_latest_comic_info.cancel() 32 | 33 | @tasks.loop(minutes=30) 34 | async def get_latest_comic_info(self) -> None: 35 | """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic.""" 36 | async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp: 37 | if resp.status == 200: 38 | self.latest_comic_info = await resp.json() 39 | else: 40 | log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}") 41 | 42 | @commands.slash_command(name="xkcd") 43 | async def fetch_xkcd_comics( 44 | self, inter: disnake.ApplicationCommandInteraction, comic: Optional[str] = None 45 | ) -> None: 46 | """ 47 | View an xkcd comic. 48 | 49 | Parameters 50 | ---------- 51 | comic: number or 'latest'. Leave empty to show a random comic. 52 | """ 53 | embed = disnake.Embed(title=f"XKCD comic '{comic}'") 54 | 55 | # temporary casting back to a string, until a subcommand is added for latest support 56 | if comic is not None: 57 | comic = str(comic) 58 | 59 | embed.colour = Colours.soft_red 60 | 61 | if comic and (comic := re.match(COMIC_FORMAT, comic)) is None: 62 | raise commands.BadArgument("Comic parameter should either be an integer or 'latest'.") 63 | 64 | comic = randint(1, self.latest_comic_info["num"]) if comic is None else comic.group(0) 65 | 66 | if comic == "latest": 67 | info = self.latest_comic_info 68 | else: 69 | async with self.bot.http_session.get(f"{BASE_URL}/{comic}/info.0.json") as resp: 70 | if resp.status == 200: 71 | info = await resp.json() 72 | elif resp.status == 404: 73 | # 404 was avoided as an easter egg. We should show an embed for it 74 | if comic != "404": 75 | raise commands.BadArgument("That comic doesn't exist.") 76 | embed.title = f"XKCD comic #{comic}" 77 | embed.description = f"{resp.status}: Could not retrieve xkcd comic #{comic}." 78 | await inter.send(embed=embed, components=DeleteButton(inter.user)) 79 | return 80 | 81 | else: 82 | log.error(f"XKCD comic could not be fetched. Something went wrong fetching {comic}") 83 | 84 | raise APIError("xkcd", resp.status, "Could not fetch that comic from XKCD. Please try again later.") 85 | 86 | embed.title = f"XKCD comic #{info['num']}" 87 | embed.description = info["alt"] 88 | embed.url = f"{BASE_URL}/{info['num']}" 89 | 90 | if info["img"][-3:] in ("jpg", "png", "gif"): 91 | embed.set_image(url=info["img"]) 92 | date = f"{info['year']}/{info['month']}/{info['day']}" 93 | embed.set_footer(text=f"{date} - #{info['num']}, '{info['safe_title']}'") 94 | embed.colour = Colours.soft_green 95 | else: 96 | embed.description = ( 97 | "The selected comic is interactive, and cannot be displayed within an embed.\n" 98 | f"Comic can be viewed [here](https://xkcd.com/{info['num']})." 99 | ) 100 | 101 | components = DeleteButton(inter.author, allow_manage_messages=False) 102 | await inter.send(embed=embed, components=components) 103 | 104 | 105 | def setup(bot: Monty) -> None: 106 | """Load the XKCD cog.""" 107 | bot.add_cog(XKCD(bot)) 108 | -------------------------------------------------------------------------------- /monty/exts/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onerandomusername/monty-python/57f66e7147d659523e11b09402bc495fa8abd517/monty/exts/utils/__init__.py -------------------------------------------------------------------------------- /monty/exts/utils/delete.py: -------------------------------------------------------------------------------- 1 | import disnake 2 | from disnake.ext import commands 3 | 4 | from monty.bot import Monty 5 | from monty.log import get_logger 6 | from monty.utils.messages import DELETE_ID_V2 7 | 8 | 9 | VIEW_DELETE_ID_V1 = "wait_for_deletion_interaction_trash" 10 | 11 | logger = get_logger(__name__) 12 | 13 | 14 | class DeleteManager(commands.Cog, slash_command_attrs={"dm_permission": False}): 15 | """Handle delete buttons being pressed.""" 16 | 17 | def __init__(self, bot: Monty) -> None: 18 | self.bot = bot 19 | 20 | # button schema 21 | # prefix:PERMS:USERID 22 | # optional :MSGID 23 | @commands.Cog.listener("on_button_click") 24 | async def handle_v2_button(self, inter: disnake.MessageInteraction) -> None: 25 | """Delete a message if the user is authorized to delete the message.""" 26 | if not inter.component.custom_id.startswith(DELETE_ID_V2): 27 | return 28 | 29 | custom_id = inter.component.custom_id.removeprefix(DELETE_ID_V2) 30 | 31 | perms, user_id, *extra = custom_id.split(":") 32 | delete_msg = None 33 | if extra: 34 | if extra[0]: 35 | delete_msg = int(extra[0]) 36 | 37 | perms, user_id = int(perms), int(user_id) 38 | 39 | # check if the user id is the allowed user OR check if the user has any of the permissions allowed 40 | if not (is_orig_author := inter.author.id == user_id): 41 | permissions = disnake.Permissions(perms) 42 | user_permissions = inter.permissions 43 | if not permissions.value & user_permissions.value: 44 | await inter.response.send_message("Sorry, this delete button is not for you!", ephemeral=True) 45 | return 46 | 47 | if ( 48 | not hasattr(inter.channel, "guild") 49 | or not (myperms := inter.channel.permissions_for(inter.me)).read_messages 50 | ): 51 | await inter.response.defer() 52 | await inter.delete_original_message() 53 | return 54 | 55 | await inter.message.delete() 56 | if not delete_msg or not myperms.manage_messages or not is_orig_author: 57 | return 58 | if msg := inter.bot.get_message(delete_msg): 59 | if msg.edited_at: 60 | return 61 | else: 62 | msg = inter.channel.get_partial_message(delete_msg) 63 | try: 64 | await msg.delete() 65 | except disnake.NotFound: 66 | pass 67 | except disnake.Forbidden: 68 | logger.warning("Cache is unreliable, or something weird occured.") 69 | 70 | @commands.Cog.listener("on_button_click") 71 | async def handle_v1_buttons(self, inter: disnake.MessageInteraction) -> None: 72 | """Handle old, legacy, buggy v1 deletion buttons that still may exist.""" 73 | if inter.component.custom_id != VIEW_DELETE_ID_V1: 74 | return 75 | 76 | view = disnake.ui.View.from_message(inter.message) 77 | # get the button from the view 78 | for comp in view.children: 79 | if VIEW_DELETE_ID_V1 == getattr(comp, "custom_id", None): 80 | break 81 | else: 82 | raise RuntimeError("view doesn't contain the button that was clicked.") 83 | 84 | comp.disabled = True 85 | await inter.response.edit_message(view=view) 86 | await inter.followup.send("This button should not have been enabled, and no longer works.", ephemeral=True) 87 | 88 | 89 | def setup(bot: Monty) -> None: 90 | """Add the DeleteManager to the bot.""" 91 | bot.add_cog(DeleteManager(bot)) 92 | -------------------------------------------------------------------------------- /monty/exts/utils/dev_tools.py: -------------------------------------------------------------------------------- 1 | from disnake.ext import commands 2 | 3 | from monty.bot import Monty 4 | 5 | 6 | class DevTools(commands.Cog, slash_command_attrs={"dm_permission": False}): 7 | """Command for inviting a bot.""" 8 | 9 | def __init__(self, bot: Monty) -> None: 10 | self.bot = bot 11 | 12 | 13 | def setup(bot: Monty) -> None: 14 | """Add the devtools cog to the bot.""" 15 | bot.add_cog(DevTools(bot)) 16 | -------------------------------------------------------------------------------- /monty/exts/utils/status_codes.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from random import choice 3 | 4 | import disnake 5 | from disnake.ext import commands 6 | 7 | from monty.bot import Monty 8 | 9 | 10 | HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg" 11 | HTTP_CAT_URL = "https://http.cat/{code}.jpg" 12 | 13 | 14 | class HTTPStatusCodes(commands.Cog, name="HTTP Status Codes", slash_command_attrs={"dm_permission": False}): 15 | """ 16 | Fetch an image depicting HTTP status codes as a dog or a cat. 17 | 18 | If neither animal is selected a cat or dog is chosen randomly for the given status code. 19 | """ 20 | 21 | def __init__(self, bot: Monty) -> None: 22 | self.bot = bot 23 | 24 | @commands.group( 25 | name="http_status", 26 | aliases=("status", "httpstatus", "http"), 27 | invoke_without_command=True, 28 | ) 29 | async def http_status_group(self, ctx: commands.Context, code: int) -> None: 30 | """Choose a cat or dog randomly for the given status code.""" 31 | subcmd = choice((self.http_cat, self.http_dog)) 32 | await subcmd(ctx, code) 33 | 34 | @http_status_group.command(name="cat") 35 | async def http_cat(self, ctx: commands.Context, code: int) -> None: 36 | """Sends an embed with an image of a cat, portraying the status code.""" 37 | embed = disnake.Embed(title=f"**Status: {code}**") 38 | url = HTTP_CAT_URL.format(code=code) 39 | 40 | try: 41 | HTTPStatus(code) 42 | async with self.bot.http_session.get(url, allow_redirects=False) as response: 43 | if response.status != 404: 44 | embed.set_image(url=url) 45 | else: 46 | raise NotImplementedError 47 | 48 | except ValueError: 49 | embed.set_footer(text="Inputted status code does not exist.") 50 | 51 | except NotImplementedError: 52 | embed.set_footer(text="Inputted status code is not implemented by http.cat yet.") 53 | 54 | finally: 55 | await ctx.send(embed=embed) 56 | 57 | @http_status_group.command(name="dog") 58 | async def http_dog(self, ctx: commands.Context, code: int) -> None: 59 | """Sends an embed with an image of a dog, portraying the status code.""" 60 | # These codes aren't server-friendly. 61 | if code in (304, 422): 62 | await self.http_cat(ctx, code) 63 | return 64 | 65 | embed = disnake.Embed(title=f"**Status: {code}**") 66 | url = HTTP_DOG_URL.format(code=code) 67 | 68 | try: 69 | HTTPStatus(code) 70 | async with self.bot.http_session.get(url, allow_redirects=False) as response: 71 | if response.status != 302: 72 | embed.set_image(url=url) 73 | else: 74 | raise NotImplementedError 75 | 76 | except ValueError: 77 | embed.set_footer(text="Inputted status code does not exist.") 78 | 79 | except NotImplementedError: 80 | embed.set_footer(text="Inputted status code is not implemented by httpstatusdogs.com yet.") 81 | 82 | finally: 83 | await ctx.send(embed=embed) 84 | 85 | 86 | def setup(bot: Monty) -> None: 87 | """Load the HTTPStatusCodes cog.""" 88 | bot.add_cog(HTTPStatusCodes(bot)) 89 | -------------------------------------------------------------------------------- /monty/exts/utils/timed.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from time import perf_counter 3 | 4 | import disnake 5 | from disnake.ext import commands 6 | 7 | from monty.bot import Monty 8 | 9 | 10 | class TimedCommands(commands.Cog, name="Timed Commands", slash_command_attrs={"dm_permission": False}): 11 | """Time the command execution of a command.""" 12 | 13 | @staticmethod 14 | async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context: 15 | """Get a new execution context for a command.""" 16 | msg: disnake.Message = copy(ctx.message) 17 | msg.content = f"{ctx.prefix}{command}" 18 | 19 | return await ctx.bot.get_context(msg) 20 | 21 | @commands.command(name="timed", aliases=("time", "t")) 22 | async def timed(self, ctx: commands.Context, *, command: str) -> None: 23 | """Time the command execution of a command.""" 24 | new_ctx = await self.create_execution_context(ctx, command) 25 | 26 | if not new_ctx.command: 27 | help_command = f"{ctx.prefix}help" 28 | error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands." 29 | 30 | await ctx.send(error) 31 | return 32 | 33 | if new_ctx.command.qualified_name == "timed": 34 | await ctx.send("You are not allowed to time the execution of the `timed` command.") 35 | return 36 | 37 | t_start = perf_counter() 38 | await new_ctx.command.invoke(new_ctx) 39 | t_end = perf_counter() 40 | 41 | await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.") 42 | 43 | 44 | def setup(bot: Monty) -> None: 45 | """Load the Timed cog.""" 46 | bot.add_cog(TimedCommands()) 47 | -------------------------------------------------------------------------------- /monty/group.py: -------------------------------------------------------------------------------- 1 | from disnake.ext import commands 2 | 3 | 4 | class Group(commands.Group): 5 | """ 6 | A `discord.ext.commands.Group` subclass which supports root aliases. 7 | 8 | A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as 9 | top-level groups rather than being aliases of the command's group. It's stored as an attribute 10 | also named `root_aliases`. 11 | """ 12 | 13 | def __init__(self, *args, **kwargs) -> None: 14 | super().__init__(*args, **kwargs) 15 | self.root_aliases = kwargs.get("root_aliases", []) 16 | 17 | if not isinstance(self.root_aliases, (list, tuple)): 18 | raise TypeError("Root aliases of a group must be a list or a tuple of strings.") 19 | -------------------------------------------------------------------------------- /monty/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | import os 4 | import sys 5 | from pathlib import Path 6 | from typing import cast 7 | 8 | import coloredlogs 9 | 10 | from monty.constants import Client 11 | 12 | 13 | TRACE = 5 14 | 15 | 16 | def get_logger(*args, **kwargs) -> "MontyLogger": 17 | """Stub method for logging.getLogger.""" 18 | return cast("MontyLogger", logging.getLogger(*args, **kwargs)) 19 | 20 | 21 | class MontyLogger(logging.Logger): 22 | """Custom logger which implements the trace level.""" 23 | 24 | def trace(self, msg: str, *args, **kwargs) -> None: 25 | """ 26 | Log 'msg % args' with severity 'TRACE'. 27 | 28 | To pass exception information, use the keyword argument exc_info with a true value, e.g. 29 | logger.trace("Houston, we have a %s", "tiny detail.", exc_info=1) 30 | """ 31 | if self.isEnabledFor(TRACE): 32 | self._log(TRACE, msg, args, **kwargs) 33 | 34 | 35 | def setup() -> None: 36 | """Set up loggers.""" 37 | # Configure the "TRACE" logging level (e.g. "log.trace(message)") 38 | logging.TRACE = TRACE # type: ignore 39 | logging.addLevelName(TRACE, "TRACE") 40 | logging.setLoggerClass(MontyLogger) 41 | 42 | format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" 43 | log_format = logging.Formatter(format_string) 44 | root_logger = logging.getLogger() 45 | 46 | # Set up file logging 47 | log_file = Path("logs/monty-python.log") 48 | log_file.parent.mkdir(exist_ok=True) 49 | 50 | # we use a rotating sized log handler for local development. 51 | # in production, we log each day's logs to a new file and delete it after 14 days 52 | if Client.log_mode == "daily": 53 | file_handler = logging.handlers.TimedRotatingFileHandler( 54 | log_file, 55 | "midnight", 56 | utc=True, 57 | backupCount=14, 58 | encoding="utf-8", 59 | ) 60 | else: 61 | # File handler rotates logs every 5 MB 62 | file_handler = logging.handlers.RotatingFileHandler( 63 | log_file, 64 | maxBytes=5 * (2**20), 65 | backupCount=10, 66 | encoding="utf-8", 67 | ) 68 | file_handler.setFormatter(log_format) 69 | root_logger.addHandler(file_handler) 70 | 71 | if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: 72 | coloredlogs.DEFAULT_LEVEL_STYLES = { 73 | **coloredlogs.DEFAULT_LEVEL_STYLES, 74 | "trace": {"color": 246}, 75 | "critical": {"background": "red"}, 76 | "debug": coloredlogs.DEFAULT_LEVEL_STYLES["info"], 77 | } 78 | 79 | if "COLOREDLOGS_LOG_FORMAT" not in os.environ: 80 | coloredlogs.DEFAULT_LOG_FORMAT = format_string 81 | 82 | coloredlogs.install(level=TRACE, stream=sys.stdout) 83 | 84 | root_logger.setLevel(logging.DEBUG if Client.debug_logging else logging.INFO) 85 | # Silence irrelevant loggers 86 | logging.getLogger("disnake").setLevel(logging.WARNING) 87 | logging.getLogger("websockets").setLevel(logging.WARNING) 88 | logging.getLogger("cachingutils").setLevel(logging.INFO) 89 | logging.getLogger("sqlalchemy.engine").setLevel(logging.DEBUG) 90 | logging.getLogger("sqlalchemy.pool").setLevel(logging.INFO) 91 | logging.getLogger("gql.transport.aiohttp").setLevel(logging.WARNING) 92 | _set_trace_loggers() 93 | 94 | root_logger.info("Logging initialization complete") 95 | 96 | 97 | def _set_trace_loggers() -> None: 98 | """ 99 | Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var. 100 | 101 | When the env var is a list of logger names delimited by a comma, 102 | each of the listed loggers will be set to the trace level. 103 | 104 | If this list is prefixed with a "!", all of the loggers except the listed ones will be set to the trace level. 105 | 106 | Otherwise if the env var begins with a "*", 107 | the root logger is set to the trace level and other contents are ignored. 108 | """ 109 | level_filter = Client.trace_loggers 110 | if level_filter: 111 | if level_filter.startswith("*"): 112 | logging.getLogger().setLevel(TRACE) 113 | 114 | elif level_filter.startswith("!"): 115 | logging.getLogger().setLevel(TRACE) 116 | for logger_name in level_filter.strip("!,").split(","): 117 | logging.getLogger(logger_name.strip()).setLevel(logging.DEBUG) 118 | 119 | else: 120 | for logger_name in level_filter.strip(",").split(","): 121 | logging.getLogger(logger_name.strip()).setLevel(TRACE) 122 | -------------------------------------------------------------------------------- /monty/metadata.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass() 5 | class ExtMetadata: 6 | """Ext metadata class to determine if extension should load at runtime depending on bot configuration.""" 7 | 8 | core: bool = False 9 | "Whether or not to always load by default." 10 | no_unload: bool = False 11 | "False to allow the cog to be unloaded, True to block." 12 | 13 | def __init__(self, *, core: bool = False, no_unload: bool = False) -> None: 14 | self.core = core 15 | self.no_unload = no_unload 16 | -------------------------------------------------------------------------------- /monty/monkey_patches.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import disnake 4 | import disnake.http 5 | from disnake.ext import commands 6 | 7 | from monty.log import get_logger 8 | from monty.utils.helpers import utcnow 9 | 10 | 11 | log = get_logger(__name__) 12 | 13 | 14 | class Command(commands.Command): 15 | """ 16 | A `discord.ext.commands.Command` subclass which supports root aliases. 17 | 18 | A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as 19 | top-level commands rather than being aliases of the command's group. It's stored as an attribute 20 | also named `root_aliases`. 21 | """ 22 | 23 | def __init__(self, *args, **kwargs) -> None: 24 | super().__init__(*args, **kwargs) 25 | self.root_aliases = kwargs.get("root_aliases", []) 26 | 27 | if not isinstance(self.root_aliases, (list, tuple)): 28 | raise TypeError("Root aliases of a command must be a list or a tuple of strings.") 29 | 30 | 31 | class Group(commands.Group): 32 | """ 33 | A `discord.ext.commands.Group` subclass which supports root aliases. 34 | 35 | A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as 36 | top-level groups rather than being aliases of the command's group. It's stored as an attribute 37 | also named `root_aliases`. 38 | """ 39 | 40 | def __init__(self, *args, **kwargs) -> None: 41 | super().__init__(*args, **kwargs) 42 | self.root_aliases = kwargs.get("root_aliases", []) 43 | 44 | if not isinstance(self.root_aliases, (list, tuple)): 45 | raise TypeError("Root aliases of a group must be a list or a tuple of strings.") 46 | 47 | 48 | def patch_typing() -> None: 49 | """ 50 | Sometimes discord turns off typing events by throwing 403's. 51 | 52 | Handle those issues by patching the trigger_typing method so it ignores 403's in general. 53 | """ 54 | log.debug("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!") 55 | 56 | original = disnake.http.HTTPClient.send_typing 57 | last_403 = None 58 | 59 | async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001 60 | nonlocal last_403 61 | if last_403 and (utcnow() - last_403) < timedelta(minutes=5): 62 | log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.") 63 | return 64 | try: 65 | await original(self, channel_id) 66 | except disnake.Forbidden: 67 | last_403 = utcnow() 68 | log.warning("Got a 403 from typing event!") 69 | pass 70 | 71 | disnake.http.HTTPClient.send_typing = honeybadger_type # type: ignore 72 | 73 | 74 | original_inter_send = disnake.Interaction.send 75 | 76 | 77 | def patch_inter_send() -> None: 78 | """Patch disnake.Interaction.send to always send a message, even if we encounter a race condition.""" 79 | log.debug("Patching disnake.Interaction.send before a fix is submitted to the upstream version.") 80 | 81 | async def always_send(self: disnake.Interaction, *args, **kwargs) -> None: 82 | try: 83 | return await original_inter_send(self, *args, **kwargs) 84 | except disnake.HTTPException as e: 85 | if e.code != 40060: # interaction already responded 86 | raise 87 | return await self.followup.send(*args, **kwargs) # type: ignore 88 | 89 | disnake.Interaction.send = always_send 90 | -------------------------------------------------------------------------------- /monty/resources/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | 4 | folder = pathlib.Path(__file__).parent 5 | -------------------------------------------------------------------------------- /monty/statsd.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | from typing import cast 4 | 5 | from statsd.client.base import StatsClientBase 6 | 7 | from monty.utils import scheduling 8 | 9 | 10 | class AsyncStatsClient(StatsClientBase): 11 | """An async transport method for statsd communication.""" 12 | 13 | def __init__(self, *, host: str, port: int, prefix: str = None) -> None: 14 | """Create a new client.""" 15 | self._addr = (socket.gethostbyname(host), port) 16 | self._prefix = prefix 17 | self._transport = None 18 | self._loop = asyncio.get_running_loop() 19 | scheduling.create_task(self.create_socket()) 20 | 21 | async def create_socket(self) -> None: 22 | """Use the loop.create_datagram_endpoint method to create a socket.""" 23 | transport, _ = await self._loop.create_datagram_endpoint( 24 | asyncio.DatagramProtocol, family=socket.AF_INET, remote_addr=self._addr 25 | ) 26 | self._transport = cast(asyncio.DatagramTransport, transport) 27 | 28 | def _send(self, data: str) -> None: 29 | """Start an async task to send data to statsd.""" 30 | scheduling.create_task(self._async_send(data)) 31 | 32 | async def _async_send(self, data: str) -> None: 33 | """Send data to the statsd server using the async transport.""" 34 | assert self._transport 35 | self._transport.sendto(data.encode("utf-8"), self._addr) 36 | -------------------------------------------------------------------------------- /monty/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | import string 4 | from typing import List, Optional 5 | 6 | import disnake 7 | from disnake.ext import commands 8 | 9 | from monty.utils.pagination import LinePaginator 10 | 11 | from .helpers import pad_base64 12 | 13 | 14 | __all__ = [ 15 | "disambiguate", 16 | "replace_many", 17 | "pad_base64", 18 | ] 19 | 20 | 21 | async def disambiguate( 22 | ctx: commands.Context, 23 | entries: List[str], 24 | *, 25 | timeout: float = 30, 26 | entries_per_page: int = 20, 27 | empty: bool = False, 28 | embed: Optional[disnake.Embed] = None, 29 | ) -> str: 30 | """ 31 | Has the user choose between multiple entries in case one could not be chosen automatically. 32 | 33 | Disambiguation will be canceled after `timeout` seconds. 34 | 35 | This will raise a commands.BadArgument if entries is empty, if the disambiguation event times out, 36 | or if the user makes an invalid choice. 37 | """ 38 | if len(entries) == 0: 39 | raise commands.BadArgument("No matches found.") 40 | 41 | if len(entries) == 1: 42 | return entries[0] 43 | 44 | choices = (f"{index}: {entry}" for index, entry in enumerate(entries, start=1)) 45 | 46 | def check(message: disnake.Message) -> bool: 47 | return message.content.isdecimal() and message.author == ctx.author and message.channel == ctx.channel 48 | 49 | try: 50 | if embed is None: 51 | embed = disnake.Embed() 52 | 53 | coro1 = ctx.bot.wait_for("message", check=check, timeout=timeout) 54 | coro2 = LinePaginator.paginate( 55 | choices, 56 | ctx, 57 | embed=embed, 58 | max_lines=entries_per_page, 59 | empty=empty, 60 | max_size=6000, 61 | timeout=9000, 62 | ) 63 | 64 | # wait_for timeout will go to except instead of the wait_for thing as I expected 65 | futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)] 66 | done, pending = await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) 67 | 68 | # :yert: 69 | result = list(done)[0].result() 70 | 71 | # Pagination was canceled - result is None 72 | if result is None: 73 | for coro in pending: 74 | coro.cancel() 75 | raise commands.BadArgument("Canceled.") 76 | 77 | # Pagination was not initiated, only one page 78 | if result.author == ctx.bot.user: 79 | # Continue the wait_for 80 | result = await list(pending)[0] 81 | 82 | # Love that duplicate code 83 | for coro in pending: 84 | coro.cancel() 85 | except asyncio.TimeoutError: 86 | raise commands.BadArgument("Timed out.") from None 87 | 88 | # Guaranteed to not error because of isdecimal() in check 89 | index = int(result.content) 90 | 91 | try: 92 | return entries[index - 1] 93 | except IndexError: 94 | raise commands.BadArgument("Invalid choice.") from None 95 | 96 | 97 | def replace_many( 98 | sentence: str, 99 | replacements: dict, 100 | *, 101 | ignore_case: bool = False, 102 | match_case: bool = False, 103 | ) -> str: 104 | """ 105 | Replaces multiple substrings in a string given a mapping of strings. 106 | 107 | By default replaces long strings before short strings, and lowercase before uppercase. 108 | Example: 109 | var = replace_many("This is a sentence", {"is": "was", "This": "That"}) 110 | assert var == "That was a sentence" 111 | 112 | If `ignore_case` is given, does a case insensitive match. 113 | Example: 114 | var = replace_many("THIS is a sentence", {"IS": "was", "tHiS": "That"}, ignore_case=True) 115 | assert var == "That was a sentence" 116 | 117 | If `match_case` is given, matches the case of the replacement with the replaced word. 118 | Example: 119 | var = replace_many( 120 | "This IS a sentence", {"is": "was", "this": "that"}, ignore_case=True, match_case=True 121 | ) 122 | assert var == "That WAS a sentence" 123 | """ 124 | if ignore_case: 125 | replacements = {word.lower(): replacement for word, replacement in replacements.items()} 126 | 127 | words_to_replace = sorted(replacements, key=lambda s: (-len(s), s)) 128 | 129 | # Join and compile words to replace into a regex 130 | pattern = "|".join(re.escape(word) for word in words_to_replace) 131 | regex = re.compile(pattern, re.I if ignore_case else 0) 132 | 133 | def _repl(match: re.Match) -> str: 134 | """Returns replacement depending on `ignore_case` and `match_case`.""" 135 | word = match.group(0) 136 | replacement = replacements[word.lower() if ignore_case else word] 137 | 138 | if not match_case: 139 | return replacement 140 | 141 | # Clean punctuation from word so string methods work 142 | cleaned_word = word.translate(str.maketrans("", "", string.punctuation)) 143 | if cleaned_word.isupper(): 144 | return replacement.upper() 145 | elif cleaned_word[0].isupper(): 146 | return replacement.capitalize() 147 | else: 148 | return replacement.lower() 149 | 150 | return regex.sub(_repl, sentence) 151 | -------------------------------------------------------------------------------- /monty/utils/caching.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import contextlib 5 | import datetime 6 | import functools 7 | from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Coroutine, Optional, Tuple, Type, TypeVar, Union 8 | from weakref import WeakValueDictionary 9 | 10 | import cachingutils 11 | import cachingutils.redis 12 | 13 | from monty import constants 14 | from monty.log import get_logger 15 | 16 | 17 | if TYPE_CHECKING: 18 | from typing_extensions import ParamSpec 19 | 20 | P = ParamSpec("P") 21 | T = TypeVar("T") 22 | Coro = Coroutine[Any, Any, T] 23 | 24 | KT = TypeVar("KT") 25 | VT = TypeVar("VT") 26 | UNSET = object() 27 | 28 | 29 | # vendored from cachingutils, but as they're internal, they're put here in case they change 30 | def _extend_posargs(sig: list[int], posargs: list[int], *args: Any) -> None: 31 | for i in posargs: 32 | val = args[i] 33 | 34 | hashed = hash(val) 35 | 36 | sig.append(hashed) 37 | 38 | 39 | def _extend_kwargs(sig: list[int], _kwargs: list[str], allow_unset: bool = False, **kwargs: Any) -> None: 40 | for name in _kwargs: 41 | try: 42 | val = kwargs[name] 43 | except KeyError: 44 | if allow_unset: 45 | continue 46 | 47 | raise 48 | 49 | hashed = hash(val) 50 | 51 | sig.append(hashed) 52 | 53 | 54 | def _get_sig( 55 | func: Callable[..., Any], 56 | args: Any, 57 | kwargs: Any, 58 | include_posargs: Optional[list[int]] = None, 59 | include_kwargs: Optional[list[str]] = None, 60 | allow_unset: bool = False, 61 | ) -> Tuple[int]: 62 | signature: list[int] = [id(func)] 63 | 64 | if include_posargs is not None: 65 | _extend_posargs(signature, include_posargs, *args) 66 | else: 67 | for arg in args: 68 | signature.append(hash(arg)) 69 | 70 | if include_kwargs is not None: 71 | _extend_kwargs(signature, include_kwargs, allow_unset, **kwargs) 72 | else: 73 | for name, value in kwargs.items(): 74 | signature.append(hash((name, value))) 75 | 76 | return tuple(signature) 77 | 78 | 79 | def redis_cache( 80 | prefix: str, 81 | /, 82 | key_func: Any = None, 83 | skip_cache_func: Any = lambda *args, **kwargs: False, 84 | timeout: Optional[Union[int, float, datetime.timedelta]] = 60 * 60 * 24 * 7, 85 | include_posargs: Optional[list[int]] = None, 86 | include_kwargs: Optional[list[str]] = None, 87 | allow_unset: bool = False, 88 | cache_cls: Optional[Type[cachingutils.redis.AsyncRedisCache]] = None, 89 | cache: Any = None, 90 | ) -> Callable[[Callable[P, Coro[T]]], Callable[P, Coro[T]]]: 91 | """Decorate a function to cache its result in redis.""" 92 | redis_cache = cachingutils.redis.async_session(constants.Client.config_prefix) 93 | if cache_cls: 94 | # we actually want to do it this way, as it is important that they are *actually* the same class 95 | if cache and type(cache_cls) is not type(cache): 96 | raise TypeError("cache cannot be provided if cache_cls is provided and cache and cache_cls are different") 97 | _cache: cachingutils.redis.AsyncRedisCache = cache_cls(session=redis_cache._redis) # type: ignore 98 | else: 99 | _cache = redis_cache 100 | 101 | if isinstance(timeout, datetime.timedelta): 102 | timeout = int(timeout.total_seconds()) 103 | elif isinstance(timeout, float): 104 | timeout = int(timeout) 105 | 106 | cache_logger = get_logger(__package__ + ".caching") 107 | 108 | prefix = prefix + ":" 109 | 110 | def decorator(func: Callable[P, Coro[T]]) -> Callable[P, Coro[T]]: 111 | @functools.wraps(func) 112 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: 113 | if key_func is not None: 114 | if include_posargs is not None: 115 | key_args = tuple(k for i, k in enumerate(args) if i in include_posargs) 116 | else: 117 | key_args = args 118 | 119 | if include_kwargs is not None: 120 | key_kwargs = {k: v for k, v in kwargs if k in include_kwargs} 121 | else: 122 | key_kwargs = kwargs.copy() 123 | 124 | key = prefix + key_func(*key_args, **key_kwargs) 125 | 126 | else: 127 | key = prefix + str( 128 | _get_sig( 129 | func, 130 | args, 131 | kwargs, 132 | include_posargs=include_posargs, 133 | include_kwargs=include_kwargs, 134 | allow_unset=allow_unset, 135 | ) 136 | ) 137 | 138 | if not skip_cache_func(*args, **kwargs): 139 | value = await _cache.get(key, UNSET) 140 | 141 | if value is not UNSET: 142 | if constants.Client.debug: 143 | cache_logger.info("Cache hit on %s", key) 144 | 145 | return value 146 | 147 | result: T = await func(*args, **kwargs) 148 | 149 | await _cache.set(key, result, timeout=timeout) 150 | return result 151 | 152 | return wrapper 153 | 154 | return decorator 155 | 156 | 157 | class RedisCache: 158 | def __init__( 159 | self, 160 | prefix: str, 161 | *, 162 | timeout: datetime.timedelta = datetime.timedelta(days=1), # noqa: B008 163 | ) -> None: 164 | session = cachingutils.redis.async_session(constants.Client.redis_prefix) 165 | self._rediscache = cachingutils.redis.AsyncRedisCache(prefix=prefix.rstrip(":") + ":", session=session._redis) 166 | self._redis_timeout = timeout.total_seconds() 167 | self._locks: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary() 168 | 169 | async def get(self, key: str, default: Optional[tuple[Optional[str], Any]] = None) -> Any: 170 | """ 171 | Get the provided key from the internal caches. 172 | 173 | First position in the response is the ETag, if set, the second item is the contents. 174 | """ 175 | return await self._rediscache.get(key, default=default) 176 | 177 | async def set(self, key: str, value: Any, *, timeout: Optional[float] = None) -> None: 178 | """Set the provided key and value into the internal caches.""" 179 | return await self._rediscache.set(key, value=value, timeout=timeout or self._redis_timeout) 180 | 181 | @contextlib.asynccontextmanager 182 | async def lock(self, key: str) -> AsyncGenerator[None, None]: 183 | """Runs a lock with the provided key.""" 184 | if key not in self._locks: 185 | lock = asyncio.Lock() 186 | self._locks[key] = lock 187 | else: 188 | lock = self._locks[key] 189 | 190 | async with lock: 191 | yield 192 | -------------------------------------------------------------------------------- /monty/utils/extensions.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import pkgutil 4 | from typing import TYPE_CHECKING, Generator, NoReturn, Tuple 5 | 6 | from disnake.ext import commands 7 | 8 | from monty import exts 9 | from monty.log import get_logger 10 | 11 | 12 | if TYPE_CHECKING: 13 | from monty.metadata import ExtMetadata 14 | 15 | log = get_logger(__name__) 16 | 17 | 18 | def unqualify(name: str) -> str: 19 | """Return an unqualified name given a qualified module/package `name`.""" 20 | return name.rsplit(".", maxsplit=1)[-1] 21 | 22 | 23 | def walk_extensions() -> Generator[Tuple[str, "ExtMetadata"], None, None]: 24 | """Yield extension names from monty.exts subpackage.""" 25 | from monty.metadata import ExtMetadata 26 | 27 | def on_error(name: str) -> NoReturn: 28 | raise ImportError(name=name) # pragma: no cover 29 | 30 | for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error): 31 | if unqualify(module.name).startswith("_"): 32 | # Ignore module/package names starting with an underscore. 33 | continue 34 | 35 | imported = importlib.import_module(module.name) 36 | if not inspect.isfunction(getattr(imported, "setup", None)): 37 | # If it lacks a setup function, it's not an extension. 38 | continue 39 | 40 | ext_metadata = getattr(imported, "EXT_METADATA", None) 41 | if ext_metadata is not None: 42 | if not isinstance(ext_metadata, ExtMetadata): 43 | if ext_metadata == ExtMetadata: 44 | log.info( 45 | f"{module.name!r} seems to have passed the ExtMetadata class directly to " 46 | "EXT_METADATA. Using defaults." 47 | ) 48 | else: 49 | log.error( 50 | f"Extension {module.name!r} contains an invalid EXT_METADATA variable. " 51 | "Loading with metadata defaults. Please report this bug to the developers." 52 | ) 53 | yield module.name, ExtMetadata() 54 | continue 55 | 56 | log.debug(f"{module.name!r} contains a EXT_METADATA variable. Loading it.") 57 | 58 | yield module.name, ext_metadata 59 | continue 60 | 61 | log.trace(f"Extension {module.name!r} is missing an EXT_METADATA variable. Assuming its a normal extension.") 62 | 63 | # Presume Production Mode/Metadata defaults if metadata var does not exist. 64 | yield module.name, ExtMetadata() 65 | 66 | 67 | async def invoke_help_command(ctx: commands.Context) -> None: 68 | """Invoke the help command or default help command if help extensions is not loaded.""" 69 | if "monty.exts.backend.help" in ctx.bot.extensions: 70 | help_command = ctx.bot.get_command("help") 71 | await ctx.invoke(help_command, ctx.command.qualified_name) # type: ignore 72 | return 73 | await ctx.send_help(ctx.command) 74 | 75 | 76 | EXTENSIONS: dict[str, "ExtMetadata"] = {} 77 | -------------------------------------------------------------------------------- /monty/utils/features.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utils for managing features. 3 | 4 | This provides a util for a feature in the database to be created representing a specific local feature. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from typing import TYPE_CHECKING, Callable, Optional, TypeVar, Union 10 | 11 | import disnake 12 | from disnake.ext import commands 13 | 14 | from monty.database.feature import NAME_REGEX 15 | from monty.errors import FeatureDisabled 16 | 17 | 18 | if TYPE_CHECKING: 19 | from monty.bot import Monty 20 | 21 | 22 | AnyContext = Union[disnake.ApplicationCommandInteraction, commands.Context] 23 | T = TypeVar("T") 24 | 25 | 26 | def require_feature(name: str) -> Callable[[T], T]: 27 | """Require the specified feature for this command.""" 28 | # validate the name meets the regex 29 | match = NAME_REGEX.fullmatch(name) 30 | if not match: 31 | raise RuntimeError(f"name must match regex '{NAME_REGEX.pattern}'") 32 | 33 | async def predicate(ctx: AnyContext) -> bool: 34 | bot: Monty = ctx.bot # type: ignore # this will be a Monty instance 35 | 36 | guild_id: Optional[int] = getattr(ctx, "guild_id", None) or (ctx.guild and ctx.guild.id) 37 | 38 | is_enabled = await bot.guild_has_feature(guild_id, name) 39 | if is_enabled: 40 | return True 41 | 42 | raise FeatureDisabled() 43 | 44 | return commands.check(predicate) 45 | -------------------------------------------------------------------------------- /monty/utils/function.py: -------------------------------------------------------------------------------- 1 | """Utilities for interaction with functions.""" 2 | 3 | import functools 4 | import inspect 5 | import types 6 | import typing as t 7 | 8 | from monty.log import get_logger 9 | 10 | 11 | log = get_logger(__name__) 12 | 13 | AnyCallable = t.Callable[..., t.Any] 14 | FuncT = t.TypeVar("FuncT", bound=AnyCallable) 15 | OtherFuncT = t.TypeVar("OtherFuncT", bound=AnyCallable) 16 | 17 | Argument = t.Union[int, str] 18 | BoundArgs = t.OrderedDict[str, t.Any] 19 | Decorator = t.Callable[[FuncT], OtherFuncT] 20 | ArgValGetter = t.Callable[[BoundArgs], t.Any] 21 | 22 | 23 | class GlobalNameConflictError(Exception): 24 | """Raised when there's a conflict between the globals used to resolve annotations of wrapped and its wrapper.""" 25 | 26 | 27 | def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: 28 | """ 29 | Return a value from `arguments` based on a name or position. 30 | 31 | `arguments` is an ordered mapping of parameter names to argument values. 32 | 33 | Raise TypeError if `name_or_pos` isn't a str or int. 34 | Raise ValueError if `name_or_pos` does not match any argument. 35 | """ 36 | if isinstance(name_or_pos, int): 37 | # Convert arguments to a tuple to make them indexable. 38 | arg_values = tuple(arguments.items()) 39 | arg_pos = name_or_pos 40 | 41 | try: 42 | _, value = arg_values[arg_pos] 43 | return value 44 | except IndexError: 45 | raise ValueError(f"Argument position {arg_pos} is out of bounds.") from None 46 | elif isinstance(name_or_pos, str): 47 | arg_name = name_or_pos 48 | try: 49 | return arguments[arg_name] 50 | except KeyError: 51 | raise ValueError(f"Argument {arg_name!r} doesn't exist.") from None 52 | else: 53 | raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") 54 | 55 | 56 | def get_arg_value_wrapper( 57 | decorator_func: t.Callable[[ArgValGetter], Decorator[FuncT, OtherFuncT]], 58 | name_or_pos: Argument, 59 | func: t.Callable[[t.Any], t.Any] = None, 60 | ) -> Decorator[FuncT, OtherFuncT]: 61 | """ 62 | Call `decorator_func` with the value of the arg at the given name/position. 63 | 64 | `decorator_func` must accept a callable as a parameter to which it will pass a mapping of 65 | parameter names to argument values of the function it's decorating. 66 | 67 | `func` is an optional callable which will return a new value given the argument's value. 68 | 69 | Return the decorator returned by `decorator_func`. 70 | """ 71 | 72 | def wrapper(args: BoundArgs) -> t.Any: 73 | value = get_arg_value(name_or_pos, args) 74 | if func: 75 | value = func(value) 76 | return value 77 | 78 | return decorator_func(wrapper) 79 | 80 | 81 | def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs: 82 | """ 83 | Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. 84 | 85 | Default parameter values are also set. 86 | """ 87 | sig = inspect.signature(func) 88 | bound_args = sig.bind(*args, **kwargs) 89 | bound_args.apply_defaults() 90 | 91 | return bound_args.arguments 92 | 93 | 94 | def update_wrapper_globals( 95 | wrapper: FuncT, 96 | wrapped: AnyCallable, 97 | *, 98 | ignored_conflict_names: t.Union[set[str], frozenset[str]] = None, 99 | ) -> FuncT: 100 | """ 101 | Update globals of `wrapper` with the globals from `wrapped`. 102 | 103 | For forwardrefs in command annotations discordpy uses the __global__ attribute of the function 104 | to resolve their values, with decorators that replace the function this breaks because they have 105 | their own globals. 106 | 107 | This function creates a new function functionally identical to `wrapper`, which has the globals replaced with 108 | a merge of `wrapped`s globals and the `wrapper`s globals. 109 | 110 | An exception will be raised in case `wrapper` and `wrapped` share a global name that is used by 111 | `wrapped`'s typehints and is not in `ignored_conflict_names`, 112 | as this can cause incorrect objects being used by discordpy's converters. 113 | """ 114 | if ignored_conflict_names is None: 115 | ignored_conflict_names = frozenset() 116 | 117 | annotation_global_names = ( 118 | ann.split(".", maxsplit=1)[0] for ann in wrapped.__annotations__.values() if isinstance(ann, str) 119 | ) 120 | # Conflicting globals from both functions' modules that are also used in the wrapper and in wrapped's annotations. 121 | shared_globals = set(wrapper.__code__.co_names) & set(annotation_global_names) 122 | shared_globals &= set(wrapped.__globals__) & set(wrapper.__globals__) - ignored_conflict_names 123 | if shared_globals: 124 | raise GlobalNameConflictError( 125 | "wrapper and the wrapped function share the following " 126 | f"global names used by annotations: {', '.join(shared_globals)}. Resolve the conflicts or add " 127 | "the name to the `ignored_conflict_names` set to suppress this error if this is intentional." 128 | ) 129 | 130 | new_globals = wrapper.__globals__.copy() 131 | new_globals.update((k, v) for k, v in wrapped.__globals__.items() if k not in wrapper.__code__.co_names) 132 | return types.FunctionType( 133 | code=wrapper.__code__, 134 | globals=new_globals, 135 | name=wrapper.__name__, 136 | argdefs=wrapper.__defaults__, 137 | closure=wrapper.__closure__, 138 | ) # type: ignore 139 | 140 | 141 | def command_wraps( 142 | wrapped: AnyCallable, 143 | assigned: t.Sequence[str] = functools.WRAPPER_ASSIGNMENTS, 144 | updated: t.Sequence[str] = functools.WRAPPER_UPDATES, 145 | *, 146 | ignored_conflict_names: t.Union[set[str], frozenset[str]] = None, 147 | ) -> t.Callable[[FuncT], FuncT]: 148 | """Update the decorated function to look like `wrapped` and update globals for discordpy forwardref evaluation.""" 149 | if ignored_conflict_names is None: 150 | ignored_conflict_names = frozenset() 151 | 152 | def decorator(wrapper: FuncT) -> FuncT: 153 | return functools.update_wrapper( 154 | update_wrapper_globals(wrapper, wrapped, ignored_conflict_names=ignored_conflict_names), 155 | wrapped, 156 | assigned, 157 | updated, 158 | ) 159 | 160 | return decorator 161 | -------------------------------------------------------------------------------- /monty/utils/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import datetime 5 | import ssl 6 | from typing import TYPE_CHECKING, Any, Coroutine, Optional, TypeVar, Union 7 | from urllib.parse import urlsplit, urlunsplit 8 | 9 | import base65536 10 | import dateutil.parser 11 | import disnake 12 | 13 | from monty.log import get_logger 14 | from monty.utils import scheduling 15 | from monty.utils.messages import extract_urls 16 | 17 | 18 | if TYPE_CHECKING: 19 | from typing_extensions import ParamSpec 20 | 21 | P = ParamSpec("P") 22 | T = TypeVar("T") 23 | Coro = Coroutine[Any, Any, T] 24 | UNSET = object() 25 | 26 | logger = get_logger(__name__) 27 | 28 | 29 | def suppress_links(message: str) -> str: 30 | """Accepts a message that may contain links, suppresses them, and returns them.""" 31 | for link in extract_urls(message): 32 | message = message.replace(link, f"<{link}>") 33 | return message 34 | 35 | 36 | def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: 37 | """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" 38 | index = 0 39 | for _ in range(n): 40 | index = string.find(substring, index + 1) 41 | if index == -1: 42 | return None 43 | return index 44 | 45 | 46 | def get_num_suffix(num: int) -> str: 47 | """Get the suffix for the provided number. Currently a lazy implementation so this only supports 1-20.""" 48 | if num == 1: 49 | suffix = "st" 50 | elif num == 2: 51 | suffix = "nd" 52 | elif num == 3: 53 | suffix = "rd" 54 | elif 4 <= num < 20: 55 | suffix = "th" 56 | else: 57 | err = "num must be within 1-20. If you receive this error you should refactor the get_num_suffix method." 58 | raise RuntimeError(err) 59 | return suffix 60 | 61 | 62 | def has_lines(string: str, count: int) -> bool: 63 | """Return True if `string` has at least `count` lines.""" 64 | # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break. 65 | split = string.split("\n", count - 1) 66 | 67 | # Make sure the last part isn't empty, which would happen if there was a final newline. 68 | return bool(split[-1]) and len(split) == count 69 | 70 | 71 | def pad_base64(data: str) -> str: 72 | """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" 73 | return data + "=" * (-len(data) % 4) 74 | 75 | 76 | EXPAND_BUTTON_PREFIX = "ghexp-v1:" 77 | 78 | 79 | def encode_github_link(link: str) -> str: 80 | """Encode a github link with base 65536.""" 81 | scheme, netloc, path, query, fragment = urlsplit(link) 82 | user, repo, literal_blob, blob, file_path = path.lstrip("/").split("/", 4) 83 | data = f"{user}/{repo}/{blob}/{file_path}#{fragment}" 84 | 85 | encoded = base65536.encode(data.encode()) 86 | end_result = EXPAND_BUTTON_PREFIX + encoded 87 | assert link == decode_github_link(end_result), f"{link} != {decode_github_link(end_result)}" 88 | return end_result 89 | 90 | 91 | def decode_github_link(compressed: str) -> str: 92 | """Decode a GitHub link that was encoded with `encode_github_link`.""" 93 | compressed = compressed.removeprefix(EXPAND_BUTTON_PREFIX) 94 | # compressed = compressed.encode() 95 | data = base65536.decode(compressed).decode() 96 | 97 | if "#" in data: 98 | path, fragment = data.rsplit("#", 1) 99 | else: 100 | path, fragment = data, "" 101 | user, repo, blob, file_path = path.split("/", 3) 102 | path = f"{user}/{repo}/blob/{blob}/{file_path}" 103 | return urlunsplit(("https", "github.com", path, "", fragment)) 104 | 105 | 106 | def maybe_defer(inter: disnake.Interaction, *, delay: Union[float, int] = 2.0, **options) -> asyncio.Task: 107 | """Defer an interaction if it has not been responded to after ``delay`` seconds.""" 108 | loop = inter.bot.loop 109 | if delay <= 0: 110 | return scheduling.create_task(inter.response.defer(**options)) 111 | 112 | async def internal_task() -> None: 113 | now = loop.time() 114 | await asyncio.sleep(delay - (start - now)) 115 | 116 | if inter.response.is_done(): 117 | return 118 | try: 119 | await inter.response.defer(**options) 120 | except disnake.HTTPException as e: 121 | if e.code == 40060: # interaction has already been acked 122 | logger.warning("interaction was already responded to (race condition)") 123 | return 124 | raise e 125 | 126 | start = loop.time() 127 | return scheduling.create_task(internal_task()) 128 | 129 | 130 | def utcnow() -> datetime.datetime: 131 | """Return the current time as an aware datetime in UTC.""" 132 | return datetime.datetime.now(datetime.timezone.utc) 133 | 134 | 135 | def fromisoformat(timestamp: str) -> datetime.datetime: 136 | """Parse the given ISO-8601 timestamp to an aware datetime object, assuming UTC if timestamp contains no timezone.""" # noqa: E501 137 | dt = dateutil.parser.isoparse(timestamp) 138 | if not dt.tzinfo: 139 | # assume UTC if naive datetime 140 | dt = dt.replace(tzinfo=datetime.timezone.utc) 141 | return dt 142 | 143 | 144 | def ssl_create_default_context() -> ssl.SSLContext: 145 | """Return an ssl context that CloudFlare shouldn't flag.""" 146 | ssl_context = ssl.create_default_context() 147 | ssl_context.post_handshake_auth = True 148 | return ssl_context 149 | -------------------------------------------------------------------------------- /monty/utils/inventory_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import zlib 5 | from collections import defaultdict 6 | from datetime import timedelta 7 | from typing import TYPE_CHECKING, AsyncIterator, DefaultDict, List, Optional, Tuple, Union 8 | 9 | import aiohttp 10 | 11 | from monty.log import get_logger 12 | from monty.utils import helpers 13 | from monty.utils.caching import redis_cache 14 | 15 | 16 | if TYPE_CHECKING: 17 | from monty.bot import Monty 18 | 19 | 20 | log = get_logger(__name__) 21 | 22 | FAILED_REQUEST_ATTEMPTS = 3 23 | _V2_LINE_RE = re.compile(r"(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)") 24 | 25 | InventoryDict = DefaultDict[str, List[Union[Tuple[str, str], Tuple[str, str, str]]]] 26 | 27 | 28 | class InvalidHeaderError(Exception): 29 | """Raised when an inventory file has an invalid header.""" 30 | 31 | 32 | class ZlibStreamReader: 33 | """Class used for decoding zlib data of a stream line by line.""" 34 | 35 | READ_CHUNK_SIZE = 16 * 1024 36 | 37 | def __init__(self, stream: aiohttp.StreamReader) -> None: 38 | self.stream = stream 39 | 40 | async def _read_compressed_chunks(self) -> AsyncIterator[bytes]: 41 | """Read zlib data in `READ_CHUNK_SIZE` sized chunks and decompress.""" 42 | decompressor = zlib.decompressobj() 43 | async for chunk in self.stream.iter_chunked(self.READ_CHUNK_SIZE): 44 | yield decompressor.decompress(chunk) 45 | 46 | yield decompressor.flush() 47 | 48 | async def __aiter__(self) -> AsyncIterator[str]: 49 | """Yield lines of decompressed text.""" 50 | buf = b"" 51 | async for chunk in self._read_compressed_chunks(): 52 | buf += chunk 53 | pos = buf.find(b"\n") 54 | while pos != -1: 55 | yield buf[:pos].decode() 56 | buf = buf[pos + 1 :] 57 | pos = buf.find(b"\n") 58 | 59 | 60 | async def _load_v1(stream: aiohttp.StreamReader) -> InventoryDict: 61 | invdata = defaultdict(list) 62 | 63 | async for line in stream: 64 | name, type_, location = line.decode().rstrip().split(maxsplit=2) 65 | # version 1 did not add anchors to the location 66 | if type_ == "mod": 67 | type_ = "py:module" 68 | location += "#module-" + name 69 | else: 70 | type_ = "py:" + type_ 71 | location += "#" + name 72 | invdata[type_].append((name, location, name)) 73 | return invdata 74 | 75 | 76 | async def _load_v2(stream: aiohttp.StreamReader) -> InventoryDict: 77 | invdata = defaultdict(list) 78 | 79 | async for line in ZlibStreamReader(stream): 80 | m = _V2_LINE_RE.match(line.rstrip()) 81 | if m is None: 82 | continue 83 | name, type_, _priority, location, dispname = m.groups() # ignore the parsed items we don't need 84 | if location.endswith("$"): 85 | location = location[:-1] + name 86 | 87 | invdata[type_].append((name, location, dispname)) 88 | return invdata 89 | 90 | 91 | async def _fetch_inventory(bot: Monty, url: str) -> InventoryDict: 92 | """Fetch, parse and return an intersphinx inventory file from an url.""" 93 | timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5) 94 | async with bot.http_session.get( 95 | url, timeout=timeout, raise_for_status=True, use_cache=False, ssl=helpers.ssl_create_default_context() 96 | ) as response: 97 | stream = response.content 98 | 99 | inventory_header = (await stream.readline()).decode().rstrip() 100 | try: 101 | inventory_version = int(inventory_header[-1:]) 102 | except ValueError as e: 103 | raise InvalidHeaderError("Unable to convert inventory version header.") from e 104 | 105 | has_project_header = (await stream.readline()).startswith(b"# Project") 106 | has_version_header = (await stream.readline()).startswith(b"# Version") 107 | if not (has_project_header and has_version_header): 108 | raise InvalidHeaderError("Inventory missing project or version header.") 109 | 110 | if inventory_version == 1: 111 | return await _load_v1(stream) 112 | 113 | elif inventory_version == 2: 114 | if b"zlib" not in await stream.readline(): 115 | raise InvalidHeaderError("'zlib' not found in header of compressed inventory.") 116 | return await _load_v2(stream) 117 | 118 | raise InvalidHeaderError("Incompatible inventory version.") 119 | 120 | 121 | @redis_cache( 122 | "sphinx-inventory", 123 | lambda url, **kw: url, # type: ignore 124 | include_posargs=[1], 125 | skip_cache_func=lambda *args, **kwargs: not kwargs.get("use_cache", True), # type: ignore 126 | timeout=timedelta(hours=12), 127 | ) 128 | async def fetch_inventory(bot: Monty, url: str, *, use_cache: bool = True) -> Optional[InventoryDict]: 129 | """ 130 | Get an inventory dict from `url`, retrying `FAILED_REQUEST_ATTEMPTS` times on errors. 131 | 132 | `url` should point at a valid sphinx objects.inv inventory file, which will be parsed into the 133 | inventory dict in the format of {"domain:role": [("symbol_name", "relative_url_to_symbol"), ...], ...} 134 | """ 135 | for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): 136 | try: 137 | inventory = await _fetch_inventory(bot, url) 138 | except aiohttp.ClientConnectorError: 139 | log.warning( 140 | f"Failed to connect to inventory url at {url}; trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." 141 | ) 142 | except aiohttp.ClientError: 143 | log.error(f"Failed to get inventory from {url}; trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS}).") 144 | except InvalidHeaderError: 145 | raise 146 | except Exception: 147 | log.exception( 148 | f"An unexpected error has occurred during fetching of {url}; " 149 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." 150 | ) 151 | raise 152 | else: 153 | return inventory 154 | 155 | return None 156 | -------------------------------------------------------------------------------- /monty/utils/lock.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import inspect 5 | from collections import defaultdict 6 | from functools import partial 7 | from types import TracebackType 8 | from typing import ( 9 | TYPE_CHECKING, 10 | Any, 11 | Awaitable, 12 | Callable, 13 | Coroutine, 14 | Hashable, 15 | Literal, 16 | Optional, 17 | Type, 18 | TypeVar, 19 | Union, 20 | overload, 21 | ) 22 | from weakref import WeakValueDictionary 23 | 24 | from monty.errors import LockedResourceError 25 | from monty.log import get_logger 26 | from monty.utils import function 27 | from monty.utils.function import command_wraps 28 | 29 | 30 | if TYPE_CHECKING: 31 | from typing_extensions import ParamSpec 32 | 33 | P = ParamSpec("P") 34 | T = TypeVar("T") 35 | Coro = Coroutine[Any, Any, T] 36 | 37 | 38 | log = get_logger(__name__) 39 | __lock_dicts = defaultdict(WeakValueDictionary) 40 | 41 | _IdCallableReturn = Union[Hashable, Awaitable[Hashable]] 42 | _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] 43 | ResourceId = Union[Hashable, _IdCallable] 44 | 45 | 46 | class SharedEvent: 47 | """ 48 | Context manager managing an internal event exposed through the wait coro. 49 | 50 | While any code is executing in this context manager, the underlying event will not be set; 51 | when all of the holders finish the event will be set. 52 | """ 53 | 54 | def __init__(self) -> None: 55 | self._active_count = 0 56 | self._event = asyncio.Event() 57 | self._event.set() 58 | 59 | def __enter__(self) -> None: 60 | """Increment the count of the active holders and clear the internal event.""" 61 | self._active_count += 1 62 | self._event.clear() 63 | 64 | def __exit__( 65 | self, 66 | _exc_type: Optional[Type[BaseException]], 67 | _exc_val: Optional[BaseException], 68 | _exc_tb: Optional[TracebackType], 69 | ) -> None: # noqa: ANN001 70 | """Decrement the count of the active holders; if 0 is reached set the internal event.""" 71 | self._active_count -= 1 72 | if not self._active_count: 73 | self._event.set() 74 | 75 | async def wait(self) -> None: 76 | """Wait for all active holders to exit.""" 77 | await self._event.wait() 78 | 79 | 80 | @overload 81 | def lock( 82 | namespace: Hashable, 83 | resource_id: ResourceId, 84 | *, 85 | raise_error: Literal[False] = False, 86 | wait: bool = ..., 87 | ) -> Callable[[Callable[P, Coro[T]]], Callable[P, Coro[T | None]]]: ... 88 | 89 | 90 | @overload 91 | def lock( 92 | namespace: Hashable, 93 | resource_id: ResourceId, 94 | *, 95 | raise_error: Literal[True], 96 | wait: bool = False, 97 | ) -> Callable[[Callable[P, Coro[T]]], Callable[P, Coro[T]]]: ... 98 | 99 | 100 | def lock( 101 | namespace: Hashable, 102 | resource_id: ResourceId, 103 | *, 104 | raise_error: bool = False, 105 | wait: bool = False, 106 | ) -> Callable[[Callable[P, Coro[T]]], Callable[P, Coro[T | None]]]: 107 | """ 108 | Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. 109 | 110 | If `wait` is True, wait until the lock becomes available. Otherwise, if any other mutually 111 | exclusive function currently holds the lock for a resource, do not run the decorated function 112 | and return None. 113 | 114 | If `raise_error` is True, raise `LockedResourceError` if the lock cannot be acquired. 115 | 116 | `namespace` is an identifier used to prevent collisions among resource IDs. 117 | 118 | `resource_id` identifies a resource on which to perform a mutually exclusive operation. 119 | It may also be a callable or awaitable which will return the resource ID given an ordered 120 | mapping of the parameters' names to arguments' values. 121 | 122 | If decorating a command, this decorator must go before (below) the `command` decorator. 123 | """ 124 | 125 | def decorator(func: Callable[P, Coro[T]]) -> Callable[P, Coro[T | None]]: 126 | name = func.__name__ 127 | 128 | @command_wraps(func) 129 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None: 130 | log.trace(f"{name}: mutually exclusive decorator called") 131 | 132 | if callable(resource_id): 133 | log.trace(f"{name}: binding args to signature") 134 | bound_args = function.get_bound_args(func, args, kwargs) 135 | 136 | log.trace(f"{name}: calling the given callable to get the resource ID") 137 | id_ = resource_id(bound_args) 138 | 139 | if inspect.isawaitable(id_): 140 | log.trace(f"{name}: awaiting to get resource ID") 141 | id_ = await id_ 142 | else: 143 | id_ = resource_id 144 | 145 | log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}") 146 | 147 | # Get the lock for the ID. Create a lock if one doesn't exist yet. 148 | locks = __lock_dicts[namespace] 149 | lock_ = locks.setdefault(id_, asyncio.Lock()) 150 | 151 | # It's safe to check an asyncio.Lock is free before acquiring it because: 152 | # 1. Synchronous code like `if not lock_.locked()` does not yield execution 153 | # 2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free 154 | # 3. awaits only yield execution to the event loop at actual I/O boundaries 155 | if wait or not lock_.locked(): 156 | log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...") 157 | async with lock_: 158 | return await func(*args, **kwargs) 159 | else: 160 | log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") 161 | if raise_error: 162 | raise LockedResourceError(str(namespace), id_) 163 | 164 | return wrapper 165 | 166 | return decorator 167 | 168 | 169 | def lock_arg( 170 | namespace: Hashable, 171 | name_or_pos: function.Argument, 172 | func: Callable[[Any], _IdCallableReturn] = None, 173 | *, 174 | raise_error: bool = False, 175 | wait: bool = False, 176 | ) -> Callable: 177 | """ 178 | Apply the `lock` decorator using the value of the arg at the given name/position as the ID. 179 | 180 | `func` is an optional callable or awaitable which will return the ID given the argument value. 181 | See `lock` docs for more information. 182 | """ 183 | decorator_func = partial(lock, namespace, raise_error=raise_error, wait=wait) 184 | return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) 185 | -------------------------------------------------------------------------------- /monty/utils/responses.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper methods for responses from the bot to the user. 3 | 4 | These help ensure consistency between errors, as they will all be consistent between different uses. 5 | 6 | Note: these are to used for general success or general errors. Typically, the error handler will make a 7 | response if a command raises a disnake.ext.commands.CommandError exception. 8 | """ 9 | 10 | import random 11 | from typing import Any, List, Literal 12 | 13 | import disnake 14 | from disnake.ext import commands 15 | 16 | from monty.log import get_logger 17 | 18 | 19 | __all__ = ( 20 | "DEFAULT_SUCCESS_COLOUR", 21 | "SUCCESS_HEADERS", 22 | "DEFAULT_FAILURE_COLOUR", 23 | "FAILURE_HEADERS", 24 | "send_general_response", 25 | "send_positive_response", 26 | "send_negatory_response", 27 | ) 28 | 29 | _UNSET: Any = object() 30 | 31 | logger = get_logger(__name__) 32 | 33 | 34 | DEFAULT_SUCCESS_COLOUR = disnake.Colour.green() 35 | SUCCESS_HEADERS: List[str] = [ 36 | "Affirmative", 37 | "As you wish", 38 | "Done", 39 | "Fine by me", 40 | "There we go", 41 | "Sure!", 42 | "Okay", 43 | "You got it", 44 | "Your wish is my command", 45 | ] 46 | 47 | DEFAULT_FAILURE_COLOUR = disnake.Colour.red() 48 | FAILURE_HEADERS: List[str] = [ 49 | "Abort!", 50 | "I cannot do that", 51 | "Hold up!", 52 | "I was unable to interpret that", 53 | "Not understood", 54 | "Oops", 55 | "Something went wrong", 56 | "\U0001f914", 57 | "Unable to complete your command", 58 | ] 59 | 60 | 61 | async def send_general_response( 62 | channel: disnake.abc.Messageable, 63 | response: str, 64 | *, 65 | message: disnake.Message = None, 66 | embed: disnake.Embed = _UNSET, 67 | colour: disnake.Colour = None, 68 | title: str = None, 69 | tag_as: Literal["general", "affirmative", "negatory"] = "general", 70 | **kwargs, 71 | ) -> disnake.Message: 72 | """ 73 | Helper method to send a response. 74 | 75 | Shortcuts are provided as `send_positive_response` and `send_negatory_response` which 76 | fill in the title and colour automatically. 77 | """ 78 | kwargs["allowed_mentions"] = kwargs.get("allowed_mentions", disnake.AllowedMentions.none()) 79 | 80 | if isinstance(channel, commands.Context): # pragma: nocover 81 | channel = channel.channel 82 | 83 | logger.debug(f"Requested to send {tag_as} response message to {channel!s}. Response: {response!s}") 84 | 85 | if embed is None: 86 | if message is None: 87 | return await channel.send(response, **kwargs) 88 | else: 89 | return await message.edit(response, **kwargs) 90 | 91 | if embed is _UNSET: # pragma: no branch 92 | embed = disnake.Embed(colour=colour) 93 | 94 | if title is not None: 95 | embed.title = title 96 | 97 | embed.description = response 98 | 99 | if message is None: 100 | return await channel.send(embed=embed, **kwargs) 101 | else: 102 | return await message.edit(embed=embed, **kwargs) 103 | 104 | 105 | async def send_positive_response( 106 | channel: disnake.abc.Messageable, 107 | response: str, 108 | *, 109 | colour: disnake.Colour = _UNSET, 110 | **kwargs, 111 | ) -> disnake.Message: 112 | """ 113 | Send an affirmative response. 114 | 115 | Requires a messageable, and a response. 116 | If embed is set to None, this will send response as a plaintext message, with no allowed_mentions. 117 | If embed is provided, this method will send a response using the provided embed, edited in place. 118 | Extra kwargs are passed to Messageable.send() 119 | 120 | If message is provided, it will attempt to edit that message rather than sending a new one. 121 | """ 122 | if colour is _UNSET: # pragma: no branch 123 | colour = DEFAULT_SUCCESS_COLOUR 124 | 125 | kwargs["title"] = kwargs.get("title", random.choice(SUCCESS_HEADERS)) 126 | 127 | return await send_general_response( 128 | channel=channel, 129 | response=response, 130 | colour=colour, 131 | tag_as="affirmative", 132 | **kwargs, 133 | ) 134 | 135 | 136 | async def send_negatory_response( 137 | channel: disnake.abc.Messageable, 138 | response: str, 139 | *, 140 | colour: disnake.Colour = _UNSET, 141 | **kwargs, 142 | ) -> disnake.Message: 143 | """ 144 | Send a negatory response. 145 | 146 | Requires a messageable, and a response. 147 | If embed is set to None, this will send response as a plaintext message, with no allowed_mentions. 148 | If embed is provided, this method will send a response using the provided embed, edited in place. 149 | Extra kwargs are passed to Messageable.send() 150 | """ 151 | if colour is _UNSET: # pragma: no branch 152 | colour = DEFAULT_FAILURE_COLOUR 153 | 154 | kwargs["title"] = kwargs.get("title", random.choice(FAILURE_HEADERS)) 155 | 156 | return await send_general_response( 157 | channel=channel, 158 | response=response, 159 | colour=colour, 160 | tag_as="negatory", 161 | **kwargs, 162 | ) 163 | -------------------------------------------------------------------------------- /monty/utils/rollouts.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | import math 4 | import random 5 | 6 | from monty.database import Rollout 7 | 8 | 9 | def update_counts_to_time(rollout: Rollout, current_time: datetime.datetime) -> tuple[int, int]: 10 | """Calculate the new rollout hash levels for the current time, in relation the scheduled time.""" 11 | if rollout.rollout_by is None: 12 | raise RuntimeError("rollout must have rollout_by set.") 13 | 14 | # if the current time is after rollout_by, return the current values 15 | if rollout.rollout_by < current_time: 16 | return rollout.rollout_hash_low, rollout.rollout_hash_high 17 | 18 | # if we're within a 5 minute range complete the rollout 19 | if abs(rollout.rollout_by - current_time) < datetime.timedelta(minutes=5): 20 | return find_new_hash_levels(rollout, goal_percent=rollout.rollout_to_percent) 21 | 22 | old_level = compute_current_percent(rollout) * 100 23 | goal = rollout.rollout_to_percent 24 | seconds_elapsed = (current_time - rollout.hashes_last_updated).total_seconds() 25 | total_seconds = (rollout.rollout_by - rollout.hashes_last_updated).total_seconds() 26 | 27 | new_diff = round(seconds_elapsed / (total_seconds) * (goal - old_level), 2) 28 | return find_new_hash_levels(rollout, new_diff + old_level) 29 | 30 | 31 | def compute_current_percent(rollout: Rollout) -> float: 32 | """Computes the current rollout percentage.""" 33 | return (rollout.rollout_hash_high - rollout.rollout_hash_low) / 10_000 34 | 35 | 36 | def find_new_hash_levels(rollout: Rollout, goal_percent: float) -> tuple[int, int]: 37 | """Calcuate the new hash levels from the provided goal percentage.""" 38 | # the goal_percent comes as 0 to 100, instead of 0 to 1. 39 | goal_percent = round(goal_percent / 100, 5) 40 | high: float = rollout.rollout_hash_high 41 | low: float = rollout.rollout_hash_low 42 | 43 | # this is the goal result of hash_high minus hash_low 44 | needed_difference = math.floor(goal_percent * 10_000) 45 | current_difference = high - low 46 | 47 | if current_difference > needed_difference: 48 | raise RuntimeError("the current percent is above the new goal percent.") 49 | 50 | if current_difference == needed_difference: 51 | # shortcut and return the existing values 52 | return low, high 53 | 54 | # difference is the total amount that needs to be added to the range right now 55 | difference = needed_difference - current_difference 56 | 57 | if low == 0: 58 | # can't change the low hash at all, so we just change the high hash 59 | high += difference 60 | return low, high 61 | 62 | if high == 10_000: 63 | # can't change the high hash at all, so we just change the low hash 64 | low -= difference 65 | return low, high 66 | 67 | # do some math to compute adding a random amount to each 68 | add_to_low = min(random.choice(range(0, difference, 50)), low) 69 | 70 | difference -= add_to_low 71 | low -= add_to_low 72 | high += difference 73 | 74 | return low, high 75 | 76 | 77 | def is_rolled_out_to(id: int, *, rollout: Rollout, include_rollout_id: bool = True) -> bool: 78 | """ 79 | Check if the provided rollout is rolled out to the provided Discord ID. 80 | 81 | This method hashes the rollout name with the ID and checks if the result 82 | is within hash_low and hash_high. 83 | """ 84 | to_hash = rollout.name + ":" + str(id) 85 | if include_rollout_id: 86 | to_hash = str(rollout.id) + ":" + to_hash 87 | 88 | hash = hashlib.sha256(to_hash.encode()).hexdigest() 89 | hash_int = int(hash, 16) 90 | is_enabled = (hash_int % 10_000) in range(rollout.rollout_hash_low, rollout.rollout_hash_high) 91 | 92 | return is_enabled 93 | -------------------------------------------------------------------------------- /monty/utils/services.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import Dict, Optional 3 | 4 | from aiohttp import ClientConnectorError 5 | from attrs import define 6 | 7 | from monty import constants 8 | from monty.bot import Monty 9 | from monty.errors import APIError 10 | from monty.log import get_logger 11 | 12 | 13 | if typing.TYPE_CHECKING: 14 | import aiohttp 15 | 16 | log = get_logger(__name__) 17 | 18 | FAILED_REQUEST_ATTEMPTS = 3 19 | 20 | PASTE_DISABLED = not constants.URLs.paste_service 21 | 22 | GITHUB_REQUEST_HEADERS = { 23 | "Accept": "application/vnd.github.v3+json", 24 | "X-GitHub-Api-Version": "2022-11-28", 25 | } 26 | if constants.Tokens.github: 27 | GITHUB_REQUEST_HEADERS["Authorization"] = f"token {constants.Tokens.github}" 28 | 29 | 30 | @define() 31 | class GitHubRateLimit: 32 | limit: int 33 | remaining: int 34 | reset: int 35 | used: int 36 | 37 | 38 | GITHUB_RATELIMITS: dict[str, GitHubRateLimit] = {} 39 | 40 | 41 | async def send_to_paste_service(bot: Monty, contents: str, *, extension: str = "") -> Optional[str]: 42 | """ 43 | Upload `contents` to the paste service. 44 | 45 | `extension` is added to the output URL 46 | 47 | When an error occurs, `None` is returned, otherwise the generated URL with the suffix. 48 | """ 49 | if PASTE_DISABLED: 50 | return "Sorry, paste isn't configured!" 51 | 52 | log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") 53 | paste_url = constants.URLs.paste_service.format(key="api/new") 54 | json: dict[str, str] = { 55 | "content": contents, 56 | } 57 | if extension: 58 | json["language"] = extension 59 | response = None 60 | for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): 61 | response_json = {} 62 | try: 63 | async with bot.http_session.post(paste_url, json=json) as response: 64 | response_json = await response.json() 65 | if not 200 <= response.status < 300 and attempt == FAILED_REQUEST_ATTEMPTS: 66 | raise APIError("workbin", response.status, "The paste service could not be used at this time.") 67 | except ClientConnectorError: 68 | log.warning( 69 | f"Failed to connect to paste service at url {paste_url}, " 70 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." 71 | ) 72 | continue 73 | except Exception: 74 | log.exception( 75 | "An unexpected error has occurred during handling of the request, " 76 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." 77 | ) 78 | continue 79 | 80 | if "message" in response_json: 81 | log.warning( 82 | f"Paste service returned error {response_json['message']} with status code {response.status}, " 83 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." 84 | ) 85 | continue 86 | elif "key" in response_json: 87 | log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") 88 | 89 | paste_link = constants.URLs.paste_service.format(key=f'?id={response_json["key"]}') 90 | if extension: 91 | paste_link += f"&language={extension}" 92 | 93 | return paste_link 94 | 95 | log.warning( 96 | f"Got unexpected JSON response from paste service: {response_json}\n" 97 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." 98 | ) 99 | 100 | raise APIError("workbin", response.status if response else 0, "The paste service could not be used at this time.") 101 | 102 | 103 | # https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#checking-the-status-of-your-rate-limit 104 | def update_github_ratelimits_on_request(resp: "aiohttp.ClientResponse") -> None: 105 | """Given a ClientResponse, update the stored GitHub Ratelimits.""" 106 | resource_name = resp.headers.get("x-ratelimit-resource") 107 | if not resource_name: 108 | # there's nothing to update as the resource name does not exist 109 | return 110 | GITHUB_RATELIMITS[resource_name] = GitHubRateLimit( 111 | limit=int(resp.headers["x-ratelimit-limit"]), 112 | remaining=int(resp.headers["x-ratelimit-remaining"]), 113 | reset=int(resp.headers["x-ratelimit-reset"]), 114 | used=int(resp.headers["x-ratelimit-used"]), 115 | ) 116 | 117 | 118 | # https://docs.github.com/en/rest/rate-limit/rate-limit?apiVersion=2022-11-28 119 | def update_github_ratelimits_from_ratelimit_page(json: dict[str, typing.Any]) -> None: 120 | """Given the response from GitHub's rate_limit API page, update the stored GitHub Ratelimits.""" 121 | ratelimits: Dict[str, Dict[str, int]] = json["resources"] 122 | for name, resource in ratelimits.items(): 123 | GITHUB_RATELIMITS[name] = GitHubRateLimit( 124 | limit=resource["limit"], 125 | remaining=resource["remaining"], 126 | reset=resource["reset"], 127 | used=resource["used"], 128 | ) 129 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "monty" 3 | version = "1.0.0" 4 | description = "Helpful bot for python, github, and discord things." 5 | authors = ["aru "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "~=3.10" 10 | aiodns = "~=3.4" 11 | aiohttp = { version = "~=3.11.18", extras = ['speedups'] } 12 | yarl = "^1.17.2" # transitive dependency of aiohttp 13 | arrow = "~=1.3.0" 14 | attrs = "^24.2.0" 15 | base65536 = "^0.1.1" 16 | beautifulsoup4 = "^4.13.4" 17 | cachingutils = { git = "https://github.com/onerandomusername/cachingutils.git", rev='vcokltfre/feat/v2' } 18 | colorama = { version = "~=0.4.5", markers = "sys_platform == 'win32'" } 19 | coloredlogs = "~=15.0" 20 | disnake = { version = '~=2.9.3' } 21 | orjson = "^3.10.18" 22 | fakeredis = "^2.29.0" 23 | gql = "^3.5.2" 24 | lxml = "^5.4.0" 25 | markdownify = "==0.11.6" 26 | mistune = "^2.0.4" 27 | msgpack = "^1.1.0" 28 | redis = { version = "^5.2.0", extras = ['hiredis'] } 29 | Pillow = "^11.2" 30 | psutil = "^5.9.8" 31 | python-dateutil = "^2.9.0" 32 | rapidfuzz = "^3.13.0" 33 | sentry-sdk = "^2.28.0" 34 | statsd = "^3.3.0" 35 | # database dependencies 36 | alembic = "^1.15.2" 37 | asyncpg = "^0.30.0" 38 | SQLAlchemy = { version = "~=2.0.41", extras = ['asyncio'] } 39 | watchfiles = "^1.0.5" 40 | 41 | [tool.poetry.extras] 42 | fakeredis = ['fakeredis'] 43 | dev = ['fakeredis', 'watchfiles'] 44 | 45 | [tool.poetry.dev-dependencies] 46 | black = "^24.10.0" 47 | ruff = "==0.7.4" 48 | isort = "^5.13.2" 49 | pre-commit = "^4.2.0" 50 | taskipy = "~=1.14.0" 51 | python-dotenv = "^1.1.0" 52 | pyright = "==1.1.389" 53 | msgpack-types = "^0.5.0" 54 | 55 | [build-system] 56 | requires = ["poetry-core>=1.0.0"] 57 | build-backend = "poetry.core.masonry.api" 58 | 59 | [tool.black] 60 | line-length = 120 61 | target-version = ['py38'] 62 | include = '\.pyi?$' 63 | preview = true 64 | 65 | [tool.isort] 66 | profile = "black" 67 | atomic = true 68 | ensure_newline_before_comments = true 69 | force_grid_wrap = 0 70 | include_trailing_comma = true 71 | line_length = 120 72 | lines_after_imports = 2 73 | multi_line_output = 3 74 | use_parentheses = true 75 | known_first_party = ["monty"] 76 | 77 | [tool.ruff] 78 | line-length = 120 79 | target-version = "py38" 80 | 81 | [tool.ruff.lint.isort] 82 | known-first-party = ["monty"] 83 | 84 | 85 | [tool.ruff.lint] 86 | select = [ 87 | "E", # pycodestyle 88 | "F", # pyflakes 89 | "W", # pycodestyle 90 | "S", # bandit 91 | # "RUF", # ruff specific exceptions 92 | "ANN", # flake8-annotations 93 | "B", # flake8-bugbear 94 | "C", # flake8-comprehensions 95 | "D", # flake-docstrings 96 | "DTZ", # flake8-datetimez 97 | "G", # flake8-logging-format 98 | "Q", # flake8-quotes 99 | "T201", "T203" # flake8-print 100 | ] 101 | ignore = [ 102 | # Missing Docstrings 103 | "D100","D101","D104","D105","D106","D107", 104 | # Docstring Whitespace 105 | "D203","D212","D214","D215", 106 | # Docstring Content 107 | "D400","D402","D404","D405","D406","D407","D408","D409","D410","D411","D412","D413","D414","D416","D417", 108 | 109 | # ignore imperative mood for now 110 | "D401", 111 | # Type Annotations 112 | "ANN002","ANN003","ANN101","ANN102","ANN204","ANN206","ANN401", 113 | 114 | # temporarily disabled 115 | "C901", # mccabe 116 | "G004", # Logging statement uses f-string 117 | "S101", # Use of `assert` detected 118 | "S110", # try-except-pass 119 | "S311", # pseduo-random generators, random is used everywhere for random choices. 120 | ] 121 | 122 | [tool.ruff.lint.per-file-ignores] 123 | "monty/alembic/*" = ["D"] 124 | "_global_source_snekcode.py" = ["T201"] 125 | 126 | [tool.ruff.lint.mccabe] 127 | max-complexity = 20 128 | 129 | [tool.taskipy.tasks] 130 | export = 'poetry export --without-hashes -o requirements.txt' 131 | start = "python -m monty" 132 | lint = "pre-commit run --all-files" 133 | precommit = "pre-commit install" 134 | pyright = { cmd = "dotenv -f task.env run -- pyright", help = "Run pyright" } 135 | html = "coverage html" 136 | report = "coverage report" 137 | 138 | [tool.pyright] 139 | typeCheckingMode = "basic" 140 | include = [ 141 | "monty", 142 | "*.py", 143 | ] 144 | 145 | strictParameterNoneValue = false 146 | -------------------------------------------------------------------------------- /task.env: -------------------------------------------------------------------------------- 1 | PYRIGHT_PYTHON_IGNORE_WARNINGS=1 2 | --------------------------------------------------------------------------------