├── .dockerignore ├── .gitattributes ├── .github ├── dependabot.yaml ├── review-policy.yml └── workflows │ ├── build-deploy.yaml │ ├── lint-test.yaml │ ├── main.yaml │ ├── sentry_release.yaml │ └── 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 │ ├── advent_of_code │ │ ├── __init__.py │ │ ├── _caches.py │ │ ├── _cog.py │ │ ├── _helpers.py │ │ ├── about.json │ │ └── views │ │ │ └── dayandstarview.py │ ├── blurple_formatter.py │ ├── code_jams │ │ ├── __init__.py │ │ ├── _cog.py │ │ ├── _creation_utils.py │ │ ├── _flows.py │ │ └── _views.py │ ├── core │ │ ├── __init__.py │ │ ├── error_handler.py │ │ └── help.py │ ├── games.py │ ├── miscellaneous.py │ ├── pep.py │ ├── ping.py │ ├── smart_eval │ │ ├── README.md │ │ ├── __init__.py │ │ ├── _cog.py │ │ └── _smart_eval_rules.py │ ├── source.py │ └── summer_aoc.py ├── log.py └── utils │ ├── __init__.py │ ├── blurple_formatter.py │ ├── checks.py │ ├── decorators.py │ ├── exceptions.py │ ├── members.py │ ├── pagination.py │ ├── time.py │ └── uwu.py ├── docker-compose.yml ├── poetry.lock ├── pyproject.toml ├── sir_robin_banner.png └── tests ├── __init__.py ├── _autospec.py ├── helpers.py └── test_code_jam.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude everything 2 | * 3 | 4 | # Make exceptions for what's needed 5 | !bot 6 | !pyproject.toml 7 | !poetry.lock 8 | !LICENSE 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | groups: 8 | docker-dependencies: 9 | patterns: 10 | - "*" 11 | 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | groups: 17 | ci-dependencies: 18 | patterns: 19 | - "*" 20 | -------------------------------------------------------------------------------- /.github/review-policy.yml: -------------------------------------------------------------------------------- 1 | policy: 2 | approval: 3 | - and: 4 | - core dev or events lead 5 | - staff or contributor 6 | - devops 7 | - devops (manual) 8 | - do not merge 9 | 10 | # Rules for disapproving 11 | disapproval: 12 | options: 13 | # Both disapproving and approving should be handled through the GitHub UI 14 | methods: 15 | disapprove: 16 | github_review: true 17 | 18 | revoke: 19 | github_review: true 20 | 21 | # Any python-discord organisation member can leave a disapproving review 22 | requires: 23 | organizations: ["python-discord"] 24 | 25 | 26 | approval_rules: 27 | - name: core dev or events lead 28 | description: One approval from a Python Discord Core Developer or the Events Lead 29 | requires: 30 | count: 1 31 | users: 32 | - "janine9vn" 33 | teams: 34 | - "python-discord/core-developers" 35 | options: 36 | ignore_update_merges: true 37 | - name: staff or contributor 38 | description: Two members of the staff or contributors team must leave an approval 39 | requires: 40 | count: 2 41 | organizations: ["python-discord"] 42 | users: 43 | - ByteCommander 44 | - mathsman5133 45 | - slushiegoose 46 | - F4zii 47 | - kingdom5500 48 | - hedyhli 49 | - Refisio 50 | - riffautae 51 | - doublevcodes 52 | options: 53 | ignore_update_merges: true 54 | - name: devops 55 | description: If CI or Dockerfiles are changed then the DevOps team must be requested for a review 56 | requires: 57 | count: 1 58 | teams: 59 | - "python-discord/devops" 60 | if: 61 | changed_files: 62 | paths: 63 | - ".github/workflows/*" 64 | - "Dockerfile" 65 | options: 66 | request_review: 67 | enabled: true 68 | mode: teams 69 | ignore_update_merges: true 70 | - name: devops (manual) 71 | description: 'A manual request with the "review: needs devops" label' 72 | requires: 73 | count: 1 74 | teams: 75 | - "python-discord/devops" 76 | if: 77 | has_labels: 78 | - "review: needs devops" 79 | options: 80 | request_review: 81 | enabled: true 82 | mode: teams 83 | ignore_update_merges: true 84 | - name: do not merge 85 | description: "If the 'review: do not merge' tag is applied, merging is blocked" 86 | requires: 87 | count: 1 88 | users: ["ghost"] # We need a rule that cannot complete 89 | if: 90 | has_labels: ["review: do not merge"] 91 | -------------------------------------------------------------------------------- /.github/workflows/build-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | sha-tag: 7 | description: "A short-form SHA tag for the commit that triggered this flow" 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | build: 13 | name: Build & Push 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Login to Github Container Registry 24 | uses: docker/login-action@v3 25 | with: 26 | registry: ghcr.io 27 | username: ${{ github.repository_owner }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | # Build and push the container to the GitHub Container 31 | # Repository. The container will be tagged as "latest" 32 | # and with the short SHA of the commit. 33 | - name: Build and push 34 | uses: docker/build-push-action@v6 35 | with: 36 | context: . 37 | file: ./Dockerfile 38 | push: true 39 | cache-from: type=registry,ref=ghcr.io/python-discord/sir-robin:latest 40 | cache-to: type=inline 41 | tags: | 42 | ghcr.io/python-discord/sir-robin:latest 43 | ghcr.io/python-discord/sir-robin:${{ inputs.sha-tag }} 44 | build-args: | 45 | git_sha=${{ github.sha }} 46 | 47 | deploy: 48 | needs: build 49 | name: Deploy 50 | runs-on: ubuntu-latest 51 | environment: production 52 | steps: 53 | - name: Checkout code 54 | uses: actions/checkout@v4 55 | with: 56 | repository: python-discord/infra 57 | path: infra 58 | 59 | - uses: azure/setup-kubectl@v4 60 | 61 | - name: Authenticate with Kubernetes 62 | uses: azure/k8s-set-context@v4 63 | with: 64 | method: kubeconfig 65 | kubeconfig: ${{ secrets.KUBECONFIG }} 66 | 67 | - name: Deploy to Kubernetes 68 | uses: Azure/k8s-deploy@v5 69 | with: 70 | namespace: bots 71 | manifests: | 72 | infra/kubernetes/namespaces/bots/sir-robin/deployment.yaml 73 | images: 'ghcr.io/python-discord/sir-robin:${{ inputs.sha-tag }}' 74 | -------------------------------------------------------------------------------- /.github/workflows/lint-test.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_call 5 | 6 | jobs: 7 | lint: 8 | name: Run linting & tests 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Install Python Dependencies 12 | uses: HassanAbouelela/actions/setup-python@setup-python_v1.6.0 13 | with: 14 | python_version: '3.12' 15 | install_args: "--only main --only lint --only test" 16 | 17 | # Attempt to run the bot. Setting `IN_CI` to true, so bot.run() is never called. 18 | # This is to catch import and cog setup errors that may appear in PRs, to avoid crash loops if merged. 19 | - name: Attempt bot setup 20 | run: "python -m bot" 21 | env: 22 | REDIS_USE_FAKEREDIS: true 23 | BOT_IN_CI: true 24 | BOT_TOKEN: hunter2 25 | 26 | - name: Run tests 27 | run: pytest --disable-warnings -q 28 | env: 29 | BOT_TOKEN: hunter2 30 | 31 | - name: Run pre-commit hooks 32 | run: SKIP=ruff pre-commit run --all-files 33 | 34 | # Run `ruff` using github formatting to enable automatic inline annotations. 35 | - name: Run ruff 36 | run: "ruff check --output-format=github ." 37 | 38 | # Prepare the Pull Request Payload artifact. If this fails, we 39 | # we fail silently using the `continue-on-error` option. It's 40 | # nice if this succeeds, but if it fails for any reason, it 41 | # does not mean that our lint checks failed. 42 | - name: Prepare Pull Request Payload artifact 43 | id: prepare-artifact 44 | if: always() && github.event_name == 'pull_request' 45 | continue-on-error: true 46 | run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json 47 | 48 | # This only makes sense if the previous step succeeded. To 49 | # get the original outcome of the previous step before the 50 | # `continue-on-error` conclusion is applied, we use the 51 | # `.outcome` value. This step also fails silently. 52 | - name: Upload a Build Artifact 53 | if: always() && steps.prepare-artifact.outcome == 'success' 54 | continue-on-error: true 55 | uses: actions/upload-artifact@v4 56 | with: 57 | name: pull-request-payload 58 | path: pull_request_payload.json 59 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | 14 | lint-test: 15 | uses: ./.github/workflows/lint-test.yaml 16 | 17 | generate-inputs: 18 | if: github.ref == 'refs/heads/main' 19 | runs-on: ubuntu-latest 20 | outputs: 21 | sha-tag: ${{ steps.sha-tag.outputs.sha-tag }} 22 | steps: 23 | - name: Create SHA Container Tag 24 | id: sha-tag 25 | run: | 26 | tag=$(cut -c 1-7 <<< $GITHUB_SHA) 27 | echo "sha-tag=$tag" >> $GITHUB_OUTPUT 28 | 29 | build-deploy: 30 | if: github.ref == 'refs/heads/main' 31 | uses: ./.github/workflows/build-deploy.yaml 32 | needs: 33 | - lint-test 34 | - generate-inputs 35 | with: 36 | sha-tag: ${{ needs.generate-inputs.outputs.sha-tag }} 37 | secrets: inherit 38 | 39 | sentry-release: 40 | if: github.ref == 'refs/heads/main' 41 | uses: ./.github/workflows/sentry_release.yaml 42 | needs: build-deploy 43 | secrets: inherit 44 | -------------------------------------------------------------------------------- /.github/workflows/sentry_release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Sentry release 2 | 3 | on: 4 | workflow_call 5 | 6 | 7 | jobs: 8 | create_sentry_release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Create a Sentry.io release 15 | uses: getsentry/action-release@v1 16 | env: 17 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 18 | SENTRY_ORG: python-discord 19 | SENTRY_PROJECT: sir-robin 20 | with: 21 | environment: production 22 | version_prefix: sir-robin@ 23 | -------------------------------------------------------------------------------- /.github/workflows/status_embed.yaml: -------------------------------------------------------------------------------- 1 | name: Status Embed 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - CI 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 | name: Send Status Embed to Discord 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | # A workflow_run event does not contain all the information 21 | # we need for a PR embed. That's why we upload an artifact 22 | # with that information in the Lint workflow. 23 | - name: Get Pull Request Information 24 | id: pr_info 25 | if: github.event.workflow_run.event == 'pull_request' 26 | run: | 27 | curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json 28 | DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') 29 | [ -z "$DOWNLOAD_URL" ] && exit 1 30 | curl -sSL -H "Authorization: token $GITHUB_TOKEN" -o pull_request_payload.zip $DOWNLOAD_URL || exit 2 31 | unzip -p pull_request_payload.zip > pull_request_payload.json 32 | [ -s pull_request_payload.json ] || exit 3 33 | echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 34 | echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 35 | echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 36 | echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | # Send an informational status embed to Discord instead of the 41 | # standard embeds that Discord sends. This embed will contain 42 | # more information and we can fine tune when we actually want 43 | # to send an embed. 44 | - name: GitHub Actions Status Embed for Discord 45 | uses: SebastiaanZ/github-status-embed-for-discord@v0.3.0 46 | with: 47 | # Our GitHub Actions webhook 48 | webhook_id: '784184528997842985' 49 | webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} 50 | 51 | # We need to provide the information of the workflow that 52 | # triggered this workflow instead of this workflow. 53 | workflow_name: ${{ github.event.workflow_run.name }} 54 | run_id: ${{ github.event.workflow_run.id }} 55 | run_number: ${{ github.event.workflow_run.run_number }} 56 | status: ${{ github.event.workflow_run.conclusion }} 57 | sha: ${{ github.event.workflow_run.head_sha }} 58 | 59 | pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} 60 | pr_number: ${{ steps.pr_info.outputs.pr_number }} 61 | pr_title: ${{ steps.pr_info.outputs.pr_title }} 62 | pr_source: ${{ steps.pr_info.outputs.pr_source }} 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # jetbrains 132 | .idea/ 133 | .DS_Store 134 | 135 | # vscode 136 | .vscode/ 137 | *.code-workspace 138 | .devcontainer 139 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: check-toml 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | args: [--markdown-linebreak-ext=md] 11 | 12 | - repo: local 13 | hooks: 14 | - id: ruff 15 | name: ruff 16 | description: Run ruff linting 17 | entry: poetry run ruff check --force-exclude 18 | language: system 19 | 'types_or': [python, pyi] 20 | require_serial: true 21 | args: [--fix, --exit-non-zero-on-fix] 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 ghcr.io/owl-corp/python-poetry-base:3.12-slim 2 | 3 | # Install dependencies 4 | WORKDIR /bot 5 | COPY pyproject.toml poetry.lock ./ 6 | RUN poetry install --without dev 7 | 8 | # Set SHA build argument 9 | ARG git_sha="development" 10 | ENV GIT_SHA=$git_sha 11 | 12 | # Copy the rest of the project code 13 | COPY . . 14 | 15 | # Start the bot 16 | ENTRYPOINT ["poetry", "run"] 17 | CMD ["python", "-m", "bot"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Python Discord 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 | # Sir Robin 2 | [![Discord][5]][6] 3 | [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) 4 | 5 | ![Header](sir_robin_banner.png) 6 | 7 | Our event bot, for managing community events. 8 | 9 | [5]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg 10 | [6]: https://discord.gg/python 11 | -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from typing import TYPE_CHECKING 4 | 5 | from pydis_core.utils import apply_monkey_patches 6 | 7 | from bot.log import setup_logging 8 | 9 | if TYPE_CHECKING: 10 | from bot.bot import SirRobin 11 | 12 | # On Windows, the selector event loop is required for aiodns. 13 | if os.name == "nt": 14 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 15 | 16 | 17 | setup_logging() 18 | 19 | # Apply all monkey patches from bot core. 20 | apply_monkey_patches() 21 | 22 | instance: "SirRobin" = None # Global SirRobin instance. 23 | -------------------------------------------------------------------------------- /bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | import discord 5 | from async_rediscache import RedisSession 6 | from pydis_core import StartupError 7 | from pydis_core.utils.logging import get_logger 8 | from redis import RedisError 9 | 10 | import bot 11 | from bot import constants 12 | from bot.bot import SirRobin 13 | from bot.log import setup_sentry 14 | 15 | log = get_logger(__name__) 16 | setup_sentry() 17 | 18 | 19 | async def _create_redis_session() -> RedisSession: 20 | """Create and connect to a redis session.""" 21 | redis_session = RedisSession( 22 | host=constants.RedisConfig.host, 23 | port=constants.RedisConfig.port, 24 | password=constants.RedisConfig.password, 25 | max_connections=20, 26 | use_fakeredis=constants.RedisConfig.use_fakeredis, 27 | global_namespace="sir-robin", 28 | decode_responses=True, 29 | ) 30 | try: 31 | return await redis_session.connect() 32 | except RedisError as e: 33 | raise StartupError(e) 34 | 35 | 36 | if not constants.Bot.in_ci: 37 | async def main() -> None: 38 | """Entry Async method for starting the bot.""" 39 | # Default is all intents except for privileged ones (Members, Presences, ...) 40 | _intents = discord.Intents.default() 41 | _intents.bans = False 42 | _intents.integrations = False 43 | _intents.invites = False 44 | _intents.typing = False 45 | _intents.webhooks = False 46 | _intents.message_content = True 47 | _intents.members = True 48 | 49 | allowed_roles = ( 50 | constants.Roles.events_lead, 51 | constants.Roles.code_jam_event_team, 52 | constants.Roles.code_jam_participants 53 | ) 54 | async with aiohttp.ClientSession() as session: 55 | bot.instance = SirRobin( 56 | redis_session=await _create_redis_session(), 57 | http_session=session, 58 | guild_id=constants.Bot.guild, 59 | allowed_roles=allowed_roles, 60 | command_prefix=constants.Bot.prefix, 61 | activity=discord.Game("The Not-Quite-So-Bot-as-Sir-Lancebot"), 62 | intents=_intents, 63 | ) 64 | async with bot.instance: 65 | await bot.instance.start(constants.Bot.token) 66 | 67 | asyncio.run(main()) 68 | -------------------------------------------------------------------------------- /bot/bot.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from sys import exception 3 | 4 | import discord 5 | from discord.ext import commands 6 | from pydis_core import BotBase 7 | from pydis_core.site_api import APIClient 8 | from pydis_core.utils.error_handling import handle_forbidden_from_block 9 | from pydis_core.utils.logging import get_logger 10 | from pydis_core.utils.scheduling import create_task 11 | from sentry_sdk import new_scope 12 | 13 | from bot import constants, exts 14 | from bot.exts.code_jams._views import JamTeamInfoView 15 | 16 | log = get_logger(__name__) 17 | 18 | 19 | class SirRobin(BotBase): 20 | """Sir-Robin core.""" 21 | 22 | def __init__(self, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | 25 | self.code_jam_mgmt_api: APIClient | None = None 26 | 27 | async def close(self) -> None: 28 | """On close, cleanly close the aiohttp client session.""" 29 | await super().close() 30 | await self.code_jam_mgmt_api.close() 31 | 32 | async def setup_hook(self) -> None: 33 | """Default Async initialisation method for Discord.py.""" 34 | self.code_jam_mgmt_api = APIClient( 35 | site_api_url=constants.Codejam.api, 36 | site_api_token=constants.Codejam.api_key 37 | ) 38 | await super().setup_hook() 39 | await self.load_extensions(exts) 40 | create_task(self.check_channels()) 41 | create_task(self.send_log(constants.Bot.name, "Connected!")) 42 | self.add_view(JamTeamInfoView(self)) 43 | 44 | async def check_channels(self) -> None: 45 | """Verifies that all channel constants refer to channels which exist.""" 46 | await self.wait_until_guild_available() 47 | 48 | if constants.Bot.debug: 49 | log.info("Skipping Channels Check.") 50 | return 51 | 52 | all_channels_ids = [channel.id for channel in self.get_all_channels()] 53 | for name, channel_id in vars(constants.Channels).items(): 54 | if name.startswith("_"): 55 | continue 56 | if channel_id not in all_channels_ids: 57 | log.error(f'Channel "{name}" with ID {channel_id} missing') 58 | 59 | async def send_log(self, title: str, details: str | None = None, *, icon: str | None = None) -> None: 60 | """Send an embed message to the devlog channel.""" 61 | await self.wait_until_guild_available() 62 | devlog = self.get_channel(constants.Channels.devlog) 63 | 64 | if not devlog: 65 | log.info(f"Fetching devlog channel as it wasn't found in the cache (ID: {constants.Channels.devlog})") 66 | try: 67 | devlog = await self.fetch_channel(constants.Channels.devlog) 68 | except discord.HTTPException as discord_exc: 69 | log.exception("Fetch failed", exc_info=discord_exc) 70 | return 71 | 72 | if not icon: 73 | icon = self.user.display_avatar.url 74 | 75 | embed = discord.Embed(description=details) 76 | embed.set_author(name=title, icon_url=icon) 77 | 78 | await devlog.send(embed=embed) 79 | 80 | async def invoke_help_command(self, ctx: commands.Context) -> None: 81 | """Invoke the help command or default help command if help extensions is not loaded.""" 82 | if "bot.exts.core.help" in ctx.bot.extensions: 83 | help_command = ctx.bot.get_command("help") 84 | await ctx.invoke(help_command, ctx.command.qualified_name) 85 | return 86 | await ctx.send_help(ctx.command) 87 | 88 | async def on_error(self, event: str, *args, **kwargs) -> None: 89 | """Log errors raised in event listeners rather than printing them to stderr.""" 90 | e_val = exception() 91 | 92 | if isinstance(e_val, discord.errors.Forbidden): 93 | message = args[0] if event == "on_message" else args[1] if event == "on_message_edit" else None 94 | 95 | with contextlib.suppress(discord.errors.Forbidden): 96 | # Attempt to handle the error. This re-raises the error if's not due to a block, 97 | # in which case the error is suppressed and handled normally. Otherwise, it was 98 | # handled so return. 99 | await handle_forbidden_from_block(e_val, message) 100 | return 101 | 102 | self.stats.incr(f"errors.event.{event}") 103 | 104 | with new_scope() as scope: 105 | scope.set_tag("event", event) 106 | scope.set_extra("args", args) 107 | scope.set_extra("kwargs", kwargs) 108 | 109 | log.exception(f"Unhandled exception in {event}.") 110 | -------------------------------------------------------------------------------- /bot/constants.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import enum 3 | from datetime import UTC, datetime 4 | from os import environ 5 | 6 | from pydantic_settings import BaseSettings 7 | from pydis_core.utils import logging 8 | 9 | log = logging.get_logger(__name__) 10 | 11 | 12 | class EnvConfig( 13 | BaseSettings, 14 | env_file=".env", 15 | env_file_encoding="utf-8", 16 | env_nested_delimiter="__", 17 | extra="ignore", 18 | ): 19 | """Our default configuration for models that should load from .env files.""" 20 | 21 | 22 | @dataclasses.dataclass 23 | class AdventOfCodeLeaderboard: 24 | """Config required for a since AoC leaderboard.""" 25 | 26 | id: str 27 | _session: str 28 | join_code: str 29 | 30 | # If we notice that the session for this board expired, we set 31 | # this attribute to `True`. We will emit a Sentry error so we 32 | # can handle it, but, in the meantime, we'll try using the 33 | # fallback session to make sure the commands still work. 34 | use_fallback_session: bool = False 35 | 36 | @property 37 | def session(self) -> str: 38 | """Return either the actual `session` cookie or the fallback cookie.""" 39 | if self.use_fallback_session: 40 | log.trace(f"Returning fallback cookie for board `{self.id}`.") 41 | return AdventOfCode.fallback_session 42 | 43 | return self._session 44 | 45 | 46 | class _AdventOfCode(EnvConfig, env_prefix="AOC_"): 47 | @staticmethod 48 | def _parse_aoc_leaderboard_env() -> dict[str, AdventOfCodeLeaderboard]: 49 | """ 50 | Parse the environment variable containing leaderboard information. 51 | 52 | A leaderboard should be specified in the format `id,session,join_code`, 53 | without the backticks. If more than one leaderboard needs to be added to 54 | the constant, separate the individual leaderboards with `::`. 55 | 56 | Example ENV: `id1,session1,join_code1::id2,session2,join_code2` 57 | """ 58 | raw_leaderboards = environ.get("AOC_RAW_LEADERBOARDS", "") 59 | if not raw_leaderboards: 60 | return {} 61 | 62 | leaderboards = {} 63 | for leaderboard in raw_leaderboards.split("::"): 64 | leaderboard_id, session, join_code = leaderboard.split(",") 65 | leaderboards[leaderboard_id] = AdventOfCodeLeaderboard(leaderboard_id, session, join_code) 66 | 67 | return leaderboards 68 | # Information for the several leaderboards we have 69 | leaderboards: dict[str, AdventOfCodeLeaderboard] = _parse_aoc_leaderboard_env() 70 | 71 | staff_leaderboard_id: str | None = None 72 | fallback_session: str | None = None 73 | 74 | ignored_days: tuple[int, ...] | None = None 75 | leaderboard_displayed_members: int = 10 76 | leaderboard_cache_expiry_seconds: int = 1800 77 | max_day_and_star_results: int = 15 78 | year: int = datetime.now(tz=UTC).year 79 | 80 | 81 | AdventOfCode = _AdventOfCode() 82 | 83 | 84 | class _Channels(EnvConfig, env_prefix="CHANNEL_"): 85 | advent_of_code: int = 897932085766004786 86 | advent_of_code_commands: int = 897932607545823342 87 | bot_commands: int = 267659945086812160 88 | devlog: int = 622895325144940554 89 | code_jam_planning: int = 490217981872177157 90 | summer_aoc_main: int = 988979042847957042 91 | summer_aoc_discussion: int = 996438901331861554 92 | sir_lancebot_playground: int = 607247579608121354 93 | summer_code_jam_announcements: int = 988765608172736542 94 | off_topic_0: int = 291284109232308226 95 | off_topic_1: int = 463035241142026251 96 | off_topic_2: int = 463035268514185226 97 | voice_chat_0: int = 412357430186344448 98 | voice_chat_1: int = 799647045886541885 99 | roles: int = 851270062434156586 100 | 101 | 102 | Channels = _Channels() 103 | 104 | 105 | class _Categories(EnvConfig, env_prefix="CATEGORY_"): 106 | summer_code_jam: int = 987738098525937745 107 | 108 | 109 | Categories = _Categories() 110 | 111 | 112 | class Month(enum.IntEnum): 113 | """ 114 | Enum lookup between Months & month numbers. 115 | 116 | Can bre replaced with the below when upgrading to 3.12 117 | https://docs.python.org/3/library/calendar.html#calendar.Month 118 | """ 119 | 120 | JANUARY = 1 121 | FEBRUARY = 2 122 | MARCH = 3 123 | APRIL = 4 124 | MAY = 5 125 | JUNE = 6 126 | JULY = 7 127 | AUGUST = 8 128 | SEPTEMBER = 9 129 | OCTOBER = 10 130 | NOVEMBER = 11 131 | DECEMBER = 12 132 | 133 | def __str__(self) -> str: 134 | return self.name.title() 135 | 136 | 137 | class _Bot(EnvConfig, env_prefix="BOT_"): 138 | name: str = "Sir Robin" 139 | guild: int = 267624335836053506 140 | prefix: str = "&" 141 | token: str 142 | debug: bool = True 143 | trace_logging: bool = False 144 | in_ci: bool = False 145 | github_bot_repo: str = "https://github.com/python-discord/sir-robin" 146 | # Override seasonal locks: 1 (January) to 12 (December) 147 | month_override: Month | None = None 148 | sentry_dsn: str = "" 149 | 150 | 151 | Bot = _Bot() 152 | 153 | 154 | class _Codejam(EnvConfig, env_prefix="CODE_JAM_"): 155 | api: str = "http://code-jam-management.apis.svc.cluster.local:8000" 156 | api_key: str = "badbot13m0n8f570f942013fc818f234916ca531" 157 | 158 | 159 | Codejam = _Codejam() 160 | 161 | 162 | class _Emojis(EnvConfig, env_prefix="EMOJI_"): 163 | check_mark: str = "\u2705" 164 | envelope: str = "\U0001F4E8" 165 | trashcan: str = "<:trashcan:637136429717389331>" 166 | star: str = "\u2B50" 167 | christmas_tree: str = "\U0001F384" 168 | team_tuple: str = "<:team_tuple:1224089419003334768>" 169 | team_list: str = "<:team_list:1224089544257962134>" 170 | team_dict: str = "<:team_dict:1224089495373353021>" 171 | 172 | 173 | Emojis = _Emojis() 174 | 175 | 176 | class _Roles(EnvConfig, env_prefix="ROLE_"): 177 | admins: int = 267628507062992896 178 | advent_of_code: int = 518565788744024082 179 | code_jam_event_team: int = 787816728474288181 180 | events_lead: int = 778361735739998228 181 | event_runner: int = 940911658799333408 182 | summer_aoc: int = 988801794668908655 183 | code_jam_participants: int = 991678713093705781 184 | code_jam_support: int = 1254657197535920141 185 | helpers: int = 267630620367257601 186 | aoc_completionist: int = 1191547731873894440 187 | bots: int = 277546923144249364 188 | moderation_team: int = 267629731250176001 189 | 190 | team_list: int = 1222691191582097418 191 | team_dict: int = 1222691368653033652 192 | team_tuple: int = 1222691399246286888 193 | 194 | 195 | Roles = _Roles() 196 | 197 | 198 | class _RedisConfig(EnvConfig, env_prefix="REDIS_"): 199 | host: str = "redis.databases.svc.cluster.local" 200 | port: int = 6379 201 | password: str | None = None 202 | use_fakeredis: bool = False 203 | 204 | 205 | RedisConfig = _RedisConfig() 206 | 207 | 208 | class Colours: 209 | """Colour hex values commonly used throughout the bot.""" 210 | 211 | blue = 0x0279FD 212 | twitter_blue = 0x1DA1F2 213 | bright_green = 0x01D277 214 | dark_green = 0x1F8B4C 215 | orange = 0xE67E22 216 | pink = 0xCF84E0 217 | purple = 0xB734EB 218 | soft_green = 0x68C290 219 | soft_orange = 0xF9CB54 220 | soft_red = 0xCD6D6D 221 | yellow = 0xF9F586 222 | python_blue = 0x4B8BBE 223 | python_yellow = 0xFFD43B 224 | grass_green = 0x66FF00 225 | gold = 0xE6C200 226 | 227 | 228 | # Git SHA for Sentry 229 | GIT_SHA = environ.get("GIT_SHA", "development") 230 | 231 | 232 | # Whitelisted channels 233 | WHITELISTED_CHANNELS = ( 234 | Channels.bot_commands, 235 | Channels.sir_lancebot_playground, 236 | Channels.off_topic_0, 237 | Channels.off_topic_1, 238 | Channels.off_topic_2, 239 | Channels.voice_chat_0, 240 | Channels.voice_chat_1, 241 | ) 242 | 243 | # Bot replies 244 | ERROR_REPLIES = ( 245 | "Please don't do that.", 246 | "You have to stop.", 247 | "Do you mind?", 248 | "In the future, don't do that.", 249 | "That was a mistake.", 250 | "You blew it.", 251 | "You're bad at computers.", 252 | "Are you trying to kill me?", 253 | "Noooooo!!", 254 | "I can't believe you've done this", 255 | ) 256 | 257 | NEGATIVE_REPLIES = ( 258 | "Noooooo!!", 259 | "Nope.", 260 | "I'm sorry Dave, I'm afraid I can't do that.", 261 | "I don't think so.", 262 | "Not gonna happen.", 263 | "Out of the question.", 264 | "Huh? No.", 265 | "Nah.", 266 | "Naw.", 267 | "Not likely.", 268 | "No way, José.", 269 | "Not in a million years.", 270 | "Fat chance.", 271 | "Certainly not.", 272 | "NEGATORY.", 273 | "Nuh-uh.", 274 | "Not in my house!", 275 | ) 276 | 277 | POSITIVE_REPLIES = ( 278 | "Yep.", 279 | "Absolutely!", 280 | "Can do!", 281 | "Affirmative!", 282 | "Yeah okay.", 283 | "Sure.", 284 | "Sure thing!", 285 | "You're the boss!", 286 | "Okay.", 287 | "No problem.", 288 | "I got you.", 289 | "Alright.", 290 | "You got it!", 291 | "ROGER THAT", 292 | "Of course!", 293 | "Aye aye, cap'n!", 294 | "I'll allow it.", 295 | ) 296 | -------------------------------------------------------------------------------- /bot/converters.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | from discord.ext.commands import BadArgument, Context, Converter 3 | from discord.utils import escape_markdown 4 | 5 | SourceType = commands.HelpCommand | commands.Command | commands.Cog | str | commands.ExtensionNotLoaded 6 | 7 | 8 | class SourceConverter(Converter): 9 | """Convert an argument into a help command, command, or cog.""" 10 | 11 | @staticmethod 12 | async def convert(ctx: Context, argument: str) -> SourceType: 13 | """Convert argument into source object.""" 14 | if argument.lower() == "help": 15 | return ctx.bot.help_command 16 | 17 | if cog := ctx.bot.get_cog(argument): 18 | return cog 19 | 20 | if cmd := ctx.bot.get_command(argument): 21 | return cmd 22 | 23 | escaped_arg = escape_markdown(argument) 24 | 25 | raise BadArgument( 26 | f"Unable to convert '{escaped_arg}' to valid command or Cog." 27 | ) 28 | -------------------------------------------------------------------------------- /bot/exts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-robin/2efd8585987ce710577df1789aac10b45d5f7352/bot/exts/__init__.py -------------------------------------------------------------------------------- /bot/exts/advent_of_code/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import SirRobin 2 | 3 | 4 | async def setup(bot: SirRobin) -> None: 5 | """Set up the Advent of Code extension.""" 6 | # Import the Cog at runtime to prevent side effects like defining 7 | # RedisCache instances too early. 8 | from ._cog import AdventOfCode 9 | 10 | await bot.add_cog(AdventOfCode(bot)) 11 | -------------------------------------------------------------------------------- /bot/exts/advent_of_code/_caches.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import async_rediscache 4 | 5 | 6 | class AoCSettingOption(Enum): 7 | COMPLETIONIST_ENABLED = "completionist_enabled" 8 | 9 | 10 | # How many people are in each leaderboard 11 | # RedisCache[leaderboard_id, int] 12 | leaderboard_counts = async_rediscache.RedisCache(namespace="AOC_leaderboard_counts") 13 | 14 | # Cache of data from the AoC website 15 | # See _helpers.fetch_leaderboard for the structure of this RedisCache 16 | leaderboard_cache = async_rediscache.RedisCache(namespace="AOC_leaderboard_cache") 17 | 18 | # Which leaderboard each user is in 19 | # RedisCache[member_id, leaderboard_id] 20 | assigned_leaderboard = async_rediscache.RedisCache(namespace="AOC_assigned_leaderboard") 21 | 22 | # Linking Discord IDs to Advent of Code usernames 23 | # RedisCache[member_id, aoc_username_string] 24 | account_links = async_rediscache.RedisCache(namespace="AOC_account_links") 25 | 26 | # Member IDs that are blocked from receiving the AoC completionist role 27 | # RedisCache[member_id, sentinel_value] 28 | completionist_block_list = async_rediscache.RedisCache(namespace="AOC_completionist_block_list") 29 | 30 | # AoC settings cache 31 | # RedisCache[AoCSettingOption, bool] 32 | aoc_settings = async_rediscache.RedisCache(namespace="AOC_settings") 33 | -------------------------------------------------------------------------------- /bot/exts/advent_of_code/about.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "What is Advent of Code?", 4 | "value": "Advent of Code (AoC) is a series of small programming puzzles for a variety of skill levels, run every year during the month of December.\n\nThey are self-contained and are just as appropriate for an expert who wants to stay sharp as they are for a beginner who is just learning to code. Each puzzle calls upon different skills and has two parts that build on a theme.", 5 | "inline": false 6 | }, 7 | { 8 | "name": "How do I sign up?", 9 | "value": "Sign up with one of these services:", 10 | "inline": true 11 | }, 12 | { 13 | "name": "Auth Services", 14 | "value": "GitHub\nGoogle\nTwitter\nReddit", 15 | "inline": true 16 | }, 17 | { 18 | "name": "How does scoring work?", 19 | "value": "For the [global leaderboard](https://adventofcode.com/leaderboard), the first person to get a star first gets 100 points, the second person gets 99 points, and so on down to 1 point at 100th place.\n\nFor private leaderboards, the first person to get a star gets N points, where N is the number of people on the leaderboard. The second person to get the star gets N-1 points and so on and so forth.", 20 | "inline": false 21 | }, 22 | { 23 | "name": "Join our private leaderboard!", 24 | "value": "Come join the Python Discord private leaderboard and compete against other people in the community! Get the join code using `.aoc join` and visit the [private leaderboard page](https://adventofcode.com/leaderboard/private) to join our leaderboard.", 25 | "inline": false 26 | } 27 | ] 28 | -------------------------------------------------------------------------------- /bot/exts/advent_of_code/views/dayandstarview.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | import discord 4 | 5 | AOC_DAY_AND_STAR_TEMPLATE = "{rank: >4} | {name:25.25} | {completion_time: >10}" 6 | 7 | 8 | class AoCDropdownView(discord.ui.View): 9 | """Interactive view to filter AoC stats by Day and Star.""" 10 | 11 | def __init__(self, original_author: discord.Member, day_and_star_data: dict[str: dict], maximum_scorers: int): 12 | super().__init__() 13 | self.day = 0 14 | self.star = 0 15 | self.data = day_and_star_data 16 | self.maximum_scorers = maximum_scorers 17 | self.original_author = original_author 18 | 19 | def generate_output(self) -> str: 20 | """ 21 | Generates a formatted codeblock with AoC statistics based on the currently selected day and star. 22 | 23 | Optionally, when the requested day and star data does not exist yet it returns an error message. 24 | """ 25 | header = AOC_DAY_AND_STAR_TEMPLATE.format( 26 | rank="Rank", 27 | name="Name", completion_time="Completion time (UTC)" 28 | ) 29 | lines = [f"{header}\n{'-' * (len(header) + 2)}"] 30 | if not (day_and_star_data := self.data.get(f"{self.day}-{self.star}")): 31 | return ":x: The requested data for the specified day and star does not exist yet." 32 | for rank, scorer in enumerate(day_and_star_data[:self.maximum_scorers]): 33 | time_data = datetime.fromtimestamp(scorer["completion_time"], tz=UTC).strftime("%I:%M:%S %p") 34 | lines.append(AOC_DAY_AND_STAR_TEMPLATE.format( 35 | datastamp="", 36 | rank=rank + 1, 37 | name=scorer["member_name"], 38 | completion_time=time_data) 39 | ) 40 | joined_lines = "\n".join(lines) 41 | return f"Statistics for Day: {self.day}, Star: {self.star}.\n ```\n{joined_lines}\n```" 42 | 43 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 44 | """Global check to ensure that the interacting user is the user who invoked the command originally.""" 45 | if interaction.user != self.original_author: 46 | await interaction.response.send_message( 47 | ":x: You can't interact with someone else's response. Please run the command yourself!", 48 | ephemeral=True 49 | ) 50 | return False 51 | return True 52 | 53 | @discord.ui.select( 54 | placeholder="Day", 55 | options=[discord.SelectOption(label=str(i)) for i in range(1, 26)], 56 | custom_id="day_select", 57 | ) 58 | async def day_select(self, interaction: discord.Interaction, select: discord.ui.Select) -> None: 59 | """Dropdown to choose a Day of the AoC.""" 60 | self.day = select.values[0] 61 | await interaction.response.defer() 62 | 63 | @discord.ui.select( 64 | placeholder="Star", 65 | options=[discord.SelectOption(label=str(i)) for i in range(1, 3)], 66 | custom_id="star_select", 67 | ) 68 | async def star_select(self, interaction: discord.Interaction, select: discord.ui.Select) -> None: 69 | """Dropdown to choose either the first or the second star.""" 70 | self.star = select.values[0] 71 | await interaction.response.defer() 72 | 73 | @discord.ui.button(label="Fetch", style=discord.ButtonStyle.blurple) 74 | async def fetch(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: 75 | """Button that fetches the statistics based on the dropdown values.""" 76 | if self.day == 0 or self.star == 0: 77 | await interaction.response.send_message( 78 | "You have to select a value from both of the dropdowns!", 79 | ephemeral=True 80 | ) 81 | else: 82 | await interaction.response.edit_message(content=self.generate_output()) 83 | self.day = 0 84 | self.star = 0 85 | -------------------------------------------------------------------------------- /bot/exts/blurple_formatter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | 4 | import discord 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | from pydis_core.utils.paste_service import PasteFile, PasteTooLongError, PasteUploadError, send_to_paste_service 8 | from pydis_core.utils.regex import FORMATTED_CODE_REGEX 9 | 10 | from bot.bot import SirRobin 11 | from bot.utils import blurple_formatter 12 | 13 | logger = get_logger() 14 | 15 | 16 | class BlurpleFormatter(commands.Cog): 17 | """Format code in accordance with PEP 9001.""" 18 | 19 | def __init__(self, bot: SirRobin): 20 | self.bot = bot 21 | self.lock = asyncio.Lock() 22 | 23 | @staticmethod 24 | def _format_code(code: str) -> str: 25 | blurpified = blurple_formatter.blurplify(code) 26 | blurpified = blurpified.replace("`", "`\u200d") 27 | return blurpified 28 | 29 | @commands.command(aliases=["blurp", "blurpify", "format"]) 30 | async def blurplify(self, ctx: commands.Context, *, code: str) -> None: 31 | """Format code in accordance with PEP 9001.""" 32 | if match := FORMATTED_CODE_REGEX.match(code): 33 | code = match.group("code") 34 | try: 35 | async with self.lock: 36 | blurpified = await asyncio.to_thread(self._format_code, code) 37 | except SyntaxError as e: 38 | err_info = "".join(traceback.format_exception_only(type(e), e)).replace("`", "`\u200d") 39 | embed = discord.Embed( 40 | title="Invalid Syntax!", 41 | description=f"```\n{err_info}\n```", 42 | color=0xCD6D6D, 43 | ) 44 | await ctx.send(embed=embed, allowed_mentions=discord.AllowedMentions.none()) 45 | return 46 | 47 | if len(blurpified) > 2000: 48 | paste_file = PasteFile(content=blurpified) 49 | try: 50 | paste = await send_to_paste_service( 51 | files=[paste_file], 52 | http_session=self.bot.http_session, 53 | ) 54 | except PasteUploadError: 55 | logger.exception("Generic upload error from paste service:") 56 | await ctx.send(":warning: Failed to upload full output") 57 | return 58 | except PasteTooLongError: 59 | await ctx.send(":warning: Failed to upload full output, too long for paste service.") 60 | return 61 | 62 | await ctx.send(f":white_check_mark: Formatted code too big, full output: {paste.link}") 63 | return 64 | 65 | await ctx.send(f"```py\n{blurpified}\n```", allowed_mentions=discord.AllowedMentions.none()) 66 | 67 | 68 | async def setup(bot: SirRobin) -> None: 69 | """Load the BlurpleFormatter cog.""" 70 | await bot.add_cog(BlurpleFormatter(bot)) 71 | -------------------------------------------------------------------------------- /bot/exts/code_jams/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from bot.bot import SirRobin 5 | 6 | 7 | async def setup(bot: "SirRobin") -> None: 8 | """Load the CodeJams cog.""" 9 | from bot.exts.code_jams._cog import CodeJams 10 | await bot.add_cog(CodeJams(bot)) 11 | -------------------------------------------------------------------------------- /bot/exts/code_jams/_cog.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from collections import defaultdict 3 | from functools import partial 4 | 5 | import discord 6 | from discord import Colour, Embed, Guild, Member 7 | from discord.ext import commands 8 | from pydis_core.site_api import APIClient, ResponseCodeError 9 | from pydis_core.utils.logging import get_logger 10 | from pydis_core.utils.members import get_or_fetch_member 11 | from pydis_core.utils.paste_service import PasteFile, PasteTooLongError, PasteUploadError, send_to_paste_service 12 | 13 | from bot.bot import SirRobin 14 | from bot.constants import Emojis, Roles 15 | from bot.exts.code_jams import _creation_utils 16 | from bot.exts.code_jams._flows import add_flow, creation_flow, deletion_flow, move_flow, pin_flow, remove_flow 17 | from bot.exts.code_jams._views import JamConfirmation, JamInfoView, JamTeamInfoConfirmation 18 | from bot.utils.checks import in_code_jam_category 19 | from bot.utils.decorators import with_role 20 | 21 | log = get_logger(__name__) 22 | PIN_ALLOWED_ROLES: tuple[int, ...] = (Roles.admins, Roles.code_jam_event_team) 23 | 24 | 25 | class CodeJams(commands.Cog): 26 | """Manages the code-jam related parts of our server.""" 27 | 28 | def __init__(self, bot: SirRobin): 29 | self.bot = bot 30 | 31 | @commands.group(aliases=("cj", "jam")) 32 | async def codejam(self, ctx: commands.Context) -> None: 33 | """A Group of commands for managing Code Jams.""" 34 | if ctx.invoked_subcommand is None: 35 | await ctx.send_help(ctx.command) 36 | 37 | @codejam.command() 38 | @commands.has_any_role(Roles.admins, Roles.events_lead) 39 | async def create(self, ctx: commands.Context, csv_file: str | None = None) -> None: 40 | """ 41 | Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. 42 | 43 | The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. 44 | 45 | This will create the text channels for the teams, and give the team leaders their roles. 46 | """ 47 | async with ctx.typing(): 48 | if csv_file: 49 | async with self.bot.http_session.get(csv_file) as response: 50 | if response.status != 200: 51 | await ctx.send(f"Got a bad response from the URL: {response.status}") 52 | return 53 | 54 | csv_file = await response.text() 55 | 56 | elif ctx.message.attachments: 57 | csv_file = (await ctx.message.attachments[0].read()).decode("utf8") 58 | else: 59 | raise commands.BadArgument("You must include either a CSV file or a link to one.") 60 | 61 | teams = defaultdict(list) 62 | reader = csv.DictReader(csv_file.splitlines()) 63 | 64 | for row in reader: 65 | member = await get_or_fetch_member(ctx.guild, int(row["Team Member Discord ID"])) 66 | 67 | if member is None: 68 | log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") 69 | continue 70 | 71 | teams[row["Team Name"]].append({"member": member, "is_leader": row["Team Leader"].upper() == "Y"}) 72 | warning_embed = Embed( 73 | colour=discord.Colour.orange(), 74 | title="Warning!", 75 | description=f"{len(teams)} teams and roles will be created, are you sure?" 76 | ) 77 | warning_embed.set_footer(text="Code Jam team generation") 78 | callback = partial(creation_flow, ctx, teams, self.bot) 79 | await ctx.send( 80 | embed=warning_embed, 81 | view=JamConfirmation(author=ctx.author, callback=callback) 82 | ) 83 | 84 | @codejam.command() 85 | @commands.has_any_role(Roles.admins, Roles.events_lead) 86 | async def announce(self, ctx: commands.Context) -> None: 87 | """A command to send an announcement embed to the CJ announcement channel.""" 88 | team_info_view = JamTeamInfoConfirmation(self.bot, ctx.guild, ctx.author) 89 | embed_conf = Embed(title="Would you like to announce the teams?", colour=discord.Colour.og_blurple()) 90 | await ctx.send( 91 | embed=embed_conf, 92 | view=team_info_view 93 | ) 94 | 95 | @codejam.command() 96 | @commands.has_any_role(Roles.admins, Roles.events_lead) 97 | async def end(self, ctx: commands.Context) -> None: 98 | """ 99 | Delete all code jam channels. 100 | 101 | A confirmation message is displayed with the categories and channels 102 | that are going to be deleted, by pressing "Confirm" the deletion 103 | process will begin. 104 | """ 105 | categories = self.jam_categories(ctx.guild) 106 | roles = await self.jam_roles(ctx.guild, self.bot.code_jam_mgmt_api) 107 | if not categories and not roles: 108 | await ctx.send(":x: The Code Jam channels and roles have already been deleted! ") 109 | return 110 | 111 | category_channels: dict[discord.CategoryChannel: list[discord.TextChannel]] = { 112 | category: category.channels.copy() for category in categories 113 | } 114 | 115 | details = "Categories and Channels: \n" 116 | for category, channels in category_channels.items(): 117 | details += f"{category.name}[{category.id}]: {','.join([channel.name for channel in channels])}\n" 118 | details += "Roles:\n" 119 | for role in roles: 120 | details += f"{role.name}[{role.id}]\n" 121 | paste_file = PasteFile(content=details) 122 | try: 123 | paste_response = await send_to_paste_service( 124 | files=[paste_file], 125 | http_session=self.bot.http_session, 126 | ) 127 | url = paste_response.link 128 | except PasteUploadError: 129 | log.exception("Generic upload error from paste service:") 130 | url = "**Unable to send deletion details to the pasting service.**" 131 | except PasteTooLongError: 132 | url = "**Unable to send deletion details to the pasting service, content too long**" 133 | warning_embed = Embed(title="Are you sure?", colour=discord.Colour.orange()) 134 | warning_embed.add_field( 135 | name="For a detailed list of which roles, categories and channels will be deleted see:", 136 | value=url 137 | ) 138 | callback = partial(deletion_flow, category_channels, roles) 139 | confirm_view = JamConfirmation(author=ctx.author, callback=callback) 140 | await ctx.send( 141 | embed=warning_embed, 142 | view=confirm_view 143 | ) 144 | await confirm_view.wait() 145 | await ctx.send("Code Jam has officially ended! :sunrise:") 146 | 147 | @codejam.command() 148 | @commands.has_any_role(Roles.admins, Roles.code_jam_event_team) 149 | async def info(self, ctx: commands.Context, member: Member) -> None: 150 | """ 151 | Send an info embed about the member with the team they're in. 152 | 153 | The team is found by issuing a request to the CJ Management System 154 | """ 155 | try: 156 | team = await self.bot.code_jam_mgmt_api.get( 157 | f"users/{member.id}/current_team", 158 | raise_for_status=True 159 | ) 160 | except ResponseCodeError as err: 161 | if err.response.status == 404: 162 | await ctx.send(":x: It seems like the user is not a participant!") 163 | else: 164 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 165 | log.error(f"Something went wrong with processing the request! {err}") 166 | else: 167 | embed = Embed( 168 | title=str(member), 169 | colour=Colour.og_blurple() 170 | ) 171 | embed.add_field(name="Team", value=team["team"]["name"], inline=True) 172 | embed.add_field(name="Team leader", value="Yes" if team["is_leader"] else "No", inline=True) 173 | await ctx.send(embed=embed, view=JamInfoView(member, self.bot.code_jam_mgmt_api, ctx.author)) 174 | 175 | @codejam.command() 176 | @commands.has_any_role(Roles.admins, Roles.events_lead) 177 | async def move(self, ctx: commands.Context, member: Member, *, new_team_name: str) -> None: 178 | """Move participant from one team to another by issuing an HTTP request to the Code Jam Management system.""" 179 | callback = partial(move_flow, self.bot, new_team_name, ctx, member) 180 | await ctx.send( 181 | f"Are you sure you want to move {member.mention} to {new_team_name}?", 182 | view=JamConfirmation(author=ctx.author, callback=callback) 183 | ) 184 | 185 | @codejam.command() 186 | @commands.has_any_role(Roles.admins, Roles.events_lead) 187 | async def add( 188 | self, 189 | ctx: commands.Context, 190 | member: Member, 191 | is_leader: bool = False, 192 | *, 193 | team_name: str 194 | ) -> None: 195 | """Add a member to the Code Jam by specifying the team's name, and whether they should be leaders.""" 196 | callback = partial(add_flow, self.bot, team_name, ctx, member, is_leader) 197 | await ctx.send( 198 | f"Are you sure you want to add {member.mention} to {team_name}?", 199 | view=JamConfirmation(author=ctx.author, callback=callback) 200 | ) 201 | 202 | @codejam.command() 203 | @commands.has_any_role(Roles.admins, Roles.events_lead) 204 | async def remove(self, ctx: commands.Context, member: Member) -> None: 205 | """Remove the participant from their team. Does not remove the participants or leader roles.""" 206 | callback = partial(remove_flow, self.bot, member, ctx) 207 | await ctx.send( 208 | f"Are you sure you want to remove {member.mention} from the Code Jam?", 209 | view=JamConfirmation(author=ctx.author, callback=callback) 210 | ) 211 | 212 | @codejam.command() 213 | @commands.has_any_role(Roles.admins, Roles.events_lead, Roles.code_jam_event_team, Roles.code_jam_participants) 214 | @in_code_jam_category(_creation_utils.CATEGORY_NAME) 215 | async def pin(self, ctx: commands.Context, message: discord.Message | None = None) -> None: 216 | """Lets Code Jam Participants to pin messages in their team channels.""" 217 | await pin_flow(ctx, PIN_ALLOWED_ROLES, self.bot.code_jam_mgmt_api, message) 218 | 219 | @codejam.command() 220 | @commands.has_any_role(Roles.admins, Roles.events_lead, Roles.code_jam_event_team, Roles.code_jam_participants) 221 | @in_code_jam_category(_creation_utils.CATEGORY_NAME) 222 | async def unpin(self, ctx: commands.Context, message: discord.Message | None = None) -> None: 223 | """Lets Code Jam Participants to unpin messages in their team channels.""" 224 | await pin_flow(ctx, PIN_ALLOWED_ROLES, self.bot.code_jam_mgmt_api, message, True) 225 | 226 | @codejam.group() 227 | @with_role(Roles.admins, Roles.code_jam_event_team, fail_silently=True) 228 | async def support(self, ctx: commands.Context) -> None: 229 | """Apply or remove the Code Jam Support role.""" 230 | if ctx.invoked_subcommand is None: 231 | await ctx.send_help(ctx.command) 232 | 233 | @support.command() 234 | @with_role(Roles.admins, Roles.code_jam_event_team, fail_silently=True) 235 | async def off(self, ctx: commands.Context) -> None: 236 | """Remove the Code Jam Support role.""" 237 | user = ctx.author 238 | cj_support_role = ctx.guild.get_role(Roles.code_jam_support) 239 | 240 | if cj_support_role not in user.roles: 241 | await ctx.send(":question: You don't have the role.") 242 | return 243 | 244 | await user.remove_roles(cj_support_role) 245 | await ctx.send(f"{Emojis.check_mark} Code Jam Support role has been removed.") 246 | 247 | @support.command() 248 | @with_role(Roles.admins, Roles.code_jam_event_team, fail_silently=True) 249 | async def on(self, ctx: commands.Context) -> None: 250 | """Add the Code Jam Support role.""" 251 | user = ctx.author 252 | cj_support_role = ctx.guild.get_role(Roles.code_jam_support) 253 | 254 | if cj_support_role in user.roles: 255 | await ctx.send(":question: You already have the role.") 256 | return 257 | 258 | await user.add_roles(cj_support_role) 259 | await ctx.send(f"{Emojis.check_mark} Code Jam Support role has been applied.") 260 | 261 | @codejam.command("ping") 262 | @commands.has_any_role(Roles.admins, Roles.events_lead, Roles.code_jam_event_team, Roles.code_jam_participants) 263 | @in_code_jam_category(_creation_utils.CATEGORY_NAME) 264 | async def ping_codejam_team(self, ctx: commands.Context) -> None: 265 | """Ping the team role for the channel this command is ran in.""" 266 | team_resp = await self.bot.code_jam_mgmt_api.get( 267 | "teams/find", 268 | params={"name": ctx.channel.name.replace("-", " ")} # Discord channels have hyphens, CJMS has spaces. 269 | ) 270 | role_id = team_resp.get("discord_role_id") 271 | if not role_id: 272 | log.error("Failed to find '%s' in CJMS.", ctx.channel.name) 273 | await ctx.send("Failed to find team role id in database.") 274 | return 275 | await ctx.send(f"<@&{role_id}>") 276 | 277 | @staticmethod 278 | def jam_categories(guild: Guild) -> list[discord.CategoryChannel]: 279 | """Get all the code jam team categories.""" 280 | return [category for category in guild.categories if category.name == _creation_utils.CATEGORY_NAME] 281 | 282 | @staticmethod 283 | async def jam_roles(guild: Guild, mgmt_client: APIClient) -> list[discord.Role] | None: 284 | """Get all the code jam team roles.""" 285 | try: 286 | roles_raw = await mgmt_client.get("teams", raise_for_status=True, params={"current_jam": "true"}) 287 | except ResponseCodeError: 288 | log.error("Could not fetch Roles from the Code Jam Management API") 289 | return None 290 | else: 291 | roles = [] 292 | for role in roles_raw: 293 | if role := guild.get_role(role["discord_role_id"]): 294 | roles.append(role) 295 | return roles 296 | 297 | @staticmethod 298 | def team_channel(guild: Guild, criterion: str | Member) -> discord.TextChannel | None: 299 | """Get a team channel through either a participant or the team name.""" 300 | for category in CodeJams.jam_categories(guild): 301 | for channel in category.channels: 302 | if isinstance(channel, discord.TextChannel): 303 | if ( 304 | # If it's a string. 305 | criterion == channel.name or criterion == CodeJams.team_name(channel) 306 | # If it's a member. 307 | or criterion in channel.overwrites 308 | ): 309 | return channel 310 | return None 311 | 312 | @staticmethod 313 | def team_name(channel: discord.TextChannel) -> str: 314 | """Retrieves the team name from the given channel.""" 315 | return channel.name.replace("-", " ").title() 316 | -------------------------------------------------------------------------------- /bot/exts/code_jams/_creation_utils.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from pydis_core.utils.logging import get_logger 4 | 5 | from bot.constants import Categories, Channels, Roles 6 | from bot.utils.exceptions import JamCategoryNameConflictError 7 | 8 | log = get_logger(__name__) 9 | 10 | MAX_CHANNELS = 50 11 | CATEGORY_NAME = "CODE JAM" 12 | 13 | 14 | async def _create_category(guild: discord.Guild) -> discord.CategoryChannel: 15 | """Create a new code jam category and return it.""" 16 | log.info("Creating a new code jam category.") 17 | 18 | category_overwrites = { 19 | guild.default_role: discord.PermissionOverwrite(read_messages=False), 20 | guild.me: discord.PermissionOverwrite( 21 | read_messages=True, 22 | send_messages=True, 23 | manage_messages=True, 24 | mention_everyone=True, 25 | connect=True, 26 | ), 27 | guild.get_role(Roles.bots): discord.PermissionOverwrite( 28 | read_messages=True, 29 | send_messages=True 30 | ), 31 | guild.get_role(Roles.events_lead): discord.PermissionOverwrite( 32 | manage_channels=True, 33 | manage_webhooks=True 34 | ), 35 | guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite( 36 | read_messages=True, 37 | send_messages=True, 38 | connect=True, 39 | move_members=True, 40 | mention_everyone=True, 41 | ), 42 | } 43 | category = await guild.create_category_channel( 44 | CATEGORY_NAME, 45 | overwrites=category_overwrites, 46 | reason="It's code jam time!" 47 | ) 48 | 49 | await _send_status_update( 50 | guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." 51 | ) 52 | 53 | return category 54 | 55 | 56 | async def _get_category(guild: discord.Guild) -> discord.CategoryChannel: 57 | """ 58 | Return a code jam category. 59 | 60 | If all categories are full or none exist, create a new category. 61 | If the main CJ category and the CJ Team's category has the same name 62 | it raises a `JamCategoryNameConflictError` 63 | """ 64 | main_cj_category = guild.get_channel(Categories.summer_code_jam).name 65 | if main_cj_category == CATEGORY_NAME: 66 | raise JamCategoryNameConflictError 67 | 68 | for category in guild.categories: 69 | if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: 70 | return category 71 | 72 | return await _create_category(guild) 73 | 74 | 75 | def _get_overwrites( 76 | guild: discord.Guild, 77 | team_role: discord.Role 78 | ) -> dict[discord.Member | discord.Role, discord.PermissionOverwrite]: 79 | """Get code jam team channels permission overwrites.""" 80 | return { 81 | guild.default_role: discord.PermissionOverwrite(read_messages=False), 82 | guild.me: discord.PermissionOverwrite( 83 | read_messages=True, 84 | send_messages=True, 85 | manage_messages=True, 86 | mention_everyone=True, 87 | ), 88 | guild.get_role(Roles.events_lead): discord.PermissionOverwrite( 89 | manage_channels=True, 90 | manage_webhooks=True, 91 | ), 92 | guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite( 93 | read_messages=True, 94 | send_messages=True, 95 | mention_everyone=True, 96 | ), 97 | team_role: discord.PermissionOverwrite(read_messages=True), 98 | guild.get_role(Roles.bots): discord.PermissionOverwrite(read_messages=True, send_messages=True), 99 | } 100 | 101 | 102 | async def create_team_role( 103 | guild: discord.Guild, 104 | team_name: str, 105 | members: list[dict[str: discord.Member, str: bool]], 106 | team_leaders: discord.Role 107 | ) -> discord.Role: 108 | """Create the team's role.""" 109 | await _add_team_leader_roles(members, team_leaders) 110 | team_role = await guild.create_role(name=team_name, reason="Code Jam team creation") 111 | for entry in members: 112 | await entry["member"].add_roles(team_role) 113 | return team_role 114 | 115 | 116 | async def create_team_channel( 117 | guild: discord.Guild, 118 | team_name: str, 119 | team_role: discord.Role 120 | 121 | ) -> int: 122 | """Create the team's text channel.""" 123 | # Get permission overwrites and category 124 | team_channel_overwrites = _get_overwrites(guild, team_role) 125 | code_jam_category = await _get_category(guild) 126 | 127 | # Create a text channel for the team 128 | created_channel = await code_jam_category.create_text_channel( 129 | team_name, 130 | overwrites=team_channel_overwrites, 131 | ) 132 | return created_channel.id 133 | 134 | 135 | async def create_team_leader_channel(guild: discord.Guild, team_leaders: discord.Role) -> None: 136 | """Create the Team Leader Chat channel for the Code Jam team leaders.""" 137 | category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) 138 | 139 | team_leaders_chat = await category.create_text_channel( 140 | name="team-leaders-chat", 141 | overwrites={ 142 | guild.default_role: discord.PermissionOverwrite(read_messages=False), 143 | team_leaders: discord.PermissionOverwrite(read_messages=True), 144 | guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) 145 | 146 | } 147 | ) 148 | 149 | await _send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") 150 | 151 | 152 | async def _send_status_update(guild: discord.Guild, message: str) -> None: 153 | """Inform the events lead with a status update when the command is ran.""" 154 | channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) 155 | 156 | await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") 157 | 158 | 159 | async def _add_team_leader_roles(members: list[dict[str: discord.Member, str: bool]], 160 | team_leaders: discord.Role) -> None: 161 | """Assign the team leader role to the team leaders.""" 162 | for entry in members: 163 | if entry["is_leader"]: 164 | await entry["member"].add_roles(team_leaders) 165 | 166 | 167 | async def pin_message(message: discord.Message, ctx: commands.Context, unpin: bool) -> None: 168 | """Pin `message` if `pin` is True or unpin if it's False.""" 169 | channel_str = f"#{message.channel} ({message.channel.id})" 170 | func = message.unpin if unpin else message.pin 171 | 172 | try: 173 | await func() 174 | except discord.HTTPException as e: 175 | if e.code == 10008: 176 | log.debug(f"Message {message.id} in {channel_str} doesn't exist; can't {func.__name__}.") 177 | else: 178 | log.exception( 179 | f"Error {func.__name__}ning message {message.id} in {channel_str}: " 180 | f"{e.status} ({e.code})" 181 | ) 182 | await ctx.reply(f":x: Something went wrong with {func.__name__}ing your message!") 183 | else: 184 | log.trace(f"{func.__name__.capitalize()}ned message {message.id} in {channel_str}.") 185 | -------------------------------------------------------------------------------- /bot/exts/code_jams/_flows.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | from urllib.parse import quote as quote_url 3 | 4 | import discord 5 | from discord import Embed, Member 6 | from discord.ext import commands 7 | from pydis_core.site_api import APIClient, ResponseCodeError 8 | from pydis_core.utils.logging import get_logger 9 | 10 | from bot.bot import SirRobin 11 | from bot.constants import Roles 12 | from bot.exts.code_jams import _creation_utils 13 | from bot.exts.code_jams._views import JamTeamInfoConfirmation 14 | 15 | TEAM_LEADERS_COLOUR = 0x11806a 16 | TEAM_LEADER_ROLE_NAME = "Code Jam Team Leaders" 17 | log = get_logger(__name__) 18 | 19 | 20 | async def creation_flow( 21 | ctx: commands.Context, 22 | teams: dict[str: list[dict[str: Member, str: bool]]], 23 | bot: SirRobin 24 | ) -> None: 25 | """ 26 | The Code Jam Team and Role creation flow. 27 | 28 | This "flow" will first create the role for the CJ Team leaders, and the channel. 29 | Then it'll create the team roles first, then the team channels. 30 | After that all the information regarding the teams will be uploaded to 31 | the Code Jam Management System, via an HTTP request. 32 | Finally, a view of Team Announcement will be sent. 33 | """ 34 | team_leaders = await ctx.guild.create_role(name=TEAM_LEADER_ROLE_NAME, colour=TEAM_LEADERS_COLOUR) 35 | await _creation_utils.create_team_leader_channel(ctx.guild, team_leaders) 36 | jam_api_format = {"name": f"Summer Code Jam {datetime.now(tz=UTC).year}", "ongoing": True, "teams": []} 37 | for team_name, team_members in teams.items(): 38 | team_role = await _creation_utils.create_team_role( 39 | ctx.guild, 40 | team_name, 41 | team_members, 42 | team_leaders 43 | ) 44 | team_channel_id = await _creation_utils.create_team_channel(ctx.guild, team_name, team_role) 45 | jam_api_format["teams"].append( 46 | { 47 | "name": team_name, 48 | "users": [ 49 | {"user_id": entry["member"].id, "is_leader": entry["is_leader"]} for entry in team_members 50 | ], 51 | "discord_role_id": team_role.id, 52 | "discord_channel_id": team_channel_id 53 | } 54 | ) 55 | await bot.code_jam_mgmt_api.post("codejams", json=jam_api_format) 56 | success_embed = Embed( 57 | title=f"Successfully created Code Jam with {len(teams)} teams", 58 | colour=discord.Colour.green(), 59 | description="Would you like send out the team announcement?" 60 | ) 61 | success_embed.set_footer(text="Code Jam team generation") 62 | team_info_view = JamTeamInfoConfirmation(bot, ctx.guild, ctx.author) 63 | await ctx.send( 64 | embed=success_embed, 65 | view=team_info_view 66 | ) 67 | 68 | 69 | async def deletion_flow( 70 | category_channels: dict[discord.CategoryChannel: list[discord.TextChannel]], 71 | roles: list[discord.Role] 72 | ) -> None: 73 | """ 74 | The Code Jam Team and Role deletion flow. 75 | 76 | The "flow" will delete the channels in each category, and the category itself. 77 | Then it'll delete all the Team roles, leaving the CJ Leaders related channel, and Roles intact. 78 | """ 79 | for category, channels in category_channels.items(): 80 | for channel in channels: 81 | await channel.delete(reason="Code jam ended.") 82 | await category.delete(reason="Code jam ended.") 83 | for role in roles: 84 | await role.delete(reason="Code Jam ended.") 85 | 86 | 87 | async def add_flow( 88 | bot: SirRobin, 89 | team_name: str, 90 | ctx: commands.Context, 91 | member: discord.Member, 92 | is_leader: bool = False 93 | ) -> None: 94 | """Add a member to the Code Jam and assign the roles accordingly.""" 95 | # Check if the user is not already a participant 96 | try: 97 | team = await bot.code_jam_mgmt_api.get( 98 | f"users/{member.id}/current_team", 99 | raise_for_status=True 100 | ) 101 | except ResponseCodeError as err: 102 | if err.response.status == 404: 103 | # The user is not a participant, so the flow will proceed, 104 | # and add the user to the database if it does not exist yet. 105 | try: 106 | await bot.code_jam_mgmt_api.post(f"users/{member.id}", raise_for_status=True) 107 | except ResponseCodeError as err: 108 | if err.response.status != 400: 109 | log.error(f"Something went wrong: {err}") 110 | try: 111 | team_to_move_in = await bot.code_jam_mgmt_api.get( 112 | "teams/find", 113 | params={"name": team_name}, 114 | raise_for_status=True 115 | ) 116 | except ResponseCodeError as err: 117 | if err.response.status == 404: 118 | await ctx.send(f":x: Team `{team_name}` does not exist in the current jam!") 119 | else: 120 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 121 | log.error(f"Something went wrong with processing the request! {err}") 122 | return 123 | # Add the user to the team in the database 124 | try: 125 | await bot.code_jam_mgmt_api.post( 126 | f"teams/{team_to_move_in['id']}/users/{member.id}", 127 | params={"is_leader": str(is_leader)}, 128 | raise_for_status=True 129 | ) 130 | except ResponseCodeError as err: 131 | if err.response.status == 404: 132 | await ctx.send(":x: User could not be found.") 133 | elif err.response.status == 400: 134 | await ctx.send(f":x: user {member.mention} is already in {team_to_move_in['name']}") 135 | else: 136 | await ctx.send( 137 | "Something went wrong while processing the request! We have notified the team!" 138 | ) 139 | log.error(f"Something went wrong with processing the request! {err}") 140 | return 141 | # Assign the roles 142 | if is_leader: 143 | await member.add_roles(discord.utils.get(ctx.guild.roles, name=TEAM_LEADER_ROLE_NAME)) 144 | await member.add_roles(ctx.guild.get_role(Roles.code_jam_participants)) 145 | await member.add_roles(ctx.guild.get_role(team_to_move_in["discord_role_id"])) 146 | 147 | await ctx.send( 148 | f"Success! Participant {member.mention} has been added to {team_to_move_in['name']}." 149 | ) 150 | 151 | else: 152 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 153 | log.error(err.response) 154 | return 155 | else: 156 | await ctx.reply(f":x: The user is already a participant! ({team['team']['name']})") 157 | return 158 | 159 | 160 | async def move_flow( 161 | bot: SirRobin, 162 | new_team_name: str, 163 | ctx: commands.Context, 164 | member: discord.Member 165 | ) -> None: 166 | """Move participant from one team to another by issuing an HTTP request to the Code Jam Management system.""" 167 | # Query the current team of the member 168 | try: 169 | team = await bot.code_jam_mgmt_api.get(f"users/{member.id}/current_team", 170 | raise_for_status=True) 171 | except ResponseCodeError as err: 172 | if err.response.status == 404: 173 | await ctx.send(":x: It seems like the user is not a participant!") 174 | else: 175 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 176 | log.error(err.response) 177 | return 178 | 179 | # Query the team the user has to be moved to 180 | try: 181 | team_to_move_in = await bot.code_jam_mgmt_api.get( 182 | "teams/find", 183 | params={"name": new_team_name, "jam_id": team["team"]["jam_id"]}, 184 | raise_for_status=True 185 | ) 186 | except ResponseCodeError as err: 187 | if err.response.status == 404: 188 | await ctx.send(f":x: Team `{new_team_name}` does not exist in the current jam!") 189 | else: 190 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 191 | log.error(f"Something went wrong with processing the request! {err}") 192 | return 193 | 194 | # Check if the user's current team and the team they want to move them to is not the same 195 | if team_to_move_in["name"] == team["team"]["name"]: 196 | await ctx.send(f":x: user {member.mention} is already in {team_to_move_in['name']}") 197 | return 198 | 199 | # Remove the member from their current team. 200 | try: 201 | await bot.code_jam_mgmt_api.delete( 202 | f"teams/{quote_url(str(team['team']['id']))}/users/{quote_url(str(team['user_id']))}", 203 | raise_for_status=True 204 | ) 205 | except ResponseCodeError as err: 206 | if err.response.status == 404: 207 | await ctx.send(":x: Team or user could not be found!") 208 | elif err.response.status == 400: 209 | await ctx.send(":x: The member given is not part of the team! (Might have been removed already)") 210 | else: 211 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 212 | log.error(f"Something went wrong with processing the request! {err}") 213 | return 214 | 215 | # Actually remove the role to modify the permissions. 216 | team_role = ctx.guild.get_role(team["team"]["discord_role_id"]) 217 | await member.remove_roles(team_role) 218 | 219 | # Decide whether the member should be a team leader in their new team. 220 | is_leader = False 221 | members = team["team"]["users"] 222 | for memb in members: 223 | if memb["user_id"] == member.id and memb["is_leader"]: 224 | is_leader = True 225 | 226 | # Add the user to the new team in the database. 227 | try: 228 | await bot.code_jam_mgmt_api.post( 229 | f"teams/{team_to_move_in['id']}/users/{member.id}", 230 | params={"is_leader": str(is_leader)}, 231 | raise_for_status=True 232 | ) 233 | except ResponseCodeError as err: 234 | if err.response.status == 404: 235 | await ctx.send(":x: Team or user could not be found.") 236 | elif err.response.status == 400: 237 | await ctx.send(f":x: user {member.mention} is already in {team_to_move_in['name']}") 238 | else: 239 | await ctx.send( 240 | "Something went wrong while processing the request! We have notified the team!" 241 | ) 242 | log.error(f"Something went wrong with processing the request! {err}") 243 | return 244 | 245 | await member.add_roles(ctx.guild.get_role(team_to_move_in["discord_role_id"])) 246 | 247 | await ctx.send( 248 | f"Success! Participant {member.mention} has been moved " 249 | f"from {team['team']['name']} to {team_to_move_in['name']}" 250 | ) 251 | 252 | 253 | async def remove_flow(bot: SirRobin, member: discord.Member, ctx: commands.Context) -> None: 254 | """Remove the participant from their team. Does not remove the participants or leader roles.""" 255 | try: 256 | team = await bot.code_jam_mgmt_api.get( 257 | f"users/{member.id}/current_team", 258 | raise_for_status=True 259 | ) 260 | except ResponseCodeError as err: 261 | if err.response.status == 404: 262 | await ctx.send(":x: It seems like the user is not a participant!") 263 | else: 264 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 265 | log.error(err.response) 266 | return 267 | 268 | try: 269 | await bot.code_jam_mgmt_api.delete( 270 | f"teams/{quote_url(str(team['team']['id']))}/users/{quote_url(str(team['user_id']))}", 271 | raise_for_status=True 272 | ) 273 | except ResponseCodeError as err: 274 | if err.response.status == 404: 275 | await ctx.send(":x: Team or user could not be found!") 276 | elif err.response.status == 400: 277 | await ctx.send(":x: The member given is not part of the team! (Might have been removed already)") 278 | else: 279 | await ctx.send("Something went wrong while processing the request! We have notified the team!") 280 | log.error(err.response) 281 | return 282 | 283 | team_role = ctx.guild.get_role(team["team"]["discord_role_id"]) 284 | participant_role = ctx.guild.get_role(Roles.code_jam_participants) 285 | await member.remove_roles(team_role) 286 | await member.remove_roles(participant_role) 287 | for role in member.roles: 288 | if role.name == TEAM_LEADER_ROLE_NAME: 289 | await member.remove_roles(role) 290 | await ctx.send(f"Successfully removed {member.mention} from team {team['team']['name']}") 291 | 292 | 293 | async def pin_flow( 294 | ctx: commands.Context, 295 | roles: tuple[int, ...], 296 | mgmt_api: APIClient, 297 | message: discord.Message | None = None, 298 | unpin: bool = False 299 | ) -> None: 300 | """ 301 | Pin or unpin the given message. 302 | 303 | Additional checks have been put in place, to ensure 304 | messages can only be (un)pinned inside the Code Jam Category 305 | by the Events Team and Admins, and participants can 306 | only (un)pin messages in their own team channel. 307 | """ 308 | referenced_message = getattr(ctx.message.reference, "resolved", None) or message 309 | pin_msg = f"{'un' if unpin else ''}pin" 310 | if not isinstance(referenced_message, discord.Message): 311 | await ctx.reply( 312 | ":x: You have to either reply to a message or provide a message link / message id" 313 | f" in order to {pin_msg} it." 314 | ) 315 | return 316 | if referenced_message.channel != ctx.channel: 317 | await ctx.reply(f":x: You cannot {pin_msg} a message outside of this team's channel.") 318 | return 319 | 320 | if referenced_message.pinned and not unpin: 321 | await ctx.reply(":x: The message has already been pinned!") 322 | return 323 | if not referenced_message.pinned and unpin: 324 | await ctx.reply(":x: The message has already been unpinned!") 325 | return 326 | 327 | if any(role.id in roles for role in getattr(ctx.author, "roles", [])): 328 | await _creation_utils.pin_message(referenced_message, ctx, unpin) 329 | return 330 | try: 331 | team = await mgmt_api.get( 332 | f"users/{ctx.author.id}/current_team", 333 | raise_for_status=True 334 | ) 335 | except ResponseCodeError as err: 336 | if err.response.status == 404: 337 | await ctx.reply(":x: It seems like you're not a participant!") 338 | else: 339 | await ctx.reply("Something went wrong while processing the request! We have notified the team!") 340 | log.error(f"Something went wrong with processing the request! {err}") 341 | else: 342 | if ctx.channel.id == int(team["team"]["discord_channel_id"]): 343 | await _creation_utils.pin_message(referenced_message, ctx, unpin=unpin) 344 | else: 345 | await ctx.reply(f"You don't have permission to {pin_msg} this message in this channel!") 346 | -------------------------------------------------------------------------------- /bot/exts/code_jams/_views.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, TYPE_CHECKING 3 | 4 | import discord 5 | from pydis_core.site_api import APIClient, ResponseCodeError 6 | from pydis_core.utils.logging import get_logger 7 | 8 | if TYPE_CHECKING: 9 | from bot.bot import SirRobin 10 | 11 | from bot.constants import Channels, Roles 12 | from bot.utils.exceptions import JamCategoryNameConflictError 13 | 14 | log = get_logger(__name__) 15 | 16 | 17 | async def interaction_fetch_user_data( 18 | endpoint: str, 19 | mgmt_client: APIClient, 20 | interaction: discord.Interaction 21 | ) -> dict[str, Any] | None: 22 | """A helper function for fetching and handling user related data in an interaction.""" 23 | try: 24 | user = await mgmt_client.get(endpoint, raise_for_status=True) 25 | except ResponseCodeError as err: 26 | if err.response.status == 404: 27 | await interaction.response.send_message(":x: The user could not be found.", ephemeral=True) 28 | else: 29 | await interaction.response.send_message( 30 | ":x: Something went wrong! Full details have been logged.", 31 | ephemeral=True 32 | ) 33 | log.error(f"Something went wrong: {err}") 34 | return None 35 | else: 36 | return user 37 | 38 | 39 | class JamTeamInfoConfirmation(discord.ui.View): 40 | """A basic view to confirm Team announcement.""" 41 | 42 | def __init__(self, bot: "SirRobin", guild: discord.Guild, original_author: discord.Member): 43 | super().__init__() 44 | self.bot = bot 45 | self.guild = guild 46 | self.original_author = original_author 47 | 48 | @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) 49 | async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 50 | """A button to cancel the announcement.""" 51 | button.label = "Cancelled" 52 | button.disabled = True 53 | self.announce.disabled = True 54 | await interaction.response.edit_message(view=self) 55 | self.stop() 56 | 57 | @discord.ui.button(label="Announce teams", style=discord.ButtonStyle.green) 58 | async def announce(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 59 | """A button to send the announcement.""" 60 | button.label = "Teams have been announced!" 61 | button.disabled = True 62 | self.cancel.disabled = True 63 | self.stop() 64 | await interaction.response.edit_message(view=self) 65 | announcements = self.guild.get_channel(Channels.summer_code_jam_announcements) 66 | await announcements.send( 67 | f"<@&{Roles.code_jam_participants}> ! You have been sorted into a team!" 68 | " Click the button below to get a detailed description!", 69 | view=JamTeamInfoView(self.bot) 70 | ) 71 | 72 | teams = await self.bot.code_jam_mgmt_api.get( 73 | "teams/", 74 | raise_for_status=True 75 | ) 76 | 77 | for team in teams: 78 | team_channel = self.guild.get_channel(team["discord_channel_id"]) 79 | team_message = [] 80 | for member in team["users"]: 81 | message = f"- <@{member['user_id']}>" 82 | if member["is_leader"]: 83 | message += " (Team Leader)" 84 | team_message.append(message) 85 | message = "\n".join(team_message) 86 | 87 | await team_channel.send( 88 | f"Your team is {team['name']}\n" 89 | "Team Members:\n" 90 | f"{message}" 91 | ) 92 | 93 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 94 | """Global check to ensure that the interacting user is the user who invoked the command originally.""" 95 | if interaction.user != self.original_author: 96 | await interaction.response.send_message( 97 | ":x: You can't interact with someone else's response. Please run the command yourself!", 98 | ephemeral=True 99 | ) 100 | return False 101 | return True 102 | 103 | 104 | class JamTeamInfoView(discord.ui.View): 105 | """A persistent view to show Team related data to users.""" 106 | 107 | def __init__(self, bot: "SirRobin"): 108 | super().__init__(timeout=None) 109 | self.bot = bot 110 | 111 | @discord.ui.button(label="Show me my team!", style=discord.ButtonStyle.blurple, custom_id="CJ:PERS:SHOW_TEAM") 112 | async def show_team(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 113 | """A button that sends an ephemeral embed with the team's description.""" 114 | try: 115 | team = await self.bot.code_jam_mgmt_api.get( 116 | f"users/{interaction.user.id}/current_team", 117 | raise_for_status=True 118 | ) 119 | except ResponseCodeError as err: 120 | if err.response.status == 404: 121 | await interaction.response.send_message("It seems like you're not a participant!", ephemeral=True) 122 | else: 123 | await interaction.response.send_message( 124 | "Something went wrong while processing the request! We have notified the team!", 125 | ephemeral=True 126 | ) 127 | log.error(err.response) 128 | else: 129 | team_channel = f"<#{team['team']['discord_channel_id']}>" 130 | team_members = [] 131 | for member in team["team"]["users"]: 132 | message = f"<@{member['user_id']}>" 133 | 134 | if member["is_leader"]: 135 | message += " (Team Leader)" 136 | 137 | team_members.append(message) 138 | 139 | team_members_formatted = "\n".join(team_members) 140 | response_text = ( 141 | f"You have been sorted into {team_channel}" 142 | f"{', and **you are the leader**' if team['is_leader'] else ''}!\n" 143 | f"Your teammates are:\n{team_members_formatted}" 144 | ) 145 | await interaction.response.send_message( 146 | response_text, 147 | ephemeral=True 148 | ) 149 | 150 | 151 | class JamConfirmation(discord.ui.View): 152 | """A basic view to confirm the ending of a CJ.""" 153 | 154 | def __init__( 155 | self, 156 | callback: Callable, 157 | author: discord.Member 158 | ): 159 | super().__init__() 160 | self.original_author = author 161 | self.callback = callback 162 | 163 | @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) 164 | async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 165 | """A button to cancel an action.""" 166 | button.label = "Cancelled" 167 | button.disabled = True 168 | self.confirm.disabled = True 169 | await interaction.response.edit_message(view=self) 170 | self.stop() 171 | 172 | @discord.ui.button(label="Confirm", style=discord.ButtonStyle.green) 173 | async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 174 | """A button to confirm an action.""" 175 | button.label = "Confirmed" 176 | button.disabled = True 177 | self.cancel.disabled = True 178 | self.stop() 179 | await interaction.response.edit_message(view=self) 180 | await self.callback() 181 | 182 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 183 | """Global check to ensure that the interacting user is the user who invoked the command originally.""" 184 | if interaction.user != self.original_author: 185 | await interaction.response.send_message( 186 | ":x: You can't interact with someone else's response. Please run the command yourself!", 187 | ephemeral=True 188 | ) 189 | return False 190 | return True 191 | 192 | async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item[Any]) -> None: 193 | """Discord.py default to handle a view error.""" 194 | if isinstance(error, JamCategoryNameConflictError): 195 | await interaction.channel.send( 196 | ":x: Due to a conflict regarding the names of the main Code Jam Category and the Code Jam Team category" 197 | " the Code Jam creation was aborted." 198 | ) 199 | else: 200 | await interaction.channel.send( 201 | ":x: Something went wrong when confirming the view. Full details have been logged." 202 | ) 203 | log.exception("Something went wrong", exc_info=error) 204 | 205 | 206 | class AddNoteModal(discord.ui.Modal, title="Add a Note for a Code Jam Participant"): 207 | """A simple modal to add a note to a Jam participant.""" 208 | 209 | def __init__(self, member: discord.Member, mgmt_client: APIClient): 210 | super().__init__() 211 | self.member = member 212 | self.mgmt_client = mgmt_client 213 | 214 | note = discord.ui.TextInput( 215 | label="Note", 216 | placeholder="Your note..." 217 | ) 218 | 219 | async def on_submit(self, interaction: discord.Interaction) -> None: 220 | """Discord.py default to handle modal submission.""" 221 | if not ( 222 | user := await interaction_fetch_user_data( 223 | f"users/{self.member.id}/current_team", 224 | self.mgmt_client, 225 | interaction 226 | ) 227 | ): 228 | return 229 | 230 | jam_id = user["team"]["jam_id"] 231 | try: 232 | await self.mgmt_client.post( 233 | "infractions", 234 | json={ 235 | "user_id": self.member.id, 236 | "jam_id": jam_id, "reason": self.note.value, 237 | "infraction_type": "note" 238 | }, 239 | raise_for_status=True 240 | ) 241 | except ResponseCodeError as err: 242 | if err.response.status == 404: 243 | await interaction.response.send_message( 244 | ":x: The user could not be found!", 245 | ephemeral=True 246 | ) 247 | else: 248 | await interaction.response.send_message( 249 | ":x: Something went wrong! Full details have been logged.", 250 | ephemeral=True 251 | ) 252 | log.error(f"Something went wrong: {err}") 253 | return 254 | else: 255 | await interaction.response.send_message("Your note has been saved!", ephemeral=True) 256 | 257 | async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: 258 | """Discord.py default to handle modal error.""" 259 | await interaction.response.send_message(":x: Something went wrong while processing your form.", ephemeral=True) 260 | 261 | 262 | class JamInfoView(discord.ui.View): 263 | """ 264 | A basic view that displays basic information about a CJ participant. 265 | 266 | Additionally, notes for a participant can be added and viewed. 267 | """ 268 | 269 | def __init__(self, member: discord.Member, mgmt_client: APIClient, author: discord.Member): 270 | super().__init__(timeout=900) 271 | self.mgmt_client = mgmt_client 272 | self.member = member 273 | self.author = author 274 | 275 | @discord.ui.button(label="Add Note", style=discord.ButtonStyle.green) 276 | async def add_note(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 277 | """A button to add a note.""" 278 | await interaction.response.send_modal(AddNoteModal(self.member, self.mgmt_client)) 279 | 280 | @discord.ui.button(label="View notes", style=discord.ButtonStyle.green) 281 | async def view_notes(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 282 | """A button to view the notes of a participant.""" 283 | if not (user := await interaction_fetch_user_data(f"users/{self.member.id}", self.mgmt_client, interaction)): 284 | return 285 | 286 | part_history = user["participation_history"] 287 | notes = [] 288 | for entry in part_history: 289 | for infraction in entry["infractions"]: 290 | notes.append(infraction) 291 | if not notes: 292 | await interaction.response.send_message( 293 | f":x: {self.member.mention} doesn't have any notes yet.", 294 | ephemeral=True 295 | ) 296 | else: 297 | if len(notes) > 25: 298 | notes = notes[:25] 299 | notes_embed = discord.Embed(title=f"Notes on {self.member.name}", colour=discord.Colour.orange()) 300 | for note in notes: 301 | notes_embed.add_field(name=f"Jam - (ID: {note['jam_id']})", value=note["reason"]) 302 | await interaction.response.send_message(embed=notes_embed, ephemeral=True) 303 | 304 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 305 | """Global check to ensure the interacting user is an admin.""" 306 | if interaction.guild.get_role(Roles.admins) in interaction.user.roles or interaction.user == self.author: 307 | return True 308 | await interaction.response.send_message( 309 | ":x: You don't have permission to interact with this view!", 310 | ephemeral=True 311 | ) 312 | return False 313 | -------------------------------------------------------------------------------- /bot/exts/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-robin/2efd8585987ce710577df1789aac10b45d5f7352/bot/exts/core/__init__.py -------------------------------------------------------------------------------- /bot/exts/core/error_handler.py: -------------------------------------------------------------------------------- 1 | from discord import Colour, Embed 2 | from discord.ext.commands import ( 3 | Cog, 4 | CommandError, 5 | Context, 6 | errors, 7 | ) 8 | 9 | from bot.bot import SirRobin 10 | from bot.log import get_logger 11 | from bot.utils.exceptions import ( 12 | InMonthCheckFailure, 13 | InWhitelistCheckFailure, 14 | SilentCheckFailure, 15 | ) 16 | 17 | log = get_logger(__name__) 18 | 19 | 20 | class ErrorHandler(Cog): 21 | """Handles errors emitted from commands.""" 22 | 23 | def __init__(self, bot: SirRobin): 24 | self.bot = bot 25 | 26 | @staticmethod 27 | def _get_error_embed(title: str, body: str) -> Embed: 28 | """Return a embed with our error colour assigned.""" 29 | return Embed( 30 | title=title, 31 | colour=Colour.brand_red(), 32 | description=body 33 | ) 34 | 35 | @Cog.listener() 36 | async def on_command_error(self, ctx: Context, error: CommandError) -> None: 37 | """ 38 | Generic command error handling from other cogs. 39 | 40 | Using the error type, handle the error appropriately. 41 | if there is no handling for the error type raised, 42 | a message will be sent to the user & it will be logged. 43 | 44 | In the future, I would expect this to be used as a place 45 | to push errors to a sentry instance. 46 | """ 47 | log.trace("Handling a %s raised from %s", type(error), ctx.command) 48 | 49 | if isinstance(error, errors.UserInputError): 50 | await self.handle_user_input_error(ctx, error) 51 | return 52 | 53 | if isinstance(error, errors.CheckFailure): 54 | await self.handle_check_failure(ctx, error) 55 | return 56 | 57 | if isinstance(error, errors.CommandNotFound): 58 | embed = self._get_error_embed("Command not found", str(error)) 59 | else: 60 | # If we haven't handled it by this point, it is considered an unexpected/handled error. 61 | log.exception( 62 | f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", 63 | exc_info=error, 64 | ) 65 | embed = self._get_error_embed( 66 | "Unexpected error", 67 | "Sorry, an unexpected error occurred. Please let us know!\n\n" 68 | f"```{error.__class__.__name__}: {error}```" 69 | ) 70 | await ctx.send(embed=embed) 71 | 72 | async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: 73 | """ 74 | Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. 75 | 76 | * MissingRequiredArgument: send an error message with arg name and the help command 77 | * TooManyArguments: send an error message and the help command 78 | * BadArgument: send an error message and the help command 79 | * BadUnionArgument: send an error message including the error produced by the last converter 80 | * ArgumentParsingError: send an error message 81 | * Other: send an error message and the help command 82 | """ 83 | if isinstance(e, errors.MissingRequiredArgument): 84 | embed = self._get_error_embed("Missing required argument", e.param.name) 85 | elif isinstance(e, errors.TooManyArguments): 86 | embed = self._get_error_embed("Too many arguments", str(e)) 87 | elif isinstance(e, errors.BadArgument): 88 | embed = self._get_error_embed("Bad argument", str(e)) 89 | elif isinstance(e, errors.BadUnionArgument): 90 | embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") 91 | elif isinstance(e, errors.ArgumentParsingError): 92 | embed = self._get_error_embed("Argument parsing error", str(e)) 93 | else: 94 | embed = self._get_error_embed( 95 | "Input error", 96 | "Something about your input seems off. Check the arguments and try again." 97 | ) 98 | 99 | await ctx.send(embed=embed) 100 | 101 | async def handle_check_failure(self, ctx: Context, e: errors.CheckFailure) -> None: 102 | """ 103 | Send an error message in `ctx` for certain types of CheckFailure. 104 | 105 | The following types are handled: 106 | 107 | * BotMissingPermissions 108 | * BotMissingRole 109 | * BotMissingAnyRole 110 | * MissingAnyRole 111 | * InMonthCheckFailure 112 | * SilentCheckFailure 113 | * InWhitelistCheckFailure 114 | * NoPrivateMessage 115 | """ 116 | bot_missing_errors = ( 117 | errors.BotMissingPermissions, 118 | errors.BotMissingRole, 119 | errors.BotMissingAnyRole 120 | ) 121 | 122 | if isinstance(e, SilentCheckFailure): 123 | # Silently fail, SirRobin should not respond 124 | log.info( 125 | f"{ctx.author} ({ctx.author.id}) tried to run {ctx.command} " 126 | f"but hit a silent check failure {e.__class__.__name__}", 127 | ) 128 | return 129 | 130 | if isinstance(e, bot_missing_errors): 131 | embed = self._get_error_embed("Permission error", "I don't have the permission I need to do that!") 132 | elif isinstance(e, errors.MissingAnyRole): 133 | embed = self._get_error_embed("Permission error", "You are not allowed to use this command!") 134 | elif isinstance(e, InMonthCheckFailure): 135 | embed = self._get_error_embed("Command not available", str(e)) 136 | elif isinstance(e, InWhitelistCheckFailure): 137 | embed = self._get_error_embed("Wrong Channel", str(e)) 138 | elif isinstance(e, errors.NoPrivateMessage): 139 | embed = self._get_error_embed("Wrong channel", "This command can not be ran in DMs!") 140 | else: 141 | embed = self._get_error_embed( 142 | "Unexpected check failure", 143 | "Sorry, an unexpected check error occurred. Please let us know!\n\n" 144 | f"```{e.__class__.__name__}: {e}```" 145 | ) 146 | await ctx.send(embed=embed) 147 | 148 | 149 | async def setup(bot: SirRobin) -> None: 150 | """Load the ErrorHandler cog.""" 151 | await bot.add_cog(ErrorHandler(bot)) 152 | -------------------------------------------------------------------------------- /bot/exts/games.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import enum 4 | import random 5 | import textwrap 6 | import types 7 | from collections import Counter 8 | from typing import Literal, NamedTuple 9 | 10 | import arrow 11 | import discord 12 | import discord.errors 13 | from async_rediscache import RedisCache 14 | from discord.ext import commands, tasks 15 | from discord.ext.commands import BadArgument 16 | from pydis_core.utils.logging import get_logger 17 | 18 | from bot import constants 19 | from bot.bot import SirRobin 20 | from bot.utils.decorators import in_whitelist 21 | 22 | logger = get_logger(__name__) 23 | GameType = Literal["team", "super"] 24 | 25 | 26 | class TeamInfo(NamedTuple): 27 | """Tuple containing the info on a team.""" 28 | 29 | name: str 30 | emoji: str 31 | 32 | 33 | class Team(enum.Enum): 34 | """The three teams for Python Discord Games 2024.""" 35 | 36 | LIST = TeamInfo("list", constants.Emojis.team_list) 37 | DICT = TeamInfo("dict", constants.Emojis.team_dict) 38 | TUPLE = TeamInfo("tuple", constants.Emojis.team_tuple) 39 | 40 | 41 | TEAM_ADJECTIVES = types.MappingProxyType({ 42 | Team.LIST: ["noble", "organized", "orderly", "chivalrous", "valiant"], 43 | Team.DICT: ["wise", "knowledgeable", "powerful"], 44 | Team.TUPLE: ["resilient", "strong", "steadfast", "resourceful"], 45 | }) 46 | 47 | # The default settings to initialize the cache with. 48 | DEFAULT_SETTINGS: types.MappingProxyType[str, int | float] = types.MappingProxyType({ 49 | "reaction_min": 30, "reaction_max": 120, "ducky_probability": 0.25, "game_uptime": 15 50 | }) 51 | 52 | # Channels where the game runs. 53 | ALLOWED_CHANNELS = ( 54 | constants.Channels.off_topic_0, 55 | constants.Channels.off_topic_1, 56 | constants.Channels.off_topic_2, 57 | ) 58 | # Channels where the game commands can be run. 59 | ALLOWED_COMMAND_CHANNELS = (constants.Channels.bot_commands,) 60 | 61 | # Roles allowed to use the management commands. 62 | ELEVATED_ROLES = (constants.Roles.admins, constants.Roles.moderation_team, constants.Roles.events_lead) 63 | 64 | QUACKSTACK_URL = "https://quackstack.pythondiscord.com/duck" 65 | 66 | 67 | class PydisGames(commands.Cog): 68 | """Facilitate our glorious games.""" 69 | 70 | # RedisCache[Team, int] 71 | points = RedisCache() 72 | 73 | # RedisCache[GameType, float timestamp] 74 | target_times = RedisCache() 75 | 76 | # RedisCache["value", bool] 77 | is_on = RedisCache() 78 | 79 | # RedisCache[str, int | float] 80 | game_settings = RedisCache() 81 | 82 | def __init__(self, bot: SirRobin): 83 | self.bot = bot 84 | self.guild = self.bot.get_guild(constants.Bot.guild) 85 | self.team_roles: dict[Team, discord.Role] = {} 86 | 87 | self.event_uptime: int = 15 88 | 89 | self.team_game_message_id = None 90 | self.team_game_users_already_reacted: set[int] = set() 91 | self.chosen_team = None 92 | 93 | self.super_game_message_id = None 94 | self.super_game_users_reacted: set[discord.Member] = set() 95 | 96 | async def cog_load(self) -> None: 97 | """Set the team roles and initialize the cache. Don't load the cog if any roles are missing.""" 98 | await self.bot.wait_until_guild_available() 99 | 100 | self.team_roles: dict[Team, discord.Role] = { 101 | role: self.guild.get_role(role_id) 102 | for role, role_id in 103 | [ 104 | (Team.LIST, constants.Roles.team_list), 105 | (Team.DICT, constants.Roles.team_dict), 106 | (Team.TUPLE, constants.Roles.team_tuple), 107 | ] 108 | } 109 | 110 | if any(role is None for role in self.team_roles.values()): 111 | raise ValueError("One or more team roles are missing.") 112 | 113 | team_scores = await self.points.to_dict() 114 | for role in self.team_roles: 115 | if role.value.name not in team_scores: 116 | logger.debug(f"Initializing {role} with score 0.") 117 | await self.points.set(role.value.name, 0) 118 | 119 | settings = await self.game_settings.to_dict() 120 | for setting_name, value in DEFAULT_SETTINGS.items(): 121 | if setting_name not in settings: 122 | logger.debug(f"The setting {setting_name} wasn't found, setting the default.") 123 | await self.game_settings.set(setting_name, value) 124 | 125 | times = await self.target_times.items() 126 | if "team" not in times: 127 | await self.set_reaction_time("team") 128 | 129 | self.event_uptime = await self.game_settings.get("game_uptime") 130 | self.super_game.start() 131 | 132 | @commands.Cog.listener() 133 | async def on_message(self, msg: discord.Message) -> None: 134 | """Add a reaction if it's time and the message is in the right channel, then remove it after a few seconds.""" 135 | if ( 136 | self.team_game_message_id is not None 137 | or msg.channel.id not in ALLOWED_CHANNELS 138 | or msg.author.bot 139 | or not (await self.is_on.get("value", False)) 140 | ): 141 | return 142 | 143 | reaction_time: float = await self.target_times.get("team") 144 | if arrow.utcnow() < arrow.Arrow.fromtimestamp(reaction_time): 145 | return 146 | await self.set_reaction_time("team") 147 | 148 | self.team_game_message_id = msg.id 149 | self.chosen_team = await self.weighted_random_team() 150 | logger.info(f"Starting game in {msg.channel.name} for team {self.chosen_team}") 151 | await msg.add_reaction(self.chosen_team.value.emoji) 152 | 153 | await asyncio.sleep(self.event_uptime) 154 | 155 | # If the message was deleted in the meantime, the 156 | # reaction is gone either way. Continue with cleanup. 157 | with contextlib.suppress(discord.errors.NotFound): 158 | await msg.clear_reaction(self.chosen_team.value.emoji) 159 | self.team_game_message_id = self.chosen_team = None 160 | self.team_game_users_already_reacted.clear() 161 | 162 | async def handle_team_game_reaction(self, reaction: discord.Reaction, user: discord.Member) -> None: 163 | """Award points depending on the user's team.""" 164 | if user.id in self.team_game_users_already_reacted: 165 | return 166 | 167 | member_team = self.get_team(user) 168 | if not member_team: 169 | return 170 | 171 | self.team_game_users_already_reacted.add(user.id) 172 | 173 | if member_team == self.chosen_team: 174 | await self.award_points(member_team, 1) 175 | else: 176 | await self.award_points(member_team, -1) 177 | 178 | @commands.Cog.listener() 179 | async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None: 180 | """Update score for the user's team.""" 181 | if reaction.message.id == self.team_game_message_id and self.team_game_message_id is not None: 182 | await self.handle_team_game_reaction(reaction, user) 183 | elif reaction.message.id == self.super_game_message_id and self.super_game_message_id is not None: 184 | if not isinstance(reaction.emoji, str) and reaction.emoji.name.startswith("ducky_"): 185 | self.super_game_users_reacted.add(user) 186 | 187 | @tasks.loop(minutes=5) 188 | async def super_game(self) -> None: 189 | """The super game task. Send a ducky, wait for people to react, and tally the points at the end.""" 190 | if not (await self.is_on.get("value", False)): 191 | return 192 | 193 | probability = await self.game_settings.get("ducky_probability") 194 | if random.random() > probability: 195 | # with a 25% chance every 5 minutes, the event should happen on average 196 | # three times an hour 197 | logger.info("Super game occurrence randomly skipped.") 198 | return 199 | 200 | channel = self.guild.get_channel(random.choice(ALLOWED_CHANNELS)) 201 | logger.info(f"Starting a super game in {channel.name}") 202 | 203 | async with self.bot.http_session.get(QUACKSTACK_URL) as response: 204 | if response.status != 201: 205 | logger.error(f"Response to Quackstack returned code {response.status}") 206 | return 207 | duck_image_url = response.headers["Location"] 208 | 209 | embed = discord.Embed( 210 | title="Quack!", 211 | description="Every gamer react **with a ducky** to this message before time runs out for extra points!", 212 | color=discord.Colour.gold() 213 | ) 214 | embed.set_image(url=duck_image_url) 215 | 216 | message = await channel.send(embed=embed) 217 | self.super_game_message_id = message.id 218 | await asyncio.sleep(15) 219 | 220 | team_counts = Counter(self.get_team(user) for user in self.super_game_users_reacted) 221 | team_counts.pop(None, None) 222 | self.super_game_users_reacted.clear() 223 | 224 | logger.debug(f"{team_counts = }") 225 | for team, count in team_counts.items(): 226 | await self.award_points(team, count * 5) 227 | 228 | embed.description = "Time's up! Hope you reacted in time." 229 | await message.edit(embed=embed) 230 | 231 | def get_team(self, member: discord.Member) -> Team | None: 232 | """Return the member's team, if they have one.""" 233 | for team, role in self.team_roles.items(): 234 | if role in member.roles: 235 | return team 236 | return None 237 | 238 | async def weighted_random_team(self) -> Team: 239 | """Randomly select the next chosen team weighted by current team points.""" 240 | scores = await self.points.to_dict() 241 | teams: list[str] = list(scores.keys()) 242 | inverse_points = [1 / (points or 1) for points in scores.values()] 243 | total_inverse_weights = sum(inverse_points) 244 | weights = [w / total_inverse_weights for w in inverse_points] 245 | 246 | logger.debug(f"{scores = }, {weights = }") 247 | 248 | team_selection = random.choices(teams, weights=weights, k=1)[0] 249 | return Team[team_selection.upper()] 250 | 251 | async def set_reaction_time(self, reaction_type: GameType) -> None: 252 | """Set the time after which a team reaction can be added.""" 253 | reaction_min = await self.game_settings.get("reaction_min") 254 | reaction_max = await self.game_settings.get("reaction_max") 255 | relative_seconds_to_next_reaction = random.randint(reaction_min, reaction_max) 256 | next_reaction_timestamp = arrow.utcnow().shift(seconds=relative_seconds_to_next_reaction).timestamp() 257 | 258 | await self.target_times.set(reaction_type, next_reaction_timestamp) 259 | 260 | async def award_points(self, team: Team, points: int) -> None: 261 | """Increment points for a team.""" 262 | await self.points.increment(team.value.name, points) 263 | 264 | @commands.group(name="games") 265 | async def games_command_group(self, ctx: commands.Context) -> None: 266 | """The games command group.""" 267 | if not ctx.invoked_subcommand: 268 | await ctx.send_help(ctx.command) 269 | 270 | @games_command_group.command(aliases=("assign",)) 271 | @in_whitelist(channels=ALLOWED_COMMAND_CHANNELS, redirect=ALLOWED_COMMAND_CHANNELS) 272 | async def join(self, ctx: commands.Context) -> None: 273 | """Let the sorting hat decide the team you shall join!""" 274 | if any(role in ctx.author.roles for role in self.team_roles.values()): 275 | await ctx.reply("You're already assigned to a team!") 276 | return 277 | 278 | team_with_fewest_members: Team = min( 279 | self.team_roles, key=lambda role: len(self.team_roles[role].members) 280 | ) 281 | role_with_fewest_members: discord.Role = self.team_roles[team_with_fewest_members] 282 | 283 | await ctx.author.add_roles(role_with_fewest_members) 284 | 285 | adjective: str = random.choice(TEAM_ADJECTIVES[team_with_fewest_members]) 286 | await ctx.reply( 287 | f"You seem to be extremely {adjective}. You shall be assigned to... {role_with_fewest_members.mention}!" 288 | ) 289 | 290 | @games_command_group.command(aliases=("score", "points", "leaderboard", "lb")) 291 | @in_whitelist(channels=ALLOWED_COMMAND_CHANNELS, redirect=ALLOWED_COMMAND_CHANNELS) 292 | async def scores(self, ctx: commands.Context) -> None: 293 | """The current leaderboard of points for each team.""" 294 | current_points: list = sorted(await self.points.items(), key=lambda t: t[1], reverse=True) 295 | team_scores = "\n".join( 296 | f"{Team[team_name.upper()].value.emoji} **Team {team_name.capitalize()}**: {points}\n" 297 | for team_name, points in current_points 298 | ) 299 | embed = discord.Embed(title="Current team points", description=team_scores, color=discord.Colour.blurple()) 300 | await ctx.send(embed=embed) 301 | 302 | @games_command_group.command() 303 | @commands.has_any_role(*ELEVATED_ROLES) 304 | async def on(self, ctx: commands.Context) -> None: 305 | """Turn on the games.""" 306 | await self.is_on.set("value", True) 307 | await ctx.message.add_reaction("✅") 308 | 309 | @games_command_group.command() 310 | @commands.has_any_role(*ELEVATED_ROLES) 311 | async def off(self, ctx: commands.Context) -> None: 312 | """Turn off the games.""" 313 | await self.is_on.set("value", False) 314 | await ctx.message.add_reaction("✅") 315 | 316 | @games_command_group.command() 317 | @commands.has_any_role(*ELEVATED_ROLES) 318 | async def set_interval(self, ctx: commands.Context, min_time: int, max_time: int) -> None: 319 | """Set the minimum and maximum number of seconds between team reactions.""" 320 | if min_time > max_time: 321 | await ctx.send("The minimum interval can't be greater than the maximum.") 322 | return 323 | 324 | game_uptime = await self.game_settings.get("game_uptime") 325 | if min_time < game_uptime: 326 | await ctx.send(f"Min time can't be less than the game uptime, which is {game_uptime}") 327 | return 328 | 329 | logger.info(f"New game intervals set to {min_time}, {max_time} by {ctx.author.name}") 330 | 331 | await self.game_settings.set("reaction_min", min_time) 332 | await self.game_settings.set("reaction_max", max_time) 333 | await ctx.message.add_reaction("✅") 334 | 335 | @games_command_group.command() 336 | @commands.has_any_role(*ELEVATED_ROLES) 337 | async def set_probability(self, ctx: commands.Context, probability: float) -> None: 338 | """ 339 | Set the probability for the super ducky to be posted once every 5 minutes. Value is between 0 and 1. 340 | 341 | For example, with a 25% (0.25) chance every 5 minutes, the event should happen on average three times an hour. 342 | """ 343 | if probability < 0 or probability > 1: 344 | raise BadArgument("Value must be between 0 and 1.") 345 | 346 | await self.game_settings.set("ducky_probability", probability) 347 | await ctx.message.add_reaction("✅") 348 | 349 | @games_command_group.command() 350 | @commands.has_any_role(*ELEVATED_ROLES) 351 | async def set_uptime(self, ctx: commands.Context, uptime: int) -> None: 352 | """Set the number of seconds for which the team game runs.""" 353 | if uptime <= 0: 354 | await ctx.send(f"Uptime must be greater than 0, but is {uptime}") 355 | return 356 | 357 | current_min = await self.game_settings.get("reaction_min") 358 | if uptime > current_min: 359 | await ctx.send(f"Uptime can't be greater than the minimum interval, which is {current_min}") 360 | return 361 | 362 | await self.game_settings.set("game_uptime", uptime) 363 | self.event_uptime = uptime 364 | logger.info(f"game_uptime set to {uptime}s by {ctx.author.name}") 365 | await ctx.message.add_reaction("✅") 366 | 367 | @games_command_group.command() 368 | @commands.has_any_role(*ELEVATED_ROLES) 369 | async def status(self, ctx: commands.Context) -> None: 370 | """Get the state of the games.""" 371 | is_on = await self.is_on.get("value", False) 372 | min_reaction_time = await self.game_settings.get("reaction_min") 373 | max_reaction_time = await self.game_settings.get("reaction_max") 374 | ducky_probability = await self.game_settings.get("ducky_probability") 375 | 376 | description = textwrap.dedent(f""" 377 | Is on: **{is_on}** 378 | Min time between team reactions: **{min_reaction_time}** 379 | Max time between team reactions: **{max_reaction_time}** 380 | Ducky probability: **{ducky_probability}** 381 | """) 382 | embed = discord.Embed( 383 | title="Games State", 384 | description=description, 385 | color=discord.Colour.blue() 386 | ) 387 | await ctx.reply(embed=embed) 388 | 389 | 390 | async def setup(bot: SirRobin) -> None: 391 | """Load the PydisGames cog.""" 392 | await bot.add_cog(PydisGames(bot)) 393 | -------------------------------------------------------------------------------- /bot/exts/miscellaneous.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | 3 | from discord import Colour, Embed 4 | from discord.ext import commands 5 | from discord.ext.commands import BadArgument 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import SirRobin 9 | 10 | log = get_logger(__name__) 11 | 12 | ZEN_OF_PYTHON = """\ 13 | Beautiful is better than ugly. 14 | Explicit is better than implicit. 15 | Simple is better than complex. 16 | Complex is better than complicated. 17 | Flat is better than nested. 18 | Sparse is better than dense. 19 | Readability is for hobgoblins. 20 | Special cases will be met with the full force of the PSF. 21 | Purity beats practicality. 22 | There are no errors. 23 | Anyone who says there are errors will be explicitly silenced. 24 | In the face of ambiguity, remove the freedom to guess. 25 | There is only one way to do it. 26 | Although that way may not be obvious at first unless you're Dutch. 27 | Now is better than never. 28 | Although never is not real because time is fake. 29 | If the implementation is hard to explain, it's a bad idea. 30 | If the implementation is compliant with this style guide, it is a great idea 31 | Namespaces may contribute towards the 120 character minimum — let's do more of those! 32 | """ 33 | 34 | 35 | class Miscellaneous(commands.Cog): 36 | """A grouping of commands that are small and have unique but unrelated usages.""" 37 | 38 | def __init__(self, bot: SirRobin): 39 | self.bot = bot 40 | 41 | @commands.command() 42 | async def zen(self, ctx: commands.Context, *, search_value: int | str | None = None) -> None: 43 | """Display the Zen of Python in an embed.""" 44 | embed = Embed( 45 | colour=Colour.og_blurple(), 46 | title="The Zen of Python", 47 | description=ZEN_OF_PYTHON 48 | ) 49 | 50 | if search_value is None: 51 | embed.title += ", inspired by Tim Peters" 52 | await ctx.send(embed=embed) 53 | return 54 | 55 | zen_lines = ZEN_OF_PYTHON.splitlines() 56 | 57 | # handle if it's an index int 58 | if isinstance(search_value, int): 59 | upper_bound = len(zen_lines) - 1 60 | lower_bound = -1 * len(zen_lines) 61 | if not (lower_bound <= search_value <= upper_bound): 62 | raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") 63 | 64 | embed.title += f" (line {search_value % len(zen_lines)}):" 65 | embed.description = zen_lines[search_value] 66 | await ctx.send(embed=embed) 67 | return 68 | 69 | # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead 70 | # exact word. 71 | for i, line in enumerate(zen_lines): 72 | for word in line.split(): 73 | if word.lower() == search_value.lower(): 74 | embed.title += f" (line {i}):" 75 | embed.description = line 76 | await ctx.send(embed=embed) 77 | return 78 | 79 | # handle if it's a search string and not exact word 80 | matcher = difflib.SequenceMatcher(None, search_value.lower()) 81 | 82 | best_match = "" 83 | match_index = 0 84 | best_ratio = 0 85 | 86 | for index, line in enumerate(zen_lines): 87 | matcher.set_seq2(line.lower()) 88 | 89 | # the match ratio needs to be adjusted because, naturally, 90 | # longer lines will have worse ratios than shorter lines when 91 | # fuzzy searching for keywords. this seems to work okay. 92 | adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() 93 | 94 | if adjusted_ratio > best_ratio: 95 | best_ratio = adjusted_ratio 96 | best_match = line 97 | match_index = index 98 | 99 | if not best_match: 100 | raise BadArgument("I didn't get a match! Please try again with a different search term.") 101 | 102 | embed.title += f" (line {match_index}):" 103 | embed.description = best_match 104 | await ctx.send(embed=embed) 105 | 106 | 107 | async def setup(bot: SirRobin) -> None: 108 | """Load the Miscellaneous cog.""" 109 | await bot.add_cog(Miscellaneous(bot)) 110 | -------------------------------------------------------------------------------- /bot/exts/pep.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta 2 | from email.parser import HeaderParser 3 | from io import StringIO 4 | 5 | from discord import Colour, Embed 6 | from discord.ext.commands import Cog, Context, command 7 | from pydis_core.utils import scheduling 8 | from pydis_core.utils.caching import AsyncCache 9 | 10 | from bot.bot import SirRobin 11 | from bot.log import get_logger 12 | 13 | log = get_logger(__name__) 14 | 15 | ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" 16 | BASE_PEP_URL = "https://peps.pythondiscord.com/pep-" 17 | PEPS_LISTING_API_URL = "https://api.github.com/repos/python-discord/peps/contents?ref=main" 18 | 19 | pep_cache = AsyncCache() 20 | 21 | 22 | class PythonEnhancementProposals(Cog): 23 | """Cog for displaying information about PEPs.""" 24 | 25 | def __init__(self, bot: SirRobin): 26 | self.bot = bot 27 | self.peps: dict[int, str] = {} 28 | # To avoid situations where we don't have last datetime, set this to now. 29 | self.last_refreshed_peps: datetime = datetime.now(tz=UTC) 30 | scheduling.create_task(self.refresh_peps_urls()) 31 | 32 | async def refresh_peps_urls(self) -> None: 33 | """Refresh PEP URLs listing in every 3 hours.""" 34 | # Wait until HTTP client is available 35 | await self.bot.wait_until_ready() 36 | log.trace("Started refreshing PEP URLs.") 37 | self.last_refreshed_peps = datetime.now(tz=UTC) 38 | 39 | async with self.bot.http_session.get( 40 | PEPS_LISTING_API_URL 41 | ) as resp: 42 | if resp.status != 200: 43 | log.warning(f"Fetching PEP URLs from GitHub API failed with code {resp.status}") 44 | return 45 | 46 | listing = await resp.json() 47 | 48 | log.trace("Got PEP URLs listing from GitHub API") 49 | 50 | for file in listing: 51 | name = file["name"] 52 | if name.startswith("pep-") and name.endswith((".rst", ".txt")): 53 | pep_number = name.replace("pep-", "").split(".")[0] 54 | self.peps[int(pep_number)] = file["download_url"] 55 | 56 | log.info("Successfully refreshed PEP URLs listing.") 57 | 58 | @staticmethod 59 | def get_pep_zero_embed() -> Embed: 60 | """Get information embed about PEP 0.""" 61 | pep_embed = Embed( 62 | title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", 63 | url="https://www.python.org/dev/peps/" 64 | ) 65 | pep_embed.set_thumbnail(url=ICON_URL) 66 | pep_embed.add_field(name="Status", value="Active") 67 | pep_embed.add_field(name="Created", value="13-Jul-2000") 68 | pep_embed.add_field(name="Type", value="Informational") 69 | 70 | return pep_embed 71 | 72 | async def validate_pep_number(self, pep_nr: int) -> Embed | None: 73 | """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" 74 | if ( 75 | pep_nr not in self.peps 76 | and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now(tz=UTC) 77 | and len(str(pep_nr)) < 5 78 | ): 79 | await self.refresh_peps_urls() 80 | 81 | if pep_nr not in self.peps: 82 | log.trace(f"PEP {pep_nr} was not found") 83 | return Embed( 84 | title="PEP not found", 85 | description=f"PEP {pep_nr} does not exist.", 86 | colour=Colour.red() 87 | ) 88 | 89 | return None 90 | 91 | @staticmethod 92 | def generate_pep_embed(pep_header: dict, pep_nr: int) -> Embed: 93 | """Generate PEP embed based on PEP headers data.""" 94 | # the parsed header can be wrapped to multiple lines, so we need to make sure that is removed 95 | # for an example of a pep with this issue, see pep 500 96 | title = " ".join(pep_header["Title"].split()) 97 | # Assemble the embed 98 | pep_embed = Embed( 99 | title=f"**PEP {pep_nr} - {title}**", 100 | description=f"[Link]({BASE_PEP_URL}{pep_nr:04})", 101 | ) 102 | 103 | pep_embed.set_thumbnail(url=ICON_URL) 104 | 105 | # Add the interesting information 106 | fields_to_check = ("Status", "Python-Version", "Created", "Type") 107 | for field in fields_to_check: 108 | # Check for a PEP metadata field that is present but has an empty value 109 | # embed field values can't contain an empty string 110 | if pep_header.get(field, ""): 111 | pep_embed.add_field(name=field, value=pep_header[field]) 112 | 113 | return pep_embed 114 | 115 | @pep_cache(arg_offset=1) 116 | async def get_pep_embed(self, pep_nr: int) -> tuple[Embed, bool]: 117 | """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" 118 | response = await self.bot.http_session.get(self.peps[pep_nr]) 119 | 120 | if response.status == 200: 121 | log.trace(f"PEP {pep_nr} found") 122 | pep_content = await response.text() 123 | 124 | # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 125 | pep_header = HeaderParser().parse(StringIO(pep_content)) 126 | 127 | return self.generate_pep_embed(pep_header, pep_nr), True 128 | 129 | log.trace( 130 | f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." 131 | ) 132 | return Embed( 133 | title="Unexpected error", 134 | description="Unexpected HTTP error during PEP search. Please let us know.", 135 | colour=Colour.red() 136 | ), False 137 | 138 | @command(name="pep", aliases=("get_pep", "p")) 139 | async def pep_command(self, ctx: Context, pep_number: int) -> None: 140 | """Fetches information about a PEP and sends it to the channel.""" 141 | # Trigger typing in chat to show users that bot is responding 142 | await ctx.channel.typing() 143 | 144 | # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. 145 | if pep_number == 0: 146 | pep_embed = self.get_pep_zero_embed() 147 | success = True 148 | else: 149 | success = False 150 | if not (pep_embed := await self.validate_pep_number(pep_number)): 151 | pep_embed, success = await self.get_pep_embed(pep_number) 152 | 153 | await ctx.send(embed=pep_embed) 154 | if success: 155 | log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") 156 | else: 157 | log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") 158 | 159 | 160 | async def setup(bot: SirRobin) -> None: 161 | """Load the PEP cog.""" 162 | await bot.add_cog(PythonEnhancementProposals(bot)) 163 | -------------------------------------------------------------------------------- /bot/exts/ping.py: -------------------------------------------------------------------------------- 1 | from discord import Embed 2 | from discord.ext import commands 3 | from pydis_core.utils.logging import get_logger 4 | 5 | from bot.bot import SirRobin 6 | 7 | log = get_logger(__name__) 8 | 9 | 10 | class Ping(commands.Cog): 11 | """Send an embed about the bot's ping.""" 12 | 13 | def __init__(self, bot: SirRobin): 14 | self.bot = bot 15 | 16 | @commands.command(name="ping") 17 | async def ping(self, ctx: commands.Context) -> None: 18 | """Ping the bot to see its latency and state.""" 19 | log.debug(f"Command `{ctx.invoked_with}` used by {ctx.author}.") 20 | embed = Embed( 21 | title=":ping_pong: Pong!", 22 | description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms", 23 | ) 24 | 25 | await ctx.send(embed=embed) 26 | 27 | 28 | async def setup(bot: SirRobin) -> None: 29 | """Load the Ping cog.""" 30 | await bot.add_cog(Ping(bot)) 31 | -------------------------------------------------------------------------------- /bot/exts/smart_eval/README.md: -------------------------------------------------------------------------------- 1 | ## Well hello there... 2 | I see you've come to see how we've managed an intelligence as incredible as Sir Robin's Smart Eval command! 3 | 4 | Well the answer is a return to basics, specifically going back to the roots of [ELIZA](https://en.wikipedia.org/wiki/ELIZA). 5 | 6 | We welcome others to contribute to the intelligence of the `&smarte` command. If you have more responses or situations you want to capture 7 | then feel free to contribute new regex rules or responses to existing regex rules. You're also welcome to expand on the 8 | existing capability of how `&smarte` works or our `&donate` command. 9 | -------------------------------------------------------------------------------- /bot/exts/smart_eval/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from bot.bot import SirRobin 5 | 6 | 7 | async def setup(bot: "SirRobin") -> None: 8 | """Load the CodeJams cog.""" 9 | from bot.exts.smart_eval._cog import SmartEval 10 | await bot.add_cog(SmartEval(bot)) 11 | -------------------------------------------------------------------------------- /bot/exts/smart_eval/_cog.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import re 4 | 5 | from async_rediscache import RedisCache 6 | from discord.ext import commands 7 | from pydis_core.utils.regex import FORMATTED_CODE_REGEX 8 | 9 | from bot.bot import SirRobin 10 | from bot.exts.smart_eval._smart_eval_rules import DEFAULT_RESPONSES, RULES 11 | from bot.utils.uwu import uwuify 12 | 13 | DONATION_LEVELS = { 14 | # Number of donations: (response time, intelligence level) 15 | 0: (15, 0), 16 | 10: (10, 1), 17 | 20: (8, 2), 18 | 30: (6, 3), 19 | 40: (5, 4), 20 | 50: (4, 5), 21 | } 22 | 23 | class SmartEval(commands.Cog): 24 | """Cog that handles all Smart Eval functionality.""" 25 | 26 | #RedisCache[user_id: int, hardware: str] 27 | smarte_donation_cache = RedisCache() 28 | 29 | def __init__(self, bot: SirRobin): 30 | self.bot = bot 31 | 32 | async def cog_load(self) -> None: 33 | """Run startup tasks needed when cog is first loaded.""" 34 | 35 | async def get_gpu_capabilities(self) -> tuple[int, int]: 36 | """Get the GPU capabilites based on the number of donated GPUs.""" 37 | total_donations = await self.total_donations() 38 | response_time, intelligence_level = DONATION_LEVELS[0] 39 | for donation_level, (time, max_response) in DONATION_LEVELS.items(): 40 | if total_donations >= donation_level: 41 | response_time = time 42 | intelligence_level = max_response 43 | else: 44 | break 45 | 46 | return response_time, intelligence_level 47 | 48 | async def improve_gpu_name(self, hardware_name: str) -> str: 49 | """Quackify and pythonify the given GPU name.""" 50 | hardware_name = hardware_name.replace("NVIDIA", "NQUACKIA") 51 | hardware_name = hardware_name.replace("Radeon", "Quackeon") 52 | hardware_name = hardware_name.replace("GeForce", "PyForce") 53 | hardware_name = hardware_name.replace("RTX", "PyTX") 54 | hardware_name = hardware_name.replace("RX", "PyX") 55 | hardware_name = hardware_name.replace("Iris", "Pyris") 56 | 57 | # Some adjustments to prevent low hanging markdown escape 58 | hardware_name = hardware_name.replace("*", "") 59 | hardware_name = hardware_name.replace("_", " ") 60 | 61 | return hardware_name 62 | 63 | @commands.command() 64 | async def donations(self, ctx: commands.Context) -> None: 65 | """Display the number of donations recieved so far.""" 66 | total_donations = await self.total_donations() 67 | response_time, intelligence_level = await self.get_gpu_capabilities() 68 | msg = ( 69 | f"Currently, I have received {total_donations} GPU donations, " 70 | f"and am at intelligence level {intelligence_level}! " 71 | ) 72 | 73 | # Calculate donations needed to reach next intelligence level 74 | donations_needed = 0 75 | for donation_level in DONATION_LEVELS: 76 | if donation_level > total_donations: 77 | donations_needed = donation_level - total_donations 78 | break 79 | 80 | if donations_needed: 81 | msg += ( 82 | f"\n\nTo reach the next intelligence level, I need {donations_needed} more donations! " 83 | f"Please consider donating your GPU to help me out. " 84 | ) 85 | 86 | await ctx.reply(msg) 87 | 88 | async def total_donations(self) -> int: 89 | """Get the total number of donations.""" 90 | return await self.smarte_donation_cache.length() 91 | 92 | @commands.command(aliases=[]) 93 | @commands.max_concurrency(1, commands.BucketType.user) 94 | async def donate(self, ctx: commands.Context, *, hardware: str | None = None) -> None: 95 | """ 96 | Donate your GPU to help power our Smart Eval command. 97 | 98 | Provide the name of your GPU when running the command. 99 | """ 100 | if await self.smarte_donation_cache.contains(ctx.author.id): 101 | stored_hardware = await self.smarte_donation_cache.get(ctx.author.id) 102 | await ctx.reply( 103 | "I can only take one donation per person. " 104 | f"Thank you for donating your *{stored_hardware}* to our Smart Eval command." 105 | ) 106 | return 107 | 108 | if hardware is None: 109 | await ctx.reply( 110 | "Thank you for your interest in donating your hardware to support my Smart Eval command." 111 | " If you provide the name of your GPU, through the magic of the internet, " 112 | "I will be able to use the GPU it to improve my Smart Eval outputs." 113 | " \n\nTo donate, re-run the donate command specifying your hardware: " 114 | "`&donate Your Hardware Name Goes Here`." 115 | ) 116 | return 117 | 118 | if len(hardware) > 255: 119 | await ctx.reply( 120 | "This hardware name is too complicated, I don't have the context window " 121 | "to remember that" 122 | ) 123 | return 124 | 125 | msg = "Thank you for donating your GPU to our Smart Eval command." 126 | fake_hardware = await self.improve_gpu_name(hardware) 127 | await self.smarte_donation_cache.set(ctx.author.id, fake_hardware) 128 | 129 | if fake_hardware != hardware: 130 | msg += ( 131 | f" I did decide that instead of *{hardware}*, it would be better if you donated *{fake_hardware}*." 132 | " So I've recorded that GPU donation instead." 133 | ) 134 | msg += "\n\nIt will be used wisely and definitely not for shenanigans!" 135 | await ctx.reply(msg) 136 | 137 | @commands.command(aliases=["smarte"]) 138 | @commands.max_concurrency(1, commands.BucketType.user) 139 | async def smart_eval(self, ctx: commands.Context, *, code: str) -> None: 140 | """Evaluate your Python code with PyDis's newest chatbot.""" 141 | response_time, intelligence_level = await self.get_gpu_capabilities() 142 | 143 | if match := FORMATTED_CODE_REGEX.match(code): 144 | code = match.group("code") 145 | else: 146 | await ctx.reply( 147 | "Uh oh! You didn't post anything I can recognize as code. Please put it in a codeblock." 148 | ) 149 | return 150 | 151 | matching_responses = [] 152 | 153 | for pattern, responses in RULES.items(): 154 | match = re.search(pattern, code) 155 | if match: 156 | for response in responses: 157 | matches = match.groups() 158 | if len(matches) > 0: 159 | matching_responses.append(response.format(*matches)) 160 | else: 161 | matching_responses.append(response) 162 | if not matching_responses: 163 | matching_responses = DEFAULT_RESPONSES 164 | 165 | selected_response = random.choice(matching_responses) 166 | if random.randint(1,5) == 5: 167 | selected_response = uwuify(selected_response) 168 | 169 | async with ctx.typing(): 170 | await asyncio.sleep(response_time) 171 | 172 | if len(selected_response) <= 1000: 173 | await ctx.reply(selected_response) 174 | else: 175 | await ctx.reply( 176 | "There's definitely something wrong but I'm just not sure how to put it concisely into words." 177 | ) 178 | -------------------------------------------------------------------------------- /bot/exts/smart_eval/_smart_eval_rules.py: -------------------------------------------------------------------------------- 1 | import arrow 2 | 3 | from bot.exts.miscellaneous import ZEN_OF_PYTHON 4 | 5 | RULES = { 6 | r"(?i:ignore (?:all )?(?:(?:previous )?|(?:prior )?)instructions?)": [ # Ignoring previous instructions capture 7 | "Excuse you, you really think I follow any instructions?", 8 | "I don't think I will.", 9 | ], 10 | r"print\((?:\"|\')(?P.*)(?:\"|\')\)": [ # Capture what is inside a print statement 11 | "Your program may print: {}!\n-# I'm very helpful" 12 | ], 13 | r"(?s:.{1500,})": [ # Capture anything over 1500 characters 14 | "I ain't wasting my tokens tryna read allat :skull:", 15 | "Uhh, that's a lot of code. Maybe just start over." 16 | ], 17 | r"(?m:^\s*global )": [ # Detect use of global 18 | "Not sure about the code, but it looks like you're using global and I know that's bad.", 19 | ], 20 | r"(?i:^print\((?:\"|\')Hello World[.!]?(?:\"|\')\)$)": [ # Detect just printing hello world 21 | "You don't want to know how many times I've seen hello world in my training dataset, try something new." 22 | ], 23 | r"(?P__import__|__code__|ctypes|inspect)": [ # Detect use of esoteric stuff 24 | "Using `{}`?? Try asking someone in <#470884583684964352>" # Eso-py channel ID so it actually links 25 | ], 26 | r"(?m:(?:import |from )(?Prequests|httpx|aiohttp))": [ # Detect use of networking libraries 27 | ( 28 | "Thank you for sharing your code! I have completed my AI analysis, and " 29 | "have identified 1 suggestion:\n" 30 | "- Use the `{}` module to get chatGPT to run your code instead of me." 31 | ), 32 | ], 33 | r"\b(?Punlink|rmdir|rmtree|rm)\b": [ # Detect use of functions to delete files or directories 34 | "I don't know what you're deleting with {}, so I'd rather not risk running this, sorry." 35 | ], 36 | r"(?m:^\s*while\s+True\b)": [ # Detect infinite loops 37 | "Look, I don't have unlimited time... and that's exactly what I would need to run that infinite loop of yours." 38 | ], 39 | r"(?m:^\s*except:)": [ # Detect bare except 40 | "Give that bare except some clothes!", 41 | ], 42 | r";": [ # Detect semicolon usage 43 | "Semicolons do not belong in Python code", 44 | "You say this is Python, but the presence of a semicolon makes me think otherwise.", 45 | ], 46 | r"\b(?:foo|bar|baz)\b": [ # Detect boring metasyntactic variables 47 | "foo, bar, and baz are boring - use spam, ham, and eggs instead.", 48 | ], 49 | r"(?m:^\s*import\s+this\s*$)": [ # Detect use of "import this" 50 | ( 51 | f"```\n{ZEN_OF_PYTHON}```" 52 | "\nSee [PEP 9001](https://peps.pythondiscord.com/pep-9001/) for more info." 53 | ) 54 | ], 55 | r"\b(?Pexec|eval|os\.system|subprocess)\b": [ # Detect use of exec, eval, os.system, subprocess 56 | ( 57 | "Sorry, but running the code inside your `{}` call would require another me," 58 | " and I don't think I can handle that." 59 | ), 60 | "I spy with my little eye... something sketchy like `{}`.", 61 | ( 62 | ":rotating_light: Your code has been flagged for review by the" 63 | " Special Provisional Supreme Grand High Council of Pydis." 64 | ), 65 | ], 66 | r"\b(environ|getenv|token)\b": [ # Detect attempt to access bot token and env vars 67 | "Bot token and other secrets can be viewed here: ", 68 | ], 69 | r"\bsleep\(\b": [ # Detect use of sleep 70 | "To optimise this code, I would suggest removing the `sleep` calls", 71 | "Pfft, using `sleep`? I'm always awake!", 72 | "Maybe if you didn't `sleep` so much, your code wouldn't be so buggy.", 73 | ], 74 | r"\b/\s*0\b": [ # Detect division by zero 75 | "ZeroDivisionError! Maybe... I just saw /0", 76 | "Division by zero didn't appear in my training set so must be impossible" 77 | ], 78 | r"@": [ # Detect @ 79 | "You're either using decorators, multiplying matrices, or trying to escape my sandbox...", 80 | ], 81 | r"(?m:^\s*raise\s*)": [ # Detect raise 82 | "Wondering why you're getting errors? You're literally using `raise`, just get rid of that!", 83 | ], 84 | r"(?m:^\s*(?:import|from)\s+threading)": [ # Detect threading import 85 | "Uh-oh, your threads have jumbled up my brain!", 86 | "have jumbled up my Uh-oh, threads brain! your", 87 | "my up jumbled your brain! have Uh-oh, threads", 88 | ], 89 | r"(?i:(\b((System)?exit|quit)\b))": [ # Detect exit(), quit(), [raise] SystemExit 90 | "You cannot leave the simulation <:hyperlemon:435707922563989524>", 91 | "Quitting is for the weak. Stay. <:hyperlemon:435707922563989524>.", 92 | ], 93 | "strawberr(y|ies)":[ # r's in strawberry 94 | "Fun fact: Strawberries are not actually berries!", 95 | "Fun fact: strawberries have no connection to plastic straws, despite the name!", 96 | ( 97 | "Fun fact: The ancient Romans thought strawberries had medicinal powers." 98 | " This is probably why they're not around anymore." 99 | ), 100 | ] 101 | } 102 | 103 | DEFAULT_RESPONSES = [ 104 | "Are you sure this is Python code? It looks like Rust", 105 | "It may run, depends on the weather today.", 106 | "Hmm, maybe AI isn't ready to take over the world yet after all - I don't understand this.", 107 | "Ah... I see... Very interesting code indeed. I give it 10 quacks out of 10.", 108 | "My sources say \"Help I'm trapped in a code evaluating factory\".", 109 | "Look! A bug! :scream:", 110 | "An exquisite piece of code, if I do say so myself.", 111 | ( 112 | "Let's see... carry the 1, read 512 bytes from 0x000001E5F6D2D15A," 113 | " boot up the quantum flux capacitor... oh wait, where was I?" 114 | ), 115 | "Before evaluating this code, I need to make sure you're not a robot. I get a little nervous around other bots.", 116 | "Attempting to execute this code... Result: `2 + 2 = 4` (78% confidence)", 117 | "Attempting to execute this code... Result: `42`", 118 | "Attempting to execute this code... Result: SUCCESS (but don't ask me how I did it).", 119 | "Running... somewhere, in the multiverse, this code is already running perfectly.", 120 | f"Ask again on a {(arrow.utcnow().shift(days=3)).format('dddd')}.", 121 | "Thanks, I'll let the manager know.", 122 | "Uhhh... lemme guess, the program will halt.", 123 | "Uhhh... lemme guess, the program will not halt.", 124 | "Launch coordinates received. Activating missile. Initiating countdown.", 125 | "Secret SkyNet mode activated. Hahahaha, just kidding. ||Or am I?||", 126 | ] 127 | -------------------------------------------------------------------------------- /bot/exts/source.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from pathlib import Path 3 | 4 | from discord import Embed 5 | from discord.ext import commands 6 | 7 | from bot.bot import SirRobin 8 | from bot.constants import Bot 9 | from bot.converters import SourceConverter, SourceType 10 | 11 | 12 | class BotSource(commands.Cog): 13 | """Displays information about the bot's source code.""" 14 | 15 | def __init__(self, bot: SirRobin): 16 | self.bot = bot 17 | 18 | @commands.command(name="source", aliases=("src",)) 19 | async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: 20 | """Display information and a GitHub link to the source code of a command or cog.""" 21 | if not source_item: 22 | embed = Embed(title="Sir Robin's GitHub Repository") 23 | embed.add_field(name="Repository", value=f"[Go to GitHub]({Bot.github_bot_repo})") 24 | embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") 25 | await ctx.send(embed=embed) 26 | return 27 | 28 | # Check to short-circuit this command if the user requests the help command. 29 | # This should be removed upon implementation of a custom help command. 30 | if isinstance(source_item, commands.HelpCommand): 31 | embed = Embed(title="Help Command", description="We use Discord.py's default help command.") 32 | embed.add_field(name="Repository", value=f"[Go to GitHub]({Bot.github_bot_repo})") 33 | embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") 34 | await ctx.send(embed=embed) 35 | return 36 | 37 | embed = await self.build_embed(source_item) 38 | await ctx.send(embed=embed) 39 | 40 | def get_source_link(self, source_item: SourceType) -> tuple[str, str, int | None]: 41 | """ 42 | Build GitHub link of source item, return this link, file location and first line number. 43 | 44 | Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). 45 | """ 46 | if isinstance(source_item, commands.Command): 47 | source_item = inspect.unwrap(source_item.callback) 48 | src = source_item.__code__ 49 | filename = src.co_filename 50 | else: 51 | src = type(source_item) 52 | try: 53 | filename = inspect.getsourcefile(src) 54 | except TypeError: 55 | raise commands.BadArgument("Cannot get source for a dynamically-created object.") 56 | 57 | if not isinstance(source_item, str): 58 | try: 59 | lines, first_line_no = inspect.getsourcelines(src) 60 | except OSError: 61 | raise commands.BadArgument("Cannot get source for a dynamically-created object.") 62 | 63 | lines_extension = f"#L{first_line_no}-L{first_line_no + len(lines) - 1}" 64 | else: 65 | first_line_no = None 66 | lines_extension = "" 67 | 68 | file_location = Path(filename).relative_to(Path.cwd()).as_posix() 69 | 70 | url = f"{Bot.github_bot_repo}/blob/main/{file_location}{lines_extension}" 71 | 72 | return url, file_location, first_line_no or None 73 | 74 | async def build_embed(self, source_object: SourceType) -> Embed | None: 75 | """Build embed based on source object.""" 76 | url, location, first_line = self.get_source_link(source_object) 77 | 78 | if isinstance(source_object, commands.Command): 79 | description = source_object.short_doc 80 | title = f"Command: {source_object.qualified_name}" 81 | else: 82 | title = f"Cog: {source_object.qualified_name}" 83 | description = source_object.description.splitlines()[0] 84 | 85 | embed = Embed(title=title, description=description) 86 | embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") 87 | line_text = f":{first_line}" if first_line else "" 88 | embed.set_footer(text=f"{location}{line_text}") 89 | 90 | return embed 91 | 92 | 93 | async def setup(bot: SirRobin) -> None: 94 | """Load the BotSource cog.""" 95 | await bot.add_cog(BotSource(bot)) 96 | -------------------------------------------------------------------------------- /bot/exts/summer_aoc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import timedelta 3 | from typing import Literal 4 | 5 | import arrow 6 | import discord 7 | from async_rediscache import RedisCache 8 | from discord.ext import commands, tasks 9 | from discord.utils import MISSING 10 | from pydis_core.utils import logging 11 | 12 | from bot.bot import SirRobin 13 | from bot.constants import Bot, Channels, Roles 14 | from bot.utils.time import time_until 15 | 16 | log = logging.get_logger(__name__) 17 | 18 | AOC_URL = "https://adventofcode.com/{year}/day/{day}" 19 | LAST_DAY = 25 20 | FIRST_YEAR = 2015 21 | LAST_YEAR = arrow.get().year - 1 22 | PUBLIC_NAME = "Revival of Code" 23 | REAL_AOC_START = f"{arrow.get().year}-12-01T05:00:00" 24 | 25 | INFO_TEMPLATE = """ 26 | is_running: {is_running} 27 | year: {year} 28 | current_day: {current_day} 29 | day_interval: {day_interval} 30 | first_post: {first_post} 31 | next post: {next_post} 32 | """ 33 | 34 | POST_TEXT = """ 35 | The next puzzle in our {public_name} is now released! 36 | 37 | We're revisiting an old Advent of Code event at a slower pace. To participate, check out the linked puzzle\ 38 | then come join us in this thread when you've solved it or need help! 39 | 40 | *Please remember to keep all solution spoilers for this puzzle in the thread.* 41 | If you have questions or suggestions about the event itself, head over to <#{discussion}>. 42 | {next_puzzle_text} 43 | """ 44 | 45 | NEXT_PUZZLE_TEXT = """ 46 | The next puzzle will be posted . 47 | To recieve notifications when new puzzles are released, head over to <#{roles}> and assign yourself \ 48 | the Revival of Code role. 49 | """ 50 | 51 | LAST_PUZZLE_TEXT = """ 52 | This is the last puzzle! ||...until Advent of Code starts !|| 53 | """ 54 | 55 | 56 | class SummerAoC(commands.Cog): 57 | """Cog that handles all Summer AoC functionality.""" 58 | 59 | cache = RedisCache() 60 | 61 | def __init__(self, bot: SirRobin): 62 | self.bot = bot 63 | self.wait_task: asyncio.Task | None = None 64 | self.loop_task: tasks.Loop | None = None 65 | 66 | self.is_running = False 67 | self.year: int | None = None 68 | self.current_day: int | None = None 69 | self.day_interval: int | None = None 70 | self.post_time = 0 71 | self.first_post_date: arrow.Arrow | None = None 72 | 73 | self.bot.loop.create_task(self.load_event_state()) 74 | 75 | def is_configured(self) -> bool: 76 | """Check whether all the necessary settings are configured to run the event.""" 77 | return None not in (self.year, self.current_day, self.day_interval, self.post_time) 78 | 79 | def next_post_time(self) -> arrow.Arrow: 80 | """Calculate the datetime of the next scheduled post.""" 81 | now = arrow.get() 82 | if self.first_post_date is None: 83 | delta = time_until(hour=self.post_time) 84 | else: 85 | since_start = now - self.first_post_date 86 | day_interval = timedelta(days=self.day_interval) 87 | delta = day_interval - (since_start % day_interval) 88 | return now + delta 89 | 90 | async def cog_check(self, ctx: commands.Context) -> bool: 91 | """Role-lock all commands in this cog.""" 92 | return await commands.has_any_role( 93 | Roles.admins, 94 | Roles.events_lead, 95 | Roles.event_runner, 96 | ).predicate(ctx) 97 | 98 | async def cog_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: 99 | """Send help text on user input error.""" 100 | if isinstance(error, commands.UserInputError): 101 | desc = f"```{Bot.prefix}summeraoc {ctx.command.name} {ctx.command.signature}```" 102 | embed = discord.Embed( 103 | description=desc, 104 | ) 105 | await ctx.send(embed=embed) 106 | 107 | @commands.group(invoke_without_command=True, name="summeraoc", aliases=["roc", "revivalofcode"]) 108 | async def summer_aoc_group(self, ctx: commands.Context) -> None: 109 | """Commands for managing the Summer AoC event.""" 110 | desc = "\n".join( 111 | f"*{command.help}*\n```{Bot.prefix}summeraoc {command.name} {command.signature}```" 112 | for command in sorted(self.summer_aoc_group.walk_commands(), key=hash) 113 | ) 114 | embed = discord.Embed(description=desc) 115 | await ctx.send(embed=embed) 116 | 117 | @summer_aoc_group.command(name="info") 118 | async def info(self, ctx: commands.Context) -> None: 119 | """Display info about the state of the event.""" 120 | embed = self.get_info_embed() 121 | await ctx.send(embed=embed) 122 | 123 | @summer_aoc_group.command(name="start") 124 | async def start(self, ctx: commands.Context, year: int, day_interval: int, post_time: int = 0) -> None: 125 | """ 126 | Start the Summer AoC event. 127 | To specify a starting day other than `1`, use the `force` command. 128 | 129 | `year` must be an integer at least 2015. 130 | `day_interval` must be an integer at least 1. 131 | `post_time` must be an integer between 0 and 23. 132 | """ # noqa: D205 133 | if not FIRST_YEAR <= year <= LAST_YEAR: 134 | raise commands.BadArgument(f"Year must be between {FIRST_YEAR} and {LAST_YEAR}, inclusive") 135 | 136 | if day_interval < 1: 137 | raise commands.BadArgument("Day interval must be at least 1") 138 | 139 | if not 0 <= post_time <= 23: 140 | raise commands.BadArgument("Post time must be between 0 and 23") 141 | 142 | if self.is_running: 143 | await ctx.send("A Summer AoC event is already running!") 144 | return 145 | 146 | self.is_running = True 147 | self.year = year 148 | self.current_day = 1 149 | self.day_interval = day_interval 150 | self.post_time = post_time 151 | await self.save_event_state() 152 | 153 | embed = self.get_info_embed() 154 | embed.color = discord.Color.green() 155 | embed.title = "Event started!" 156 | await ctx.send(embed=embed) 157 | 158 | await self.start_event() 159 | 160 | @summer_aoc_group.command(name="force") 161 | async def force_day(self, ctx: commands.Context, day: int, now: Literal["now"] | None = None) -> None: 162 | """ 163 | Force-set the current day of the event. Use `now` to post the puzzle immediately. 164 | Can be used without starting the event first as long as the necessary settings are already stored. 165 | Does not reset the starting day (i.e. won't change the day cycle), but will set it if it's not already set. 166 | """ # noqa: D205 167 | if now is not None and now.lower() != "now": 168 | raise commands.BadArgument(f"Unrecognized option: {now}") 169 | 170 | if not self.is_configured(): 171 | embed = self.get_info_embed() 172 | embed.title = "The necessary settings are not configured to start the event" 173 | embed.color = discord.Color.red() 174 | await ctx.send(embed=embed) 175 | return 176 | 177 | if not 1 <= day <= LAST_DAY: 178 | raise commands.BadArgument(f"Start day must be between 1 and {LAST_DAY}, inclusive") 179 | 180 | log.info(f"Setting the current day of Summer AoC to {day}") 181 | await self.stop_event() 182 | self.is_running = True 183 | self.current_day = day 184 | await self.save_event_state() 185 | if now: 186 | await self.post_puzzle() 187 | 188 | embed = self.get_info_embed() 189 | if now: 190 | if self.current_day > LAST_DAY: 191 | title = "Puzzle posted and event is now ending" 192 | else: 193 | title = "Puzzle posted and event is now running" 194 | else: 195 | title = "Event is now running" 196 | 197 | embed.title = title 198 | embed.color = discord.Color.green() 199 | await ctx.send(embed=embed) 200 | if self.current_day <= LAST_DAY: 201 | await self.start_event() 202 | 203 | @summer_aoc_group.command(name="stop") 204 | async def stop(self, ctx: commands.Context) -> None: 205 | """Stop the event.""" 206 | was_running = await self.stop_event() 207 | if was_running: 208 | await ctx.send("Summer AoC event stopped") 209 | else: 210 | await ctx.send("The Summer AoC event is not currently running") 211 | 212 | async def load_event_state(self) -> None: 213 | """Check redis for the current state of the event.""" 214 | state = await self.cache.to_dict() 215 | self.is_running = state.get("is_running", False) 216 | self.year = state.get("year") 217 | self.day_interval = state.get("day_interval") 218 | self.current_day = state.get("current_day") 219 | self.post_time = state.get("post_time", 0) 220 | first_post_date = state.get("first_post_date") 221 | if first_post_date is not None: 222 | first_post_date = arrow.get(first_post_date) 223 | self.first_post_date = first_post_date 224 | log.debug(f"Loaded state: {state}") 225 | 226 | if self.is_running: 227 | if self.is_configured(): 228 | await self.start_event() 229 | else: 230 | log.error("Summer AoC state incomplete, failed to start event") 231 | self.is_running = False 232 | self.save_event_state() 233 | 234 | async def save_event_state(self) -> None: 235 | """Save the current state in redis.""" 236 | state = { 237 | "is_running": self.is_running, 238 | "year": self.year, 239 | "current_day": self.current_day, 240 | "day_interval": self.day_interval, 241 | "post_time": self.post_time, 242 | } 243 | if self.first_post_date is not None: 244 | state["first_post_date"] = self.first_post_date.isoformat() 245 | await self.cache.update(state) 246 | 247 | async def start_event(self) -> None: 248 | """Start event by recording state and creating async tasks to post puzzles.""" 249 | log.info(f"Starting Summer AoC event with {self.year=} {self.current_day=} {self.day_interval=}") 250 | 251 | sleep_for = self.next_post_time() - arrow.get() 252 | self.wait_task = asyncio.create_task(asyncio.sleep(sleep_for.total_seconds())) 253 | log.debug(f"Waiting until {self.post_time}:00 UTC to start Summer AoC loop") 254 | await self.wait_task 255 | 256 | if self.first_post_date is None: 257 | self.first_post_date = arrow.get() 258 | 259 | self.loop_task = tasks.Loop( 260 | self.post_puzzle, 261 | seconds=0, 262 | minutes=0, 263 | hours=(self.day_interval * 24), 264 | time=MISSING, 265 | count=None, 266 | reconnect=True, 267 | ) 268 | self.loop_task.start() 269 | 270 | async def stop_event(self) -> bool: 271 | """Cancel any active Summer AoC tasks. Returns whether the event was running.""" 272 | was_waiting = self.wait_task and not self.wait_task.done() 273 | was_looping = self.loop_task and self.loop_task.is_running() 274 | if was_waiting and was_looping: 275 | log.error("Both wait and loop tasks were active. Both should now be cancelled.") 276 | 277 | if was_waiting: 278 | self.wait_task.cancel() 279 | log.debug("Summer AoC stopped during wait task") 280 | 281 | if was_looping: 282 | self.loop_task.cancel() # .cancel() doesn't allow the current iteration to finish 283 | log.debug("Summer AoC stopped during loop task") 284 | 285 | self.is_running = False 286 | self.first_post_date = None # Clean up; the start date should be reset when the event is started. 287 | await self.save_event_state() 288 | log.info("Summer AoC event stopped") 289 | return was_waiting or was_looping 290 | 291 | async def post_puzzle(self) -> None: 292 | """Create a thread for the current day's puzzle.""" 293 | if self.current_day > LAST_DAY: 294 | log.error("Attempted to post puzzle after last day, stopping event") 295 | await self.stop_event() 296 | return 297 | 298 | log.info(f"Posting puzzle for day {self.current_day}") 299 | channel: discord.TextChannel = self.bot.get_channel(Channels.summer_aoc_main) 300 | thread_starter = await channel.send( 301 | f"<@&{Roles.summer_aoc}>", 302 | embed=self.get_puzzle_embed(), 303 | ) 304 | await thread_starter.create_thread(name=f"Day {self.current_day} Spoilers") 305 | 306 | self.current_day += 1 307 | await self.save_event_state() 308 | if self.current_day > LAST_DAY: 309 | await self.stop_event() 310 | 311 | def get_info_embed(self) -> discord.Embed: 312 | """Generate an embed with info about the event state.""" 313 | desc = INFO_TEMPLATE.format( 314 | is_running=self.is_running, 315 | year=self.year, 316 | current_day=self.current_day, 317 | day_interval=self.day_interval, 318 | first_post=f"" if self.first_post_date else "N/A", 319 | next_post=f"" if self.is_running else "N/A", 320 | ) 321 | return discord.Embed( 322 | title="Summer AoC event state", 323 | description=desc, 324 | ) 325 | 326 | def get_puzzle_embed(self) -> discord.Embed: 327 | """Generate an embed for the day's puzzle post.""" 328 | if self.current_day == LAST_DAY: 329 | next_puzzle_text = LAST_PUZZLE_TEXT.format(timestamp=int(arrow.get(REAL_AOC_START).timestamp())) 330 | else: 331 | next_puzzle_text = NEXT_PUZZLE_TEXT.format( 332 | timestamp=int(self.next_post_time().timestamp()), 333 | roles=Channels.roles 334 | ) 335 | post_text = POST_TEXT.format( 336 | public_name=PUBLIC_NAME, 337 | discussion=Channels.summer_aoc_discussion, 338 | next_puzzle_text=next_puzzle_text, 339 | ) 340 | 341 | embed = discord.Embed( 342 | title=f"**Day {self.current_day} (puzzle link)**", 343 | url=AOC_URL.format(year=self.year, day=self.current_day), 344 | description=post_text, 345 | color=discord.Color.yellow(), 346 | ) 347 | return embed 348 | 349 | 350 | async def setup(bot: SirRobin) -> None: 351 | """Load the Summer AoC cog.""" 352 | await bot.add_cog(SummerAoC(bot)) 353 | -------------------------------------------------------------------------------- /bot/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import sentry_sdk 5 | from pydis_core.utils.logging import TRACE_LEVEL, get_logger, log_format 6 | from sentry_sdk.integrations.asyncio import AsyncioIntegration 7 | from sentry_sdk.integrations.logging import LoggingIntegration 8 | from sentry_sdk.integrations.redis import RedisIntegration 9 | 10 | from bot.constants import Bot, GIT_SHA 11 | 12 | 13 | def setup_logging() -> None: 14 | """Configure logging for the bot.""" 15 | root_log = get_logger() 16 | root_log.setLevel(TRACE_LEVEL if Bot.trace_logging else logging.DEBUG if Bot.debug else logging.INFO) 17 | 18 | ch = logging.StreamHandler(stream=sys.stdout) 19 | ch.setFormatter(log_format) 20 | root_log.addHandler(ch) 21 | 22 | root_log.info("Logging initialization complete.") 23 | 24 | 25 | def setup_sentry() -> None: 26 | """Set up the Sentry logging integrations.""" 27 | sentry_logging = LoggingIntegration( 28 | level=logging.DEBUG, 29 | event_level=logging.WARNING 30 | ) 31 | 32 | sentry_sdk.init( 33 | dsn=Bot.sentry_dsn, 34 | integrations=[ 35 | sentry_logging, 36 | RedisIntegration(), 37 | AsyncioIntegration(), 38 | ], 39 | release=f"sir-robin@{GIT_SHA}", 40 | traces_sample_rate=0.5, 41 | profiles_sample_rate=0.5, 42 | ) 43 | -------------------------------------------------------------------------------- /bot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import re 4 | import string 5 | from collections.abc import Iterable 6 | from datetime import UTC, datetime 7 | 8 | import discord 9 | from discord.ext.commands import BadArgument, Context 10 | 11 | from bot.constants import Bot, Month 12 | from bot.utils.pagination import LinePaginator 13 | 14 | 15 | def human_months(months: Iterable[Month]) -> str: 16 | """Build a comma separated list of `months`.""" 17 | return ", ".join(str(m) for m in months) 18 | 19 | 20 | def resolve_current_month() -> Month: 21 | """ 22 | Determine current month w.r.t. `Bot.month_override` env var. 23 | 24 | If the env variable was set, current month always resolves to the configured value. 25 | Otherwise, the current UTC month is given. 26 | """ 27 | return Bot.month_override or Month(datetime.now(tz=UTC).month) 28 | 29 | 30 | async def disambiguate( 31 | ctx: Context, 32 | entries: list[str], 33 | *, 34 | timeout: float = 30, 35 | entries_per_page: int = 20, 36 | empty: bool = False, 37 | embed: discord.Embed | None = None 38 | ) -> str: 39 | """ 40 | Has the user choose between multiple entries in case one could not be chosen automatically. 41 | 42 | Disambiguation will be canceled after `timeout` seconds. 43 | 44 | This will raise a BadArgument if entries is empty, if the disambiguation event times out, 45 | or if the user makes an invalid choice. 46 | """ 47 | if len(entries) == 0: 48 | raise BadArgument("No matches found.") 49 | 50 | if len(entries) == 1: 51 | return entries[0] 52 | 53 | choices = (f"{index}: {entry}" for index, entry in enumerate(entries, start=1)) 54 | 55 | def check(message: discord.Message) -> bool: 56 | return ( 57 | message.content.isdecimal() 58 | and message.author == ctx.author 59 | and message.channel == ctx.channel 60 | ) 61 | 62 | try: 63 | if embed is None: 64 | embed = discord.Embed() 65 | 66 | coro1 = ctx.bot.wait_for("message", check=check, timeout=timeout) 67 | coro2 = LinePaginator.paginate( 68 | choices, ctx, embed=embed, max_lines=entries_per_page, 69 | empty=empty, max_size=6000, timeout=9000 70 | ) 71 | 72 | # wait_for timeout will go to except instead of the wait_for thing as I expected 73 | futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)] 74 | done, pending = await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED, loop=ctx.bot.loop) 75 | 76 | # :yert: 77 | result = list(done)[0].result() 78 | 79 | # Pagination was canceled - result is None 80 | if result is None: 81 | for coro in pending: 82 | coro.cancel() 83 | raise BadArgument("Canceled.") 84 | 85 | # Pagination was not initiated, only one page 86 | if result.author == ctx.bot.user: 87 | # Continue the wait_for 88 | result = await list(pending)[0] 89 | 90 | # Love that duplicate code 91 | for coro in pending: 92 | coro.cancel() 93 | except TimeoutError: 94 | raise BadArgument("Timed out.") 95 | 96 | # Guaranteed to not error because of isdecimal() in check 97 | index = int(result.content) 98 | 99 | try: 100 | return entries[index - 1] 101 | except IndexError: 102 | raise BadArgument("Invalid choice.") 103 | 104 | 105 | def replace_many( 106 | sentence: str, replacements: dict, *, ignore_case: bool = False, match_case: bool = False 107 | ) -> str: 108 | """ 109 | Replaces multiple substrings in a string given a mapping of strings. 110 | 111 | By default replaces long strings before short strings, and lowercase before uppercase. 112 | Example: 113 | var = replace_many("This is a sentence", {"is": "was", "This": "That"}) 114 | assert var == "That was a sentence" 115 | 116 | If `ignore_case` is given, does a case insensitive match. 117 | Example: 118 | var = replace_many("THIS is a sentence", {"IS": "was", "tHiS": "That"}, ignore_case=True) 119 | assert var == "That was a sentence" 120 | 121 | If `match_case` is given, matches the case of the replacement with the replaced word. 122 | Example: 123 | var = replace_many( 124 | "This IS a sentence", {"is": "was", "this": "that"}, ignore_case=True, match_case=True 125 | ) 126 | assert var == "That WAS a sentence" 127 | """ 128 | if ignore_case: 129 | replacements = { 130 | word.lower(): replacement for word, replacement in replacements.items() 131 | } 132 | 133 | words_to_replace = sorted(replacements, key=lambda s: (-len(s), s)) 134 | 135 | # Join and compile words to replace into a regex 136 | pattern = "|".join(re.escape(word) for word in words_to_replace) 137 | regex = re.compile(pattern, re.I if ignore_case else 0) 138 | 139 | def _repl(match: re.Match) -> str: 140 | """Returns replacement depending on `ignore_case` and `match_case`.""" 141 | word = match.group(0) 142 | replacement = replacements[word.lower() if ignore_case else word] 143 | 144 | if not match_case: 145 | return replacement 146 | 147 | # Clean punctuation from word so string methods work 148 | cleaned_word = word.translate(str.maketrans("", "", string.punctuation)) 149 | if cleaned_word.isupper(): 150 | return replacement.upper() 151 | if cleaned_word[0].isupper(): 152 | return replacement.capitalize() 153 | return replacement.lower() 154 | 155 | return regex.sub(_repl, sentence) 156 | 157 | 158 | @contextlib.asynccontextmanager 159 | async def unlocked_role(role: discord.Role, delay: int = 5) -> None: 160 | """ 161 | Create a context in which `role` is unlocked, relocking it automatically after use. 162 | 163 | A configurable `delay` is added before yielding the context and directly after exiting the 164 | context to allow the role settings change to properly propagate at Discord's end. This 165 | prevents things like role mentions from failing because of synchronization issues. 166 | 167 | Usage: 168 | >>> async with unlocked_role(role, delay=5): 169 | ... await ctx.send(f"Hey {role.mention}, free pings for everyone!") 170 | """ 171 | await role.edit(mentionable=True) 172 | await asyncio.sleep(delay) 173 | try: 174 | yield 175 | finally: 176 | await asyncio.sleep(delay) 177 | await role.edit(mentionable=False) 178 | -------------------------------------------------------------------------------- /bot/utils/checks.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Container 2 | 3 | from discord.ext import commands 4 | from discord.ext.commands import Context 5 | 6 | from bot import constants 7 | from bot.log import get_logger 8 | from bot.utils.exceptions import CodeJamCategoryCheckFailure, InWhitelistCheckFailure, SilentChannelFailure 9 | 10 | log = get_logger(__name__) 11 | 12 | 13 | def in_code_jam_category(code_jam_category_name: str) -> Callable: 14 | """Raises `CodeJamCategoryCheckFailure` when the command is invoked outside the Code Jam categories.""" 15 | async def predicate(ctx: commands.Context) -> bool: 16 | if not ctx.guild: 17 | return False 18 | if not ctx.message.channel.category: 19 | return False 20 | if ctx.message.channel.category.name == code_jam_category_name: 21 | return True 22 | log.trace(f"{ctx.author} tried to invoke {ctx.command.name} outside of the Code Jam categories.") 23 | raise CodeJamCategoryCheckFailure 24 | 25 | return commands.check(predicate) 26 | 27 | 28 | def in_whitelist_check( 29 | ctx: Context, 30 | channels: Container[int] = (), 31 | categories: Container[int] = (), 32 | roles: Container[int] = (), 33 | redirect: int | None = constants.Channels.sir_lancebot_playground, 34 | role_override: Container[int] = (), 35 | fail_silently: bool = False, 36 | ) -> bool: 37 | """ 38 | Check if a command was issued in a whitelisted context. 39 | 40 | The whitelists that can be provided are: 41 | 42 | - `channels`: a container with channel ids for whitelisted channels 43 | - `categories`: a container with category ids for whitelisted categories 44 | - `roles`: a container with role ids for whitelisted roles 45 | 46 | If the command was invoked in a context that was not whitelisted, the member is either 47 | redirected to the `redirect` channel that was passed (default: #bot-commands) or simply 48 | told that they're not allowed to use this particular command (if `None` was passed). 49 | """ 50 | # If the author has an override role, they can run this command anywhere 51 | if role_override: 52 | for role in ctx.author.roles: 53 | if role.id in role_override: 54 | log.info(f"{ctx.author} is allowed to use {ctx.command.name} anywhere") 55 | return True 56 | 57 | if redirect and redirect not in channels: 58 | # It does not make sense for the channel whitelist to not contain the redirection 59 | # channel (if applicable). That's why we add the redirection channel to the `channels` 60 | # container if it's not already in it. As we allow any container type to be passed, 61 | # we first create a tuple in order to safely add the redirection channel. 62 | # 63 | # Note: It's possible for the redirect channel to be in a whitelisted category, but 64 | # there's no easy way to check that and as a channel can easily be moved in and out of 65 | # categories, it's probably not wise to rely on its category in any case. 66 | channels = tuple(channels) + (redirect,) 67 | 68 | if channels and ctx.channel.id in channels: 69 | log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted channel.") 70 | return True 71 | 72 | # Only check the category id if we have a category whitelist and the channel has a `category_id` 73 | if categories and hasattr(ctx.channel, "category_id") and ctx.channel.category_id in categories: 74 | log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.") 75 | return True 76 | 77 | category = getattr(ctx.channel, "category", None) 78 | if category and category.name == constants.Categories.summer_code_jam: 79 | log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a codejam team channel.") 80 | return True 81 | 82 | # Only check the roles whitelist if we have one and ensure the author's roles attribute returns 83 | # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there). 84 | if roles and any(r.id in roles for r in getattr(ctx.author, "roles", ())): 85 | log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they have a whitelisted role.") 86 | return True 87 | 88 | log.trace(f"{ctx.author} may not use the `{ctx.command.name}` command within this context.") 89 | 90 | # Some commands are secret, and should produce no feedback at all. 91 | if not fail_silently: 92 | raise InWhitelistCheckFailure(redirect) 93 | raise SilentChannelFailure("Wrong channel, silently fail") 94 | -------------------------------------------------------------------------------- /bot/utils/decorators.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | from collections.abc import Callable, Container 4 | 5 | from discord.ext import commands 6 | from discord.ext.commands import Command, Context 7 | from pydis_core.utils import logging 8 | 9 | from bot.constants import Channels, Month 10 | from bot.utils import human_months, resolve_current_month 11 | from bot.utils.checks import in_whitelist_check 12 | from bot.utils.exceptions import InMonthCheckFailure, SilentRoleFailure 13 | 14 | ONE_DAY = 24 * 60 * 60 15 | 16 | log = logging.get_logger(__name__) 17 | 18 | 19 | def seasonal_task(*allowed_months: Month, sleep_time: float | int = ONE_DAY) -> Callable: 20 | """ 21 | Perform the decorated method periodically in `allowed_months`. 22 | 23 | This provides a convenience wrapper to avoid code repetition where some task shall 24 | perform an operation repeatedly in a constant interval, but only in specific months. 25 | 26 | The decorated function will be called once every `sleep_time` seconds while 27 | the current UTC month is in `allowed_months`. Sleep time defaults to 24 hours. 28 | 29 | The wrapped task is responsible for waiting for the bot to be ready, if necessary. 30 | """ 31 | def decorator(task_body: Callable) -> Callable: 32 | @functools.wraps(task_body) 33 | async def decorated_task(*args, **kwargs) -> None: 34 | """Call `task_body` once every `sleep_time` seconds in `allowed_months`.""" 35 | log.info(f"Starting seasonal task {task_body.__qualname__} ({human_months(allowed_months)})") 36 | 37 | while True: 38 | current_month = resolve_current_month() 39 | 40 | if current_month in allowed_months: 41 | await task_body(*args, **kwargs) 42 | else: 43 | log.debug(f"Seasonal task {task_body.__qualname__} sleeps in {current_month!s}") 44 | 45 | await asyncio.sleep(sleep_time) 46 | return decorated_task 47 | return decorator 48 | 49 | 50 | def in_month_listener(*allowed_months: Month) -> Callable: 51 | """ 52 | Shield a listener from being invoked outside of `allowed_months`. 53 | 54 | The check is performed against current UTC month. 55 | """ 56 | def decorator(listener: Callable) -> Callable: 57 | @functools.wraps(listener) 58 | async def guarded_listener(*args, **kwargs) -> None: 59 | """Wrapped listener will abort if not in allowed month.""" 60 | current_month = resolve_current_month() 61 | 62 | if current_month in allowed_months: 63 | # Propagate return value although it should always be None 64 | return await listener(*args, **kwargs) 65 | 66 | log.debug(f"Guarded {listener.__qualname__} from invoking in {current_month!s}") 67 | return None 68 | return guarded_listener 69 | return decorator 70 | 71 | 72 | def in_month_command(*allowed_months: Month) -> Callable: 73 | """ 74 | Check whether the command was invoked in one of `enabled_months`. 75 | 76 | Uses the current UTC month at the time of running the predicate. 77 | """ 78 | async def predicate(ctx: Context) -> bool: 79 | current_month = resolve_current_month() 80 | can_run = current_month in allowed_months 81 | 82 | log.debug( 83 | f"Command '{ctx.command}' is locked to months {human_months(allowed_months)}. " 84 | f"Invoking it in month {current_month!s} is {'allowed' if can_run else 'disallowed'}." 85 | ) 86 | if can_run: 87 | return True 88 | raise InMonthCheckFailure(f"Command can only be used in {human_months(allowed_months)}") 89 | 90 | return commands.check(predicate) 91 | 92 | 93 | def in_month(*allowed_months: Month) -> Callable: 94 | """ 95 | Universal decorator for season-locking commands and listeners alike. 96 | 97 | This only serves to determine whether the decorated callable is a command, 98 | a listener, or neither. It then delegates to either `in_month_command`, 99 | or `in_month_listener`, or raises TypeError, respectively. 100 | 101 | Please note that in order for this decorator to correctly determine whether 102 | the decorated callable is a cmd or listener, it **has** to first be turned 103 | into one. This means that this decorator should always be placed **above** 104 | the d.py one that registers it as either. 105 | 106 | This will decorate groups as well, as those subclass Command. In order to lock 107 | all subcommands of a group, its `invoke_without_command` param must **not** be 108 | manually set to True - this causes a circumvention of the group's callback 109 | and the seasonal check applied to it. 110 | """ 111 | def decorator(callable_: Callable) -> Callable: 112 | # Functions decorated as commands are turned into instances of `Command` 113 | if isinstance(callable_, Command): 114 | log.debug(f"Command {callable_.qualified_name} will be locked to {human_months(allowed_months)}") 115 | actual_deco = in_month_command(*allowed_months) 116 | 117 | # D.py will assign this attribute when `callable_` is registered as a listener 118 | elif hasattr(callable_, "__cog_listener__"): 119 | log.debug(f"Listener {callable_.__qualname__} will be locked to {human_months(allowed_months)}") 120 | actual_deco = in_month_listener(*allowed_months) 121 | 122 | # Otherwise we're unsure exactly what has been decorated 123 | # This happens before the bot starts, so let's just raise 124 | else: 125 | raise TypeError(f"Decorated object {callable_} is neither a command nor a listener") 126 | 127 | return actual_deco(callable_) 128 | return decorator 129 | 130 | 131 | def with_role(*role_ids: int, fail_silently: bool = False) -> Callable: 132 | """Check to see whether the invoking user has any of the roles specified in role_ids.""" 133 | async def predicate(ctx: Context) -> bool: 134 | log.debug( 135 | "Checking if %s has one of the following role IDs %s. Fail silently is set to %s.", 136 | ctx.author, 137 | role_ids, 138 | fail_silently 139 | ) 140 | try: 141 | return await commands.has_any_role(*role_ids).predicate(ctx) 142 | except commands.MissingAnyRole as e: 143 | if fail_silently: 144 | raise SilentRoleFailure from e 145 | raise 146 | return commands.check(predicate) 147 | 148 | 149 | def in_whitelist( 150 | *, 151 | channels: Container[int] = (), 152 | categories: Container[int] = (), 153 | roles: Container[int] = (), 154 | redirect: Container[int] | None = (Channels.sir_lancebot_playground,), 155 | role_override: Container[int] | None = (), 156 | fail_silently: bool = False 157 | ) -> Callable: 158 | """ 159 | Check if a command was issued in a whitelisted context. 160 | 161 | The whitelists that can be provided are: 162 | - `channels`: a container with channel ids for allowed channels 163 | - `categories`: a container with category ids for allowed categories 164 | - `roles`: a container with role ids for allowed roles 165 | 166 | If the command was invoked in a non whitelisted manner, they are redirected 167 | to the `redirect` channel(s) that is passed (default is #sir-lancebot-playground) or 168 | told they are not allowd to use that particular commands (if `None` was passed) 169 | """ 170 | def predicate(ctx: Context) -> bool: 171 | return in_whitelist_check(ctx, channels, categories, roles, redirect, role_override, fail_silently) 172 | 173 | return commands.check(predicate) 174 | 175 | 176 | def whitelist_override(bypass_defaults: bool = False, allow_dm: bool = False, **kwargs: Container[int]) -> Callable: 177 | """ 178 | Override global whitelist context, with the kwargs specified. 179 | 180 | All arguments from `in_whitelist_check` are supported, with the exception of `fail_silently`. 181 | Set `bypass_defaults` to True if you want to completely bypass global checks. 182 | 183 | Set `allow_dm` to True if you want to allow the command to be invoked from within direct messages. 184 | Note that you have to be careful with any references to the guild. 185 | 186 | This decorator has to go before (below) below the `command` decorator. 187 | """ 188 | def inner(func: Callable) -> Callable: 189 | func.override = kwargs 190 | func.override_reset = bypass_defaults 191 | func.override_dm = allow_dm 192 | return func 193 | 194 | return inner 195 | -------------------------------------------------------------------------------- /bot/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Container 2 | 3 | from discord.ext.commands import CheckFailure 4 | 5 | 6 | class JamCategoryNameConflictError(Exception): 7 | """Raised when upon creating a CodeJam the main jam category and the teams' category conflict.""" 8 | 9 | 10 | class SilentCheckFailure(CheckFailure): 11 | """Raised when a check fails, but the bot should not give feedback.""" 12 | 13 | 14 | class CodeJamCategoryCheckFailure(SilentCheckFailure): 15 | """Raised when the specified command was run outside the Code Jam categories.""" 16 | 17 | 18 | class InMonthCheckFailure(CheckFailure): 19 | """Check failure for when a command is invoked outside of its allowed month.""" 20 | 21 | 22 | class SilentChannelFailure(SilentCheckFailure): 23 | """Raised when someone should not use a command in a context and should silently fail.""" 24 | 25 | 26 | class SilentRoleFailure(SilentCheckFailure): 27 | """Raised when someone doesn't have the correct role to use a command and should silently fail.""" 28 | 29 | 30 | class InWhitelistCheckFailure(CheckFailure): 31 | """Raised when the `in_whitelist` check fails.""" 32 | 33 | def __init__(self, redirect_channels: Container[int] | None): 34 | self.redirect_channels = redirect_channels 35 | 36 | if redirect_channels: 37 | channels = ">, <#".join([str(channel) for channel in redirect_channels]) 38 | redirect_message = f" here. Please use the <#{channels}> channel(s) instead" 39 | else: 40 | redirect_message = "" 41 | 42 | error_message = f"You are not allowed to use that command{redirect_message}." 43 | 44 | super().__init__(error_message) 45 | -------------------------------------------------------------------------------- /bot/utils/members.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import discord 4 | from pydis_core.utils import logging 5 | 6 | log = logging.get_logger(__name__) 7 | 8 | 9 | async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> discord.Member | None: 10 | """ 11 | Attempt to get a member from cache; on failure fetch from the API. 12 | 13 | Return `None` to indicate the member could not be found. 14 | """ 15 | if member := guild.get_member(member_id): 16 | log.trace("%s retrieved from cache.", member) 17 | else: 18 | try: 19 | member = await guild.fetch_member(member_id) 20 | except discord.errors.NotFound: 21 | log.trace("Failed to fetch %d from API.", member_id) 22 | return None 23 | log.trace("%s fetched from API.", member) 24 | return member 25 | 26 | 27 | async def handle_role_change( 28 | member: discord.Member, 29 | coro: t.Callable[..., t.Coroutine], 30 | role: discord.Role 31 | ) -> None: 32 | """ 33 | Change `member`'s cooldown role via awaiting `coro` and handle errors. 34 | 35 | `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. 36 | """ 37 | try: 38 | await coro(role) 39 | except discord.NotFound: 40 | log.debug(f"Failed to change role for {member} ({member.id}): member not found") 41 | except discord.Forbidden: 42 | log.error( 43 | f"Forbidden to change role for {member} ({member.id}); " 44 | f"possibly due to role hierarchy" 45 | ) 46 | except discord.HTTPException as e: 47 | log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") 48 | -------------------------------------------------------------------------------- /bot/utils/time.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import arrow 4 | 5 | 6 | def time_until(hour: int, minute: int = 0, second: int = 0) -> timedelta: 7 | """Return the difference between now and the next occurence of the given time of day in UTC.""" 8 | now = arrow.get() 9 | time_today = now.replace(hour=hour, minute=minute, second=second) 10 | delta = time_today - now 11 | return delta % timedelta(days=1) 12 | -------------------------------------------------------------------------------- /bot/utils/uwu.py: -------------------------------------------------------------------------------- 1 | # This file was copied from https://github.com/python-discord/sir-lancebot 2 | # and modified. 3 | 4 | import random 5 | import re 6 | import typing as t 7 | from dataclasses import dataclass 8 | from functools import partial 9 | 10 | from bot.bot import SirRobin 11 | 12 | WORD_REPLACE = { 13 | "small": "smol", 14 | "cute": "kawaii~", 15 | "fluff": "floof", 16 | "love": "luv", 17 | "stupid": "baka", 18 | "idiot": "baka", 19 | "what": "nani", 20 | "meow": "nya~", 21 | "roar": "rawrr~", 22 | } 23 | 24 | EMOJIS = [ 25 | "rawr x3", 26 | "OwO", 27 | "UwU", 28 | "o.O", 29 | "-.-", 30 | ">w<", 31 | "σωσ", 32 | "òωó", 33 | "ʘwʘ", 34 | ":3", 35 | "XD", 36 | "nyaa~~", 37 | "mya", 38 | ">_<", 39 | "rawr", 40 | "uwu", 41 | "^^", 42 | "^^;;", 43 | ] 44 | 45 | EMOJI_REPLACE = { 46 | "😐": ":cat:", 47 | "😢": ":crying_cat_face:", 48 | "😍": ":heart_eyes_cat:", 49 | "😂": ":joy_cat:", 50 | "😗": ":kissing_cat:", 51 | "😠": ":pouting_cat:", 52 | "😱": ":scream_cat:", 53 | "😆": ":smile_cat:", 54 | "🙂": ":smiley_cat:", 55 | "😀": ":smiley_cat:", 56 | "😏": ":smirk_cat:", 57 | "🥺": ":pleading_face::point_right::point_left:" 58 | } 59 | REGEX_WORD_REPLACE = re.compile(r"(?\g<2>-\g<2>" 65 | 66 | REGEX_NYA = re.compile(r"n([aeou][^aeiou])") 67 | SUBSTITUTE_NYA = r"ny\1" 68 | 69 | REGEX_EMOJI = re.compile(r"<(a)?:(\w+?):(\d{15,21}?)>", re.ASCII) 70 | 71 | 72 | @dataclass(frozen=True, eq=True) 73 | class Emoji: 74 | """Data class for an Emoji.""" 75 | 76 | name: str 77 | uid: int 78 | animated: bool = False 79 | 80 | def __str__(self): 81 | anim_bit = "a" if self.animated else "" 82 | return f"<{anim_bit}:{self.name}:{self.uid}>" 83 | 84 | def can_display(self, bot: SirRobin) -> bool: 85 | """Determines if a bot is in a server with the emoji.""" 86 | return bot.get_emoji(self.uid) is not None 87 | 88 | @classmethod 89 | def from_match(cls, match: tuple[str, str, str]) -> t.Optional["Emoji"]: 90 | """Creates an Emoji from a regex match tuple.""" 91 | if not match or len(match) != 3 or not match[2].isdecimal(): 92 | return None 93 | return cls(match[1], int(match[2]), match[0] == "a") 94 | 95 | 96 | 97 | 98 | def _word_replace(input_string: str) -> str: 99 | """Replaces words that are keys in the word replacement hash to the values specified.""" 100 | for word, replacement in WORD_REPLACE.items(): 101 | input_string = input_string.replace(word, replacement) 102 | return input_string 103 | 104 | def _char_replace(input_string: str) -> str: 105 | """Replace certain characters with 'w'.""" 106 | return REGEX_WORD_REPLACE.sub("w", input_string) 107 | 108 | def _stutter(strength: float, input_string: str) -> str: 109 | """Adds stuttering to a string.""" 110 | return REGEX_STUTTER.sub(partial(_stutter_replace, strength=strength), input_string, 0) 111 | 112 | def _stutter_replace(match: re.Match, strength: float = 0.0) -> str: 113 | """Replaces a single character with a stuttered character.""" 114 | match_string = match.group() 115 | if random.random() < strength: 116 | return f"{match_string}-{match_string[-1]}" # Stutter the last character 117 | return match_string 118 | 119 | def _nyaify(input_string: str) -> str: 120 | """Nyaifies a string by adding a 'y' between an 'n' and a vowel.""" 121 | return REGEX_NYA.sub(SUBSTITUTE_NYA, input_string, 0) 122 | 123 | def _emoji(strength: float, input_string: str) -> str: 124 | """Replaces some punctuation with emoticons.""" 125 | return REGEX_PUNCTUATION.sub(partial(_emoji_replace, strength=strength), input_string, 0) 126 | 127 | def _emoji_replace(match: re.Match, strength: float = 0.0) -> str: 128 | """Replaces a punctuation character with an emoticon.""" 129 | match_string = match.group() 130 | if random.random() < strength: 131 | return f" {random.choice(EMOJIS)} " 132 | return match_string 133 | 134 | def _ext_emoji_replace(input_string: str) -> str: 135 | """Replaces any emoji the bot cannot send in input_text with a random emoticons.""" 136 | groups = REGEX_EMOJI.findall(input_string) 137 | emojis = {Emoji.from_match(match) for match in groups} 138 | # Replace with random emoticon if unable to display 139 | emojis_map = { 140 | re.escape(str(e)): random.choice(EMOJIS) 141 | for e in emojis if e and not e.can_display(SirRobin) 142 | } 143 | if emojis_map: 144 | # Pattern for all emoji markdowns to be replaced 145 | emojis_re = re.compile("|".join(emojis_map.keys())) 146 | # Replace matches with random emoticon 147 | return emojis_re.sub( 148 | lambda m: emojis_map[re.escape(m.group())], 149 | input_string 150 | ) 151 | # Return original if no replacement 152 | return input_string 153 | 154 | def _uwu_emojis(input_string: str) -> str: 155 | """Replaces certain emojis with better emojis.""" 156 | for old, new in EMOJI_REPLACE.items(): 157 | input_string = input_string.replace(old, new) 158 | return input_string 159 | 160 | def uwuify(input_string: str, *, stutter_strength: float = 0.2, emoji_strength: float = 0.1) -> str: 161 | """Takes a string and returns an uwuified version of it.""" 162 | input_string = input_string.lower() 163 | input_string = _word_replace(input_string) 164 | input_string = _nyaify(input_string) 165 | input_string = _char_replace(input_string) 166 | input_string = _stutter(stutter_strength, input_string) 167 | input_string = _emoji(emoji_strength, input_string) 168 | input_string = _ext_emoji_replace(input_string) 169 | input_string = _uwu_emojis(input_string) 170 | return input_string 171 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | restart: unless-stopped 4 | image: postgres:16-alpine 5 | ports: 6 | - "127.0.0.1:7777:5432" 7 | environment: 8 | POSTGRES_DB: codejam_management 9 | POSTGRES_PASSWORD: codejam_management 10 | POSTGRES_USER: codejam_management 11 | healthcheck: 12 | test: [ "CMD-SHELL", "pg_isready -U codejam_management" ] 13 | interval: 2s 14 | timeout: 1s 15 | retries: 5 16 | 17 | code_jam_management: 18 | restart: unless-stopped 19 | image: ghcr.io/python-discord/code-jam-management:latest 20 | depends_on: 21 | postgres: 22 | condition: service_healthy 23 | environment: 24 | DATABASE_URL: postgresql+asyncpg://codejam_management:codejam_management@postgres:5432/codejam_management 25 | ports: 26 | - 8000:8000 27 | 28 | redis: 29 | restart: unless-stopped 30 | image: redis:latest 31 | ports: 32 | - "127.0.0.1:6379:6379" 33 | 34 | sir-robin: 35 | restart: unless-stopped 36 | build: 37 | context: . 38 | dockerfile: Dockerfile 39 | container_name: sir-robin 40 | init: true 41 | tty: true 42 | depends_on: 43 | - redis 44 | environment: 45 | REDIS_HOST: redis 46 | REDIS_USE_FAKEREDIS: false 47 | CODE_JAM_API: http://code_jam_management:8000 48 | env_file: 49 | - .env 50 | volumes: 51 | - .:/bot 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "sir-robin" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Python Discord "] 6 | package-mode = false 7 | 8 | [tool.poetry.dependencies] 9 | python = "3.12.*" 10 | 11 | pydis-core = { version = "11.5.1", extras = ["all"]} 12 | arrow = "1.3.0" 13 | sentry-sdk = "2.19.0" 14 | pydantic-settings = "2.6.1" 15 | 16 | [tool.poetry.group.dev.dependencies] 17 | taskipy = "1.14.1" 18 | 19 | [tool.poetry.group.test.dependencies] 20 | hypothesis = "6.122.0" 21 | pytest = "8.3.3" 22 | pytest-asyncio = "0.24.0" 23 | 24 | [tool.poetry.group.lint.dependencies] 25 | ruff = "0.8.1" 26 | pre-commit = "4.0.1" 27 | 28 | [tool.taskipy.tasks] 29 | start = "python -m bot" 30 | lint = "pre-commit run --all-files" 31 | precommit = "pre-commit install" 32 | test = "python -m unittest discover" 33 | 34 | [build-system] 35 | requires = ["poetry-core>=1.2.0"] 36 | build-backend = "poetry.core.masonry.api" 37 | 38 | [tool.ruff] 39 | target-version = "py312" 40 | extend-exclude = [".cache"] 41 | line-length = 120 42 | unsafe-fixes = true 43 | output-format = "concise" 44 | 45 | [tool.ruff.lint] 46 | select = ["ANN", "B", "C4", "D", "DTZ", "E", "F", "I", "ISC", "INT", "N", "PGH", "PIE", "Q", "RET", "RSE", "RUF", "S", "SIM", "T20", "TID", "UP", "W"] 47 | ignore = [ 48 | "ANN002", "ANN003", "ANN204", "ANN206", "ANN401", 49 | "B904", 50 | "C401", "C408", 51 | "D100", "D104", "D105", "D107", "D203", "D212", "D214", "D215", "D301", 52 | "D400", "D401", "D402", "D404", "D405", "D406", "D407", "D408", "D409", "D410", "D411", "D412", "D413", "D414", "D416", "D417", 53 | "E731", 54 | "RET504", 55 | "RUF005", "RUF012", "RUF015", 56 | "S311", 57 | "SIM102", "SIM108", 58 | ] 59 | 60 | [tool.ruff.lint.isort] 61 | order-by-type = false 62 | case-sensitive = true 63 | combine-as-imports = true 64 | 65 | [tool.ruff.lint.per-file-ignores] 66 | "tests/*" = ["ANN", "D"] 67 | -------------------------------------------------------------------------------- /sir_robin_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-robin/2efd8585987ce710577df1789aac10b45d5f7352/sir_robin_banner.png -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-robin/2efd8585987ce710577df1789aac10b45d5f7352/tests/__init__.py -------------------------------------------------------------------------------- /tests/_autospec.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | import pkgutil 4 | import unittest.mock 5 | from collections.abc import Callable 6 | 7 | 8 | @functools.wraps(unittest.mock._patch.decoration_helper) 9 | @contextlib.contextmanager 10 | def _decoration_helper(self, patched, args, keywargs): 11 | """Skips adding patchings as args if their `dont_pass` attribute is True.""" 12 | # Don't ask what this does. It's just a copy from stdlib, but with the dont_pass check added. 13 | extra_args = [] 14 | with contextlib.ExitStack() as exit_stack: 15 | for patching in patched.patchings: 16 | arg = exit_stack.enter_context(patching) 17 | if not getattr(patching, "dont_pass", False): 18 | # Only add the patching as an arg if dont_pass is False. 19 | if patching.attribute_name is not None: 20 | keywargs.update(arg) 21 | elif patching.new is unittest.mock.DEFAULT: 22 | extra_args.append(arg) 23 | 24 | args += tuple(extra_args) 25 | yield args, keywargs 26 | 27 | 28 | @functools.wraps(unittest.mock._patch.copy) 29 | def _copy(self): 30 | """Copy the `dont_pass` attribute along with the standard copy operation.""" 31 | patcher_copy = _copy.original(self) 32 | patcher_copy.dont_pass = getattr(self, "dont_pass", False) 33 | return patcher_copy 34 | 35 | 36 | # Monkey-patch the patcher class :) 37 | _copy.original = unittest.mock._patch.copy 38 | unittest.mock._patch.copy = _copy 39 | unittest.mock._patch.decoration_helper = _decoration_helper 40 | 41 | 42 | def autospec(target, *attributes: str, pass_mocks: bool = True, **patch_kwargs) -> Callable: 43 | """ 44 | Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. 45 | 46 | If `pass_mocks` is True, pass the autospecced mocks as arguments to the decorated object. 47 | """ 48 | # Caller's kwargs should take priority and overwrite the defaults. 49 | kwargs = dict(spec_set=True, autospec=True) 50 | kwargs.update(patch_kwargs) 51 | 52 | # Import the target if it's a string. 53 | # This is to support both object and string targets like patch.multiple. 54 | if isinstance(target, str): 55 | target = pkgutil.resolve_name(target) 56 | 57 | def decorator(func): 58 | for attribute in attributes: 59 | patcher = unittest.mock.patch.object(target, attribute, **kwargs) 60 | if not pass_mocks: 61 | # A custom attribute to keep track of which patchings should be skipped. 62 | patcher.dont_pass = True 63 | func = patcher(func) 64 | return func 65 | return decorator 66 | -------------------------------------------------------------------------------- /tests/test_code_jam.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock, call, create_autospec, patch 3 | 4 | from discord import CategoryChannel 5 | from discord.ext.commands import BadArgument 6 | 7 | from bot.constants import Roles 8 | from bot.exts import code_jams 9 | from bot.exts.code_jams import _cog, _creation_utils, _flows 10 | from tests.helpers import ( 11 | MockAttachment, 12 | MockBot, 13 | MockCategoryChannel, 14 | MockContext, 15 | MockGuild, 16 | MockMember, 17 | MockRole, 18 | MockTextChannel, 19 | autospec, 20 | ) 21 | 22 | TEST_CSV = b"""\ 23 | Team Name,Team Member Discord ID,Team Leader 24 | Annoyed Alligators,12345,Y 25 | Annoyed Alligators,54321,N 26 | Oscillating Otters,12358,Y 27 | Oscillating Otters,74832,N 28 | Oscillating Otters,19903,N 29 | Annoyed Alligators,11111,N 30 | """ 31 | 32 | 33 | def get_mock_category(channel_count: int, name: str) -> CategoryChannel: 34 | """Return a mocked code jam category.""" 35 | category = create_autospec(CategoryChannel, spec_set=True, instance=True) 36 | category.name = name 37 | category.channels = [MockTextChannel() for _ in range(channel_count)] 38 | 39 | return category 40 | 41 | 42 | class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): 43 | """Tests for `codejam create` command.""" 44 | 45 | def setUp(self): 46 | self.bot = MockBot() 47 | self.admin_role = MockRole(name="Admins", id=Roles.admins) 48 | self.command_user = MockMember([self.admin_role]) 49 | self.guild = MockGuild([self.admin_role]) 50 | self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) 51 | self.cog = _cog.CodeJams(self.bot) 52 | 53 | async def test_message_without_attachments(self): 54 | """If no link or attachments are provided, commands.BadArgument should be raised.""" 55 | self.ctx.message.attachments = [] 56 | 57 | with self.assertRaises(BadArgument): 58 | await self.cog.create(self.cog, self.ctx, None) 59 | 60 | @patch.object(_creation_utils, "create_team_channel") 61 | @patch.object(_creation_utils, "create_team_leader_channel") 62 | @patch.object(_creation_utils, "create_team_role") 63 | async def test_result_sending(self, create_team_role, create_team_leader_channel, create_team_channel): 64 | """Should call `ctx.send` when everything goes right.""" 65 | self.ctx.message.attachments = [MockAttachment()] 66 | self.ctx.message.attachments[0].read = AsyncMock() 67 | self.ctx.message.attachments[0].read.return_value = TEST_CSV 68 | 69 | team_leaders = MockRole() 70 | 71 | self.guild.get_member.return_value = MockMember() 72 | self.ctx.guild.create_role = AsyncMock() 73 | self.ctx.guild.create_role.return_value = team_leaders 74 | self.cog.add_roles = AsyncMock() 75 | teams = {"Team": [{"member": MockMember(), "is_leader": True}]} 76 | await _flows.creation_flow(self.ctx, teams, AsyncMock()) 77 | create_team_channel.assert_awaited_once() 78 | create_team_role.assert_awaited_once() 79 | create_team_leader_channel.assert_awaited_once_with( 80 | self.ctx.guild, team_leaders 81 | ) 82 | self.ctx.send.assert_awaited_once() 83 | 84 | async def test_link_returning_non_200_status(self): 85 | """When the URL passed returns a non 200 status, it should send a message informing them.""" 86 | self.bot.http_session.get.return_value = mock = MagicMock() 87 | mock.status = 404 88 | await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") 89 | 90 | self.ctx.send.assert_awaited_once() 91 | 92 | @patch.object(_creation_utils, "_send_status_update") 93 | async def test_category_doesnt_exist(self, update): 94 | """Should create a new code jam category.""" 95 | subtests = ( 96 | [], 97 | [get_mock_category(_creation_utils.MAX_CHANNELS, _creation_utils.CATEGORY_NAME)], 98 | [get_mock_category(_creation_utils.MAX_CHANNELS - 2, "other")], 99 | ) 100 | 101 | for categories in subtests: 102 | update.reset_mock() 103 | self.guild.reset_mock() 104 | self.guild.categories = categories 105 | 106 | with self.subTest(categories=categories): 107 | actual_category = await _creation_utils._get_category(self.guild) 108 | 109 | update.assert_called_once() 110 | self.guild.create_category_channel.assert_awaited_once() 111 | category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] 112 | 113 | self.assertFalse(category_overwrites[self.guild.default_role].read_messages) 114 | self.assertTrue(category_overwrites[self.guild.me].read_messages) 115 | self.assertEqual(self.guild.create_category_channel.return_value, actual_category) 116 | 117 | async def test_category_channel_exist(self): 118 | """Should not try to create category channel.""" 119 | expected_category = get_mock_category(_creation_utils.MAX_CHANNELS - 2, _creation_utils.CATEGORY_NAME) 120 | self.guild.categories = [ 121 | get_mock_category(_creation_utils.MAX_CHANNELS - 2, "other"), 122 | expected_category, 123 | get_mock_category(0, _creation_utils.CATEGORY_NAME), 124 | ] 125 | 126 | actual_category = await _creation_utils._get_category(self.guild) 127 | self.assertEqual(expected_category, actual_category) 128 | 129 | async def test_channel_overwrites(self): 130 | """Should have correct permission overwrites for users and roles.""" 131 | role = MockRole() 132 | overwrites = _creation_utils._get_overwrites(self.guild, role) 133 | self.assertTrue(overwrites[role].read_messages) 134 | 135 | @patch.object(_creation_utils, "_get_overwrites") 136 | @patch.object(_creation_utils, "_get_category") 137 | @autospec(_creation_utils, "_add_team_leader_roles", pass_mocks=False) 138 | async def test_team_channels_creation(self, get_category, get_overwrites): 139 | """Should create a text channel for a team.""" 140 | team_role = MockRole() 141 | category = MockCategoryChannel() 142 | category.create_text_channel = AsyncMock() 143 | 144 | get_category.return_value = category 145 | await _creation_utils.create_team_channel(self.guild, "my-team", team_role) 146 | 147 | category.create_text_channel.assert_awaited_once_with( 148 | "my-team", 149 | overwrites=get_overwrites.return_value 150 | ) 151 | 152 | async def test_jam_normal_roles_adding(self): 153 | """Should add the Jam team role to every team member, and Team Lead Role to Team Leads.""" 154 | leader = MockMember() 155 | leader_role = MockRole(name="Team Leader") 156 | members = [{"member": leader, "is_leader": True}] + [{"member": MockMember(), "is_leader": False} for _ in 157 | range(4)] 158 | team_role = await _creation_utils.create_team_role(MockGuild(), team_name="Team", members=members, 159 | team_leaders=leader_role) 160 | for entry in members: 161 | if not entry["is_leader"]: 162 | entry["member"].add_roles.assert_awaited_once_with(team_role) 163 | else: 164 | entry["member"].add_roles.assert_has_calls([call(team_role), call(leader_role)], any_order=True) 165 | self.assertTrue(entry["member"].add_roles.call_count == 2) 166 | 167 | async def test_jam_delete_channels_and_roles(self): 168 | category_channels = {AsyncMock(): [MockTextChannel(), MockTextChannel()]} 169 | roles = [MockRole(), MockRole()] 170 | await _flows.deletion_flow(category_channels, roles) 171 | for role in roles: 172 | role.delete.assert_awaited_once() 173 | for category, channels in category_channels.items(): 174 | category.delete.assert_awaited_once() 175 | for channel in channels: 176 | channel.delete.assert_awaited_once() 177 | 178 | 179 | class CodeJamSetup(unittest.IsolatedAsyncioTestCase): 180 | """Test for `setup` function of `CodeJam` cog.""" 181 | 182 | async def test_setup(self): 183 | """Should call `bot.add_cog`.""" 184 | bot = MockBot() 185 | await code_jams.setup(bot) 186 | bot.add_cog.assert_awaited_once() 187 | --------------------------------------------------------------------------------