├── .dockerignore ├── .gitattributes ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ └── feature.md ├── dependabot.yml ├── pull_request_template.md ├── review-policy.yml └── workflows │ ├── build-deploy.yaml │ ├── lint.yaml │ ├── main.yaml │ ├── sentry_release.yaml │ └── status_embed.yaml ├── .gitignore ├── .gitpod.yml ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── bot ├── __init__.py ├── __main__.py ├── bot.py ├── constants.py ├── exts │ ├── __init__.py │ ├── avatar_modification │ │ ├── __init__.py │ │ ├── _effects.py │ │ └── avatar_modify.py │ ├── core │ │ ├── __init__.py │ │ ├── error_handler.py │ │ ├── extensions.py │ │ ├── help.py │ │ ├── internal_eval │ │ │ ├── __init__.py │ │ │ ├── _helpers.py │ │ │ └── _internal_eval.py │ │ ├── ping.py │ │ └── source.py │ ├── events │ │ ├── __init__.py │ │ ├── hacktoberfest │ │ │ ├── __init__.py │ │ │ ├── hacktober_issue_finder.py │ │ │ ├── hacktoberstats.py │ │ │ └── timeleft.py │ │ └── trivianight │ │ │ ├── __init__.py │ │ │ ├── _game.py │ │ │ ├── _questions.py │ │ │ ├── _scoreboard.py │ │ │ └── trivianight.py │ ├── fun │ │ ├── __init__.py │ │ ├── anagram.py │ │ ├── battleship.py │ │ ├── catify.py │ │ ├── coinflip.py │ │ ├── connect_four.py │ │ ├── duck_game.py │ │ ├── fun.py │ │ ├── game.py │ │ ├── hangman.py │ │ ├── latex.py │ │ ├── madlibs.py │ │ ├── magic_8ball.py │ │ ├── minesweeper.py │ │ ├── movie.py │ │ ├── quack.py │ │ ├── recommend_game.py │ │ ├── rps.py │ │ ├── snakes │ │ │ ├── __init__.py │ │ │ ├── _converter.py │ │ │ ├── _snakes_cog.py │ │ │ └── _utils.py │ │ ├── space.py │ │ ├── speedrun.py │ │ ├── status_codes.py │ │ ├── tic_tac_toe.py │ │ ├── trivia_quiz.py │ │ ├── uwu.py │ │ ├── wonder_twins.py │ │ └── xkcd.py │ ├── holidays │ │ ├── __init__.py │ │ ├── earth_day │ │ │ ├── __init__.py │ │ │ └── save_the_planet.py │ │ ├── easter │ │ │ ├── __init__.py │ │ │ ├── april_fools_vids.py │ │ │ ├── bunny_name_generator.py │ │ │ ├── earth_photos.py │ │ │ ├── easter_riddle.py │ │ │ ├── egg_decorating.py │ │ │ ├── egg_facts.py │ │ │ ├── egghead_quiz.py │ │ │ └── traditions.py │ │ ├── halloween │ │ │ ├── __init__.py │ │ │ ├── candy_collection.py │ │ │ ├── eight_ball.py │ │ │ ├── halloween_facts.py │ │ │ ├── halloweenify.py │ │ │ ├── monsterbio.py │ │ │ ├── monstersurvey.py │ │ │ ├── scarymovie.py │ │ │ ├── spookygif.py │ │ │ ├── spookynamerate.py │ │ │ └── spookyrating.py │ │ ├── hanukkah │ │ │ ├── __init__.py │ │ │ └── hanukkah_embed.py │ │ ├── holidayreact.py │ │ ├── pride │ │ │ ├── __init__.py │ │ │ ├── drag_queen_name.py │ │ │ ├── pride_anthem.py │ │ │ ├── pride_facts.py │ │ │ └── pride_leader.py │ │ └── valentines │ │ │ ├── __init__.py │ │ │ ├── be_my_valentine.py │ │ │ ├── lovecalculator.py │ │ │ ├── movie_generator.py │ │ │ ├── myvalenstate.py │ │ │ ├── pickuplines.py │ │ │ ├── savethedate.py │ │ │ ├── valentine_zodiac.py │ │ │ └── whoisvalentine.py │ └── utilities │ │ ├── __init__.py │ │ ├── bookmark.py │ │ ├── challenges.py │ │ ├── cheatsheet.py │ │ ├── colour.py │ │ ├── conversationstarters.py │ │ ├── emoji.py │ │ ├── epoch.py │ │ ├── githubinfo.py │ │ ├── logging.py │ │ ├── pythonfacts.py │ │ ├── realpython.py │ │ ├── reddit.py │ │ ├── rfc.py │ │ ├── stackoverflow.py │ │ ├── timed.py │ │ ├── twemoji.py │ │ ├── wikipedia.py │ │ ├── wolfram.py │ │ └── wtf_python.py ├── log.py ├── resources │ ├── fun │ │ ├── LuckiestGuy-Regular.ttf │ │ ├── all_cards.png │ │ ├── anagram.json │ │ ├── caesar_info.json │ │ ├── ducks_help_ex.png │ │ ├── game_recs │ │ │ ├── chrono_trigger.json │ │ │ ├── digimon_world.json │ │ │ ├── doom_2.json │ │ │ └── skyrim.json │ │ ├── hangman_words.txt │ │ ├── html_colours.json │ │ ├── latex_template.txt │ │ ├── madlibs_templates.json │ │ ├── magic8ball.json │ │ ├── snakes │ │ │ ├── snake_cards │ │ │ │ ├── backs │ │ │ │ │ ├── card_back1.jpg │ │ │ │ │ └── card_back2.jpg │ │ │ │ ├── card_bottom.png │ │ │ │ ├── card_frame.png │ │ │ │ ├── card_top.png │ │ │ │ └── expressway.ttf │ │ │ ├── snake_facts.json │ │ │ ├── snake_idioms.json │ │ │ ├── snake_names.json │ │ │ ├── snake_quiz.json │ │ │ ├── snakes_and_ladders │ │ │ │ ├── banner.jpg │ │ │ │ └── board.jpg │ │ │ └── special_snakes.json │ │ ├── speedrun_links.json │ │ ├── trivia_quiz.json │ │ ├── wonder_twins.yaml │ │ └── xkcd_colours.json │ ├── holidays │ │ ├── earth_day │ │ │ └── save_the_planet.json │ │ ├── easter │ │ │ ├── april_fools_vids.json │ │ │ ├── bunny_names.json │ │ │ ├── chocolate_bunny.png │ │ │ ├── easter_egg_facts.json │ │ │ ├── easter_eggs │ │ │ │ ├── design1.png │ │ │ │ ├── design2.png │ │ │ │ ├── design3.png │ │ │ │ ├── design4.png │ │ │ │ ├── design5.png │ │ │ │ └── design6.png │ │ │ ├── easter_riddle.json │ │ │ ├── egghead_questions.json │ │ │ └── traditions.json │ │ ├── halloween │ │ │ ├── bat-clipart.png │ │ │ ├── bloody-pentagram.png │ │ │ ├── halloween_facts.json │ │ │ ├── halloweenify.json │ │ │ ├── monster.json │ │ │ ├── monstersurvey.json │ │ │ ├── responses.json │ │ │ ├── spooky_rating.json │ │ │ ├── spookynamerate_names.json │ │ │ └── spookyrating │ │ │ │ ├── baby.jpeg │ │ │ │ ├── candle.jpeg │ │ │ │ ├── clown.jpeg │ │ │ │ ├── costume.jpeg │ │ │ │ ├── devil.jpeg │ │ │ │ ├── ghost.jpeg │ │ │ │ ├── jackolantern.jpeg │ │ │ │ ├── necromancer.jpeg │ │ │ │ └── tiger.jpeg │ │ ├── pride │ │ │ ├── anthems.json │ │ │ ├── drag_queen_names.json │ │ │ ├── facts.json │ │ │ ├── flags │ │ │ │ ├── agender.png │ │ │ │ ├── androgyne.png │ │ │ │ ├── aromantic.png │ │ │ │ ├── asexual.png │ │ │ │ ├── bigender.png │ │ │ │ ├── bisexual.png │ │ │ │ ├── demiboy.png │ │ │ │ ├── demigirl.png │ │ │ │ ├── demisexual.png │ │ │ │ ├── gay.png │ │ │ │ ├── genderfluid.png │ │ │ │ ├── genderqueer.png │ │ │ │ ├── intersex.png │ │ │ │ ├── lesbian.png │ │ │ │ ├── nonbinary.png │ │ │ │ ├── omnisexual.png │ │ │ │ ├── pangender.png │ │ │ │ ├── pansexual.png │ │ │ │ ├── polyamory.png │ │ │ │ ├── polysexual.png │ │ │ │ ├── transgender.png │ │ │ │ └── trigender.png │ │ │ ├── gender_options.json │ │ │ └── prideleader.json │ │ └── valentines │ │ │ ├── bemyvalentine_valentines.json │ │ │ ├── date_ideas.json │ │ │ ├── love_matches.json │ │ │ ├── pickup_lines.json │ │ │ ├── valenstates.json │ │ │ ├── valentine_facts.json │ │ │ ├── zodiac_compatibility.json │ │ │ └── zodiac_explanation.json │ └── utilities │ │ ├── py_topics.yaml │ │ ├── python_facts.txt │ │ ├── ryanzec_colours.json │ │ ├── starter.yaml │ │ └── wtf_python_logo.jpg └── utils │ ├── __init__.py │ ├── checks.py │ ├── commands.py │ ├── converters.py │ ├── decorators.py │ ├── exceptions.py │ ├── halloween │ ├── __init__.py │ └── spookifications.py │ ├── helpers.py │ ├── messages.py │ ├── pagination.py │ ├── randomization.py │ └── time.py ├── docker-compose.yml ├── poetry.lock ├── pyproject.toml └── sir-lancebot-logo.png /.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude everything 2 | * 3 | 4 | # Make exceptions for what's needed 5 | !bot 6 | !pyproject.toml 7 | !poetry.lock 8 | !LICENSE 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.png binary 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/.github/CODEOWNERS -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: python_discord 2 | custom: https://www.redbubble.com/people/pythondiscord 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Issues for reporting bugs with the bot. 4 | title: '' 5 | labels: 'type: bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | 13 | 14 | ## Steps to Reproduce 15 | 16 | 17 | 18 | ## Expected Behaviour 19 | 20 | 21 | 22 | ## Actual Behaviour 23 | 24 | 25 | 26 | ## Known Impacted Platforms 27 | 28 | 29 | - [ ] Web 30 | - [ ] Desktop 31 | - [ ] Android App 32 | - [ ] iOS App 33 | 34 | ## Possible Solutions 35 | 36 | 37 | 38 | ## Additional Details 39 | 40 | 41 | 42 | ## Would you like to implement a fix? 43 | 44 | ***Note: For high-priority or critical bugs, fixes may be implemented by staff.*** 45 | 46 | 47 | - [ ] I'd like to implement the bug fix 48 | - [ ] Anyone can implement the bug fix 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Python Discord Community 4 | url: https://discord.gg/python 5 | about: Contributors must be part of the community, so be sure to join! 6 | - name: Contributing Guide 7 | url: https://pythondiscord.com/pages/contributing/sir-lancebot/ 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Issues for requesting feature changes or additions. 4 | title: '' 5 | labels: 'status: planning, type: feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | 13 | 14 | ## Reasoning 15 | 16 | 17 | 18 | ## Proposed Implementation 19 | 20 | 21 | 22 | ## Additional Details 23 | 24 | 25 | 26 | 27 | ## Would you like to implement this yourself? 28 | 29 | 30 | - [ ] I'd like to implement this feature myself 31 | - [ ] Anyone can implement this feature 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | reviewers: 12 | - "python-discord/devops" 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Relevant Issues 2 | 6 | 7 | 8 | 9 | 10 | ## Description 11 | 12 | 13 | ## Did you: 14 | 15 | 16 | 17 | - [ ] Join the [**Python Discord Community**](https://discord.gg/python)? 18 | - [ ] Read all the comments in this template? 19 | - [ ] Ensure there is an issue open, or link relevant discord discussions? 20 | - [ ] Read and agree to the [contributing guidelines](https://pythondiscord.com/pages/contributing/contributing-guidelines/)? 21 | -------------------------------------------------------------------------------- /.github/review-policy.yml: -------------------------------------------------------------------------------- 1 | remote: python-discord/.github 2 | path: review-policies/core-developers.yml 3 | ref: main 4 | -------------------------------------------------------------------------------- /.github/workflows/build-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | sha-tag: 7 | description: "A short-form SHA tag for the commit that triggered this flow" 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | build: 13 | name: Build & Push 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | 18 | # Check out the current repository in the `sir-lancebot` subdirectory 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Login to Github Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | # Build and push the container to the GitHub Container 33 | # Repository. The container will be tagged as "latest" 34 | # and with the short SHA of the commit. 35 | - name: Build and push 36 | uses: docker/build-push-action@v6 37 | with: 38 | context: . 39 | file: ./Dockerfile 40 | push: true 41 | cache-from: type=registry,ref=ghcr.io/python-discord/sir-lancebot:latest 42 | cache-to: type=inline 43 | tags: | 44 | ghcr.io/python-discord/sir-lancebot:latest 45 | ghcr.io/python-discord/sir-lancebot:${{ inputs.sha-tag }} 46 | build-args: | 47 | git_sha=${{ github.sha }} 48 | 49 | deploy: 50 | needs: build 51 | name: Deploy 52 | runs-on: ubuntu-latest 53 | environment: production 54 | 55 | steps: 56 | - name: Checkout Kubernetes Repository 57 | uses: actions/checkout@v4 58 | with: 59 | repository: python-discord/infra 60 | path: infra 61 | 62 | - uses: azure/setup-kubectl@v4 63 | 64 | - name: Authenticate with Kubernetes 65 | uses: azure/k8s-set-context@v4 66 | with: 67 | method: kubeconfig 68 | kubeconfig: ${{ secrets.KUBECONFIG }} 69 | 70 | - name: Deploy to Kubernetes 71 | uses: Azure/k8s-deploy@v5 72 | with: 73 | namespace: bots 74 | manifests: | 75 | infra/kubernetes/namespaces/bots/sir-lancebot/deployment.yaml 76 | images: 'ghcr.io/python-discord/sir-lancebot:${{ inputs.sha-tag }}' 77 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | lint: 7 | name: Run linting & tests 8 | runs-on: ubuntu-latest 9 | env: 10 | # List of licenses that are compatible with the MIT License and 11 | # can be used in our project 12 | ALLOWED_LICENSES: Apache Software License; 13 | BSD; BSD License; 14 | GNU Library or Lesser General Public License (LGPL); 15 | Historical Permission Notice and Disclaimer (HPND); 16 | ISC License (ISCL); 17 | MIT License; 18 | Mozilla Public License 2.0 (MPL 2.0); 19 | Public Domain; 20 | Python Software Foundation License 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Install Python Dependencies 27 | uses: HassanAbouelela/actions/setup-python@setup-python_v1.6.0 28 | with: 29 | python_version: "3.13" 30 | 31 | # Check all of our dev dependencies are compatible with the MIT license. 32 | # If you added a new dependencies that is being rejected, 33 | # please make sure it is compatible with the license for this project, 34 | # and add it to the ALLOWED_LICENSE variable 35 | - name: Check Dependencies License 36 | run: | 37 | poetry self add poetry-plugin-export 38 | pip-licenses --allow-only="$ALLOWED_LICENSE" \ 39 | --package $(poetry export -f requirements.txt --without-hashes | sed "s/==.*//g" | tr "\n" " ") 40 | 41 | # Attempt to run the bot. Setting `IN_CI` to true, so bot.run() is never called. 42 | # This is to catch import and cog setup errors that may appear in PRs, to avoid crash loops if merged. 43 | - name: Attempt bot setup 44 | run: "python -m bot" 45 | env: 46 | REDIS_USE_FAKEREDIS: true 47 | CLIENT_IN_CI: true 48 | CLIENT_TOKEN: "" 49 | 50 | - name: Run pre-commit hooks 51 | run: SKIP=ruff pre-commit run --all-files 52 | 53 | # Run `ruff` using github formatting to enable automatic inline annotations. 54 | - name: Run ruff 55 | run: "ruff check --output-format=github ." 56 | 57 | # Prepare the Pull Request Payload artifact. If this fails, we 58 | # we fail silently using the `continue-on-error` option. It's 59 | # nice if this succeeds, but if it fails for any reason, it 60 | # does not mean that our lint checks failed. 61 | - name: Prepare Pull Request Payload artifact 62 | id: prepare-artifact 63 | if: always() && github.event_name == 'pull_request' 64 | continue-on-error: true 65 | run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json 66 | 67 | # This only makes sense if the previous step succeeded. To 68 | # get the original outcome of the previous step before the 69 | # `continue-on-error` conclusion is applied, we use the 70 | # `.outcome` value. This step also fails silently. 71 | - name: Upload a Build Artifact 72 | if: always() && steps.prepare-artifact.outcome == 'success' 73 | continue-on-error: true 74 | uses: actions/upload-artifact@v4 75 | with: 76 | name: pull-request-payload 77 | path: pull_request_payload.json 78 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | 14 | lint: 15 | uses: ./.github/workflows/lint.yaml 16 | 17 | generate-inputs: 18 | if: github.ref == 'refs/heads/main' 19 | runs-on: ubuntu-latest 20 | outputs: 21 | sha-tag: ${{ steps.sha-tag.outputs.sha-tag }} 22 | steps: 23 | - name: Create SHA Container Tag 24 | id: sha-tag 25 | run: | 26 | tag=$(cut -c 1-7 <<< $GITHUB_SHA) 27 | echo "sha-tag=$tag" >> $GITHUB_OUTPUT 28 | 29 | build-deploy: 30 | if: github.ref == 'refs/heads/main' 31 | uses: ./.github/workflows/build-deploy.yaml 32 | needs: 33 | - lint 34 | - generate-inputs 35 | with: 36 | sha-tag: ${{ needs.generate-inputs.outputs.sha-tag }} 37 | secrets: inherit 38 | 39 | sentry-release: 40 | if: github.ref == 'refs/heads/main' 41 | uses: ./.github/workflows/sentry_release.yaml 42 | needs: build-deploy 43 | secrets: inherit 44 | -------------------------------------------------------------------------------- /.github/workflows/sentry_release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Sentry release 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | create_sentry_release: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | 12 | - name: Create a Sentry.io release 13 | uses: getsentry/action-release@v1 14 | env: 15 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 16 | SENTRY_ORG: python-discord 17 | SENTRY_PROJECT: sir-lancebot 18 | with: 19 | environment: production 20 | version_prefix: sir-lancebot@ 21 | -------------------------------------------------------------------------------- /.github/workflows/status_embed.yaml: -------------------------------------------------------------------------------- 1 | name: Status Embed 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - CI 7 | types: 8 | - completed 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | status_embed: 16 | name: Send Status Embed to Discord 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | # A workflow_run event does not contain all the information 21 | # we need for a PR embed. That's why we upload an artifact 22 | # with that information in the Lint workflow. 23 | - name: Get Pull Request Information 24 | id: pr_info 25 | if: github.event.workflow_run.event == 'pull_request' 26 | run: | 27 | curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json 28 | DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') 29 | [ -z "$DOWNLOAD_URL" ] && exit 1 30 | curl -sSL -H "Authorization: token $GITHUB_TOKEN" -o pull_request_payload.zip $DOWNLOAD_URL || exit 2 31 | unzip -p pull_request_payload.zip > pull_request_payload.json 32 | [ -s pull_request_payload.json ] || exit 3 33 | echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 34 | echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 35 | echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 36 | echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | # Send an informational status embed to Discord instead of the 41 | # standard embeds that Discord sends. This embed will contain 42 | # more information and we can fine tune when we actually want 43 | # to send an embed. 44 | - name: GitHub Actions Status Embed for Discord 45 | uses: SebastiaanZ/github-status-embed-for-discord@v0.3.0 46 | with: 47 | # Our GitHub Actions webhook 48 | webhook_id: '784184528997842985' 49 | webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} 50 | 51 | # We need to provide the information of the workflow that 52 | # triggered this workflow instead of this workflow. 53 | workflow_name: ${{ github.event.workflow_run.name }} 54 | run_id: ${{ github.event.workflow_run.id }} 55 | run_number: ${{ github.event.workflow_run.run_number }} 56 | status: ${{ github.event.workflow_run.conclusion }} 57 | sha: ${{ github.event.workflow_run.head_sha }} 58 | 59 | pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} 60 | pr_number: ${{ steps.pr_info.outputs.pr_number }} 61 | pr_title: ${{ steps.pr_info.outputs.pr_title }} 62 | pr_source: ${{ steps.pr_info.outputs.pr_source }} 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # bot (project-specific) 2 | log/* 3 | data/* 4 | bot/exts/fun/_latex_cache/* 5 | 6 | 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | 113 | # jetbrains 114 | .idea/ 115 | .DS_Store 116 | 117 | # vscode 118 | .vscode/ 119 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: "Python Environment" 3 | before: "pyenv install 3.13 && pyenv global 3.13" 4 | init: "pip install poetry" 5 | command: "export PIP_USER=false && poetry install && poetry run pre-commit install" 6 | -------------------------------------------------------------------------------- /.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 | 12 | - repo: local 13 | hooks: 14 | - id: ruff 15 | name: ruff 16 | description: Run ruff linting 17 | entry: poetry run ruff check --force-exclude 18 | language: system 19 | 'types_or': [python, pyi] 20 | require_serial: true 21 | args: [--fix, --exit-non-zero-on-fix] 22 | -------------------------------------------------------------------------------- /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.13-slim 2 | 3 | # Install dependencies 4 | WORKDIR /bot 5 | COPY pyproject.toml poetry.lock ./ 6 | RUN poetry install --only main 7 | 8 | # Set SHA build argument 9 | ARG git_sha="development" 10 | ENV GIT_SHA=$git_sha 11 | 12 | # Copy the rest of the project code 13 | COPY . . 14 | 15 | # Start the bot 16 | ENTRYPOINT ["poetry", "run"] 17 | CMD ["python", "-m", "bot"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 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 | # Sir Lancebot 2 | 3 | [![Discord][3]][4] 4 | [![CI Badge][1]][2] 5 | [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) 6 | [![Open in Gitpod](https://img.shields.io/badge/Gitpod-ready--to--code-908a85?logo=gitpod)](https://gitpod.io/#/github.com/python-discord/sir-lancebot) 7 | 8 | ![Header](sir-lancebot-logo.png) 9 | 10 | A Discord bot built by the Python Discord community, for the Python Discord community. 11 | 12 | You can find our community by going to https://discord.gg/python 13 | 14 | ## Motivations 15 | 16 | We know it can be difficult to get into the whole open source thing at first. To help out, we started the HacktoberBot community project during [Hacktoberfest 2018](https://hacktoberfest.digitalocean.com) to help introduce and encourage members to participate in contributing to open source, providing a calmer and helpful environment for those who want to be part of it. 17 | 18 | This later evolved into a bot designed as a fun and beginner-friendly learning environment for writing bot features and learning open-source. 19 | 20 | ## Getting started 21 | Before you start, please take some time to read through our [contributing guidelines](https://pythondiscord.com/pages/guides/pydis-guides/contributing/contributing-guidelines/). 22 | 23 | See [Sir Lancebot's Wiki](https://pythondiscord.com/pages/contributing/sir-lancebot/) for in-depth guides on getting started with the project! 24 | 25 | [1]:https://github.com/python-discord/sir-lancebot/workflows/CI/badge.svg?branch=main 26 | [2]:https://github.com/python-discord/sir-lancebot/actions?query=workflow%3ACI+branch%3Amain 27 | [3]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg 28 | [4]: https://discord.gg/python 29 | -------------------------------------------------------------------------------- /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 | import arrow 6 | from pydis_core.utils import apply_monkey_patches 7 | 8 | from bot import log 9 | 10 | if TYPE_CHECKING: 11 | from bot.bot import Bot 12 | 13 | log.setup() 14 | 15 | # Set timestamp of when execution started (approximately) 16 | start_time = arrow.utcnow() 17 | 18 | # On Windows, the selector event loop is required for aiodns. 19 | if os.name == "nt": 20 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 21 | 22 | apply_monkey_patches() 23 | 24 | instance: "Bot" = None # Global Bot instance. 25 | -------------------------------------------------------------------------------- /bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | import discord 5 | from async_rediscache import RedisSession 6 | from discord.ext import commands 7 | from pydis_core import StartupError 8 | from pydis_core.utils.logging import get_logger 9 | from redis import RedisError 10 | 11 | import bot 12 | from bot import constants 13 | from bot.bot import Bot 14 | from bot.log import setup_sentry 15 | from bot.utils.decorators import whitelist_check 16 | 17 | log = get_logger(__name__) 18 | setup_sentry() 19 | 20 | 21 | async def _create_redis_session() -> RedisSession: 22 | """Create and connect to a redis session.""" 23 | redis_session = RedisSession( 24 | host=constants.Redis.host, 25 | port=constants.Redis.port, 26 | password=constants.Redis.password.get_secret_value(), 27 | max_connections=20, 28 | use_fakeredis=constants.Redis.use_fakeredis, 29 | global_namespace="bot", 30 | decode_responses=True, 31 | ) 32 | try: 33 | return await redis_session.connect() 34 | except RedisError as e: 35 | raise StartupError(e) 36 | 37 | 38 | async def test_bot_in_ci(bot: Bot) -> None: 39 | """ 40 | Attempt to import all extensions and then return. 41 | 42 | This is to ensure that all extensions can at least be 43 | imported and have a setup function within our CI. 44 | """ 45 | from pydis_core.utils._extensions import walk_extensions 46 | 47 | from bot import exts 48 | 49 | for _ in walk_extensions(exts): 50 | # walk_extensions does all the heavy lifting within the generator. 51 | pass 52 | 53 | 54 | async def main() -> None: 55 | """Entry async method for starting the bot.""" 56 | allowed_roles = list({discord.Object(id_) for id_ in constants.MODERATION_ROLES}) 57 | intents = discord.Intents.default() 58 | intents.bans = False 59 | intents.integrations = False 60 | intents.invites = False 61 | intents.message_content = True 62 | intents.typing = False 63 | intents.webhooks = False 64 | 65 | async with aiohttp.ClientSession() as session: 66 | bot.instance = Bot( 67 | guild_id=constants.Client.guild, 68 | http_session=session, 69 | redis_session=await _create_redis_session(), 70 | command_prefix=commands.when_mentioned_or(constants.Client.prefix), 71 | activity=discord.Game(name=f"Commands: {constants.Client.prefix}help"), 72 | case_insensitive=True, 73 | allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), 74 | intents=intents, 75 | allowed_roles=allowed_roles, 76 | ) 77 | 78 | async with bot.instance as _bot: 79 | _bot.add_check(whitelist_check( 80 | channels=constants.WHITELISTED_CHANNELS, 81 | roles=constants.STAFF_ROLES, 82 | )) 83 | if constants.Client.in_ci: 84 | await test_bot_in_ci(_bot) 85 | else: 86 | await _bot.start(constants.Client.token.get_secret_value()) 87 | 88 | 89 | asyncio.run(main()) 90 | -------------------------------------------------------------------------------- /bot/bot.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord import DiscordException, Embed 3 | from discord.ext import commands 4 | from pydis_core import BotBase 5 | from pydis_core.utils import scheduling 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot import constants, exts 9 | 10 | log = get_logger(__name__) 11 | 12 | __all__ = ("Bot", ) 13 | 14 | 15 | class Bot(BotBase): 16 | """ 17 | Base bot instance. 18 | 19 | While in debug mode, the asset upload methods (avatar, banner, ...) will not 20 | perform the upload, and will instead only log the passed download urls and pretend 21 | that the upload was successful. See the `mock_in_debug` decorator for further details. 22 | """ 23 | 24 | name = constants.Client.name 25 | 26 | @property 27 | def member(self) -> discord.Member | None: 28 | """Retrieves the guild member object for the bot.""" 29 | guild = self.get_guild(constants.Client.guild) 30 | if not guild: 31 | return None 32 | return guild.me 33 | 34 | async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None: 35 | """Check command errors for UserInputError and reset the cooldown if thrown.""" 36 | if isinstance(exception, commands.UserInputError): 37 | context.command.reset_cooldown(context) 38 | else: 39 | await super().on_command_error(context, exception) 40 | 41 | async def log_to_dev_log(self, title: str, details: str | None = None, *, icon: str | None = None) -> None: 42 | """Send an embed message to the dev-log channel.""" 43 | devlog = self.get_channel(constants.Channels.devlog) 44 | 45 | if not icon: 46 | icon = self.user.display_avatar.url 47 | 48 | embed = Embed(description=details) 49 | embed.set_author(name=title, icon_url=icon) 50 | 51 | await devlog.send(embed=embed) 52 | 53 | async def setup_hook(self) -> None: 54 | """Default async initialisation method for discord.py.""" 55 | await super().setup_hook() 56 | 57 | # This is not awaited to avoid a deadlock with any cogs that have 58 | # wait_until_guild_available in their cog_load method. 59 | scheduling.create_task(self.load_extensions(exts)) 60 | 61 | async def invoke_help_command(self, ctx: commands.Context) -> None: 62 | """Invoke the help command or default help command if help extensions is not loaded.""" 63 | if "bot.exts.core.help" in ctx.bot.extensions: 64 | help_command = ctx.bot.get_command("help") 65 | await ctx.invoke(help_command, ctx.command.qualified_name) 66 | return 67 | await ctx.send_help(ctx.command) 68 | -------------------------------------------------------------------------------- /bot/exts/__init__.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | from collections.abc import Iterator 3 | 4 | from pydis_core.utils.logging import get_logger 5 | 6 | __all__ = ("get_package_names",) 7 | 8 | log = get_logger(__name__) 9 | 10 | 11 | def get_package_names() -> Iterator[str]: 12 | """Iterate names of all packages located in /bot/exts/.""" 13 | for package in pkgutil.iter_modules(__path__): 14 | if package.ispkg: 15 | yield package.name 16 | -------------------------------------------------------------------------------- /bot/exts/avatar_modification/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/avatar_modification/__init__.py -------------------------------------------------------------------------------- /bot/exts/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/core/__init__.py -------------------------------------------------------------------------------- /bot/exts/core/internal_eval/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.bot import Bot 2 | 3 | 4 | async def setup(bot: Bot) -> None: 5 | """Set up the Internal Eval extension.""" 6 | # Import the Cog at runtime to prevent side effects like defining 7 | # RedisCache instances too early. 8 | from ._internal_eval import InternalEval 9 | 10 | await bot.add_cog(InternalEval(bot)) 11 | -------------------------------------------------------------------------------- /bot/exts/core/ping.py: -------------------------------------------------------------------------------- 1 | import arrow 2 | from dateutil.relativedelta import relativedelta 3 | from discord import Embed 4 | from discord.ext import commands 5 | 6 | from bot import start_time 7 | from bot.bot import Bot 8 | from bot.constants import Colours 9 | 10 | 11 | class Ping(commands.Cog): 12 | """Get info about the bot's ping and uptime.""" 13 | 14 | def __init__(self, bot: Bot): 15 | self.bot = bot 16 | 17 | @commands.command(name="ping") 18 | async def ping(self, ctx: commands.Context) -> None: 19 | """Ping the bot to see its latency and state.""" 20 | embed = Embed( 21 | title=":ping_pong: Pong!", 22 | colour=Colours.bright_green, 23 | description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms", 24 | ) 25 | 26 | await ctx.send(embed=embed) 27 | 28 | # Originally made in 70d2170a0a6594561d59c7d080c4280f1ebcd70b by lemon & gdude2002 29 | @commands.command(name="uptime") 30 | async def uptime(self, ctx: commands.Context) -> None: 31 | """Get the current uptime of the bot.""" 32 | difference = relativedelta(start_time - arrow.utcnow()) 33 | uptime_string = start_time.shift( 34 | seconds=-difference.seconds, 35 | minutes=-difference.minutes, 36 | hours=-difference.hours, 37 | days=-difference.days 38 | ).humanize() 39 | 40 | await ctx.send(f"I started up {uptime_string}.") 41 | 42 | 43 | async def setup(bot: Bot) -> None: 44 | """Load the Ping cog.""" 45 | await bot.add_cog(Ping(bot)) 46 | -------------------------------------------------------------------------------- /bot/exts/core/source.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from pathlib import Path 3 | 4 | from discord import Embed 5 | from discord.ext import commands 6 | 7 | from bot.bot import Bot 8 | from bot.constants import Channels, WHITELISTED_CHANNELS 9 | from bot.utils.converters import SourceConverter, SourceType 10 | from bot.utils.decorators import whitelist_override 11 | 12 | GITHUB_BOT_URL = "https://github.com/python-discord/sir-lancebot" 13 | BOT_AVATAR_URL = "https://avatars1.githubusercontent.com/u/9919" 14 | 15 | 16 | class BotSource(commands.Cog): 17 | """Displays information about the bot's source code.""" 18 | 19 | @commands.command(name="source", aliases=("src",)) 20 | @whitelist_override(channels=WHITELISTED_CHANNELS+(Channels.community_meta, Channels.dev_contrib)) 21 | async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: 22 | """Display information and a GitHub link to the source code of a command, tag, or cog.""" 23 | if not source_item: 24 | embed = Embed(title="Sir Lancebot's GitHub Repository") 25 | embed.add_field(name="Repository", value=f"[Go to GitHub]({GITHUB_BOT_URL})") 26 | embed.set_thumbnail(url=BOT_AVATAR_URL) 27 | await ctx.send(embed=embed) 28 | return 29 | 30 | embed = await self.build_embed(source_item) 31 | await ctx.send(embed=embed) 32 | 33 | def get_source_link(self, source_item: SourceType) -> tuple[str, str, int | None]: 34 | """ 35 | Build GitHub link of source item, return this link, file location and first line number. 36 | 37 | Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). 38 | """ 39 | if isinstance(source_item, commands.Command): 40 | callback = inspect.unwrap(source_item.callback) 41 | src = callback.__code__ 42 | filename = src.co_filename 43 | else: 44 | src = type(source_item) 45 | try: 46 | filename = inspect.getsourcefile(src) 47 | except TypeError: 48 | raise commands.BadArgument("Cannot get source for a dynamically-created object.") 49 | 50 | if not isinstance(source_item, str): 51 | try: 52 | lines, first_line_no = inspect.getsourcelines(src) 53 | except OSError: 54 | raise commands.BadArgument("Cannot get source for a dynamically-created object.") 55 | 56 | lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" 57 | else: 58 | first_line_no = None 59 | lines_extension = "" 60 | 61 | file_location = Path(filename).relative_to(Path.cwd()).as_posix() 62 | 63 | url = f"{GITHUB_BOT_URL}/blob/main/{file_location}{lines_extension}" 64 | 65 | return url, file_location, first_line_no or None 66 | 67 | async def build_embed(self, source_object: SourceType) -> Embed | None: 68 | """Build embed based on source object.""" 69 | url, location, first_line = self.get_source_link(source_object) 70 | 71 | if isinstance(source_object, commands.Command): 72 | description = source_object.short_doc 73 | title = f"Command: {source_object.qualified_name}" 74 | else: 75 | title = f"Cog: {source_object.qualified_name}" 76 | description = source_object.description.splitlines()[0] 77 | 78 | embed = Embed(title=title, description=description) 79 | embed.set_thumbnail(url=BOT_AVATAR_URL) 80 | embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") 81 | line_text = f":{first_line}" if first_line else "" 82 | embed.set_footer(text=f"{location}{line_text}") 83 | 84 | return embed 85 | 86 | 87 | async def setup(bot: Bot) -> None: 88 | """Load the BotSource cog.""" 89 | await bot.add_cog(BotSource()) 90 | -------------------------------------------------------------------------------- /bot/exts/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/events/__init__.py -------------------------------------------------------------------------------- /bot/exts/events/hacktoberfest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/events/hacktoberfest/__init__.py -------------------------------------------------------------------------------- /bot/exts/events/hacktoberfest/timeleft.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | from discord.ext import commands 4 | from pydis_core.utils.logging import get_logger 5 | 6 | from bot.bot import Bot 7 | 8 | log = get_logger(__name__) 9 | 10 | 11 | class TimeLeft(commands.Cog): 12 | """A Cog that tells users how long left until Hacktober is over!""" 13 | 14 | def in_hacktober(self) -> bool: 15 | """Return True if the current time is within Hacktoberfest.""" 16 | _, end, start = self.load_date() 17 | 18 | now = datetime.now(tz=UTC) 19 | 20 | return start <= now <= end 21 | 22 | @staticmethod 23 | def load_date() -> tuple[datetime, datetime, datetime]: 24 | """Return of a tuple of the current time and the end and start times of the next Hacktober.""" 25 | now = datetime.now(tz=UTC) 26 | year = now.year 27 | if now.month > 10: 28 | year += 1 29 | end = datetime(year, 11, 1, 12, tzinfo=UTC) # November 1st 12:00 (UTC-12:00) 30 | start = datetime(year, 9, 30, 10, tzinfo=UTC) # September 30th 10:00 (UTC+14:00) 31 | return now, end, start 32 | 33 | @commands.command() 34 | async def timeleft(self, ctx: commands.Context) -> None: 35 | """ 36 | Calculates the time left until the end of Hacktober. 37 | 38 | Whilst in October, displays the days, hours and minutes left. 39 | Only displays the days left until the beginning and end whilst in a different month. 40 | 41 | This factors in that Hacktoberfest starts when it is October anywhere in the world 42 | and ends with the same rules. It treats the start as UTC+14:00 and the end as 43 | UTC-12. 44 | """ 45 | now, end, start = self.load_date() 46 | diff = end - now 47 | days, seconds = diff.days, diff.seconds 48 | if self.in_hacktober(): 49 | minutes = seconds // 60 50 | hours, minutes = divmod(minutes, 60) 51 | 52 | await ctx.send( 53 | f"There are {days} days, {hours} hours and {minutes}" 54 | f" minutes left until the end of Hacktober." 55 | ) 56 | else: 57 | start_diff = start - now 58 | start_days = start_diff.days 59 | await ctx.send( 60 | f"It is not currently Hacktober. However, the next one will start in {start_days} days " 61 | f"and will finish in {days} days." 62 | ) 63 | 64 | 65 | async def setup(bot: Bot) -> None: 66 | """Load the Time Left Cog.""" 67 | await bot.add_cog(TimeLeft()) 68 | -------------------------------------------------------------------------------- /bot/exts/events/trivianight/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/events/trivianight/__init__.py -------------------------------------------------------------------------------- /bot/exts/fun/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/fun/__init__.py -------------------------------------------------------------------------------- /bot/exts/fun/anagram.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | from pathlib import Path 5 | 6 | import discord 7 | from discord.ext import commands 8 | from pydis_core.utils.logging import get_logger 9 | 10 | from bot.bot import Bot 11 | from bot.constants import Colours 12 | 13 | log = get_logger(__name__) 14 | 15 | TIME_LIMIT = 60 16 | 17 | # anagram.json file contains all the anagrams 18 | with open(Path("bot/resources/fun/anagram.json")) as f: 19 | ANAGRAMS_ALL = json.load(f) 20 | 21 | 22 | class AnagramGame: 23 | """ 24 | Used for creating instances of anagram games. 25 | 26 | Once multiple games can be run at the same time, this class' instances 27 | can be used for keeping track of each anagram game. 28 | """ 29 | 30 | def __init__(self, scrambled: str, correct: list[str]) -> None: 31 | self.scrambled = scrambled 32 | self.correct = set(correct) 33 | 34 | self.winners = set() 35 | 36 | async def message_creation(self, message: discord.Message) -> None: 37 | """Check if the message is a correct answer and remove it from the list of answers.""" 38 | if message.content.lower() in self.correct: 39 | self.winners.add(message.author.mention) 40 | self.correct.remove(message.content.lower()) 41 | 42 | 43 | class Anagram(commands.Cog): 44 | """Cog for the Anagram game command.""" 45 | 46 | def __init__(self, bot: Bot): 47 | self.bot = bot 48 | 49 | self.games: dict[int, AnagramGame] = {} 50 | 51 | @commands.command(name="anagram", aliases=("anag", "gram", "ag")) 52 | async def anagram_command(self, ctx: commands.Context) -> None: 53 | """ 54 | Given shuffled letters, rearrange them into anagrams. 55 | 56 | Show an embed with scrambled letters which if rearranged can form words. 57 | After a specific amount of time, list the correct answers and whether someone provided a 58 | correct answer. 59 | """ 60 | if self.games.get(ctx.channel.id): 61 | await ctx.send("An anagram is already being solved in this channel!") 62 | return 63 | 64 | scrambled_letters, correct = random.choice(list(ANAGRAMS_ALL.items())) 65 | 66 | game = AnagramGame(scrambled_letters, correct) 67 | self.games[ctx.channel.id] = game 68 | 69 | anagram_embed = discord.Embed( 70 | title=f"Find anagrams from these letters: '{scrambled_letters.upper()}'", 71 | description=f"You have {TIME_LIMIT} seconds to find correct words.", 72 | colour=Colours.purple, 73 | ) 74 | 75 | await ctx.send(embed=anagram_embed) 76 | await asyncio.sleep(TIME_LIMIT) 77 | 78 | if game.winners: 79 | win_list = ", ".join(game.winners) 80 | content = f"Well done {win_list} for getting it right!" 81 | else: 82 | content = "Nobody got it right." 83 | 84 | answer_embed = discord.Embed( 85 | title=f"The words were: `{'`, `'.join(ANAGRAMS_ALL[game.scrambled])}`!", 86 | colour=Colours.pink, 87 | ) 88 | 89 | await ctx.send(content, embed=answer_embed) 90 | 91 | # Game is finished, let's remove it from the dict 92 | self.games.pop(ctx.channel.id) 93 | 94 | @commands.Cog.listener() 95 | async def on_message(self, message: discord.Message) -> None: 96 | """Check a message for an anagram attempt and pass to an ongoing game.""" 97 | if message.author.bot or not message.guild: 98 | return 99 | 100 | game = self.games.get(message.channel.id) 101 | if not game: 102 | return 103 | 104 | await game.message_creation(message) 105 | 106 | 107 | async def setup(bot: Bot) -> None: 108 | """Load the Anagram cog.""" 109 | await bot.add_cog(Anagram(bot)) 110 | -------------------------------------------------------------------------------- /bot/exts/fun/catify.py: -------------------------------------------------------------------------------- 1 | import random 2 | from contextlib import suppress 3 | 4 | from discord import AllowedMentions, Embed, Forbidden 5 | from discord.ext import commands 6 | 7 | from bot.bot import Bot 8 | from bot.constants import Colours, NEGATIVE_REPLIES 9 | from bot.utils import helpers 10 | 11 | CATS = ["ᓚᘏᗢ", "ᘡᘏᗢ", "🐈", "ᓕᘏᗢ", "ᓇᘏᗢ", "ᓂᘏᗢ", "ᘣᘏᗢ", "ᕦᘏᗢ", "ᕂᘏᗢ"] 12 | 13 | 14 | class Catify(commands.Cog): 15 | """Cog for the catify command.""" 16 | 17 | @commands.command(aliases=("ᓚᘏᗢify", "ᓚᘏᗢ")) 18 | @commands.cooldown(1, 5, commands.BucketType.user) 19 | async def catify(self, ctx: commands.Context, *, text: str | None) -> None: 20 | """ 21 | Convert the provided text into a cat themed sentence by interspercing cats throughout text. 22 | 23 | If no text is given then the users nickname is edited. 24 | """ 25 | if not text: 26 | display_name = ctx.author.display_name 27 | 28 | if len(display_name) > 26: 29 | embed = Embed( 30 | title=random.choice(NEGATIVE_REPLIES), 31 | description=( 32 | "Your display name is too long to be catified! " 33 | "Please change it to be under 26 characters." 34 | ), 35 | color=Colours.soft_red 36 | ) 37 | await ctx.send(embed=embed) 38 | return 39 | 40 | display_name += f" | {random.choice(CATS)}" 41 | 42 | await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) 43 | 44 | with suppress(Forbidden): 45 | await ctx.author.edit(nick=display_name) 46 | else: 47 | if len(text) >= 1500: 48 | embed = Embed( 49 | title=random.choice(NEGATIVE_REPLIES), 50 | description="Submitted text was too large! Please submit something under 1500 characters.", 51 | color=Colours.soft_red 52 | ) 53 | await ctx.send(embed=embed) 54 | return 55 | 56 | string_list = text.split() 57 | for index, name in enumerate(string_list): 58 | name = name.lower() 59 | if "cat" in name: 60 | if random.randint(0, 5) == 5: 61 | string_list[index] = name.replace("cat", f"**{random.choice(CATS)}**") 62 | else: 63 | string_list[index] = name.replace("cat", random.choice(CATS)) 64 | for cat in CATS: 65 | if cat in name: 66 | string_list[index] = name.replace(cat, "cat") 67 | 68 | string_len = len(string_list) // 3 or len(string_list) 69 | 70 | for _ in range(random.randint(1, string_len)): 71 | # insert cat at random index 72 | if random.randint(0, 5) == 5: 73 | string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(CATS)}**") 74 | else: 75 | string_list.insert(random.randint(0, len(string_list)), random.choice(CATS)) 76 | 77 | text = helpers.suppress_links(" ".join(string_list)) 78 | await ctx.send( 79 | f">>> {text}", 80 | allowed_mentions=AllowedMentions.none() 81 | ) 82 | 83 | 84 | async def setup(bot: Bot) -> None: 85 | """Loads the catify cog.""" 86 | await bot.add_cog(Catify()) 87 | -------------------------------------------------------------------------------- /bot/exts/fun/coinflip.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from discord.ext import commands 4 | 5 | from bot.bot import Bot 6 | from bot.constants import Emojis 7 | 8 | 9 | class CoinSide(commands.Converter): 10 | """Class used to convert the `side` parameter of coinflip command.""" 11 | 12 | HEADS = ("h", "head", "heads") 13 | TAILS = ("t", "tail", "tails") 14 | 15 | async def convert(self, ctx: commands.Context, side: str) -> str: 16 | """Converts the provided `side` into the corresponding string.""" 17 | side = side.lower() 18 | if side in self.HEADS: 19 | return "heads" 20 | 21 | if side in self.TAILS: 22 | return "tails" 23 | 24 | raise commands.BadArgument(f"{side!r} is not a valid coin side.") 25 | 26 | 27 | class CoinFlip(commands.Cog): 28 | """Cog for the CoinFlip command.""" 29 | 30 | @commands.command(name="coinflip", aliases=("flip", "coin", "cf")) 31 | async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) -> None: 32 | """ 33 | Flips a coin. 34 | 35 | If `side` is provided will state whether you guessed the side correctly. 36 | """ 37 | flipped_side = random.choice(["heads", "tails"]) 38 | 39 | message = f"{ctx.author.mention} flipped **{flipped_side}**. " 40 | if not side: 41 | await ctx.send(message) 42 | return 43 | 44 | if side == flipped_side: 45 | message += f"You guessed correctly! {Emojis.lemon_hyperpleased}" 46 | else: 47 | message += f"You guessed incorrectly. {Emojis.lemon_pensive}" 48 | await ctx.send(message) 49 | 50 | 51 | async def setup(bot: Bot) -> None: 52 | """Loads the coinflip cog.""" 53 | await bot.add_cog(CoinFlip()) 54 | -------------------------------------------------------------------------------- /bot/exts/fun/magic_8ball.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from pathlib import Path 4 | 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | 10 | log = get_logger(__name__) 11 | 12 | ANSWERS = json.loads(Path("bot/resources/fun/magic8ball.json").read_text("utf8")) 13 | 14 | 15 | class Magic8ball(commands.Cog): 16 | """A Magic 8ball command to respond to a user's question.""" 17 | 18 | @commands.command(name="8ball") 19 | async def output_answer(self, ctx: commands.Context, *, question: str) -> None: 20 | """Return a Magic 8ball answer from answers list.""" 21 | if len(question.split()) >= 3: 22 | answer = random.choice(ANSWERS) 23 | await ctx.send(answer) 24 | else: 25 | await ctx.send("Usage: .8ball (minimum length of 3 eg: `will I win?`)") 26 | 27 | 28 | async def setup(bot: Bot) -> None: 29 | """Load the Magic8Ball Cog.""" 30 | await bot.add_cog(Magic8ball()) 31 | -------------------------------------------------------------------------------- /bot/exts/fun/quack.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Literal 3 | 4 | import discord 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | from bot.constants import Colours, NEGATIVE_REPLIES 10 | 11 | API_URL = "https://quackstack.pythondiscord.com" 12 | 13 | log = get_logger(__name__) 14 | 15 | 16 | class Quackstack(commands.Cog): 17 | """Cog used for wrapping Quackstack.""" 18 | 19 | def __init__(self, bot: Bot): 20 | self.bot = bot 21 | 22 | @commands.command(aliases=("ducky",)) 23 | async def quack( 24 | self, 25 | ctx: commands.Context, 26 | ducktype: Literal["duck", "manduck"] = "duck", 27 | *, 28 | seed: str | None = None 29 | ) -> None: 30 | """ 31 | Use the Quackstack API to generate a random duck. 32 | 33 | If a seed is provided, a duck is generated based on the given seed. 34 | Either "duck" or "manduck" can be provided to change the duck type generated. 35 | """ 36 | ducktype = ducktype.lower() 37 | quackstack_url = f"{API_URL}/{ducktype}" 38 | params = {} 39 | if seed is not None: 40 | try: 41 | seed = int(seed) 42 | except ValueError: 43 | # We just need to turn the string into an integer any way possible 44 | seed = int.from_bytes(seed.encode(), "big") 45 | params["seed"] = seed 46 | 47 | async with self.bot.http_session.get(quackstack_url, params=params) as response: 48 | error_embed = discord.Embed( 49 | title=random.choice(NEGATIVE_REPLIES), 50 | description="The request failed. Please try again later.", 51 | color=Colours.soft_red, 52 | ) 53 | if response.status != 201: 54 | log.error(f"Response to Quackstack returned code {response.status}") 55 | await ctx.send(embed=error_embed) 56 | return 57 | 58 | file = response.headers["Location"] 59 | 60 | embed = discord.Embed( 61 | title=f"Quack! Here's a {ducktype} for you.", 62 | description=f"A {ducktype} from Quackstack.", 63 | color=Colours.grass_green, 64 | url=f"{API_URL}/docs" 65 | ) 66 | 67 | embed.set_image(url=file) 68 | 69 | await ctx.send(embed=embed) 70 | 71 | 72 | async def setup(bot: Bot) -> None: 73 | """Loads the Quack cog.""" 74 | await bot.add_cog(Quackstack(bot)) 75 | -------------------------------------------------------------------------------- /bot/exts/fun/recommend_game.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from random import shuffle 4 | 5 | import discord 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | 11 | log = get_logger(__name__) 12 | game_recs = [] 13 | 14 | # Populate the list `game_recs` with resource files 15 | for rec_path in Path("bot/resources/fun/game_recs").glob("*.json"): 16 | data = json.loads(rec_path.read_text("utf8")) 17 | game_recs.append(data) 18 | shuffle(game_recs) 19 | 20 | 21 | class RecommendGame(commands.Cog): 22 | """Commands related to recommending games.""" 23 | 24 | def __init__(self, bot: Bot): 25 | self.bot = bot 26 | self.index = 0 27 | 28 | @commands.command(name="recommendgame", aliases=("gamerec",)) 29 | async def recommend_game(self, ctx: commands.Context) -> None: 30 | """Sends an Embed of a random game recommendation.""" 31 | if self.index >= len(game_recs): 32 | self.index = 0 33 | shuffle(game_recs) 34 | game = game_recs[self.index] 35 | self.index += 1 36 | 37 | author = self.bot.get_user(int(game["author"])) 38 | 39 | # Creating and formatting Embed 40 | embed = discord.Embed(color=discord.Colour.blue()) 41 | if author is not None: 42 | embed.set_author(name=author.name, icon_url=author.display_avatar.url) 43 | embed.set_image(url=game["image"]) 44 | embed.add_field(name=f"Recommendation: {game['title']}\n{game['link']}", value=game["description"]) 45 | 46 | await ctx.send(embed=embed) 47 | 48 | 49 | async def setup(bot: Bot) -> None: 50 | """Loads the RecommendGame cog.""" 51 | await bot.add_cog(RecommendGame(bot)) 52 | -------------------------------------------------------------------------------- /bot/exts/fun/rps.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | from discord.ext import commands 4 | 5 | from bot.bot import Bot 6 | 7 | CHOICES = ["rock", "paper", "scissors"] 8 | SHORT_CHOICES = ["r", "p", "s"] 9 | 10 | # Using a dictionary instead of conditions to check for the winner. 11 | WINNER_DICT = { 12 | "r": { 13 | "r": 0, 14 | "p": -1, 15 | "s": 1, 16 | }, 17 | "p": { 18 | "r": 1, 19 | "p": 0, 20 | "s": -1, 21 | }, 22 | "s": { 23 | "r": -1, 24 | "p": 1, 25 | "s": 0, 26 | } 27 | } 28 | 29 | 30 | class RPS(commands.Cog): 31 | """Rock Paper Scissors. The Classic Game!""" 32 | 33 | @commands.command(case_insensitive=True) 34 | async def rps(self, ctx: commands.Context, move: str) -> None: 35 | """Play the classic game of Rock Paper Scissors with your own sir-lancebot!""" 36 | move = move.lower() 37 | player_mention = ctx.author.mention 38 | 39 | if move not in CHOICES and move not in SHORT_CHOICES: 40 | raise commands.BadArgument(f"Invalid move. Please make move from options: {', '.join(CHOICES).upper()}.") 41 | 42 | bot_move = choice(CHOICES) 43 | # value of player_result will be from (-1, 0, 1) as (lost, tied, won). 44 | player_result = WINNER_DICT[move[0]][bot_move[0]] 45 | 46 | if player_result == 0: 47 | message_string = f"{player_mention} You and Sir Lancebot played {bot_move}, it's a tie." 48 | await ctx.send(message_string) 49 | elif player_result == 1: 50 | await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won!") 51 | else: 52 | await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} lost!") 53 | 54 | 55 | async def setup(bot: Bot) -> None: 56 | """Load the RPS Cog.""" 57 | await bot.add_cog(RPS(bot)) 58 | -------------------------------------------------------------------------------- /bot/exts/fun/snakes/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from pydis_core.utils.logging import get_logger 3 | 4 | from bot.bot import Bot 5 | from bot.constants import Tokens 6 | from bot.exts.fun.snakes._snakes_cog import Snakes 7 | 8 | log = get_logger(__name__) 9 | 10 | 11 | async def setup(bot: Bot) -> None: 12 | """Load the Snakes Cog.""" 13 | if not Tokens.youtube: 14 | log.warning("No Youtube token. All YouTube related commands in Snakes cog won't work.") 15 | await bot.add_cog(Snakes(bot)) 16 | -------------------------------------------------------------------------------- /bot/exts/fun/snakes/_converter.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from collections.abc import Iterable 4 | 5 | import discord 6 | from discord.ext.commands import Context, Converter 7 | from pydis_core.utils.logging import get_logger 8 | from rapidfuzz import fuzz 9 | 10 | from bot.exts.fun.snakes._utils import SNAKE_RESOURCES 11 | from bot.utils import disambiguate 12 | 13 | log = get_logger(__name__) 14 | 15 | 16 | class Snake(Converter): 17 | """Snake converter for the Snakes Cog.""" 18 | 19 | snakes = None 20 | special_cases = None 21 | 22 | async def convert(self, ctx: Context, name: str) -> str: 23 | """Convert the input snake name to the closest matching Snake object.""" 24 | await self.build_list() 25 | name = name.lower() 26 | 27 | if name == "python": 28 | return "Python (programming language)" 29 | 30 | def get_potential(iterable: Iterable, *, threshold: int = 80) -> list[str]: 31 | nonlocal name 32 | potential = [] 33 | 34 | for item in iterable: 35 | original, item = item, item.lower() 36 | 37 | if name == item: 38 | return [original] 39 | 40 | a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item) 41 | if a >= threshold or b >= threshold: 42 | potential.append(original) 43 | 44 | return potential 45 | 46 | # Handle special cases 47 | if name.lower() in self.special_cases: 48 | return self.special_cases.get(name.lower(), name.lower()) 49 | 50 | names = {snake["name"]: snake["scientific"] for snake in self.snakes} 51 | all_names = names.keys() | names.values() 52 | timeout = len(all_names) * (3 / 4) 53 | 54 | embed = discord.Embed( 55 | title="Found multiple choices. Please choose the correct one.", colour=0x59982F) 56 | embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url) 57 | 58 | name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed) 59 | return names.get(name, name) 60 | 61 | @classmethod 62 | async def build_list(cls) -> None: 63 | """Build list of snakes from the static snake resources.""" 64 | # Get all the snakes 65 | if cls.snakes is None: 66 | cls.snakes = json.loads((SNAKE_RESOURCES / "snake_names.json").read_text("utf8")) 67 | # Get the special cases 68 | if cls.special_cases is None: 69 | special_cases = json.loads((SNAKE_RESOURCES / "special_snakes.json").read_text("utf8")) 70 | cls.special_cases = {snake["name"].lower(): snake for snake in special_cases} 71 | 72 | @classmethod 73 | async def random(cls) -> str: 74 | """ 75 | Get a random Snake from the loaded resources. 76 | 77 | This is stupid. We should find a way to somehow get the global session into a global context, 78 | so I can get it from here. 79 | """ 80 | await cls.build_list() 81 | names = [snake["scientific"] for snake in cls.snakes] 82 | return random.choice(names) 83 | -------------------------------------------------------------------------------- /bot/exts/fun/speedrun.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from random import choice 4 | 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | 10 | log = get_logger(__name__) 11 | 12 | LINKS = json.loads(Path("bot/resources/fun/speedrun_links.json").read_text("utf8")) 13 | 14 | 15 | class Speedrun(commands.Cog): 16 | """Commands about the video game speedrunning community.""" 17 | 18 | @commands.command(name="speedrun") 19 | async def get_speedrun(self, ctx: commands.Context) -> None: 20 | """Sends a link to a video of a random speedrun.""" 21 | await ctx.send(choice(LINKS)) 22 | 23 | 24 | async def setup(bot: Bot) -> None: 25 | """Load the Speedrun cog.""" 26 | await bot.add_cog(Speedrun()) 27 | -------------------------------------------------------------------------------- /bot/exts/fun/status_codes.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | import discord 4 | from discord.ext import commands 5 | 6 | from bot.bot import Bot 7 | 8 | HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg" 9 | HTTP_CAT_URL = "https://http.cat/{code}.jpg" 10 | STATUS_TEMPLATE = "**Status: {code}**" 11 | ERR_404 = "Unable to find status floof for {code}." 12 | ERR_UNKNOWN = "Error attempting to retrieve status floof for {code}." 13 | ERROR_LENGTH_EMBED = discord.Embed( 14 | title="Input status code does not exist", 15 | description="The range of valid status codes is 100 to 599", 16 | ) 17 | 18 | 19 | class HTTPStatusCodes(commands.Cog): 20 | """ 21 | Fetch an image depicting HTTP status codes as a dog or a cat. 22 | 23 | If neither animal is selected a cat or dog is chosen randomly for the given status code. 24 | """ 25 | 26 | def __init__(self, bot: Bot): 27 | self.bot = bot 28 | 29 | @commands.group( 30 | name="http_status", 31 | aliases=("status", "httpstatus"), 32 | invoke_without_command=True, 33 | ) 34 | async def http_status_group(self, ctx: commands.Context, code: int) -> None: 35 | """Choose a cat or dog randomly for the given status code.""" 36 | subcmd = choice((self.http_cat, self.http_dog)) 37 | await subcmd(ctx, code) 38 | 39 | @http_status_group.command(name="cat") 40 | async def http_cat(self, ctx: commands.Context, code: int) -> None: 41 | """Send a cat version of the requested HTTP status code.""" 42 | if code in range(100, 600): 43 | await self.build_embed(url=HTTP_CAT_URL.format(code=code), ctx=ctx, code=code) 44 | return 45 | await ctx.send(embed=ERROR_LENGTH_EMBED) 46 | 47 | @http_status_group.command(name="dog") 48 | async def http_dog(self, ctx: commands.Context, code: int) -> None: 49 | """Send a dog version of the requested HTTP status code.""" 50 | if code in range(100, 600): 51 | await self.build_embed(url=HTTP_DOG_URL.format(code=code), ctx=ctx, code=code) 52 | return 53 | await ctx.send(embed=ERROR_LENGTH_EMBED) 54 | 55 | async def build_embed(self, url: str, ctx: commands.Context, code: int) -> None: 56 | """Attempt to build and dispatch embed. Append error message instead if something goes wrong.""" 57 | async with self.bot.http_session.get(url, allow_redirects=False) as response: 58 | if response.status in range(200, 300): 59 | await ctx.send( 60 | embed=discord.Embed( 61 | title=STATUS_TEMPLATE.format(code=code) 62 | ).set_image(url=url) 63 | ) 64 | elif response.status in (302, 404): # dog URL returns 302 instead of 404 65 | if "dog" in url: 66 | await ctx.send( 67 | embed=discord.Embed( 68 | title=ERR_404.format(code=code) 69 | ).set_image(url="https://httpstatusdogs.com/img/404.jpg") 70 | ) 71 | return 72 | await ctx.send( 73 | embed=discord.Embed( 74 | title=ERR_404.format(code=code) 75 | ).set_image(url="https://http.cat/404.jpg") 76 | ) 77 | else: 78 | await ctx.send( 79 | embed=discord.Embed( 80 | title=STATUS_TEMPLATE.format(code=code) 81 | ).set_footer(text=ERR_UNKNOWN.format(code=code)) 82 | ) 83 | 84 | 85 | async def setup(bot: Bot) -> None: 86 | """Load the HTTPStatusCodes cog.""" 87 | await bot.add_cog(HTTPStatusCodes(bot)) 88 | -------------------------------------------------------------------------------- /bot/exts/fun/wonder_twins.py: -------------------------------------------------------------------------------- 1 | import random 2 | from pathlib import Path 3 | 4 | import yaml 5 | from discord.ext.commands import Cog, Context, command 6 | 7 | from bot.bot import Bot 8 | 9 | 10 | class WonderTwins(Cog): 11 | """Cog for a Wonder Twins inspired command.""" 12 | 13 | def __init__(self): 14 | with open(Path.cwd() / "bot" / "resources" / "fun" / "wonder_twins.yaml", encoding="utf-8") as f: 15 | info = yaml.safe_load(f) 16 | self.water_types = info["water_types"] 17 | self.objects = info["objects"] 18 | self.adjectives = info["adjectives"] 19 | 20 | @staticmethod 21 | def append_onto(phrase: str, insert_word: str) -> str: 22 | """Appends one word onto the end of another phrase in order to format with the proper determiner.""" 23 | if insert_word.endswith("s"): 24 | phrase = phrase.split() 25 | del phrase[0] 26 | phrase = " ".join(phrase) 27 | 28 | insert_word = insert_word.split()[-1] 29 | return " ".join([phrase, insert_word]) 30 | 31 | def format_phrase(self) -> str: 32 | """Creates a transformation phrase from available words.""" 33 | adjective = random.choice((None, random.choice(self.adjectives))) 34 | object_name = random.choice(self.objects) 35 | water_type = random.choice(self.water_types) 36 | 37 | if adjective: 38 | object_name = self.append_onto(adjective, object_name) 39 | return f"{object_name} of {water_type}" 40 | 41 | @command(name="formof", aliases=("wondertwins", "wondertwin", "fo")) 42 | async def form_of(self, ctx: Context) -> None: 43 | """Command to send a Wonder Twins inspired phrase to the user invoking the command.""" 44 | await ctx.send(f"Form of {self.format_phrase()}!") 45 | 46 | 47 | async def setup(bot: Bot) -> None: 48 | """Load the WonderTwins cog.""" 49 | await bot.add_cog(WonderTwins()) 50 | -------------------------------------------------------------------------------- /bot/exts/fun/xkcd.py: -------------------------------------------------------------------------------- 1 | import re 2 | from random import randint 3 | 4 | from discord import Embed 5 | from discord.ext import tasks 6 | from discord.ext.commands import Cog, Context, command 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.constants import Colours 11 | 12 | log = get_logger(__name__) 13 | 14 | COMIC_FORMAT = re.compile(r"latest|[0-9]+") 15 | BASE_URL = "https://xkcd.com" 16 | 17 | 18 | class XKCD(Cog): 19 | """Retrieving XKCD comics.""" 20 | 21 | def __init__(self, bot: Bot): 22 | self.bot = bot 23 | self.latest_comic_info: dict[str, str | int] = {} 24 | self.get_latest_comic_info.start() 25 | 26 | def cog_unload(self) -> None: 27 | """Cancels refreshing of the task for refreshing the most recent comic info.""" 28 | self.get_latest_comic_info.cancel() 29 | 30 | @tasks.loop(minutes=30) 31 | async def get_latest_comic_info(self) -> None: 32 | """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic.""" 33 | async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp: 34 | if resp.status == 200: 35 | self.latest_comic_info = await resp.json() 36 | else: 37 | log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}") 38 | 39 | @command(name="xkcd") 40 | async def fetch_xkcd_comics(self, ctx: Context, comic: str | None) -> None: 41 | """ 42 | Getting an xkcd comic's information along with the image. 43 | 44 | To get a random comic, don't type any number as an argument. To get the latest, type 'latest'. 45 | """ 46 | embed = Embed(title=f"XKCD comic '{comic}'") 47 | 48 | embed.colour = Colours.soft_red 49 | 50 | if comic and (comic := re.match(COMIC_FORMAT, comic)) is None: 51 | embed.description = "Comic parameter should either be an integer or 'latest'." 52 | await ctx.send(embed=embed) 53 | return 54 | 55 | comic = randint(1, self.latest_comic_info["num"]) if comic is None else comic.group(0) 56 | 57 | if comic == "latest": 58 | info = self.latest_comic_info 59 | else: 60 | async with self.bot.http_session.get(f"{BASE_URL}/{comic}/info.0.json") as resp: 61 | if resp.status == 200: 62 | info = await resp.json() 63 | else: 64 | embed.title = f"XKCD comic #{comic}" 65 | embed.description = f"{resp.status}: Could not retrieve xkcd comic #{comic}." 66 | log.debug(f"Retrieving xkcd comic #{comic} failed with status code {resp.status}.") 67 | await ctx.send(embed=embed) 68 | return 69 | 70 | embed.title = f"XKCD comic #{info['num']}" 71 | embed.description = info["alt"] 72 | embed.url = f"{BASE_URL}/{info['num']}" 73 | 74 | if info["img"][-3:] in ("jpg", "png", "gif"): 75 | embed.set_image(url=info["img"]) 76 | date = f"{info['year']}/{info['month']}/{info['day']}" 77 | embed.set_footer(text=f"{date} - #{info['num']}, '{info['safe_title']}'") 78 | embed.colour = Colours.soft_green 79 | else: 80 | embed.description = ( 81 | "The selected comic is interactive, and cannot be displayed within an embed.\n" 82 | f"Comic can be viewed [here](https://xkcd.com/{info['num']})." 83 | ) 84 | 85 | await ctx.send(embed=embed) 86 | 87 | 88 | async def setup(bot: Bot) -> None: 89 | """Load the XKCD cog.""" 90 | await bot.add_cog(XKCD(bot)) 91 | -------------------------------------------------------------------------------- /bot/exts/holidays/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/holidays/__init__.py -------------------------------------------------------------------------------- /bot/exts/holidays/earth_day/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/holidays/earth_day/__init__.py -------------------------------------------------------------------------------- /bot/exts/holidays/earth_day/save_the_planet.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from discord import Embed 5 | from discord.ext import commands 6 | 7 | from bot.bot import Bot 8 | from bot.utils.randomization import RandomCycle 9 | 10 | EMBED_DATA = RandomCycle(json.loads(Path("bot/resources/holidays/earth_day/save_the_planet.json").read_text("utf8"))) 11 | 12 | 13 | class SaveThePlanet(commands.Cog): 14 | """A cog that teaches users how they can help our planet.""" 15 | 16 | @commands.command(aliases=("savetheearth", "saveplanet", "saveearth")) 17 | async def savetheplanet(self, ctx: commands.Context) -> None: 18 | """Responds with a random tip on how to be eco-friendly and help our planet.""" 19 | return_embed = Embed.from_dict(next(EMBED_DATA)) 20 | await ctx.send(embed=return_embed) 21 | 22 | 23 | async def setup(bot: Bot) -> None: 24 | """Load the Save the Planet Cog.""" 25 | await bot.add_cog(SaveThePlanet()) 26 | -------------------------------------------------------------------------------- /bot/exts/holidays/easter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/holidays/easter/__init__.py -------------------------------------------------------------------------------- /bot/exts/holidays/easter/april_fools_vids.py: -------------------------------------------------------------------------------- 1 | import random 2 | from json import loads 3 | from pathlib import Path 4 | 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | 10 | log = get_logger(__name__) 11 | 12 | ALL_VIDS = loads(Path("bot/resources/holidays/easter/april_fools_vids.json").read_text("utf-8")) 13 | 14 | 15 | class AprilFoolVideos(commands.Cog): 16 | """A cog for April Fools' that gets a random April Fools' video from Youtube.""" 17 | 18 | @commands.command(name="fool") 19 | async def april_fools(self, ctx: commands.Context) -> None: 20 | """Get a random April Fools' video from Youtube.""" 21 | video = random.choice(ALL_VIDS) 22 | 23 | channel, url = video["channel"], video["url"] 24 | 25 | await ctx.send(f"Check out this April Fools' video by {channel}.\n\n{url}") 26 | 27 | 28 | async def setup(bot: Bot) -> None: 29 | """Load the April Fools' Cog.""" 30 | await bot.add_cog(AprilFoolVideos()) 31 | -------------------------------------------------------------------------------- /bot/exts/holidays/easter/bunny_name_generator.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import re 4 | from pathlib import Path 5 | 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | 11 | log = get_logger(__name__) 12 | 13 | BUNNY_NAMES = json.loads(Path("bot/resources/holidays/easter/bunny_names.json").read_text("utf8")) 14 | 15 | 16 | class BunnyNameGenerator(commands.Cog): 17 | """Generate a random bunny name, or bunnify your Discord username!""" 18 | 19 | @staticmethod 20 | def find_separators(displayname: str) -> list[str] | None: 21 | """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" 22 | new_name = re.split(r"[_.\s]", displayname) 23 | if displayname not in new_name: 24 | return new_name 25 | return None 26 | 27 | @staticmethod 28 | def find_vowels(displayname: str) -> str | None: 29 | """ 30 | Finds vowels in the user's display name. 31 | 32 | If the Discord name contains a vowel and the letter y, it will match one or more of these patterns. 33 | 34 | Only the most recently matched pattern will apply the changes. 35 | """ 36 | expressions = [ 37 | ("a.+y", "patchy"), 38 | ("e.+y", "ears"), 39 | ("i.+y", "ditsy"), 40 | ("o.+y", "oofy"), 41 | ("u.+y", "uffy"), 42 | ] 43 | 44 | for exp, vowel_sub in expressions: 45 | new_name = re.sub(exp, vowel_sub, displayname) 46 | if new_name != displayname: 47 | return new_name 48 | return None 49 | 50 | @staticmethod 51 | def append_name(displayname: str) -> str: 52 | """Adds a suffix to the end of the Discord name.""" 53 | extensions = ["foot", "ear", "nose", "tail"] 54 | suffix = random.choice(extensions) 55 | appended_name = displayname + suffix 56 | 57 | return appended_name 58 | 59 | @commands.command() 60 | async def bunnyname(self, ctx: commands.Context) -> None: 61 | """Picks a random bunny name from a JSON file.""" 62 | await ctx.send(random.choice(BUNNY_NAMES["names"])) 63 | 64 | @commands.command() 65 | async def bunnifyme(self, ctx: commands.Context) -> None: 66 | """Gets your Discord username and bunnifies it.""" 67 | username = ctx.author.display_name 68 | 69 | # If name contains spaces or other separators, get the individual words to randomly bunnify 70 | spaces_in_name = self.find_separators(username) 71 | 72 | # If name contains vowels, see if it matches any of the patterns in this function 73 | # If there are matches, the bunnified name is returned. 74 | vowels_in_name = self.find_vowels(username) 75 | 76 | # Default if the checks above return None 77 | unmatched_name = self.append_name(username) 78 | 79 | if spaces_in_name is not None: 80 | replacements = ["Cotton", "Fluff", "Floof", "Bounce", "Snuffle", "Nibble", "Cuddle", "Velvetpaw", "Carrot"] 81 | word_to_replace = random.choice(spaces_in_name) 82 | substitute = random.choice(replacements) 83 | bunnified_name = username.replace(word_to_replace, substitute) 84 | elif vowels_in_name is not None: 85 | bunnified_name = vowels_in_name 86 | elif unmatched_name: 87 | bunnified_name = unmatched_name 88 | 89 | await ctx.send(bunnified_name) 90 | 91 | 92 | async def setup(bot: Bot) -> None: 93 | """Load the Bunny Name Generator Cog.""" 94 | await bot.add_cog(BunnyNameGenerator()) 95 | -------------------------------------------------------------------------------- /bot/exts/holidays/easter/earth_photos.py: -------------------------------------------------------------------------------- 1 | 2 | import discord 3 | from discord.ext import commands 4 | from pydis_core.utils.logging import get_logger 5 | 6 | from bot.bot import Bot 7 | from bot.constants import Colours, Tokens 8 | 9 | log = get_logger(__name__) 10 | 11 | API_URL = "https://api.unsplash.com/photos/random" 12 | 13 | 14 | class EarthPhotos(commands.Cog): 15 | """The earth photos cog.""" 16 | 17 | def __init__(self, bot: Bot): 18 | self.bot = bot 19 | 20 | @commands.command(aliases=("earth",)) 21 | async def earth_photos(self, ctx: commands.Context) -> None: 22 | """Returns a random photo of earth, sourced from Unsplash.""" 23 | async with ctx.typing(): 24 | async with self.bot.http_session.get( 25 | API_URL, 26 | params={"query": "planet_earth", "client_id": Tokens.unsplash.get_secret_value()} 27 | ) as r: 28 | jsondata = await r.json() 29 | linksdata = jsondata.get("urls") 30 | embedlink = linksdata.get("regular") 31 | downloadlinksdata = jsondata.get("links") 32 | userdata = jsondata.get("user") 33 | username = userdata.get("name") 34 | userlinks = userdata.get("links") 35 | profile = userlinks.get("html") 36 | # Referral flags 37 | rf = "?utm_source=Sir%20Lancebot&utm_medium=referral" 38 | async with self.bot.http_session.get( 39 | downloadlinksdata.get("download_location"), 40 | params={"client_id": Tokens.unsplash.get_secret_value()} 41 | ) as _: 42 | pass 43 | 44 | embed = discord.Embed( 45 | title="Earth Photo", 46 | description="A photo of Earth 🌎 from Unsplash.", 47 | color=Colours.grass_green 48 | ) 49 | embed.set_image(url=embedlink) 50 | embed.add_field( 51 | name="Author", 52 | value=( 53 | f"Photo by [{username}]({profile}{rf}) " 54 | f"on [Unsplash](https://unsplash.com{rf})." 55 | ) 56 | ) 57 | await ctx.send(embed=embed) 58 | 59 | 60 | async def setup(bot: Bot) -> None: 61 | """Load the Earth Photos cog.""" 62 | if not Tokens.unsplash: 63 | log.warning("No Unsplash access key found. Cog not loading.") 64 | return 65 | await bot.add_cog(EarthPhotos(bot)) 66 | -------------------------------------------------------------------------------- /bot/exts/holidays/easter/easter_riddle.py: -------------------------------------------------------------------------------- 1 | import random 2 | from json import loads 3 | from pathlib import Path 4 | 5 | import discord 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.constants import Colours, NEGATIVE_REPLIES 11 | 12 | log = get_logger(__name__) 13 | 14 | RIDDLE_QUESTIONS = loads(Path("bot/resources/holidays/easter/easter_riddle.json").read_text("utf8")) 15 | 16 | TIMELIMIT = 10 17 | 18 | 19 | class EasterRiddle(commands.Cog): 20 | """The Easter quiz cog.""" 21 | 22 | def __init__(self, bot: Bot): 23 | self.bot = bot 24 | self.current_channel = None 25 | 26 | @commands.command(aliases=("riddlemethis", "riddleme")) 27 | async def riddle(self, ctx: commands.Context) -> None: 28 | """ 29 | Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer. 30 | 31 | The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file. 32 | """ 33 | if self.current_channel: 34 | await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") 35 | return 36 | 37 | # Don't let users start in a DM 38 | if not ctx.guild: 39 | await ctx.send( 40 | embed=discord.Embed( 41 | title=random.choice(NEGATIVE_REPLIES), 42 | description="You can't start riddles in DMs", 43 | colour=discord.Colour.red(), 44 | ) 45 | ) 46 | return 47 | 48 | self.current_channel = ctx.channel 49 | 50 | random_question = random.choice(RIDDLE_QUESTIONS) 51 | question = random_question["question"] 52 | hints = random_question["riddles"] 53 | correct = random_question["correct_answer"] 54 | 55 | description = f"You have {TIMELIMIT} seconds before the first hint." 56 | 57 | riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink) 58 | 59 | await ctx.send(embed=riddle_embed) 60 | hint_number = 0 61 | winner = None 62 | while hint_number < 3: 63 | try: 64 | response = await self.bot.wait_for( 65 | "message", 66 | check=lambda m: m.channel == ctx.channel 67 | and m.author != self.bot.user 68 | and m.content.lower() == correct.lower(), 69 | timeout=TIMELIMIT, 70 | ) 71 | winner = response.author.mention 72 | break 73 | except TimeoutError: 74 | hint_number += 1 75 | 76 | try: 77 | hint_embed = discord.Embed( 78 | title=f"Here's a hint: {hints[hint_number-1]}!", 79 | colour=Colours.pink, 80 | ) 81 | except IndexError: 82 | break 83 | await ctx.send(embed=hint_embed) 84 | 85 | if winner: 86 | content = f"Well done {winner} for getting it right!" 87 | else: 88 | content = "Nobody got it right..." 89 | 90 | answer_embed = discord.Embed(title=f"The answer is: {correct}!", colour=Colours.pink) 91 | 92 | await ctx.send(content, embed=answer_embed) 93 | self.current_channel = None 94 | 95 | 96 | async def setup(bot: Bot) -> None: 97 | """Easter Riddle Cog load.""" 98 | await bot.add_cog(EasterRiddle(bot)) 99 | -------------------------------------------------------------------------------- /bot/exts/holidays/easter/egg_facts.py: -------------------------------------------------------------------------------- 1 | import random 2 | from json import loads 3 | from pathlib import Path 4 | 5 | import discord 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.constants import Channels, Colours, Month 11 | from bot.utils.decorators import seasonal_task 12 | 13 | log = get_logger(__name__) 14 | 15 | EGG_FACTS = loads(Path("bot/resources/holidays/easter/easter_egg_facts.json").read_text("utf8")) 16 | 17 | 18 | class EasterFacts(commands.Cog): 19 | """ 20 | A cog contains a command that will return an easter egg fact when called. 21 | 22 | It also contains a background task which sends an easter egg fact in the event channel everyday. 23 | """ 24 | 25 | def __init__(self, bot: Bot): 26 | self.bot = bot 27 | self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily()) 28 | 29 | @seasonal_task(Month.APRIL) 30 | async def send_egg_fact_daily(self) -> None: 31 | """A background task that sends an easter egg fact in the event channel everyday.""" 32 | channel = self.bot.get_channel(Channels.sir_lancebot_playground) 33 | await channel.send(embed=self.make_embed()) 34 | 35 | @commands.command(name="eggfact", aliases=("fact",)) 36 | async def easter_facts(self, ctx: commands.Context) -> None: 37 | """Get easter egg facts.""" 38 | embed = self.make_embed() 39 | await ctx.send(embed=embed) 40 | 41 | @staticmethod 42 | def make_embed() -> discord.Embed: 43 | """Makes a nice embed for the message to be sent.""" 44 | return discord.Embed( 45 | colour=Colours.soft_red, 46 | title="Easter Egg Fact", 47 | description=random.choice(EGG_FACTS) 48 | ) 49 | 50 | 51 | async def setup(bot: Bot) -> None: 52 | """Load the Easter Egg facts Cog.""" 53 | await bot.add_cog(EasterFacts(bot)) 54 | -------------------------------------------------------------------------------- /bot/exts/holidays/easter/traditions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from pathlib import Path 4 | 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | 10 | log = get_logger(__name__) 11 | 12 | traditions = json.loads(Path("bot/resources/holidays/easter/traditions.json").read_text("utf8")) 13 | 14 | 15 | class Traditions(commands.Cog): 16 | """A cog which allows users to get a random easter tradition or custom from a random country.""" 17 | 18 | @commands.command(aliases=("eastercustoms",)) 19 | async def easter_tradition(self, ctx: commands.Context) -> None: 20 | """Responds with a random tradition or custom.""" 21 | random_country = random.choice(list(traditions)) 22 | 23 | await ctx.send(f"{random_country}:\n{traditions[random_country]}") 24 | 25 | 26 | async def setup(bot: Bot) -> None: 27 | """Load the Traditions Cog.""" 28 | await bot.add_cog(Traditions()) 29 | -------------------------------------------------------------------------------- /bot/exts/holidays/halloween/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/holidays/halloween/__init__.py -------------------------------------------------------------------------------- /bot/exts/holidays/halloween/eight_ball.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | from pathlib import Path 5 | 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | 11 | log = get_logger(__name__) 12 | 13 | RESPONSES = json.loads(Path("bot/resources/holidays/halloween/responses.json").read_text("utf8")) 14 | 15 | 16 | class SpookyEightBall(commands.Cog): 17 | """Spooky Eightball answers.""" 18 | 19 | @commands.command(aliases=("spooky8ball",)) 20 | async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None: 21 | """Responds with a random response to a question.""" 22 | choice = random.choice(RESPONSES["responses"]) 23 | msg = await ctx.send(choice[0]) 24 | if len(choice) > 1: 25 | await asyncio.sleep(random.randint(2, 5)) 26 | await msg.edit(content=f"{choice[0]} \n{choice[1]}") 27 | 28 | 29 | async def setup(bot: Bot) -> None: 30 | """Load the Spooky Eight Ball Cog.""" 31 | await bot.add_cog(SpookyEightBall()) 32 | -------------------------------------------------------------------------------- /bot/exts/holidays/halloween/halloween_facts.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from datetime import timedelta 4 | from pathlib import Path 5 | 6 | import discord 7 | from discord.ext import commands 8 | from pydis_core.utils.logging import get_logger 9 | 10 | from bot.bot import Bot 11 | 12 | log = get_logger(__name__) 13 | 14 | SPOOKY_EMOJIS = [ 15 | "\N{BAT}", 16 | "\N{DERELICT HOUSE BUILDING}", 17 | "\N{EXTRATERRESTRIAL ALIEN}", 18 | "\N{GHOST}", 19 | "\N{JACK-O-LANTERN}", 20 | "\N{SKULL}", 21 | "\N{SKULL AND CROSSBONES}", 22 | "\N{SPIDER WEB}", 23 | ] 24 | PUMPKIN_ORANGE = 0xFF7518 25 | INTERVAL = timedelta(hours=6).total_seconds() 26 | 27 | FACTS = json.loads(Path("bot/resources/holidays/halloween/halloween_facts.json").read_text("utf8")) 28 | FACTS = list(enumerate(FACTS)) 29 | 30 | 31 | class HalloweenFacts(commands.Cog): 32 | """A Cog for displaying interesting facts about Halloween.""" 33 | 34 | def random_fact(self) -> tuple[int, str]: 35 | """Return a random fact from the loaded facts.""" 36 | return random.choice(FACTS) 37 | 38 | @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") 39 | async def get_random_fact(self, ctx: commands.Context) -> None: 40 | """Reply with the most recent Halloween fact.""" 41 | index, fact = self.random_fact() 42 | embed = self._build_embed(index, fact) 43 | await ctx.send(embed=embed) 44 | 45 | @staticmethod 46 | def _build_embed(index: int, fact: str) -> discord.Embed: 47 | """Builds a Discord embed from the given fact and its index.""" 48 | emoji = random.choice(SPOOKY_EMOJIS) 49 | title = f"{emoji} Halloween Fact #{index + 1}" 50 | return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) 51 | 52 | 53 | async def setup(bot: Bot) -> None: 54 | """Load the Halloween Facts Cog.""" 55 | await bot.add_cog(HalloweenFacts()) 56 | -------------------------------------------------------------------------------- /bot/exts/holidays/halloween/halloweenify.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | from pathlib import Path 3 | from random import choice 4 | 5 | import discord 6 | from discord.errors import Forbidden 7 | from discord.ext import commands 8 | from discord.ext.commands import BucketType 9 | from pydis_core.utils.logging import get_logger 10 | 11 | from bot.bot import Bot 12 | 13 | log = get_logger(__name__) 14 | 15 | HALLOWEENIFY_DATA = loads(Path("bot/resources/holidays/halloween/halloweenify.json").read_text("utf8")) 16 | 17 | 18 | class Halloweenify(commands.Cog): 19 | """A cog to change a invokers nickname to a spooky one!""" 20 | 21 | @commands.cooldown(1, 300, BucketType.user) 22 | @commands.command() 23 | async def halloweenify(self, ctx: commands.Context) -> None: 24 | """Change your nickname into a much spookier one!""" 25 | async with ctx.typing(): 26 | # Choose a random character from our list we loaded above and set apart the nickname and image url. 27 | character = choice(HALLOWEENIFY_DATA["characters"]) 28 | nickname = "".join(nickname for nickname in character) 29 | image = "".join(character[nickname] for nickname in character) 30 | 31 | # Build up a Embed 32 | embed = discord.Embed() 33 | embed.colour = discord.Colour.dark_orange() 34 | embed.title = "Not spooky enough?" 35 | embed.description = ( 36 | f"**{ctx.author.display_name}** wasn't spooky enough for you? That's understandable, " 37 | f"{ctx.author.display_name} isn't scary at all! " 38 | "Let me think of something better. Hmm... I got it!\n\n " 39 | ) 40 | embed.set_image(url=image) 41 | 42 | if isinstance(ctx.author, discord.Member): 43 | try: 44 | await ctx.author.edit(nick=nickname) 45 | embed.description += f"Your new nickname will be: \n:ghost: **{nickname}** :jack_o_lantern:" 46 | 47 | except Forbidden: # The bot doesn't have enough permission 48 | embed.description += ( 49 | f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n" 50 | f"It looks like I cannot change your name, but feel free to change it yourself." 51 | ) 52 | 53 | else: # The command has been invoked in DM 54 | embed.description += ( 55 | f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n" 56 | f"Feel free to change it yourself, or invoke the command again inside the server." 57 | ) 58 | 59 | await ctx.send(embed=embed) 60 | 61 | 62 | async def setup(bot: Bot) -> None: 63 | """Load the Halloweenify Cog.""" 64 | await bot.add_cog(Halloweenify()) 65 | -------------------------------------------------------------------------------- /bot/exts/holidays/halloween/monsterbio.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from pathlib import Path 4 | 5 | import discord 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.constants import Colours 11 | 12 | log = get_logger(__name__) 13 | 14 | TEXT_OPTIONS = json.loads( 15 | Path("bot/resources/holidays/halloween/monster.json").read_text("utf8") 16 | ) # Data for a mad-lib style generation of text 17 | 18 | 19 | class MonsterBio(commands.Cog): 20 | """A cog that generates a spooky monster biography.""" 21 | 22 | def generate_name(self, seeded_random: random.Random) -> str: 23 | """Generates a name (for either monster species or monster name).""" 24 | n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"])) 25 | return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings)) 26 | 27 | @commands.command(brief="Sends your monster bio!") 28 | async def monsterbio(self, ctx: commands.Context) -> None: 29 | """Sends a description of a monster.""" 30 | seeded_random = random.Random(ctx.author.id) # Seed a local Random instance rather than the system one 31 | 32 | name = self.generate_name(seeded_random) 33 | species = self.generate_name(seeded_random) 34 | biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"]) 35 | words = {"monster_name": name, "monster_species": species} 36 | for key, value in biography_text.items(): 37 | if key == "text": 38 | continue 39 | 40 | options = seeded_random.sample(TEXT_OPTIONS[key], value) 41 | words[key] = " ".join(options) 42 | 43 | embed = discord.Embed( 44 | title=f"{name}'s Biography", 45 | color=seeded_random.choice([Colours.orange, Colours.purple]), 46 | description=biography_text["text"].format_map(words), 47 | ) 48 | 49 | await ctx.send(embed=embed) 50 | 51 | 52 | async def setup(bot: Bot) -> None: 53 | """Load the Monster Bio Cog.""" 54 | await bot.add_cog(MonsterBio()) 55 | -------------------------------------------------------------------------------- /bot/exts/holidays/halloween/spookygif.py: -------------------------------------------------------------------------------- 1 | 2 | import discord 3 | from discord.ext import commands 4 | from pydis_core.utils.logging import get_logger 5 | 6 | from bot.bot import Bot 7 | from bot.constants import Colours, Tokens 8 | 9 | log = get_logger(__name__) 10 | 11 | API_URL = "http://api.giphy.com/v1/gifs/random" 12 | 13 | 14 | class SpookyGif(commands.Cog): 15 | """A cog to fetch a random spooky gif from the web!""" 16 | 17 | def __init__(self, bot: Bot): 18 | self.bot = bot 19 | 20 | @commands.command(name="spookygif", aliases=("sgif", "scarygif")) 21 | async def spookygif(self, ctx: commands.Context) -> None: 22 | """Fetches a random gif from the GIPHY API and responds with it.""" 23 | async with ctx.typing(): 24 | params = {"api_key": Tokens.giphy.get_secret_value(), "tag": "halloween", "rating": "g"} 25 | # Make a GET request to the Giphy API to get a random halloween gif. 26 | async with self.bot.http_session.get(API_URL, params=params) as resp: 27 | data = await resp.json() 28 | url = data["data"]["images"]["downsized"]["url"] 29 | 30 | embed = discord.Embed(title="A spooooky gif!", colour=Colours.purple) 31 | embed.set_image(url=url) 32 | 33 | await ctx.send(embed=embed) 34 | 35 | 36 | async def setup(bot: Bot) -> None: 37 | """Spooky GIF Cog load.""" 38 | if not Tokens.giphy: 39 | log.warning("No Giphy token. Not loading SpookyGif cog.") 40 | return 41 | await bot.add_cog(SpookyGif(bot)) 42 | -------------------------------------------------------------------------------- /bot/exts/holidays/halloween/spookyrating.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | import json 3 | import random 4 | from pathlib import Path 5 | 6 | import discord 7 | from discord.ext import commands 8 | from pydis_core.utils.logging import get_logger 9 | 10 | from bot.bot import Bot 11 | from bot.constants import Colours 12 | 13 | log = get_logger(__name__) 14 | 15 | data: dict[str, dict[str, str]] = json.loads( 16 | Path("bot/resources/holidays/halloween/spooky_rating.json").read_text("utf8") 17 | ) 18 | SPOOKY_DATA = sorted((int(key), value) for key, value in data.items()) 19 | 20 | 21 | class SpookyRating(commands.Cog): 22 | """A cog for calculating one's spooky rating.""" 23 | 24 | def __init__(self): 25 | self.local_random = random.Random() 26 | 27 | @commands.command() 28 | @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) 29 | async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None: 30 | """ 31 | Calculates the spooky rating of someone. 32 | 33 | Any user will always yield the same result, no matter who calls the command 34 | """ 35 | if who is None: 36 | who = ctx.author 37 | 38 | # This ensures that the same result over multiple runtimes 39 | self.local_random.seed(who.id) 40 | spooky_percent = self.local_random.randint(1, 101) 41 | 42 | # We need the -1 due to how bisect returns the point 43 | # see the documentation for further detail 44 | # https://docs.python.org/3/library/bisect.html#bisect.bisect 45 | index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1 46 | 47 | _, data = SPOOKY_DATA[index] 48 | 49 | embed = discord.Embed( 50 | title=data["title"], 51 | description=f"{who} scored {spooky_percent}%!", 52 | color=Colours.orange 53 | ) 54 | embed.add_field( 55 | name="A whisper from Satan", 56 | value=data["text"] 57 | ) 58 | embed.set_thumbnail( 59 | url=data["image"] 60 | ) 61 | 62 | await ctx.send(embed=embed) 63 | 64 | 65 | async def setup(bot: Bot) -> None: 66 | """Load the Spooky Rating Cog.""" 67 | await bot.add_cog(SpookyRating()) 68 | -------------------------------------------------------------------------------- /bot/exts/holidays/hanukkah/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/holidays/hanukkah/__init__.py -------------------------------------------------------------------------------- /bot/exts/holidays/pride/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/holidays/pride/__init__.py -------------------------------------------------------------------------------- /bot/exts/holidays/pride/drag_queen_name.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from pathlib import Path 4 | 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | 10 | log = get_logger(__name__) 11 | 12 | NAMES = json.loads(Path("bot/resources/holidays/pride/drag_queen_names.json").read_text("utf8")) 13 | 14 | 15 | class DragNames(commands.Cog): 16 | """Gives a random drag queen name!""" 17 | 18 | @commands.command(name="dragname", aliases=("dragqueenname", "queenme")) 19 | async def dragname(self, ctx: commands.Context) -> None: 20 | """Sends a message with a drag queen name.""" 21 | await ctx.send(random.choice(NAMES)) 22 | 23 | 24 | async def setup(bot: Bot) -> None: 25 | """Load the Drag Names Cog.""" 26 | await bot.add_cog(DragNames()) 27 | -------------------------------------------------------------------------------- /bot/exts/holidays/pride/pride_anthem.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from pathlib import Path 4 | 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | 10 | log = get_logger(__name__) 11 | 12 | VIDEOS = json.loads(Path("bot/resources/holidays/pride/anthems.json").read_text("utf8")) 13 | 14 | 15 | class PrideAnthem(commands.Cog): 16 | """Embed a random youtube video for a gay anthem!""" 17 | 18 | def get_video(self, genre: str | None = None) -> dict: 19 | """ 20 | Picks a random anthem from the list. 21 | 22 | If `genre` is supplied, it will pick from videos attributed with that genre. 23 | If none can be found, it will log this as well as provide that information to the user. 24 | """ 25 | if not genre: 26 | return random.choice(VIDEOS) 27 | 28 | songs = [song for song in VIDEOS if genre.casefold() in song["genre"]] 29 | try: 30 | return random.choice(songs) 31 | except IndexError: 32 | log.info("No videos for that genre.") 33 | 34 | @commands.command(name="prideanthem", aliases=("anthem", "pridesong")) 35 | async def prideanthem(self, ctx: commands.Context, genre: str | None = None) -> None: 36 | """ 37 | Sends a message with a video of a random pride anthem. 38 | 39 | If `genre` is supplied, it will select from that genre only. 40 | """ 41 | anthem = self.get_video(genre) 42 | if anthem: 43 | await ctx.send(anthem["url"]) 44 | else: 45 | await ctx.send("I couldn't find a video, sorry!") 46 | 47 | 48 | async def setup(bot: Bot) -> None: 49 | """Load the Pride Anthem Cog.""" 50 | await bot.add_cog(PrideAnthem()) 51 | -------------------------------------------------------------------------------- /bot/exts/holidays/pride/pride_facts.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from datetime import UTC, datetime 4 | from pathlib import Path 5 | 6 | import discord 7 | from discord.ext import commands 8 | from pydis_core.utils.logging import get_logger 9 | 10 | from bot.bot import Bot 11 | from bot.constants import Channels, Colours, Month 12 | from bot.utils.decorators import seasonal_task 13 | 14 | log = get_logger(__name__) 15 | 16 | FACTS = json.loads(Path("bot/resources/holidays/pride/facts.json").read_text("utf8")) 17 | 18 | 19 | class PrideFacts(commands.Cog): 20 | """Provides a new fact every day during the Pride season!""" 21 | 22 | def __init__(self, bot: Bot): 23 | self.bot = bot 24 | self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily()) 25 | 26 | @seasonal_task(Month.JUNE) 27 | async def send_pride_fact_daily(self) -> None: 28 | """Background task to post the daily pride fact every day.""" 29 | channel = self.bot.get_channel(Channels.sir_lancebot_playground) 30 | await self.send_select_fact(channel, datetime.now(tz=UTC).day) 31 | 32 | async def send_select_fact(self, target: discord.abc.Messageable, day_num: int) -> None: 33 | """Provides the fact for the specified day.""" 34 | try: 35 | await target.send(embed=self.get_fact_embed(day_num)) 36 | except IndexError: 37 | await target.send(f"Day {day_num} is not supported") 38 | return 39 | 40 | @commands.command(name="pridefact", aliases=("pridefacts",)) 41 | async def pridefact(self, ctx: commands.Context, option: int | str | None = None) -> None: 42 | """ 43 | Sends a message with a pride fact of the day. 44 | 45 | "option" is an optional setting, which has two has two accepted values: 46 | - "random": a random previous fact will be provided. 47 | - If a option is a number (1-30), the fact for that given day of June is returned. 48 | """ 49 | if not option: 50 | await self.send_select_fact(ctx, datetime.now(tz=UTC).day) 51 | elif isinstance(option, int): 52 | await self.send_select_fact(ctx, option) 53 | elif option.lower().startswith("rand"): 54 | await ctx.send(embed=self.get_fact_embed()) 55 | else: 56 | await ctx.send(f"Could not parse option {option}") 57 | 58 | @staticmethod 59 | def get_fact_embed(day_num: int | None = None) -> discord.Embed: 60 | """ 61 | Makes a embed for the fact on the given day_num to be sent. 62 | 63 | if day_num is not set, a random fact is selected. 64 | """ 65 | fact = FACTS[day_num-1] if day_num else random.choice(FACTS) 66 | return discord.Embed( 67 | colour=Colours.pink, 68 | title=f"Day {day_num}'s pride fact!" if day_num else "Random pride fact!", 69 | description=fact 70 | ) 71 | 72 | 73 | async def setup(bot: Bot) -> None: 74 | """Load the Pride Facts Cog.""" 75 | await bot.add_cog(PrideFacts(bot)) 76 | -------------------------------------------------------------------------------- /bot/exts/holidays/valentines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/holidays/valentines/__init__.py -------------------------------------------------------------------------------- /bot/exts/holidays/valentines/movie_generator.py: -------------------------------------------------------------------------------- 1 | import random 2 | from os import environ 3 | 4 | import discord 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | 10 | TMDB_API_KEY = environ.get("TMDB_API_KEY") 11 | 12 | log = get_logger(__name__) 13 | 14 | 15 | class RomanceMovieFinder(commands.Cog): 16 | """A Cog that returns a random romance movie suggestion to a user.""" 17 | 18 | def __init__(self, bot: Bot): 19 | self.bot = bot 20 | 21 | @commands.command(name="romancemovie") 22 | async def romance_movie(self, ctx: commands.Context) -> None: 23 | """Randomly selects a romance movie and displays information about it.""" 24 | # Selecting a random int to parse it to the page parameter 25 | random_page = random.randint(0, 20) 26 | # TMDB api params 27 | params = { 28 | "api_key": TMDB_API_KEY, 29 | "language": "en-US", 30 | "sort_by": "popularity.desc", 31 | "include_adult": "false", 32 | "include_video": "false", 33 | "page": random_page, 34 | "with_genres": "10749" 35 | } 36 | # The api request url 37 | request_url = "https://api.themoviedb.org/3/discover/movie" 38 | async with self.bot.http_session.get(request_url, params=params) as resp: 39 | # Trying to load the json file returned from the api 40 | try: 41 | data = await resp.json() 42 | # Selecting random result from results object in the json file 43 | selected_movie = random.choice(data["results"]) 44 | 45 | embed = discord.Embed( 46 | title=f":sparkling_heart: {selected_movie['title']} :sparkling_heart:", 47 | description=selected_movie["overview"], 48 | ) 49 | embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}") 50 | embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"]) 51 | embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"]) 52 | embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") 53 | embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") 54 | await ctx.send(embed=embed) 55 | except KeyError: 56 | warning_message = ( 57 | "A KeyError was raised while fetching information on the movie. The API service" 58 | " could be unavailable or the API key could be set incorrectly." 59 | ) 60 | embed = discord.Embed(title=warning_message) 61 | log.warning(warning_message) 62 | await ctx.send(embed=embed) 63 | 64 | 65 | async def setup(bot: Bot) -> None: 66 | """Load the Romance movie Cog.""" 67 | await bot.add_cog(RomanceMovieFinder(bot)) 68 | -------------------------------------------------------------------------------- /bot/exts/holidays/valentines/myvalenstate.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import json 3 | from pathlib import Path 4 | from random import choice 5 | 6 | import discord 7 | from discord.ext import commands 8 | from pydis_core.utils.logging import get_logger 9 | 10 | from bot.bot import Bot 11 | from bot.constants import Colours 12 | 13 | log = get_logger(__name__) 14 | 15 | STATES = json.loads(Path("bot/resources/holidays/valentines/valenstates.json").read_text("utf8")) 16 | 17 | 18 | class MyValenstate(commands.Cog): 19 | """A Cog to find your most likely Valentine's vacation destination.""" 20 | 21 | def levenshtein(self, source: str, goal: str) -> int: 22 | """Calculates the Levenshtein Distance between source and goal.""" 23 | if len(source) < len(goal): 24 | return self.levenshtein(goal, source) 25 | if len(source) == 0: 26 | return len(goal) 27 | if len(goal) == 0: 28 | return len(source) 29 | 30 | pre_row = list(range(len(source) + 1)) 31 | for i, source_c in enumerate(source): 32 | cur_row = [i + 1] 33 | for j, goal_c in enumerate(goal): 34 | if source_c != goal_c: 35 | cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]) + 1) 36 | else: 37 | cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j])) 38 | pre_row = cur_row 39 | return pre_row[-1] 40 | 41 | @commands.command() 42 | async def myvalenstate(self, ctx: commands.Context, *, name: str | None = None) -> None: 43 | """Find the vacation spot(s) with the most matching characters to the invoking user.""" 44 | eq_chars = collections.defaultdict(int) 45 | if name is None: 46 | author = ctx.author.name.lower().replace(" ", "") 47 | else: 48 | author = name.lower().replace(" ", "") 49 | 50 | for state in STATES: 51 | lower_state = state.lower().replace(" ", "") 52 | eq_chars[state] = self.levenshtein(author, lower_state) 53 | 54 | matches = [x for x, y in eq_chars.items() if y == min(eq_chars.values())] 55 | valenstate = choice(matches) 56 | matches.remove(valenstate) 57 | 58 | embed_title = "But there are more!" 59 | if len(matches) > 1: 60 | leftovers = f"{', '.join(matches[:-2])}, and {matches[-1]}" 61 | embed_text = f"You have {len(matches)} more matches, these being {leftovers}." 62 | elif len(matches) == 1: 63 | embed_title = "But there's another one!" 64 | embed_text = f"You have another match, this being {matches[0]}." 65 | else: 66 | embed_title = "You have a true match!" 67 | embed_text = "This state is your true Valenstate! There are no states that would suit you better" 68 | 69 | embed = discord.Embed( 70 | title=f"Your Valenstate is {valenstate} \u2764", 71 | description=STATES[valenstate]["text"], 72 | colour=Colours.pink 73 | ) 74 | embed.add_field(name=embed_title, value=embed_text) 75 | embed.set_image(url=STATES[valenstate]["flag"]) 76 | await ctx.send(embed=embed) 77 | 78 | 79 | async def setup(bot: Bot) -> None: 80 | """Load the Valenstate Cog.""" 81 | await bot.add_cog(MyValenstate()) 82 | -------------------------------------------------------------------------------- /bot/exts/holidays/valentines/pickuplines.py: -------------------------------------------------------------------------------- 1 | import random 2 | from json import loads 3 | from pathlib import Path 4 | 5 | import discord 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.constants import Colours 11 | 12 | log = get_logger(__name__) 13 | 14 | PICKUP_LINES = loads(Path("bot/resources/holidays/valentines/pickup_lines.json").read_text("utf8")) 15 | 16 | 17 | class PickupLine(commands.Cog): 18 | """A cog that gives random cheesy pickup lines.""" 19 | 20 | @commands.command() 21 | async def pickupline(self, ctx: commands.Context) -> None: 22 | """ 23 | Gives you a random pickup line. 24 | 25 | Note that most of them are very cheesy. 26 | """ 27 | random_line = random.choice(PICKUP_LINES["lines"]) 28 | embed = discord.Embed( 29 | title=":cheese: Your pickup line :cheese:", 30 | description=random_line["line"], 31 | color=Colours.pink 32 | ) 33 | embed.set_thumbnail( 34 | url=random_line.get("image", PICKUP_LINES["placeholder"]) 35 | ) 36 | await ctx.send(embed=embed) 37 | 38 | 39 | async def setup(bot: Bot) -> None: 40 | """Load the Pickup lines Cog.""" 41 | await bot.add_cog(PickupLine()) 42 | -------------------------------------------------------------------------------- /bot/exts/holidays/valentines/savethedate.py: -------------------------------------------------------------------------------- 1 | import random 2 | from json import loads 3 | from pathlib import Path 4 | 5 | import discord 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.constants import Colours 11 | 12 | log = get_logger(__name__) 13 | 14 | HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] 15 | 16 | VALENTINES_DATES = loads(Path("bot/resources/holidays/valentines/date_ideas.json").read_text("utf8")) 17 | 18 | 19 | class SaveTheDate(commands.Cog): 20 | """A cog that gives random suggestion for a Valentine's date.""" 21 | 22 | @commands.command() 23 | async def savethedate(self, ctx: commands.Context) -> None: 24 | """Gives you ideas for what to do on a date with your valentine.""" 25 | random_date = random.choice(VALENTINES_DATES["ideas"]) 26 | emoji_1 = random.choice(HEART_EMOJIS) 27 | emoji_2 = random.choice(HEART_EMOJIS) 28 | embed = discord.Embed( 29 | title=f"{emoji_1}{random_date['name']}{emoji_2}", 30 | description=f"{random_date['description']}", 31 | colour=Colours.pink 32 | ) 33 | await ctx.send(embed=embed) 34 | 35 | 36 | async def setup(bot: Bot) -> None: 37 | """Load the Save the date Cog.""" 38 | await bot.add_cog(SaveTheDate()) 39 | -------------------------------------------------------------------------------- /bot/exts/holidays/valentines/whoisvalentine.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from random import choice 4 | 5 | import discord 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.constants import Colours 11 | 12 | log = get_logger(__name__) 13 | 14 | FACTS = json.loads(Path("bot/resources/holidays/valentines/valentine_facts.json").read_text("utf8")) 15 | 16 | 17 | class ValentineFacts(commands.Cog): 18 | """A Cog for displaying facts about Saint Valentine.""" 19 | 20 | @commands.command(aliases=("whoisvalentine", "saint_valentine")) 21 | async def who_is_valentine(self, ctx: commands.Context) -> None: 22 | """Displays info about Saint Valentine.""" 23 | embed = discord.Embed( 24 | title="Who is Saint Valentine?", 25 | description=FACTS["whois"], 26 | color=Colours.pink 27 | ) 28 | embed.set_thumbnail( 29 | url="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_" 30 | "facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg" 31 | ) 32 | 33 | await ctx.send(embed=embed) 34 | 35 | @commands.command() 36 | async def valentine_fact(self, ctx: commands.Context) -> None: 37 | """Shows a random fact about Valentine's Day.""" 38 | embed = discord.Embed( 39 | title=choice(FACTS["titles"]), 40 | description=choice(FACTS["text"]), 41 | color=Colours.pink 42 | ) 43 | 44 | await ctx.send(embed=embed) 45 | 46 | 47 | async def setup(bot: Bot) -> None: 48 | """Load the Who is Valentine Cog.""" 49 | await bot.add_cog(ValentineFacts()) 50 | -------------------------------------------------------------------------------- /bot/exts/utilities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/exts/utilities/__init__.py -------------------------------------------------------------------------------- /bot/exts/utilities/cheatsheet.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | from urllib.parse import quote_plus 4 | 5 | from discord import Embed 6 | from discord.ext import commands 7 | from discord.ext.commands import BucketType, Context 8 | 9 | from bot import constants 10 | from bot.bot import Bot 11 | from bot.constants import Categories, Channels, Colours, ERROR_REPLIES 12 | from bot.utils.decorators import whitelist_override 13 | 14 | ERROR_MESSAGE = f""" 15 | Unknown cheat sheet. Please try to reformulate your query. 16 | 17 | **Examples**: 18 | ```md 19 | {constants.Client.prefix}cht read json 20 | {constants.Client.prefix}cht hello world 21 | {constants.Client.prefix}cht lambda 22 | ``` 23 | If the problem persists send a message in <#{Channels.dev_contrib}> 24 | """ 25 | 26 | URL = "https://cheat.sh/python/{search}" 27 | ESCAPE_TT = str.maketrans({"`": "\\`"}) 28 | ANSI_RE = re.compile(r"\x1b\[.*?m") 29 | # We need to pass headers as curl otherwise it would default to aiohttp which would return raw html. 30 | HEADERS = {"User-Agent": "curl/7.68.0"} 31 | 32 | 33 | class CheatSheet(commands.Cog): 34 | """Commands that sends a result of a cht.sh search in code blocks.""" 35 | 36 | def __init__(self, bot: Bot): 37 | self.bot = bot 38 | 39 | @staticmethod 40 | def fmt_error_embed() -> Embed: 41 | """ 42 | Format the Error Embed. 43 | 44 | If the cht.sh search returned 404, overwrite it to send a custom error embed. 45 | link -> https://github.com/chubin/cheat.sh/issues/198 46 | """ 47 | embed = Embed( 48 | title=random.choice(ERROR_REPLIES), 49 | description=ERROR_MESSAGE, 50 | colour=Colours.soft_red 51 | ) 52 | return embed 53 | 54 | def result_fmt(self, url: str, body_text: str) -> tuple[bool, str | Embed]: 55 | """Format Result.""" 56 | if body_text.startswith("# 404 NOT FOUND"): 57 | embed = self.fmt_error_embed() 58 | return True, embed 59 | 60 | body_space = min(1986 - len(url), 1000) 61 | 62 | if len(body_text) > body_space: 63 | description = ( 64 | f"**Result Of cht.sh**\n" 65 | f"```python\n{body_text[:body_space]}\n" 66 | f"... (truncated - too many lines)\n```\n" 67 | f"Full results: {url} " 68 | ) 69 | else: 70 | description = ( 71 | f"**Result Of cht.sh**\n" 72 | f"```python\n{body_text}\n```\n" 73 | f"{url}" 74 | ) 75 | return False, description 76 | 77 | @commands.command( 78 | name="cheat", 79 | aliases=("cht.sh", "cheatsheet", "cheat-sheet", "cht"), 80 | ) 81 | @commands.cooldown(1, 10, BucketType.user) 82 | @whitelist_override(categories=[Categories.python_help_system]) 83 | async def cheat_sheet(self, ctx: Context, *search_terms: str) -> None: 84 | """ 85 | Search cheat.sh. 86 | 87 | Gets a post from https://cheat.sh/python/ by default. 88 | Usage: 89 | --> .cht read json 90 | """ 91 | async with ctx.typing(): 92 | search_string = quote_plus(" ".join(search_terms)) 93 | 94 | async with self.bot.http_session.get( 95 | URL.format(search=search_string), headers=HEADERS 96 | ) as response: 97 | result = ANSI_RE.sub("", await response.text()).translate(ESCAPE_TT) 98 | 99 | is_embed, description = self.result_fmt( 100 | URL.format(search=search_string), 101 | result 102 | ) 103 | if is_embed: 104 | await ctx.send(embed=description) 105 | else: 106 | await ctx.send(content=description) 107 | 108 | 109 | async def setup(bot: Bot) -> None: 110 | """Load the CheatSheet cog.""" 111 | await bot.add_cog(CheatSheet(bot)) 112 | -------------------------------------------------------------------------------- /bot/exts/utilities/logging.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog 2 | from pydis_core.utils.logging import get_logger 3 | 4 | from bot import constants 5 | from bot.bot import Bot 6 | 7 | log = get_logger(__name__) 8 | 9 | 10 | class Logging(Cog): 11 | """Debug logging module.""" 12 | 13 | def __init__(self, bot: Bot): 14 | self.bot = bot 15 | 16 | async def cog_load(self) -> None: 17 | """Announce our presence to the configured dev-log channel after checking channel constants.""" 18 | await self.check_channels() 19 | await self.bot.log_to_dev_log( 20 | title=self.bot.name, 21 | details="Connected!", 22 | ) 23 | 24 | async def check_channels(self) -> None: 25 | """Verifies that all channel constants refer to channels which exist.""" 26 | if constants.Client.debug: 27 | log.info("Skipping Channels Check.") 28 | return 29 | 30 | all_channels_ids = [channel.id for channel in self.bot.get_all_channels()] 31 | for name, channel_id in vars(constants.Channels).items(): 32 | if name.startswith("_"): 33 | continue 34 | if channel_id not in all_channels_ids: 35 | log.error(f'Channel "{name}" with ID {channel_id} missing') 36 | 37 | 38 | async def setup(bot: Bot) -> None: 39 | """Load the Logging cog.""" 40 | await bot.add_cog(Logging(bot)) 41 | -------------------------------------------------------------------------------- /bot/exts/utilities/pythonfacts.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import discord 4 | from discord.ext import commands 5 | 6 | from bot.bot import Bot 7 | from bot.constants import Colours 8 | 9 | with open("bot/resources/utilities/python_facts.txt") as file: 10 | FACTS = itertools.cycle(list(file)) 11 | 12 | COLORS = itertools.cycle([Colours.python_blue, Colours.python_yellow]) 13 | PYFACTS_DISCUSSION = "https://github.com/python-discord/meta/discussions/93" 14 | 15 | 16 | class PythonFacts(commands.Cog): 17 | """Sends a random fun fact about Python.""" 18 | 19 | @commands.command(name="pythonfact", aliases=("pyfact",)) 20 | async def get_python_fact(self, ctx: commands.Context) -> None: 21 | """Sends a Random fun fact about Python.""" 22 | embed = discord.Embed( 23 | title="Python Facts", 24 | description=next(FACTS), 25 | colour=next(COLORS) 26 | ) 27 | embed.add_field( 28 | name="Suggestions", 29 | value=f"Suggest more facts [here!]({PYFACTS_DISCUSSION})" 30 | ) 31 | await ctx.send(embed=embed) 32 | 33 | 34 | async def setup(bot: Bot) -> None: 35 | """Load the PythonFacts Cog.""" 36 | await bot.add_cog(PythonFacts()) 37 | -------------------------------------------------------------------------------- /bot/exts/utilities/realpython.py: -------------------------------------------------------------------------------- 1 | from html import unescape 2 | from urllib.parse import quote_plus 3 | 4 | from discord import Embed 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | from bot.constants import Colours 10 | 11 | logger = get_logger(__name__) 12 | 13 | API_ROOT = "https://realpython.com/search/api/v1/" 14 | ARTICLE_URL = "https://realpython.com{article_url}" 15 | SEARCH_URL = "https://realpython.com/search?q={user_search}" 16 | HOME_URL = "https://realpython.com/" 17 | 18 | ERROR_EMBED = Embed( 19 | title="Error while searching Real Python", 20 | description="There was an error while trying to reach Real Python. Please try again shortly.", 21 | color=Colours.soft_red, 22 | ) 23 | 24 | 25 | class RealPython(commands.Cog): 26 | """User initiated command to search for a Real Python article.""" 27 | 28 | def __init__(self, bot: Bot): 29 | self.bot = bot 30 | 31 | @commands.command(aliases=["rp"]) 32 | @commands.cooldown(1, 10, commands.cooldowns.BucketType.user) 33 | async def realpython( 34 | self, 35 | ctx: commands.Context, 36 | amount: int | None = 5, 37 | *, 38 | user_search: str | None = None 39 | ) -> None: 40 | """ 41 | Send some articles from RealPython that match the search terms. 42 | 43 | By default, the top 5 matches are sent. This can be overwritten to 44 | a number between 1 and 5 by specifying an amount before the search query. 45 | If no search query is specified by the user, the home page is sent. 46 | """ 47 | if user_search is None: 48 | home_page_embed = Embed(title="Real Python Home Page", url=HOME_URL, colour=Colours.orange) 49 | 50 | await ctx.send(embed=home_page_embed) 51 | 52 | return 53 | 54 | if not 1 <= amount <= 5: 55 | await ctx.send("`amount` must be between 1 and 5 (inclusive).") 56 | return 57 | 58 | params = {"q": user_search, "limit": amount, "kind": "article"} 59 | async with self.bot.http_session.get(url=API_ROOT, params=params) as response: 60 | if response.status != 200: 61 | logger.error( 62 | f"Unexpected status code {response.status} from Real Python" 63 | ) 64 | await ctx.send(embed=ERROR_EMBED) 65 | return 66 | 67 | data = await response.json() 68 | 69 | articles = data["results"] 70 | 71 | if len(articles) == 0: 72 | no_articles = Embed( 73 | title=f"No articles found for '{user_search}'", color=Colours.soft_red 74 | ) 75 | await ctx.send(embed=no_articles) 76 | return 77 | 78 | if len(articles) == 1: 79 | article_description = "Here is the result:" 80 | else: 81 | article_description = f"Here are the top {len(articles)} results:" 82 | 83 | article_embed = Embed( 84 | title="Search results - Real Python", 85 | url=SEARCH_URL.format(user_search=quote_plus(user_search)), 86 | description=article_description, 87 | color=Colours.orange, 88 | ) 89 | 90 | for article in articles: 91 | article_embed.add_field( 92 | name=unescape(article["title"]), 93 | value=ARTICLE_URL.format(article_url=article["url"]), 94 | inline=False, 95 | ) 96 | article_embed.set_footer(text="Click the links to go to the articles.") 97 | 98 | await ctx.send(embed=article_embed) 99 | 100 | 101 | async def setup(bot: Bot) -> None: 102 | """Load the Real Python Cog.""" 103 | await bot.add_cog(RealPython(bot)) 104 | -------------------------------------------------------------------------------- /bot/exts/utilities/rfc.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pydantic 4 | from discord import Embed 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | from bot.constants import Colours, Roles 10 | from bot.utils.decorators import whitelist_override 11 | 12 | logger = get_logger(__name__) 13 | 14 | API_URL = "https://datatracker.ietf.org/doc/rfc{rfc_id}/doc.json" 15 | DOCUMENT_URL = "https://datatracker.ietf.org/doc/rfc{rfc_id}" 16 | 17 | 18 | class RfcDocument(pydantic.BaseModel): 19 | """Represents an RFC document.""" 20 | 21 | title: str 22 | description: str 23 | revisions: int 24 | created: datetime.datetime 25 | 26 | 27 | class Rfc(commands.Cog): 28 | """Retrieves RFCs by their ID.""" 29 | 30 | def __init__(self, bot: Bot): 31 | self.bot = bot 32 | self.cache: dict[int, RfcDocument] = {} 33 | 34 | async def retrieve_data(self, rfc_id: int) -> RfcDocument | None: 35 | """Retrieves the RFC from the cache or API, and adds to the cache if it does not exist.""" 36 | if rfc_id in self.cache: 37 | return self.cache[rfc_id] 38 | 39 | async with self.bot.http_session.get(API_URL.format(rfc_id=rfc_id)) as resp: 40 | if resp.status != 200: 41 | return None 42 | 43 | data = await resp.json() 44 | 45 | description = data["abstract"] 46 | 47 | revisions = data["rev"] or len(data["rev_history"]) 48 | 49 | raw_date = data["rev_history"][0]["published"] 50 | creation_date = datetime.datetime.strptime(raw_date, "%Y-%m-%dT%H:%M:%S%z") 51 | 52 | document = RfcDocument( 53 | title=data["title"], 54 | description=description, 55 | revisions=revisions, 56 | created=creation_date, 57 | ) 58 | 59 | self.cache[rfc_id] = document 60 | 61 | return document 62 | 63 | @commands.cooldown(1, 5, commands.BucketType.user) 64 | @commands.command() 65 | @commands.guild_only() 66 | @whitelist_override(roles=(Roles.everyone,)) 67 | async def rfc(self, ctx: commands.Context, rfc_id: int) -> None: 68 | """Sends the corresponding RFC with the given ID.""" 69 | document = await self.retrieve_data(rfc_id) 70 | 71 | if not document: 72 | embed = Embed( 73 | title="RFC not found", 74 | description=f"RFC {rfc_id} does not exist.", 75 | colour=Colours.soft_red, 76 | ) 77 | 78 | await ctx.send(embed=embed) 79 | 80 | return 81 | 82 | logger.info(f"Fetching RFC {rfc_id}") 83 | 84 | embed = Embed( 85 | title=f"RFC {rfc_id} - {document.title}", 86 | description=document.description, 87 | colour=Colours.gold, 88 | url=DOCUMENT_URL.format(rfc_id=rfc_id), 89 | ) 90 | 91 | embed.add_field( 92 | name="Current Revision", 93 | value=document.revisions, 94 | ) 95 | 96 | embed.add_field( 97 | name="Created", 98 | value=document.created.strftime("%Y-%m-%d"), 99 | ) 100 | await ctx.send(embed=embed) 101 | 102 | 103 | async def setup(bot: Bot) -> None: 104 | """Load the Rfc cog.""" 105 | await bot.add_cog(Rfc(bot)) 106 | -------------------------------------------------------------------------------- /bot/exts/utilities/stackoverflow.py: -------------------------------------------------------------------------------- 1 | from html import unescape 2 | from urllib.parse import quote_plus 3 | 4 | from discord import Embed, HTTPException 5 | from discord.ext import commands 6 | from pydis_core.utils.logging import get_logger 7 | 8 | from bot.bot import Bot 9 | from bot.constants import Colours, Emojis 10 | 11 | logger = get_logger(__name__) 12 | 13 | BASE_URL = "https://api.stackexchange.com/2.2/search/advanced" 14 | SO_PARAMS = { 15 | "order": "desc", 16 | "sort": "activity", 17 | "site": "stackoverflow" 18 | } 19 | SEARCH_URL = "https://stackoverflow.com/search?q={query}" 20 | ERR_EMBED = Embed( 21 | title="Error in fetching results from Stackoverflow", 22 | description=( 23 | "Sorry, there was en error while trying to fetch data from the Stackoverflow website. Please try again in some " 24 | "time. If this issue persists, please contact the staff or send a message in #dev-contrib." 25 | ), 26 | color=Colours.soft_red 27 | ) 28 | 29 | 30 | class Stackoverflow(commands.Cog): 31 | """Contains command to interact with stackoverflow from discord.""" 32 | 33 | def __init__(self, bot: Bot): 34 | self.bot = bot 35 | 36 | @commands.command(aliases=["so"]) 37 | @commands.cooldown(1, 15, commands.cooldowns.BucketType.user) 38 | async def stackoverflow(self, ctx: commands.Context, *, search_query: str) -> None: 39 | """Sends the top 5 results of a search query from stackoverflow.""" 40 | params = SO_PARAMS | {"q": search_query} 41 | async with self.bot.http_session.get(url=BASE_URL, params=params) as response: 42 | if response.status == 200: 43 | data = await response.json() 44 | else: 45 | logger.error(f"Status code is not 200, it is {response.status}") 46 | await ctx.send(embed=ERR_EMBED) 47 | return 48 | if not data["items"]: 49 | no_search_result = Embed( 50 | title=f"No search results found for {search_query}", 51 | color=Colours.soft_red 52 | ) 53 | await ctx.send(embed=no_search_result) 54 | return 55 | 56 | top5 = data["items"][:5] 57 | encoded_search_query = quote_plus(search_query) 58 | embed = Embed( 59 | title="Search results - Stackoverflow", 60 | url=SEARCH_URL.format(query=encoded_search_query), 61 | description=f"Here are the top {len(top5)} results:", 62 | color=Colours.orange 63 | ) 64 | for item in top5: 65 | embed.add_field( 66 | name=unescape(item["title"]), 67 | value=( 68 | f"[{Emojis.reddit_upvote} {item['score']} " 69 | f"{Emojis.stackoverflow_views} {item['view_count']} " 70 | f"{Emojis.reddit_comments} {item['answer_count']} " 71 | f"{Emojis.stackoverflow_tag} {', '.join(item['tags'][:3])}]" 72 | f"({item['link']})" 73 | ), 74 | inline=False) 75 | embed.set_footer(text="View the original link for more results.") 76 | try: 77 | await ctx.send(embed=embed) 78 | except HTTPException: 79 | search_query_too_long = Embed( 80 | title="Your search query is too long, please try shortening your search query", 81 | color=Colours.soft_red 82 | ) 83 | await ctx.send(embed=search_query_too_long) 84 | 85 | 86 | async def setup(bot: Bot) -> None: 87 | """Load the Stackoverflow Cog.""" 88 | await bot.add_cog(Stackoverflow(bot)) 89 | -------------------------------------------------------------------------------- /bot/exts/utilities/timed.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from time import perf_counter 3 | 4 | from discord import Message 5 | from discord.ext import commands 6 | 7 | from bot.bot import Bot 8 | 9 | 10 | class TimedCommands(commands.Cog): 11 | """Time the command execution of a command.""" 12 | 13 | @staticmethod 14 | async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context: 15 | """Get a new execution context for a command.""" 16 | msg: Message = copy(ctx.message) 17 | msg.content = f"{ctx.prefix}{command}" 18 | 19 | return await ctx.bot.get_context(msg) 20 | 21 | @commands.command(name="timed", aliases=("time", "t")) 22 | async def timed(self, ctx: commands.Context, *, command: str) -> None: 23 | """Time the command execution of a command.""" 24 | new_ctx = await self.create_execution_context(ctx, command) 25 | 26 | ctx.subcontext = new_ctx 27 | 28 | if not ctx.subcontext.command: 29 | help_command = f"{ctx.prefix}help" 30 | error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands." 31 | 32 | await ctx.send(error) 33 | return 34 | 35 | if new_ctx.command.qualified_name == "timed": 36 | await ctx.send("You are not allowed to time the execution of the `timed` command.") 37 | return 38 | 39 | t_start = perf_counter() 40 | await new_ctx.command.invoke(new_ctx) 41 | t_end = perf_counter() 42 | 43 | await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.") 44 | 45 | 46 | async def setup(bot: Bot) -> None: 47 | """Load the Timed cog.""" 48 | await bot.add_cog(TimedCommands()) 49 | -------------------------------------------------------------------------------- /bot/exts/utilities/wikipedia.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import UTC, datetime 3 | from html import unescape 4 | 5 | from discord import Color, Embed, TextChannel 6 | from discord.ext import commands 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot.bot import Bot 10 | from bot.utils import LinePaginator 11 | from bot.utils.exceptions import APIError 12 | 13 | log = get_logger(__name__) 14 | 15 | SEARCH_API = ( 16 | "https://en.wikipedia.org/w/api.php" 17 | ) 18 | WIKI_PARAMS = { 19 | "action": "query", 20 | "list": "search", 21 | "prop": "info", 22 | "inprop": "url", 23 | "utf8": "", 24 | "format": "json", 25 | "origin": "*", 26 | 27 | } 28 | WIKI_THUMBNAIL = ( 29 | "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg" 30 | "/330px-Wikipedia-logo-v2.svg.png" 31 | ) 32 | WIKI_SNIPPET_REGEX = r"(|<[^>]*>)" 33 | WIKI_SEARCH_RESULT = ( 34 | "**[{name}]({url})**\n" 35 | "{description}\n" 36 | ) 37 | 38 | 39 | class WikipediaSearch(commands.Cog): 40 | """Get info from wikipedia.""" 41 | 42 | def __init__(self, bot: Bot): 43 | self.bot = bot 44 | 45 | async def wiki_request(self, channel: TextChannel, search: str) -> list[str]: 46 | """Search wikipedia search string and return formatted first 10 pages found.""" 47 | params = WIKI_PARAMS | {"srlimit": 10, "srsearch": search} 48 | async with self.bot.http_session.get(url=SEARCH_API, params=params) as resp: 49 | if resp.status != 200: 50 | log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`") 51 | raise APIError("Wikipedia API", resp.status) 52 | 53 | raw_data = await resp.json() 54 | 55 | if not raw_data.get("query"): 56 | if error := raw_data.get("errors"): 57 | log.error(f"There was an error while communicating with the Wikipedia API: {error}") 58 | raise APIError("Wikipedia API", resp.status, error) 59 | 60 | lines = [] 61 | if raw_data["query"]["searchinfo"]["totalhits"]: 62 | for article in raw_data["query"]["search"]: 63 | line = WIKI_SEARCH_RESULT.format( 64 | name=article["title"], 65 | description=unescape( 66 | re.sub( 67 | WIKI_SNIPPET_REGEX, "", article["snippet"] 68 | ) 69 | ), 70 | url=f"https://en.wikipedia.org/?curid={article['pageid']}" 71 | ) 72 | lines.append(line) 73 | 74 | return lines 75 | 76 | @commands.cooldown(1, 10, commands.BucketType.user) 77 | @commands.command(name="wikipedia", aliases=("wiki",)) 78 | async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None: 79 | """Sends paginated top 10 results of Wikipedia search..""" 80 | contents = await self.wiki_request(ctx.channel, search) 81 | 82 | if contents: 83 | embed = Embed( 84 | title="Wikipedia Search Results", 85 | colour=Color.og_blurple() 86 | ) 87 | embed.set_thumbnail(url=WIKI_THUMBNAIL) 88 | embed.timestamp = datetime.now(tz=UTC) 89 | await LinePaginator.paginate(contents, ctx, embed, restrict_to_user=ctx.author) 90 | else: 91 | await ctx.send( 92 | "Sorry, we could not find a wikipedia article using that search term." 93 | ) 94 | 95 | 96 | async def setup(bot: Bot) -> None: 97 | """Load the WikipediaSearch cog.""" 98 | await bot.add_cog(WikipediaSearch(bot)) 99 | -------------------------------------------------------------------------------- /bot/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | import coloredlogs 8 | import sentry_sdk 9 | from pydis_core.utils import logging as core_logging 10 | from sentry_sdk.integrations.asyncio import AsyncioIntegration 11 | from sentry_sdk.integrations.logging import LoggingIntegration 12 | from sentry_sdk.integrations.redis import RedisIntegration 13 | 14 | from bot.constants import Client, GIT_SHA, Logging 15 | 16 | 17 | def setup() -> None: 18 | """Set up loggers.""" 19 | root_logger = core_logging.get_logger() 20 | 21 | if Logging.file_logs: 22 | # Set up file logging 23 | log_file = Path("logs", "sir-lancebot.log") 24 | log_file.parent.mkdir(exist_ok=True) 25 | 26 | # File handler rotates logs every 5 MB 27 | file_handler = logging.handlers.RotatingFileHandler( 28 | log_file, maxBytes=5 * (2 ** 20), backupCount=10, encoding="utf-8", 29 | ) 30 | file_handler.setFormatter(core_logging.log_format) 31 | root_logger.addHandler(file_handler) 32 | 33 | if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: 34 | coloredlogs.DEFAULT_LEVEL_STYLES = { 35 | **coloredlogs.DEFAULT_LEVEL_STYLES, 36 | "trace": {"color": 246}, 37 | "critical": {"background": "red"}, 38 | "debug": coloredlogs.DEFAULT_LEVEL_STYLES["info"], 39 | } 40 | 41 | if "COLOREDLOGS_LOG_FORMAT" not in os.environ: 42 | coloredlogs.DEFAULT_LOG_FORMAT = core_logging.log_format._fmt 43 | 44 | coloredlogs.install(level=core_logging.TRACE_LEVEL, stream=sys.stdout) 45 | 46 | root_logger.setLevel(logging.DEBUG if Logging.debug else logging.INFO) 47 | 48 | logging.getLogger("PIL").setLevel(logging.ERROR) 49 | logging.getLogger("matplotlib").setLevel(logging.ERROR) 50 | 51 | _set_trace_loggers() 52 | root_logger.info("Logging initialization complete") 53 | 54 | 55 | def setup_sentry() -> None: 56 | """Set up the Sentry logging integrations.""" 57 | sentry_logging = LoggingIntegration( 58 | level=logging.DEBUG, 59 | event_level=logging.WARNING 60 | ) 61 | 62 | sentry_sdk.init( 63 | dsn=Client.sentry_dsn, 64 | integrations=[ 65 | sentry_logging, 66 | AsyncioIntegration(), 67 | RedisIntegration(), 68 | ], 69 | release=f"bot@{GIT_SHA}", 70 | traces_sample_rate=0.5, 71 | profiles_sample_rate=0.5, 72 | ) 73 | 74 | 75 | def _set_trace_loggers() -> None: 76 | """ 77 | Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var. 78 | 79 | When the env var is a list of logger names delimited by a comma, 80 | each of the listed loggers will be set to the trace level. 81 | 82 | If this list is prefixed with a "!", all of the loggers except the listed ones will be set to the trace level. 83 | 84 | Otherwise if the env var begins with a "*", 85 | the root logger is set to the trace level and other contents are ignored. 86 | """ 87 | level_filter = Logging.trace_loggers 88 | if level_filter: 89 | if level_filter.startswith("*"): 90 | core_logging.get_logger().setLevel(core_logging.TRACE_LEVEL) 91 | 92 | elif level_filter.startswith("!"): 93 | core_logging.get_logger().setLevel(core_logging.TRACE_LEVEL) 94 | for logger_name in level_filter.strip("!,").split(","): 95 | core_logging.get_logger(logger_name).setLevel(logging.DEBUG) 96 | 97 | else: 98 | for logger_name in level_filter.strip(",").split(","): 99 | core_logging.get_logger(logger_name).setLevel(core_logging.TRACE_LEVEL) 100 | -------------------------------------------------------------------------------- /bot/resources/fun/LuckiestGuy-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/LuckiestGuy-Regular.ttf -------------------------------------------------------------------------------- /bot/resources/fun/all_cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/all_cards.png -------------------------------------------------------------------------------- /bot/resources/fun/caesar_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Caesar Cipher", 3 | "description": "**Information**\nThe Caesar Cipher, named after the Roman General Julius Caesar, is one of the simplest and most widely known encryption techniques. It is a type of substitution cipher in which each letter in the plaintext is replaced by a letter given a specific position offset in the alphabet, with the letters wrapping around both sides.\n\n**Examples**\n1) `Hello World` <=> `Khoor Zruog` where letters are shifted forwards by `3`.\n2) `Julius Caesar` <=> `Yjaxjh Rpthpg` where letters are shifted backwards by `11`." 4 | } 5 | -------------------------------------------------------------------------------- /bot/resources/fun/ducks_help_ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/ducks_help_ex.png -------------------------------------------------------------------------------- /bot/resources/fun/game_recs/chrono_trigger.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Chrono Trigger", 3 | "description": "One of the best games of all time. A brilliant story involving time-travel with loveable characters. It has a brilliant score by Yasonuri Mitsuda and artwork by Akira Toriyama. With over 20 endings and New Game+, there is a huge amount of replay value here.", 4 | "link": "https://rawg.io/games/chrono-trigger-1995", 5 | "image": "https://vignette.wikia.nocookie.net/chrono/images/2/24/Chrono_Trigger_cover.jpg", 6 | "author": "352635617709916161" 7 | } 8 | -------------------------------------------------------------------------------- /bot/resources/fun/game_recs/digimon_world.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Digimon World", 3 | "description": "A great mix of town-building and pet-raising set in the Digimon universe. With plenty of Digimon to raise and recruit to the village, this charming game will keep you occupied for a long time.", 4 | "image": "https://www.mobygames.com/images/covers/l/437308-digimon-world-playstation-front-cover.jpg", 5 | "link": "https://rawg.io/games/digimon-world", 6 | "author": "352635617709916161" 7 | } 8 | -------------------------------------------------------------------------------- /bot/resources/fun/game_recs/doom_2.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Doom II", 3 | "description": "Doom 2 was one of the first FPS games that I truly enjoyed. It offered awesome weapons, terrifying demons to kill, and a great atmosphere to do it in.", 4 | "image": "https://upload.wikimedia.org/wikipedia/en/thumb/2/29/Doom_II_-_Hell_on_Earth_Coverart.png/220px-Doom_II_-_Hell_on_Earth_Coverart.png", 5 | "link": "https://rawg.io/games/doom-ii", 6 | "author": "352635617709916161" 7 | } 8 | -------------------------------------------------------------------------------- /bot/resources/fun/game_recs/skyrim.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Elder Scrolls V: Skyrim", 3 | "description": "The latest mainline Elder Scrolls game offered a fantastic role-playing experience with untethered freedom and a great story. Offering vast mod support, the game has endless customization and replay value.", 4 | "image": "https://upload.wikimedia.org/wikipedia/en/1/15/The_Elder_Scrolls_V_Skyrim_cover.png", 5 | "link": "https://rawg.io/games/the-elder-scrolls-v-skyrim", 6 | "author": "352635617709916161" 7 | } 8 | -------------------------------------------------------------------------------- /bot/resources/fun/latex_template.txt: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | \usepackage{amsmath,amsthm,amssymb,amsfonts} 3 | \usepackage{bm} % nice bold symbols for matrices and vectors 4 | \usepackage{bbm} % bold and calligraphic numbers 5 | \usepackage[binary-units=true]{siunitx} % SI unit handling 6 | \usepackage{tikz} % from here on, to make nice diagrams with tikz 7 | \usepackage{ifthen} 8 | \usetikzlibrary{patterns} 9 | \usetikzlibrary{shapes, arrows, chains, fit, positioning, calc, decorations.pathreplacing} 10 | \begin{document} 11 | \pagenumbering{gobble} 12 | $text 13 | \end{document} 14 | -------------------------------------------------------------------------------- /bot/resources/fun/magic8ball.json: -------------------------------------------------------------------------------- 1 | [ 2 | "It is certain", 3 | "It is decidedly so", 4 | "Without a doubt", 5 | "Yes definitely", 6 | "You may rely on it", 7 | "As I see it, yes", 8 | "Most likely", 9 | "Outlook good", 10 | "Yes", 11 | "Signs point to yes", 12 | "Reply hazy try again", 13 | "Ask again later", 14 | "Better not tell you now", 15 | "Cannot predict now", 16 | "Concentrate and ask again", 17 | "Don't count on it", 18 | "My reply is no", 19 | "My sources say no", 20 | "Outlook not so good", 21 | "Very doubtful" 22 | ] 23 | -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snake_cards/backs/card_back1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snake_cards/backs/card_back1.jpg -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snake_cards/backs/card_back2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snake_cards/backs/card_back2.jpg -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snake_cards/card_bottom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snake_cards/card_bottom.png -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snake_cards/card_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snake_cards/card_frame.png -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snake_cards/card_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snake_cards/card_top.png -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snake_cards/expressway.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snake_cards/expressway.ttf -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snakes_and_ladders/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snakes_and_ladders/banner.jpg -------------------------------------------------------------------------------- /bot/resources/fun/snakes/snakes_and_ladders/board.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/fun/snakes/snakes_and_ladders/board.jpg -------------------------------------------------------------------------------- /bot/resources/fun/snakes/special_snakes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Bob Ross", 4 | "info": "Robert Norman Ross (October 29, 1942 – July 4, 1995) was an American painter, art instructor, and television host. He was the creator and host of The Joy of Painting, an instructional television program that aired from 1983 to 1994 on PBS in the United States, and also aired in Canada, Latin America, and Europe.", 5 | "image_list": [ 6 | "https://d3atagt0rnqk7k.cloudfront.net/wp-content/uploads/2016/09/23115633/bob-ross-1-1280x800.jpg" 7 | ] 8 | }, 9 | { 10 | "name": "Mystery Snake", 11 | "info": "The Mystery Snake is rumored to be a thin, serpentine creature that hides in spaghetti dinners. It has yellow, pasta-like scales with a completely smooth texture, and is quite glossy. ", 12 | "image_list": [ 13 | "https://img.thrfun.com/img/080/349/spaghetti_dinner_l1.jpg" 14 | ] 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /bot/resources/fun/speedrun_links.json: -------------------------------------------------------------------------------- 1 | [ 2 | "https://www.youtube.com/watch?v=jNE28SDXdyQ", 3 | "https://www.youtube.com/watch?v=iI8Giq7zQDk", 4 | "https://www.youtube.com/watch?v=VqNnkqQgFbc", 5 | "https://www.youtube.com/watch?v=Gum4GI2Jr0s", 6 | "https://www.youtube.com/watch?v=5YHjHzHJKkU", 7 | "https://www.youtube.com/watch?v=X0pJSTy4tJI", 8 | "https://www.youtube.com/watch?v=aVFq0H6D6_M", 9 | "https://www.youtube.com/watch?v=1O6LuJbEbSI", 10 | "https://www.youtube.com/watch?v=Bgh30BiWG58", 11 | "https://www.youtube.com/watch?v=wwvgAAvhxM8", 12 | "https://www.youtube.com/watch?v=0TWQr0_fi80", 13 | "https://www.youtube.com/watch?v=hatqZby-0to", 14 | "https://www.youtube.com/watch?v=tmnMq2Hw72w", 15 | "https://www.youtube.com/watch?v=UTkyeTCAucA", 16 | "https://www.youtube.com/watch?v=67kQ3l-1qMs", 17 | "https://www.youtube.com/watch?v=14wqBA5Q1yc" 18 | ] 19 | -------------------------------------------------------------------------------- /bot/resources/fun/wonder_twins.yaml: -------------------------------------------------------------------------------- 1 | water_types: 2 | - ice 3 | - water 4 | - steam 5 | - snow 6 | 7 | objects: 8 | - a bucket 9 | - a spear 10 | - a wall 11 | - a lake 12 | - a ladder 13 | - a boat 14 | - a vial 15 | - a ski slope 16 | - a hand 17 | - a ramp 18 | - clippers 19 | - a bridge 20 | - a dam 21 | - a glacier 22 | - a crowbar 23 | - stilts 24 | - a pole 25 | - a hook 26 | - a wave 27 | - a cage 28 | - a basket 29 | - bolt cutters 30 | - a trapeze 31 | - a puddle 32 | - a toboggan 33 | - a gale 34 | - a cloud 35 | - a unicycle 36 | - a spout 37 | - a sheet 38 | - a gelatin dessert 39 | - a saw 40 | - a geyser 41 | - a jet 42 | - a ball 43 | - handcuffs 44 | - a door 45 | - a row 46 | - a gondola 47 | - a sled 48 | - a rocket 49 | - a swing 50 | - a blizzard 51 | - a saddle 52 | - cubes 53 | - a horse 54 | - a knight 55 | - a rocket pack 56 | - a slick 57 | - a drill 58 | - a shield 59 | - a crane 60 | - a reflector 61 | - a bowling ball 62 | - a turret 63 | - a catapault 64 | - a blanket 65 | - balls 66 | - a faucet 67 | - shears 68 | - a thunder cloud 69 | - a net 70 | - a yoyo 71 | - a block 72 | - a straight-jacket 73 | - a slingshot 74 | - a jack 75 | - a car 76 | - a club 77 | - a vault 78 | - a storm 79 | - a wrench 80 | - an anchor 81 | - a beast 82 | 83 | adjectives: 84 | - a large 85 | - a giant 86 | - a massive 87 | - a small 88 | - a tiny 89 | - a super cool 90 | - a frozen 91 | - a minuscule 92 | - a minute 93 | - a microscopic 94 | - a very small 95 | - a little 96 | - a huge 97 | - an enourmous 98 | - a gigantic 99 | - a great 100 | -------------------------------------------------------------------------------- /bot/resources/holidays/earth_day/save_the_planet.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Choose renewable energy", 4 | "image": {"url": "https://cdn.dnaindia.com/sites/default/files/styles/full/public/2019/07/23/851602-renewable-energy-istock-072419.jpg"}, 5 | "footer": {"text": "Help out by sharing this information!"}, 6 | "fields": [ 7 | { 8 | "name": "The problem", 9 | "value": "Getting energy from oil or fossil fuels isn't a good idea, because there is only so much of it.", 10 | "inline": false 11 | }, 12 | 13 | { 14 | "name": "What you can do", 15 | "value": "Use renewable energy, such as wind, solar, and hydro, because it is healthier and is not a finite resource!", 16 | "inline": false 17 | } 18 | ] 19 | }, 20 | 21 | { 22 | "title": "Save the trees!", 23 | "image": {"url": "https://www.thecollegesolution.com/wp-content/uploads/2014/07/crumpled-paper-1.jpg"}, 24 | "footer": {"text": "Help out by sharing this information!"}, 25 | "fields": [ 26 | { 27 | "name": "The problem", 28 | "value": "We often waste trees on making paper, and just getting rid of them for no good reason.", 29 | "inline": false 30 | }, 31 | 32 | { 33 | "name": "What you can do", 34 | "value": "Make sure you only use paper when absolutely necessary. When you do, make sure to use recycled paper because making new paper causes pollution. Find ways to plant trees (Hacktober Fest!) to combat losing them.", 35 | "inline": false 36 | } 37 | ] 38 | }, 39 | 40 | { 41 | "title": "Less time in the car!", 42 | "image": {"url": "https://www.careeraddict.com/uploads/article/55294/businessman-riding-bike.jpg"}, 43 | "footer": {"text": "Help out by sharing this information!"}, 44 | "fields": [ 45 | { 46 | "name": "The problem", 47 | "value": "Every mile you drive to work produces about a pound of C0₂. That's crazy! What's crazier is how clean the planet could be if we spent less time in the car!", 48 | "inline": false 49 | }, 50 | 51 | { 52 | "name": "What you can do", 53 | "value": "Instead of using your car, ride your bike if possible! Not only does it save that pound of C0₂, it is also great exercise and is cheaper!", 54 | "inline": false 55 | } 56 | ] 57 | }, 58 | 59 | { 60 | "title":"Paint your roof white!", 61 | "image": {"url": "https://modernize.com/wp-content/uploads/2016/10/Cool-roof.jpg"}, 62 | "footer": {"text":"Help out by sharing this information!"}, 63 | "fields": [ 64 | { 65 | "name": "The problem", 66 | "value": "People with dark roofs often spend 20 to 40% more on their electricity bills because of the extra heat, which means more electricity needs to be made, and a lot of it isn't renewable.", 67 | "inline": false 68 | }, 69 | 70 | { 71 | "name":"What you can do", 72 | "value": "Having a light colored roof will save you money, and also researchers at the Lawrence Berkeley National Laboratory estimated that if 80 percent of roofs in tropical and temperate climate areas were painted white, it could offset the greenhouse gas emissions of 300 million automobiles around the world.", 73 | "inline": false 74 | } 75 | ] 76 | } 77 | ] 78 | -------------------------------------------------------------------------------- /bot/resources/holidays/easter/april_fools_vids.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://youtu.be/OYcv406J_J4", 4 | "channel": "google" 5 | }, 6 | { 7 | "url": "https://youtu.be/0_5X6N6DHyk", 8 | "channel": "google" 9 | }, 10 | { 11 | "url": "https://youtu.be/UmJ2NBHXTqo", 12 | "channel": "google" 13 | }, 14 | { 15 | "url": "https://youtu.be/3MA6_21nka8", 16 | "channel": "google" 17 | }, 18 | { 19 | "url": "https://youtu.be/QAwL0O5nXe0", 20 | "channel": "google" 21 | }, 22 | { 23 | "url": "https://youtu.be/DPEJB-FCItk", 24 | "channel": "google" 25 | }, 26 | { 27 | "url": "https://youtu.be/LSZPNwZex9s", 28 | "channel": "google" 29 | }, 30 | { 31 | "url": "https://youtu.be/dFrgNiweQDk", 32 | "channel": "google" 33 | }, 34 | { 35 | "url": "https://youtu.be/F0F6SnbqUcE", 36 | "channel": "google" 37 | }, 38 | { 39 | "url": "https://youtu.be/VkOuShXpoKc", 40 | "channel": "google" 41 | }, 42 | { 43 | "url": "https://youtu.be/HQtGFBbwKEk", 44 | "channel": "google" 45 | }, 46 | { 47 | "url": "https://youtu.be/Cp10_PygJ4o", 48 | "channel": "google" 49 | }, 50 | { 51 | "url": "https://youtu.be/XTTtkisylQw", 52 | "channel": "google" 53 | }, 54 | { 55 | "url": "https://youtu.be/hydLZJXG3Tk", 56 | "channel": "google" 57 | }, 58 | { 59 | "url": "https://youtu.be/U2JBFlW--UU", 60 | "channel": "google" 61 | }, 62 | { 63 | "url": "https://youtu.be/G3NXNnoGr3Y", 64 | "channel": "google" 65 | }, 66 | { 67 | "url": "https://youtu.be/4YMD6xELI_k", 68 | "channel": "google" 69 | }, 70 | { 71 | "url": "https://youtu.be/qcgWRpQP6ds", 72 | "channel": "google" 73 | }, 74 | { 75 | "url": "https://youtu.be/Zr4JwPb99qU", 76 | "channel": "google" 77 | }, 78 | { 79 | "url": "https://youtu.be/VFbYadm_mrw", 80 | "channel": "google" 81 | }, 82 | { 83 | "url": "https://youtu.be/_qFFHC0eIUc", 84 | "channel": "google" 85 | }, 86 | { 87 | "url": "https://youtu.be/H542nLTTbu0", 88 | "channel": "google" 89 | }, 90 | { 91 | "url": "https://youtu.be/Je7Xq9tdCJc", 92 | "channel": "google" 93 | }, 94 | { 95 | "url": "https://youtu.be/re0VRK6ouwI", 96 | "channel": "google" 97 | }, 98 | { 99 | "url": "https://youtu.be/1KhZKNZO8mQ", 100 | "channel": "google" 101 | }, 102 | { 103 | "url": "https://youtu.be/UiLSiqyDf4Y", 104 | "channel": "google" 105 | }, 106 | { 107 | "url": "https://youtu.be/rznYifPHxDg", 108 | "channel": "google" 109 | }, 110 | { 111 | "url": "https://youtu.be/blB_X38YSxQ", 112 | "channel": "google" 113 | }, 114 | { 115 | "url": "https://youtu.be/Bu927_ul_X0", 116 | "channel": "google" 117 | }, 118 | { 119 | "url": "https://youtu.be/smM-Wdk2RLQ", 120 | "channel": "nvidia" 121 | }, 122 | { 123 | "url": "https://youtu.be/IlCx5gjAmqI", 124 | "channel": "razer" 125 | }, 126 | { 127 | "url": "https://youtu.be/j8UJE7DoyJ8", 128 | "channel": "razer" 129 | } 130 | ] 131 | -------------------------------------------------------------------------------- /bot/resources/holidays/easter/bunny_names.json: -------------------------------------------------------------------------------- 1 | { 2 | "names": [ 3 | "Flopsy", 4 | "Hopsalot", 5 | "Thumper", 6 | "Nibbles", 7 | "Daisy", 8 | "Fuzzy", 9 | "Cottontail", 10 | "Carrot Top", 11 | "Marshmallow", 12 | "Lucky", 13 | "Clover", 14 | "Daffodil", 15 | "Buttercup", 16 | "Goldie", 17 | "Dizzy", 18 | "Trixie", 19 | "Snuffles", 20 | "Hopscotch", 21 | "Skipper", 22 | "Thunderfoot", 23 | "Bigwig", 24 | "Dandelion", 25 | "Pipkin", 26 | "Buckthorn", 27 | "Skipper" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /bot/resources/holidays/easter/chocolate_bunny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/easter/chocolate_bunny.png -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_egg_facts.json: -------------------------------------------------------------------------------- 1 | [ 2 | "The first story of a rabbit (later named the \"Easter Bunny\") hiding eggs in a garden was published in 1680.", 3 | "Rabbits are known to be prolific pro creators and are an ancient symbol of fertility and new life. The German immigrants brought the tale of Easter Bunny in the 1700s with the tradition of an egg-laying hare called \"Osterhase\". The kids then would make nests in which the creature would lay coloured eggs. The tradition has been revolutionized in the form of candies and gifts instead of eggs.", 4 | "In earlier days, a festival of egg throwing was held in church, when the priest would throw a hard-boiled egg to one of the choirboys. It was then tossed from one choirboy to the next and whoever held the egg when the clock struck 12 on Easter, was the winner and could keep it.", 5 | "In medieval times, Easter eggs were boiled with onions to give them a golden sheen. Edward I went beyond this tradition in 1290 and ordered 450 eggs to be covered in gold leaf and given as Easter gifts.", 6 | "Decorating Easter eggs is an ancient tradition that dates back to 13th century. One of the explanations for this custom is that eggs were considered as a forbidden food during the Lenten season (40 days before Easter). Therefore, people would paint and decorate them to mark an end of the period of penance and fasting and later eat them on Easter. The tradition of decorating eggs is called Pysanka which is creating a traditional Ukrainian folk design using wax-resist method.", 7 | "Members of the Greek Orthodox faith often paint their Easter eggs red, which symbolizes Jesus' blood and his victory over death. The color red, symbolizes renewal of life, such as, Jesus' resurrection.", 8 | "Eggs rolling take place in many parts of the world which symbolizes stone which was rolled away from the tomb where Jesus' body was laid after his death.", 9 | "Easter eggs have been considered as a symbol of fertility, rebirth and new life. The custom of giving eggs has been derived from Egyptians, Persians, Gauls, Greeks, and Romans.", 10 | "The first chocolate Easter egg was made by Fry's in 1873. Before this, people would give hollow cardboard eggs, filled with gifts.", 11 | "The tallest chocolate Easter egg was made in Italy in 2011. Standing 10.39 metres tall and weighing 7,200 kg, it was taller than a giraffe and heavier than an elephant.", 12 | "The largest ever Easter egg hunt was in Florida, where 9,753 children searched for 501,000 eggs.", 13 | "In 2007, an Easter egg covered in diamonds sold for almost £9 million. Every hour, a cockerel made of jewels pops up from the top of the Faberge egg, flaps its wings four times, nods its head three times and makes a crowing noise. The gold-and-pink enamel egg was made by the Russian royal family as an engagement gift for French aristocrat Baron Edouard de Rothschild.", 14 | "The White House held their first official egg roll in 1878 when Rutherford B. Hayes was the President. It is a race in which children push decorated, hard-boiled eggs across the White House lawn as an annual event held the Monday after Easter. In 2009, the Obamas hosted their first Easter egg roll with the theme, \"Let's go play\" which was meant to encourage young people to lead healthy and active lives.", 15 | "80 million chocolate Easter eggs are sold each year. This accounts for 10% of Britain's annual spending on chocolate!", 16 | "John Cadbury soon followed suit and made his first Cadbury Easter egg in 1875. By 1892 the company was producing 19 different lines, all made from dark chocolate." 17 | ] 18 | -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_eggs/design1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/easter/easter_eggs/design1.png -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_eggs/design2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/easter/easter_eggs/design2.png -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_eggs/design3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/easter/easter_eggs/design3.png -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_eggs/design4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/easter/easter_eggs/design4.png -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_eggs/design5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/easter/easter_eggs/design5.png -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_eggs/design6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/easter/easter_eggs/design6.png -------------------------------------------------------------------------------- /bot/resources/holidays/easter/easter_riddle.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "question": "What kind of music do bunnies like?", 4 | "riddles": [ 5 | "Two words", 6 | "Jump to the beat" 7 | ], 8 | "correct_answer": "Hip hop" 9 | }, 10 | { 11 | "question": "What kind of jewelry do rabbits wear?", 12 | "riddles": [ 13 | "They can eat it too", 14 | "14 ___ gold" 15 | ], 16 | "correct_answer": "14 carrot gold" 17 | }, 18 | { 19 | "question": "What does the easter bunny get for making a basket?", 20 | "riddles": [ 21 | "KOBE!", 22 | "1+1 = ?" 23 | ], 24 | "correct_answer": "2 points" 25 | }, 26 | { 27 | "question": "Where does the easter bunny eat breakfast?", 28 | "riddles": [ 29 | "No waffles here", 30 | "An international home" 31 | ], 32 | "correct_answer": "IHOP" 33 | }, 34 | { 35 | "question": "What do you call a rabbit with fleas?", 36 | "riddles": [ 37 | "A bit of a looney tune", 38 | "What's up Doc?" 39 | ], 40 | "correct_answer": "Bugs Bunny" 41 | }, 42 | { 43 | "question": "Why was the little girl sad after the race?", 44 | "riddles": [ 45 | "2nd place?", 46 | "Who beat her?" 47 | ], 48 | "correct_answer": "Because an egg beater" 49 | }, 50 | { 51 | "question": "What happened to the Easter Bunny when he misbehaved at school?", 52 | "riddles": [ 53 | "Won't be back anymore", 54 | "Worse than suspension" 55 | ], 56 | "correct_answer": "He was eggspelled" 57 | }, 58 | { 59 | "question": "What kind of bunny can't hop?", 60 | "riddles": [ 61 | "Might melt in the sun", 62 | "Fragile and yummy" 63 | ], 64 | "correct_answer": "A chocolate one" 65 | }, 66 | { 67 | "question": "Why did the Easter Bunny have to fire the duck?", 68 | "riddles": [ 69 | "Quack", 70 | "MY EGGS!!" 71 | ], 72 | "correct_answer": "He kept quacking the eggs" 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /bot/resources/holidays/easter/traditions.json: -------------------------------------------------------------------------------- 1 | {"England": "Easter in England is celebrated through the exchange of Easter Eggs and other gifts like clothes, chocolates or holidays packages. Easter bonnets or baskets are also made that have fillings like daffodils in them.", 2 | "Haiti": "In Haiti, kids have the freedom to spend Good Friday playing outdoors. On this day colourful kites fill the sky and children run long distances, often barefoot, trying to get their kite higher than their friends.", 3 | "Indonesia": "Slightly unconventional, but kids in Indonesia celebrate Easter with a tooth brushing competition!", 4 | "Ethipoia": "In Ethiopia, Easter is called Fasika and marks the end of a 55-day fast during which Christians have only eaten one vegetarian meal a day. Ethiopians will often break their fast after church by eating injera (a type of bread) or teff pancakes, made from grass flour.", 5 | "El Salvador": "On Good Friday communities make rug-like paintings on the streets with sand and sawdust. These later become the path for processions and main avenues and streets are closed", 6 | "Ghana": "Ghanaians dress in certain colours to mark the different days of Easter. On Good Friday, depending on the church denomination, men and women will either dress in dark mourning clothes or bright colours. On Easter Sunday everyone wears white.", 7 | "Kenya": "On Easter Sunday, kids in Kenya look forward to a sumptuous Easter meal after church (Easter services are known to last for three hours!). Children share Nyama Choma (roasted meat) and have a soft drink with their meal!", 8 | "Guatemala": "In Guatemala, Easter customs include a large, colourful celebration marked by countless processions. The main roads are closed, and the sound of music rings through the streets. Special food is prepared such as curtido (a diced vegetable mix which is cooked in vinegar to achieve a sour taste), fish, eggs, chickpeas, fruit mix, pumpkin, pacaya palm and spondias fruit (a Spanish version of a plum.)", 9 | "Germany": "In Germany, Easter is known by the name of Ostern. Easter holidays for children last for about three weeks. Good Friday, Easter Saturday and Easter Sunday are the days when people do not work at all.", 10 | "Mexico": "Semana Santa and Pascua (two separate observances) form a part of Easter celebrations in Mexico. Semana Santa stands for the entire Holy Week, from Palm Sunday to Easter Saturday, whereas the Pascua is the observance of the period from the Resurrection Sunday to the following Saturday.", 11 | "Poland": "They shape the Easter Butter Lamb (Baranek Wielkanocyny) from a chunk of butter. They attempt to make it look like a fluffy lamb!", 12 | "Greece": "They burn an effigy of Judas Iscariot, the betrayer of Jesus, sometimes is done as part of a Passion Play! It is hung by the neck and then burnt.", 13 | "Philippines": "Some Christians put themselves through the same pain that Christ endured, they have someone naile them to a cross and put a crown of thornes on their head."} 14 | -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/bat-clipart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/bat-clipart.png -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/bloody-pentagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/bloody-pentagram.png -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/halloween_facts.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Halloween or Hallowe'en is also known as Allhalloween, All Hallows' Eve and All Saints' Eve.", 3 | "It is widely believed that many Halloween traditions originated from ancient Celtic harvest festivals, particularly the Gaelic festival Samhain, which means \"summer's end\".", 4 | "It is believed that the custom of making jack-o'-lanterns at Halloween began in Ireland. In the 19th century, turnips or mangel wurzels, hollowed out to act as lanterns and often carved with grotesque faces, were used at Halloween in parts of Ireland and the Scottish Highlands.", 5 | "Halloween is the second highest grossing commercial holiday after Christmas.", 6 | "The word \"witch\" comes from the Old English *wicce*, meaning \"wise woman\". In fact, *wiccan* were highly respected people at one time. According to popular belief, witches held one of their two main meetings, or *sabbats*, on Halloween night.", 7 | "Samhainophobia is the fear of Halloween.", 8 | "The owl is a popular Halloween image. In Medieval Europe, owls were thought to be witches, and to hear an owl's call meant someone was about to die.", 9 | "An Irish legend about jack-o'-lanterns goes as follows:\n*On route home after a night's drinking, Jack encounters the Devil and tricks him into climbing a tree. A quick-thinking Jack etches the sign of the cross into the bark, thus trapping the Devil. Jack strikes a bargain that Satan can never claim his soul. After a life of sin, drink, and mendacity, Jack is refused entry to heaven when he dies. Keeping his promise, the Devil refuses to let Jack into hell and throws a live coal straight from the fires of hell at him. It was a cold night, so Jack places the coal in a hollowed out turnip to stop it from going out, since which time Jack and his lantern have been roaming looking for a place to rest.*", 10 | "Trick-or-treating evolved from the ancient Celtic tradition of putting out treats and food to placate spirits who roamed the streets at Samhain, a sacred festival that marked the end of the Celtic calendar year.", 11 | "Comedian John Evans once quipped: \"What do you get if you divide the circumference of a jack-o’-lantern by its diameter? Pumpkin π.\"", 12 | "Dressing up as ghouls and other spooks originated from the ancient Celtic tradition of townspeople disguising themselves as demons and spirits. The Celts believed that disguising themselves this way would allow them to escape the notice of the real spirits wandering the streets during Samhain.", 13 | "In Western history, black cats have typically been looked upon as a symbol of evil omens, specifically being suspected of being the familiars of witches, or actually shape-shifting witches themselves. They are, however, too cute to be evil." 14 | ] 15 | -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/monster.json: -------------------------------------------------------------------------------- 1 | { 2 | "monster_type": [ 3 | ["El", "Go", "Ma", "Nya", "Wo", "Hom", "Shar", "Gronn", "Grom", "Blar"], 4 | ["gaf", "mot", "phi", "zyme", "qur", "tile", "pim"], 5 | ["yam", "ja", "rok", "pym", "el"], 6 | ["ya", "tor", "tir", "tyre", "pam"] 7 | ], 8 | "scientist_first_name": ["Ellis", "Elliot", "Rick", "Laurent", "Morgan", "Sophia", "Oak"], 9 | "scientist_last_name": ["E. M.", "E. T.", "Smith", "Schimm", "Schiftner", "Smile", "Tomson", "Thompson", "Huffson", "Argor", "Lephtain", "S. M.", "A. R.", "P. G."], 10 | "verb": [ 11 | "discovered", "created", "found" 12 | ], 13 | "adjective": [ 14 | "ferocious", "spectacular", "incredible", "terrifying" 15 | ], 16 | "physical_adjective": [ 17 | "springy", "rubbery", "bouncy", "tough", "notched", "chipped" 18 | ], 19 | "color": [ 20 | "blue", "green", "teal", "black", "pure white", "obsidian black", "purple", "bright red", "bright yellow" 21 | ], 22 | "attribute": [ 23 | "horns", "teeth", "shell", "fur", "bones", "exoskeleton", "spikes" 24 | ], 25 | "ability": [ 26 | "breathe fire", "devour dreams", "lift thousand-pound weights", "devour metal", "chew up diamonds", "create diamonds", "create gemstones", "breathe icy cold air", "spit poison", "live forever" 27 | ], 28 | "ingredients": [ 29 | "witch's eye", "frog legs", "slime", "true love's kiss", "a lock of golden hair", "the skin of a snake", "a never-melting chunk of ice" 30 | ], 31 | "time": [ 32 | "dusk", "dawn", "mid-day", "midnight on a full moon", "midnight on Halloween night", "the time of a solar eclipse", "the time of a lunar eclipse." 33 | ], 34 | "year": [ 35 | "1996", "1594", "1330", "1700" 36 | ], 37 | "biography_text": [ 38 | {"scientist_first_name": 1, "scientist_last_name": 1, "verb": 1, "adjective": 1, "attribute": 1, "ability": 1, "color": 1, "year": 1, "time": 1, "physical_adjective": 1, "text": "Your name is {monster_name}, a member of the {adjective} species {monster_species}. The first {monster_species} was {verb} by {scientist_first_name} {scientist_last_name} in {year} at {time}. The species {monster_species} is known for its {physical_adjective} {color} {attribute}. It is said to even be able to {ability}!"}, 39 | {"scientist_first_name": 1, "scientist_last_name": 1, "adjective": 1, "attribute": 1, "physical_adjective": 1, "ingredients": 2, "time": 1, "ability": 1, "verb": 1, "color": 1, "year": 1, "text": "The {monster_species} is an {adjective} species, and you, {monster_name}, are no exception. {monster_species} is famed for its {physical_adjective} {attribute}. Whispers say that when brewed with {ingredients[0]} and {ingredients[1]} at {time}, a foul, {color} brew will be produced, granting it's drinker the ability to {ability}! This species was {verb} by {scientist_first_name} {scientist_last_name} in {year}."} 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/monstersurvey.json: -------------------------------------------------------------------------------- 1 | { 2 | "frankenstein": { 3 | "full_name": "Frankenstein's Monster", 4 | "summary": "His limbs were in proportion, and I had selected his features as beautiful. Beautiful! Great God! His yellow skin scarcely covered the work of muscles and arteries beneath; his hair was of a lustrous black, and flowing; his teeth of a pearly whiteness; but these luxuriances only formed a more horrid contrast with his watery eyes, that seemed almost of the same colour as the dun-white sockets in which they were set, his shrivelled complexion and straight black lips.", 5 | "image": "https://upload.wikimedia.org/wikipedia/commons/a/a7/Frankenstein%27s_monster_%28Boris_Karloff%29.jpg", 6 | "votes": [] 7 | }, 8 | "dracula": { 9 | "full_name": "Count Dracula", 10 | "summary": "Count Dracula is an undead, centuries-old vampire, and a Transylvanian nobleman who claims to be a Sz\u00c3\u00a9kely descended from Attila the Hun. He inhabits a decaying castle in the Carpathian Mountains near the Borgo Pass. Unlike the vampires of Eastern European folklore, which are portrayed as repulsive, corpse-like creatures, Dracula wears a veneer of aristocratic charm. In his conversations with Jonathan Harker, he reveals himself as deeply proud of his boyar heritage and nostalgic for the past, which he admits have become only a memory of heroism, honour and valour in modern times.", 11 | "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg/250px-Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg", 12 | "votes": [ 13 | ] 14 | }, 15 | "goofy": { 16 | "full_name": "Goofy in the Monster's INC World", 17 | "summary": "Pure nightmare fuel.\nThis monster is nothing like its original counterpart. With two different eyes, a pointed nose, fins growing out of its blue skin, and dark spots covering his body, he's a true nightmare come to life.", 18 | "image": "https://www.dailydot.com/wp-content/uploads/3a2/a8/bf38aedbef9f795f.png", 19 | "votes": [] 20 | }, 21 | "refisio": { 22 | "full_name": "Refisio", 23 | "summary": "Who let this guy write this? That's who the real monster is.", 24 | "image": "https://avatars0.githubusercontent.com/u/24819750?s=460&v=4", 25 | "votes": [ 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/responses.json: -------------------------------------------------------------------------------- 1 | { 2 | "responses": [ 3 | ["No."], 4 | ["Yes."], 5 | ["I will seek and answer from the devil...", "...after requesting the devils knowledge the answer is far more complicated than a simple yes or no."], 6 | ["This knowledge is not available to me, I will seek the answers from someone far more powerful...", "...there is no answer to this question, not even the Grim Reaper could find an answer."], 7 | ["The ghosts I summoned have confirmed that is is certain."], 8 | ["Double, double, toil and trouble,\nFire burn and cauldron bubble.\nCool it with a baboon's blood,\nand tell me the answer to his question...", "...the great cauldron can only confirm your beliefs."], 9 | ["Double, double, toil and trouble,\nFire burn and cauldron bubble.\nCool it with a baboon's blood,\nand tell me the answer to his question...", "...the great cauldron can only confirm that the answer to your question is no."], 10 | ["If I tell you I will have to kill you..."], 11 | ["I swear on my spider that you are correct."], 12 | ["With great certainty, under the watch of the Pumpkin King, I can confirm your suspicions."], 13 | ["The undead have sworn me to secrecy. I can not answer your question."] 14 | ]} 15 | -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/baby.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/baby.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/candle.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/candle.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/clown.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/clown.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/costume.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/costume.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/devil.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/devil.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/ghost.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/ghost.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/jackolantern.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/jackolantern.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/necromancer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/necromancer.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/halloween/spookyrating/tiger.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/halloween/spookyrating/tiger.jpeg -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/agender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/agender.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/androgyne.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/androgyne.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/aromantic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/aromantic.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/asexual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/asexual.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/bigender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/bigender.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/bisexual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/bisexual.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/demiboy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/demiboy.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/demigirl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/demigirl.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/demisexual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/demisexual.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/gay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/gay.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/genderfluid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/genderfluid.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/genderqueer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/genderqueer.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/intersex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/intersex.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/lesbian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/lesbian.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/nonbinary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/nonbinary.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/omnisexual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/omnisexual.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/pangender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/pangender.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/pansexual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/pansexual.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/polyamory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/polyamory.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/polysexual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/polysexual.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/transgender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/transgender.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/flags/trigender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/holidays/pride/flags/trigender.png -------------------------------------------------------------------------------- /bot/resources/holidays/pride/gender_options.json: -------------------------------------------------------------------------------- 1 | { 2 | "agender": "agender", 3 | "androgyne": "androgyne", 4 | "androgynous": "androgyne", 5 | "aromantic": "aromantic", 6 | "aro": "aromantic", 7 | "ace": "asexual", 8 | "asexual": "asexual", 9 | "bigender": "bigender", 10 | "bisexual": "bisexual", 11 | "bi": "bisexual", 12 | "demiboy": "demiboy", 13 | "demigirl": "demigirl", 14 | "demi": "demisexual", 15 | "demisexual": "demisexual", 16 | "gay": "gay", 17 | "lgbt": "gay", 18 | "queer": "gay", 19 | "homosexual": "gay", 20 | "fluid": "genderfluid", 21 | "genderfluid": "genderfluid", 22 | "genderqueer": "genderqueer", 23 | "intersex": "intersex", 24 | "lesbian": "lesbian", 25 | "non-binary": "nonbinary", 26 | "enby": "nonbinary", 27 | "nb": "nonbinary", 28 | "nonbinary": "nonbinary", 29 | "omnisexual": "omnisexual", 30 | "omni": "omnisexual", 31 | "pansexual": "pansexual", 32 | "pan": "pansexual", 33 | "pangender": "pangender", 34 | "poly": "polysexual", 35 | "polysexual": "polysexual", 36 | "polyamory": "polyamory", 37 | "polyamorous": "polyamory", 38 | "transgender": "transgender", 39 | "trans": "transgender", 40 | "trigender": "trigender" 41 | } 42 | -------------------------------------------------------------------------------- /bot/resources/holidays/valentines/love_matches.json: -------------------------------------------------------------------------------- 1 | { 2 | "0": { 3 | "titles": [ 4 | "\ud83d\udc94 There's no real connection between you two \ud83d\udc94" 5 | ], 6 | "text": "The chance of this relationship working out is really low. You can get it to work, but with high costs and no guarantee of working out. Do not sit back, spend as much time together as possible, talk a lot with each other to increase the chances of this relationship's survival." 7 | }, 8 | "5": { 9 | "titles": [ 10 | "\ud83d\udc99 A small acquaintance \ud83d\udc99" 11 | ], 12 | "text": "There might be a chance of this relationship working out somewhat well, but it is not very high. With a lot of time and effort you'll get it to work eventually, however don't count on it. It might fall apart quicker than you'd expect." 13 | }, 14 | "20": { 15 | "titles": [ 16 | "\ud83d\udc9c You two seem like casual friends \ud83d\udc9c" 17 | ], 18 | "text": "The chance of this relationship working is not very high. You both need to put time and effort into this relationship, if you want it to work out well for both of you. Talk with each other about everything and don't lock yourself up. Spend time together. This will improve the chances of this relationship's survival by a lot." 19 | }, 20 | "30": { 21 | "titles": [ 22 | "\ud83d\udc97 You seem like you are good friends \ud83d\udc97" 23 | ], 24 | "text": "The chance of this relationship working is not very high, but its not that low either. If you both want this relationship to work, and put time and effort into it, meaning spending time together, talking to each other etc., than nothing shall stand in your way." 25 | }, 26 | "45": { 27 | "titles": [ 28 | "\ud83d\udc98 You two are really close aren't you? \ud83d\udc98" 29 | ], 30 | "text": "Your relationship has a reasonable amount of working out. But do not overestimate yourself there. Your relationship will suffer good and bad times. Make sure to not let the bad times destroy your relationship, so do not hesitate to talk to each other, figure problems out together etc." 31 | }, 32 | "60": { 33 | "titles": [ 34 | "\u2764 So when will you two go on a date? \u2764" 35 | ], 36 | "text": "Your relationship will most likely work out. It won't be perfect and you two need to spend a lot of time together, but if you keep on having contact, the good times in your relationship will outweigh the bad ones." 37 | }, 38 | "80": { 39 | "titles": [ 40 | "\ud83d\udc95 Aww look you two fit so well together \ud83d\udc95" 41 | ], 42 | "text": "Your relationship will most likely work out well. Don't hesitate on making contact with each other though, as your relationship might suffer from a lack of time spent together. Talking with each other and spending time together is key." 43 | }, 44 | "95": { 45 | "titles": [ 46 | "\ud83d\udc96 Love is in the air \ud83d\udc96", 47 | "\ud83d\udc96 Planned your future yet? \ud83d\udc96" 48 | ], 49 | "text": "Your relationship will most likely work out perfect. This doesn't mean thought that you don't need to put effort into it. Talk to each other, spend time together, and you two won't have a hard time." 50 | }, 51 | "100": { 52 | "titles": [ 53 | "\ud83d\udc9b When will you two marry? \ud83d\udc9b", 54 | "\ud83d\udc9b Now kiss already \ud83d\udc9b" 55 | ], 56 | "text": "You two will most likely have the perfect relationship. But don't think that this means you don't have to do anything for it to work. Talking to each other and spending time together is key, even in a seemingly perfect relationship." 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /bot/resources/utilities/python_facts.txt: -------------------------------------------------------------------------------- 1 | Python was named after Monty Python, a British Comedy Troupe, which Guido van Rossum likes. 2 | If you type `import this` in the Python REPL, you'll get a poem about the philosophies about Python. (check it out by doing !zen in <#267659945086812160>) 3 | If you type `import antigravity` in the Python REPL, you'll be directed to an [xkcd comic](https://xkcd.com/353/) about how easy Python is. 4 | -------------------------------------------------------------------------------- /bot/resources/utilities/starter.yaml: -------------------------------------------------------------------------------- 1 | # Conversation starters for channels that are not Python-related. 2 | 3 | - What is your favourite Easter candy or treat? 4 | - What is your earliest memory of Easter? 5 | - What is the title of the last book you read? 6 | - "What is better: Milk, Dark or White chocolate?" 7 | - What is your favourite holiday? 8 | - If you could have any superpower, what would it be? 9 | - If you could be anyone else for one day, who would it be? 10 | - What Easter tradition do you enjoy most? 11 | - What is the best gift you've been given? 12 | - Name one famous person you would like to have at your easter dinner. 13 | - What was the last movie you saw in a cinema? 14 | - What is your favourite food? 15 | - If you could travel anywhere in the world, where would you go? 16 | - Tell us 5 things you do well. 17 | - What is your favourite place that you have visited? 18 | - What is your favourite color? 19 | - If you had $100 bill in your Easter Basket, what would you do with it? 20 | - What would you do if you know you could succeed at anything you chose to do? 21 | - If you could take only three things from your house, what would they be? 22 | - What's the best pastry? 23 | - What's your favourite kind of soup? 24 | - What is the most useless talent that you have? 25 | - Would you rather fight 100 duck sized horses or one horse sized duck? 26 | - What is your favourite color? 27 | - What's your favourite type of weather? 28 | - Tea or coffee? What about milk? 29 | - Do you speak a language other than English? 30 | - What is your favorite TV show? 31 | - What is your favorite media genre? 32 | - How many years have you spent coding? 33 | - What book do you highly recommend everyone to read? 34 | - What websites do you use daily to keep yourself up to date with the industry? 35 | - What is the best advice you have ever gotten in regards to programming/software? 36 | - What is the most satisfying thing you've done in your life? 37 | - Who is your favorite music composer/producer/singer? 38 | - What is your favorite song? 39 | - What is your favorite video game? 40 | - What are your hobbies other than programming? 41 | - Who is your favorite Writer? 42 | - What is your favorite movie? 43 | - What is your favorite sport? 44 | - What is your favorite fruit? 45 | - What is your favorite juice? 46 | - What is the best scenery you've ever seen? 47 | - What artistic talents do you have? 48 | - What is the tallest building you've entered? 49 | - What is the oldest computer you've ever used? 50 | - What animals do you like? 51 | -------------------------------------------------------------------------------- /bot/resources/utilities/wtf_python_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/resources/utilities/wtf_python_logo.jpg -------------------------------------------------------------------------------- /bot/utils/checks.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Container 2 | 3 | from discord.ext.commands import ( 4 | Context, 5 | ) 6 | from pydis_core.utils.checks import in_whitelist_check as core_in_whitelist_check 7 | from pydis_core.utils.logging import get_logger 8 | 9 | from bot import constants 10 | 11 | log = get_logger(__name__) 12 | CODEJAM_CATEGORY_NAME = "Code Jam" 13 | 14 | 15 | def in_whitelist_check( 16 | ctx: Context, 17 | channels: Container[int] = (), 18 | categories: Container[int] = (), 19 | roles: Container[int] = (), 20 | redirect: int | None = constants.Channels.sir_lancebot_playground, 21 | fail_silently: bool = False, 22 | ) -> bool: 23 | """ 24 | Check if a command was issued in a whitelisted context. 25 | 26 | Check bot-core's in_whitelist_check docstring for more details. 27 | """ 28 | return core_in_whitelist_check( 29 | ctx=ctx, channels=channels, categories=categories, roles=roles, redirect=redirect, fail_silently=fail_silently 30 | ) 31 | 32 | 33 | def with_role_check(ctx: Context, *role_ids: int) -> bool: 34 | """Returns True if the user has any one of the roles in role_ids.""" 35 | if not ctx.guild: # Return False in a DM 36 | log.trace( 37 | f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " 38 | "This command is restricted by the with_role decorator. Rejecting request." 39 | ) 40 | return False 41 | 42 | for role in ctx.author.roles: 43 | if role.id in role_ids: 44 | log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.") 45 | return True 46 | 47 | log.trace( 48 | f"{ctx.author} does not have the required role to use " 49 | f"the '{ctx.command.name}' command, so the request is rejected." 50 | ) 51 | return False 52 | 53 | 54 | def without_role_check(ctx: Context, *role_ids: int) -> bool: 55 | """Returns True if the user does not have any of the roles in role_ids.""" 56 | if not ctx.guild: # Return False in a DM 57 | log.trace( 58 | f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " 59 | "This command is restricted by the without_role decorator. Rejecting request." 60 | ) 61 | return False 62 | 63 | author_roles = [role.id for role in ctx.author.roles] 64 | check = all(role not in author_roles for role in role_ids) 65 | log.trace( 66 | f"{ctx.author} tried to call the '{ctx.command.name}' command. " 67 | f"The result of the without_role check was {check}." 68 | ) 69 | return check 70 | -------------------------------------------------------------------------------- /bot/utils/commands.py: -------------------------------------------------------------------------------- 1 | from rapidfuzz import process 2 | 3 | 4 | def get_command_suggestions(all_commands: list[str], query: str, *, cutoff: int = 60, limit: int = 3) -> list[str]: 5 | """Get similar command names.""" 6 | results = process.extract(query, all_commands, score_cutoff=cutoff, limit=limit) 7 | return [result[0] for result in results] 8 | -------------------------------------------------------------------------------- /bot/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class UserNotPlayingError(Exception): 4 | """Raised when users try to use game commands when they are not playing.""" 5 | 6 | 7 | class APIError(Exception): 8 | """Raised when an external API (eg. Wikipedia) returns an error response.""" 9 | 10 | def __init__(self, api: str, status_code: int, error_msg: str | None = None): 11 | super().__init__() 12 | self.api = api 13 | self.status_code = status_code 14 | self.error_msg = error_msg 15 | 16 | 17 | class MovedCommandError(Exception): 18 | """Raised when a command has moved locations.""" 19 | 20 | def __init__(self, new_command_name: str): 21 | self.new_command_name = new_command_name 22 | -------------------------------------------------------------------------------- /bot/utils/halloween/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/bot/utils/halloween/__init__.py -------------------------------------------------------------------------------- /bot/utils/halloween/spookifications.py: -------------------------------------------------------------------------------- 1 | from random import choice, randint 2 | 3 | from PIL import Image, ImageOps 4 | from pydis_core.utils.logging import get_logger 5 | 6 | log = get_logger() 7 | 8 | 9 | def inversion(im: Image.Image) -> Image.Image: 10 | """ 11 | Inverts the image. 12 | 13 | Returns an inverted image when supplied with an Image object. 14 | """ 15 | im = im.convert("RGB") 16 | inv = ImageOps.invert(im) 17 | return inv 18 | 19 | 20 | def pentagram(im: Image.Image) -> Image.Image: 21 | """Adds pentagram to the image.""" 22 | im = im.convert("RGB") 23 | wt, ht = im.size 24 | penta = Image.open("bot/resources/holidays/halloween/bloody-pentagram.png") 25 | penta = penta.resize((wt, ht)) 26 | im.paste(penta, (0, 0), penta) 27 | return im 28 | 29 | 30 | def bat(im: Image.Image) -> Image.Image: 31 | """ 32 | Adds a bat silhouette to the image. 33 | 34 | The bat silhouette is of a size at least one-fifths that of the original image and may be rotated 35 | up to 90 degrees anti-clockwise. 36 | """ 37 | im = im.convert("RGB") 38 | wt, _ = im.size 39 | bat = Image.open("bot/resources/holidays/halloween/bat-clipart.png") 40 | bat_size = randint(wt//10, wt//7) 41 | rot = randint(0, 90) 42 | bat = bat.resize((bat_size, bat_size)) 43 | bat = bat.rotate(rot) 44 | x = randint(wt-(bat_size * 3), wt-bat_size) 45 | y = randint(10, bat_size) 46 | im.paste(bat, (x, y), bat) 47 | im.paste(bat, (x + bat_size, y + (bat_size // 4)), bat) 48 | im.paste(bat, (x - bat_size, y - (bat_size // 2)), bat) 49 | return im 50 | 51 | 52 | def get_random_effect(im: Image.Image) -> Image.Image: 53 | """Randomly selects and applies an effect.""" 54 | effects = [inversion, pentagram, bat] 55 | effect = choice(effects) 56 | log.info("Spookyavatar's chosen effect: " + effect.__name__) 57 | return effect(im) 58 | -------------------------------------------------------------------------------- /bot/utils/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def suppress_links(message: str) -> str: 5 | """Accepts a message that may contain links, suppresses them, and returns them.""" 6 | for link in set(re.findall(r"https?://[^\s]+", message, re.IGNORECASE)): 7 | message = message.replace(link, f"<{link}>") 8 | return message 9 | -------------------------------------------------------------------------------- /bot/utils/messages.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import re 3 | from collections.abc import Callable 4 | 5 | from discord import Embed, Message 6 | from discord.ext import commands 7 | from discord.ext.commands import Context, MessageConverter 8 | from pydis_core.utils.logging import get_logger 9 | 10 | log = get_logger(__name__) 11 | 12 | 13 | def sub_clyde(username: str | None) -> str | None: 14 | """ 15 | Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"Е" and return the new string. 16 | 17 | Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. 18 | Return None only if `username` is None. 19 | """ # noqa: RUF002 20 | def replace_e(match: re.Match) -> str: 21 | char = "е" if match[2] == "e" else "Е" # noqa: RUF001 22 | return match[1] + char 23 | 24 | if username: 25 | return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) 26 | return username # Empty string or None 27 | 28 | 29 | async def get_discord_message(ctx: Context, text: str) -> Message | str: 30 | """ 31 | Attempts to convert a given `text` to a discord Message object and return it. 32 | 33 | Conversion will succeed if given a discord Message ID or link. 34 | Returns `text` if the conversion fails. 35 | """ 36 | with contextlib.suppress(commands.BadArgument): 37 | text = await MessageConverter().convert(ctx, text) 38 | return text 39 | 40 | 41 | async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Embed | None]: 42 | """ 43 | Attempts to extract the text and embed from a possible link to a discord Message. 44 | 45 | Does not retrieve the text and embed from the Message if it is in a channel the user does 46 | not have read permissions in. 47 | 48 | Returns a tuple of: 49 | str: If `text` is a valid discord Message, the contents of the message, else `text`. 50 | Optional[Embed]: The embed if found in the valid Message, else None 51 | """ 52 | embed: Embed | None = None 53 | 54 | msg = await get_discord_message(ctx, text) 55 | # Ensure the user has read permissions for the channel the message is in 56 | if isinstance(msg, Message): 57 | permissions = msg.channel.permissions_for(ctx.author) 58 | if permissions.read_messages: 59 | text = msg.clean_content 60 | # Take first embed because we can't send multiple embeds 61 | if msg.embeds: 62 | embed = msg.embeds[0] 63 | 64 | return text, embed 65 | 66 | 67 | def convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: 68 | """ 69 | Converts the text in an embed using a given conversion function, then return the embed. 70 | 71 | Only modifies the following fields: title, description, footer, fields 72 | """ 73 | embed_dict = embed.to_dict() 74 | 75 | embed_dict["title"] = func(embed_dict.get("title", "")) 76 | embed_dict["description"] = func(embed_dict.get("description", "")) 77 | 78 | if "footer" in embed_dict: 79 | embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) 80 | 81 | if "fields" in embed_dict: 82 | for field in embed_dict["fields"]: 83 | field["name"] = func(field.get("name", "")) 84 | field["value"] = func(field.get("value", "")) 85 | 86 | return Embed.from_dict(embed_dict) 87 | -------------------------------------------------------------------------------- /bot/utils/randomization.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import random 3 | from collections.abc import Iterable 4 | from typing import TypeVar 5 | 6 | T = TypeVar("T") 7 | 8 | 9 | class RandomCycle: 10 | """ 11 | Cycles through elements from a randomly shuffled iterable, repeating indefinitely. 12 | 13 | The iterable is reshuffled after each full cycle. 14 | """ 15 | 16 | def __init__(self, iterable: Iterable[T]): 17 | self.iterable = list(iterable) 18 | self.index = itertools.cycle(range(len(iterable))) 19 | 20 | def __next__(self) -> T: 21 | idx = next(self.index) 22 | 23 | if idx == 0: 24 | random.shuffle(self.iterable) 25 | 26 | return self.iterable[idx] 27 | -------------------------------------------------------------------------------- /bot/utils/time.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | from dateutil.relativedelta import relativedelta 4 | 5 | 6 | # All these functions are from https://github.com/python-discord/bot/blob/main/bot/utils/time.py 7 | def _stringify_time_unit(value: int, unit: str) -> str: 8 | """ 9 | Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. 10 | 11 | >>> _stringify_time_unit(1, "seconds") 12 | "1 second" 13 | >>> _stringify_time_unit(24, "hours") 14 | "24 hours" 15 | >>> _stringify_time_unit(0, "minutes") 16 | "less than a minute" 17 | """ 18 | if unit == "seconds" and value == 0: 19 | return "0 seconds" 20 | if value == 1: 21 | return f"{value} {unit[:-1]}" 22 | if value == 0: 23 | return f"less than a {unit[:-1]}" 24 | return f"{value} {unit}" 25 | 26 | 27 | def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: 28 | """ 29 | Returns a human-readable version of the relativedelta. 30 | 31 | precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). 32 | max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). 33 | """ 34 | if max_units <= 0: 35 | raise ValueError("max_units must be positive") 36 | 37 | units = ( 38 | ("years", delta.years), 39 | ("months", delta.months), 40 | ("days", delta.days), 41 | ("hours", delta.hours), 42 | ("minutes", delta.minutes), 43 | ("seconds", delta.seconds), 44 | ) 45 | 46 | # Add the time units that are >0, but stop at accuracy or max_units. 47 | time_strings = [] 48 | unit_count = 0 49 | for unit, value in units: 50 | if value: 51 | time_strings.append(_stringify_time_unit(value, unit)) 52 | unit_count += 1 53 | 54 | if unit == precision or unit_count >= max_units: 55 | break 56 | 57 | # Add the 'and' between the last two units, if necessary 58 | if len(time_strings) > 1: 59 | time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}" 60 | del time_strings[-2] 61 | 62 | # If nothing has been found, just make the value 0 precision, e.g. `0 days`. 63 | if not time_strings: 64 | humanized = _stringify_time_unit(0, precision) 65 | else: 66 | humanized = ", ".join(time_strings) 67 | 68 | return humanized 69 | 70 | 71 | def time_since(past_datetime: datetime, precision: str = "seconds", max_units: int = 6) -> str: 72 | """ 73 | Takes a datetime and returns a human-readable string that describes how long ago that datetime was. 74 | 75 | precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). 76 | max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). 77 | """ 78 | now = datetime.now(tz=UTC) 79 | delta = abs(relativedelta(now, past_datetime)) 80 | 81 | humanized = humanize_delta(delta, precision, max_units) 82 | 83 | return f"{humanized} ago" 84 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-restart-policy: &restart_policy 2 | restart: unless-stopped 3 | 4 | services: 5 | sir-lancebot: 6 | << : *restart_policy 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | container_name: sir-lancebot 11 | init: true 12 | tty: true 13 | 14 | depends_on: 15 | - redis 16 | 17 | environment: 18 | - REDIS_HOST=redis 19 | env_file: 20 | - .env 21 | 22 | volumes: 23 | - .:/bot 24 | 25 | redis: 26 | << : *restart_policy 27 | image: redis:latest 28 | ports: 29 | - "127.0.0.1:6379:6379" 30 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "sir-lancebot" 3 | version = "0.1.0" 4 | description = "A Discord bot designed as a fun and beginner-friendly learning environment for writing bot features and learning open-source." 5 | authors = ["Python Discord "] 6 | license = "MIT" 7 | package-mode = false 8 | 9 | [tool.poetry.dependencies] 10 | python = "3.13.*" 11 | 12 | # See https://bot-core.pythondiscord.com/ for docs. 13 | pydis_core = { version = "11.6.0", extras = ["all"] } 14 | 15 | arrow = "1.3.0" 16 | beautifulsoup4 = "4.12.3" 17 | colorama = { version = "0.4.6", markers = "sys_platform == 'win32'" } 18 | coloredlogs = "15.0.1" 19 | emoji = "2.14.0" 20 | emojis = "0.7.0" 21 | lxml = "5.3.0" 22 | pillow = "11.0.0" 23 | pydantic = { version = "2.10.1", extras = ["dotenv"]} 24 | pydantic-settings = "2.8.1" 25 | pyjokes = "0.8.3" 26 | PyYAML = "6.0.2" 27 | rapidfuzz = "3.12.2" 28 | sentry-sdk = "2.19.2" 29 | 30 | [tool.poetry.dev-dependencies] 31 | pip-licenses = "5.0.0" 32 | pre-commit = "4.0.1" 33 | python-dotenv = "1.0.1" 34 | ruff = "0.8.4" 35 | taskipy = "1.14.1" 36 | 37 | [tool.taskipy.tasks] 38 | start = "python -m bot" 39 | lint = "pre-commit run --all-files" 40 | precommit = "pre-commit install" 41 | 42 | [build-system] 43 | requires = ["poetry-core>=1.0.0"] 44 | build-backend = "poetry.core.masonry.api" 45 | 46 | [tool.isort] 47 | multi_line_output = 6 48 | order_by_type = false 49 | case_sensitive = true 50 | combine_as_imports = true 51 | line_length = 120 52 | atomic = true 53 | known_first_party = ["bot"] 54 | 55 | [tool.ruff] 56 | target-version = "py313" 57 | extend-exclude = [".cache"] 58 | output-format = "concise" 59 | line-length = 120 60 | unsafe-fixes = true 61 | 62 | [tool.ruff.lint] 63 | select = ["ANN", "B", "C4", "D", "DTZ", "E", "F", "I", "ISC", "INT", "N", "PGH", "PIE", "Q", "RET", "RSE", "RUF", "S", "SIM", "T20", "TID", "UP", "W"] 64 | ignore = [ 65 | "ANN002", "ANN003", "ANN204", "ANN206", "ANN401", 66 | "B904", 67 | "C401", "C408", 68 | "D100", "D104", "D105", "D107", "D203", "D212", "D214", "D215", "D301", 69 | "D400", "D401", "D402", "D404", "D405", "D406", "D407", "D408", "D409", "D410", "D411", "D412", "D413", "D414", "D416", "D417", 70 | "E226", "E731", 71 | "RET504", 72 | "RUF005", 73 | "RUF029", 74 | "S311", 75 | "SIM102", "SIM108", 76 | ] 77 | 78 | [tool.ruff.lint.isort] 79 | known-first-party = ["bot"] 80 | order-by-type = false 81 | case-sensitive = true 82 | combine-as-imports = true 83 | -------------------------------------------------------------------------------- /sir-lancebot-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/sir-lancebot/1bb852d61b9828e19fbfc95c71b05e6670bb8da4/sir-lancebot-logo.png --------------------------------------------------------------------------------