├── .dockerignore ├── .git-blame-ignore-revs ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml ├── review-policy.yml └── workflows │ ├── build-deploy.yml │ ├── lint-test.yml │ ├── main.yml │ ├── sentry_release.yml │ └── status_embed.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── LICENSE-THIRD-PARTY ├── README.md ├── SECURITY.md ├── bot ├── __init__.py ├── __main__.py ├── bot.py ├── constants.py ├── converters.py ├── decorators.py ├── errors.py ├── exts │ ├── __init__.py │ ├── backend │ │ ├── __init__.py │ │ ├── branding │ │ │ ├── __init__.py │ │ │ ├── _cog.py │ │ │ └── _repository.py │ │ ├── config_verifier.py │ │ ├── error_handler.py │ │ ├── logging.py │ │ ├── security.py │ │ └── sync │ │ │ ├── __init__.py │ │ │ ├── _cog.py │ │ │ └── _syncers.py │ ├── filtering │ │ ├── FILTERS-DEVELOPMENT.md │ │ ├── __init__.py │ │ ├── _filter_context.py │ │ ├── _filter_lists │ │ │ ├── __init__.py │ │ │ ├── antispam.py │ │ │ ├── domain.py │ │ │ ├── extension.py │ │ │ ├── filter_list.py │ │ │ ├── invite.py │ │ │ ├── token.py │ │ │ └── unique.py │ │ ├── _filters │ │ │ ├── __init__.py │ │ │ ├── antispam │ │ │ │ ├── __init__.py │ │ │ │ ├── attachments.py │ │ │ │ ├── burst.py │ │ │ │ ├── chars.py │ │ │ │ ├── duplicates.py │ │ │ │ ├── emoji.py │ │ │ │ ├── links.py │ │ │ │ ├── mentions.py │ │ │ │ ├── newlines.py │ │ │ │ └── role_mentions.py │ │ │ ├── domain.py │ │ │ ├── extension.py │ │ │ ├── filter.py │ │ │ ├── invite.py │ │ │ ├── token.py │ │ │ └── unique │ │ │ │ ├── __init__.py │ │ │ │ ├── discord_token.py │ │ │ │ ├── everyone.py │ │ │ │ └── webhook.py │ │ ├── _settings.py │ │ ├── _settings_types │ │ │ ├── __init__.py │ │ │ ├── actions │ │ │ │ ├── __init__.py │ │ │ │ ├── infraction_and_notification.py │ │ │ │ ├── ping.py │ │ │ │ ├── remove_context.py │ │ │ │ └── send_alert.py │ │ │ ├── settings_entry.py │ │ │ └── validations │ │ │ │ ├── __init__.py │ │ │ │ ├── bypass_roles.py │ │ │ │ ├── channel_scope.py │ │ │ │ ├── enabled.py │ │ │ │ └── filter_dm.py │ │ ├── _ui │ │ │ ├── __init__.py │ │ │ ├── filter.py │ │ │ ├── filter_list.py │ │ │ ├── search.py │ │ │ └── ui.py │ │ ├── _utils.py │ │ └── filtering.py │ ├── fun │ │ ├── __init__.py │ │ ├── duck_pond.py │ │ └── off_topic_names.py │ ├── help_channels │ │ ├── __init__.py │ │ ├── _caches.py │ │ ├── _channel.py │ │ ├── _cog.py │ │ └── _stats.py │ ├── info │ │ ├── __init__.py │ │ ├── code_snippets.py │ │ ├── codeblock │ │ │ ├── __init__.py │ │ │ ├── _cog.py │ │ │ ├── _instructions.py │ │ │ └── _parsing.py │ │ ├── doc │ │ │ ├── __init__.py │ │ │ ├── _batch_parser.py │ │ │ ├── _cog.py │ │ │ ├── _html.py │ │ │ ├── _inventory_parser.py │ │ │ ├── _markdown.py │ │ │ ├── _parsing.py │ │ │ └── _redis_cache.py │ │ ├── help.py │ │ ├── information.py │ │ ├── patreon.py │ │ ├── pep.py │ │ ├── pypi.py │ │ ├── python_news.py │ │ ├── resources.py │ │ ├── source.py │ │ ├── stats.py │ │ ├── subscribe.py │ │ └── tags.py │ ├── moderation │ │ ├── __init__.py │ │ ├── alts.py │ │ ├── clean.py │ │ ├── defcon.py │ │ ├── dm_relay.py │ │ ├── incidents.py │ │ ├── infraction │ │ │ ├── __init__.py │ │ │ ├── _scheduler.py │ │ │ ├── _utils.py │ │ │ ├── _views.py │ │ │ ├── infractions.py │ │ │ ├── management.py │ │ │ └── superstarify.py │ │ ├── metabase.py │ │ ├── modlog.py │ │ ├── modpings.py │ │ ├── silence.py │ │ ├── slowmode.py │ │ ├── stream.py │ │ ├── verification.py │ │ ├── voice_gate.py │ │ └── watchchannels │ │ │ ├── __init__.py │ │ │ ├── _watchchannel.py │ │ │ └── bigbrother.py │ ├── recruitment │ │ ├── __init__.py │ │ └── talentpool │ │ │ ├── __init__.py │ │ │ ├── _api.py │ │ │ ├── _cog.py │ │ │ └── _review.py │ └── utils │ │ ├── __init__.py │ │ ├── attachment_pastebin_uploader.py │ │ ├── bot.py │ │ ├── extensions.py │ │ ├── internal.py │ │ ├── ping.py │ │ ├── reminders.py │ │ ├── snekbox │ │ ├── __init__.py │ │ ├── _cog.py │ │ ├── _eval.py │ │ └── _io.py │ │ ├── thread_bumper.py │ │ └── utils.py ├── log.py ├── pagination.py ├── resources │ ├── foods.json │ ├── media │ │ └── print-return.gif │ ├── stars.json │ └── tags │ │ ├── args-kwargs.md │ │ ├── async-await.md │ │ ├── blocking.md │ │ ├── botvar.md │ │ ├── class.md │ │ ├── classmethod.md │ │ ├── codeblock.md │ │ ├── comparison.md │ │ ├── contribute.md │ │ ├── customchecks.md │ │ ├── customcooldown.md │ │ ├── customhelp.md │ │ ├── dashmpip.md │ │ ├── decorators.md │ │ ├── defaultdict.md │ │ ├── dict-get.md │ │ ├── dictcomps.md │ │ ├── discord-bot-hosting.md │ │ ├── docstring.md │ │ ├── dotenv.md │ │ ├── dunder-methods.md │ │ ├── empty-json.md │ │ ├── enumerate.md │ │ ├── environments.md │ │ ├── equals-true.md │ │ ├── except.md │ │ ├── exit().md │ │ ├── f-strings.md │ │ ├── faq.md │ │ ├── floats.md │ │ ├── foo.md │ │ ├── for-else.md │ │ ├── functions-are-objects.md │ │ ├── global.md │ │ ├── guilds.md │ │ ├── identity.md │ │ ├── if-name-main.md │ │ ├── in-place.md │ │ ├── indent.md │ │ ├── inline.md │ │ ├── intents.md │ │ ├── iterate-dict.md │ │ ├── kindling-projects.md │ │ ├── listcomps.md │ │ ├── local-file.md │ │ ├── loop-remove.md │ │ ├── message-content-intent.md │ │ ├── microsoft-build-tools.md │ │ ├── modmail.md │ │ ├── mutability.md │ │ ├── mutable-default-args.md │ │ ├── names.md │ │ ├── nomodule.md │ │ ├── off-topic-names.md │ │ ├── on-message-event.md │ │ ├── open.md │ │ ├── or-gotcha.md │ │ ├── ot.md │ │ ├── param-arg.md │ │ ├── paste.md │ │ ├── pathlib.md │ │ ├── pep8.md │ │ ├── positional-keyword.md │ │ ├── precedence.md │ │ ├── quotes.md │ │ ├── range-len.md │ │ ├── regex.md │ │ ├── relative-path.md │ │ ├── repl.md │ │ ├── return-gif.md │ │ ├── return.md │ │ ├── round.md │ │ ├── scope.md │ │ ├── seek.md │ │ ├── self.md │ │ ├── site.md │ │ ├── slicing.md │ │ ├── sql-fstring.md │ │ ├── star-imports.md │ │ ├── str-join.md │ │ ├── string-formatting.md │ │ ├── strip-gotcha.md │ │ ├── system-python.md │ │ ├── tools.md │ │ ├── traceback.md │ │ ├── type-hint.md │ │ ├── underscore.md │ │ ├── venv.md │ │ ├── voice-verification.md │ │ ├── windows-path.md │ │ ├── with.md │ │ ├── xy-problem.md │ │ ├── ytdl.md │ │ └── zip.md └── utils │ ├── __init__.py │ ├── channel.py │ ├── checks.py │ ├── function.py │ ├── helpers.py │ ├── lock.py │ ├── message_cache.py │ ├── messages.py │ ├── modlog.py │ ├── time.py │ └── webhooks.py ├── botstrap.py ├── docker-compose.yml ├── poetry.lock ├── pyproject.toml └── tests ├── README.md ├── __init__.py ├── _autospec.py ├── base.py ├── bot ├── .testenv ├── __init__.py ├── exts │ ├── __init__.py │ ├── backend │ │ ├── __init__.py │ │ ├── sync │ │ │ ├── __init__.py │ │ │ ├── test_base.py │ │ │ ├── test_cog.py │ │ │ ├── test_roles.py │ │ │ └── test_users.py │ │ ├── test_error_handler.py │ │ ├── test_logging.py │ │ └── test_security.py │ ├── filtering │ │ ├── __init__.py │ │ ├── test_discord_token_filter.py │ │ ├── test_extension_filter.py │ │ ├── test_settings.py │ │ ├── test_settings_entries.py │ │ └── test_token_filter.py │ ├── info │ │ ├── __init__.py │ │ ├── doc │ │ │ ├── __init__.py │ │ │ └── test_parsing.py │ │ ├── test_help.py │ │ └── test_information.py │ ├── moderation │ │ ├── __init__.py │ │ ├── infraction │ │ │ ├── __init__.py │ │ │ ├── test_infractions.py │ │ │ └── test_utils.py │ │ ├── test_clean.py │ │ ├── test_incidents.py │ │ ├── test_modlog.py │ │ ├── test_silence.py │ │ └── test_slowmode.py │ ├── recruitment │ │ ├── __init__.py │ │ └── talentpool │ │ │ └── test_review.py │ ├── test_cogs.py │ └── utils │ │ ├── __init__.py │ │ ├── snekbox │ │ ├── __init__.py │ │ ├── test_io.py │ │ └── test_snekbox.py │ │ └── test_utils.py ├── resources │ ├── __init__.py │ └── test_resources.py ├── test_constants.py ├── test_converters.py ├── test_decorators.py └── utils │ ├── __init__.py │ ├── test_checks.py │ ├── test_helpers.py │ ├── test_message_cache.py │ ├── test_messages.py │ └── test_time.py ├── helpers.py ├── test_base.py └── test_helpers.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | scripts 3 | htmlcov 4 | __pycache__ 5 | .vagrant 6 | .pytest_cache 7 | .github 8 | .gitlab 9 | .cache 10 | Vagrantfile 11 | .coverage 12 | .coveragerc 13 | .gitignore 14 | .travis.yml 15 | *.log 16 | docker 17 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Migrate code style to ruff 2 | 8dca42846d2956122d45795763095559a6a51b64 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Extensions 2 | **/bot/exts/backend/sync/** @MarkKoz 3 | **/bot/exts/moderation/*silence.py @MarkKoz 4 | bot/exts/info/codeblock/** @MarkKoz 5 | bot/exts/utils/extensions.py @MarkKoz 6 | bot/exts/utils/snekbox.py @MarkKoz 7 | bot/exts/moderation/** @mbaruh 8 | bot/exts/info/information.py @mbaruh 9 | bot/exts/filtering/** @mbaruh 10 | bot/exts/recruitment/** @wookie184 11 | 12 | # Utils 13 | bot/utils/function.py @MarkKoz 14 | bot/utils/lock.py @MarkKoz 15 | 16 | # Tests 17 | tests/_autospec.py @MarkKoz 18 | tests/bot/exts/test_cogs.py @MarkKoz 19 | 20 | # CI & Docker 21 | .github/workflows/** @MarkKoz 22 | Dockerfile @MarkKoz 23 | docker-compose.yml @MarkKoz 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | ignore: 8 | - dependency-name: "*" 9 | update-types: 10 | - version-update:semver-patch 11 | - version-update:semver-minor 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | reviewers: 17 | - "python-discord/devops" 18 | -------------------------------------------------------------------------------- /.github/review-policy.yml: -------------------------------------------------------------------------------- 1 | remote: python-discord/.github 2 | path: review-policies/core-developers.yml 3 | ref: main 4 | -------------------------------------------------------------------------------- /.github/workflows/build-deploy.yml: -------------------------------------------------------------------------------- 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 workflow" 8 | required: true 9 | type: string 10 | 11 | 12 | jobs: 13 | build: 14 | name: Build & Push 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | # The current version (v2) of Docker's build-push action uses 22 | # buildx, which comes with BuildKit features that help us speed 23 | # up our builds using additional cache features. Buildx also 24 | # has a lot of other features that are not as relevant to us. 25 | # 26 | # See https://github.com/docker/build-push-action 27 | 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v3 30 | 31 | - name: Login to Github Container Registry 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.repository_owner }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | # Build and push the container to the GitHub Container 39 | # Repository. The container will be tagged as "latest" 40 | # and with the short SHA of the commit. 41 | 42 | - name: Build and push 43 | uses: docker/build-push-action@v6 44 | with: 45 | context: . 46 | file: ./Dockerfile 47 | push: ${{ github.ref == 'refs/heads/main' }} 48 | cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest 49 | cache-to: type=inline 50 | tags: | 51 | ghcr.io/python-discord/bot:latest 52 | ghcr.io/python-discord/bot:${{ inputs.sha-tag }} 53 | build-args: | 54 | git_sha=${{ github.sha }} 55 | 56 | deploy: 57 | name: Deploy 58 | needs: build 59 | runs-on: ubuntu-latest 60 | if: ${{ github.ref == 'refs/heads/main' }} 61 | environment: production 62 | steps: 63 | - name: Checkout Kubernetes repository 64 | uses: actions/checkout@v4 65 | with: 66 | repository: python-discord/infra 67 | path: infra 68 | 69 | - uses: azure/setup-kubectl@v4 70 | 71 | - name: Authenticate with Kubernetes 72 | uses: azure/k8s-set-context@v4 73 | with: 74 | method: kubeconfig 75 | kubeconfig: ${{ secrets.KUBECONFIG }} 76 | 77 | - name: Deploy to Kubernetes 78 | uses: azure/k8s-deploy@v5 79 | with: 80 | namespace: bots 81 | manifests: | 82 | infra/kubernetes/namespaces/bots/bot/deployment.yaml 83 | images: 'ghcr.io/python-discord/bot:${{ inputs.sha-tag }}' 84 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | 15 | jobs: 16 | 17 | lint-test: 18 | uses: ./.github/workflows/lint-test.yml 19 | 20 | generate-sha-tag: 21 | if: github.ref == 'refs/heads/main' 22 | runs-on: ubuntu-latest 23 | outputs: 24 | sha-tag: ${{ steps.sha-tag.outputs.sha-tag }} 25 | steps: 26 | - name: Create SHA Container tag 27 | id: sha-tag 28 | run: | 29 | tag=$(cut -c 1-7 <<< $GITHUB_SHA) 30 | echo "sha-tag=$tag" >> $GITHUB_OUTPUT 31 | 32 | 33 | build-deploy: 34 | uses: ./.github/workflows/build-deploy.yml 35 | needs: 36 | - lint-test 37 | - generate-sha-tag 38 | with: 39 | sha-tag: ${{ needs.generate-sha-tag.outputs.sha-tag }} 40 | secrets: inherit 41 | 42 | sentry-release: 43 | if: github.ref == 'refs/heads/main' 44 | uses: ./.github/workflows/sentry_release.yml 45 | needs: build-deploy 46 | secrets: inherit 47 | -------------------------------------------------------------------------------- /.github/workflows/sentry_release.yml: -------------------------------------------------------------------------------- 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@v3 16 | env: 17 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 18 | SENTRY_ORG: python-discord 19 | SENTRY_PROJECT: bot 20 | with: 21 | environment: production 22 | version_prefix: bot@ 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | .venv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # mypy 100 | .mypy_cache/ 101 | 102 | # PyCharm 103 | .idea/ 104 | 105 | # VSCode 106 | .vscode/ 107 | 108 | # Vagrant 109 | .vagrant 110 | 111 | # Logfiles 112 | log.* 113 | *.log.* 114 | !log.py 115 | 116 | # Custom user configuration 117 | *config.yml 118 | docker-compose.override.yml 119 | metricity-config.toml 120 | pyrightconfig.json 121 | 122 | # xmlrunner unittest XML reports 123 | TEST-**.xml 124 | 125 | # Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder 126 | .DS_Store 127 | *.env* 128 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: check-toml 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | args: [--markdown-linebreak-ext=md] 11 | - repo: local 12 | hooks: 13 | - id: ruff 14 | name: ruff 15 | description: Run ruff linting 16 | entry: poetry run ruff check --force-exclude 17 | language: system 18 | 'types_or': [python, pyi] 19 | require_serial: true 20 | args: [--fix, --exit-non-zero-on-fix] 21 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The Python Discord Code of Conduct can be found [on our website](https://pydis.com/coc). 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | The Contributing Guidelines for Python Discord projects can be found [on our website](https://pydis.com/contributing.md). 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 ghcr.io/owl-corp/python-poetry-base:3.12-slim 2 | 3 | # Define Git SHA build argument for sentry 4 | ARG git_sha="development" 5 | ENV GIT_SHA=$git_sha 6 | 7 | # Install project dependencies 8 | WORKDIR /bot 9 | COPY pyproject.toml poetry.lock ./ 10 | RUN poetry install --without dev 11 | 12 | # Copy the source code in last to optimize rebuilding the image 13 | COPY . . 14 | 15 | ENTRYPOINT ["poetry"] 16 | CMD ["run", "python", "-m", "bot"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 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 | # Python Utility Bot 2 | 3 | [![Discord][1]][2] 4 | [![CI][3]][4] 5 | [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) 6 | 7 | This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities 8 | and other tools to help keep the server running like a well-oiled machine. 9 | 10 | Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) on our website if you're interested in helping out. 11 | 12 | [1]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg 13 | [2]: https://discord.gg/python 14 | [3]: https://github.com/python-discord/bot/actions/workflows/main.yml/badge.svg?branch=main 15 | [4]: https://github.com/python-discord/bot/actions/workflows/main.yml?query=branch%3Amain 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Notice 2 | 3 | The Security Notice for Python Discord projects can be found [on our website](https://pydis.com/security.md). 4 | -------------------------------------------------------------------------------- /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 import log 8 | 9 | if TYPE_CHECKING: 10 | from bot.bot import Bot 11 | 12 | log.setup() 13 | 14 | # On Windows, the selector event loop is required for aiodns. 15 | if os.name == "nt": 16 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 17 | 18 | apply_monkey_patches() 19 | 20 | instance: "Bot" = None # Global Bot instance. 21 | -------------------------------------------------------------------------------- /bot/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Hashable 4 | from typing import TYPE_CHECKING 5 | 6 | from discord.ext.commands import ConversionError, Converter 7 | 8 | if TYPE_CHECKING: 9 | from bot.converters import MemberOrUser 10 | 11 | 12 | class LockedResourceError(RuntimeError): 13 | """ 14 | Exception raised when an operation is attempted on a locked resource. 15 | 16 | Attributes: 17 | `type` -- name of the locked resource's type 18 | `id` -- ID of the locked resource 19 | """ 20 | 21 | def __init__(self, resource_type: str, resource_id: Hashable): 22 | self.type = resource_type 23 | self.id = resource_id 24 | 25 | super().__init__( 26 | f"Cannot operate on {self.type.lower()} `{self.id}`; " 27 | "it is currently locked and in use by another operation." 28 | ) 29 | 30 | 31 | class InvalidInfractedUserError(Exception): 32 | """ 33 | Exception raised upon attempt of infracting an invalid user. 34 | 35 | Attributes: 36 | `user` -- User or Member which is invalid 37 | """ 38 | 39 | def __init__(self, user: MemberOrUser, reason: str = "User infracted is a bot."): 40 | 41 | self.user = user 42 | self.reason = reason 43 | 44 | super().__init__(reason) 45 | 46 | 47 | class InvalidInfractionError(ConversionError): 48 | """ 49 | Raised by the Infraction converter when trying to fetch an invalid infraction id. 50 | 51 | Attributes: 52 | `infraction_arg` -- the value that we attempted to convert into an Infraction 53 | """ 54 | 55 | def __init__(self, converter: Converter, original: Exception, infraction_arg: int | str): 56 | 57 | self.infraction_arg = infraction_arg 58 | super().__init__(converter, original) 59 | 60 | 61 | class BrandingMisconfigurationError(RuntimeError): 62 | """Raised by the Branding cog when branding misconfiguration is detected.""" 63 | 64 | 65 | 66 | class NonExistentRoleError(ValueError): 67 | """ 68 | Raised by the Information Cog when encountering a Role that does not exist. 69 | 70 | Attributes: 71 | `role_id` -- the ID of the role that does not exist 72 | """ 73 | 74 | def __init__(self, role_id: int): 75 | super().__init__(f"Could not fetch data for role {role_id}") 76 | 77 | self.role_id = role_id 78 | -------------------------------------------------------------------------------- /bot/exts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/__init__.py -------------------------------------------------------------------------------- /bot/exts/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/backend/__init__.py -------------------------------------------------------------------------------- /bot/exts/backend/branding/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import Bot 2 | from bot.exts.backend.branding._cog import Branding 3 | 4 | 5 | async def setup(bot: Bot) -> None: 6 | """Load Branding cog.""" 7 | await bot.add_cog(Branding(bot)) 8 | -------------------------------------------------------------------------------- /bot/exts/backend/config_verifier.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog 2 | 3 | from bot import constants 4 | from bot.bot import Bot 5 | from bot.log import get_logger 6 | 7 | log = get_logger(__name__) 8 | 9 | 10 | class ConfigVerifier(Cog): 11 | """Verify config on startup.""" 12 | 13 | def __init__(self, bot: Bot): 14 | self.bot = bot 15 | 16 | async def cog_load(self) -> None: 17 | """ 18 | Verify channels. 19 | 20 | If any channels in config aren't present in server, log them in a warning. 21 | """ 22 | await self.bot.wait_until_guild_available() 23 | server = self.bot.get_guild(constants.Guild.id) 24 | 25 | server_channel_ids = {channel.id for channel in server.channels} 26 | invalid_channels = [ 27 | (channel_name, channel_id) for channel_name, channel_id in constants.Channels 28 | if channel_id not in server_channel_ids 29 | ] 30 | 31 | if invalid_channels: 32 | log.warning(f"Configured channels do not exist in server: {invalid_channels}.") 33 | 34 | 35 | async def setup(bot: Bot) -> None: 36 | """Load the ConfigVerifier cog.""" 37 | await bot.add_cog(ConfigVerifier(bot)) 38 | -------------------------------------------------------------------------------- /bot/exts/backend/logging.py: -------------------------------------------------------------------------------- 1 | from discord import Embed 2 | from discord.ext.commands import Cog 3 | from pydis_core.utils import scheduling 4 | 5 | from bot.bot import Bot 6 | from bot.constants import Channels, DEBUG_MODE 7 | from bot.log import get_logger 8 | 9 | log = get_logger(__name__) 10 | 11 | 12 | class Logging(Cog): 13 | """Debug logging module.""" 14 | 15 | def __init__(self, bot: Bot): 16 | self.bot = bot 17 | 18 | scheduling.create_task(self.startup_greeting()) 19 | 20 | async def startup_greeting(self) -> None: 21 | """Announce our presence to the configured devlog channel.""" 22 | await self.bot.wait_until_guild_available() 23 | log.info("Bot connected!") 24 | 25 | embed = Embed(description="Connected!") 26 | embed.set_author( 27 | name="Python Bot", 28 | url="https://github.com/python-discord/bot", 29 | icon_url=( 30 | "https://raw.githubusercontent.com/" 31 | "python-discord/branding/main/logos/logo_circle/logo_circle_large.png" 32 | ) 33 | ) 34 | 35 | if not DEBUG_MODE: 36 | await self.bot.get_channel(Channels.dev_log).send(embed=embed) 37 | 38 | 39 | async def setup(bot: Bot) -> None: 40 | """Load the Logging cog.""" 41 | await bot.add_cog(Logging(bot)) 42 | -------------------------------------------------------------------------------- /bot/exts/backend/security.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog, Context, NoPrivateMessage 2 | 3 | from bot.bot import Bot 4 | from bot.log import get_logger 5 | 6 | log = get_logger(__name__) 7 | 8 | 9 | class Security(Cog): 10 | """Security-related helpers.""" 11 | 12 | def __init__(self, bot: Bot): 13 | self.bot = bot 14 | self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all 15 | self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM 16 | 17 | def check_not_bot(self, ctx: Context) -> bool: 18 | """Check if the context is a bot user.""" 19 | return not ctx.author.bot 20 | 21 | def check_on_guild(self, ctx: Context) -> bool: 22 | """Check if the context is in a guild.""" 23 | if ctx.guild is None: 24 | raise NoPrivateMessage("This command cannot be used in private messages.") 25 | return True 26 | 27 | 28 | async def setup(bot: Bot) -> None: 29 | """Load the Security cog.""" 30 | await bot.add_cog(Security(bot)) 31 | -------------------------------------------------------------------------------- /bot/exts/backend/sync/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import Bot 2 | 3 | 4 | async def setup(bot: Bot) -> None: 5 | """Load the Sync cog.""" 6 | # Defer import to reduce side effects from importing the sync package. 7 | from bot.exts.backend.sync._cog import Sync 8 | await bot.add_cog(Sync(bot)) 9 | -------------------------------------------------------------------------------- /bot/exts/filtering/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/filtering/__init__.py -------------------------------------------------------------------------------- /bot/exts/filtering/_filter_lists/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType, ListTypeConverter 4 | from bot.exts.filtering._utils import subclasses_in_package 5 | 6 | filter_list_types = subclasses_in_package(dirname(__file__), f"{__name__}.", FilterList) 7 | filter_list_types = {filter_list.name: filter_list for filter_list in filter_list_types} 8 | 9 | __all__ = [filter_list_types, FilterList, ListType, ListTypeConverter] 10 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filter_lists/unique.py: -------------------------------------------------------------------------------- 1 | from pydis_core.utils.logging import get_logger 2 | 3 | from bot.exts.filtering._filter_context import FilterContext 4 | from bot.exts.filtering._filter_lists.filter_list import ListType, UniquesListBase 5 | from bot.exts.filtering._filters.filter import Filter, UniqueFilter 6 | from bot.exts.filtering._filters.unique import unique_filter_types 7 | from bot.exts.filtering._settings import ActionSettings 8 | 9 | log = get_logger(__name__) 10 | 11 | 12 | class UniquesList(UniquesListBase): 13 | """ 14 | A list of unique filters. 15 | 16 | Unique filters are ones that should only be run once in a given context. 17 | Each unique filter subscribes to a subset of events to respond to. 18 | """ 19 | 20 | name = "unique" 21 | 22 | def get_filter_type(self, content: str) -> type[UniqueFilter] | None: 23 | """Get a subclass of filter matching the filter list and the filter's content.""" 24 | try: 25 | return unique_filter_types[content] 26 | except KeyError: 27 | return None 28 | 29 | async def actions_for( 30 | self, ctx: FilterContext 31 | ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: 32 | """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" 33 | triggers = await self[ListType.DENY].filter_list_result(ctx) 34 | actions = None 35 | messages = [] 36 | if triggers: 37 | actions = self[ListType.DENY].merge_actions(triggers) 38 | messages = self[ListType.DENY].format_messages(triggers) 39 | return actions, messages, {ListType.DENY: triggers} 40 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/filtering/_filters/__init__.py -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from bot.exts.filtering._filters.filter import UniqueFilter 4 | from bot.exts.filtering._utils import subclasses_in_package 5 | 6 | antispam_filter_types = subclasses_in_package(dirname(__file__), f"{__name__}.", UniqueFilter) 7 | antispam_filter_types = {filter_.name: filter_ for filter_ in antispam_filter_types} 8 | 9 | __all__ = [antispam_filter_types] 10 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/attachments.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from itertools import takewhile 3 | from typing import ClassVar 4 | 5 | import arrow 6 | from pydantic import BaseModel 7 | 8 | from bot.exts.filtering._filter_context import Event, FilterContext 9 | from bot.exts.filtering._filters.filter import UniqueFilter 10 | 11 | 12 | class ExtraAttachmentsSettings(BaseModel): 13 | """Extra settings for when to trigger the antispam rule.""" 14 | 15 | interval_description: ClassVar[str] = ( 16 | "Look for rule violations in messages from the last `interval` number of seconds." 17 | ) 18 | threshold_description: ClassVar[str] = "Maximum number of attachments before the filter is triggered." 19 | 20 | interval: int = 10 21 | threshold: int = 6 22 | 23 | 24 | class AttachmentsFilter(UniqueFilter): 25 | """Detects too many attachments sent by a single user.""" 26 | 27 | name = "attachments" 28 | events = (Event.MESSAGE,) 29 | extra_fields_type = ExtraAttachmentsSettings 30 | 31 | async def triggered_on(self, ctx: FilterContext) -> bool: 32 | """Search for the filter's content within a given context.""" 33 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 34 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 35 | 36 | detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author and len(msg.attachments) > 0} 37 | total_recent_attachments = sum(len(msg.attachments) for msg in detected_messages) 38 | 39 | if total_recent_attachments > self.extra_fields.threshold: 40 | ctx.related_messages |= detected_messages 41 | ctx.filter_info[self] = f"sent {total_recent_attachments} attachments" 42 | return True 43 | return False 44 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/burst.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from itertools import takewhile 3 | from typing import ClassVar 4 | 5 | import arrow 6 | from pydantic import BaseModel 7 | 8 | from bot.exts.filtering._filter_context import Event, FilterContext 9 | from bot.exts.filtering._filters.filter import UniqueFilter 10 | 11 | 12 | class ExtraBurstSettings(BaseModel): 13 | """Extra settings for when to trigger the antispam rule.""" 14 | 15 | interval_description: ClassVar[str] = ( 16 | "Look for rule violations in messages from the last `interval` number of seconds." 17 | ) 18 | threshold_description: ClassVar[str] = "Maximum number of messages before the filter is triggered." 19 | 20 | interval: int = 10 21 | threshold: int = 7 22 | 23 | 24 | class BurstFilter(UniqueFilter): 25 | """Detects too many messages sent by a single user.""" 26 | 27 | name = "burst" 28 | events = (Event.MESSAGE,) 29 | extra_fields_type = ExtraBurstSettings 30 | 31 | async def triggered_on(self, ctx: FilterContext) -> bool: 32 | """Search for the filter's content within a given context.""" 33 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 34 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 35 | 36 | detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} 37 | if len(detected_messages) > self.extra_fields.threshold: 38 | ctx.related_messages |= detected_messages 39 | ctx.filter_info[self] = f"sent {len(detected_messages)} messages" 40 | return True 41 | return False 42 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/chars.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from itertools import takewhile 3 | from typing import ClassVar 4 | 5 | import arrow 6 | from pydantic import BaseModel 7 | 8 | from bot.exts.filtering._filter_context import Event, FilterContext 9 | from bot.exts.filtering._filters.filter import UniqueFilter 10 | 11 | 12 | class ExtraCharsSettings(BaseModel): 13 | """Extra settings for when to trigger the antispam rule.""" 14 | 15 | interval_description: ClassVar[str] = ( 16 | "Look for rule violations in messages from the last `interval` number of seconds." 17 | ) 18 | threshold_description: ClassVar[str] = "Maximum number of characters before the filter is triggered." 19 | 20 | interval: int = 5 21 | threshold: int = 4_200 22 | 23 | 24 | class CharsFilter(UniqueFilter): 25 | """Detects too many characters sent by a single user.""" 26 | 27 | name = "chars" 28 | events = (Event.MESSAGE,) 29 | extra_fields_type = ExtraCharsSettings 30 | 31 | async def triggered_on(self, ctx: FilterContext) -> bool: 32 | """Search for the filter's content within a given context.""" 33 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 34 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 35 | 36 | detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} 37 | total_recent_chars = sum(len(msg.content) for msg in detected_messages) 38 | 39 | if total_recent_chars > self.extra_fields.threshold: 40 | ctx.related_messages |= detected_messages 41 | ctx.filter_info[self] = f"sent {total_recent_chars} characters" 42 | return True 43 | return False 44 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/duplicates.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from itertools import takewhile 3 | from typing import ClassVar 4 | 5 | import arrow 6 | from pydantic import BaseModel 7 | 8 | from bot.exts.filtering._filter_context import Event, FilterContext 9 | from bot.exts.filtering._filters.filter import UniqueFilter 10 | 11 | 12 | class ExtraDuplicatesSettings(BaseModel): 13 | """Extra settings for when to trigger the antispam rule.""" 14 | 15 | interval_description: ClassVar[str] = ( 16 | "Look for rule violations in messages from the last `interval` number of seconds." 17 | ) 18 | threshold_description: ClassVar[str] = "Maximum number of duplicate messages before the filter is triggered." 19 | 20 | interval: int = 10 21 | threshold: int = 3 22 | 23 | 24 | class DuplicatesFilter(UniqueFilter): 25 | """Detects duplicated messages sent by a single user.""" 26 | 27 | name = "duplicates" 28 | events = (Event.MESSAGE,) 29 | extra_fields_type = ExtraDuplicatesSettings 30 | 31 | async def triggered_on(self, ctx: FilterContext) -> bool: 32 | """Search for the filter's content within a given context.""" 33 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 34 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 35 | 36 | detected_messages = { 37 | msg for msg in relevant_messages 38 | if msg.author == ctx.author and msg.content == ctx.message.content and msg.content 39 | } 40 | if len(detected_messages) > self.extra_fields.threshold: 41 | ctx.related_messages |= detected_messages 42 | ctx.filter_info[self] = f"sent {len(detected_messages)} duplicate messages" 43 | return True 44 | return False 45 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/emoji.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import timedelta 3 | from itertools import takewhile 4 | from typing import ClassVar 5 | 6 | import arrow 7 | from emoji import demojize 8 | from pydantic import BaseModel 9 | 10 | from bot.exts.filtering._filter_context import Event, FilterContext 11 | from bot.exts.filtering._filters.filter import UniqueFilter 12 | 13 | DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:") 14 | CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) 15 | 16 | 17 | class ExtraEmojiSettings(BaseModel): 18 | """Extra settings for when to trigger the antispam rule.""" 19 | 20 | interval_description: ClassVar[str] = ( 21 | "Look for rule violations in messages from the last `interval` number of seconds." 22 | ) 23 | threshold_description: ClassVar[str] = "Maximum number of emojis before the filter is triggered." 24 | 25 | interval: int = 10 26 | threshold: int = 20 27 | 28 | 29 | class EmojiFilter(UniqueFilter): 30 | """Detects too many emojis sent by a single user.""" 31 | 32 | name = "emoji" 33 | events = (Event.MESSAGE,) 34 | extra_fields_type = ExtraEmojiSettings 35 | 36 | async def triggered_on(self, ctx: FilterContext) -> bool: 37 | """Search for the filter's content within a given context.""" 38 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 39 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 40 | detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} 41 | 42 | # Get rid of code blocks in the message before searching for emojis. 43 | # Convert Unicode emojis to :emoji: format to get their count. 44 | total_emojis = sum( 45 | len(DISCORD_EMOJI_RE.findall(demojize(CODE_BLOCK_RE.sub("", msg.content)))) 46 | for msg in detected_messages 47 | ) 48 | 49 | if total_emojis > self.extra_fields.threshold: 50 | ctx.related_messages |= detected_messages 51 | ctx.filter_info[self] = f"sent {total_emojis} emojis" 52 | return True 53 | return False 54 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/links.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import timedelta 3 | from itertools import takewhile 4 | from typing import ClassVar 5 | 6 | import arrow 7 | from pydantic import BaseModel 8 | 9 | from bot.exts.filtering._filter_context import Event, FilterContext 10 | from bot.exts.filtering._filters.filter import UniqueFilter 11 | 12 | LINK_RE = re.compile(r"(https?://\S+)") 13 | 14 | 15 | class ExtraLinksSettings(BaseModel): 16 | """Extra settings for when to trigger the antispam rule.""" 17 | 18 | interval_description: ClassVar[str] = ( 19 | "Look for rule violations in messages from the last `interval` number of seconds." 20 | ) 21 | threshold_description: ClassVar[str] = "Maximum number of links before the filter is triggered." 22 | 23 | interval: int = 10 24 | threshold: int = 10 25 | 26 | 27 | class LinksFilter(UniqueFilter): 28 | """Detects too many links sent by a single user.""" 29 | 30 | name = "links" 31 | events = (Event.MESSAGE,) 32 | extra_fields_type = ExtraLinksSettings 33 | 34 | async def triggered_on(self, ctx: FilterContext) -> bool: 35 | """Search for the filter's content within a given context.""" 36 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 37 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 38 | detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} 39 | 40 | total_links = 0 41 | messages_with_links = 0 42 | for msg in detected_messages: 43 | total_matches = len(LINK_RE.findall(msg.content)) 44 | if total_matches: 45 | messages_with_links += 1 46 | total_links += total_matches 47 | 48 | if total_links > self.extra_fields.threshold and messages_with_links > 1: 49 | ctx.related_messages |= detected_messages 50 | ctx.filter_info[self] = f"sent {total_links} links" 51 | return True 52 | return False 53 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/newlines.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import timedelta 3 | from itertools import takewhile 4 | from typing import ClassVar 5 | 6 | import arrow 7 | from pydantic import BaseModel 8 | 9 | from bot.exts.filtering._filter_context import Event, FilterContext 10 | from bot.exts.filtering._filters.filter import UniqueFilter 11 | 12 | NEWLINES = re.compile(r"(\n+)") 13 | 14 | 15 | class ExtraNewlinesSettings(BaseModel): 16 | """Extra settings for when to trigger the antispam rule.""" 17 | 18 | interval_description: ClassVar[str] = ( 19 | "Look for rule violations in messages from the last `interval` number of seconds." 20 | ) 21 | threshold_description: ClassVar[str] = "Maximum number of newlines before the filter is triggered." 22 | consecutive_threshold_description: ClassVar[str] = ( 23 | "Maximum number of consecutive newlines before the filter is triggered." 24 | ) 25 | 26 | interval: int = 10 27 | threshold: int = 100 28 | consecutive_threshold: int = 10 29 | 30 | 31 | class NewlinesFilter(UniqueFilter): 32 | """Detects too many newlines sent by a single user.""" 33 | 34 | name = "newlines" 35 | events = (Event.MESSAGE,) 36 | extra_fields_type = ExtraNewlinesSettings 37 | 38 | async def triggered_on(self, ctx: FilterContext) -> bool: 39 | """Search for the filter's content within a given context.""" 40 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 41 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 42 | detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} 43 | 44 | # Identify groups of newline characters and get group & total counts 45 | newline_counts = [] 46 | for msg in detected_messages: 47 | newline_counts += [len(group) for group in NEWLINES.findall(msg.content)] 48 | total_recent_newlines = sum(newline_counts) 49 | # Get maximum newline group size 50 | max_newline_group = max(newline_counts, default=0) 51 | 52 | # Check first for total newlines, if this passes then check for large groupings 53 | if total_recent_newlines > self.extra_fields.threshold: 54 | ctx.related_messages |= detected_messages 55 | ctx.filter_info[self] = f"sent {total_recent_newlines} newlines" 56 | return True 57 | if max_newline_group > self.extra_fields.consecutive_threshold: 58 | ctx.related_messages |= detected_messages 59 | ctx.filter_info[self] = f"sent {max_newline_group} consecutive newlines" 60 | return True 61 | return False 62 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/antispam/role_mentions.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from itertools import takewhile 3 | from typing import ClassVar 4 | 5 | import arrow 6 | from pydantic import BaseModel 7 | 8 | from bot.exts.filtering._filter_context import Event, FilterContext 9 | from bot.exts.filtering._filters.filter import UniqueFilter 10 | 11 | 12 | class ExtraRoleMentionsSettings(BaseModel): 13 | """Extra settings for when to trigger the antispam rule.""" 14 | 15 | interval_description: ClassVar[str] = ( 16 | "Look for rule violations in messages from the last `interval` number of seconds." 17 | ) 18 | threshold_description: ClassVar[str] = "Maximum number of role mentions before the filter is triggered." 19 | 20 | interval: int = 10 21 | threshold: int = 3 22 | 23 | 24 | class RoleMentionsFilter(UniqueFilter): 25 | """Detects too many role mentions sent by a single user.""" 26 | 27 | name = "role_mentions" 28 | events = (Event.MESSAGE,) 29 | extra_fields_type = ExtraRoleMentionsSettings 30 | 31 | async def triggered_on(self, ctx: FilterContext) -> bool: 32 | """Search for the filter's content within a given context.""" 33 | earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) 34 | relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) 35 | detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} 36 | total_recent_mentions = sum(len(msg.role_mentions) for msg in detected_messages) 37 | 38 | if total_recent_mentions > self.extra_fields.threshold: 39 | ctx.related_messages |= detected_messages 40 | ctx.filter_info[self] = f"sent {total_recent_mentions} role mentions" 41 | return True 42 | return False 43 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/domain.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import ClassVar 3 | from urllib.parse import urlparse 4 | 5 | import tldextract 6 | from discord.ext.commands import BadArgument 7 | from pydantic import BaseModel 8 | 9 | from bot.exts.filtering._filter_context import FilterContext 10 | from bot.exts.filtering._filters.filter import Filter 11 | 12 | URL_RE = re.compile(r"(?:https?://)?(\S+?)[\\/]*", flags=re.IGNORECASE) 13 | 14 | 15 | class ExtraDomainSettings(BaseModel): 16 | """Extra settings for how domains should be matched in a message.""" 17 | 18 | only_subdomains_description: ClassVar[str] = ( 19 | "A boolean. If True, will only trigger for subdomains and subpaths, and not for the domain itself." 20 | ) 21 | 22 | # Whether to trigger only for subdomains and subpaths, and not the specified domain itself. 23 | only_subdomains: bool = False 24 | 25 | 26 | class DomainFilter(Filter): 27 | """ 28 | A filter which looks for a specific domain given by URL. 29 | 30 | The schema (http, https) does not need to be included in the filter. 31 | Will also match subdomains. 32 | """ 33 | 34 | name = "domain" 35 | extra_fields_type = ExtraDomainSettings 36 | 37 | async def triggered_on(self, ctx: FilterContext) -> bool: 38 | """Searches for a domain within a given context.""" 39 | domain = tldextract.extract(self.content).registered_domain.lower() 40 | 41 | for found_url in ctx.content: 42 | extract = tldextract.extract(found_url) 43 | if self.content.lower() in found_url and extract.registered_domain == domain: 44 | if self.extra_fields.only_subdomains: 45 | if not extract.subdomain and not urlparse(f"https://{found_url}").path: 46 | return False 47 | ctx.matches.append(found_url) 48 | ctx.notification_domain = self.content 49 | return True 50 | return False 51 | 52 | @classmethod 53 | async def process_input(cls, content: str, description: str) -> tuple[str, str]: 54 | """ 55 | Process the content and description into a form which will work with the filtering. 56 | 57 | A BadArgument should be raised if the content can't be used. 58 | """ 59 | match = URL_RE.fullmatch(content) 60 | if not match or not match.group(1): 61 | raise BadArgument(f"`{content}` is not a URL.") 62 | return match.group(1), description 63 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/extension.py: -------------------------------------------------------------------------------- 1 | from bot.exts.filtering._filter_context import FilterContext 2 | from bot.exts.filtering._filters.filter import Filter 3 | 4 | 5 | class ExtensionFilter(Filter): 6 | """ 7 | A filter which looks for a specific attachment extension in messages. 8 | 9 | The filter stores the extension preceded by a dot. 10 | """ 11 | 12 | name = "extension" 13 | 14 | async def triggered_on(self, ctx: FilterContext) -> bool: 15 | """Searches for an attachment extension in the context content, given as a set of extensions.""" 16 | return self.content in ctx.content 17 | 18 | @classmethod 19 | async def process_input(cls, content: str, description: str) -> tuple[str, str]: 20 | """ 21 | Process the content and description into a form which will work with the filtering. 22 | 23 | A BadArgument should be raised if the content can't be used. 24 | """ 25 | if not content.startswith("."): 26 | content = f".{content}" 27 | return content, description 28 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/invite.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from discord import NotFound 4 | from discord.ext.commands import BadArgument 5 | from pydis_core.utils.regex import DISCORD_INVITE 6 | 7 | import bot 8 | from bot.exts.filtering._filter_context import FilterContext 9 | from bot.exts.filtering._filters.filter import Filter 10 | 11 | 12 | class InviteFilter(Filter): 13 | """ 14 | A filter which looks for invites to a specific guild in messages. 15 | 16 | The filter stores the guild ID which is allowed or denied. 17 | """ 18 | 19 | name = "invite" 20 | 21 | def __init__(self, filter_data: dict, defaults_data: dict | None = None): 22 | super().__init__(filter_data, defaults_data) 23 | self.content = int(self.content) 24 | 25 | async def triggered_on(self, ctx: FilterContext) -> bool: 26 | """Searches for a guild ID in the context content, given as a set of IDs.""" 27 | return self.content in ctx.content 28 | 29 | @classmethod 30 | async def process_input(cls, content: str, description: str) -> tuple[str, str]: 31 | """ 32 | Process the content and description into a form which will work with the filtering. 33 | 34 | A BadArgument should be raised if the content can't be used. 35 | """ 36 | match = DISCORD_INVITE.fullmatch(content) 37 | if not match or not match.group("invite"): 38 | if not re.fullmatch(r"\S+", content): 39 | raise BadArgument(f"`{content}` is not a valid Discord invite.") 40 | invite_code = content 41 | else: 42 | invite_code = match.group("invite") 43 | 44 | try: 45 | invite = await bot.instance.fetch_invite(invite_code) 46 | except NotFound: 47 | raise BadArgument(f"`{invite_code}` is not a valid Discord invite code.") 48 | if not invite.guild: 49 | raise BadArgument("Did you just try to add a group DM?") 50 | 51 | guild_name = invite.guild.name if hasattr(invite.guild, "name") else "" 52 | if guild_name.lower() not in description.lower(): 53 | description = " - ".join(part for part in (f'Guild "{guild_name}"', description) if part) 54 | return str(invite.guild.id), description 55 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/token.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from discord.ext.commands import BadArgument 4 | 5 | from bot.exts.filtering._filter_context import FilterContext 6 | from bot.exts.filtering._filters.filter import Filter 7 | 8 | 9 | class TokenFilter(Filter): 10 | """A filter which looks for a specific token given by regex.""" 11 | 12 | name = "token" 13 | 14 | async def triggered_on(self, ctx: FilterContext) -> bool: 15 | """Searches for a regex pattern within a given context.""" 16 | pattern = self.content 17 | 18 | match = re.search(pattern, ctx.content, flags=re.IGNORECASE) 19 | if match: 20 | ctx.matches.append(match[0]) 21 | return True 22 | return False 23 | 24 | @classmethod 25 | async def process_input(cls, content: str, description: str) -> tuple[str, str]: 26 | """ 27 | Process the content and description into a form which will work with the filtering. 28 | 29 | A BadArgument should be raised if the content can't be used. 30 | """ 31 | try: 32 | re.compile(content) 33 | except re.error as e: 34 | raise BadArgument(str(e)) 35 | return content, description 36 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/unique/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from bot.exts.filtering._filters.filter import UniqueFilter 4 | from bot.exts.filtering._utils import subclasses_in_package 5 | 6 | unique_filter_types = subclasses_in_package(dirname(__file__), f"{__name__}.", UniqueFilter) 7 | unique_filter_types = {filter_.name: filter_ for filter_ in unique_filter_types} 8 | 9 | __all__ = [unique_filter_types] 10 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/unique/everyone.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from bot.constants import Guild 4 | from bot.exts.filtering._filter_context import Event, FilterContext 5 | from bot.exts.filtering._filters.filter import UniqueFilter 6 | 7 | EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{Guild.id}>|@here") 8 | CODE_BLOCK_RE = re.compile( 9 | r"(?P``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock 10 | r"|```(.+?)```", # Multiline codeblock 11 | re.DOTALL | re.MULTILINE 12 | ) 13 | 14 | 15 | class EveryoneFilter(UniqueFilter): 16 | """Filter messages which contain `@everyone` and `@here` tags outside a codeblock.""" 17 | 18 | name = "everyone" 19 | events = (Event.MESSAGE, Event.MESSAGE_EDIT, Event.SNEKBOX) 20 | 21 | async def triggered_on(self, ctx: FilterContext) -> bool: 22 | """Search for the filter's content within a given context.""" 23 | # First pass to avoid running re.sub on every message 24 | if not EVERYONE_PING_RE.search(ctx.content): 25 | return False 26 | 27 | content_without_codeblocks = CODE_BLOCK_RE.sub("", ctx.content) 28 | return bool(EVERYONE_PING_RE.search(content_without_codeblocks)) 29 | -------------------------------------------------------------------------------- /bot/exts/filtering/_filters/unique/webhook.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections.abc import Callable, Coroutine 3 | 4 | from pydis_core.utils.logging import get_logger 5 | 6 | import bot 7 | from bot import constants 8 | from bot.exts.filtering._filter_context import Event, FilterContext 9 | from bot.exts.filtering._filters.filter import UniqueFilter 10 | from bot.exts.moderation.modlog import ModLog 11 | 12 | log = get_logger(__name__) 13 | 14 | 15 | WEBHOOK_URL_RE = re.compile( 16 | r"((?:https?://)?(?:ptb\.|canary\.)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", 17 | re.IGNORECASE 18 | ) 19 | 20 | 21 | class WebhookFilter(UniqueFilter): 22 | """Scan messages to detect Discord webhooks links.""" 23 | 24 | name = "webhook" 25 | events = (Event.MESSAGE, Event.MESSAGE_EDIT, Event.SNEKBOX) 26 | 27 | @property 28 | def mod_log(self) -> ModLog | None: 29 | """Get current instance of `ModLog`.""" 30 | return bot.instance.get_cog("ModLog") 31 | 32 | async def triggered_on(self, ctx: FilterContext) -> bool: 33 | """Search for a webhook in the given content. If found, attempt to delete it.""" 34 | matches = set(WEBHOOK_URL_RE.finditer(ctx.content)) 35 | if not matches: 36 | return False 37 | 38 | # Don't log this. 39 | if ctx.message and (mod_log := self.mod_log): 40 | mod_log.ignore(constants.Event.message_delete, ctx.message.id) 41 | 42 | for i, match in enumerate(matches, start=1): 43 | extra = "" if len(matches) == 1 else f" ({i})" 44 | # Queue the webhook for deletion. 45 | ctx.additional_actions.append(self._delete_webhook_wrapper(match[0], extra)) 46 | # Don't show the full webhook in places such as the mod alert. 47 | ctx.content = ctx.content.replace(match[0], match[1] + "xxx") 48 | 49 | return True 50 | 51 | @staticmethod 52 | def _delete_webhook_wrapper(webhook_url: str, extra_message: str) -> Callable[[FilterContext], Coroutine]: 53 | """Create the action to perform when a webhook should be deleted.""" 54 | async def _delete_webhook(ctx: FilterContext) -> None: 55 | """Delete the given webhook and update the filter context.""" 56 | async with bot.instance.http_session.delete(webhook_url) as resp: 57 | # The Discord API Returns a 204 NO CONTENT response on success. 58 | if resp.status == 204: 59 | ctx.action_descriptions.append("webhook deleted" + extra_message) 60 | else: 61 | ctx.action_descriptions.append("failed to delete webhook" + extra_message) 62 | 63 | return _delete_webhook 64 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.exts.filtering._settings_types.actions import action_types 2 | from bot.exts.filtering._settings_types.validations import validation_types 3 | 4 | settings_types = { 5 | "ActionEntry": {settings_type.name: settings_type for settings_type in action_types}, 6 | "ValidationEntry": {settings_type.name: settings_type for settings_type in validation_types} 7 | } 8 | 9 | __all__ = [settings_types] 10 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/actions/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from bot.exts.filtering._settings_types.settings_entry import ActionEntry 4 | from bot.exts.filtering._utils import subclasses_in_package 5 | 6 | action_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ActionEntry) 7 | 8 | __all__ = [action_types] 9 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/actions/ping.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Self 2 | 3 | from pydantic import field_validator 4 | 5 | from bot.exts.filtering._filter_context import FilterContext 6 | from bot.exts.filtering._settings_types.settings_entry import ActionEntry 7 | from bot.exts.filtering._utils import resolve_mention 8 | 9 | 10 | class Ping(ActionEntry): 11 | """A setting entry which adds the appropriate pings to the alert.""" 12 | 13 | name: ClassVar[str] = "mentions" 14 | description: ClassVar[dict[str, str]] = { 15 | "guild_pings": ( 16 | "A list of role IDs/role names/user IDs/user names/here/everyone. " 17 | "If a mod-alert is generated for a filter triggered in a public channel, these will be pinged." 18 | ), 19 | "dm_pings": ( 20 | "A list of role IDs/role names/user IDs/user names/here/everyone. " 21 | "If a mod-alert is generated for a filter triggered in DMs, these will be pinged." 22 | ) 23 | } 24 | 25 | guild_pings: set[str] 26 | dm_pings: set[str] 27 | 28 | @field_validator("*", mode="before") 29 | @classmethod 30 | def init_sequence_if_none(cls, pings: list[str] | None) -> list[str]: 31 | """Initialize an empty sequence if the value is None.""" 32 | if pings is None: 33 | return [] 34 | return pings 35 | 36 | async def action(self, ctx: FilterContext) -> None: 37 | """Add the stored pings to the alert message content.""" 38 | mentions = self.guild_pings if not ctx.channel or ctx.channel.guild else self.dm_pings 39 | new_content = " ".join([resolve_mention(mention) for mention in mentions]) 40 | ctx.alert_content = f"{new_content} {ctx.alert_content}" 41 | 42 | def union(self, other: Self) -> Self: 43 | """Combines two actions of the same type. Each type of action is executed once per filter.""" 44 | return Ping(guild_pings=self.guild_pings | other.guild_pings, dm_pings=self.dm_pings | other.dm_pings) 45 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/actions/send_alert.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Self 2 | 3 | from bot.exts.filtering._filter_context import FilterContext 4 | from bot.exts.filtering._settings_types.settings_entry import ActionEntry 5 | 6 | 7 | class SendAlert(ActionEntry): 8 | """A setting entry which tells whether to send an alert message.""" 9 | 10 | name: ClassVar[str] = "send_alert" 11 | description: ClassVar[str] = "A boolean. If all filters triggered set this to False, no mod-alert will be created." 12 | 13 | send_alert: bool 14 | 15 | async def action(self, ctx: FilterContext) -> None: 16 | """Add the stored pings to the alert message content.""" 17 | ctx.send_alert = self.send_alert 18 | 19 | def union(self, other: Self) -> Self: 20 | """Combines two actions of the same type. Each type of action is executed once per filter.""" 21 | return SendAlert(send_alert=self.send_alert or other.send_alert) 22 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/validations/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from bot.exts.filtering._settings_types.settings_entry import ValidationEntry 4 | from bot.exts.filtering._utils import subclasses_in_package 5 | 6 | validation_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ValidationEntry) 7 | 8 | __all__ = [validation_types] 9 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/validations/bypass_roles.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import ClassVar 3 | 4 | from discord import Member 5 | from pydantic import field_validator 6 | 7 | from bot.exts.filtering._filter_context import FilterContext 8 | from bot.exts.filtering._settings_types.settings_entry import ValidationEntry 9 | 10 | 11 | class RoleBypass(ValidationEntry): 12 | """A setting entry which tells whether the roles the member has allow them to bypass the filter.""" 13 | 14 | name: ClassVar[str] = "bypass_roles" 15 | description: ClassVar[str] = "A list of role IDs or role names. Users with these roles will not trigger the filter." 16 | 17 | bypass_roles: set[int | str] 18 | 19 | @field_validator("bypass_roles", mode="before") 20 | @classmethod 21 | def init_if_bypass_roles_none(cls, bypass_roles: Sequence[int | str] | None) -> Sequence[int | str]: 22 | """ 23 | Initialize an empty sequence if the value is None. 24 | 25 | This also coerces each element of bypass_roles to an int, if possible. 26 | """ 27 | if bypass_roles is None: 28 | return [] 29 | 30 | def _coerce_to_int(input: int | str) -> int | str: 31 | try: 32 | return int(input) 33 | except ValueError: 34 | return input 35 | 36 | return map(_coerce_to_int, bypass_roles) 37 | 38 | def triggers_on(self, ctx: FilterContext) -> bool: 39 | """Return whether the filter should be triggered on this user given their roles.""" 40 | if not isinstance(ctx.author, Member): 41 | return True 42 | return all( 43 | member_role.id not in self.bypass_roles and member_role.name not in self.bypass_roles 44 | for member_role in ctx.author.roles 45 | ) 46 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/validations/enabled.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from bot.exts.filtering._filter_context import FilterContext 4 | from bot.exts.filtering._settings_types.settings_entry import ValidationEntry 5 | 6 | 7 | class Enabled(ValidationEntry): 8 | """A setting entry which tells whether the filter is enabled.""" 9 | 10 | name: ClassVar[str] = "enabled" 11 | description: ClassVar[str] = ( 12 | "A boolean field. Setting it to False allows disabling the filter without deleting it entirely." 13 | ) 14 | 15 | enabled: bool 16 | 17 | def triggers_on(self, ctx: FilterContext) -> bool: 18 | """Return whether the filter is enabled.""" 19 | return self.enabled 20 | -------------------------------------------------------------------------------- /bot/exts/filtering/_settings_types/validations/filter_dm.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from bot.exts.filtering._filter_context import FilterContext 4 | from bot.exts.filtering._settings_types.settings_entry import ValidationEntry 5 | 6 | 7 | class FilterDM(ValidationEntry): 8 | """A setting entry which tells whether to apply the filter to DMs.""" 9 | 10 | name: ClassVar[str] = "filter_dm" 11 | description: ClassVar[str] = "A boolean field. If True, the filter can trigger for messages sent to the bot in DMs." 12 | 13 | filter_dm: bool 14 | 15 | def triggers_on(self, ctx: FilterContext) -> bool: 16 | """Return whether the filter should be triggered even if it was triggered in DMs.""" 17 | if not ctx.channel: # No channel - out of scope for this setting. 18 | return True 19 | 20 | return ctx.channel.guild is not None or self.filter_dm 21 | -------------------------------------------------------------------------------- /bot/exts/filtering/_ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/filtering/_ui/__init__.py -------------------------------------------------------------------------------- /bot/exts/fun/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/fun/__init__.py -------------------------------------------------------------------------------- /bot/exts/help_channels/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from bot.bot import Bot 3 | from bot.constants import HelpChannels 4 | from bot.exts.help_channels._cog import HelpForum 5 | from bot.log import get_logger 6 | 7 | log = get_logger(__name__) 8 | 9 | 10 | async def setup(bot: Bot) -> None: 11 | """Load the HelpForum cog.""" 12 | if not HelpChannels.enable: 13 | log.warning("HelpChannel.enabled set to false, not loading help channel cog.") 14 | return 15 | await bot.add_cog(HelpForum(bot)) 16 | -------------------------------------------------------------------------------- /bot/exts/help_channels/_caches.py: -------------------------------------------------------------------------------- 1 | from async_rediscache import RedisCache 2 | 3 | # Stores posts that have had a non-claimant, non-bot, reply. 4 | # Currently only used to determine whether the post was answered or not when collecting stats. 5 | posts_with_non_claimant_messages = RedisCache(namespace="HelpChannels.posts_with_non_claimant_messages") 6 | -------------------------------------------------------------------------------- /bot/exts/help_channels/_stats.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import arrow 4 | import discord 5 | 6 | import bot 7 | from bot import constants 8 | from bot.exts.help_channels import _caches 9 | from bot.log import get_logger 10 | 11 | log = get_logger(__name__) 12 | 13 | 14 | class ClosingReason(Enum): 15 | """All possible closing reasons for help channels.""" 16 | 17 | COMMAND = "command" 18 | INACTIVE = "auto.inactive" 19 | NATIVE = "auto.native" 20 | DELETED = "auto.deleted" 21 | CLEANUP = "auto.cleanup" 22 | 23 | 24 | def report_post_count() -> None: 25 | """Report post count stats of the help forum.""" 26 | help_forum = bot.instance.get_channel(constants.Channels.python_help) 27 | bot.instance.stats.gauge("help.total.in_use", len(help_forum.threads)) 28 | 29 | 30 | async def report_complete_session(help_session_post: discord.Thread, closed_on: ClosingReason) -> None: 31 | """ 32 | Report stats for a completed help session post `help_session_post`. 33 | 34 | `closed_on` is the reason why the post was closed. See `ClosingReason` for possible reasons. 35 | """ 36 | bot.instance.stats.incr(f"help.dormant_calls.{closed_on.value}") 37 | 38 | open_time = discord.utils.snowflake_time(help_session_post.id) 39 | in_use_time = arrow.utcnow() - open_time 40 | bot.instance.stats.timing("help.in_use_time", in_use_time) 41 | 42 | if await _caches.posts_with_non_claimant_messages.get(help_session_post.id): 43 | bot.instance.stats.incr("help.sessions.answered") 44 | else: 45 | bot.instance.stats.incr("help.sessions.unanswered") 46 | -------------------------------------------------------------------------------- /bot/exts/info/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/info/__init__.py -------------------------------------------------------------------------------- /bot/exts/info/codeblock/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import Bot 2 | 3 | 4 | async def setup(bot: Bot) -> None: 5 | """Load the CodeBlockCog cog.""" 6 | # Defer import to reduce side effects from importing the codeblock package. 7 | from bot.exts.info.codeblock._cog import CodeBlockCog 8 | await bot.add_cog(CodeBlockCog(bot)) 9 | -------------------------------------------------------------------------------- /bot/exts/info/doc/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import Bot 2 | 3 | from ._redis_cache import DocRedisCache 4 | 5 | MAX_SIGNATURE_AMOUNT = 3 6 | PRIORITY_PACKAGES = ( 7 | "python", 8 | ) 9 | NAMESPACE = "doc" 10 | 11 | doc_cache = DocRedisCache(namespace=NAMESPACE) 12 | 13 | 14 | async def setup(bot: Bot) -> None: 15 | """Load the Doc cog.""" 16 | from ._cog import DocCog 17 | await bot.add_cog(DocCog(bot)) 18 | -------------------------------------------------------------------------------- /bot/exts/info/resources.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import quote 3 | 4 | from discord import Embed 5 | from discord.ext import commands 6 | 7 | from bot.bot import Bot 8 | 9 | REGEX_CONSECUTIVE_NON_LETTERS = r"[^A-Za-z0-9]+" 10 | RESOURCE_URL = "https://www.pythondiscord.com/resources/" 11 | 12 | 13 | def to_kebabcase(resource_topic: str) -> str: 14 | """ 15 | Convert any string to kebab-case. 16 | 17 | For example, convert 18 | "__Favorite FROOT¤#/$?is----LeMON???" to 19 | "favorite-froot-is-lemon" 20 | 21 | Code adopted from: 22 | https://github.com/python-discord/site/blob/main/pydis_site/apps/resources/templatetags/to_kebabcase.py 23 | """ 24 | # First, make it lowercase, and just remove any apostrophes. 25 | # We remove the apostrophes because "wasnt" is better than "wasn-t" 26 | resource_topic = resource_topic.casefold() 27 | resource_topic = resource_topic.replace("'", "") 28 | 29 | # Now, replace any non-alphanumerics that remains with a dash. 30 | # If there are multiple consecutive non-letters, just replace them with a single dash. 31 | # my-favorite-class is better than my-favorite------class 32 | resource_topic = re.sub( 33 | REGEX_CONSECUTIVE_NON_LETTERS, 34 | "-", 35 | resource_topic, 36 | ) 37 | 38 | # Now we use strip to get rid of any leading or trailing dashes. 39 | resource_topic = resource_topic.strip("-") 40 | return resource_topic 41 | 42 | 43 | class Resources(commands.Cog): 44 | """Display information about the Python Discord website Resource page.""" 45 | 46 | def __init__(self, bot: Bot): 47 | self.bot = bot 48 | 49 | @commands.command(name="resources", aliases=("res",)) 50 | async def resources_command(self, ctx: commands.Context, *, resource_topic: str | None) -> None: 51 | """Display information and a link to the Python Discord website Resources page.""" 52 | url = RESOURCE_URL 53 | 54 | if resource_topic: 55 | # Capture everything prior to new line allowing users to add messages below the command then prep for url 56 | url = f"{url}?topics={quote(to_kebabcase(resource_topic.splitlines()[0]))}" 57 | 58 | embed = Embed( 59 | title="Resources", 60 | description=f"The [Resources page]({url}) on our website contains a list " 61 | f"of hand-selected learning resources that we " 62 | f"regularly recommend to both beginners and experts." 63 | ) 64 | await ctx.send(embed=embed) 65 | 66 | 67 | async def setup(bot: Bot) -> None: 68 | """Load the Resources cog.""" 69 | await bot.add_cog(Resources(bot)) 70 | -------------------------------------------------------------------------------- /bot/exts/moderation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/moderation/__init__.py -------------------------------------------------------------------------------- /bot/exts/moderation/infraction/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/moderation/infraction/__init__.py -------------------------------------------------------------------------------- /bot/exts/moderation/infraction/_views.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import discord 4 | from discord import ButtonStyle, Interaction 5 | from discord.ui import Button 6 | from pydis_core.utils import interactions 7 | 8 | 9 | class BanConfirmationView(interactions.ViewWithUserAndRoleCheck): 10 | """A confirmation view to be sent before issuing potentially suspect infractions.""" 11 | 12 | def __init__(self, *args: Any, **kwargs: Any) -> None: 13 | super().__init__(*args, **kwargs) 14 | self.confirmed = False 15 | 16 | @discord.ui.button(label="Ban", style=ButtonStyle.red) 17 | async def confirm(self, interaction: Interaction, button: Button) -> None: 18 | """Callback coroutine that is called when the "Ban" button is pressed.""" 19 | self.confirmed = True 20 | await interaction.response.defer() 21 | self.stop() 22 | 23 | @discord.ui.button(label="Cancel", style=ButtonStyle.gray) 24 | async def cancel(self, interaction: Interaction, button: Button) -> None: 25 | """Callback coroutine that is called when the "cancel" button is pressed.""" 26 | await interaction.response.send_message("Cancelled infraction.") 27 | self.stop() 28 | 29 | async def on_timeout(self) -> None: 30 | await super().on_timeout() 31 | await self.message.reply("Cancelled infraction due to timeout.") 32 | -------------------------------------------------------------------------------- /bot/exts/moderation/watchchannels/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/moderation/watchchannels/__init__.py -------------------------------------------------------------------------------- /bot/exts/recruitment/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/recruitment/__init__.py -------------------------------------------------------------------------------- /bot/exts/recruitment/talentpool/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import Bot 2 | 3 | 4 | async def setup(bot: Bot) -> None: 5 | """Load the TalentPool cog.""" 6 | from bot.exts.recruitment.talentpool._cog import TalentPool 7 | 8 | await bot.add_cog(TalentPool(bot)) 9 | -------------------------------------------------------------------------------- /bot/exts/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/exts/utils/__init__.py -------------------------------------------------------------------------------- /bot/exts/utils/bot.py: -------------------------------------------------------------------------------- 1 | 2 | from discord import Embed, TextChannel 3 | from discord.ext.commands import Cog, Context, command, group, has_any_role 4 | 5 | from bot.bot import Bot 6 | from bot.constants import Bot as BotConfig, Guild, MODERATION_ROLES, URLs 7 | from bot.log import get_logger 8 | 9 | log = get_logger(__name__) 10 | 11 | 12 | class BotCog(Cog, name="Bot"): 13 | """Bot information commands.""" 14 | 15 | def __init__(self, bot: Bot): 16 | self.bot = bot 17 | 18 | @group(invoke_without_command=True, name="bot", hidden=True) 19 | async def botinfo_group(self, ctx: Context) -> None: 20 | """Bot informational commands.""" 21 | await ctx.send_help(ctx.command) 22 | 23 | @botinfo_group.command(name="about", aliases=("info",), hidden=True) 24 | async def about_command(self, ctx: Context) -> None: 25 | """Get information about the bot.""" 26 | embed = Embed( 27 | description=( 28 | "A utility bot designed just for the Python server! " 29 | f"Try `{BotConfig.prefix}help` for more info." 30 | ), 31 | url="https://github.com/python-discord/bot" 32 | ) 33 | 34 | embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) 35 | embed.set_author( 36 | name="Python Bot", 37 | url="https://github.com/python-discord/bot", 38 | icon_url=URLs.bot_avatar 39 | ) 40 | 41 | await ctx.send(embed=embed) 42 | 43 | @command(name="echo", aliases=("print",)) 44 | @has_any_role(*MODERATION_ROLES) 45 | async def echo_command(self, ctx: Context, channel: TextChannel | None, *, text: str) -> None: 46 | """Repeat the given message in either a specified channel or the current channel.""" 47 | if channel is None: 48 | await ctx.send(text) 49 | elif not channel.permissions_for(ctx.author).send_messages: 50 | await ctx.send("You don't have permission to speak in that channel.") 51 | else: 52 | await channel.send(text) 53 | 54 | @command(name="embed") 55 | @has_any_role(*MODERATION_ROLES) 56 | async def embed_command(self, ctx: Context, channel: TextChannel | None, *, text: str) -> None: 57 | """Send the input within an embed to either a specified channel or the current channel.""" 58 | embed = Embed(description=text) 59 | 60 | if channel is None: 61 | await ctx.send(embed=embed) 62 | else: 63 | await channel.send(embed=embed) 64 | 65 | 66 | async def setup(bot: Bot) -> None: 67 | """Load the Bot cog.""" 68 | await bot.add_cog(BotCog(bot)) 69 | -------------------------------------------------------------------------------- /bot/exts/utils/ping.py: -------------------------------------------------------------------------------- 1 | import arrow 2 | from aiohttp import client_exceptions 3 | from discord import Embed 4 | from discord.ext import commands 5 | 6 | from bot.bot import Bot 7 | from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES, URLs 8 | from bot.decorators import in_whitelist 9 | 10 | DESCRIPTIONS = ( 11 | "Command processing time", 12 | "Python Discord website status", 13 | "Discord API latency" 14 | ) 15 | ROUND_LATENCY = 3 16 | 17 | 18 | class Latency(commands.Cog): 19 | """Getting the latency between the bot and websites.""" 20 | 21 | def __init__(self, bot: Bot) -> None: 22 | self.bot = bot 23 | 24 | @commands.command() 25 | @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_PARTNERS_COMMUNITY_ROLES) 26 | async def ping(self, ctx: commands.Context) -> None: 27 | """ 28 | Gets different measures of latency within the bot. 29 | 30 | Returns bot, Python Discord Site, Discord Protocol latency. 31 | """ 32 | # datetime.datetime objects do not have the "milliseconds" attribute. 33 | # It must be converted to seconds before converting to milliseconds. 34 | bot_ping = (arrow.utcnow() - ctx.message.created_at).total_seconds() * 1000 35 | if bot_ping <= 0: 36 | bot_ping = "Your clock is out of sync, could not calculate ping." 37 | else: 38 | bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" 39 | 40 | try: 41 | async with self.bot.http_session.get(f"{URLs.site_api}/healthcheck") as request: 42 | request.raise_for_status() 43 | site_status = "Healthy" 44 | 45 | except client_exceptions.ClientResponseError as e: 46 | """The site returned an unexpected response.""" 47 | site_status = f"The site returned an error in the response: ({e.status}) {e}" 48 | except client_exceptions.ClientConnectionError: 49 | """Something went wrong with the connection.""" 50 | site_status = "Could not establish connection with the site." 51 | 52 | # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds. 53 | discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms" 54 | 55 | embed = Embed(title="Pong!") 56 | 57 | for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_status, discord_ping], strict=True): 58 | embed.add_field(name=desc, value=latency, inline=False) 59 | 60 | await ctx.send(embed=embed) 61 | 62 | 63 | async def setup(bot: Bot) -> None: 64 | """Load the Latency cog.""" 65 | await bot.add_cog(Latency(bot)) 66 | -------------------------------------------------------------------------------- /bot/exts/utils/snekbox/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import Bot 2 | from bot.exts.utils.snekbox._cog import CodeblockConverter, Snekbox, SupportedPythonVersions 3 | from bot.exts.utils.snekbox._eval import EvalJob, EvalResult 4 | 5 | __all__ = ("CodeblockConverter", "EvalJob", "EvalResult", "Snekbox", "SupportedPythonVersions") 6 | 7 | 8 | async def setup(bot: Bot) -> None: 9 | """Load the Snekbox cog.""" 10 | # Defer import to reduce side effects from importing the codeblock package. 11 | from bot.exts.utils.snekbox._cog import Snekbox 12 | await bot.add_cog(Snekbox(bot)) 13 | -------------------------------------------------------------------------------- /bot/pagination.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | 3 | import discord 4 | from discord.ext.commands import Context 5 | from pydis_core.utils.pagination import LinePaginator as _LinePaginator, PaginationEmojis 6 | 7 | from bot.constants import Emojis 8 | 9 | 10 | class LinePaginator(_LinePaginator): 11 | """ 12 | A class that aids in paginating code blocks for Discord messages. 13 | 14 | See the super class's docs for more info. 15 | """ 16 | 17 | @classmethod 18 | async def paginate( 19 | cls, 20 | lines: list[str], 21 | ctx: Context | discord.Interaction, 22 | embed: discord.Embed, 23 | prefix: str = "", 24 | suffix: str = "", 25 | max_lines: int | None = None, 26 | max_size: int = 500, 27 | scale_to_size: int = 4000, 28 | empty: bool = True, 29 | restrict_to_user: discord.User | None = None, 30 | timeout: int = 300, 31 | footer_text: str | None = None, url: str | None = None, 32 | exception_on_empty_embed: bool = False, 33 | reply: bool = False, 34 | allowed_roles: Sequence[int] | None = None, **kwargs 35 | ) -> discord.Message | None: 36 | """ 37 | Use a paginator and set of reactions to provide pagination over a set of lines. 38 | 39 | Acts as a wrapper for the super class' `paginate` method to provide the pagination emojis by default. 40 | 41 | Consult the super class's `paginate` method for detailed information. 42 | """ 43 | return await super().paginate( 44 | pagination_emojis=PaginationEmojis(delete=Emojis.trashcan), 45 | lines=lines, 46 | ctx=ctx, 47 | embed=embed, 48 | prefix=prefix, 49 | suffix=suffix, 50 | max_lines=max_lines, 51 | max_size=max_size, 52 | scale_to_size=scale_to_size, 53 | empty=empty, 54 | restrict_to_user=restrict_to_user, 55 | timeout=timeout, 56 | footer_text=footer_text, 57 | url=url, 58 | exception_on_empty_embed=exception_on_empty_embed, 59 | reply=reply, 60 | allowed_roles=allowed_roles 61 | ) 62 | -------------------------------------------------------------------------------- /bot/resources/foods.json: -------------------------------------------------------------------------------- 1 | [ 2 | "apple", 3 | "avocado", 4 | "bagel", 5 | "banana", 6 | "bread", 7 | "broccoli", 8 | "burrito", 9 | "cake", 10 | "candy", 11 | "carrot", 12 | "cheese", 13 | "cherries", 14 | "chestnut", 15 | "chili", 16 | "chocolate", 17 | "coconut", 18 | "coffee", 19 | "cookie", 20 | "corn", 21 | "croissant", 22 | "cupcake", 23 | "donut", 24 | "dumpling", 25 | "falafel", 26 | "grapes", 27 | "honey", 28 | "kiwi", 29 | "lemon", 30 | "lollipop", 31 | "mango", 32 | "mushroom", 33 | "orange", 34 | "pancakes", 35 | "peanut", 36 | "pear", 37 | "pie", 38 | "pineapple", 39 | "popcorn", 40 | "potato", 41 | "pretzel", 42 | "ramen", 43 | "rice", 44 | "salad", 45 | "spaghetti", 46 | "stew", 47 | "strawberry", 48 | "sushi", 49 | "taco", 50 | "tomato", 51 | "watermelon" 52 | ] 53 | -------------------------------------------------------------------------------- /bot/resources/media/print-return.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/bot/resources/media/print-return.gif -------------------------------------------------------------------------------- /bot/resources/stars.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Adele", 3 | "Aerosmith", 4 | "Aretha Franklin", 5 | "Ayumi Hamasaki", 6 | "B'z", 7 | "Barbra Streisand", 8 | "Barry Manilow", 9 | "Barry White", 10 | "Beyonce", 11 | "Billy Joel", 12 | "Bob Dylan", 13 | "Bob Marley", 14 | "Bob Seger", 15 | "Bon Jovi", 16 | "Britney Spears", 17 | "Bruce Springsteen", 18 | "Bruno Mars", 19 | "Bryan Adams", 20 | "Céline Dion", 21 | "Cher", 22 | "Christina Aguilera", 23 | "Darude", 24 | "David Bowie", 25 | "Donna Summer", 26 | "Drake", 27 | "Ed Sheeran", 28 | "Elton John", 29 | "Elvis Presley", 30 | "Eminem", 31 | "Enya", 32 | "Flo Rida", 33 | "Frank Sinatra", 34 | "Garth Brooks", 35 | "George Harrison", 36 | "George Michael", 37 | "George Strait", 38 | "Guido Van Rossum", 39 | "James Taylor", 40 | "Janet Jackson", 41 | "Jay-Z", 42 | "John Lennon", 43 | "Johnny Cash", 44 | "Johnny Hallyday", 45 | "Julio Iglesias", 46 | "Justin Bieber", 47 | "Justin Timberlake", 48 | "Kanye West", 49 | "Katy Perry", 50 | "Kenny G", 51 | "Kenny Rogers", 52 | "Lady Gaga", 53 | "Lil Wayne", 54 | "Linda Ronstadt", 55 | "Lionel Richie", 56 | "Madonna", 57 | "Mariah Carey", 58 | "Meat Loaf", 59 | "Michael Jackson", 60 | "Neil Diamond", 61 | "Nicki Minaj", 62 | "Olivia Newton-John", 63 | "Paul McCartney", 64 | "Phil Collins", 65 | "Pink", 66 | "Prince", 67 | "Reba McEntire", 68 | "Rick Astley", 69 | "Rihanna", 70 | "Ringo Starr", 71 | "Robbie Williams", 72 | "Rod Stewart", 73 | "Santana", 74 | "Shania Twain", 75 | "Stevie Wonder", 76 | "Taylor Swift", 77 | "The Weeknd", 78 | "Tim McGraw", 79 | "Tina Turner", 80 | "Tom Petty", 81 | "Tupac Shakur", 82 | "Usher", 83 | "Van Halen", 84 | "Whitney Houston" 85 | ] 86 | -------------------------------------------------------------------------------- /bot/resources/tags/args-kwargs.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `*args` and `**kwargs` parameters" 4 | --- 5 | These special parameters allow functions to take arbitrary amounts of positional and keyword arguments. The names `args` and `kwargs` are purely convention, and could be named any other valid variable name. The special functionality comes from the single and double asterisks (`*`). If both are used in a function signature, `*args` **must** appear before `**kwargs`. 6 | 7 | **Single asterisk** 8 | `*args` will ingest an arbitrary amount of **positional arguments**, and store it in a tuple. If there are parameters after `*args` in the parameter list with no default value, they will become **required** keyword arguments by default. 9 | 10 | **Double asterisk** 11 | `**kwargs` will ingest an arbitrary amount of **keyword arguments**, and store it in a dictionary. There can be **no** additional parameters **after** `**kwargs` in the parameter list. 12 | 13 | **Use cases** 14 | - **Decorators** (see `/tag decorators`) 15 | - **Inheritance** (overriding methods) 16 | - **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) 17 | - **Flexibility** (writing functions that behave like `dict()` or `print()`) 18 | 19 | *See* `/tag positional-keyword` *for information about positional and keyword arguments* 20 | -------------------------------------------------------------------------------- /bot/resources/tags/async-await.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Concurrency in Python" 4 | --- 5 | Python provides the ability to run multiple tasks and coroutines simultaneously with the use of the `asyncio` library, which is included in the Python standard library. 6 | 7 | This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all other coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in other threads. 8 | 9 | To call an async function we can either `await` it, or run it in an event loop which we get from `asyncio`. 10 | 11 | To create a coroutine that can be used with asyncio we need to define a function using the `async` keyword: 12 | ```py 13 | async def main(): 14 | await something_awaitable() 15 | ``` 16 | Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, it would raise the exception `SyntaxError: 'await' outside async function` 17 | 18 | To run the top level async function from outside the event loop we need to use [`asyncio.run()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.run), like this: 19 | ```py 20 | import asyncio 21 | 22 | async def main(): 23 | await something_awaitable() 24 | 25 | asyncio.run(main()) 26 | ``` 27 | Note that in the `asyncio.run()`, where we appear to be calling `main()`, this does not execute the code in `main`. Rather, it creates and returns a new `coroutine` object (i.e `main() is not main()`) which is then handled and run by the event loop via `asyncio.run()`. 28 | 29 | To learn more about asyncio and its use, see the [asyncio documentation](https://docs.python.org/3/library/asyncio.html). 30 | -------------------------------------------------------------------------------- /bot/resources/tags/blocking.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Asynchronous programming" 4 | --- 5 | Imagine that you're coding a Discord bot and every time somebody uses a command, you need to get some information from a database. But there's a catch: the database servers are acting up today and take a whole 10 seconds to respond. If you do **not** use asynchronous methods, your whole bot will stop running until it gets a response from the database. How do you fix this? Asynchronous programming. 6 | 7 | **What is asynchronous programming?** 8 | An asynchronous program utilises the `async` and `await` keywords. An asynchronous program pauses what it's doing and does something else whilst it waits for some third-party service to complete whatever it's supposed to do. Any code within an `async` context manager or function marked with the `await` keyword indicates to Python, that whilst this operation is being completed, it can do something else. For example: 9 | 10 | ```py 11 | import discord 12 | 13 | # Bunch of bot code 14 | 15 | async def ping(ctx): 16 | await ctx.send("Pong!") 17 | ``` 18 | **What does the term "blocking" mean?** 19 | A blocking operation is wherever you do something without `await`ing it. This tells Python that this step must be completed before it can do anything else. Common examples of blocking operations, as simple as they may seem, include: outputting text, adding two numbers and appending an item onto a list. Most common Python libraries have an asynchronous version available to use in asynchronous contexts. 20 | 21 | **`async` libraries** 22 | - The standard async library - `asyncio` 23 | - Asynchronous web requests - `aiohttp` 24 | - Talking to PostgreSQL asynchronously - `asyncpg` 25 | - MongoDB interactions asynchronously - `motor` 26 | - Check out [this](https://github.com/timofurrer/awesome-asyncio) list for even more! 27 | -------------------------------------------------------------------------------- /bot/resources/tags/botvar.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Bot variables" 4 | --- 5 | Python allows you to set custom attributes to most objects, like your bot! By storing things as attributes of the bot object, you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: 6 | 7 | ```py 8 | bot = commands.Bot(command_prefix="!") 9 | # Set an attribute on our bot 10 | bot.test = "I am accessible everywhere!" 11 | 12 | @bot.command() 13 | async def get(ctx: commands.Context): 14 | """A command to get the current value of `test`.""" 15 | # Send what the test attribute is currently set to 16 | await ctx.send(ctx.bot.test) 17 | 18 | @bot.command() 19 | async def setval(ctx: commands.Context, *, new_text: str): 20 | """A command to set a new value of `test`.""" 21 | # Here we change the attribute to what was specified in new_text 22 | bot.test = new_text 23 | ``` 24 | 25 | This all applies to cogs as well! You can set attributes to `self` as you wish. 26 | 27 | *Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* 28 | -------------------------------------------------------------------------------- /bot/resources/tags/class.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Classes" 4 | --- 5 | Classes are used to create objects that have specific behavior. 6 | 7 | Every object in Python has a class, including `list`s, `dict`ionaries and even numbers. Using a class to group code and data like this is the foundation of Object Oriented Programming. Classes allow you to expose a simple, consistent interface while hiding the more complicated details. This simplifies the rest of your program and makes it easier to separately maintain and debug each component. 8 | 9 | Here is an example class: 10 | 11 | ```python 12 | class Foo: 13 | def __init__(self, somedata): 14 | self.my_attrib = somedata 15 | 16 | def show(self): 17 | print(self.my_attrib) 18 | ``` 19 | 20 | To use a class, you need to instantiate it. The following creates a new object named `bar`, with `Foo` as its class. 21 | 22 | ```python 23 | bar = Foo('data') 24 | bar.show() 25 | ``` 26 | 27 | We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`. 28 | -------------------------------------------------------------------------------- /bot/resources/tags/classmethod.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `@classmethod` decorator" 4 | --- 5 | Although most methods are tied to an _object instance_, it can sometimes be useful to create a method that does something with _the class itself_. To achieve this in Python, you can use the `@classmethod` decorator. This is often used to provide alternative constructors for a class. 6 | 7 | For example, you may be writing a class that takes some magic token (like an API key) as a constructor argument, but you sometimes read this token from a configuration file. You could make use of a `@classmethod` to create an alternate constructor for when you want to read from the configuration file. 8 | ```py 9 | class Bot: 10 | def __init__(self, token: str): 11 | self._token = token 12 | 13 | @classmethod 14 | def from_config(cls, config: dict) -> Bot: 15 | token = config['token'] 16 | return cls(token) 17 | 18 | # now we can create the bot instance like this 19 | alternative_bot = Bot.from_config(default_config) 20 | 21 | # but this still works, too 22 | regular_bot = Bot("tokenstring") 23 | ``` 24 | This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752). 25 | -------------------------------------------------------------------------------- /bot/resources/tags/codeblock.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Formatting code on Discord" 4 | --- 5 | Here's how to format Python code on Discord: 6 | 7 | \`\`\`py 8 | print('Hello world!') 9 | \`\`\` 10 | 11 | **These are backticks, not quotes.** Check [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) out if you can't find the backtick key. 12 | 13 | **For long code samples,** you can use our [pastebin](https://paste.pythondiscord.com/). 14 | -------------------------------------------------------------------------------- /bot/resources/tags/comparison.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Assignment vs comparison`" 4 | --- 5 | The assignment operator (`=`) is used to assign variables. 6 | ```python 7 | x = 5 8 | print(x) # Prints 5 9 | ``` 10 | The equality operator (`==`) is used to compare values. 11 | ```python 12 | if x == 5: 13 | print("The value of x is 5") 14 | ``` 15 | -------------------------------------------------------------------------------- /bot/resources/tags/contribute.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Contribute to Python Discord's open source projects" 4 | --- 5 | Looking to contribute to Open Source Projects for the first time? Want to add a feature or fix a bug on the bots on this server? We have on-going projects that people can contribute to, even if you've never contributed to open source before! 6 | 7 | **Projects to Contribute to** 8 | - [Sir Lancebot](https://github.com/python-discord/sir-lancebot) - our fun, beginner-friendly bot 9 | - [Python](https://github.com/python-discord/bot) - our utility & moderation bot 10 | - [Site](https://github.com/python-discord/site) - resources, guides, and more 11 | 12 | **Where to start** 13 | 1. Read our [contribution guide](https://pythondiscord.com/pages/guides/pydis-guides/contributing/) 14 | 2. Chat with us in <#635950537262759947> if you're ready to jump in or have any questions 15 | 3. Open an issue or ask to be assigned to an issue to work on 16 | -------------------------------------------------------------------------------- /bot/resources/tags/customchecks.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Custom command checks in discord.py" 4 | --- 5 | Often you may find the need to use checks that don't exist by default in discord.py. Fortunately, discord.py provides `discord.ext.commands.check` which allows you to create you own checks like this: 6 | ```py 7 | from discord.ext.commands import check, Context 8 | 9 | def in_any_channel(*channels): 10 | async def predicate(ctx: Context): 11 | return ctx.channel.id in channels 12 | return check(predicate) 13 | ``` 14 | This check is to check whether the invoked command is in a given set of channels. The inner function, named `predicate` here, is used to perform the actual check on the command, and check logic should go in this function. It must be an async function, and always provides a single `commands.Context` argument which you can use to create check logic. This check function should return a boolean value indicating whether the check passed (return `True`) or failed (return `False`). 15 | 16 | The check can now be used like any other commands check as a decorator of a command, such as this: 17 | ```py 18 | @bot.command(name="ping") 19 | @in_any_channel(728343273562701984) 20 | async def ping(ctx: Context): 21 | ... 22 | ``` 23 | This would lock the `ping` command to only be used in the channel `728343273562701984`. If this check function fails it will raise a `CheckFailure` exception, which can be handled in your error handler. 24 | -------------------------------------------------------------------------------- /bot/resources/tags/customcooldown.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Cooldowns in discord.py" 4 | --- 5 | Cooldowns can be used in discord.py to rate-limit. In this example, we're using it in an on_message. 6 | 7 | ```python 8 | from discord.ext import commands 9 | 10 | message_cooldown = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) 11 | 12 | @bot.event 13 | async def on_message(message): 14 | bucket = message_cooldown.get_bucket(message) 15 | retry_after = bucket.update_rate_limit() 16 | if retry_after: 17 | await message.channel.send(f"Slow down! Try again in {retry_after} seconds.") 18 | else: 19 | await message.channel.send("Not ratelimited!") 20 | ``` 21 | 22 | `from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.BucketType). 23 | -------------------------------------------------------------------------------- /bot/resources/tags/customhelp.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Custom help commands in discord.py" 4 | --- 5 | To learn more about how to create custom help commands in discord.py by subclassing the help command, please see [this tutorial](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96#embed-minimalhelpcommand) by Stella#2000 6 | -------------------------------------------------------------------------------- /bot/resources/tags/dashmpip.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["minusmpip"] 3 | embed: 4 | title: "Install packages with `python -m pip`" 5 | --- 6 | When trying to install a package via `pip`, it's recommended to invoke pip as a module: `python -m pip install your_package`. 7 | 8 | **Why would we use `python -m pip` instead of `pip`?** 9 | Invoking pip as a module ensures you know *which* pip you're using. This is helpful if you have multiple Python versions. You always know which Python version you're installing packages to. 10 | 11 | **Note** 12 | The exact `python` command you invoke can vary. It may be `python3` or `py`, ensure it's correct for your system. 13 | -------------------------------------------------------------------------------- /bot/resources/tags/decorators.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Decorators" 4 | --- 5 | A decorator is a function that modifies another function. 6 | 7 | Consider the following example of a timer decorator: 8 | ```py 9 | >>> import time 10 | >>> def timer(f): 11 | ... def inner(*args, **kwargs): 12 | ... start = time.time() 13 | ... result = f(*args, **kwargs) 14 | ... print('Time elapsed:', time.time() - start) 15 | ... return result 16 | ... return inner 17 | ... 18 | >>> @timer 19 | ... def slow(delay=1): 20 | ... time.sleep(delay) 21 | ... return 'Finished!' 22 | ... 23 | >>> print(slow()) 24 | Time elapsed: 1.0011568069458008 25 | Finished! 26 | >>> print(slow(3)) 27 | Time elapsed: 3.000307321548462 28 | Finished! 29 | ``` 30 | 31 | More information: 32 | - [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) 33 | - [Real python article](https://realpython.com/primer-on-python-decorators/) 34 | -------------------------------------------------------------------------------- /bot/resources/tags/defaultdict.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `collections.defaultdict` class" 4 | --- 5 | The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically insert the key and generate a default value for it. 6 | While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys. 7 | 8 | ```py 9 | >>> from collections import defaultdict 10 | >>> my_dict = defaultdict(int) 11 | >>> my_dict 12 | defaultdict(, {}) 13 | ``` 14 | 15 | In this example, we've used the `int` class which returns 0 when called like a function, so any missing key will get a default value of 0. You can also get an empty list by default with `list` or an empty string with `str`. 16 | 17 | ```py 18 | >>> my_dict["foo"] 19 | 0 20 | >>> my_dict["bar"] += 5 21 | >>> my_dict 22 | defaultdict(, {'foo': 0, 'bar': 5}) 23 | ``` 24 | Check out the [`docs`](https://docs.python.org/3/library/collections.html#collections.defaultdict) to learn even more! 25 | -------------------------------------------------------------------------------- /bot/resources/tags/dict-get.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `dict.get` method" 4 | --- 5 | Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary. Python gives you some neat ways to handle them. 6 | 7 | The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, and None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. 8 | ```py 9 | >>> my_dict = {"foo": 1, "bar": 2} 10 | >>> print(my_dict.get("foobar")) 11 | None 12 | ``` 13 | Below, 3 is the default value to be returned, because the key doesn't exist- 14 | ```py 15 | >>> print(my_dict.get("foobar", 3)) 16 | 3 17 | ``` 18 | Some other methods for handling `KeyError`s gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method and [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) (check out the `!defaultdict` tag). 19 | -------------------------------------------------------------------------------- /bot/resources/tags/dictcomps.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Dictionary comprehensions" 4 | --- 5 | Dictionary comprehensions (*dict comps*) provide a convenient way to make dictionaries, just like list comps: 6 | ```py 7 | >>> {word.lower(): len(word) for word in ('I', 'love', 'Python')} 8 | {'i': 1, 'love': 4, 'python': 6} 9 | ``` 10 | The syntax is very similar to list comps except that you surround it with curly braces and have two expressions: one for the key and one for the value. 11 | 12 | One can use a dict comp to change an existing dictionary using its `items` method 13 | ```py 14 | >>> first_dict = {'i': 1, 'love': 4, 'python': 6} 15 | >>> {key.upper(): value * 2 for key, value in first_dict.items()} 16 | {'I': 2, 'LOVE': 8, 'PYTHON': 12} 17 | ``` 18 | For more information and examples, check out [PEP 274](https://peps.python.org/pep-0274/) 19 | -------------------------------------------------------------------------------- /bot/resources/tags/discord-bot-hosting.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: Discord Bot Hosting 4 | --- 5 | 6 | Using free hosting options like repl.it for continuous 24/7 bot hosting is strongly discouraged. 7 | Instead, opt for a virtual private server (VPS) or use your own spare hardware if you'd rather not pay for hosting. 8 | 9 | See our [Discord Bot Hosting Guide](https://www.pythondiscord.com/pages/guides/python-guides/vps-services/) on our website that compares many hosting providers, both free and paid. 10 | 11 | You may also use <#965291480992321536> to discuss different discord bot hosting options. 12 | -------------------------------------------------------------------------------- /bot/resources/tags/docstring.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Docstrings" 4 | --- 5 | A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string - always using triple quotes - that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: 6 | ```py 7 | def greet(name: str, age: int) -> str: 8 | """ 9 | Return a string that greets the given person, using their name and age. 10 | 11 | :param name: The name of the person to greet. 12 | :param age: The age of the person to greet. 13 | 14 | :return: The greeting. 15 | """ 16 | return f"Hello {name}, you are {age} years old!" 17 | ``` 18 | You can get the docstring by using the [`inspect.getdoc`](https://docs.python.org/3/library/inspect.html#inspect.getdoc) function, from the built-in [`inspect`](https://docs.python.org/3/library/inspect.html) module, or by accessing the `.__doc__` attribute. `inspect.getdoc` is often preferred, as it clears indents from the docstring. 19 | 20 | For the last example, you can print it by doing this: `print(inspect.getdoc(greet))`. 21 | 22 | For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://peps.python.org/pep-0257/#what-is-a-docstring). 23 | -------------------------------------------------------------------------------- /bot/resources/tags/dotenv.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Using .env files in Python" 4 | --- 5 | `.env` (dotenv) files are a type of file commonly used for storing application secrets and variables, for example API tokens and URLs, although they may also be used for storing other configurable values. While they are commonly used for storing secrets, at a high level their purpose is to load environment variables into a program. 6 | 7 | Dotenv files are especially suited for storing secrets as they are a key-value store in a file, which can be easily loaded in most programming languages and ignored by version control systems like Git with a single entry in a `.gitignore` file. 8 | 9 | In Python you can use dotenv files with the [`python-dotenv`](https://pypi.org/project/python-dotenv) module from PyPI, which can be installed with `pip install python-dotenv`. To use dotenv files you'll first need a file called `.env`, with content such as the following: 10 | ``` 11 | TOKEN=a00418c85bff087b49f23923efe40aa5 12 | ``` 13 | Next, in your main Python file, you need to load the environment variables from the dotenv file you just created: 14 | ```py 15 | from dotenv import load_dotenv 16 | 17 | load_dotenv() 18 | ``` 19 | The variables from the file have now been loaded into your program's environment, and you can access them using `os.getenv()` anywhere in your program, like this: 20 | ```py 21 | from os import getenv 22 | 23 | my_token = getenv("TOKEN") 24 | ``` 25 | For further reading about tokens and secrets, please read [this explanation](https://tutorial.vco.sh/tips/tokens/). 26 | -------------------------------------------------------------------------------- /bot/resources/tags/dunder-methods.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Dunder methods" 4 | --- 5 | Double-underscore methods, or "dunder" methods, are special methods defined in a class that are invoked implicitly. Like the name suggests, they are prefixed and suffixed with dunders. You've probably already seen some, such as the `__init__` dunder method, also known as the "constructor" of a class, which is implicitly invoked when you instantiate an instance of a class. 6 | 7 | When you create a new class, there will be default dunder methods inherited from the `object` class. However, we can override them by redefining these methods within the new class. For example, the default `__init__` method from `object` doesn't take any arguments, so we almost always override that to fit our needs. 8 | 9 | Other common dunder methods to override are `__str__` and `__repr__`. `__repr__` is the developer-friendly string representation of an object - usually the syntax to recreate it - and is implicitly called on arguments passed into the `repr` function. `__str__` is the user-friendly string representation of an object, and is called by the `str` function. Note here that, if not overriden, the default `__str__` invokes `__repr__` as a fallback. 10 | 11 | ```py 12 | class Foo: 13 | def __init__(self, value): # constructor 14 | self.value = value 15 | def __str__(self): 16 | return f"This is a Foo object, with a value of {self.value}!" # string representation 17 | def __repr__(self): 18 | return f"Foo({self.value!r})" # way to recreate this object 19 | 20 | 21 | bar = Foo(5) 22 | 23 | # print also implicitly calls __str__ 24 | print(bar) # Output: This is a Foo object, with a value of 5! 25 | 26 | # dev-friendly representation 27 | print(repr(bar)) # Output: Foo(5) 28 | ``` 29 | 30 | Another example: did you know that when you use the ` + ` syntax, you're implicitly calling `.__add__()`? The same applies to other operators, and you can look at the [`operator` built-in module documentation](https://docs.python.org/3/library/operator.html) for more information! 31 | -------------------------------------------------------------------------------- /bot/resources/tags/empty-json.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Empty JSON error" 4 | --- 5 | When using JSON, you might run into the following error: 6 | ``` 7 | JSONDecodeError: Expecting value: line 1 column 1 (char 0) 8 | ``` 9 | This error could have appeared because you just created the JSON file and there is nothing in it at the moment. 10 | 11 | Whilst having empty data is no problem, the file itself may never be completely empty. 12 | 13 | You most likely wanted to structure your JSON as a dictionary. To do this, edit your empty JSON file so that it instead contains `{}`. 14 | 15 | Different data types are also supported. If you wish to read more on these, please refer to [this article](https://www.tutorialspoint.com/json/json_data_types.htm). 16 | -------------------------------------------------------------------------------- /bot/resources/tags/enumerate.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `enumerate` function" 4 | --- 5 | Ever find yourself in need of the current iteration number of your `for` loop? You should use **enumerate**! Using `enumerate`, you can turn code that looks like this: 6 | ```py 7 | index = 0 8 | for item in my_list: 9 | print(f"{index}: {item}") 10 | index += 1 11 | ``` 12 | into beautiful, _pythonic_ code: 13 | ```py 14 | for index, item in enumerate(my_list): 15 | print(f"{index}: {item}") 16 | ``` 17 | For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://peps.python.org/pep-0279/). 18 | -------------------------------------------------------------------------------- /bot/resources/tags/environments.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["envs"] 3 | embed: 4 | title: "Python environments" 5 | --- 6 | The main purpose of Python [virtual environments](https://docs.Python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using pip, regardless of what dependencies every other project has. 7 | 8 | To see the current environment in use by Python, you can run: 9 | ```py 10 | >>> import sys 11 | >>> sys.executable 12 | '/usr/bin/python3' 13 | ``` 14 | 15 | To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use e.g. `sys.executable: /usr/bin/python3`. 16 | 17 | If Python's `sys.executable` doesn't match pip's, then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment. 18 | 19 | **Why use a virtual environment?** 20 | 21 | - Resolve dependency issues by allowing the use of different versions of a package for different projects. For example, you could use Package A v2.7 for Project X and Package A v1.3 for Project Y. 22 | - Make your project self-contained and reproducible by capturing all package dependencies in a requirements file. Try running `pip freeze` to see what you currently have installed! 23 | - Keep your global `site-packages/` directory tidy by removing the need to install packages system-wide which you might only need for one project. 24 | 25 | 26 | **Further reading:** 27 | 28 | - [Python Virtual Environments: A Primer](https://realpython.com/python-virtual-environments-a-primer) 29 | - [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) 30 | -------------------------------------------------------------------------------- /bot/resources/tags/equals-true.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Comparisons to `True` and `False`" 4 | --- 5 | It's tempting to think that if statements always need a comparison operator like `==` or `!=`, but this isn't true. 6 | If you're just checking if a value is truthy or falsey, you don't need `== True` or `== False`. 7 | ```py 8 | # instead of this... 9 | if user_input.startswith('y') == True: 10 | my_func(user_input) 11 | 12 | # ...write this 13 | if user_input.startswith('y'): 14 | my_func(user_input) 15 | 16 | # for false conditions, instead of this... 17 | if user_input.startswith('y') == False: 18 | my_func(user_input) 19 | 20 | # ...just use `not` 21 | if not user_input.startswith('y'): 22 | my_func(user_input) 23 | ``` 24 | This also applies to expressions that use `is True` or `is False`. 25 | -------------------------------------------------------------------------------- /bot/resources/tags/except.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Error handling" 4 | --- 5 | A key part of the Python philosophy is to ask for forgiveness, not permission. This means that it's okay to write code that may produce an error, as long as you specify how that error should be handled. Code written this way is readable and resilient. 6 | ```py 7 | try: 8 | number = int(user_input) 9 | except ValueError: 10 | print("failed to convert user_input to a number. setting number to 0.") 11 | number = 0 12 | ``` 13 | You should always specify the exception type if it is possible to do so, and your `try` block should be as short as possible. Attempting to handle broad categories of unexpected exceptions can silently hide serious problems. 14 | ```py 15 | try: 16 | number = int(user_input) 17 | item = some_list[number] 18 | except: 19 | print("An exception was raised, but we have no idea if it was a ValueError or an IndexError.") 20 | ``` 21 | For more information about exception handling, see [the official Python docs](https://docs.python.org/3/tutorial/errors.html), or watch [Corey Schafer's video on exception handling](https://www.youtube.com/watch?v=NIWwJbo-9_8). 22 | -------------------------------------------------------------------------------- /bot/resources/tags/exit().md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Exiting programmatically" 4 | --- 5 | If you want to exit your code programmatically, you might think to use the functions `exit()` or `quit()`, however this is bad practice. These functions are constants added by the [`site`](https://docs.python.org/3/library/site.html#module-site) module as a convenient method for exiting the interactive interpreter shell, and should not be used in programs. 6 | 7 | You should use either [`SystemExit`](https://docs.python.org/3/library/exceptions.html#SystemExit) or [`sys.exit()`](https://docs.python.org/3/library/sys.html#sys.exit) instead. 8 | There's not much practical difference between these two other than having to `import sys` for the latter. Both take an optional argument to provide an exit status. 9 | 10 | [Official documentation](https://docs.python.org/3/library/constants.html#exit) with the warning not to use `exit()` or `quit()` in source code. 11 | -------------------------------------------------------------------------------- /bot/resources/tags/f-strings.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["fstrings", "fstring", "f-string"] 3 | embed: 4 | title: "Format-strings" 5 | --- 6 | Creating a Python string with your variables using the `+` operator can be difficult to write and read. F-strings (*format-strings*) make it easy to insert values into a string. If you put an `f` in front of the first quote, you can then put Python expressions between curly braces in the string. 7 | 8 | ```py 9 | >>> snake = "pythons" 10 | >>> number = 21 11 | >>> f"There are {number * 2} {snake} on the plane." 12 | "There are 42 pythons on the plane." 13 | ``` 14 | Note that even when you include an expression that isn't a string, like `number * 2`, Python will convert it to a string for you. 15 | -------------------------------------------------------------------------------- /bot/resources/tags/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Frequently asked questions" 4 | --- 5 | As the largest Python community on Discord, we get hundreds of questions every day. Many of these questions have been asked before. We've compiled a list of the most frequently asked questions along with their answers, which can be found on our [FAQ page](https://www.pythondiscord.com/pages/frequently-asked-questions/). 6 | -------------------------------------------------------------------------------- /bot/resources/tags/floats.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Floating point arithmetic" 4 | --- 5 | You may have noticed that when doing arithmetic with floats in Python you sometimes get strange results, like this: 6 | ```python 7 | >>> 0.1 + 0.2 8 | 0.30000000000000004 9 | ``` 10 | **Why this happens** 11 | Internally your computer stores floats as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used. 12 | 13 | **How you can avoid this** 14 | You can use [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) to check if two floats are close, or to get an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here are some examples: 15 | ```python 16 | >>> math.isclose(0.1 + 0.2, 0.3) 17 | True 18 | >>> decimal.Decimal('0.1') + decimal.Decimal('0.2') 19 | Decimal('0.3') 20 | ``` 21 | Note that with `decimal.Decimal` we enter the number we want as a string so we don't pass on the imprecision from the float. 22 | 23 | For more details on why this happens check out this [page in the python docs](https://docs.python.org/3/tutorial/floatingpoint.html) or this [Computerphile video](https://www.youtube.com/watch/PZRI1IfStY0). 24 | -------------------------------------------------------------------------------- /bot/resources/tags/foo.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Metasyntactic variables" 4 | --- 5 | A specific word or set of words identified as a placeholder used in programming. They are used to name entities such as variables, functions, etc, whose exact identity is unimportant and serve only to demonstrate a concept, which is useful for teaching programming. 6 | 7 | Common examples include `foobar`, `foo`, `bar`, `baz`, and `qux`. 8 | Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. This is a reference to a [Monty Python](https://en.wikipedia.org/wiki/Monty_Python) sketch (the eponym of the language). 9 | 10 | More information: 11 | - [History of foobar](https://en.wikipedia.org/wiki/Foobar) 12 | - [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) 13 | -------------------------------------------------------------------------------- /bot/resources/tags/for-else.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The for-else block" 4 | --- 5 | In Python it's possible to attach an `else` clause to a for loop. The code under the `else` block will be run when the iterable is exhausted (there are no more items to iterate over). Code within the else block will **not** run if the loop is broken out using `break`. 6 | 7 | Here's an example of its usage: 8 | ```py 9 | numbers = [1, 3, 5, 7, 9, 11] 10 | 11 | for number in numbers: 12 | if number % 2 == 0: 13 | print(f"Found an even number: {number}") 14 | break 15 | print(f"{number} is odd.") 16 | else: 17 | print("All numbers are odd. How odd.") 18 | ``` 19 | Try running this example but with an even number in the list, see how the output changes as you do so. 20 | -------------------------------------------------------------------------------- /bot/resources/tags/functions-are-objects.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Calling vs. referencing functions" 4 | --- 5 | When assigning a new name to a function, storing it in a container, or passing it as an argument, a common mistake made is to call the function. Instead of getting the actual function, you'll get its return value. 6 | 7 | In Python you can treat function names just like any other variable. Assume there was a function called `now` that returns the current time. If you did `x = now()`, the current time would be assigned to `x`, but if you did `x = now`, the function `now` itself would be assigned to `x`. `x` and `now` would both equally reference the function. 8 | 9 | **Examples** 10 | ```py 11 | # assigning new name 12 | 13 | def foo(): 14 | return 'bar' 15 | 16 | def spam(): 17 | return 'eggs' 18 | 19 | baz = foo 20 | baz() # returns 'bar' 21 | 22 | ham = spam 23 | ham() # returns 'eggs' 24 | ``` 25 | ```py 26 | # storing in container 27 | 28 | import math 29 | functions = [math.sqrt, math.factorial, math.log] 30 | functions[0](25) # returns 5.0 31 | # the above equivalent to math.sqrt(25) 32 | ``` 33 | ```py 34 | # passing as argument 35 | 36 | class C: 37 | builtin_open = staticmethod(open) 38 | 39 | # open function is passed 40 | # to the staticmethod class 41 | ``` 42 | -------------------------------------------------------------------------------- /bot/resources/tags/global.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Globals" 4 | --- 5 | When adding functions or classes to a program, it can be tempting to reference inaccessible variables by declaring them as global. Doing this can result in code that is harder to read, debug and test. Instead of using globals, pass variables or objects as parameters and receive return values. 6 | 7 | Instead of writing 8 | ```py 9 | def update_score(): 10 | global score, roll 11 | score = score + roll 12 | update_score() 13 | ``` 14 | do this instead 15 | ```py 16 | def update_score(score, roll): 17 | return score + roll 18 | score = update_score(score, roll) 19 | ``` 20 | For in-depth explanations on why global variables are bad news in a variety of situations, see [this Stack Overflow answer](https://stackoverflow.com/questions/19158339/why-are-global-variables-evil/19158418#19158418). 21 | -------------------------------------------------------------------------------- /bot/resources/tags/guilds.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Communities" 4 | --- 5 | The [communities page](https://pythondiscord.com/pages/resources/communities/) on our website contains a number of communities we have partnered with as well as a [curated list](https://github.com/mhxion/awesome-discord-communities) of other communities relating to programming and technology. 6 | -------------------------------------------------------------------------------- /bot/resources/tags/identity.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Identity vs. equality" 4 | --- 5 | Should I be using `is` or `==`? 6 | 7 | To check if two objects are equal, use the equality operator (`==`). 8 | ```py 9 | x = 5 10 | if x == 5: 11 | print("x equals 5") 12 | if x == 3: 13 | print("x equals 3") 14 | # Prints 'x equals 5' 15 | ``` 16 | To check if two objects are actually the same thing in memory, use the identity comparison operator (`is`). 17 | ```py 18 | >>> list_1 = [1, 2, 3] 19 | >>> list_2 = [1, 2, 3] 20 | >>> if list_1 is [1, 2, 3]: 21 | ... print("list_1 is list_2") 22 | ... 23 | >>> reference_to_list_1 = list_1 24 | >>> if list_1 is reference_to_list_1: 25 | ... print("list_1 is reference_to_list_1") 26 | ... 27 | list_1 is reference_to_list_1 28 | ``` 29 | -------------------------------------------------------------------------------- /bot/resources/tags/if-name-main.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "`if __name__ == '__main__'`" 4 | --- 5 | This is a statement that is only true if the module (your source code) it appears in is being run directly, as opposed to being imported into another module. When you run your module, the `__name__` special variable is automatically set to the string `'__main__'`. Conversely, when you import that same module into a different one, and run that, `__name__` is instead set to the filename of your module minus the `.py` extension. 6 | 7 | **Example** 8 | ```py 9 | # foo.py 10 | 11 | print('spam') 12 | 13 | if __name__ == '__main__': 14 | print('eggs') 15 | ``` 16 | If you run the above module `foo.py` directly, both `'spam'`and `'eggs'` will be printed. Now consider this next example: 17 | ```py 18 | # bar.py 19 | 20 | import foo 21 | ``` 22 | If you run this module named `bar.py`, it will execute the code in `foo.py`. First it will print `'spam'`, and then the `if` statement will fail, because `__name__` will now be the string `'foo'`. 23 | 24 | **Why would I do this?** 25 | 26 | - Your module is a library, but also has a special case where it can be run directly 27 | - Your module is a library and you want to safeguard it against people running it directly (like what `pip` does) 28 | - Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test 29 | -------------------------------------------------------------------------------- /bot/resources/tags/in-place.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "In-place vs. Out-of-place operations" 4 | --- 5 | 6 | In programming, there are two types of operations: 7 | - "In-place" operations, which modify the original object 8 | - "Out-of-place" operations, which returns a new object and leaves the original object unchanged 9 | 10 | For example, the `.sort()` method of lists is in-place, so it modifies the list you call `.sort()` on: 11 | ```python 12 | >>> my_list = [5, 2, 3, 1] 13 | >>> my_list.sort() # Returns None 14 | >>> my_list 15 | [1, 2, 3, 5] 16 | ``` 17 | On the other hand, the `sorted()` function is out-of-place, so it returns a new list and leaves the original list unchanged: 18 | ```python 19 | >>> my_list = [5, 2, 3, 1] 20 | >>> sorted_list = sorted(my_list) 21 | >>> sorted_list 22 | [1, 2, 3, 5] 23 | >>> my_list 24 | [5, 2, 3, 1] 25 | ``` 26 | In general, methods of mutable objects tend to be in-place (since it can be expensive to create a new object), whereas operations on immutable objects are always out-of-place (since they cannot be modified). 27 | -------------------------------------------------------------------------------- /bot/resources/tags/indent.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Indentation" 4 | --- 5 | Indentation is leading whitespace (spaces and tabs) at the beginning of a line of code. In the case of Python, they are used to determine the grouping of statements. 6 | 7 | Spaces should be preferred over tabs. To be clear, this is in reference to the character itself, not the keys on a keyboard. Your editor/IDE should be configured to insert spaces when the TAB key is pressed. The amount of spaces should be a multiple of 4, except optionally in the case of continuation lines. 8 | 9 | **Example** 10 | ```py 11 | def foo(): 12 | bar = 'baz' # indented one level 13 | if bar == 'baz': 14 | print('ham') # indented two levels 15 | return bar # indented one level 16 | ``` 17 | The first line is not indented. The next two lines are indented to be inside of the function definition. They will only run when the function is called. The fourth line is indented to be inside the `if` statement, and will only run if the `if` statement evaluates to `True`. The fifth and last line is like the 2nd and 3rd and will always run when the function is called. It effectively closes the `if` statement above as no more lines can be inside the `if` statement below that line. 18 | 19 | **Indentation is used after:** 20 | **1.** [Compound statements](https://docs.python.org/3/reference/compound_stmts.html) (eg. `if`, `while`, `for`, `try`, `with`, `def`, `class`, and their counterparts) 21 | **2.** [Continuation lines](https://peps.python.org/pep-0008/#indentation) 22 | 23 | **More Info** 24 | **1.** [Indentation style guide](https://peps.python.org/pep-0008/#indentation) 25 | **2.** [Tabs or Spaces?](https://peps.python.org/pep-0008/#tabs-or-spaces) 26 | **3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) 27 | -------------------------------------------------------------------------------- /bot/resources/tags/inline.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Inline codeblocks" 4 | --- 5 | Inline codeblocks look `like this`. To create them you surround text with single backticks, so \`hello\` would become `hello`. 6 | 7 | Note that backticks are not quotes, see [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) if you are struggling to find the backtick key. 8 | 9 | If the wrapped code itself has a backtick, wrap it with two backticks from each side: \`\`back \` tick\`\` would become ``back ` tick``. 10 | 11 | For how to make multiline codeblocks see the `!codeblock` tag. 12 | -------------------------------------------------------------------------------- /bot/resources/tags/intents.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Using intents in discord.py" 4 | --- 5 | Intents are a feature of Discord that tells the gateway exactly which events to send your bot. Various features of discord.py rely on having particular intents enabled, further detailed [in its documentation](https://discordpy.readthedocs.io/en/stable/api.html#intents). Since discord.py v2.0.0, it has become **mandatory** for developers to explicitly define the values of these intents in their code. 6 | 7 | There are *standard* and *privileged* intents. To use privileged intents like `Presences`, `Server Members`, and `Message Content`, you have to first enable them in the [Discord Developer Portal](https://discord.com/developers/applications). In there, go to the `Bot` page of your application, scroll down to the `Privileged Gateway Intents` section, and enable the privileged intents that you need. Standard intents can be used without any changes in the developer portal. 8 | 9 | Afterwards in your code, you need to set the intents you want to connect with in the bot's constructor using the `intents` keyword argument, like this: 10 | ```py 11 | from discord import Intents 12 | from discord.ext import commands 13 | 14 | # Enable all standard intents and message content 15 | # (prefix commands generally require message content) 16 | intents = Intents.default() 17 | intents.message_content = True 18 | 19 | bot = commands.Bot(command_prefix="!", intents=intents) 20 | ``` 21 | For more info about using intents, see [discord.py's related guide](https://discordpy.readthedocs.io/en/stable/intents.html), and for general information about them, see the [Discord developer documentation on intents](https://discord.com/developers/docs/topics/gateway#gateway-intents). 22 | -------------------------------------------------------------------------------- /bot/resources/tags/iterate-dict.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Iteration over dictionaries" 4 | --- 5 | There are two common ways to iterate over a dictionary in Python. To iterate over the keys: 6 | ```py 7 | for key in my_dict: 8 | print(key) 9 | ``` 10 | To iterate over both the keys and values: 11 | ```py 12 | for key, val in my_dict.items(): 13 | print(key, val) 14 | ``` 15 | -------------------------------------------------------------------------------- /bot/resources/tags/kindling-projects.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Kindling Projects" 4 | --- 5 | The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) contains a list of projects and ideas programmers can tackle to build their skills and knowledge. 6 | -------------------------------------------------------------------------------- /bot/resources/tags/listcomps.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "List comprehensions" 4 | --- 5 | Do you ever find yourself writing something like this? 6 | ```py 7 | >>> squares = [] 8 | >>> for n in range(5): 9 | ... squares.append(n ** 2) 10 | [0, 1, 4, 9, 16] 11 | ``` 12 | Using list comprehensions can make this both shorter and more readable. As a list comprehension, the same code would look like this: 13 | ```py 14 | >>> [n ** 2 for n in range(5)] 15 | [0, 1, 4, 9, 16] 16 | ``` 17 | List comprehensions also get an `if` clause: 18 | ```py 19 | >>> [n ** 2 for n in range(5) if n % 2 == 0] 20 | [0, 4, 16] 21 | ``` 22 | 23 | For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python). 24 | -------------------------------------------------------------------------------- /bot/resources/tags/local-file.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Sending images in embeds using discord.py" 4 | --- 5 | Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of [`discord.File`](https://discordpy.readthedocs.io/en/stable/api.html#discord.File) class: 6 | ```py 7 | # When you know the file exact path, you can pass it. 8 | file = discord.File("/this/is/path/to/my/file.png", filename="file.png") 9 | 10 | # When you have the file-like object, then you can pass this instead path. 11 | with open("/this/is/path/to/my/file.png", "rb") as f: 12 | file = discord.File(f) 13 | ``` 14 | When using the file-like object, you have to open it in `rb` ('read binary') mode. Also, in this case, passing `filename` to it is not necessary. 15 | Please note that `filename` must not contain underscores. This is a Discord limitation. 16 | 17 | [`discord.Embed`](https://discordpy.readthedocs.io/en/stable/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/stable/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: 18 | ```py 19 | embed = discord.Embed() 20 | # Set other fields 21 | embed.set_image(url="attachment://file.png") # Filename here must be exactly same as attachment filename. 22 | ``` 23 | After this, you can send an embed with an attachment to Discord: 24 | ```py 25 | await channel.send(file=file, embed=embed) 26 | ``` 27 | This example uses [`discord.TextChannel`](https://discordpy.readthedocs.io/en/stable/api.html#discord.TextChannel) for sending, but any instance of [`discord.abc.Messageable`](https://discordpy.readthedocs.io/en/stable/api.html#discord.abc.Messageable) can be used for sending. 28 | -------------------------------------------------------------------------------- /bot/resources/tags/loop-remove.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["loop-add", "loop-modify"] 3 | embed: 4 | title: "Removing items inside a for loop" 5 | --- 6 | Avoid adding to or removing from a collection, such as a list, as you iterate that collection in a `for` loop: 7 | ```py 8 | data = [1, 2, 3, 4] 9 | for item in data: 10 | data.remove(item) 11 | print(data) # [2, 4] <-- every OTHER item was removed! 12 | ``` 13 | Inside the loop, an index tracks the current position. If the list is modified, this index may no longer refer to the same element, causing elements to be repeated or skipped. 14 | 15 | In the example above, `1` is removed, shifting `2` to index *0*. The loop then moves to index *1*, removing `3` (and skipping `2`!). 16 | 17 | You can avoid this pitfall by: 18 | - using a **list comprehension** to produce a new list (as a way of filtering items): 19 | ```py 20 | data = [x for x in data if x % 2 == 0] 21 | ``` 22 | - using a `while` loop and `.pop()` (treating the list as a stack): 23 | ```py 24 | while data: 25 | item = data.pop() 26 | ``` 27 | - considering whether you need to remove items in the first place! 28 | -------------------------------------------------------------------------------- /bot/resources/tags/message-content-intent.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["mcintent", "message_content", "message_content_intent"] 3 | embed: 4 | title: "Discord Message Content Intent" 5 | --- 6 | 7 | The Discord gateway only dispatches events you subscribe to, which you can configure by using "intents." 8 | 9 | The message content intent is what determines if an app will receive the actual content of newly created messages. Without this intent, discord.py won't be able to detect prefix commands, so prefix commands won't respond. 10 | 11 | Privileged intents, such as message content, have to be explicitly enabled from the [Discord Developer Portal](https://discord.com/developers/applications) in addition to being enabled in the code: 12 | 13 | ```py 14 | intents = discord.Intents.default() # create a default Intents instance 15 | intents.message_content = True # enable message content intents 16 | 17 | bot = commands.Bot(command_prefix="!", intents=intents) # actually pass it into the constructor 18 | ``` 19 | For more information on intents, see `/tag intents`. If prefix commands are still not working, see `/tag on-message-event`. 20 | -------------------------------------------------------------------------------- /bot/resources/tags/microsoft-build-tools.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Microsoft Visual C++ Build Tools" 4 | --- 5 | When you install a library through `pip` on Windows, sometimes you may encounter this error: 6 | 7 | ``` 8 | error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ 9 | ``` 10 | 11 | This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) 12 | 13 | **1.** Open https://visualstudio.microsoft.com/visual-cpp-build-tools/. 14 | **2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. 15 | **3.** Run the downloaded file. Click **`Continue`** to proceed. 16 | **4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. 17 | **5.** Try installing the library via `pip` again. 18 | -------------------------------------------------------------------------------- /bot/resources/tags/modmail.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Contacting the moderation team via ModMail" 4 | --- 5 | <@!683001325440860340> is a bot that will relay your messages to our moderation team, so that you can start a conversation with the moderation team. Your messages will be relayed to the entire moderator team, who will be able to respond to you via the bot. 6 | 7 | It supports attachments, codeblocks, and reactions. As communication happens over direct messages, the conversation will stay between you and the mod team. 8 | 9 | **To use it, simply send a direct message to the bot.** 10 | 11 | Should there be an urgent and immediate need for a moderator to look at a channel, feel free to ping the <@&831776746206265384> role instead. 12 | -------------------------------------------------------------------------------- /bot/resources/tags/mutability.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Mutable vs immutable objects" 4 | --- 5 | Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method. 6 | 7 | You might think that this would work: 8 | ```python 9 | >>> greeting = "hello" 10 | >>> greeting.upper() 11 | 'HELLO' 12 | >>> greeting 13 | 'hello' 14 | ``` 15 | 16 | `greeting` didn't change. Why is that so? 17 | 18 | That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones. 19 | 20 | ```python 21 | >>> greeting = "hello" 22 | >>> greeting = greeting.upper() 23 | >>> greeting 24 | 'HELLO' 25 | ``` 26 | 27 | `greeting.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case. 28 | 29 | `int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python. 30 | 31 | Mutable data types like `list`, on the other hand, can be changed in-place: 32 | ```python 33 | >>> my_list = [1, 2, 3] 34 | >>> my_list.append(4) 35 | >>> my_list 36 | [1, 2, 3, 4] 37 | ``` 38 | 39 | Other examples of mutable data types in Python are `dict` and `set`. Instances of user-defined classes are also mutable. 40 | 41 | For an in-depth guide on mutability see [Ned Batchelder's video on names and values](https://youtu.be/_AEJHKGk9ns/). 42 | -------------------------------------------------------------------------------- /bot/resources/tags/mutable-default-args.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Mutable default arguments" 4 | --- 5 | Default arguments in Python are evaluated *once* when the function is 6 | **defined**, *not* each time the function is **called**. This means that if 7 | you have a mutable default argument and mutate it, you will have 8 | mutated that object for all future calls to the function as well. 9 | 10 | For example, the following `append_one` function appends `1` to a list 11 | and returns it. `foo` is set to an empty list by default. 12 | ```python 13 | >>> def append_one(foo=[]): 14 | ... foo.append(1) 15 | ... return foo 16 | ... 17 | ``` 18 | See what happens when we call it a few times: 19 | ```python 20 | >>> append_one() 21 | [1] 22 | >>> append_one() 23 | [1, 1] 24 | >>> append_one() 25 | [1, 1, 1] 26 | ``` 27 | Each call appends an additional `1` to our list `foo`. It does not 28 | receive a new empty list on each call, it is the same list everytime. 29 | 30 | To avoid this problem, you have to create a new object every time the 31 | function is **called**: 32 | ```python 33 | >>> def append_one(foo=None): 34 | ... if foo is None: 35 | ... foo = [] 36 | ... foo.append(1) 37 | ... return foo 38 | ... 39 | >>> append_one() 40 | [1] 41 | >>> append_one() 42 | [1] 43 | ``` 44 | 45 | **Note**: 46 | 47 | - This behavior can be used intentionally to maintain state between 48 | calls of a function (eg. when writing a caching function). 49 | - This behavior is not unique to mutable objects, all default 50 | arguments are evaulated only once when the function is defined. 51 | -------------------------------------------------------------------------------- /bot/resources/tags/names.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Naming and binding" 4 | --- 5 | A name is a piece of text that is bound to an object. They are a **reference** to an object. Examples are function names, class names, module names, variables, etc. 6 | 7 | **Note:** Names **cannot** reference other names, and assignment **never** creates a copy. 8 | ```py 9 | x = 1 # x is bound to 1 10 | y = x # y is bound to VALUE of x 11 | x = 2 # x is bound to 2 12 | print(x, y) # 2 1 13 | ``` 14 | When doing `y = x`, the name `y` is being bound to the *value* of `x` which is `1`. Neither `x` nor `y` are the 'real' name. The object `1` simply has *multiple* names. They are the exact same object. 15 | ``` 16 | >>> x = 1 17 | x ━━ 1 18 | 19 | >>> y = x 20 | x ━━ 1 21 | y ━━━┛ 22 | 23 | >>> x = 2 24 | x ━━ 2 25 | y ━━ 1 26 | ``` 27 | **Names are created in multiple ways** 28 | You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: 29 | - `import` statements 30 | - `class` and `def` 31 | - `for` loop headers 32 | - `as` keyword when used with `except`, `import`, and `with` 33 | - formal parameters in function headers 34 | 35 | There is also `del` which has the purpose of *unbinding* a name. 36 | 37 | **More info** 38 | - Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples 39 | - [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) 40 | -------------------------------------------------------------------------------- /bot/resources/tags/nomodule.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `ModuleNotFoundError` error" 4 | --- 5 | If you've installed a package but you're getting a ModuleNotFoundError when you try to import it, it's likely that the environment where your code is running is different from the one where you did the installation. 6 | 7 | You can read about Python environments at `/tag environments` and `/tag venv`. 8 | 9 | Common causes of this problem include: 10 | 11 | - You installed your package using `pip install ...`. It could be that the `pip` command is not pointing to the environment where your code runs. For greater control, you could instead run pip as a module within the python environment you specify: 12 | ``` 13 | python -m pip install 14 | ``` 15 | - Your editor/ide is configured to create virtual environments automatically (PyCharm is configured this way by default). 16 | -------------------------------------------------------------------------------- /bot/resources/tags/off-topic-names.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Off-topic channels" 4 | --- 5 | There are three off-topic channels: 6 | - <#291284109232308226> 7 | - <#463035241142026251> 8 | - <#463035268514185226> 9 | 10 | The channel names change every night at midnight UTC and are often fun meta references to jokes or conversations that happened on the server. 11 | 12 | See our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) page for more guidance on how the channels should be used. 13 | -------------------------------------------------------------------------------- /bot/resources/tags/on-message-event.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The discord.py `on_message` event" 4 | --- 5 | 6 | Registering the `on_message` event with [`@bot.event`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.event) will override the default behavior of the event. This may cause prefix commands to stop working, because they rely on the default `on_message` event handler. 7 | 8 | Instead, use [`@bot.listen`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.listen) to add a listener. Listeners get added alongside the default `on_message` handler which allows you to have multiple handlers for the same event. This means prefix commands can still be invoked as usual. Here's an example: 9 | ```python 10 | @bot.listen() 11 | async def on_message(message): 12 | ... # do stuff here 13 | 14 | # Or... 15 | 16 | @bot.listen('on_message') 17 | async def message_listener(message): 18 | ... # do stuff here 19 | ``` 20 | You can also tell discord.py to process the message for commands as usual at the end of the `on_message` handler with [`bot.process_commands()`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.process_commands). However, this method isn't recommended as it does not allow you to add multiple `on_message` handlers. 21 | 22 | If your prefix commands are still not working, it may be because you haven't enabled the `message_content` intent. See `/tag message_content` for more info. 23 | -------------------------------------------------------------------------------- /bot/resources/tags/open.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Opening files" 4 | --- 5 | The built-in function `open()` is one of several ways to open files on your computer. It accepts many different parameters, so this tag will only go over two of them (`file` and `mode`). For more extensive documentation on all these parameters, consult the [official documentation](https://docs.python.org/3/library/functions.html#open). The object returned from this function is a [file object or stream](https://docs.python.org/3/glossary.html#term-file-object), for which the full documentation can be found [here](https://docs.python.org/3/library/io.html#io.TextIOBase). 6 | 7 | See also: 8 | - `!tags with` for information on context managers 9 | - `!tags pathlib` for an alternative way of opening files 10 | - `!tags seek` for information on changing your position in a file 11 | 12 | **The `file` parameter** 13 | 14 | This should be a [path-like object](https://docs.python.org/3/glossary.html#term-path-like-object) denoting the name or path (absolute or relative) to the file you want to open. 15 | 16 | An absolute path is the full path from your root directory to the file you want to open. Generally this is the option you should choose so it doesn't matter what directory you're in when you execute your module. 17 | 18 | See `!tags relative-path` for more information on relative paths. 19 | 20 | **The `mode` parameter** 21 | 22 | This is an optional string that specifies the mode in which the file should be opened. There's not enough room to discuss them all, but listed below are some of the more confusing modes. 23 | 24 | - `'r+'` Opens for reading and writing (file must already exist) 25 | - `'w+'` Opens for reading and writing and truncates (can create files) 26 | - `'x'` Creates file and opens for writing (file must **not** already exist) 27 | - `'x+'` Creates file and opens for reading and writing (file must **not** already exist) 28 | - `'a+'` Opens file for reading and writing at **end of file** (can create files) 29 | -------------------------------------------------------------------------------- /bot/resources/tags/or-gotcha.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The or-gotcha" 4 | --- 5 | When checking if something is equal to one thing or another, you might think that this is possible: 6 | ```py 7 | # Incorrect... 8 | if favorite_fruit == 'grapefruit' or 'lemon': 9 | print("That's a weird favorite fruit to have.") 10 | ``` 11 | While this makes sense in English, it may not behave the way you would expect. In Python, you should have _[complete instructions on both sides of the logical operator](https://docs.python.org/3/reference/expressions.html#boolean-operations)_. 12 | 13 | So, if you want to check if something is equal to one thing or another, there are two common ways: 14 | ```py 15 | # Like this... 16 | if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': 17 | print("That's a weird favorite fruit to have.") 18 | 19 | # ...or like this. 20 | if favorite_fruit in ('grapefruit', 'lemon'): 21 | print("That's a weird favorite fruit to have.") 22 | ``` 23 | -------------------------------------------------------------------------------- /bot/resources/tags/ot.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Off-topic channel" 4 | --- 5 | <#463035268514185226> 6 | 7 | Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations. 8 | -------------------------------------------------------------------------------- /bot/resources/tags/param-arg.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Parameters vs. arguments" 4 | --- 5 | A parameter is a variable defined in a function signature (the line with `def` in it), while arguments are objects passed to a function call. 6 | 7 | ```py 8 | def square(n): # n is the parameter 9 | return n*n 10 | 11 | print(square(5)) # 5 is the argument 12 | ``` 13 | 14 | Note that `5` is the argument passed to `square`, but `square(5)` in its entirety is the argument passed to `print` 15 | -------------------------------------------------------------------------------- /bot/resources/tags/paste.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Pasting large amounts of code" 4 | --- 5 | If your code is too long to fit in a codeblock in Discord, you can paste your code here: 6 | https://paste.pythondiscord.com/ 7 | 8 | After pasting your code, **save** it by clicking the Paste! button in the bottom left, or by pressing `CTRL + S`. After doing that, you will be navigated to the new paste's page. Copy the URL and post it here so others can see it. 9 | -------------------------------------------------------------------------------- /bot/resources/tags/pathlib.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `pathlib` module" 4 | --- 5 | Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Path` objects work nearly everywhere that `os.path` can be used, meaning you can integrate your new code directly into legacy code without having to rewrite anything. Pathlib makes working with paths way simpler than `os.path` does. 6 | 7 | **Feature spotlight**: 8 | 9 | - Normalizes file paths for all platforms automatically 10 | - Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files 11 | - Can read and write files, and close them automatically 12 | - Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) 13 | - Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) 14 | - Supports method chaining 15 | - Move and delete files 16 | - And much more 17 | 18 | **More Info**: 19 | 20 | - [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) 21 | - [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) 22 | - [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) 23 | - [**PEP 519** - Adding a file system path protocol](https://peps.python.org/pep-0519/) 24 | -------------------------------------------------------------------------------- /bot/resources/tags/pep8.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "PEP 8" 4 | --- 5 | **PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like flake8 to verify that the code they're writing complies with the style guide. 6 | 7 | More information: 8 | - [PEP 8 document](https://peps.python.org/pep-0008/) 9 | - [Our PEP 8 song!](https://www.youtube.com/watch?v=hgI0p1zf31k) :notes: 10 | -------------------------------------------------------------------------------- /bot/resources/tags/positional-keyword.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Positional vs. keyword arguments" 4 | --- 5 | Functions can take two different kinds of arguments. A positional argument is just the object itself. A keyword argument is a name assigned to an object. 6 | 7 | **Example** 8 | ```py 9 | >>> print('Hello', 'world!', sep=', ') 10 | Hello, world! 11 | ``` 12 | The first two strings `'Hello'` and `'world!'` are positional arguments. 13 | The `sep=', '` is a keyword argument. 14 | 15 | **Note** 16 | A keyword argument can be passed positionally in some cases. 17 | ```py 18 | def sum(a, b=1): 19 | return a + b 20 | 21 | sum(1, b=5) 22 | sum(1, 5) # same as above 23 | ``` 24 | [Sometimes this is forced](https://peps.python.org/pep-0570/#history-of-positional-only-parameter-semantics-in-python), in the case of the `pow()` function. 25 | 26 | The reverse is also true: 27 | ```py 28 | >>> def foo(a, b): 29 | ... print(a, b) 30 | ... 31 | >>> foo(a=1, b=2) 32 | 1 2 33 | >>> foo(b=1, a=2) 34 | 2 1 35 | ``` 36 | 37 | **More info** 38 | - [Keyword only arguments](https://peps.python.org/pep-3102/) 39 | - [Positional only arguments](https://peps.python.org/pep-0570/) 40 | - `/tag param-arg` (Parameters vs. Arguments) 41 | -------------------------------------------------------------------------------- /bot/resources/tags/precedence.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Operator precedence" 4 | --- 5 | Operator precedence is essentially like an order of operations for Python's operators. 6 | 7 | **Example 1** (arithmetic) 8 | `2 * 3 + 1` is `7` because multiplication is first 9 | `2 * (3 + 1)` is `8` because the parenthesis change the precedence allowing the sum to be first 10 | 11 | **Example 2** (logic) 12 | `not True or True` is `True` because the `not` is first 13 | `not (True or True)` is `False` because the `or` is first 14 | 15 | The full table of precedence from lowest to highest is [here](https://docs.python.org/3/reference/expressions.html#operator-precedence) 16 | -------------------------------------------------------------------------------- /bot/resources/tags/quotes.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "String quotes" 4 | --- 5 | Single and Double quoted strings are the **same** in Python. The choice of which one to use is up to you, just make sure that you **stick to that choice**. 6 | 7 | With that said, there are exceptions to this that are more important than consistency. If a single or double quote is needed *inside* the string, using the opposite quotation is better than using escape characters. 8 | 9 | Example: 10 | ```py 11 | 'My name is "Guido"' # good 12 | "My name is \"Guido\"" # bad 13 | 14 | "Don't go in there" # good 15 | 'Don\'t go in there' # bad 16 | ``` 17 | **Note:** 18 | If you need both single and double quotes inside your string, use the version that would result in the least amount of escapes. In the case of a tie, use the quotation you use the most. 19 | 20 | **References:** 21 | - [pep-8 on quotes](https://peps.python.org/pep-0008/#string-quotes) 22 | - [convention for triple quoted strings](https://peps.python.org/pep-0257/) 23 | -------------------------------------------------------------------------------- /bot/resources/tags/range-len.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Pythonic way of iterating over ordered collections" 4 | --- 5 | Beginners often iterate over `range(len(...))` because they look like Java or C-style loops, but this is almost always a bad practice in Python. 6 | ```py 7 | for i in range(len(my_list)): 8 | do_something(my_list[i]) 9 | ``` 10 | It's much simpler to iterate over the list (or other sequence) directly: 11 | ```py 12 | for item in my_list: 13 | do_something(item) 14 | ``` 15 | Python has other solutions for cases when the index itself might be needed. To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). 16 | -------------------------------------------------------------------------------- /bot/resources/tags/regex.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Regular expressions" 4 | --- 5 | Regular expressions (regex) are a tool for finding patterns in strings. The standard library's `re` module defines functions for using regex patterns. 6 | 7 | **Example** 8 | We can use regex to pull out all the numbers in a sentence: 9 | ```py 10 | >>> import re 11 | >>> text = "On Oct 18 1963 a cat was launched aboard rocket #47" 12 | >>> regex_pattern = r"[0-9]{1,3}" # Matches 1-3 digits 13 | >>> re.findall(regex_pattern, text) 14 | ['18', '196', '3', '47'] # Notice the year is cut off 15 | ``` 16 | **See Also** 17 | - [The re docs](https://docs.python.org/3/library/re.html) - for functions that use regex 18 | - [regex101.com](https://regex101.com) - an interactive site for testing your regular expression 19 | -------------------------------------------------------------------------------- /bot/resources/tags/relative-path.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Relative path" 4 | --- 5 | A relative path is a partial path that is relative to your current working directory. A common misconception is that your current working directory is the location of the module you're executing, **but this is not the case**. Your current working directory is actually the **directory you were in when you ran the Python interpreter**. The reason for this misconception is because a common way to run your code is to navigate to the directory your module is stored, and run `python .py`. Thus, in this case your current working directory will be the same as the location of the module. However, if we instead did `python path/to/.py`, our current working directory would no longer be the same as the location of the module we're executing. 6 | 7 | **Why is this important?** 8 | 9 | When opening files in Python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. 10 | -------------------------------------------------------------------------------- /bot/resources/tags/repl.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Read-Eval-Print Loop (REPL)" 4 | --- 5 | A REPL is an interactive shell where you can execute individual lines of code one at a time, like so: 6 | ```python-repl 7 | >>> x = 5 8 | >>> x + 2 9 | 7 10 | >>> for i in range(3): 11 | ... print(i) 12 | ... 13 | 0 14 | 1 15 | 2 16 | >>> 17 | ``` 18 | To enter the REPL, run `python` (`py` on Windows) in the command line without any arguments. The `>>>` or `...` at the start of some lines are prompts to enter code, and indicate that you are in the Python REPL. Any other lines show the output of the code. 19 | 20 | Trying to execute commands for the command-line (such as `pip install xyz`) in the REPL will throw an error. To run these commands, exit the REPL first by running `exit()` and then run the original command. 21 | -------------------------------------------------------------------------------- /bot/resources/tags/return-gif.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["print-return", "return-jif"] 3 | embed: 4 | title: Print and return 5 | image: 6 | url: https://raw.githubusercontent.com/python-discord/bot/main/bot/resources/media/print-return.gif 7 | --- 8 | Here's a handy animation demonstrating how `print` and `return` differ in behavior. 9 | 10 | See also: `/tag return` 11 | -------------------------------------------------------------------------------- /bot/resources/tags/return.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Return statement" 4 | --- 5 | A value created inside a function can't be used outside of it unless you `return` it. 6 | 7 | Consider the following function: 8 | ```py 9 | def square(n): 10 | return n * n 11 | ``` 12 | If we wanted to store 5 squared in a variable called `x`, we would do: 13 | `x = square(5)`. `x` would now equal `25`. 14 | 15 | **Common Mistakes** 16 | ```py 17 | >>> def square(n): 18 | ... n * n # calculates then throws away, returns None 19 | ... 20 | >>> x = square(5) 21 | >>> print(x) 22 | None 23 | >>> def square(n): 24 | ... print(n * n) # calculates and prints, then throws away and returns None 25 | ... 26 | >>> x = square(5) 27 | 25 28 | >>> print(x) 29 | None 30 | ``` 31 | **Things to note** 32 | - `print()` and `return` do **not** accomplish the same thing. `print()` will show the value, and then it will be gone. 33 | - A function will return `None` if it ends without a `return` statement. 34 | - When you want to print a value from a function, it's best to return the value and print the *function call* instead, like `print(square(5))`. 35 | -------------------------------------------------------------------------------- /bot/resources/tags/round.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Round half to even*" 4 | --- 5 | Python 3 uses bankers' rounding (also known by other names), where if the fractional part of a number is `.5`, it's rounded to the nearest **even** result instead of away from zero. 6 | 7 | Example: 8 | ```py 9 | >>> round(2.5) 10 | 2 11 | >>> round(1.5) 12 | 2 13 | ``` 14 | In the first example, there is a tie between 2 and 3, and since 3 is odd and 2 is even, the result is 2. 15 | In the second example, the tie is between 1 and 2, and so 2 is also the result. 16 | 17 | **Why this is done:** 18 | The round half up technique creates a slight bias towards the larger number. With a large amount of calculations, this can be significant. The round half to even technique eliminates this bias. 19 | 20 | It should be noted that round half to even distorts the distribution by increasing the probability of evens relative to odds, however this is considered less important than the bias explained above. 21 | 22 | **References:** 23 | - [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) 24 | - [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) 25 | - [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) 26 | - [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) 27 | -------------------------------------------------------------------------------- /bot/resources/tags/scope.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Scoping rules" 4 | --- 5 | A *scope* defines the visibility of a name within a block, where a block is a piece of Python code executed as a unit. For simplicity, this would be a module, a function body, and a class definition. A name refers to text bound to an object. 6 | 7 | *For more information about names, see `/tag names`* 8 | 9 | A module is the source code file itself, and encompasses all blocks defined within it. Therefore if a variable is defined at the module level (top-level code block), it is a global variable and can be accessed anywhere in the module as long as the block in which it's referenced is executed after it was defined. 10 | 11 | Alternatively if a variable is defined within a function block for example, it is a local variable. It is not accessible at the module level, as that would be *outside* its scope. This is the purpose of the `return` statement, as it hands an object back to the scope of its caller. Conversely if a function was defined *inside* the previously mentioned block, it *would* have access to that variable, because it is within the first function's scope. 12 | ```py 13 | >>> def outer(): 14 | ... foo = 'bar' # local variable to outer 15 | ... def inner(): 16 | ... print(foo) # has access to foo from scope of outer 17 | ... return inner # brings inner to scope of caller 18 | ... 19 | >>> inner = outer() # get inner function 20 | >>> inner() # prints variable foo without issue 21 | bar 22 | ``` 23 | **Official Documentation** 24 | **1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) 25 | **2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) 26 | **3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) 27 | -------------------------------------------------------------------------------- /bot/resources/tags/seek.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Seek" 4 | --- 5 | In the context of a [file object](https://docs.python.org/3/glossary.html#term-file-object), the `seek` function changes the stream position to a given byte offset, with an optional argument of where to offset from. While you can find the official documentation [here](https://docs.python.org/3/library/io.html#io.IOBase.seek), it can be unclear how to actually use this feature, so keep reading to see examples on how to use it. 6 | 7 | File named `example`: 8 | ``` 9 | foobar 10 | spam eggs 11 | ``` 12 | Open file for reading in byte mode: 13 | ```py 14 | f = open('example', 'rb') 15 | ``` 16 | Note that stream positions start from 0 in much the same way that the index for a list does. If we do `f.seek(3, 0)`, our stream position will move 3 bytes forward relative to the **beginning** of the stream. Now if we then did `f.read(1)` to read a single byte from where we are in the stream, it would return the string `'b'` from the 'b' in 'foobar'. Notice that the 'b' is the 4th character. Also note that after we did `f.read(1)`, we moved the stream position again 1 byte forward relative to the **current** position in the stream. So the stream position is now currently at position 4. 17 | 18 | Now lets do `f.seek(4, 1)`. This will move our stream position 4 bytes forward relative to our **current** position in the stream. Now if we did `f.read(1)`, it would return the string `'p'` from the 'p' in 'spam' on the next line. Note this time that the character at position 6 is the newline character `'\n'`. 19 | 20 | Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes relative to the **end** of the stream. Now if we did `f.read()` to read everything after our position in the file, it would return the string `'eggs'` and also move our stream position to the end of the file. 21 | 22 | **Note** 23 | - For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. 24 | - `os.SEEK_CUR` is only usable when the file is in byte mode. 25 | -------------------------------------------------------------------------------- /bot/resources/tags/self.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Class instance" 4 | --- 5 | When calling a method from a class instance (ie. `instance.method()`), the instance itself will automatically be passed as the first argument implicitly. By convention, we call this `self`, but it could technically be called any valid variable name. 6 | 7 | ```py 8 | class Foo: 9 | def bar(self): 10 | print('bar') 11 | 12 | def spam(self, eggs): 13 | print(eggs) 14 | 15 | foo = Foo() 16 | ``` 17 | 18 | If we call `foo.bar()`, it is equivalent to doing `Foo.bar(foo)`. Our instance `foo` is passed for us to the `bar` function, so while we initially gave zero arguments, it is actually called with one. 19 | 20 | Similarly if we call `foo.spam('ham')`, it is equivalent to 21 | doing `Foo.spam(foo, 'ham')`. 22 | 23 | **Why is this useful?** 24 | 25 | Methods do not inherently have access to attributes defined in the class. In order for any one method to be able to access other methods or variables defined in the class, it must have access to the instance. 26 | 27 | Consider if outside the class, we tried to do this: `spam(foo, 'ham')`. This would give an error, because we don't have access to the `spam` method directly, we have to call it by doing `foo.spam('ham')`. This is also the case inside of the class. If we wanted to call the `bar` method inside the `spam` method, we'd have to do `self.bar()`, just doing `bar()` would give an error. 28 | -------------------------------------------------------------------------------- /bot/resources/tags/site.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Python Discord website" 4 | --- 5 | [Our official website](https://www.pythondiscord.com/) is an open-source community project created with Python and Django. It contains information about the server itself, lets you sign up for upcoming events, has its own wiki, contains a list of valuable learning resources, and much more. 6 | -------------------------------------------------------------------------------- /bot/resources/tags/slicing.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["slice", "seqslice", "seqslicing", "sequence-slice", "sequence-slicing"] 3 | embed: 4 | title: "Sequence slicing" 5 | --- 6 | **Slicing** is a way of accessing a part of a sequence by specifying a start, stop, and step. As with normal indexing, negative numbers can be used to count backwards. 7 | 8 | **Examples** 9 | ```py 10 | >>> letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] 11 | >>> letters[2:] # from element 2 to the end 12 | ['c', 'd', 'e', 'f', 'g'] 13 | >>> letters[:4] # up to element 4 14 | ['a', 'b', 'c', 'd'] 15 | >>> letters[3:5] # elements 3 and 4 -- the right bound is not included 16 | ['d', 'e'] 17 | >>> letters[2:-1:2] # Every other element between 2 and the last 18 | ['c', 'e'] 19 | >>> letters[::-1] # The whole list in reverse 20 | ['g', 'f', 'e', 'd', 'c', 'b', 'a'] 21 | >>> words = "Hello world!" 22 | >>> words[2:7] # Strings are also sequences 23 | "llo w" 24 | ``` 25 | -------------------------------------------------------------------------------- /bot/resources/tags/sql-fstring.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "SQL & f-strings" 4 | --- 5 | Don't use f-strings (`f""`) or other forms of "string interpolation" (`%`, `+`, `.format`) to inject data into a SQL query. It is an endless source of bugs and syntax errors. Additionally, in user-facing applications, it presents a major security risk via SQL injection. 6 | 7 | Your database library should support "query parameters". A query parameter is a placeholder that you put in the SQL query. When the query is executed, you provide data to the database library, and the library inserts the data into the query for you, **safely**. 8 | 9 | For example, the sqlite3 package supports using `?` as a placeholder: 10 | ```py 11 | query = "SELECT * FROM stocks WHERE symbol = ?;" 12 | params = ("RHAT",) 13 | db.execute(query, params) 14 | ``` 15 | Note: Different database libraries support different placeholder styles, e.g. `%s` and `$1`. Consult your library's documentation for details. 16 | 17 | **See Also** 18 | - [Python sqlite3 docs](https://docs.python.org/3/library/sqlite3.html#how-to-use-placeholders-to-bind-values-in-sql-queries) - How to use placeholders to bind values in SQL queries 19 | - [PEP-249](https://peps.python.org/pep-0249/) - A specification of how database libraries in Python should work 20 | -------------------------------------------------------------------------------- /bot/resources/tags/star-imports.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Star / Wildcard imports" 4 | --- 5 | 6 | Wildcard imports are import statements in the form `from import *`. What imports like these do is that they import everything **[1]** from the module into the current module's namespace **[2]**. This allows you to use names defined in the imported module without prefixing the module's name. 7 | 8 | Example: 9 | ```python 10 | >>> from math import * 11 | >>> sin(pi / 2) 12 | 1.0 13 | ``` 14 | **This is discouraged, for various reasons:** 15 | 16 | Example: 17 | ```python 18 | >>> from custom_sin import sin 19 | >>> from math import * 20 | >>> sin(pi / 2) # uses sin from math rather than your custom sin 21 | ``` 22 | - Potential namespace collision. Names defined from a previous import might get shadowed by a wildcard import. 23 | - Causes ambiguity. From the example, it is unclear which `sin` function is actually being used. From the Zen of Python **[3]**: `Explicit is better than implicit.` 24 | - Makes import order significant, which they shouldn't. Certain IDE's `sort import` functionality may end up breaking code due to namespace collision. 25 | 26 | **How should you import?** 27 | 28 | - Import the module under the module's namespace (Only import the name of the module, and names defined in the module can be used by prefixing the module's name) 29 | ```python 30 | >>> import math 31 | >>> math.sin(math.pi / 2) 32 | ``` 33 | - Explicitly import certain names from the module 34 | ```python 35 | >>> from math import sin, pi 36 | >>> sin(pi / 2) 37 | ``` 38 | Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3]* 39 | 40 | **[1]** If the module defines the variable `__all__`, the names defined in `__all__` will get imported by the wildcard import, otherwise all the names in the module get imported (except for names with a leading underscore) 41 | **[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace) 42 | **[3]** [Zen of Python](https://peps.python.org/pep-0020/) 43 | -------------------------------------------------------------------------------- /bot/resources/tags/str-join.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Joining iterables" 4 | --- 5 | If you want to display a list (or some other iterable), you can write: 6 | ```py 7 | colors = ['red', 'green', 'blue', 'yellow'] 8 | output = "" 9 | separator = ", " 10 | for color in colors: 11 | output += color + separator 12 | print(output) 13 | # Prints 'red, green, blue, yellow, ' 14 | ``` 15 | However, the separator is still added to the last element, and it is relatively slow. 16 | 17 | A better solution is to use `str.join`. 18 | ```py 19 | colors = ['red', 'green', 'blue', 'yellow'] 20 | separator = ", " 21 | print(separator.join(colors)) 22 | # Prints 'red, green, blue, yellow' 23 | ``` 24 | An important thing to note is that you can only `str.join` strings. For a list of ints, 25 | you must convert each element to a string before joining. 26 | ```py 27 | integers = [1, 3, 6, 10, 15] 28 | print(", ".join(str(e) for e in integers)) 29 | # Prints '1, 3, 6, 10, 15' 30 | ``` 31 | -------------------------------------------------------------------------------- /bot/resources/tags/string-formatting.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "String formatting mini-language" 4 | --- 5 | The String Formatting Language in Python is a powerful way to tailor the display of strings and other data structures. This string formatting mini language works for f-strings and `.format()`. 6 | 7 | Take a look at some of these examples! 8 | ```py 9 | >>> my_num = 2134234523 10 | >>> print(f"{my_num:,}") 11 | 2,134,234,523 12 | 13 | >>> my_smaller_num = -30.0532234 14 | >>> print(f"{my_smaller_num:=09.2f}") 15 | -00030.05 16 | 17 | >>> my_str = "Center me!" 18 | >>> print(f"{my_str:-^20}") 19 | -----Center me!----- 20 | 21 | >>> repr_str = "Spam \t Ham" 22 | >>> print(f"{repr_str!r}") 23 | 'Spam \t Ham' 24 | ``` 25 | **Full Specification & Resources** 26 | [String Formatting Mini Language Specification](https://docs.python.org/3/library/string.html#format-specification-mini-language) 27 | [pyformat.info](https://pyformat.info/) 28 | -------------------------------------------------------------------------------- /bot/resources/tags/strip-gotcha.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The strip-gotcha" 4 | --- 5 | When working with `strip`, `lstrip`, or `rstrip`, you might think that this would be the case: 6 | ```py 7 | >>> "Monty Python".rstrip(" Python") 8 | "Monty" 9 | ``` 10 | While this seems intuitive, it would actually result in: 11 | ```py 12 | "M" 13 | ``` 14 | as Python interprets the argument to these functions as a set of characters rather than a substring. 15 | 16 | If you want to remove a prefix/suffix from a string, `str.removeprefix` and `str.removesuffix` are recommended and were added in 3.9. 17 | ```py 18 | >>> "Monty Python".removesuffix(" Python") 19 | "Monty" 20 | ``` 21 | See the documentation of [str.removeprefix](https://docs.python.org/3.10/library/stdtypes.html#str.removeprefix) and [str.removesuffix](https://docs.python.org/3.10/library/stdtypes.html#str.removesuffix) for more information. 22 | -------------------------------------------------------------------------------- /bot/resources/tags/system-python.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "System Python" 4 | --- 5 | 6 | *Why Avoid System Python for Development on Unix-like Systems:* 7 | 8 | - **Critical Operating System Dependencies:** Altering the system Python installation may harm internal operating system dependencies. 9 | - **Stability and Security Concerns:** System interpreters lag behind current releases, lacking the latest features and security patches. 10 | - **Limited Package Control:** External package management restricts control over versions, leading to compatibility issues with outdated packages. 11 | 12 | *Recommended Approach:* 13 | 14 | - **Install Independent Interpreter:** Install Python from source or utilize a virtual environment for flexibility and control. 15 | - **Utilize [Pyenv](https://github.com/pyenv/pyenv) or Similar Tools:** Manage multiple Python versions and create isolated development environments for smoother workflows. 16 | -------------------------------------------------------------------------------- /bot/resources/tags/tools.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Tools" 4 | --- 5 | 6 | The [Tools page](https://www.pythondiscord.com/resources/tools/) on our website contains a couple of the most popular tools for programming in Python. 7 | -------------------------------------------------------------------------------- /bot/resources/tags/traceback.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Traceback" 4 | --- 5 | Please provide the full traceback for your exception in order to help us identify your issue. 6 | While the last line of the error message tells us what kind of error you got, 7 | the full traceback will tell us which line, and other critical information to solve your problem. 8 | Please avoid screenshots so we can copy and paste parts of the message. 9 | 10 | A full traceback could look like: 11 | ```py 12 | Traceback (most recent call last): 13 | File "my_file.py", line 5, in 14 | add_three("6") 15 | File "my_file.py", line 2, in add_three 16 | a = num + 3 17 | ~~~~^~~ 18 | TypeError: can only concatenate str (not "int") to str 19 | ``` 20 | If the traceback is long, use [our pastebin](https://paste.pythondiscord.com/). 21 | -------------------------------------------------------------------------------- /bot/resources/tags/type-hint.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Type hints" 4 | --- 5 | A type hint indicates what type a variable is expected to be. 6 | ```python 7 | def add(a: int, b: int) -> int: 8 | return a + b 9 | ``` 10 | The type hints indicate that for our `add` function the parameters `a` and `b` should be integers, and the function should return an integer when called. 11 | 12 | It's important to note these are just hints and are not enforced at runtime. 13 | 14 | ```python 15 | add("hello ", "world") 16 | ``` 17 | The above code won't error even though it doesn't follow the function's type hints; the two strings will be concatenated as normal. 18 | 19 | Third party tools like [mypy](https://mypy.readthedocs.io/en/stable/introduction.html) can validate your code to ensure it is type hinted correctly. This can help you identify potentially buggy code, for example it would error on the second example as our `add` function is not intended to concatenate strings. 20 | 21 | [mypy's documentation](https://mypy.readthedocs.io/en/stable/builtin_types.html) contains useful information on type hinting, and for more information check out [this documentation page](https://typing.readthedocs.io/en/latest/index.html). 22 | -------------------------------------------------------------------------------- /bot/resources/tags/underscore.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["under"] 3 | embed: 4 | title: "Meanings of underscores in identifier names" 5 | --- 6 | 7 | - `__name__`: Used to implement special behaviour, such as the `+` operator for classes with the `__add__` method. [More info](https://dbader.org/blog/python-dunder-methods) 8 | - `_name`: Indicates that a variable is "private" and should only be used by the class or module that defines it 9 | - `name_`: Used to avoid naming conflicts. For example, as `class` is a keyword, you could call a variable `class_` instead 10 | - `__name`: Causes the name to be "mangled" if defined inside a class. [More info](https://docs.python.org/3/tutorial/classes.html#private-variables) 11 | 12 | A single underscore, **`_`**, has multiple uses: 13 | - To indicate an unused variable, e.g. in a for loop if you don't care which iteration you are on 14 | ```python 15 | for _ in range(10): 16 | print("Hello World") 17 | ``` 18 | - In the REPL, where the previous result is assigned to the variable `_` 19 | ```python 20 | >>> 1 + 1 # Evaluated and stored in `_` 21 | 2 22 | >>> _ + 3 # Take the previous result and add 3 23 | 5 24 | ``` 25 | - In integer literals, e.g. `x = 1_500_000` can be written instead of `x = 1500000` to improve readability 26 | 27 | See also ["Reserved classes of identifiers"](https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers) in the Python docs, and [this more detailed guide](https://dbader.org/blog/meaning-of-underscores-in-python). 28 | -------------------------------------------------------------------------------- /bot/resources/tags/venv.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: ["virtualenv"] 3 | embed: 4 | title: "Virtual environments" 5 | --- 6 | 7 | Virtual environments are isolated Python environments, which make it easier to keep your system clean and manage dependencies. By default, when activated, only libraries and scripts installed in the virtual environment are accessible, preventing cross-project dependency conflicts, and allowing easy isolation of requirements. 8 | 9 | To create a new virtual environment, you can use the standard library `venv` module: `python3 -m venv .venv` (replace `python3` with `python` or `py` on Windows) 10 | 11 | Then, to activate the new virtual environment: 12 | 13 | **Windows** (PowerShell): `.venv\Scripts\Activate.ps1` 14 | or (Command Prompt): `.venv\Scripts\activate.bat` 15 | **MacOS / Linux** (Bash): `source .venv/bin/activate` 16 | 17 | Packages can then be installed to the virtual environment using `pip`, as normal. 18 | 19 | For more information, take a read of the [documentation](https://docs.python.org/3/library/venv.html). If you run code through your editor, check its documentation on how to make it use your virtual environment. For example, see the [VSCode](https://code.visualstudio.com/docs/python/environments#_select-and-activate-an-environment) or [PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html) docs. 20 | 21 | Tools such as [poetry](https://python-poetry.org/docs/basic-usage/) and [pipenv](https://pipenv.pypa.io/en/latest/) can manage the creation of virtual environments as well as project dependencies, making packaging and installing your project easier. 22 | 23 | **Note:** When using PowerShell in Windows, you may need to change the [execution policy](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies) first. This is only required once per user: 24 | ```ps1 25 | Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser 26 | ``` 27 | -------------------------------------------------------------------------------- /bot/resources/tags/voice-verification.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Voice verification" 4 | --- 5 | Can’t talk in voice chat? Check out <#764802555427029012> to get access. The criteria for verifying are specified there. 6 | -------------------------------------------------------------------------------- /bot/resources/tags/windows-path.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "PATH on Windows" 4 | --- 5 | If you have installed Python but forgot to check the `Add Python to PATH` option during the installation, you may still be able to access your installation with ease. 6 | 7 | If you did not uncheck the option to install the `py launcher`, then you'll instead have a `py` command which can be used in the same way. If you want to be able to access your Python installation via the `python` command, then your best option is to re-install Python (remembering to tick the `Add Python to PATH` checkbox). 8 | 9 | You can pass any options to the Python interpreter, e.g. to install the [`numpy`](https://pypi.org/project/numpy/) module from PyPI you can run `py -3 -m pip install numpy` or `python -m pip install numpy`. 10 | 11 | You can also access different versions of Python using the version flag of the `py` command, like so: 12 | ``` 13 | C:\Users\Username> py -3.7 14 | ... Python 3.7 starts ... 15 | C:\Users\Username> py -3.6 16 | ... Python 3.6 starts ... 17 | C:\Users\Username> py -2 18 | ... Python 2 (any version installed) starts ... 19 | ``` 20 | -------------------------------------------------------------------------------- /bot/resources/tags/with.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `with` keyword" 4 | --- 5 | The `with` keyword triggers a context manager. Context managers automatically set up and take down data connections, or any other kind of object that implements the magic methods `__enter__` and `__exit__`. 6 | ```py 7 | with open("test.txt", "r") as file: 8 | do_things(file) 9 | ``` 10 | The above code automatically closes `file` when the `with` block exits, so you never have to manually do a `file.close()`. Most connection types, including file readers and database connections, support this. 11 | 12 | For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://peps.python.org/pep-0343/). 13 | -------------------------------------------------------------------------------- /bot/resources/tags/xy-problem.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "xy-problem" 4 | --- 5 | The XY problem can be summarised as asking about your attempted solution, rather than your actual problem. 6 | 7 | Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. 8 | 9 | For more information and examples, see http://xyproblem.info/. 10 | -------------------------------------------------------------------------------- /bot/resources/tags/ytdl.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "Our youtube-dl, or equivalents, policy" 4 | --- 5 | Per [Python Discord's Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, pytube, or other YouTube video downloaders, as their usage violates YouTube's Terms of Service. 6 | 7 | For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?gl=GB&template=terms), as of 2021-03-17: 8 | ``` 9 | The following restrictions apply to your use of the Service. You are not allowed to: 10 | 11 | 1. access, reproduce, download, distribute, transmit, broadcast, display, sell, license, alter, modify or otherwise use any part of the Service or any Content except: (a) as specifically permitted by the Service; (b) with prior written permission from YouTube and, if applicable, the respective rights holders; or (c) as permitted by applicable law; 12 | 13 | 3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law; 14 | 15 | 9. use the Service to view or listen to Content other than for personal, non-commercial use (for example, you may not publicly screen videos or stream music from the Service) 16 | ``` 17 | -------------------------------------------------------------------------------- /bot/resources/tags/zip.md: -------------------------------------------------------------------------------- 1 | --- 2 | embed: 3 | title: "The `zip` function" 4 | --- 5 | The zip function allows you to iterate through multiple iterables simultaneously. It joins the iterables together, almost like a zipper, so that each new element is a tuple with one element from each iterable. 6 | 7 | ```py 8 | letters = 'abc' 9 | numbers = [1, 2, 3] 10 | # list(zip(letters, numbers)) --> [('a', 1), ('b', 2), ('c', 3)] 11 | for letter, number in zip(letters, numbers): 12 | print(letter, number) 13 | ``` 14 | The `zip()` iterator is exhausted after the length of the shortest iterable is exceeded. If you would like to retain the other values, consider using [itertools.zip_longest](https://docs.python.org/3/library/itertools.html#itertools.zip_longest). 15 | 16 | For more information on zip, please refer to the [official documentation](https://docs.python.org/3/library/functions.html#zip). 17 | -------------------------------------------------------------------------------- /bot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64 2 | 3 | __all__ = [ 4 | "CogABCMeta", 5 | "find_nth_occurrence", 6 | "has_lines", 7 | "pad_base64", 8 | ] 9 | -------------------------------------------------------------------------------- /bot/utils/channel.py: -------------------------------------------------------------------------------- 1 | 2 | import discord 3 | 4 | import bot 5 | from bot import constants 6 | from bot.log import get_logger 7 | 8 | log = get_logger(__name__) 9 | 10 | 11 | def is_mod_channel(channel: discord.TextChannel | discord.Thread) -> bool: 12 | """True if channel, or channel.parent for threads, is considered a mod channel.""" 13 | if isinstance(channel, discord.Thread): 14 | channel = channel.parent 15 | 16 | if channel.id in constants.MODERATION_CHANNELS: 17 | log.trace(f"Channel #{channel} is a configured mod channel") 18 | return True 19 | 20 | if any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): 21 | log.trace(f"Channel #{channel} is in a configured mod category") 22 | return True 23 | 24 | log.trace(f"Channel #{channel} is not a mod channel") 25 | return False 26 | 27 | 28 | def is_staff_channel(channel: discord.TextChannel) -> bool: 29 | """True if `channel` is considered a staff channel.""" 30 | guild = bot.instance.get_guild(constants.Guild.id) 31 | 32 | if channel.type is discord.ChannelType.category: 33 | return False 34 | 35 | # Channel is staff-only if staff have explicit read allow perms 36 | # and @everyone has explicit read deny perms 37 | return any( 38 | channel.overwrites_for(guild.get_role(staff_role)).read_messages is True 39 | and channel.overwrites_for(guild.default_role).read_messages is False 40 | for staff_role in constants.STAFF_ROLES 41 | ) 42 | 43 | 44 | def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: 45 | """Return True if `channel` is within a category with `category_id`.""" 46 | return getattr(channel, "category_id", None) == category_id 47 | -------------------------------------------------------------------------------- /bot/utils/helpers.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from urllib.parse import urlparse 3 | 4 | from discord.ext.commands import CogMeta 5 | from tldextract import extract 6 | 7 | 8 | class CogABCMeta(CogMeta, ABCMeta): 9 | """Metaclass for ABCs meant to be implemented as Cogs.""" 10 | 11 | 12 | def find_nth_occurrence(string: str, substring: str, n: int) -> int | None: 13 | """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" 14 | index = 0 15 | for _ in range(n): 16 | index = string.find(substring, index+1) 17 | if index == -1: 18 | return None 19 | return index 20 | 21 | 22 | def has_lines(string: str, count: int) -> bool: 23 | """Return True if `string` has at least `count` lines.""" 24 | # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break. 25 | split = string.split("\n", count - 1) 26 | 27 | # Make sure the last part isn't empty, which would happen if there was a final newline. 28 | return split[-1] != "" and len(split) == count 29 | 30 | 31 | def pad_base64(data: str) -> str: 32 | """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" 33 | return data + "=" * (-len(data) % 4) 34 | 35 | 36 | def remove_subdomain_from_url(url: str) -> str: 37 | """Removes subdomains from a URL whilst preserving the original URL composition.""" 38 | parsed_url = urlparse(url) 39 | extracted_url = extract(url) 40 | # Eliminate subdomain by using the registered domain only 41 | netloc = extracted_url.registered_domain 42 | parsed_url = parsed_url._replace(netloc=netloc) 43 | return parsed_url.geturl() 44 | -------------------------------------------------------------------------------- /bot/utils/modlog.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | import discord 4 | 5 | from bot.bot import Bot 6 | from bot.constants import Channels, Roles 7 | 8 | 9 | async def send_log_message( 10 | bot: Bot, 11 | icon_url: str | None, 12 | colour: discord.Colour | int, 13 | title: str | None, 14 | text: str, 15 | *, 16 | thumbnail: str | discord.Asset | None = None, 17 | channel_id: int = Channels.mod_log, 18 | ping_everyone: bool = False, 19 | files: list[discord.File] | None = None, 20 | content: str | None = None, 21 | additional_embeds: list[discord.Embed] | None = None, 22 | timestamp_override: datetime | None = None, 23 | footer: str | None = None, 24 | ) -> discord.Message: 25 | """Generate log embed and send to logging channel.""" 26 | await bot.wait_until_guild_available() 27 | # Truncate string directly here to avoid removing newlines 28 | embed = discord.Embed( 29 | description=text[:4093] + "..." if len(text) > 4096 else text 30 | ) 31 | 32 | if title and icon_url: 33 | embed.set_author(name=title, icon_url=icon_url) 34 | elif title: 35 | raise ValueError("title cannot be set without icon_url") 36 | elif icon_url: 37 | raise ValueError("icon_url cannot be set without title") 38 | 39 | embed.colour = colour 40 | embed.timestamp = timestamp_override or datetime.now(tz=UTC) 41 | 42 | if footer: 43 | embed.set_footer(text=footer) 44 | 45 | if thumbnail: 46 | embed.set_thumbnail(url=thumbnail) 47 | 48 | if ping_everyone: 49 | if content: 50 | content = f"<@&{Roles.moderators}> {content}" 51 | else: 52 | content = f"<@&{Roles.moderators}>" 53 | 54 | # Truncate content to 2000 characters and append an ellipsis. 55 | if content and len(content) > 2000: 56 | content = content[:2000 - 3] + "..." 57 | 58 | channel = bot.get_channel(channel_id) 59 | log_message = await channel.send( 60 | content=content, 61 | embed=embed, 62 | files=files 63 | ) 64 | 65 | if additional_embeds: 66 | for additional_embed in additional_embeds: 67 | await channel.send(embed=additional_embed) 68 | 69 | return log_message 70 | -------------------------------------------------------------------------------- /bot/utils/webhooks.py: -------------------------------------------------------------------------------- 1 | 2 | import discord 3 | from discord import Embed 4 | 5 | from bot.log import get_logger 6 | from bot.utils.messages import sub_clyde 7 | 8 | log = get_logger(__name__) 9 | 10 | 11 | async def send_webhook( 12 | webhook: discord.Webhook, 13 | content: str | None = None, 14 | username: str | None = None, 15 | avatar_url: str | None = None, 16 | embed: Embed | None = None, 17 | wait: bool | None = False 18 | ) -> discord.Message: 19 | """ 20 | Send a message using the provided webhook. 21 | 22 | This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash. 23 | """ 24 | try: 25 | return await webhook.send( 26 | content=content, 27 | username=sub_clyde(username), 28 | avatar_url=avatar_url, 29 | embed=embed, 30 | wait=wait, 31 | ) 32 | except discord.HTTPException: 33 | log.exception("Failed to send a message to the webhook!") 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bot.log import get_logger 4 | 5 | log = get_logger() 6 | log.setLevel(logging.CRITICAL) 7 | -------------------------------------------------------------------------------- /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/bot/.testenv: -------------------------------------------------------------------------------- 1 | unittests_goat=volcyy 2 | unittests_nested__server_name=pydis 3 | -------------------------------------------------------------------------------- /tests/bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/backend/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/backend/sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/backend/sync/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/backend/sync/test_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from pydis_core.site_api import ResponseCodeError 5 | 6 | from bot.exts.backend.sync._syncers import Syncer 7 | from tests import helpers 8 | 9 | 10 | class TestSyncer(Syncer): 11 | """Syncer subclass with mocks for abstract methods for testing purposes.""" 12 | 13 | name = "test" 14 | _get_diff = mock.AsyncMock() 15 | _sync = mock.AsyncMock() 16 | 17 | 18 | class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): 19 | """Tests for main function orchestrating the sync.""" 20 | 21 | def setUp(self): 22 | patcher = mock.patch("bot.instance", new=helpers.MockBot(user=helpers.MockMember(bot=True))) 23 | self.bot = patcher.start() 24 | self.addCleanup(patcher.stop) 25 | 26 | self.guild = helpers.MockGuild() 27 | 28 | TestSyncer._get_diff.reset_mock(return_value=True, side_effect=True) 29 | TestSyncer._sync.reset_mock(return_value=True, side_effect=True) 30 | 31 | # Make sure `_get_diff` returns a MagicMock, not an AsyncMock 32 | TestSyncer._get_diff.return_value = mock.MagicMock() 33 | 34 | async def test_sync_message_edited(self): 35 | """The message should be edited if one was sent, even if the sync has an API error.""" 36 | subtests = ( 37 | (None, None, False), 38 | (helpers.MockMessage(), None, True), 39 | (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), 40 | ) 41 | 42 | for message, side_effect, should_edit in subtests: 43 | with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): 44 | TestSyncer._sync.side_effect = side_effect 45 | ctx = helpers.MockContext() 46 | ctx.send.return_value = message 47 | 48 | await TestSyncer.sync(self.guild, ctx) 49 | 50 | if should_edit: 51 | message.edit.assert_called_once() 52 | self.assertIn("content", message.edit.call_args[1]) 53 | 54 | async def test_sync_message_sent(self): 55 | """If ctx is given, a new message should be sent.""" 56 | subtests = ( 57 | (None, None), 58 | (helpers.MockContext(), helpers.MockMessage()), 59 | ) 60 | 61 | for ctx, message in subtests: 62 | with self.subTest(ctx=ctx, message=message): 63 | await TestSyncer.sync(self.guild, ctx) 64 | 65 | if ctx is not None: 66 | ctx.send.assert_called_once() 67 | -------------------------------------------------------------------------------- /tests/bot/exts/backend/test_logging.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from bot import constants 5 | from bot.exts.backend.logging import Logging 6 | from tests.helpers import MockBot, MockTextChannel, no_create_task 7 | 8 | 9 | class LoggingTests(unittest.IsolatedAsyncioTestCase): 10 | """Test cases for connected login.""" 11 | 12 | def setUp(self): 13 | self.bot = MockBot() 14 | with no_create_task(): 15 | self.cog = Logging(self.bot) 16 | self.dev_log = MockTextChannel(id=1234, name="dev-log") 17 | 18 | @patch("bot.exts.backend.logging.DEBUG_MODE", False) 19 | async def test_debug_mode_false(self): 20 | """Should send connected message to dev-log.""" 21 | self.bot.get_channel.return_value = self.dev_log 22 | 23 | await self.cog.startup_greeting() 24 | self.bot.wait_until_guild_available.assert_awaited_once_with() 25 | self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) 26 | self.dev_log.send.assert_awaited_once() 27 | 28 | @patch("bot.exts.backend.logging.DEBUG_MODE", True) 29 | async def test_debug_mode_true(self): 30 | """Should not send anything to dev-log.""" 31 | await self.cog.startup_greeting() 32 | self.bot.wait_until_guild_available.assert_awaited_once_with() 33 | self.bot.get_channel.assert_not_called() 34 | -------------------------------------------------------------------------------- /tests/bot/exts/backend/test_security.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from discord.ext.commands import NoPrivateMessage 4 | 5 | from bot.exts.backend import security 6 | from tests.helpers import MockBot, MockContext 7 | 8 | 9 | class SecurityCogTests(unittest.TestCase): 10 | """Tests the `Security` cog.""" 11 | 12 | def setUp(self): 13 | """Attach an instance of the cog to the class for tests.""" 14 | self.bot = MockBot() 15 | self.cog = security.Security(self.bot) 16 | self.ctx = MockContext() 17 | 18 | def test_check_additions(self): 19 | """The cog should add its checks after initialization.""" 20 | self.bot.check.assert_any_call(self.cog.check_on_guild) 21 | self.bot.check.assert_any_call(self.cog.check_not_bot) 22 | 23 | def test_check_not_bot_returns_false_for_humans(self): 24 | """The bot check should return `True` when invoked with human authors.""" 25 | self.ctx.author.bot = False 26 | self.assertTrue(self.cog.check_not_bot(self.ctx)) 27 | 28 | def test_check_not_bot_returns_true_for_robots(self): 29 | """The bot check should return `False` when invoked with robotic authors.""" 30 | self.ctx.author.bot = True 31 | self.assertFalse(self.cog.check_not_bot(self.ctx)) 32 | 33 | def test_check_on_guild_raises_when_outside_of_guild(self): 34 | """When invoked outside of a guild, `check_on_guild` should cause an error.""" 35 | self.ctx.guild = None 36 | 37 | with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): 38 | self.cog.check_on_guild(self.ctx) 39 | 40 | def test_check_on_guild_returns_true_inside_of_guild(self): 41 | """When invoked inside of a guild, `check_on_guild` should return `True`.""" 42 | self.ctx.guild = "lemon's lemonade stand" 43 | self.assertTrue(self.cog.check_on_guild(self.ctx)) 44 | 45 | 46 | class SecurityCogLoadTests(unittest.IsolatedAsyncioTestCase): 47 | """Tests loading the `Security` cog.""" 48 | 49 | async def test_security_cog_load(self): 50 | """Setup of the extension should call add_cog.""" 51 | bot = MockBot() 52 | await security.setup(bot) 53 | bot.add_cog.assert_awaited_once() 54 | -------------------------------------------------------------------------------- /tests/bot/exts/filtering/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/filtering/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/filtering/test_settings.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import bot.exts.filtering._settings 4 | from bot.exts.filtering._settings import create_settings 5 | 6 | 7 | class FilterTests(unittest.TestCase): 8 | """Test functionality of the Settings class and its subclasses.""" 9 | 10 | def test_create_settings_returns_none_for_empty_data(self): 11 | """`create_settings` should return a tuple of two Nones when passed an empty dict.""" 12 | result = create_settings({}) 13 | 14 | self.assertEqual(result, (None, None)) 15 | 16 | def test_unrecognized_entry_makes_a_warning(self): 17 | """When an unrecognized entry name is passed to `create_settings`, it should be added to `_already_warned`.""" 18 | create_settings({"abcd": {}}) 19 | 20 | self.assertIn("abcd", bot.exts.filtering._settings._already_warned) 21 | -------------------------------------------------------------------------------- /tests/bot/exts/filtering/test_token_filter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import arrow 4 | 5 | from bot.exts.filtering._filter_context import Event, FilterContext 6 | from bot.exts.filtering._filters.token import TokenFilter 7 | from tests.helpers import MockMember, MockMessage, MockTextChannel 8 | 9 | 10 | class TokenFilterTests(unittest.IsolatedAsyncioTestCase): 11 | """Test functionality of the token filter.""" 12 | 13 | def setUp(self) -> None: 14 | member = MockMember(id=123) 15 | channel = MockTextChannel(id=345) 16 | message = MockMessage(author=member, channel=channel) 17 | self.ctx = FilterContext(Event.MESSAGE, member, channel, "", message) 18 | 19 | async def test_token_filter_triggers(self): 20 | """The filter should evaluate to True only if its token is found in the context content.""" 21 | test_cases = ( 22 | (r"hi", "oh hi there", True), 23 | (r"hi", "goodbye", False), 24 | (r"bla\d{2,4}", "bla18", True), 25 | (r"bla\d{2,4}", "bla1", False), 26 | # See advisory https://github.com/python-discord/bot/security/advisories/GHSA-j8c3-8x46-8pp6 27 | (r"TOKEN", "https://google.com TOKEN", True), 28 | (r"TOKEN", "https://google.com something else", False) 29 | ) 30 | now = arrow.utcnow().timestamp() 31 | 32 | for pattern, content, expected in test_cases: 33 | with self.subTest( 34 | pattern=pattern, 35 | content=content, 36 | expected=expected, 37 | ): 38 | filter_ = TokenFilter({ 39 | "id": 1, 40 | "content": pattern, 41 | "description": None, 42 | "settings": {}, 43 | "additional_settings": {}, 44 | "created_at": now, 45 | "updated_at": now 46 | }) 47 | self.ctx.content = content 48 | result = await filter_.triggered_on(self.ctx) 49 | self.assertEqual(result, expected) 50 | -------------------------------------------------------------------------------- /tests/bot/exts/info/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/info/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/info/doc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/info/doc/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/info/test_help.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import rapidfuzz 4 | 5 | from bot.exts.info import help 6 | from tests.helpers import MockBot, MockContext, autospec 7 | 8 | 9 | class HelpCogTests(unittest.IsolatedAsyncioTestCase): 10 | def setUp(self) -> None: 11 | """Attach an instance of the cog to the class for tests.""" 12 | self.bot = MockBot() 13 | self.cog = help.Help(self.bot) 14 | self.ctx = MockContext(bot=self.bot) 15 | 16 | @autospec(help.CustomHelpCommand, "get_all_help_choices", return_value={"help"}, pass_mocks=False) 17 | async def test_help_fuzzy_matching(self): 18 | """Test fuzzy matching of commands when called from help.""" 19 | result = await self.bot.help_command.command_not_found("holp") 20 | 21 | match = {"help": rapidfuzz.fuzz.ratio("help", "holp")} 22 | self.assertEqual(match, result.possible_matches) 23 | -------------------------------------------------------------------------------- /tests/bot/exts/moderation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/moderation/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/moderation/infraction/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/moderation/infraction/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/moderation/test_modlog.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import discord 4 | 5 | from bot.exts.moderation.modlog import ModLog 6 | from bot.utils.modlog import send_log_message 7 | from tests.helpers import MockBot, MockTextChannel 8 | 9 | 10 | class ModLogTests(unittest.IsolatedAsyncioTestCase): 11 | """Tests for moderation logs.""" 12 | 13 | def setUp(self): 14 | self.bot = MockBot() 15 | self.cog = ModLog(self.bot) 16 | self.channel = MockTextChannel() 17 | 18 | async def test_log_entry_description_truncation(self): 19 | """Test that embed description for ModLog entry is truncated.""" 20 | self.bot.get_channel.return_value = self.channel 21 | await send_log_message( 22 | self.bot, 23 | icon_url="foo", 24 | colour=discord.Colour.blue(), 25 | title="bar", 26 | text="foo bar" * 3000 27 | ) 28 | embed = self.channel.send.call_args[1]["embed"] 29 | self.assertEqual( 30 | embed.description, ("foo bar" * 3000)[:4093] + "..." 31 | ) 32 | -------------------------------------------------------------------------------- /tests/bot/exts/recruitment/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/recruitment/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/utils/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/utils/snekbox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/exts/utils/snekbox/__init__.py -------------------------------------------------------------------------------- /tests/bot/exts/utils/snekbox/test_io.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | # noinspection PyProtectedMember 4 | from bot.exts.utils.snekbox import _io 5 | 6 | 7 | class SnekboxIOTests(TestCase): 8 | # noinspection SpellCheckingInspection 9 | def test_normalize_file_name(self): 10 | """Invalid file names should be normalized.""" 11 | cases = [ 12 | # ANSI escape sequences -> underscore 13 | (r"\u001b[31mText", "_Text"), 14 | # (Multiple consecutive should be collapsed to one underscore) 15 | (r"a\u001b[35m\u001b[37mb", "a_b"), 16 | # Backslash escaped chars -> underscore 17 | (r"\n", "_"), 18 | (r"\r", "_"), 19 | (r"A\0\tB", "A__B"), 20 | # Any other disallowed chars -> underscore 21 | (r"\\.txt", "_.txt"), 22 | (r"A!@#$%^&*B, C()[]{}+=D.txt", "A_B_C_D.txt"), 23 | (" ", "_"), 24 | # Normal file names should be unchanged 25 | ("legal_file-name.txt", "legal_file-name.txt"), 26 | ("_-.", "_-."), 27 | ] 28 | for name, expected in cases: 29 | with self.subTest(name=name, expected=expected): 30 | # Test function directly 31 | self.assertEqual(_io.normalize_discord_file_name(name), expected) 32 | # Test FileAttachment.to_file() 33 | obj = _io.FileAttachment(name, b"") 34 | self.assertEqual(obj.to_file().filename, expected) 35 | -------------------------------------------------------------------------------- /tests/bot/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/resources/__init__.py -------------------------------------------------------------------------------- /tests/bot/resources/test_resources.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from pathlib import Path 4 | 5 | 6 | class ResourceValidationTests(unittest.TestCase): 7 | """Validates resources used by the bot.""" 8 | def test_stars_valid(self): 9 | """The resource `bot/resources/stars.json` should contain a list of strings.""" 10 | path = Path("bot", "resources", "stars.json") 11 | content = path.read_text() 12 | data = json.loads(content) 13 | 14 | self.assertIsInstance(data, list) 15 | for name in data: 16 | with self.subTest(name=name): 17 | self.assertIsInstance(name, str) 18 | -------------------------------------------------------------------------------- /tests/bot/test_constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from unittest import TestCase, mock 4 | 5 | from pydantic import BaseModel 6 | 7 | from bot.constants import EnvConfig 8 | 9 | current_path = Path(__file__) 10 | env_file_path = current_path.parent / ".testenv" 11 | 12 | 13 | class _TestEnvConfig( 14 | EnvConfig, 15 | env_file=env_file_path, 16 | ): 17 | """Our default configuration for models that should load from .env files.""" 18 | 19 | 20 | class NestedModel(BaseModel): 21 | server_name: str 22 | 23 | 24 | class _TestConfig(_TestEnvConfig, env_prefix="unittests_"): 25 | 26 | goat: str 27 | execution_env: str = "local" 28 | nested: NestedModel 29 | 30 | 31 | class ConstantsTests(TestCase): 32 | """Tests for our constants.""" 33 | 34 | @mock.patch.dict(os.environ, {"UNITTESTS_EXECUTION_ENV": "production"}) 35 | def test_section_configuration_matches_type_specification(self): 36 | """"The section annotations should match the actual types of the sections.""" 37 | 38 | testconfig = _TestConfig() 39 | self.assertEqual("volcyy", testconfig.goat) 40 | self.assertEqual("pydis", testconfig.nested.server_name) 41 | self.assertEqual("production", testconfig.execution_env) 42 | -------------------------------------------------------------------------------- /tests/bot/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/bot/27bf2fd7205fe56c21625257a846d6e3f6c5a689/tests/bot/utils/__init__.py -------------------------------------------------------------------------------- /tests/bot/utils/test_messages.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from bot.utils import messages 4 | 5 | 6 | class TestMessages(unittest.TestCase): 7 | """Tests for functions in the `bot.utils.messages` module.""" 8 | 9 | def test_sub_clyde(self): 10 | """Uppercase E's and lowercase e's are substituted with their cyrillic counterparts.""" 11 | sub_e = "\u0435" 12 | sub_E = "\u0415" # noqa: N806: Uppercase E in variable name 13 | 14 | test_cases = ( 15 | (None, None), 16 | ("", ""), 17 | ("clyde", f"clyd{sub_e}"), 18 | ("CLYDE", f"CLYD{sub_E}"), 19 | ("cLyDe", f"cLyD{sub_e}"), 20 | ("BIGclyde", f"BIGclyd{sub_e}"), 21 | ("small clydeus the unholy", f"small clyd{sub_e}us the unholy"), 22 | ("BIGCLYDE, babyclyde", f"BIGCLYD{sub_E}, babyclyd{sub_e}"), 23 | ) 24 | 25 | for username_in, username_out in test_cases: 26 | with self.subTest(input=username_in, expected_output=username_out): 27 | self.assertEqual(messages.sub_clyde(username_in), username_out) 28 | --------------------------------------------------------------------------------