├── .dockerignore ├── .env-example ├── .flake8 ├── .github └── workflows │ ├── lint-build-deploy.yml │ └── status-embed.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── bot ├── __init__.py ├── __main__.py ├── bot.py ├── constants.py ├── converters.py ├── exts │ ├── __init__.py │ ├── backend │ │ ├── __init__.py │ │ └── error_handler.py │ ├── fun │ │ ├── __init__.py │ │ ├── bonker.py │ │ ├── ciphers.py │ │ ├── off_topic.py │ │ └── xkcd.py │ ├── github │ │ ├── __init__.py │ │ ├── _issues.py │ │ ├── _profile.py │ │ ├── _source.py │ │ └── github.py │ ├── gurkan │ │ ├── gurkan_stats.py │ │ ├── gurkify.py │ │ └── make_gurkan.py │ ├── gurkcraft │ │ └── gurkcraft.py │ ├── moderation │ │ ├── __init__.py │ │ ├── logs.py │ │ └── mod_utils.py │ └── utils │ │ ├── __init__.py │ │ ├── _eval_helper.py │ │ ├── bot_stats.py │ │ ├── color.py │ │ ├── devops.py │ │ ├── eval.py │ │ ├── reminder.py │ │ ├── subscription.py │ │ └── utils.py ├── postgres │ ├── __init__.py │ ├── tables │ │ ├── off_topic_names.sql │ │ └── reminders.sql │ └── utils.py ├── resources │ ├── bot_replies.yml │ ├── eval │ │ ├── default_langs.yml │ │ ├── quick_map.yml │ │ └── wrapping.yml │ └── images │ │ └── yodabonk.gif └── utils │ ├── __init__.py │ ├── checks.py │ ├── is_gurkan.py │ ├── pagination.py │ └── parsers.py ├── docker-compose.yaml ├── poetry.lock └── pyproject.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDE and Editors 107 | .vscode/ 108 | .idea/ 109 | 110 | # MACOS Files and Property file 111 | .DS_Store 112 | 113 | # Git 114 | .git/ 115 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | TOKEN="your bot token here" # here is a tutorial on how to get setup a bot and get this 2 | PREFIX="!" # the prefix the bot should use, will default to "!" if this is not present 3 | 4 | # This is required if you do not want to wait up to an hour for commands to sync 5 | # Example: TEST_GUILDS=789192517375623228,793864455527202847 6 | TEST_GUILDS= 7 | 8 | CHANNEL_DEVALERTS="" 9 | CHANNEL_DEVLOG="" 10 | CHANNEL_DEV_GURKBOT="" 11 | CHANNEL_DEV_REAGURK="" 12 | CHANNEL_DEV_GURKLANG="" 13 | CHANNEL_DEV_BRANDING="" 14 | CHANNEL_LOG="" 15 | 16 | EMOJI_TRASHCAN="" 17 | 18 | ROLE_STEERING_COUNCIL="" 19 | ROLE_MODERATORS="" 20 | ROLE_DEVOPS="" 21 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=110 3 | docstring-convention=all 4 | import-order-style=pycharm 5 | exclude=constants.py,__pycache__,.cache, 6 | .git, 7 | .md,.svg,.png 8 | tests, 9 | venv,.venv, 10 | .json 11 | 12 | ignore= 13 | B311,W503,E226,S311,T000 14 | # Missing Docstrings 15 | D100,D104,D105,D107, 16 | # Docstring Whitespace 17 | D203,D212,D214,D215, 18 | # Docstring Quotes 19 | D301,D302, 20 | # Docstring Content 21 | D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417 22 | # Type Annotations 23 | ANN002,ANN003,ANN101,ANN102,ANN204,ANN206 24 | # Whitespace Before 25 | E203 26 | -------------------------------------------------------------------------------- /.github/workflows/lint-build-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Lint, Build & Deploy 2 | 3 | on: [push, pull_request] 4 | concurrency: lint-build-push-${{ github.sha }} 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | 10 | env: 11 | PIP_CACHE_DIR: /tmp/pip-cache-dir 12 | POETRY_CACHE_DIR: /tmp/pip-cache-dir 13 | 14 | steps: 15 | - name: Checks out repository 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Python 3.9 19 | id: python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: 3.9 23 | 24 | # This step caches our Python dependencies. To make sure we 25 | # only restore a cache when the dependencies, the python version and 26 | # the runner operating system we create a cache key 27 | # that is a composite of those states. 28 | # Only when the context is exactly the same, we will restore the cache. 29 | - name: Restore pip cache 30 | uses: actions/cache@v3 31 | with: 32 | path: ${{ env.PIP_CACHE_DIR }} 33 | key: "python-0-${{ runner.os }}-\ 34 | ${{ steps.python.outputs.python-version }}-\ 35 | ${{ hashFiles('./pyproject.toml', './poetry.lock') }}" 36 | 37 | - name: Install dependencies 38 | run: | 39 | pip install poetry 40 | poetry install 41 | 42 | # We will not run `black` or `flake8` here, as we will use a separate 43 | # black and flake8 action. As pre-commit does not support user installs, 44 | # we set PIP_USER=0 to not do a user install. 45 | - name: Run pre-commit hooks 46 | id: pre-commit 47 | run: export PIP_USER=0; SKIP="black,flake8" poetry run pre-commit run --all-files 48 | 49 | # Run flake8 and have it format the linting errors in the format of 50 | # the GitHub Workflow command to register error annotations. This 51 | # means that our flake8 output is automatically added as an error 52 | # annotation to both the run result and in the "Files" tab of a 53 | # pull request. 54 | # 55 | # Format used: 56 | # ::error file={filename},line={line},col={col}::{message} 57 | - name: Run flake8 58 | # this check ensures that black always runs if the pre-commit step ran 59 | # Its best to only have to fix pre-commit once than twice 60 | if: always() && (steps.pre-commit.outcome == 'success' || steps.pre-commit.outcome == 'failure') 61 | run: "poetry run flake8 \ 62 | --format='::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s'" 63 | 64 | # Run black 65 | - name: Run black 66 | # see flake8's comment above 67 | if: always() && (steps.pre-commit.outcome == 'success' || steps.pre-commit.outcome == 'failure') 68 | run: poetry run black . --check --diff 69 | 70 | # Prepare the Pull Request Payload artifact. If this fails, we 71 | # we fail silently using the `continue-on-error` option. It's 72 | # nice if this succeeds, but if it fails for any reason, it 73 | # does not mean that our lint-test checks failed. 74 | - name: Prepare Pull Request Payload artifact 75 | id: prepare-artifact 76 | if: always() && github.event_name == 'pull_request' 77 | continue-on-error: true 78 | run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json 79 | 80 | # This only makes sense if the previous step succeeded. To 81 | # get the original outcome of the previous step before the 82 | # `continue-on-error` conclusion is applied, we use the 83 | # `.outcome` value. This step also fails silently. 84 | - name: Upload a Build Artifact 85 | if: always() && steps.prepare-artifact.outcome == 'success' 86 | continue-on-error: true 87 | uses: actions/upload-artifact@v2 88 | with: 89 | name: pull-request-payload 90 | path: pull_request_payload.json 91 | 92 | build: 93 | runs-on: ubuntu-latest 94 | needs: 95 | - lint 96 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 97 | 98 | steps: 99 | # Create a commit SHA-based tag for the container repositories 100 | - name: Create SHA Container Tag 101 | id: sha_tag 102 | run: | 103 | tag=$(cut -c 1-7 <<< $GITHUB_SHA) 104 | echo "tag=$tag" >> $GITHUB_OUTPUT 105 | 106 | # Check out the current repository in the `gurkbot` subdirectory 107 | - name: Checkout code 108 | uses: actions/checkout@v3 109 | with: 110 | path: gurkbot 111 | 112 | - name: Set up Docker Buildx 113 | uses: docker/setup-buildx-action@v2 114 | 115 | - name: Login to Github Container Registry 116 | uses: docker/login-action@v2 117 | with: 118 | registry: ghcr.io 119 | username: ${{ github.repository_owner }} 120 | password: ${{ secrets.GITHUB_TOKEN }} 121 | 122 | # Build and push the container to the GitHub Container 123 | # Repository. The container will be tagged as "latest" 124 | # and with the short SHA of the commit. 125 | - name: Build and push 126 | uses: docker/build-push-action@v4 127 | with: 128 | context: gurkbot/ 129 | file: gurkbot/Dockerfile 130 | push: true 131 | cache-from: type=registry,ref=ghcr.io/gurkult/gurkbot:latest 132 | cache-to: type=inline 133 | tags: | 134 | ghcr.io/gurkult/gurkbot:latest 135 | ghcr.io/gurkult/gurkbot:${{ steps.sha_tag.outputs.tag }} 136 | build-args: | 137 | git_sha=${{ github.sha }} 138 | 139 | 140 | deploy: 141 | runs-on: ubuntu-latest 142 | needs: 143 | - build 144 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 145 | 146 | steps: 147 | # Save the kubeconfig to a file to be used by kubectl, and roll the deployment 148 | - name: Deploy using Kubectl 149 | run: | 150 | echo "$KUBECONFIG" > .kubeconfig 151 | KUBECONFIG=.kubeconfig kubectl rollout restart deployment gurkbot 152 | env: 153 | KUBECONFIG: ${{ secrets.STARLAKE_KUBECONFIG }} 154 | -------------------------------------------------------------------------------- /.github/workflows/status-embed.yaml: -------------------------------------------------------------------------------- 1 | name: Status Embed 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - Lint, Build & Deploy 7 | types: 8 | - completed 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | status_embed: 16 | # We need to send a status embed whenever the workflow 17 | # sequence we're running terminates. There are a number 18 | # of situations in which that happens: 19 | # 20 | # 1. We reach the end of the Deploy workflow, without 21 | # it being skipped. 22 | # 23 | # 2. A `pull_request` triggered a Lint & Test workflow, 24 | # as the sequence always terminates with one run. 25 | # 26 | # 3. If any workflow ends in failure or was cancelled. 27 | if: github.event.workflow_run.conclusion != 'skipped' 28 | name: Send Status Embed to Discord 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | # A workflow_run event does not contain all the information 33 | # we need for a PR embed. That's why we upload an artifact 34 | # with that information in the Lint workflow. 35 | - name: Get Pull Request Information 36 | id: pr_info 37 | if: github.event.workflow_run.event == 'pull_request' 38 | run: | 39 | curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json 40 | DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') 41 | [ -z "$DOWNLOAD_URL" ] && exit 1 42 | wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2 43 | unzip -p pull_request_payload.zip > pull_request_payload.json 44 | [ -s pull_request_payload.json ] || exit 3 45 | echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 46 | echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 47 | echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 48 | echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | 52 | # Send an informational status embed to Discord instead of the 53 | # standard embeds that Discord sends. This embed will contain 54 | # more information and we can fine tune when we actually want 55 | # to send an embed. 56 | - name: GitHub Actions Status Embed for Discord 57 | uses: SebastiaanZ/github-status-embed-for-discord@v0.2.1 58 | with: 59 | # Our GitHub Actions webhook 60 | webhook_id: '789428354541420544' 61 | webhook_token: ${{ secrets.WEBHOOK_TOKEN }} 62 | 63 | # Workflow information 64 | workflow_name: ${{ github.event.workflow_run.name }} 65 | run_id: ${{ github.event.workflow_run.id }} 66 | run_number: ${{ github.event.workflow_run.run_number }} 67 | status: ${{ github.event.workflow_run.conclusion }} 68 | actor: ${{ github.actor }} 69 | repository: ${{ github.repository }} 70 | ref: ${{ github.ref }} 71 | sha: ${{ github.event.workflow_run.head_sha }} 72 | 73 | pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} 74 | pr_number: ${{ steps.pr_info.outputs.pr_number }} 75 | pr_title: ${{ steps.pr_info.outputs.pr_title }} 76 | pr_source: ${{ steps.pr_info.outputs.pr_source }} 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDE and Editors 107 | .vscode/ 108 | .idea/ 109 | 110 | # MACOS Files and Property file 111 | .DS_Store 112 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: check-toml 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: mixed-line-ending 10 | args: [ --fix=lf ] 11 | - id: trailing-whitespace 12 | 13 | - repo: https://github.com/pre-commit/pygrep-hooks 14 | rev: v1.10.0 15 | hooks: 16 | - id: python-check-blanket-noqa 17 | 18 | - repo: https://github.com/PyCQA/isort 19 | rev: 5.12.0 20 | hooks: 21 | - id: isort 22 | 23 | - repo: local 24 | hooks: 25 | - id: black 26 | name: Black 27 | description: Formats the python source code using black. 28 | language: system 29 | entry: poetry run task format 30 | require_serial: true 31 | pass_filenames: false 32 | 33 | - id: flake8 34 | name: Flake8 35 | description: Lints this repository using flake8. 36 | language: system 37 | entry: poetry run python -m flake8 38 | require_serial: true 39 | pass_filenames: false 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | # Create the working directory 4 | WORKDIR /bot 5 | 6 | # Set necessary environment variables 7 | ENV PIP_NO_CACHE_DIR=false 8 | 9 | # Set the start command 10 | ENTRYPOINT ["python"] 11 | CMD ["-m" , "bot"] 12 | 13 | # Install the latest version of poetry 14 | RUN pip install -U poetry 15 | 16 | # Install production dependencies using poetry 17 | COPY poetry.lock pyproject.toml ./ 18 | RUN poetry config virtualenvs.create false && poetry install --no-dev --no-root 19 | 20 | # Copy the source code in last to optimize rebuilding the image 21 | COPY . . 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Gurkult 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gurkbot 2 | 3 | The official bot for [the Gurkult](https://gurkult.com/discord) — an open source community with the aim of bringing people together. 4 | 5 | --- 6 | 7 | ## Contribute 8 | 9 | If you want to contribute, report a problem, add or suggest a new fix or feature, you can [open a new issue](https://github.com/gurkult/gurkbot/issues/new/choose). The issue should be accepted and discussed before starting to work on the feature. See [Dev Installation](#Dev-Installation) to know how to start working on said feature. 10 | 11 | --- 12 | 13 | ## Discord Setup 14 | 15 | To get a **token**, go to [Discord Developer Portal](https://discord.com/developers/applications). Create an application and add a bot. 16 | 17 | ## Dev Installation 18 | 1. Clone the repository: 19 | - Traditional way: `git clone https://github.com/gurkult/gurkbot.git` or `git clone git@github.com:gurkult/gurkbot.git`. 20 | 21 | - Using Github CLI: `gh repo clone gurkult/gurkbot`. 22 | 23 | Then navigate to the directory `cd gurkbot/` 24 | 25 | 2. Create a new branch by `git checkout -b main` or `git switch -c main`. Make sure the new branch name is related to the feature or the fix you have in mind. 26 | 27 | 28 | ## Environment variable setup 29 | Create a `.env` file in the project root folder. 30 | Copy the contents from [`.env-example`](https://github.com/gurkult/gurkbot/blob/main/.env-example) file into your `.env` file and fill up the fields with your bot token and server details. 31 | 32 | 33 | ## Docker setup (recommended) 34 | 35 | 1. Pre-requisites 36 | - [Docker](https://docs.docker.com/engine/install/) 37 | - [Docker Compose](https://docs.docker.com/compose/install/) 38 | 4. Running and stopping the project 39 | ```SH 40 | # Build image and start project 41 | # This will start both the Postgres database and the bot. 42 | # Running this the first time will build the image. 43 | docker-compose up --build 44 | 45 | # Start/create the postgres database and the bot containers. 46 | docker-compose up 47 | 48 | # Use -d flag for detached mode 49 | # This will free the terminal for other uses. 50 | docker-compose up -d 51 | 52 | # Stop project 53 | # Use ctrl+C if not in detached mode 54 | docker-compose stop 55 | 56 | # Stop and remove containers. 57 | docker-compose down 58 | 59 | # Use -v or --volumes flag to remove volumes 60 | docker-compose down --volumes 61 | 62 | # Alternativily, `docker-compose` can be 63 | # replaced with `docker compose` (without the hyphen). 64 | ``` 65 | 5. Running only database with docker 66 | ```SH 67 | docker-compose up postgres 68 | ``` 69 | 70 | 5. Running only bot with docker 71 | ```SH 72 | docker-compose up gurkbot --no-deps 73 | ``` 74 | 75 | 76 | ## Running manually (without docker) 77 | 1. Prerequisites 78 | - [Python 3.9](https://www.python.org/downloads/) 79 | - [Poetry](https://python-poetry.org/docs/#installation) 80 | - Postgres database 81 | - [Download](https://www.postgresql.org/download/) 82 | 83 | 2. Database setup 84 | - Open terminal/cmd and enter psql 85 | ```SH 86 | psql -U postgres -d postgres 87 | ``` 88 | - Create user and database 89 | ```SH 90 | CREATE USER gurkbotdb WITH SUPERUSER PASSWORD 'gurkbotdb'; 91 | CREATE DATABASE gurkbot WITH OWNER gurkbotdb; 92 | ``` 93 | 3. Add `DATABASE_URL` variable to `.env` file. 94 | ``` 95 | DATABASE_URL = postgres://gurkbotdb:gurkbotdb@localhost:5432/gurkbot 96 | ``` 97 | #### About the URL 98 | - format: `postgres://:@:/` 99 | - If you have changed any of the parameters such has `port`, `username`, `password` or `database` during installation or in psql, reflect those changes in the `DATABASE_URL`. 100 | - The host will be `localhost` unless you want to connect to a database which is not hosted on your machine. 101 | 102 | 4. Command to run the bot: `poetry run task bot` 103 | 5. Commands to remember: 104 | ```SH 105 | # Installs the pre-commit git hook. 106 | poetry run task precommit 107 | 108 | # Formats the project with black. 109 | poetry run task format 110 | 111 | # Runs pre-commit across the project, formatting and linting files. 112 | poetry run task lint 113 | 114 | # Runs the discord bot. 115 | poetry run task bot 116 | ``` 117 | -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import typing 5 | 6 | import loguru 7 | from loguru import logger 8 | 9 | from .constants import ENVIRONMENT, LOG_FILE 10 | 11 | 12 | def should_rotate(message: loguru.Message, file: typing.TextIO) -> bool: 13 | """When should the bot rotate : Once in 1 week or if the size is greater than 5 MB.""" 14 | filepath = os.path.abspath(file.name) 15 | creation = os.path.getmtime(filepath) 16 | now = message.record["time"].timestamp() 17 | max_time = 7 * 24 * 60 * 60 # 1 week in seconds 18 | if file.tell() + len(message) > 5 * (2 ** 20): # if greater than size 5 MB 19 | return True 20 | if now - creation > max_time: 21 | return True 22 | return False 23 | 24 | 25 | if ENVIRONMENT != "production": 26 | logger.add(LOG_FILE, rotation=should_rotate) 27 | logger.info("Logging Process Started") 28 | -------------------------------------------------------------------------------- /bot/__main__.py: -------------------------------------------------------------------------------- 1 | from .bot import Bot 2 | 3 | if __name__ == "__main__": 4 | bot = Bot() 5 | bot.run() 6 | -------------------------------------------------------------------------------- /bot/bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | import asyncpg 6 | from aiohttp import ClientSession 7 | from disnake import AllowedMentions, Embed, Intents, Object 8 | from disnake.ext import commands 9 | from loguru import logger 10 | 11 | from bot.postgres import create_tables 12 | 13 | from . import constants 14 | 15 | 16 | class Bot(commands.Bot): 17 | """The core of the bot.""" 18 | 19 | def __init__(self) -> None: 20 | intents = Intents.default() 21 | intents.members = True 22 | intents.presences = True 23 | 24 | self.http_session = ClientSession() 25 | self.db_pool: asyncpg.Pool = asyncpg.create_pool(constants.DATABASE_URL) 26 | allowed_mention_roles = [ 27 | Object(r) 28 | for r in [ 29 | constants.Roles.steering_council, 30 | constants.Roles.moderators, 31 | constants.Roles.gurkult_lords, 32 | ] 33 | ] 34 | 35 | test_guilds = None 36 | if constants.TEST_GUILDS: 37 | logger.warning("Loading with test guilds.") 38 | test_guilds = constants.TEST_GUILDS 39 | 40 | super().__init__( 41 | command_prefix=constants.PREFIX, 42 | intents=intents, 43 | test_guilds=test_guilds, 44 | allowed_mentions=AllowedMentions( 45 | everyone=None, 46 | users=True, 47 | roles=allowed_mention_roles, 48 | replied_user=True, 49 | ), 50 | ) 51 | 52 | self.loop.create_task(self._db_setup()) 53 | 54 | self.launch_time = datetime.utcnow().timestamp() 55 | 56 | async def notify_dev_alert( 57 | self, content: Optional[str] = None, embed: Optional[Embed] = None 58 | ) -> None: 59 | """Notify dev alert channel.""" 60 | await self.wait_until_ready() 61 | await self.get_channel(constants.Channels.devalerts).send( 62 | content=content, embed=embed 63 | ) 64 | 65 | async def _db_setup(self) -> None: 66 | """Setup and initialize database connection.""" 67 | try: 68 | await self.db_pool 69 | await create_tables(self.db_pool) 70 | except Exception as e: 71 | error_msg = f"**{e.__class__.__name__}**\n```{e}```" 72 | logger.error(f"Database ERROR: {error_msg}") 73 | 74 | await self.notify_dev_alert( 75 | embed=Embed( 76 | title="Database error", 77 | description=error_msg, 78 | colour=constants.Colours.soft_red, 79 | ) 80 | ) 81 | await self.close() 82 | 83 | self.load_extensions() 84 | 85 | def load_extensions(self) -> None: 86 | """Load all the extensions in the exts/ folder.""" 87 | logger.info("Start loading extensions from ./exts/") 88 | for extension in constants.EXTENSIONS.glob("*/*.py"): 89 | if extension.name.startswith("_"): 90 | continue # ignore files starting with _ 91 | dot_path = str(extension).replace(os.sep, ".")[:-3] # remove the .py 92 | self.load_extension(dot_path) 93 | logger.info(f"Successfully loaded extension: {dot_path}") 94 | 95 | def run(self) -> None: 96 | """Run the bot with the token in constants.py/.env .""" 97 | logger.info("Starting bot") 98 | 99 | if constants.TOKEN is None: 100 | raise EnvironmentError( 101 | "token value is None. Make sure you have configured the TOKEN field in .env" 102 | ) 103 | 104 | super().run(constants.TOKEN) 105 | 106 | async def on_ready(self) -> None: 107 | """Ran when the bot has connected to discord and is ready.""" 108 | logger.info("Bot online") 109 | await self.startup_greeting() 110 | 111 | async def startup_greeting(self) -> None: 112 | """Announce presence to the devlog channel.""" 113 | embed = Embed(description="Connected!") 114 | embed.set_author( 115 | name="Gurkbot", 116 | url=constants.BOT_REPO_URL, 117 | icon_url=self.user.display_avatar.url, 118 | ) 119 | await self.get_channel(constants.Channels.devlog).send(embed=embed) 120 | 121 | async def close(self) -> None: 122 | """Close Http session when bot is shutting down.""" 123 | if self.http_session: 124 | await self.http_session.close() 125 | 126 | if self.db_pool: 127 | await self.db_pool.close() 128 | 129 | await super().close() 130 | -------------------------------------------------------------------------------- /bot/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | from typing import NamedTuple 4 | 5 | import yaml 6 | 7 | ENVIRONMENT = os.getenv("ENVIRONMENT") 8 | if ENVIRONMENT is None: 9 | from dotenv import load_dotenv 10 | 11 | load_dotenv(dotenv_path=f"{os.getcwd()}/.env") 12 | 13 | # env vars 14 | PREFIX = os.getenv("PREFIX", "!") 15 | 16 | TOKEN = os.getenv("TOKEN") 17 | 18 | BOT_REPO_URL = "https://github.com/gurkult/gurkbot" 19 | 20 | DATABASE_URL = os.getenv("DATABASE_URL") 21 | 22 | # paths 23 | EXTENSIONS = pathlib.Path("bot/exts/") 24 | LOG_FILE = pathlib.Path("log/gurkbot.log") 25 | 26 | 27 | if TEST_GUILDS := os.getenv("TEST_GUILDS"): 28 | TEST_GUILDS = [int(x) for x in TEST_GUILDS.split(",")] 29 | 30 | 31 | class Emojis(NamedTuple): 32 | issue_emoji = "<:IssueOpen:794834041450266624>" 33 | issue_closed_emoji = "<:IssueClosed:794834041240289321>" 34 | pull_request_emoji = "<:PROpen:794834041416187935>" 35 | pull_request_closed_emoji = "<:PRClosed:794834041073172501>" 36 | merge_emoji = "<:PRMerged:794834041173704744>" 37 | 38 | cucumber_emoji = "\U0001f952" 39 | 40 | invalid_emoji = "\u274c" 41 | trashcan = str(os.getenv("EMOJI_TRASHCAN", "<:trash:798179380626587658>")) 42 | 43 | confirmation_emoji = "<:confirmation:824252277262123029>" 44 | warning_emoji = "\u26a0" 45 | 46 | CHECK_MARK_EMOJI = "\U00002705" 47 | CROSS_MARK_EMOJI = "\U0000274C" 48 | MAG_RIGHT_EMOJI = "\U0001f50e" 49 | 50 | 51 | class Colours: 52 | green = 0x1F8B4C 53 | yellow = 0xF1C502 54 | soft_red = 0xCD6D6D 55 | 56 | 57 | class GurkanNameEndings: 58 | name_endings = ["gurk", "gurkan", "urkan"] 59 | 60 | 61 | class Channels(NamedTuple): 62 | off_topic = int(os.getenv("CHANNEL_OFF_TOPIC", 789198156218892358)) 63 | gurkcraft = int(os.getenv("CHANNEL_GURKCRAFT", 878159594189381662)) 64 | gurkcraft_relay = int(os.getenv("CHANNEL_GURKCRAFT_RELAY", 932334985053102101)) 65 | 66 | devalerts = int(os.getenv("CHANNEL_DEVALERTS", 796695123177766982)) 67 | devlog = int(os.getenv("CHANNEL_DEVLOG", 789431367167377448)) 68 | 69 | dev_gurkbot = int(os.getenv("CHANNEL_DEV_GURKBOT", 789295038315495455)) 70 | dev_reagurk = int(os.getenv("CHANNEL_DEV_REAGURK", 789241204696416287)) 71 | dev_gurklang = int(os.getenv("CHANNEL_DEV_GURKLANG", 789249499800535071)) 72 | dev_branding = int(os.getenv("CHANNEL_DEV_BRANDING", 789193817051234306)) 73 | 74 | log = int(os.getenv("CHANNEL_LOGS", 831432092226158652)) 75 | dm_log = int(os.getenv("CHANNEL_LOGS", 833345326675918900)) 76 | 77 | 78 | class Roles(NamedTuple): 79 | gurkans = int(os.getenv("ROLE_GURKANS", 789195552121290823)) 80 | steering_council = int(os.getenv("ROLE_STEERING_COUNCIL", 789213682332598302)) 81 | moderators = int(os.getenv("ROLE_MODERATORS", 818107766585163808)) 82 | gurkult_lords = int(os.getenv("ROLE_GURKULT_LORDS", 789197216869777440)) 83 | devops = int(os.getenv("ROLE_DEVOPS", 918880926606430308)) 84 | 85 | announcements = int(os.getenv("ANNOUNCEMENTS_ID", 789978290844598272)) 86 | polls = int(os.getenv("POLLS_ID", 790043110360350740)) 87 | events = int(os.getenv("EVENTS_ID", 890656665328820224)) 88 | 89 | 90 | # Bot replies 91 | with pathlib.Path("bot/resources/bot_replies.yml").open(encoding="utf8") as file: 92 | bot_replies = yaml.safe_load(file) 93 | ERROR_REPLIES = bot_replies["ERROR_REPLIES"] 94 | POSITIVE_REPLIES = bot_replies["POSITIVE_REPLIES"] 95 | NEGATIVE_REPLIES = bot_replies["NEGATIVE_REPLIES"] 96 | 97 | # Minecraft Server 98 | class Minecraft(NamedTuple): 99 | server_address = "mc.gurkult.com" 100 | -------------------------------------------------------------------------------- /bot/converters.py: -------------------------------------------------------------------------------- 1 | from disnake.ext.commands import BadArgument, Context, Converter 2 | 3 | 4 | class OffTopicName(Converter): 5 | """ 6 | A converter that ensures an added off-topic name is valid. 7 | 8 | Adopted from python-discord/bot https://github.com/python-discord/bot/blob/master/bot/converters.py#L344 9 | License: MIT License 10 | Copyright (c) 2018 Python Discord 11 | """ 12 | 13 | allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" 14 | table = str.maketrans(allowed_characters, "𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-") 15 | 16 | async def convert(self, ctx: Context, argument: str) -> str: 17 | """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" 18 | argument = argument.lower() 19 | 20 | # Chain multiple words to a single one 21 | argument = "-".join(argument.split()) 22 | 23 | if not (2 <= len(argument) <= 29): 24 | raise BadArgument("Channel name must be between 2 and 29 chars long") 25 | 26 | elif not all(c.isalnum() or c in self.allowed_characters for c in argument): 27 | raise BadArgument( 28 | "Channel name must only consist of " 29 | "alphanumeric characters, minus signs or apostrophes." 30 | ) 31 | 32 | # Replace invalid characters with unicode alternatives. 33 | 34 | return argument.translate(self.table) 35 | -------------------------------------------------------------------------------- /bot/exts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/exts/__init__.py -------------------------------------------------------------------------------- /bot/exts/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/exts/backend/__init__.py -------------------------------------------------------------------------------- /bot/exts/backend/error_handler.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | from typing import Optional 4 | 5 | import disnake 6 | from disnake import Embed, Message 7 | from disnake.ext import commands 8 | from loguru import logger 9 | 10 | from bot.constants import ERROR_REPLIES, Channels, Colours 11 | 12 | 13 | class CommandErrorHandler(commands.Cog): 14 | """Handles errors emitted from commands.""" 15 | 16 | def __init__(self, bot: commands.Bot): 17 | self.bot = bot 18 | 19 | @staticmethod 20 | def revert_cooldown_counter(command: commands.Command, message: Message) -> None: 21 | """Undoes the last cooldown counter for user-error cases.""" 22 | if command._buckets.valid: 23 | bucket = command._buckets.get_bucket(message) 24 | bucket._tokens = min(bucket.rate, bucket._tokens + 1) 25 | logger.debug( 26 | "Cooldown counter reverted as the command was not used correctly." 27 | ) 28 | 29 | @staticmethod 30 | def error_embed(message: str, title: Optional[str] = None) -> Embed: 31 | """Build a basic embed with red colour and either a random error title or a title provided.""" 32 | title = title or random.choice(ERROR_REPLIES) 33 | embed = Embed(colour=Colours.soft_red, title=title) 34 | embed.description = message 35 | return embed 36 | 37 | @commands.Cog.listener() 38 | async def on_command_error( 39 | self, ctx: commands.Context, error: commands.CommandError 40 | ) -> None: 41 | """Activates when a command opens an error.""" 42 | if getattr(error, "handled", False): 43 | logger.debug( 44 | f"Command {ctx.command} had its error already handled locally; ignoring." 45 | ) 46 | return 47 | 48 | error = getattr(error, "original", error) 49 | 50 | if isinstance(error, commands.CommandNotFound): 51 | return # Skip logging CommandNotFound Error 52 | 53 | elif isinstance(error, commands.UserInputError): 54 | if isinstance(error, commands.MissingRequiredArgument): 55 | description = ( 56 | f"`{error.param.name}` is a required argument that is missing." 57 | "\n\nUsage:\n" 58 | f"```{ctx.prefix}{ctx.command} {ctx.command.signature}```" 59 | ) 60 | else: 61 | description = ( 62 | f"Your input was invalid: {error}\n\nUsage:\n" 63 | f"```{ctx.prefix}{ctx.command} {ctx.command.signature}```" 64 | ) 65 | 66 | embed = self.error_embed(description) 67 | await ctx.send(embed=embed) 68 | 69 | elif isinstance(error, commands.CommandOnCooldown): 70 | mins, secs = divmod(math.ceil(error.retry_after), 60) 71 | embed = self.error_embed( 72 | f"This command is on cooldown:\nPlease retry in **{mins} minutes {secs} seconds**." 73 | ) 74 | await ctx.send(embed=embed, delete_after=10) 75 | 76 | elif isinstance(error, commands.DisabledCommand): 77 | await ctx.send(embed=self.error_embed("This command has been disabled.")) 78 | 79 | elif isinstance(error, commands.NoPrivateMessage): 80 | await ctx.send( 81 | embed=self.error_embed("This command can only be used in the server.") 82 | ) 83 | 84 | elif isinstance(error, commands.CheckFailure): 85 | await ctx.send( 86 | embed=self.error_embed("You aren't allowed to use this command.") 87 | ) 88 | 89 | elif isinstance(error, commands.BadArgument): 90 | self.revert_cooldown_counter(ctx.command, ctx.message) 91 | embed = self.error_embed( 92 | "The argument you provided was invalid: " 93 | f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```" 94 | ) 95 | await ctx.send(embed=embed) 96 | else: 97 | await self.handle_unexpected_error(ctx, error) 98 | return # Exit early to avoid logging. 99 | 100 | logger.debug( 101 | f"Error Encountered: {type(error).__name__} - {str(error)}, " 102 | f"Command: {ctx.command}, " 103 | f"Author: {ctx.author}, " 104 | f"Channel: {ctx.channel}" 105 | ) 106 | 107 | async def handle_unexpected_error( 108 | self, ctx: commands.Context, error: commands.CommandError 109 | ) -> None: 110 | """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" 111 | await ctx.send( 112 | f"Sorry, an unexpected error occurred. Please let us know!\n\n" 113 | f"```{error.__class__.__name__}: {error}```" 114 | ) 115 | 116 | push_alert = Embed( 117 | title="An unexpected error occurred", 118 | color=Colours.soft_red, 119 | ) 120 | push_alert.add_field( 121 | name="User", 122 | value=f"id: {ctx.author.id} | username: {ctx.author.mention}", 123 | inline=False, 124 | ) 125 | push_alert.add_field( 126 | name="Command", value=ctx.command.qualified_name, inline=False 127 | ) 128 | push_alert.add_field( 129 | name="Message & Channel", 130 | value=f"Message: [{ctx.message.id}]({ctx.message.jump_url}) | Channel: <#{ctx.channel.id}>", 131 | inline=False, 132 | ) 133 | push_alert.add_field( 134 | name="Full Message", value=ctx.message.content, inline=False 135 | ) 136 | 137 | dev_alerts = self.bot.get_channel(Channels.devalerts) 138 | if dev_alerts is None: 139 | logger.info( 140 | f"Fetching dev-alerts channel as it wasn't found in the cache (ID: {Channels.devalerts})" 141 | ) 142 | try: 143 | dev_alerts = await self.bot.fetch_channel(Channels.devalerts) 144 | except disnake.HTTPException as discord_exc: 145 | logger.exception("Fetch failed", exc_info=discord_exc) 146 | return 147 | 148 | # Trigger the logger before trying to use Discord in case that's the issue 149 | logger.error( 150 | f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", 151 | exc_info=error, 152 | ) 153 | await dev_alerts.send(embed=push_alert) 154 | 155 | 156 | def setup(bot: commands.Bot) -> None: 157 | """Error handler Cog load.""" 158 | bot.add_cog(CommandErrorHandler(bot)) 159 | -------------------------------------------------------------------------------- /bot/exts/fun/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/exts/fun/__init__.py -------------------------------------------------------------------------------- /bot/exts/fun/bonker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | from concurrent import futures 4 | from io import BytesIO 5 | from typing import Dict 6 | 7 | import disnake 8 | from disnake.ext import commands 9 | from loguru import logger 10 | from PIL import Image, ImageDraw, ImageFile, ImageSequence 11 | 12 | ImageFile.LOAD_TRUNCATED_IMAGES = True 13 | 14 | LARGE_DIAMETER = 110 15 | SMALL_DIAMETER = 80 16 | 17 | # Two masks, one for the normal size, and a smaller one for the final stage of the bonk 18 | LARGE_MASK = Image.new("L", (LARGE_DIAMETER,) * 2) 19 | draw = ImageDraw.Draw(LARGE_MASK) 20 | draw.ellipse((0, 0, LARGE_DIAMETER, LARGE_DIAMETER), fill=255) 21 | 22 | SMALL_MASK = Image.new("L", (SMALL_DIAMETER,) * 2) 23 | draw = ImageDraw.Draw(SMALL_MASK) 24 | draw.ellipse((0, 0, SMALL_DIAMETER, SMALL_DIAMETER), fill=255) 25 | 26 | BONK_GIF = Image.open("bot/resources/images/yodabonk.gif") 27 | 28 | PFP_ENTRY_FRAME = 31 29 | BONK_FRAME = 43 30 | PFP_EXIT_FRAME = 56 31 | PFP_CENTRE = (355, 73) 32 | 33 | 34 | class Bonk(commands.Cog): 35 | """Cog for sending bonking gifs.""" 36 | 37 | def __init__(self, bot: commands.Bot): 38 | self.bot = bot 39 | 40 | @staticmethod 41 | def _generate_frame( 42 | frame_number: int, frame: Image.Image, pfps_by_size: Dict[str, int] 43 | ) -> Image.Image: 44 | canvas = Image.new("RGBA", BONK_GIF.size) 45 | canvas.paste(frame.convert("RGBA"), (0, 0)) 46 | 47 | if PFP_ENTRY_FRAME <= frame_number <= PFP_EXIT_FRAME: 48 | if frame_number == BONK_FRAME: 49 | canvas.paste( 50 | pfps_by_size["small"], 51 | ( 52 | PFP_CENTRE[0] - SMALL_DIAMETER // 2, 53 | PFP_CENTRE[1] 54 | - SMALL_DIAMETER // 2 55 | + 10, # Shift avatar down by 10 px in the bonk frame 56 | ), 57 | SMALL_MASK, 58 | ) 59 | else: 60 | canvas.paste( 61 | pfps_by_size["large"], 62 | ( 63 | PFP_CENTRE[0] - LARGE_DIAMETER // 2, 64 | PFP_CENTRE[1] - LARGE_DIAMETER // 2, 65 | ), 66 | LARGE_MASK, 67 | ) 68 | 69 | return canvas 70 | 71 | def _generate_gif(self, pfp: bytes) -> BytesIO: 72 | logger.trace("Starting bonk gif generation.") 73 | 74 | pfp = Image.open(BytesIO(pfp)) 75 | pfps_by_size = { 76 | "large": pfp.resize((LARGE_DIAMETER,) * 2), 77 | "small": pfp.resize((SMALL_DIAMETER,) * 2), 78 | } 79 | 80 | out_images = [ 81 | self._generate_frame(i, frame, pfps_by_size) 82 | for i, frame in enumerate(ImageSequence.Iterator(BONK_GIF)) 83 | ] 84 | 85 | out_gif = BytesIO() 86 | out_images[0].save( 87 | out_gif, 88 | "GIF", 89 | save_all=True, 90 | append_images=out_images[1:], 91 | loop=0, 92 | duration=50, 93 | ) 94 | 95 | logger.trace("Bonk gif generated.") 96 | return out_gif 97 | 98 | @commands.command() 99 | @commands.max_concurrency(3) 100 | async def bonk(self, ctx: commands.Context, member: disnake.User) -> None: 101 | """Sends gif of mentioned member being "bonked" by Yoda.""" 102 | pfp = await member.display_avatar.read() 103 | created_at = ctx.message.created_at.strftime("%Y-%m-%d_%H-%M") 104 | out_filename = f"bonk_{member.id}_{created_at}.gif" 105 | func = functools.partial(self._generate_gif, pfp) 106 | 107 | async with ctx.typing(): 108 | with futures.ThreadPoolExecutor() as pool: 109 | out_gif = await asyncio.get_running_loop().run_in_executor(pool, func) 110 | 111 | out_gif.seek(0) 112 | await ctx.send(file=disnake.File(out_gif, out_filename)) 113 | 114 | 115 | def setup(bot: commands.Bot) -> None: 116 | """Load the Bonk cog.""" 117 | bot.add_cog(Bonk(bot)) 118 | -------------------------------------------------------------------------------- /bot/exts/fun/ciphers.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | 4 | from disnake import Embed 5 | from disnake.ext.commands import BadArgument, Cog, Context, group 6 | 7 | from bot.bot import Bot 8 | from bot.constants import Colours 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Ciphers(Cog): 14 | """Commands for working with ciphers, hashes and encryptions.""" 15 | 16 | def __init__(self, bot: Bot) -> None: 17 | self.bot = bot 18 | 19 | @group(name="hash", invoke_without_command=True) 20 | async def hash( 21 | self, 22 | ctx: Context, 23 | algorithm: str, 24 | *, 25 | original: str, 26 | ) -> None: 27 | """Hashes the passed string and returns the result.""" 28 | if algorithm not in hashlib.algorithms_guaranteed: 29 | raise BadArgument( 30 | f"The algorithm `{algorithm}` is not supported. \ 31 | Run `{ctx.prefix}hash algorithms` for a list of supported algorithms." 32 | ) 33 | 34 | func = getattr(hashlib, algorithm) 35 | hashed = func(original.encode("utf-8")).hexdigest() 36 | 37 | embed = Embed( 38 | title=f"Hash ({algorithm})", 39 | description=hashed, 40 | colour=Colours.green, 41 | ) 42 | await ctx.send(embed=embed) 43 | 44 | @hash.command( 45 | name="algorithms", aliases=("algorithm", "algos", "algo", "list", "l") 46 | ) 47 | async def algorithms(self, ctx: Context) -> None: 48 | """Sends a list of all supported hashing algorithms.""" 49 | embed = Embed( 50 | title="Supported algorithms", 51 | description="\n".join( 52 | f"• {algo}" for algo in hashlib.algorithms_guaranteed 53 | ), # Shouldn't need pagination 54 | colour=Colours.green, 55 | ) 56 | await ctx.send(embed=embed) 57 | 58 | 59 | def setup(bot: Bot) -> None: 60 | """Loading the Ciphers cog.""" 61 | bot.add_cog(Ciphers(bot)) 62 | -------------------------------------------------------------------------------- /bot/exts/fun/off_topic.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from datetime import datetime, time, timedelta 4 | from typing import Dict, Iterable, List 5 | 6 | from disnake import Embed, Reaction, TextChannel, User 7 | from disnake.ext.commands import Cog, Context, group, has_any_role 8 | from disnake.utils import sleep_until 9 | from fuzzywuzzy import fuzz 10 | from loguru import logger 11 | 12 | from bot import constants 13 | from bot.bot import Bot 14 | from bot.converters import OffTopicName as OT_Converter 15 | from bot.postgres.utils import db_execute, db_fetch 16 | from bot.utils.pagination import LinePaginator 17 | 18 | FUZZ_RATIO = 80 19 | FUZZ_PARTIAL_RATIO = 100 20 | OT_NAME_PREFIX = "ot|" 21 | 22 | 23 | class OffTopicNames(Cog): 24 | """Manage off-topic channel names.""" 25 | 26 | def __init__(self, bot: Bot) -> None: 27 | self.bot = bot 28 | 29 | self.ot_channel: TextChannel = ... 30 | self.ot_names: Dict[str:int] = ... 31 | 32 | self.bot.loop.create_task(self._cache()) 33 | self.bot.loop.create_task(self.update_ot_channel_name()) 34 | 35 | async def _cache(self) -> None: 36 | """Get all off topic channel names.""" 37 | await self.bot.wait_until_ready() 38 | 39 | self.ot_channel: TextChannel = self.bot.get_channel( 40 | constants.Channels.off_topic 41 | ) 42 | self.ot_names = dict( 43 | await db_fetch(self.bot.db_pool, "SELECT * FROM offtopicnames") 44 | ) 45 | 46 | def _find(self, name: OT_Converter) -> List[str]: 47 | """Find similar Off-topic names.""" 48 | return [ 49 | ot_name 50 | for ot_name in self.ot_names 51 | if fuzz.ratio(ot_name, name) > FUZZ_RATIO 52 | or fuzz.partial_ratio(ot_name, name) == FUZZ_PARTIAL_RATIO 53 | ] 54 | 55 | @staticmethod 56 | def _find_embed_builder(ot_names: List[str], title: str) -> Embed: 57 | """Build embed with a list of Off Topic names.""" 58 | return Embed( 59 | title=title, 60 | description=( 61 | "\n".join( 62 | f"{i}. {ot_name}" for i, ot_name in enumerate(ot_names, start=1) 63 | ) 64 | if ot_names 65 | else ":x: 0 Matches found." 66 | ), 67 | colour=constants.Colours.green if ot_names else constants.Colours.soft_red, 68 | ) 69 | 70 | @staticmethod 71 | async def _send_paginated_embed( 72 | ctx: Context, lines: Iterable[str], title: str 73 | ) -> None: 74 | """Send paginated embed.""" 75 | embed = Embed() 76 | embed.set_author(name=title) 77 | await LinePaginator.paginate( 78 | [f"{i}. {line}" for i, line in enumerate(lines, start=1)], 79 | ctx, 80 | embed, 81 | allow_empty_lines=True, 82 | ) 83 | 84 | @staticmethod 85 | async def _send_ot_embed( 86 | channel: TextChannel, 87 | description: str, 88 | positive: bool, 89 | title: str = "Off Topic Names", 90 | ) -> None: 91 | """Embed helper for Off Topic Names cog.""" 92 | embed = Embed( 93 | title=title, 94 | description=description, 95 | colour=constants.Colours.green if positive else constants.Colours.soft_red, 96 | ) 97 | await channel.send(embed=embed) 98 | 99 | @group(name="offtopicnames", aliases=("otn",), invoke_without_command=True) 100 | async def off_topic_names(self, ctx: Context) -> None: 101 | """Commands for managing off-topic-channel names.""" 102 | await ctx.send_help(ctx.command) 103 | 104 | @off_topic_names.command(name="add", aliases=("a",)) 105 | async def add_ot_name(self, ctx: Context, *, name: OT_Converter) -> None: 106 | """Add off topic channel name.""" 107 | if name in self.ot_names: 108 | await self._send_ot_embed( 109 | ctx.channel, f":x:`{name}` already exists!", False 110 | ) 111 | return 112 | 113 | similar_names = self._find(name) 114 | 115 | if similar_names: 116 | embed = self._find_embed_builder( 117 | similar_names[:5], "Found similar existing names :name_badge:" 118 | ) 119 | embed.set_footer(text="Do you still want to add the off topic name?") 120 | confirmation_msg = await ctx.send(embed=embed) 121 | 122 | await confirmation_msg.add_reaction(constants.Emojis.CHECK_MARK_EMOJI) 123 | await confirmation_msg.add_reaction(constants.Emojis.CROSS_MARK_EMOJI) 124 | 125 | def check(reaction: Reaction, user: User) -> bool: 126 | return reaction.message == confirmation_msg and user == ctx.author 127 | 128 | async def _exit() -> None: 129 | await confirmation_msg.delete() 130 | await self._send_ot_embed( 131 | ctx.channel, f"Off topic name `{name}` not added.", False 132 | ) 133 | 134 | try: 135 | reaction, _ = await self.bot.wait_for( 136 | "reaction_add", check=check, timeout=60.0 137 | ) 138 | except asyncio.TimeoutError: 139 | return await _exit() 140 | 141 | if str(reaction.emoji) == constants.Emojis.CROSS_MARK_EMOJI: 142 | return await _exit() 143 | 144 | await db_execute( 145 | self.bot.db_pool, "INSERT INTO offtopicnames VALUES ($1)", name 146 | ) 147 | self.ot_names[name] = 0 148 | 149 | await self._send_ot_embed( 150 | ctx.channel, f"`{name}` has been added :ok_hand:", True 151 | ) 152 | 153 | @off_topic_names.command(name="delete", aliases=("d", "r", "remove")) 154 | async def delete_ot_name(self, ctx: Context, *, name: OT_Converter) -> None: 155 | """Delete off topic channel name.""" 156 | if name not in self.ot_names: 157 | await self._send_ot_embed(ctx.channel, f":x: `{name}` not found!", False) 158 | if names := self._find(name): 159 | await self._send_paginated_embed( 160 | ctx, names, "Did you mean one of the following?" 161 | ) 162 | return 163 | 164 | await db_execute( 165 | self.bot.db_pool, "DELETE FROM offtopicnames WHERE name=$1", name 166 | ) 167 | del self.ot_names[name] 168 | 169 | await self._send_ot_embed( 170 | ctx.channel, f"`{name}` has been deleted :white_check_mark:", True 171 | ) 172 | 173 | @off_topic_names.command(name="find", aliases=("f", "s", "search")) 174 | async def find_ot_name(self, ctx: Context, *, name: OT_Converter) -> None: 175 | """Find similar off-topic names in database.""" 176 | await self._send_paginated_embed( 177 | ctx, 178 | self._find(name), 179 | f"{constants.Emojis.MAG_RIGHT_EMOJI} Search result: {name}", 180 | ) 181 | 182 | @off_topic_names.command(name="list", aliases=("l",)) 183 | async def list_ot_names(self, ctx: Context) -> None: 184 | """List all Off Topic names.""" 185 | await self._send_paginated_embed(ctx, self.ot_names.keys(), "Off Topic Names") 186 | 187 | async def update_ot_channel_name(self) -> None: 188 | """Update ot-channel name everyday at midnight.""" 189 | while not self.bot.is_closed(): 190 | now = datetime.utcnow() 191 | midnight_datetime = datetime.combine( 192 | now.date() + timedelta(days=1), time(0) 193 | ) 194 | logger.info( 195 | f"Waiting until {midnight_datetime} for re-naming off-topic channel name." 196 | ) 197 | await sleep_until(midnight_datetime) 198 | 199 | # Algorithm to select next off topic name based on usage/number of times the name has been used. 200 | # Least used names have a higher chance of being selected. 201 | usage = set(self.ot_names.values()) 202 | distribution = [1 / (i + 1) for i in usage] 203 | chosen_usage = random.choices(list(usage), distribution)[0] 204 | 205 | chosen_ot_names = [ 206 | name for name, usage in self.ot_names.items() if usage == chosen_usage 207 | ] 208 | 209 | def to_channel_name(ot_name: str) -> str: 210 | """Append off topic name prefix.""" 211 | return f"{OT_NAME_PREFIX}{ot_name}" 212 | 213 | def from_channel_name(ot_name: str) -> str: 214 | """Detach off topic name prefix.""" 215 | return ot_name[len(OT_NAME_PREFIX) :] 216 | 217 | current_name = from_channel_name(self.ot_channel.name) 218 | new_name = current_name 219 | 220 | while new_name == current_name: 221 | new_name = random.choice(chosen_ot_names) 222 | 223 | self.ot_names[new_name] += 1 224 | await db_execute( 225 | self.bot.db_pool, 226 | "UPDATE offtopicnames SET num_used=num_used+1 WHERE name=$1", 227 | new_name, 228 | ) 229 | 230 | new_name = to_channel_name(new_name) 231 | await self.ot_channel.edit(name=new_name) 232 | 233 | await self._send_ot_embed( 234 | self.ot_channel, f"{new_name}", True, "Today's Off-Topic Name!" 235 | ) 236 | logger.info(f"Off-topic Channel name changed to {new_name}.") 237 | 238 | async def cog_check(self, ctx: Context) -> bool: 239 | """Check if user is gurkult lord or mod.""" 240 | return await has_any_role( 241 | constants.Roles.moderators, constants.Roles.gurkult_lords 242 | ).predicate(ctx) 243 | 244 | 245 | def setup(bot: Bot) -> None: 246 | """Load cog.""" 247 | bot.add_cog(OffTopicNames(bot)) 248 | -------------------------------------------------------------------------------- /bot/exts/fun/xkcd.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from datetime import datetime 4 | from random import randint 5 | from typing import Dict, Optional, Union 6 | 7 | from disnake import Embed 8 | from disnake.ext import tasks 9 | from disnake.ext.commands import Cog, Context, command 10 | 11 | from bot.bot import Bot 12 | from bot.constants import Colours 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | COMIC_FORMAT = re.compile(r"latest|[0-9]+") 17 | BASE_URL = "https://xkcd.com" 18 | 19 | 20 | class XKCD(Cog): 21 | """Retrieving XKCD comics.""" 22 | 23 | def __init__(self, bot: Bot) -> None: 24 | self.bot = bot 25 | self.latest_comic_info: Dict[str, Union[str, int]] = {} 26 | self.get_latest_comic_info.start() 27 | 28 | def cog_unload(self) -> None: 29 | """Cancels refreshing of the task for refreshing the most recent comic info.""" 30 | self.get_latest_comic_info.cancel() 31 | 32 | @tasks.loop(minutes=30) 33 | async def get_latest_comic_info(self) -> None: 34 | """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic.""" 35 | async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp: 36 | if resp.status == 200: 37 | self.latest_comic_info = await resp.json() 38 | else: 39 | log.debug( 40 | f"Failed to get latest XKCD comic information. Status code {resp.status}" 41 | ) 42 | 43 | @command(name="xkcd") 44 | async def fetch_xkcd_comics(self, ctx: Context, comic: Optional[str]) -> None: 45 | """ 46 | Getting an xkcd comic's information along with the image. 47 | 48 | To get a random comic, don't type any number as an argument. To get the latest, type 'latest'. 49 | """ 50 | embed = Embed(title=f"XKCD comic '{comic}'") 51 | 52 | embed.colour = Colours.soft_red 53 | 54 | if comic and (comic := re.match(COMIC_FORMAT, comic)) is None: 55 | embed.description = ( 56 | "Comic parameter should either be an integer or 'latest'." 57 | ) 58 | await ctx.send(embed=embed) 59 | return 60 | 61 | comic = ( 62 | randint(1, self.latest_comic_info["num"]) 63 | if comic is None 64 | else comic.group(0) 65 | ) 66 | 67 | if comic == "latest": 68 | info = self.latest_comic_info 69 | else: 70 | async with self.bot.http_session.get( 71 | f"{BASE_URL}/{comic}/info.0.json" 72 | ) as resp: 73 | if resp.status == 200: 74 | info = await resp.json() 75 | else: 76 | embed.title = f"XKCD comic #{comic}" 77 | embed.description = ( 78 | f"{resp.status}: Could not retrieve xkcd comic #{comic}." 79 | ) 80 | log.debug( 81 | f"Retrieving xkcd comic #{comic} failed with status code {resp.status}." 82 | ) 83 | await ctx.send(embed=embed) 84 | return 85 | 86 | embed.title = f"{info['safe_title']} (#{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 | date = datetime( 92 | year=int(info["year"]), month=int(info["month"]), day=int(info["day"]) 93 | ) 94 | embed.timestamp = date 95 | 96 | embed.set_image(url=info["img"]) 97 | embed.set_footer(text=f"#{info['num']} • {info['safe_title']}") 98 | embed.colour = Colours.green 99 | else: 100 | embed.description = ( 101 | "The selected comic is interactive, and cannot be displayed within an embed.\n" 102 | f"Comic can be viewed [here](https://xkcd.com/{info['num']})." 103 | ) 104 | 105 | await ctx.send(embed=embed) 106 | 107 | 108 | def setup(bot: Bot) -> None: 109 | """Loading the XKCD cog.""" 110 | bot.add_cog(XKCD(bot)) 111 | -------------------------------------------------------------------------------- /bot/exts/github/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/exts/github/__init__.py -------------------------------------------------------------------------------- /bot/exts/github/_issues.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | from typing import Optional 3 | 4 | import disnake 5 | from aiohttp import ClientSession 6 | from disnake import Embed 7 | from disnake.ext import commands 8 | from loguru import logger 9 | 10 | from bot.constants import ERROR_REPLIES, Channels, Emojis 11 | 12 | BAD_RESPONSE = { 13 | 404: "Issue/pull request not located! Please enter a valid number!", 14 | 403: "Rate limit has been hit! Please try again later!", 15 | } 16 | MAX_REQUESTS = 5 17 | REPO_CHANNEL_MAP = { 18 | Channels.dev_reagurk: "reagurk", 19 | Channels.dev_gurkbot: "gurkbot", 20 | Channels.dev_gurklang: "py-gurklang", 21 | Channels.dev_branding: "branding", 22 | } 23 | 24 | 25 | class Issues: 26 | """Cog that allows users to retrieve issues from GitHub.""" 27 | 28 | def __init__(self, http_session: ClientSession) -> None: 29 | self.http_session = http_session 30 | 31 | @staticmethod 32 | def get_repo(channel: disnake.TextChannel) -> Optional[str]: 33 | """Get repository for the particular channel.""" 34 | return REPO_CHANNEL_MAP.get(channel.id, "gurkbot") 35 | 36 | @staticmethod 37 | def error_embed(error_msg: str) -> Embed: 38 | """Generate Error Embed for Issues command.""" 39 | embed = disnake.Embed( 40 | title=choice(ERROR_REPLIES), 41 | color=disnake.Color.red(), 42 | description=error_msg, 43 | ) 44 | return embed 45 | 46 | async def issue( 47 | self, 48 | channel: disnake.TextChannel, 49 | numbers: commands.Greedy[int], 50 | repository: Optional[str], 51 | user: str, 52 | ) -> Embed: 53 | """Retrieve issue(s) from a GitHub repository.""" 54 | links = [] 55 | numbers = set(numbers) 56 | 57 | repository = repository if repository else self.get_repo(channel) 58 | 59 | if len(numbers) > MAX_REQUESTS: 60 | embed = self.error_embed( 61 | "You can specify a maximum of {MAX_REQUESTS} issues/PRs only." 62 | ) 63 | return embed 64 | 65 | for number in numbers: 66 | url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" 67 | merge_url = ( 68 | f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" 69 | ) 70 | 71 | logger.trace(f"Querying GH issues API: {url}") 72 | async with self.http_session.get(url) as r: 73 | json_data = await r.json() 74 | 75 | if r.status in BAD_RESPONSE: 76 | logger.warning(f"Received response {r.status} from: {url}") 77 | embed = self.error_embed(f"#{number} {BAD_RESPONSE.get(r.status)}") 78 | return embed 79 | 80 | if "issues" in json_data.get("html_url"): 81 | icon_url = ( 82 | Emojis.issue_emoji 83 | if json_data.get("state") == "open" 84 | else Emojis.issue_closed_emoji 85 | ) 86 | 87 | else: 88 | logger.info( 89 | f"PR provided, querying GH pulls API for additional information: {merge_url}" 90 | ) 91 | async with self.http_session.get(merge_url) as m: 92 | if json_data.get("state") == "open": 93 | icon_url = Emojis.pull_request_emoji 94 | elif m.status == 204: 95 | icon_url = Emojis.merge_emoji 96 | else: 97 | icon_url = Emojis.pull_request_closed_emoji 98 | 99 | issue_url = json_data.get("html_url") 100 | links.append( 101 | ( 102 | icon_url, 103 | f"[{user}/{repository}] #{number} {json_data.get('title')}", 104 | issue_url, 105 | ) 106 | ) 107 | 108 | resp = disnake.Embed( 109 | colour=disnake.Color.green(), 110 | description="\n".join("{0} [{1}]({2})".format(*link) for link in links), 111 | ) 112 | resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}") 113 | return resp 114 | -------------------------------------------------------------------------------- /bot/exts/github/_profile.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from random import choice 3 | from typing import Optional 4 | 5 | import disnake 6 | from aiohttp import ClientSession 7 | from disnake import Embed 8 | 9 | from bot.constants import ERROR_REPLIES 10 | 11 | 12 | class GithubInfo: 13 | """Fetches info from GitHub.""" 14 | 15 | def __init__(self, http_session: ClientSession) -> None: 16 | self.http_session = http_session 17 | 18 | async def fetch_data(self, url: str) -> dict: 19 | """Retrieve data as a dictionary.""" 20 | async with self.http_session.get(url) as r: 21 | return await r.json() 22 | 23 | @staticmethod 24 | def get_data(username: Optional[str], user_data: dict, org_data: dict) -> Embed: 25 | """Return embed containing filtered github data for user.""" 26 | orgs = [org["login"] for org in org_data] 27 | 28 | if user_data.get("message") != "Not Found": 29 | # Forming blog link 30 | if user_data["blog"].startswith("http"): # Blog link is complete 31 | blog = user_data["blog"] 32 | elif user_data["blog"]: # Blog exists but the link is not complete 33 | blog = f"https://{user_data['blog']}" 34 | else: 35 | blog = "-" 36 | 37 | embed = disnake.Embed( 38 | title=f"{user_data['login']}'s GitHub profile info", 39 | description=f"```{user_data['bio']}```\n" 40 | if user_data["bio"] is not None 41 | else "", 42 | colour=disnake.Colour.green(), 43 | url=user_data["html_url"], 44 | timestamp=datetime.strptime( 45 | user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ" 46 | ), 47 | ) 48 | embed.set_thumbnail(url=user_data["avatar_url"]) 49 | embed.set_footer(text="Account created at") 50 | 51 | embed.add_field( 52 | name="Followers", 53 | value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)", 54 | ) 55 | embed.add_field( 56 | name="Following", 57 | value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)", 58 | ) 59 | embed.add_field( 60 | name="Public repos", 61 | value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)", 62 | ) 63 | embed.add_field( 64 | name="Gists", 65 | value=f"[{user_data['public_gists']}](https://gist.github.com/{username})", 66 | ) 67 | embed.add_field(name="Website", value=blog) 68 | embed.add_field( 69 | name="Organizations", value=" | ".join(orgs) if orgs else "-" 70 | ) 71 | 72 | return embed 73 | 74 | async def get_github_info(self, username: str) -> Embed: 75 | """Fetches a user's GitHub information.""" 76 | user_data = await self.fetch_data(f"https://api.github.com/users/{username}") 77 | 78 | # User_data will not have a message key if the user exists 79 | if user_data.get("message") is not None: 80 | embed = disnake.Embed( 81 | title=choice(ERROR_REPLIES), 82 | description=f"The profile for `{username}` was not found.", 83 | url=Embed.Empty, 84 | colour=disnake.Colour.red(), 85 | ) 86 | return embed 87 | 88 | org_data = await self.fetch_data(user_data["organizations_url"]) 89 | embed = self.get_data(username, user_data, org_data) 90 | 91 | return embed 92 | -------------------------------------------------------------------------------- /bot/exts/github/_source.py: -------------------------------------------------------------------------------- 1 | import re 2 | from inspect import getsourcelines 3 | from textwrap import dedent 4 | from typing import Optional 5 | 6 | import disnake 7 | from aiohttp import ClientSession 8 | from disnake.ext.commands import Command 9 | 10 | from bot import constants 11 | 12 | doc_reg_class = r'("""|\'\'\')([\s\S]*?)(\1\s*)' 13 | 14 | 15 | class Source: 16 | """Displays information about the bot's source code.""" 17 | 18 | def __init__( 19 | self, http_session: ClientSession, bot_avatar: disnake.asset.Asset 20 | ) -> None: 21 | self.http_session = http_session 22 | self.MAX_FIELD_LENGTH = 500 23 | self.bot_avatar = bot_avatar 24 | 25 | async def inspect(self, cmd: Optional[Command]) -> Optional[disnake.Embed]: 26 | """Display information and a GitHub link to the source code of a command.""" 27 | if cmd is None: 28 | return 29 | 30 | module = cmd.module 31 | code_lines, start_line = getsourcelines(cmd.callback) 32 | url = ( 33 | f"<{constants.BOT_REPO_URL}/tree/main/" 34 | f'{"/".join(module.split("."))}.py#L{start_line}>\n' 35 | ) 36 | 37 | source_code = "".join(code_lines) 38 | sanitized = source_code.replace("`", "\u200B`") 39 | sanitized = re.sub(doc_reg_class, "", sanitized) 40 | # The help argument of commands.command gets changed to `help=` 41 | sanitized = sanitized.replace("help=", 'help=""') 42 | 43 | # Remove the extra indentation in the code. 44 | sanitized = dedent(sanitized) 45 | 46 | if len(sanitized) > self.MAX_FIELD_LENGTH: 47 | sanitized = ( 48 | sanitized[: self.MAX_FIELD_LENGTH] 49 | + "\n... (truncated - too many lines)" 50 | ) 51 | 52 | embed = disnake.Embed(title="Gurkbot's Source Link", description=f"{url}") 53 | embed.add_field( 54 | name="Source Code Snippet", value=f"```python\n{sanitized}\n```" 55 | ) 56 | 57 | return embed 58 | -------------------------------------------------------------------------------- /bot/exts/github/github.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from disnake import Embed 4 | from disnake.ext import commands 5 | from disnake.ext.commands.cooldowns import BucketType 6 | 7 | from bot.constants import BOT_REPO_URL 8 | 9 | from . import _issues, _profile, _source 10 | 11 | 12 | class Github(commands.Cog): 13 | """ 14 | Github Category cog, which contains commands related to github. 15 | 16 | Commands: 17 | ├ profile Fetches a user's GitHub information. 18 | ├ issue Command to retrieve issue(s) from a GitHub repository. 19 | └ source Displays information about the bot's source code. 20 | """ 21 | 22 | def __init__(self, bot: commands.Bot) -> None: 23 | self.bot = bot 24 | 25 | @commands.group(name="github", aliases=("gh",), invoke_without_command=True) 26 | async def github_group(self, ctx: commands.Context) -> None: 27 | """Commands for Github.""" 28 | await ctx.send_help(ctx.command) 29 | 30 | @github_group.command(name="profile") 31 | @commands.cooldown(1, 10, BucketType.user) 32 | async def profile(self, ctx: commands.Context, username: str) -> None: 33 | """ 34 | Fetches a user's GitHub information. 35 | 36 | Username is optional and sends the help command if not specified. 37 | """ 38 | github_profile = _profile.GithubInfo(self.bot.http_session) 39 | embed = await github_profile.get_github_info(username) 40 | 41 | await ctx.send(embed=embed) 42 | 43 | @github_group.command(name="issue", aliases=("pr",)) 44 | async def issue( 45 | self, 46 | ctx: commands.Context, 47 | numbers: commands.Greedy[int], 48 | repository: typing.Optional[str] = None, 49 | ) -> None: 50 | """Command to retrieve issue(s) from a GitHub repository.""" 51 | github_issue = _issues.Issues(self.bot.http_session) 52 | 53 | if not numbers: 54 | raise commands.MissingRequiredArgument(ctx.command.clean_params["numbers"]) 55 | 56 | if repository is None: 57 | user = "gurkult" 58 | else: 59 | user, _, repository = repository.rpartition("/") 60 | if user == "": 61 | user = "gurkult" 62 | 63 | embed = await github_issue.issue(ctx.message.channel, numbers, repository, user) 64 | 65 | await ctx.send(embed=embed) 66 | 67 | @github_group.command(name="source", aliases=("src", "inspect")) 68 | async def source_command( 69 | self, ctx: commands.Context, *, source_item: typing.Optional[str] = None 70 | ) -> None: 71 | """Displays information about the bot's source code.""" 72 | if source_item is None: 73 | embed = Embed(title="Gurkbot's GitHub Repository") 74 | embed.add_field(name="Repository", value=f"[Go to GitHub]({BOT_REPO_URL})") 75 | embed.set_thumbnail(url=self.bot.user.display_avatar.url) 76 | await ctx.send(embed=embed) 77 | return 78 | elif not ctx.bot.get_command(source_item): 79 | raise commands.BadArgument( 80 | f"Unable to convert `{source_item}` to valid command or Cog." 81 | ) 82 | 83 | github_source = _source.Source( 84 | self.bot.http_session, self.bot.user.display_avatar.url 85 | ) 86 | embed = await github_source.inspect(cmd=ctx.bot.get_command(source_item)) 87 | 88 | await ctx.send(embed=embed) 89 | 90 | 91 | def setup(bot: commands.Bot) -> None: 92 | """Load the Github cog.""" 93 | bot.add_cog(Github(bot)) 94 | -------------------------------------------------------------------------------- /bot/exts/gurkan/gurkan_stats.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from disnake import Embed, Member 4 | from disnake.ext.commands import Cog, Context, command 5 | 6 | from bot.bot import Bot 7 | from bot.constants import Colours, Emojis 8 | from bot.utils.is_gurkan import gurkan_check, gurkan_rate 9 | 10 | RATE_DICT = { 11 | range(1, 10): "pathetic", 12 | range(10, 30): "not bad", 13 | range(30, 60): "good", 14 | range(60, 80): "cool!", 15 | range(80, 95): "so Epic!!", 16 | range(95, 100): "just wow, superb!", 17 | } 18 | 19 | 20 | class GurkanStats(Cog): 21 | """Commands for showing stats on the Gurkan server.""" 22 | 23 | def __init__(self, bot: Bot) -> None: 24 | self.bot = bot 25 | 26 | @command( 27 | name="gurkancount", 28 | aliases=( 29 | "gc", 30 | "gurkcount", 31 | ), 32 | brief="Get the count of people who are valid gurkans", 33 | help="""gurkancount 34 | 35 | Get the count of people who are valid gurkans. 36 | """, 37 | ) 38 | async def gurkan_count(self, ctx: Context) -> None: 39 | """ 40 | Goes through a list of all the members and uses regex to check if the member is a gurkan. 41 | 42 | Sends the count of total Gurkans in the server,\ 43 | and the percentage of the gurkans to the server members. 44 | """ 45 | members = ctx.guild.members 46 | gurkans = sum(gurkan_check(member.display_name) for member in members) 47 | rate = round((gurkans / len(members)) * 100) 48 | 49 | count_emb = Embed() 50 | 51 | if rate == 100: 52 | title = f"Whoa!! All {gurkans} members are gurkans!" 53 | color = Colours.green 54 | 55 | elif rate == 0: 56 | title = "No one is a gurkan?! That's lame." 57 | color = Colours.soft_red 58 | 59 | else: 60 | rate_m = [RATE_DICT[r] for r in RATE_DICT if rate in r][0] 61 | 62 | title = f"{Emojis.cucumber_emoji} {gurkans} members" 63 | color = Colours.green 64 | description = f"About {rate}% ({gurkans}/ {len(members)}) of members are gurkans, that's {rate_m}" 65 | 66 | count_emb.title = title 67 | count_emb.color = color 68 | count_emb.description = description 69 | 70 | await ctx.send(embed=count_emb) 71 | 72 | @command( 73 | name="isgurkan", 74 | brief="Get an embed of how gurkan a user is", 75 | aliases=("gr", "gurkanrate", "gurkrate", "isgurk"), 76 | help="""isgurkan [user/text (optional)] 77 | 78 | Check if someone is gurkan and get their gurkanrate. 79 | """, 80 | ) 81 | async def is_gurkan( 82 | self, ctx: Context, *, user: Optional[Union[Member, str]] 83 | ) -> None: 84 | """ 85 | The gurkanrate of the user and whether the user is a gurkan is sent in an embed,\ 86 | the color depending on how high the rate is. 87 | 88 | Can be used on other members, or even text. 89 | """ 90 | if not isinstance(user, str): 91 | user = user.display_name if user else ctx.author.display_name 92 | 93 | gurk_state = gurkan_check(user) 94 | gurk_rate = gurkan_rate(user) 95 | rate_embed = Embed(description=f"{user}'s gurk rate is {gurk_rate}%") 96 | 97 | if not gurk_state: 98 | color = Colours.soft_red 99 | title = f"{Emojis.invalid_emoji} Not gurkan" 100 | else: 101 | color = Colours.green 102 | title = f"{Emojis.cucumber_emoji} Gurkan" 103 | 104 | rate_embed.color = color 105 | rate_embed.title = title 106 | 107 | await ctx.send(embed=rate_embed) 108 | 109 | 110 | def setup(bot: Bot) -> None: 111 | """Load the GurkanStats cog.""" 112 | bot.add_cog(GurkanStats(bot)) 113 | -------------------------------------------------------------------------------- /bot/exts/gurkan/gurkify.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from disnake import Embed, Forbidden 4 | from disnake.ext import commands 5 | 6 | from bot.bot import Bot 7 | from bot.constants import NEGATIVE_REPLIES, POSITIVE_REPLIES, Colours, GurkanNameEndings 8 | from bot.utils.is_gurkan import gurkan_check 9 | 10 | 11 | class Gurkify(commands.Cog): 12 | """Cog for the gurkify command.""" 13 | 14 | @commands.command(name="gurkify") 15 | async def gurkify(self, ctx: commands.Context) -> None: 16 | """Gurkify user's display name.""" 17 | display_name = ctx.author.display_name 18 | 19 | if gurkan_check(display_name): 20 | embed = Embed( 21 | title="I love the ambition...", 22 | description=( 23 | "... but you're already a gurkan! Instead of becoming a 'double-gurkan', " 24 | "why not focus on living a truly gurkan life instead?" 25 | ), 26 | color=Colours.yellow, 27 | ) 28 | elif len(display_name) > 26: 29 | embed = Embed( 30 | title=random.choice(NEGATIVE_REPLIES), 31 | description=( 32 | "Your nick name is too long to be gurkified. " 33 | "Please change it to be under 26 characters." 34 | ), 35 | color=Colours.soft_red, 36 | ) 37 | 38 | else: # No obvious issues with gurkifying were found 39 | try: 40 | display_name += random.choice(GurkanNameEndings.name_endings) 41 | await ctx.author.edit(nick=display_name) 42 | except Forbidden: 43 | embed = Embed( 44 | title="You're too powerful!", 45 | description="I can't change the names of users with top roles higher than mine.", 46 | color=Colours.soft_red, 47 | ) 48 | else: 49 | embed = Embed( 50 | title=random.choice(POSITIVE_REPLIES), 51 | description="You nick name has been gurkified.", 52 | color=Colours.green, 53 | ) 54 | 55 | await ctx.send(content=ctx.author.mention, embed=embed) 56 | 57 | 58 | def setup(bot: Bot) -> None: 59 | """Loads the gurkify cog.""" 60 | bot.add_cog(Gurkify()) 61 | -------------------------------------------------------------------------------- /bot/exts/gurkan/make_gurkan.py: -------------------------------------------------------------------------------- 1 | import disnake 2 | from disnake.ext import commands 3 | from disnake.utils import get 4 | 5 | from bot.bot import Bot 6 | from bot.constants import Roles 7 | from bot.utils.is_gurkan import gurkan_check 8 | 9 | 10 | class MakeGurkan(commands.Cog): 11 | """Makes sure that only members with gurkan names have the gurkan role.""" 12 | 13 | def __init__(self, bot: Bot) -> None: 14 | self.bot = bot 15 | 16 | @commands.Cog.listener() 17 | async def on_member_update( 18 | self, before: disnake.Member, after: disnake.Member 19 | ) -> None: 20 | """ 21 | Adds/Removes the gurkan role on member update. 22 | 23 | When the member updates their nickname or username, this function will check if member is 24 | classified to be a gurkan or not. 25 | """ 26 | if after.bot: 27 | return 28 | if before.display_name != after.display_name: 29 | role = get(after.guild.roles, id=Roles.gurkans) 30 | if gurkan_check(after.display_name): 31 | if role not in after.roles: 32 | await after.add_roles(role) 33 | else: 34 | if role in after.roles: 35 | await after.remove_roles(role) 36 | 37 | @commands.Cog.listener() 38 | async def on_member_join(self, member: disnake.Member) -> None: 39 | """Adds the gurkan role to new members who are classified as gurkans.""" 40 | if member.bot: 41 | return 42 | role = get(member.guild.roles, id=Roles.gurkans) 43 | if gurkan_check(member.display_name): 44 | await member.add_roles(role) 45 | 46 | 47 | def setup(bot: commands.Bot) -> None: 48 | """Load the Cog.""" 49 | bot.add_cog(MakeGurkan(bot)) 50 | -------------------------------------------------------------------------------- /bot/exts/gurkcraft/gurkcraft.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import disnake 4 | from disnake import Embed, TextChannel 5 | from disnake.ext import commands, tasks 6 | from disnake.ext.commands import Bot 7 | from loguru import logger 8 | from mcstatus import MinecraftServer 9 | 10 | from bot.constants import Channels, Colours, Minecraft 11 | 12 | CHAT_HEADER = f"Our gurkan Minecraft server. Join: {Minecraft.server_address}! \n" 13 | RELAY_HEADER = "The live chat of our gurkan Minecraft server. \n" 14 | 15 | 16 | def _extract_users(status: dict) -> list: 17 | """Extract the list of users connected to the server.""" 18 | try: 19 | return [user["name"] for user in status.raw["players"]["sample"]] 20 | except KeyError: 21 | return [] 22 | 23 | 24 | class Gurkcraft(commands.Cog): 25 | """Gurkcraft Cog.""" 26 | 27 | def __init__(self, bot: Bot) -> None: 28 | self.bot = bot 29 | self.server = MinecraftServer.lookup(Minecraft.server_address) 30 | self.gurkcraft: Optional[TextChannel] = None 31 | self.gurkcraft_relay: Optional[TextChannel] = None 32 | 33 | self.update_channel_description.start() 34 | 35 | @commands.command() 36 | async def mcstatus(self, ctx: commands.Context) -> None: 37 | """Collects data from minecraft server.""" 38 | try: 39 | status = self.server.status() 40 | except OSError: 41 | await ctx.send( 42 | embed=Embed( 43 | description="The server is currently offline.", 44 | colour=Colours.soft_red, 45 | ) 46 | ) 47 | return 48 | players = _extract_users(status) 49 | 50 | embed = disnake.Embed(title="Gurkcraft", color=Colours.green) 51 | embed.add_field(name="Server", value="mc.gurkult.com") 52 | embed.add_field(name="Server Latency", value=f"{status.latency}ms") 53 | embed.add_field(name="Gurkans Online", value=status.players.online) 54 | embed.add_field( 55 | name="Gurkans Connected", value=", ".join(players) if players else "None" 56 | ) 57 | await ctx.send(embed=embed) 58 | 59 | @tasks.loop(minutes=5) 60 | async def update_channel_description(self) -> None: 61 | """Collect information about the server and update the description of the channel.""" 62 | logger.debug("Updating topic of the #gurkcraft and #gurkcraft-relay channels") 63 | 64 | if not self.gurkcraft: 65 | self.gurkcraft = await self.bot.fetch_channel(Channels.gurkcraft) 66 | 67 | if not self.gurkcraft: 68 | logger.warning( 69 | f"Failed to retrieve #gurkcraft channel {Channels.gurkcraft}. Aborting." 70 | ) 71 | return 72 | if not self.gurkcraft_relay: 73 | self.gurkcraft_relay = await self.bot.fetch_channel( 74 | Channels.gurkcraft_relay 75 | ) 76 | 77 | if not self.gurkcraft_relay: 78 | logger.warning( 79 | f"Failed to retrieve #gurkcraft_relay channel {Channels.gurkcraft_relay}. Aborting." 80 | ) 81 | return 82 | 83 | is_online = True 84 | try: 85 | status = self.server.status() 86 | players = _extract_users(status) 87 | except OSError: 88 | is_online = False 89 | players = [] 90 | 91 | if is_online: 92 | description = ( 93 | "No players currently online." 94 | if not players 95 | else ( 96 | f"{status.players.online} player{'s' if len(players) > 1 else ''} " 97 | f"online: {', '.join(players)}." 98 | ) 99 | ) 100 | else: 101 | description = "The server is currently offline :(" 102 | 103 | await self.gurkcraft.edit(topic=CHAT_HEADER + description) 104 | await self.gurkcraft_relay.edit(topic=RELAY_HEADER + description) 105 | 106 | 107 | def setup(bot: Bot) -> None: 108 | """Loading the Gurkcraft cog.""" 109 | bot.add_cog(Gurkcraft(bot)) 110 | -------------------------------------------------------------------------------- /bot/exts/moderation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/exts/moderation/__init__.py -------------------------------------------------------------------------------- /bot/exts/moderation/logs.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Callable, Optional 3 | 4 | from disnake import Embed, Member, Message, RawMessageDeleteEvent, TextChannel, User 5 | from disnake.ext.commands import Cog 6 | from loguru import logger 7 | 8 | from bot.bot import Bot 9 | from bot.constants import Channels, Colours 10 | 11 | 12 | def get_post_message(bot: Bot) -> Callable: 13 | """Magic function returning the `post_message` function.""" 14 | cog = bot.get_cog("ModerationLog") 15 | if not cog: 16 | raise ValueError("The ModerationLog cog can't be found.") 17 | 18 | return cog.post_message 19 | 20 | 21 | def get_post_formatted_message(bot: Bot) -> Callable: 22 | """Magic function returning the `post_formatted_message` function.""" 23 | cog = bot.get_cog("ModerationLog") 24 | if not cog: 25 | raise ValueError("The ModerationLog cog can't be found.") 26 | 27 | return cog.post_formatted_message 28 | 29 | 30 | class ModerationLog(Cog): 31 | """Cog used to log important actions in the community to a log channel.""" 32 | 33 | def __init__(self, bot: Bot) -> None: 34 | self.bot = bot 35 | self.log_channel: Optional[TextChannel] = None 36 | self.dm_log_channel: Optional[TextChannel] = None 37 | super().__init__() 38 | 39 | async def post_message(self, embed: Embed) -> Optional[Message]: 40 | """Send an embed to the #logs channel.""" 41 | if not self.log_channel: 42 | await self.bot.wait_until_ready() 43 | self.log_channel = await self.bot.fetch_channel(Channels.log) 44 | 45 | if not self.log_channel: 46 | logger.error(f"Failed to get the #log channel with ID {Channels.log}.") 47 | return 48 | 49 | return await self.log_channel.send(embed=embed) 50 | 51 | async def post_formatted_message( 52 | self, 53 | actor: User, 54 | action: str, 55 | *, 56 | body: Optional[str] = None, 57 | link: Optional[str] = None, 58 | colour: int = Colours.green, 59 | ) -> None: 60 | """Format and post a message to the #log channel.""" 61 | logger.trace(f'Creating log "{actor.id} {action}"') 62 | 63 | embed = Embed( 64 | title=( 65 | f"{actor} " 66 | f"{f'({actor.display_name}) ' if actor.display_name != actor.name else ''}" 67 | f"({actor.id}) {action}" 68 | ), 69 | description=body or "", 70 | colour=colour, 71 | timestamp=datetime.utcnow(), 72 | ).set_thumbnail(url=actor.display_avatar.url) 73 | 74 | if link: 75 | embed.url = link 76 | 77 | await self.post_message(embed=embed) 78 | 79 | @Cog.listener() 80 | async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None: 81 | """Log message deletion.""" 82 | if message := payload.cached_message: 83 | if message.author.bot: 84 | return 85 | 86 | await self.post_formatted_message( 87 | message.author, 88 | f"deleted a message in #{message.channel}.", 89 | body=message.content, 90 | colour=Colours.soft_red, 91 | ) 92 | else: 93 | await self.post_message( 94 | Embed( 95 | title=( 96 | f"Message {payload.message_id} deleted in " 97 | f"#{await self.bot.fetch_channel(payload.channel_id)}." 98 | ), 99 | description="The message wasn't cached so it cannot be displayed.", 100 | colour=Colours.soft_red, 101 | ) 102 | ) 103 | 104 | @Cog.listener() 105 | async def on_message_edit(self, before: Message, after: Message) -> None: 106 | """Log message edits.""" 107 | if after.author.bot or before.content == after.content: 108 | return 109 | 110 | await self.post_formatted_message( 111 | after.author, 112 | f"edited a message in #{after.channel}.", 113 | body=f"**Before:**\n{before.content}\n\n**After:**\n{after.content}", 114 | colour=Colours.yellow, 115 | ) 116 | 117 | @Cog.listener() 118 | async def on_member_join(self, member: Member) -> None: 119 | """Log members joining.""" 120 | await self.post_formatted_message(member, "joined.") 121 | 122 | @Cog.listener() 123 | async def on_member_remove(self, member: Member) -> None: 124 | """Log members leaving.""" 125 | await self.post_formatted_message(member, "left.", colour=Colours.soft_red) 126 | 127 | @Cog.listener() 128 | async def on_member_update(self, before: Member, after: Member) -> None: 129 | """Log nickname changes.""" 130 | if before.nick == after.nick: 131 | return 132 | 133 | await self.post_formatted_message( 134 | after, "updated their nickname.", body=f"`{before.nick}` -> `{after.nick}`" 135 | ) 136 | 137 | @Cog.listener() 138 | async def on_message(self, message: Message) -> None: 139 | """Log DM messages to #dm-logs.""" 140 | # If the guild attribute is set it isn't a DM 141 | if message.guild: 142 | return 143 | 144 | # Outbound messages shouldn't be logged 145 | if message.author.id == self.bot.user.id: 146 | return 147 | 148 | if not self.dm_log_channel: 149 | await self.bot.wait_until_ready() 150 | self.dm_log_channel = await self.bot.fetch_channel(Channels.dm_log) 151 | 152 | if not self.dm_log_channel: 153 | logger.error( 154 | f"Failed to get the #dm-log channel with ID {Channels.dm_log}." 155 | ) 156 | return 157 | 158 | await self.dm_log_channel.send( 159 | embed=Embed( 160 | title=f"Direct message from {message.author}", 161 | description=message.content, 162 | colour=Colours.green, 163 | timestamp=datetime.utcnow(), 164 | ).set_thumbnail(url=message.author.display_avatar.url) 165 | ) 166 | 167 | 168 | def setup(bot: Bot) -> None: 169 | """Load the moderation log during setup.""" 170 | bot.add_cog(ModerationLog(bot)) 171 | -------------------------------------------------------------------------------- /bot/exts/moderation/mod_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | 3 | from disnake import Forbidden, Member 4 | from disnake.ext.commands import Cog, Context, command, has_any_role 5 | from loguru import logger 6 | 7 | from bot.bot import Bot 8 | from bot.constants import Colours, Roles 9 | from bot.exts.moderation.logs import get_post_formatted_message 10 | 11 | 12 | class ModUtils(Cog): 13 | """Cog used for various moderation utility commands.""" 14 | 15 | def __init__(self, bot: Bot) -> None: 16 | self.bot = bot 17 | self.post_formatted_message: Optional[Callable] = None 18 | super().__init__() 19 | 20 | @command(aliases=("dm",)) 21 | @has_any_role(Roles.moderators, Roles.steering_council) 22 | async def send_dm(self, ctx: Context, user: Member, *, message: str) -> None: 23 | """Send a DM to the specified user.""" 24 | logger.info(f"Sending message {message!r} to {user}.") 25 | 26 | if not self.post_formatted_message: 27 | self.post_formatted_message = get_post_formatted_message(self.bot) 28 | 29 | try: 30 | await user.send(message) 31 | except Forbidden: 32 | await ctx.message.add_reaction("\N{CROSS MARK}") 33 | await self.post_formatted_message( 34 | ctx.author, 35 | f"tried to send a message to {user.id} but it failed.", 36 | body=message, 37 | colour=Colours.soft_red, 38 | ) 39 | else: 40 | await ctx.message.add_reaction("\N{OK HAND SIGN}") 41 | await self.post_formatted_message( 42 | ctx.author, f"sent a message to {user.id}.", body=message 43 | ) 44 | 45 | 46 | def setup(bot: Bot) -> None: 47 | """Load the moderation utils cog during setup.""" 48 | bot.add_cog(ModUtils(bot)) 49 | -------------------------------------------------------------------------------- /bot/exts/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/exts/utils/__init__.py -------------------------------------------------------------------------------- /bot/exts/utils/_eval_helper.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib.parse 3 | import zlib 4 | from functools import partial 5 | from io import BytesIO 6 | from typing import Any, Dict, List, Optional, Tuple, Union 7 | 8 | import aiohttp 9 | from disnake import Embed 10 | from disnake.ext import commands 11 | from disnake.ext.commands import Context 12 | from loguru import logger 13 | 14 | to_bytes = partial(bytes, encoding="utf-8") 15 | 16 | 17 | def _to_tio_string(couple: tuple) -> bytes: 18 | """ 19 | Return a tio api compatible string. 20 | 21 | For example: 22 | (language) bash -> b'Vlang\x001\x00bash\x00' 23 | (code) echo "bash" -> b'F.code.tio\x0019\x00echo "bash"\n\x00' 24 | """ 25 | name, obj = couple 26 | if not obj: 27 | return b"" 28 | elif isinstance(obj, list): 29 | content = [f"V{name}", str(len(obj))] + obj 30 | return to_bytes("\x00".join(content) + "\x00") 31 | else: 32 | return to_bytes(f"F{name}\x00{len(to_bytes(obj))}\x00{obj}\x00") 33 | 34 | 35 | class Tio: 36 | """Helper class for eval command.""" 37 | 38 | def __init__( 39 | self, 40 | language: str, 41 | code: str, 42 | inputs: str = "", 43 | compiler_flags: Optional[list] = None, 44 | command_line_options: Optional[list] = None, 45 | args: Optional[list] = None, 46 | ) -> None: 47 | if args is None: 48 | args = [] 49 | if command_line_options is None: 50 | command_line_options = [] 51 | if compiler_flags is None: 52 | compiler_flags = [] 53 | self.backend = "https://tio.run/cgi-bin/run/api/" 54 | self.json = "https://tio.run/languages.json" 55 | 56 | strings = { 57 | "lang": [language], 58 | ".code.tio": code, 59 | ".input.tio": inputs, 60 | "TIO_CFLAGS": compiler_flags, 61 | "TIO_OPTIONS": command_line_options, 62 | "args": args, 63 | } 64 | 65 | bytes_ = ( 66 | b"".join( 67 | map( 68 | _to_tio_string, # func 69 | zip(strings.keys(), strings.values()), # iterables 70 | ) 71 | ) 72 | + b"R" 73 | ) 74 | 75 | # This returns a DEFLATE-compressed byte-string, which is what the API requires 76 | self.request = zlib.compress(bytes_, 9)[2:-4] 77 | 78 | async def get_result(self) -> str: 79 | """Send Request to Tio Run API And Get Result.""" 80 | async with aiohttp.ClientSession() as client_session: 81 | async with client_session.post(self.backend, data=self.request) as res: 82 | if res.status != 200: 83 | logger.warning( 84 | f"HttpProcessingError while getting result of code from tio api with " 85 | f"status code: {res.status}" 86 | ) 87 | 88 | data = await res.read() 89 | data = data.decode("utf-8") 90 | return data.replace(data[:16], "") # remove token 91 | 92 | 93 | class EvalHelper: 94 | """Eval Helper class.""" 95 | 96 | def __init__(self, language: str) -> None: 97 | self.lang = language.strip("`").lower() 98 | self.authorized = ( 99 | "https://hastebin.com", 100 | "https://gist.github.com", 101 | "https://gist.githubusercontent.com", 102 | ) 103 | self.max_file_size = 20000 104 | self.truncated_error = "The output exceeded 128 KiB and was truncated." 105 | self.hastebin_link = "https://hastebin.com" 106 | self.bin_link = "https://bin.drlazor.be/" 107 | 108 | async def parse( 109 | self, code: str 110 | ) -> Tuple[ 111 | str, str, str, Dict[Union[str, Any], bool], List[str], List[str], List[str] 112 | ]: 113 | """Returned parsed data.""" 114 | options = {"--stats": False, "--wrapped": False} 115 | options_amount = len(options) 116 | 117 | # Setting options and removing them from the beginning of the command 118 | # options may be separated by any single whitespace, which we keep in the list 119 | code = re.split(r"(\s)", code, maxsplit=options_amount) 120 | for option in options: 121 | if option in code[: options_amount * 2]: 122 | options[option] = True 123 | i = code.index(option) 124 | code.pop(i) 125 | code.pop(i) # Remove following whitespace character 126 | code = "".join(code) 127 | 128 | compiler_flags = [] 129 | command_line_options = [] 130 | args = [] 131 | inputs = [] 132 | 133 | lines = code.split("\n") 134 | code = [] 135 | for line in lines: 136 | if line.startswith("input "): 137 | inputs.append(" ".join(line.split(" ")[1:]).strip("`")) 138 | elif line.startswith("compiler-flags "): 139 | compiler_flags.extend(line[15:].strip("`").split(" ")) 140 | elif line.startswith("command-line-options "): 141 | command_line_options.extend(line[21:].strip("`").split(" ")) 142 | elif line.startswith("arguments "): 143 | args.extend(line[10:].strip("`").split(" ")) 144 | else: 145 | code.append(line.strip("`")) 146 | 147 | inputs = "\n".join(inputs) 148 | code = "\n".join(code) 149 | return ( 150 | inputs, 151 | code, 152 | self.lang, 153 | options, 154 | compiler_flags, 155 | command_line_options, 156 | args, 157 | ) 158 | 159 | async def code_from_attachments(self, ctx: Context) -> Optional[str]: 160 | """Code in file.""" 161 | file = ctx.message.attachments[0] 162 | if file.size > self.max_file_size: 163 | await ctx.send("File must be smaller than 20 kio.") 164 | logger.info("Exiting | File bigger than 20 kio.") 165 | return 166 | buffer = BytesIO() 167 | await ctx.message.attachments[0].save(buffer) 168 | text = buffer.read().decode("utf-8") 169 | return text 170 | 171 | async def code_from_url(self, ctx: Context, code: str) -> Optional[str]: 172 | """Get code from url.""" 173 | base_url = urllib.parse.quote_plus( 174 | code.split(" ")[-1][5:].strip("/"), safe=";/?:@&=$,><-[]" 175 | ) 176 | print(base_url) 177 | url = self.get_raw(base_url) 178 | print(url) 179 | 180 | async with aiohttp.ClientSession() as client_session: 181 | async with client_session.get(url) as response: 182 | print(response.status) 183 | if response.status == 404: 184 | await ctx.send("Nothing found. Check your link") 185 | logger.info("Exiting | Nothing found in link.") 186 | return 187 | elif response.status != 200: 188 | logger.warning( 189 | f"An error occurred | status code: " 190 | f"{response.status} | on request by: {ctx.author}" 191 | ) 192 | await ctx.send( 193 | f"An error occurred (status code: {response.status}). " 194 | f"Retry later." 195 | ) 196 | return 197 | text = await response.text() 198 | return text 199 | 200 | async def paste(self, text: str) -> Union[str, dict]: 201 | """Upload the eval output to a paste service and return a URL to it if successful.""" 202 | logger.info("Uploading full output to paste service...") 203 | result = dict() 204 | text, exit_code = text.split("Exit code: ") 205 | if self.truncated_error in exit_code: 206 | exit_code = exit_code.replace(self.truncated_error, "") 207 | result["exit_code"] = exit_code 208 | result["icon"] = ":white_check_mark:" if exit_code == "0" else ":warning:" 209 | 210 | async with aiohttp.ClientSession() as session: 211 | post = await session.post(f"{self.hastebin_link}/documents", data=text) 212 | if post.status == 200: 213 | response = await post.text() 214 | result["link"] = f"{self.hastebin_link}/{response[8:-2]}.txt" 215 | return result 216 | 217 | # Rollback bin 218 | post = await session.post(f"{self.bin_link}", data={"val": text}) 219 | if post.status == 200: 220 | result["link"] = post.url 221 | return result 222 | 223 | def get_raw(self, link: str) -> str: 224 | """Returns the url to raw text version of certain pastebin services.""" 225 | link = link.strip("<>/") # Allow for no-embed links 226 | 227 | if not any(link.startswith(url) for url in self.authorized): 228 | raise commands.BadArgument( 229 | message=f"Only links from the following domains are accepted: {', '.join(self.authorized)}. " 230 | f"(Starting with 'https')." 231 | ) 232 | 233 | domain = link.split("/")[2] 234 | 235 | if domain == "hastebin.com": 236 | if "/raw/" in link: 237 | return link 238 | token = link.split("/")[-1] 239 | if "." in token: 240 | token = token[: token.rfind(".")] # removes extension 241 | return f"{self.hastebin_link}/raw/{token}" 242 | else: 243 | # Github uses redirection so raw -> user content and no raw -> normal 244 | # We still need to ensure we get a raw version after this potential redirection 245 | if "/raw" in link: 246 | return link 247 | return link + "/raw" 248 | 249 | 250 | class FormatOutput: 251 | """Format Output sent by the Tio.run Api and return embed.""" 252 | 253 | def __init__(self, language: str) -> None: 254 | self.language = language 255 | self.GREEN = 0x1F8B4C 256 | self.max_lines = 11 257 | self.max_output_length = 500 258 | self.eval_helper = EvalHelper(self.language) 259 | 260 | @staticmethod 261 | def get_icon(exit_code: str) -> str: 262 | """Get icon depending on what is the exit code.""" 263 | return ":white_check_mark:" if exit_code == "0" else ":warning:" 264 | 265 | def embed_helper(self, description: str, field: str) -> Embed: 266 | """Embed helper function.""" 267 | embed = Embed(title="Eval Results", colour=self.GREEN, description=description) 268 | embed.add_field( 269 | name="Output", 270 | value=field, 271 | ) 272 | return embed 273 | 274 | def format_hastebin_output(self, output: dict, result: str) -> Embed: 275 | """ 276 | Format Hastebin Output. 277 | 278 | Helper function to format output to return embed if the result, 279 | is more than 1991 characters or 40 lines. 280 | """ 281 | logger.info("Formatting hastebin output...") 282 | if result.count("\n") > self.max_lines: 283 | result = [ 284 | f"{i:02d} | {line}" for i, line in enumerate(result.split("\n"), 1) 285 | ] 286 | result = result[: self.max_lines] # Limiting to only 11 lines 287 | program_output = "\n".join(result) + "\n... (truncated - too many lines)" 288 | 289 | elif len(result) > self.max_output_length: 290 | program_output = ( 291 | result[: self.max_output_length] + "\n... (truncated - too long)" 292 | ) 293 | 294 | embed = self.embed_helper( 295 | description=f"{output['icon']} Your {self.language} eval job has " 296 | f"completed with return code `{output['exit_code']}`", 297 | field=f"```\n{program_output}```\nYou can find the complete " 298 | f"output [here]({output['link']})", 299 | ) 300 | 301 | logger.info("Output Formatted") 302 | return embed 303 | 304 | def format_code_output(self, result: str) -> Embed: 305 | """ 306 | Format Code Output. 307 | 308 | Helper function to format output to return embed if the result 309 | is less than 1991 characters or 40 lines. 310 | """ 311 | logger.info("Formatting message output...") 312 | 313 | zero = "\N{zero width space}" 314 | result = re.sub("```", f"{zero}`{zero}`{zero}`{zero}", result) 315 | result, exit_code = result.split("Exit code: ") 316 | icon = self.get_icon(exit_code) 317 | result = result.rstrip("\n") 318 | lines = result.count("\n") 319 | 320 | if lines > 0: 321 | result = [ 322 | f"{i:02d} | {line}" for i, line in enumerate(result.split("\n"), 1) 323 | ] 324 | result = result[: self.max_lines] # Limiting to only 11 lines 325 | result = "\n".join(result) 326 | 327 | embed = self.embed_helper( 328 | description=f"{icon} Your {self.language} eval job has completed with return code `{exit_code}`.", 329 | field=f"```\n{'[No output]' if result == '' else result}```", 330 | ) 331 | 332 | logger.info("Output Formatted") 333 | return embed 334 | -------------------------------------------------------------------------------- /bot/exts/utils/bot_stats.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from platform import python_version 3 | 4 | import humanize 5 | from disnake import Embed, __version__ 6 | from disnake.ext import commands 7 | 8 | from bot.bot import Bot 9 | from bot.constants import Colours 10 | 11 | 12 | class BotStats(commands.Cog): 13 | """Get info about the bot.""" 14 | 15 | def __init__(self, bot: Bot): 16 | self.bot = bot 17 | 18 | @commands.command() 19 | async def ping(self, ctx: commands.Context) -> None: 20 | """Ping the bot to see its latency.""" 21 | embed = Embed( 22 | title="Pong!", 23 | description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms", 24 | color=Colours.green, 25 | ) 26 | await ctx.send(content=ctx.author.mention, embed=embed) 27 | 28 | @commands.command() 29 | async def stats(self, ctx: commands.Context) -> None: 30 | """Get the information and current uptime of the bot.""" 31 | embed = Embed( 32 | title="Bot Stats", 33 | color=Colours.green, 34 | ) 35 | 36 | embed.set_thumbnail(url=self.bot.user.display_avatar.url) 37 | 38 | uptime = humanize.precisedelta( 39 | datetime.utcnow().timestamp() - self.bot.launch_time 40 | ) 41 | 42 | fields = { 43 | "Python version": python_version(), 44 | "Disnake version": __version__, 45 | "Uptime": uptime, 46 | } 47 | 48 | for name, value in list(fields.items()): 49 | embed.add_field(name=name, value=value, inline=False) 50 | await ctx.send(content=ctx.author.mention, embed=embed) 51 | 52 | 53 | def setup(bot: Bot) -> None: 54 | """Loads the botstats cog.""" 55 | bot.add_cog(BotStats(bot)) 56 | -------------------------------------------------------------------------------- /bot/exts/utils/color.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from string import hexdigits 3 | 4 | from disnake import File 5 | from disnake.ext.commands import Cog, Context, command 6 | from PIL import Image 7 | 8 | from bot.bot import Bot 9 | 10 | 11 | class Color(Cog): 12 | """A cog containing a parser function for parsing the colors and the command function.""" 13 | 14 | IMAGE_SIZE = (128, 128) 15 | 16 | @staticmethod 17 | def parse_color(color_code: str) -> str: 18 | """Parse a color code string to its respective mode.""" 19 | color_code = color_code.replace("0x", "#") 20 | color_code = ( 21 | "#" + color_code 22 | if len(color_code) == 6 and all(i in hexdigits for i in color_code) 23 | else color_code 24 | ) 25 | if "rgb" in color_code or "rgba" in color_code: 26 | return color_code 27 | color_code = color_code.replace(",", " ") 28 | 29 | if len(ls := color_code.split()) == 3: 30 | color_code = "rgb(" + ", ".join(ls) + ")" 31 | elif len(ls) == 4: 32 | color_code = "rgba(" + ", ".join(ls) + ")" 33 | 34 | return color_code 35 | 36 | @command( 37 | help="""color 38 | Get a visual picture for color given as input, valid formats are - 39 | 40 | rgb, rgba - 41 | color rgb(v1, v2, v3); color rgba(v1, v2, v3, v4) 42 | color v1, v2, v3; color v1, v2, v3, v4 43 | Hex - 44 | color #181818; color 0x181818 45 | """, 46 | brief="Get a image of the color given as input", 47 | name="color", 48 | aliases=("col",), 49 | ) 50 | async def color_command(self, ctx: Context, *, color_code: str) -> None: 51 | """Sends an image which is the color of provided as the input.""" 52 | parsed_color_code = self.parse_color(color_code) 53 | 54 | try: 55 | new_col = Image.new("RGB", self.IMAGE_SIZE, parsed_color_code) 56 | except ValueError: 57 | await ctx.send(f"Unknown color specifier `{color_code}`") 58 | return 59 | bufferio = BytesIO() 60 | new_col.save(bufferio, format="PNG") 61 | bufferio.seek(0) 62 | 63 | file = File(bufferio, filename=f"{parsed_color_code}.png") 64 | 65 | await ctx.send(file=file) 66 | 67 | 68 | def setup(bot: Bot) -> None: 69 | """Load the Color cog.""" 70 | bot.add_cog(Color(bot)) 71 | -------------------------------------------------------------------------------- /bot/exts/utils/devops.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Python Discord 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | import contextlib 13 | import inspect 14 | import pprint 15 | import re 16 | import textwrap 17 | import traceback 18 | from io import StringIO 19 | from typing import Any, Optional, Tuple 20 | 21 | import disnake 22 | from disnake.ext.commands import Cog, Context, group, has_any_role, is_owner 23 | from loguru import logger 24 | 25 | from bot.bot import Bot 26 | from bot.constants import ENVIRONMENT, Roles 27 | 28 | 29 | def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: 30 | """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" 31 | index = 0 32 | for _ in range(n): 33 | index = string.find(substring, index + 1) 34 | if index == -1: 35 | return None 36 | return index 37 | 38 | 39 | class DevOps(Cog): 40 | """Commands for the bot DevOps.""" 41 | 42 | def __init__(self, bot: Bot): 43 | self.bot = bot 44 | self.env = {} 45 | self.ln = 0 46 | self.stdout = StringIO() 47 | 48 | if ENVIRONMENT != "production": 49 | self.eval.add_check(is_owner().predicate) 50 | 51 | def _format(self, inp: str, out: Any) -> Tuple[str, Optional[disnake.Embed]]: 52 | """Format the eval output into a string & attempt to format it into an Embed.""" 53 | self._ = out 54 | 55 | res = "" 56 | 57 | # Erase temp input we made 58 | if inp.startswith("_ = "): 59 | inp = inp[4:] 60 | 61 | # Get all non-empty lines 62 | lines = [line for line in inp.split("\n") if line.strip()] 63 | if len(lines) != 1: 64 | lines += [""] 65 | 66 | # Create the input dialog 67 | for i, line in enumerate(lines): 68 | if i == 0: 69 | # Start dialog 70 | start = f"In [{self.ln}]: " 71 | 72 | else: 73 | # Indent the 3 dots correctly; 74 | # Normally, it's something like 75 | # In [X]: 76 | # ...: 77 | # 78 | # But if it's 79 | # In [XX]: 80 | # ...: 81 | # 82 | # You can see it doesn't look right. 83 | # This code simply indents the dots 84 | # far enough to align them. 85 | # we first `str()` the line number 86 | # then we get the length 87 | # and use `str.rjust()` 88 | # to indent it. 89 | start = "...: ".rjust(len(str(self.ln)) + 7) 90 | 91 | if i == len(lines) - 2: 92 | if line.startswith("return"): 93 | line = line[6:].strip() 94 | 95 | # Combine everything 96 | res += start + line + "\n" 97 | 98 | self.stdout.seek(0) 99 | text = self.stdout.read() 100 | self.stdout.close() 101 | self.stdout = StringIO() 102 | 103 | if text: 104 | res += text + "\n" 105 | 106 | if out is None: 107 | # No output, return the input statement 108 | return (res, None) 109 | 110 | res += f"Out[{self.ln}]: " 111 | 112 | if isinstance(out, disnake.Embed): 113 | # We made an embed? Send that as embed 114 | res += "" 115 | res = (res, out) 116 | 117 | else: 118 | if isinstance(out, str) and out.startswith( 119 | "Traceback (most recent call last):\n" 120 | ): 121 | # Leave out the traceback message 122 | out = "\n" + "\n".join(out.split("\n")[1:]) 123 | 124 | if isinstance(out, str): 125 | pretty = out 126 | else: 127 | pretty = pprint.pformat(out, compact=True, width=60) 128 | 129 | if pretty != str(out): 130 | # We're using the pretty version, start on the next line 131 | res += "\n" 132 | 133 | if pretty.count("\n") > 20: 134 | # Text too long, shorten 135 | li = pretty.split("\n") 136 | 137 | pretty = ( 138 | "\n".join(li[:3]) # First 3 lines 139 | + "\n ...\n" # Ellipsis to indicate removed lines 140 | + "\n".join(li[-3:]) 141 | ) # last 3 lines 142 | 143 | # Add the output 144 | res += pretty 145 | res = (res, None) 146 | 147 | return res # Return (text, embed) 148 | 149 | async def _eval(self, ctx: Context, code: str) -> Optional[disnake.Message]: 150 | """Eval the input code string & send an embed to the invoking context.""" 151 | logger.info(f"Evaluating the following snippet:\n{code}") 152 | self.ln += 1 153 | 154 | if code == "exit": 155 | self.ln = 0 156 | self.env = {} 157 | return await ctx.send("```Reset history!```") 158 | 159 | env = { 160 | "message": ctx.message, 161 | "author": ctx.message.author, 162 | "channel": ctx.channel, 163 | "guild": ctx.guild, 164 | "ctx": ctx, 165 | "self": self, 166 | "bot": self.bot, 167 | "inspect": inspect, 168 | "discord": disnake, 169 | "disnake": disnake, 170 | "contextlib": contextlib, 171 | } 172 | 173 | self.env.update(env) 174 | 175 | # Ignore this code, it works 176 | code_ = """ 177 | async def func(): # (None,) -> Any 178 | try: 179 | with contextlib.redirect_stdout(self.stdout): 180 | {0} 181 | if '_' in locals(): 182 | if inspect.isawaitable(_): 183 | _ = await _ 184 | return _ 185 | finally: 186 | self.env.update(locals()) 187 | """.format( 188 | textwrap.indent(code, " ") 189 | ) 190 | 191 | try: 192 | exec(code_, self.env) # noqa: B102,S102 193 | func = self.env["func"] 194 | res = await func() 195 | 196 | except Exception: 197 | res = traceback.format_exc() 198 | 199 | out, embed = self._format(code, res) 200 | out = out.rstrip("\n") # Strip empty lines from output 201 | 202 | # Truncate output to max 15 lines or 1500 characters 203 | newline_truncate_index = find_nth_occurrence(out, "\n", 15) 204 | 205 | if newline_truncate_index is None or newline_truncate_index > 1500: 206 | truncate_index = 1500 207 | else: 208 | truncate_index = newline_truncate_index 209 | 210 | if len(out) > truncate_index: 211 | await ctx.send( 212 | f"```py\n{out[:truncate_index]}\n```" 213 | f"... response truncated; full output uploaded as an attachment", 214 | embed=embed, 215 | file=disnake.File(fp=StringIO(out), filename="out.txt"), 216 | ) 217 | return 218 | 219 | await ctx.send(f"```py\n{out}```", embed=embed) 220 | 221 | @group(name="devops") 222 | @has_any_role(Roles.devops, Roles.steering_council) 223 | async def devops_group(self, ctx: Context) -> None: 224 | """Internal commands to access the inner working of the bot.""" 225 | if not ctx.invoked_subcommand: 226 | await ctx.send_help(ctx.command) 227 | 228 | @devops_group.command(name="eval", aliases=("e",)) 229 | async def eval(self, ctx: Context, *, code: str) -> None: 230 | """Run eval in a REPL-like format.""" 231 | code = code.strip("`") 232 | if re.match("py(thon)?\n", code): 233 | code = "\n".join(code.split("\n")[1:]) 234 | 235 | if ( 236 | not re.search( # Check if it's an expression 237 | r"^(return|import|for|while|def|class|" r"from|exit|[a-zA-Z0-9]+\s*=)", 238 | code, 239 | re.M, 240 | ) 241 | and len(code.split("\n")) == 1 242 | ): 243 | code = "_ = " + code 244 | 245 | await self._eval(ctx, code) 246 | 247 | 248 | def setup(bot: Bot) -> None: 249 | """Load the DevOps cog.""" 250 | bot.add_cog(DevOps(bot)) 251 | -------------------------------------------------------------------------------- /bot/exts/utils/eval.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | import aiohttp 7 | from disnake import Embed, Message 8 | from disnake.ext import commands, tasks 9 | from disnake.ext.commands import Cog, Context, command 10 | from disnake.utils import escape_mentions 11 | from loguru import logger 12 | from yaml import safe_load 13 | 14 | from bot.bot import Bot 15 | 16 | from ._eval_helper import EvalHelper, FormatOutput, Tio 17 | 18 | SOFT_RED = 0xCD6D6D 19 | GREEN = 0x1F8B4C 20 | 21 | 22 | class Eval(Cog): 23 | """Safe evaluation of Code using Tio Run Api.""" 24 | 25 | def __init__(self, bot: Bot) -> None: 26 | self.bot = bot 27 | with Path("bot/resources/eval/default_langs.yml").open(encoding="utf8") as file: 28 | self.default_languages = safe_load(file) 29 | self.languages_url = "https://tio.run/languages.json" 30 | self.update_languages.start() 31 | with Path("bot/resources/eval/wrapping.yml").open(encoding="utf8") as file: 32 | self.wrapping = safe_load(file) 33 | with Path("bot/resources/eval/quick_map.yml").open(encoding="utf8") as file: 34 | self.quick_map = safe_load(file) 35 | 36 | @tasks.loop(hours=5) 37 | async def update_languages(self) -> None: 38 | """Update list of languages supported by api every 5 hour.""" 39 | logger.info("Updating List Of Languages") 40 | async with aiohttp.ClientSession() as client_session: 41 | async with client_session.get(self.languages_url) as response: 42 | if response.status != 200: 43 | logger.warning( 44 | f"Couldn't reach languages.json (status code: {response.status})." 45 | ) 46 | languages = tuple(sorted(json.loads(await response.text()))) 47 | self.languages = languages 48 | logger.info( 49 | f"Successfully Updated List Of Languages To Date: {datetime.datetime.now()}" 50 | ) 51 | 52 | @command( 53 | help="""eval [--wrapped] [--stats] 54 | 55 | for command-line-options, compiler-flags and arguments you may 56 | add a line starting with this argument, and after a space add 57 | your options, flags or args. 58 | 59 | stats - option displays more information on execution consumption 60 | wrapped - allows you to not put main function in some languages 61 | 62 | may be normal code, but also an attached file, or a link from \ 63 | [hastebin](https://hastebin.com) or [Github gist](https://gist.github.com) 64 | If you use a link, your command must end with this syntax: \ 65 | `link=` (no space around `=`) 66 | for instance : `!eval python link=https://hastebin.com/gurkbot.py` 67 | 68 | If the output exceeds 40 lines or Discord max message length, it will be put 69 | in a new hastebin and the link will be returned. 70 | """, 71 | brief="Execute code in a given programming language", 72 | name="eval", 73 | aliases=("e",), 74 | ) 75 | @commands.cooldown(3, 10, commands.BucketType.user) 76 | async def eval_command( 77 | self, ctx: Context, language: str, *, code: str = "" 78 | ) -> Optional[Message]: 79 | """ 80 | Evaluate code, format it, and send the output to the corresponding channel. 81 | 82 | Return the bot response. 83 | """ 84 | async with ctx.typing(): 85 | eval_helper = EvalHelper(language) 86 | 87 | parsed_data = await eval_helper.parse(code) 88 | ( 89 | inputs, 90 | code, 91 | lang, 92 | options, 93 | compiler_flags, 94 | command_line_options, 95 | args, 96 | ) = parsed_data 97 | text = None 98 | 99 | if ctx.message.attachments: 100 | text = await eval_helper.code_from_attachments(ctx) 101 | if not text: 102 | return 103 | 104 | elif code.split(" ")[-1].startswith("link="): 105 | # Code in a paste service (gist or a hastebin link) 106 | text = await eval_helper.code_from_url(ctx, code) 107 | if not text: 108 | return 109 | 110 | elif code.strip("`"): 111 | # Code in message 112 | text = code.strip("`") 113 | first_line = text.splitlines()[0] 114 | if not language.startswith("```"): 115 | text = text[len(first_line) + 1 :] 116 | 117 | if text is None: 118 | # Ensures code isn't empty after removing options 119 | raise commands.MissingRequiredArgument(ctx.command.clean_params["code"]) 120 | 121 | if lang in self.quick_map: 122 | lang = self.quick_map[lang] 123 | if lang in self.default_languages: 124 | lang = self.default_languages[lang] 125 | if lang not in self.languages: 126 | if not escape_mentions(lang): 127 | embed = Embed( 128 | title="MissingRequiredArgument", 129 | description=f"Missing Argument Language.\n\nUsage:\n" 130 | f"```{ctx.prefix}{ctx.command} {ctx.command.signature}```", 131 | color=SOFT_RED, 132 | ) 133 | else: 134 | embed = Embed( 135 | title="Language Not Supported", 136 | description=f"Your language was invalid: {lang}\n" 137 | f"All Supported languages: [here](https://tio.run)\n\nUsage:\n" 138 | f"```{ctx.prefix}{ctx.command} {ctx.command.signature}```", 139 | color=SOFT_RED, 140 | ) 141 | await ctx.send(embed=embed) 142 | logger.info("Exiting | Language not found.") 143 | return 144 | 145 | if options["--wrapped"]: 146 | if not ( 147 | any(map(lambda x: lang.split("-")[0] == x, self.wrapping)) 148 | ) or lang in ("cs-mono-shell", "cs-csi"): 149 | await ctx.send(f"`{lang}` cannot be wrapped") 150 | return 151 | 152 | for beginning in self.wrapping: 153 | if lang.split("-")[0] == beginning: 154 | text = self.wrapping[beginning].replace("code", text) 155 | break 156 | 157 | tio = Tio(lang, text, inputs, compiler_flags, command_line_options, args) 158 | result = await tio.get_result() 159 | result = result.rstrip("\n") 160 | 161 | if not options["--stats"]: 162 | try: 163 | start, end = result.rindex("Real time: "), result.rindex( 164 | "%\nExit code: " 165 | ) 166 | result = result[:start] + result[end + 2 :] 167 | except ValueError: 168 | pass 169 | 170 | format_output = FormatOutput(language=lang) 171 | 172 | if ( 173 | len(result) > format_output.max_output_length 174 | or result.count("\n") > format_output.max_lines 175 | ): 176 | output = await eval_helper.paste(result) 177 | 178 | embed = format_output.format_hastebin_output(output, result) 179 | 180 | await ctx.send(content=f"{ctx.author.mention}", embed=embed) 181 | logger.info("Result Sent.") 182 | return 183 | 184 | embed = format_output.format_code_output(result) 185 | await ctx.send(content=f"{ctx.author.mention}", embed=embed) 186 | logger.info("Result Sent.") 187 | 188 | 189 | def setup(bot: Bot) -> None: 190 | """Load the Eval cog.""" 191 | bot.add_cog(Eval(bot)) 192 | -------------------------------------------------------------------------------- /bot/exts/utils/reminder.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from contextlib import suppress 4 | from datetime import datetime 5 | from typing import Optional, Union 6 | 7 | import disnake 8 | import humanize 9 | from asyncpg import Record 10 | from disnake import Embed 11 | from disnake.ext.commands import Cog, Context, group 12 | from disnake.utils import sleep_until 13 | 14 | from bot.bot import Bot 15 | from bot.constants import Colours 16 | from bot.postgres.utils import db_execute, db_fetch 17 | from bot.utils.pagination import LinePaginator 18 | from bot.utils.parsers import parse_duration 19 | 20 | REMINDER_DESCRIPTION = "**Arrives in**: {arrive_in}\n" 21 | 22 | 23 | class Reminder(Cog): 24 | """Reminder for events, tasks, etc.""" 25 | 26 | def __init__(self, bot: Bot) -> None: 27 | self.bot = bot 28 | self.reminders: dict = {} 29 | self.current_scheduled: Optional[int] = None 30 | self.scheduled_coroutine: Optional[asyncio.Task] = None 31 | 32 | self.bot.loop.create_task(self._sync_reminders()) 33 | 34 | def get_recent_reminder(self) -> Optional[dict]: 35 | """Get recent reminder(with the earliest end time) out of all reminders.""" 36 | with suppress(ValueError): 37 | return min(self.reminders.values(), key=lambda record: record["end_time"]) 38 | return 39 | 40 | async def _sync_reminders(self) -> None: 41 | """Cache reminders from the database and schedule reminders.""" 42 | self.reminders = { 43 | reminder["reminder_id"]: reminder 44 | for reminder in await db_fetch(self.bot.db_pool, "SELECT * FROM reminders") 45 | } 46 | await self.schedule_reminder(self.get_recent_reminder()) 47 | 48 | async def cancel_reminder_task(self) -> None: 49 | """Cancel reminder task.""" 50 | if isinstance(self.scheduled_coroutine, asyncio.Task): 51 | self.scheduled_coroutine.cancel() 52 | 53 | self.scheduled_coroutine = None 54 | 55 | async def schedule_reminder(self, recent: Optional[dict]) -> None: 56 | """Pull and schedule all reminders from the database.""" 57 | if not recent: 58 | self.current_scheduled = None 59 | return 60 | 61 | if recent["reminder_id"] != self.current_scheduled: 62 | self.current_scheduled = recent["reminder_id"] 63 | 64 | # Cancel old reminder for more recent reminder. 65 | await self.cancel_reminder_task() 66 | 67 | self.scheduled_coroutine = self.bot.loop.create_task( 68 | self.send_reminder(recent) 69 | ) 70 | 71 | async def send_reminder(self, reminder: Union[Record, dict]) -> None: 72 | """Send scheduled reminder.""" 73 | if (until := reminder["end_time"]) > datetime.utcnow(): 74 | await sleep_until(until) 75 | 76 | await self.bot.wait_until_ready() 77 | user: disnake.User = self.bot.get_user(reminder["user_id"]) 78 | channel: disnake.TextChannel = self.bot.get_channel(reminder["channel_id"]) 79 | 80 | message_id = int(reminder["jump_url"].split("/")[-1]) 81 | jump_url = f"\n[Jump to original message]({reminder['jump_url']})" 82 | try: 83 | message = await channel.fetch_message(message_id) 84 | jump_url = "" 85 | except disnake.NotFound: 86 | message = None 87 | jump_url = "" 88 | except (disnake.Forbidden, disnake.HTTPException): 89 | message = None 90 | 91 | embed = disnake.Embed( 92 | title=":alarm_clock: Reminder arrived", 93 | color=Colours.green, 94 | description=f"\n{reminder['content'][:50]}{jump_url}", 95 | ) 96 | 97 | embed.timestamp = datetime.utcnow() 98 | 99 | # taken from disnake.Message.raw_mentions() 100 | mentions = [ 101 | f"<@{x}>" for x in re.findall(r"<@!?([0-9]+)>", reminder["content"]) 102 | ] 103 | mentions.append(user.mention) 104 | mentions = ", ".join(mentions) 105 | 106 | if message is not None: 107 | await message.reply(embed=embed) 108 | else: 109 | await channel.send(content=mentions, embed=embed) 110 | 111 | await db_execute( 112 | self.bot.db_pool, 113 | "DELETE FROM reminders WHERE reminder_id=$1", 114 | reminder["reminder_id"], 115 | ) 116 | 117 | del self.reminders[reminder["reminder_id"]] 118 | await self.schedule_reminder(self.get_recent_reminder()) 119 | 120 | @group(name="remind", aliases=("reminder",), invoke_without_command=True) 121 | async def remind_group(self, ctx: Context, duration: str, *, content: str) -> None: 122 | """ 123 | Set reminders. 124 | 125 | syntax: !remind 126 | Accepted duration formats: 127 | - d|day|days 128 | - h|hour|hours 129 | - m|min|mins|minute|minutes 130 | - s|sec|secs|second|seconds 131 | 132 | Example: 133 | !remind 1hour drink water 134 | !remind 10m submit assignment 135 | !remind 1hour30min workout 136 | !remind 20days1hour20min my birthday 137 | """ 138 | await self.remind_duration(ctx, duration, content=content) 139 | 140 | async def append_reminder( 141 | self, timestamp: datetime, ctx: Context, content: str 142 | ) -> None: 143 | """Add reminder to database and schedule it.""" 144 | sql = ( 145 | "INSERT INTO reminders(jump_url, user_id, channel_id, end_time, content) " 146 | "VALUES ($1, $2, $3, $4, $5)RETURNING reminder_id" 147 | ) 148 | async with self.bot.db_pool.acquire() as connection: 149 | reminder_id = await connection.fetchval( 150 | sql, 151 | ctx.message.jump_url, 152 | ctx.author.id, 153 | ctx.channel.id, 154 | timestamp, 155 | content, 156 | ) 157 | 158 | embed = Embed( 159 | title=":white_check_mark: Reminder set", 160 | color=Colours.green, 161 | description=REMINDER_DESCRIPTION.format( 162 | arrive_in=humanize.precisedelta( 163 | timestamp - datetime.utcnow(), format="%0.0f" 164 | ), 165 | ), 166 | ) 167 | embed.set_footer(text=f"ID: {reminder_id}") 168 | await ctx.send(embed=embed) 169 | self.reminders[reminder_id] = { 170 | "reminder_id": reminder_id, 171 | "jump_url": ctx.message.jump_url, 172 | "user_id": ctx.author.id, 173 | "channel_id": ctx.channel.id, 174 | "end_time": timestamp, 175 | "content": content, 176 | } 177 | 178 | await self.schedule_reminder(self.get_recent_reminder()) 179 | 180 | async def remind_duration( 181 | self, ctx: Context, duration: str, *, content: str 182 | ) -> None: 183 | """Set reminder base on duration.""" 184 | future_timestamp = parse_duration(duration) 185 | if not future_timestamp: 186 | await ctx.send("Invalid duration!") 187 | return 188 | await self.append_reminder(future_timestamp, ctx, content) 189 | 190 | @remind_group.command(name="list", aliases=("l",)) 191 | async def list_reminders(self, ctx: Context) -> None: 192 | """List all your reminders.""" 193 | reminders = [ 194 | reminder 195 | for reminder in self.reminders.values() 196 | if reminder["user_id"] == ctx.author.id 197 | ] 198 | 199 | lines = [ 200 | f"**Arrives in {humanize.precisedelta(reminder['end_time'] - datetime.utcnow(), format='%0.0f')}" 201 | f"** (ID: {reminder['reminder_id']})\n{reminder['content']}\n" 202 | for i, reminder in enumerate(reminders, start=1) 203 | ] 204 | embed = Embed( 205 | title=f":hourglass: Active reminders ({len(lines)})", 206 | timestamp=datetime.utcnow(), 207 | color=Colours.green, 208 | ) 209 | 210 | await LinePaginator.paginate( 211 | lines, 212 | ctx, 213 | embed, 214 | allow_empty_lines=True, 215 | ) 216 | 217 | @remind_group.command(name="delete", aliases=("d", "del")) 218 | async def delete_reminder(self, ctx: Context, reminder_id: int) -> None: 219 | """Delete scheduled reminder.""" 220 | if reminder_id in self.reminders: 221 | await db_execute( 222 | self.bot.db_pool, 223 | "DELETE FROM reminders WHERE reminder_id=$1 and user_id=$2;", 224 | reminder_id, 225 | ctx.author.id, 226 | ) 227 | del self.reminders[reminder_id] 228 | if self.current_scheduled == reminder_id: 229 | await self.cancel_reminder_task() 230 | await self.schedule_reminder(self.get_recent_reminder()) 231 | await ctx.send("Reminder deleted successfully!") 232 | 233 | else: 234 | await ctx.send(f"Reminder with ID: {reminder_id} not found!") 235 | 236 | 237 | def setup(bot: Bot) -> None: 238 | """Init cog.""" 239 | bot.add_cog(Reminder(bot)) 240 | -------------------------------------------------------------------------------- /bot/exts/utils/subscription.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from disnake import Embed, Role 4 | from disnake.ext.commands import Cog, Context, group 5 | 6 | from bot.bot import Bot 7 | from bot.constants import Colours, Emojis, Roles 8 | 9 | 10 | class Subscription(Cog): 11 | """Subscribe and Unsubscribe to announcements and polls notifications.""" 12 | 13 | def __init__(self, bot: Bot) -> None: 14 | self.bot = bot 15 | 16 | @staticmethod 17 | async def get_roles(ctx: Context) -> tuple: 18 | """Gets announcements and polls role from the guild of the context.""" 19 | return ( 20 | ctx.guild.get_role(Roles.announcements), 21 | ctx.guild.get_role(Roles.polls), 22 | ctx.guild.get_role(Roles.events), 23 | ) 24 | 25 | @staticmethod 26 | async def apply_role(ctx: Context, role_name: Role) -> bool: 27 | """ 28 | Applies the provided role to the user. 29 | 30 | Returns `True` if role was successfully added otherwise it returns `False`. 31 | """ 32 | if role_name in ctx.author.roles: 33 | return False # User already has the role 34 | await ctx.author.add_roles(role_name, reason=f"Subscribed to {role_name}") 35 | return True 36 | 37 | @staticmethod 38 | async def remove_role(ctx: Context, role_name: Role) -> bool: 39 | """ 40 | Removes the provided role from the user. 41 | 42 | Returns `True` if role was successfully removed otherwise it returns `False`. 43 | """ 44 | if role_name in ctx.author.roles: 45 | await ctx.author.remove_roles( 46 | role_name, reason=f"Unsubscribed to {role_name}" 47 | ) 48 | return True 49 | return False # User doesn't have the role 50 | 51 | async def sub_unsub_helper( 52 | self, ctx: Context, role_id: int, func: Callable, action: str 53 | ) -> None: 54 | """ 55 | Helper function for sending embeds for subscribe and unsubscribe to announcements or polls. 56 | 57 | If `func` is `apply_role`, it checks if user have the role (returns `False`) or not (returns `True`) 58 | If `func` is `remove_role`, it checks if user have the role (returns `True`) or not (returns `False`) 59 | """ 60 | role_name = ctx.guild.get_role(role_id) 61 | if await func(ctx, role_name): 62 | embed = Embed( 63 | title=f"{Emojis.confirmation_emoji} {action}", 64 | description=f"You've {action.lower()} to {ctx.guild}'s {role_name}.", 65 | color=Colours.green, 66 | ) 67 | else: 68 | embed = Embed( 69 | title=f"{Emojis.warning_emoji} Already {action.lower()}", 70 | description=f"You're already {action.lower()} to {ctx.guild}'s {role_name}.", 71 | color=Colours.soft_red, 72 | ) 73 | await ctx.send(content=ctx.author.mention, embed=embed) 74 | 75 | async def sub_unsub_group_helper( 76 | self, ctx: Context, func: Callable, action: str 77 | ) -> None: 78 | """ 79 | Helper function for subscribe_group and unsubscribe_group. 80 | 81 | If `func` is `apply_role`, `role_lst` stores the role(s) which are added to the user 82 | If `func` is `remove_role`, `role_lst` stores the role(s) which are removed from the user 83 | """ 84 | roles = await self.get_roles(ctx) 85 | role_lst = [ 86 | role.name for role in roles if await func(ctx, role) 87 | ] # Applies/Removes the role(s) and stores a list of applied/removed role(s) 88 | if role_lst: 89 | msg = ( 90 | f"{', '.join(role_lst[:-1])} and {role_lst[-1]}" 91 | if len(role_lst) > 1 92 | else role_lst[0] 93 | ) # Stores a string which tells what role(s) is/are added to or removed from the user 94 | embed = Embed( 95 | title=f"{Emojis.confirmation_emoji} {action}", 96 | description=f"You've {action.lower()} to {ctx.guild}'s {msg}.", 97 | color=Colours.green, 98 | ) 99 | else: 100 | embed = Embed( 101 | title=f"{Emojis.warning_emoji} Already {action.lower()}", 102 | description=f"You're already {action.lower()} to {ctx.guild}'s announcements and polls.", 103 | color=Colours.soft_red, 104 | ) 105 | await ctx.send(content=ctx.author.mention, embed=embed) 106 | 107 | async def subscribe_list(self, ctx: Context) -> None: 108 | """Sends an embed for list of Roles that you can subscribe to.""" 109 | roles = await self.get_roles(ctx) 110 | escape = "\n- " 111 | embed = Embed( 112 | title=f"{Emojis.confirmation_emoji} Subscribe List", 113 | description=f"- {escape.join(role.name for role in roles)}", # Creates a bullet list 114 | color=Colours.green, 115 | ) 116 | await ctx.send(content=ctx.author.mention, embed=embed) 117 | 118 | @group(name="subscribe", aliases=("sub",), invoke_without_command=True) 119 | async def subscribe_group(self, ctx: Context) -> None: 120 | """Subscribe to announcements and polls notifications, by assigning yourself the roles.""" 121 | await self.sub_unsub_group_helper(ctx, self.apply_role, "Subscribed") 122 | 123 | @subscribe_group.command(name="announcements", aliases=("announcement",)) 124 | async def announcements_subscribe(self, ctx: Context) -> None: 125 | """Subscribe to announcements notifications, by assigning yourself the role.""" 126 | await self.sub_unsub_helper( 127 | ctx, Roles.announcements, self.apply_role, "Subscribed" 128 | ) 129 | 130 | @subscribe_group.command(name="polls", aliases=("poll",)) 131 | async def polls_subscribe(self, ctx: Context) -> None: 132 | """Subscribe to polls notification, by assigning yourself the role.""" 133 | await self.sub_unsub_helper(ctx, Roles.polls, self.apply_role, "Subscribed") 134 | 135 | @subscribe_group.command(name="events", aliases=("event",)) 136 | async def events_subscribe(self, ctx: Context) -> None: 137 | """Subscribe to events notification, by assigning yourself the role.""" 138 | await self.sub_unsub_helper(ctx, Roles.events, self.apply_role, "Subscribed") 139 | 140 | @group(name="unsubscribe", aliases=("unsub",), invoke_without_command=True) 141 | async def unsubscribe_group(self, ctx: Context) -> None: 142 | """Unsubscribe to announcements and polls notifications, by removing your roles.""" 143 | await self.sub_unsub_group_helper(ctx, self.remove_role, "Unsubscribed") 144 | 145 | @unsubscribe_group.command(name="announcements", aliases=("announcement",)) 146 | async def announcements_unsubscribe(self, ctx: Context) -> None: 147 | """Unsubscribe to announcements notification, by removing your role.""" 148 | await self.sub_unsub_helper( 149 | ctx, Roles.announcements, self.remove_role, "Unsubscribed" 150 | ) 151 | 152 | @unsubscribe_group.command(name="polls", aliases=("poll",)) 153 | async def polls_unsubscribe(self, ctx: Context) -> None: 154 | """Unsubscribe to polls notification, by removing your role.""" 155 | await self.sub_unsub_helper(ctx, Roles.polls, self.remove_role, "Unsubscribed") 156 | 157 | @unsubscribe_group.command(name="events", aliases=("event",)) 158 | async def events_unsubscribe(self, ctx: Context) -> None: 159 | """Unsubscribe to events notification, by removing your role.""" 160 | await self.sub_unsub_helper(ctx, Roles.events, self.remove_role, "Unsubscribed") 161 | 162 | @group(name="subscribelist", aliases=("sublist",), invoke_without_command=True) 163 | async def subscribelist(self, ctx: Context) -> None: 164 | """Gives a list of roles that you can subscribe to.""" 165 | await self.subscribe_list(ctx) 166 | 167 | 168 | def setup(bot: Bot) -> None: 169 | """Load the Subscription cog.""" 170 | bot.add_cog(Subscription(bot)) 171 | -------------------------------------------------------------------------------- /bot/exts/utils/utils.py: -------------------------------------------------------------------------------- 1 | # Some commands in this file are based on the Python Discord 2 | # "Python" bot, available at https://github.com/python-discord/bot. 3 | # The source code for the bot is available under the MIT-licensed. 4 | 5 | import re 6 | import unicodedata 7 | from typing import Tuple 8 | 9 | from disnake import Embed, utils 10 | from disnake.ext.commands import Cog, Context, command 11 | from disnake.ext.commands.errors import BadArgument 12 | 13 | from bot.bot import Bot 14 | from bot.constants import Colours 15 | from bot.utils.pagination import LinePaginator 16 | 17 | 18 | class Utils(Cog): 19 | """A general collection of utilities.""" 20 | 21 | def __init__(self, bot: Bot) -> None: 22 | self.bot = bot 23 | 24 | @command() 25 | async def charinfo(self, ctx: Context, *, characters: str) -> None: 26 | """Shows you information about up to 50 unicode characters.""" 27 | match = re.match(r"<(a?):(\w+):(\d+)>", characters) 28 | if match: 29 | raise BadArgument( 30 | "Only unicode characters can be processed, but a custom Discord emoji " 31 | "was found. Please remove it and try again.", 32 | ) 33 | if len(characters) > 50: 34 | raise BadArgument(f"Too many characters ({len(characters)}/50)") 35 | 36 | def get_info(char: str) -> Tuple[str, str]: 37 | digit = f"{ord(char):x}" 38 | if len(digit) <= 4: 39 | u_code = f"\\u{digit:>04}" 40 | else: 41 | u_code = f"\\U{digit:>08}" 42 | url = f"https://www.compart.com/en/unicode/U+{digit:>04}" 43 | name = f"[{unicodedata.name(char, '')}]({url})" 44 | info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" 45 | return info, u_code 46 | 47 | char_list, raw_list = zip(*(get_info(c) for c in characters)) 48 | embed = Embed(title="Character info", colour=Colours.green) 49 | 50 | if len(characters) > 1: 51 | # Maximum length possible is 502 out of 1024, so there's no need to truncate. 52 | embed.add_field( 53 | name="Full Raw Text", value=f"`{''.join(raw_list)}`", inline=False 54 | ) 55 | 56 | await LinePaginator.paginate( 57 | char_list, ctx, embed, max_lines=10, max_size=2000, empty=False 58 | ) 59 | 60 | 61 | def setup(bot: Bot) -> None: 62 | """Setup Utils cog.""" 63 | bot.add_cog(Utils(bot)) 64 | -------------------------------------------------------------------------------- /bot/postgres/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from asyncpg import Pool 4 | 5 | 6 | async def create_tables(pool: Pool) -> None: 7 | """Execute all sql files inside /tables folder.""" 8 | tables_path = Path("bot", "postgres", "tables") 9 | async with pool.acquire() as connection: 10 | async with connection.transaction(): 11 | for table_file in tables_path.iterdir(): 12 | await connection.execute(table_file.read_text()) 13 | -------------------------------------------------------------------------------- /bot/postgres/tables/off_topic_names.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS offtopicnames ( 2 | name VARCHAR(256) UNIQUE PRIMARY KEY, 3 | num_used SMALLINT check ( num_used >= 0 ) DEFAULT 0 4 | ); 5 | -------------------------------------------------------------------------------- /bot/postgres/tables/reminders.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS reminders ( 2 | reminder_id serial NOT NULL PRIMARY KEY, 3 | jump_url TEXT UNIQUE, 4 | user_id BIGINT NOT NULL, 5 | channel_id BIGINT NOT NULL, 6 | end_time TIMESTAMP NOT NULL, 7 | content VARCHAR(512) DEFAULT NULL 8 | ); 9 | -------------------------------------------------------------------------------- /bot/postgres/utils.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from asyncpg import Pool, Record 4 | from loguru import logger 5 | 6 | 7 | async def db_execute(pool: Pool, sql_statement: str, *args) -> str: 8 | """Execute SQL statement.""" 9 | async with pool.acquire() as connection: 10 | logger.info(f"Executing SQL: {sql_statement}") 11 | logger.info(f"with args: {args}") 12 | status = await connection.execute(sql_statement, *args) 13 | logger.info(f"DB execute status: {status}") 14 | return status 15 | 16 | 17 | async def db_fetch(pool: Pool, sql_statement: str, *args) -> List[Record]: 18 | """Execute SQL statement.""" 19 | async with pool.acquire() as connection: 20 | result = await connection.fetch( 21 | sql_statement, 22 | *args, 23 | ) 24 | 25 | return result 26 | -------------------------------------------------------------------------------- /bot/resources/bot_replies.yml: -------------------------------------------------------------------------------- 1 | ERROR_REPLIES: [ 2 | "Please don't do that.", 3 | "You have to stop.", 4 | "Do you mind?", 5 | "In the future, don't do that.", 6 | "That was a mistake.", 7 | "You blew it.", 8 | "You're bad at computers.", 9 | "Are you trying to kill me?", 10 | "Noooooo!!", 11 | "I can't believe you've done this" 12 | ] 13 | 14 | NEGATIVE_REPLIES: [ 15 | "Noooooo!!", 16 | "Nope.", 17 | "I'm sorry Gurk, I'm afraid I can't do that.", 18 | "I don't think so., Not gonna happen.", 19 | "Out of the question.", 20 | "Huh? No.", 21 | "Nah.", 22 | "Naw.", 23 | "Not likely.", 24 | "Not in a million years.", 25 | "Fat chance.", 26 | "Certainly not.", 27 | "NEGATORY.", 28 | "Nuh-uh.", 29 | "Not in my house!" 30 | ] 31 | 32 | POSITIVE_REPLIES : [ 33 | "Yep. Absolutely!", 34 | "Can do!", 35 | "Affirmative!", 36 | "Yeah okay.", 37 | "Sure.", 38 | "Sure thing!", 39 | "You're the boss!", 40 | "Okay.", 41 | "No problem.", 42 | "I got you.", 43 | "Alright.", 44 | "You got it!", 45 | "ROGER THAT", 46 | "Of course!", 47 | "Aye aye, cap'n!", 48 | "I'll allow it." 49 | ] 50 | -------------------------------------------------------------------------------- /bot/resources/eval/default_langs.yml: -------------------------------------------------------------------------------- 1 | ada: 'ada-gnat' 2 | apl: 'apl-dyalog-classic' 3 | assembly: 'assembly-gcc' 4 | b: 'ybc' 5 | c: 'c-gcc' 6 | cobol: 'cobol-gnu' 7 | cpp: 'cpp-gcc' 8 | cs: 'cs-core' 9 | erlang: 'erlang-escript' 10 | euphoria: 'euphoria4' 11 | fasm: 'assembly-fasm' 12 | fs: 'fs-core' 13 | java: 'java-jdk' 14 | javascript: 'javascript-node' 15 | julia: 'julia1x' 16 | k: 'kona' 17 | kobeři-c: 'koberi-c' 18 | nasm: 'assembly-nasm' 19 | objective-c: 'objective-c-gcc' 20 | pascal: 'pascal-fpc' 21 | perl: 'perl6' 22 | pilot: 'pilot-rpilot' 23 | postscript: 'postscript-xpost' 24 | python: 'python3' 25 | qs: 'qs-core' 26 | snobol: 'snobol4' 27 | sql: 'sqlite' 28 | u6: 'mu6' 29 | vb: 'vb-core' 30 | -------------------------------------------------------------------------------- /bot/resources/eval/quick_map.yml: -------------------------------------------------------------------------------- 1 | asm : "assembly" 2 | c# : "cs" 3 | c++ : "cpp" 4 | csharp : "cs" 5 | f# : "fs" 6 | fsharp : "fs" 7 | js : "javascript" 8 | nimrod : "nim" 9 | py : "python" 10 | q# : "qs" 11 | rs : "rust" 12 | sh : "bash" 13 | -------------------------------------------------------------------------------- /bot/resources/eval/wrapping.yml: -------------------------------------------------------------------------------- 1 | c: "#include \nint main() {code}" 2 | cpp: "#include \nint main() {code}" 3 | cs: "using System;class Program {static void Main(string[] args) {code}}" 4 | java: "public class Main {public static void main(String[] args) {code}}" 5 | rust: "fn main() {code}" 6 | d: "import std.stdio; void main(){code}" 7 | kotlin: "fun main(args: Array) {code}" 8 | -------------------------------------------------------------------------------- /bot/resources/images/yodabonk.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/resources/images/yodabonk.gif -------------------------------------------------------------------------------- /bot/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gurkult/gurkbot/95d0475aff6d3b7f037ba7ea65b69b4be49155c2/bot/utils/__init__.py -------------------------------------------------------------------------------- /bot/utils/checks.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from disnake.ext.commands import CheckFailure 4 | 5 | 6 | class InWhitelistCheckFailure(CheckFailure): 7 | """Raised when the `in_whitelist` check fails.""" 8 | 9 | def __init__(self, redirect_channel: Optional[int]) -> None: 10 | self.redirect_channel = redirect_channel 11 | 12 | if redirect_channel is not None: 13 | redirect_message = ( 14 | f"Here. Please use the <#{redirect_channel}> channel instead" 15 | ) 16 | else: 17 | redirect_message = "" 18 | 19 | error_message = f"You are not allowed to use that command{redirect_message}." 20 | 21 | super().__init__(error_message) 22 | -------------------------------------------------------------------------------- /bot/utils/is_gurkan.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def gurkan_check(target: str) -> bool: 5 | """Returns a bool stating if the name given is a gurkan or not.""" 6 | return bool(re.search(r"gurk|urkan", target.lower())) 7 | 8 | 9 | def gurkan_rate(name: str) -> float: 10 | """Returns the rate of gurkan in the name given.""" 11 | if name == "": 12 | return 0.0 13 | gurkanness = 0 14 | for match in re.finditer("gurkan|urkan|gurk", name.lower()): 15 | begin, end = match.span() 16 | gurkanness += end - begin 17 | return int((gurkanness / len(name)) * 100) 18 | -------------------------------------------------------------------------------- /bot/utils/pagination.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing as t 3 | from contextlib import suppress 4 | 5 | import disnake 6 | from disnake.abc import User 7 | from disnake.ext.commands import Context, Paginator 8 | from loguru import logger 9 | 10 | from bot import constants 11 | 12 | FIRST_EMOJI = "\u23EE" # [:track_previous:] 13 | LEFT_EMOJI = "\u2B05" # [:arrow_left:] 14 | RIGHT_EMOJI = "\u27A1" # [:arrow_right:] 15 | LAST_EMOJI = "\u23ED" # [:track_next:] 16 | DELETE_EMOJI = constants.Emojis.trashcan # [:trashcan:] 17 | 18 | PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI) 19 | 20 | CONTINUATION_HEADER = "(Continued)\n-----------\n" 21 | 22 | 23 | class EmptyPaginatorEmbed(Exception): 24 | """Raised when attempting to paginate with empty contents.""" 25 | 26 | pass 27 | 28 | 29 | class LinePaginator(Paginator): 30 | """ 31 | A class that aids in paginating code blocks for Discord messages. 32 | 33 | Available attributes include: 34 | * prefix: `str` 35 | The prefix inserted to every page. e.g. three backticks. 36 | * suffix: `str` 37 | The suffix appended at the end of every page. e.g. three backticks. 38 | * max_size: `int` 39 | The maximum amount of codepoints allowed in a page. 40 | * scale_to_size: `int` 41 | The maximum amount of characters a single line can scale up to. 42 | * max_lines: `int` 43 | The maximum amount of lines allowed in a page. 44 | """ 45 | 46 | def __init__( 47 | self, 48 | prefix: str = "```", 49 | suffix: str = "```", 50 | max_size: int = 2000, 51 | scale_to_size: int = 2000, 52 | max_lines: t.Optional[int] = None, 53 | ) -> None: 54 | """ 55 | This function overrides the Paginator.__init__ from inside disnake.ext.commands. 56 | 57 | It overrides in order to allow us to configure the maximum number of lines per page. 58 | """ 59 | super().__init__() 60 | self.prefix = prefix 61 | self.suffix = suffix 62 | 63 | # Embeds that exceed 2048 characters will result in an HTTPException (Discord API limit) 64 | if max_size > 2000: 65 | raise ValueError(f"max_size must be <= 2,000 characters. ({max_size})") 66 | 67 | self.max_size = max_size - len(suffix) 68 | 69 | if scale_to_size < max_size: 70 | raise ValueError( 71 | f"scale_to_size must be >= max_size. ({scale_to_size=}, {max_size=})" 72 | ) 73 | 74 | if scale_to_size > 2000: 75 | raise ValueError( 76 | f"scale_to_size must be <= 2,000 characters. ({scale_to_size})" 77 | ) 78 | 79 | self.scale_to_size = scale_to_size - len(suffix) 80 | self.max_lines = max_lines 81 | self._current_page = [prefix] 82 | self._line_count = 0 83 | self._count = len(prefix) + 1 # prefix + newline 84 | self._pages = [] 85 | 86 | def add_line(self, line: str = "", *, empty: bool = False) -> None: 87 | """ 88 | Adds a line to the current page. 89 | 90 | If a line on a page exceeds `max_size` characters, then `max_size` will go up to 91 | `scale_to_size` for a single line before creating a new page for the overflow words. If it 92 | is still exceeded, the excess characters are stored and placed on the next pages until 93 | there are none remaining (by word boundary). The line is truncated if `scale_to_size` is 94 | still exceeded after attempting to continue onto the next page. 95 | 96 | In the case that the page already contains one or more lines and the new lines would cause 97 | `max_size` to be exceeded, a new page is created. This is done in order to make a best 98 | effort to avoid breaking up single lines across pages, while keeping the total length of the 99 | page at a reasonable size. 100 | """ 101 | remaining_words = None 102 | if len(line) > (max_chars := self.max_size - len(self.prefix) - 2): 103 | if len(line) > self.scale_to_size: 104 | line, remaining_words = self._split_remaining_words(line, max_chars) 105 | if len(line) > self.scale_to_size: 106 | logger.debug("Could not continue to next page, truncating line.") 107 | line = line[: self.scale_to_size] 108 | 109 | # Check if we should start a new page or continue the line on the current one 110 | if self.max_lines and self._line_count >= self.max_lines: 111 | logger.debug("max_lines exceeded, creating new page.") 112 | self._new_page() 113 | elif self._count + len(line) + 1 > self.max_size and self._line_count > 0: 114 | logger.debug("max_size exceeded on page with lines, creating new page.") 115 | self._new_page() 116 | 117 | self._line_count += 1 118 | 119 | self._count += len(line) + 1 120 | self._current_page.append(line) 121 | 122 | if empty: 123 | self._current_page.append("") 124 | self._count += 1 125 | 126 | # Start a new page if there were any overflow words 127 | if remaining_words: 128 | self._new_page() 129 | self.add_line(remaining_words) 130 | 131 | def _new_page(self) -> None: 132 | """ 133 | Internal: start a new page for the paginator. 134 | 135 | This closes the current page and resets the counters for the new page's line count and 136 | character count. 137 | """ 138 | self._line_count = 0 139 | self._count = len(self.prefix) + 1 140 | self.close_page() 141 | 142 | @staticmethod 143 | def _split_remaining_words( 144 | line: str, max_chars: int 145 | ) -> t.Tuple[str, t.Optional[str]]: 146 | """ 147 | Internal: split a line into two strings. 148 | 149 | i) reduced_words: the remaining words in `line`, after attempting to remove all words that 150 | exceed `max_chars 151 | 152 | ii) remaining_words: the words in `line` which exceed `max_chars`. 153 | """ 154 | reduced_words = line[:max_chars].rsplit(" ", 1)[0] 155 | remaining_words = line.replace(reduced_words, "") 156 | 157 | return ( 158 | reduced_words + "..." if remaining_words else "", 159 | CONTINUATION_HEADER + remaining_words if remaining_words else None, 160 | ) 161 | 162 | @classmethod 163 | async def paginate( 164 | cls, 165 | lines: t.List[str], 166 | ctx: Context, 167 | embed: disnake.Embed, 168 | prefix: str = "", 169 | suffix: str = "", 170 | max_lines: t.Optional[int] = None, 171 | max_size: int = 500, 172 | scale_to_size: int = 2000, 173 | empty: bool = False, 174 | restrict_to_user: User = None, 175 | timeout: int = 300, 176 | footer_text: str = None, 177 | url: str = None, 178 | allow_empty_lines: bool = False, 179 | ) -> t.Optional[disnake.Message]: 180 | """ 181 | Use a paginator and set of reactions to provide pagination over a set of lines. 182 | 183 | When used, this will send a message using `ctx.send()` and apply the pagination reactions, 184 | to control the embed. 185 | 186 | Pagination will also be removed automatically if no reaction is added for `timeout` seconds. 187 | 188 | The interaction will be limited to `restrict_to_user` (ctx.author by default) or 189 | to any user with a moderation role. 190 | 191 | Example: 192 | >>> people = ["Guido van Rossum", "Linus Torvalds", "Gurkbot", "Bjarne Stroustrup"] 193 | >>> e = disnake.Embed() 194 | >>> e.set_author(name="Ain't these people just awesome?") 195 | >>> await LinePaginator.paginate(people, ctx, e) 196 | """ 197 | 198 | def event_check(reaction_: disnake.Reaction, user_: disnake.Member) -> bool: 199 | """Make sure that this reaction is what we want to operate on.""" 200 | return ( 201 | # Conditions for a successful pagination: 202 | all( 203 | ( 204 | # Reaction is on this message 205 | reaction_.message.id == message.id, 206 | # Reaction is one of the pagination emotes 207 | str(reaction_.emoji) in PAGINATION_EMOJI, 208 | # Reaction was not made by the Bot 209 | user_.id != ctx.bot.user.id, 210 | # The reaction was by a whitelisted user 211 | user_.id == restrict_to_user.id, 212 | ) 213 | ) 214 | ) 215 | 216 | paginator = cls( 217 | prefix=prefix, 218 | suffix=suffix, 219 | max_size=max_size, 220 | max_lines=max_lines, 221 | scale_to_size=scale_to_size, 222 | ) 223 | current_page = 0 224 | 225 | # If the `restrict_to_user` is empty then set it to the original message author. 226 | restrict_to_user = restrict_to_user or ctx.author 227 | 228 | if not lines: 229 | if not allow_empty_lines: 230 | logger.exception( 231 | "`Empty lines found, raising error as `allow_empty_lines` is `False`." 232 | ) 233 | raise EmptyPaginatorEmbed("No lines to paginate.") 234 | 235 | logger.debug( 236 | "Empty lines found, `allow_empty_lines` is `True`, adding 'nothing to display' as content." 237 | ) 238 | lines.append("(nothing to display)") 239 | 240 | for line in lines: 241 | try: 242 | paginator.add_line(line, empty=empty) 243 | except Exception: 244 | logger.exception(f"Failed to add line to paginator: '{line}'.") 245 | raise 246 | 247 | logger.debug(f"Paginator created with {len(paginator.pages)} pages.") 248 | 249 | # Set embed description to content of current page. 250 | embed.description = paginator.pages[current_page] 251 | 252 | if len(paginator.pages) <= 1: 253 | if footer_text: 254 | embed.set_footer(text=footer_text) 255 | 256 | if url: 257 | embed.url = url 258 | 259 | logger.debug("Less than two pages, skipping pagination.") 260 | await ctx.send(embed=embed) 261 | return 262 | else: 263 | if footer_text: 264 | embed.set_footer( 265 | text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})" 266 | ) 267 | else: 268 | embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") 269 | 270 | if url: 271 | embed.url = url 272 | 273 | message = await ctx.send(embed=embed) 274 | 275 | logger.debug("Adding emoji reactions to message...") 276 | 277 | for emoji in PAGINATION_EMOJI: 278 | # Add all the applicable emoji to the message 279 | await message.add_reaction(emoji) 280 | 281 | logger.debug("Successfully added all pagination emojis to message.") 282 | 283 | while True: 284 | try: 285 | reaction, user = await ctx.bot.wait_for( 286 | "reaction_add", timeout=timeout, check=event_check 287 | ) 288 | logger.trace(f"Got reaction: {reaction}.") 289 | except asyncio.TimeoutError: 290 | logger.debug("Timed out waiting for a reaction.") 291 | break # We're done, no reactions for the last 5 minutes 292 | 293 | if str(reaction.emoji) == DELETE_EMOJI: 294 | logger.debug("Got delete reaction.") 295 | await message.delete() 296 | return 297 | 298 | if reaction.emoji == FIRST_EMOJI: 299 | await message.remove_reaction(reaction.emoji, user) 300 | current_page = 0 301 | 302 | logger.debug( 303 | f"Got first page reaction - changing to page 1/{len(paginator.pages)}." 304 | ) 305 | 306 | embed.description = paginator.pages[current_page] 307 | if footer_text: 308 | # Current page is zero index based. 309 | embed.set_footer( 310 | text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})" 311 | ) 312 | else: 313 | embed.set_footer( 314 | text=f"Page {current_page + 1}/{len(paginator.pages)}" 315 | ) 316 | await message.edit(embed=embed) 317 | 318 | if reaction.emoji == LAST_EMOJI: 319 | await message.remove_reaction(reaction.emoji, user) 320 | current_page = len(paginator.pages) - 1 321 | 322 | logger.debug( 323 | f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}" 324 | ) 325 | 326 | embed.description = paginator.pages[current_page] 327 | if footer_text: 328 | embed.set_footer( 329 | text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})" 330 | ) 331 | else: 332 | embed.set_footer( 333 | text=f"Page {current_page + 1}/{len(paginator.pages)}" 334 | ) 335 | await message.edit(embed=embed) 336 | 337 | if reaction.emoji == LEFT_EMOJI: 338 | await message.remove_reaction(reaction.emoji, user) 339 | 340 | if current_page <= 0: 341 | logger.debug( 342 | "Got previous page reaction while they are on the first page, ignoring." 343 | ) 344 | continue 345 | 346 | current_page -= 1 347 | logger.debug( 348 | f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}" 349 | ) 350 | 351 | embed.description = paginator.pages[current_page] 352 | 353 | if footer_text: 354 | embed.set_footer( 355 | text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})" 356 | ) 357 | else: 358 | embed.set_footer( 359 | text=f"Page {current_page + 1}/{len(paginator.pages)}" 360 | ) 361 | await message.edit(embed=embed) 362 | 363 | if reaction.emoji == RIGHT_EMOJI: 364 | await message.remove_reaction(reaction.emoji, user) 365 | 366 | if current_page >= len(paginator.pages) - 1: 367 | logger.debug( 368 | "Got next page reaction while they are on the last page, ignoring." 369 | ) 370 | continue 371 | 372 | current_page += 1 373 | logger.debug( 374 | f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}" 375 | ) 376 | 377 | embed.description = paginator.pages[current_page] 378 | 379 | if footer_text: 380 | embed.set_footer( 381 | text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})" 382 | ) 383 | else: 384 | embed.set_footer( 385 | text=f"Page {current_page + 1}/{len(paginator.pages)}" 386 | ) 387 | await message.edit(embed=embed) 388 | 389 | logger.debug("Ending pagination and clearing reactions.") 390 | with suppress(disnake.NotFound): 391 | await message.clear_reactions() 392 | -------------------------------------------------------------------------------- /bot/utils/parsers.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime, timedelta 3 | from typing import Optional 4 | 5 | from disnake.ext.commands import BadArgument 6 | 7 | DURATION_REGEX = re.compile( 8 | r"^((?P[0-9]+)(d|day|days))?" 9 | r"((?P[0-9]+)(h|hour|hours))?" 10 | r"((?P[0-9]+)(m|min|mins|minute|minutes))?" 11 | r"((?P[0-9]+)(s|sec|secs|second|seconds))?$" 12 | ) 13 | 14 | 15 | def parse_duration(duration_pattern: str) -> Optional[datetime]: 16 | """Parse duration string to future datetime object.""" 17 | result = DURATION_REGEX.match(duration_pattern) 18 | if not result: 19 | return 20 | group_dict = {k: int(v) for k, v in result.groupdict(default="0").items()} 21 | try: 22 | return datetime.utcnow() + timedelta(**group_dict) 23 | except OverflowError: 24 | raise BadArgument("Oops! We can not take in such huge numbers...") 25 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | postgres: 4 | image: postgres:12-alpine 5 | ports: 6 | - "127.0.0.1:5000:5432" 7 | environment: 8 | POSTGRES_DB: gurkbot 9 | POSTGRES_USER: gurkbotdb 10 | POSTGRES_PASSWORD: gurkbotdb 11 | volumes: 12 | - ./bot/postgres/tables:/docker-entrypoint-initdb.d/ 13 | 14 | gurkbot: 15 | build: 16 | context: . 17 | dockerfile: Dockerfile 18 | container_name: gurkbot 19 | init: true 20 | 21 | restart: on-failure 22 | env_file: 23 | - .env 24 | 25 | environment: 26 | - ENVIRONMENT=DOCKER-DEVELOPMENT 27 | - DATABASE_URL=postgres://gurkbotdb:gurkbotdb@postgres:5432/gurkbot 28 | 29 | volumes: 30 | - .:/bot 31 | 32 | depends_on: 33 | - postgres 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "gurkbot" 3 | version = "0.1.0" 4 | description = "Our community bot, used for running the server." 5 | authors = ["The Gurkult Community "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/gurkult/gurkbot" 9 | repository = "https://github.com/gurkult/gurkbot" 10 | packages = [ 11 | { include = "bot" }, 12 | ] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.9.0" 16 | disnake = "^2.3" 17 | loguru = "^0.5.3" 18 | PyYAML = "^5.4.1" 19 | Pillow = "^9.3.0" 20 | fuzzywuzzy = "^0.18.0" 21 | asyncpg = "^0.23.0" 22 | python-dateutil = "^2.8.2" 23 | humanize = "^3.11.0" 24 | mcstatus = "^6.5.0" 25 | 26 | [tool.poetry.dev-dependencies] 27 | flake8 = "^3.9.2" 28 | flake8-annotations = "^2.6.2" 29 | flake8-bugbear = "^21.4.3" 30 | flake8-docstrings = "^1.6.0" 31 | flake8-string-format = "^0.3.0" 32 | flake8-todo = "^0.7" 33 | isort = "^5.12.0" 34 | black = "^21.6b0" 35 | pep8-naming = "^0.11.1" 36 | pre-commit = "^2.13.0" 37 | taskipy = "^1.8.1" 38 | python-dotenv = "^0.18.0" 39 | 40 | [tool.isort] 41 | profile = "black" 42 | line_length = 110 43 | 44 | [tool.taskipy.tasks] 45 | bot = { cmd = "python -m bot", help = "Runs Bot"} 46 | lint = { cmd = "pre-commit run --all-files", help = "Lints project" } 47 | precommit = { cmd = "pre-commit install", help = "Installs the pre-commit git hook" } 48 | format = { cmd = "black --check .", help = "Runs the black python formatter" } 49 | 50 | [build-system] 51 | requires = ["poetry-core>=1.0.0"] 52 | build-backend = "poetry.core.masonry.api" 53 | --------------------------------------------------------------------------------